@marimo-team/islands 0.23.10-dev25 → 0.23.10-dev27
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/{code-visibility-CXkMXcdB.js → code-visibility-DI2QSiFC.js} +1646 -1179
- package/dist/main.js +7312 -7424
- package/dist/{reveal-component-dIolR_34.js → reveal-component-BA7HaWOX.js} +410 -333
- package/package.json +1 -1
- package/src/components/data-table/filter-by-values-picker.tsx +39 -17
- package/src/components/data-table/filter-pills.tsx +1 -1
- package/src/components/editor/cell/code/language-toggle.tsx +7 -1
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
- package/src/components/editor/renderers/slides-layout/types.ts +1 -0
- package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
- package/src/components/slides/reveal-component.tsx +283 -61
- package/src/components/slides/slide-cell-view.tsx +26 -2
- package/src/components/slides/slide-form.tsx +26 -4
- package/src/components/ui/combobox.tsx +51 -32
- package/src/components/ui/select-core/__tests__/use-select-list.test.ts +294 -0
- package/src/components/ui/select-core/__tests__/utils.test.ts +222 -0
- package/src/components/ui/select-core/index.ts +16 -0
- package/src/components/ui/select-core/option-row.tsx +33 -0
- package/src/components/ui/select-core/render-slot.ts +20 -0
- package/src/components/ui/select-core/select-list.tsx +248 -0
- package/src/components/ui/select-core/types.ts +44 -0
- package/src/components/ui/select-core/use-select-list.ts +347 -0
- package/src/components/ui/select-core/utils.ts +121 -0
- package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
- package/src/plugins/impl/SearchableSelect.tsx +16 -97
- package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
- package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
- package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
- /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { ComboboxItem } from "../combobox";
|
|
4
|
+
import type { Option, OptionState } from "./types";
|
|
5
|
+
|
|
6
|
+
interface OptionRowProps<V> {
|
|
7
|
+
option: Option<V>;
|
|
8
|
+
checked: boolean;
|
|
9
|
+
renderOption?: (option: Option<V>, state: OptionState) => React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function OptionRowImpl<V>({
|
|
13
|
+
option,
|
|
14
|
+
checked,
|
|
15
|
+
renderOption,
|
|
16
|
+
}: OptionRowProps<V>): React.JSX.Element {
|
|
17
|
+
return (
|
|
18
|
+
<ComboboxItem
|
|
19
|
+
data-slot="select-option"
|
|
20
|
+
data-checked={checked || undefined}
|
|
21
|
+
// Selection identity, not the display string: the Combobox tracks
|
|
22
|
+
// `isSelected`/`onSelect` by this value and echoes it back through
|
|
23
|
+
// `onValueChange`. The cast bridges the unconstrained option type to
|
|
24
|
+
// ComboboxItem's stringifiable-value bound.
|
|
25
|
+
value={option.value as string | number}
|
|
26
|
+
disabled={option.disabled}
|
|
27
|
+
>
|
|
28
|
+
{renderOption ? renderOption(option, { checked }) : option.label}
|
|
29
|
+
</ComboboxItem>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const OptionRow = React.memo(OptionRowImpl) as typeof OptionRowImpl;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A customizable piece of UI: either fixed content, or a function that builds it
|
|
7
|
+
* from `A`. The thunk form lets callers defer work (or read render-time args)
|
|
8
|
+
* until the slot is actually shown.
|
|
9
|
+
*/
|
|
10
|
+
export type Slot<A extends unknown[] = []> =
|
|
11
|
+
| React.ReactNode
|
|
12
|
+
| ((...args: A) => React.ReactNode);
|
|
13
|
+
|
|
14
|
+
/** Resolve a {@link Slot} to its node, calling the thunk form with `args`. */
|
|
15
|
+
export function renderSlot<A extends unknown[]>(
|
|
16
|
+
slot: Slot<A>,
|
|
17
|
+
...args: A
|
|
18
|
+
): React.ReactNode {
|
|
19
|
+
return typeof slot === "function" ? slot(...args) : slot;
|
|
20
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import { Virtuoso } from "react-virtuoso";
|
|
4
|
+
import { CompactChipRow } from "@/components/ui/value-chips";
|
|
5
|
+
import { cn } from "@/utils/cn";
|
|
6
|
+
import { Combobox } from "../combobox";
|
|
7
|
+
import { CommandItem, CommandSeparator } from "../command";
|
|
8
|
+
import { OptionRow } from "./option-row";
|
|
9
|
+
import { renderSlot, type Slot } from "./render-slot";
|
|
10
|
+
import type { BulkAction, Option, OptionState } from "./types";
|
|
11
|
+
import { useSelectList } from "./use-select-list";
|
|
12
|
+
|
|
13
|
+
/** Above this many options the list virtualizes. */
|
|
14
|
+
export const VIRTUALIZE_THRESHOLD = 200;
|
|
15
|
+
|
|
16
|
+
/** Fixed pixel height of the virtualized viewport (Virtuoso requires one). */
|
|
17
|
+
export const VIRTUALIZED_LIST_HEIGHT = 200;
|
|
18
|
+
|
|
19
|
+
function bulkActionLabel<V>(action: BulkAction<V>): string {
|
|
20
|
+
switch (action.kind) {
|
|
21
|
+
case "select-all":
|
|
22
|
+
return "Select all";
|
|
23
|
+
case "deselect-all":
|
|
24
|
+
return "Deselect all";
|
|
25
|
+
case "select-matching":
|
|
26
|
+
return `Select ${action.items.length} matching`;
|
|
27
|
+
case "deselect-matching":
|
|
28
|
+
return `Deselect ${action.items.length} matching`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SelectListProps<V> {
|
|
33
|
+
options: Array<Option<V>>;
|
|
34
|
+
/** Current selection: an array when `multiple`, otherwise a single value or null. */
|
|
35
|
+
value: V[] | V | null;
|
|
36
|
+
onChange: (next: V[] | V | null) => void;
|
|
37
|
+
/** Multi-select when true; single-select (replace-on-pick) when false. */
|
|
38
|
+
multiple: boolean;
|
|
39
|
+
/** Cap on multi-select size. At the cap, picking another drops the oldest. */
|
|
40
|
+
maxSelections?: number;
|
|
41
|
+
/** Single-select only: re-picking the current value clears it to null. */
|
|
42
|
+
allowSelectNone?: boolean;
|
|
43
|
+
/** Float the (frozen) selection to the top of the idle menu, with a separator. */
|
|
44
|
+
pinSelected?: boolean;
|
|
45
|
+
/** Summarize the selection in the trigger as a compact chip row instead of "N selected". */
|
|
46
|
+
compactChipTrigger?: boolean;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
fullWidth?: boolean;
|
|
50
|
+
className?: string;
|
|
51
|
+
id?: string;
|
|
52
|
+
"data-testid"?: string;
|
|
53
|
+
/** Renders the row content; the core owns the interactive container. */
|
|
54
|
+
renderOption?: (option: Option<V>, state: OptionState) => React.ReactNode;
|
|
55
|
+
/** Shown when no option matches the current query. */
|
|
56
|
+
renderEmpty?: Slot;
|
|
57
|
+
/**
|
|
58
|
+
* Virtualize once the visible option count exceeds this. Lower it when
|
|
59
|
+
* `renderOption` produces expensive rows so they virtualize sooner.
|
|
60
|
+
*/
|
|
61
|
+
virtualizeThreshold?: number;
|
|
62
|
+
/** Fixed pixel height of the virtualized viewport. */
|
|
63
|
+
virtualizedHeight?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SelectList<V>(props: SelectListProps<V>): React.JSX.Element {
|
|
67
|
+
const {
|
|
68
|
+
options,
|
|
69
|
+
value,
|
|
70
|
+
onChange,
|
|
71
|
+
multiple,
|
|
72
|
+
maxSelections,
|
|
73
|
+
allowSelectNone,
|
|
74
|
+
pinSelected = false,
|
|
75
|
+
compactChipTrigger = false,
|
|
76
|
+
placeholder = "Select...",
|
|
77
|
+
disabled = false,
|
|
78
|
+
fullWidth = false,
|
|
79
|
+
className,
|
|
80
|
+
id,
|
|
81
|
+
renderOption,
|
|
82
|
+
renderEmpty = "Nothing found.",
|
|
83
|
+
virtualizeThreshold = VIRTUALIZE_THRESHOLD,
|
|
84
|
+
virtualizedHeight = VIRTUALIZED_LIST_HEIGHT,
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
const list = useSelectList<V>({
|
|
88
|
+
options,
|
|
89
|
+
value,
|
|
90
|
+
onChange,
|
|
91
|
+
multiple,
|
|
92
|
+
maxSelections,
|
|
93
|
+
allowSelectNone,
|
|
94
|
+
pinSelected,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const handleComboChange = (next: V[] | V | null): void => {
|
|
98
|
+
if (!multiple) {
|
|
99
|
+
if (next == null && !allowSelectNone) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
onChange(next);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let arr = Array.isArray(next) ? next : [];
|
|
106
|
+
if (maxSelections != null && arr.length > maxSelections) {
|
|
107
|
+
arr = arr.slice(-maxSelections);
|
|
108
|
+
}
|
|
109
|
+
onChange(arr);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Bulk rows render as raw CommandItem (not ComboboxItem) so Combobox's per-item
|
|
113
|
+
// toggle doesn't intercept them — only the action's own `run` fires on select.
|
|
114
|
+
const bulkRows: React.ReactNode[] = list.bulkActions.map((action) => {
|
|
115
|
+
const disabled =
|
|
116
|
+
"enabled" in action ? !action.enabled : action.items.length === 0;
|
|
117
|
+
return (
|
|
118
|
+
<CommandItem
|
|
119
|
+
key={action.kind}
|
|
120
|
+
data-slot="select-bulk"
|
|
121
|
+
className="pl-6 m-1 py-1"
|
|
122
|
+
value={`__bulk_${action.kind}`}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
onSelect={() => {
|
|
125
|
+
if (!disabled) {
|
|
126
|
+
action.run();
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{bulkActionLabel(action)}
|
|
131
|
+
</CommandItem>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
if (bulkRows.length > 0) {
|
|
135
|
+
bulkRows.push(
|
|
136
|
+
<CommandSeparator key="_bulk_separator" data-slot="select-separator" />,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Rendered at the start of the first unpinned row, not after the last pinned row,
|
|
141
|
+
// so the menu-separator's `last:hidden` Tailwind variant doesn't hide it inside
|
|
142
|
+
// Virtuoso's per-item wrapper.
|
|
143
|
+
const pinnedSeparator = (index: number): React.ReactNode =>
|
|
144
|
+
list.pinnedCount > 0 &&
|
|
145
|
+
index === list.pinnedCount &&
|
|
146
|
+
list.pinnedCount < list.visibleOptions.length ? (
|
|
147
|
+
<CommandSeparator key="_pinned_separator" data-slot="select-separator" />
|
|
148
|
+
) : null;
|
|
149
|
+
|
|
150
|
+
const renderItems = () => {
|
|
151
|
+
if (list.visibleOptions.length > virtualizeThreshold) {
|
|
152
|
+
return (
|
|
153
|
+
<Virtuoso
|
|
154
|
+
data-slot="select-list"
|
|
155
|
+
style={{ height: virtualizedHeight }}
|
|
156
|
+
totalCount={list.visibleOptions.length}
|
|
157
|
+
overscan={50}
|
|
158
|
+
itemContent={(i: number) => {
|
|
159
|
+
const option = list.visibleOptions[i];
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
{i === 0 && bulkRows}
|
|
163
|
+
{pinnedSeparator(i)}
|
|
164
|
+
<OptionRow
|
|
165
|
+
option={option}
|
|
166
|
+
checked={list.isChecked(option.value)}
|
|
167
|
+
renderOption={renderOption}
|
|
168
|
+
/>
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rows = list.visibleOptions.flatMap((option, i) => {
|
|
177
|
+
const separator = pinnedSeparator(i);
|
|
178
|
+
const row = (
|
|
179
|
+
<OptionRow
|
|
180
|
+
key={String(option.value)}
|
|
181
|
+
option={option}
|
|
182
|
+
checked={list.isChecked(option.value)}
|
|
183
|
+
renderOption={renderOption}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
return separator ? [separator, row] : [row];
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
{bulkRows}
|
|
192
|
+
{rows}
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const renderTriggerValue = (current: V[] | V | null): React.ReactNode => {
|
|
198
|
+
const items = Array.isArray(current)
|
|
199
|
+
? current
|
|
200
|
+
: current != null
|
|
201
|
+
? [current]
|
|
202
|
+
: [];
|
|
203
|
+
if (items.length === 0) {
|
|
204
|
+
return <span className="text-muted-foreground">{placeholder}</span>;
|
|
205
|
+
}
|
|
206
|
+
return <CompactChipRow items={items.map(list.labelOf)} max={3} />;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Props shared by both branches. `multiple`/`value`/`onValueChange` are the
|
|
210
|
+
// Combobox's discriminant trio, so they're set per-branch below with the
|
|
211
|
+
// literal `multiple` that matches each value/handler shape.
|
|
212
|
+
const comboboxProps = {
|
|
213
|
+
"data-slot": "select-root",
|
|
214
|
+
"data-testid": props["data-testid"],
|
|
215
|
+
displayValue: (option: V) => list.labelOf(option),
|
|
216
|
+
renderValue: compactChipTrigger ? renderTriggerValue : undefined,
|
|
217
|
+
placeholder,
|
|
218
|
+
className: cn({ "w-full": fullWidth }, className),
|
|
219
|
+
shouldFilter: false as const,
|
|
220
|
+
search: list.searchQuery,
|
|
221
|
+
onSearchChange: list.setSearchQuery,
|
|
222
|
+
open: list.open,
|
|
223
|
+
onOpenChange: list.setOpen,
|
|
224
|
+
emptyState: renderSlot(renderEmpty),
|
|
225
|
+
disabled,
|
|
226
|
+
id,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return multiple ? (
|
|
230
|
+
<Combobox<V>
|
|
231
|
+
{...comboboxProps}
|
|
232
|
+
multiple={true}
|
|
233
|
+
value={value as V[] | null}
|
|
234
|
+
onValueChange={handleComboChange}
|
|
235
|
+
>
|
|
236
|
+
{renderItems()}
|
|
237
|
+
</Combobox>
|
|
238
|
+
) : (
|
|
239
|
+
<Combobox<V>
|
|
240
|
+
{...comboboxProps}
|
|
241
|
+
multiple={false}
|
|
242
|
+
value={value as V | null}
|
|
243
|
+
onValueChange={handleComboChange}
|
|
244
|
+
>
|
|
245
|
+
{renderItems()}
|
|
246
|
+
</Combobox>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/** A single selectable item. */
|
|
4
|
+
export interface Option<V = string> {
|
|
5
|
+
/** Selection identity; what the adapter serializes. */
|
|
6
|
+
value: V;
|
|
7
|
+
/** Human-readable string used for display, filtering, and chip text. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Whether the option can be selected. */
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Arbitrary per-row payload (e.g. `{ count }`) read by render slots. */
|
|
12
|
+
data?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Live state of an option, passed to `renderOption` so custom rows can reflect
|
|
17
|
+
* selection. Keyboard highlight is exposed on the row element as
|
|
18
|
+
* `aria-selected` / `data-selected`, so custom rows style it via CSS.
|
|
19
|
+
*/
|
|
20
|
+
export interface OptionState {
|
|
21
|
+
/** Whether the option is currently selected. */
|
|
22
|
+
checked: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pure-data description of one bulk row to render above the option list. The
|
|
27
|
+
* hook decides which specs exist for the current search/cap state; the facade
|
|
28
|
+
* decides labels and markup.
|
|
29
|
+
*
|
|
30
|
+
* - `select-all` / `deselect-all` act on the whole option list — the facade
|
|
31
|
+
* already has it as a prop, so the spec just carries `enabled` for the
|
|
32
|
+
* disabled-but-visible state (e.g. everything already picked).
|
|
33
|
+
* - `select-matching` / `deselect-matching` act on the search-filtered subset,
|
|
34
|
+
* which the facade can't see; `items` carries that subset so the facade can
|
|
35
|
+
* label the row ("Select N matching") and the slot is omitted when empty.
|
|
36
|
+
*/
|
|
37
|
+
export type BulkActionSpec<V> =
|
|
38
|
+
| { kind: "select-all"; enabled: boolean }
|
|
39
|
+
| { kind: "deselect-all"; enabled: boolean }
|
|
40
|
+
| { kind: "select-matching"; items: Array<Option<V>> }
|
|
41
|
+
| { kind: "deselect-matching"; items: Array<Option<V>> };
|
|
42
|
+
|
|
43
|
+
/** A renderable bulk action: spec + the closure that applies it on click. */
|
|
44
|
+
export type BulkAction<V> = BulkActionSpec<V> & { run: () => void };
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { assertNever } from "@/utils/assertNever";
|
|
4
|
+
import type { BulkAction, Option } from "./types";
|
|
5
|
+
import {
|
|
6
|
+
deselectMatching,
|
|
7
|
+
getBulkActions,
|
|
8
|
+
getVisibleOptions,
|
|
9
|
+
multiselectFilterFn,
|
|
10
|
+
selectMatching,
|
|
11
|
+
} from "./utils";
|
|
12
|
+
|
|
13
|
+
/** cmdk-style relevance score for `(label, query)`; any positive score matches. */
|
|
14
|
+
type FilterFn = (label: string, query: string) => number;
|
|
15
|
+
|
|
16
|
+
interface UseSelectListParams<V> {
|
|
17
|
+
options: Array<Option<V>>;
|
|
18
|
+
/** Current selection: an array when `multiple`, otherwise a single value or null. */
|
|
19
|
+
value: V[] | V | null;
|
|
20
|
+
onChange: (next: V[] | V | null) => void;
|
|
21
|
+
/** Multi-select when true; single-select (replace-on-pick) when false. */
|
|
22
|
+
multiple: boolean;
|
|
23
|
+
/** Cap on multi-select size. At the cap, picking another drops the oldest. */
|
|
24
|
+
maxSelections?: number;
|
|
25
|
+
/** Single-select only: re-picking the current value clears it to null. */
|
|
26
|
+
allowSelectNone?: boolean;
|
|
27
|
+
/** Match predicate over `(label, query)`; defaults to the strict word match. */
|
|
28
|
+
filterFn?: FilterFn;
|
|
29
|
+
/** Float the (frozen) selection to the top of the idle menu. */
|
|
30
|
+
pinSelected?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UseSelectListResult<V> {
|
|
34
|
+
searchQuery: string;
|
|
35
|
+
setSearchQuery: (query: string) => void;
|
|
36
|
+
open: boolean;
|
|
37
|
+
setOpen: (open: boolean) => void;
|
|
38
|
+
/** Filtered, and (when `pinSelected` and idle) selected-first ordered. */
|
|
39
|
+
visibleOptions: Array<Option<V>>;
|
|
40
|
+
/** Count of pinned options at the head of `visibleOptions` (0 unless pinned + idle). */
|
|
41
|
+
pinnedCount: number;
|
|
42
|
+
isChecked: (value: V) => boolean;
|
|
43
|
+
toggle: (value: V) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Renderable bulk rows for the current search/cap state, in display order.
|
|
46
|
+
* Empty for single-select. Each entry carries its data (`enabled` or `items`)
|
|
47
|
+
* and a `run` closure that applies it.
|
|
48
|
+
*/
|
|
49
|
+
bulkActions: Array<BulkAction<V>>;
|
|
50
|
+
/** Map a value back to its option label; falls back to `String(value)`. */
|
|
51
|
+
labelOf: (value: V) => string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asArray<V>(value: V[] | V | null): V[] {
|
|
55
|
+
if (value == null) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return Array.isArray(value) ? value : [value];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SearchParams<V> {
|
|
62
|
+
options: Array<Option<V>>;
|
|
63
|
+
filterFn: FilterFn;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Search query and the options that currently match it. */
|
|
67
|
+
function useSearch<V>({ options, filterFn }: SearchParams<V>): {
|
|
68
|
+
searchQuery: string;
|
|
69
|
+
setSearchQuery: (query: string) => void;
|
|
70
|
+
filteredOptions: Array<Option<V>>;
|
|
71
|
+
} {
|
|
72
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
73
|
+
const filteredOptions = useMemo(() => {
|
|
74
|
+
if (!searchQuery) {
|
|
75
|
+
return options;
|
|
76
|
+
}
|
|
77
|
+
return options.filter((o) => filterFn(o.label, searchQuery) > 0);
|
|
78
|
+
}, [options, searchQuery, filterFn]);
|
|
79
|
+
return { searchQuery, setSearchQuery, filteredOptions };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PinningParams<V> {
|
|
83
|
+
value: V[] | V | null;
|
|
84
|
+
pinSelected: boolean;
|
|
85
|
+
options: Array<Option<V>>;
|
|
86
|
+
searchQuery: string;
|
|
87
|
+
filteredOptions: Array<Option<V>>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Open state plus the frozen selection snapshot that orders the idle menu. The
|
|
92
|
+
* snapshot is retaken (`repin`) when the menu opens or the search clears, so a row
|
|
93
|
+
* toggled mid-session keeps its place instead of jumping to the top under the cursor.
|
|
94
|
+
*/
|
|
95
|
+
function usePinning<V>({
|
|
96
|
+
value,
|
|
97
|
+
pinSelected,
|
|
98
|
+
options,
|
|
99
|
+
searchQuery,
|
|
100
|
+
filteredOptions,
|
|
101
|
+
}: PinningParams<V>): {
|
|
102
|
+
open: boolean;
|
|
103
|
+
setOpen: (open: boolean) => void;
|
|
104
|
+
repin: () => void;
|
|
105
|
+
visibleOptions: Array<Option<V>>;
|
|
106
|
+
pinnedCount: number;
|
|
107
|
+
} {
|
|
108
|
+
const [open, setOpenState] = useState(false);
|
|
109
|
+
const [pinnedSelection, setPinnedSelection] = useState<Set<V>>(
|
|
110
|
+
() => new Set(asArray(value)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const repin = (): void => setPinnedSelection(new Set(asArray(value)));
|
|
114
|
+
|
|
115
|
+
const setOpen = (nextOpen: boolean): void => {
|
|
116
|
+
setOpenState(nextOpen);
|
|
117
|
+
if (nextOpen) {
|
|
118
|
+
repin();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const { visibleOptions, pinnedCount } = useMemo(() => {
|
|
123
|
+
if (searchQuery || !pinSelected) {
|
|
124
|
+
return { visibleOptions: filteredOptions, pinnedCount: 0 };
|
|
125
|
+
}
|
|
126
|
+
return getVisibleOptions(options, pinnedSelection);
|
|
127
|
+
}, [searchQuery, pinSelected, filteredOptions, options, pinnedSelection]);
|
|
128
|
+
|
|
129
|
+
return { open, setOpen, repin, visibleOptions, pinnedCount };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ToggleParams<V> {
|
|
133
|
+
value: V[] | V | null;
|
|
134
|
+
onChange: (next: V[] | V | null) => void;
|
|
135
|
+
multiple: boolean;
|
|
136
|
+
maxSelections: number | undefined;
|
|
137
|
+
allowSelectNone: boolean | undefined;
|
|
138
|
+
selected: ReadonlySet<V>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Membership test and the cap/cardinality-aware single-item toggle. */
|
|
142
|
+
function useToggle<V>({
|
|
143
|
+
value,
|
|
144
|
+
onChange,
|
|
145
|
+
multiple,
|
|
146
|
+
maxSelections,
|
|
147
|
+
allowSelectNone,
|
|
148
|
+
selected,
|
|
149
|
+
}: ToggleParams<V>): {
|
|
150
|
+
isChecked: (value: V) => boolean;
|
|
151
|
+
toggle: (value: V) => void;
|
|
152
|
+
} {
|
|
153
|
+
const isChecked = (candidate: V): boolean => selected.has(candidate);
|
|
154
|
+
|
|
155
|
+
const toggle = (candidate: V): void => {
|
|
156
|
+
if (!multiple) {
|
|
157
|
+
if (allowSelectNone && value === candidate) {
|
|
158
|
+
onChange(null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
onChange(candidate);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const current = asArray(value);
|
|
166
|
+
if (selected.has(candidate)) {
|
|
167
|
+
onChange(current.filter((v) => v !== candidate));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let next = [...current, candidate];
|
|
172
|
+
if (maxSelections != null && next.length > maxSelections) {
|
|
173
|
+
next = next.slice(-maxSelections);
|
|
174
|
+
}
|
|
175
|
+
onChange(next);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return { isChecked, toggle };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface BulkParams<V> {
|
|
182
|
+
value: V[] | V | null;
|
|
183
|
+
onChange: (next: V[] | V | null) => void;
|
|
184
|
+
multiple: boolean;
|
|
185
|
+
options: Array<Option<V>>;
|
|
186
|
+
filteredOptions: Array<Option<V>>;
|
|
187
|
+
searchQuery: string;
|
|
188
|
+
maxSelections: number | undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Bulk-row specs paired with run closures; inert for single-select. */
|
|
192
|
+
function useBulk<V>({
|
|
193
|
+
value,
|
|
194
|
+
onChange,
|
|
195
|
+
multiple,
|
|
196
|
+
options,
|
|
197
|
+
filteredOptions,
|
|
198
|
+
searchQuery,
|
|
199
|
+
maxSelections,
|
|
200
|
+
}: BulkParams<V>): { bulkActions: Array<BulkAction<V>> } {
|
|
201
|
+
const bulkActions = useMemo<Array<BulkAction<V>>>(() => {
|
|
202
|
+
if (!multiple) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const specs = getBulkActions({
|
|
206
|
+
options,
|
|
207
|
+
filteredOptions,
|
|
208
|
+
value: asArray(value),
|
|
209
|
+
searchQuery,
|
|
210
|
+
maxSelections,
|
|
211
|
+
});
|
|
212
|
+
return specs.map((spec): BulkAction<V> => {
|
|
213
|
+
switch (spec.kind) {
|
|
214
|
+
case "select-all":
|
|
215
|
+
return {
|
|
216
|
+
...spec,
|
|
217
|
+
run: () =>
|
|
218
|
+
onChange(
|
|
219
|
+
selectMatching(
|
|
220
|
+
asArray(value),
|
|
221
|
+
options.filter((o) => !o.disabled).map((o) => o.value),
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
};
|
|
225
|
+
case "deselect-all":
|
|
226
|
+
return { ...spec, run: () => onChange([]) };
|
|
227
|
+
case "select-matching":
|
|
228
|
+
return {
|
|
229
|
+
...spec,
|
|
230
|
+
run: () =>
|
|
231
|
+
onChange(
|
|
232
|
+
selectMatching(
|
|
233
|
+
asArray(value),
|
|
234
|
+
spec.items.map((o) => o.value),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
case "deselect-matching":
|
|
239
|
+
return {
|
|
240
|
+
...spec,
|
|
241
|
+
run: () =>
|
|
242
|
+
onChange(
|
|
243
|
+
deselectMatching(
|
|
244
|
+
asArray(value),
|
|
245
|
+
spec.items.map((o) => o.value),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
};
|
|
249
|
+
default:
|
|
250
|
+
return assertNever(spec);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}, [
|
|
254
|
+
multiple,
|
|
255
|
+
options,
|
|
256
|
+
filteredOptions,
|
|
257
|
+
value,
|
|
258
|
+
searchQuery,
|
|
259
|
+
maxSelections,
|
|
260
|
+
onChange,
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
return { bulkActions };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Headless state for a searchable select list, shared across the multiselect,
|
|
268
|
+
* dropdown, and top-K filter facades. Composes four focused concerns — search,
|
|
269
|
+
* pinning/freeze, membership/toggle, and bulk actions — behind one entry point.
|
|
270
|
+
* Pinning and bulk rows are opt-in so the single-select and top-K facades share
|
|
271
|
+
* only what they need.
|
|
272
|
+
*/
|
|
273
|
+
export function useSelectList<V>({
|
|
274
|
+
options,
|
|
275
|
+
value,
|
|
276
|
+
onChange,
|
|
277
|
+
multiple,
|
|
278
|
+
maxSelections,
|
|
279
|
+
allowSelectNone,
|
|
280
|
+
filterFn = multiselectFilterFn,
|
|
281
|
+
pinSelected = false,
|
|
282
|
+
}: UseSelectListParams<V>): UseSelectListResult<V> {
|
|
283
|
+
const selected = useMemo(() => new Set(asArray(value)), [value]);
|
|
284
|
+
|
|
285
|
+
const labelByValue = useMemo(() => {
|
|
286
|
+
const map = new Map<V, string>();
|
|
287
|
+
for (const option of options) {
|
|
288
|
+
map.set(option.value, option.label);
|
|
289
|
+
}
|
|
290
|
+
return map;
|
|
291
|
+
}, [options]);
|
|
292
|
+
const labelOf = (candidate: V): string =>
|
|
293
|
+
labelByValue.get(candidate) ?? String(candidate);
|
|
294
|
+
|
|
295
|
+
const {
|
|
296
|
+
searchQuery,
|
|
297
|
+
setSearchQuery: setSearchQueryState,
|
|
298
|
+
filteredOptions,
|
|
299
|
+
} = useSearch({ options, filterFn });
|
|
300
|
+
|
|
301
|
+
const { open, setOpen, repin, visibleOptions, pinnedCount } = usePinning({
|
|
302
|
+
value,
|
|
303
|
+
pinSelected,
|
|
304
|
+
options,
|
|
305
|
+
searchQuery,
|
|
306
|
+
filteredOptions,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const { isChecked, toggle } = useToggle({
|
|
310
|
+
value,
|
|
311
|
+
onChange,
|
|
312
|
+
multiple,
|
|
313
|
+
maxSelections,
|
|
314
|
+
allowSelectNone,
|
|
315
|
+
selected,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const { bulkActions } = useBulk({
|
|
319
|
+
value,
|
|
320
|
+
onChange,
|
|
321
|
+
multiple,
|
|
322
|
+
options,
|
|
323
|
+
filteredOptions,
|
|
324
|
+
searchQuery,
|
|
325
|
+
maxSelections,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const setSearchQuery = (query: string): void => {
|
|
329
|
+
setSearchQueryState(query);
|
|
330
|
+
if (query === "") {
|
|
331
|
+
repin();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
searchQuery,
|
|
337
|
+
setSearchQuery,
|
|
338
|
+
open,
|
|
339
|
+
setOpen,
|
|
340
|
+
visibleOptions,
|
|
341
|
+
pinnedCount,
|
|
342
|
+
isChecked,
|
|
343
|
+
toggle,
|
|
344
|
+
bulkActions,
|
|
345
|
+
labelOf,
|
|
346
|
+
};
|
|
347
|
+
}
|