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.
@@ -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
- The component uses discriminated union types based on the `multiple` prop.
28
-
29
- ### Base Props (Common to both modes)
30
-
31
- | Prop | Type | Default | Description |
32
- | -------------------- | ------------------------------ | ------------------------------------------------ | ----------------------------------------------------------- |
33
- | `options` | `AppSelectOption[]` | **required** | Array of selectable options |
34
- | `placeholder` | `string` | `"Seleziona..."` | Text shown in trigger when no selection |
35
- | `searchPlaceholder` | `string` | `"Cerca..."` | Placeholder for search input |
36
- | `emptyPlaceholder` | `string` | `"Nessun risultato"` | Text shown when no options match search |
37
- | `addItemPlaceholder` | `string` | `"Aggiungi"` | Text prefix for creating new items |
38
- | `noGroupLabel` | `string` | `"Nessun gruppo"` | Label shown for options without a group |
39
- | `itemCountMessage` | `(selected: number) => string` | `(n) => "${n} elementi selezionati"` | Message function for selected count display |
40
- | `maxSelectedMessage` | `(max: number) => string` | `(m) => "Puoi selezionare fino a ${m} elementi"` | Message shown when max selection reached |
41
- | `label` | `string \| React.ReactNode` | `undefined` | Label displayed above the select |
42
- | `labelClassName` | `string` | `""` | Additional Tailwind classes for the label |
43
- | `className` | `string` | `""` | Additional Tailwind classes for the trigger |
44
- | `wrpClassName` | `string` | `""` | Additional Tailwind classes for the wrapper |
45
- | `searchable` | `boolean` | `false` | Enables search/filter functionality |
46
- | `creatable` | `boolean` | `false` | Allows creating new options from search input |
47
- | `disabled` | `boolean` | `false` | Disables the entire select component |
48
- | `size` | `"sm" \| "default" \| "lg"` | `"default"` | Size variant affecting height and text size |
49
- | `onClear` | `() => void` | `undefined` | Callback fired when clear button is clicked |
50
- | `id` | `string` | `undefined` | HTML id attribute for the trigger (useful for testing) |
51
- | `data-testid` | `string` | `undefined` | Test identifier attribute for E2E testing (e.g. Playwright) |
52
- | `aria-label` | `string` | `undefined` | Accessible name forwarded to the trigger. Applied ONLY when `label` is absent |
53
- | `aria-labelledby` | `string` | `undefined` | Id(s) of external element(s) naming the trigger. Applied ONLY when `label` is absent |
54
- | `portalContainer` | `HTMLElement \| null` | `undefined` (`<body>`) | Element the options popover portals into. Use it when the select lives inside a container with its own stacking context (e.g. a fixed full-screen grid), where a `<body>` portal would open behind it |
55
-
56
- ### Single Select Props
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; // Single selection mode (default)
61
- value?: string | number; // Controlled value
62
- defaultValue?: string | number; // Uncontrolled default value
63
- onValueChange?: (value: string | number | undefined) => void; // Value change callback
64
- renderValue?: (option: AppSelectOption) => React.ReactNode; // Custom render for selected value
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; // Multiple selection mode
74
- value?: (string | number)[]; // Controlled values array
75
- defaultValue?: (string | number)[]; // Uncontrolled default values
76
- onValueChange?: (value: (string | number)[]) => void; // Value change callback
77
- renderValue?: (option: AppSelectOption) => React.ReactNode; // Custom render for selected values
78
- maxSelected?: number; // Maximum number of selectable items
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 a value is selected
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 always visible when selections exist
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 based on label text
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 English `aria-label` (no per-project i18n required)
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
- - Each chip has an X button to remove the item
189
- - Options with `fixed={true}` show chips without X button (cannot be removed)
190
- - Chips are scrollable horizontally if they overflow
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>"`). A `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.
511
- - **without `label`**: the trigger falls back to whatever the consumer passes — `aria-labelledby` first, then `aria-label` — and to NOTHING if neither is passed. It no longer falls back to the value id, which made the accessible name change every time the user picked an option (WCAG 4.1.2).
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
- ### Keyboard
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
- On the closed trigger: `Enter`, `Space`, `ArrowDown`, `ArrowUp`, `Home`, `End` open the list. With the list open: `ArrowDown` / `ArrowUp` / `Home` / `End` move the active option (disabled options are skipped), `Enter` commits the active option, `Space` closes, `Escape` dismisses, `Tab` moves to the next element in the document AND dismisses the list. `aria-activedescendant` tracks the active option while open; `aria-controls` points at the `[cmdk-list]` node that carries `role="listbox"`.
728
+ Story: `SenzaEtichettaNomeAccessibile` (in `app-select.test.stories.tsx`) and the stories in `app-select-fix-nome-accessibile.stories.tsx`.
518
729
 
519
- Stories: `AccessibilitaTastiera`, `TastieraElencoAperto`, `CongedoDaTastieraEClickFuori`.
730
+ ### Keyboard and focus
520
731
 
521
- ### The popover is NOT modal (behaviour change) — measured
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 listbox is rendered inside a Radix `Popover` with `modal={false}`.
737
+ ### The popover is not modal
524
738
 
525
- **Why.** With `modal`, Radix renders `PopoverContentModal`, which calls `hideOthers()` from `aria-hidden`. Measured: with the list open, the trigger ended up inside an `aria-hidden="true"` subtree while focus was still on it (`onOpenAutoFocus` is prevented for the mobile-keyboard reason documented below). So exactly while `aria-expanded="true"`, no screen reader could read the combobox, its state, or its `aria-controls`. A select-only combobox is not modal per APG.
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
- **What else changed.** The modal branch carries four behaviours, and dropping it dropped all four: `hideOthers` (the one we wanted gone), `RemoveScroll`, `disableOutsidePointerEvents: true` and `trapFocus`. The table below is the measured delta — obtained by flipping `modal` in `app-select.tsx` and re-running the two stories in the browser test runner.
741
+ What that means in practice (measured with the two settings):
528
742
 
529
- | Situation | with `modal` (before) | with `modal={false}` (now) |
530
- | --- | --- | --- |
531
- | Trigger inside an `aria-hidden` subtree while the list is open | yes | no |
532
- | `AppSelect` nested in another `Popover`, ONE click on a target outside both | the select closes, the surrounding Popover stays open (`aria-expanded="true"`), the target's `onClick` does not fire (counter `0`) | both close (`"false"` / `"false"`) and the target's `onClick` fires (counter `1`) |
533
- | What a real click at the outside target's centre hits (`document.elementFromPoint`) | the `HTML` element — the target's computed `pointer-events` is `none` | the target itself — computed `pointer-events` is `auto` |
534
- | Page scroll with the list open | body carries `data-scroll-locked="1"`, computed body `overflow: hidden`, a `wheel` event is `defaultPrevented` | no lock attribute, `overflow: visible`, the `wheel` event is not prevented — the page scrolls under the open list |
535
- | `Tab` from the open trigger | focus reaches the next element, but the list stays OPEN (the modal branch prevents `onFocusOutside`) | focus reaches the next element and the list dismisses |
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
- Two notes on the measurement itself, so nobody re-derives them wrongly:
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
- - assigning `scrollTop` from script is NOT a probe for the scroll lock: it moved the page in both branches (200 → 200). `RemoveScroll` cancels the scroll *event* and hides the scrollbar, it does not disable the API.
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 now also activates what is under the pointer.** The modal branch gave a free "the first outside click only closes the menu" guard; it is gone. In a nested layout — an `AppSelect` inside another `Popover`, the shape used by the DataTable sorting panel — one click outside now dismisses BOTH layers and fires the underlying handler. If a consumer needs the old guard, it has to be added explicitly at that call site (e.g. `onInteractOutside` on the surrounding layer); `AppSelect` will not restore it, because bringing `modal` back would bring `hideOthers` back with it.
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**: Proper ARIA attributes and focus management
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**: Popover prevents default auto-focus on open via `onOpenAutoFocus`, so the soft keyboard does not pop up automatically when `searchable` is enabled. This avoids the visual-viewport collapse that previously caused the dropdown to flip off-screen on mobile. Tap the search input to focus it explicitly when needed.
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.