forty-cdk 0.2.0 → 0.3.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 (220) hide show
  1. package/accordion/README.md +122 -0
  2. package/aspect-ratio/README.md +76 -0
  3. package/avatar/README.md +100 -0
  4. package/breadcrumbs/README.md +49 -0
  5. package/breakpoints/README.md +81 -0
  6. package/button/README.md +49 -0
  7. package/calendar/README.md +458 -0
  8. package/carousel/README.md +358 -0
  9. package/checkbox/README.md +146 -0
  10. package/combobox/README.md +535 -0
  11. package/context-menu/README.md +139 -0
  12. package/date-field/README.md +184 -0
  13. package/date-picker/README.md +338 -0
  14. package/dialog/README.md +388 -0
  15. package/disclosure/README.md +114 -0
  16. package/drag-drop/README.md +359 -0
  17. package/drawer/README.md +560 -0
  18. package/dropdown-menu/README.md +176 -0
  19. package/fesm2022/forty-cdk-accordion.mjs +348 -0
  20. package/fesm2022/forty-cdk-accordion.mjs.map +1 -0
  21. package/fesm2022/forty-cdk-aspect-ratio.mjs +74 -0
  22. package/fesm2022/forty-cdk-aspect-ratio.mjs.map +1 -0
  23. package/fesm2022/forty-cdk-avatar.mjs +308 -0
  24. package/fesm2022/forty-cdk-avatar.mjs.map +1 -0
  25. package/fesm2022/forty-cdk-breadcrumbs.mjs +125 -0
  26. package/fesm2022/forty-cdk-breadcrumbs.mjs.map +1 -0
  27. package/fesm2022/forty-cdk-breakpoints.mjs +117 -0
  28. package/fesm2022/forty-cdk-breakpoints.mjs.map +1 -0
  29. package/fesm2022/forty-cdk-button.mjs +134 -0
  30. package/fesm2022/forty-cdk-button.mjs.map +1 -0
  31. package/fesm2022/forty-cdk-calendar.mjs +2034 -0
  32. package/fesm2022/forty-cdk-calendar.mjs.map +1 -0
  33. package/fesm2022/forty-cdk-carousel.mjs +968 -0
  34. package/fesm2022/forty-cdk-carousel.mjs.map +1 -0
  35. package/fesm2022/forty-cdk-checkbox.mjs +226 -0
  36. package/fesm2022/forty-cdk-checkbox.mjs.map +1 -0
  37. package/fesm2022/forty-cdk-combobox.mjs +2596 -0
  38. package/fesm2022/forty-cdk-combobox.mjs.map +1 -0
  39. package/fesm2022/forty-cdk-context-menu.mjs +413 -0
  40. package/fesm2022/forty-cdk-context-menu.mjs.map +1 -0
  41. package/fesm2022/forty-cdk-core.mjs +9022 -0
  42. package/fesm2022/forty-cdk-core.mjs.map +1 -0
  43. package/fesm2022/forty-cdk-date-field.mjs +744 -0
  44. package/fesm2022/forty-cdk-date-field.mjs.map +1 -0
  45. package/fesm2022/forty-cdk-date-picker.mjs +1011 -0
  46. package/fesm2022/forty-cdk-date-picker.mjs.map +1 -0
  47. package/fesm2022/forty-cdk-dialog.mjs +707 -0
  48. package/fesm2022/forty-cdk-dialog.mjs.map +1 -0
  49. package/fesm2022/forty-cdk-disclosure.mjs +190 -0
  50. package/fesm2022/forty-cdk-disclosure.mjs.map +1 -0
  51. package/fesm2022/forty-cdk-drag-drop.mjs +1180 -0
  52. package/fesm2022/forty-cdk-drag-drop.mjs.map +1 -0
  53. package/fesm2022/forty-cdk-drawer.mjs +1641 -0
  54. package/fesm2022/forty-cdk-drawer.mjs.map +1 -0
  55. package/fesm2022/forty-cdk-dropdown-menu.mjs +350 -0
  56. package/fesm2022/forty-cdk-dropdown-menu.mjs.map +1 -0
  57. package/fesm2022/forty-cdk-field.mjs +425 -0
  58. package/fesm2022/forty-cdk-field.mjs.map +1 -0
  59. package/fesm2022/forty-cdk-fieldset.mjs +164 -0
  60. package/fesm2022/forty-cdk-fieldset.mjs.map +1 -0
  61. package/fesm2022/forty-cdk-file-upload.mjs +221 -0
  62. package/fesm2022/forty-cdk-file-upload.mjs.map +1 -0
  63. package/fesm2022/forty-cdk-hover-card.mjs +496 -0
  64. package/fesm2022/forty-cdk-hover-card.mjs.map +1 -0
  65. package/fesm2022/forty-cdk-input.mjs +274 -0
  66. package/fesm2022/forty-cdk-input.mjs.map +1 -0
  67. package/fesm2022/forty-cdk-internationalized-date.mjs +1 -1
  68. package/fesm2022/forty-cdk-internationalized-date.mjs.map +1 -1
  69. package/fesm2022/forty-cdk-listbox.mjs +1279 -0
  70. package/fesm2022/forty-cdk-listbox.mjs.map +1 -0
  71. package/fesm2022/forty-cdk-menu.mjs +1439 -0
  72. package/fesm2022/forty-cdk-menu.mjs.map +1 -0
  73. package/fesm2022/forty-cdk-menubar.mjs +787 -0
  74. package/fesm2022/forty-cdk-menubar.mjs.map +1 -0
  75. package/fesm2022/forty-cdk-meter.mjs +211 -0
  76. package/fesm2022/forty-cdk-meter.mjs.map +1 -0
  77. package/fesm2022/forty-cdk-navigation-menu.mjs +1145 -0
  78. package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -0
  79. package/fesm2022/forty-cdk-number-input.mjs +559 -0
  80. package/fesm2022/forty-cdk-number-input.mjs.map +1 -0
  81. package/fesm2022/forty-cdk-otp-input.mjs +527 -0
  82. package/fesm2022/forty-cdk-otp-input.mjs.map +1 -0
  83. package/fesm2022/forty-cdk-pagination.mjs +323 -0
  84. package/fesm2022/forty-cdk-pagination.mjs.map +1 -0
  85. package/fesm2022/forty-cdk-pane-resizer.mjs +297 -0
  86. package/fesm2022/forty-cdk-pane-resizer.mjs.map +1 -0
  87. package/fesm2022/forty-cdk-popover.mjs +698 -0
  88. package/fesm2022/forty-cdk-popover.mjs.map +1 -0
  89. package/fesm2022/forty-cdk-progress.mjs +226 -0
  90. package/fesm2022/forty-cdk-progress.mjs.map +1 -0
  91. package/fesm2022/forty-cdk-radio-group.mjs +378 -0
  92. package/fesm2022/forty-cdk-radio-group.mjs.map +1 -0
  93. package/fesm2022/forty-cdk-scroll-area.mjs +640 -0
  94. package/fesm2022/forty-cdk-scroll-area.mjs.map +1 -0
  95. package/fesm2022/forty-cdk-search.mjs +205 -0
  96. package/fesm2022/forty-cdk-search.mjs.map +1 -0
  97. package/fesm2022/forty-cdk-select.mjs +1661 -0
  98. package/fesm2022/forty-cdk-select.mjs.map +1 -0
  99. package/fesm2022/forty-cdk-separator.mjs +82 -0
  100. package/fesm2022/forty-cdk-separator.mjs.map +1 -0
  101. package/fesm2022/forty-cdk-signal-forms.mjs +97 -0
  102. package/fesm2022/forty-cdk-signal-forms.mjs.map +1 -0
  103. package/fesm2022/forty-cdk-slider.mjs +803 -0
  104. package/fesm2022/forty-cdk-slider.mjs.map +1 -0
  105. package/fesm2022/forty-cdk-stepper.mjs +886 -0
  106. package/fesm2022/forty-cdk-stepper.mjs.map +1 -0
  107. package/fesm2022/forty-cdk-switch.mjs +137 -0
  108. package/fesm2022/forty-cdk-switch.mjs.map +1 -0
  109. package/fesm2022/forty-cdk-table.mjs +1518 -0
  110. package/fesm2022/forty-cdk-table.mjs.map +1 -0
  111. package/fesm2022/forty-cdk-tabs.mjs +400 -0
  112. package/fesm2022/forty-cdk-tabs.mjs.map +1 -0
  113. package/fesm2022/forty-cdk-time-field.mjs +593 -0
  114. package/fesm2022/forty-cdk-time-field.mjs.map +1 -0
  115. package/fesm2022/forty-cdk-time-picker.mjs +1013 -0
  116. package/fesm2022/forty-cdk-time-picker.mjs.map +1 -0
  117. package/fesm2022/forty-cdk-toast.mjs +1153 -0
  118. package/fesm2022/forty-cdk-toast.mjs.map +1 -0
  119. package/fesm2022/forty-cdk-toggle.mjs +516 -0
  120. package/fesm2022/forty-cdk-toggle.mjs.map +1 -0
  121. package/fesm2022/forty-cdk-toolbar.mjs +374 -0
  122. package/fesm2022/forty-cdk-toolbar.mjs.map +1 -0
  123. package/fesm2022/forty-cdk-tooltip.mjs +672 -0
  124. package/fesm2022/forty-cdk-tooltip.mjs.map +1 -0
  125. package/fesm2022/forty-cdk-tree.mjs +2007 -0
  126. package/fesm2022/forty-cdk-tree.mjs.map +1 -0
  127. package/fesm2022/forty-cdk-virtualization.mjs +1 -1
  128. package/fesm2022/forty-cdk-virtualization.mjs.map +1 -1
  129. package/fesm2022/forty-cdk.mjs +0 -43310
  130. package/fesm2022/forty-cdk.mjs.map +1 -1
  131. package/field/README.md +97 -0
  132. package/fieldset/README.md +86 -0
  133. package/file-upload/README.md +73 -0
  134. package/hover-card/README.md +171 -0
  135. package/input/README.md +156 -0
  136. package/listbox/README.md +424 -0
  137. package/menu/README.md +181 -0
  138. package/menubar/README.md +140 -0
  139. package/meter/README.md +128 -0
  140. package/navigation-menu/README.md +253 -0
  141. package/number-input/README.md +171 -0
  142. package/otp-input/README.md +198 -0
  143. package/package.json +213 -1
  144. package/pagination/README.md +61 -0
  145. package/pane-resizer/README.md +136 -0
  146. package/popover/README.md +262 -0
  147. package/progress/README.md +115 -0
  148. package/radio-group/README.md +129 -0
  149. package/scroll-area/README.md +184 -0
  150. package/search/README.md +42 -0
  151. package/select/README.md +488 -0
  152. package/separator/README.md +84 -0
  153. package/signal-forms/README.md +72 -0
  154. package/slider/README.md +152 -0
  155. package/stepper/README.md +292 -0
  156. package/switch/README.md +116 -0
  157. package/table/README.md +769 -0
  158. package/tabs/README.md +130 -0
  159. package/time-field/README.md +157 -0
  160. package/time-picker/README.md +172 -0
  161. package/toast/README.md +398 -0
  162. package/toggle/README.md +224 -0
  163. package/toolbar/README.md +109 -0
  164. package/tooltip/README.md +274 -0
  165. package/tree/README.md +708 -0
  166. package/types/forty-cdk-accordion.d.ts +242 -0
  167. package/types/forty-cdk-aspect-ratio.d.ts +59 -0
  168. package/types/forty-cdk-avatar.d.ts +133 -0
  169. package/types/forty-cdk-breadcrumbs.d.ts +92 -0
  170. package/types/forty-cdk-breakpoints.d.ts +141 -0
  171. package/types/forty-cdk-button.d.ts +80 -0
  172. package/types/forty-cdk-calendar.d.ts +914 -0
  173. package/types/forty-cdk-carousel.d.ts +530 -0
  174. package/types/forty-cdk-checkbox.d.ts +141 -0
  175. package/types/forty-cdk-combobox.d.ts +1259 -0
  176. package/types/forty-cdk-context-menu.d.ts +313 -0
  177. package/types/forty-cdk-core.d.ts +5774 -0
  178. package/types/forty-cdk-date-field.d.ts +307 -0
  179. package/types/forty-cdk-date-picker.d.ts +622 -0
  180. package/types/forty-cdk-dialog.d.ts +546 -0
  181. package/types/forty-cdk-disclosure.d.ts +127 -0
  182. package/types/forty-cdk-drag-drop.d.ts +456 -0
  183. package/types/forty-cdk-drawer.d.ts +871 -0
  184. package/types/forty-cdk-dropdown-menu.d.ts +242 -0
  185. package/types/forty-cdk-field.d.ts +236 -0
  186. package/types/forty-cdk-fieldset.d.ts +119 -0
  187. package/types/forty-cdk-file-upload.d.ts +124 -0
  188. package/types/forty-cdk-hover-card.d.ts +320 -0
  189. package/types/forty-cdk-input.d.ts +169 -0
  190. package/types/forty-cdk-internationalized-date.d.ts +1 -1
  191. package/types/forty-cdk-listbox.d.ts +513 -0
  192. package/types/forty-cdk-menu.d.ts +629 -0
  193. package/types/forty-cdk-menubar.d.ts +451 -0
  194. package/types/forty-cdk-meter.d.ts +122 -0
  195. package/types/forty-cdk-navigation-menu.d.ts +514 -0
  196. package/types/forty-cdk-number-input.d.ts +319 -0
  197. package/types/forty-cdk-otp-input.d.ts +248 -0
  198. package/types/forty-cdk-pagination.d.ts +214 -0
  199. package/types/forty-cdk-pane-resizer.d.ts +145 -0
  200. package/types/forty-cdk-popover.d.ts +509 -0
  201. package/types/forty-cdk-progress.d.ts +143 -0
  202. package/types/forty-cdk-radio-group.d.ts +222 -0
  203. package/types/forty-cdk-scroll-area.d.ts +258 -0
  204. package/types/forty-cdk-search.d.ts +142 -0
  205. package/types/forty-cdk-select.d.ts +899 -0
  206. package/types/forty-cdk-separator.d.ts +59 -0
  207. package/types/forty-cdk-signal-forms.d.ts +58 -0
  208. package/types/forty-cdk-slider.d.ts +379 -0
  209. package/types/forty-cdk-stepper.d.ts +650 -0
  210. package/types/forty-cdk-switch.d.ts +87 -0
  211. package/types/forty-cdk-table.d.ts +723 -0
  212. package/types/forty-cdk-tabs.d.ts +235 -0
  213. package/types/forty-cdk-time-field.d.ts +307 -0
  214. package/types/forty-cdk-time-picker.d.ts +578 -0
  215. package/types/forty-cdk-toast.d.ts +598 -0
  216. package/types/forty-cdk-toggle.d.ts +310 -0
  217. package/types/forty-cdk-toolbar.d.ts +217 -0
  218. package/types/forty-cdk-tooltip.d.ts +436 -0
  219. package/types/forty-cdk-tree.d.ts +688 -0
  220. package/types/forty-cdk.d.ts +1 -19743
@@ -0,0 +1,424 @@
1
+ # Listbox
2
+
3
+ Headless implementation of the [WAI-ARIA Listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) with single / multi select, roving tabindex, typeahead, and `FormValueControl<readonly T[]>` integration.
4
+
5
+ `[forListbox]` is generic over the option value type `T` (default `string`). Bind primitive ids for the simple case or full objects for richer models — the directive infers `T` from `[(value)]` and `[forListboxOption][value]`. See [Object values](#object-values) for the object-mode contract.
6
+
7
+ ## Pieces
8
+
9
+ | Class | Selector | Role |
10
+ | --------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `ForListbox` | `[forListbox]` | Container. Owns selected values, mode, orientation. Provides the shared context. |
12
+ | `ForListboxOption` | `[forListboxOption]` | One option. Apply on a `<button type="button">`. |
13
+ | `ForListboxOptionIndicator` | `[forListboxOptionIndicator]` | Optional slot inside an option. Mirrors `data-state` and self-hides while the option is unselected (see [Self-hiding pieces](#self-hiding-pieces)). |
14
+ | `ForListboxReorder` | `[forListboxReorder]` | Optional. Apply on the same element as `[forListbox]` to make the options pointer- and keyboard-sortable while keeping selection + typeahead (see [Reordering](#reordering-sortable)). |
15
+
16
+ ## Inputs / models
17
+
18
+ ### `ForListbox`
19
+
20
+ | API | Type | Description |
21
+ | ------------------------------------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
22
+ | `value` | `model<readonly T[]>` | Two-way bindable. Selected values. Single mode keeps 0 or 1; multi any number. Required by `FormValueControl<readonly T[]>`. |
23
+ | `selected` | `Signal<T \| null>` | Read-only single-select convenience view of `value`: the sole selected value, or `null` when none / many are selected. Lets single-select consumers skip `value()[0]`. |
24
+ | `isItemEqualToValue` | `input<(a: T, b: T) => boolean>` | How two items compare. Defaults to `===`. Override for object values so selection / range actions locate entries by id (or any stable key). |
25
+ | `itemToFormValue` | `input<(item: T) => string>` | Serialize an item for the hidden form input. Defaults to `String` for strings and `JSON.stringify` for objects. Override to emit a per-item id. |
26
+ | `multiple` | `input<boolean>` | When true, multiple options can be selected. Default `false`. |
27
+ | `ariaLabel` | `input<string \| null>` | Reactive accessible name for the listbox, reflected as `aria-label`. Default `null` (and an empty string) emits no attribute. Prefer native `aria-labelledby` when a visible label element exists. |
28
+ | `orientation` | `input<'vertical' \| 'horizontal'>` | Default `'vertical'`. Drives keyboard nav and `aria-orientation`. |
29
+ | `loop` | `input<boolean>` | When `true` (default), arrow nav wraps at the ends. Set `false` to stop at the boundaries. Range extension (Shift+Arrow) never wraps regardless, per the APG. |
30
+ | `dir` | `input<'ltr' \| 'rtl'>` | Default `'ltr'`. |
31
+ | `selectionFollowsFocus` | `input<boolean>` | Single-mode only. When true, arrow nav also selects the focused option. APG flags this as case-by-case — leave off unless your UX specifically benefits. Default `false`. |
32
+ | `disabled` / `readonly` / `required` / `invalid` / `pending` | `input<boolean>` | Reflected as `aria-*` / `data-*`. |
33
+ | `name` | `input<string>` | For form association. |
34
+ | `errors` | `input<ValidationError.WithOptionalFieldTree[]>` | Wired by `[formField]`. |
35
+ | `touched` | `model<boolean>` | Set on focusout outside the listbox. |
36
+
37
+ ### `ForListboxOption`
38
+
39
+ | API | Type | Description |
40
+ | ---------- | ------------------- | ------------------------------------------------------------------------------------------------------ |
41
+ | `value` | `input.required<T>` | The option's value (defaults to `string`). Must be unique within the listbox per `isItemEqualToValue`. |
42
+ | `disabled` | `input<boolean>` | Disables this option independently of the group. |
43
+
44
+ ## Stand-alone usage (single select)
45
+
46
+ ```ts
47
+ import { Component, signal } from '@angular/core';
48
+ import { ForListbox, ForListboxOption } from 'forty-cdk/listbox';
49
+
50
+ @Component({
51
+ selector: 'demo-fruit',
52
+ imports: [ForListbox, ForListboxOption],
53
+ template: `
54
+ <ul forListbox [(value)]="picked" aria-label="Fruit">
55
+ <li>
56
+ <button type="button" forListboxOption class="listbox-option" value="apple">Apple</button>
57
+ </li>
58
+ <li>
59
+ <button type="button" forListboxOption class="listbox-option" value="banana">Banana</button>
60
+ </li>
61
+ <li>
62
+ <button type="button" forListboxOption class="listbox-option" value="cherry">Cherry</button>
63
+ </li>
64
+ </ul>
65
+ `,
66
+ })
67
+ export class DemoFruit {
68
+ readonly picked = signal<readonly string[]>([]);
69
+ }
70
+ ```
71
+
72
+ ## Multi select
73
+
74
+ ```html
75
+ <ul forListbox multiple [(value)]="tags" aria-label="Tags">
76
+ <li>
77
+ <button type="button" forListboxOption class="listbox-option" value="urgent">Urgent</button>
78
+ </li>
79
+ <li><button type="button" forListboxOption class="listbox-option" value="bug">Bug</button></li>
80
+ <li><button type="button" forListboxOption class="listbox-option" value="ui">UI</button></li>
81
+ </ul>
82
+ ```
83
+
84
+ Click toggles individual options in multi mode; click selects in single mode.
85
+
86
+ ## Reordering (sortable)
87
+
88
+ Add `[forListboxReorder]` on the same element as `[forListbox]` to make a listbox **sortable** — a selectable _and_ sortable list (e.g. a chip grid) in one composition, with no `@angular/cdk/drag-drop`.
89
+
90
+ `[forDraggable]` can't stack on a `[forListboxOption]`: both manage the option's roving tabindex and keyboard, so they collide on `tabindex`, on Space / Enter activation, and on `orientation`. `[forListboxReorder]` is a container-level coordinator (the same shape as `[forTreeNodeDrag]` / `[forTableRowReorder]`): it lives on the listbox, **never touches the option's roving tabindex**, intercepts keys in the capture phase with a dedicated lift chord, and owns its own 2D drop geometry — so selection, typeahead, and arrow navigation keep working unchanged.
91
+
92
+ It **never reorders the options itself** (BYO-data): `(optionReorder)` emits `{ from, to }` on each committed drop; apply `moveItemInArray(items, from, to)` to your own array.
93
+
94
+ ```ts
95
+ import { Component, signal } from '@angular/core';
96
+ import { moveItemInArray } from 'forty-cdk/drag-drop';
97
+ import { ForListbox, ForListboxOption, ForListboxReorder } from 'forty-cdk/listbox';
98
+
99
+ @Component({
100
+ selector: 'demo-sortable-tags',
101
+ imports: [ForListbox, ForListboxOption, ForListboxReorder],
102
+ template: `
103
+ <ul
104
+ forListbox
105
+ forListboxReorder
106
+ multiple
107
+ [(value)]="selected"
108
+ (optionReorder)="reorder($event)"
109
+ aria-label="Tags"
110
+ style="display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0"
111
+ >
112
+ @for (tag of tags(); track tag) {
113
+ <li>
114
+ <button type="button" forListboxOption [value]="tag" class="chip">{{ tag }}</button>
115
+ </li>
116
+ }
117
+ </ul>
118
+ `,
119
+ })
120
+ export class DemoSortableTags {
121
+ readonly tags = signal<readonly string[]>(['urgent', 'bug', 'ui', 'docs']);
122
+ readonly selected = signal<readonly string[]>([]);
123
+
124
+ reorder({ from, to }: { from: number; to: number }): void {
125
+ this.tags.update((tags) => moveItemInArray(tags, from, to));
126
+ }
127
+ }
128
+ ```
129
+
130
+ ### Inputs / output
131
+
132
+ | API | Type | Description |
133
+ | ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
134
+ | `reorderDisabled` | `input<boolean>` | Disable reorder while keeping selection / typeahead. The listbox's own `disabled` also disables reorder. Default `false`. |
135
+ | `optionReorder` | `output<{ from; to }>` | Fires once per committed reorder with the previous / new index (both 0-based, DOM order). Apply `moveItemInArray`. |
136
+
137
+ ### Keyboard
138
+
139
+ - **Ctrl+Space** (or **Cmd+Space**) lifts the focused option.
140
+ - While lifted: **arrow keys** step the target position (linearly in DOM order, so a wrapping grid sorts with either axis), **Home / End** jump to the ends, **Space / Enter** drop, **Escape / Tab** cancel.
141
+ - The lift chord is intercepted in the capture phase, so it never collides with the option's native Space / Enter selection or with arrow navigation while idle.
142
+
143
+ ### Pointer
144
+
145
+ Drag an option past a small threshold to reorder; a short press without movement still selects (the post-drag click is suppressed). A floating preview follows the pointer and drop geometry is resolved in 2D, so vertical lists, horizontal lists, and wrapping chip grids all work without configuring `orientation`.
146
+
147
+ > **Scope:** `[forListboxReorder]` targets the standard roving-tabindex listbox. A virtualized listbox (`[totalCount]` set) is left untouched, since reordering a windowed subset is ill-defined.
148
+
149
+ ### Data attributes
150
+
151
+ | Piece | Attribute | Values | Notes |
152
+ | --------------------- | --------------- | ----------------- | ------------------------------------------------------------------- |
153
+ | `[forListboxReorder]` | `data-dragging` | present \| absent | On the container while any drag (pointer or keyboard) is in flight. |
154
+ | `[forListboxOption]` | `data-dragging` | present \| absent | On the lifted option for the duration of the drag. |
155
+
156
+ ## Object values
157
+
158
+ Real apps usually have richer option models — `{ id, name, ... }` — where the comparison key differs from what you'd serialize for a form. `[forListbox]` is generic over `T` to support that without forcing the consumer to stringify and re-hydrate.
159
+
160
+ Two inputs configure the object behaviour. Defaults make string mode work unchanged:
161
+
162
+ | Input | Default | Purpose |
163
+ | ---------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
164
+ | `[isItemEqualToValue]` | `(a, b) => a === b` | How two items compare. Override for object values so selection / range actions locate by id (or any stable key). |
165
+ | `[itemToFormValue]` | `(item) => typeof item === 'string' ? item : JSON.stringify(item)` | Serialize an item for the hidden form input. Override to emit a per-item id (or any wire format your backend wants). |
166
+
167
+ The visible option label is just the rendered `textContent`, so there's no separate label function. All of the multi-select range actions (Shift+Arrow, Shift+Space, Ctrl/Cmd+A, Ctrl+Shift+Home/End) dedupe by `isItemEqualToValue`, so object values never accumulate duplicates.
168
+
169
+ ```ts
170
+ import { Component, signal } from '@angular/core';
171
+ import { ForListbox, ForListboxOption } from 'forty-cdk/listbox';
172
+
173
+ interface City {
174
+ id: string;
175
+ name: string;
176
+ }
177
+
178
+ @Component({
179
+ selector: 'demo-cities',
180
+ imports: [ForListbox, ForListboxOption],
181
+ template: `
182
+ <ul forListbox multiple [(value)]="picked" [isItemEqualToValue]="byId" aria-label="Cities">
183
+ @for (c of cities; track c.id) {
184
+ <li>
185
+ <button type="button" forListboxOption class="listbox-option" [value]="c">
186
+ {{ c.name }}
187
+ </button>
188
+ </li>
189
+ }
190
+ </ul>
191
+ `,
192
+ })
193
+ export class DemoCities {
194
+ readonly picked = signal<readonly City[]>([]);
195
+ readonly cities: readonly City[] = [
196
+ { id: 'paris', name: 'Paris' },
197
+ { id: 'berlin', name: 'Berlin' },
198
+ ];
199
+ readonly byId = (a: City, b: City) => a.id === b.id;
200
+ }
201
+ ```
202
+
203
+ ## Signal Forms usage
204
+
205
+ ```ts
206
+ import { Component, signal } from '@angular/core';
207
+ import { form, required, requiredError, validate } from '@angular/forms/signals';
208
+ import { ForListbox, ForListboxOption } from 'forty-cdk/listbox';
209
+
210
+ @Component({
211
+ selector: 'demo-priorities',
212
+ imports: [ForListbox, ForListboxOption /* , FormField from @angular/forms */],
213
+ template: `
214
+ <ul forListbox multiple [formField]="prefs.priorities" aria-label="Priorities">
215
+ <li>
216
+ <button type="button" forListboxOption class="listbox-option" value="speed">Speed</button>
217
+ </li>
218
+ <li>
219
+ <button type="button" forListboxOption class="listbox-option" value="quality">
220
+ Quality
221
+ </button>
222
+ </li>
223
+ <li>
224
+ <button type="button" forListboxOption class="listbox-option" value="cost">Cost</button>
225
+ </li>
226
+ </ul>
227
+ `,
228
+ })
229
+ export class DemoPriorities {
230
+ readonly model = signal({ priorities: [] as string[] });
231
+ readonly prefs = form(this.model, (s) => {
232
+ required(s.priorities);
233
+ validate(s.priorities, ({ value }) =>
234
+ value().length === 0 ? requiredError({ message: 'Pick at least one priority' }) : undefined,
235
+ );
236
+ });
237
+ }
238
+ ```
239
+
240
+ > **Requiring a non-empty selection.** The value is a `readonly string[]`, and Angular's `required()`
241
+ > treats only `''`, `false`, `null`, and `NaN` as empty — an empty array `[]` counts as _present_, so
242
+ > `required(s.priorities)` reflects `aria-required="true"` but never makes the form invalid on its own.
243
+ > Enforce "at least one" with the explicit `validate(...)` length rule above, or with Angular's
244
+ > `minLength(s.priorities, 1)` (which emits a `minLengthError` instead of a `requiredError`).
245
+
246
+ > **Single-select fields.** When you model the field as `T | null` rather than `readonly T[]`,
247
+ > bridge it with `forSingleValueField` so the standard `[formField]` wiring still works:
248
+ > `[formField]="forSingleValueField(prefs.fruit)"`. See
249
+ > [Signal Forms helpers](../signal-forms/README.md).
250
+
251
+ ## Styling
252
+
253
+ forty-cdk ships no styles. Add your own class to each piece — the `for*` selectors are the behavior API, not a styling contract (see [Styling forty-cdk](../../../../../docs/styling.md)). Key your CSS off the reflected `data-*` attributes below.
254
+
255
+ ### Data attributes
256
+
257
+ | Piece | Attribute | Values | Notes |
258
+ | ----------------------------- | ------------------ | -------------------------- | --------------------------------------------------------- |
259
+ | `[forListbox]` | `data-orientation` | `horizontal` \| `vertical` | |
260
+ | `[forListbox]` | `data-disabled` | present \| absent | |
261
+ | `[forListboxOption]` | `data-state` | `checked` \| `unchecked` | |
262
+ | `[forListboxOption]` | `data-highlighted` | present \| absent | Works in both roving-tabindex and activedescendant paths. |
263
+ | `[forListboxOption]` | `data-disabled` | present \| absent | |
264
+ | `[forListboxOptionIndicator]` | `data-state` | `checked` \| `unchecked` | |
265
+
266
+ ```css
267
+ .listbox-option[data-highlighted] {
268
+ background: rgb(0 0 0 / 0.06);
269
+ }
270
+
271
+ .listbox-option[data-state='checked'] {
272
+ font-weight: 600;
273
+ }
274
+ ```
275
+
276
+ ## Keyboard
277
+
278
+ ### Single mode (and the basics for both)
279
+
280
+ > **Virtualized path (`[totalCount]` set):** the listbox container is always the single Tab stop. Arrow / Home / End / Enter / Space all fire on the container (not individual options). See the [Virtualization](#virtualization) section for the full contract.
281
+
282
+ - **Tab** moves focus into / out of the listbox; lands on the first selected option (or the first enabled one if nothing is selected, or the last user-focused option after first interaction). With several preselected options in multi mode, only the first selected one is the tab stop — the group exposes a single `tabindex="0"`. When no option can serve as that entry point (the listbox is empty, or every option is disabled), the listbox host itself becomes the single Tab stop (`tabindex="0"`) so the control stays reachable; a disabled listbox is never tabbable.
283
+ - **ArrowDown / ArrowUp** in vertical, **ArrowRight / ArrowLeft** in horizontal: move focus, wrap-around, skip disabled.
284
+ - **Home / End** jump to first / last enabled option.
285
+ - **PageUp / PageDown** jump to first / last enabled option.
286
+ - **Space / Enter** activate the focused option (toggles in multi, selects in single) via the underlying button.
287
+ - **Typeahead**: typing characters focuses the first option whose visible text starts with the typed prefix (case-insensitive, debounced).
288
+ - Disabled options are skipped on arrow nav. They keep `aria-disabled="true"` and `data-disabled=""` (no native `disabled` attribute, per APG): focusable for screen-reader announcement, but click and keyboard activation are no-ops.
289
+
290
+ ### Multi mode (APG-recommended range selection)
291
+
292
+ The full WAI-ARIA APG "Recommended Selection" model is implemented and active automatically when `multiple` is set. All shortcuts skip disabled options.
293
+
294
+ | Shortcut | Behavior |
295
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
296
+ | **Shift+ArrowDown / ArrowUp** | Move focus to the next / previous enabled option AND toggle its selected state. |
297
+ | **Shift+Space** | Select every enabled option between the anchor (most recent unmodified click / Space) and the focused option, inclusive. Existing selection outside the range is preserved. |
298
+ | **Ctrl+A** (or **Cmd+A** on mac) | Select every enabled option. If every enabled option is already selected, clears the selection. |
299
+ | **Ctrl+Shift+Home** | Select from the focused option to the first enabled option, and move focus there. |
300
+ | **Ctrl+Shift+End** | Select from the focused option to the last enabled option, and move focus there. |
301
+
302
+ The **anchor** for `Shift+Space` is set on every unmodified activation (click, plain Space, plain Enter) and is unaffected by `Shift+ArrowDown`/`ArrowUp` — that lets users click an option, navigate away with Shift+Arrow, and then Shift+Space to select the contiguous block back to where they started.
303
+
304
+ When `readonly` is set, the focus-moving shortcuts (Shift+Arrow, Ctrl+Shift+Home/End) still move focus but do not change the selection — same contract as plain arrow nav under `readonly`. Pure-selection shortcuts (Shift+Space, Ctrl+A) are no-ops.
305
+
306
+ ## Virtualization
307
+
308
+ Setting `[totalCount]` on `[forListbox]` enables the **activedescendant focus model**: the listbox container becomes the single Tab stop (`tabindex="0"`) and focus never moves to individual options. Keyboard navigation and selection work exactly as in the roving-tabindex path, but the active option is tracked by `aria-activedescendant` instead of DOM focus, so the active row can be unmounted as it scrolls out of the window.
309
+
310
+ Without `[totalCount]` (the default), the roving-tabindex model is used unchanged.
311
+
312
+ ### Inputs and output
313
+
314
+ | Input / Output | Type | Description |
315
+ | ------------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
316
+ | `[totalCount]` | `number \| undefined` | Total number of items in the source data. Setting this switches to the activedescendant model. |
317
+ | `[visibleRange]` | `readonly [number, number] \| undefined` | Inclusive-exclusive `[start, end)` range of rendered options. Provided by `injectVirtualizer`. |
318
+ | `[forListboxOption][posInSet]` | `number \| null` | Zero-based absolute position of this option in the full data. Required in the virtualized path. |
319
+ | `(scrollToIndex)` | `number` | Emitted when navigation lands on an off-window option. Pass to `injectVirtualizer`'s `scrollToIndex` to recenter the window. |
320
+
321
+ ### Focus-model switch
322
+
323
+ | Mode | Tab stop | Active option tracking |
324
+ | ----------------------------------- | ----------------- | -------------------------------------------- |
325
+ | Roving-tabindex (default) | Active option | DOM focus + `data-highlighted` |
326
+ | Activedescendant (`totalCount` set) | Listbox container | `aria-activedescendant` + `data-highlighted` |
327
+
328
+ Both paths reflect `data-highlighted=""` on the active option, so consumer CSS for hover/focus rings works the same way in either mode.
329
+
330
+ ### Navigation flow
331
+
332
+ 1. Consumer provides `[totalCount]`, `[visibleRange]`, and handles `(scrollToIndex)`.
333
+ 2. On focus, the listbox seeds `aria-activedescendant` to the first selected enabled option, or the first enabled option ordered by `posInSet`.
334
+ 3. Arrow / Home / End navigation computes the target index against the full `totalCount`. If the target is inside `[visibleRange]`, `aria-activedescendant` is set immediately. If outside, `(scrollToIndex)` is emitted with the target index.
335
+ 4. When the consumer's virtualizer scrolls and the target option mounts, the listbox resolves the pending navigation and sets `aria-activedescendant`.
336
+
337
+ ### Example
338
+
339
+ ```ts
340
+ import {
341
+ ChangeDetectionStrategy,
342
+ Component,
343
+ type ElementRef,
344
+ computed,
345
+ signal,
346
+ viewChild,
347
+ } from '@angular/core';
348
+ import { ForListbox, ForListboxOption } from 'forty-cdk/listbox';
349
+ import { injectVirtualizer } from 'forty-cdk/virtualization';
350
+
351
+ interface Item {
352
+ readonly id: string;
353
+ readonly label: string;
354
+ }
355
+
356
+ @Component({
357
+ selector: 'demo-virtualized-listbox',
358
+ changeDetection: ChangeDetectionStrategy.OnPush,
359
+ imports: [ForListbox, ForListboxOption],
360
+ template: `
361
+ <div
362
+ forListbox
363
+ #scroll
364
+ aria-label="Virtualized items"
365
+ [(value)]="picked"
366
+ [totalCount]="items.length"
367
+ [visibleRange]="v.range()"
368
+ (scrollToIndex)="v.scrollToIndex($event, { align: 'auto' })"
369
+ style="overflow: auto; max-height: 300px; position: relative"
370
+ >
371
+ <div [style.height.px]="v.totalSize()" style="position: relative">
372
+ @for (vi of v.virtualItems(); track vi.key) {
373
+ <button
374
+ type="button"
375
+ forListboxOption
376
+ [value]="items[vi.index]!.id"
377
+ [posInSet]="vi.index"
378
+ [style.transform]="'translateY(' + vi.start + 'px)'"
379
+ style="position: absolute; left: 0; right: 0;"
380
+ >
381
+ {{ items[vi.index]!.label }}
382
+ </button>
383
+ }
384
+ </div>
385
+ </div>
386
+ `,
387
+ })
388
+ export class DemoVirtualizedListbox {
389
+ protected readonly items: readonly Item[] = Array.from({ length: 10000 }, (_, i) => ({
390
+ id: `item-${i}`,
391
+ label: `Item ${i}`,
392
+ }));
393
+ protected readonly picked = signal<readonly string[]>([]);
394
+ private readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
395
+ private readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
396
+ protected readonly v = injectVirtualizer({
397
+ count: computed(() => this.items.length),
398
+ estimateSize: () => 36,
399
+ scrollElement: this.scrollElement,
400
+ });
401
+ }
402
+ ```
403
+
404
+ ### Intentional limitations
405
+
406
+ - **Multi-select range modifiers** (Shift+Arrow, Shift+Space, Ctrl+A, Ctrl+Shift+Home/End) are not available in the virtualized path. These require the full materialized option set to compute ranges, which contradicts windowing. Per-option toggling via Enter, Space, or click works normally in both single and multi mode.
407
+ - **Typeahead** matches only the currently rendered window. Options outside the visible range cannot be reached by typing.
408
+
409
+ ## Self-hiding pieces
410
+
411
+ `[forListboxOptionIndicator]` hides itself while its option is unselected with an inline `display: none` in addition to the `hidden` attribute that removes it from the accessibility tree. Because the inline style beats any author selector rule, you can give the indicator a custom `display` (e.g. `display: inline-flex` for a check icon) without a `.x[hidden] { display: none }` workaround — the directive's `display: none` still wins while the option is unselected, and your `display` applies once it's selected.
412
+
413
+ ## Accessibility notes
414
+
415
+ - **Label the listbox** via the reactive `[ariaLabel]` input or a native `aria-labelledby` pointing at a visible label element.
416
+ - **Use `<button>` for each option** so Space / Enter activate via native click. Other host elements break keyboard activation.
417
+ - **Visible text on each option** is what typeahead matches against — keep it descriptive and unique-prefixed.
418
+ - **`selectionFollowsFocus`** is an opt-in for single-select. Avoid combining it with side effects that depend on commit semantics — it changes the form value on every arrow key.
419
+ - **`data-highlighted=""`** is reflected on the option that is the current active item in both the roving-tabindex and activedescendant paths — same vocabulary as the menu / select / combobox primitives, useful when you want a uniform "keyboard focus ring" across surfaces without coupling to `:focus`.
420
+ - **Virtualized path**: the listbox publishes `aria-activedescendant` on the container and each rendered option carries `aria-setsize` / `aria-posinset` so screen readers announce the true list size even when only a window is mounted.
421
+
422
+ ## Wrapping in a design system
423
+
424
+ Both supported wrapper patterns — `hostDirectives` with the exported `FOR_LISTBOX_HOST_DIRECTIVE_INPUTS` / `FOR_LISTBOX_HOST_DIRECTIVE_OUTPUTS` name tuples, and subclassing — are documented in [Wrapping form primitives](../../../../../docs/wrapping-form-primitives.md).
package/menu/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # Menu (shared pieces)
2
+
3
+ Shared surface and item directives consumed by `[forDropdownMenu]` (button trigger) and `[forContextMenu]` (right-click). The folder doesn't expose its own root primitive — open the menu via one of those two flavors.
4
+
5
+ Implements the [WAI-ARIA Menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) for the surface (`role="menu"`) and for items (`menuitem` / `menuitemcheckbox` / `menuitemradio`).
6
+
7
+ ## Pieces
8
+
9
+ | Class | Selector | Role |
10
+ | ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `ForMenuContent` | `[forMenuContent]` / `[forMenuSubContent]` | The menu surface. Portaled, positioned by floating-ui, dismissable layer attached. The `Sub` selector is an alias used inside `[forMenuSub]` for template readability. |
12
+ | `ForMenuItem` | `[forMenuItem]` | One action item. Activation closes the menu. |
13
+ | `ForMenuCheckboxItem` | `[forMenuCheckboxItem]` | `model<boolean> checked`. Activation toggles + closes. |
14
+ | `ForMenuRadioGroup` | `[forMenuRadioGroup]` | `model<string> value` shared by its radio items. |
15
+ | `ForMenuRadioItem` | `[forMenuRadioItem]` | One radio option. `value: required<string>`. |
16
+ | `ForMenuItemIndicator` | `[forMenuItemIndicator]` | Optional. Used inside checkbox / radio items. Hides itself when the parent is unchecked. Mirrors the parent's `data-state`. `[forceMount]` keeps it mounted. |
17
+ | `ForMenuSeparator` | `[forMenuSeparator]` | Decorative separator, `role="separator"`. |
18
+ | `ForMenuGroup` | `[forMenuGroup]` | Logical grouping, `role="group"` with `aria-labelledby`. |
19
+ | `ForMenuGroupLabel` | `[forMenuGroupLabel]` | Label registered with the parent group. |
20
+ | `ForMenuSub` | `[forMenuSub]` | Root for a nested submenu — owns its own `open`, ids, and item collection. |
21
+ | `ForMenuSubTrigger` | `[forMenuSubTrigger]` | The `menuitem` in the parent menu that opens the submenu. Wires `aria-haspopup` / `aria-expanded`. |
22
+
23
+ For the recommended `[forceMount]` + `opacity` pattern that keeps indicator columns aligned across checkbox / radio items, see the [selected-indicator alignment guide](../../../../../docs/selected-indicator-pattern.md).
24
+
25
+ ## Mount/visibility convention
26
+
27
+ `[forMenuContent]` follows the floating-overlay convention: the consumer's signal drives `@if`, the directive emits `(close)` (forwarded by the root primitive) when it wants to be unmounted. No `[hidden]`. See `[forDropdownMenu]` and `[forContextMenu]` for end-to-end examples.
28
+
29
+ ## Item activation contract
30
+
31
+ Every item type emits a vetoable `(activate)` event — handlers receive a `VetoableEvent`. The default action is to close the menu after the item's state has been applied (toggle for checkbox, set value for radio). Call `event.preventDefault()` on the veto to keep the menu open.
32
+
33
+ ```html
34
+ <!-- Closes menu by default -->
35
+ <button forMenuItem class="menu-item" (activate)="save()">Save</button>
36
+
37
+ <!-- Stays open -->
38
+ <button
39
+ forMenuCheckboxItem
40
+ class="menu-checkbox-item"
41
+ [(checked)]="bold"
42
+ (activate)="$event.preventDefault()"
43
+ >
44
+ Bold
45
+ </button>
46
+ ```
47
+
48
+ ## Keyboard
49
+
50
+ - **ArrowDown / ArrowUp** — move focus to the next / previous enabled item, wrapping by default.
51
+ - **Home / End** — jump to first / last enabled item.
52
+ - **Enter / click** — activate the focused item via native `<button>` semantics. Closes the menu unless the consumer calls `event.preventDefault()` on `(activate)`.
53
+ - **Space** — activates the focused item:
54
+ - On a plain `[forMenuItem]`, behaves like Enter / click (closes the menu).
55
+ - On `[forMenuCheckboxItem]` and `[forMenuRadioItem]`, toggles `checked` / sets the group `value`, emits `(activate)`, and **never closes** the menu — per APG, so users can flip several options before dismissing. Calling `event.preventDefault()` on `(activate)` is unnecessary for Space (the menu already stays open) but is still respected on Enter / click.
56
+ - **Tab / Shift+Tab** — close the menu and return focus to the trigger. Inside a submenu, propagates upward and tears down the entire chain.
57
+ - **Escape** — close the menu and return focus to the trigger. Inside a submenu, closes only that level (parent stays open).
58
+ - **ArrowRight** (on a `[forMenuSubTrigger]`) — open the submenu and focus its first item. (LTR.)
59
+ - **ArrowLeft** (on an item inside a submenu) — close the submenu and return focus to the `[forMenuSubTrigger]`.
60
+ - **Typeahead** — single printable characters move focus to the first item whose text starts with the buffered string. Disabled items are skipped. By default the match is run against the item's `textContent`; pass `textValue="…"` on `[forMenuItem]`, `[forMenuCheckboxItem]`, or `[forMenuRadioItem]` to override the matched string when the DOM contains icons, kbd hints, or badges that would otherwise bleed into it.
61
+
62
+ ```html
63
+ <!-- Without textValue, prefix-match would compare against "3 Archive" -->
64
+ <button forMenuItem class="menu-item" textValue="Archive">
65
+ <span class="badge">3</span>
66
+ Archive
67
+ </button>
68
+ ```
69
+
70
+ ## Submenu
71
+
72
+ A nested menu is opened by a `[forMenuSubTrigger]` — itself a `menuitem` in the parent menu. The `[forMenuSub]` root owns the submenu's open state, item collection, and dismissable layer.
73
+
74
+ Both `[forMenuSub]` (`exportAs: 'forMenuSub'`) and the parent `[forDropdownMenu]` / `[forContextMenu]` own `open` as a `model<boolean>`, so the minimal case needs no consumer signals at all — expose each with a template reference variable (`#menu="forDropdownMenu"`, `#sub="forMenuSub"`) and drive the `@if` straight off its `open()`:
75
+
76
+ ```html
77
+ <div forDropdownMenu #menu="forDropdownMenu">
78
+ <button forDropdownMenuTrigger>File</button>
79
+ @if (menu.open()) {
80
+ <div forMenuContent>
81
+ <button forMenuItem class="menu-item" (activate)="openFile()">Open</button>
82
+ <div forMenuSub #sub="forMenuSub">
83
+ <button forMenuSubTrigger class="menu-sub-trigger">Open recent</button>
84
+ @if (sub.open()) {
85
+ <div forMenuSubContent>
86
+ <button forMenuItem class="menu-item" (activate)="openFile('a.txt')">a.txt</button>
87
+ <button forMenuItem class="menu-item" (activate)="openFile('b.txt')">b.txt</button>
88
+ </div>
89
+ }
90
+ </div>
91
+ </div>
92
+ }
93
+ </div>
94
+ ```
95
+
96
+ Bind `[(open)]="mySignal"` on either level instead only when the component class needs to read or drive that level's open state itself. The `[forMenuSubTrigger]` is registered as a `menuitem` in the **parent** menu's collection, so parent navigation (ArrowDown/Up, typeahead) reaches it. Reading open state from the **submenu**, it wires `aria-haspopup="menu"`, `aria-expanded`, and `aria-controls` to the submenu's content.
97
+
98
+ Closing semantics propagate upward by default: activating an item inside a submenu (or pressing Tab, or clicking outside both menus) tears down the entire chain. Escape closes only the level that has focus — Escape inside a submenu closes the submenu and returns focus to the `[forMenuSubTrigger]`, leaving the parent open.
99
+
100
+ The submenu's dismissable layer exempts the **parent menu's content** — clicking on a parent menu item doesn't fire the submenu's outside-handler. Instead, the parent item's own click activates and tears down everything via the propagated `closeMenu`.
101
+
102
+ ### Pointer (mouse hover)
103
+
104
+ Additive to the click / keyboard behaviour, a `[forMenuSubTrigger]` also opens its submenu on **mouse hover** — like native desktop menus:
105
+
106
+ - **pointerenter** over the sub-trigger opens the submenu after `subMenuOpenDelay` (default `100`ms), **without** moving focus into it (only keyboard / click move focus in).
107
+ - **pointerleave** closes it after `subMenuCloseDelay` (default `100`ms) — _unless_ the pointer is travelling toward the open submenu. A pointer-grace "safe triangle" is drawn from the cursor to the submenu's near edge; while the pointer stays inside it (heading to the submenu) the close is held off. The triangle's lifetime is capped by `subMenuPointerGraceDuration` (default `300`ms).
108
+ - Touch / pen never hover, so they open the submenu by tap (the native click) — the hover listeners are gated to `pointerType === 'mouse'`.
109
+
110
+ Tune the timings per injector scope with `provideForMenuDefaults` (applies to every submenu in the surrounding scope, across DropdownMenu / ContextMenu / Menubar):
111
+
112
+ ```ts
113
+ import { provideForMenuDefaults } from 'forty-cdk/menu';
114
+
115
+ bootstrapApplication(App, {
116
+ providers: [provideForMenuDefaults({ subMenuOpenDelay: 150, subMenuCloseDelay: 200 })],
117
+ });
118
+ ```
119
+
120
+ Partial overrides inherit unspecified keys from the parent scope (or the library defaults at the root), so a component-level `providers: [provideForMenuDefaults({ subMenuOpenDelay: 0 })]` layers on top of an app-level configuration per key.
121
+
122
+ ## Styling
123
+
124
+ forty-cdk ships no styles. Add your own class to each piece — the `for*` selectors are the behavior API, not a styling contract (see [Styling forty-cdk](../../../../../docs/styling.md)). Key your CSS off the reflected `data-*` attributes below.
125
+
126
+ ### Data attributes
127
+
128
+ | Piece | Attribute | Values |
129
+ | ------------------------------------------ | ------------------ | ------------------------ |
130
+ | `[forMenuContent]` / `[forMenuSubContent]` | `data-state` | `open` \| `closed` |
131
+ | `[forMenuItem]` | `data-disabled` | present \| absent |
132
+ | `[forMenuItem]` | `data-highlighted` | present \| absent |
133
+ | `[forMenuCheckboxItem]` | `data-state` | `checked` \| `unchecked` |
134
+ | `[forMenuCheckboxItem]` | `data-disabled` | present \| absent |
135
+ | `[forMenuCheckboxItem]` | `data-highlighted` | present \| absent |
136
+ | `[forMenuRadioItem]` | `data-state` | `checked` \| `unchecked` |
137
+ | `[forMenuRadioItem]` | `data-disabled` | present \| absent |
138
+ | `[forMenuRadioItem]` | `data-highlighted` | present \| absent |
139
+ | `[forMenuItemIndicator]` | `data-state` | `checked` \| `unchecked` |
140
+ | `[forMenuSub]` | `data-state` | `open` \| `closed` |
141
+ | `[forMenuSub]` | `data-disabled` | present \| absent |
142
+ | `[forMenuSubTrigger]` | `data-state` | `open` \| `closed` |
143
+ | `[forMenuSubTrigger]` | `data-disabled` | present \| absent |
144
+
145
+ > `[forMenuContent]` / `[forMenuSubContent]` portal to `document.body`, so a class scoped to your trigger's component cannot reach the surface. Style it with **global CSS** or a class you pass through (see [Styling floating content](../../../../../docs/styling-floating-content.md)). The content host also exposes the shared positioner custom properties — `--for-anchor-width` / `--for-anchor-height`, `--for-available-width` / `--for-available-height`, and `--for-content-transform-origin` — tabulated below and documented in full in [Styling floating content](../../../../../docs/styling-floating-content.md).
146
+
147
+ ### CSS custom properties
148
+
149
+ See also: [Styling floating content](../../../../../docs/styling-floating-content.md) — animation rules and standalone `scale`/`opacity`.
150
+
151
+ `[forMenuContent]` / `[forMenuSubContent]` are portaled to `document.body` and get their position resolved by floating-ui. The resolved geometry is exposed as custom properties on the content host (cleared on close). These also drive the content surface for `[forDropdownMenu]` and `[forContextMenu]`, which reuse `[forMenuContent]`:
152
+
153
+ | Custom property | Type / range | Meaning |
154
+ | -------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
155
+ | `--for-anchor-width` | px | Anchor (trigger) width — match it with `width: var(--for-anchor-width)`. |
156
+ | `--for-anchor-height` | px | Anchor (trigger) height. |
157
+ | `--for-available-width` | px | Space available along the inline axis (floating-ui `size` middleware) — clamp with `max-width`. |
158
+ | `--for-available-height` | px | Space available along the block axis — clamp with `max-height`. |
159
+ | `--for-content-transform-origin` | `<origin>` keywords | `transform-origin` matching the resolved side / align, so a `scale` enter animation pivots from the trigger. |
160
+
161
+ ```css
162
+ .menu-item[data-highlighted],
163
+ .menu-checkbox-item[data-highlighted],
164
+ .menu-radio-item[data-highlighted] {
165
+ background: rgba(0, 0, 0, 0.06);
166
+ }
167
+ .menu-sub-trigger[data-state='open'] .chevron {
168
+ transform: rotate(90deg);
169
+ }
170
+ ```
171
+
172
+ ## Accessibility notes
173
+
174
+ - Apply each item directive to a `<button>` so Space / Enter activation come from native button behavior.
175
+ - Disabled items keep `tabindex="-1"` and `aria-disabled="true"` (per APG) — they remain focusable so screen readers can announce them, but click and keyboard activation are no-ops.
176
+ - `[forMenuSeparator]` is decorative and never registers with the menu's item collection — it's skipped during navigation and typeahead automatically.
177
+ - `[forMenuGroup]` is purely advisory grouping — items inside still register flatly with the parent menu, so navigation flows through groups without interruption.
178
+ - Submenus use `side="right"` `align="start"` by default in LTR and `side="left"` `align="start"` in RTL — set `[dir]="'rtl'"` on the top-level `[forDropdownMenu]` / `[forContextMenu]` and every nested `[forMenuSub]` inherits it (and flips `side`, ArrowLeft/Right semantics, etc.). Override per-submenu with `[dir]` or `[side]` if a specific submenu needs to render against the opposite direction.
179
+ - In RTL, ArrowLeft opens a submenu and ArrowRight closes it back to the parent — the swap mirrors the visual flip of the menu chain.
180
+ - **`data-highlighted=""`** is reflected on the focused `[forMenuItem]` / `[forMenuCheckboxItem]` / `[forMenuRadioItem]` so consumers can paint a uniform focus ring shared with the listbox / select / combobox primitives. The attribute is intent-driven: opening a menu with the pointer focuses the first item **without** highlighting it (no "preselected" look on mouse open), while a keyboard open (Enter / Space / ArrowDown / ArrowUp on the trigger, `Shift+F10` for context menus) highlights the initially focused item. Arrow / Home / End / typeahead navigation always highlights the focused item.
181
+ - **Hover follows the pointer.** Moving the mouse over an enabled item focuses **and** highlights it, so the keyboard highlight and the mouse hover never disagree — there is a single "active candidate" at a time. Hovering an adjacent item moves the highlight with it; hovering a disabled item is inert. When the pointer leaves the menu surface the highlight clears, while DOM focus stays anchored on the item so keyboard navigation continues from there. This means `[data-highlighted]` is the only hover styling hook you need — you do **not** add a separate `:hover` rule (it would fight the highlight). Touch / pen never hover, so this applies to mouse input only.