Skip to content

feat: integrate sonner as brain entry#1252

Open
marcjulian wants to merge 21 commits intospartan-ng:mainfrom
marcjulian:feat/sonner-brain
Open

feat: integrate sonner as brain entry#1252
marcjulian wants to merge 21 commits intospartan-ng:mainfrom
marcjulian:feat/sonner-brain

Conversation

@marcjulian
Copy link
Collaborator

@marcjulian marcjulian commented Mar 5, 2026

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • Other... Please describe:

Which package are you modifying?

Primitives

  • accordion
  • alert
  • alert-dialog
  • aspect-ratio
  • autocomplete
  • avatar
  • badge
  • breadcrumb
  • button
  • button-group
  • calendar
  • card
  • carousel
  • checkbox
  • collapsible
  • combobox
  • command
  • context-menu
  • data-table
  • date-picker
  • dialog
  • empty
  • dropdown-menu
  • field
  • form-field
  • hover-card
  • icon
  • input
  • input-group
  • input-otp
  • item
  • kbd
  • label
  • menubar
  • native-select
  • navigation-menu
  • pagination
  • popover
  • progress
  • radio-group
  • resizable
  • scroll-area
  • select
  • separator
  • sheet
  • sidebar
  • skeleton
  • slider
  • sonner
  • spinner
  • switch
  • table
  • tabs
  • textarea
  • toggle
  • toggle-group
  • tooltip
  • typography

Others

  • trpc
  • nx
  • repo
  • cli

What is the current behavior?

Closes #1240

What is the new behavior?

  • added @spartan-ng/brain/sonner entry which replaces ngx-sonner
  • fix vanishing of custom icons
  • fix expand toasts groups by position
  • update modifiers

Does this PR introduce a breaking change?

  • Yes
  • No

@spartan-ng/brain/sonner entry which replaces ngx-sonner

Added healthcheck and migration to replace ngx-sonner with @spartan-ng/brain/sonner for the toast call.
Requires two steps:

  1. regenerate sonner helm
  2. run healthcheck

Other information

@marcjulian marcjulian marked this pull request as ready for review March 5, 2026 13:53
@greptile-apps
Copy link

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR replaces the third-party ngx-sonner package with a first-party @spartan-ng/brain/sonner entry, giving the spartan-ng team full control over the toast implementation. The brain library ports the Sonner API to Angular signals (linkedSignal, afterRenderEffect, viewChild), adds multi-position support, fixes custom icon rendering, and ships a CLI migration generator + healthcheck to help consumers upgrade. The helm layer (hlm-toaster) is regenerated to import from the new brain entry.

Key changes and issues found:

  • _expanded resets on every toast change (brn-toaster.ts:169)linkedSignal's computation re-runs and overwrites the user's manual .set(true) call whenever any toast is added or dismissed. A user hovering over an expanded stack will see it collapse to the stacked view the moment any toast auto-dismisses, even while their mouse is stationary over the container.
  • --front-toast-height uses unfiltered global heights (brn-toaster.ts:189)this._heights is the unfiltered toastState.heights signal. With multiple position groups active, this._heights()[0] picks up the height of whichever toast is first globally (regardless of position), and the same value is written to all <ol> elements, producing incorrect entrance/exit animations for all but the first position group.
  • _toasts filter excludes default-position toasts (brn-toast.ts:165) — previously flagged; the t.position === this.position() guard filters out toasts with position: undefined, making --z-index evaluate to a negative value for the common case of toasts created without an explicit position. The ToastFilterPipe handles this correctly with (!toast.position && index === 0) || toast.position === position — the same logic should be applied here.
  • The CLI generator and healthcheck look correct; previously reported issues (replacereplaceAll, JSDoc copy-paste, filename typo) have all been addressed.

Confidence Score: 3/5

  • The PR is functional for the common single-position use-case but contains two logic bugs that affect multi-position setups and the expanded hover UX.
  • The core toast() API, state management, and migration tooling are solid. However, the linkedSignal reset bug in _expanded will cause a visible UX regression (collapsing on auto-dismiss while hovering), and the unfiltered --front-toast-height will cause animation glitches for multi-position setups. Combined with the previously flagged _toasts filter issue that remains unresolved, three independent logic bugs are present in the core component files.
  • libs/brain/sonner/src/lib/brn-toaster.ts and libs/brain/sonner/src/lib/brn-toast.ts need attention before merging.

Important Files Changed

Filename Overview
libs/brain/sonner/src/lib/brn-toaster.ts Core toaster host component. Two logic bugs found: linkedSignal for _expanded resets on every toast change (collapsing the hover-expanded view when a toast auto-dismisses), and --front-toast-height reads from the global unfiltered heights array, giving wrong values for all but the first position group.
libs/brain/sonner/src/lib/brn-toast.ts Individual toast component. The _toasts computed filter (t.position === this.position()) excludes default-position toasts (position: undefined), causing --z-index to be negative for the most common use-case where toasts are created without an explicit position. Noted in previous review threads.
libs/brain/sonner/src/lib/state.ts Global toast state as a module-level singleton. Correctly implements create/dismiss/update semantics, promise toasts, and height tracking with sorted insertion.
libs/cli/src/generators/migrate-sonner/generator.ts Migration generator correctly uses replaceAll to replace all ngx-sonner references and properly skips hlm-toaster.ts. Previously flagged issues (JSDoc, replace vs replaceAll) have been addressed.
libs/cli/src/generators/healthcheck/healthchecks/sonner.ts Healthcheck correctly detects remaining ngx-sonner references and delegates the fix to migrateSonnerGenerator. Previously flagged typos have been corrected.
libs/brain/sonner/src/lib/brn-toaster.spec.ts Good test coverage for core toast interactions, timer behavior, and custom icons. Tests are clear and well-structured. Missing coverage for multi-position scenarios and the _toasts position filter edge case.
libs/helm/sonner/src/lib/hlm-toaster.ts Helm wrapper correctly migrated to use @spartan-ng/brain/sonner. Provides custom icon slots and default CSS variable bindings for themed toasts.
libs/brain/sonner/src/lib/pipes/toast-filter.pipe.ts Correctly handles default-position toasts (!toast.position && index === 0) and explicit-position toasts, assigning them to the right <ol> group. This logic is unfortunately not mirrored in brn-toast.ts's own _toasts computed.

Sequence Diagram

sequenceDiagram
    participant App as Application Code
    participant State as toastState (singleton)
    participant Toaster as BrnSonnerToaster
    participant Toast as BrnSonnerToast
    participant Pipe as ToastFilterPipe

    App->>State: toast('message') / toast.success() / toast.promise()
    State->>State: create() — adds ToastT to toasts signal

    State-->>Toaster: toasts signal changes
    Toaster->>Toaster: _possiblePositions() recomputed
    Toaster->>Pipe: _toasts() | toastFilter: $index : pos
    Pipe-->>Toaster: filtered ToastT[] per position group

    Toaster->>Toast: renders <brn-sonner-toast [position]="pos" ...>
    Toast->>Toast: ngAfterViewInit() — measures height
    Toast->>State: addHeight({ toastId, height, position })
    State-->>Toaster: heights signal changes → _toasterStyles() recomputed

    Note over Toast: afterRenderEffect — startTimer()
    Toast->>Toast: startTimer() — schedules deleteToast()
    Toast->>State: dismiss(id) after duration
    State-->>Toaster: toasts signal changes → _expanded recomputed

    Note over App,Toaster: User hover / keyboard
    App->>Toaster: mouseenter / mousemove → _expanded.set(true)
    App->>Toaster: Alt+T hotkey → _expanded.set(true), listRef.focus()
    App->>Toaster: Escape → _expanded.set(false)
    App->>Toast: pointerdown/move/up → swipe-to-dismiss
Loading

Last reviewed commit: 6e8bdc7

}

if (content.includes('ngx-sonner')) {
content = content.replace('ngx-sonner', '@spartan-ng/brain/sonner');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String.prototype.replace with a plain string only replaces the first occurrence. A file with both a value import and a type import from ngx-sonner would only have the first one migrated, leaving a broken import behind.

Use replaceAll to ensure all occurrences are replaced:

Suggested change
content = content.replace('ngx-sonner', '@spartan-ng/brain/sonner');
content = content.replaceAll('ngx-sonner', '@spartan-ng/brain/sonner');

});
});

it('should add HlmContextMenuImports import (Standalone)', async () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test description is a copy-paste from a different generator and does not describe what this test verifies. It should reflect the actual behavior being tested.

Suggested change
it('should add HlmContextMenuImports import (Standalone)', async () => {
it('should replace ngx-sonner imports with @spartan-ng/brain/sonner', async () => {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

/** internal **/
public readonly direction = this._dir.valueSignal;

private readonly _listRef = viewChild<ElementRef<HTMLOListElement>>('listRef');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template uses @for (pos of _possiblePositions()) to render multiple <ol #listRef> elements, one per position. However, viewChild always resolves to the first matching element in template order. When toasts appear at multiple positions, the keyboard handlers (Alt+T hotkey and Escape key) will only affect the first position group.

Consider using viewChildren instead to handle all position groups:

private readonly _listRefs = viewChildren<ElementRef<HTMLOListElement>>('listRef');

Then in handleKeydown, iterate over all lists or focus the most relevant one based on current context.

@marcjulian
Copy link
Collaborator Author

@greptileai review again

@marcjulian
Copy link
Collaborator Author

@greptileai review again

@@ -0,0 +1,3 @@
export function cn(...classes: (string | undefined)[]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for not using the hlm function?

Copy link
Collaborator Author

@marcjulian marcjulian Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied over the code from ngx-sonner, which has this cn function. I also thought about using the helm version, but the brain package cannot rely on helm. Helm should consume the brain only. But the brain has a deps on clsx which we could use instead of this custom function. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh I missed that the hlm function is only available in hlm but that totally makes sense. My bad!

Using clsx directly is a great idea when its already available.

But if not I guess its also fine. I just wanted to make sure that this didn't slip your eyes when doing the integration.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries, I totally had the same thought as you to use hlm there! Thanks for the review :) I will go with clsx here!

@marcjulian marcjulian mentioned this pull request Mar 6, 2026
75 tasks
@marcjulian marcjulian requested a review from MerlinMoos March 9, 2026 09:20
Copy link
Collaborator

@MerlinMoos MerlinMoos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM thanks for moving that to our repo

/>
>
<ng-template #loadingIcon>
<ng-icon name="lucideLoader2" class="overflow-visible! text-base [&>svg]:motion-safe:animate-spin" />
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashley-hunter the sonner styles moves the svg to the left or right, but because ng-icon has overflow hidden it looks like its cut of. I set overflow to visible. Do you think that is fine in this case?

@marcjulian
Copy link
Collaborator Author

@MerlinMoos we reference ngx-sonner on the sonner page and about page. Should we keep it or should we update the text?

@MerlinMoos
Copy link
Collaborator

@MerlinMoos we reference ngx-sonner on the sonner page and about page. Should we keep it or should we update the text?

I would say we can remove it, now we are maintaining that. We can keep it simple and remove the reference to the ngx-sonner

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sonner: add custom icons using ng-icon + lucide

3 participants