@ng-cn/core 1.0.18 → 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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/app/lib/components/ui/alert-dialog/alert-dialog-content.component.ts +1 -1
  3. package/src/app/lib/components/ui/calendar/calendar.component.ts +65 -12
  4. package/src/app/lib/components/ui/context-menu/context-menu-content.component.ts +1 -0
  5. package/src/app/lib/components/ui/country-selector/country-data.ts +63 -0
  6. package/src/app/lib/components/ui/country-selector/country-selector.component.ts +199 -0
  7. package/src/app/lib/components/ui/country-selector/index.ts +2 -0
  8. package/src/app/lib/components/ui/date-picker/date-picker.component.ts +47 -5
  9. package/src/app/lib/components/ui/dialog/dialog-content.component.ts +1 -1
  10. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-content.component.ts +23 -21
  11. package/src/app/lib/components/ui/field/field-content.component.ts +34 -0
  12. package/src/app/lib/components/ui/field/field-description.component.ts +35 -0
  13. package/src/app/lib/components/ui/field/field-error.component.ts +48 -0
  14. package/src/app/lib/components/ui/field/field-group.component.ts +34 -0
  15. package/src/app/lib/components/ui/field/field-label.component.ts +46 -0
  16. package/src/app/lib/components/ui/field/field-legend.component.ts +41 -0
  17. package/src/app/lib/components/ui/field/field-separator.component.ts +49 -0
  18. package/src/app/lib/components/ui/field/field-set.component.ts +37 -0
  19. package/src/app/lib/components/ui/field/field-title.component.ts +30 -0
  20. package/src/app/lib/components/ui/field/field.component.ts +66 -0
  21. package/src/app/lib/components/ui/field/index.ts +15 -0
  22. package/src/app/lib/components/ui/item/index.ts +21 -0
  23. package/src/app/lib/components/ui/item/item-actions.component.ts +29 -0
  24. package/src/app/lib/components/ui/item/item-content.component.ts +31 -0
  25. package/src/app/lib/components/ui/item/item-description.component.ts +30 -0
  26. package/src/app/lib/components/ui/item/item-footer.component.ts +30 -0
  27. package/src/app/lib/components/ui/item/item-group.component.ts +32 -0
  28. package/src/app/lib/components/ui/item/item-header.component.ts +30 -0
  29. package/src/app/lib/components/ui/item/item-media.component.ts +63 -0
  30. package/src/app/lib/components/ui/item/item-separator.component.ts +33 -0
  31. package/src/app/lib/components/ui/item/item-title.component.ts +27 -0
  32. package/src/app/lib/components/ui/item/item.component.ts +77 -0
  33. package/src/app/lib/components/ui/phone-input/index.ts +1 -0
  34. package/src/app/lib/components/ui/phone-input/phone-input.component.ts +169 -0
  35. package/src/app/lib/components/ui/resizable/resizable-handle.component.ts +2 -2
  36. package/src/app/lib/components/ui/sheet/sheet-content.component.ts +1 -1
  37. package/src/app/lib/components/ui/tabs/tabs-list.component.ts +3 -1
  38. package/src/app/lib/components/ui/textarea/textarea.component.ts +110 -10
  39. package/src/app/lib/components/ui/toast/toast.service.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ng-cn/core",
3
- "version": "1.0.18",
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",
@@ -119,7 +119,7 @@ export class AlertDialogContent {
119
119
  }
120
120
 
121
121
  private lockBodyScroll(): void {
122
- if (typeof document !== 'undefined') {
122
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
123
123
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
124
124
  this.previousBodyOverflow = document.body.style.overflow;
125
125
  this.previousBodyPaddingRight = document.body.style.paddingRight;
@@ -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,
@@ -51,6 +52,7 @@ import { buttonVariants } from '../button';
51
52
  type="button"
52
53
  [class]="navButtonClass()"
53
54
  (click)="previousMonth()"
55
+ [disabled]="isPrevMonthDisabled()"
54
56
  [attr.aria-label]="'Go to previous month, ' + getPreviousMonthLabel()"
55
57
  >
56
58
  <lucide-icon [img]="ChevronLeftIcon" class="h-4 w-4" aria-hidden="true" />
@@ -59,6 +61,7 @@ import { buttonVariants } from '../button';
59
61
  type="button"
60
62
  [class]="navButtonClass()"
61
63
  (click)="nextMonth()"
64
+ [disabled]="isNextMonthDisabled()"
62
65
  [attr.aria-label]="'Go to next month, ' + getNextMonthLabel()"
63
66
  >
64
67
  <lucide-icon [img]="ChevronRightIcon" class="h-4 w-4" aria-hidden="true" />
@@ -98,6 +101,7 @@ import { buttonVariants } from '../button';
98
101
  <button
99
102
  type="button"
100
103
  [class]="getDayClass(day)"
104
+ [attr.data-date]="day.date.getFullYear() + '-' + day.date.getMonth() + '-' + day.date.getDate()"
101
105
  [attr.aria-label]="getDateLabel(day.date)"
102
106
  [attr.aria-selected]="isSelected(day.date) ? 'true' : null"
103
107
  [attr.aria-current]="isToday(day.date) ? 'date' : null"
@@ -138,12 +142,19 @@ export class Calendar {
138
142
  readonly showOutsideDays = input<boolean>(true);
139
143
  /** Disabled dates function */
140
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');
141
151
  /** Accessible label for the calendar */
142
152
  readonly ariaLabel = input<string>('Calendar');
143
153
  /** Additional CSS classes */
144
154
  readonly class = input<string>('');
145
155
 
146
156
  private readonly _liveAnnouncer = inject(LiveAnnouncerService);
157
+ private readonly _elementRef = inject(ElementRef);
147
158
 
148
159
  protected readonly computedClass = computed(() => cn('w-full p-3', this.class()));
149
160
  protected readonly navButtonClass = computed(() =>
@@ -152,9 +163,25 @@ export class Calendar {
152
163
  'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 hover:bg-gray-100 dark:hover:bg-neutral-800',
153
164
  ),
154
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
+ });
155
182
  protected readonly monthYear = computed(() => {
156
183
  const date = this.currentMonth();
157
- return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
184
+ return date.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
158
185
  });
159
186
  protected readonly calendarWeeks = computed(() => {
160
187
  const date = this.currentMonth();
@@ -188,7 +215,12 @@ export class Calendar {
188
215
  const dayDate = new Date(current);
189
216
  const isOutside = dayDate.getMonth() !== month;
190
217
  const disabledFn = this.disabled();
191
- 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));
192
224
 
193
225
  week.push({
194
226
  date: dayDate,
@@ -228,7 +260,7 @@ export class Calendar {
228
260
  day: 'numeric',
229
261
  year: 'numeric',
230
262
  };
231
- const label = date.toLocaleDateString('en-US', options);
263
+ const label = date.toLocaleDateString(this.locale(), options);
232
264
 
233
265
  if (this.isToday(date)) {
234
266
  return `${label}, today`;
@@ -246,13 +278,13 @@ export class Calendar {
246
278
  protected getPreviousMonthLabel(): string {
247
279
  const current = this.currentMonth();
248
280
  const prev = new Date(current.getFullYear(), current.getMonth() - 1, 1);
249
- return prev.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
281
+ return prev.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
250
282
  }
251
283
  /** Get label for next month button */
252
284
  protected getNextMonthLabel(): string {
253
285
  const current = this.currentMonth();
254
286
  const next = new Date(current.getFullYear(), current.getMonth() + 1, 1);
255
- return next.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
287
+ return next.toLocaleDateString(this.locale(), { month: 'long', year: 'numeric' });
256
288
  }
257
289
  /** Get tabindex for day button (roving tabindex) */
258
290
  protected getDayTabIndex(day: { date: Date; isOutside: boolean; disabled: boolean }): number {
@@ -302,13 +334,21 @@ export class Calendar {
302
334
  if (newDate.getMonth() !== this.currentMonth().getMonth()) {
303
335
  this.currentMonth.set(new Date(newDate.getFullYear(), newDate.getMonth(), 1));
304
336
  }
305
- // Focus the new date after DOM update
306
- setTimeout(() => {
307
- const button = document.querySelector(
308
- `[aria-label*="${newDate!.getDate()},"]`,
309
- ) as HTMLElement;
310
- button?.focus();
311
- }, 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
+ }
312
352
  }
313
353
  }
314
354
  protected getDayClass(day: { date: Date; isOutside: boolean; disabled: boolean }): string {
@@ -357,4 +397,17 @@ export class Calendar {
357
397
  date1.getDate() === date2.getDate()
358
398
  );
359
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
360
412
  }
413
+
@@ -115,6 +115,7 @@ export class ContextMenuContent implements OnDestroy {
115
115
  }
116
116
 
117
117
  private clampPosition(): void {
118
+ if (typeof window === 'undefined') return;
118
119
  const menu = this._elementRef.nativeElement.querySelector('[role="menu"]') as HTMLElement;
119
120
  if (!menu) return;
120
121
  const pos = this.context.position();
@@ -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,6 +42,9 @@ 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>
@@ -47,25 +54,43 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
47
54
  'attr.data-slot': '"date-picker"',
48
55
  class: 'contents',
49
56
  },
57
+ providers: [
58
+ {
59
+ provide: NG_VALUE_ACCESSOR,
60
+ useExisting: forwardRef(() => DatePicker),
61
+ multi: true,
62
+ },
63
+ ],
50
64
  changeDetection: ChangeDetectionStrategy.OnPush,
51
65
  })
52
- export class DatePicker {
66
+ export class DatePicker implements ControlValueAccessor {
53
67
  private readonly popover = viewChild(Popover);
54
68
 
55
69
  /** Date select event */
56
70
  readonly onSelect = output<Date | undefined>();
57
71
 
58
- /** Selected date */
72
+ /** Selected date (model input for two-way binding) */
59
73
  readonly date = model<Date | undefined>(undefined);
60
74
 
61
75
  /** Additional CSS classes */
62
76
  readonly class = input<string>('');
63
77
  /** Placeholder text */
64
78
  readonly placeholder = input<string>('Pick a date');
65
- /** Date format */
66
- readonly dateFormat = input<string>('PPP');
67
79
  /** Disabled dates function */
68
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 = () => {};
69
94
 
70
95
  protected readonly computedButtonClass = computed(() =>
71
96
  cn(
@@ -74,6 +99,7 @@ export class DatePicker {
74
99
  this.popover()?.isOpen() &&
75
100
  'border-primary/30 ring-primary/20 ring-2 dark:border-white/30 dark:ring-white/20',
76
101
  !this.date() && 'text-muted-foreground',
102
+ this.isDisabled() && 'pointer-events-none cursor-not-allowed opacity-50',
77
103
  this.class(),
78
104
  ),
79
105
  );
@@ -81,7 +107,7 @@ export class DatePicker {
81
107
  protected readonly CalendarIconRef = CalendarIcon;
82
108
 
83
109
  protected formatDate(date: Date): string {
84
- return date.toLocaleDateString('en-US', {
110
+ return date.toLocaleDateString(this.locale(), {
85
111
  year: 'numeric',
86
112
  month: 'long',
87
113
  day: 'numeric',
@@ -90,6 +116,22 @@ export class DatePicker {
90
116
  protected onDateSelect(date: Date | undefined): void {
91
117
  this.date.set(date);
92
118
  this.onSelect.emit(date);
119
+ this.onChange(date);
120
+ this.onTouched();
93
121
  this.popover()?.setOpen(false);
94
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
+ }
95
137
  }
@@ -155,7 +155,7 @@ export class DialogContent {
155
155
  }
156
156
 
157
157
  private lockBodyScroll(): void {
158
- if (typeof document !== 'undefined') {
158
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
159
159
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
160
160
  this.previousBodyOverflow = document.body.style.overflow;
161
161
  this.previousBodyPaddingRight = document.body.style.paddingRight;
@@ -55,29 +55,31 @@ export class DropdownMenuContent implements OnDestroy {
55
55
  effect(() => {
56
56
  if (this.context.open()) {
57
57
  if (this.strategy() === 'fixed') {
58
- const trigger = this.context.triggerElement();
59
- if (trigger) {
60
- const rect = trigger.getBoundingClientRect();
61
- const side = this.side();
62
- const offset = this.sideOffset();
63
- let top = rect.top;
64
- let left = rect.left;
58
+ if (typeof window !== 'undefined') {
59
+ const trigger = this.context.triggerElement();
60
+ if (trigger) {
61
+ const rect = trigger.getBoundingClientRect();
62
+ const side = this.side();
63
+ const offset = this.sideOffset();
64
+ let top = rect.top;
65
+ let left = rect.left;
65
66
 
66
- if (side === 'bottom') {
67
- top = rect.bottom + offset;
68
- left = rect.left;
69
- } else if (side === 'top') {
70
- top = rect.top - offset;
71
- left = rect.left;
72
- } else if (side === 'right') {
73
- top = rect.top;
74
- left = rect.right + offset;
75
- } else if (side === 'left') {
76
- top = rect.top;
77
- left = rect.left - offset;
78
- }
67
+ if (side === 'bottom') {
68
+ top = rect.bottom + offset;
69
+ left = rect.left;
70
+ } else if (side === 'top') {
71
+ top = rect.top - offset;
72
+ left = rect.left;
73
+ } else if (side === 'right') {
74
+ top = rect.top;
75
+ left = rect.right + offset;
76
+ } else if (side === 'left') {
77
+ top = rect.top;
78
+ left = rect.left - offset;
79
+ }
79
80
 
80
- this.fixedPos.set({ top, left });
81
+ this.fixedPos.set({ top, left });
82
+ }
81
83
  }
82
84
  } else {
83
85
  this.fixedPos.set(null);