laif-ds 1.1.2 → 1.1.4
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/dist/CHANGELOG.md +33 -0
- package/dist/agent-docs/adoption-report.json +39 -45
- package/dist/agent-docs/components/AppEditor.md +51 -13
- package/dist/agent-docs/components/AppSelect.md +305 -90
- package/dist/agent-docs/manifest.json +33 -17
- package/dist/components/editor/plugins/toolbar/font-format-toolbar-plugin.js +34 -30
- package/dist/components/editor/plugins/toolbar/link-toolbar-plugin.js +99 -0
- package/dist/components/editor/themes/editor-theme.js +5 -2
- package/dist/components/ui/app-editor.js +162 -138
- package/dist/components/ui/app-select.js +566 -366
- package/dist/index.d.ts +52 -29
- package/dist/styles.css +1 -1
- package/dist/styles.v3.css +1 -1
- package/package.json +3 -1
|
@@ -14,6 +14,7 @@ A flexible select component supporting both single and multiple selection modes
|
|
|
14
14
|
export interface AppSelectOption {
|
|
15
15
|
value: string | number; // Unique identifier for the option
|
|
16
16
|
label: string | React.ReactNode; // Display label (can be JSX for custom rendering)
|
|
17
|
+
keywords?: string[]; // Search terms; needed for a JSX label to be searchable by text. The first one also names the option (chip remove button, selection summary)
|
|
17
18
|
disabled?: boolean; // Disables selection of this option
|
|
18
19
|
group?: string; // Group name for grouping options
|
|
19
20
|
fixed?: boolean; // In chip mode, prevents removal of this option
|
|
@@ -24,69 +25,112 @@ export interface AppSelectOption {
|
|
|
24
25
|
|
|
25
26
|
## Props
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
###
|
|
30
|
-
|
|
31
|
-
| Prop
|
|
32
|
-
|
|
|
33
|
-
| `options`
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
|
|
56
|
-
###
|
|
28
|
+
Required first, then grouped by function — the same order as the Storybook Docs page. `AppSelectProps` is a union discriminated by `multiple`: see [Types by mode](#types-by-mode) for the props whose type changes with it.
|
|
29
|
+
|
|
30
|
+
### Required
|
|
31
|
+
|
|
32
|
+
| Prop | Type | Default | Description |
|
|
33
|
+
| --------- | ------------------- | ------------ | ---------------------------------------------------------------- |
|
|
34
|
+
| `options` | `AppSelectOption[]` | **required** | The selectable options (see [AppSelectOption](#appselectoption)) |
|
|
35
|
+
|
|
36
|
+
### Value
|
|
37
|
+
|
|
38
|
+
| Prop | Type | Default | Description |
|
|
39
|
+
| --------------- | ------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `multiple` | `boolean` | `false` | Multiple selection. Switches `value`, `defaultValue` and `onValueChange` from a single value to an array |
|
|
41
|
+
| `value` | `string \| number`, or `(string \| number)[]` with `multiple` | `undefined` | Selected value, controlled mode |
|
|
42
|
+
| `defaultValue` | same as `value` | `undefined` | Initial value, uncontrolled mode |
|
|
43
|
+
| `onValueChange` | `(value) => void`, `value` typed as above | `undefined` | Called on every selection change; `undefined` when a single select is cleared |
|
|
44
|
+
|
|
45
|
+
### Behaviour
|
|
46
|
+
|
|
47
|
+
| Prop | Type | Default | Description |
|
|
48
|
+
| ------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
49
|
+
| `searchable` | `boolean` | `false` | Shows the search input and filters the options by label and `keywords` |
|
|
50
|
+
| `creatable` | `boolean` | `false` | Lets the user add the searched text as a new value when no option matches |
|
|
51
|
+
| `selectableAll` | `boolean` | `true` | Multiple only. Tri-state select-all / deselect-all toggle; acts on the search-filtered options, respects `maxSelected` and `fixed` options |
|
|
52
|
+
| `maxSelected` | `number` | `undefined` | Multiple only. Maximum number of selected options; once reached the others are disabled and `maxSelectedMessage` is shown |
|
|
53
|
+
| `isSingleSelectClearable` | `boolean` | `false` | Single only. Shows the X that clears the value |
|
|
54
|
+
| `onClear` | `() => void` | `undefined` | Called when the selection is cleared with the X (in multiple, `fixed` options are kept) |
|
|
55
|
+
| `disabled` | `boolean` | `false` | Disables the select: it does not open, the value does not change, and the trigger leaves the tab order |
|
|
56
|
+
|
|
57
|
+
### Label and form state
|
|
58
|
+
|
|
59
|
+
| Prop | Type | Default | Description |
|
|
60
|
+
| -------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------- |
|
|
61
|
+
| `label` | `string \| React.ReactNode` | `undefined` | Visible label above the select; it is also the trigger's accessible name |
|
|
62
|
+
| `required` | `boolean` | `false` | Shows `*` next to the label and sets `aria-required` |
|
|
63
|
+
| `aria-invalid` | `boolean` | `false` | Error state: destructive border and red `*` on the label, announced by screen readers |
|
|
64
|
+
|
|
65
|
+
### Texts
|
|
66
|
+
|
|
67
|
+
| Prop | Type | Default | Description |
|
|
68
|
+
| -------------------- | ------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------ |
|
|
69
|
+
| `placeholder` | `string` | `"Seleziona..."` | Trigger text when nothing is selected |
|
|
70
|
+
| `searchPlaceholder` | `string` | `"Cerca..."` | Placeholder of the search input |
|
|
71
|
+
| `emptyPlaceholder` | `string` | `"Nessun risultato"` | Shown when the search matches no option |
|
|
72
|
+
| `addItemPlaceholder` | `string` | `"Aggiungi"` | With `creatable`: prefix of the button that adds the searched text |
|
|
73
|
+
| `noGroupLabel` | `string` | `"Nessun gruppo"` | Heading of the options without `group`, when other options have one |
|
|
74
|
+
| `itemCountMessage` | `(selected: number) => string` | `"N elementi selezionati"` (singular for 1) | Multiple without chips: trigger text with the number of selected options |
|
|
75
|
+
| `maxSelectedMessage` | `(max: number) => string` | `"Puoi selezionare fino a N elementi"` | With `maxSelected`: notice shown when the limit is reached |
|
|
76
|
+
|
|
77
|
+
### Appearance
|
|
78
|
+
|
|
79
|
+
| Prop | Type | Default | Description |
|
|
80
|
+
| ------------------------- | ---------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------ |
|
|
81
|
+
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Trigger height and text size (see [Size Variants](#size-variants)) |
|
|
82
|
+
| `showChipsInsteadOfCount` | `boolean` | `false` | Multiple only. Shows the selected options as removable chips in the trigger instead of the count |
|
|
83
|
+
| `renderValue` | `(option: AppSelectOption) => React.ReactNode` | `undefined` | Custom rendering of the selection in the trigger (the value in single, each chip in multiple) |
|
|
84
|
+
| `className` | `string` | `""` | Additional Tailwind classes for the trigger |
|
|
85
|
+
| `labelClassName` | `string` | `""` | Additional Tailwind classes for the label |
|
|
86
|
+
| `wrpClassName` | `string` | `""` | Additional Tailwind classes for the wrapper (label + trigger) |
|
|
87
|
+
|
|
88
|
+
### Accessibility and integration
|
|
89
|
+
|
|
90
|
+
| Prop | Type | Default | Description |
|
|
91
|
+
| ------------------ | --------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
92
|
+
| `id` | `string` | `undefined` | HTML id of the trigger |
|
|
93
|
+
| `data-testid` | `string` | `undefined` | `data-testid` of the trigger, for end-to-end tests (e.g. Playwright) |
|
|
94
|
+
| `aria-label` | `string` | `undefined` (falls back to `placeholder`) | Accessible name of the trigger. Applied ONLY when `label` is absent |
|
|
95
|
+
| `aria-labelledby` | `string` | `undefined` | Id(s) of the element(s) naming the trigger. Applied ONLY when `label` is absent; wins over `aria-label` |
|
|
96
|
+
| `portalContainer` | `HTMLElement \| null` | `undefined` (`<body>`) | Element the options popover portals into. Use it inside a container with its own stacking context (e.g. a fixed full-screen grid), where a `<body>` portal opens behind it |
|
|
97
|
+
| ...HTML attributes | `AppSelectHTMLProps` | — | Forwarded to the trigger, see below |
|
|
98
|
+
|
|
99
|
+
#### HTML attributes on the trigger
|
|
100
|
+
|
|
101
|
+
Any other attribute of a `div` (`aria-*`, `data-*`, `title`, `style`, event handlers such as `onBlur` or `onFocus`) is forwarded to the trigger, the element with `role="combobox"`. Type: `AppSelectHTMLProps`, i.e. `React.HTMLAttributes<HTMLDivElement>` without the props above (so also without `aria-label` / `aria-labelledby`, which are own props), `children`, `dangerouslySetInnerHTML`, `contentEditable`, `defaultValue`, `defaultChecked` and `onChange` (use `onValueChange`). Precedence:
|
|
102
|
+
|
|
103
|
+
- **Kept by the component** (the combobox depends on them): `role`, `tabIndex`, `aria-expanded`, `aria-haspopup`, `aria-controls` (set by Radix), `aria-disabled` (from `disabled`), `aria-required` (from `required`), `aria-invalid` (its own prop). They are excluded from the type
|
|
104
|
+
- **`aria-label` / `aria-labelledby`** follow the naming rules in [Accessible name](#accessible-name): with a visible `label` they are ignored (WCAG 2.5.3), without it `aria-labelledby` wins, then `aria-label`, then `placeholder`
|
|
105
|
+
- **`aria-describedby`**: merged with the id of the selection summary (`"<yours> <summary id>"`), so both are announced
|
|
106
|
+
- **Event handlers** are composed, never replaced: yours runs first. Calling `event.preventDefault()` in `onKeyDown` skips the built-in keyboard opening, and in `onClick` skips opening the popover
|
|
107
|
+
- `className` stays the dedicated prop for the trigger classes. `ref` is not forwarded (the component does not use `forwardRef`)
|
|
108
|
+
|
|
109
|
+
### Types by mode
|
|
57
110
|
|
|
58
111
|
```ts
|
|
59
112
|
export type SingleSelectProps = BaseProps & {
|
|
60
|
-
multiple?: false; //
|
|
61
|
-
value?: string | number;
|
|
62
|
-
defaultValue?: string | number;
|
|
63
|
-
onValueChange?: (value: string | number | undefined) => void;
|
|
64
|
-
renderValue?: (option: AppSelectOption) => React.ReactNode;
|
|
65
|
-
isSingleSelectClearable?: boolean; // Shows clear button (default: false)
|
|
113
|
+
multiple?: false; // single selection (default)
|
|
114
|
+
value?: string | number;
|
|
115
|
+
defaultValue?: string | number;
|
|
116
|
+
onValueChange?: (value: string | number | undefined) => void;
|
|
117
|
+
renderValue?: (option: AppSelectOption) => React.ReactNode;
|
|
66
118
|
};
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Multi Select Props
|
|
70
119
|
|
|
71
|
-
```ts
|
|
72
120
|
export type MultiSelectProps = BaseProps & {
|
|
73
|
-
multiple: true;
|
|
74
|
-
value?: (string | number)[];
|
|
75
|
-
defaultValue?: (string | number)[];
|
|
76
|
-
onValueChange?: (value: (string | number)[]) => void;
|
|
77
|
-
renderValue?: (option: AppSelectOption) => React.ReactNode;
|
|
78
|
-
|
|
79
|
-
showChipsInsteadOfCount?: boolean; // Display chips instead of count (default: false)
|
|
80
|
-
selectableAll?: boolean; // Shows tri-state select-all toggle next to the search bar (default: true)
|
|
121
|
+
multiple: true;
|
|
122
|
+
value?: (string | number)[];
|
|
123
|
+
defaultValue?: (string | number)[];
|
|
124
|
+
onValueChange?: (value: (string | number)[]) => void;
|
|
125
|
+
renderValue?: (option: AppSelectOption) => React.ReactNode;
|
|
126
|
+
selectableAll?: boolean; // default: true
|
|
81
127
|
};
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
### Combined Type
|
|
85
128
|
|
|
86
|
-
```ts
|
|
87
129
|
export type AppSelectProps = SingleSelectProps | MultiSelectProps;
|
|
88
130
|
```
|
|
89
131
|
|
|
132
|
+
`BaseProps` holds every other prop above, common to both modes. `isSingleSelectClearable`, `maxSelected` and `showChipsInsteadOfCount` are in `BaseProps` too, but only take effect in the mode noted in their row.
|
|
133
|
+
|
|
90
134
|
---
|
|
91
135
|
|
|
92
136
|
## Prop Details
|
|
@@ -103,6 +147,8 @@ The `size` prop controls the trigger height and text size:
|
|
|
103
147
|
|
|
104
148
|
The `label` property in `AppSelectOption` supports both strings and React nodes, allowing for rich custom rendering with icons, badges, or any JSX content. When using JSX labels, the component properly handles them without wrapping issues (React 19 compatible).
|
|
105
149
|
|
|
150
|
+
A JSX label has no text the search can read, so pass `keywords` (for example `["Mario Rossi", "mario.rossi@example.com"]`): the search matches any of them, and the first one is used as the option's text name in the chip remove button (`"Rimuovi Mario Rossi"`) and in the selection summary. Without `keywords`, a JSX-labelled option only matches on `String(value)`.
|
|
151
|
+
|
|
106
152
|
### Controlled vs Uncontrolled
|
|
107
153
|
|
|
108
154
|
- **Controlled**: Component is controlled when the `value` prop is provided (checked via `props.hasOwnProperty('value')`)
|
|
@@ -119,25 +165,54 @@ The `label` property in `AppSelectOption` supports both strings and React nodes,
|
|
|
119
165
|
|
|
120
166
|
- Clicking an option selects it and closes the popover
|
|
121
167
|
- Only one option can be selected at a time
|
|
122
|
-
- Clear button appears when `isSingleSelectClearable={true}` and
|
|
168
|
+
- Clear button appears when `isSingleSelectClearable={true}` and an option is actually selected
|
|
123
169
|
- Clearing sets value to `undefined`
|
|
170
|
+
- `value=""` counts as **no selection**: the trigger shows `placeholder` and no clear button, unless an option really has `value: ""` (e.g. an "All" entry), which is then shown as selected. In multiple mode a `""` with no matching option is ignored (no empty chip); the next change emits the array without it
|
|
124
171
|
|
|
125
172
|
#### Multiple Select
|
|
126
173
|
|
|
127
174
|
- Clicking an option toggles its selection state
|
|
128
175
|
- Popover remains open for multiple selections
|
|
129
176
|
- Selected count or chips displayed in trigger
|
|
130
|
-
- Clear button
|
|
131
|
-
- Clearing sets value to empty array `[]`
|
|
177
|
+
- Clear button (`aria-label="Rimuovi tutte le selezioni"`) visible when at least one selected option can be removed
|
|
178
|
+
- Clearing sets value to an empty array `[]`, except for `fixed: true` options, which stay selected
|
|
179
|
+
- The list opens scrolled to the top, since the selection is listed as badges above the search (single select instead scrolls to its selected option)
|
|
180
|
+
|
|
181
|
+
### Popover Placement (Multiple)
|
|
182
|
+
|
|
183
|
+
In multiple mode the popover opens **on top of the trigger** instead of below it (single select still opens 4px below the trigger):
|
|
184
|
+
|
|
185
|
+
- It has the trigger's width and covers it from its top edge; its first row (on the trigger's input surface, `bg-d-input`) sits exactly where the trigger content was, so the chips do not move when it opens (same horizontal padding, first row as tall as the trigger, for every `size`). This pixel continuity holds for the default chips: with `renderValue` the trigger shows your custom content while the popover always shows the standard badges, so positions can change
|
|
186
|
+
- Below the top row come the search / select-all row and the option list. There is no chevron and no clear-all inside the popover: close it with `Escape` or a click outside. `Escape` returns focus to the trigger
|
|
187
|
+
- With `searchable`, every open (pointer or keyboard) focuses the search input. Without it, a pointer open moves focus onto the list root: arrow keys work at once, `Shift+Tab` reaches the badges' remove buttons
|
|
188
|
+
- When there is no room below, Radix flips it upwards: it then covers the trigger aligned to its **bottom** edge, and the order is **reversed** so the top row still lands on the trigger — from the top: option list, search / select-all, then the badges row at the bottom, with its first row on the trigger and further rows growing upwards. Only the presentation is reversed: DOM, reading and `Tab` order stay the same as the non-flipped layout (badges, then search, then list), so in the flipped layout `Tab` moves from the badges upwards
|
|
189
|
+
- Its height is capped to the space Radix reports as available (`--radix-popover-content-available-height`): on a short viewport the badges area shrinks down to one row and scrolls, and the option list shrinks and scrolls, instead of leaving the viewport
|
|
190
|
+
- It only fades in and out: the slide/zoom used by the other popovers would move the chips it lands on
|
|
191
|
+
- With `aria-invalid`, the popover border turns destructive, like the trigger it covers
|
|
192
|
+
- The top row is always there: the selected badges, or — with no selection — a row as tall as the trigger showing `placeholder` in the trigger's muted style (hidden from assistive tech, since the trigger already announces it)
|
|
193
|
+
|
|
194
|
+
### Selected Badges
|
|
195
|
+
|
|
196
|
+
In multiple mode (both count and chips display), when at least one value is selected, the top row of the dropdown — above the search bar and the select-all toggle — lists the selection as badges:
|
|
197
|
+
|
|
198
|
+
- One badge per selected value, in `options` order followed by created values. It is a live view: it updates as you toggle options, add or remove values
|
|
199
|
+
- Badges wrap onto several lines under the first one; the area has a maximum height (`max-h-24`, about three rows) and scrolls vertically, so a long selection does not push the option list away
|
|
200
|
+
- When every badge is fixed or disabled (no remove button), the area itself is focusable, so it can still be scrolled from the keyboard
|
|
201
|
+
- Badges look like the trigger chips (a long label is truncated), but always use the standard badge, even when `renderValue` is set, because their job is removing values
|
|
202
|
+
- Each badge's X (`aria-label="Rimuovi <label>"`) removes the value, keeps the popover open and does not steal focus from the search input; the option's checkbox updates accordingly. These X buttons are in the tab order: this area is the accessible way to remove selections (`role="list"`, `aria-label="Selezionati"`)
|
|
203
|
+
- `fixed: true` and `disabled` options show no X
|
|
204
|
+
- Created values (not part of `options`) appear here, labelled with their value, and can be removed
|
|
205
|
+
- Options stay in their normal groups, with their checkbox; selected `fixed: true` options are checked and cannot be toggled, selected `disabled` options stay disabled
|
|
132
206
|
|
|
133
207
|
### Search Functionality
|
|
134
208
|
|
|
135
209
|
When `searchable={true}`:
|
|
136
210
|
|
|
137
211
|
- Search input appears at the top of the popover
|
|
138
|
-
- Options are filtered client-side
|
|
212
|
+
- Options are filtered client-side on the label text plus the option's `keywords`; an option with a JSX label and no `keywords` is matched on `String(value)`
|
|
139
213
|
- Search is case-insensitive
|
|
140
214
|
- Empty state shows `emptyPlaceholder` when no matches
|
|
215
|
+
- Each option is identified by its `value`, so options sharing a label across different groups stay distinct for highlighting and keyboard navigation
|
|
141
216
|
|
|
142
217
|
### Creatable Options
|
|
143
218
|
|
|
@@ -153,7 +228,7 @@ When `creatable={true}`:
|
|
|
153
228
|
|
|
154
229
|
When options have a `group` property:
|
|
155
230
|
|
|
156
|
-
- Options are automatically grouped under headings
|
|
231
|
+
- Options are automatically grouped under headings, each rendered on a muted background (`bg-d-muted`) to set it apart from the selectable rows
|
|
157
232
|
- Groups are rendered in the order they appear in the options array
|
|
158
233
|
- Options without a group value (empty string, undefined, or missing) appear under the `noGroupLabel` heading
|
|
159
234
|
- Grouping is based on the `group` property of `AppSelectOption`
|
|
@@ -168,7 +243,7 @@ In multiple mode with `selectableAll={true}` (default):
|
|
|
168
243
|
- **`SquareCheck`** icon → all filtered options selected (or `maxSelected` cap reached); click deselects only the currently-filtered items — **out-of-view selections and `fixed: true` items are preserved**
|
|
169
244
|
- **`SquareDot`** icon (disabled) → search yields no matches; the button stays visible but inactive so the layout doesn't collapse
|
|
170
245
|
- Both actions are scoped to the current search filter — symmetric, predictable, and prevents accidental data loss
|
|
171
|
-
- Uses `role="checkbox"` with dynamic `aria-checked` (`true` / `false` / `"mixed"`) and a hardcoded
|
|
246
|
+
- Uses `role="checkbox"` with dynamic `aria-checked` (`true` / `false` / `"mixed"`) and a hardcoded Italian `aria-label` (`"Seleziona tutto"` / `"Deseleziona tutto"`)
|
|
172
247
|
- Available only on multi-select; type-rejected on single-select. Opt out with `selectableAll={false}`
|
|
173
248
|
|
|
174
249
|
### Max Selection Limit
|
|
@@ -184,10 +259,12 @@ In multiple mode with `maxSelected` set:
|
|
|
184
259
|
|
|
185
260
|
In multiple mode with `showChipsInsteadOfCount={true}`:
|
|
186
261
|
|
|
187
|
-
- Shows individual badge chips for each selected item
|
|
188
|
-
-
|
|
189
|
-
-
|
|
190
|
-
-
|
|
262
|
+
- Shows individual badge chips for each selected item, in `options` order followed by created values
|
|
263
|
+
- Chips stay on one line and never scroll. When they overflow, the right edge of the row fades out (a CSS `mask-image`, so it works on any trigger background); the clear button and the chevron are never faded
|
|
264
|
+
- Chips that end under the fade, or beyond it, are inert: clicking there opens the dropdown. The first chip always stays interactive, and a single very long label is truncated with an ellipsis
|
|
265
|
+
- A chip is removed **only from its X button** (`aria-label="Rimuovi <label>"`); clicking the chip body opens the dropdown
|
|
266
|
+
- Options with `fixed={true}` (and selected `disabled` options) show chips without X button (cannot be removed)
|
|
267
|
+
- The trigger takes the width of its parent: put it in a container with a defined width (or a constrained flex/grid cell), otherwise a long selection makes it grow instead of fading
|
|
191
268
|
|
|
192
269
|
### Disabled Options
|
|
193
270
|
|
|
@@ -197,11 +274,30 @@ Options with `disabled={true}`:
|
|
|
197
274
|
- Cannot be selected or deselected
|
|
198
275
|
- Cursor changes to `not-allowed`
|
|
199
276
|
|
|
277
|
+
### Accessibility
|
|
278
|
+
|
|
279
|
+
- The trigger has `role="combobox"`, `aria-expanded`, `aria-haspopup="dialog"` (the popover is a Radix dialog holding badges, search and list) and `aria-disabled`, and is in the tab order (`tabIndex={0}`, `-1` when disabled). It stays a `div` because it contains buttons
|
|
280
|
+
- `Enter`, `Space` or `ArrowDown` on the focused trigger opens the popover and moves focus into the list (the search input when `searchable`). A pointer open focuses the search input when `searchable`; otherwise it keeps focus on the trigger (single) or moves it to the list root (multiple). `Escape` closes it and returns focus to the trigger
|
|
281
|
+
- Accessible name: see [Accessible name](#accessible-name) — the visible `label` (plus the value, in single mode) when set, otherwise `aria-labelledby` / `aria-label` / `placeholder`
|
|
282
|
+
- The trigger shows its focus ring on keyboard focus only (`focus-visible`), not after a mouse pick
|
|
283
|
+
- In multiple mode the visible count or chips row is `aria-hidden`; the trigger's `aria-describedby` points to a visually hidden summary of the whole selection (`"3 selezionati: React, Vue, Svelte"`), chips under the fade included
|
|
284
|
+
- Clear buttons are real `<button>` elements with Italian labels (`"Rimuovi selezione"`, `"Rimuovi tutte le selezioni"`). The trigger's chip X buttons are outside the tab order, since the row is `aria-hidden`; keyboard users remove values from the badges at the top of the dropdown (reachable with `Tab`) or from the list
|
|
285
|
+
|
|
286
|
+
### Form Label
|
|
287
|
+
|
|
288
|
+
Same label behaviour as `Input`, driven by props since there is no native `<input>` to validate:
|
|
289
|
+
|
|
290
|
+
- `required` adds `*` after the label and `aria-required` on the trigger
|
|
291
|
+
- `aria-invalid` (e.g. `!!errors.field` from react-hook-form) sets `aria-invalid` on the trigger: destructive border (and ring while open or focused, the error wins over the focus ring) and a red `*`, shown even when not `required`
|
|
292
|
+
- The asterisk is `aria-hidden`: the meaning is carried by `aria-required` / `aria-invalid`
|
|
293
|
+
- The trigger is a `div`, so the label cannot use `htmlFor`: it names the trigger via `aria-labelledby`, and clicking it focuses the trigger without opening the popover (like a native `<select>`). Nothing happens when `disabled`
|
|
294
|
+
- No hidden input: the value does not take part in native `FormData` / HTML validation, and no error message is rendered — show it yourself (as `AppForm` does)
|
|
295
|
+
|
|
200
296
|
### Disabled Component
|
|
201
297
|
|
|
202
298
|
When `disabled={true}`:
|
|
203
299
|
|
|
204
|
-
- Entire component is non-interactive
|
|
300
|
+
- Entire component is non-interactive: the trigger leaves the tab order, and chip / clear buttons do nothing
|
|
205
301
|
- Trigger shows reduced opacity
|
|
206
302
|
- Popover cannot be opened
|
|
207
303
|
- Cursor changes to `not-allowed`
|
|
@@ -380,6 +476,47 @@ export function CustomLabelsSelect() {
|
|
|
380
476
|
}
|
|
381
477
|
```
|
|
382
478
|
|
|
479
|
+
### Searchable JSX Labels
|
|
480
|
+
|
|
481
|
+
```tsx
|
|
482
|
+
import { AppSelect, AppSelectOption } from "laif-ds";
|
|
483
|
+
import { useState } from "react";
|
|
484
|
+
|
|
485
|
+
const users = [
|
|
486
|
+
{ id: 1, name: "Mario Rossi", email: "mario.rossi@example.com" },
|
|
487
|
+
{ id: 2, name: "Anna Bianchi", email: "anna.bianchi@example.com" },
|
|
488
|
+
];
|
|
489
|
+
|
|
490
|
+
// Two-line option: the label is JSX, so `keywords` carries the searchable text.
|
|
491
|
+
const options: AppSelectOption[] = users.map((u) => ({
|
|
492
|
+
value: u.id,
|
|
493
|
+
label: (
|
|
494
|
+
<div className="flex flex-col">
|
|
495
|
+
<span>{u.name}</span>
|
|
496
|
+
<span className="text-d-muted-foreground text-xs">{u.email}</span>
|
|
497
|
+
</div>
|
|
498
|
+
),
|
|
499
|
+
keywords: [u.name, u.email],
|
|
500
|
+
}));
|
|
501
|
+
|
|
502
|
+
export function UsersSelect() {
|
|
503
|
+
const [value, setValue] = useState<(string | number)[]>([]);
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<AppSelect
|
|
507
|
+
multiple
|
|
508
|
+
searchable
|
|
509
|
+
showChipsInsteadOfCount
|
|
510
|
+
options={options}
|
|
511
|
+
value={value}
|
|
512
|
+
onValueChange={setValue}
|
|
513
|
+
label="Users"
|
|
514
|
+
placeholder="Search by name or email"
|
|
515
|
+
/>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
383
520
|
### Clearable Single Select
|
|
384
521
|
|
|
385
522
|
```tsx
|
|
@@ -409,6 +546,48 @@ export function ClearableSelect() {
|
|
|
409
546
|
}
|
|
410
547
|
```
|
|
411
548
|
|
|
549
|
+
### Required with Validation
|
|
550
|
+
|
|
551
|
+
```tsx
|
|
552
|
+
import { AppSelect, Button } from "laif-ds";
|
|
553
|
+
import { Controller, useForm } from "react-hook-form";
|
|
554
|
+
|
|
555
|
+
const options = [
|
|
556
|
+
{ value: "draft", label: "Draft" },
|
|
557
|
+
{ value: "published", label: "Published" },
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
export function RequiredSelect() {
|
|
561
|
+
const { control, handleSubmit } = useForm<{ status?: string }>();
|
|
562
|
+
|
|
563
|
+
return (
|
|
564
|
+
<form onSubmit={handleSubmit(console.log)}>
|
|
565
|
+
<Controller
|
|
566
|
+
name="status"
|
|
567
|
+
control={control}
|
|
568
|
+
rules={{ required: true }}
|
|
569
|
+
render={({ field, fieldState }) => (
|
|
570
|
+
<>
|
|
571
|
+
<AppSelect
|
|
572
|
+
options={options}
|
|
573
|
+
value={field.value}
|
|
574
|
+
onValueChange={field.onChange}
|
|
575
|
+
label="Status"
|
|
576
|
+
required
|
|
577
|
+
aria-invalid={!!fieldState.error}
|
|
578
|
+
/>
|
|
579
|
+
{fieldState.error && (
|
|
580
|
+
<p className="text-d-destructive text-sm">Campo obbligatorio</p>
|
|
581
|
+
)}
|
|
582
|
+
</>
|
|
583
|
+
)}
|
|
584
|
+
/>
|
|
585
|
+
<Button type="submit">Salva</Button>
|
|
586
|
+
</form>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
412
591
|
### With Disabled Options
|
|
413
592
|
|
|
414
593
|
```tsx
|
|
@@ -468,6 +647,40 @@ export function SelectAllExample() {
|
|
|
468
647
|
}
|
|
469
648
|
```
|
|
470
649
|
|
|
650
|
+
### Help Text via `aria-describedby`
|
|
651
|
+
|
|
652
|
+
```tsx
|
|
653
|
+
import { AppSelect } from "laif-ds";
|
|
654
|
+
import { useState } from "react";
|
|
655
|
+
|
|
656
|
+
export function FrameworkWithHelp() {
|
|
657
|
+
const [values, setValues] = useState<(string | number)[]>([]);
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<div className="w-80 space-y-1">
|
|
661
|
+
<AppSelect
|
|
662
|
+
multiple
|
|
663
|
+
showChipsInsteadOfCount
|
|
664
|
+
label="Framework"
|
|
665
|
+
options={[
|
|
666
|
+
{ value: "react", label: "React" },
|
|
667
|
+
{ value: "vue", label: "Vue" },
|
|
668
|
+
]}
|
|
669
|
+
value={values}
|
|
670
|
+
onValueChange={setValues}
|
|
671
|
+
// Forwarded to the trigger and merged with the selection summary
|
|
672
|
+
aria-describedby="framework-help"
|
|
673
|
+
data-analytics-id="framework-select"
|
|
674
|
+
onBlur={() => console.log("trigger blurred")}
|
|
675
|
+
/>
|
|
676
|
+
<p id="framework-help" className="text-d-muted-foreground text-xs">
|
|
677
|
+
Choose the frameworks used in the project.
|
|
678
|
+
</p>
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
```
|
|
683
|
+
|
|
471
684
|
### Custom Render Value
|
|
472
685
|
|
|
473
686
|
```tsx
|
|
@@ -507,55 +720,57 @@ export function CustomRenderSelect() {
|
|
|
507
720
|
|
|
508
721
|
The trigger is a `role="combobox"`. How it is named depends on whether `label` is set:
|
|
509
722
|
|
|
510
|
-
- **with `label`**: `aria-labelledby` points at the label plus the current value (`"<labelId> <valueId>"`).
|
|
511
|
-
- **without `label`**: the trigger falls back to whatever the consumer passes — `aria-labelledby` first, then `aria-label` — and to
|
|
512
|
-
|
|
513
|
-
Story: `SenzaEtichettaNomeAccessibile`.
|
|
723
|
+
- **with `label`**: `aria-labelledby` points at the visible label — plus the current value in single mode (`"<labelId> <valueId>"`). In multiple mode only the label: the chips are `aria-hidden` and contain the «Rimuovi …» buttons, and the selection is announced through the summary in `aria-describedby`. An `aria-label` / `aria-labelledby` passed by the consumer is ignored in this branch on purpose — a second name next to a visible label is a WCAG 2.5.3 risk.
|
|
724
|
+
- **without `label`**: the trigger falls back to whatever the consumer passes — `aria-labelledby` first, then `aria-label` — and to `placeholder` if neither is passed, so the tab stop is never nameless (`combobox` is not named from content, WCAG 4.1.2). It does not fall back to the value, which would make the name change at every pick.
|
|
514
725
|
|
|
515
|
-
|
|
726
|
+
The option list (`[cmdk-list]`, `role="listbox"`) is named the same way instead of cmdk's English default («Suggestions»), and so is the popover (`role="dialog"`). In multiple mode each option carries `aria-checked` (cmdk's `aria-selected` is the highlight, not the selection), and the checkbox inside it is decorative (`aria-hidden`, out of the tab order).
|
|
516
727
|
|
|
517
|
-
|
|
728
|
+
Story: `SenzaEtichettaNomeAccessibile` (in `app-select.test.stories.tsx`) and the stories in `app-select-fix-nome-accessibile.stories.tsx`.
|
|
518
729
|
|
|
519
|
-
|
|
730
|
+
### Keyboard and focus
|
|
520
731
|
|
|
521
|
-
|
|
732
|
+
- On the closed trigger: `Enter`, `Space` or `ArrowDown` open the popover.
|
|
733
|
+
- **Focus moves into the popover** (there is no `aria-activedescendant`: the focused element is the real one). With `searchable` it always lands on the search input, from the keyboard or the pointer. Without it, a keyboard open focuses the list; a pointer open focuses the list root in multiple mode (the trigger is covered) and keeps focus on the trigger in single mode, where `Enter` / `Space` / `ArrowDown` then move focus into the list.
|
|
734
|
+
- In the list: no option is highlighted on open; the first arrow key highlights the first option, typing highlights the first match, `Enter` picks the highlighted option (disabled ones are skipped). `Escape` closes and returns focus to the trigger.
|
|
735
|
+
- The trigger declares `aria-haspopup="dialog"` and Radix's `aria-controls`, which points at the popover content — the dialog that holds the badges, the search and the `role="listbox"` list.
|
|
522
736
|
|
|
523
|
-
The
|
|
737
|
+
### The popover is not modal
|
|
524
738
|
|
|
525
|
-
|
|
739
|
+
The popover is a Radix `Popover` with `modal={false}`, even though focus moves into it (see above). With `modal`, Radix would call `hideOthers()` — the trigger would sit in an `aria-hidden="true"` subtree while `aria-expanded="true"`, unreadable exactly while open — and `disableOutsidePointerEvents`, which blocks the first click on any other control (in `AppDataGrid` every filter or page-size change after a select needed one extra click). A select-only combobox is not modal per APG.
|
|
526
740
|
|
|
527
|
-
|
|
741
|
+
What that means in practice (measured with the two settings):
|
|
528
742
|
|
|
529
|
-
| Situation
|
|
530
|
-
|
|
|
531
|
-
| Trigger inside an `aria-hidden` subtree while the list is open | yes
|
|
532
|
-
| `AppSelect` nested in another `Popover`, ONE click on a target outside both |
|
|
533
|
-
|
|
|
534
|
-
|
|
|
535
|
-
| `Tab`
|
|
743
|
+
| Situation | `modal={false}` (current) | with `modal` |
|
|
744
|
+
| --------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
745
|
+
| Trigger inside an `aria-hidden` subtree while the list is open | no | yes |
|
|
746
|
+
| `AppSelect` nested in another `Popover`, ONE click on a target outside both | both close and the target's `onClick` fires | the select closes, the surrounding Popover stays open, the target's `onClick` does not fire |
|
|
747
|
+
| Page scroll with the list open | the page scrolls under the open list | locked (`data-scroll-locked`, `overflow: hidden`, `wheel` prevented) |
|
|
748
|
+
| Wheel / touch on the list inside a `Dialog`, `Sheet` or `Drawer` | scrolls (own `RemoveScroll`, see below) | scrolls |
|
|
749
|
+
| `Tab` / `Shift+Tab` at the edges of the popover | focus wraps inside it (Radix's `FocusScope` loops in both modes) | focus wraps inside it |
|
|
536
750
|
|
|
537
|
-
|
|
751
|
+
Note: assigning `scrollTop` from script is NOT a probe for the scroll lock — `RemoveScroll` cancels the scroll _event_ and hides the scrollbar, it does not disable the API.
|
|
538
752
|
|
|
539
|
-
|
|
540
|
-
- `trapFocus` is inert in this component: `onOpenAutoFocus` is prevented, so focus never enters the portal and Radix's `FocusScope` has nothing to pull back. The `Tab` difference in the table comes from `onFocusOutside`, not from the focus trap.
|
|
753
|
+
**The list keeps its own scroll lock.** Overlays (`Dialog`, `Sheet`, `Drawer`) lock scrolling with `react-remove-scroll`, which cancels wheel and touch on anything outside the overlay's content — and the list is portalled to `<body>`, outside it. Only the most recent lock acts, so the popover content is wrapped in its own `RemoveScroll`, the one `modal` used to bring. It never locks the body (`removeScrollBar={false}`), and it isolates only when another lock is already active (the body carries `data-scroll-locked`): inside an overlay, wheel and touch on the backdrop stay cancelled while the list is open (iOS ignores `overflow: hidden` on touch); outside overlays it isolates nothing (`noIsolation`), so the "page scroll" row above is unchanged. Without it, from v1.1.0 the list did not scroll with the wheel inside any overlay.
|
|
541
754
|
|
|
542
|
-
Stories: `DentroUnPopoverAnnidato`, `PaginaScorreEFuocoNonIntrappolato`, `DentroUnDialogModale`.
|
|
755
|
+
Stories (in `app-select.test.stories.tsx`): `DentroUnPopoverAnnidato`, `PaginaScorreEFuocoNonIntrappolato`, `DentroUnDialogModale`, `CongedoDaTastieraEClickFuori`, `ScorrimentoNegliOverlay`.
|
|
543
756
|
|
|
544
757
|
### Residual limits
|
|
545
758
|
|
|
546
|
-
- **A click outside
|
|
759
|
+
- **A click outside also activates what is under the pointer.** In a nested layout — an `AppSelect` inside another `Popover`, like the DataTable sorting panel — one click outside dismisses BOTH layers and fires the underlying handler. A consumer that needs a "first click only closes" guard has to add it at that call site (e.g. `onInteractOutside` on the surrounding layer).
|
|
547
760
|
- **The page scrolls under the open list.** The list stays anchored to the trigger (Radix repositions it), but the content behind it moves.
|
|
761
|
+
- **`Tab` does not leave the open popover**: it cycles through search, select-all and the badges' remove buttons, so the covered trigger and its clear button are never focused while hidden. Close with `Escape` (focus returns to the trigger once the popover has faded out) or a click outside.
|
|
548
762
|
- Inside a modal `Dialog` the non-modal popover still works: it opens after the dialog, so its dismissable layer sits higher in the stack and receives `pointer-events: auto` (measured by `DentroUnDialogModale`, which picks an option with the pointer).
|
|
549
763
|
|
|
550
764
|
---
|
|
551
765
|
|
|
552
766
|
## Notes
|
|
553
767
|
|
|
768
|
+
- **Storybook**: `App/AppSelect` holds one story per main behaviour; the regression tests (`play` functions on keyboard, focus, accessible name, layers and scroll) live in `app-select.test.stories.tsx`, hidden from the sidebar and the Docs page (`!dev`, `!autodocs`) but run by `yarn spec`
|
|
554
769
|
- **React 19 Compatible**: Properly handles JSX labels without ref warnings
|
|
555
|
-
- **Responsive Width**: Popover automatically matches trigger width
|
|
556
|
-
- **Keyboard Navigation**: Full keyboard support via Command component
|
|
557
|
-
- **Accessibility**:
|
|
770
|
+
- **Responsive Width**: Popover automatically matches trigger width (and, in multiple mode, covers the trigger — see [Popover Placement](#popover-placement-multiple))
|
|
771
|
+
- **Keyboard Navigation**: Full keyboard support via Command component. No option is highlighted when the popover opens; the first arrow key press highlights the first option, and typing a search highlights the first match so `Enter` picks it
|
|
772
|
+
- **Accessibility**: Combobox semantics, keyboard opening and a full selection summary on the trigger (see [Accessibility](#accessibility))
|
|
558
773
|
- **Performance**: Efficient rendering with useMemo for computed values
|
|
559
774
|
- **Custom Rendering**: Use `renderValue` prop to customize how selected options appear in the trigger
|
|
560
775
|
- **Controlled Component Detection**: Uses `props.hasOwnProperty('value')` to determine if component is controlled, ensuring stable behavior even when value is `undefined`
|
|
561
|
-
- **Mobile Keyboard**:
|
|
776
|
+
- **Mobile Keyboard**: with `searchable`, opening the popover focuses the search input, so on touch devices the soft keyboard pops up on open. Without `searchable` no input is focused and no keyboard appears.
|