@lotics/ui 1.24.0 → 1.25.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.25.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
@@ -61,9 +61,11 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
61
61
  {col.label}
62
62
  </Text>
63
63
  {sortable ? (
64
- <Text size="xs" color="muted" userSelect="none">
65
- {active ? (sort?.dir === "asc" ? "" : "") : ""}
66
- </Text>
64
+ <Icon
65
+ name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
66
+ size={14}
67
+ color={active ? colors.zinc[700] : colors.zinc[400]}
68
+ />
67
69
  ) : null}
68
70
  </>
69
71
  );
@@ -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
  },