@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
@@ -1,12 +1,16 @@
1
- import { cn, Presence } from '@/lib/utils';
1
+ import { Align, cn, computePosition, getTransformOrigin, Presence, Side } from '@/lib/utils';
2
2
  import {
3
+ afterNextRender,
3
4
  ChangeDetectionStrategy,
4
5
  Component,
5
6
  computed,
7
+ effect,
6
8
  ElementRef,
7
9
  inject,
10
+ Injector,
8
11
  input,
9
12
  OnDestroy,
13
+ signal,
10
14
  } from '@angular/core';
11
15
  import { HOVER_CARD_CONTEXT, HoverCardAlign, HoverCardSide } from './hover-card-context';
12
16
 
@@ -38,31 +42,6 @@ export interface HoverCardContentProps {
38
42
  * HoverCardContent displays the preview content. It stays open when
39
43
  * hovered, allowing users to interact with the content.
40
44
  *
41
- * ## Features
42
- * - Stays open when content is hovered
43
- * - Configurable side and alignment
44
- * - Smooth animations
45
- * - Escape key to dismiss
46
- *
47
- * ## Accessibility
48
- * - `role="dialog"` on the content
49
- * - Focusable content items
50
- * - Escape returns focus to trigger
51
- *
52
- * @example Basic usage
53
- * ```html
54
- * <HoverCardContent>
55
- * <p>Preview content</p>
56
- * </HoverCardContent>
57
- * ```
58
- *
59
- * @example With positioning
60
- * ```html
61
- * <HoverCardContent side="right" align="start">
62
- * <p>Right-aligned content</p>
63
- * </HoverCardContent>
64
- * ```
65
- *
66
45
  * @data-attributes
67
46
  * - `data-state` - 'open' | 'closed'
68
47
  * - `data-side` - 'top' | 'right' | 'bottom' | 'left'
@@ -78,9 +57,10 @@ export interface HoverCardContentProps {
78
57
  [attr.aria-modal]="false"
79
58
  tabindex="-1"
80
59
  [class]="computedClass()"
60
+ [style]="positionStyles()"
81
61
  [attr.data-state]="state()"
82
- [attr.data-side]="side()"
83
- [attr.data-align]="align()"
62
+ [attr.data-side]="computedSide()"
63
+ [attr.data-align]="computedAlign()"
84
64
  data-slot="hover-card-content"
85
65
  (mouseenter)="onMouseEnter()"
86
66
  (mouseleave)="onMouseLeave()"
@@ -99,6 +79,25 @@ export interface HoverCardContentProps {
99
79
  changeDetection: ChangeDetectionStrategy.OnPush,
100
80
  })
101
81
  export class HoverCardContent implements OnDestroy {
82
+ constructor() {
83
+ effect(() => {
84
+ const isOpen = this.context.open();
85
+ if (isOpen) {
86
+ this.isPositioned.set(false);
87
+ afterNextRender(
88
+ () => {
89
+ this.schedulePositionUpdate();
90
+ },
91
+ { injector: this._injector },
92
+ );
93
+ } else {
94
+ this.cancelScheduledPositionUpdate();
95
+ this.isPositioned.set(false);
96
+ this.positionStyles.set({ position: 'fixed', top: '-9999px', left: '-9999px' });
97
+ }
98
+ });
99
+ }
100
+
102
101
  /** The preferred side of the trigger to render against */
103
102
  readonly side = input<HoverCardSide>('bottom');
104
103
  /** The distance in pixels from the trigger */
@@ -109,80 +108,129 @@ export class HoverCardContent implements OnDestroy {
109
108
  readonly class = input<string>('');
110
109
 
111
110
  private readonly _elementRef = inject(ElementRef<HTMLElement>);
111
+ private readonly _injector = inject(Injector);
112
112
 
113
113
  protected readonly context = inject(HOVER_CARD_CONTEXT);
114
114
 
115
- protected readonly computedClass = computed(() => {
116
- const sideClasses = {
117
- top: 'bottom-full mb-2',
118
- bottom: 'top-full mt-2',
119
- left: 'right-full mr-2',
120
- right: 'left-full ml-2',
121
- };
122
-
123
- const alignClasses = {
124
- start: 'left-0',
125
- center: 'left-1/2 -translate-x-1/2',
126
- end: 'right-0',
127
- };
128
-
129
- return cn(
130
- 'absolute z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
115
+ protected readonly computedClass = computed(() =>
116
+ cn(
117
+ 'fixed z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
131
118
  'data-[state=open]:animate-in data-[state=closed]:animate-out',
132
119
  'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
133
120
  'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
134
121
  'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
135
122
  'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
136
- sideClasses[this.side()],
137
- this.side() === 'top' || this.side() === 'bottom' ? alignClasses[this.align()] : '',
123
+ !this.isPositioned() && 'pointer-events-none opacity-0',
138
124
  this.class(),
139
- );
125
+ ),
126
+ );
127
+
128
+ protected readonly positionStyles = signal<Record<string, string>>({
129
+ position: 'fixed',
130
+ top: '-9999px',
131
+ left: '-9999px',
140
132
  });
133
+ protected readonly isPositioned = signal(false);
134
+ protected readonly computedSide = signal<Side>('bottom');
135
+ protected readonly computedAlign = signal<Align>('center');
141
136
 
142
- private closeTimeout: ReturnType<typeof setTimeout> | null = null;
143
137
  /** Current state: open or closed */
144
138
  protected readonly state = computed<HoverCardContentState>(() =>
145
139
  this.context.open() ? 'open' : 'closed',
146
140
  );
147
141
 
142
+ private closeTimeout: ReturnType<typeof setTimeout> | null = null;
143
+ private positionFrameId: number | null = null;
144
+
148
145
  ngOnDestroy(): void {
149
- this.clearTimeout();
146
+ this.clearCloseTimeout();
147
+ this.cancelScheduledPositionUpdate();
150
148
  }
151
149
 
152
150
  onMouseEnter(): void {
153
- this.clearTimeout();
151
+ this.clearCloseTimeout();
154
152
  }
155
153
  onMouseLeave(): void {
156
154
  this.closeTimeout = setTimeout(() => {
157
155
  this.context.setOpen(false);
158
- }, this.context.closeDelay);
156
+ }, this.context.closeDelay());
159
157
  }
160
158
  onFocusIn(): void {
161
- this.clearTimeout();
159
+ this.clearCloseTimeout();
162
160
  }
163
161
  onFocusOut(event: FocusEvent): void {
164
162
  const relatedTarget = event.relatedTarget as HTMLElement | null;
165
- const trigger = this._elementRef.nativeElement.parentElement?.querySelector('[data-state]');
163
+ const trigger = this.context.triggerRef();
166
164
 
167
- // Check if focus moved to trigger or stayed within content
168
165
  if (relatedTarget && (trigger === relatedTarget || trigger?.contains(relatedTarget))) {
169
166
  return;
170
167
  }
171
168
 
172
169
  this.closeTimeout = setTimeout(() => {
173
170
  this.context.setOpen(false);
174
- }, this.context.closeDelay);
171
+ }, this.context.closeDelay());
175
172
  }
176
173
  onEscape(): void {
177
174
  this.context.setOpen(false);
178
- // Return focus to trigger
179
- const trigger = this._elementRef.nativeElement.parentElement?.querySelector(
180
- '[data-state]',
175
+ this.context.triggerRef()?.focus();
176
+ }
177
+
178
+ private schedulePositionUpdate(): void {
179
+ this.cancelScheduledPositionUpdate();
180
+ this.positionFrameId = requestAnimationFrame(() => {
181
+ this.updatePosition();
182
+ this.positionFrameId = requestAnimationFrame(() => {
183
+ this.updatePosition();
184
+ this.positionFrameId = null;
185
+ });
186
+ });
187
+ }
188
+ private cancelScheduledPositionUpdate(): void {
189
+ if (this.positionFrameId !== null) {
190
+ cancelAnimationFrame(this.positionFrameId);
191
+ this.positionFrameId = null;
192
+ }
193
+ }
194
+ private updatePosition(): void {
195
+ const triggerElement = this.context.triggerRef();
196
+ const contentElement = this._elementRef.nativeElement.querySelector(
197
+ '[role="dialog"]',
181
198
  ) as HTMLElement;
182
- trigger?.focus();
199
+
200
+ if (!triggerElement || !contentElement) return;
201
+
202
+ const triggerRect = triggerElement.getBoundingClientRect();
203
+ const contentRect = contentElement.getBoundingClientRect();
204
+ const overlayWidth = Math.round(contentRect.width || 256);
205
+ const overlayHeight = Math.round(contentRect.height || 100);
206
+
207
+ const result = computePosition(
208
+ triggerRect,
209
+ { width: overlayWidth, height: overlayHeight },
210
+ {
211
+ side: this.side(),
212
+ align: this.align(),
213
+ sideOffset: this.sideOffset(),
214
+ alignOffset: 0,
215
+ avoidCollisions: true,
216
+ collisionPadding: 8,
217
+ },
218
+ );
219
+
220
+ this.computedSide.set(result.side);
221
+ this.computedAlign.set(result.align);
222
+
223
+ const transformOrigin = getTransformOrigin(result.side, result.align);
224
+ this.positionStyles.set({
225
+ position: 'fixed',
226
+ top: result.styles.top || '',
227
+ left: result.styles.left || '',
228
+ '--radix-hover-card-content-transform-origin': transformOrigin,
229
+ });
230
+ this.isPositioned.set(true);
183
231
  }
184
232
 
185
- private clearTimeout(): void {
233
+ private clearCloseTimeout(): void {
186
234
  if (this.closeTimeout) {
187
235
  clearTimeout(this.closeTimeout);
188
236
  this.closeTimeout = null;
@@ -9,9 +9,11 @@ export interface HoverCardContextValue {
9
9
  /** Set open state */
10
10
  setOpen: (open: boolean) => void;
11
11
  /** The duration from when the pointer enters the trigger until the hover card opens (ms) */
12
- openDelay: number;
12
+ openDelay: () => number;
13
13
  /** The duration from when the pointer leaves the trigger/content until the hover card closes (ms) */
14
- closeDelay: number;
14
+ closeDelay: () => number;
15
+ /** Reference to the trigger element for fixed positioning */
16
+ triggerRef: WritableSignal<HTMLElement | null>;
15
17
  }
16
18
 
17
19
  export const HOVER_CARD_CONTEXT = new InjectionToken<HoverCardContextValue>('HOVER_CARD_CONTEXT');
@@ -96,18 +96,20 @@ export class HoverCardTrigger implements OnDestroy {
96
96
  }
97
97
 
98
98
  onMouseEnter(): void {
99
+ this.context.triggerRef.set(this._elementRef.nativeElement);
99
100
  this.clearTimeouts();
100
101
  this.openTimeout = setTimeout(() => {
101
102
  this.context.setOpen(true);
102
- }, this.context.openDelay);
103
+ }, this.context.openDelay());
103
104
  }
104
105
  onMouseLeave(): void {
105
106
  this.clearTimeouts();
106
107
  this.closeTimeout = setTimeout(() => {
107
108
  this.context.setOpen(false);
108
- }, this.context.closeDelay);
109
+ }, this.context.closeDelay());
109
110
  }
110
111
  onFocus(): void {
112
+ this.context.triggerRef.set(this._elementRef.nativeElement);
111
113
  this.clearTimeouts();
112
114
  // Open immediately on focus for keyboard users
113
115
  this.context.setOpen(true);
@@ -127,7 +129,7 @@ export class HoverCardTrigger implements OnDestroy {
127
129
  this.clearTimeouts();
128
130
  this.closeTimeout = setTimeout(() => {
129
131
  this.context.setOpen(false);
130
- }, this.context.closeDelay);
132
+ }, this.context.closeDelay());
131
133
  }
132
134
  onKeyDown(event: Event): void {
133
135
  event.preventDefault();
@@ -5,6 +5,8 @@ import {
5
5
  input,
6
6
  output,
7
7
  signal,
8
+ Signal,
9
+ WritableSignal,
8
10
  } from '@angular/core';
9
11
  import { HOVER_CARD_CONTEXT, type HoverCardContextValue } from './hover-card-context';
10
12
 
@@ -93,7 +95,7 @@ export interface HoverCardProps {
93
95
  template: `<ng-content />`,
94
96
  host: {
95
97
  'attr.data-slot': '"hover-card"',
96
- class: 'relative inline-block',
98
+ class: 'inline-block',
97
99
  },
98
100
  providers: [
99
101
  {
@@ -120,10 +122,13 @@ export class HoverCard implements HoverCardContextValue {
120
122
 
121
123
  readonly open = signal(false);
122
124
 
125
+ /** Reference to the trigger element for fixed positioning */
126
+ readonly triggerRef: WritableSignal<HTMLElement | null> = signal<HTMLElement | null>(null);
127
+
123
128
  /** The duration from when the pointer enters the trigger until the hover card opens (ms) */
124
- readonly openDelay = 700;
129
+ readonly openDelay = input<number>(700);
125
130
  /** The duration from when the pointer leaves the trigger/content until the hover card closes (ms) */
126
- readonly closeDelay = 300;
131
+ readonly closeDelay = input<number>(300);
127
132
 
128
133
  setOpen(open: boolean): void {
129
134
  if (this.controlledOpen() === undefined) {
@@ -14,6 +14,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
14
14
  selector: 'InputGroupAddon',
15
15
  template: `<ng-content />`,
16
16
  host: {
17
+ 'attr.data-slot': '"input-group-addon"',
17
18
  '[class]': 'computedClass()',
18
19
  },
19
20
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
10
10
  selector: 'InputGroupInput',
11
11
  template: ``,
12
12
  host: {
13
+ 'attr.data-slot': '"input-group-input"',
13
14
  '[class]': 'computedClass()',
14
15
  },
15
16
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -30,6 +30,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
30
30
  selector: 'InputGroup',
31
31
  template: `<ng-content />`,
32
32
  host: {
33
+ 'attr.data-slot': '"input-group"',
33
34
  '[class]': 'computedClass()',
34
35
  },
35
36
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -114,7 +114,7 @@ export class MenubarContent implements OnDestroy {
114
114
  if (content) {
115
115
  this.menuItems = Array.from(
116
116
  content.querySelectorAll(
117
- '[role="menuitem"]:not([aria-disabled="true"]):not([data-disabled])',
117
+ '[role="menuitem"]:not([data-disabled=""]), [role="menuitemcheckbox"]:not([data-disabled=""]), [role="menuitemradio"]:not([data-disabled=""])',
118
118
  ),
119
119
  );
120
120
  }
@@ -18,7 +18,13 @@ import { NAVIGATION_MENU_CONTEXT, NAVIGATION_MENU_ITEM_CONTEXT } from './navigat
18
18
  imports: [Presence],
19
19
  template: `
20
20
  <Presence [present]="itemContext.open()">
21
- <div [class]="computedClass()" [attr.data-state]="itemContext.open() ? 'open' : 'closed'">
21
+ <div
22
+ [class]="computedClass()"
23
+ [attr.id]="itemContext.contentId"
24
+ [attr.data-state]="itemContext.open() ? 'open' : 'closed'"
25
+ [attr.aria-labelledby]="itemContext.triggerId"
26
+ role="region"
27
+ >
22
28
  <ng-content />
23
29
  </div>
24
30
  </Presence>
@@ -22,6 +22,16 @@ export interface NavigationMenuContextValue {
22
22
  activeItem: WritableSignal<string | null>;
23
23
  /** Layout orientation of the menu */
24
24
  orientation: WritableSignal<NavigationMenuOrientation>;
25
+ /** Ordered list of registered trigger element IDs */
26
+ triggerIds: WritableSignal<string[]>;
27
+ /** Register a trigger ID (called by NavigationMenuTrigger on init) */
28
+ registerTrigger: (triggerId: string) => void;
29
+ /** Unregister a trigger ID (called by NavigationMenuTrigger on destroy) */
30
+ unregisterTrigger: (triggerId: string) => void;
31
+ /** Move DOM focus to the next trigger in document order */
32
+ focusNextTrigger: (currentTriggerId: string) => void;
33
+ /** Move DOM focus to the previous trigger in document order */
34
+ focusPreviousTrigger: (currentTriggerId: string) => void;
25
35
  }
26
36
 
27
37
  export const NAVIGATION_MENU_CONTEXT = new InjectionToken<NavigationMenuContextValue>(
@@ -38,6 +48,10 @@ export const NAVIGATION_MENU_CONTEXT = new InjectionToken<NavigationMenuContextV
38
48
  export interface NavigationMenuItemContextValue {
39
49
  /** Unique identifier for this item */
40
50
  itemId: string;
51
+ /** DOM id for the trigger element (used for aria-controls / aria-labelledby) */
52
+ triggerId: string;
53
+ /** DOM id for the content element (used for aria-controls) */
54
+ contentId: string;
41
55
  /** Whether this item's content is open */
42
56
  open: WritableSignal<boolean>;
43
57
  }
@@ -17,10 +17,15 @@ let itemIdCounter = 0;
17
17
  providers: [
18
18
  {
19
19
  provide: NAVIGATION_MENU_ITEM_CONTEXT,
20
- useFactory: (): NavigationMenuItemContextValue => ({
21
- itemId: `nav-item-${itemIdCounter++}`,
22
- open: signal(false),
23
- }),
20
+ useFactory: (): NavigationMenuItemContextValue => {
21
+ const id = itemIdCounter++;
22
+ return {
23
+ itemId: `nav-item-${id}`,
24
+ triggerId: `nav-trigger-${id}`,
25
+ contentId: `nav-content-${id}`,
26
+ open: signal(false),
27
+ };
28
+ },
24
29
  },
25
30
  ],
26
31
  host: {
@@ -1,5 +1,13 @@
1
1
  import { cn } from '@/lib/utils';
2
- import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ computed,
6
+ inject,
7
+ input,
8
+ OnDestroy,
9
+ OnInit,
10
+ } from '@angular/core';
3
11
  import { ChevronDown, LucideAngularModule } from 'lucide-angular';
4
12
  import { NAVIGATION_MENU_CONTEXT, NAVIGATION_MENU_ITEM_CONTEXT } from './navigation-menu-context';
5
13
  import { navigationMenuTriggerStyle } from './navigation-menu-trigger-style';
@@ -22,13 +30,19 @@ import { navigationMenuTriggerStyle } from './navigation-menu-trigger-style';
22
30
  host: {
23
31
  'attr.data-slot': '"navigation-menu-trigger"',
24
32
  '[class]': 'computedClass()',
33
+ '[attr.id]': 'itemContext.triggerId',
25
34
  '[attr.data-state]': 'itemContext.open() ? "open" : "closed"',
35
+ '[attr.role]': '"button"',
36
+ '[attr.aria-expanded]': 'itemContext.open()',
37
+ '[attr.aria-haspopup]': '"menu"',
38
+ '[attr.aria-controls]': 'itemContext.contentId',
26
39
  '(click)': 'toggle()',
27
40
  '(mouseenter)': 'onMouseEnter()',
41
+ '(keydown)': 'onKeyDown($event)',
28
42
  },
29
43
  changeDetection: ChangeDetectionStrategy.OnPush,
30
44
  })
31
- export class NavigationMenuTrigger {
45
+ export class NavigationMenuTrigger implements OnInit, OnDestroy {
32
46
  /** Additional CSS classes */
33
47
  readonly class = input<string>('');
34
48
 
@@ -41,6 +55,14 @@ export class NavigationMenuTrigger {
41
55
 
42
56
  protected readonly ChevronDownIcon = ChevronDown;
43
57
 
58
+ ngOnInit(): void {
59
+ this.context.registerTrigger(this.itemContext.triggerId);
60
+ }
61
+
62
+ ngOnDestroy(): void {
63
+ this.context.unregisterTrigger(this.itemContext.triggerId);
64
+ }
65
+
44
66
  protected toggle(): void {
45
67
  this.itemContext.open.update((v) => !v);
46
68
  if (this.itemContext.open()) {
@@ -49,6 +71,7 @@ export class NavigationMenuTrigger {
49
71
  this.context.activeItem.set(null);
50
72
  }
51
73
  }
74
+
52
75
  protected onMouseEnter(): void {
53
76
  const activeItem = this.context.activeItem();
54
77
  if (activeItem && activeItem !== this.itemContext.itemId) {
@@ -56,4 +79,48 @@ export class NavigationMenuTrigger {
56
79
  this.itemContext.open.set(true);
57
80
  }
58
81
  }
82
+
83
+ protected onKeyDown(event: KeyboardEvent): void {
84
+ switch (event.key) {
85
+ case 'Enter':
86
+ case ' ':
87
+ event.preventDefault();
88
+ this.toggle();
89
+ if (this.itemContext.open()) {
90
+ this.focusFirstContentItem();
91
+ }
92
+ break;
93
+ case 'ArrowDown':
94
+ event.preventDefault();
95
+ if (!this.itemContext.open()) {
96
+ this.itemContext.open.set(true);
97
+ this.context.activeItem.set(this.itemContext.itemId);
98
+ }
99
+ this.focusFirstContentItem();
100
+ break;
101
+ case 'ArrowRight':
102
+ event.preventDefault();
103
+ this.context.focusNextTrigger(this.itemContext.triggerId);
104
+ break;
105
+ case 'ArrowLeft':
106
+ event.preventDefault();
107
+ this.context.focusPreviousTrigger(this.itemContext.triggerId);
108
+ break;
109
+ case 'Escape':
110
+ if (this.itemContext.open()) {
111
+ this.itemContext.open.set(false);
112
+ this.context.activeItem.set(null);
113
+ document.getElementById(this.itemContext.triggerId)?.focus();
114
+ }
115
+ break;
116
+ }
117
+ }
118
+
119
+ private focusFirstContentItem(): void {
120
+ setTimeout(() => {
121
+ const content = document.getElementById(this.itemContext.contentId);
122
+ const focusable = content?.querySelector<HTMLElement>('a, button, [tabindex]');
123
+ focusable?.focus();
124
+ }, 10);
125
+ }
59
126
  }
@@ -7,6 +7,37 @@ import {
7
7
  } from './navigation-menu-context';
8
8
  import { NavigationMenuViewport } from './navigation-menu-viewport.component';
9
9
 
10
+ function createNavigationMenuContext(): NavigationMenuContextValue {
11
+ const triggerIds = signal<string[]>([]);
12
+ return {
13
+ activeItem: signal(null),
14
+ orientation: signal('horizontal'),
15
+ triggerIds,
16
+ registerTrigger: (triggerId: string) => {
17
+ triggerIds.update((ids) => [...ids, triggerId]);
18
+ },
19
+ unregisterTrigger: (triggerId: string) => {
20
+ triggerIds.update((ids) => ids.filter((id) => id !== triggerId));
21
+ },
22
+ focusNextTrigger: (currentTriggerId: string) => {
23
+ const ids = triggerIds();
24
+ const idx = ids.indexOf(currentTriggerId);
25
+ const nextId = idx < ids.length - 1 ? ids[idx + 1] : ids[0];
26
+ if (nextId) {
27
+ document.getElementById(nextId)?.focus();
28
+ }
29
+ },
30
+ focusPreviousTrigger: (currentTriggerId: string) => {
31
+ const ids = triggerIds();
32
+ const idx = ids.indexOf(currentTriggerId);
33
+ const prevId = idx > 0 ? ids[idx - 1] : ids[ids.length - 1];
34
+ if (prevId) {
35
+ document.getElementById(prevId)?.focus();
36
+ }
37
+ },
38
+ };
39
+ }
40
+
10
41
  /**
11
42
  * Props for the NavigationMenu component
12
43
  */
@@ -121,10 +152,7 @@ export interface NavigationMenuProps {
121
152
  providers: [
122
153
  {
123
154
  provide: NAVIGATION_MENU_CONTEXT,
124
- useFactory: (): NavigationMenuContextValue => ({
125
- activeItem: signal(null),
126
- orientation: signal('horizontal'),
127
- }),
155
+ useFactory: createNavigationMenuContext,
128
156
  },
129
157
  ],
130
158
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -29,12 +29,14 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
29
29
  host: {
30
30
  'attr.data-slot': '"pagination"',
31
31
  role: 'navigation',
32
- '[attr.aria-label]': '"pagination"',
32
+ '[attr.aria-label]': 'ariaLabel()',
33
33
  '[class]': 'computedClass()',
34
34
  },
35
35
  changeDetection: ChangeDetectionStrategy.OnPush,
36
36
  })
37
37
  export class Pagination {
38
+ /** Accessible label for the navigation landmark */
39
+ readonly ariaLabel = input<string>('Pagination');
38
40
  /** Additional CSS classes */
39
41
  readonly class = input<string>('');
40
42
 
@@ -100,6 +100,7 @@ export interface PopoverContentProps {
100
100
  [attr.data-align]="computedAlign()"
101
101
  [style]="mergedStyles()"
102
102
  role="dialog"
103
+ tabindex="-1"
103
104
  [attr.aria-modal]="context.modal() || null"
104
105
  >
105
106
  <ng-content />
@@ -277,5 +278,15 @@ export class PopoverContent {
277
278
  '--radix-popover-content-transform-origin': transformOrigin,
278
279
  });
279
280
  this.isPositioned.set(true);
281
+
282
+ // Move focus into the popover for keyboard accessibility
283
+ setTimeout(() => {
284
+ const dialog = this._elementRef.nativeElement.querySelector('[role="dialog"]') as HTMLElement;
285
+ if (!dialog) return;
286
+ const firstFocusable = dialog.querySelector<HTMLElement>(
287
+ 'button:not([disabled]):not([data-disabled=""]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
288
+ );
289
+ (firstFocusable ?? dialog).focus({ preventScroll: true });
290
+ }, 0);
280
291
  }
281
292
  }
@@ -16,6 +16,8 @@ export interface PopoverContextValue {
16
16
  triggerRef?: Signal<HTMLElement | null>;
17
17
  /** Set the trigger element reference */
18
18
  setTriggerRef?: (element: HTMLElement | null) => void;
19
+ /** Unique ID for aria-controls relationship */
20
+ contentId: string;
19
21
  }
20
22
 
21
23
  export const POPOVER_CONTEXT = new InjectionToken<PopoverContextValue>('POPOVER_CONTEXT');
@@ -10,6 +10,8 @@ import { POPOVER_CONTEXT, type PopoverContextValue } from './popover-context';
10
10
 
11
11
  export type PopoverState = 'open' | 'closed';
12
12
 
13
+ let idCounter = 0;
14
+
13
15
  /**
14
16
  * Props for the Popover component
15
17
  */
@@ -128,6 +130,8 @@ export class Popover implements PopoverContextValue {
128
130
  readonly controlledOpen = input<boolean | undefined>(undefined, { alias: 'open' });
129
131
 
130
132
  readonly open = signal(false);
133
+ /** Unique ID for aria-controls relationship */
134
+ readonly contentId = `popover-content-${++idCounter}`;
131
135
  /** Reference to the trigger element for positioning */
132
136
  readonly triggerRef = signal<HTMLElement | null>(null);
133
137
 
@@ -102,9 +102,8 @@ export type ProgressProps = {
102
102
  '[attr.aria-label]': 'ariaLabel()',
103
103
  '[attr.aria-valuemin]': '0',
104
104
  '[attr.aria-valuemax]': 'max()',
105
- '[attr.aria-valuenow]': 'value()',
105
+ '[attr.aria-valuenow]': 'value() !== null ? value() : null',
106
106
  '[attr.aria-valuetext]': 'computedValueText()',
107
- '[attr.aria-live]': '"polite"',
108
107
  '[attr.data-state]': 'state()',
109
108
  '[attr.data-value]': 'value()',
110
109
  '[attr.data-max]': 'max()',