@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.
- package/package.json +6 -5
- package/src/app/lib/components/ui/alert-dialog/alert-dialog-content.component.ts +22 -21
- package/src/app/lib/components/ui/avatar/ui-avatar.component.ts +4 -0
- package/src/app/lib/components/ui/calendar/calendar.component.ts +70 -13
- package/src/app/lib/components/ui/carousel/carousel-content.component.ts +1 -0
- package/src/app/lib/components/ui/carousel/carousel-item.component.ts +1 -0
- package/src/app/lib/components/ui/carousel/carousel-next.component.ts +1 -0
- package/src/app/lib/components/ui/carousel/carousel-previous.component.ts +1 -0
- package/src/app/lib/components/ui/carousel/carousel.component.ts +1 -0
- package/src/app/lib/components/ui/chart/chart-container.component.ts +1 -0
- package/src/app/lib/components/ui/chart/chart-legend-content.component.ts +1 -0
- package/src/app/lib/components/ui/chart/chart-legend.component.ts +5 -5
- package/src/app/lib/components/ui/chart/chart-tooltip-content.component.ts +5 -5
- package/src/app/lib/components/ui/chart/chart-tooltip.component.ts +5 -5
- package/src/app/lib/components/ui/chart/chart.component.ts +1 -0
- package/src/app/lib/components/ui/checkbox/checkbox.component.ts +1 -1
- package/src/app/lib/components/ui/collapsible/collapsible-content.component.ts +2 -1
- package/src/app/lib/components/ui/collapsible/collapsible-context.ts +1 -0
- package/src/app/lib/components/ui/collapsible/collapsible-trigger.component.ts +1 -0
- package/src/app/lib/components/ui/collapsible/collapsible.component.ts +3 -0
- package/src/app/lib/components/ui/context-menu/context-menu-content.component.ts +49 -17
- package/src/app/lib/components/ui/context-menu/context-menu-sub-content.component.ts +2 -0
- package/src/app/lib/components/ui/context-menu/context-menu-sub-trigger.component.ts +30 -1
- package/src/app/lib/components/ui/context-menu/context-menu-sub.component.ts +3 -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 +48 -5
- package/src/app/lib/components/ui/dialog/dialog-content.component.ts +27 -20
- package/src/app/lib/components/ui/direction/direction-context.ts +9 -0
- package/src/app/lib/components/ui/direction/direction.component.ts +48 -0
- package/src/app/lib/components/ui/direction/index.ts +2 -0
- package/src/app/lib/components/ui/drawer/drawer-content.component.ts +44 -0
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-content.component.ts +25 -23
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.component.ts +1 -0
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.component.ts +2 -0
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.component.ts +28 -2
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub.component.ts +3 -0
- package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-trigger.component.ts +25 -0
- package/src/app/lib/components/ui/empty/empty-action.component.ts +1 -0
- package/src/app/lib/components/ui/empty/empty-description.component.ts +1 -0
- package/src/app/lib/components/ui/empty/empty-icon.component.ts +2 -1
- package/src/app/lib/components/ui/empty/empty-title.component.ts +1 -0
- package/src/app/lib/components/ui/empty/empty.component.ts +1 -0
- 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/form/form-description.component.ts +2 -2
- package/src/app/lib/components/ui/hover-card/hover-card-content.component.ts +108 -60
- package/src/app/lib/components/ui/hover-card/hover-card-context.ts +4 -2
- package/src/app/lib/components/ui/hover-card/hover-card-trigger.component.ts +5 -3
- package/src/app/lib/components/ui/hover-card/hover-card.component.ts +8 -3
- package/src/app/lib/components/ui/input-group/input-group-addon.component.ts +1 -0
- package/src/app/lib/components/ui/input-group/input-group-input.component.ts +1 -0
- package/src/app/lib/components/ui/input-group/input-group.component.ts +1 -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/menubar/menubar-content.component.ts +1 -1
- package/src/app/lib/components/ui/navigation-menu/navigation-menu-content.component.ts +7 -1
- package/src/app/lib/components/ui/navigation-menu/navigation-menu-context.ts +14 -0
- package/src/app/lib/components/ui/navigation-menu/navigation-menu-item.component.ts +9 -4
- package/src/app/lib/components/ui/navigation-menu/navigation-menu-trigger.component.ts +69 -2
- package/src/app/lib/components/ui/navigation-menu/navigation-menu.component.ts +32 -4
- package/src/app/lib/components/ui/pagination/pagination.component.ts +3 -1
- 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/popover/popover-content.component.ts +11 -0
- package/src/app/lib/components/ui/popover/popover-context.ts +2 -0
- package/src/app/lib/components/ui/popover/popover.component.ts +4 -0
- package/src/app/lib/components/ui/progress/progress.component.ts +1 -2
- package/src/app/lib/components/ui/resizable/resizable-handle.component.ts +2 -2
- package/src/app/lib/components/ui/scroll-area/scroll-area.component.ts +7 -6
- package/src/app/lib/components/ui/segmented/segmented-item.component.ts +1 -0
- package/src/app/lib/components/ui/segmented/segmented.component.ts +1 -0
- package/src/app/lib/components/ui/select/select-content.component.ts +35 -15
- package/src/app/lib/components/ui/select/select-context.ts +10 -0
- package/src/app/lib/components/ui/select/select-item.component.ts +25 -7
- package/src/app/lib/components/ui/select/select-trigger.component.ts +6 -13
- package/src/app/lib/components/ui/select/select.component.ts +46 -0
- package/src/app/lib/components/ui/sheet/sheet-content.component.ts +23 -6
- package/src/app/lib/components/ui/slider/slider.component.ts +2 -2
- package/src/app/lib/components/ui/sonner/index.ts +2 -0
- package/src/app/lib/components/ui/sonner/sonner.component.ts +70 -0
- package/src/app/lib/components/ui/switch/switch.component.ts +1 -14
- package/src/app/lib/components/ui/tabs/tabs-list.component.ts +20 -0
- package/src/app/lib/components/ui/tabs/tabs-trigger.component.ts +0 -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/src/app/lib/components/ui/toggle/toggle.component.ts +12 -6
- package/src/app/lib/components/ui/tooltip/tooltip-content.component.ts +141 -17
- package/src/app/lib/components/ui/tooltip/tooltip-context.ts +3 -1
- package/src/app/lib/components/ui/tooltip/tooltip-provider.component.ts +1 -1
- package/src/app/lib/components/ui/tooltip/tooltip-trigger.component.ts +5 -2
- 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
|
-
|
|
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
|
+
}
|
|
@@ -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(
|
|
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
|
-
|
|
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]="
|
|
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
|
-
|
|
99
|
+
wasOpen = true;
|
|
95
100
|
this.lockBodyScroll();
|
|
96
101
|
} else {
|
|
97
102
|
this.unlockBodyScroll();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|