@ng-cn/core 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +6 -5
  2. package/src/app/lib/components/ui/alert-dialog/alert-dialog-content.component.ts +21 -20
  3. package/src/app/lib/components/ui/avatar/ui-avatar.component.ts +4 -0
  4. package/src/app/lib/components/ui/calendar/calendar.component.ts +5 -1
  5. package/src/app/lib/components/ui/carousel/carousel-content.component.ts +1 -0
  6. package/src/app/lib/components/ui/carousel/carousel-item.component.ts +1 -0
  7. package/src/app/lib/components/ui/carousel/carousel-next.component.ts +1 -0
  8. package/src/app/lib/components/ui/carousel/carousel-previous.component.ts +1 -0
  9. package/src/app/lib/components/ui/carousel/carousel.component.ts +1 -0
  10. package/src/app/lib/components/ui/chart/chart-container.component.ts +1 -0
  11. package/src/app/lib/components/ui/chart/chart-legend-content.component.ts +1 -0
  12. package/src/app/lib/components/ui/chart/chart-legend.component.ts +5 -5
  13. package/src/app/lib/components/ui/chart/chart-tooltip-content.component.ts +5 -5
  14. package/src/app/lib/components/ui/chart/chart-tooltip.component.ts +5 -5
  15. package/src/app/lib/components/ui/chart/chart.component.ts +1 -0
  16. package/src/app/lib/components/ui/checkbox/checkbox.component.ts +1 -1
  17. package/src/app/lib/components/ui/collapsible/collapsible-content.component.ts +2 -1
  18. package/src/app/lib/components/ui/collapsible/collapsible-context.ts +1 -0
  19. package/src/app/lib/components/ui/collapsible/collapsible-trigger.component.ts +1 -0
  20. package/src/app/lib/components/ui/collapsible/collapsible.component.ts +3 -0
  21. package/src/app/lib/components/ui/context-menu/context-menu-content.component.ts +48 -17
  22. package/src/app/lib/components/ui/context-menu/context-menu-sub-content.component.ts +2 -0
  23. package/src/app/lib/components/ui/context-menu/context-menu-sub-trigger.component.ts +30 -1
  24. package/src/app/lib/components/ui/context-menu/context-menu-sub.component.ts +3 -0
  25. package/src/app/lib/components/ui/date-picker/date-picker.component.ts +1 -0
  26. package/src/app/lib/components/ui/dialog/dialog-content.component.ts +26 -19
  27. package/src/app/lib/components/ui/direction/direction-context.ts +9 -0
  28. package/src/app/lib/components/ui/direction/direction.component.ts +48 -0
  29. package/src/app/lib/components/ui/direction/index.ts +2 -0
  30. package/src/app/lib/components/ui/drawer/drawer-content.component.ts +44 -0
  31. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-content.component.ts +2 -2
  32. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.component.ts +1 -0
  33. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.component.ts +2 -0
  34. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.component.ts +28 -2
  35. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub.component.ts +3 -0
  36. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-trigger.component.ts +25 -0
  37. package/src/app/lib/components/ui/empty/empty-action.component.ts +1 -0
  38. package/src/app/lib/components/ui/empty/empty-description.component.ts +1 -0
  39. package/src/app/lib/components/ui/empty/empty-icon.component.ts +2 -1
  40. package/src/app/lib/components/ui/empty/empty-title.component.ts +1 -0
  41. package/src/app/lib/components/ui/empty/empty.component.ts +1 -0
  42. package/src/app/lib/components/ui/form/form-description.component.ts +2 -2
  43. package/src/app/lib/components/ui/hover-card/hover-card-content.component.ts +108 -60
  44. package/src/app/lib/components/ui/hover-card/hover-card-context.ts +4 -2
  45. package/src/app/lib/components/ui/hover-card/hover-card-trigger.component.ts +5 -3
  46. package/src/app/lib/components/ui/hover-card/hover-card.component.ts +8 -3
  47. package/src/app/lib/components/ui/input-group/input-group-addon.component.ts +1 -0
  48. package/src/app/lib/components/ui/input-group/input-group-input.component.ts +1 -0
  49. package/src/app/lib/components/ui/input-group/input-group.component.ts +1 -0
  50. package/src/app/lib/components/ui/menubar/menubar-content.component.ts +1 -1
  51. package/src/app/lib/components/ui/navigation-menu/navigation-menu-content.component.ts +7 -1
  52. package/src/app/lib/components/ui/navigation-menu/navigation-menu-context.ts +14 -0
  53. package/src/app/lib/components/ui/navigation-menu/navigation-menu-item.component.ts +9 -4
  54. package/src/app/lib/components/ui/navigation-menu/navigation-menu-trigger.component.ts +69 -2
  55. package/src/app/lib/components/ui/navigation-menu/navigation-menu.component.ts +32 -4
  56. package/src/app/lib/components/ui/pagination/pagination.component.ts +3 -1
  57. package/src/app/lib/components/ui/popover/popover-content.component.ts +11 -0
  58. package/src/app/lib/components/ui/popover/popover-context.ts +2 -0
  59. package/src/app/lib/components/ui/popover/popover.component.ts +4 -0
  60. package/src/app/lib/components/ui/progress/progress.component.ts +1 -2
  61. package/src/app/lib/components/ui/scroll-area/scroll-area.component.ts +7 -6
  62. package/src/app/lib/components/ui/segmented/segmented-item.component.ts +1 -0
  63. package/src/app/lib/components/ui/segmented/segmented.component.ts +1 -0
  64. package/src/app/lib/components/ui/select/select-content.component.ts +35 -15
  65. package/src/app/lib/components/ui/select/select-context.ts +10 -0
  66. package/src/app/lib/components/ui/select/select-item.component.ts +25 -7
  67. package/src/app/lib/components/ui/select/select-trigger.component.ts +6 -13
  68. package/src/app/lib/components/ui/select/select.component.ts +46 -0
  69. package/src/app/lib/components/ui/sheet/sheet-content.component.ts +22 -5
  70. package/src/app/lib/components/ui/slider/slider.component.ts +2 -2
  71. package/src/app/lib/components/ui/sonner/index.ts +2 -0
  72. package/src/app/lib/components/ui/sonner/sonner.component.ts +70 -0
  73. package/src/app/lib/components/ui/switch/switch.component.ts +1 -14
  74. package/src/app/lib/components/ui/tabs/tabs-list.component.ts +18 -0
  75. package/src/app/lib/components/ui/tabs/tabs-trigger.component.ts +0 -1
  76. package/src/app/lib/components/ui/toggle/toggle.component.ts +12 -6
  77. package/src/app/lib/components/ui/tooltip/tooltip-content.component.ts +141 -17
  78. package/src/app/lib/components/ui/tooltip/tooltip-context.ts +3 -1
  79. package/src/app/lib/components/ui/tooltip/tooltip-provider.component.ts +1 -1
  80. package/src/app/lib/components/ui/tooltip/tooltip-trigger.component.ts +5 -2
  81. package/src/app/lib/components/ui/tooltip/tooltip.component.ts +3 -1
@@ -98,15 +98,16 @@ export class ScrollArea {
98
98
 
99
99
  protected readonly computedClass = computed(() => cn('relative overflow-hidden', this.class()));
100
100
 
101
- protected readonly viewportClass = computed(() =>
102
- cn(
101
+ protected readonly viewportClass = computed(() => {
102
+ const type = this.type();
103
+ return cn(
103
104
  'h-full w-full rounded-[inherit]',
104
105
  '[&>div]:!block',
105
- // Hide native scrollbar
106
+ // Hide native scrollbar (custom ScrollBar component provides visual feedback)
106
107
  '[&::-webkit-scrollbar]:hidden',
107
108
  '[-ms-overflow-style:none]',
108
109
  '[scrollbar-width:none]',
109
- 'overflow-auto',
110
- ),
111
- );
110
+ type === 'always' ? 'overflow-scroll' : 'overflow-auto',
111
+ );
112
+ });
112
113
  }
@@ -13,6 +13,7 @@ import { segmentedItemVariants } from './segmented-variants';
13
13
  selector: 'SegmentedItem',
14
14
  template: `<ng-content />`,
15
15
  host: {
16
+ 'attr.data-slot': '"segmented-item"',
16
17
  '[class]': 'computedClass()',
17
18
  '[attr.role]': 'itemRole()',
18
19
  '[attr.aria-selected]': 'itemRole() === "tab" ? isSelected() : null',
@@ -33,6 +33,7 @@ import { segmentedVariants, type SegmentedVariants } from './segmented-variants'
33
33
  selector: 'Segmented',
34
34
  template: `<ng-content />`,
35
35
  host: {
36
+ 'attr.data-slot': '"segmented"',
36
37
  '[class]': 'computedClass()',
37
38
  role: 'tablist',
38
39
  '[attr.aria-orientation]': '"horizontal"',
@@ -1,4 +1,4 @@
1
- import { cn } from '@/lib/utils';
1
+ import { cn, Presence } from '@/lib/utils';
2
2
  import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
3
3
  import { SELECT_CONTEXT } from './select-context';
4
4
 
@@ -9,25 +9,29 @@ import { SELECT_CONTEXT } from './select-context';
9
9
  */
10
10
  @Component({
11
11
  selector: 'SelectContent',
12
+ imports: [Presence],
12
13
  template: `
13
- <div
14
- [class]="dropdownClass()"
15
- [attr.id]="context?.contentId"
16
- [attr.data-state]="context?.open() ? 'open' : 'closed'"
17
- [attr.data-side]="side()"
18
- [attr.data-align]="align()"
19
- role="listbox"
20
- aria-label="Options"
21
- (keydown.escape)="onEscape()"
22
- >
23
- <div [class]="viewportClass()">
24
- <ng-content />
14
+ <Presence [present]="context?.open() ?? false">
15
+ <div
16
+ [class]="dropdownClass()"
17
+ [attr.id]="context?.contentId"
18
+ [attr.data-state]="context?.open() ? 'open' : 'closed'"
19
+ [attr.data-side]="side()"
20
+ [attr.data-align]="align()"
21
+ [attr.aria-activedescendant]="focusedItemId()"
22
+ role="listbox"
23
+ (keydown.escape)="onEscape()"
24
+ (keydown)="onKeydown($event)"
25
+ >
26
+ <div [class]="viewportClass()">
27
+ <ng-content />
28
+ </div>
25
29
  </div>
26
- </div>
30
+ </Presence>
27
31
  `,
28
32
  host: {
29
33
  class: 'contents',
30
- 'data-slot': 'select-content',
34
+ 'attr.data-slot': '"select-content"',
31
35
  },
32
36
  changeDetection: ChangeDetectionStrategy.OnPush,
33
37
  })
@@ -59,6 +63,15 @@ export class SelectContent {
59
63
  /** Viewport class */
60
64
  protected readonly viewportClass = computed(() => cn('max-h-60 overflow-y-auto p-1'));
61
65
 
66
+ /** ID of the currently focused item for aria-activedescendant */
67
+ protected readonly focusedItemId = computed(() => {
68
+ if (!this.context) return null;
69
+ const values = this.context.itemValues();
70
+ const focusedIndex = this.context.focusedIndex();
71
+ const focusedValue = values[focusedIndex];
72
+ return focusedValue ? `select-item-${focusedValue}` : null;
73
+ });
74
+
62
75
  protected onEscape(): void {
63
76
  this.context?.setOpen(false);
64
77
  const trigger = this.context?.triggerElement();
@@ -66,4 +79,11 @@ export class SelectContent {
66
79
  setTimeout(() => trigger.focus());
67
80
  }
68
81
  }
82
+
83
+ /** Handle printable character keys for typeahead search */
84
+ onKeydown(event: KeyboardEvent): void {
85
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
86
+ this.context?.handleTypeahead(event.key);
87
+ }
88
+ }
69
89
  }
@@ -31,6 +31,16 @@ export interface SelectContext {
31
31
  required?: () => boolean;
32
32
  /** Name for form submission */
33
33
  name?: () => string;
34
+ /** Map of item value → display label for typeahead */
35
+ itemLabels: WritableSignal<Map<string, string>>;
36
+ /** Accumulated typeahead characters */
37
+ typeaheadBuffer: WritableSignal<string>;
38
+ /** Timeout handle for clearing the typeahead buffer */
39
+ typeaheadTimeout: WritableSignal<ReturnType<typeof setTimeout> | null>;
40
+ /** Handle a typed character for typeahead search */
41
+ handleTypeahead: (char: string) => void;
42
+ /** Focus the first item whose label starts with query (case-insensitive) */
43
+ focusMatchingItem: (query: string) => void;
34
44
  }
35
45
 
36
46
  export interface SelectGroupContext {
@@ -47,6 +47,7 @@ import { SELECT_CONTEXT } from './select-context';
47
47
  host: {
48
48
  '[class]': 'computedClass()',
49
49
  role: 'option',
50
+ '[attr.id]': 'itemId()',
50
51
  '[attr.aria-selected]': 'isSelected()',
51
52
  '[attr.data-state]': 'isSelected() ? "checked" : "unchecked"',
52
53
  '[attr.data-disabled]': 'disabled() ? "" : null',
@@ -55,7 +56,7 @@ import { SELECT_CONTEXT } from './select-context';
55
56
  '[attr.tabindex]': 'disabled() ? -1 : 0',
56
57
  '(click)': 'select()',
57
58
  '(keydown)': 'onKeyDown($event)',
58
- 'data-slot': 'select-item',
59
+ 'attr.data-slot': '"select-item"',
59
60
  },
60
61
  changeDetection: ChangeDetectionStrategy.OnPush,
61
62
  })
@@ -72,6 +73,9 @@ export class SelectItem implements OnInit, OnDestroy {
72
73
 
73
74
  private readonly _context = inject(SELECT_CONTEXT, { optional: true });
74
75
 
76
+ /** Stable DOM id for aria-activedescendant */
77
+ readonly itemId = computed(() => `select-item-${this.value()}`);
78
+
75
79
  /** Whether this item is selected */
76
80
  protected readonly isSelected = computed(() => {
77
81
  return this._context?.value() === this.value();
@@ -94,17 +98,28 @@ export class SelectItem implements OnInit, OnDestroy {
94
98
  });
95
99
 
96
100
  ngOnInit(): void {
97
- // Register this item
101
+ // Register this item's value
98
102
  this._context?.itemValues.update((values) => {
99
103
  if (!values.includes(this.value())) {
100
104
  return [...values, this.value()];
101
105
  }
102
106
  return values;
103
107
  });
108
+ // Register this item's label for typeahead
109
+ this._context?.itemLabels.update((m) => {
110
+ const label = this.textContent()?.nativeElement?.textContent?.trim() || this.value();
111
+ m.set(this.value(), label);
112
+ return m;
113
+ });
104
114
  }
105
115
  ngOnDestroy(): void {
106
- // Unregister this item
116
+ // Unregister this item's value
107
117
  this._context?.itemValues.update((values) => values.filter((v) => v !== this.value()));
118
+ // Unregister this item's label
119
+ this._context?.itemLabels.update((m) => {
120
+ m.delete(this.value());
121
+ return m;
122
+ });
108
123
  }
109
124
 
110
125
  /** Handle keyboard navigation */
@@ -117,17 +132,20 @@ export class SelectItem implements OnInit, OnDestroy {
117
132
  event.preventDefault();
118
133
  this.select();
119
134
  break;
120
- case 'ArrowDown':
135
+ case 'ArrowDown': {
121
136
  event.preventDefault();
122
137
  const currentIndex = this._context.focusedIndex();
123
138
  const itemCount = this._context.itemValues().length;
124
- this._context.focusItem(Math.min(currentIndex + 1, itemCount - 1));
139
+ this._context.focusItem((currentIndex + 1) % itemCount);
125
140
  break;
126
- case 'ArrowUp':
141
+ }
142
+ case 'ArrowUp': {
127
143
  event.preventDefault();
128
144
  const idx = this._context.focusedIndex();
129
- this._context.focusItem(Math.max(idx - 1, 0));
145
+ const count = this._context.itemValues().length;
146
+ this._context.focusItem(idx > 0 ? idx - 1 : count - 1);
130
147
  break;
148
+ }
131
149
  case 'Home':
132
150
  event.preventDefault();
133
151
  this._context.focusItem(0);
@@ -57,7 +57,7 @@ import { SELECT_CONTEXT } from './select-context';
57
57
  `,
58
58
  host: {
59
59
  class: 'contents',
60
- 'data-slot': 'select-trigger',
60
+ 'attr.data-slot': '"select-trigger"',
61
61
  },
62
62
  changeDetection: ChangeDetectionStrategy.OnPush,
63
63
  })
@@ -114,35 +114,28 @@ export class SelectTrigger {
114
114
  case 'ArrowDown':
115
115
  event.preventDefault();
116
116
  if (!this.context.open()) {
117
- // Save trigger element
118
117
  const button = this.triggerButton()?.nativeElement;
119
- if (button) {
120
- this.context.triggerElement.set(button);
121
- }
118
+ if (button) this.context.triggerElement.set(button);
122
119
  this.context.setOpen(true);
123
120
  setTimeout(() => this.context?.focusItem(0));
124
121
  } else {
125
- // Move to next item
126
122
  const currentIndex = this.context.focusedIndex();
127
123
  const itemCount = this.context.itemValues().length;
128
- this.context.focusItem(Math.min(currentIndex + 1, itemCount - 1));
124
+ this.context.focusItem((currentIndex + 1) % itemCount);
129
125
  }
130
126
  break;
131
127
  case 'ArrowUp':
132
128
  event.preventDefault();
133
129
  if (!this.context.open()) {
134
- // Save trigger element
135
130
  const button = this.triggerButton()?.nativeElement;
136
- if (button) {
137
- this.context.triggerElement.set(button);
138
- }
131
+ if (button) this.context.triggerElement.set(button);
139
132
  this.context.setOpen(true);
140
133
  const lastIndex = this.context.itemValues().length - 1;
141
134
  setTimeout(() => this.context?.focusItem(lastIndex));
142
135
  } else {
143
- // Move to previous item
144
136
  const currentIndex = this.context.focusedIndex();
145
- this.context.focusItem(Math.max(currentIndex - 1, 0));
137
+ const itemCount = this.context.itemValues().length;
138
+ this.context.focusItem(currentIndex > 0 ? currentIndex - 1 : itemCount - 1);
146
139
  }
147
140
  break;
148
141
  case 'Escape':
@@ -199,6 +199,11 @@ export class Select {
199
199
  private readonly _isDisabled = signal<boolean>(false);
200
200
 
201
201
  private readonly ariaIds = this._ariaIdService.generateMenuIds('select');
202
+ /** Internal typeahead signals */
203
+ private readonly _itemLabels = signal<Map<string, string>>(new Map());
204
+ private readonly _typeaheadBuffer = signal<string>('');
205
+ private readonly _typeaheadTimeout = signal<ReturnType<typeof setTimeout> | null>(null);
206
+
202
207
  /** Context for child components */
203
208
  readonly context: SelectContext = {
204
209
  value: this._value,
@@ -232,6 +237,11 @@ export class Select {
232
237
  }
233
238
  },
234
239
  focusItem: (index: number) => this.focusItemByIndex(index),
240
+ itemLabels: this._itemLabels,
241
+ typeaheadBuffer: this._typeaheadBuffer,
242
+ typeaheadTimeout: this._typeaheadTimeout,
243
+ handleTypeahead: (char: string) => this.handleTypeahead(char),
244
+ focusMatchingItem: (query: string) => this.focusMatchingItem(query),
235
245
  };
236
246
 
237
247
  /** Focus an item by index */
@@ -248,4 +258,40 @@ export class Select {
248
258
  selectItem.focus();
249
259
  }
250
260
  }
261
+
262
+ /** Handle typeahead: accumulate char, clear after 500 ms, then focus matching item */
263
+ private handleTypeahead(char: string): void {
264
+ // Clear existing timeout
265
+ const existingTimeout = this._typeaheadTimeout();
266
+ if (existingTimeout !== null) {
267
+ clearTimeout(existingTimeout);
268
+ }
269
+
270
+ const newBuffer = this._typeaheadBuffer() + char;
271
+ this._typeaheadBuffer.set(newBuffer);
272
+
273
+ const timeout = setTimeout(() => {
274
+ this._typeaheadBuffer.set('');
275
+ this._typeaheadTimeout.set(null);
276
+ }, 500);
277
+ this._typeaheadTimeout.set(timeout);
278
+
279
+ this.focusMatchingItem(newBuffer);
280
+ }
281
+
282
+ /** Focus the first item whose label starts with query (case-insensitive) */
283
+ private focusMatchingItem(query: string): void {
284
+ const values = this.context.itemValues();
285
+ const labels = this._itemLabels();
286
+ const lowerQuery = query.toLowerCase();
287
+
288
+ const matchIndex = values.findIndex((v) => {
289
+ const label = labels.get(v) ?? v;
290
+ return label.toLowerCase().startsWith(lowerQuery);
291
+ });
292
+
293
+ if (matchIndex !== -1) {
294
+ this.focusItemByIndex(matchIndex);
295
+ }
296
+ }
251
297
  }
@@ -83,13 +83,21 @@ import { sheetVariants, type SheetVariants } from './sheet-variants';
83
83
  })
84
84
  export class SheetContent implements OnDestroy {
85
85
  constructor() {
86
- // Handle body scroll lock based on open state (browser-only via effect + afterNextRender)
86
+ let wasOpen = false;
87
+
88
+ // Handle body scroll lock and focus restoration based on open state
87
89
  effect(() => {
88
90
  const isOpen = this.context.open();
89
91
  if (isOpen) {
92
+ wasOpen = true;
90
93
  this.lockBodyScroll();
91
94
  } else {
92
95
  this.unlockBodyScroll();
96
+ // Restore focus whenever sheet closes (covers close button, overlay, Escape, programmatic)
97
+ if (wasOpen) {
98
+ this.restoreFocus();
99
+ }
100
+ wasOpen = false;
93
101
  }
94
102
  });
95
103
  }
@@ -105,13 +113,14 @@ export class SheetContent implements OnDestroy {
105
113
  cn(sheetVariants({ side: this.side() }), this.class()),
106
114
  );
107
115
 
108
- /** Previous body overflow for restoration */
116
+ /** Previous body overflow/padding for restoration */
109
117
  private previousBodyOverflow = '';
118
+ private previousBodyPaddingRight = '';
110
119
 
111
120
  ngOnDestroy(): void {
112
121
  // Restore body scroll
113
122
  this.unlockBodyScroll();
114
- // Restore focus to trigger element
123
+ // Restore focus to trigger element (fallback if not already called via effect)
115
124
  this.restoreFocus();
116
125
  }
117
126
 
@@ -120,7 +129,10 @@ export class SheetContent implements OnDestroy {
120
129
  this.close();
121
130
  }
122
131
  onEscapeKey(): void {
123
- this.close();
132
+ // Guard: only close when the sheet is actually open (not during exit animation)
133
+ if (this.context.open()) {
134
+ this.close();
135
+ }
124
136
  }
125
137
  onClose(): void {
126
138
  this.close();
@@ -128,17 +140,22 @@ export class SheetContent implements OnDestroy {
128
140
 
129
141
  private lockBodyScroll(): void {
130
142
  if (typeof document !== 'undefined') {
143
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
131
144
  this.previousBodyOverflow = document.body.style.overflow;
145
+ this.previousBodyPaddingRight = document.body.style.paddingRight;
132
146
  document.body.style.overflow = 'hidden';
147
+ if (scrollbarWidth > 0) {
148
+ document.body.style.paddingRight = scrollbarWidth + 'px';
149
+ }
133
150
  }
134
151
  }
135
152
  private unlockBodyScroll(): void {
136
153
  if (typeof document !== 'undefined') {
137
154
  document.body.style.overflow = this.previousBodyOverflow;
155
+ document.body.style.paddingRight = this.previousBodyPaddingRight;
138
156
  }
139
157
  }
140
158
  private close(): void {
141
- this.restoreFocus();
142
159
  this.context.setOpen(false);
143
160
  }
144
161
  private restoreFocus(): void {
@@ -346,10 +346,10 @@ export class Slider implements ControlValueAccessor {
346
346
  newValue -= stepValue * increment;
347
347
  break;
348
348
  case 'ArrowUp':
349
- newValue += stepValue;
349
+ newValue += stepValue * (this.inverted() ? -1 : 1);
350
350
  break;
351
351
  case 'ArrowDown':
352
- newValue -= stepValue;
352
+ newValue -= stepValue * (this.inverted() ? -1 : 1);
353
353
  break;
354
354
  case 'PageUp':
355
355
  newValue += largeStep;
@@ -0,0 +1,2 @@
1
+ export { toast } from 'ngx-sonner';
2
+ export { Sonner } from './sonner.component';
@@ -0,0 +1,70 @@
1
+ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2
+ import { NgxSonnerToaster } from 'ngx-sonner';
3
+
4
+ export { toast } from 'ngx-sonner';
5
+
6
+ /**
7
+ * Sonner component - toast notification toaster.
8
+ * Wraps ngx-sonner's Toaster with shadcn/ui default styling.
9
+ * Place once near the root of your app.
10
+ *
11
+ * @example
12
+ * <!-- In app root template -->
13
+ * <Sonner />
14
+ *
15
+ * <!-- With custom position -->
16
+ * <Sonner position="top-right" />
17
+ *
18
+ * <!-- Then trigger toasts anywhere -->
19
+ * import { toast } from '@/lib/components/ui/sonner';
20
+ * toast('Hello world');
21
+ * toast.success('Saved!');
22
+ * toast.error('Something went wrong');
23
+ */
24
+ @Component({
25
+ selector: 'Sonner',
26
+ imports: [NgxSonnerToaster],
27
+ changeDetection: ChangeDetectionStrategy.OnPush,
28
+ host: {
29
+ 'attr.data-slot': '"sonner"',
30
+ style: 'display: contents',
31
+ },
32
+ template: `
33
+ <ngx-sonner-toaster
34
+ [theme]="theme()"
35
+ [position]="position()"
36
+ [richColors]="richColors()"
37
+ [expand]="expand()"
38
+ [duration]="duration()"
39
+ [visibleToasts]="visibleToasts()"
40
+ [closeButton]="closeButton()"
41
+ [offset]="offset()"
42
+ [dir]="dir()"
43
+ [class]="toasterClass()"
44
+ />
45
+ `,
46
+ })
47
+ export class Sonner {
48
+ /** Visual theme */
49
+ readonly theme = input<'light' | 'dark' | 'system'>('system');
50
+ /** Position of the toaster */
51
+ readonly position = input<
52
+ 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
53
+ >('bottom-right');
54
+ /** Use rich colors for success/error/warning/info */
55
+ readonly richColors = input<boolean>(false);
56
+ /** Expand toasts by default */
57
+ readonly expand = input<boolean>(false);
58
+ /** Default duration in ms */
59
+ readonly duration = input<number>(4000);
60
+ /** Max visible toasts */
61
+ readonly visibleToasts = input<number>(3);
62
+ /** Show close button on each toast */
63
+ readonly closeButton = input<boolean>(false);
64
+ /** Offset from edge */
65
+ readonly offset = input<string | number | null>(null);
66
+ /** Text direction */
67
+ readonly dir = input<'ltr' | 'rtl' | 'auto'>('auto');
68
+ /** Additional CSS classes passed to the toaster */
69
+ readonly toasterClass = input<string>('toaster group');
70
+ }
@@ -103,9 +103,7 @@ export type SwitchProps = {
103
103
  [attr.data-disabled]="isDisabled() ? '' : null"
104
104
  [disabled]="isDisabled()"
105
105
  [class]="trackClass()"
106
- [style.backgroundColor]="checked() ? checkedBgColor() : 'var(--color-input)'"
107
106
  (click)="toggle()"
108
- (keydown)="onKeyDown($event)"
109
107
  >
110
108
  <span data-slot="switch-thumb" [class]="thumbClass()" [attr.data-state]="state()"></span>
111
109
  </button>
@@ -125,7 +123,7 @@ export type SwitchProps = {
125
123
  `,
126
124
  host: {
127
125
  '[class]': 'computedClass()',
128
- 'data-slot': 'switch',
126
+ 'attr.data-slot': '"switch"',
129
127
  },
130
128
  providers: [
131
129
  {
@@ -168,9 +166,6 @@ export class Switch implements ControlValueAccessor {
168
166
  readonly class = input<string>('');
169
167
  /** Additional CSS classes to apply to the inner track button */
170
168
  readonly buttonClass = input<string>('');
171
- /** Background color for checked state (hex, rgb, or CSS color name) */
172
- readonly checkedBgColor = input<string>('rgb(59, 130, 246)');
173
-
174
169
  /** Current state for data attribute */
175
170
  protected readonly state = computed(
176
171
  (): SwitchState => (this.checked() ? 'checked' : 'unchecked'),
@@ -246,12 +241,4 @@ export class Switch implements ControlValueAccessor {
246
241
  }
247
242
  }
248
243
 
249
- /** Handle keyboard events */
250
- protected onKeyDown(event: KeyboardEvent): void {
251
- // Switch should only respond to Space (Enter is handled by button default)
252
- if (event.key === ' ') {
253
- // Let the click handler deal with it
254
- return;
255
- }
256
- }
257
244
  }
@@ -187,6 +187,12 @@ export class TabsList {
187
187
 
188
188
  if (handled) {
189
189
  event.preventDefault();
190
+
191
+ // Skip disabled tabs — scan in the movement direction
192
+ const direction =
193
+ event.key === 'ArrowLeft' || event.key === 'ArrowUp' || event.key === 'End' ? -1 : 1;
194
+ newIndex = this.findEnabledIndex(tabValues, newIndex, direction);
195
+
190
196
  const newValue = tabValues[newIndex];
191
197
 
192
198
  // In automatic mode, activate on focus; in manual mode, just focus
@@ -201,6 +207,18 @@ export class TabsList {
201
207
  }
202
208
  }
203
209
 
210
+ private findEnabledIndex(tabValues: string[], startIndex: number, direction: 1 | -1): number {
211
+ const length = tabValues.length;
212
+ let index = startIndex;
213
+ for (let i = 0; i < length; i++) {
214
+ const tabId = this.tabs.getTabId(tabValues[index]);
215
+ const el = document.getElementById(tabId);
216
+ if (!el?.hasAttribute('data-disabled')) return index;
217
+ index = ((index + direction) + length) % length;
218
+ }
219
+ return startIndex;
220
+ }
221
+
204
222
  private updateIndicator(): void {
205
223
  const activeValue = this.tabs.value();
206
224
  if (!activeValue) return;
@@ -83,7 +83,6 @@ export interface TabsTriggerProps {
83
83
  '[attr.aria-controls]': 'panelId()',
84
84
  '[attr.aria-disabled]': 'disabled() || null',
85
85
  '[attr.tabindex]': 'isActive() ? 0 : -1',
86
- '[attr.disabled]': 'disabled() ? "" : null',
87
86
  '(click)': 'onClick()',
88
87
  '(keydown.enter)': 'onClick()',
89
88
  '(keydown.space)': 'onSpace($event)',
@@ -7,6 +7,7 @@ import {
7
7
  input,
8
8
  model,
9
9
  output,
10
+ signal,
10
11
  } from '@angular/core';
11
12
  import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
12
13
  import { toggleVariants, type ToggleVariants } from './toggle-variants';
@@ -97,8 +98,8 @@ export type ToggleProps = {
97
98
  type: 'button',
98
99
  '[attr.aria-pressed]': 'pressed()',
99
100
  '[attr.data-state]': 'state()',
100
- '[attr.data-disabled]': 'disabled() ? "" : null',
101
- '[attr.disabled]': 'disabled() ? "" : null',
101
+ '[attr.data-disabled]': 'isDisabled() ? "" : null',
102
+ '[attr.disabled]': 'isDisabled() ? "" : null',
102
103
  '(click)': 'toggle()',
103
104
  'data-slot': 'toggle',
104
105
  },
@@ -136,6 +137,9 @@ export class Toggle implements ControlValueAccessor {
136
137
  /** Additional CSS classes to apply */
137
138
  readonly class = input<string>('');
138
139
 
140
+ /** Whether the toggle is effectively disabled (input or Angular Forms) */
141
+ protected readonly isDisabled = computed(() => this.disabled() || this.isFormsDisabled());
142
+
139
143
  /** Current state for data attribute */
140
144
  protected readonly state = computed((): ToggleState => (this.pressed() ? 'on' : 'off'));
141
145
  /** Computed class combining variants and custom classes */
@@ -149,17 +153,19 @@ export class Toggle implements ControlValueAccessor {
149
153
  ),
150
154
  );
151
155
 
156
+ /** Tracks disabled state set by Angular Forms (.disable() / .enable()) */
157
+ private readonly isFormsDisabled = signal<boolean>(false);
158
+
152
159
  /** ControlValueAccessor callbacks */
153
160
  private onChange: (value: boolean) => void = () => {};
154
161
  private onTouched: () => void = () => {};
155
- setDisabledState?(isDisabled: boolean): void {
156
- // Disabled state is managed by the disabled input
157
- // Angular forms will call this but we use the input binding
162
+ setDisabledState(isDisabled: boolean): void {
163
+ this.isFormsDisabled.set(isDisabled);
158
164
  }
159
165
 
160
166
  /** Toggle the pressed state */
161
167
  toggle(): void {
162
- if (!this.disabled()) {
168
+ if (!this.isDisabled()) {
163
169
  const newValue = !this.pressed();
164
170
  this.pressed.set(newValue);
165
171
  this.onChange(newValue);