@lotics/ui 11.2.0 → 11.4.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/AGENTS.md +13 -2
- package/examples/tpl_task_board.tsx +2 -2
- package/package.json +1 -1
- package/src/date_calendar.tsx +14 -4
- package/src/date_picker.tsx +12 -23
- package/src/inline_date_picker.tsx +29 -4
- package/src/inline_member_select.tsx +4 -0
- package/src/inline_select.tsx +21 -1
- package/src/locale.tsx +11 -3
- package/src/option_list.tsx +29 -1
- package/src/popover.tsx +79 -28
- package/src/use_option_list.ts +3 -0
package/AGENTS.md
CHANGED
|
@@ -227,7 +227,18 @@ clicking floats a multi `OptionList` (checkbox rows), CLOSING commits the new se
|
|
|
227
227
|
`onSave` — never a borderless `Select` posing as an inline field) — all
|
|
228
228
|
on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
|
|
229
229
|
saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
|
|
230
|
-
without losing the edit.
|
|
230
|
+
without losing the edit. To let a value be UNSET (a diff-write CLEAR — unassign, remove a due
|
|
231
|
+
date, drop a select), pass **`onClear`** to `InlineSelect` / `InlineMemberSelect` /
|
|
232
|
+
`InlineDatePicker`. It surfaces through each popover's OWN clear affordance — `InlineSelect` renders
|
|
233
|
+
a compact left-aligned "Clear" `Button` below the `OptionList` (only while a value is set);
|
|
234
|
+
`InlineDatePicker` reuses the calendar's own footer "Clear" button. NOT a bolted-on sibling row: that
|
|
235
|
+
can't reach the option list's active-highlight (so the last-hovered option stays lit) and would
|
|
236
|
+
double the footer hairline;
|
|
237
|
+
and never a persistent ✕ on the resting cell (noise on a dense board, and a pressable nested in the
|
|
238
|
+
view's button trigger is invalid DOM). `onSave`'s `next` stays non-null so a caller opts in per field;
|
|
239
|
+
the clear fires `onClear`, which writes `null` (the app workflow must ACCEPT null on that input — a
|
|
240
|
+
`select`/`date`/`member` field clears on null). `InlineTagSelect` (multi) needs no `onClear` — an
|
|
241
|
+
empty set is already a valid `onSave`. A STACK of rows lives in a `DetailTable` (label ·
|
|
231
242
|
value · trailing laid out like a TABLE: `labelWidth` / `trailingWidth` /
|
|
232
243
|
`minHeight` set ONCE on the parent, plus the 6px row gap the zinc-50 chips
|
|
233
244
|
need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a
|
|
@@ -1065,7 +1076,7 @@ check_circle (CheckCircle — the completion ring: an empty ring that springs to
|
|
|
1065
1076
|
floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
|
|
1066
1077
|
columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
|
|
1067
1078
|
sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Example: `tpl_task_board`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
|
|
1068
|
-
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover (Popover + PopoverTrigger + PopoverContent — **PopoverContent already insets its body 12px; put content directly in it, NEVER add your own padding View (that double-pads). Title/actions via PopoverHeader / PopoverFooter**) · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
|
|
1079
|
+
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover (Popover + PopoverTrigger + PopoverContent — **NON-MODAL: the anchored popover has NO blocking overlay, so the rest of the page stays interactive; clicking another control both dismisses this popover AND activates that control in one click; clicking outside, scrolling an ancestor, or Escape dismisses. Only `small` (bottom sheet) is modal (scrim). PopoverContent already insets its body 12px; put content directly in it, NEVER add your own padding View (that double-pads). Title/actions via PopoverHeader / PopoverFooter**) · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
|
|
1069
1080
|
alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
|
|
1070
1081
|
CalloutText · CalloutActions) · kpi_card · kpi_strip · summary_line (SummaryLine — the light inline summary of a register/list's FILTERED view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band) · metric · trend_chip · sparkline ·
|
|
1071
1082
|
bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
|
|
@@ -265,8 +265,8 @@ export function TplTaskBoard() {
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
const propertyCols: DataGridColumn<Task>[] = [
|
|
268
|
-
{ key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect background="transparent" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
|
|
269
|
-
{ key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
|
|
268
|
+
{ key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect background="transparent" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
|
|
269
|
+
{ key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
|
|
270
270
|
{ key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect background="transparent" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
|
|
271
271
|
{ key: "tags", label: "Tags", width: 168, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
|
|
272
272
|
];
|
package/package.json
CHANGED
package/src/date_calendar.tsx
CHANGED
|
@@ -7,6 +7,15 @@ import { Picker, PickerOption } from "./picker";
|
|
|
7
7
|
import { IconButton } from "./icon_button";
|
|
8
8
|
import { FOCUS_RING } from "./control_surface";
|
|
9
9
|
import { useFocusRing } from "./use_focus_ring";
|
|
10
|
+
import { useLoticsLocale } from "./locale";
|
|
11
|
+
|
|
12
|
+
/** Accessible names for the calendar's month-navigation arrows. Backs the
|
|
13
|
+
* `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
|
|
14
|
+
* `locale` prop, not from here.) */
|
|
15
|
+
export interface CalendarLabels {
|
|
16
|
+
previousMonth: string;
|
|
17
|
+
nextMonth: string;
|
|
18
|
+
}
|
|
10
19
|
|
|
11
20
|
function DayCell(props: {
|
|
12
21
|
day: number;
|
|
@@ -237,6 +246,7 @@ function CalendarMonth(props: CalendarMonthProps) {
|
|
|
237
246
|
firstDayOfWeek = 1, // Default to Monday
|
|
238
247
|
locale,
|
|
239
248
|
} = props;
|
|
249
|
+
const navLabels = useLoticsLocale().calendar;
|
|
240
250
|
|
|
241
251
|
const names = getLocalizedNames(locale);
|
|
242
252
|
const daysInMonth = getDaysInMonth(year, month);
|
|
@@ -314,14 +324,14 @@ function CalendarMonth(props: CalendarMonthProps) {
|
|
|
314
324
|
{showNavigation && (
|
|
315
325
|
<IconButton
|
|
316
326
|
icon="chevron-left"
|
|
317
|
-
accessibilityLabel=
|
|
327
|
+
accessibilityLabel={navLabels.previousMonth}
|
|
318
328
|
onPress={handlePrevMonth}
|
|
319
329
|
/>
|
|
320
330
|
)}
|
|
321
331
|
{showLeftArrow && onPrevMonth && (
|
|
322
332
|
<IconButton
|
|
323
333
|
icon="chevron-left"
|
|
324
|
-
accessibilityLabel=
|
|
334
|
+
accessibilityLabel={navLabels.previousMonth}
|
|
325
335
|
onPress={onPrevMonth}
|
|
326
336
|
/>
|
|
327
337
|
)}
|
|
@@ -348,14 +358,14 @@ function CalendarMonth(props: CalendarMonthProps) {
|
|
|
348
358
|
{showNavigation && (
|
|
349
359
|
<IconButton
|
|
350
360
|
icon="chevron-right"
|
|
351
|
-
accessibilityLabel=
|
|
361
|
+
accessibilityLabel={navLabels.nextMonth}
|
|
352
362
|
onPress={handleNextMonth}
|
|
353
363
|
/>
|
|
354
364
|
)}
|
|
355
365
|
{showRightArrow && onNextMonth && (
|
|
356
366
|
<IconButton
|
|
357
367
|
icon="chevron-right"
|
|
358
|
-
accessibilityLabel=
|
|
368
|
+
accessibilityLabel={navLabels.nextMonth}
|
|
359
369
|
onPress={onNextMonth}
|
|
360
370
|
/>
|
|
361
371
|
)}
|
package/src/date_picker.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Calendar, CalendarRangeValue } from "./date_calendar";
|
|
|
7
7
|
import { TimePicker } from "./time_picker";
|
|
8
8
|
import { DateField } from "./date_field";
|
|
9
9
|
import { SegmentLabels } from "./date_segments";
|
|
10
|
+
import { useLoticsLocale } from "./locale";
|
|
10
11
|
import { Icon } from "./icon";
|
|
11
12
|
import { Button } from "./button";
|
|
12
13
|
import { IconButton } from "./icon_button";
|
|
@@ -28,6 +29,10 @@ import {
|
|
|
28
29
|
// Types
|
|
29
30
|
// =============================================================================
|
|
30
31
|
|
|
32
|
+
/** All translatable strings the date picker renders — the calendar popover's
|
|
33
|
+
* quick actions + accessible names, plus the segmented field's per-segment
|
|
34
|
+
* labels ({@link SegmentLabels}). Backs the `datePicker` locale slice; a caller
|
|
35
|
+
* may still override per-instance via a `labels` prop. */
|
|
31
36
|
export interface DatePickerLabels extends SegmentLabels {
|
|
32
37
|
/** Quick action: set the value to today. Shown for the `date` format. */
|
|
33
38
|
today: string;
|
|
@@ -51,25 +56,6 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
51
56
|
removeTime: string;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
const DEFAULT_LABELS: DatePickerLabels = {
|
|
55
|
-
today: "Today",
|
|
56
|
-
now: "Now",
|
|
57
|
-
clear: "Clear",
|
|
58
|
-
done: "Done",
|
|
59
|
-
openCalendar: "Open calendar",
|
|
60
|
-
time: "Time",
|
|
61
|
-
startTime: "Start time",
|
|
62
|
-
endTime: "End time",
|
|
63
|
-
addTime: "Add time",
|
|
64
|
-
removeTime: "Remove time",
|
|
65
|
-
year: "Year",
|
|
66
|
-
month: "Month",
|
|
67
|
-
day: "Day",
|
|
68
|
-
hour: "Hour",
|
|
69
|
-
minute: "Minute",
|
|
70
|
-
dayPeriod: "AM/PM",
|
|
71
|
-
};
|
|
72
|
-
|
|
73
59
|
export interface DatePickerProps {
|
|
74
60
|
value?: string | null;
|
|
75
61
|
onValueChange: (value: string) => void;
|
|
@@ -82,7 +68,9 @@ export interface DatePickerProps {
|
|
|
82
68
|
/** Fires when the text input loses focus, after any typed value is committed. */
|
|
83
69
|
onBlur?: () => void;
|
|
84
70
|
testID?: string;
|
|
85
|
-
/**
|
|
71
|
+
/** Per-instance label overrides (quick actions + accessible names). Anything
|
|
72
|
+
* omitted resolves from the `datePicker` locale slice — English by default, or
|
|
73
|
+
* the active `LoticsLocaleProvider` pack. */
|
|
86
74
|
labels?: Partial<DatePickerLabels>;
|
|
87
75
|
/** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
|
|
88
76
|
locale?: string;
|
|
@@ -119,7 +107,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
119
107
|
const optTime = !!optionalTime && !isRange;
|
|
120
108
|
const valueHasTime = isoHasTime(value);
|
|
121
109
|
const hasTime = optTime ? valueHasTime : hasTimeFormat(format);
|
|
122
|
-
const mergedLabels = { ...
|
|
110
|
+
const mergedLabels = { ...useLoticsLocale().datePicker, ...labels };
|
|
123
111
|
|
|
124
112
|
const [startIso, endIso] = useMemo<[string, string]>(() => {
|
|
125
113
|
if (!value) return ["", ""];
|
|
@@ -342,9 +330,10 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
342
330
|
const optTime = !!optionalTime && !isRange;
|
|
343
331
|
// The trigger's segments follow the value's shape in optionalTime mode.
|
|
344
332
|
const hasTime = optTime ? isoHasTime(value) : hasTimeFormat(format);
|
|
333
|
+
const datePickerLabels = useLoticsLocale().datePicker;
|
|
345
334
|
const mergedLabels = useMemo<DatePickerLabels>(
|
|
346
|
-
() => ({ ...
|
|
347
|
-
[labels],
|
|
335
|
+
() => ({ ...datePickerLabels, ...labels }),
|
|
336
|
+
[datePickerLabels, labels],
|
|
348
337
|
);
|
|
349
338
|
|
|
350
339
|
const [open, setOpen] = useState(false);
|
|
@@ -15,6 +15,11 @@ export interface InlineDatePickerProps {
|
|
|
15
15
|
/** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
|
|
16
16
|
value: string | null;
|
|
17
17
|
onSave: (next: string) => void | Promise<void>;
|
|
18
|
+
/** Unset the date to empty. Provide it to make the value clearable: the
|
|
19
|
+
* calendar's own "Clear" button then unsets the field through this callback
|
|
20
|
+
* (without `onClear` an empty selection is ignored — a required date can't be
|
|
21
|
+
* emptied). Kept separate from `onSave` (whose next is a non-empty string). */
|
|
22
|
+
onClear?: () => void | Promise<void>;
|
|
18
23
|
/** "date" (default) or "datetime". */
|
|
19
24
|
format?: "date" | "datetime";
|
|
20
25
|
/** Let the user OPTIONALLY add a time to a date (the value's shape decides — date
|
|
@@ -37,7 +42,7 @@ export interface InlineDatePickerProps {
|
|
|
37
42
|
* dismissing without a change reverts.
|
|
38
43
|
*/
|
|
39
44
|
export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
40
|
-
const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
|
|
45
|
+
const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
|
|
41
46
|
const labels = useLoticsLocale().inline;
|
|
42
47
|
const [open, setOpen] = useState(false);
|
|
43
48
|
const [draft, setDraft] = useState<string | null>(value);
|
|
@@ -49,7 +54,23 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
49
54
|
|
|
50
55
|
const commit = useCallback(async () => {
|
|
51
56
|
const next = draftRef.current;
|
|
52
|
-
if (next === value
|
|
57
|
+
if (next === value) return;
|
|
58
|
+
// The panel's "Clear" empties the draft (onValueChange("")). Treat that as an
|
|
59
|
+
// explicit unset when the field is clearable (onClear + a current value);
|
|
60
|
+
// otherwise ignore an empty draft — a required date can't be emptied.
|
|
61
|
+
if (!next) {
|
|
62
|
+
if (!onClear || !value) return;
|
|
63
|
+
setSaving(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
await onClear();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
69
|
+
} finally {
|
|
70
|
+
setSaving(false);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
53
74
|
setSaving(true);
|
|
54
75
|
setError(null);
|
|
55
76
|
try {
|
|
@@ -59,7 +80,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
59
80
|
} finally {
|
|
60
81
|
setSaving(false);
|
|
61
82
|
}
|
|
62
|
-
}, [value, onSave]);
|
|
83
|
+
}, [value, onSave, onClear, labels.saveError]);
|
|
63
84
|
|
|
64
85
|
const onOpenChange = useCallback(
|
|
65
86
|
(next: boolean) => {
|
|
@@ -108,7 +129,11 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
108
129
|
format={format}
|
|
109
130
|
optionalTime={optionalTime}
|
|
110
131
|
locale={locale}
|
|
111
|
-
|
|
132
|
+
// Commit on panel close. A single-date pick and the "Today" button
|
|
133
|
+
// auto-close through here — route it through onOpenChange so commit
|
|
134
|
+
// runs. A bare setOpen(false) is a controlled close the Popover never
|
|
135
|
+
// reports to onOpenChange, so the pick/clear would be silently dropped.
|
|
136
|
+
onRequestClose={() => onOpenChange(false)}
|
|
112
137
|
/>
|
|
113
138
|
</PopoverContent>
|
|
114
139
|
</Popover>
|
|
@@ -16,6 +16,10 @@ interface InlineMemberSelectProps {
|
|
|
16
16
|
/** Commit a new assignment (the picked member id). Throwing surfaces the
|
|
17
17
|
* inline error and reverts, like every inline editor. */
|
|
18
18
|
onSave: (memberId: string) => void | Promise<void>;
|
|
19
|
+
/** Unassign — unset the field to empty. Provide it to make the assignment
|
|
20
|
+
* clearable: a compact "Clear" button appears below the picker options when a
|
|
21
|
+
* member is set. Kept separate from `onSave` (whose id is non-null). */
|
|
22
|
+
onClear?: () => void | Promise<void>;
|
|
19
23
|
placeholder?: string;
|
|
20
24
|
disabled?: boolean;
|
|
21
25
|
accessibilityLabel?: string;
|
package/src/inline_select.tsx
CHANGED
|
@@ -13,6 +13,11 @@ import { useLoticsLocale } from "./locale";
|
|
|
13
13
|
export interface InlineSelectProps<T extends string> {
|
|
14
14
|
value: T | null;
|
|
15
15
|
onSave: (next: T) => void | Promise<void>;
|
|
16
|
+
/** Unset the field to empty. Provide it to make the value clearable: a compact
|
|
17
|
+
* "Clear" button appears below the options whenever there IS a value. Kept
|
|
18
|
+
* separate from `onSave` (whose next is a non-null `T`) so an app opts in
|
|
19
|
+
* without every caller having to handle null. */
|
|
20
|
+
onClear?: () => void | Promise<void>;
|
|
16
21
|
options: PickerOption<T>[];
|
|
17
22
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
18
23
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
@@ -39,7 +44,7 @@ export interface InlineSelectProps<T extends string> {
|
|
|
39
44
|
* Pass `renderOptionContent` for rich options, or omit it for a plain label list.
|
|
40
45
|
*/
|
|
41
46
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
42
|
-
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
|
|
47
|
+
const { value, onSave, onClear, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
|
|
43
48
|
const labels = useLoticsLocale().inline;
|
|
44
49
|
const [open, setOpen] = useState(false);
|
|
45
50
|
const [saving, setSaving] = useState(false);
|
|
@@ -64,6 +69,20 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
64
69
|
[value, onSave],
|
|
65
70
|
);
|
|
66
71
|
|
|
72
|
+
const clear = useCallback(async () => {
|
|
73
|
+
setOpen(false);
|
|
74
|
+
if (value == null || !onClear) return;
|
|
75
|
+
setSaving(true);
|
|
76
|
+
setError(null);
|
|
77
|
+
try {
|
|
78
|
+
await onClear();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
81
|
+
} finally {
|
|
82
|
+
setSaving(false);
|
|
83
|
+
}
|
|
84
|
+
}, [value, onClear]);
|
|
85
|
+
|
|
67
86
|
return (
|
|
68
87
|
<View>
|
|
69
88
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
@@ -90,6 +109,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
90
109
|
options={options}
|
|
91
110
|
value={value}
|
|
92
111
|
onValueChange={(next) => void pick(next)}
|
|
112
|
+
onClear={onClear ? () => void clear() : undefined}
|
|
93
113
|
onRequestClose={() => setOpen(false)}
|
|
94
114
|
renderOptionContent={renderOptionContent}
|
|
95
115
|
/>
|
package/src/locale.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { type DatePickerLabels } from "./date_picker";
|
|
3
|
+
import { type CalendarLabels } from "./date_calendar";
|
|
2
4
|
import { type PaginationLabels } from "./pagination";
|
|
3
5
|
import { type SortHeaderLabels } from "./sort_header";
|
|
4
6
|
import { type ConfidenceLabels } from "./confidence";
|
|
@@ -28,7 +30,9 @@ export interface LoticsLocale {
|
|
|
28
30
|
/** `OptionList` (and everything built on it — `Select`, `Combobox`, the
|
|
29
31
|
* in-cell editors): the select-all/deselect-all links, the empty state, the
|
|
30
32
|
* internal search-field placeholder, and the `Combobox` recents header. */
|
|
31
|
-
optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string; searchPlaceholder: string };
|
|
33
|
+
optionList: { selectAll: string; deselectAll: string; clear: string; noResults: string; recent: string; searchPlaceholder: string };
|
|
34
|
+
datePicker: DatePickerLabels;
|
|
35
|
+
calendar: CalendarLabels;
|
|
32
36
|
/** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
|
|
33
37
|
* a call site doesn't pass a dimension-specific `clearLabel`. */
|
|
34
38
|
filterChip: { clear: string };
|
|
@@ -99,7 +103,9 @@ export const en: LoticsLocale = {
|
|
|
99
103
|
ascending: ", ascending",
|
|
100
104
|
descending: ", descending",
|
|
101
105
|
},
|
|
102
|
-
optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
106
|
+
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
107
|
+
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM" },
|
|
108
|
+
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
103
109
|
filterChip: { clear: "Clear" },
|
|
104
110
|
formField: { optional: "Optional" },
|
|
105
111
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
@@ -176,7 +182,9 @@ export const vi: LoticsLocale = {
|
|
|
176
182
|
ascending: " (tăng dần)",
|
|
177
183
|
descending: " (giảm dần)",
|
|
178
184
|
},
|
|
179
|
-
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
185
|
+
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
186
|
+
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH" },
|
|
187
|
+
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
|
180
188
|
filterChip: { clear: "Xóa" },
|
|
181
189
|
formField: { optional: "Tùy chọn" },
|
|
182
190
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
package/src/option_list.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Text } from "./text";
|
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
import { Checkbox } from "./checkbox";
|
|
7
7
|
import { MenuButton } from "./menu_button";
|
|
8
|
+
import { Button } from "./button";
|
|
8
9
|
import { TextLink } from "./text_link";
|
|
9
10
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
11
|
import { TextInputField } from "./text_input_field";
|
|
@@ -25,6 +26,11 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
|
|
|
25
26
|
accessibilityLabel?: string;
|
|
26
27
|
selectAllLabel?: string;
|
|
27
28
|
deselectAllLabel?: string;
|
|
29
|
+
/** Single-select only: unset the value. When provided AND a value is selected, a
|
|
30
|
+
* "Clear" row shows below the options (a real `MenuButton`, so it shares the
|
|
31
|
+
* option rows' height/hover/a11y). Hovering it moves the active highlight OFF
|
|
32
|
+
* the options — hence it lives inside the list, not as a sibling. */
|
|
33
|
+
onClear?: () => void;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -39,7 +45,7 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
|
|
|
39
45
|
export function OptionList<T extends string, MULTI extends boolean = false, D = unknown>(
|
|
40
46
|
props: OptionListProps<T, MULTI, D>,
|
|
41
47
|
) {
|
|
42
|
-
const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search } = props;
|
|
48
|
+
const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search, onClear } = props;
|
|
43
49
|
const loc = useLoticsLocale().optionList;
|
|
44
50
|
const emptyText = props.emptyText ?? loc.noResults;
|
|
45
51
|
const selectAllLabel = props.selectAllLabel ?? loc.selectAll;
|
|
@@ -47,6 +53,12 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
47
53
|
const list = useOptionList(props);
|
|
48
54
|
const { small } = useScreenSize();
|
|
49
55
|
|
|
56
|
+
// The option rows set `activeIndex` on hover but have no hover-OUT, so entering
|
|
57
|
+
// the Clear footer clears it here — otherwise the last-hovered option stays lit
|
|
58
|
+
// while the pointer is on Clear. RN's types don't declare mouse handlers;
|
|
59
|
+
// rn-web forwards them to the DOM (the boundary cast pressable_row.tsx uses).
|
|
60
|
+
const clearHover = { onMouseEnter: () => list.setActiveIndex(-1, false) } as object;
|
|
61
|
+
|
|
50
62
|
const onSearchKeyPress = (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
51
63
|
if (list.handleKey(e.nativeEvent.key)) e.preventDefault();
|
|
52
64
|
};
|
|
@@ -157,6 +169,14 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
157
169
|
)}
|
|
158
170
|
</ScrollView>
|
|
159
171
|
|
|
172
|
+
{onClear && !props.multi && list.hasSelection ? (
|
|
173
|
+
// A plain compact Button (matches the inline date picker's footer "Clear"),
|
|
174
|
+
// not a full-width row.
|
|
175
|
+
<View style={styles.clearRow} {...clearHover}>
|
|
176
|
+
<Button title={loc.clear} color="muted" onPress={onClear} />
|
|
177
|
+
</View>
|
|
178
|
+
) : null}
|
|
179
|
+
|
|
160
180
|
{list.showSelectAll || list.showDeselectAll ? (
|
|
161
181
|
<View style={styles.selectAllContainer}>
|
|
162
182
|
{list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
|
|
@@ -192,6 +212,14 @@ const styles = StyleSheet.create({
|
|
|
192
212
|
option: {
|
|
193
213
|
marginHorizontal: 0,
|
|
194
214
|
},
|
|
215
|
+
// The single-select "Clear" footer — a left-aligned compact button, set off from
|
|
216
|
+
// the options above by the same hairline the select-all/deselect-all footer uses.
|
|
217
|
+
clearRow: {
|
|
218
|
+
flexDirection: "row",
|
|
219
|
+
paddingTop: 4,
|
|
220
|
+
borderTopWidth: 1,
|
|
221
|
+
borderTopColor: colors.border,
|
|
222
|
+
},
|
|
195
223
|
statusRow: {
|
|
196
224
|
alignItems: "center",
|
|
197
225
|
justifyContent: "center",
|
package/src/popover.tsx
CHANGED
|
@@ -188,7 +188,7 @@ export interface PopoverContentProps {
|
|
|
188
188
|
* restores it on close. Set false for an anchored panel whose trigger must
|
|
189
189
|
* keep focus — e.g. a search-as-you-type combobox whose input lives outside
|
|
190
190
|
* the popover and drives the list via `aria-activedescendant`. Focus then
|
|
191
|
-
* stays on the trigger; keyboard still reaches
|
|
191
|
+
* stays on the trigger; keyboard still reaches the content. */
|
|
192
192
|
manageFocus?: boolean;
|
|
193
193
|
}
|
|
194
194
|
|
|
@@ -212,9 +212,9 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
212
212
|
const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
|
|
213
213
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
214
214
|
// Last known trigger geometry. The trigger can unmount while the popover is
|
|
215
|
-
// open — a hover-revealed menu button stops being rendered
|
|
216
|
-
//
|
|
217
|
-
//
|
|
215
|
+
// open — a hover-revealed menu button stops being rendered once its row loses
|
|
216
|
+
// hover/focus — so positioning cannot rely on `triggerRef.current` still being
|
|
217
|
+
// live when it runs.
|
|
218
218
|
const triggerRectRef = useRef<DOMRect | null>(null);
|
|
219
219
|
|
|
220
220
|
const handleClose = useCallback(() => {
|
|
@@ -224,9 +224,9 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
224
224
|
|
|
225
225
|
// Snapshot the trigger geometry synchronously the moment we open, while it is
|
|
226
226
|
// guaranteed to still be mounted. `calculatePosition` runs later in a rAF, by
|
|
227
|
-
// which point a hover-revealed trigger may already be gone (
|
|
228
|
-
//
|
|
229
|
-
// anchored to where the trigger was instead of stranding it off-screen.
|
|
227
|
+
// which point a hover-revealed trigger may already be gone (a hover-revealed row
|
|
228
|
+
// button can unmount once hover/focus leaves the row). Capturing here keeps the
|
|
229
|
+
// popover anchored to where the trigger was instead of stranding it off-screen.
|
|
230
230
|
useLayoutEffect(() => {
|
|
231
231
|
if (!open || small) return;
|
|
232
232
|
if (triggerRef.current) {
|
|
@@ -249,7 +249,10 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
249
249
|
const firstFocusable = content.querySelector<HTMLElement>(
|
|
250
250
|
'input, select, textarea, button, [role="button"], [role="menuitem"], [role="option"], [tabindex]:not([tabindex="-1"])',
|
|
251
251
|
);
|
|
252
|
-
|
|
252
|
+
// preventScroll: focusing the content must never scroll an ancestor into
|
|
253
|
+
// view — that scroll would trip the scroll-to-dismiss listener and close the
|
|
254
|
+
// popover the instant it opens.
|
|
255
|
+
(firstFocusable ?? content).focus({ preventScroll: true });
|
|
253
256
|
});
|
|
254
257
|
|
|
255
258
|
return () => {
|
|
@@ -262,11 +265,11 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
262
265
|
// popover (a Picker/InlineSelect/menu) it is <body>; restoring there strands
|
|
263
266
|
// focus and the next Tab jumps to the page's first focusable. Fall back to
|
|
264
267
|
// the prior element only if the trigger is gone (e.g. a hover-revealed menu
|
|
265
|
-
// button that unmounted
|
|
268
|
+
// button that unmounted once its row lost hover).
|
|
266
269
|
const trigger = triggerRef.current;
|
|
267
270
|
const target = trigger && trigger.isConnected ? trigger : prior;
|
|
268
271
|
if (target && typeof target.focus === "function") {
|
|
269
|
-
target.focus();
|
|
272
|
+
target.focus({ preventScroll: true });
|
|
270
273
|
}
|
|
271
274
|
};
|
|
272
275
|
}, [open, manageFocus]);
|
|
@@ -313,6 +316,34 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
313
316
|
};
|
|
314
317
|
}, [open, small, onOpenChange]);
|
|
315
318
|
|
|
319
|
+
// Outside-dismiss (NON-MODAL anchored popover): with no blocking overlay, the
|
|
320
|
+
// rest of the page stays interactive, so we close on a click that lands outside
|
|
321
|
+
// this popover's content AND its trigger. We listen on `click` (not pointerdown)
|
|
322
|
+
// in the CAPTURE phase so that clicking ANOTHER field's trigger completes that
|
|
323
|
+
// trigger's own press — the old popover dismisses AND the new one opens in a
|
|
324
|
+
// single click, instead of the press being cancelled mid-gesture. A click INSIDE
|
|
325
|
+
// a deeper (nested) popover keeps this one open — a Select opened inside a filter
|
|
326
|
+
// popover must not dismiss the filter. The bottom sheet (`small`) is modal and
|
|
327
|
+
// dismisses via its scrim, so it opts out here.
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (!open || small) return;
|
|
330
|
+
const onOutsideClick = (e: MouseEvent) => {
|
|
331
|
+
const node = e.target instanceof Node ? e.target : null;
|
|
332
|
+
if (!node) return;
|
|
333
|
+
if (popoverRef.current?.contains(node)) return; // inside my content
|
|
334
|
+
if (triggerRef.current?.contains(node)) return; // on my trigger — its own press toggles
|
|
335
|
+
// Keep open when the click is inside a DEEPER popover (e.g. a Select opened
|
|
336
|
+
// inside this one). `data-popover-level` is the same nesting depth as z-index.
|
|
337
|
+
const myLevel = Number(popoverRef.current?.getAttribute("data-popover-level") ?? "0");
|
|
338
|
+
const el = node instanceof Element ? node : node.parentElement;
|
|
339
|
+
const hit = el?.closest("[data-popover]");
|
|
340
|
+
if (hit && Number(hit.getAttribute("data-popover-level") ?? "0") > myLevel) return; // deeper popover
|
|
341
|
+
onOpenChange(false);
|
|
342
|
+
};
|
|
343
|
+
document.addEventListener("click", onOutsideClick, true);
|
|
344
|
+
return () => document.removeEventListener("click", onOutsideClick, true);
|
|
345
|
+
}, [open, small, onOpenChange, triggerRef]);
|
|
346
|
+
|
|
316
347
|
const calculatePosition = useCallback(() => {
|
|
317
348
|
if (!popoverRef.current) return;
|
|
318
349
|
if (small) return;
|
|
@@ -476,11 +507,26 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
476
507
|
}
|
|
477
508
|
});
|
|
478
509
|
|
|
510
|
+
// Non-modal: the page can now scroll UNDER an open popover. A scroll of the
|
|
511
|
+
// page or any ancestor is a "moved on" signal, so we DISMISS (as native
|
|
512
|
+
// <select> and most menu systems do) rather than chase the trigger and risk a
|
|
513
|
+
// panel detached from an off-screen anchor. Scrolling the popover's OWN content
|
|
514
|
+
// (a long option list) must NOT close it. Capture catches nested scrollers.
|
|
515
|
+
const onScroll = (e: Event) => {
|
|
516
|
+
if (e.target instanceof Node && popoverRef.current?.contains(e.target)) return;
|
|
517
|
+
onOpenChange(false);
|
|
518
|
+
};
|
|
519
|
+
window.addEventListener("scroll", onScroll, true);
|
|
520
|
+
// A window resize just re-anchors (the layout moved, the intent didn't).
|
|
521
|
+
window.addEventListener("resize", calculatePosition);
|
|
522
|
+
|
|
479
523
|
return () => {
|
|
480
524
|
cancelAnimationFrame(frame);
|
|
481
525
|
resizeObserver?.disconnect();
|
|
526
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
527
|
+
window.removeEventListener("resize", calculatePosition);
|
|
482
528
|
};
|
|
483
|
-
}, [open, calculatePosition, small]);
|
|
529
|
+
}, [open, calculatePosition, small, onOpenChange]);
|
|
484
530
|
|
|
485
531
|
const handleOverlayClick = useCallback(
|
|
486
532
|
(e: React.MouseEvent) => {
|
|
@@ -501,27 +547,32 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
501
547
|
|
|
502
548
|
return (
|
|
503
549
|
<Portal>
|
|
504
|
-
{/*
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
550
|
+
{/* Modal scrim — ONLY for the bottom-sheet (`small`) mode, which IS modal.
|
|
551
|
+
The anchored popover is NON-MODAL: no overlay, so the rest of the page
|
|
552
|
+
stays interactive; outside-dismiss is the click listener above. */}
|
|
553
|
+
{small && (
|
|
554
|
+
<div
|
|
555
|
+
style={{
|
|
556
|
+
position: "fixed",
|
|
557
|
+
top: 0,
|
|
558
|
+
left: 0,
|
|
559
|
+
right: 0,
|
|
560
|
+
bottom: 0,
|
|
561
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
562
|
+
opacity: isBottomSheetShown ? 1 : 0,
|
|
563
|
+
transition: "opacity 0.3s ease",
|
|
564
|
+
zIndex: overlayZIndex,
|
|
565
|
+
pointerEvents: "auto",
|
|
566
|
+
}}
|
|
567
|
+
onClick={handleOverlayClick}
|
|
568
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
521
571
|
{/* Popover */}
|
|
522
572
|
<div
|
|
523
573
|
ref={popoverRef}
|
|
524
574
|
data-popover="true"
|
|
575
|
+
data-popover-level={nestingLevel}
|
|
525
576
|
data-testid={testID}
|
|
526
577
|
role="dialog"
|
|
527
578
|
aria-modal={small ? true : undefined}
|
package/src/use_option_list.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface UseOptionList<T extends string, D> {
|
|
|
86
86
|
deselectAll: () => void;
|
|
87
87
|
showSelectAll: boolean;
|
|
88
88
|
showDeselectAll: boolean;
|
|
89
|
+
/** Whether anything is selected — single: a non-empty value; multi: ≥1. */
|
|
90
|
+
hasSelection: boolean;
|
|
89
91
|
listboxId: string;
|
|
90
92
|
scrollRef: React.RefObject<ScrollView | null>;
|
|
91
93
|
}
|
|
@@ -351,6 +353,7 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
|
|
|
351
353
|
deselectAll,
|
|
352
354
|
showSelectAll,
|
|
353
355
|
showDeselectAll,
|
|
356
|
+
hasSelection,
|
|
354
357
|
listboxId,
|
|
355
358
|
scrollRef,
|
|
356
359
|
};
|