@spartan-ng/cli 0.0.1-alpha.720 → 1.0.0

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 (69) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -2
  3. package/src/generators/base/generator.js +1 -2
  4. package/src/generators/base/generator.js.map +1 -1
  5. package/src/generators/base/lib/initialize-angular-library.d.ts +1 -1
  6. package/src/generators/base/lib/initialize-angular-library.js.map +1 -1
  7. package/src/generators/base/lib/styles/transform-style-map.d.ts +2 -0
  8. package/src/generators/base/lib/styles/transform-style-map.js +5 -4
  9. package/src/generators/base/lib/styles/transform-style-map.js.map +1 -1
  10. package/src/generators/base/schema.d.ts +2 -0
  11. package/src/generators/migrate-helm-libraries/generator.js +2 -2
  12. package/src/generators/migrate-helm-libraries/generator.js.map +1 -1
  13. package/src/generators/migrate-helm-libraries/schema.d.ts +2 -0
  14. package/src/generators/migrate-hlm/generator.js +1 -1
  15. package/src/generators/migrate-hlm/generator.js.map +1 -1
  16. package/src/generators/ui/generator.d.ts +2 -0
  17. package/src/generators/ui/generator.js +7 -4
  18. package/src/generators/ui/generator.js.map +1 -1
  19. package/src/generators/ui/libs/autocomplete/files/lib/hlm-autocomplete-input.ts.template +37 -33
  20. package/src/generators/ui/libs/breadcrumb/files/lib/hlm-breadcrumb-ellipsis.ts.template +11 -17
  21. package/src/generators/ui/libs/calendar/files/lib/hlm-calendar-multi.ts.template +1 -1
  22. package/src/generators/ui/libs/calendar/files/lib/hlm-calendar-range.ts.template +1 -1
  23. package/src/generators/ui/libs/calendar/files/lib/hlm-calendar.ts.template +1 -1
  24. package/src/generators/ui/libs/combobox/files/lib/hlm-combobox-input.ts.template +47 -42
  25. package/src/generators/ui/libs/context-menu/files/lib/hlm-context-menu-trigger.ts.template +8 -14
  26. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker-anchor.ts.template +4 -2
  27. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker-input.ts.template +49 -37
  28. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker-multi.ts.template +6 -6
  29. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker-trigger.ts.template +1 -1
  30. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker.ts.template +6 -6
  31. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-range-picker.ts.template +9 -6
  32. package/src/generators/ui/libs/dropdown-menu/files/index.ts.template +1 -0
  33. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-checkbox.ts.template +2 -0
  34. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-focus-on-hover.ts.template +35 -0
  35. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-item.ts.template +2 -0
  36. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-radio.ts.template +2 -0
  37. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-sub-trigger.ts.template +8 -11
  38. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-sub.ts.template +11 -25
  39. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu-trigger.ts.template +7 -14
  40. package/src/generators/ui/libs/dropdown-menu/files/lib/hlm-dropdown-menu.ts.template +11 -25
  41. package/src/generators/ui/libs/input-otp/files/lib/hlm-input-otp-fake-caret.ts.template +7 -4
  42. package/src/generators/ui/libs/menubar/files/lib/hlm-menubar-trigger.ts.template +8 -14
  43. package/src/generators/ui/libs/pagination/files/lib/hlm-numbered-pagination-query-params.ts.template +65 -62
  44. package/src/generators/ui/libs/popover/files/lib/hlm-popover-content.ts.template +1 -4
  45. package/src/generators/ui/libs/sheet/files/lib/hlm-sheet-overlay.ts.template +1 -6
  46. package/src/generators/ui/libs/sidebar/files/lib/hlm-sidebar.service.ts.template +1 -1
  47. package/src/generators/ui/libs/tooltip/files/lib/hlm-tooltip.ts.template +1 -1
  48. package/src/generators/ui/style-luma.css +9 -9
  49. package/src/generators/ui/style-lyra.css +7 -7
  50. package/src/generators/ui/style-maia.css +9 -9
  51. package/src/generators/ui/style-mira.css +9 -9
  52. package/src/generators/ui/style-nova.css +40 -10
  53. package/src/generators/ui/style-vega.css +457 -311
  54. package/src/generators/ui/supported-ui-libraries.json +44 -46
  55. package/src/index.d.ts +2 -2
  56. package/src/index.js +4 -3
  57. package/src/index.js.map +1 -1
  58. package/src/utils/config.d.ts +9 -2
  59. package/src/utils/config.js +28 -4
  60. package/src/utils/config.js.map +1 -1
  61. package/src/utils/supported-styles.d.ts +2 -0
  62. package/src/utils/supported-styles.js +7 -0
  63. package/src/utils/supported-styles.js.map +1 -0
  64. package/src/registry/index.d.ts +0 -1
  65. package/src/registry/index.js +0 -5
  66. package/src/registry/index.js.map +0 -1
  67. package/src/registry/schema.d.ts +0 -2180
  68. package/src/registry/schema.js +0 -175
  69. package/src/registry/schema.js.map +0 -1
@@ -2,58 +2,59 @@ import { BooleanInput } from '@angular/cdk/coercion';
2
2
  import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
3
3
  import { NgIcon, provideIcons } from '@ng-icons/core';
4
4
  import { lucideChevronDown, lucideX } from '@ng-icons/lucide';
5
- import { BrnComboboxImports, BrnComboboxPopoverTrigger } from '@spartan-ng/brain/combobox';
6
- import { HlmInputGroupImports } from '<%- importAlias %>/input-group';
5
+ import { BrnComboboxAnchor, BrnComboboxImports, BrnComboboxPopoverTrigger } from '@spartan-ng/brain/combobox';
6
+ import { HlmInputGroup, HlmInputGroupImports } from '<%- importAlias %>/input-group';
7
+ import { classes } from '<%- importAlias %>/utils';
7
8
 
8
9
  @Component({
9
10
  selector: 'hlm-combobox-input',
10
11
  imports: [HlmInputGroupImports, NgIcon, BrnComboboxImports, BrnComboboxPopoverTrigger],
11
12
  providers: [provideIcons({ lucideChevronDown, lucideX })],
12
13
  changeDetection: ChangeDetectionStrategy.OnPush,
14
+ hostDirectives: [BrnComboboxAnchor, HlmInputGroup],
13
15
  template: `
14
- <hlm-input-group brnComboboxAnchor class="w-auto">
15
- <input
16
- brnComboboxInput
17
- #comboboxInput="brnComboboxInput"
18
- brnComboboxPopoverTrigger
19
- hlmInputGroupInput
20
- [id]="inputId()"
21
- [placeholder]="placeholder()"
22
- [forceInvalid]="forceInvalid()"
23
- [aria-invalid]="ariaInvalidOverride()"
24
- />
16
+ <input
17
+ brnComboboxInput
18
+ #comboboxInput="brnComboboxInput"
19
+ brnComboboxPopoverTrigger
20
+ [closeOnTriggerClick]="false"
21
+ hlmInputGroupInput
22
+ [id]="inputId()"
23
+ [placeholder]="placeholder()"
24
+ [forceInvalid]="forceInvalid()"
25
+ [aria-invalid]="ariaInvalidOverride()"
26
+ />
25
27
 
26
- <hlm-input-group-addon align="inline-end">
27
- @if (showTrigger()) {
28
- <button
29
- brnComboboxPopoverTrigger
30
- hlmInputGroupButton
31
- data-slot="input-group-button"
32
- [disabled]="comboboxInput.disabled()"
33
- size="icon-xs"
34
- variant="ghost"
35
- class="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
36
- >
37
- <ng-icon name="lucideChevronDown" />
38
- </button>
39
- }
28
+ <hlm-input-group-addon align="inline-end">
29
+ @if (showTrigger()) {
30
+ <button
31
+ brnComboboxPopoverTrigger
32
+ hlmInputGroupButton
33
+ data-slot="input-group-button"
34
+ [disabled]="comboboxInput.disabled()"
35
+ size="icon-xs"
36
+ variant="ghost"
37
+ class="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
38
+ >
39
+ <ng-icon name="lucideChevronDown" />
40
+ </button>
41
+ }
40
42
 
41
- @if (showClear()) {
42
- <button
43
- *brnComboboxClear
44
- hlmInputGroupButton
45
- data-slot="combobox-clear"
46
- [disabled]="comboboxInput.disabled()"
47
- size="icon-xs"
48
- variant="ghost"
49
- >
50
- <ng-icon name="lucideX" />
51
- </button>
52
- }
53
- </hlm-input-group-addon>
43
+ @if (showClear()) {
44
+ <button
45
+ *brnComboboxClear
46
+ hlmInputGroupButton
47
+ data-slot="combobox-clear"
48
+ [disabled]="comboboxInput.disabled()"
49
+ size="icon-xs"
50
+ variant="ghost"
51
+ >
52
+ <ng-icon name="lucideX" />
53
+ </button>
54
+ }
55
+ </hlm-input-group-addon>
54
56
 
55
- <ng-content />
56
- </hlm-input-group>
57
+ <ng-content />
57
58
  `,
58
59
  })
59
60
  export class HlmComboboxInput {
@@ -71,4 +72,8 @@ export class HlmComboboxInput {
71
72
  transform: (v: BooleanInput) => (v === '' || v === undefined ? undefined : booleanAttribute(v)),
72
73
  alias: 'aria-invalid',
73
74
  });
75
+
76
+ constructor() {
77
+ classes(() => 'w-auto');
78
+ }
74
79
  }
@@ -1,13 +1,13 @@
1
1
  import { type BooleanInput } from '@angular/cdk/coercion';
2
2
  import { CdkContextMenuTrigger } from '@angular/cdk/menu';
3
- import { booleanAttribute, computed, Directive, effect, inject, input } from '@angular/core';
4
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
5
- import { createMenuPosition, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
3
+ import { booleanAttribute, computed, Directive, effect, forwardRef, inject, input } from '@angular/core';
4
+ import { createMenuPosition, MENU_SIDE, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
6
5
  import { classes } from '<%- importAlias %>/utils';
7
6
  import { injectHlmContextMenuConfig } from './hlm-context-menu-token';
8
7
 
9
8
  @Directive({
10
9
  selector: '[hlmContextMenuTrigger]',
10
+ providers: [{ provide: MENU_SIDE, useExisting: forwardRef(() => HlmContextMenuTrigger) }],
11
11
  hostDirectives: [
12
12
  {
13
13
  directive: CdkContextMenuTrigger,
@@ -36,17 +36,11 @@ export class HlmContextMenuTrigger {
36
36
  private readonly _menuPosition = computed(() => createMenuPosition(this.align(), this.side()));
37
37
 
38
38
  constructor() {
39
- // once the trigger opens we wait until the next tick and then grab the last position
40
- // used to position the menu. we store this in our trigger which the brnMenu directive has
41
- // access to through DI
42
- this._cdkTrigger.opened.pipe(takeUntilDestroyed()).subscribe(() =>
43
- setTimeout(
44
- () =>
45
- // eslint-disable-next-line
46
- ((this._cdkTrigger as any)._spartanLastPosition = // eslint-disable-next-line
47
- (this._cdkTrigger as any).overlayRef._positionStrategy._lastPosition),
48
- ),
49
- );
39
+ // CDK sets transform-origin on the menu content (a shared hlm-dropdown-menu) from the resolved
40
+ // position; the content reads it to animate from the anchored corner and to derive its data-side.
41
+ // Cast tolerates @angular/cdk < 21.2 (we still support >=21.0), where the property is absent and
42
+ // the assignment is a harmless no-op.
43
+ (this._cdkTrigger as { transformOriginSelector?: string }).transformOriginSelector = '[data-slot="dropdown-menu"]';
50
44
 
51
45
  effect(() => {
52
46
  this._cdkTrigger.menuPosition = this._menuPosition();
@@ -1,4 +1,4 @@
1
- import { Directive, effect, ElementRef, inject, input } from '@angular/core';
1
+ import { Directive, effect, ElementRef, inject, input, linkedSignal } from '@angular/core';
2
2
  import { BrnOverlay } from '@spartan-ng/brain/overlay';
3
3
  import { BrnPopover } from '@spartan-ng/brain/popover';
4
4
 
@@ -7,10 +7,12 @@ export class HlmDatePickerAnchor {
7
7
  private readonly _host = inject(ElementRef, { host: true });
8
8
  private readonly _brnOverlay = inject(BrnOverlay, { optional: true });
9
9
 
10
- public readonly hlmDatePickerAnchorFor = input<BrnPopover | undefined>(undefined, {
10
+ public readonly hlmDatePickerAnchorForInput = input<BrnPopover | undefined>(undefined, {
11
11
  alias: 'hlmDatePickerAnchorFor',
12
12
  });
13
13
 
14
+ public readonly hlmDatePickerAnchorFor = linkedSignal(this.hlmDatePickerAnchorForInput);
15
+
14
16
  constructor() {
15
17
  effect(() => {
16
18
  this.hlmDatePickerAnchorFor()?.setOrigin(this._host.nativeElement);
@@ -1,61 +1,69 @@
1
1
  import { BooleanInput } from '@angular/cdk/coercion';
2
- import { booleanAttribute, ChangeDetectionStrategy, Component, computed, input, linkedSignal } from '@angular/core';
2
+ import {
3
+ booleanAttribute,
4
+ ChangeDetectionStrategy,
5
+ Component,
6
+ computed,
7
+ effect,
8
+ ElementRef,
9
+ inject,
10
+ input,
11
+ linkedSignal,
12
+ } from '@angular/core';
3
13
  import { NgIcon, provideIcons } from '@ng-icons/core';
4
14
  import { lucideCalendar, lucideX } from '@ng-icons/lucide';
5
- import { HlmButtonImports } from '<%- importAlias %>/button';
6
- import { HlmInputGroupImports } from '<%- importAlias %>/input-group';
7
- import { HlmDatePickerAnchor } from './hlm-date-picker-anchor';
15
+ import { HlmInputGroup, HlmInputGroupImports } from '<%- importAlias %>/input-group';
8
16
  import { HlmDatePickerTriggerBase, provideHlmDatePickerTrigger } from './hlm-date-picker-trigger.token';
9
17
  import { injectHlmDatePicker, injectHlmDatePickerConfig } from './hlm-date-picker.token';
10
18
 
11
19
  @Component({
12
20
  selector: 'hlm-date-picker-input',
13
- imports: [HlmInputGroupImports, HlmButtonImports, HlmDatePickerAnchor, NgIcon],
21
+ imports: [HlmInputGroupImports, NgIcon],
14
22
  providers: [provideIcons({ lucideCalendar, lucideX }), provideHlmDatePickerTrigger(HlmDatePickerInput)],
15
23
  changeDetection: ChangeDetectionStrategy.OnPush,
24
+ hostDirectives: [HlmInputGroup],
16
25
  template: `
17
- <hlm-input-group hlmDatePickerAnchor [hlmDatePickerAnchorFor]="_popover()">
18
- <input
19
- hlmInputGroupInput
20
- [value]="_inputValue()"
21
- [id]="inputId()"
22
- [placeholder]="placeholder()"
23
- [disabled]="_disabled()"
24
- [forceInvalid]="forceInvalid()"
25
- (click)="_handleClick()"
26
- (keydown.arrowDown)="_open()"
27
- (keydown.enter)="_handleEnter($event)"
28
- (input)="_handleInputChange($event)"
29
- (blur)="_commitDate()"
30
- />
31
- <hlm-input-group-addon align="inline-end">
32
- @if (_showClearButton()) {
33
- <button
34
- hlmInputGroupButton
35
- size="icon-xs"
36
- variant="ghost"
37
- [attr.aria-label]="clearAriaLabel()"
38
- (click)="_clear()"
39
- [disabled]="_disabled()"
40
- >
41
- <ng-icon name="lucideX" />
42
- </button>
43
- }
26
+ <input
27
+ hlmInputGroupInput
28
+ [value]="_inputValue()"
29
+ [id]="inputId()"
30
+ [placeholder]="placeholder()"
31
+ [disabled]="_disabled()"
32
+ [forceInvalid]="forceInvalid()"
33
+ (click)="_handleClick()"
34
+ (keydown.arrowDown)="_open()"
35
+ (keydown.enter)="_handleEnter($event)"
36
+ (input)="_handleInputChange($event)"
37
+ (blur)="_commitDate()"
38
+ />
39
+ <hlm-input-group-addon align="inline-end">
40
+ @if (_showClearButton()) {
44
41
  <button
45
42
  hlmInputGroupButton
46
43
  size="icon-xs"
47
- [attr.aria-label]="calendarAriaLabel()"
48
- (click)="_popover().open()"
44
+ variant="ghost"
45
+ [attr.aria-label]="clearAriaLabel()"
46
+ (click)="_clear()"
49
47
  [disabled]="_disabled()"
50
48
  >
51
- <ng-icon name="lucideCalendar" />
49
+ <ng-icon name="lucideX" />
52
50
  </button>
53
- </hlm-input-group-addon>
54
- </hlm-input-group>
51
+ }
52
+ <button
53
+ hlmInputGroupButton
54
+ size="icon-xs"
55
+ [attr.aria-label]="calendarAriaLabel()"
56
+ (click)="_popover().open()"
57
+ [disabled]="_disabled()"
58
+ >
59
+ <ng-icon name="lucideCalendar" />
60
+ </button>
61
+ </hlm-input-group-addon>
55
62
  `,
56
63
  })
57
64
  export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
58
65
  private static _nextId = 0;
66
+ private readonly _host = inject(ElementRef);
59
67
  private readonly _datePicker = injectHlmDatePicker<T>();
60
68
  private readonly _config = injectHlmDatePickerConfig<T>();
61
69
 
@@ -129,6 +137,10 @@ export class HlmDatePickerInput<T> implements HlmDatePickerTriggerBase {
129
137
  },
130
138
  });
131
139
 
140
+ constructor() {
141
+ effect(() => this._popover()?.setOrigin(this._host.nativeElement));
142
+ }
143
+
132
144
  protected _handleInputChange(event: Event) {
133
145
  const text = (event.target as HTMLInputElement).value;
134
146
  this._inputValue.set(text);
@@ -42,12 +42,7 @@ export const HLM_DATE_PICKER_MUTLI_VALUE_ACCESSOR = {
42
42
  hostDirectives: [BrnFieldControl],
43
43
  host: { class: 'block' },
44
44
  template: `
45
- <hlm-popover
46
- sideOffset="5"
47
- [state]="_popoverState()"
48
- (stateChanged)="_popoverState.set($event)"
49
- (closed)="_onTouched?.()"
50
- >
45
+ <hlm-popover sideOffset="5" [state]="_popoverState()" (stateChanged)="_onStateChange($event)">
51
46
  <ng-content />
52
47
 
53
48
  <hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
@@ -136,6 +131,11 @@ export class HlmDatePickerMulti<T> implements HlmDatePickerBase<T>, ControlValue
136
131
  protected _onChange?: ChangeFn<T[]>;
137
132
  protected _onTouched?: TouchFn;
138
133
 
134
+ protected _onStateChange(state: BrnOverlayState) {
135
+ this._popoverState.set(state);
136
+ if (state === 'closed') this._onTouched?.();
137
+ }
138
+
139
139
  protected _handleChange(value: T[] | undefined) {
140
140
  if (value === undefined) return;
141
141
 
@@ -63,7 +63,7 @@ export class HlmDatePickerTrigger implements HlmDatePickerTriggerBase {
63
63
 
64
64
  public readonly userClass = input<ClassValue>('', { alias: 'class' });
65
65
  protected readonly _computedClass = computed(() =>
66
- hlm('spartan-date-picker-trigger w-64 justify-between', this.userClass()),
66
+ hlm('spartan-date-picker-trigger justify-between', this.userClass()),
67
67
  );
68
68
 
69
69
  protected readonly _isPlaceholder = computed(() => !this._datePicker.hasDate());
@@ -36,12 +36,7 @@ export const HLM_DATE_PICKER_VALUE_ACCESSOR = {
36
36
  hostDirectives: [BrnFieldControl],
37
37
  host: { class: 'block' },
38
38
  template: `
39
- <hlm-popover
40
- sideOffset="5"
41
- [state]="_popoverState()"
42
- (stateChanged)="_popoverState.set($event)"
43
- (closed)="_onTouched?.()"
44
- >
39
+ <hlm-popover sideOffset="5" [state]="_popoverState()" (stateChanged)="_onStateChange($event)">
45
40
  <ng-content />
46
41
 
47
42
  <hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
@@ -122,6 +117,11 @@ export class HlmDatePicker<T> implements HlmDatePickerBase<T>, ControlValueAcces
122
117
  protected _onChange?: ChangeFn<T>;
123
118
  protected _onTouched?: TouchFn;
124
119
 
120
+ protected _onStateChange(state: BrnOverlayState) {
121
+ this._popoverState.set(state);
122
+ if (state === 'closed') this._onTouched?.();
123
+ }
124
+
125
125
  protected _handleChange(value: T | undefined) {
126
126
  if (this._disabled()) return;
127
127
  this.updateDate(value);
@@ -42,12 +42,7 @@ export const HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR = {
42
42
  hostDirectives: [BrnFieldControl],
43
43
  host: { class: 'block' },
44
44
  template: `
45
- <hlm-popover
46
- sideOffset="5"
47
- [state]="_popoverState()"
48
- (stateChanged)="_popoverState.set($event)"
49
- (closed)="_onClose(); _onTouched?.()"
50
- >
45
+ <hlm-popover sideOffset="5" [state]="_popoverState()" (stateChanged)="_onStateChange($event)">
51
46
  <ng-content />
52
47
 
53
48
  <hlm-popover-content class="w-fit p-0" *hlmPopoverPortal="let ctx">
@@ -130,6 +125,14 @@ export class HlmDateRangePicker<T> implements HlmDatePickerBase<T>, ControlValue
130
125
  protected _onChange?: ChangeFn<[T, T] | null>;
131
126
  protected _onTouched?: TouchFn;
132
127
 
128
+ protected _onStateChange(state: BrnOverlayState) {
129
+ this._popoverState.set(state);
130
+ if (state === 'closed') {
131
+ this._onClose();
132
+ this._onTouched?.();
133
+ }
134
+ }
135
+
133
136
  protected _handleStartDayChange(value: T | undefined) {
134
137
  this._start.set(value);
135
138
  }
@@ -17,6 +17,7 @@ import { HlmDropdownMenuTrigger } from './lib/hlm-dropdown-menu-trigger';
17
17
  export * from './lib/hlm-dropdown-menu';
18
18
  export * from './lib/hlm-dropdown-menu-checkbox';
19
19
  export * from './lib/hlm-dropdown-menu-checkbox-indicator';
20
+ export * from './lib/hlm-dropdown-menu-focus-on-hover';
20
21
  export * from './lib/hlm-dropdown-menu-group';
21
22
  export * from './lib/hlm-dropdown-menu-item';
22
23
  export * from './lib/hlm-dropdown-menu-item-sub-indicator';
@@ -2,6 +2,7 @@ import { type BooleanInput } from '@angular/cdk/coercion';
2
2
  import { CdkMenuItem, CdkMenuItemCheckbox, CdkMenuItemSelectable } from '@angular/cdk/menu';
3
3
  import { Directive, booleanAttribute, inject, input } from '@angular/core';
4
4
  import { classes } from '<%- importAlias %>/utils';
5
+ import { HlmDropdownMenuFocusOnHover } from './hlm-dropdown-menu-focus-on-hover';
5
6
 
6
7
  /** @internal. Use HlmDropdownMenuCheckbox instead. */
7
8
  @Directive({
@@ -28,6 +29,7 @@ export class HlmDropdownMenuCheckboxCdk extends CdkMenuItemCheckbox {
28
29
  inputs: ['cdkMenuItemDisabled: disabled', 'cdkMenuItemChecked: checked', 'keepOpen'],
29
30
  outputs: ['cdkMenuItemTriggered: triggered'],
30
31
  },
32
+ HlmDropdownMenuFocusOnHover,
31
33
  ],
32
34
  host: {
33
35
  'data-slot': 'dropdown-menu-checkbox-item',
@@ -0,0 +1,35 @@
1
+ import { InputModalityDetector } from '@angular/cdk/a11y';
2
+ import { CdkMenu, CdkMenuItem } from '@angular/cdk/menu';
3
+ import { Directive, inject } from '@angular/core';
4
+
5
+ /**
6
+ * @internal
7
+ * Moves DOM focus to the hovered menu item. CDK menus only move focus with the keyboard, so on a pointer
8
+ * the highlight would otherwise stay on the last keyboard-focused item (leaving two rows highlighted) and,
9
+ * when a submenu closes, focus would fall to <body>, the menu stack would report no focus, and the whole
10
+ * dropdown would collapse. Following the pointer with focus (Radix/shadcn behaviour) keeps a single
11
+ * highlight and keeps focus inside the menu stack. setActiveMenuItem also syncs the key manager so keyboard
12
+ * navigation continues from the hovered item.
13
+ *
14
+ * Applied as a host directive on every dropdown item type (item, checkbox, radio, sub-trigger).
15
+ */
16
+ @Directive({
17
+ selector: '[hlmDropdownMenuFocusOnHover]',
18
+ host: {
19
+ '(mouseenter)': '_focusOnHover()',
20
+ },
21
+ })
22
+ export class HlmDropdownMenuFocusOnHover {
23
+ private readonly _cdkMenuItem = inject(CdkMenuItem, { self: true });
24
+ private readonly _parentMenu = inject(CdkMenu, { optional: true });
25
+ private readonly _inputModality = inject(InputModalityDetector);
26
+
27
+ protected _focusOnHover(): void {
28
+ // Only skip synthetic hovers from touch taps; every real hover (mouse, or keyboard-then-hover,
29
+ // which leaves the modality as 'keyboard') should move focus to keep a single highlight.
30
+ if (this._inputModality.mostRecentModality === 'touch' || this._cdkMenuItem.disabled) {
31
+ return;
32
+ }
33
+ this._parentMenu?.setActiveMenuItem(this._cdkMenuItem);
34
+ }
35
+ }
@@ -2,6 +2,7 @@ import { type BooleanInput } from '@angular/cdk/coercion';
2
2
  import { CdkMenuItem } from '@angular/cdk/menu';
3
3
  import { booleanAttribute, Directive, HOST_TAG_NAME, inject, input } from '@angular/core';
4
4
  import { classes } from '<%- importAlias %>/utils';
5
+ import { HlmDropdownMenuFocusOnHover } from './hlm-dropdown-menu-focus-on-hover';
5
6
 
6
7
  @Directive({
7
8
  selector: '[hlmDropdownMenuItem],hlm-dropdown-menu-item',
@@ -11,6 +12,7 @@ import { classes } from '<%- importAlias %>/utils';
11
12
  inputs: ['cdkMenuItemDisabled: disabled'],
12
13
  outputs: ['cdkMenuItemTriggered: triggered'],
13
14
  },
15
+ HlmDropdownMenuFocusOnHover,
14
16
  ],
15
17
  host: {
16
18
  'data-slot': 'dropdown-menu-item',
@@ -2,6 +2,7 @@ import { type BooleanInput } from '@angular/cdk/coercion';
2
2
  import { CdkMenuItem, CdkMenuItemRadio, CdkMenuItemSelectable } from '@angular/cdk/menu';
3
3
  import { Directive, booleanAttribute, inject, input } from '@angular/core';
4
4
  import { classes } from '<%- importAlias %>/utils';
5
+ import { HlmDropdownMenuFocusOnHover } from './hlm-dropdown-menu-focus-on-hover';
5
6
 
6
7
  /** @internal. Use HlmDropdownMenuRadio instead. */
7
8
  @Directive({
@@ -28,6 +29,7 @@ export class HlmDropdownMenuRadioCdk extends CdkMenuItemRadio {
28
29
  inputs: ['cdkMenuItemDisabled: disabled', 'cdkMenuItemChecked: checked', 'keepOpen'],
29
30
  outputs: ['cdkMenuItemTriggered: triggered'],
30
31
  },
32
+ HlmDropdownMenuFocusOnHover,
31
33
  ],
32
34
  host: {
33
35
  'data-slot': 'dropdown-menu-radio-item',
@@ -1,12 +1,12 @@
1
1
  import { CdkMenuTrigger } from '@angular/cdk/menu';
2
- import { computed, Directive, effect, inject, input } from '@angular/core';
3
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
- import { createMenuPosition, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
2
+ import { computed, Directive, effect, forwardRef, inject, input } from '@angular/core';
3
+ import { createMenuPosition, MENU_SIDE, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
5
4
  import { classes } from '<%- importAlias %>/utils';
6
5
  import { injectHlmDropdownMenuConfig } from './hlm-dropdown-menu-token';
7
6
 
8
7
  @Directive({
9
8
  selector: '[hlmDropdownMenuSubTrigger]',
9
+ providers: [{ provide: MENU_SIDE, useExisting: forwardRef(() => HlmDropdownMenuSubTrigger) }],
10
10
  hostDirectives: [
11
11
  {
12
12
  directive: CdkMenuTrigger,
@@ -26,14 +26,11 @@ export class HlmDropdownMenuSubTrigger {
26
26
  private readonly _menuPosition = computed(() => createMenuPosition(this.align(), this.side()));
27
27
 
28
28
  constructor() {
29
- this._cdkTrigger.opened.pipe(takeUntilDestroyed()).subscribe(() =>
30
- setTimeout(
31
- () =>
32
- // eslint-disable-next-line
33
- ((this._cdkTrigger as any)._spartanLastPosition = // eslint-disable-next-line
34
- (this._cdkTrigger as any).overlayRef._positionStrategy._lastPosition),
35
- ),
36
- );
29
+ // CDK sets transform-origin on the submenu content from the resolved position; the content reads it
30
+ // to animate from the anchored corner and to derive its data-side. Cast tolerates @angular/cdk < 21.2
31
+ // (we still support >=21.0), where the property is absent and the assignment is a harmless no-op.
32
+ (this._cdkTrigger as { transformOriginSelector?: string }).transformOriginSelector =
33
+ '[data-slot="dropdown-menu-sub"]';
37
34
 
38
35
  effect(() => {
39
36
  this._cdkTrigger.menuPosition = this._menuPosition();
@@ -1,6 +1,7 @@
1
1
  import { CdkMenu } from '@angular/cdk/menu';
2
- import { Directive, inject, signal } from '@angular/core';
2
+ import { Directive, ElementRef, inject, signal } from '@angular/core';
3
3
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { deriveMenuSideFromTransformOrigin, MENU_SIDE, type MenuSide } from '@spartan-ng/brain/core';
4
5
  import { classes } from '<%- importAlias %>/utils';
5
6
 
6
7
  @Directive({
@@ -14,12 +15,15 @@ import { classes } from '<%- importAlias %>/utils';
14
15
  })
15
16
  export class HlmDropdownMenuSub {
16
17
  private readonly _host = inject(CdkMenu);
18
+ private readonly _elementRef = inject(ElementRef<HTMLElement>);
19
+ // The sub-trigger provides its configured side; CDK parents this content's injector under it.
20
+ private readonly _menuSide = inject(MENU_SIDE, { optional: true });
17
21
 
18
22
  protected readonly _state = signal('open');
19
- protected readonly _side = signal('top');
23
+ protected readonly _side = signal<MenuSide>(this._menuSide?.side() ?? 'right');
20
24
 
21
25
  constructor() {
22
- this.setSideWithDarkMagic();
26
+ this.setSideFromTransformOrigin();
23
27
  // this is a best effort, but does not seem to work currently
24
28
  // TODO: figure out a way for us to know the host is about to be closed. might not be possible with CDK
25
29
  this._host.closed.pipe(takeUntilDestroyed()).subscribe(() => this._state.set('closed'));
@@ -27,29 +31,11 @@ export class HlmDropdownMenuSub {
27
31
  classes(() => 'spartan-dropdown-menu-sub-content w-auto');
28
32
  }
29
33
 
30
- private setSideWithDarkMagic() {
31
- /**
32
- * This is an ugly workaround to at least figure out the correct side of where a submenu
33
- * will appear and set the attribute to the host accordingly
34
- *
35
- * First of all we take advantage of the menu stack not being aware of the root
36
- * object immediately after it is added. This code executes before the root element is added,
37
- * which means the stack is still empty and the peek method returns undefined.
38
- */
39
- const isRoot = this._host.menuStack.peek() === undefined;
34
+ private setSideFromTransformOrigin() {
35
+ const side = this._menuSide?.side() ?? 'right';
36
+ // CDK sets transform-origin on this element synchronously on attach; read it next tick and derive side
40
37
  setTimeout(() => {
41
- // our menu trigger directive leaves the last position used for use immediately after opening
42
- // we can access it here and determine the correct side.
43
- // eslint-disable-next-line
44
- const ps = (this._host as any)._parentTrigger._spartanLastPosition;
45
- if (!ps) {
46
- // if we have no last position we default to the most likely option
47
- // I hate that we have to do this and hope we can revisit soon and improve
48
- this._side.set(isRoot ? 'top' : 'left');
49
- return;
50
- }
51
- const side = isRoot ? ps.originY : ps.originX === 'end' ? 'right' : 'left';
52
- this._side.set(side);
38
+ this._side.set(deriveMenuSideFromTransformOrigin(this._elementRef.nativeElement.style.transformOrigin, side));
53
39
  });
54
40
  }
55
41
  }
@@ -1,11 +1,11 @@
1
1
  import { CdkMenuTrigger } from '@angular/cdk/menu';
2
- import { computed, Directive, effect, inject, input } from '@angular/core';
3
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
- import { createMenuPosition, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
2
+ import { computed, Directive, effect, forwardRef, inject, input } from '@angular/core';
3
+ import { createMenuPosition, MENU_SIDE, type MenuAlign, type MenuSide } from '@spartan-ng/brain/core';
5
4
  import { injectHlmDropdownMenuConfig } from './hlm-dropdown-menu-token';
6
5
 
7
6
  @Directive({
8
7
  selector: '[hlmDropdownMenuTrigger]',
8
+ providers: [{ provide: MENU_SIDE, useExisting: forwardRef(() => HlmDropdownMenuTrigger) }],
9
9
  hostDirectives: [
10
10
  {
11
11
  directive: CdkMenuTrigger,
@@ -25,17 +25,10 @@ export class HlmDropdownMenuTrigger {
25
25
  private readonly _menuPosition = computed(() => createMenuPosition(this.align(), this.side()));
26
26
 
27
27
  constructor() {
28
- // once the trigger opens we wait until the next tick and then grab the last position
29
- // used to position the menu. we store this in our trigger which the brnMenu directive has
30
- // access to through DI
31
- this._cdkTrigger.opened.pipe(takeUntilDestroyed()).subscribe(() =>
32
- setTimeout(
33
- () =>
34
- // eslint-disable-next-line
35
- ((this._cdkTrigger as any)._spartanLastPosition = // eslint-disable-next-line
36
- (this._cdkTrigger as any).overlayRef._positionStrategy._lastPosition),
37
- ),
38
- );
28
+ // CDK sets transform-origin on the menu content from the resolved position; the content reads it to
29
+ // animate from the anchored corner and to derive its data-side. Cast tolerates @angular/cdk < 21.2
30
+ // (we still support >=21.0), where the property is absent and the assignment is a harmless no-op.
31
+ (this._cdkTrigger as { transformOriginSelector?: string }).transformOriginSelector = '[data-slot="dropdown-menu"]';
39
32
 
40
33
  effect(() => {
41
34
  this._cdkTrigger.menuPosition = this._menuPosition();