@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
@@ -43,9 +43,11 @@ export class ContextMenuSubContent {
43
43
  );
44
44
 
45
45
  protected onMouseEnter(): void {
46
+ this.subContext.isMouseInSubContent.set(true);
46
47
  this.subContext.open.set(true);
47
48
  }
48
49
  protected onMouseLeave(): void {
50
+ this.subContext.isMouseInSubContent.set(false);
49
51
  this.subContext.open.set(false);
50
52
  }
51
53
  }
@@ -19,7 +19,12 @@ import { CONTEXT_MENU_SUB_CONTEXT } from './context-menu-sub.component';
19
19
  '[class]': 'computedClass()',
20
20
  '(mouseenter)': 'onMouseEnter()',
21
21
  '(mouseleave)': 'onMouseLeave()',
22
+ '(keydown)': 'onKeyDown($event)',
22
23
  '[attr.data-state]': 'subContext.open() ? "open" : "closed"',
24
+ 'role': 'menuitem',
25
+ '[attr.aria-haspopup]': '"menu"',
26
+ '[attr.aria-expanded]': 'subContext.open()',
27
+ '[attr.tabindex]': '"-1"',
23
28
  },
24
29
  changeDetection: ChangeDetectionStrategy.OnPush,
25
30
  })
@@ -46,6 +51,30 @@ export class ContextMenuSubTrigger {
46
51
  this.subContext.open.set(true);
47
52
  }
48
53
  protected onMouseLeave(): void {
49
- // Keep open to allow mouse movement to subcontent
54
+ setTimeout(() => {
55
+ if (!this.subContext.isMouseInSubContent()) {
56
+ this.subContext.open.set(false);
57
+ }
58
+ }, 100);
59
+ }
60
+ protected onKeyDown(event: KeyboardEvent): void {
61
+ if (event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') {
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ this.subContext.open.set(true);
65
+ // Focus first focusable item in sub-content after it renders
66
+ setTimeout(() => {
67
+ const subContent = (event.target as HTMLElement)
68
+ .closest('ContextMenuSub')
69
+ ?.querySelector<HTMLElement>('[role="menu"] [role="menuitem"]:not([aria-disabled="true"]):not([data-disabled=""])');
70
+ if (subContent) {
71
+ subContent.focus();
72
+ }
73
+ }, 10);
74
+ }
75
+ if (event.key === 'ArrowLeft') {
76
+ event.preventDefault();
77
+ this.subContext.open.set(false);
78
+ }
50
79
  }
51
80
  }
@@ -8,6 +8,8 @@ import {
8
8
 
9
9
  export interface ContextMenuSubContext {
10
10
  open: WritableSignal<boolean>;
11
+ /** True while the mouse is hovering over the sub-content panel */
12
+ isMouseInSubContent: WritableSignal<boolean>;
11
13
  }
12
14
 
13
15
  export const CONTEXT_MENU_SUB_CONTEXT = new InjectionToken<ContextMenuSubContext>(
@@ -26,6 +28,7 @@ export const CONTEXT_MENU_SUB_CONTEXT = new InjectionToken<ContextMenuSubContext
26
28
  provide: CONTEXT_MENU_SUB_CONTEXT,
27
29
  useFactory: (): ContextMenuSubContext => ({
28
30
  open: signal(false),
31
+ isMouseInSubContent: signal(false),
29
32
  }),
30
33
  },
31
34
  ],
@@ -0,0 +1,63 @@
1
+ export interface Country {
2
+ code: string; // ISO 2-letter code: "US", "GB", "JP"
3
+ name: string; // "United States", "United Kingdom"
4
+ dialCode: string; // "+1", "+44", "+81"
5
+ flag: string; // emoji flag: "🇺🇸", "🇬🇧"
6
+ }
7
+
8
+ export const COUNTRIES: Country[] = [
9
+ { code: 'US', name: 'United States', dialCode: '+1', flag: '🇺🇸' },
10
+ { code: 'GB', name: 'United Kingdom', dialCode: '+44', flag: '🇬🇧' },
11
+ { code: 'CA', name: 'Canada', dialCode: '+1', flag: '🇨🇦' },
12
+ { code: 'AU', name: 'Australia', dialCode: '+61', flag: '🇦🇺' },
13
+ { code: 'NZ', name: 'New Zealand', dialCode: '+64', flag: '🇳🇿' },
14
+ { code: 'DE', name: 'Germany', dialCode: '+49', flag: '🇩🇪' },
15
+ { code: 'FR', name: 'France', dialCode: '+33', flag: '🇫🇷' },
16
+ { code: 'ES', name: 'Spain', dialCode: '+34', flag: '🇪🇸' },
17
+ { code: 'IT', name: 'Italy', dialCode: '+39', flag: '🇮🇹' },
18
+ { code: 'PT', name: 'Portugal', dialCode: '+351', flag: '🇵🇹' },
19
+ { code: 'NL', name: 'Netherlands', dialCode: '+31', flag: '🇳🇱' },
20
+ { code: 'BE', name: 'Belgium', dialCode: '+32', flag: '🇧🇪' },
21
+ { code: 'SE', name: 'Sweden', dialCode: '+46', flag: '🇸🇪' },
22
+ { code: 'NO', name: 'Norway', dialCode: '+47', flag: '🇳🇴' },
23
+ { code: 'DK', name: 'Denmark', dialCode: '+45', flag: '🇩🇰' },
24
+ { code: 'FI', name: 'Finland', dialCode: '+358', flag: '🇫🇮' },
25
+ { code: 'CH', name: 'Switzerland', dialCode: '+41', flag: '🇨🇭' },
26
+ { code: 'AT', name: 'Austria', dialCode: '+43', flag: '🇦🇹' },
27
+ { code: 'PL', name: 'Poland', dialCode: '+48', flag: '🇵🇱' },
28
+ { code: 'CZ', name: 'Czech Republic', dialCode: '+420', flag: '🇨🇿' },
29
+ { code: 'HU', name: 'Hungary', dialCode: '+36', flag: '🇭🇺' },
30
+ { code: 'RO', name: 'Romania', dialCode: '+40', flag: '🇷🇴' },
31
+ { code: 'GR', name: 'Greece', dialCode: '+30', flag: '🇬🇷' },
32
+ { code: 'JP', name: 'Japan', dialCode: '+81', flag: '🇯🇵' },
33
+ { code: 'CN', name: 'China', dialCode: '+86', flag: '🇨🇳' },
34
+ { code: 'KR', name: 'South Korea', dialCode: '+82', flag: '🇰🇷' },
35
+ { code: 'IN', name: 'India', dialCode: '+91', flag: '🇮🇳' },
36
+ { code: 'PK', name: 'Pakistan', dialCode: '+92', flag: '🇵🇰' },
37
+ { code: 'BD', name: 'Bangladesh', dialCode: '+880', flag: '🇧🇩' },
38
+ { code: 'NG', name: 'Nigeria', dialCode: '+234', flag: '🇳🇬' },
39
+ { code: 'ZA', name: 'South Africa', dialCode: '+27', flag: '🇿🇦' },
40
+ { code: 'EG', name: 'Egypt', dialCode: '+20', flag: '🇪🇬' },
41
+ { code: 'SA', name: 'Saudi Arabia', dialCode: '+966', flag: '🇸🇦' },
42
+ { code: 'AE', name: 'United Arab Emirates', dialCode: '+971', flag: '🇦🇪' },
43
+ { code: 'TR', name: 'Turkey', dialCode: '+90', flag: '🇹🇷' },
44
+ { code: 'MX', name: 'Mexico', dialCode: '+52', flag: '🇲🇽' },
45
+ { code: 'BR', name: 'Brazil', dialCode: '+55', flag: '🇧🇷' },
46
+ { code: 'AR', name: 'Argentina', dialCode: '+54', flag: '🇦🇷' },
47
+ { code: 'CO', name: 'Colombia', dialCode: '+57', flag: '🇨🇴' },
48
+ { code: 'CL', name: 'Chile', dialCode: '+56', flag: '🇨🇱' },
49
+ { code: 'PE', name: 'Peru', dialCode: '+51', flag: '🇵🇪' },
50
+ { code: 'VE', name: 'Venezuela', dialCode: '+58', flag: '🇻🇪' },
51
+ { code: 'ID', name: 'Indonesia', dialCode: '+62', flag: '🇮🇩' },
52
+ { code: 'PH', name: 'Philippines', dialCode: '+63', flag: '🇵🇭' },
53
+ { code: 'TH', name: 'Thailand', dialCode: '+66', flag: '🇹🇭' },
54
+ { code: 'VN', name: 'Vietnam', dialCode: '+84', flag: '🇻🇳' },
55
+ { code: 'MY', name: 'Malaysia', dialCode: '+60', flag: '🇲🇾' },
56
+ { code: 'SG', name: 'Singapore', dialCode: '+65', flag: '🇸🇬' },
57
+ { code: 'HK', name: 'Hong Kong', dialCode: '+852', flag: '🇭🇰' },
58
+ { code: 'TW', name: 'Taiwan', dialCode: '+886', flag: '🇹🇼' },
59
+ ];
60
+
61
+ export function getCountryByCode(code: string): Country | undefined {
62
+ return COUNTRIES.find((c) => c.code === code);
63
+ }
@@ -0,0 +1,199 @@
1
+ import { cn } from '@/lib/utils';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ computed,
6
+ effect,
7
+ forwardRef,
8
+ input,
9
+ output,
10
+ signal,
11
+ } from '@angular/core';
12
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
13
+ import { Command } from '../command/command.component';
14
+ import { CommandEmpty } from '../command/command-empty.component';
15
+ import { CommandInput } from '../command/command-input.component';
16
+ import { CommandItem } from '../command/command-item.component';
17
+ import { CommandList } from '../command/command-list.component';
18
+ import { Popover } from '../popover/popover.component';
19
+ import { PopoverContent } from '../popover/popover-content.component';
20
+ import { PopoverTrigger } from '../popover/popover-trigger.component';
21
+ import { COUNTRIES, type Country, getCountryByCode } from './country-data';
22
+
23
+ /**
24
+ * CountrySelector component
25
+ *
26
+ * A searchable country dropdown that integrates with Angular Forms via ControlValueAccessor.
27
+ * The value is the ISO 2-letter country code (e.g. "US").
28
+ *
29
+ * @example
30
+ * ```html
31
+ * <CountrySelector [(ngModel)]="countryCode" />
32
+ * <CountrySelector [value]="'US'" (countryChange)="onCountryChange($event)" />
33
+ * ```
34
+ */
35
+ @Component({
36
+ selector: 'CountrySelector',
37
+ imports: [Popover, PopoverTrigger, PopoverContent, Command, CommandInput, CommandList, CommandItem, CommandEmpty],
38
+ template: `
39
+ <Popover>
40
+ <PopoverTrigger>
41
+ <button
42
+ type="button"
43
+ [disabled]="isDisabled()"
44
+ [class]="triggerClass()"
45
+ [attr.aria-label]="selectedCountry() ? selectedCountry()!.name : placeholder()"
46
+ >
47
+ @if (selectedCountry()) {
48
+ <span class="text-base leading-none">{{ selectedCountry()!.flag }}</span>
49
+ <span class="truncate">{{ selectedCountry()!.name }}</span>
50
+ } @else {
51
+ <span class="text-muted-foreground truncate">{{ placeholder() }}</span>
52
+ }
53
+ <svg
54
+ class="ml-auto h-4 w-4 shrink-0 opacity-50"
55
+ xmlns="http://www.w3.org/2000/svg"
56
+ viewBox="0 0 24 24"
57
+ fill="none"
58
+ stroke="currentColor"
59
+ stroke-width="2"
60
+ stroke-linecap="round"
61
+ stroke-linejoin="round"
62
+ aria-hidden="true"
63
+ >
64
+ <path d="m7 15 5 5 5-5" />
65
+ <path d="m7 9 5-5 5 5" />
66
+ </svg>
67
+ </button>
68
+ </PopoverTrigger>
69
+ <PopoverContent [matchTriggerWidth]="true" class="p-0">
70
+ <Command>
71
+ <CommandInput placeholder="Search country..." />
72
+ <CommandEmpty>No country found.</CommandEmpty>
73
+ <CommandList>
74
+ @for (country of countries; track country.code) {
75
+ <CommandItem
76
+ [value]="country.name"
77
+ [keywords]="[country.code, country.dialCode]"
78
+ (onSelect)="selectCountry(country)"
79
+ [class]="country.code === selectedCode() ? 'bg-accent text-accent-foreground' : ''"
80
+ >
81
+ <span class="text-base leading-none mr-2">{{ country.flag }}</span>
82
+ <span class="flex-1">{{ country.name }}</span>
83
+ <span class="text-muted-foreground text-xs ml-auto">{{ country.dialCode }}</span>
84
+ @if (country.code === selectedCode()) {
85
+ <svg
86
+ class="ml-2 h-4 w-4 shrink-0"
87
+ xmlns="http://www.w3.org/2000/svg"
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ stroke-width="2"
92
+ stroke-linecap="round"
93
+ stroke-linejoin="round"
94
+ aria-hidden="true"
95
+ >
96
+ <path d="M20 6 9 17l-5-5" />
97
+ </svg>
98
+ }
99
+ </CommandItem>
100
+ }
101
+ </CommandList>
102
+ </Command>
103
+ </PopoverContent>
104
+ </Popover>
105
+ `,
106
+ host: {
107
+ 'attr.data-slot': '"country-selector"',
108
+ '[class]': 'hostClass()',
109
+ },
110
+ providers: [
111
+ {
112
+ provide: NG_VALUE_ACCESSOR,
113
+ useExisting: forwardRef(() => CountrySelector),
114
+ multi: true,
115
+ },
116
+ ],
117
+ changeDetection: ChangeDetectionStrategy.OnPush,
118
+ })
119
+ export class CountrySelector implements ControlValueAccessor {
120
+ /** Placeholder text shown when no country is selected */
121
+ readonly placeholder = input<string>('Select country');
122
+ /** Whether the selector is disabled */
123
+ readonly disabled = input<boolean>(false);
124
+ /** Additional CSS classes */
125
+ readonly class = input<string>('');
126
+ /**
127
+ * Direct value binding (ISO 2-letter country code).
128
+ * Takes precedence when set — syncs to internal state.
129
+ */
130
+ readonly value = input<string>('');
131
+
132
+ /** Emits the full Country object when selection changes */
133
+ readonly countryChange = output<Country>();
134
+
135
+ /** All available countries */
136
+ protected readonly countries = COUNTRIES;
137
+
138
+ /** Internal selected country code */
139
+ protected readonly selectedCode = signal<string>('');
140
+ /** Internal disabled state (from CVA) */
141
+ private readonly _isDisabledFromCVA = signal<boolean>(false);
142
+
143
+ constructor() {
144
+ // Sync the value input to internal signal
145
+ effect(() => {
146
+ const v = this.value();
147
+ if (v) {
148
+ this.selectedCode.set(v);
149
+ }
150
+ });
151
+ }
152
+
153
+ protected readonly isDisabled = computed(() => this.disabled() || this._isDisabledFromCVA());
154
+
155
+ protected readonly selectedCountry = computed(() => {
156
+ const code = this.selectedCode();
157
+ return code ? getCountryByCode(code) : undefined;
158
+ });
159
+
160
+ protected readonly hostClass = computed(() => cn('block', this.class()));
161
+
162
+ protected readonly triggerClass = computed(() =>
163
+ cn(
164
+ 'flex h-10 w-full items-center gap-2 rounded-xl border px-3 py-2 text-sm text-left',
165
+ 'bg-zinc-50 dark:bg-zinc-800/50 border-zinc-300 dark:border-zinc-700/50',
166
+ 'text-zinc-900 dark:text-zinc-50',
167
+ 'shadow-xs transition-[color,box-shadow] outline-none',
168
+ 'focus-visible:border-primary/30 dark:focus-visible:border-white/30 focus-visible:ring-primary/20 dark:focus-visible:ring-white/20 focus-visible:ring-2',
169
+ 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
170
+ ),
171
+ );
172
+
173
+ /** ControlValueAccessor callbacks */
174
+ private onChange: (value: string) => void = () => {};
175
+ private onTouched: () => void = () => {};
176
+
177
+ writeValue(value: string): void {
178
+ this.selectedCode.set(value ?? '');
179
+ }
180
+
181
+ registerOnChange(fn: (value: string) => void): void {
182
+ this.onChange = fn;
183
+ }
184
+
185
+ registerOnTouched(fn: () => void): void {
186
+ this.onTouched = fn;
187
+ }
188
+
189
+ setDisabledState(isDisabled: boolean): void {
190
+ this._isDisabledFromCVA.set(isDisabled);
191
+ }
192
+
193
+ protected selectCountry(country: Country): void {
194
+ this.selectedCode.set(country.code);
195
+ this.onChange(country.code);
196
+ this.onTouched();
197
+ this.countryChange.emit(country);
198
+ }
199
+ }
@@ -0,0 +1,2 @@
1
+ export { CountrySelector } from './country-selector.component';
2
+ export { COUNTRIES, getCountryByCode, type Country } from './country-data';
@@ -3,11 +3,14 @@ import {
3
3
  ChangeDetectionStrategy,
4
4
  Component,
5
5
  computed,
6
+ forwardRef,
6
7
  input,
7
8
  model,
8
9
  output,
10
+ signal,
9
11
  viewChild,
10
12
  } from '@angular/core';
13
+ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
11
14
  import { CalendarIcon, LucideAngularModule } from 'lucide-angular';
12
15
  import { buttonVariants } from '../button';
13
16
  import { Calendar } from '../calendar';
@@ -16,6 +19,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
16
19
  /**
17
20
  * DatePicker component - date selection with calendar popover.
18
21
  * Matches shadcn/ui React DatePicker exactly.
22
+ * Implements ControlValueAccessor for Angular Forms integration.
19
23
  */
20
24
  @Component({
21
25
  selector: 'DatePicker',
@@ -38,33 +42,55 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
38
42
  [selected]="date()"
39
43
  (onSelect)="onDateSelect($event)"
40
44
  [disabled]="disabledDates()"
45
+ [minDate]="minDate()"
46
+ [maxDate]="maxDate()"
47
+ [locale]="locale()"
41
48
  class="w-full"
42
49
  />
43
50
  </PopoverContent>
44
51
  </Popover>
45
52
  `,
46
53
  host: {
54
+ 'attr.data-slot': '"date-picker"',
47
55
  class: 'contents',
48
56
  },
57
+ providers: [
58
+ {
59
+ provide: NG_VALUE_ACCESSOR,
60
+ useExisting: forwardRef(() => DatePicker),
61
+ multi: true,
62
+ },
63
+ ],
49
64
  changeDetection: ChangeDetectionStrategy.OnPush,
50
65
  })
51
- export class DatePicker {
66
+ export class DatePicker implements ControlValueAccessor {
52
67
  private readonly popover = viewChild(Popover);
53
68
 
54
69
  /** Date select event */
55
70
  readonly onSelect = output<Date | undefined>();
56
71
 
57
- /** Selected date */
72
+ /** Selected date (model input for two-way binding) */
58
73
  readonly date = model<Date | undefined>(undefined);
59
74
 
60
75
  /** Additional CSS classes */
61
76
  readonly class = input<string>('');
62
77
  /** Placeholder text */
63
78
  readonly placeholder = input<string>('Pick a date');
64
- /** Date format */
65
- readonly dateFormat = input<string>('PPP');
66
79
  /** Disabled dates function */
67
80
  readonly disabledDates = input<((date: Date) => boolean) | undefined>(undefined);
81
+ /** Minimum selectable date — passed to Calendar */
82
+ readonly minDate = input<Date | undefined>(undefined);
83
+ /** Maximum selectable date — passed to Calendar */
84
+ readonly maxDate = input<Date | undefined>(undefined);
85
+ /** Locale used for date formatting (e.g. 'en-US', 'fr-FR') */
86
+ readonly locale = input<string>('en-US');
87
+
88
+ /** Internal disabled state set by ControlValueAccessor */
89
+ protected readonly isDisabled = signal<boolean>(false);
90
+
91
+ /** ControlValueAccessor callbacks */
92
+ private onChange: (value: Date | undefined) => void = () => {};
93
+ private onTouched: () => void = () => {};
68
94
 
69
95
  protected readonly computedButtonClass = computed(() =>
70
96
  cn(
@@ -73,6 +99,7 @@ export class DatePicker {
73
99
  this.popover()?.isOpen() &&
74
100
  'border-primary/30 ring-primary/20 ring-2 dark:border-white/30 dark:ring-white/20',
75
101
  !this.date() && 'text-muted-foreground',
102
+ this.isDisabled() && 'pointer-events-none cursor-not-allowed opacity-50',
76
103
  this.class(),
77
104
  ),
78
105
  );
@@ -80,7 +107,7 @@ export class DatePicker {
80
107
  protected readonly CalendarIconRef = CalendarIcon;
81
108
 
82
109
  protected formatDate(date: Date): string {
83
- return date.toLocaleDateString('en-US', {
110
+ return date.toLocaleDateString(this.locale(), {
84
111
  year: 'numeric',
85
112
  month: 'long',
86
113
  day: 'numeric',
@@ -89,6 +116,22 @@ export class DatePicker {
89
116
  protected onDateSelect(date: Date | undefined): void {
90
117
  this.date.set(date);
91
118
  this.onSelect.emit(date);
119
+ this.onChange(date);
120
+ this.onTouched();
92
121
  this.popover()?.setOpen(false);
93
122
  }
123
+
124
+ // ControlValueAccessor implementation
125
+ writeValue(value: Date | undefined): void {
126
+ this.date.set(value ?? undefined);
127
+ }
128
+ registerOnChange(fn: (value: Date | undefined) => void): void {
129
+ this.onChange = fn;
130
+ }
131
+ registerOnTouched(fn: () => void): void {
132
+ this.onTouched = fn;
133
+ }
134
+ setDisabledState(isDisabled: boolean): void {
135
+ this.isDisabled.set(isDisabled);
136
+ }
94
137
  }
@@ -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,22 +10,25 @@ import {
10
10
  HostListener,
11
11
  inject,
12
12
  input,
13
- signal,
14
13
  } from '@angular/core';
15
14
  import { DIALOG_CONTEXT } from './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
  * DialogContent component - the content of the dialog.
22
18
  * Matches shadcn/ui React DialogContent exactly.
19
+ *
20
+ * Features:
21
+ * - Escape key closes the dialog
22
+ * - Overlay click closes the dialog
23
+ * - Focus is trapped within the dialog
24
+ * - Exit animations handled by Presence component (no setTimeout needed)
25
+ * - Focus restored on any close path (overlay click, close button, Escape, programmatic)
23
26
  */
24
27
  @Component({
25
28
  selector: 'DialogContent',
26
- imports: [FocusTrapDirective],
29
+ imports: [FocusTrapDirective, Presence],
27
30
  template: `
28
- @if (shouldRender()) {
31
+ <Presence [present]="context.isOpen()">
29
32
  <!-- Overlay -->
30
33
  <div
31
34
  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"
@@ -38,7 +41,7 @@ const EXIT_ANIMATION_MS = 200;
38
41
  hlmFocusTrap
39
42
  [trapFocus]="context.isOpen()"
40
43
  [autoFocus]="true"
41
- [restoreFocus]="true"
44
+ [restoreFocus]="false"
42
45
  [initialFocus]="initialFocus()"
43
46
  [class]="computedClass()"
44
47
  [attr.data-state]="context.isOpen() ? 'open' : 'closed'"
@@ -76,7 +79,7 @@ const EXIT_ANIMATION_MS = 200;
76
79
  </button>
77
80
  }
78
81
  </div>
79
- }
82
+ </Presence>
80
83
  `,
81
84
  host: {
82
85
  'attr.data-slot': '"dialog-content"',
@@ -86,22 +89,22 @@ const EXIT_ANIMATION_MS = 200;
86
89
  })
87
90
  export class DialogContent {
88
91
  constructor() {
92
+ let wasOpen = false;
93
+
89
94
  effect(() => {
90
95
  const isOpen = this.context.isOpen();
91
96
  this._cdr.markForCheck();
92
97
 
93
98
  if (isOpen) {
94
- this.shouldRender.set(true);
99
+ wasOpen = true;
95
100
  this.lockBodyScroll();
96
101
  } else {
97
102
  this.unlockBodyScroll();
98
- if (this.shouldRender()) {
99
- // Keep DOM alive for the exit animation, then unmount
100
- setTimeout(() => {
101
- this.shouldRender.set(false);
102
- this._cdr.markForCheck();
103
- }, EXIT_ANIMATION_MS);
103
+ // Restore focus on any close path (overlay click, close button, Escape, programmatic)
104
+ if (wasOpen) {
105
+ this.restoreFocus();
104
106
  }
107
+ wasOpen = false;
105
108
  }
106
109
  });
107
110
 
@@ -121,8 +124,6 @@ export class DialogContent {
121
124
 
122
125
  readonly context = inject(DIALOG_CONTEXT);
123
126
 
124
- protected readonly shouldRender = signal(false);
125
-
126
127
  protected readonly computedClass = computed(() =>
127
128
  cn(
128
129
  'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background text-foreground p-6 shadow-lg duration-200',
@@ -137,6 +138,7 @@ export class DialogContent {
137
138
  );
138
139
 
139
140
  private previousBodyOverflow = '';
141
+ private previousBodyPaddingRight = '';
140
142
 
141
143
  @HostListener('document:keydown.escape')
142
144
  onEscapeKey(): void {
@@ -153,18 +155,23 @@ export class DialogContent {
153
155
  }
154
156
 
155
157
  private lockBodyScroll(): void {
156
- if (typeof document !== 'undefined') {
158
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
159
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
157
160
  this.previousBodyOverflow = document.body.style.overflow;
161
+ this.previousBodyPaddingRight = document.body.style.paddingRight;
158
162
  document.body.style.overflow = 'hidden';
163
+ if (scrollbarWidth > 0) {
164
+ document.body.style.paddingRight = scrollbarWidth + 'px';
165
+ }
159
166
  }
160
167
  }
161
168
  private unlockBodyScroll(): void {
162
169
  if (typeof document !== 'undefined') {
163
170
  document.body.style.overflow = this.previousBodyOverflow;
171
+ document.body.style.paddingRight = this.previousBodyPaddingRight;
164
172
  }
165
173
  }
166
174
  private close(): void {
167
- this.restoreFocus();
168
175
  this.context.setOpen(false);
169
176
  }
170
177
  private restoreFocus(): void {
@@ -0,0 +1,9 @@
1
+ import { InjectionToken, signal } from '@angular/core';
2
+
3
+ export type Direction = 'ltr' | 'rtl';
4
+
5
+ export interface DirectionContext {
6
+ dir: ReturnType<typeof signal<Direction>>;
7
+ }
8
+
9
+ export const DIRECTION_CONTEXT = new InjectionToken<DirectionContext>('DirectionContext');
@@ -0,0 +1,48 @@
1
+ import { ChangeDetectionStrategy, Component, effect, forwardRef, input, signal } from '@angular/core';
2
+ import {
3
+ DIRECTION_CONTEXT,
4
+ type Direction as DirectionType,
5
+ type DirectionContext,
6
+ } from './direction-context';
7
+
8
+ /**
9
+ * Direction component - provides RTL/LTR direction context to descendants.
10
+ * Equivalent to Radix UI's DirectionProvider.
11
+ *
12
+ * @example
13
+ * <Direction dir="rtl">
14
+ * <!-- All components inside will be RTL -->
15
+ * <Select>...</Select>
16
+ * </Direction>
17
+ */
18
+ @Component({
19
+ selector: 'Direction',
20
+ template: `<ng-content />`,
21
+ changeDetection: ChangeDetectionStrategy.OnPush,
22
+ host: {
23
+ 'attr.data-slot': '"direction"',
24
+ '[attr.dir]': 'dir()',
25
+ style: 'display: contents',
26
+ },
27
+ providers: [
28
+ {
29
+ provide: DIRECTION_CONTEXT,
30
+ useFactory: (component: Direction) => component.context,
31
+ deps: [forwardRef(() => Direction)],
32
+ },
33
+ ],
34
+ })
35
+ export class Direction {
36
+ /** Text direction */
37
+ readonly dir = input<DirectionType>('ltr');
38
+
39
+ readonly context: DirectionContext = {
40
+ dir: signal<DirectionType>(this.dir()),
41
+ };
42
+
43
+ constructor() {
44
+ effect(() => {
45
+ this.context.dir.set(this.dir());
46
+ });
47
+ }
48
+ }
@@ -0,0 +1,2 @@
1
+ export { Direction } from './direction.component';
2
+ export { DIRECTION_CONTEXT, type Direction as DirectionType, type DirectionContext } from './direction-context';