@lotics/ui 5.8.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 (104) hide show
  1. package/AGENTS.md +184 -56
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_dossier.tsx +5 -4
  5. package/examples/tpl_order.tsx +1 -1
  6. package/examples/tpl_pipeline.tsx +400 -0
  7. package/examples/tpl_ratedesk.tsx +1 -1
  8. package/examples/tpl_record.tsx +6 -5
  9. package/examples/tpl_shifts.tsx +7 -5
  10. package/examples/tpl_task_board.tsx +473 -0
  11. package/examples/tpl_tasks.tsx +440 -0
  12. package/package.json +19 -6
  13. package/src/accordion.tsx +1 -1
  14. package/src/agent_progress.tsx +1 -1
  15. package/src/agent_run.tsx +1 -1
  16. package/src/breakdown.tsx +2 -2
  17. package/src/button.tsx +12 -3
  18. package/src/calendar/calendar_view.tsx +10 -10
  19. package/src/calendar/month_view.tsx +30 -11
  20. package/src/calendar/time_grid_view.tsx +25 -7
  21. package/src/card_select_item.tsx +11 -13
  22. package/src/check_circle.tsx +88 -0
  23. package/src/checkbox_input.tsx +5 -1
  24. package/src/chip.tsx +1 -1
  25. package/src/chip_group.tsx +1 -1
  26. package/src/column_filter.tsx +3 -2
  27. package/src/combobox.tsx +84 -219
  28. package/src/confidence.tsx +14 -3
  29. package/src/control_surface.ts +22 -7
  30. package/src/data_grid.tsx +159 -0
  31. package/src/date_calendar.tsx +46 -23
  32. package/src/date_field.tsx +10 -2
  33. package/src/date_filter.tsx +5 -9
  34. package/src/date_picker.tsx +78 -26
  35. package/src/date_picker_value.ts +11 -0
  36. package/src/date_range_filter_field.tsx +6 -4
  37. package/src/drawer.tsx +1 -1
  38. package/src/file_dropzone.tsx +5 -0
  39. package/src/file_gallery_modal.tsx +227 -139
  40. package/src/file_grid.tsx +184 -0
  41. package/src/file_preview.web.tsx +349 -12
  42. package/src/file_preview_types.ts +49 -0
  43. package/src/file_row.tsx +118 -0
  44. package/src/file_rows.tsx +109 -0
  45. package/src/file_thumbnail.tsx +132 -81
  46. package/src/file_thumbnail_grid.tsx +135 -55
  47. package/src/files_editor.tsx +232 -0
  48. package/src/filter_chip.tsx +25 -12
  49. package/src/focus_ring_pressable.tsx +34 -0
  50. package/src/form_picker.tsx +3 -3
  51. package/src/gantt/gantt_view.tsx +25 -7
  52. package/src/heatmap.tsx +5 -1
  53. package/src/icon_button.tsx +14 -3
  54. package/src/image_gallery.tsx +8 -14
  55. package/src/index.css +8 -12
  56. package/src/inline_date_picker.tsx +11 -11
  57. package/src/inline_edit.tsx +22 -6
  58. package/src/inline_member_select.tsx +1 -1
  59. package/src/inline_select.tsx +9 -8
  60. package/src/inline_text_input.tsx +4 -1
  61. package/src/link.tsx +11 -1
  62. package/src/link_button.tsx +1 -1
  63. package/src/list_item.tsx +2 -2
  64. package/src/member_select.tsx +9 -4
  65. package/src/menu_button.tsx +2 -2
  66. package/src/menu_list_item.tsx +2 -2
  67. package/src/number_input.tsx +11 -4
  68. package/src/option_list.tsx +211 -0
  69. package/src/pdfjs_worker.d.ts +5 -0
  70. package/src/peek.tsx +1 -1
  71. package/src/picker.tsx +24 -213
  72. package/src/pressable_highlight.tsx +42 -23
  73. package/src/radio_picker.tsx +2 -2
  74. package/src/range_slider.tsx +35 -7
  75. package/src/react_native.d.ts +3 -0
  76. package/src/scroll_to_bottom.tsx +1 -1
  77. package/src/section.tsx +7 -2
  78. package/src/segmented_control.tsx +2 -2
  79. package/src/select.tsx +299 -0
  80. package/src/sort_header.tsx +1 -1
  81. package/src/sources.tsx +1 -1
  82. package/src/spreadsheet_view.tsx +43 -17
  83. package/src/status_grid.tsx +4 -3
  84. package/src/stepper.tsx +1 -1
  85. package/src/switch.tsx +5 -1
  86. package/src/switch_button.tsx +1 -1
  87. package/src/switcher.tsx +1 -1
  88. package/src/table.tsx +5 -1
  89. package/src/tabs.tsx +2 -2
  90. package/src/text_input_field.tsx +15 -4
  91. package/src/time_picker.tsx +11 -4
  92. package/src/timeline.tsx +1 -1
  93. package/src/uploading_thumbnail.tsx +212 -0
  94. package/src/use_focus_ring.ts +80 -0
  95. package/src/use_hover.ts +26 -0
  96. package/src/use_list_keyboard_nav.test.ts +71 -0
  97. package/src/use_list_keyboard_nav.ts +52 -5
  98. package/src/use_option_list.test.ts +193 -0
  99. package/src/use_option_list.ts +354 -0
  100. package/src/command_menu.tsx +0 -205
  101. package/src/hover_action.tsx +0 -54
  102. package/src/picker_menu.tsx +0 -355
  103. package/src/tag_input.tsx +0 -203
  104. package/src/time_field.tsx +0 -297
@@ -2,6 +2,8 @@ import { useRef, useState } from "react";
2
2
  import { StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
+ import { FOCUS_RING } from "./control_surface";
6
+ import { useFocusRing } from "./use_focus_ring";
5
7
 
6
8
  const THUMB = 20;
7
9
  const R = THUMB / 2;
@@ -23,6 +25,30 @@ export interface RangeSliderProps {
23
25
 
24
26
  type Thumb = "low" | "high";
25
27
 
28
+ function SliderThumb(props: {
29
+ label: string;
30
+ min: number;
31
+ max: number;
32
+ now: number;
33
+ left: number;
34
+ color: string;
35
+ active: boolean;
36
+ keyProps: object;
37
+ }) {
38
+ const { focusVisible, focusProps } = useFocusRing();
39
+ return (
40
+ <View
41
+ accessibilityRole="adjustable"
42
+ accessibilityLabel={props.label}
43
+ accessibilityValue={{ min: props.min, max: props.max, now: props.now }}
44
+ focusable
45
+ {...focusProps}
46
+ style={[styles.thumb, { left: props.left, borderColor: props.color }, props.active && styles.thumbActive, focusVisible && { boxShadow: FOCUS_RING }]}
47
+ {...props.keyProps}
48
+ />
49
+ );
50
+ }
51
+
26
52
  /**
27
53
  * A short human summary of a range selection for a `FilterChip` preview —
28
54
  * "5tr–10tr", or "≥ 5tr" / "≤ 10tr" when one end sits at its bound, and
@@ -129,14 +155,16 @@ export function RangeSlider(props: RangeSliderProps) {
129
155
  {(["low", "high"] as const).map((thumb) => {
130
156
  const v = thumb === "low" ? low : high;
131
157
  return (
132
- <View
158
+ <SliderThumb
133
159
  key={thumb}
134
- accessibilityRole="adjustable"
135
- accessibilityLabel={`${accessibilityLabel} ${thumb === "low" ? "minimum" : "maximum"}`}
136
- accessibilityValue={{ min, max, now: v }}
137
- focusable
138
- style={[styles.thumb, { left: x(v), borderColor: color }, dragging === thumb && styles.thumbActive]}
139
- {...keyProps(thumb)}
160
+ label={`${accessibilityLabel} ${thumb === "low" ? "minimum" : "maximum"}`}
161
+ min={min}
162
+ max={max}
163
+ now={v}
164
+ left={x(v)}
165
+ color={color}
166
+ active={dragging === thumb}
167
+ keyProps={keyProps(thumb)}
140
168
  />
141
169
  );
142
170
  })}
@@ -32,11 +32,14 @@ declare module "react-native" {
32
32
  }
33
33
 
34
34
  interface TextStyle {
35
+ boxShadow?: string;
35
36
  outline?: string;
36
37
  outlineColor?: string;
37
38
  outlineStyle?: string;
38
39
  outlineWidth?: number;
39
40
  outlineOffset?: number;
41
+ transitionDuration?: string;
42
+ transitionProperty?: string;
40
43
  appearance?: string;
41
44
  }
42
45
  }
@@ -14,7 +14,7 @@ export function ScrollToBottom(props: ScrollToBottomProps) {
14
14
 
15
15
  return (
16
16
  <PressableHighlight
17
- testID="scroll-to-bottom-button"
17
+ focusRing testID="scroll-to-bottom-button"
18
18
  tooltip={tooltip}
19
19
  onPress={onPress}
20
20
  style={styles.button}
package/src/section.tsx CHANGED
@@ -1,8 +1,10 @@
1
- import { Pressable, StyleProp, View, ViewStyle } from "react-native";
1
+ import { Pressable, View } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { useState, useImperativeHandle } from "react";
4
4
  import { Icon, IconName } from "./icon";
5
5
  import { Spacer } from "./spacer";
6
+ import { FOCUS_RING } from "./control_surface";
7
+ import { useFocusRing } from "./use_focus_ring";
6
8
 
7
9
  export interface SectionHandlers {
8
10
  expand: () => void;
@@ -46,9 +48,12 @@ export function Section(props: SectionProps) {
46
48
  },
47
49
  }));
48
50
 
51
+ const { focusVisible, focusProps } = useFocusRing();
52
+
49
53
  const titleComponent = collapsible ? (
50
54
  <Pressable
51
- style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}
55
+ {...focusProps}
56
+ style={[{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }, focusVisible && { boxShadow: FOCUS_RING, borderRadius: 4 }]}
52
57
  onPress={() => setCollapsed(!collapsed)}
53
58
  >
54
59
  <Icon name={collapsed ? "chevron-right" : "chevron-down"} size={20} />
@@ -126,8 +126,8 @@ function Segment<T extends string>(props: SegmentProps<T>) {
126
126
 
127
127
  return (
128
128
  <PressableHighlight
129
- ref={ref}
130
- // Own the per-state background: the track is zinc-100, so an unselected
129
+ focusRing
130
+ ref={ref} // Own the per-state background: the track is zinc-100, so an unselected
131
131
  // segment hovering must lift TOWARD the selected white (a half-white wash),
132
132
  // not to PressableHighlight's default zinc-100 (invisible on the track).
133
133
  style={(state) => {
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>