@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
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: 2px;
345
+ outline-offset: 0;
344
346
  }
345
347
  :focus:not(:focus-visible) {
346
348
  outline: none;
@@ -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 && (
@@ -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
- const scrollToIndex = useCallback((index: number) => {
115
- scrollRef.current?.scrollTo({ y: Math.max(0, index * OPTION_HEIGHT - 80), animated: false });
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
- const opt = list[index];
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 (focused box, no recents).
172
- const showList = open && !disabled && (searching || list.length > 0);
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
- <View style={styles.statusRow}>
243
- <Text size="sm" color="zinc-500">
244
- {emptyText}
245
- </Text>
246
- </View>
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
- <Text size="xs" color="muted" userSelect="none">
52
- {active ? (sort?.dir === "asc" ? "" : "") : ""}
53
- </Text>
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 cells: the row is a <div role="row">, never a
11
- // <button>, and an `interactive` cell's clicks are skipped via a centralized
12
- // `closest('[data-interactive]')` guardno stopPropagation, no nested <button>,
13
- // no absolute overlay. The detail panel renders full-width below the row.
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 elementthose 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
- <Text size="xs" color="muted" userSelect="none">
65
- {active ? (sort?.dir === "asc" ? "" : "") : ""}
66
- </Text>
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 clicks inside an
115
- // interactive cell (pickers/buttons) they keep their own behaviour.
116
- // The disclosure chevron is the keyboard/AT affordance for expansion.
117
- if ((e.target as HTMLElement).closest("[data-interactive]")) return;
125
+ // Whole-row click acts (expand or select), EXCEPT when it lands on an
126
+ // actual interactive elementthose 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),
@@ -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
- paddingHorizontal: 4,
273
- },
274
- title: {
275
- paddingHorizontal: 4,
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
  },
@@ -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 `interactive` cells) calls this —
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 `interactive` cells) is click-to-expand. */
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>;