@ng-cn/core 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +6 -5
  2. package/src/app/lib/components/ui/alert-dialog/alert-dialog-content.component.ts +21 -20
  3. package/src/app/lib/components/ui/avatar/ui-avatar.component.ts +4 -0
  4. package/src/app/lib/components/ui/calendar/calendar.component.ts +5 -1
  5. package/src/app/lib/components/ui/carousel/carousel-content.component.ts +1 -0
  6. package/src/app/lib/components/ui/carousel/carousel-item.component.ts +1 -0
  7. package/src/app/lib/components/ui/carousel/carousel-next.component.ts +1 -0
  8. package/src/app/lib/components/ui/carousel/carousel-previous.component.ts +1 -0
  9. package/src/app/lib/components/ui/carousel/carousel.component.ts +1 -0
  10. package/src/app/lib/components/ui/chart/chart-container.component.ts +1 -0
  11. package/src/app/lib/components/ui/chart/chart-legend-content.component.ts +1 -0
  12. package/src/app/lib/components/ui/chart/chart-legend.component.ts +5 -5
  13. package/src/app/lib/components/ui/chart/chart-tooltip-content.component.ts +5 -5
  14. package/src/app/lib/components/ui/chart/chart-tooltip.component.ts +5 -5
  15. package/src/app/lib/components/ui/chart/chart.component.ts +1 -0
  16. package/src/app/lib/components/ui/checkbox/checkbox.component.ts +1 -1
  17. package/src/app/lib/components/ui/collapsible/collapsible-content.component.ts +2 -1
  18. package/src/app/lib/components/ui/collapsible/collapsible-context.ts +1 -0
  19. package/src/app/lib/components/ui/collapsible/collapsible-trigger.component.ts +1 -0
  20. package/src/app/lib/components/ui/collapsible/collapsible.component.ts +3 -0
  21. package/src/app/lib/components/ui/context-menu/context-menu-content.component.ts +48 -17
  22. package/src/app/lib/components/ui/context-menu/context-menu-sub-content.component.ts +2 -0
  23. package/src/app/lib/components/ui/context-menu/context-menu-sub-trigger.component.ts +30 -1
  24. package/src/app/lib/components/ui/context-menu/context-menu-sub.component.ts +3 -0
  25. package/src/app/lib/components/ui/date-picker/date-picker.component.ts +1 -0
  26. package/src/app/lib/components/ui/dialog/dialog-content.component.ts +26 -19
  27. package/src/app/lib/components/ui/direction/direction-context.ts +9 -0
  28. package/src/app/lib/components/ui/direction/direction.component.ts +48 -0
  29. package/src/app/lib/components/ui/direction/index.ts +2 -0
  30. package/src/app/lib/components/ui/drawer/drawer-content.component.ts +44 -0
  31. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-content.component.ts +2 -2
  32. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.component.ts +1 -0
  33. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.component.ts +2 -0
  34. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.component.ts +28 -2
  35. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-sub.component.ts +3 -0
  36. package/src/app/lib/components/ui/dropdown-menu/dropdown-menu-trigger.component.ts +25 -0
  37. package/src/app/lib/components/ui/empty/empty-action.component.ts +1 -0
  38. package/src/app/lib/components/ui/empty/empty-description.component.ts +1 -0
  39. package/src/app/lib/components/ui/empty/empty-icon.component.ts +2 -1
  40. package/src/app/lib/components/ui/empty/empty-title.component.ts +1 -0
  41. package/src/app/lib/components/ui/empty/empty.component.ts +1 -0
  42. package/src/app/lib/components/ui/form/form-description.component.ts +2 -2
  43. package/src/app/lib/components/ui/hover-card/hover-card-content.component.ts +108 -60
  44. package/src/app/lib/components/ui/hover-card/hover-card-context.ts +4 -2
  45. package/src/app/lib/components/ui/hover-card/hover-card-trigger.component.ts +5 -3
  46. package/src/app/lib/components/ui/hover-card/hover-card.component.ts +8 -3
  47. package/src/app/lib/components/ui/input-group/input-group-addon.component.ts +1 -0
  48. package/src/app/lib/components/ui/input-group/input-group-input.component.ts +1 -0
  49. package/src/app/lib/components/ui/input-group/input-group.component.ts +1 -0
  50. package/src/app/lib/components/ui/menubar/menubar-content.component.ts +1 -1
  51. package/src/app/lib/components/ui/navigation-menu/navigation-menu-content.component.ts +7 -1
  52. package/src/app/lib/components/ui/navigation-menu/navigation-menu-context.ts +14 -0
  53. package/src/app/lib/components/ui/navigation-menu/navigation-menu-item.component.ts +9 -4
  54. package/src/app/lib/components/ui/navigation-menu/navigation-menu-trigger.component.ts +69 -2
  55. package/src/app/lib/components/ui/navigation-menu/navigation-menu.component.ts +32 -4
  56. package/src/app/lib/components/ui/pagination/pagination.component.ts +3 -1
  57. package/src/app/lib/components/ui/popover/popover-content.component.ts +11 -0
  58. package/src/app/lib/components/ui/popover/popover-context.ts +2 -0
  59. package/src/app/lib/components/ui/popover/popover.component.ts +4 -0
  60. package/src/app/lib/components/ui/progress/progress.component.ts +1 -2
  61. package/src/app/lib/components/ui/scroll-area/scroll-area.component.ts +7 -6
  62. package/src/app/lib/components/ui/segmented/segmented-item.component.ts +1 -0
  63. package/src/app/lib/components/ui/segmented/segmented.component.ts +1 -0
  64. package/src/app/lib/components/ui/select/select-content.component.ts +35 -15
  65. package/src/app/lib/components/ui/select/select-context.ts +10 -0
  66. package/src/app/lib/components/ui/select/select-item.component.ts +25 -7
  67. package/src/app/lib/components/ui/select/select-trigger.component.ts +6 -13
  68. package/src/app/lib/components/ui/select/select.component.ts +46 -0
  69. package/src/app/lib/components/ui/sheet/sheet-content.component.ts +22 -5
  70. package/src/app/lib/components/ui/slider/slider.component.ts +2 -2
  71. package/src/app/lib/components/ui/sonner/index.ts +2 -0
  72. package/src/app/lib/components/ui/sonner/sonner.component.ts +70 -0
  73. package/src/app/lib/components/ui/switch/switch.component.ts +1 -14
  74. package/src/app/lib/components/ui/tabs/tabs-list.component.ts +18 -0
  75. package/src/app/lib/components/ui/tabs/tabs-trigger.component.ts +0 -1
  76. package/src/app/lib/components/ui/toggle/toggle.component.ts +12 -6
  77. package/src/app/lib/components/ui/tooltip/tooltip-content.component.ts +141 -17
  78. package/src/app/lib/components/ui/tooltip/tooltip-context.ts +3 -1
  79. package/src/app/lib/components/ui/tooltip/tooltip-provider.component.ts +1 -1
  80. package/src/app/lib/components/ui/tooltip/tooltip-trigger.component.ts +5 -2
  81. package/src/app/lib/components/ui/tooltip/tooltip.component.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ng-cn/core",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Beautifully designed Angular components built with Tailwind CSS v4 - The official Angular port of shadcn/ui",
5
5
  "keywords": [
6
6
  "angular",
@@ -58,6 +58,8 @@
58
58
  ]
59
59
  },
60
60
  "dependencies": {
61
+ "@angular-devkit/core": "^21.2.5",
62
+ "@angular-devkit/schematics": "^21.2.5",
61
63
  "@angular/cdk": "^21.2.4",
62
64
  "@angular/common": "^21.2.6",
63
65
  "@angular/compiler": "^21.2.6",
@@ -74,18 +76,17 @@
74
76
  "express": "^5.1.0",
75
77
  "lucide-angular": "^1.0.0",
76
78
  "ng-apexcharts": "^2.3.0",
79
+ "ngx-sonner": "^3.1.0",
77
80
  "postcss": "^8.5.8",
78
81
  "rxjs": "~7.8.0",
79
82
  "shiki": "^4.0.2",
80
83
  "tailwind-merge": "^3.5.0",
81
84
  "tailwindcss": "^4.2.2",
82
- "tslib": "^2.3.0",
83
- "@angular-devkit/core": "^21.2.5",
84
- "@angular-devkit/schematics": "^21.2.5"
85
+ "tslib": "^2.3.0"
85
86
  },
86
87
  "devDependencies": {
87
- "@analogjs/vitest-angular": "^2.3.1",
88
88
  "@analogjs/vite-plugin-angular": "^2.2.0",
89
+ "@analogjs/vitest-angular": "^2.3.1",
89
90
  "@angular/build": "^21.2.5",
90
91
  "@angular/cli": "^21.2.5",
91
92
  "@angular/compiler-cli": "^21.2.6",
@@ -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,13 +10,9 @@ import {
10
10
  HostListener,
11
11
  inject,
12
12
  input,
13
- signal,
14
13
  } from '@angular/core';
15
14
  import { ALERT_DIALOG_CONTEXT } from './alert-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
  * AlertDialogContent component - the modal content of the alert dialog.
22
18
  * Matches shadcn/ui React AlertDialogContent exactly.
@@ -26,12 +22,14 @@ const EXIT_ANIMATION_MS = 200;
26
22
  * - Overlay/backdrop click does NOT close the dialog
27
23
  * - Focus is trapped within the dialog
28
24
  * - User must explicitly click Cancel or Action to close
25
+ * - Exit animations handled by Presence component (no setTimeout needed)
26
+ * - Focus restored on any programmatic close (Action/Cancel/Escape)
29
27
  */
30
28
  @Component({
31
29
  selector: 'AlertDialogContent',
32
- imports: [FocusTrapDirective],
30
+ imports: [FocusTrapDirective, Presence],
33
31
  template: `
34
- @if (shouldRender()) {
32
+ <Presence [present]="context.isOpen()">
35
33
  <!-- Overlay - does NOT close on click -->
36
34
  <div
37
35
  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"
@@ -55,7 +53,7 @@ const EXIT_ANIMATION_MS = 200;
55
53
  >
56
54
  <ng-content />
57
55
  </div>
58
- }
56
+ </Presence>
59
57
  `,
60
58
  host: {
61
59
  'attr.data-slot': '"alert-dialog-content"',
@@ -65,21 +63,22 @@ const EXIT_ANIMATION_MS = 200;
65
63
  })
66
64
  export class AlertDialogContent {
67
65
  constructor() {
66
+ let wasOpen = false;
67
+
68
68
  effect(() => {
69
69
  const isOpen = this.context.isOpen();
70
70
  this._cdr.markForCheck();
71
71
 
72
72
  if (isOpen) {
73
- this.shouldRender.set(true);
73
+ wasOpen = true;
74
74
  this.lockBodyScroll();
75
75
  } else {
76
76
  this.unlockBodyScroll();
77
- if (this.shouldRender()) {
78
- setTimeout(() => {
79
- this.shouldRender.set(false);
80
- this._cdr.markForCheck();
81
- }, EXIT_ANIMATION_MS);
77
+ // Restore focus whenever dialog closes (covers Action/Cancel/Escape paths)
78
+ if (wasOpen) {
79
+ this.restoreFocus();
82
80
  }
81
+ wasOpen = false;
83
82
  }
84
83
  });
85
84
 
@@ -95,7 +94,6 @@ export class AlertDialogContent {
95
94
  private readonly _cdr = inject(ChangeDetectorRef);
96
95
 
97
96
  protected readonly context = inject(ALERT_DIALOG_CONTEXT);
98
- protected readonly shouldRender = signal(false);
99
97
 
100
98
  protected readonly computedClass = computed(() =>
101
99
  cn(
@@ -111,29 +109,32 @@ export class AlertDialogContent {
111
109
  );
112
110
 
113
111
  private previousBodyOverflow = '';
112
+ private previousBodyPaddingRight = '';
114
113
 
115
114
  @HostListener('document:keydown.escape')
116
115
  onEscapeKey(): void {
117
116
  if (this.context.isOpen()) {
118
- this.close();
117
+ this.context.setOpen(false);
119
118
  }
120
119
  }
121
120
 
122
121
  private lockBodyScroll(): void {
123
122
  if (typeof document !== 'undefined') {
123
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
124
124
  this.previousBodyOverflow = document.body.style.overflow;
125
+ this.previousBodyPaddingRight = document.body.style.paddingRight;
125
126
  document.body.style.overflow = 'hidden';
127
+ if (scrollbarWidth > 0) {
128
+ document.body.style.paddingRight = scrollbarWidth + 'px';
129
+ }
126
130
  }
127
131
  }
128
132
  private unlockBodyScroll(): void {
129
133
  if (typeof document !== 'undefined') {
130
134
  document.body.style.overflow = this.previousBodyOverflow;
135
+ document.body.style.paddingRight = this.previousBodyPaddingRight;
131
136
  }
132
137
  }
133
- private close(): void {
134
- this.restoreFocus();
135
- this.context.setOpen(false);
136
- }
137
138
  private restoreFocus(): void {
138
139
  const triggerEl = this.context.getTriggerElement();
139
140
  if (triggerEl) {
@@ -14,6 +14,10 @@ import { Avatar } from './avatar.component';
14
14
  selector: 'ui-avatar',
15
15
  changeDetection: ChangeDetectionStrategy.OnPush,
16
16
  imports: [Avatar, AvatarImage, AvatarFallback],
17
+ host: {
18
+ 'attr.data-slot': '"ui-avatar"',
19
+ style: 'display: contents',
20
+ },
17
21
  template: `
18
22
  <Avatar [class]="class()">
19
23
  @if (src()) {
@@ -26,8 +26,12 @@ import { buttonVariants } from '../button';
26
26
  @Component({
27
27
  selector: 'Calendar',
28
28
  imports: [LucideAngularModule],
29
+ host: {
30
+ 'attr.data-slot': '"calendar"',
31
+ '[class]': 'computedClass()',
32
+ },
29
33
  template: `
30
- <div [class]="computedClass()" role="application" [attr.aria-label]="ariaLabel()">
34
+ <div role="application" [attr.aria-label]="ariaLabel()">
31
35
  <!-- Header with navigation -->
32
36
  <div class="w-full">
33
37
  <div class="w-full space-y-4">
@@ -14,6 +14,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
14
14
  </div>
15
15
  `,
16
16
  host: {
17
+ 'attr.data-slot': '"carousel-content"',
17
18
  '[class]': 'computedClass()',
18
19
  'aria-atomic': 'false',
19
20
  },
@@ -10,6 +10,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
10
10
  selector: 'CarouselItem',
11
11
  template: `<ng-content />`,
12
12
  host: {
13
+ 'attr.data-slot': '"carousel-item"',
13
14
  '[class]': 'computedClass()',
14
15
  role: 'group',
15
16
  'aria-roledescription': 'slide',
@@ -24,6 +24,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
24
24
  </button>
25
25
  `,
26
26
  host: {
27
+ 'attr.data-slot': '"carousel-next"',
27
28
  class: 'contents',
28
29
  },
29
30
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -24,6 +24,7 @@ import { CAROUSEL_CONTEXT } from './carousel-context';
24
24
  </button>
25
25
  `,
26
26
  host: {
27
+ 'attr.data-slot': '"carousel-previous"',
27
28
  class: 'contents',
28
29
  },
29
30
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -77,6 +77,7 @@ import {
77
77
  },
78
78
  ],
79
79
  host: {
80
+ 'attr.data-slot': '"carousel"',
80
81
  '[class]': 'computedClass()',
81
82
  role: 'region',
82
83
  '[attr.aria-label]': 'ariaLabel()',
@@ -24,6 +24,7 @@ import { CHART_CONTEXT, type ChartConfig, type ChartContext } from './chart-cont
24
24
  selector: 'ChartContainer',
25
25
  template: `<ng-content />`,
26
26
  host: {
27
+ 'attr.data-slot': '"chart-container"',
27
28
  '[class]': 'computedClass()',
28
29
  '[style]': 'chartStyles()',
29
30
  'data-chart': '',
@@ -16,6 +16,7 @@ import { CHART_COLORS, CHART_CONTEXT } from './chart-context';
16
16
  }
17
17
  `,
18
18
  host: {
19
+ 'attr.data-slot': '"chart-legend-content"',
19
20
  '[class]': 'computedClass()',
20
21
  },
21
22
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -6,11 +6,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
6
6
  */
7
7
  @Component({
8
8
  selector: 'ChartLegend',
9
- template: `
10
- <div [class]="computedClass()">
11
- <ng-content />
12
- </div>
13
- `,
9
+ template: `<ng-content />`,
10
+ host: {
11
+ 'attr.data-slot': '"chart-legend"',
12
+ '[class]': 'computedClass()',
13
+ },
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  })
16
16
  export class ChartLegend {
@@ -6,11 +6,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
6
6
  */
7
7
  @Component({
8
8
  selector: 'ChartTooltipContent',
9
- template: `
10
- <div [class]="computedClass()">
11
- <ng-content />
12
- </div>
13
- `,
9
+ template: `<ng-content />`,
10
+ host: {
11
+ 'attr.data-slot': '"chart-tooltip-content"',
12
+ '[class]': 'computedClass()',
13
+ },
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  })
16
16
  export class ChartTooltipContent {
@@ -6,11 +6,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
6
6
  */
7
7
  @Component({
8
8
  selector: 'ChartTooltip',
9
- template: `
10
- <div [class]="computedClass()">
11
- <ng-content />
12
- </div>
13
- `,
9
+ template: `<ng-content />`,
10
+ host: {
11
+ 'attr.data-slot': '"chart-tooltip"',
12
+ '[class]': 'computedClass()',
13
+ },
14
14
  changeDetection: ChangeDetectionStrategy.OnPush,
15
15
  })
16
16
  export class ChartTooltip {
@@ -114,6 +114,7 @@ import {
114
114
  </svg>
115
115
  `,
116
116
  host: {
117
+ 'attr.data-slot': '"chart"',
117
118
  '[class]': 'computedClass()',
118
119
  },
119
120
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -96,7 +96,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
96
96
  `,
97
97
  host: {
98
98
  '[class]': 'computedClass()',
99
- 'data-slot': 'checkbox',
99
+ 'attr.data-slot': '"checkbox"',
100
100
  },
101
101
  providers: [
102
102
  {
@@ -22,7 +22,8 @@ import { COLLAPSIBLE_CONTEXT } from './collapsible-context';
22
22
  '[class]': 'computedClass()',
23
23
  '[attr.data-state]': 'collapsible.isOpen() ? "open" : "closed"',
24
24
  '[attr.data-disabled]': 'collapsible.disabled() ? "" : null',
25
- '[attr.aria-hidden]': '!collapsible.isOpen()',
25
+ '[attr.id]': 'collapsible.contentId',
26
+ '[attr.inert]': '!collapsible.isOpen() || null',
26
27
  },
27
28
  styles: [
28
29
  `
@@ -4,6 +4,7 @@ export interface CollapsibleContext {
4
4
  isOpen: () => boolean;
5
5
  toggle: () => void;
6
6
  disabled: () => boolean;
7
+ contentId: string;
7
8
  }
8
9
 
9
10
  export const COLLAPSIBLE_CONTEXT = new InjectionToken<CollapsibleContext>('CollapsibleContext');
@@ -17,6 +17,7 @@ import { COLLAPSIBLE_CONTEXT } from './collapsible-context';
17
17
  '[attr.data-state]': 'collapsible.isOpen() ? "open" : "closed"',
18
18
  '[attr.data-disabled]': 'collapsible.disabled() ? "" : null',
19
19
  '[attr.aria-expanded]': 'collapsible.isOpen()',
20
+ '[attr.aria-controls]': 'collapsible.contentId',
20
21
  '[attr.disabled]': 'collapsible.disabled() ? true : null',
21
22
  '(click)': 'onClick()',
22
23
  '(keydown.enter)': 'onClick()',
@@ -77,6 +77,9 @@ export class Collapsible implements CollapsibleContext {
77
77
 
78
78
  protected readonly computedClass = computed(() => cn('', this.class()));
79
79
 
80
+ /** Stable ID linking the trigger (aria-controls) to the content (id) */
81
+ readonly contentId = `collapsible-content-${Math.random().toString(36).slice(2)}`;
82
+
80
83
  /** Internal state for open/closed */
81
84
  private readonly _isOpen = signal<boolean>(false);
82
85
 
@@ -7,8 +7,10 @@ import {
7
7
  effect,
8
8
  ElementRef,
9
9
  inject,
10
+ Injector,
10
11
  input,
11
12
  OnDestroy,
13
+ signal,
12
14
  } from '@angular/core';
13
15
  import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
14
16
 
@@ -26,8 +28,8 @@ import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
26
28
  [class]="computedClass()"
27
29
  [attr.data-state]="context.open() ? 'open' : 'closed'"
28
30
  [style.position]="'fixed'"
29
- [style.left.px]="context.position().x"
30
- [style.top.px]="context.position().y"
31
+ [style.left.px]="displayPosition().x"
32
+ [style.top.px]="displayPosition().y"
31
33
  role="menu"
32
34
  aria-orientation="vertical"
33
35
  tabindex="-1"
@@ -40,7 +42,7 @@ import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
40
42
  host: {
41
43
  'attr.data-slot': '"context-menu-content"',
42
44
  class: 'contents',
43
- '(document:click)': 'onDocumentClick()',
45
+ '(document:click)': 'onDocumentClick($event)',
44
46
  '(document:keydown.escape)': 'onEscapeKey()',
45
47
  '(document:contextmenu)': 'onAnotherContextMenu()',
46
48
  },
@@ -48,19 +50,28 @@ import { CONTEXT_MENU_CONTEXT } from './context-menu-context';
48
50
  })
49
51
  export class ContextMenuContent implements OnDestroy {
50
52
  constructor() {
51
- // Focus first item when menu opens
53
+ // Clamp position and focus first item when menu opens
52
54
  effect(() => {
53
55
  if (this.context.open()) {
54
- setTimeout(() => {
55
- this.updateMenuItems();
56
- const focusedIdx = this.context.focusedIndex();
57
- if (focusedIdx >= 0 && this.menuItems[focusedIdx]) {
58
- this.menuItems[focusedIdx].focus();
59
- } else if (this.menuItems.length > 0) {
60
- this.menuItems[0].focus();
61
- this.context.focusedIndex.set(0);
62
- }
63
- }, 0);
56
+ this.isPositioned.set(false);
57
+ afterNextRender(
58
+ () => {
59
+ this.clampPosition();
60
+ this.isPositioned.set(true);
61
+ this.updateMenuItems();
62
+ const focusedIdx = this.context.focusedIndex();
63
+ if (focusedIdx >= 0 && this.menuItems[focusedIdx]) {
64
+ this.menuItems[focusedIdx].focus();
65
+ } else if (this.menuItems.length > 0) {
66
+ this.menuItems[0].focus();
67
+ this.context.focusedIndex.set(0);
68
+ }
69
+ },
70
+ { injector: this._injector },
71
+ );
72
+ } else {
73
+ this.isPositioned.set(false);
74
+ this.clampedPos.set(null);
64
75
  }
65
76
  });
66
77
 
@@ -74,15 +85,21 @@ export class ContextMenuContent implements OnDestroy {
74
85
  readonly class = input<string>('');
75
86
 
76
87
  private readonly _elementRef = inject(ElementRef);
88
+ private readonly _injector = inject(Injector);
77
89
 
78
90
  protected readonly context = inject(CONTEXT_MENU_CONTEXT);
79
91
 
92
+ protected readonly isPositioned = signal(false);
93
+ protected readonly clampedPos = signal<{ x: number; y: number } | null>(null);
94
+ protected readonly displayPosition = computed(() => this.clampedPos() ?? this.context.position());
95
+
80
96
  protected readonly computedClass = computed(() =>
81
97
  cn(
82
98
  'z-50 min-w-[12rem] overflow-hidden rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg',
83
99
  'data-[state=open]:animate-in data-[state=closed]:animate-out',
84
100
  'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
85
101
  'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
102
+ !this.isPositioned() && 'pointer-events-none opacity-0',
86
103
  this.class(),
87
104
  ),
88
105
  );
@@ -97,12 +114,22 @@ export class ContextMenuContent implements OnDestroy {
97
114
  }
98
115
  }
99
116
 
117
+ private clampPosition(): void {
118
+ const menu = this._elementRef.nativeElement.querySelector('[role="menu"]') as HTMLElement;
119
+ if (!menu) return;
120
+ const pos = this.context.position();
121
+ const rect = menu.getBoundingClientRect();
122
+ const padding = 8;
123
+ const x = Math.max(padding, Math.min(pos.x, window.innerWidth - rect.width - padding));
124
+ const y = Math.max(padding, Math.min(pos.y, window.innerHeight - rect.height - padding));
125
+ this.clampedPos.set({ x, y });
126
+ }
100
127
  private updateMenuItems(): void {
101
128
  const content = this._elementRef.nativeElement.querySelector('[role="menu"]');
102
129
  if (content) {
103
130
  this.menuItems = Array.from(
104
131
  content.querySelectorAll(
105
- '[role="menuitem"]:not([aria-disabled="true"]):not([data-disabled])',
132
+ ':is([role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]):not([aria-disabled="true"]):not([data-disabled=""])',
106
133
  ),
107
134
  );
108
135
  }
@@ -194,8 +221,12 @@ export class ContextMenuContent implements OnDestroy {
194
221
  triggerEl.focus();
195
222
  }
196
223
  }
197
- protected onDocumentClick(): void {
198
- this.close();
224
+ protected onDocumentClick(event: MouseEvent): void {
225
+ const target = event.target as HTMLElement;
226
+ const host = this._elementRef.nativeElement;
227
+ if (!host.contains(target)) {
228
+ this.close();
229
+ }
199
230
  }
200
231
  protected onEscapeKey(): void {
201
232
  this.close();
@@ -43,9 +43,11 @@ export class ContextMenuSubContent {
43
43
  );
44
44
 
45
45
  protected onMouseEnter(): void {
46
+ this.subContext.isMouseInSubContent.set(true);
46
47
  this.subContext.open.set(true);
47
48
  }
48
49
  protected onMouseLeave(): void {
50
+ this.subContext.isMouseInSubContent.set(false);
49
51
  this.subContext.open.set(false);
50
52
  }
51
53
  }
@@ -19,7 +19,12 @@ import { CONTEXT_MENU_SUB_CONTEXT } from './context-menu-sub.component';
19
19
  '[class]': 'computedClass()',
20
20
  '(mouseenter)': 'onMouseEnter()',
21
21
  '(mouseleave)': 'onMouseLeave()',
22
+ '(keydown)': 'onKeyDown($event)',
22
23
  '[attr.data-state]': 'subContext.open() ? "open" : "closed"',
24
+ 'role': 'menuitem',
25
+ '[attr.aria-haspopup]': '"menu"',
26
+ '[attr.aria-expanded]': 'subContext.open()',
27
+ '[attr.tabindex]': '"-1"',
23
28
  },
24
29
  changeDetection: ChangeDetectionStrategy.OnPush,
25
30
  })
@@ -46,6 +51,30 @@ export class ContextMenuSubTrigger {
46
51
  this.subContext.open.set(true);
47
52
  }
48
53
  protected onMouseLeave(): void {
49
- // Keep open to allow mouse movement to subcontent
54
+ setTimeout(() => {
55
+ if (!this.subContext.isMouseInSubContent()) {
56
+ this.subContext.open.set(false);
57
+ }
58
+ }, 100);
59
+ }
60
+ protected onKeyDown(event: KeyboardEvent): void {
61
+ if (event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') {
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ this.subContext.open.set(true);
65
+ // Focus first focusable item in sub-content after it renders
66
+ setTimeout(() => {
67
+ const subContent = (event.target as HTMLElement)
68
+ .closest('ContextMenuSub')
69
+ ?.querySelector<HTMLElement>('[role="menu"] [role="menuitem"]:not([aria-disabled="true"]):not([data-disabled=""])');
70
+ if (subContent) {
71
+ subContent.focus();
72
+ }
73
+ }, 10);
74
+ }
75
+ if (event.key === 'ArrowLeft') {
76
+ event.preventDefault();
77
+ this.subContext.open.set(false);
78
+ }
50
79
  }
51
80
  }
@@ -8,6 +8,8 @@ import {
8
8
 
9
9
  export interface ContextMenuSubContext {
10
10
  open: WritableSignal<boolean>;
11
+ /** True while the mouse is hovering over the sub-content panel */
12
+ isMouseInSubContent: WritableSignal<boolean>;
11
13
  }
12
14
 
13
15
  export const CONTEXT_MENU_SUB_CONTEXT = new InjectionToken<ContextMenuSubContext>(
@@ -26,6 +28,7 @@ export const CONTEXT_MENU_SUB_CONTEXT = new InjectionToken<ContextMenuSubContext
26
28
  provide: CONTEXT_MENU_SUB_CONTEXT,
27
29
  useFactory: (): ContextMenuSubContext => ({
28
30
  open: signal(false),
31
+ isMouseInSubContent: signal(false),
29
32
  }),
30
33
  },
31
34
  ],
@@ -44,6 +44,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
44
44
  </Popover>
45
45
  `,
46
46
  host: {
47
+ 'attr.data-slot': '"date-picker"',
47
48
  class: 'contents',
48
49
  },
49
50
  changeDetection: ChangeDetectionStrategy.OnPush,