@ng-cn/core 1.0.17 → 1.0.20

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 (111) hide show
  1. package/package.json +6 -5
  2. package/src/app/lib/components/ui/alert-dialog/alert-dialog-content.component.ts +22 -21
  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 +70 -13
  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 +49 -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/country-selector/country-data.ts +63 -0
  26. package/src/app/lib/components/ui/country-selector/country-selector.component.ts +199 -0
  27. package/src/app/lib/components/ui/country-selector/index.ts +2 -0
  28. package/src/app/lib/components/ui/date-picker/date-picker.component.ts +48 -5
  29. package/src/app/lib/components/ui/dialog/dialog-content.component.ts +27 -20
  30. package/src/app/lib/components/ui/direction/direction-context.ts +9 -0
  31. package/src/app/lib/components/ui/direction/direction.component.ts +48 -0
  32. package/src/app/lib/components/ui/direction/index.ts +2 -0
  33. package/src/app/lib/components/ui/drawer/drawer-content.component.ts +44 -0
  34. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-content.component.ts +25 -23
  35. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.component.ts +1 -0
  36. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.component.ts +2 -0
  37. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.component.ts +28 -2
  38. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub.component.ts +3 -0
  39. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-trigger.component.ts +25 -0
  40. package/src/app/lib/components/ui/empty/empty-action.component.ts +1 -0
  41. package/src/app/lib/components/ui/empty/empty-description.component.ts +1 -0
  42. package/src/app/lib/components/ui/empty/empty-icon.component.ts +2 -1
  43. package/src/app/lib/components/ui/empty/empty-title.component.ts +1 -0
  44. package/src/app/lib/components/ui/empty/empty.component.ts +1 -0
  45. package/src/app/lib/components/ui/field/field-content.component.ts +34 -0
  46. package/src/app/lib/components/ui/field/field-description.component.ts +35 -0
  47. package/src/app/lib/components/ui/field/field-error.component.ts +48 -0
  48. package/src/app/lib/components/ui/field/field-group.component.ts +34 -0
  49. package/src/app/lib/components/ui/field/field-label.component.ts +46 -0
  50. package/src/app/lib/components/ui/field/field-legend.component.ts +41 -0
  51. package/src/app/lib/components/ui/field/field-separator.component.ts +49 -0
  52. package/src/app/lib/components/ui/field/field-set.component.ts +37 -0
  53. package/src/app/lib/components/ui/field/field-title.component.ts +30 -0
  54. package/src/app/lib/components/ui/field/field.component.ts +66 -0
  55. package/src/app/lib/components/ui/field/index.ts +15 -0
  56. package/src/app/lib/components/ui/form/form-description.component.ts +2 -2
  57. package/src/app/lib/components/ui/hover-card/hover-card-content.component.ts +108 -60
  58. package/src/app/lib/components/ui/hover-card/hover-card-context.ts +4 -2
  59. package/src/app/lib/components/ui/hover-card/hover-card-trigger.component.ts +5 -3
  60. package/src/app/lib/components/ui/hover-card/hover-card.component.ts +8 -3
  61. package/src/app/lib/components/ui/input-group/input-group-addon.component.ts +1 -0
  62. package/src/app/lib/components/ui/input-group/input-group-input.component.ts +1 -0
  63. package/src/app/lib/components/ui/input-group/input-group.component.ts +1 -0
  64. package/src/app/lib/components/ui/item/index.ts +21 -0
  65. package/src/app/lib/components/ui/item/item-actions.component.ts +29 -0
  66. package/src/app/lib/components/ui/item/item-content.component.ts +31 -0
  67. package/src/app/lib/components/ui/item/item-description.component.ts +30 -0
  68. package/src/app/lib/components/ui/item/item-footer.component.ts +30 -0
  69. package/src/app/lib/components/ui/item/item-group.component.ts +32 -0
  70. package/src/app/lib/components/ui/item/item-header.component.ts +30 -0
  71. package/src/app/lib/components/ui/item/item-media.component.ts +63 -0
  72. package/src/app/lib/components/ui/item/item-separator.component.ts +33 -0
  73. package/src/app/lib/components/ui/item/item-title.component.ts +27 -0
  74. package/src/app/lib/components/ui/item/item.component.ts +77 -0
  75. package/src/app/lib/components/ui/menubar/menubar-content.component.ts +1 -1
  76. package/src/app/lib/components/ui/navigation-menu/navigation-menu-content.component.ts +7 -1
  77. package/src/app/lib/components/ui/navigation-menu/navigation-menu-context.ts +14 -0
  78. package/src/app/lib/components/ui/navigation-menu/navigation-menu-item.component.ts +9 -4
  79. package/src/app/lib/components/ui/navigation-menu/navigation-menu-trigger.component.ts +69 -2
  80. package/src/app/lib/components/ui/navigation-menu/navigation-menu.component.ts +32 -4
  81. package/src/app/lib/components/ui/pagination/pagination.component.ts +3 -1
  82. package/src/app/lib/components/ui/phone-input/index.ts +1 -0
  83. package/src/app/lib/components/ui/phone-input/phone-input.component.ts +169 -0
  84. package/src/app/lib/components/ui/popover/popover-content.component.ts +11 -0
  85. package/src/app/lib/components/ui/popover/popover-context.ts +2 -0
  86. package/src/app/lib/components/ui/popover/popover.component.ts +4 -0
  87. package/src/app/lib/components/ui/progress/progress.component.ts +1 -2
  88. package/src/app/lib/components/ui/resizable/resizable-handle.component.ts +2 -2
  89. package/src/app/lib/components/ui/scroll-area/scroll-area.component.ts +7 -6
  90. package/src/app/lib/components/ui/segmented/segmented-item.component.ts +1 -0
  91. package/src/app/lib/components/ui/segmented/segmented.component.ts +1 -0
  92. package/src/app/lib/components/ui/select/select-content.component.ts +35 -15
  93. package/src/app/lib/components/ui/select/select-context.ts +10 -0
  94. package/src/app/lib/components/ui/select/select-item.component.ts +25 -7
  95. package/src/app/lib/components/ui/select/select-trigger.component.ts +6 -13
  96. package/src/app/lib/components/ui/select/select.component.ts +46 -0
  97. package/src/app/lib/components/ui/sheet/sheet-content.component.ts +23 -6
  98. package/src/app/lib/components/ui/slider/slider.component.ts +2 -2
  99. package/src/app/lib/components/ui/sonner/index.ts +2 -0
  100. package/src/app/lib/components/ui/sonner/sonner.component.ts +70 -0
  101. package/src/app/lib/components/ui/switch/switch.component.ts +1 -14
  102. package/src/app/lib/components/ui/tabs/tabs-list.component.ts +20 -0
  103. package/src/app/lib/components/ui/tabs/tabs-trigger.component.ts +0 -1
  104. package/src/app/lib/components/ui/textarea/textarea.component.ts +110 -10
  105. package/src/app/lib/components/ui/toast/toast.service.ts +1 -1
  106. package/src/app/lib/components/ui/toggle/toggle.component.ts +12 -6
  107. package/src/app/lib/components/ui/tooltip/tooltip-content.component.ts +141 -17
  108. package/src/app/lib/components/ui/tooltip/tooltip-context.ts +3 -1
  109. package/src/app/lib/components/ui/tooltip/tooltip-provider.component.ts +1 -1
  110. package/src/app/lib/components/ui/tooltip/tooltip-trigger.component.ts +5 -2
  111. package/src/app/lib/components/ui/tooltip/tooltip.component.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ng-cn/core",
3
- "version": "1.0.17",
3
+ "version": "1.0.20",
4
4
  "description": "Beautifully designed Angular components built with Tailwind CSS v4 - The official Angular port of shadcn/ui",
5
5
  "keywords": [
6
6
  "angular",
@@ -58,6 +58,8 @@
58
58
  ]
59
59
  },
60
60
  "dependencies": {
61
+ "@angular-devkit/core": "^21.2.5",
62
+ "@angular-devkit/schematics": "^21.2.5",
61
63
  "@angular/cdk": "^21.2.4",
62
64
  "@angular/common": "^21.2.6",
63
65
  "@angular/compiler": "^21.2.6",
@@ -74,18 +76,17 @@
74
76
  "express": "^5.1.0",
75
77
  "lucide-angular": "^1.0.0",
76
78
  "ng-apexcharts": "^2.3.0",
79
+ "ngx-sonner": "^3.1.0",
77
80
  "postcss": "^8.5.8",
78
81
  "rxjs": "~7.8.0",
79
82
  "shiki": "^4.0.2",
80
83
  "tailwind-merge": "^3.5.0",
81
84
  "tailwindcss": "^4.2.2",
82
- "tslib": "^2.3.0",
83
- "@angular-devkit/core": "^21.2.5",
84
- "@angular-devkit/schematics": "^21.2.5"
85
+ "tslib": "^2.3.0"
85
86
  },
86
87
  "devDependencies": {
87
- "@analogjs/vitest-angular": "^2.3.1",
88
88
  "@analogjs/vite-plugin-angular": "^2.2.0",
89
+ "@analogjs/vitest-angular": "^2.3.1",
89
90
  "@angular/build": "^21.2.5",
90
91
  "@angular/cli": "^21.2.5",
91
92
  "@angular/compiler-cli": "^21.2.6",
@@ -1,4 +1,4 @@
1
- import { cn } from '@/lib/utils';
1
+ import { cn, Presence } from '@/lib/utils';
2
2
  import { FocusTrapDirective } from '@/lib/utils/accessibility';
3
3
  import {
4
4
  ChangeDetectionStrategy,
@@ -10,13 +10,9 @@ import {
10
10
  HostListener,
11
11
  inject,
12
12
  input,
13
- signal,
14
13
  } from '@angular/core';
15
14
  import { ALERT_DIALOG_CONTEXT } from './alert-dialog-context';
16
15
 
17
- /** Animation duration in ms — must match Tailwind's duration-200 */
18
- const EXIT_ANIMATION_MS = 200;
19
-
20
16
  /**
21
17
  * AlertDialogContent component - the modal content of the alert dialog.
22
18
  * Matches shadcn/ui React AlertDialogContent exactly.
@@ -26,12 +22,14 @@ const EXIT_ANIMATION_MS = 200;
26
22
  * - Overlay/backdrop click does NOT close the dialog
27
23
  * - Focus is trapped within the dialog
28
24
  * - User must explicitly click Cancel or Action to close
25
+ * - Exit animations handled by Presence component (no setTimeout needed)
26
+ * - Focus restored on any programmatic close (Action/Cancel/Escape)
29
27
  */
30
28
  @Component({
31
29
  selector: 'AlertDialogContent',
32
- imports: [FocusTrapDirective],
30
+ imports: [FocusTrapDirective, Presence],
33
31
  template: `
34
- @if (shouldRender()) {
32
+ <Presence [present]="context.isOpen()">
35
33
  <!-- Overlay - does NOT close on click -->
36
34
  <div
37
35
  class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200"
@@ -55,7 +53,7 @@ const EXIT_ANIMATION_MS = 200;
55
53
  >
56
54
  <ng-content />
57
55
  </div>
58
- }
56
+ </Presence>
59
57
  `,
60
58
  host: {
61
59
  'attr.data-slot': '"alert-dialog-content"',
@@ -65,21 +63,22 @@ const EXIT_ANIMATION_MS = 200;
65
63
  })
66
64
  export class AlertDialogContent {
67
65
  constructor() {
66
+ let wasOpen = false;
67
+
68
68
  effect(() => {
69
69
  const isOpen = this.context.isOpen();
70
70
  this._cdr.markForCheck();
71
71
 
72
72
  if (isOpen) {
73
- this.shouldRender.set(true);
73
+ wasOpen = true;
74
74
  this.lockBodyScroll();
75
75
  } else {
76
76
  this.unlockBodyScroll();
77
- if (this.shouldRender()) {
78
- setTimeout(() => {
79
- this.shouldRender.set(false);
80
- this._cdr.markForCheck();
81
- }, EXIT_ANIMATION_MS);
77
+ // Restore focus whenever dialog closes (covers Action/Cancel/Escape paths)
78
+ if (wasOpen) {
79
+ this.restoreFocus();
82
80
  }
81
+ wasOpen = false;
83
82
  }
84
83
  });
85
84
 
@@ -95,7 +94,6 @@ export class AlertDialogContent {
95
94
  private readonly _cdr = inject(ChangeDetectorRef);
96
95
 
97
96
  protected readonly context = inject(ALERT_DIALOG_CONTEXT);
98
- protected readonly shouldRender = signal(false);
99
97
 
100
98
  protected readonly computedClass = computed(() =>
101
99
  cn(
@@ -111,29 +109,32 @@ export class AlertDialogContent {
111
109
  );
112
110
 
113
111
  private previousBodyOverflow = '';
112
+ private previousBodyPaddingRight = '';
114
113
 
115
114
  @HostListener('document:keydown.escape')
116
115
  onEscapeKey(): void {
117
116
  if (this.context.isOpen()) {
118
- this.close();
117
+ this.context.setOpen(false);
119
118
  }
120
119
  }
121
120
 
122
121
  private lockBodyScroll(): void {
123
- if (typeof document !== 'undefined') {
122
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
123
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
124
124
  this.previousBodyOverflow = document.body.style.overflow;
125
+ this.previousBodyPaddingRight = document.body.style.paddingRight;
125
126
  document.body.style.overflow = 'hidden';
127
+ if (scrollbarWidth > 0) {
128
+ document.body.style.paddingRight = scrollbarWidth + 'px';
129
+ }
126
130
  }
127
131
  }
128
132
  private unlockBodyScroll(): void {
129
133
  if (typeof document !== 'undefined') {
130
134
  document.body.style.overflow = this.previousBodyOverflow;
135
+ document.body.style.paddingRight = this.previousBodyPaddingRight;
131
136
  }
132
137
  }
133
- private close(): void {
134
- this.restoreFocus();
135
- this.context.setOpen(false);
136
- }
137
138
  private restoreFocus(): void {
138
139
  const triggerEl = this.context.getTriggerElement();
139
140
  if (triggerEl) {
@@ -14,6 +14,10 @@ import { Avatar } from './avatar.component';
14
14
  selector: 'ui-avatar',
15
15
  changeDetection: ChangeDetectionStrategy.OnPush,
16
16
  imports: [Avatar, AvatarImage, AvatarFallback],
17
+ host: {
18
+ 'attr.data-slot': '"ui-avatar"',
19
+ style: 'display: contents',
20
+ },
17
21
  template: `
18
22
  <Avatar [class]="class()">
19
23
  @if (src()) {
@@ -4,6 +4,7 @@ import {
4
4
  ChangeDetectionStrategy,
5
5
  Component,
6
6
  computed,
7
+ ElementRef,
7
8
  inject,
8
9
  input,
9
10
  model,
@@ -26,8 +27,12 @@ import { buttonVariants } from '../button';
26
27
  @Component({
27
28
  selector: 'Calendar',
28
29
  imports: [LucideAngularModule],
30
+ host: {
31
+ 'attr.data-slot': '"calendar"',
32
+ '[class]': 'computedClass()',
33
+ },
29
34
  template: `
30
- <div [class]="computedClass()" role="application" [attr.aria-label]="ariaLabel()">
35
+ <div role="application" [attr.aria-label]="ariaLabel()">
31
36
  <!-- Header with navigation -->
32
37
  <div class="w-full">
33
38
  <div class="w-full space-y-4">
@@ -47,6 +52,7 @@ import { buttonVariants } from '../button';
47
52
  type="button"
48
53
  [class]="navButtonClass()"
49
54
  (click)="previousMonth()"
55
+ [disabled]="isPrevMonthDisabled()"
50
56
  [attr.aria-label]="'Go to previous month, ' + getPreviousMonthLabel()"
51
57
  >
52
58
  <lucide-icon [img]="ChevronLeftIcon" class="h-4 w-4" aria-hidden="true" />
@@ -55,6 +61,7 @@ import { buttonVariants } from '../button';
55
61
  type="button"
56
62
  [class]="navButtonClass()"
57
63
  (click)="nextMonth()"
64
+ [disabled]="isNextMonthDisabled()"
58
65
  [attr.aria-label]="'Go to next month, ' + getNextMonthLabel()"
59
66
  >
60
67
  <lucide-icon [img]="ChevronRightIcon" class="h-4 w-4" aria-hidden="true" />
@@ -94,6 +101,7 @@ import { buttonVariants } from '../button';
94
101
  <button
95
102
  type="button"
96
103
  [class]="getDayClass(day)"
104
+ [attr.data-date]="day.date.getFullYear() + '-' + day.date.getMonth() + '-' + day.date.getDate()"
97
105
  [attr.aria-label]="getDateLabel(day.date)"
98
106
  [attr.aria-selected]="isSelected(day.date) ? 'true' : null"
99
107
  [attr.aria-current]="isToday(day.date) ? 'date' : null"
@@ -134,12 +142,19 @@ export class Calendar {
134
142
  readonly showOutsideDays = input<boolean>(true);
135
143
  /** Disabled dates function */
136
144
  readonly disabled = input<((date: Date) => boolean) | undefined>(undefined);
145
+ /** Minimum selectable date — dates before this are disabled, navigation before this month is blocked */
146
+ readonly minDate = input<Date | undefined>(undefined);
147
+ /** Maximum selectable date — dates after this are disabled, navigation after this month is blocked */
148
+ readonly maxDate = input<Date | undefined>(undefined);
149
+ /** Locale for date formatting (e.g. 'en-US', 'fr-FR') */
150
+ readonly locale = input<string>('en-US');
137
151
  /** Accessible label for the calendar */
138
152
  readonly ariaLabel = input<string>('Calendar');
139
153
  /** Additional CSS classes */
140
154
  readonly class = input<string>('');
141
155
 
142
156
  private readonly _liveAnnouncer = inject(LiveAnnouncerService);
157
+ private readonly _elementRef = inject(ElementRef);
143
158
 
144
159
  protected readonly computedClass = computed(() => cn('w-full p-3', this.class()));
145
160
  protected readonly navButtonClass = computed(() =>
@@ -148,9 +163,25 @@ export class Calendar {
148
163
  'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 hover:bg-gray-100 dark:hover:bg-neutral-800',
149
164
  ),
150
165
  );
166
+ /** True when navigating to the previous month would go before minDate */
167
+ protected readonly isPrevMonthDisabled = computed(() => {
168
+ const min = this.minDate();
169
+ if (!min) return false;
170
+ const current = this.currentMonth();
171
+ const prevMonthEnd = new Date(current.getFullYear(), current.getMonth(), 0);
172
+ return prevMonthEnd < new Date(min.getFullYear(), min.getMonth(), 1);
173
+ });
174
+ /** True when navigating to the next month would go after maxDate */
175
+ protected readonly isNextMonthDisabled = computed(() => {
176
+ const max = this.maxDate();
177
+ if (!max) return false;
178
+ const current = this.currentMonth();
179
+ const nextMonthStart = new Date(current.getFullYear(), current.getMonth() + 1, 1);
180
+ return nextMonthStart > new Date(max.getFullYear(), max.getMonth() + 1, 0);
181
+ });
151
182
  protected readonly monthYear = computed(() => {
152
183
  const date = this.currentMonth();
153
- return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
184
+ return date.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
154
185
  });
155
186
  protected readonly calendarWeeks = computed(() => {
156
187
  const date = this.currentMonth();
@@ -184,7 +215,12 @@ export class Calendar {
184
215
  const dayDate = new Date(current);
185
216
  const isOutside = dayDate.getMonth() !== month;
186
217
  const disabledFn = this.disabled();
187
- const isDisabled = disabledFn ? disabledFn(dayDate) : false;
218
+ const min = this.minDate();
219
+ const max = this.maxDate();
220
+ const isDisabled =
221
+ (disabledFn ? disabledFn(dayDate) : false) ||
222
+ (min != null && this.isBeforeDay(dayDate, min)) ||
223
+ (max != null && this.isAfterDay(dayDate, max));
188
224
 
189
225
  week.push({
190
226
  date: dayDate,
@@ -224,7 +260,7 @@ export class Calendar {
224
260
  day: 'numeric',
225
261
  year: 'numeric',
226
262
  };
227
- const label = date.toLocaleDateString('en-US', options);
263
+ const label = date.toLocaleDateString(this.locale(), options);
228
264
 
229
265
  if (this.isToday(date)) {
230
266
  return `${label}, today`;
@@ -242,13 +278,13 @@ export class Calendar {
242
278
  protected getPreviousMonthLabel(): string {
243
279
  const current = this.currentMonth();
244
280
  const prev = new Date(current.getFullYear(), current.getMonth() - 1, 1);
245
- return prev.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
281
+ return prev.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
246
282
  }
247
283
  /** Get label for next month button */
248
284
  protected getNextMonthLabel(): string {
249
285
  const current = this.currentMonth();
250
286
  const next = new Date(current.getFullYear(), current.getMonth() + 1, 1);
251
- return next.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
287
+ return next.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
252
288
  }
253
289
  /** Get tabindex for day button (roving tabindex) */
254
290
  protected getDayTabIndex(day: { date: Date; isOutside: boolean; disabled: boolean }): number {
@@ -298,13 +334,21 @@ export class Calendar {
298
334
  if (newDate.getMonth() !== this.currentMonth().getMonth()) {
299
335
  this.currentMonth.set(new Date(newDate.getFullYear(), newDate.getMonth(), 1));
300
336
  }
301
- // Focus the new date after DOM update
302
- setTimeout(() => {
303
- const button = document.querySelector(
304
- `[aria-label*="${newDate!.getDate()},"]`,
305
- ) as HTMLElement;
306
- button?.focus();
307
- }, 0);
337
+ // Focus the new date after DOM update (browser only)
338
+ if (typeof document !== 'undefined') {
339
+ const targetDate = newDate;
340
+ setTimeout(() => {
341
+ const root: HTMLElement = this._elementRef.nativeElement;
342
+ const buttons = root.querySelectorAll<HTMLElement>('button[data-date]');
343
+ const targetStr = `${targetDate.getFullYear()}-${targetDate.getMonth()}-${targetDate.getDate()}`;
344
+ for (const btn of Array.from(buttons)) {
345
+ if (btn.dataset['date'] === targetStr) {
346
+ btn.focus();
347
+ break;
348
+ }
349
+ }
350
+ }, 0);
351
+ }
308
352
  }
309
353
  }
310
354
  protected getDayClass(day: { date: Date; isOutside: boolean; disabled: boolean }): string {
@@ -353,4 +397,17 @@ export class Calendar {
353
397
  date1.getDate() === date2.getDate()
354
398
  );
355
399
  }
400
+ private isBeforeDay(date: Date, ref: Date): boolean {
401
+ if (date.getFullYear() !== ref.getFullYear()) return date.getFullYear() < ref.getFullYear();
402
+ if (date.getMonth() !== ref.getMonth()) return date.getMonth() < ref.getMonth();
403
+ return date.getDate() < ref.getDate();
404
+ }
405
+ private isAfterDay(date: Date, ref: Date): boolean {
406
+ if (date.getFullYear() !== ref.getFullYear()) return date.getFullYear() > ref.getFullYear();
407
+ if (date.getMonth() !== ref.getMonth()) return date.getMonth() > ref.getMonth();
408
+ return date.getDate() > ref.getDate();
409
+ }
410
+
411
+ // TODO: range and multiple modes - requires multi-select state
356
412
  }
413
+
@@ -14,6 +14,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
14
14
  </div>
15
15
  `,
16
16
  host: {
17
+ 'attr.data-slot': '"carousel-content"',
17
18
  '[class]': 'computedClass()',
18
19
  'aria-atomic': 'false',
19
20
  },
@@ -10,6 +10,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
10
10
  selector: 'CarouselItem',
11
11
  template: `<ng-content />`,
12
12
  host: {
13
+ 'attr.data-slot': '"carousel-item"',
13
14
  '[class]': 'computedClass()',
14
15
  role: 'group',
15
16
  'aria-roledescription': 'slide',
@@ -24,6 +24,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
24
24
  </button>
25
25
  `,
26
26
  host: {
27
+ 'attr.data-slot': '"carousel-next"',
27
28
  class: 'contents',
28
29
  },
29
30
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -24,6 +24,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
24
24
  </button>
25
25
  `,
26
26
  host: {
27
+ 'attr.data-slot': '"carousel-previous"',
27
28
  class: 'contents',
28
29
  },
29
30
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -77,6 +77,7 @@ import {
77
77
  },
78
78
  ],
79
79
  host: {
80
+ 'attr.data-slot': '"carousel"',
80
81
  '[class]': 'computedClass()',
81
82
  role: 'region',
82
83
  '[attr.aria-label]': 'ariaLabel()',
@@ -24,6 +24,7 @@ import { CHART_CONTEXT, type ChartConfig, type ChartContext } from './chart-cont
24
24
  selector: 'ChartContainer',
25
25
  template: `<ng-content />`,
26
26
  host: {
27
+ 'attr.data-slot': '"chart-container"',
27
28
  '[class]': 'computedClass()',
28
29
  '[style]': 'chartStyles()',
29
30
  'data-chart': '',
@@ -16,6 +16,7 @@ import { CHART_COLORS, CHART_CONTEXT } from './chart-context';
16
16
  }
17
17
  `,
18
18
  host: {
19
+ 'attr.data-slot': '"chart-legend-content"',
19
20
  '[class]': 'computedClass()',
20
21
  },
21
22
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -6,11 +6,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
6
6
  */
7
7
  @Component({
8
8
  selector: 'ChartLegend',
9
- template: `
10
- <div [class]="computedClass()">
11
- <ng-content />
12
- </div>
13
- `,
9
+ template: `<ng-content />`,
10
+ host: {
11
+ 'attr.data-slot': '"chart-legend"',
12
+ '[class]': 'computedClass()',
13
+ },
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  })
16
16
  export class ChartLegend {
@@ -6,11 +6,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
6
6
  */
7
7
  @Component({
8
8
  selector: 'ChartTooltipContent',
9
- template: `
10
- <div [class]="computedClass()">
11
- <ng-content />
12
- </div>
13
- `,
9
+ template: `<ng-content />`,
10
+ host: {
11
+ 'attr.data-slot': '"chart-tooltip-content"',
12
+ '[class]': 'computedClass()',
13
+ },
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  })
16
16
  export class ChartTooltipContent {
@@ -6,11 +6,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
6
6
  */
7
7
  @Component({
8
8
  selector: 'ChartTooltip',
9
- template: `
10
- <div [class]="computedClass()">
11
- <ng-content />
12
- </div>
13
- `,
9
+ template: `<ng-content />`,
10
+ host: {
11
+ 'attr.data-slot': '"chart-tooltip"',
12
+ '[class]': 'computedClass()',
13
+ },
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  })
16
16
  export class ChartTooltip {
@@ -114,6 +114,7 @@ import {
114
114
  </svg>
115
115
  `,
116
116
  host: {
117
+ 'attr.data-slot': '"chart"',
117
118
  '[class]': 'computedClass()',
118
119
  },
119
120
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -96,7 +96,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
96
96
  `,
97
97
  host: {
98
98
  '[class]': 'computedClass()',
99
- 'data-slot': 'checkbox',
99
+ 'attr.data-slot': '"checkbox"',
100
100
  },
101
101
  providers: [
102
102
  {
@@ -22,7 +22,8 @@ import { COLLAPSIBLE_CONTEXT } from './collapsible-context';
22
22
  '[class]': 'computedClass()',
23
23
  '[attr.data-state]': 'collapsible.isOpen() ? "open" : "closed"',
24
24
  '[attr.data-disabled]': 'collapsible.disabled() ? "" : null',
25
- '[attr.aria-hidden]': '!collapsible.isOpen()',
25
+ '[attr.id]': 'collapsible.contentId',
26
+ '[attr.inert]': '!collapsible.isOpen() || null',
26
27
  },
27
28
  styles: [
28
29
  `
@@ -4,6 +4,7 @@ export interface CollapsibleContext {
4
4
  isOpen: () => boolean;
5
5
  toggle: () => void;
6
6
  disabled: () => boolean;
7
+ contentId: string;
7
8
  }
8
9
 
9
10
  export const COLLAPSIBLE_CONTEXT = new InjectionToken<CollapsibleContext>('CollapsibleContext');
@@ -17,6 +17,7 @@ import { COLLAPSIBLE_CONTEXT } from './collapsible-context';
17
17
  '[attr.data-state]': 'collapsible.isOpen() ? "open" : "closed"',
18
18
  '[attr.data-disabled]': 'collapsible.disabled() ? "" : null',
19
19
  '[attr.aria-expanded]': 'collapsible.isOpen()',
20
+ '[attr.aria-controls]': 'collapsible.contentId',
20
21
  '[attr.disabled]': 'collapsible.disabled() ? true : null',
21
22
  '(click)': 'onClick()',
22
23
  '(keydown.enter)': 'onClick()',
@@ -77,6 +77,9 @@ export class Collapsible implements CollapsibleContext {
77
77
 
78
78
  protected readonly computedClass = computed(() => cn('', this.class()));
79
79
 
80
+ /** Stable ID linking the trigger (aria-controls) to the content (id) */
81
+ readonly contentId = `collapsible-content-${Math.random().toString(36).slice(2)}`;
82
+
80
83
  /** Internal state for open/closed */
81
84
  private readonly _isOpen = signal<boolean>(false);
82
85
 
@@ -7,8 +7,10 @@ import {
7
7
  effect,
8
8
  ElementRef,
9
9
  inject,
10
+ Injector,
10
11
  input,
11
12
  OnDestroy,
13
+ signal,
12
14
  } from '@angular/core';
13
15
  import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
14
16
 
@@ -26,8 +28,8 @@ import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
26
28
  [class]="computedClass()"
27
29
  [attr.data-state]="context.open() ? 'open' : 'closed'"
28
30
  [style.position]="'fixed'"
29
- [style.left.px]="context.position().x"
30
- [style.top.px]="context.position().y"
31
+ [style.left.px]="displayPosition().x"
32
+ [style.top.px]="displayPosition().y"
31
33
  role="menu"
32
34
  aria-orientation="vertical"
33
35
  tabindex="-1"
@@ -40,7 +42,7 @@ import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
40
42
  host: {
41
43
  'attr.data-slot': '"context-menu-content"',
42
44
  class: 'contents',
43
- '(document:click)': 'onDocumentClick()',
45
+ '(document:click)': 'onDocumentClick($event)',
44
46
  '(document:keydown.escape)': 'onEscapeKey()',
45
47
  '(document:contextmenu)': 'onAnotherContextMenu()',
46
48
  },
@@ -48,19 +50,28 @@ import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
48
50
  })
49
51
  export class ContextMenuContent implements OnDestroy {
50
52
  constructor() {
51
- // Focus first item when menu opens
53
+ // Clamp position and focus first item when menu opens
52
54
  effect(() => {
53
55
  if (this.context.open()) {
54
- setTimeout(() => {
55
- this.updateMenuItems();
56
- const focusedIdx = this.context.focusedIndex();
57
- if (focusedIdx >= 0 && this.menuItems[focusedIdx]) {
58
- this.menuItems[focusedIdx].focus();
59
- } else if (this.menuItems.length > 0) {
60
- this.menuItems[0].focus();
61
- this.context.focusedIndex.set(0);
62
- }
63
- }, 0);
56
+ this.isPositioned.set(false);
57
+ afterNextRender(
58
+ () => {
59
+ this.clampPosition();
60
+ this.isPositioned.set(true);
61
+ this.updateMenuItems();
62
+ const focusedIdx = this.context.focusedIndex();
63
+ if (focusedIdx >= 0 && this.menuItems[focusedIdx]) {
64
+ this.menuItems[focusedIdx].focus();
65
+ } else if (this.menuItems.length > 0) {
66
+ this.menuItems[0].focus();
67
+ this.context.focusedIndex.set(0);
68
+ }
69
+ },
70
+ { injector: this._injector },
71
+ );
72
+ } else {
73
+ this.isPositioned.set(false);
74
+ this.clampedPos.set(null);
64
75
  }
65
76
  });
66
77
 
@@ -74,15 +85,21 @@ export class ContextMenuContent implements OnDestroy {
74
85
  readonly class = input<string>('');
75
86
 
76
87
  private readonly _elementRef = inject(ElementRef);
88
+ private readonly _injector = inject(Injector);
77
89
 
78
90
  protected readonly context = inject(CONTEXT_MENU_CONTEXT);
79
91
 
92
+ protected readonly isPositioned = signal(false);
93
+ protected readonly clampedPos = signal<{ x: number; y: number } | null>(null);
94
+ protected readonly displayPosition = computed(() => this.clampedPos() ?? this.context.position());
95
+
80
96
  protected readonly computedClass = computed(() =>
81
97
  cn(
82
98
  'z-50 min-w-[12rem] overflow-hidden rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg',
83
99
  'data-[state=open]:animate-in data-[state=closed]:animate-out',
84
100
  'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
85
101
  'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
102
+ !this.isPositioned() && 'pointer-events-none opacity-0',
86
103
  this.class(),
87
104
  ),
88
105
  );
@@ -97,12 +114,23 @@ export class ContextMenuContent implements OnDestroy {
97
114
  }
98
115
  }
99
116
 
117
+ private clampPosition(): void {
118
+ if (typeof window === 'undefined') return;
119
+ const menu = this._elementRef.nativeElement.querySelector('[role="menu"]') as HTMLElement;
120
+ if (!menu) return;
121
+ const pos = this.context.position();
122
+ const rect = menu.getBoundingClientRect();
123
+ const padding = 8;
124
+ const x = Math.max(padding, Math.min(pos.x, window.innerWidth - rect.width - padding));
125
+ const y = Math.max(padding, Math.min(pos.y, window.innerHeight - rect.height - padding));
126
+ this.clampedPos.set({ x, y });
127
+ }
100
128
  private updateMenuItems(): void {
101
129
  const content = this._elementRef.nativeElement.querySelector('[role="menu"]');
102
130
  if (content) {
103
131
  this.menuItems = Array.from(
104
132
  content.querySelectorAll(
105
- '[role="menuitem"]:not([aria-disabled="true"]):not([data-disabled])',
133
+ ':is([role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]):not([aria-disabled="true"]):not([data-disabled=""])',
106
134
  ),
107
135
  );
108
136
  }
@@ -194,8 +222,12 @@ export class ContextMenuContent implements OnDestroy {
194
222
  triggerEl.focus();
195
223
  }
196
224
  }
197
- protected onDocumentClick(): void {
198
- this.close();
225
+ protected onDocumentClick(event: MouseEvent): void {
226
+ const target = event.target as HTMLElement;
227
+ const host = this._elementRef.nativeElement;
228
+ if (!host.contains(target)) {
229
+ this.close();
230
+ }
199
231
  }
200
232
  protected onEscapeKey(): void {
201
233
  this.close();