@lotics/ui 1.24.0 → 1.26.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/package.json +1 -1
- package/src/index.css +4 -2
- package/src/menu_list_item.tsx +6 -1
- package/src/search_select.tsx +72 -19
- package/src/table.tsx +5 -3
- package/src/table.web.tsx +22 -12
- package/src/table_picker.tsx +10 -5
- package/src/table_types.ts +4 -7
package/package.json
CHANGED
package/src/index.css
CHANGED
|
@@ -337,10 +337,12 @@ html {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
/* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
|
|
340
|
-
so pointer/touch interactions stay visually unchanged.
|
|
340
|
+
so pointer/touch interactions stay visually unchanged. The ring sits flush
|
|
341
|
+
against the element's border (offset 0, no gap) and, being an outline, never
|
|
342
|
+
reflows layout. */
|
|
341
343
|
:focus-visible {
|
|
342
344
|
outline: 2px solid var(--color-zinc-900);
|
|
343
|
-
outline-offset:
|
|
345
|
+
outline-offset: 0;
|
|
344
346
|
}
|
|
345
347
|
:focus:not(:focus-visible) {
|
|
346
348
|
outline: none;
|
package/src/menu_list_item.tsx
CHANGED
|
@@ -4,11 +4,15 @@ import { Text } from "./text";
|
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { Icon, IconName } from "./icon";
|
|
6
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
+
import type { TextColor } from "./text_utils";
|
|
7
8
|
|
|
8
9
|
export interface MenuListItemProps {
|
|
9
10
|
ref?: Ref<View>;
|
|
10
11
|
icon?: IconName | React.ReactNode;
|
|
11
12
|
title: string;
|
|
13
|
+
/** Title color. Default ink — set it to mark the row as an action (e.g. a
|
|
14
|
+
* leading "Browse all" tinted to match its icon). */
|
|
15
|
+
titleColor?: TextColor;
|
|
12
16
|
description?: string;
|
|
13
17
|
right?: React.ReactNode;
|
|
14
18
|
onPress?: () => void;
|
|
@@ -32,6 +36,7 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
32
36
|
ref,
|
|
33
37
|
icon,
|
|
34
38
|
title,
|
|
39
|
+
titleColor,
|
|
35
40
|
description,
|
|
36
41
|
right,
|
|
37
42
|
onPress,
|
|
@@ -52,7 +57,7 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
52
57
|
<>
|
|
53
58
|
{resolvedIcon}
|
|
54
59
|
<View style={styles.textContainer}>
|
|
55
|
-
<Text weight="medium" numberOfLines={1} userSelect="none">
|
|
60
|
+
<Text weight="medium" color={titleColor} numberOfLines={1} userSelect="none">
|
|
56
61
|
{title}
|
|
57
62
|
</Text>
|
|
58
63
|
{!!description && (
|
package/src/search_select.tsx
CHANGED
|
@@ -14,14 +14,29 @@ import { MenuListItem } from "./menu_list_item";
|
|
|
14
14
|
import { ActivityIndicator } from "./activity_indicator";
|
|
15
15
|
import { Popover, PopoverContent } from "./popover";
|
|
16
16
|
import type { PickerOption } from "./picker";
|
|
17
|
+
import type { TextColor } from "./text_utils";
|
|
17
18
|
import { useDebouncedCallback } from "./use_debounced_callback";
|
|
18
19
|
import { useListKeyboardNav } from "./use_list_keyboard_nav";
|
|
19
20
|
|
|
21
|
+
/** A pinned action row in the dropdown — not a search result, an action. */
|
|
22
|
+
export interface SearchSelectAction {
|
|
23
|
+
key: string;
|
|
24
|
+
label: string;
|
|
25
|
+
icon?: ReactNode;
|
|
26
|
+
/** Tints the label so it reads as an action (match it to the icon's color). */
|
|
27
|
+
color?: TextColor;
|
|
28
|
+
onPress: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export interface SearchSelectProps<T extends string = string, D = unknown> {
|
|
21
32
|
/** Result options for the current query. The consumer refreshes these in
|
|
22
33
|
* response to `onSearchChange` — search runs server-side, so the whole table
|
|
23
34
|
* is never shipped to the client. */
|
|
24
35
|
options: PickerOption<T, D>[];
|
|
36
|
+
/** Pinned action rows shown at the very top of the dropdown (above results and
|
|
37
|
+
* recents), always available while open and keyboard-navigable alongside the
|
|
38
|
+
* options — e.g. a "Browse all" that opens a full picker. */
|
|
39
|
+
leadingActions?: SearchSelectAction[];
|
|
25
40
|
/** Debounced; fires as the user types. Drive a server re-query from it. */
|
|
26
41
|
onSearchChange: (query: string) => void;
|
|
27
42
|
/** Fires when the user picks a result or a recent. */
|
|
@@ -78,6 +93,7 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
78
93
|
) {
|
|
79
94
|
const {
|
|
80
95
|
options,
|
|
96
|
+
leadingActions = [],
|
|
81
97
|
onSearchChange,
|
|
82
98
|
onValueChange,
|
|
83
99
|
recentOptions,
|
|
@@ -108,16 +124,30 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
108
124
|
|
|
109
125
|
const searching = query.trim().length > 0;
|
|
110
126
|
const list = searching ? options : (recentOptions ?? []);
|
|
127
|
+
// The navigable set is the leading actions followed by the option list, so
|
|
128
|
+
// arrow keys + aria-activedescendant span both. `nLead` is the boundary.
|
|
129
|
+
const nLead = leadingActions.length;
|
|
111
130
|
|
|
112
131
|
const debouncedSearch = useDebouncedCallback(onSearchChange, searchDebounceMs);
|
|
113
132
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
// Only the option list scrolls (actions are pinned above it), so map the
|
|
134
|
+
// combined index back into the list before scrolling.
|
|
135
|
+
const scrollToIndex = useCallback(
|
|
136
|
+
(index: number) => {
|
|
137
|
+
if (index < nLead) return;
|
|
138
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, (index - nLead) * OPTION_HEIGHT - 80), animated: false });
|
|
139
|
+
},
|
|
140
|
+
[nLead],
|
|
141
|
+
);
|
|
117
142
|
|
|
118
143
|
const handleSelect = useCallback(
|
|
119
144
|
(index: number) => {
|
|
120
|
-
|
|
145
|
+
if (index < nLead) {
|
|
146
|
+
leadingActions[index].onPress();
|
|
147
|
+
setOpen(false);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const opt = list[index - nLead];
|
|
121
151
|
if (!opt || opt.disabled) return;
|
|
122
152
|
onValueChange(opt);
|
|
123
153
|
// The trailing search is irrelevant once a pick is made. Cancel it and
|
|
@@ -131,12 +161,12 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
131
161
|
// search again without re-clicking.
|
|
132
162
|
inputRef.current?.focus();
|
|
133
163
|
},
|
|
134
|
-
[list, onValueChange, onSearchChange, debouncedSearch],
|
|
164
|
+
[list, leadingActions, nLead, onValueChange, onSearchChange, debouncedSearch],
|
|
135
165
|
);
|
|
136
166
|
|
|
137
167
|
const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
|
|
138
|
-
count: list.length,
|
|
139
|
-
isDisabled: (i) => list[i]?.disabled ?? false,
|
|
168
|
+
count: nLead + list.length,
|
|
169
|
+
isDisabled: (i) => (i >= nLead ? (list[i - nLead]?.disabled ?? false) : false),
|
|
140
170
|
onSelect: handleSelect,
|
|
141
171
|
onClose: () => setOpen(false),
|
|
142
172
|
onActiveChange: scrollToIndex,
|
|
@@ -168,10 +198,11 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
168
198
|
[open, handleKey],
|
|
169
199
|
);
|
|
170
200
|
|
|
171
|
-
// Nothing to show ⇒ don't open an empty popover
|
|
172
|
-
|
|
201
|
+
// Nothing to show ⇒ don't open an empty popover. Leading actions (e.g.
|
|
202
|
+
// "Browse all") always give the popover something to show on focus.
|
|
203
|
+
const showList = open && !disabled && (nLead > 0 || searching || list.length > 0);
|
|
173
204
|
const activeOptionId =
|
|
174
|
-
activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
|
|
205
|
+
activeIndex >= 0 && activeIndex < nLead + list.length ? optionId(activeIndex) : undefined;
|
|
175
206
|
const listLabel = searching ? accessibilityLabel : recentsLabel;
|
|
176
207
|
|
|
177
208
|
return (
|
|
@@ -217,6 +248,22 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
217
248
|
testID={testID ? `${testID}-popover` : undefined}
|
|
218
249
|
>
|
|
219
250
|
<View style={styles.menu}>
|
|
251
|
+
{nLead > 0
|
|
252
|
+
? leadingActions.map((action, i) => (
|
|
253
|
+
<MenuListItem
|
|
254
|
+
key={action.key}
|
|
255
|
+
nativeID={optionId(i)}
|
|
256
|
+
testID={`search-action-${action.key}`}
|
|
257
|
+
role="option"
|
|
258
|
+
icon={action.icon}
|
|
259
|
+
title={action.label}
|
|
260
|
+
titleColor={action.color}
|
|
261
|
+
focused={i === activeIndex}
|
|
262
|
+
onPress={() => handleSelect(i)}
|
|
263
|
+
onHoverIn={() => setActiveIndex(i)}
|
|
264
|
+
/>
|
|
265
|
+
))
|
|
266
|
+
: null}
|
|
220
267
|
{!searching && list.length > 0 ? (
|
|
221
268
|
<View style={styles.sectionHeader}>
|
|
222
269
|
<Text size="xs" weight="medium" color="zinc-500">
|
|
@@ -239,27 +286,31 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
239
286
|
<ActivityIndicator />
|
|
240
287
|
</View>
|
|
241
288
|
) : list.length === 0 ? (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
289
|
+
// Only "no results" while searching — an empty recents list (no
|
|
290
|
+
// query) just shows the leading actions above, nothing more.
|
|
291
|
+
searching ? (
|
|
292
|
+
<View style={styles.statusRow}>
|
|
293
|
+
<Text size="sm" color="zinc-500">
|
|
294
|
+
{emptyText}
|
|
295
|
+
</Text>
|
|
296
|
+
</View>
|
|
297
|
+
) : null
|
|
247
298
|
) : (
|
|
248
299
|
list.map((opt, i) => (
|
|
249
300
|
<MenuListItem
|
|
250
301
|
key={opt.value}
|
|
251
|
-
nativeID={optionId(i)}
|
|
302
|
+
nativeID={optionId(nLead + i)}
|
|
252
303
|
testID={`search-option-${opt.value}`}
|
|
253
304
|
role="option"
|
|
254
305
|
icon={renderOptionIcon?.(opt)}
|
|
255
306
|
title={opt.label ?? opt.value}
|
|
256
307
|
description={getOptionDescription?.(opt)}
|
|
257
308
|
right={renderOptionRight?.(opt)}
|
|
258
|
-
focused={i === activeIndex}
|
|
309
|
+
focused={nLead + i === activeIndex}
|
|
259
310
|
selected={opt.value === selectedValue}
|
|
260
311
|
disabled={opt.disabled}
|
|
261
|
-
onPress={() => handleSelect(i)}
|
|
262
|
-
onHoverIn={() => setActiveIndex(i)}
|
|
312
|
+
onPress={() => handleSelect(nLead + i)}
|
|
313
|
+
onHoverIn={() => setActiveIndex(nLead + i)}
|
|
263
314
|
/>
|
|
264
315
|
))
|
|
265
316
|
)}
|
|
@@ -275,6 +326,8 @@ const styles = StyleSheet.create({
|
|
|
275
326
|
input: {
|
|
276
327
|
backgroundColor: colors.background,
|
|
277
328
|
boxShadow: colors.border_shadow,
|
|
329
|
+
// A pill, matching the search input inside the picker dialog.
|
|
330
|
+
borderRadius: 999,
|
|
278
331
|
},
|
|
279
332
|
menu: {
|
|
280
333
|
gap: 2,
|
package/src/table.tsx
CHANGED
|
@@ -48,9 +48,11 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
48
48
|
{col.label}
|
|
49
49
|
</Text>
|
|
50
50
|
{sortable ? (
|
|
51
|
-
<
|
|
52
|
-
{active ? (sort?.dir === "asc" ? "
|
|
53
|
-
|
|
51
|
+
<Icon
|
|
52
|
+
name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
|
|
53
|
+
size={14}
|
|
54
|
+
color={active ? colors.zinc[700] : colors.zinc[400]}
|
|
55
|
+
/>
|
|
54
56
|
) : null}
|
|
55
57
|
</View>
|
|
56
58
|
);
|
package/src/table.web.tsx
CHANGED
|
@@ -7,13 +7,22 @@ import type { Column, TableProps } from "./table_types";
|
|
|
7
7
|
export type { SortDir, TableSort, Column, TableProps } from "./table_types";
|
|
8
8
|
|
|
9
9
|
// Web table. Built from raw <div>s (not RN Pressable) so a click-to-expand row
|
|
10
|
-
// can legally contain interactive
|
|
11
|
-
// <button
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// can legally contain interactive controls: the row is a <div role="row">, never
|
|
11
|
+
// a <button>. A row click toggles expansion EXCEPT when it lands on an actual
|
|
12
|
+
// interactive element — those keep their own behaviour. This is the web mirror of
|
|
13
|
+
// RN's responder model (the innermost control wins): only the control suppresses
|
|
14
|
+
// the toggle, so empty space anywhere in the row — including around a control in a
|
|
15
|
+
// wide action column — still expands. No stopPropagation, no nested <button>, no
|
|
16
|
+
// absolute overlay, no dead zones. The detail panel renders full-width below.
|
|
14
17
|
|
|
15
18
|
const CHEVRON_W = 44;
|
|
16
19
|
|
|
20
|
+
// Clicks landing on (or inside) one of these keep their own behaviour instead of
|
|
21
|
+
// toggling the row — the standard interactive HTML tags + ARIA interactive roles,
|
|
22
|
+
// plus an explicit [data-interactive] escape hatch for a non-element control.
|
|
23
|
+
const INTERACTIVE_SELECTOR =
|
|
24
|
+
'a[href], button, input, select, textarea, label, summary, [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"], [role="slider"], [role="spinbutton"], [contenteditable="true"], [data-interactive]';
|
|
25
|
+
|
|
17
26
|
function colWidth<TRow extends Record<string, unknown>>(col: Column<TRow>): CSSProperties {
|
|
18
27
|
return col.width ? { width: col.width, flexShrink: 0 } : { flex: 1, minWidth: 0 };
|
|
19
28
|
}
|
|
@@ -61,9 +70,11 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
61
70
|
{col.label}
|
|
62
71
|
</Text>
|
|
63
72
|
{sortable ? (
|
|
64
|
-
<
|
|
65
|
-
{active ? (sort?.dir === "asc" ? "
|
|
66
|
-
|
|
73
|
+
<Icon
|
|
74
|
+
name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
|
|
75
|
+
size={14}
|
|
76
|
+
color={active ? colors.zinc[700] : colors.zinc[400]}
|
|
77
|
+
/>
|
|
67
78
|
) : null}
|
|
68
79
|
</>
|
|
69
80
|
);
|
|
@@ -111,10 +122,10 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
111
122
|
onClick={
|
|
112
123
|
pressable
|
|
113
124
|
? (e: React.MouseEvent) => {
|
|
114
|
-
// Whole-row click acts (expand or select), EXCEPT
|
|
115
|
-
// interactive
|
|
116
|
-
//
|
|
117
|
-
if ((e.target as HTMLElement).closest(
|
|
125
|
+
// Whole-row click acts (expand or select), EXCEPT when it lands on an
|
|
126
|
+
// actual interactive element — those keep their own behaviour. The
|
|
127
|
+
// disclosure chevron is the keyboard/AT affordance for expansion.
|
|
128
|
+
if ((e.target as HTMLElement).closest(INTERACTIVE_SELECTOR)) return;
|
|
118
129
|
if (expandable) toggle(key, row);
|
|
119
130
|
else onRowPress?.(row);
|
|
120
131
|
}
|
|
@@ -128,7 +139,6 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
128
139
|
<div
|
|
129
140
|
key={col.key as string}
|
|
130
141
|
role="cell"
|
|
131
|
-
data-interactive={col.interactive || undefined}
|
|
132
142
|
style={{
|
|
133
143
|
...bodyCellStyle,
|
|
134
144
|
...colWidth(col),
|
package/src/table_picker.tsx
CHANGED
|
@@ -83,6 +83,9 @@ export interface TablePickerProps<TRow extends Record<string, unknown>> {
|
|
|
83
83
|
loading?: boolean;
|
|
84
84
|
/** Message when there are no rows and nothing is loading. */
|
|
85
85
|
emptyLabel?: string;
|
|
86
|
+
/** Max dialog width — a browse table needs more room than a form dialog.
|
|
87
|
+
* Default 1080. */
|
|
88
|
+
maxWidth?: number | `${number}%`;
|
|
86
89
|
testID?: string;
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -142,6 +145,7 @@ export function TablePicker<TRow extends Record<string, unknown>>(
|
|
|
142
145
|
onPageChange,
|
|
143
146
|
loading = false,
|
|
144
147
|
emptyLabel,
|
|
148
|
+
maxWidth = 1080,
|
|
145
149
|
testID,
|
|
146
150
|
} = props;
|
|
147
151
|
|
|
@@ -196,7 +200,7 @@ export function TablePicker<TRow extends Record<string, unknown>>(
|
|
|
196
200
|
);
|
|
197
201
|
|
|
198
202
|
return (
|
|
199
|
-
<Dialog open={open} onOpenChange={onOpenChange} height="90%" testID={testID}>
|
|
203
|
+
<Dialog open={open} onOpenChange={onOpenChange} height="90%" maxWidth={maxWidth} testID={testID}>
|
|
200
204
|
<View style={styles.root}>
|
|
201
205
|
{title ? (
|
|
202
206
|
<Text size="lg" weight="semibold" color="zinc-900" style={styles.title}>
|
|
@@ -269,11 +273,12 @@ const styles = StyleSheet.create({
|
|
|
269
273
|
flex: 1,
|
|
270
274
|
width: "100%",
|
|
271
275
|
gap: 12,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
// Match the Dialog's own horizontal inset (the close button sits at 24) so
|
|
277
|
+
// the content isn't flush against the dialog edges.
|
|
278
|
+
paddingHorizontal: 24,
|
|
279
|
+
paddingBottom: 8,
|
|
276
280
|
},
|
|
281
|
+
title: {},
|
|
277
282
|
search: {
|
|
278
283
|
width: "100%",
|
|
279
284
|
},
|
package/src/table_types.ts
CHANGED
|
@@ -17,10 +17,6 @@ export interface Column<TRow extends Record<string, unknown>> {
|
|
|
17
17
|
/** When true (and `onSortChange` is set), the header is pressable + shows a
|
|
18
18
|
* sort arrow when this column is the active `sort`. */
|
|
19
19
|
sortable?: boolean;
|
|
20
|
-
/** Cells whose own controls (buttons, pickers) must NOT toggle the row. The
|
|
21
|
-
* row's expand handler ignores clicks originating inside an interactive cell,
|
|
22
|
-
* so the rest of the row stays click-to-expand. */
|
|
23
|
-
interactive?: boolean;
|
|
24
20
|
renderCell?: (params: { row: TRow; column: Column<TRow> }) => ReactNode;
|
|
25
21
|
}
|
|
26
22
|
|
|
@@ -35,13 +31,14 @@ export interface TableProps<TRow extends Record<string, unknown>> {
|
|
|
35
31
|
* parent owns the actual sorting of `rows` — this only drives the indicator. */
|
|
36
32
|
sort?: TableSort<TRow> | null;
|
|
37
33
|
onSortChange?: (key: keyof TRow) => void;
|
|
38
|
-
/** Click-to-select: pressing a row (except
|
|
39
|
-
* e.g. a picker that returns the chosen row. Mutually exclusive with
|
|
34
|
+
/** Click-to-select: pressing a row (except its interactive controls) calls
|
|
35
|
+
* this — e.g. a picker that returns the chosen row. Mutually exclusive with
|
|
40
36
|
* `renderDetail` (a row either expands or selects); `renderDetail` wins if
|
|
41
37
|
* both are set. Pair with `rowStyle` to highlight the selected row. */
|
|
42
38
|
onRowPress?: (row: TRow) => void;
|
|
43
39
|
/** Render an inline detail panel, full-width below the row. When set, the
|
|
44
|
-
* whole row (except
|
|
40
|
+
* whole row (except its interactive controls) is click-to-expand. The row
|
|
41
|
+
* detects controls by element/role, so cells need no special marking. */
|
|
45
42
|
renderDetail?: (row: TRow) => ReactNode;
|
|
46
43
|
/** Controlled set of expanded row keys. Omit for internal (uncontrolled) state. */
|
|
47
44
|
expandedKeys?: Set<string>;
|