@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ItemHeader component - full-width header row inside an Item.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <Item>
|
|
9
|
+
* <ItemHeader>Header content</ItemHeader>
|
|
10
|
+
* <ItemContent>...</ItemContent>
|
|
11
|
+
* </Item>
|
|
12
|
+
*/
|
|
13
|
+
@Component({
|
|
14
|
+
selector: 'ItemHeader',
|
|
15
|
+
template: `<ng-content />`,
|
|
16
|
+
host: {
|
|
17
|
+
'attr.data-slot': '"item-header"',
|
|
18
|
+
'[class]': 'computedClass()',
|
|
19
|
+
},
|
|
20
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
+
})
|
|
22
|
+
export class ItemHeader {
|
|
23
|
+
/** Additional CSS classes to apply */
|
|
24
|
+
readonly class = input<string>('');
|
|
25
|
+
|
|
26
|
+
/** Computed class combining base styles and custom classes */
|
|
27
|
+
protected readonly computedClass = computed(() =>
|
|
28
|
+
cn('flex basis-full items-center justify-between gap-2', this.class()),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ItemMedia variants using class-variance-authority.
|
|
7
|
+
* Matches shadcn/ui React item-media exactly.
|
|
8
|
+
*/
|
|
9
|
+
export const itemMediaVariants = cva(
|
|
10
|
+
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
variant: {
|
|
14
|
+
default: 'bg-transparent',
|
|
15
|
+
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
|
16
|
+
image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: {
|
|
20
|
+
variant: 'default',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export type ItemMediaVariants = VariantProps<typeof itemMediaVariants>;
|
|
26
|
+
export type ItemMediaVariant = 'default' | 'icon' | 'image';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ItemMedia component - leading media (icon, image or avatar) for an Item.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <!-- Icon -->
|
|
33
|
+
* <ItemMedia variant="icon">
|
|
34
|
+
* <lucide-icon [img]="Music" />
|
|
35
|
+
* </ItemMedia>
|
|
36
|
+
*
|
|
37
|
+
* <!-- Image -->
|
|
38
|
+
* <ItemMedia variant="image">
|
|
39
|
+
* <img src="..." alt="..." />
|
|
40
|
+
* </ItemMedia>
|
|
41
|
+
*/
|
|
42
|
+
@Component({
|
|
43
|
+
selector: 'ItemMedia',
|
|
44
|
+
template: `<ng-content />`,
|
|
45
|
+
host: {
|
|
46
|
+
'attr.data-slot': '"item-media"',
|
|
47
|
+
'[attr.data-variant]': 'variant()',
|
|
48
|
+
'[class]': 'computedClass()',
|
|
49
|
+
},
|
|
50
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
51
|
+
})
|
|
52
|
+
export class ItemMedia {
|
|
53
|
+
/** The visual style variant of the media container */
|
|
54
|
+
readonly variant = input<ItemMediaVariant>('default');
|
|
55
|
+
|
|
56
|
+
/** Additional CSS classes to apply */
|
|
57
|
+
readonly class = input<string>('');
|
|
58
|
+
|
|
59
|
+
/** Computed class combining base styles, variants and custom classes */
|
|
60
|
+
protected readonly computedClass = computed(() =>
|
|
61
|
+
cn(itemMediaVariants({ variant: this.variant() }), this.class()),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ItemSeparator component - horizontal divider between items in an ItemGroup.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <ItemGroup>
|
|
9
|
+
* <Item>...</Item>
|
|
10
|
+
* <ItemSeparator />
|
|
11
|
+
* <Item>...</Item>
|
|
12
|
+
* </ItemGroup>
|
|
13
|
+
*/
|
|
14
|
+
@Component({
|
|
15
|
+
selector: 'ItemSeparator',
|
|
16
|
+
template: ``,
|
|
17
|
+
host: {
|
|
18
|
+
'attr.data-slot': '"item-separator"',
|
|
19
|
+
role: 'none',
|
|
20
|
+
'attr.data-orientation': '"horizontal"',
|
|
21
|
+
'[class]': 'computedClass()',
|
|
22
|
+
},
|
|
23
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
24
|
+
})
|
|
25
|
+
export class ItemSeparator {
|
|
26
|
+
/** Additional CSS classes to apply */
|
|
27
|
+
readonly class = input<string>('');
|
|
28
|
+
|
|
29
|
+
/** Computed class combining base styles and custom classes */
|
|
30
|
+
protected readonly computedClass = computed(() =>
|
|
31
|
+
cn('bg-border shrink-0 h-px w-full my-0', this.class()),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ItemTitle component - title text for an Item.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <ItemTitle>Basic Item</ItemTitle>
|
|
9
|
+
*/
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'ItemTitle',
|
|
12
|
+
template: `<ng-content />`,
|
|
13
|
+
host: {
|
|
14
|
+
'attr.data-slot': '"item-title"',
|
|
15
|
+
'[class]': 'computedClass()',
|
|
16
|
+
},
|
|
17
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
18
|
+
})
|
|
19
|
+
export class ItemTitle {
|
|
20
|
+
/** Additional CSS classes to apply */
|
|
21
|
+
readonly class = input<string>('');
|
|
22
|
+
|
|
23
|
+
/** Computed class combining base styles and custom classes */
|
|
24
|
+
protected readonly computedClass = computed(() =>
|
|
25
|
+
cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', this.class()),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Item variants using class-variance-authority.
|
|
7
|
+
* Matches shadcn/ui React item exactly.
|
|
8
|
+
*/
|
|
9
|
+
export const itemVariants = cva(
|
|
10
|
+
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
variant: {
|
|
14
|
+
default: 'bg-transparent',
|
|
15
|
+
outline: 'border-border',
|
|
16
|
+
muted: 'bg-muted/50',
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: 'p-4 gap-4',
|
|
20
|
+
sm: 'py-3 px-4 gap-2.5',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
variant: 'default',
|
|
25
|
+
size: 'default',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export type ItemVariants = VariantProps<typeof itemVariants>;
|
|
31
|
+
export type ItemVariant = 'default' | 'outline' | 'muted';
|
|
32
|
+
export type ItemSize = 'default' | 'sm';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Item component - a flexible row for displaying content with
|
|
36
|
+
* media, title, description and actions.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <Item variant="outline">
|
|
40
|
+
* <ItemMedia variant="icon">
|
|
41
|
+
* <lucide-icon [img]="User" />
|
|
42
|
+
* </ItemMedia>
|
|
43
|
+
* <ItemContent>
|
|
44
|
+
* <ItemTitle>Profile</ItemTitle>
|
|
45
|
+
* <ItemDescription>Manage your profile settings.</ItemDescription>
|
|
46
|
+
* </ItemContent>
|
|
47
|
+
* <ItemActions>
|
|
48
|
+
* <Button size="sm">Edit</Button>
|
|
49
|
+
* </ItemActions>
|
|
50
|
+
* </Item>
|
|
51
|
+
*/
|
|
52
|
+
@Component({
|
|
53
|
+
selector: 'Item',
|
|
54
|
+
template: `<ng-content />`,
|
|
55
|
+
host: {
|
|
56
|
+
'attr.data-slot': '"item"',
|
|
57
|
+
'[attr.data-variant]': 'variant()',
|
|
58
|
+
'[attr.data-size]': 'size()',
|
|
59
|
+
'[class]': 'computedClass()',
|
|
60
|
+
},
|
|
61
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
62
|
+
})
|
|
63
|
+
export class Item {
|
|
64
|
+
/** The visual style variant of the item */
|
|
65
|
+
readonly variant = input<ItemVariant>('default');
|
|
66
|
+
|
|
67
|
+
/** The size of the item */
|
|
68
|
+
readonly size = input<ItemSize>('default');
|
|
69
|
+
|
|
70
|
+
/** Additional CSS classes to apply */
|
|
71
|
+
readonly class = input<string>('');
|
|
72
|
+
|
|
73
|
+
/** Computed class combining base styles, variants and custom classes */
|
|
74
|
+
protected readonly computedClass = computed(() =>
|
|
75
|
+
cn(itemVariants({ variant: this.variant(), size: this.size() }), this.class()),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -114,7 +114,7 @@ export class MenubarContent implements OnDestroy {
|
|
|
114
114
|
if (content) {
|
|
115
115
|
this.menuItems = Array.from(
|
|
116
116
|
content.querySelectorAll(
|
|
117
|
-
'[role="menuitem"]:not([
|
|
117
|
+
'[role="menuitem"]:not([data-disabled=""]), [role="menuitemcheckbox"]:not([data-disabled=""]), [role="menuitemradio"]:not([data-disabled=""])',
|
|
118
118
|
),
|
|
119
119
|
);
|
|
120
120
|
}
|
|
@@ -18,7 +18,13 @@ import { NAVIGATION_MENU_CONTEXT, NAVIGATION_MENU_ITEM_CONTEXT } from './navigat
|
|
|
18
18
|
imports: [Presence],
|
|
19
19
|
template: `
|
|
20
20
|
<Presence [present]="itemContext.open()">
|
|
21
|
-
<div
|
|
21
|
+
<div
|
|
22
|
+
[class]="computedClass()"
|
|
23
|
+
[attr.id]="itemContext.contentId"
|
|
24
|
+
[attr.data-state]="itemContext.open() ? 'open' : 'closed'"
|
|
25
|
+
[attr.aria-labelledby]="itemContext.triggerId"
|
|
26
|
+
role="region"
|
|
27
|
+
>
|
|
22
28
|
<ng-content />
|
|
23
29
|
</div>
|
|
24
30
|
</Presence>
|
|
@@ -22,6 +22,16 @@ export interface NavigationMenuContextValue {
|
|
|
22
22
|
activeItem: WritableSignal<string | null>;
|
|
23
23
|
/** Layout orientation of the menu */
|
|
24
24
|
orientation: WritableSignal<NavigationMenuOrientation>;
|
|
25
|
+
/** Ordered list of registered trigger element IDs */
|
|
26
|
+
triggerIds: WritableSignal<string[]>;
|
|
27
|
+
/** Register a trigger ID (called by NavigationMenuTrigger on init) */
|
|
28
|
+
registerTrigger: (triggerId: string) => void;
|
|
29
|
+
/** Unregister a trigger ID (called by NavigationMenuTrigger on destroy) */
|
|
30
|
+
unregisterTrigger: (triggerId: string) => void;
|
|
31
|
+
/** Move DOM focus to the next trigger in document order */
|
|
32
|
+
focusNextTrigger: (currentTriggerId: string) => void;
|
|
33
|
+
/** Move DOM focus to the previous trigger in document order */
|
|
34
|
+
focusPreviousTrigger: (currentTriggerId: string) => void;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export const NAVIGATION_MENU_CONTEXT = new InjectionToken<NavigationMenuContextValue>(
|
|
@@ -38,6 +48,10 @@ export const NAVIGATION_MENU_CONTEXT = new InjectionToken<NavigationMenuContextV
|
|
|
38
48
|
export interface NavigationMenuItemContextValue {
|
|
39
49
|
/** Unique identifier for this item */
|
|
40
50
|
itemId: string;
|
|
51
|
+
/** DOM id for the trigger element (used for aria-controls / aria-labelledby) */
|
|
52
|
+
triggerId: string;
|
|
53
|
+
/** DOM id for the content element (used for aria-controls) */
|
|
54
|
+
contentId: string;
|
|
41
55
|
/** Whether this item's content is open */
|
|
42
56
|
open: WritableSignal<boolean>;
|
|
43
57
|
}
|
|
@@ -17,10 +17,15 @@ let itemIdCounter = 0;
|
|
|
17
17
|
providers: [
|
|
18
18
|
{
|
|
19
19
|
provide: NAVIGATION_MENU_ITEM_CONTEXT,
|
|
20
|
-
useFactory: (): NavigationMenuItemContextValue =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
useFactory: (): NavigationMenuItemContextValue => {
|
|
21
|
+
const id = itemIdCounter++;
|
|
22
|
+
return {
|
|
23
|
+
itemId: `nav-item-${id}`,
|
|
24
|
+
triggerId: `nav-trigger-${id}`,
|
|
25
|
+
contentId: `nav-content-${id}`,
|
|
26
|
+
open: signal(false),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
24
29
|
},
|
|
25
30
|
],
|
|
26
31
|
host: {
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { cn } from '@/lib/utils';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
input,
|
|
8
|
+
OnDestroy,
|
|
9
|
+
OnInit,
|
|
10
|
+
} from '@angular/core';
|
|
3
11
|
import { ChevronDown, LucideAngularModule } from 'lucide-angular';
|
|
4
12
|
import { NAVIGATION_MENU_CONTEXT, NAVIGATION_MENU_ITEM_CONTEXT } from './navigation-menu-context';
|
|
5
13
|
import { navigationMenuTriggerStyle } from './navigation-menu-trigger-style';
|
|
@@ -22,13 +30,19 @@ import { navigationMenuTriggerStyle } from './navigation-menu-trigger-style';
|
|
|
22
30
|
host: {
|
|
23
31
|
'attr.data-slot': '"navigation-menu-trigger"',
|
|
24
32
|
'[class]': 'computedClass()',
|
|
33
|
+
'[attr.id]': 'itemContext.triggerId',
|
|
25
34
|
'[attr.data-state]': 'itemContext.open() ? "open" : "closed"',
|
|
35
|
+
'[attr.role]': '"button"',
|
|
36
|
+
'[attr.aria-expanded]': 'itemContext.open()',
|
|
37
|
+
'[attr.aria-haspopup]': '"menu"',
|
|
38
|
+
'[attr.aria-controls]': 'itemContext.contentId',
|
|
26
39
|
'(click)': 'toggle()',
|
|
27
40
|
'(mouseenter)': 'onMouseEnter()',
|
|
41
|
+
'(keydown)': 'onKeyDown($event)',
|
|
28
42
|
},
|
|
29
43
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
30
44
|
})
|
|
31
|
-
export class NavigationMenuTrigger {
|
|
45
|
+
export class NavigationMenuTrigger implements OnInit, OnDestroy {
|
|
32
46
|
/** Additional CSS classes */
|
|
33
47
|
readonly class = input<string>('');
|
|
34
48
|
|
|
@@ -41,6 +55,14 @@ export class NavigationMenuTrigger {
|
|
|
41
55
|
|
|
42
56
|
protected readonly ChevronDownIcon = ChevronDown;
|
|
43
57
|
|
|
58
|
+
ngOnInit(): void {
|
|
59
|
+
this.context.registerTrigger(this.itemContext.triggerId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ngOnDestroy(): void {
|
|
63
|
+
this.context.unregisterTrigger(this.itemContext.triggerId);
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
protected toggle(): void {
|
|
45
67
|
this.itemContext.open.update((v) => !v);
|
|
46
68
|
if (this.itemContext.open()) {
|
|
@@ -49,6 +71,7 @@ export class NavigationMenuTrigger {
|
|
|
49
71
|
this.context.activeItem.set(null);
|
|
50
72
|
}
|
|
51
73
|
}
|
|
74
|
+
|
|
52
75
|
protected onMouseEnter(): void {
|
|
53
76
|
const activeItem = this.context.activeItem();
|
|
54
77
|
if (activeItem && activeItem !== this.itemContext.itemId) {
|
|
@@ -56,4 +79,48 @@ export class NavigationMenuTrigger {
|
|
|
56
79
|
this.itemContext.open.set(true);
|
|
57
80
|
}
|
|
58
81
|
}
|
|
82
|
+
|
|
83
|
+
protected onKeyDown(event: KeyboardEvent): void {
|
|
84
|
+
switch (event.key) {
|
|
85
|
+
case 'Enter':
|
|
86
|
+
case ' ':
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
this.toggle();
|
|
89
|
+
if (this.itemContext.open()) {
|
|
90
|
+
this.focusFirstContentItem();
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case 'ArrowDown':
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
if (!this.itemContext.open()) {
|
|
96
|
+
this.itemContext.open.set(true);
|
|
97
|
+
this.context.activeItem.set(this.itemContext.itemId);
|
|
98
|
+
}
|
|
99
|
+
this.focusFirstContentItem();
|
|
100
|
+
break;
|
|
101
|
+
case 'ArrowRight':
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
this.context.focusNextTrigger(this.itemContext.triggerId);
|
|
104
|
+
break;
|
|
105
|
+
case 'ArrowLeft':
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
this.context.focusPreviousTrigger(this.itemContext.triggerId);
|
|
108
|
+
break;
|
|
109
|
+
case 'Escape':
|
|
110
|
+
if (this.itemContext.open()) {
|
|
111
|
+
this.itemContext.open.set(false);
|
|
112
|
+
this.context.activeItem.set(null);
|
|
113
|
+
document.getElementById(this.itemContext.triggerId)?.focus();
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private focusFirstContentItem(): void {
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
const content = document.getElementById(this.itemContext.contentId);
|
|
122
|
+
const focusable = content?.querySelector<HTMLElement>('a, button, [tabindex]');
|
|
123
|
+
focusable?.focus();
|
|
124
|
+
}, 10);
|
|
125
|
+
}
|
|
59
126
|
}
|
|
@@ -7,6 +7,37 @@ import {
|
|
|
7
7
|
} from './navigation-menu-context';
|
|
8
8
|
import { NavigationMenuViewport } from './navigation-menu-viewport.component';
|
|
9
9
|
|
|
10
|
+
function createNavigationMenuContext(): NavigationMenuContextValue {
|
|
11
|
+
const triggerIds = signal<string[]>([]);
|
|
12
|
+
return {
|
|
13
|
+
activeItem: signal(null),
|
|
14
|
+
orientation: signal('horizontal'),
|
|
15
|
+
triggerIds,
|
|
16
|
+
registerTrigger: (triggerId: string) => {
|
|
17
|
+
triggerIds.update((ids) => [...ids, triggerId]);
|
|
18
|
+
},
|
|
19
|
+
unregisterTrigger: (triggerId: string) => {
|
|
20
|
+
triggerIds.update((ids) => ids.filter((id) => id !== triggerId));
|
|
21
|
+
},
|
|
22
|
+
focusNextTrigger: (currentTriggerId: string) => {
|
|
23
|
+
const ids = triggerIds();
|
|
24
|
+
const idx = ids.indexOf(currentTriggerId);
|
|
25
|
+
const nextId = idx < ids.length - 1 ? ids[idx + 1] : ids[0];
|
|
26
|
+
if (nextId) {
|
|
27
|
+
document.getElementById(nextId)?.focus();
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
focusPreviousTrigger: (currentTriggerId: string) => {
|
|
31
|
+
const ids = triggerIds();
|
|
32
|
+
const idx = ids.indexOf(currentTriggerId);
|
|
33
|
+
const prevId = idx > 0 ? ids[idx - 1] : ids[ids.length - 1];
|
|
34
|
+
if (prevId) {
|
|
35
|
+
document.getElementById(prevId)?.focus();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
/**
|
|
11
42
|
* Props for the NavigationMenu component
|
|
12
43
|
*/
|
|
@@ -121,10 +152,7 @@ export interface NavigationMenuProps {
|
|
|
121
152
|
providers: [
|
|
122
153
|
{
|
|
123
154
|
provide: NAVIGATION_MENU_CONTEXT,
|
|
124
|
-
useFactory:
|
|
125
|
-
activeItem: signal(null),
|
|
126
|
-
orientation: signal('horizontal'),
|
|
127
|
-
}),
|
|
155
|
+
useFactory: createNavigationMenuContext,
|
|
128
156
|
},
|
|
129
157
|
],
|
|
130
158
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
@@ -29,12 +29,14 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
|
|
|
29
29
|
host: {
|
|
30
30
|
'attr.data-slot': '"pagination"',
|
|
31
31
|
role: 'navigation',
|
|
32
|
-
'[attr.aria-label]': '
|
|
32
|
+
'[attr.aria-label]': 'ariaLabel()',
|
|
33
33
|
'[class]': 'computedClass()',
|
|
34
34
|
},
|
|
35
35
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
36
36
|
})
|
|
37
37
|
export class Pagination {
|
|
38
|
+
/** Accessible label for the navigation landmark */
|
|
39
|
+
readonly ariaLabel = input<string>('Pagination');
|
|
38
40
|
/** Additional CSS classes */
|
|
39
41
|
readonly class = input<string>('');
|
|
40
42
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PhoneInput } from './phone-input.component';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import {
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
computed,
|
|
6
|
+
forwardRef,
|
|
7
|
+
input,
|
|
8
|
+
signal,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
11
|
+
import { CountrySelector } from '../country-selector/country-selector.component';
|
|
12
|
+
import { COUNTRIES, type Country, getCountryByCode } from '../country-selector/country-data';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* PhoneInput component
|
|
16
|
+
*
|
|
17
|
+
* Combines a CountrySelector (dial code) with a number input.
|
|
18
|
+
* Implements ControlValueAccessor — value is the full phone string (e.g. "+1 555-123-4567").
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```html
|
|
22
|
+
* <PhoneInput [(ngModel)]="phone" placeholder="Enter phone number" />
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'PhoneInput',
|
|
27
|
+
imports: [CountrySelector],
|
|
28
|
+
template: `
|
|
29
|
+
<div [class]="containerClass()">
|
|
30
|
+
<CountrySelector
|
|
31
|
+
[disabled]="isDisabled()"
|
|
32
|
+
[class]="'rounded-r-none border-r-0 shrink-0 w-auto min-w-[120px]'"
|
|
33
|
+
[placeholder]="'Country'"
|
|
34
|
+
[value]="selectedCountryCode()"
|
|
35
|
+
(countryChange)="onCountryChange($event)"
|
|
36
|
+
/>
|
|
37
|
+
<input
|
|
38
|
+
type="tel"
|
|
39
|
+
[placeholder]="placeholder()"
|
|
40
|
+
[disabled]="isDisabled()"
|
|
41
|
+
[value]="localNumber()"
|
|
42
|
+
(input)="onNumberInput($event)"
|
|
43
|
+
(blur)="onTouched()"
|
|
44
|
+
[class]="inputClass()"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
`,
|
|
48
|
+
host: {
|
|
49
|
+
'attr.data-slot': '"phone-input"',
|
|
50
|
+
'[class]': 'hostClass()',
|
|
51
|
+
},
|
|
52
|
+
providers: [
|
|
53
|
+
{
|
|
54
|
+
provide: NG_VALUE_ACCESSOR,
|
|
55
|
+
useExisting: forwardRef(() => PhoneInput),
|
|
56
|
+
multi: true,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
60
|
+
})
|
|
61
|
+
export class PhoneInput implements ControlValueAccessor {
|
|
62
|
+
/** Placeholder for the number input */
|
|
63
|
+
readonly placeholder = input<string>('Phone number');
|
|
64
|
+
/** Whether the input is disabled */
|
|
65
|
+
readonly disabled = input<boolean>(false);
|
|
66
|
+
/** Additional CSS classes */
|
|
67
|
+
readonly class = input<string>('');
|
|
68
|
+
|
|
69
|
+
/** Internal country code (ISO 2-letter) */
|
|
70
|
+
protected readonly selectedCountryCode = signal<string>('US');
|
|
71
|
+
/** Internal local number (without dial code) */
|
|
72
|
+
protected readonly localNumber = signal<string>('');
|
|
73
|
+
/** Internal disabled state from CVA */
|
|
74
|
+
private readonly _isDisabledFromCVA = signal<boolean>(false);
|
|
75
|
+
|
|
76
|
+
protected readonly isDisabled = computed(() => this.disabled() || this._isDisabledFromCVA());
|
|
77
|
+
|
|
78
|
+
protected readonly hostClass = computed(() => cn('block', this.class()));
|
|
79
|
+
|
|
80
|
+
protected readonly containerClass = computed(() =>
|
|
81
|
+
cn(
|
|
82
|
+
'flex items-stretch w-full',
|
|
83
|
+
this.isDisabled() && 'pointer-events-none opacity-50',
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
protected readonly inputClass = computed(() =>
|
|
88
|
+
cn(
|
|
89
|
+
'flex-1 min-w-0 h-10 rounded-xl rounded-l-none border px-3 py-2 text-sm',
|
|
90
|
+
'bg-zinc-50 dark:bg-zinc-800/50 border-zinc-300 dark:border-zinc-700/50',
|
|
91
|
+
'text-zinc-900 dark:text-zinc-50 placeholder:text-zinc-500',
|
|
92
|
+
'shadow-xs transition-[color,box-shadow] outline-none',
|
|
93
|
+
'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',
|
|
94
|
+
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/** ControlValueAccessor callbacks */
|
|
99
|
+
private onChange: (value: string) => void = () => {};
|
|
100
|
+
protected onTouched: () => void = () => {};
|
|
101
|
+
|
|
102
|
+
writeValue(value: string): void {
|
|
103
|
+
if (!value) {
|
|
104
|
+
this.localNumber.set('');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Parse: if value starts with a known dial code, split it
|
|
108
|
+
const country = getCountryByCode(this.selectedCountryCode());
|
|
109
|
+
const dialCode = country?.dialCode ?? '';
|
|
110
|
+
if (dialCode && value.startsWith(dialCode)) {
|
|
111
|
+
this.localNumber.set(value.slice(dialCode.length).trimStart());
|
|
112
|
+
} else if (value.startsWith('+')) {
|
|
113
|
+
// Try to find a matching dial code
|
|
114
|
+
const matched = this.findCountryByDialCode(value);
|
|
115
|
+
if (matched) {
|
|
116
|
+
this.selectedCountryCode.set(matched.code);
|
|
117
|
+
this.localNumber.set(value.slice(matched.dialCode.length).trimStart());
|
|
118
|
+
} else {
|
|
119
|
+
this.localNumber.set(value);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
this.localNumber.set(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
registerOnChange(fn: (value: string) => void): void {
|
|
127
|
+
this.onChange = fn;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
registerOnTouched(fn: () => void): void {
|
|
131
|
+
this.onTouched = fn;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setDisabledState(isDisabled: boolean): void {
|
|
135
|
+
this._isDisabledFromCVA.set(isDisabled);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
protected onCountryChange(country: Country): void {
|
|
139
|
+
this.selectedCountryCode.set(country.code);
|
|
140
|
+
this.emitValue();
|
|
141
|
+
this.onTouched();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
protected onNumberInput(event: Event): void {
|
|
145
|
+
const target = event.target as HTMLInputElement;
|
|
146
|
+
this.localNumber.set(target.value);
|
|
147
|
+
this.emitValue();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private emitValue(): void {
|
|
151
|
+
const country = getCountryByCode(this.selectedCountryCode());
|
|
152
|
+
const dialCode = country?.dialCode ?? '';
|
|
153
|
+
const number = this.localNumber().trim();
|
|
154
|
+
const fullValue = number ? `${dialCode} ${number}` : dialCode;
|
|
155
|
+
this.onChange(fullValue);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private findCountryByDialCode(phone: string): Country | undefined {
|
|
159
|
+
let best: Country | undefined;
|
|
160
|
+
let bestLen = 0;
|
|
161
|
+
for (const c of COUNTRIES) {
|
|
162
|
+
if (phone.startsWith(c.dialCode) && c.dialCode.length > bestLen) {
|
|
163
|
+
best = c;
|
|
164
|
+
bestLen = c.dialCode.length;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return best;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -100,6 +100,7 @@ export interface PopoverContentProps {
|
|
|
100
100
|
[attr.data-align]="computedAlign()"
|
|
101
101
|
[style]="mergedStyles()"
|
|
102
102
|
role="dialog"
|
|
103
|
+
tabindex="-1"
|
|
103
104
|
[attr.aria-modal]="context.modal() || null"
|
|
104
105
|
>
|
|
105
106
|
<ng-content />
|
|
@@ -277,5 +278,15 @@ export class PopoverContent {
|
|
|
277
278
|
'--radix-popover-content-transform-origin': transformOrigin,
|
|
278
279
|
});
|
|
279
280
|
this.isPositioned.set(true);
|
|
281
|
+
|
|
282
|
+
// Move focus into the popover for keyboard accessibility
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
const dialog = this._elementRef.nativeElement.querySelector('[role="dialog"]') as HTMLElement;
|
|
285
|
+
if (!dialog) return;
|
|
286
|
+
const firstFocusable = dialog.querySelector<HTMLElement>(
|
|
287
|
+
'button:not([disabled]):not([data-disabled=""]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
288
|
+
);
|
|
289
|
+
(firstFocusable ?? dialog).focus({ preventScroll: true });
|
|
290
|
+
}, 0);
|
|
280
291
|
}
|
|
281
292
|
}
|