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.
- package/accordion/README.md +122 -0
- package/aspect-ratio/README.md +76 -0
- package/avatar/README.md +100 -0
- package/breadcrumbs/README.md +49 -0
- package/breakpoints/README.md +81 -0
- package/button/README.md +49 -0
- package/calendar/README.md +458 -0
- package/carousel/README.md +358 -0
- package/checkbox/README.md +146 -0
- package/combobox/README.md +535 -0
- package/context-menu/README.md +139 -0
- package/date-field/README.md +184 -0
- package/date-picker/README.md +338 -0
- package/dialog/README.md +388 -0
- package/disclosure/README.md +114 -0
- package/drag-drop/README.md +359 -0
- package/drawer/README.md +560 -0
- package/dropdown-menu/README.md +176 -0
- package/fesm2022/forty-cdk-accordion.mjs +348 -0
- package/fesm2022/forty-cdk-accordion.mjs.map +1 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs +74 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs.map +1 -0
- package/fesm2022/forty-cdk-avatar.mjs +308 -0
- package/fesm2022/forty-cdk-avatar.mjs.map +1 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs +125 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs.map +1 -0
- package/fesm2022/forty-cdk-breakpoints.mjs +117 -0
- package/fesm2022/forty-cdk-breakpoints.mjs.map +1 -0
- package/fesm2022/forty-cdk-button.mjs +134 -0
- package/fesm2022/forty-cdk-button.mjs.map +1 -0
- package/fesm2022/forty-cdk-calendar.mjs +2034 -0
- package/fesm2022/forty-cdk-calendar.mjs.map +1 -0
- package/fesm2022/forty-cdk-carousel.mjs +968 -0
- package/fesm2022/forty-cdk-carousel.mjs.map +1 -0
- package/fesm2022/forty-cdk-checkbox.mjs +226 -0
- package/fesm2022/forty-cdk-checkbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-combobox.mjs +2596 -0
- package/fesm2022/forty-cdk-combobox.mjs.map +1 -0
- package/fesm2022/forty-cdk-context-menu.mjs +413 -0
- package/fesm2022/forty-cdk-context-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-core.mjs +9022 -0
- package/fesm2022/forty-cdk-core.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-field.mjs +744 -0
- package/fesm2022/forty-cdk-date-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-picker.mjs +1011 -0
- package/fesm2022/forty-cdk-date-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-dialog.mjs +707 -0
- package/fesm2022/forty-cdk-dialog.mjs.map +1 -0
- package/fesm2022/forty-cdk-disclosure.mjs +190 -0
- package/fesm2022/forty-cdk-disclosure.mjs.map +1 -0
- package/fesm2022/forty-cdk-drag-drop.mjs +1180 -0
- package/fesm2022/forty-cdk-drag-drop.mjs.map +1 -0
- package/fesm2022/forty-cdk-drawer.mjs +1641 -0
- package/fesm2022/forty-cdk-drawer.mjs.map +1 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs +350 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-field.mjs +425 -0
- package/fesm2022/forty-cdk-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-fieldset.mjs +164 -0
- package/fesm2022/forty-cdk-fieldset.mjs.map +1 -0
- package/fesm2022/forty-cdk-file-upload.mjs +221 -0
- package/fesm2022/forty-cdk-file-upload.mjs.map +1 -0
- package/fesm2022/forty-cdk-hover-card.mjs +496 -0
- package/fesm2022/forty-cdk-hover-card.mjs.map +1 -0
- package/fesm2022/forty-cdk-input.mjs +274 -0
- package/fesm2022/forty-cdk-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-internationalized-date.mjs +1 -1
- package/fesm2022/forty-cdk-internationalized-date.mjs.map +1 -1
- package/fesm2022/forty-cdk-listbox.mjs +1279 -0
- package/fesm2022/forty-cdk-listbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-menu.mjs +1439 -0
- package/fesm2022/forty-cdk-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-menubar.mjs +787 -0
- package/fesm2022/forty-cdk-menubar.mjs.map +1 -0
- package/fesm2022/forty-cdk-meter.mjs +211 -0
- package/fesm2022/forty-cdk-meter.mjs.map +1 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs +1145 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-number-input.mjs +559 -0
- package/fesm2022/forty-cdk-number-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-otp-input.mjs +527 -0
- package/fesm2022/forty-cdk-otp-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-pagination.mjs +323 -0
- package/fesm2022/forty-cdk-pagination.mjs.map +1 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs +297 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs.map +1 -0
- package/fesm2022/forty-cdk-popover.mjs +698 -0
- package/fesm2022/forty-cdk-popover.mjs.map +1 -0
- package/fesm2022/forty-cdk-progress.mjs +226 -0
- package/fesm2022/forty-cdk-progress.mjs.map +1 -0
- package/fesm2022/forty-cdk-radio-group.mjs +378 -0
- package/fesm2022/forty-cdk-radio-group.mjs.map +1 -0
- package/fesm2022/forty-cdk-scroll-area.mjs +640 -0
- package/fesm2022/forty-cdk-scroll-area.mjs.map +1 -0
- package/fesm2022/forty-cdk-search.mjs +205 -0
- package/fesm2022/forty-cdk-search.mjs.map +1 -0
- package/fesm2022/forty-cdk-select.mjs +1661 -0
- package/fesm2022/forty-cdk-select.mjs.map +1 -0
- package/fesm2022/forty-cdk-separator.mjs +82 -0
- package/fesm2022/forty-cdk-separator.mjs.map +1 -0
- package/fesm2022/forty-cdk-signal-forms.mjs +97 -0
- package/fesm2022/forty-cdk-signal-forms.mjs.map +1 -0
- package/fesm2022/forty-cdk-slider.mjs +803 -0
- package/fesm2022/forty-cdk-slider.mjs.map +1 -0
- package/fesm2022/forty-cdk-stepper.mjs +886 -0
- package/fesm2022/forty-cdk-stepper.mjs.map +1 -0
- package/fesm2022/forty-cdk-switch.mjs +137 -0
- package/fesm2022/forty-cdk-switch.mjs.map +1 -0
- package/fesm2022/forty-cdk-table.mjs +1518 -0
- package/fesm2022/forty-cdk-table.mjs.map +1 -0
- package/fesm2022/forty-cdk-tabs.mjs +400 -0
- package/fesm2022/forty-cdk-tabs.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-field.mjs +593 -0
- package/fesm2022/forty-cdk-time-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-picker.mjs +1013 -0
- package/fesm2022/forty-cdk-time-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-toast.mjs +1153 -0
- package/fesm2022/forty-cdk-toast.mjs.map +1 -0
- package/fesm2022/forty-cdk-toggle.mjs +516 -0
- package/fesm2022/forty-cdk-toggle.mjs.map +1 -0
- package/fesm2022/forty-cdk-toolbar.mjs +374 -0
- package/fesm2022/forty-cdk-toolbar.mjs.map +1 -0
- package/fesm2022/forty-cdk-tooltip.mjs +672 -0
- package/fesm2022/forty-cdk-tooltip.mjs.map +1 -0
- package/fesm2022/forty-cdk-tree.mjs +2007 -0
- package/fesm2022/forty-cdk-tree.mjs.map +1 -0
- package/fesm2022/forty-cdk-virtualization.mjs +1 -1
- package/fesm2022/forty-cdk-virtualization.mjs.map +1 -1
- package/fesm2022/forty-cdk.mjs +0 -43310
- package/fesm2022/forty-cdk.mjs.map +1 -1
- package/field/README.md +97 -0
- package/fieldset/README.md +86 -0
- package/file-upload/README.md +73 -0
- package/hover-card/README.md +171 -0
- package/input/README.md +156 -0
- package/listbox/README.md +424 -0
- package/menu/README.md +181 -0
- package/menubar/README.md +140 -0
- package/meter/README.md +128 -0
- package/navigation-menu/README.md +253 -0
- package/number-input/README.md +171 -0
- package/otp-input/README.md +198 -0
- package/package.json +213 -1
- package/pagination/README.md +61 -0
- package/pane-resizer/README.md +136 -0
- package/popover/README.md +262 -0
- package/progress/README.md +115 -0
- package/radio-group/README.md +129 -0
- package/scroll-area/README.md +184 -0
- package/search/README.md +42 -0
- package/select/README.md +488 -0
- package/separator/README.md +84 -0
- package/signal-forms/README.md +72 -0
- package/slider/README.md +152 -0
- package/stepper/README.md +292 -0
- package/switch/README.md +116 -0
- package/table/README.md +769 -0
- package/tabs/README.md +130 -0
- package/time-field/README.md +157 -0
- package/time-picker/README.md +172 -0
- package/toast/README.md +398 -0
- package/toggle/README.md +224 -0
- package/toolbar/README.md +109 -0
- package/tooltip/README.md +274 -0
- package/tree/README.md +708 -0
- package/types/forty-cdk-accordion.d.ts +242 -0
- package/types/forty-cdk-aspect-ratio.d.ts +59 -0
- package/types/forty-cdk-avatar.d.ts +133 -0
- package/types/forty-cdk-breadcrumbs.d.ts +92 -0
- package/types/forty-cdk-breakpoints.d.ts +141 -0
- package/types/forty-cdk-button.d.ts +80 -0
- package/types/forty-cdk-calendar.d.ts +914 -0
- package/types/forty-cdk-carousel.d.ts +530 -0
- package/types/forty-cdk-checkbox.d.ts +141 -0
- package/types/forty-cdk-combobox.d.ts +1259 -0
- package/types/forty-cdk-context-menu.d.ts +313 -0
- package/types/forty-cdk-core.d.ts +5774 -0
- package/types/forty-cdk-date-field.d.ts +307 -0
- package/types/forty-cdk-date-picker.d.ts +622 -0
- package/types/forty-cdk-dialog.d.ts +546 -0
- package/types/forty-cdk-disclosure.d.ts +127 -0
- package/types/forty-cdk-drag-drop.d.ts +456 -0
- package/types/forty-cdk-drawer.d.ts +871 -0
- package/types/forty-cdk-dropdown-menu.d.ts +242 -0
- package/types/forty-cdk-field.d.ts +236 -0
- package/types/forty-cdk-fieldset.d.ts +119 -0
- package/types/forty-cdk-file-upload.d.ts +124 -0
- package/types/forty-cdk-hover-card.d.ts +320 -0
- package/types/forty-cdk-input.d.ts +169 -0
- package/types/forty-cdk-internationalized-date.d.ts +1 -1
- package/types/forty-cdk-listbox.d.ts +513 -0
- package/types/forty-cdk-menu.d.ts +629 -0
- package/types/forty-cdk-menubar.d.ts +451 -0
- package/types/forty-cdk-meter.d.ts +122 -0
- package/types/forty-cdk-navigation-menu.d.ts +514 -0
- package/types/forty-cdk-number-input.d.ts +319 -0
- package/types/forty-cdk-otp-input.d.ts +248 -0
- package/types/forty-cdk-pagination.d.ts +214 -0
- package/types/forty-cdk-pane-resizer.d.ts +145 -0
- package/types/forty-cdk-popover.d.ts +509 -0
- package/types/forty-cdk-progress.d.ts +143 -0
- package/types/forty-cdk-radio-group.d.ts +222 -0
- package/types/forty-cdk-scroll-area.d.ts +258 -0
- package/types/forty-cdk-search.d.ts +142 -0
- package/types/forty-cdk-select.d.ts +899 -0
- package/types/forty-cdk-separator.d.ts +59 -0
- package/types/forty-cdk-signal-forms.d.ts +58 -0
- package/types/forty-cdk-slider.d.ts +379 -0
- package/types/forty-cdk-stepper.d.ts +650 -0
- package/types/forty-cdk-switch.d.ts +87 -0
- package/types/forty-cdk-table.d.ts +723 -0
- package/types/forty-cdk-tabs.d.ts +235 -0
- package/types/forty-cdk-time-field.d.ts +307 -0
- package/types/forty-cdk-time-picker.d.ts +578 -0
- package/types/forty-cdk-toast.d.ts +598 -0
- package/types/forty-cdk-toggle.d.ts +310 -0
- package/types/forty-cdk-toolbar.d.ts +217 -0
- package/types/forty-cdk-tooltip.d.ts +436 -0
- package/types/forty-cdk-tree.d.ts +688 -0
- package/types/forty-cdk.d.ts +1 -19743
package/select/README.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# Select
|
|
2
|
+
|
|
3
|
+
> New to overlays in forty-cdk? [Your first overlay](../../../../../docs/your-first-overlay.md) walks a Popover from empty markup to styled-and-animated and explains the `@if` / open-state model and the portal → global CSS rule.
|
|
4
|
+
|
|
5
|
+
Headless select primitive — a button trigger that opens a portaled listbox of options. Implements the [WAI-ARIA select-only combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/) (`role="combobox"` on the trigger, `role="listbox"` on the surface, `role="option"` on items) and the `FormValueControl<readonly T[]>` interface from `@angular/forms/signals`.
|
|
6
|
+
|
|
7
|
+
`[forSelect]` 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 `[forSelectOption][value]`. See [Object values](#object-values) for the object-mode contract.
|
|
8
|
+
|
|
9
|
+
## Pieces
|
|
10
|
+
|
|
11
|
+
| Class | Selector | Role |
|
|
12
|
+
| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `ForSelect` | `[forSelect]` | Root. Owns `[(value)]`, `[(open)]`, the option collection, ids, and the dismiss event surface. |
|
|
14
|
+
| `ForSelectTrigger` | `[forSelectTrigger]` | The `<button role="combobox">` that opens the listbox. Wires `aria-haspopup`, `aria-expanded`, `aria-controls`. |
|
|
15
|
+
| `ForSelectAnchor` | `[forSelectAnchor]` | Optional. Positions the listbox against this element instead of the trigger — wrap a decorated field box so the panel matches the visible field. See [Anchoring to a field box](#anchoring-to-a-field-box). |
|
|
16
|
+
| `ForSelectValue` | `[forSelectValue]` | Renders the selected option's text — or the placeholder — into its host via `textContent`. Optional. |
|
|
17
|
+
| `ForSelectContent` | `[forSelectContent]` | The listbox surface. Portaled, positioned by floating-ui, dismissable layer attached. |
|
|
18
|
+
| `ForSelectOption` | `[forSelectOption]` | One option. `value: required<T>` (defaults to `string`). |
|
|
19
|
+
| `ForSelectIndicator` | `[forSelectIndicator]` | Optional. Self-hides (inline `display:none` + `hidden`) when the parent option is unselected. Mirrors the option's `data-state`. |
|
|
20
|
+
| `ForSelectGroup` | `[forSelectGroup]` | Logical grouping, `role="group"` with `aria-labelledby`. |
|
|
21
|
+
| `ForSelectGroupLabel` | `[forSelectGroupLabel]` | Label registered with the parent group. |
|
|
22
|
+
| `ForSelectSeparator` | `[forSelectSeparator]` | Decorative separator, `role="separator"`. Skipped by navigation. |
|
|
23
|
+
|
|
24
|
+
## Single mode (default)
|
|
25
|
+
|
|
26
|
+
Click an option to replace the selection and close. `[(value)]` keeps 0 or 1 element. Read the sole value through the read-only `selected: Signal<T | null>` accessor (the form contract keeps `value` as `readonly T[]`; `selected()` is `value()[0]` or `null`).
|
|
27
|
+
|
|
28
|
+
```html
|
|
29
|
+
<div forSelect #select="forSelect" [(value)]="favorite" placeholder="Pick a fruit">
|
|
30
|
+
<button forSelectTrigger class="select-trigger">
|
|
31
|
+
<span forSelectValue></span>
|
|
32
|
+
</button>
|
|
33
|
+
@if (select.open()) {
|
|
34
|
+
<div forSelectContent>
|
|
35
|
+
<button forSelectOption class="select-item" value="apple">Apple</button>
|
|
36
|
+
<button forSelectOption class="select-item" value="banana">Banana</button>
|
|
37
|
+
<button forSelectOption class="select-item" value="cherry">Cherry</button>
|
|
38
|
+
</div>
|
|
39
|
+
}
|
|
40
|
+
</div>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`[(value)]` is the selection (form state) and is always the consumer's. Open state is separate: `[forSelect]` owns it as a `model<boolean>`, so the `@if` reads it straight off the directive instance. `[forSelect]` is `exportAs: 'forSelect'` — expose it with a template reference variable (`#select="forSelect"`) and gate `[forSelectContent]` on `select.open()`. The trigger toggles it; Escape, Tab, and outside-pointer flip it back. No separate `open` signal, no `[(open)]` — bind `[(open)]="mySignal"` only when the component class needs to read or drive open state itself (open it programmatically, persist it, or react to it elsewhere).
|
|
44
|
+
|
|
45
|
+
## Multi mode
|
|
46
|
+
|
|
47
|
+
Set `multiple` and bind `[(value)]` to a `string[]`. Click an option to toggle in/out — the listbox stays open. Tab, Escape, or outside-pointer close it.
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<div forSelect #select="forSelect" multiple [(value)]="tags">
|
|
51
|
+
<button forSelectTrigger class="select-trigger">
|
|
52
|
+
<span forSelectValue placeholder="Pick tags…"></span>
|
|
53
|
+
</button>
|
|
54
|
+
@if (select.open()) {
|
|
55
|
+
<div forSelectContent>
|
|
56
|
+
<button forSelectOption class="select-item" value="ng">Angular</button>
|
|
57
|
+
<button forSelectOption class="select-item" value="ts">TypeScript</button>
|
|
58
|
+
<button forSelectOption class="select-item" value="rx">RxJS</button>
|
|
59
|
+
</div>
|
|
60
|
+
}
|
|
61
|
+
</div>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Mount/visibility convention
|
|
65
|
+
|
|
66
|
+
`[forSelectContent]` follows the floating-overlay convention: the consumer's signal drives `@if`, the directive emits dismiss events (forwarded by the root primitive) when it wants to be unmounted. No `[hidden]`. The trigger's own click toggles the same signal — `[forSelect]` exposes `open` as a `model<boolean>` so two-way binding works out of the box.
|
|
67
|
+
|
|
68
|
+
## Initial focus on open
|
|
69
|
+
|
|
70
|
+
When the listbox mounts, focus lands per the trigger's hint:
|
|
71
|
+
|
|
72
|
+
- **Click / Enter / Space / ArrowDown** → focuses the currently-selected option, falling back to the first enabled option when no selection exists.
|
|
73
|
+
- **ArrowUp** → focuses the currently-selected option, or the last enabled option when no selection exists.
|
|
74
|
+
|
|
75
|
+
Override programmatically with `forSelect.openMenu('first' | 'last' | 'selected')`.
|
|
76
|
+
|
|
77
|
+
## Anchoring to a field box
|
|
78
|
+
|
|
79
|
+
By default the listbox is positioned against `[forSelectTrigger]`. When the trigger lives inside a decorated field box — padding, a prefix icon, a clear / chevron button — anchoring to the inner button makes the panel narrower than the visible field and offset from its edge. Wrap the field box in `[forSelectAnchor]` so floating-ui positions (and sizes, via `--for-anchor-width`) the listbox against the box instead:
|
|
80
|
+
|
|
81
|
+
```html
|
|
82
|
+
<div forSelect #select="forSelect" [(value)]="value">
|
|
83
|
+
<div forSelectAnchor class="field-box">
|
|
84
|
+
<icon name="search" />
|
|
85
|
+
<button forSelectTrigger>
|
|
86
|
+
<span forSelectValue placeholder="Pick a fruit"></span>
|
|
87
|
+
</button>
|
|
88
|
+
<button class="clear" (click)="value.set([])">×</button>
|
|
89
|
+
</div>
|
|
90
|
+
@if (select.open()) {
|
|
91
|
+
<div forSelectContent style="width: var(--for-anchor-width)">
|
|
92
|
+
<button forSelectOption value="apple">Apple</button>
|
|
93
|
+
<button forSelectOption value="banana">Banana</button>
|
|
94
|
+
</div>
|
|
95
|
+
}
|
|
96
|
+
</div>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`[forSelectAnchor]` changes **only** positioning. The trigger keeps `aria-haspopup` / `aria-expanded` / `aria-controls`, the click toggle, focus return on close, and its exemption from outside-pointer dismissal. Without an anchor the listbox falls back to the trigger, so existing markup is unaffected. At most one `[forSelectAnchor]` per `[forSelect]` — a second one throws `[forty-cdk/select]`.
|
|
100
|
+
|
|
101
|
+
## Triggers stamped from outside-declared templates
|
|
102
|
+
|
|
103
|
+
Angular resolves `ng-template` DI at the template's **declaration** site, not where it is stamped. A `[forSelectTrigger]` declared in a template outside the root throws the orphan error even when the template is rendered inside the root via `ngTemplateOutlet`. For that case the selector attribute accepts the root reference as a value, `routerLink`-style — grab it with `#root="forSelect"` and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<div forSelect #root="forSelect" [(value)]="value">
|
|
107
|
+
<ng-container *ngTemplateOutlet="trig; context: { root }" />
|
|
108
|
+
@if (root.open()) {
|
|
109
|
+
<div forSelectContent>…</div>
|
|
110
|
+
}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<ng-template #trig let-root="root">
|
|
114
|
+
<button [forSelectTrigger]="root">
|
|
115
|
+
<span forSelectValue placeholder="Pick a fruit"></span>
|
|
116
|
+
</button>
|
|
117
|
+
</ng-template>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Styling
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
### Data attributes
|
|
125
|
+
|
|
126
|
+
| Piece | Attribute | Values |
|
|
127
|
+
| ---------------------- | ------------------ | -------------------------- |
|
|
128
|
+
| `[forSelect]` | `data-state` | `open` \| `closed` |
|
|
129
|
+
| `[forSelect]` | `data-disabled` | present \| absent |
|
|
130
|
+
| `[forSelectTrigger]` | `data-state` | `open` \| `closed` |
|
|
131
|
+
| `[forSelectTrigger]` | `data-disabled` | present \| absent |
|
|
132
|
+
| `[forSelectValue]` | `data-placeholder` | present \| absent |
|
|
133
|
+
| `[forSelectContent]` | `data-state` | `open` \| `closed` |
|
|
134
|
+
| `[forSelectContent]` | `data-orientation` | `vertical` \| `horizontal` |
|
|
135
|
+
| `[forSelectOption]` | `data-state` | `checked` \| `unchecked` |
|
|
136
|
+
| `[forSelectOption]` | `data-disabled` | present \| absent |
|
|
137
|
+
| `[forSelectOption]` | `data-highlighted` | present \| absent |
|
|
138
|
+
| `[forSelectIndicator]` | `data-state` | `checked` \| `unchecked` |
|
|
139
|
+
|
|
140
|
+
`data-highlighted` marks the keyboard-focused option (shared vocabulary with the listbox / menu / combobox primitives). In popper mode `[forSelectContent]` also carries the positioner markers `data-side` / `data-align` / `data-placement` (and `data-detached` while `hideWhenDetached` is active); in `item-aligned` mode it carries `data-position="item-aligned"` instead — see [Styling floating content](../../../../../docs/styling-floating-content.md).
|
|
141
|
+
|
|
142
|
+
### CSS custom properties
|
|
143
|
+
|
|
144
|
+
`[forSelectContent]` is portaled to `document.body` and exposes its resolved geometry as custom properties (set on the content host). Which ones are present depends on `position`:
|
|
145
|
+
|
|
146
|
+
| Custom property | Type / range | `position` | Meaning |
|
|
147
|
+
| --------------------------------------- | ------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
148
|
+
| `--for-anchor-width` | px | both | Trigger width — size the content to match with `width: var(--for-anchor-width)`. |
|
|
149
|
+
| `--for-anchor-height` | px | both | Trigger height. |
|
|
150
|
+
| `--for-select-content-available-height` | px | `item-aligned` | Viewport height minus `collisionPadding` — clamp with `max-height: var(--for-select-content-available-height)`. |
|
|
151
|
+
| `--for-available-width` | px | `popper` | Space available to the content along the inline axis (from floating-ui's `size` middleware). |
|
|
152
|
+
| `--for-available-height` | px | `popper` | Space available to the content along the block axis. |
|
|
153
|
+
| `--for-content-transform-origin` | `<origin>` keywords | `popper` | `transform-origin` matching the resolved side / align, so a `scale` enter animation pivots from the trigger. |
|
|
154
|
+
|
|
155
|
+
> `[forSelectContent]` is portaled to `document.body`, so a scoped component style sheet will not reach it — style it with **global CSS** or pass a class the consumer keeps global. The anchored-positioning markers and shared positioner variables (`--for-anchor-width` / `-height`, `--for-available-width` / `-height`, `--for-content-transform-origin`) live on the portaled host too; see [Styling floating content](../../../../../docs/styling-floating-content.md) for the full list.
|
|
156
|
+
|
|
157
|
+
```css
|
|
158
|
+
.select-trigger svg {
|
|
159
|
+
transition: transform 150ms ease;
|
|
160
|
+
}
|
|
161
|
+
.select-trigger[data-state='open'] svg {
|
|
162
|
+
transform: rotate(180deg);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.select-item[data-highlighted] {
|
|
166
|
+
background: var(--accent);
|
|
167
|
+
}
|
|
168
|
+
.select-item:not([data-disabled]):hover {
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Keyboard
|
|
174
|
+
|
|
175
|
+
### Trigger (closed)
|
|
176
|
+
|
|
177
|
+
- **Click / Enter / Space** — open (focus selected, else first).
|
|
178
|
+
- **ArrowDown** — open (focus selected, else first).
|
|
179
|
+
- **ArrowUp** — open (focus selected, else last).
|
|
180
|
+
- **Typeahead** _(single mode only)_ — printable keys select the matching option immediately without opening, mirroring native `<select>`. The lookup goes through a cached snapshot of options (the live registry is empty while `[forSelectContent]` is unmounted); the cache is populated the first time the listbox opens, so closed-state typeahead is available after the user has interacted with the listbox at least once.
|
|
181
|
+
|
|
182
|
+
### Listbox (open)
|
|
183
|
+
|
|
184
|
+
- **ArrowDown / ArrowUp** — move focus to next / previous enabled option, wrapping by default.
|
|
185
|
+
- **Home / End** — jump to first / last enabled option.
|
|
186
|
+
- **PageUp / PageDown** — jump to first / last enabled option.
|
|
187
|
+
- **Enter / Space** — activate the focused option (native `<button>` semantics): select + close in single mode, toggle (stay open) in multi mode.
|
|
188
|
+
- **Escape** — close without changing selection. Returns focus to the trigger.
|
|
189
|
+
- **Tab / Shift+Tab** — commit the focused option (single mode only — multi-mode keeps the existing selection) and let the browser advance focus to the next / previous focusable, mirroring native `<select>`. The directive does **not** `preventDefault`, so form workflows keep flowing through tab order.
|
|
190
|
+
- **Typeahead** — single printable characters move focus to the first option whose text starts with the buffered string. Disabled options are skipped.
|
|
191
|
+
|
|
192
|
+
## macOS-style alignment (`position="item-aligned"`)
|
|
193
|
+
|
|
194
|
+
`[forSelect]` defaults to `position="popper"` — standard floating-ui anchored placement (`side` / `align` / `sideOffset` / `alignOffset` with `flip` + `shift` collision handling). Set `position="item-aligned"` to switch to the macOS-native algorithm: the listbox overlays the trigger so the **selected option's vertical center** lines up with the **trigger's vertical center**. The visual effect is that opening the menu doesn't shift the eye — the selected value stays in place; the rest of the options expand around it. Better UX for short lists with a known selected value (country / language / role pickers).
|
|
195
|
+
|
|
196
|
+
When nothing is selected, the algorithm falls back to the first enabled option. The listbox is clamped inside the viewport with `collisionPadding`; if the listbox is taller than the viewport the directive snaps it to the padding line and scrolls the selected option into view via `scrollIntoView({ block: 'nearest' })`.
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
<div
|
|
200
|
+
forSelect
|
|
201
|
+
#select="forSelect"
|
|
202
|
+
[(value)]="country"
|
|
203
|
+
position="item-aligned"
|
|
204
|
+
[collisionPadding]="10"
|
|
205
|
+
>
|
|
206
|
+
<button forSelectTrigger class="select-trigger">
|
|
207
|
+
<span forSelectValue placeholder="Country"></span>
|
|
208
|
+
</button>
|
|
209
|
+
@if (select.open()) {
|
|
210
|
+
<div forSelectContent class="select-content">
|
|
211
|
+
<button forSelectOption class="select-item" value="es">Spain</button>
|
|
212
|
+
<button forSelectOption class="select-item" value="fr">France</button>
|
|
213
|
+
<button forSelectOption class="select-item" value="de">Germany</button>
|
|
214
|
+
</div>
|
|
215
|
+
}
|
|
216
|
+
</div>
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The directive sets `--for-select-content-available-height` on the content host so consumers can clamp the visible height in CSS:
|
|
220
|
+
|
|
221
|
+
```css
|
|
222
|
+
.select-content {
|
|
223
|
+
max-height: var(--for-select-content-available-height);
|
|
224
|
+
overflow-y: auto;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
When `position="item-aligned"`, the following inputs are **no-ops**: `side`, `align`, `sideOffset`, `alignOffset`, `avoidCollisions`, `sticky`, `hideWhenDetached`, `arrowPadding`. Only `collisionPadding` (default `8`) is honored — it drives both the viewport clamp and the available-height variable. The content gets `data-position="item-aligned"` so consumers can target it with CSS; in popper mode the attribute is absent and the `data-side` / `data-align` / `data-placement` markers from `injectFloating` apply instead.
|
|
229
|
+
|
|
230
|
+
The default stays `popper` so existing consumers' visuals don't shift on upgrade — opt in per primitive when the macOS feel is what you want.
|
|
231
|
+
|
|
232
|
+
## Modal (touch) presentation (`modal`)
|
|
233
|
+
|
|
234
|
+
`[forSelect]` defaults to a **non-modal anchored popover**. On small / touch screens the established pattern (native mobile pickers) is a centered modal surface that's easier to tap. Set `modal` to route `[forSelectContent]` through `_internal/modal-shell` — a **trapped / inert / scroll-locked** surface — instead of the anchored popover. The form-value wiring is unchanged: `[(value)]`, `name`, and the `selected()` accessor keep working exactly as in popover mode.
|
|
235
|
+
|
|
236
|
+
```html
|
|
237
|
+
<div
|
|
238
|
+
forSelect
|
|
239
|
+
[(value)]="value"
|
|
240
|
+
[(open)]="open"
|
|
241
|
+
name="country"
|
|
242
|
+
[modal]="isCoarsePointer()"
|
|
243
|
+
ariaLabel="Country"
|
|
244
|
+
>
|
|
245
|
+
<button forSelectTrigger class="select-trigger">
|
|
246
|
+
<span forSelectValue placeholder="Country"></span>
|
|
247
|
+
</button>
|
|
248
|
+
@if (open()) {
|
|
249
|
+
<div forSelectContent>
|
|
250
|
+
<button forSelectOption class="select-item" value="es">Spain</button>
|
|
251
|
+
<button forSelectOption class="select-item" value="fr">France</button>
|
|
252
|
+
</div>
|
|
253
|
+
}
|
|
254
|
+
</div>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The consumer drives the mode — bind `[modal]="isCoarsePointer()"` (e.g. from a `(pointer: coarse)` media query) to switch presentation by device. The library does **not** auto-switch on viewport or pointer.
|
|
258
|
+
|
|
259
|
+
What modal mode changes:
|
|
260
|
+
|
|
261
|
+
- **Focus** is trapped inside the surface (Tab / Shift+Tab cycle through the options; they no longer commit-and-advance the way the anchored listbox does). The rest of the page is `inert` while open, and body scroll is locked.
|
|
262
|
+
- **Initial focus** still lands on the selected option (then first / last enabled), via the shared focus algorithm.
|
|
263
|
+
- **`aria-modal="true"`** is reflected on the surface as a hint. The surface keeps `role="listbox"` (several screen readers ignore `aria-modal` outside window roles), so the real modality comes from the `inert` background the shell applies — not from the attribute alone.
|
|
264
|
+
- **Dismiss** (`dismissible`), **return-focus** (`returnFocus`), `ariaLabel`, and the `(autoFocusOnOpen)` / `(autoFocusOnClose)` veto hooks all behave the same as popover mode.
|
|
265
|
+
|
|
266
|
+
The mode is read **once** when `[forSelectContent]` mounts (the two shells are structurally different; switching at runtime would need a remount, and the surface mounts lazily via `@if (open())`, well after `modal` settles). Every **anchored-positioning input is a no-op** in modal mode: `position` (`popper` / `item-aligned`), `side`, `align`, `sideOffset`, `alignOffset`, `sticky`, `hideWhenDetached`, `avoidCollisions`, `collisionPadding`, `arrowPadding`.
|
|
267
|
+
|
|
268
|
+
> **Not** a swipe / snap-point sheet. This is the batteries-included _modal_ presentation of a value field. The draggable bottom-sheet (snap points, swipe-to-dismiss) is a different use case — compose a `ForListbox` inside a `ForDrawer` by hand for that. It loses the form-value wiring, which is why it isn't an internal mode here.
|
|
269
|
+
|
|
270
|
+
## Selection follows focus
|
|
271
|
+
|
|
272
|
+
Single-mode only. Set `selectionFollowsFocus` to also commit `[(value)]` as arrow navigation moves focus — useful for "live preview" UX. Default off; APG calls it optional and recommends caution.
|
|
273
|
+
|
|
274
|
+
```html
|
|
275
|
+
<div forSelect selectionFollowsFocus [(value)]="theme">…</div>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Dismiss events
|
|
279
|
+
|
|
280
|
+
Each dismiss reason emits a vetoable event from `[forSelect]` — call `preventDefault()` on the event to keep the listbox open.
|
|
281
|
+
|
|
282
|
+
| Output | When |
|
|
283
|
+
| ---------------------- | ---------------------------------------------------------------------------- |
|
|
284
|
+
| `(escapeKeyDown)` | Escape pressed while listbox is open. |
|
|
285
|
+
| `(pointerDownOutside)` | Pointer-down outside both trigger and content. |
|
|
286
|
+
| `(focusOutside)` | Focus moves outside both trigger and content. |
|
|
287
|
+
| `(interactOutside)` | Either of the two above (single output for consumers that don't care which). |
|
|
288
|
+
|
|
289
|
+
## Auto-focus events
|
|
290
|
+
|
|
291
|
+
`(autoFocusOnOpen)` / `(autoFocusOnClose)` fire just before the listbox sends focus to the selected option (open) or returns it to the trigger (close). Both deliver a `VetoableEvent` — call `preventDefault()` on the veto to skip the imperative focus move. The listbox stays mounted; only the focus move is vetoed. These are output-shape because Select always routes close transitions through `[(open)]` (via the implicit `openChange` emitter). See [CLAUDE.md › Auto-focus hook shape](../../../../../CLAUDE.md#auto-focus-hook-shape) for why Dialog uses callback-shape inputs instead.
|
|
292
|
+
|
|
293
|
+
## Form integration
|
|
294
|
+
|
|
295
|
+
`[forSelect]` implements `FormValueControl<readonly T[]>`. Pair with the `[formField]` directive for auto-wiring with `@angular/forms/signals`:
|
|
296
|
+
|
|
297
|
+
```html
|
|
298
|
+
<div forSelect [formField]="form.color">
|
|
299
|
+
<button forSelectTrigger class="select-trigger">
|
|
300
|
+
<span forSelectValue placeholder="Color"></span>
|
|
301
|
+
</button>
|
|
302
|
+
…
|
|
303
|
+
</div>
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
For a legacy `<form action="…">` flow, set `[name]` — `[forSelect]` mirrors `[(value)]` into one `<input type="hidden">` per selected value (single produces 0–1 inputs, multi produces N). String values land verbatim in the hidden input; object values default to `JSON.stringify` (override via `[itemToFormValue]`, see below).
|
|
307
|
+
|
|
308
|
+
A single-select consumer usually models the field as `T | null` rather than `readonly T[]`. Bridge it with `forSingleValueField` so the same `[formField]` wiring works unchanged: `[formField]="forSingleValueField(form.color)"`. See [Signal Forms helpers](../signal-forms/README.md).
|
|
309
|
+
|
|
310
|
+
## Object values
|
|
311
|
+
|
|
312
|
+
Real apps usually have richer option models — `{ id, name, ... }` — where the comparison key differs from what you'd serialize for a form. `[forSelect]` is generic over `T` to support that without forcing the consumer to stringify and re-hydrate.
|
|
313
|
+
|
|
314
|
+
Three inputs configure the object behaviour. Defaults make string mode work unchanged:
|
|
315
|
+
|
|
316
|
+
| Input | Default | Purpose |
|
|
317
|
+
| ---------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
318
|
+
| `[isItemEqualToValue]` | `(a, b) => a === b` | How two items compare. Override for object values so selection locates by id (or any stable key). |
|
|
319
|
+
| `[itemToFormValue]` | `(item) => typeof item === 'string' ? item : JSON.stringify(item)` | Serialize an item for the hidden input. Override to emit a per-item id (or any wire format your backend wants). |
|
|
320
|
+
| `[itemToLabel]` | `undefined` | Resolve a selected item's display label without the listbox mounted. Supply it when a pre-set object value must render before the listbox is ever opened (see below). |
|
|
321
|
+
|
|
322
|
+
The visible option label normally comes from the rendered `textContent`, so there's no separate label function — `[forSelectValue]` renders the matching option's text.
|
|
323
|
+
|
|
324
|
+
### Pre-set object values and the `@if (open())` pattern
|
|
325
|
+
|
|
326
|
+
`[forSelectValue]` reads the selected option's label from the rendered option's `textContent`. With the recommended `@if (select.open())` markup the listbox stays unmounted until first opened, so an object value set **before** the user opens the listbox has no option to read from — `[forSelectValue]` shows the serialized form value (`[itemToFormValue]`, e.g. an id) as a last-resort fallback until the listbox is opened once.
|
|
327
|
+
|
|
328
|
+
Supply `[itemToLabel]` to resolve the label directly from the value, independent of the mounted options. It then renders correctly on first paint and never flickers from the id to the real label:
|
|
329
|
+
|
|
330
|
+
```html
|
|
331
|
+
<div
|
|
332
|
+
forSelect
|
|
333
|
+
#select="forSelect"
|
|
334
|
+
[(value)]="city"
|
|
335
|
+
[isItemEqualToValue]="byId"
|
|
336
|
+
[itemToFormValue]="toId"
|
|
337
|
+
[itemToLabel]="toName"
|
|
338
|
+
placeholder="Pick a city"
|
|
339
|
+
>
|
|
340
|
+
<button forSelectTrigger>
|
|
341
|
+
<span forSelectValue></span>
|
|
342
|
+
</button>
|
|
343
|
+
@if (select.open()) {
|
|
344
|
+
<div forSelectContent>
|
|
345
|
+
@for (c of cities(); track c.id) {
|
|
346
|
+
<button forSelectOption [value]="c">{{ c.name }}</button>
|
|
347
|
+
}
|
|
348
|
+
</div>
|
|
349
|
+
}
|
|
350
|
+
</div>
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
readonly toName = (c: City) => c.name;
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
When `[itemToLabel]` is set it is authoritative for every selected value (single and multi mode), so the rendered label is identical whether or not the listbox has been opened. String-value selects render the value verbatim and never need it. Consumers who instead keep `[forSelectContent]` mounted (drop the `@if`) get the option `textContent` for free and don't need `[itemToLabel]`.
|
|
358
|
+
|
|
359
|
+
```html
|
|
360
|
+
<div
|
|
361
|
+
forSelect
|
|
362
|
+
#select="forSelect"
|
|
363
|
+
[(value)]="city"
|
|
364
|
+
[isItemEqualToValue]="byId"
|
|
365
|
+
name="city"
|
|
366
|
+
[itemToFormValue]="toId"
|
|
367
|
+
placeholder="Pick a city"
|
|
368
|
+
>
|
|
369
|
+
<button forSelectTrigger class="select-trigger">
|
|
370
|
+
<span forSelectValue></span>
|
|
371
|
+
</button>
|
|
372
|
+
@if (select.open()) {
|
|
373
|
+
<div forSelectContent>
|
|
374
|
+
@for (c of cities; track c.id) {
|
|
375
|
+
<button forSelectOption class="select-item" [value]="c">{{ c.name }}</button>
|
|
376
|
+
}
|
|
377
|
+
</div>
|
|
378
|
+
}
|
|
379
|
+
</div>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
interface City {
|
|
384
|
+
id: string;
|
|
385
|
+
name: string;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
readonly city = signal<readonly City[]>([]);
|
|
389
|
+
readonly cities = signal<readonly City[]>([
|
|
390
|
+
{ id: 'paris', name: 'Paris' },
|
|
391
|
+
{ id: 'berlin', name: 'Berlin' },
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
readonly byId = (a: City, b: City) => a.id === b.id;
|
|
395
|
+
readonly toId = (c: City) => c.id;
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Multi mode uses the same two inputs — `[(value)]` is a `readonly City[]` and option clicks toggle entries in/out by `isItemEqualToValue`.
|
|
399
|
+
|
|
400
|
+
## Accessibility notes
|
|
401
|
+
|
|
402
|
+
- Apply each option directive to a `<button>` so Space / Enter activation come from native button behavior — the listbox doesn't intercept them.
|
|
403
|
+
- Disabled options keep `tabindex="-1"` and `aria-disabled="true"` (per APG): focusable for screen-reader announcement, but click and keyboard activation are no-ops.
|
|
404
|
+
- `[forSelectSeparator]` is decorative and never registers with the listbox's option collection — it's skipped during navigation and typeahead automatically.
|
|
405
|
+
- `[forSelectGroup]` is purely advisory grouping — options inside still register flatly with the root, so navigation flows through groups without interruption.
|
|
406
|
+
- The trigger is exempt from the dismissable layer's outside-pointer checks, so a click on the trigger while the listbox is open routes through `(click)` (toggle) instead of double-firing as an outside dismissal.
|
|
407
|
+
- **`data-highlighted=""`** is reflected on the focused `[forSelectOption]` so consumers can paint a uniform focus ring shared with the listbox / menu / combobox primitives.
|
|
408
|
+
- **Open highlights the selected option, regardless of how the listbox was opened — an intentional divergence from the menu family.** Initial focus on open lands on the currently-selected option (see [Initial focus on open](#initial-focus-on-open)), and `data-highlighted` follows that focus, so a mouse-opened Select renders the selected option highlighted. This is deliberate: the highlight **marks the current value**, it does not fake a "preselection" that isn't there. It contrasts with the `[forMenu*]` items, whose `data-highlighted` is intent-driven — a pointer open focuses the first item **without** highlighting it ([#644](https://github.com/tutkli/forty-cdk/issues/644) / [#662](https://github.com/tutkli/forty-cdk/issues/662)) — because a menu has no "current value" to mark. `[forListbox]` shows neither effect: it's an embedded roving surface with no open-driven programmatic focus, so its highlight only ever derives from the roving active option. Decided in [#661](https://github.com/tutkli/forty-cdk/issues/661).
|
|
409
|
+
|
|
410
|
+
## Virtualization
|
|
411
|
+
|
|
412
|
+
For selects with thousands of options, bind `[totalCount]` to enable the **virtualized activedescendant focus model** backed by `injectVirtualizer`. The non-virtualized path (no `[totalCount]`) is byte-for-byte unchanged.
|
|
413
|
+
|
|
414
|
+
### Focus-model switch
|
|
415
|
+
|
|
416
|
+
| Mode | Tab stop / focus | Active-option tracking |
|
|
417
|
+
| -------------------------------- | -------------------------------------------------- | ------------------------------------------------------- |
|
|
418
|
+
| Non-virtualized (default) | real DOM focus on each `[forSelectOption]` | DOM `:focus` + `data-highlighted` |
|
|
419
|
+
| Virtualized (`[totalCount]` set) | DOM focus on `[forSelectContent]` (`tabindex="0"`) | `aria-activedescendant` on content + `data-highlighted` |
|
|
420
|
+
|
|
421
|
+
### Inputs and output
|
|
422
|
+
|
|
423
|
+
| Binding | Type | Description |
|
|
424
|
+
| ---------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
425
|
+
| `[totalCount]` | `number` | Full source length. Switches to the virtualized path and populates `aria-setsize` on every rendered option. |
|
|
426
|
+
| `[visibleRange]` | `readonly [number, number]` | Inclusive-exclusive rendered window provided by `injectVirtualizer`'s `.range()`. |
|
|
427
|
+
| `(scrollToIndex)` | `number` | Emitted when navigation reaches an off-screen option. Pass to `injectVirtualizer`'s `scrollToIndex()`. |
|
|
428
|
+
| `[posInSet]` on option | `number` | Zero-based absolute index of the option in the full source. Required per option in the virtualized path. |
|
|
429
|
+
|
|
430
|
+
### Navigation flow
|
|
431
|
+
|
|
432
|
+
Arrow / Home / End keys are handled by `[forSelectContent]` (not the individual options) in the virtualized path. The content delegates to an internal navigator that walks `moveIndex` against the full `totalCount`, using the persisted position snapshot to handle disabled options outside the rendered window. When navigation lands outside the current window, `(scrollToIndex)` fires with the target index; once the option mounts the bridge effect resolves the pending activedescendant.
|
|
433
|
+
|
|
434
|
+
On open, `[forSelectContent]` seeds `aria-activedescendant` to the committed option (scrolling it into view), or the first enabled option when nothing is selected.
|
|
435
|
+
|
|
436
|
+
### Example with `injectVirtualizer`
|
|
437
|
+
|
|
438
|
+
```html
|
|
439
|
+
<div
|
|
440
|
+
forSelect
|
|
441
|
+
#select="forSelect"
|
|
442
|
+
[(value)]="value"
|
|
443
|
+
[totalCount]="items.length"
|
|
444
|
+
[visibleRange]="v.range()"
|
|
445
|
+
(scrollToIndex)="v.scrollToIndex($event, { align: 'auto' })"
|
|
446
|
+
>
|
|
447
|
+
<button forSelectTrigger>
|
|
448
|
+
<span forSelectValue placeholder="Pick an item"></span>
|
|
449
|
+
</button>
|
|
450
|
+
@if (select.open()) {
|
|
451
|
+
<div forSelectContent #scroll style="overflow:auto; max-height:300px; position:relative">
|
|
452
|
+
<div [style.height.px]="v.totalSize()" style="position:relative">
|
|
453
|
+
@for (vi of v.virtualItems(); track vi.key) {
|
|
454
|
+
<button
|
|
455
|
+
forSelectOption
|
|
456
|
+
[value]="items[vi.index]!.id"
|
|
457
|
+
[posInSet]="vi.index"
|
|
458
|
+
[style.transform]="'translateY(' + vi.start + 'px)'"
|
|
459
|
+
style="position:absolute; left:0; right:0"
|
|
460
|
+
>
|
|
461
|
+
{{ items[vi.index]!.label }}
|
|
462
|
+
</button>
|
|
463
|
+
}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
}
|
|
467
|
+
</div>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
|
|
472
|
+
readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
|
|
473
|
+
readonly v = injectVirtualizer({
|
|
474
|
+
count: computed(() => this.items.length),
|
|
475
|
+
estimateSize: () => 36,
|
|
476
|
+
scrollElement: this.scrollElement,
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Intentional limitations
|
|
481
|
+
|
|
482
|
+
- **No multi-select range modifiers in the virtualized path.** Shift+Arrow, Shift+Space, and Ctrl+A are not implemented — range operations require knowledge of every intermediate position, which is unavailable in a windowed render.
|
|
483
|
+
- **Typeahead matches only the rendered window.** `[forSelect]` runs typeahead against the live registered options; options scrolled out of the window are unmounted and invisible to the buffer.
|
|
484
|
+
- **Cold-open committed-index resolution.** On the very first open, if the committed value has never been rendered (the option has never scrolled into the window), the position snapshot is empty and `[forSelect]` falls back to focusing the first enabled option. This mirrors the `[forSelectValue]` / `[itemToLabel]` cold-cache limitation: supply `[itemToLabel]` to render the label and open the listbox once to prime the snapshot.
|
|
485
|
+
|
|
486
|
+
## Wrapping in a design system
|
|
487
|
+
|
|
488
|
+
Both supported wrapper patterns — `hostDirectives` with the exported `FOR_SELECT_HOST_DIRECTIVE_INPUTS` / `FOR_SELECT_HOST_DIRECTIVE_OUTPUTS` name tuples, and subclassing — are documented in [Wrapping form primitives](../../../../../docs/wrapping-form-primitives.md).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Separator
|
|
2
|
+
|
|
3
|
+
Headless implementation of the static [WAI-ARIA Separator pattern](https://www.w3.org/WAI/ARIA/apg/patterns/separator/): a non-focusable line that splits content groups visually and semantically.
|
|
4
|
+
|
|
5
|
+
The focusable divider that resizes two panes is a separate primitive — [`ForPaneResizer`](../pane-resizer/README.md). Keeping them apart means a plain `<hr forSeparator>` never pulls the drag / keyboard-resize code in.
|
|
6
|
+
|
|
7
|
+
## Pieces
|
|
8
|
+
|
|
9
|
+
| Class | Selector | Role |
|
|
10
|
+
| -------------- | ---------------- | ---------------------------------------------------------------------- |
|
|
11
|
+
| `ForSeparator` | `[forSeparator]` | Single attribute directive. Static semantic / decorative divider only. |
|
|
12
|
+
|
|
13
|
+
## Inputs
|
|
14
|
+
|
|
15
|
+
| API | Type | Description |
|
|
16
|
+
| ------------- | ----------------------------------- | ------------------------------------------------------------- |
|
|
17
|
+
| `orientation` | `input<'horizontal' \| 'vertical'>` | Axis the separator divides along. Defaults to `'horizontal'`. |
|
|
18
|
+
| `decorative` | `input<boolean>` | When true, the separator is purely visual (`role="none"`). |
|
|
19
|
+
|
|
20
|
+
The host gets `data-orientation="horizontal" \| "vertical"` for CSS hooks.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Component } from '@angular/core';
|
|
26
|
+
import { ForSeparator } from 'forty-cdk/separator';
|
|
27
|
+
|
|
28
|
+
@Component({
|
|
29
|
+
selector: 'demo-separator',
|
|
30
|
+
imports: [ForSeparator],
|
|
31
|
+
template: `
|
|
32
|
+
<section>
|
|
33
|
+
<h2>Profile</h2>
|
|
34
|
+
<p>…</p>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<hr forSeparator class="separator" />
|
|
38
|
+
|
|
39
|
+
<section>
|
|
40
|
+
<h2>Notifications</h2>
|
|
41
|
+
<p>…</p>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<nav>
|
|
45
|
+
<a href="/a">A</a>
|
|
46
|
+
<span forSeparator class="separator" orientation="vertical" decorative></span>
|
|
47
|
+
<a href="/b">B</a>
|
|
48
|
+
</nav>
|
|
49
|
+
`,
|
|
50
|
+
})
|
|
51
|
+
export class DemoSeparator {}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Styling
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
### Data attributes
|
|
59
|
+
|
|
60
|
+
| Piece | Attribute | Values |
|
|
61
|
+
| ---------------- | ------------------ | -------------------------- |
|
|
62
|
+
| `[forSeparator]` | `data-orientation` | `horizontal` \| `vertical` |
|
|
63
|
+
|
|
64
|
+
```css
|
|
65
|
+
.separator {
|
|
66
|
+
background: var(--border);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.separator[data-orientation='horizontal'] {
|
|
70
|
+
block-size: 1px;
|
|
71
|
+
inline-size: 100%;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.separator[data-orientation='vertical'] {
|
|
75
|
+
inline-size: 1px;
|
|
76
|
+
align-self: stretch;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Accessibility notes
|
|
81
|
+
|
|
82
|
+
- **Static is the only mode.** `[forSeparator]` keeps `role="separator"` and (for vertical) `aria-orientation="vertical"`. Horizontal omits the attribute because it is the ARIA default.
|
|
83
|
+
- **Use `decorative` when redundant.** If the section split is already announced (e.g. headings on either side), set `decorative` so the separator becomes `role="none"` and AT skips it.
|
|
84
|
+
- **Need a resizer?** Reach for [`ForPaneResizer`](../pane-resizer/README.md) — the focusable, draggable, value-carrying divider between two panes.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Signal Forms helpers
|
|
2
|
+
|
|
3
|
+
Small, framework-supported bridges between `@angular/forms/signals` and forty-cdk's form primitives. They contain no UI and no directives — just the glue the `[formField]` directive can't express on its own.
|
|
4
|
+
|
|
5
|
+
## `forSingleValueField` — single-value selection bridge
|
|
6
|
+
|
|
7
|
+
The selection primitives (`ForSelect`, `ForListbox`, `ForCombobox`) model their value as `readonly T[]` — single mode keeps the array at length ≤ 1 (see the [selection value-type contract](../../../../../.claude/rules/conventions.md)). That uniform array shape is what makes one control cover both single and multi selection, and it is the `FormValueControl<readonly T[]>` backing the `[formField]` directive auto-wires to.
|
|
8
|
+
|
|
9
|
+
But a single-select consumer models their domain field as `T | null`, so a `FieldTree<T | null>` cannot bind to the control directly — the value types don't match. `forSingleValueField` adapts the field to the array view the control expects, so the standard `[formField]` wiring works unchanged:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Component, signal } from '@angular/core';
|
|
13
|
+
import { form, FormField } from '@angular/forms/signals';
|
|
14
|
+
import {
|
|
15
|
+
ForSelect,
|
|
16
|
+
ForSelectContent,
|
|
17
|
+
ForSelectOption,
|
|
18
|
+
ForSelectTrigger,
|
|
19
|
+
ForSelectValue,
|
|
20
|
+
} from 'forty-cdk/select';
|
|
21
|
+
import { forSingleValueField } from 'forty-cdk/signal-forms';
|
|
22
|
+
|
|
23
|
+
@Component({
|
|
24
|
+
selector: 'app-country-picker',
|
|
25
|
+
imports: [
|
|
26
|
+
ForSelect,
|
|
27
|
+
ForSelectTrigger,
|
|
28
|
+
ForSelectValue,
|
|
29
|
+
ForSelectContent,
|
|
30
|
+
ForSelectOption,
|
|
31
|
+
FormField,
|
|
32
|
+
],
|
|
33
|
+
template: `
|
|
34
|
+
<div forSelect [formField]="country">
|
|
35
|
+
<button forSelectTrigger>
|
|
36
|
+
<span forSelectValue placeholder="Country"></span>
|
|
37
|
+
</button>
|
|
38
|
+
@if (forSelect.open()) {
|
|
39
|
+
<div forSelectContent>
|
|
40
|
+
<button forSelectOption value="fr">France</button>
|
|
41
|
+
<button forSelectOption value="de">Germany</button>
|
|
42
|
+
</div>
|
|
43
|
+
}
|
|
44
|
+
</div>
|
|
45
|
+
`,
|
|
46
|
+
})
|
|
47
|
+
export class CountryPicker {
|
|
48
|
+
private readonly model = signal({ country: null as string | null });
|
|
49
|
+
protected readonly profile = form(this.model);
|
|
50
|
+
|
|
51
|
+
// `profile.country` is a FieldTree<string | null>; the control expects
|
|
52
|
+
// readonly string[]. Bridge it once and bind the result with [formField].
|
|
53
|
+
protected readonly country = forSingleValueField(this.profile.country);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### What it adapts
|
|
58
|
+
|
|
59
|
+
| Direction | Behaviour |
|
|
60
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| Field → control (read) | `null` reads as `[]`; a value `v` reads as `[v]`. |
|
|
62
|
+
| Control → field (write) | `[]` clears the field to `null`; `[v]` sets it to `v`. In single mode the array never exceeds one. |
|
|
63
|
+
| Everything else | `disabled` / `readonly` / `required` / `invalid` / `errors` / `touched` / `dirty` / `pending` / `name` / validation / touch tracking / focus delegate to the original field. |
|
|
64
|
+
|
|
65
|
+
The value view is **derived** (`computed`) from the original field — there is no second copy of the value to keep in sync, so it honours the "single source of truth" rule (derive, never effect-write).
|
|
66
|
+
|
|
67
|
+
### When you don't need it
|
|
68
|
+
|
|
69
|
+
- **Multi-select** fields are already `readonly T[]` — bind `[formField]` directly, no bridge.
|
|
70
|
+
- **A single field you model as `readonly T[]`** (length ≤ 1) — bind directly. The bridge exists specifically for the `T | null` domain shape.
|
|
71
|
+
|
|
72
|
+
See the [wrapping form primitives guide](../../../../../docs/wrapping-form-primitives.md) for the full picture, including how design-system wrappers re-expose the primitives.
|