@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.
- package/package.json +1 -1
- package/src/app/lib/components/ui/alert-dialog/alert-dialog-content.component.ts +1 -1
- package/src/app/lib/components/ui/calendar/calendar.component.ts +65 -12
- package/src/app/lib/components/ui/context-menu/context-menu-content.component.ts +1 -0
- package/src/app/lib/components/ui/country-selector/country-data.ts +63 -0
- package/src/app/lib/components/ui/country-selector/country-selector.component.ts +199 -0
- package/src/app/lib/components/ui/country-selector/index.ts +2 -0
- package/src/app/lib/components/ui/date-picker/date-picker.component.ts +47 -5
- package/src/app/lib/components/ui/dialog/dialog-content.component.ts +1 -1
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-content.component.ts +23 -21
- package/src/app/lib/components/ui/field/field-content.component.ts +34 -0
- package/src/app/lib/components/ui/field/field-description.component.ts +35 -0
- package/src/app/lib/components/ui/field/field-error.component.ts +48 -0
- package/src/app/lib/components/ui/field/field-group.component.ts +34 -0
- package/src/app/lib/components/ui/field/field-label.component.ts +46 -0
- package/src/app/lib/components/ui/field/field-legend.component.ts +41 -0
- package/src/app/lib/components/ui/field/field-separator.component.ts +49 -0
- package/src/app/lib/components/ui/field/field-set.component.ts +37 -0
- package/src/app/lib/components/ui/field/field-title.component.ts +30 -0
- package/src/app/lib/components/ui/field/field.component.ts +66 -0
- package/src/app/lib/components/ui/field/index.ts +15 -0
- package/src/app/lib/components/ui/item/index.ts +21 -0
- package/src/app/lib/components/ui/item/item-actions.component.ts +29 -0
- package/src/app/lib/components/ui/item/item-content.component.ts +31 -0
- package/src/app/lib/components/ui/item/item-description.component.ts +30 -0
- package/src/app/lib/components/ui/item/item-footer.component.ts +30 -0
- package/src/app/lib/components/ui/item/item-group.component.ts +32 -0
- package/src/app/lib/components/ui/item/item-header.component.ts +30 -0
- package/src/app/lib/components/ui/item/item-media.component.ts +63 -0
- package/src/app/lib/components/ui/item/item-separator.component.ts +33 -0
- package/src/app/lib/components/ui/item/item-title.component.ts +27 -0
- package/src/app/lib/components/ui/item/item.component.ts +77 -0
- package/src/app/lib/components/ui/phone-input/index.ts +1 -0
- package/src/app/lib/components/ui/phone-input/phone-input.component.ts +169 -0
- package/src/app/lib/components/ui/resizable/resizable-handle.component.ts +2 -2
- package/src/app/lib/components/ui/sheet/sheet-content.component.ts +1 -1
- package/src/app/lib/components/ui/tabs/tabs-list.component.ts +3 -1
- package/src/app/lib/components/ui/textarea/textarea.component.ts +110 -10
- package/src/app/lib/components/ui/toast/toast.service.ts +1 -1
package/package.json
CHANGED
|
@@ -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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
}
|
|
@@ -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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
+
this.fixedPos.set({ top, left });
|
|
82
|
+
}
|
|
81
83
|
}
|
|
82
84
|
} else {
|
|
83
85
|
this.fixedPos.set(null);
|