@xsolla/xui-multi-select 0.216.1 → 0.218.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/README.md +82 -3
- package/native/index.d.mts +43 -2
- package/native/index.d.ts +43 -2
- package/native/index.js +90 -16
- package/native/index.js.map +1 -1
- package/native/index.mjs +90 -16
- package/native/index.mjs.map +1 -1
- package/package.json +7 -7
- package/web/index.d.mts +43 -2
- package/web/index.d.ts +43 -2
- package/web/index.js +90 -16
- package/web/index.js.map +1 -1
- package/web/index.mjs +90 -16
- package/web/index.mjs.map +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ A dropdown input that allows selecting multiple values simultaneously. Displays
|
|
|
50
50
|
- When State=Error, the error message must be linked via aria-describedby and in an aria-live=*"polite"* region.
|
|
51
51
|
- The ContextMenu dropdown uses role=*"listbox"* (or role=*"menu"*) with each option as role=*"option"* / role=*"menuitemcheckbox"* and aria-checked=*"true"* / *"false"*.
|
|
52
52
|
- Keyboard navigation: Enter / Space / ↓ opens the dropdown; ↑ / ↓ navigate options; Space toggles the focused option; Escape closes without further changes; Tab closes and moves focus forward.
|
|
53
|
-
- When a value is added or removed (via chip ✕ or from dropdown), announce the change via aria-live=*"polite"*: *"Design added"* or *"Design removed"
|
|
53
|
+
- When a value is added or removed (via chip ✕ or from dropdown), announce the change via aria-live=*"polite"*: *"Design added"* or *"Design removed"*.
|
|
54
54
|
<!-- END:xui-mcp-instructions:multi-select -->
|
|
55
55
|
|
|
56
56
|
## Installation
|
|
@@ -158,12 +158,15 @@ Add backdrop, click-outside, and Escape handling in your layout as needed (see S
|
|
|
158
158
|
| `errorMessage` | `string` | — | Error message; also marks the control invalid. |
|
|
159
159
|
| `variant` | `'tag' \| 'text'` | `'tag'` | How selected options are displayed. |
|
|
160
160
|
| `flexible` | `boolean` | `true` | When `true` the control grows with content; otherwise fixed-height. |
|
|
161
|
-
| `removeTagsButtons` | `boolean` | `true` | Show a remove button on each tag.
|
|
162
|
-
| `extraClear` | `boolean` | `false` | Show a clear-all button.
|
|
161
|
+
| `removeTagsButtons` | `boolean` | `true` | Show a remove button on each tag. Component-wide default — override per option with `MultiSelectOption.removable`. |
|
|
162
|
+
| `extraClear` | `boolean` | `false` | Show a clear-all button. Options with `removable: false` are kept. |
|
|
163
163
|
| `maxHeight` | `number` | `300` | Maximum dropdown height in pixels. |
|
|
164
164
|
| `searchable` | `boolean` | `false` | Show a search input at the top of the built-in dropdown that filters options in memory by label. Web only. |
|
|
165
165
|
| `searchPlaceholder` | `string` | `'Search'` | Placeholder for the search input (used when `searchable`). |
|
|
166
166
|
| `noOptionsMessage` | `string` | `'No results'` | Message shown when the search filter matches no options. |
|
|
167
|
+
| `selectAll` | `boolean` | `false` | Show a select-all / deselect-all row at the top of the built-in dropdown. Scoped to the options currently listed; skips `disabled`; never deselects locked options. Ignored when `dropdownMenu` is `false`. |
|
|
168
|
+
| `selectAllLabel` | `string` | `'Select all'` | Label of the select-all row (used when `selectAll`). |
|
|
169
|
+
| `deselectAllLabel` | `string` | `'Deselect all'` | Label of the row once everything listed is selected (used when `selectAll`). |
|
|
167
170
|
| `iconLeft` | `ReactNode` | — | Icon rendered on the left of the control. |
|
|
168
171
|
| `iconRight` | `ReactNode` | — | Icon on the right (overrides the default caret). |
|
|
169
172
|
| `dropdownMenu` | `boolean` | `true` | When `false`, hides the built-in list and disables click-to-open; use with an external picker (e.g. `GroupSelect`) wired to the same `value` / `onChange`. |
|
|
@@ -184,10 +187,28 @@ type MultiSelectState = "default" | "hover" | "focus" | "disable" | "error";
|
|
|
184
187
|
interface MultiSelectOption {
|
|
185
188
|
label: ReactNode;
|
|
186
189
|
value: string | number;
|
|
190
|
+
/** Greys the option out and blocks interaction. */
|
|
187
191
|
disabled?: boolean;
|
|
192
|
+
/** When `false`, the option is locked: selected and not removable. Defaults to `true`. */
|
|
193
|
+
removable?: boolean;
|
|
188
194
|
}
|
|
189
195
|
```
|
|
190
196
|
|
|
197
|
+
#### `disabled` vs `removable`
|
|
198
|
+
|
|
199
|
+
They solve different problems and can be combined:
|
|
200
|
+
|
|
201
|
+
| | `disabled: true` | `removable: false` |
|
|
202
|
+
| --- | --- | --- |
|
|
203
|
+
| Greyed out | Yes | No — renders like any other selection |
|
|
204
|
+
| Can be selected by the user | No | Yes |
|
|
205
|
+
| Can be deselected once selected | Yes (the flag does not lock an existing selection) | No |
|
|
206
|
+
| Tag shows the ✕ remove button | Yes | No |
|
|
207
|
+
| Included by select-all / deselect-all | No (skipped) | Selected yes, deselected no |
|
|
208
|
+
| Removed by `extraClear` | Yes | No |
|
|
209
|
+
|
|
210
|
+
Use `removable: false` for a required, default-selected value that should still look like a normal selection.
|
|
211
|
+
|
|
191
212
|
## Examples
|
|
192
213
|
|
|
193
214
|
### Sizes
|
|
@@ -285,6 +306,62 @@ const [selected, setSelected] = useState<MultiSelectValue>([]);
|
|
|
285
306
|
/>;
|
|
286
307
|
```
|
|
287
308
|
|
|
309
|
+
### Select all
|
|
310
|
+
|
|
311
|
+
Enable `selectAll` to show a select-all row at the top of the dropdown, so a long list does not have to be clicked through option by option. Once every selectable listed option is selected, the row flips to deselect-all. Customize the two labels with `selectAllLabel` / `deselectAllLabel`.
|
|
312
|
+
|
|
313
|
+
Scope rules:
|
|
314
|
+
|
|
315
|
+
- **Filtered, not global.** With `searchable` active and a query typed, select-all applies only to the options currently listed — it never silently selects options the user cannot see. Deselect-all likewise only clears the listed ones.
|
|
316
|
+
- **`disabled` options are skipped** in both directions.
|
|
317
|
+
- **`removable: false` options are never deselected** — deselect-all leaves them in place.
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
const options = [
|
|
321
|
+
{ label: "React", value: "react" },
|
|
322
|
+
{ label: "Vue", value: "vue" },
|
|
323
|
+
{ label: "Angular", value: "angular" },
|
|
324
|
+
{ label: "Svelte", value: "svelte" },
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
const [selected, setSelected] = useState<MultiSelectValue>([]);
|
|
328
|
+
|
|
329
|
+
<MultiSelect
|
|
330
|
+
label="Frameworks"
|
|
331
|
+
options={options}
|
|
332
|
+
value={selected}
|
|
333
|
+
onChange={setSelected}
|
|
334
|
+
selectAll
|
|
335
|
+
searchable
|
|
336
|
+
placeholder="Select frameworks"
|
|
337
|
+
/>;
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Locked (non-removable) options
|
|
341
|
+
|
|
342
|
+
`removeTagsButtons` is component-wide: it either shows the ✕ on every tag or on none. To lock a single value, set `removable: false` on that option instead. Its tag renders without a ✕, clicking the tag does nothing, unchecking it in the dropdown is a no-op, and `extraClear` / deselect-all leave it selected. Unlike `disabled`, it is not greyed out — so a required default reads as a normal selection.
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
// English is required and must always stay selected; the rest are optional.
|
|
346
|
+
const languages = [
|
|
347
|
+
{ label: "English", value: "en", removable: false },
|
|
348
|
+
{ label: "German", value: "de" },
|
|
349
|
+
{ label: "French", value: "fr" },
|
|
350
|
+
{ label: "Japanese", value: "ja" },
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
const [selected, setSelected] = useState<MultiSelectValue>(["en"]);
|
|
354
|
+
|
|
355
|
+
<MultiSelect
|
|
356
|
+
label="Languages"
|
|
357
|
+
options={languages}
|
|
358
|
+
value={selected}
|
|
359
|
+
onChange={setSelected}
|
|
360
|
+
extraClear
|
|
361
|
+
placeholder="Select languages"
|
|
362
|
+
/>;
|
|
363
|
+
```
|
|
364
|
+
|
|
288
365
|
### Long option labels
|
|
289
366
|
|
|
290
367
|
Long, unbreakable option labels (URLs, IDs, tokens) wrap inside the dropdown menu instead of forcing a horizontal scrollbar; the menu width stays pinned to the control.
|
|
@@ -316,3 +393,5 @@ const [selected, setSelected] = useState<MultiSelectValue>([]);
|
|
|
316
393
|
- The dropdown is keyboard navigable; selection state is announced to assistive technology.
|
|
317
394
|
- An `errorMessage` marks the control as invalid for screen readers.
|
|
318
395
|
- When `searchable`, the dropdown's search input exposes an `aria-label` matching `searchPlaceholder`; options filter in memory as the user types (web only).
|
|
396
|
+
- When `selectAll`, the select-all row is exposed as a button whose `aria-label` matches its current action (`selectAllLabel` / `deselectAllLabel`), so the select ↔ deselect flip is announced.
|
|
397
|
+
- A `removable: false` option renders no remove button, so assistive technology does not offer a removal action that would fail.
|
package/native/index.d.mts
CHANGED
|
@@ -11,6 +11,19 @@ interface MultiSelectOption {
|
|
|
11
11
|
label: ReactNode;
|
|
12
12
|
value: string | number;
|
|
13
13
|
disabled?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* When `false`, this option is **locked**: once selected it cannot be
|
|
16
|
+
* removed. Its tag renders without a remove button, clicking the tag does
|
|
17
|
+
* nothing, toggling the option off in the dropdown is a no-op, the
|
|
18
|
+
* `extraClear` clear-all button leaves it in place, and select-all /
|
|
19
|
+
* deselect-all skips it.
|
|
20
|
+
*
|
|
21
|
+
* Unlike `disabled`, a locked option is **not** greyed out — use it for a
|
|
22
|
+
* required, default-selected value (e.g. a base language that must always
|
|
23
|
+
* stay selected) that should still look like a normal selection.
|
|
24
|
+
* @default true
|
|
25
|
+
*/
|
|
26
|
+
removable?: boolean;
|
|
14
27
|
}
|
|
15
28
|
interface MultiSelectProps extends ThemeOverrideProps {
|
|
16
29
|
/**
|
|
@@ -70,12 +83,14 @@ interface MultiSelectProps extends ThemeOverrideProps {
|
|
|
70
83
|
*/
|
|
71
84
|
flexible?: boolean;
|
|
72
85
|
/**
|
|
73
|
-
* Switch on displaying a remove button in each tag.
|
|
86
|
+
* Switch on displaying a remove button in each tag. This is the
|
|
87
|
+
* component-wide default; an individual option can opt out with
|
|
88
|
+
* `MultiSelectOption.removable: false`.
|
|
74
89
|
* @default true
|
|
75
90
|
*/
|
|
76
91
|
removeTagsButtons?: boolean;
|
|
77
92
|
/**
|
|
78
|
-
* Add clear all button.
|
|
93
|
+
* Add clear all button. Options with `removable: false` are kept.
|
|
79
94
|
* @default false
|
|
80
95
|
*/
|
|
81
96
|
extraClear?: boolean;
|
|
@@ -113,6 +128,32 @@ interface MultiSelectProps extends ThemeOverrideProps {
|
|
|
113
128
|
* @default "No results"
|
|
114
129
|
*/
|
|
115
130
|
noOptionsMessage?: string;
|
|
131
|
+
/**
|
|
132
|
+
* When true, shows a select-all / deselect-all row at the top of the
|
|
133
|
+
* built-in dropdown (below the search input when `searchable`).
|
|
134
|
+
*
|
|
135
|
+
* Scope is **the options currently listed**: with `searchable` active and a
|
|
136
|
+
* search query typed, it applies only to the filtered options, matching what
|
|
137
|
+
* the user can see. Options with `disabled: true` are skipped, and options
|
|
138
|
+
* with `removable: false` are never deselected. The row flips to
|
|
139
|
+
* deselect-all once every selectable listed option is selected.
|
|
140
|
+
*
|
|
141
|
+
* Ignored when `dropdownMenu` is false.
|
|
142
|
+
* @default false
|
|
143
|
+
*/
|
|
144
|
+
selectAll?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Label of the select-all row. Only used when `selectAll` is true.
|
|
147
|
+
* @default "Select all"
|
|
148
|
+
*/
|
|
149
|
+
selectAllLabel?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Label of the select-all row once everything listed is selected, at which
|
|
152
|
+
* point activating it clears the listed selection. Only used when
|
|
153
|
+
* `selectAll` is true.
|
|
154
|
+
* @default "Deselect all"
|
|
155
|
+
*/
|
|
156
|
+
deselectAllLabel?: string;
|
|
116
157
|
/**
|
|
117
158
|
* When false, the built-in options list and backdrop are not shown and the control
|
|
118
159
|
* does not open on click. Use with an external picker (e.g. grouped select) wired
|
package/native/index.d.ts
CHANGED
|
@@ -11,6 +11,19 @@ interface MultiSelectOption {
|
|
|
11
11
|
label: ReactNode;
|
|
12
12
|
value: string | number;
|
|
13
13
|
disabled?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* When `false`, this option is **locked**: once selected it cannot be
|
|
16
|
+
* removed. Its tag renders without a remove button, clicking the tag does
|
|
17
|
+
* nothing, toggling the option off in the dropdown is a no-op, the
|
|
18
|
+
* `extraClear` clear-all button leaves it in place, and select-all /
|
|
19
|
+
* deselect-all skips it.
|
|
20
|
+
*
|
|
21
|
+
* Unlike `disabled`, a locked option is **not** greyed out — use it for a
|
|
22
|
+
* required, default-selected value (e.g. a base language that must always
|
|
23
|
+
* stay selected) that should still look like a normal selection.
|
|
24
|
+
* @default true
|
|
25
|
+
*/
|
|
26
|
+
removable?: boolean;
|
|
14
27
|
}
|
|
15
28
|
interface MultiSelectProps extends ThemeOverrideProps {
|
|
16
29
|
/**
|
|
@@ -70,12 +83,14 @@ interface MultiSelectProps extends ThemeOverrideProps {
|
|
|
70
83
|
*/
|
|
71
84
|
flexible?: boolean;
|
|
72
85
|
/**
|
|
73
|
-
* Switch on displaying a remove button in each tag.
|
|
86
|
+
* Switch on displaying a remove button in each tag. This is the
|
|
87
|
+
* component-wide default; an individual option can opt out with
|
|
88
|
+
* `MultiSelectOption.removable: false`.
|
|
74
89
|
* @default true
|
|
75
90
|
*/
|
|
76
91
|
removeTagsButtons?: boolean;
|
|
77
92
|
/**
|
|
78
|
-
* Add clear all button.
|
|
93
|
+
* Add clear all button. Options with `removable: false` are kept.
|
|
79
94
|
* @default false
|
|
80
95
|
*/
|
|
81
96
|
extraClear?: boolean;
|
|
@@ -113,6 +128,32 @@ interface MultiSelectProps extends ThemeOverrideProps {
|
|
|
113
128
|
* @default "No results"
|
|
114
129
|
*/
|
|
115
130
|
noOptionsMessage?: string;
|
|
131
|
+
/**
|
|
132
|
+
* When true, shows a select-all / deselect-all row at the top of the
|
|
133
|
+
* built-in dropdown (below the search input when `searchable`).
|
|
134
|
+
*
|
|
135
|
+
* Scope is **the options currently listed**: with `searchable` active and a
|
|
136
|
+
* search query typed, it applies only to the filtered options, matching what
|
|
137
|
+
* the user can see. Options with `disabled: true` are skipped, and options
|
|
138
|
+
* with `removable: false` are never deselected. The row flips to
|
|
139
|
+
* deselect-all once every selectable listed option is selected.
|
|
140
|
+
*
|
|
141
|
+
* Ignored when `dropdownMenu` is false.
|
|
142
|
+
* @default false
|
|
143
|
+
*/
|
|
144
|
+
selectAll?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Label of the select-all row. Only used when `selectAll` is true.
|
|
147
|
+
* @default "Select all"
|
|
148
|
+
*/
|
|
149
|
+
selectAllLabel?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Label of the select-all row once everything listed is selected, at which
|
|
152
|
+
* point activating it clears the listed selection. Only used when
|
|
153
|
+
* `selectAll` is true.
|
|
154
|
+
* @default "Deselect all"
|
|
155
|
+
*/
|
|
156
|
+
deselectAllLabel?: string;
|
|
116
157
|
/**
|
|
117
158
|
* When false, the built-in options list and backdrop are not shown and the control
|
|
118
159
|
* does not open on click. Use with an external picker (e.g. grouped select) wired
|
package/native/index.js
CHANGED
|
@@ -435,7 +435,8 @@ var useMultiSelectControl = ({
|
|
|
435
435
|
stateList: stateList.map((item) => item.value),
|
|
436
436
|
variant,
|
|
437
437
|
size,
|
|
438
|
-
removeTagsButtons
|
|
438
|
+
removeTagsButtons,
|
|
439
|
+
lockedList: stateList.filter((item) => item.removable === false).map((item) => item.value)
|
|
439
440
|
}),
|
|
440
441
|
[stateList, variant, size, removeTagsButtons]
|
|
441
442
|
);
|
|
@@ -448,11 +449,18 @@ var useMultiSelectControl = ({
|
|
|
448
449
|
return true;
|
|
449
450
|
if (prev.stateList.length !== widthsDependencies.stateList.length)
|
|
450
451
|
return true;
|
|
452
|
+
if (prev.lockedList.length !== widthsDependencies.lockedList.length)
|
|
453
|
+
return true;
|
|
451
454
|
let i = 0;
|
|
452
455
|
for (const v of prev.stateList) {
|
|
453
456
|
if (v !== widthsDependencies.stateList[i]) return true;
|
|
454
457
|
i++;
|
|
455
458
|
}
|
|
459
|
+
let j = 0;
|
|
460
|
+
for (const v of prev.lockedList) {
|
|
461
|
+
if (v !== widthsDependencies.lockedList[j]) return true;
|
|
462
|
+
j++;
|
|
463
|
+
}
|
|
456
464
|
return false;
|
|
457
465
|
};
|
|
458
466
|
const setPlaceholder = () => {
|
|
@@ -506,26 +514,28 @@ var useMultiSelectControl = ({
|
|
|
506
514
|
}
|
|
507
515
|
const content = displayedItems.map((item, index) => {
|
|
508
516
|
const isCount = item.value === COUNT_ITEM_VALUE;
|
|
517
|
+
const isLocked = !isCount && item.removable === false;
|
|
518
|
+
const canRemove = !isCount && !isLocked;
|
|
509
519
|
const isReducable = index === 0 && displayStateRef.current === 3 /* Render */ && displayedItemsCount === 0 || flexible;
|
|
510
520
|
if (variant === "tag") {
|
|
511
521
|
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
512
522
|
Box,
|
|
513
523
|
{
|
|
514
|
-
onPress:
|
|
524
|
+
onPress: canRemove ? (e) => {
|
|
515
525
|
e.stopPropagation();
|
|
516
526
|
if (!disabled) removeValue(item.value, e);
|
|
517
527
|
} : void 0,
|
|
518
528
|
style: {
|
|
519
529
|
minWidth: isReducable ? 0 : "fit-content",
|
|
520
530
|
opacity: disabled ? 0.5 : 1,
|
|
521
|
-
cursor: disabled || isCount ? "not-allowed" : "pointer"
|
|
531
|
+
cursor: isLocked ? "default" : disabled || isCount ? "not-allowed" : "pointer"
|
|
522
532
|
},
|
|
523
533
|
children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
524
534
|
import_xui_tag.Tag,
|
|
525
535
|
{
|
|
526
536
|
size,
|
|
527
537
|
tone: "primary",
|
|
528
|
-
onRemove: removeTagsButtons &&
|
|
538
|
+
onRemove: removeTagsButtons && canRemove ? (e) => {
|
|
529
539
|
e?.stopPropagation();
|
|
530
540
|
if (!disabled) removeValue(item.value, e);
|
|
531
541
|
} : void 0,
|
|
@@ -784,6 +794,7 @@ var initialState = {
|
|
|
784
794
|
selectedItems: [],
|
|
785
795
|
isOpen: false
|
|
786
796
|
};
|
|
797
|
+
var isLockedOption = (option) => option.removable === false;
|
|
787
798
|
var useMultiSelect = ({
|
|
788
799
|
options,
|
|
789
800
|
value = [],
|
|
@@ -791,9 +802,13 @@ var useMultiSelect = ({
|
|
|
791
802
|
}) => {
|
|
792
803
|
const [state, setState] = (0, import_react4.useState)(initialState);
|
|
793
804
|
const optionsRef = (0, import_react4.useRef)(options);
|
|
805
|
+
const valuesRef = (0, import_react4.useRef)(value);
|
|
794
806
|
(0, import_react4.useEffect)(() => {
|
|
795
807
|
optionsRef.current = options;
|
|
796
808
|
}, [options]);
|
|
809
|
+
(0, import_react4.useEffect)(() => {
|
|
810
|
+
valuesRef.current = state.values;
|
|
811
|
+
}, [state.values]);
|
|
797
812
|
(0, import_react4.useEffect)(() => {
|
|
798
813
|
const selectedItems = options.filter((opt) => value.includes(opt.value));
|
|
799
814
|
setState((prevState) => ({
|
|
@@ -802,9 +817,15 @@ var useMultiSelect = ({
|
|
|
802
817
|
selectedItems
|
|
803
818
|
}));
|
|
804
819
|
}, [options, value]);
|
|
820
|
+
const getLockedSelectedValues = () => optionsRef.current.filter(
|
|
821
|
+
(opt) => isLockedOption(opt) && valuesRef.current.includes(opt.value)
|
|
822
|
+
).map((opt) => opt.value);
|
|
805
823
|
const onChoose = (0, import_react4.useCallback)(
|
|
806
824
|
(selectedIds) => {
|
|
807
|
-
const
|
|
825
|
+
const lockedValues = getLockedSelectedValues();
|
|
826
|
+
const newValues = optionsRef.current.filter(
|
|
827
|
+
(opt) => selectedIds.includes(String(opt.value)) || lockedValues.includes(opt.value)
|
|
828
|
+
).map((opt) => opt.value);
|
|
808
829
|
const newSelectedItems = optionsRef.current.filter(
|
|
809
830
|
(opt) => newValues.includes(opt.value)
|
|
810
831
|
);
|
|
@@ -820,6 +841,8 @@ var useMultiSelect = ({
|
|
|
820
841
|
const onRemove = (0, import_react4.useCallback)(
|
|
821
842
|
(value2, event) => {
|
|
822
843
|
event?.stopPropagation();
|
|
844
|
+
const option = optionsRef.current.find((opt) => opt.value === value2);
|
|
845
|
+
if (option && isLockedOption(option)) return;
|
|
823
846
|
setState((prev) => {
|
|
824
847
|
const newValues = prev.values.filter((v) => v !== value2);
|
|
825
848
|
const newSelectedItems = optionsRef.current.filter(
|
|
@@ -836,12 +859,16 @@ var useMultiSelect = ({
|
|
|
836
859
|
[onChange]
|
|
837
860
|
);
|
|
838
861
|
const onRemoveAll = (0, import_react4.useCallback)(() => {
|
|
862
|
+
const keptValues = getLockedSelectedValues();
|
|
863
|
+
const keptItems = optionsRef.current.filter(
|
|
864
|
+
(opt) => keptValues.includes(opt.value)
|
|
865
|
+
);
|
|
839
866
|
setState((prevState) => ({
|
|
840
867
|
...prevState,
|
|
841
|
-
values:
|
|
842
|
-
selectedItems:
|
|
868
|
+
values: keptValues,
|
|
869
|
+
selectedItems: keptItems
|
|
843
870
|
}));
|
|
844
|
-
onChange?.(
|
|
871
|
+
onChange?.(keptValues);
|
|
845
872
|
}, [onChange]);
|
|
846
873
|
const onSelectClick = (0, import_react4.useCallback)(() => {
|
|
847
874
|
setState((prevState) => ({
|
|
@@ -875,6 +902,8 @@ var Portal = ({ children }) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(impo
|
|
|
875
902
|
// src/MultiSelect.tsx
|
|
876
903
|
var import_jsx_runtime7 = require("react/jsx-runtime");
|
|
877
904
|
var EXTERNAL_MENU_MIN_WIDTH_DEFAULT = 540;
|
|
905
|
+
var SEARCH_ROW_HEIGHT = 60;
|
|
906
|
+
var SELECT_ALL_ROW_HEIGHT = 37;
|
|
878
907
|
var MultiSelect = (0, import_react5.forwardRef)(
|
|
879
908
|
({
|
|
880
909
|
options,
|
|
@@ -901,6 +930,9 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
901
930
|
searchable = false,
|
|
902
931
|
searchPlaceholder = "Search",
|
|
903
932
|
noOptionsMessage = "No results",
|
|
933
|
+
selectAll = false,
|
|
934
|
+
selectAllLabel = "Select all",
|
|
935
|
+
deselectAllLabel = "Deselect all",
|
|
904
936
|
testID,
|
|
905
937
|
themeMode,
|
|
906
938
|
themeProductContext
|
|
@@ -952,22 +984,33 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
952
984
|
const filteredOptions = searchable && searchValue ? options.filter(
|
|
953
985
|
(opt) => getOptionText(opt).toLowerCase().includes(searchValue.toLowerCase())
|
|
954
986
|
) : options;
|
|
987
|
+
const selectedIds = values.map(String);
|
|
955
988
|
const menuItems = filteredOptions.map((opt) => {
|
|
956
989
|
const id = String(opt.value);
|
|
957
|
-
const checked =
|
|
990
|
+
const checked = selectedIds.includes(id);
|
|
958
991
|
return {
|
|
959
992
|
id,
|
|
960
993
|
children: opt.label,
|
|
961
994
|
checked,
|
|
962
|
-
disabled: opt.disabled
|
|
995
|
+
disabled: opt.disabled,
|
|
996
|
+
locked: opt.removable === false
|
|
963
997
|
};
|
|
964
998
|
});
|
|
965
999
|
const handleItemToggle = (id, checked) => {
|
|
966
|
-
const
|
|
967
|
-
if (
|
|
968
|
-
|
|
1000
|
+
const option = options.find((opt) => String(opt.value) === id);
|
|
1001
|
+
if (option === void 0) return;
|
|
1002
|
+
if (!checked && option.removable === false) return;
|
|
1003
|
+
const newValues = checked ? [...values, option.value] : values.filter((v) => v !== option.value);
|
|
969
1004
|
onChoose(newValues.map(String));
|
|
970
1005
|
};
|
|
1006
|
+
const selectAllTargets = filteredOptions.filter((opt) => !opt.disabled);
|
|
1007
|
+
const showSelectAll = selectAll && dropdownMenu && selectAllTargets.length > 0;
|
|
1008
|
+
const areAllTargetsSelected = showSelectAll && selectAllTargets.every((opt) => selectedIds.includes(String(opt.value)));
|
|
1009
|
+
const handleSelectAllToggle = () => {
|
|
1010
|
+
const targetIds = selectAllTargets.map((opt) => String(opt.value));
|
|
1011
|
+
const nextIds = areAllTargetsSelected ? selectedIds.filter((id) => !targetIds.includes(id)) : Array.from(/* @__PURE__ */ new Set([...selectedIds, ...targetIds]));
|
|
1012
|
+
onChoose(nextIds);
|
|
1013
|
+
};
|
|
971
1014
|
const controlMenuOpen = dropdownMenu ? isOpen : Boolean(menuOpen);
|
|
972
1015
|
const controlOnClick = dropdownMenu ? onSelectClick : onTriggerPress;
|
|
973
1016
|
const isScrollOverflow = optionOverflow === "scroll";
|
|
@@ -986,6 +1029,7 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
986
1029
|
overflow: "hidden",
|
|
987
1030
|
textOverflow: "ellipsis"
|
|
988
1031
|
};
|
|
1032
|
+
const optionsListMaxHeight = maxHeight - (searchable ? SEARCH_ROW_HEIGHT : 0) - (showSelectAll ? SELECT_ALL_ROW_HEIGHT : 0);
|
|
989
1033
|
(0, import_react5.useEffect)(() => {
|
|
990
1034
|
if (!isWeb || !controlMenuOpen || isDisable || !dropdownMenu) return;
|
|
991
1035
|
let rafId = null;
|
|
@@ -1119,12 +1163,41 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
1119
1163
|
)
|
|
1120
1164
|
}
|
|
1121
1165
|
),
|
|
1166
|
+
showSelectAll && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1167
|
+
Box,
|
|
1168
|
+
{
|
|
1169
|
+
role: "button",
|
|
1170
|
+
"aria-label": areAllTargetsSelected ? deselectAllLabel : selectAllLabel,
|
|
1171
|
+
testID: testID ? `${testID}-select-all` : void 0,
|
|
1172
|
+
paddingHorizontal: sizeStyles.paddingHorizontal,
|
|
1173
|
+
paddingVertical: 8,
|
|
1174
|
+
borderBottomWidth: 1,
|
|
1175
|
+
borderColor: theme.colors.border.secondary,
|
|
1176
|
+
flexDirection: "row",
|
|
1177
|
+
alignItems: "center",
|
|
1178
|
+
onPress: handleSelectAllToggle,
|
|
1179
|
+
hoverStyle: {
|
|
1180
|
+
backgroundColor: theme.colors.control.input.bgHover
|
|
1181
|
+
},
|
|
1182
|
+
style: { cursor: "pointer" },
|
|
1183
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1184
|
+
Text,
|
|
1185
|
+
{
|
|
1186
|
+
color: theme.colors.content.brand.primary,
|
|
1187
|
+
fontSize: sizeStyles.fontSize,
|
|
1188
|
+
fontWeight: "500",
|
|
1189
|
+
numberOfLines: 1,
|
|
1190
|
+
children: areAllTargetsSelected ? deselectAllLabel : selectAllLabel
|
|
1191
|
+
}
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
),
|
|
1122
1195
|
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1123
1196
|
Box,
|
|
1124
1197
|
{
|
|
1125
1198
|
paddingVertical: 4,
|
|
1126
1199
|
style: {
|
|
1127
|
-
maxHeight:
|
|
1200
|
+
maxHeight: optionsListMaxHeight,
|
|
1128
1201
|
overflowY: "auto",
|
|
1129
1202
|
overflowX: optionListOverflowX,
|
|
1130
1203
|
scrollbarWidth: "none"
|
|
@@ -1147,6 +1220,7 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
1147
1220
|
) : menuItems.map((item, _index) => {
|
|
1148
1221
|
const brandColors = theme.colors.control.brand.primary;
|
|
1149
1222
|
const contentColors = theme.colors.content;
|
|
1223
|
+
const isInert = item.locked && item.checked;
|
|
1150
1224
|
return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
|
|
1151
1225
|
Box,
|
|
1152
1226
|
{
|
|
@@ -1154,7 +1228,7 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
1154
1228
|
paddingHorizontal: sizeStyles.paddingHorizontal,
|
|
1155
1229
|
paddingVertical: 8,
|
|
1156
1230
|
onPress: () => {
|
|
1157
|
-
if (!item.disabled) {
|
|
1231
|
+
if (!item.disabled && !isInert) {
|
|
1158
1232
|
handleItemToggle(item.id, !item.checked);
|
|
1159
1233
|
}
|
|
1160
1234
|
},
|
|
@@ -1166,7 +1240,7 @@ var MultiSelect = (0, import_react5.forwardRef)(
|
|
|
1166
1240
|
backgroundColor: theme.colors.control.input.bgHover
|
|
1167
1241
|
} : void 0,
|
|
1168
1242
|
style: {
|
|
1169
|
-
cursor: item.disabled ? "not-allowed" : "pointer",
|
|
1243
|
+
cursor: item.disabled ? "not-allowed" : isInert ? "default" : "pointer",
|
|
1170
1244
|
opacity: item.disabled ? 0.5 : 1,
|
|
1171
1245
|
minWidth: optionRowMinWidth
|
|
1172
1246
|
},
|