@lotics/ui 5.9.0 → 5.10.1
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 +131 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +456 -0
- package/package.json +10 -5
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +2 -2
- package/src/image_gallery.tsx +8 -10
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
package/src/timeline.tsx
CHANGED
|
@@ -85,7 +85,7 @@ export function Timeline(props: TimelineProps) {
|
|
|
85
85
|
|
|
86
86
|
<View style={styles.contentColumn}>
|
|
87
87
|
{hasDetails ? (
|
|
88
|
-
<PressableHighlight onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
|
|
88
|
+
<PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
|
|
89
89
|
{row}
|
|
90
90
|
</PressableHighlight>
|
|
91
91
|
) : (
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combine two optional event handlers into one. Either may be undefined.
|
|
5
|
+
* Used to layer a focus-ring handler on top of a caller's (or a tooltip's)
|
|
6
|
+
* `onFocus`/`onBlur` so neither silently clobbers the other.
|
|
7
|
+
*/
|
|
8
|
+
export function composeHandler<E>(
|
|
9
|
+
a: ((event: E) => void) | undefined,
|
|
10
|
+
b: ((event: E) => void) | undefined,
|
|
11
|
+
): ((event: E) => void) | undefined {
|
|
12
|
+
if (!a) return b;
|
|
13
|
+
if (!b) return a;
|
|
14
|
+
return (event: E) => {
|
|
15
|
+
a(event);
|
|
16
|
+
b(event);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `:focus-visible` is a MODALITY heuristic: a control shows its ring on keyboard
|
|
21
|
+
// focus but not pointer focus. The kit no longer carries a global `:focus-visible`
|
|
22
|
+
// CSS rule, so every control paints its own ring — and needs that same signal. One
|
|
23
|
+
// focus event carries no modality, so it can only be read at the document level:
|
|
24
|
+
// a single module-level tracker records the last interaction. Web-only — on native
|
|
25
|
+
// / SSR there is no `document`, so it stays "pointer" and the ring is keyboard-gated
|
|
26
|
+
// off (text-like inputs opt back in with `always`).
|
|
27
|
+
type Modality = "keyboard" | "pointer";
|
|
28
|
+
let lastModality: Modality = "pointer";
|
|
29
|
+
let listenersInstalled = false;
|
|
30
|
+
|
|
31
|
+
function ensureModalityListeners(): void {
|
|
32
|
+
if (listenersInstalled) return;
|
|
33
|
+
if (typeof document === "undefined") return;
|
|
34
|
+
listenersInstalled = true;
|
|
35
|
+
// Capture phase so the modality is recorded BEFORE any control's focus handler
|
|
36
|
+
// runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
|
|
37
|
+
// heuristic listens the same way) — per-mount add/remove would be the bug.
|
|
38
|
+
const opts = { capture: true, passive: true } as const;
|
|
39
|
+
document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
|
|
40
|
+
document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
|
|
41
|
+
document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
|
|
42
|
+
document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UseFocusRingOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Ring on ANY focus, not just keyboard focus. For text-like inputs, which the
|
|
48
|
+
* browser treats as focus-visible even on pointer focus, so a clicked field shows
|
|
49
|
+
* its ring immediately. Plain controls omit this — they ring on keyboard only.
|
|
50
|
+
*/
|
|
51
|
+
always?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FocusRingState {
|
|
55
|
+
/**
|
|
56
|
+
* True while the element should show its focus ring: focused AND (unless
|
|
57
|
+
* `always`) the last interaction was the keyboard. Drive a `boxShadow` off this,
|
|
58
|
+
* composing with any existing one — e.g. `focusVisible ? FOCUS_RING : undefined`.
|
|
59
|
+
*/
|
|
60
|
+
focusVisible: boolean;
|
|
61
|
+
/** Raw focus state, regardless of modality. */
|
|
62
|
+
focused: boolean;
|
|
63
|
+
/** Spread onto the focusable element, or compose with `composeHandler`. */
|
|
64
|
+
focusProps: { onFocus: () => void; onBlur: () => void };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keyboard-aware focus state for painting a control's own focus ring inline.
|
|
69
|
+
* Replaces the kit's removed global `:focus-visible` CSS rule: each interactive
|
|
70
|
+
* primitive calls this and renders `FOCUS_RING` (`control_surface.ts`) when
|
|
71
|
+
* `focusVisible`. App authors do the same for their own raw focusable elements.
|
|
72
|
+
*/
|
|
73
|
+
export function useFocusRing(options?: UseFocusRingOptions): FocusRingState {
|
|
74
|
+
ensureModalityListeners();
|
|
75
|
+
const [focused, setFocused] = useState(false);
|
|
76
|
+
const onFocus = useCallback(() => setFocused(true), []);
|
|
77
|
+
const onBlur = useCallback(() => setFocused(false), []);
|
|
78
|
+
const focusVisible = focused && (options?.always === true || lastModality === "keyboard");
|
|
79
|
+
return { focusVisible, focused, focusProps: { onFocus, onBlur } };
|
|
80
|
+
}
|
package/src/use_hover.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface HoverState {
|
|
4
|
+
hovered: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Spread onto the element (or its wrapping View). `onMouseEnter`/`onMouseLeave`
|
|
7
|
+
* are forwarded to the DOM by react-native-web on View/TextInput/etc.; on native
|
|
8
|
+
* they're ignored, so `hovered` stays false (no hover on touch — correct).
|
|
9
|
+
*/
|
|
10
|
+
hoverProps: { onMouseEnter: () => void; onMouseLeave: () => void };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pointer-hover state for a premium hover affordance (a border darken / shadow on
|
|
15
|
+
* bordered inputs, selects, and other interactive surfaces — signalling "this is
|
|
16
|
+
* interactive" the same way the focus ring signals "this is focused"). Mirrors
|
|
17
|
+
* `useFocusRing`. For `Pressable`-based controls prefer the built-in `hovered`
|
|
18
|
+
* style state; reach for this on TextInput / the native `<select>` / raw DOM inputs
|
|
19
|
+
* that don't expose it.
|
|
20
|
+
*/
|
|
21
|
+
export function useHover(): HoverState {
|
|
22
|
+
const [hovered, setHovered] = useState(false);
|
|
23
|
+
const onMouseEnter = useCallback(() => setHovered(true), []);
|
|
24
|
+
const onMouseLeave = useCallback(() => setHovered(false), []);
|
|
25
|
+
return { hovered, hoverProps: { onMouseEnter, onMouseLeave } };
|
|
26
|
+
}
|
|
@@ -26,6 +26,11 @@ describe("useListKeyboardNav", () => {
|
|
|
26
26
|
expect(view.result.current.activeIndex).toBe(0);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it("seats the initial active row at initialIndex", () => {
|
|
30
|
+
const { view } = setup({ initialIndex: 2 });
|
|
31
|
+
expect(view.result.current.activeIndex).toBe(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
29
34
|
it("ArrowDown / ArrowUp move within bounds and report handled", () => {
|
|
30
35
|
const { view } = setup();
|
|
31
36
|
let handled = false;
|
|
@@ -111,3 +116,69 @@ describe("useListKeyboardNav", () => {
|
|
|
111
116
|
expect(onActiveChange).not.toHaveBeenCalled();
|
|
112
117
|
});
|
|
113
118
|
});
|
|
119
|
+
|
|
120
|
+
describe("useListKeyboardNav typeahead", () => {
|
|
121
|
+
const labels = ["Apple", "Banana", "Blueberry", "Cherry"];
|
|
122
|
+
function setupTypeahead(overrides: Partial<ListKeyboardNavOptions> = {}) {
|
|
123
|
+
return setup({
|
|
124
|
+
count: labels.length,
|
|
125
|
+
typeahead: { labelAt: (i) => labels[i] },
|
|
126
|
+
...overrides,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it("jumps to the first label-prefix match and reports the key handled", () => {
|
|
131
|
+
const { view } = setupTypeahead();
|
|
132
|
+
let handled = false;
|
|
133
|
+
act(() => {
|
|
134
|
+
handled = view.result.current.handleKey("c");
|
|
135
|
+
});
|
|
136
|
+
expect(handled).toBe(true);
|
|
137
|
+
expect(view.result.current.activeIndex).toBe(3); // Cherry
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("accumulates the buffer while typing", () => {
|
|
141
|
+
vi.useFakeTimers();
|
|
142
|
+
try {
|
|
143
|
+
const { view } = setupTypeahead();
|
|
144
|
+
act(() => void view.result.current.handleKey("b")); // "b" → Banana
|
|
145
|
+
expect(view.result.current.activeIndex).toBe(1);
|
|
146
|
+
act(() => void view.result.current.handleKey("l")); // "bl" → Blueberry
|
|
147
|
+
expect(view.result.current.activeIndex).toBe(2);
|
|
148
|
+
} finally {
|
|
149
|
+
vi.useRealTimers();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("resets the buffer after the idle timeout", () => {
|
|
154
|
+
vi.useFakeTimers();
|
|
155
|
+
try {
|
|
156
|
+
const { view } = setupTypeahead();
|
|
157
|
+
act(() => void view.result.current.handleKey("b")); // "b" → Banana
|
|
158
|
+
expect(view.result.current.activeIndex).toBe(1);
|
|
159
|
+
act(() => {
|
|
160
|
+
vi.advanceTimersByTime(700);
|
|
161
|
+
});
|
|
162
|
+
act(() => void view.result.current.handleKey("c")); // fresh "c" → Cherry, not "bc"
|
|
163
|
+
expect(view.result.current.activeIndex).toBe(3);
|
|
164
|
+
} finally {
|
|
165
|
+
vi.useRealTimers();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("skips disabled rows when jumping", () => {
|
|
170
|
+
const { view } = setupTypeahead({ isDisabled: (i) => i === 1 }); // Banana disabled
|
|
171
|
+
act(() => void view.result.current.handleKey("b"));
|
|
172
|
+
expect(view.result.current.activeIndex).toBe(2); // Blueberry, the first enabled b*
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("without a typeahead option, a printable key is not handled and does not move the row", () => {
|
|
176
|
+
const { view } = setup(); // no typeahead
|
|
177
|
+
let handled = true;
|
|
178
|
+
act(() => {
|
|
179
|
+
handled = view.result.current.handleKey("b");
|
|
180
|
+
});
|
|
181
|
+
expect(handled).toBe(false);
|
|
182
|
+
expect(view.result.current.activeIndex).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
export interface ListKeyboardNavOptions {
|
|
4
4
|
/** Number of rows in the list. */
|
|
@@ -11,6 +11,21 @@ export interface ListKeyboardNavOptions {
|
|
|
11
11
|
onClose?: () => void;
|
|
12
12
|
/** Fires when the active row changes — use it to scroll the row into view. */
|
|
13
13
|
onActiveChange?: (index: number) => void;
|
|
14
|
+
/** The row active on first render (default 0). Read once — seat the initial
|
|
15
|
+
* highlight here instead of via a post-mount effect, so an open list shows the
|
|
16
|
+
* right row with no flash. */
|
|
17
|
+
initialIndex?: number;
|
|
18
|
+
/** Opt-in native-`<select>`-style typeahead: a printable key jumps the active
|
|
19
|
+
* row to the first enabled label that starts with the typed buffer (the buffer
|
|
20
|
+
* accumulates as you keep typing, then resets after `timeoutMs` of inactivity).
|
|
21
|
+
* Absent ⇒ printable keys are reported NOT handled, so a list driven by a real
|
|
22
|
+
* search field lets those keys flow to the input instead of jumping rows. */
|
|
23
|
+
typeahead?: {
|
|
24
|
+
/** The label to match for row `index` (undefined ⇒ the row is skipped). */
|
|
25
|
+
labelAt: (index: number) => string | undefined;
|
|
26
|
+
/** Idle window before the buffer resets, in ms. Default 600 (native parity). */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
};
|
|
14
29
|
}
|
|
15
30
|
|
|
16
31
|
export interface ListKeyboardNav {
|
|
@@ -33,8 +48,18 @@ export interface ListKeyboardNav {
|
|
|
33
48
|
* Focus never leaves the input — this only tracks the active index.
|
|
34
49
|
*/
|
|
35
50
|
export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNav {
|
|
36
|
-
const { count, isDisabled, onSelect, onClose, onActiveChange } = opts;
|
|
37
|
-
const [activeIndex, setActiveIndexState] = useState(0);
|
|
51
|
+
const { count, isDisabled, onSelect, onClose, onActiveChange, typeahead, initialIndex } = opts;
|
|
52
|
+
const [activeIndex, setActiveIndexState] = useState(() => initialIndex ?? 0);
|
|
53
|
+
// Typeahead buffer + its idle-reset timer. Refs so they survive renders and the
|
|
54
|
+
// handler stays referentially stable; the timer is cleared on unmount.
|
|
55
|
+
const typeaheadBufferRef = useRef("");
|
|
56
|
+
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
57
|
+
useEffect(
|
|
58
|
+
() => () => {
|
|
59
|
+
if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
|
|
60
|
+
},
|
|
61
|
+
[],
|
|
62
|
+
);
|
|
38
63
|
|
|
39
64
|
const setActiveIndex = useCallback(
|
|
40
65
|
(index: number, scrollIntoView = true) => {
|
|
@@ -86,11 +111,33 @@ export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNa
|
|
|
86
111
|
// Let focus move on, but dismiss the list.
|
|
87
112
|
onClose?.();
|
|
88
113
|
return false;
|
|
89
|
-
default:
|
|
114
|
+
default: {
|
|
115
|
+
// Native-select typeahead — only when opted in. A printable key
|
|
116
|
+
// (single character) extends the buffer and jumps to the first enabled
|
|
117
|
+
// label that starts with it; otherwise the key isn't ours (a real
|
|
118
|
+
// search field, if any, consumes it).
|
|
119
|
+
if (typeahead && key.length === 1) {
|
|
120
|
+
if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
|
|
121
|
+
typeaheadBufferRef.current += key.toLowerCase();
|
|
122
|
+
const buffer = typeaheadBufferRef.current;
|
|
123
|
+
for (let i = 0; i < count; i++) {
|
|
124
|
+
if (!enabled(i)) continue;
|
|
125
|
+
const label = typeahead.labelAt(i);
|
|
126
|
+
if (label && label.toLowerCase().startsWith(buffer)) {
|
|
127
|
+
setActiveIndex(i);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
typeaheadTimerRef.current = setTimeout(() => {
|
|
132
|
+
typeaheadBufferRef.current = "";
|
|
133
|
+
}, typeahead.timeoutMs ?? 600);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
90
136
|
return false;
|
|
137
|
+
}
|
|
91
138
|
}
|
|
92
139
|
},
|
|
93
|
-
[activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex],
|
|
140
|
+
[activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex, typeahead],
|
|
94
141
|
);
|
|
95
142
|
|
|
96
143
|
return { activeIndex, setActiveIndex, handleKey };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { useOptionList, type UseOptionListParams } from "./use_option_list";
|
|
5
|
+
|
|
6
|
+
const OPTS = [
|
|
7
|
+
{ value: "a", label: "Apple" },
|
|
8
|
+
{ value: "b", label: "Banana" },
|
|
9
|
+
{ value: "c", label: "Cherry" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function setupSingle(overrides: Partial<UseOptionListParams<string, false>> = {}) {
|
|
13
|
+
const onValueChange = vi.fn();
|
|
14
|
+
const onRequestClose = vi.fn();
|
|
15
|
+
const onCustomCommit = vi.fn();
|
|
16
|
+
const params: UseOptionListParams<string, false> = {
|
|
17
|
+
options: OPTS,
|
|
18
|
+
search: { mode: "none" },
|
|
19
|
+
onValueChange,
|
|
20
|
+
onRequestClose,
|
|
21
|
+
onCustomCommit,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
25
|
+
initialProps: params,
|
|
26
|
+
});
|
|
27
|
+
return { view, onValueChange, onRequestClose, onCustomCommit };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setupMulti(overrides: Partial<UseOptionListParams<string, true>> = {}) {
|
|
31
|
+
const onValueChange = vi.fn();
|
|
32
|
+
const onRequestClose = vi.fn();
|
|
33
|
+
const params: UseOptionListParams<string, true> = {
|
|
34
|
+
options: OPTS,
|
|
35
|
+
multi: true,
|
|
36
|
+
search: { mode: "none" },
|
|
37
|
+
onValueChange,
|
|
38
|
+
onRequestClose,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
const view = renderHook((p: UseOptionListParams<string, true>) => useOptionList(p), {
|
|
42
|
+
initialProps: params,
|
|
43
|
+
});
|
|
44
|
+
return { view, onValueChange, onRequestClose };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("useOptionList", () => {
|
|
48
|
+
it("none mode lists every option and seats the active row on the first one", () => {
|
|
49
|
+
const { view } = setupSingle();
|
|
50
|
+
const { rows, activeIndex } = view.result.current;
|
|
51
|
+
expect(rows.map((r) => r.option.value)).toEqual(["a", "b", "c"]);
|
|
52
|
+
expect(rows.every((r) => r.kind === "option")).toBe(true);
|
|
53
|
+
expect(activeIndex).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("none mode seats the active row on the selected option", () => {
|
|
57
|
+
const { view } = setupSingle({ value: "c" });
|
|
58
|
+
expect(view.result.current.activeIndex).toBe(2);
|
|
59
|
+
expect(view.result.current.rows[2].selected).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("single pick emits the value and requests close", () => {
|
|
63
|
+
const { view, onValueChange, onRequestClose } = setupSingle();
|
|
64
|
+
act(() => view.result.current.pickRow(1));
|
|
65
|
+
expect(onValueChange).toHaveBeenCalledWith("b");
|
|
66
|
+
expect(onRequestClose).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("multi pick toggles the value and stays open", () => {
|
|
70
|
+
const { view, onValueChange, onRequestClose } = setupMulti({ value: [] });
|
|
71
|
+
act(() => view.result.current.pickRow(0));
|
|
72
|
+
expect(onValueChange).toHaveBeenCalledWith(["a"]);
|
|
73
|
+
expect(onRequestClose).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("multi pick removes an already-selected value", () => {
|
|
77
|
+
const { view, onValueChange } = setupMulti({ value: ["a", "b"] });
|
|
78
|
+
act(() => view.result.current.pickRow(0)); // toggle off "a"
|
|
79
|
+
expect(onValueChange).toHaveBeenCalledWith(["b"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("internal search filters the rows locally", () => {
|
|
83
|
+
const { view } = setupSingle({ search: { mode: "internal" } });
|
|
84
|
+
act(() => view.result.current.setQuery("ap"));
|
|
85
|
+
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["a"]);
|
|
86
|
+
expect(view.result.current.searching).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("allowCustom adds a create row for an unmatched query; picking it commits the raw query", () => {
|
|
90
|
+
const { view, onCustomCommit } = setupSingle({
|
|
91
|
+
search: { mode: "internal" },
|
|
92
|
+
allowCustom: true,
|
|
93
|
+
});
|
|
94
|
+
act(() => view.result.current.setQuery("kiwi"));
|
|
95
|
+
const rows = view.result.current.rows;
|
|
96
|
+
const custom = rows.find((r) => r.kind === "custom");
|
|
97
|
+
expect(custom).toBeTruthy();
|
|
98
|
+
expect(custom?.option.label).toBe('Add "kiwi"');
|
|
99
|
+
act(() => view.result.current.pickRow(custom!.index));
|
|
100
|
+
expect(onCustomCommit).toHaveBeenCalledWith("kiwi");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keeps the active row on the first match, never the create row (placement top)", () => {
|
|
104
|
+
const { view } = setupSingle({
|
|
105
|
+
search: { mode: "internal" },
|
|
106
|
+
allowCustom: true,
|
|
107
|
+
customOptionPlacement: "top",
|
|
108
|
+
});
|
|
109
|
+
act(() => view.result.current.setQuery("ap")); // create row + the Apple match
|
|
110
|
+
const rows = view.result.current.rows;
|
|
111
|
+
expect(rows[0].kind).toBe("custom");
|
|
112
|
+
expect(rows[view.result.current.activeIndex].kind).toBe("option");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("includeEmptyOption prepends a clear row that empties the value", () => {
|
|
116
|
+
const { view, onValueChange, onRequestClose } = setupSingle({
|
|
117
|
+
value: "a",
|
|
118
|
+
includeEmptyOption: true,
|
|
119
|
+
});
|
|
120
|
+
expect(view.result.current.rows[0].kind).toBe("empty");
|
|
121
|
+
act(() => view.result.current.pickRow(0));
|
|
122
|
+
expect(onValueChange).toHaveBeenCalledWith("");
|
|
123
|
+
expect(onRequestClose).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("select-all / deselect-all emit the full set and the empty set", () => {
|
|
127
|
+
const { view, onValueChange } = setupMulti({ value: ["a"], enableSelectAll: true });
|
|
128
|
+
expect(view.result.current.showSelectAll).toBe(true);
|
|
129
|
+
expect(view.result.current.showDeselectAll).toBe(true);
|
|
130
|
+
act(() => view.result.current.selectAll());
|
|
131
|
+
expect(onValueChange).toHaveBeenCalledWith(["a", "b", "c"]);
|
|
132
|
+
act(() => view.result.current.deselectAll());
|
|
133
|
+
expect(onValueChange).toHaveBeenLastCalledWith([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("a none-mode multi toggle keeps the active row where it is (no re-seat on selection)", () => {
|
|
137
|
+
const onValueChange = vi.fn();
|
|
138
|
+
const params: UseOptionListParams<string, true> = {
|
|
139
|
+
options: OPTS,
|
|
140
|
+
multi: true,
|
|
141
|
+
search: { mode: "none" },
|
|
142
|
+
value: ["a"],
|
|
143
|
+
onValueChange,
|
|
144
|
+
};
|
|
145
|
+
const view = renderHook((p: UseOptionListParams<string, true>) => useOptionList(p), {
|
|
146
|
+
initialProps: params,
|
|
147
|
+
});
|
|
148
|
+
act(() => view.result.current.setActiveIndex(2)); // arrow down to "c"
|
|
149
|
+
act(() => view.result.current.pickRow(2)); // toggle "c" on, popover stays open
|
|
150
|
+
expect(onValueChange).toHaveBeenCalledWith(["a", "c"]);
|
|
151
|
+
view.rerender({ ...params, value: ["a", "c"] }); // consumer applies the new value
|
|
152
|
+
// Regression: must NOT jump back to the first selected option.
|
|
153
|
+
expect(view.result.current.activeIndex).toBe(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("re-seats the active row to the first match when the search query changes", () => {
|
|
157
|
+
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
158
|
+
initialProps: { options: OPTS, search: { mode: "internal" }, onValueChange: vi.fn() },
|
|
159
|
+
});
|
|
160
|
+
act(() => view.result.current.setActiveIndex(2));
|
|
161
|
+
expect(view.result.current.activeIndex).toBe(2);
|
|
162
|
+
act(() => view.result.current.setQuery("ban")); // filters to "Banana"
|
|
163
|
+
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["b"]);
|
|
164
|
+
expect(view.result.current.activeIndex).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("matches options accent-insensitively (cafe -> Café, dieu -> Điều)", () => {
|
|
168
|
+
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
169
|
+
initialProps: {
|
|
170
|
+
options: [
|
|
171
|
+
{ value: "cf", label: "Café" },
|
|
172
|
+
{ value: "dd", label: "Điều độ" },
|
|
173
|
+
{ value: "x", label: "Other" },
|
|
174
|
+
],
|
|
175
|
+
search: { mode: "internal" },
|
|
176
|
+
onValueChange: vi.fn(),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
act(() => view.result.current.setQuery("dieu"));
|
|
180
|
+
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["dd"]);
|
|
181
|
+
act(() => view.result.current.setQuery("cafe"));
|
|
182
|
+
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["cf"]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("controlled server-filtered mode shows options as-is (no local re-filter)", () => {
|
|
186
|
+
const { view } = setupSingle({
|
|
187
|
+
// The server already filtered to one result; the hook must not re-filter it out.
|
|
188
|
+
options: [{ value: "z", label: "Zucchini" }],
|
|
189
|
+
search: { mode: "controlled", query: "xyz", serverFiltered: true },
|
|
190
|
+
});
|
|
191
|
+
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["z"]);
|
|
192
|
+
});
|
|
193
|
+
});
|