@lotics/ui 5.9.0 → 5.10.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.
Files changed (97) hide show
  1. package/AGENTS.md +127 -44
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_dossier.tsx +5 -4
  4. package/examples/tpl_pipeline.tsx +400 -0
  5. package/examples/tpl_record.tsx +5 -4
  6. package/examples/tpl_shifts.tsx +7 -5
  7. package/examples/tpl_task_board.tsx +473 -0
  8. package/examples/tpl_tasks.tsx +440 -0
  9. package/package.json +10 -5
  10. package/src/accordion.tsx +1 -1
  11. package/src/agent_progress.tsx +1 -1
  12. package/src/agent_run.tsx +1 -1
  13. package/src/breakdown.tsx +2 -2
  14. package/src/button.tsx +12 -3
  15. package/src/calendar/calendar_view.tsx +10 -10
  16. package/src/calendar/month_view.tsx +30 -11
  17. package/src/calendar/time_grid_view.tsx +25 -7
  18. package/src/card_select_item.tsx +11 -13
  19. package/src/check_circle.tsx +88 -0
  20. package/src/checkbox_input.tsx +5 -1
  21. package/src/chip.tsx +1 -1
  22. package/src/chip_group.tsx +1 -1
  23. package/src/column_filter.tsx +3 -2
  24. package/src/combobox.tsx +84 -219
  25. package/src/confidence.tsx +14 -3
  26. package/src/control_surface.ts +22 -7
  27. package/src/data_grid.tsx +159 -0
  28. package/src/date_calendar.tsx +46 -23
  29. package/src/date_field.tsx +10 -2
  30. package/src/date_filter.tsx +5 -9
  31. package/src/date_picker.tsx +78 -26
  32. package/src/date_picker_value.ts +11 -0
  33. package/src/date_range_filter_field.tsx +6 -4
  34. package/src/drawer.tsx +1 -1
  35. package/src/file_dropzone.tsx +5 -0
  36. package/src/file_gallery_modal.tsx +43 -42
  37. package/src/file_preview.web.tsx +25 -11
  38. package/src/file_preview_types.ts +9 -0
  39. package/src/file_row.tsx +5 -1
  40. package/src/file_rows.tsx +6 -2
  41. package/src/file_thumbnail.tsx +13 -4
  42. package/src/file_thumbnail_grid.tsx +5 -0
  43. package/src/files_editor.tsx +232 -0
  44. package/src/filter_chip.tsx +25 -12
  45. package/src/focus_ring_pressable.tsx +34 -0
  46. package/src/form_picker.tsx +3 -3
  47. package/src/gantt/gantt_view.tsx +25 -7
  48. package/src/heatmap.tsx +5 -1
  49. package/src/icon_button.tsx +2 -2
  50. package/src/image_gallery.tsx +8 -10
  51. package/src/index.css +8 -12
  52. package/src/inline_date_picker.tsx +11 -11
  53. package/src/inline_edit.tsx +22 -6
  54. package/src/inline_member_select.tsx +1 -1
  55. package/src/inline_select.tsx +9 -8
  56. package/src/inline_text_input.tsx +4 -1
  57. package/src/link.tsx +11 -1
  58. package/src/link_button.tsx +1 -1
  59. package/src/list_item.tsx +2 -2
  60. package/src/member_select.tsx +9 -4
  61. package/src/menu_button.tsx +2 -2
  62. package/src/menu_list_item.tsx +2 -2
  63. package/src/number_input.tsx +11 -4
  64. package/src/option_list.tsx +211 -0
  65. package/src/peek.tsx +1 -1
  66. package/src/picker.tsx +24 -213
  67. package/src/pressable_highlight.tsx +42 -23
  68. package/src/radio_picker.tsx +2 -2
  69. package/src/range_slider.tsx +35 -7
  70. package/src/react_native.d.ts +3 -0
  71. package/src/scroll_to_bottom.tsx +1 -1
  72. package/src/section.tsx +7 -2
  73. package/src/segmented_control.tsx +2 -2
  74. package/src/select.tsx +299 -0
  75. package/src/sort_header.tsx +1 -1
  76. package/src/sources.tsx +1 -1
  77. package/src/spreadsheet_view.tsx +43 -17
  78. package/src/status_grid.tsx +4 -3
  79. package/src/stepper.tsx +1 -1
  80. package/src/switch.tsx +5 -1
  81. package/src/switch_button.tsx +1 -1
  82. package/src/switcher.tsx +1 -1
  83. package/src/table.tsx +5 -1
  84. package/src/tabs.tsx +2 -2
  85. package/src/text_input_field.tsx +15 -4
  86. package/src/time_picker.tsx +11 -4
  87. package/src/timeline.tsx +1 -1
  88. package/src/use_focus_ring.ts +80 -0
  89. package/src/use_hover.ts +26 -0
  90. package/src/use_list_keyboard_nav.test.ts +71 -0
  91. package/src/use_list_keyboard_nav.ts +52 -5
  92. package/src/use_option_list.test.ts +193 -0
  93. package/src/use_option_list.ts +354 -0
  94. package/src/command_menu.tsx +0 -205
  95. package/src/picker_menu.tsx +0 -355
  96. package/src/tag_input.tsx +0 -203
  97. package/src/time_field.tsx +0 -297
package/src/select.tsx ADDED
@@ -0,0 +1,299 @@
1
+ import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
2
+ import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
3
+ import { colors } from "./colors";
4
+ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
5
+ import { PressableHighlight } from "./pressable_highlight";
6
+ import { Text } from "./text";
7
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
8
+ import { OptionList } from "./option_list";
9
+ import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
10
+
11
+ export interface SelectProps<T extends string = string, MULTI extends boolean = false> {
12
+ options?: (PickerOption<T> | undefined | false)[];
13
+ placeholder?: string;
14
+ /** Accessible name for the control. Required in spirit whenever there's no
15
+ * visible label beside it — the selected text describes the value, not the control. */
16
+ accessibilityLabel?: string;
17
+ /** How each OPTION renders in the menu (a colour-dot badge, a member chip).
18
+ * Also the default for the trigger's selection display when `renderSelected`
19
+ * is omitted. */
20
+ renderOptionContent?: (option: PickerOption<T>) => ReactNode;
21
+ /** How each SELECTED item renders in the anchor — a render FUNCTION, not a preset.
22
+ * Use `controls.remove` to detach it in place: render `<Chip onDismiss={remove}>`
23
+ * for a removable chip box, an `OptionBadge`, or any custom display. Omit it for
24
+ * a comma summary (single: the one value, `remove` clears it). The single seam
25
+ * for "how the selection looks" — there's no `display` mode. */
26
+ renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
27
+ /** A single-line subtitle under each option's label in the menu. */
28
+ getOptionDescription?: (option: PickerOption<T>) => string | undefined;
29
+ /** Show a search field in the menu to filter the options. */
30
+ searchable?: boolean;
31
+ /** Offer a "create" row when the query matches no option — picking it adds the
32
+ * typed value. Implies `searchable` (there has to be a query to create from). */
33
+ allowCustom?: boolean;
34
+ /** Label for the create row (default: `Add "<query>"`). */
35
+ customOptionLabel?: (query: string) => string | null;
36
+ style?: StyleProp<ViewStyle>;
37
+ testID?: string;
38
+ disabled?: boolean;
39
+ autoFocus?: boolean;
40
+ enableSelectAll?: boolean;
41
+ multi?: MULTI;
42
+ includeEmptyOption?: boolean;
43
+ value?: PickerValue<T, MULTI> | null;
44
+ onValueChange?: PickerOnValueChange<T, MULTI>;
45
+ onClose?: PickerOnClose<T, MULTI>;
46
+ /** Label for the "select all" link in multi-select mode. Default: "Select all" */
47
+ selectAllLabel?: string;
48
+ /** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
49
+ deselectAllLabel?: string;
50
+ }
51
+
52
+ /**
53
+ * Choose from a known set with RICH options. The trigger shows the current
54
+ * selection — a comma summary by default, or your own `renderSelected` (render
55
+ * removable chips via `controls.remove` for a chip box, an `OptionBadge`, any
56
+ * custom look) — and opens an `OptionList` popover. Single or multi, with an
57
+ * optional in-menu search (`searchable`), create-new (`allowCustom`), select-all,
58
+ * and a leading clear row (`includeEmptyOption`). For a plain label-only single
59
+ * select use the native `Picker`; to SEARCH a large/remote set with the input AS
60
+ * the control (type-in-place, free text reflected in the field) use `Combobox`.
61
+ */
62
+ export function Select<T extends string, MULTI extends boolean = false>(props: SelectProps<T, MULTI>) {
63
+ const {
64
+ testID,
65
+ options = [],
66
+ value,
67
+ placeholder,
68
+ accessibilityLabel,
69
+ multi = false as MULTI,
70
+ renderOptionContent,
71
+ renderSelected,
72
+ getOptionDescription,
73
+ searchable = false,
74
+ allowCustom = false,
75
+ customOptionLabel,
76
+ includeEmptyOption,
77
+ style,
78
+ disabled = false,
79
+ autoFocus = false,
80
+ enableSelectAll = false,
81
+ onValueChange,
82
+ onClose,
83
+ selectAllLabel,
84
+ deselectAllLabel,
85
+ } = props;
86
+
87
+ const [open, setOpen] = useState(autoFocus);
88
+ const handleToggle = useCallback(() => {
89
+ if (!disabled) setOpen((prev) => !prev);
90
+ }, [disabled]);
91
+ const selectedItems = useSelectedItems(multi, value, options);
92
+ const handleClose = useCallback(() => setOpen(false), []);
93
+
94
+ const multiValue = useMemo(() => (multi && Array.isArray(value) ? value : []) as T[], [multi, value]);
95
+
96
+ // Detach a selected item — multi removes it from the array, single clears.
97
+ const handleRemove = useCallback(
98
+ (v: T) => {
99
+ if (!onValueChange) return;
100
+ if (multi) (onValueChange as (x: T[]) => void)(multiValue.filter((x) => x !== v));
101
+ else (onValueChange as (x: T) => void)("" as T);
102
+ },
103
+ [multi, multiValue, onValueChange],
104
+ );
105
+
106
+ // The create row was picked — add the typed value (multi appends; single sets + closes).
107
+ const handleCustomCommit = useCallback(
108
+ (raw: string) => {
109
+ const created = raw.trim() as T;
110
+ if (!created || !onValueChange) return;
111
+ if (multi) {
112
+ if (!multiValue.includes(created)) (onValueChange as (x: T[]) => void)([...multiValue, created]);
113
+ } else {
114
+ (onValueChange as (x: T) => void)(created);
115
+ setOpen(false);
116
+ }
117
+ },
118
+ [multi, multiValue, onValueChange],
119
+ );
120
+
121
+ return (
122
+ <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
123
+ <PopoverTrigger>
124
+ <SelectTrigger
125
+ testID={testID}
126
+ open={open}
127
+ style={style}
128
+ onPress={handleToggle}
129
+ renderOptionContent={renderOptionContent}
130
+ renderSelected={renderSelected}
131
+ onRemove={handleRemove}
132
+ selectedItems={selectedItems}
133
+ placeholder={placeholder}
134
+ accessibilityLabel={accessibilityLabel}
135
+ disabled={disabled}
136
+ />
137
+ </PopoverTrigger>
138
+ {/* disableBodyScroll: OptionList owns its own row scroll + keeps the search
139
+ field/footer OUTSIDE it. The default body ScrollView would be a redundant
140
+ second scroll that also clips the autofocused search field's outset ring. */}
141
+ <PopoverContent disableBodyScroll>
142
+ <OptionList<T, MULTI>
143
+ search={{ mode: searchable || allowCustom ? "internal" : "none" }}
144
+ options={options}
145
+ multi={multi}
146
+ value={value}
147
+ onValueChange={onValueChange}
148
+ onClose={onClose}
149
+ onRequestClose={handleClose}
150
+ allowCustom={allowCustom}
151
+ customOptionLabel={customOptionLabel}
152
+ onCustomCommit={handleCustomCommit}
153
+ renderOptionContent={renderOptionContent}
154
+ getOptionDescription={getOptionDescription}
155
+ enableSelectAll={enableSelectAll}
156
+ includeEmptyOption={includeEmptyOption}
157
+ accessibilityLabel={accessibilityLabel}
158
+ selectAllLabel={selectAllLabel}
159
+ deselectAllLabel={deselectAllLabel}
160
+ />
161
+ </PopoverContent>
162
+ </Popover>
163
+ );
164
+ }
165
+
166
+ function useSelectedItems<T extends string>(
167
+ multi: boolean,
168
+ value: T | T[] | undefined | null,
169
+ options: (PickerOption<T> | undefined | false)[],
170
+ ): PickerOption<T>[] {
171
+ return useMemo(() => {
172
+ const optionsMap = new Map(
173
+ options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
174
+ );
175
+ // A value not in `options` (e.g. an `allowCustom` creation) still shows —
176
+ // fall back to a {value, label} so it renders.
177
+ const resolve = (val: T): PickerOption<T> => optionsMap.get(val) ?? { value: val, label: val };
178
+ if (multi) {
179
+ const multiValue = Array.isArray(value) ? value : [];
180
+ return multiValue.map(resolve);
181
+ }
182
+ const singleValue = Array.isArray(value) ? value[0] : value;
183
+ if (singleValue !== undefined && singleValue !== null && singleValue !== "") {
184
+ return [resolve(singleValue)];
185
+ }
186
+ return [];
187
+ }, [multi, value, options]);
188
+ }
189
+
190
+ function SelectTrigger<T extends string>({
191
+ ref,
192
+ testID,
193
+ open,
194
+ style,
195
+ onPress,
196
+ renderOptionContent,
197
+ renderSelected,
198
+ onRemove,
199
+ selectedItems,
200
+ placeholder,
201
+ accessibilityLabel,
202
+ disabled = false,
203
+ }: {
204
+ ref?: Ref<View>;
205
+ testID?: string;
206
+ open: boolean;
207
+ style?: StyleProp<ViewStyle>;
208
+ onPress: () => void;
209
+ renderOptionContent?: (option: PickerOption<T>) => ReactNode;
210
+ renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
211
+ onRemove?: (value: T) => void;
212
+ selectedItems: PickerOption<T>[];
213
+ placeholder?: string;
214
+ accessibilityLabel?: string;
215
+ disabled?: boolean;
216
+ }) {
217
+ const hasSelection = selectedItems.length > 0;
218
+
219
+ return (
220
+ // PressableHighlight owns the hover wash + keyboard focus ring (layered AROUND
221
+ // the caller's `style`, so a borderless override — `borderColor:"transparent"`
222
+ // for an in-cell Select — keeps the wash and reads like its inline neighbours).
223
+ // The hover-border sits BEFORE `style` so a transparent border suppresses it
224
+ // (wash only), while a bordered Select darkens its edge.
225
+ <PressableHighlight
226
+ focusRing
227
+ ref={ref}
228
+ testID={testID}
229
+ // Without a role this Pressable renders as an unfocusable <div> on web — the
230
+ // trigger drops out of the tab order and Enter/Space can't open it. `button`
231
+ // makes it tab-focusable and maps keyboard activation to onPress; `expanded`
232
+ // announces open/closed to assistive tech.
233
+ accessibilityRole="button"
234
+ accessibilityLabel={accessibilityLabel}
235
+ accessibilityState={{ expanded: open, disabled }}
236
+ style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, state.hovered && !disabled && { borderColor: HOVER_BORDER }, style]}
237
+ onPress={!disabled ? onPress : undefined}
238
+ disabled={disabled}
239
+ >
240
+ {hasSelection ? (
241
+ renderSelected ? (
242
+ <View style={styles.selectionRow}>
243
+ {selectedItems.map((item) => (
244
+ <View key={item.value}>
245
+ {renderSelected(item, {
246
+ // The chip's ✕ press reaches here via IconButton's forwarded event;
247
+ // stop it so detaching doesn't ALSO open the menu.
248
+ remove: (e?: GestureResponderEvent) => {
249
+ e?.stopPropagation?.();
250
+ onRemove?.(item.value);
251
+ },
252
+ })}
253
+ </View>
254
+ ))}
255
+ </View>
256
+ ) : renderOptionContent ? (
257
+ <View style={styles.selectionRow}>
258
+ {selectedItems.map((item) => (
259
+ <View key={item.value}>{renderOptionContent(item)}</View>
260
+ ))}
261
+ </View>
262
+ ) : (
263
+ <Text userSelect="none">{selectedItems.map((s) => s.label).join(", ")}</Text>
264
+ )
265
+ ) : (
266
+ <Text color="zinc-500" userSelect="none">
267
+ {placeholder}
268
+ </Text>
269
+ )}
270
+ </PressableHighlight>
271
+ );
272
+ }
273
+
274
+ const styles = StyleSheet.create({
275
+ pressable: {
276
+ flexDirection: "row",
277
+ alignItems: "center",
278
+ paddingHorizontal: 6,
279
+ paddingVertical: 4,
280
+ borderWidth: 1,
281
+ borderColor: colors.border,
282
+ borderRadius: CONTROL_RADIUS,
283
+ minHeight: 40,
284
+ },
285
+ opened: {
286
+ // Mouse-opened, so the trigger never takes keyboard focus — wear the same 2px
287
+ // zinc ring a focused input gets, over the unchanged 1px border (not a lone 1px edge).
288
+ boxShadow: FOCUS_RING,
289
+ },
290
+ disabled: {
291
+ opacity: 0.5,
292
+ },
293
+ selectionRow: {
294
+ flexDirection: "row",
295
+ gap: 4,
296
+ flexWrap: "wrap",
297
+ flex: 1,
298
+ },
299
+ });
@@ -85,7 +85,7 @@ export function SortHeader(props: SortHeaderProps) {
85
85
 
86
86
  return (
87
87
  <PressableHighlight
88
- accessibilityRole="button"
88
+ focusRing accessibilityRole="button"
89
89
  accessibilityLabel={`${sortByLabel}${dirText}`}
90
90
  onPress={() => onSort(sortKey)}
91
91
  style={[styles.header, align === "right" ? styles.right : null, style]}
package/src/sources.tsx CHANGED
@@ -51,7 +51,7 @@ export function Sources(props: SourcesProps) {
51
51
  <View style={styles.row}>
52
52
  {props.sources.map((s) =>
53
53
  props.onOpen ? (
54
- <PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
54
+ <PressableHighlight focusRing key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
55
55
  <SourceChip source={s} openable />
56
56
  </PressableHighlight>
57
57
  ) : (
@@ -1,8 +1,24 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { View, StyleSheet, Pressable } from "react-native";
2
+ import { View, StyleSheet } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Button } from "./button";
5
5
  import { colors } from "./colors";
6
+ import { FocusRingPressable } from "./focus_ring_pressable";
7
+
8
+ function SheetTab(props: { selected: boolean; onPress: () => void; name: string }) {
9
+ return (
10
+ <FocusRingPressable
11
+ accessibilityRole="tab"
12
+ accessibilityState={{ selected: props.selected }}
13
+ onPress={props.onPress}
14
+ style={[styles.tab, props.selected ? styles.tabActive : null]}
15
+ >
16
+ <Text size="xs" color={props.selected ? "default" : "muted"} numberOfLines={1}>
17
+ {props.name}
18
+ </Text>
19
+ </FocusRingPressable>
20
+ );
21
+ }
6
22
  import { isExcelMimeType } from "./mime";
7
23
  import { downloadFileFromUrl } from "./download";
8
24
  import { type PreviewLabels } from "./file_preview_types";
@@ -48,8 +64,12 @@ async function loadXlsx(): Promise<XlsxApi> {
48
64
  };
49
65
  }
50
66
 
51
- async function fetchArrayBuffer(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
52
- const response = await fetch(url, { signal });
67
+ async function fetchArrayBuffer(
68
+ url: string,
69
+ signal: AbortSignal,
70
+ credentials: RequestCredentials | undefined,
71
+ ): Promise<ArrayBuffer> {
72
+ const response = await fetch(url, { signal, credentials });
53
73
  if (!response.ok) throw new Error(`Spreadsheet fetch failed (${response.status})`);
54
74
  return response.arrayBuffer();
55
75
  }
@@ -58,6 +78,9 @@ interface SpreadsheetViewProps {
58
78
  file: DisplayFile;
59
79
  labels: PreviewLabels;
60
80
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
81
+ /** Forwarded to the byte fetch — `"include"` for the host's auth-gated proxy
82
+ * URLs, omitted for an app's presigned URLs. See `FilePreviewProps`. */
83
+ credentials?: RequestCredentials;
61
84
  }
62
85
 
63
86
  interface ParseState {
@@ -72,7 +95,7 @@ interface ParseState {
72
95
  * transparent scroll layer (sized to the sheet) sits over a viewport-sized
73
96
  * canvas that redraws on scroll. No editing, ribbon, or selection.
74
97
  */
75
- export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps) {
98
+ export function SpreadsheetView({ file, labels, onError, credentials }: SpreadsheetViewProps) {
76
99
  const apiRef = useRef<XlsxApi | null>(null);
77
100
  const [state, setState] = useState<ParseState>({ loading: true, workbook: undefined, error: undefined });
78
101
  const [sheetIndex, setSheetIndex] = useState(0);
@@ -91,8 +114,8 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
91
114
  const run = async () => {
92
115
  const api = apiRef.current ?? (apiRef.current = await loadXlsx());
93
116
  const parsed = isExcelMimeType(file.mimeType)
94
- ? api.parseExcelBuffer(await fetchArrayBuffer(file.url, controller.signal))
95
- : await api.parseCsvFile(file.url, controller.signal);
117
+ ? api.parseExcelBuffer(await fetchArrayBuffer(file.url, controller.signal, credentials))
118
+ : await api.parseCsvFile(file.url, controller.signal, credentials);
96
119
  if (controller.signal.aborted) return;
97
120
  const wb = api.loadWorkbookFromSnapshot(parsed);
98
121
  api.recomputeIfStale(wb);
@@ -112,7 +135,15 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
112
135
  });
113
136
  });
114
137
  return () => controller.abort();
115
- }, [file.url, file.id, file.mimeType, labels.loadFailed, labels.passwordProtected, onError]);
138
+ }, [
139
+ file.url,
140
+ file.id,
141
+ file.mimeType,
142
+ labels.loadFailed,
143
+ labels.passwordProtected,
144
+ onError,
145
+ credentials,
146
+ ]);
116
147
 
117
148
  // Track the viewport size.
118
149
  useEffect(() => {
@@ -242,7 +273,7 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
242
273
  icon="download"
243
274
  title={labels.download}
244
275
  color="secondary"
245
- onPress={() => void downloadFileFromUrl(file.url, file.filename)}
276
+ onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
246
277
  />
247
278
  </View>
248
279
  </View>
@@ -264,21 +295,16 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
264
295
  {sheets.length > 1 ? (
265
296
  <View style={styles.tabs}>
266
297
  {sheets.map((s, i) => (
267
- <Pressable
298
+ <SheetTab
268
299
  key={i}
269
- accessibilityRole="tab"
270
- accessibilityState={{ selected: i === sheetIndex }}
300
+ selected={i === sheetIndex}
301
+ name={s.name}
271
302
  onPress={() => {
272
303
  setSheetIndex(i);
273
304
  setScroll({ x: 0, y: 0 });
274
305
  if (scrollRef.current) scrollRef.current.scrollTo({ left: 0, top: 0 });
275
306
  }}
276
- style={[styles.tab, i === sheetIndex ? styles.tabActive : null]}
277
- >
278
- <Text size="xs" color={i === sheetIndex ? "default" : "muted"} numberOfLines={1}>
279
- {s.name}
280
- </Text>
281
- </Pressable>
307
+ />
282
308
  ))}
283
309
  </View>
284
310
  ) : null}
@@ -1,7 +1,8 @@
1
1
  import { useState } from "react";
2
- import { Pressable, StyleSheet, View } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { colors, solid, tint, type ColorName } from "./colors";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
+ import { FocusRingPressable } from "./focus_ring_pressable";
5
6
  import { Text } from "./text";
6
7
 
7
8
  export interface StatusGridState {
@@ -84,7 +85,7 @@ function Cell(props: CellProps) {
84
85
  };
85
86
  if (!onPress) return <View style={[styles.cell, base]} />;
86
87
  return (
87
- <Pressable
88
+ <FocusRingPressable
88
89
  accessibilityRole="button"
89
90
  accessibilityLabel={label}
90
91
  onPress={onPress}
@@ -130,7 +131,7 @@ export function StatusLegend(props: StatusLegendProps) {
130
131
  );
131
132
  return onSelect ? (
132
133
  <PressableHighlight
133
- key={state.key}
134
+ focusRing key={state.key}
134
135
  accessibilityRole="button"
135
136
  accessibilityState={{ selected }}
136
137
  accessibilityLabel={`${state.label}: ${count}`}
package/src/stepper.tsx CHANGED
@@ -141,7 +141,7 @@ export function Step(props: StepProps) {
141
141
  </View>
142
142
  <View style={[styles.vContent, !_last ? styles.vGap : null]}>
143
143
  {onPress ? (
144
- <PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
144
+ <PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
145
145
  {body}
146
146
  </PressableHighlight>
147
147
  ) : (
package/src/switch.tsx CHANGED
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef } from "react";
2
2
  import { Animated, StyleSheet, Pressable, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Icon } from "./icon";
5
+ import { FOCUS_RING } from "./control_surface";
6
+ import { useFocusRing } from "./use_focus_ring";
5
7
  export interface SwitchProps {
6
8
  testID?: string;
7
9
  /**
@@ -17,6 +19,7 @@ export interface SwitchProps {
17
19
  export function Switch(props: SwitchProps) {
18
20
  const { testID, accessibilityLabel, value, disabled, onChange } = props;
19
21
  const checked = useRef(new Animated.Value(value ? 1 : 0)).current;
22
+ const { focusVisible, focusProps } = useFocusRing();
20
23
 
21
24
  const handlePress = useCallback(() => {
22
25
  if (onChange !== undefined) {
@@ -72,7 +75,8 @@ export function Switch(props: SwitchProps) {
72
75
  <Pressable
73
76
  testID={testID}
74
77
  onPress={handlePress}
75
- style={styles.root}
78
+ {...focusProps}
79
+ style={[styles.root, focusVisible && { boxShadow: FOCUS_RING }]}
76
80
  disabled={disabled}
77
81
  accessibilityRole="switch"
78
82
  accessibilityLabel={accessibilityLabel}
@@ -16,7 +16,7 @@ export function SwitchButton(props: SwitchButtonProps) {
16
16
 
17
17
  return (
18
18
  <PressableHighlight
19
- style={{
19
+ focusRing style={{
20
20
  flexDirection: "row",
21
21
  gap: 16,
22
22
  alignItems: "center",
package/src/switcher.tsx CHANGED
@@ -69,7 +69,7 @@ export function Switcher(props: SwitcherProps) {
69
69
  return (
70
70
  <Popover open={open} onOpenChange={setOpen} side={side} align={align}>
71
71
  <PopoverTrigger>
72
- <PressableHighlight style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
72
+ <PressableHighlight focusRing style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
73
73
  <Text size="sm" weight="medium" color="zinc-700" numberOfLines={1}>
74
74
  {label}
75
75
  </Text>
package/src/table.tsx CHANGED
@@ -12,6 +12,8 @@ import { Text } from "./text";
12
12
  import { Divider } from "./divider";
13
13
  import { PressableRow } from "./pressable_row";
14
14
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
15
+ import { FOCUS_RING } from "./control_surface";
16
+ import { useFocusRing } from "./use_focus_ring";
15
17
 
16
18
  /**
17
19
  * One column of a register — its width/flex/align/label/sortability defined ONCE,
@@ -132,6 +134,7 @@ export function TableRow(props: TableRowProps) {
132
134
  const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[]).map(
133
135
  (cell, i) => cloneElement(cell, { _column: ctx.columns[i] }),
134
136
  );
137
+ const { focusVisible, focusProps } = useFocusRing();
135
138
 
136
139
  return (
137
140
  <PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
@@ -140,7 +143,8 @@ export function TableRow(props: TableRowProps) {
140
143
  accessibilityRole={onPress ? "button" : undefined}
141
144
  accessibilityLabel={accessibilityLabel}
142
145
  onPress={onPress}
143
- style={[styles.door, { minHeight }]}
146
+ {...focusProps}
147
+ style={[styles.door, { minHeight }, focusVisible && { boxShadow: FOCUS_RING }]}
144
148
  >
145
149
  {cells}
146
150
  </Pressable>
package/src/tabs.tsx CHANGED
@@ -110,8 +110,8 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
110
110
 
111
111
  return (
112
112
  <PressableHighlight
113
- ref={ref}
114
- style={{
113
+ focusRing
114
+ ref={ref} style={{
115
115
  borderBottomWidth: 3,
116
116
  borderBottomColor: selected ? colors.zinc["700"] : "transparent",
117
117
  backgroundColor: "transparent",
@@ -5,7 +5,9 @@ import {
5
5
  View,
6
6
  } from "react-native";
7
7
  import { colors } from "./colors";
8
- import { CONTROL_RADIUS } from "./control_surface";
8
+ import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
+ import { useFocusRing, composeHandler } from "./use_focus_ring";
10
+ import { useHover } from "./use_hover";
9
11
  import { Ref, useCallback } from "react";
10
12
  import { Icon, IconName } from "./icon";
11
13
  import { IconButton } from "./icon_button";
@@ -68,6 +70,12 @@ export function TextInputField(props: TextInputFieldProps) {
68
70
  const { small } = useScreenSize();
69
71
  const lineHeight = getInputLineHeight(small);
70
72
  const binding = useFormField();
73
+ // Text-like input: ring on ANY focus (browsers treat typing-capable fields as
74
+ // focus-visible even on pointer focus), so a clicked field shows its ring.
75
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
76
+ // Premium hover affordance: the resting border darkens, signalling interactivity.
77
+ // Tracked on the wrapping View (RN-Web forwards mouse events there reliably).
78
+ const { hovered, hoverProps } = useHover();
71
79
 
72
80
  // Describedby chains description and error so both are read. We join them
73
81
  // explicitly here because React Native Web does not flatten array attrs.
@@ -108,7 +116,7 @@ export function TextInputField(props: TextInputFieldProps) {
108
116
  );
109
117
 
110
118
  return (
111
- <View>
119
+ <View {...(hoverProps as object)}>
112
120
  {icon && (
113
121
  <View style={styles.icon}>
114
122
  <Icon name={icon} size={20} color={colors.zinc[400]} />
@@ -120,20 +128,23 @@ export function TextInputField(props: TextInputFieldProps) {
120
128
  ref={mergedRef}
121
129
  value={value}
122
130
  onChangeText={handleChangeText}
123
- onFocus={inputProps.onFocus}
124
- onBlur={inputProps.onBlur}
131
+ onFocus={composeHandler(inputProps.onFocus, focusProps.onFocus)}
132
+ onBlur={composeHandler(inputProps.onBlur, focusProps.onBlur)}
125
133
  nativeID={binding?.inputId ?? inputProps.nativeID}
126
134
  accessibilityLabelledBy={binding?.labelId ?? inputProps.accessibilityLabelledBy}
127
135
  aria-describedby={describedBy}
128
136
  aria-invalid={binding?.invalid || undefined}
129
137
  style={[
130
138
  styles.input,
139
+ CONTROL_TRANSITION,
131
140
  getInputTextStyle(),
132
141
  height ? { height } : undefined,
133
142
  autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
134
143
  !editable && styles.disabled,
135
144
  icon && styles.withIcon,
145
+ hovered && editable && { borderColor: HOVER_BORDER },
136
146
  style,
147
+ focusVisible && { boxShadow: FOCUS_RING },
137
148
  ]}
138
149
  multiline={(numberOfLines && numberOfLines > 1) || multiline}
139
150
  focusable={editable}
@@ -1,7 +1,9 @@
1
1
  import type { KeyboardEvent } from "react";
2
2
  import { colors } from "@lotics/ui/colors";
3
3
  import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
4
- import { CONTROL_RADIUS } from "./control_surface";
4
+ import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
5
+ import { useFocusRing } from "./use_focus_ring";
6
+ import { useHover } from "./use_hover";
5
7
  export interface TimePickerProps {
6
8
  value?: string;
7
9
  onValueChange: (value: string) => void;
@@ -15,6 +17,8 @@ export interface TimePickerProps {
15
17
 
16
18
  export function TimePicker(props: TimePickerProps) {
17
19
  const { value, onValueChange, onBlur, onKeyDown, autoFocus, disabled, accessibilityLabel } = props;
20
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
21
+ const { hovered, hoverProps } = useHover();
18
22
 
19
23
  return (
20
24
  <input
@@ -23,7 +27,9 @@ export function TimePicker(props: TimePickerProps) {
23
27
  onValueChange(e.target.value);
24
28
  }}
25
29
  type="time"
26
- onBlur={onBlur}
30
+ {...hoverProps}
31
+ onFocus={focusProps.onFocus}
32
+ onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
27
33
  onKeyDown={onKeyDown}
28
34
  autoFocus={autoFocus}
29
35
  disabled={disabled}
@@ -35,14 +41,15 @@ export function TimePicker(props: TimePickerProps) {
35
41
  borderRadius: CONTROL_RADIUS,
36
42
  borderWidth: 1,
37
43
  borderStyle: "solid",
38
- borderColor: colors.border,
44
+ borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
39
45
  backgroundColor: colors.background,
40
46
  fontFamily: fontFamilyRegular,
41
47
  ...inputTextStyleWeb,
42
48
  letterSpacing: -0.4,
43
- boxShadow: "none",
49
+ boxShadow: focusVisible ? FOCUS_RING : "none",
44
50
  outline: "none",
45
51
  boxSizing: "border-box",
52
+ transition: "border-color 0.12s, box-shadow 0.12s",
46
53
  }}
47
54
  />
48
55
  );