@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
@@ -0,0 +1,211 @@
1
+ import { StyleSheet, View, ScrollView, TextInput } from "react-native";
2
+ import { type ReactNode } from "react";
3
+ import { colors } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon } from "./icon";
6
+ import { Checkbox } from "./checkbox";
7
+ import { MenuButton } from "./menu_button";
8
+ import { LinkButton } from "./link_button";
9
+ import { ActivityIndicator } from "./activity_indicator";
10
+ import { TextInputField } from "./text_input_field";
11
+ import { useScreenSize } from "./use_screen_size";
12
+ import { useOptionList, type UseOptionListParams } from "./use_option_list";
13
+ import type { PickerOption } from "./picker";
14
+
15
+ export interface OptionListProps<T extends string = string, MULTI extends boolean = false, D = unknown>
16
+ extends UseOptionListParams<T, MULTI, D> {
17
+ testID?: string;
18
+ /** Rich row content (a colour-dot badge, a member chip). Falls back to the label. */
19
+ renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
20
+ /** A single-line subtitle under the label (e.g. a code or company). */
21
+ getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
22
+ loading?: boolean;
23
+ emptyText?: string;
24
+ accessibilityLabel?: string;
25
+ selectAllLabel?: string;
26
+ deselectAllLabel?: string;
27
+ }
28
+
29
+ /**
30
+ * The kit's option listbox body — the popover content every selector opens: a
31
+ * keyboard-navigable list of `MenuButton` rows with single/multi selection +
32
+ * select-all, an optional "create" row, and either native-`<select>` typeahead
33
+ * (`search.mode:"none"`) or its own search field (`"internal"`). Drives off
34
+ * `useOptionList`. `Select` (incl. its chip-box / tag-field form), the chat composer, and the in-cell
35
+ * editors render it; `Combobox` consumes `useOptionList` directly (its own input
36
+ * owns the keyboard). Don't hand-roll a search-list — reach for this.
37
+ */
38
+ export function OptionList<T extends string, MULTI extends boolean = false, D = unknown>(
39
+ props: OptionListProps<T, MULTI, D>,
40
+ ) {
41
+ const {
42
+ testID,
43
+ renderOptionContent,
44
+ getOptionDescription,
45
+ loading = false,
46
+ emptyText = "No results",
47
+ accessibilityLabel,
48
+ selectAllLabel = "Select all",
49
+ deselectAllLabel = "Deselect all",
50
+ search,
51
+ } = props;
52
+ const list = useOptionList(props);
53
+ const { small } = useScreenSize();
54
+
55
+ const onSearchKeyPress = (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
56
+ if (list.handleKey(e.nativeEvent.key)) e.preventDefault();
57
+ };
58
+
59
+ return (
60
+ <View testID={testID} style={[styles.container, small ? { flex: 1 } : { maxHeight: 480 }]}>
61
+ {search.mode === "internal" ? (
62
+ <TextInputField
63
+ icon="search"
64
+ value={list.query}
65
+ onChangeText={list.setQuery}
66
+ placeholder={search.placeholder ?? "Search…"}
67
+ autoFocus={!small}
68
+ autoCapitalize="none"
69
+ autoCorrect={false}
70
+ onKeyPress={onSearchKeyPress}
71
+ accessibilityLabel={accessibilityLabel}
72
+ role="combobox"
73
+ aria-expanded
74
+ aria-controls={list.listboxId}
75
+ aria-activedescendant={list.activeId}
76
+ aria-autocomplete="list"
77
+ />
78
+ ) : search.mode === "none" ? (
79
+ // Visually hidden but focusable: captures typeahead + arrow/enter keys
80
+ // while the popover is open, giving native-<select> behavior.
81
+ <TextInput
82
+ autoFocus={!small}
83
+ value=""
84
+ onChangeText={() => {}}
85
+ onKeyPress={onSearchKeyPress}
86
+ style={styles.typeaheadCapture}
87
+ />
88
+ ) : null}
89
+
90
+ <ScrollView
91
+ ref={list.scrollRef}
92
+ style={styles.optionsList}
93
+ nativeID={list.listboxId}
94
+ accessibilityLabel={accessibilityLabel}
95
+ keyboardShouldPersistTaps="handled"
96
+ // `listbox` is valid ARIA but absent from RN's Role enum; rn-web forwards it.
97
+ role={"listbox" as "list"}
98
+ >
99
+ {loading ? (
100
+ <View style={styles.statusRow}>
101
+ <ActivityIndicator />
102
+ </View>
103
+ ) : list.rows.length === 0 ? (
104
+ <View style={styles.statusRow}>
105
+ <Text color="zinc-500" size="sm">
106
+ {emptyText}
107
+ </Text>
108
+ </View>
109
+ ) : (
110
+ list.rows.map((row) => {
111
+ const isCustom = row.kind === "custom";
112
+ const opt = row.option;
113
+ const desc = row.kind === "option" ? getOptionDescription?.(opt) : undefined;
114
+ const label = opt.label ?? opt.value;
115
+ const title =
116
+ row.kind === "option" && renderOptionContent ? (
117
+ renderOptionContent(opt)
118
+ ) : desc ? (
119
+ <View>
120
+ <Text userSelect="none" numberOfLines={1}>
121
+ {label}
122
+ </Text>
123
+ <Text size="xs" color="zinc-500" numberOfLines={1}>
124
+ {desc}
125
+ </Text>
126
+ </View>
127
+ ) : (
128
+ <Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
129
+ {label}
130
+ </Text>
131
+ );
132
+ return (
133
+ <MenuButton
134
+ key={`${row.kind}-${opt.value}`}
135
+ nativeID={row.nativeID}
136
+ testID={
137
+ isCustom ? "option-list-custom" : opt.testID || `picker-option-${opt.value}`
138
+ }
139
+ role="option"
140
+ accessibilityLabel={isCustom ? label : (opt.label ?? String(opt.value))}
141
+ icon={
142
+ isCustom ? (
143
+ <Icon name="plus" size={16} color={colors.zinc["600"]} />
144
+ ) : props.multi && row.kind === "option" ? (
145
+ <Checkbox checked={row.selected} />
146
+ ) : undefined
147
+ }
148
+ title={title}
149
+ right={
150
+ !props.multi && row.selected ? (
151
+ <Icon name="check" size={18} color={colors.zinc["950"]} />
152
+ ) : undefined
153
+ }
154
+ focused={row.index === list.activeIndex}
155
+ disabled={opt.disabled}
156
+ onPress={() => list.pickRow(row.index)}
157
+ onHoverIn={() => list.setActiveIndex(row.index, false)}
158
+ style={styles.option}
159
+ />
160
+ );
161
+ })
162
+ )}
163
+ </ScrollView>
164
+
165
+ {list.showSelectAll || list.showDeselectAll ? (
166
+ <View style={styles.selectAllContainer}>
167
+ {list.showSelectAll ? <LinkButton title={selectAllLabel} onPress={list.selectAll} /> : null}
168
+ {list.showDeselectAll ? (
169
+ <LinkButton title={deselectAllLabel} onPress={list.deselectAll} />
170
+ ) : null}
171
+ </View>
172
+ ) : null}
173
+ </View>
174
+ );
175
+ }
176
+
177
+ const styles = StyleSheet.create({
178
+ container: {
179
+ gap: 4,
180
+ },
181
+ optionsList: {
182
+ flex: 1,
183
+ gap: 4,
184
+ maxHeight: 320,
185
+ },
186
+ selectAllContainer: {
187
+ flexDirection: "row",
188
+ alignItems: "center",
189
+ gap: 16,
190
+ paddingVertical: 4,
191
+ // Matches a MenuButton option's 8px text inset so the bleeding-wash
192
+ // LinkButtons line up under the option labels above.
193
+ paddingHorizontal: 8,
194
+ borderTopWidth: 1,
195
+ borderTopColor: colors.border,
196
+ },
197
+ option: {
198
+ marginHorizontal: 0,
199
+ },
200
+ statusRow: {
201
+ alignItems: "center",
202
+ justifyContent: "center",
203
+ paddingVertical: 16,
204
+ },
205
+ typeaheadCapture: {
206
+ position: "absolute",
207
+ width: 1,
208
+ height: 1,
209
+ opacity: 0,
210
+ },
211
+ });
@@ -0,0 +1,5 @@
1
+ // The pdf.js worker entry is imported only for its side effect: it assigns
2
+ // `globalThis.pdfjsWorker`, which lets pdf.js run on the main thread (no Web
3
+ // Worker, no worker-URL resolution) — portable across Vite (apps) and Metro
4
+ // (frontend). The file ships no types; declare it as a side-effect module.
5
+ declare module "pdfjs-dist/build/pdf.worker.min.mjs";
package/src/peek.tsx CHANGED
@@ -33,7 +33,7 @@ export function Peek(props: PeekProps) {
33
33
  <Popover side={side} align={align}>
34
34
  <PopoverTrigger>
35
35
  <PressableHighlight
36
- accessibilityRole="button"
36
+ focusRing accessibilityRole="button"
37
37
  accessibilityLabel={accessibilityLabel}
38
38
  style={styles.trigger}
39
39
  // 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
package/src/picker.tsx CHANGED
@@ -1,13 +1,12 @@
1
1
  import { Picker as RNPicker } from "@react-native-picker/picker";
2
- import { StyleSheet, View, Pressable, StyleProp, ViewStyle, TextStyle } from "react-native";
3
- import { useState, useCallback, useMemo, useEffect, useRef } from "react";
2
+ import { StyleSheet, View, StyleProp, ViewStyle, TextStyle } from "react-native";
3
+ import { useCallback, useEffect, useRef } from "react";
4
4
  import { colors } from "./colors";
5
- import { ACTIVE_RING, CONTROL_RADIUS } from "./control_surface";
6
- import { Text } from "./text";
5
+ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
6
+ import { useFocusRing } from "./use_focus_ring";
7
+ import { useHover } from "./use_hover";
7
8
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
8
9
  import { Icon } from "./icon";
9
- import { Popover, PopoverTrigger, PopoverContent } from "./popover";
10
- import { PickerMenu } from "./picker_menu";
11
10
 
12
11
  export interface PickerOption<T extends string = string, D = unknown> {
13
12
  label?: string;
@@ -31,43 +30,30 @@ export type PickerOnClose<T extends string, MULTI extends boolean> = MULTI exten
31
30
  ? (value: T[]) => void
32
31
  : (value: T) => void;
33
32
 
34
- export interface PickerProps<T extends string = string, MULTI extends boolean = false> {
33
+ export interface PickerProps<T extends string = string> {
35
34
  options?: (PickerOption<T> | undefined | false)[];
36
35
  placeholder?: string;
37
36
  /** Accessible name for the control. Required in spirit whenever the picker
38
37
  * has no visible label next to it (e.g. an unlabeled toolbar filter) — the
39
38
  * selected option's text describes the value, not the control. */
40
39
  accessibilityLabel?: string;
41
- renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
42
40
  style?: StyleProp<ViewStyle>;
43
41
  testID?: string;
44
42
  disabled?: boolean;
45
43
  autoFocus?: boolean;
46
- enableSelectAll?: boolean;
47
- multi?: MULTI;
48
44
  includeEmptyOption?: boolean;
49
- value?: PickerValue<T, MULTI> | null;
50
- onValueChange?: PickerOnValueChange<T, MULTI>;
51
- onClose?: PickerOnClose<T, MULTI>;
52
- /** Label for the "select all" link in multi-select mode. Default: "Select all" */
53
- selectAllLabel?: string;
54
- /** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
55
- deselectAllLabel?: string;
45
+ value?: T | null;
46
+ onValueChange?: (value: T) => void;
56
47
  }
57
48
 
58
49
  /**
59
- * Main Picker component that delegates to the appropriate sub-component based on props.
50
+ * A plain, label-only single select rendered as the native `<select>` (the OS
51
+ * dropdown, with native typeahead). The lightest "pick one from a known list"
52
+ * control. For RICH options (badges, member chips), multi-select, select-all, or
53
+ * a chip box (`renderSelected` → `Chip`), use `Select`; to SEARCH a large/remote/free
54
+ * set with the input AS the control use `Combobox`.
60
55
  */
61
- export function Picker<T extends string, MULTI extends boolean = false>(
62
- props: PickerProps<T, MULTI>,
63
- ) {
64
- if (props.multi || props.renderOptionContent) {
65
- return <CustomPicker {...props} />;
66
- }
67
- return <StandardPicker {...(props as PickerProps<T, false>)} />;
68
- }
69
-
70
- function StandardPicker<T extends string>(props: PickerProps<T, false>) {
56
+ export function Picker<T extends string>(props: PickerProps<T>) {
71
57
  const {
72
58
  testID,
73
59
  options = [],
@@ -82,6 +68,9 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
82
68
  } = props;
83
69
 
84
70
  const pickerRef = useRef<RNPicker<string>>(null);
71
+ // The native <select> is a text-like control — ring on any focus (like the inputs).
72
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
73
+ const { hovered, hoverProps } = useHover();
85
74
 
86
75
  useEffect(() => {
87
76
  if (autoFocus && !disabled) {
@@ -89,15 +78,15 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
89
78
  }
90
79
  }, [autoFocus, disabled]);
91
80
 
92
- const handleStandardPickerChange = useCallback(
81
+ const handleChange = useCallback(
93
82
  (newValue: string) => {
94
- (onValueChange as (value: T) => void)?.(newValue as T);
83
+ onValueChange?.(newValue as T);
95
84
  },
96
85
  [onValueChange],
97
86
  );
98
87
 
99
88
  return (
100
- <View>
89
+ <View {...(hoverProps as object)}>
101
90
  <View
102
91
  style={{
103
92
  position: "absolute",
@@ -112,12 +101,14 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
112
101
  ref={pickerRef}
113
102
  testID={testID}
114
103
  accessibilityLabel={accessibilityLabel}
104
+ onFocus={focusProps.onFocus}
105
+ onBlur={focusProps.onBlur}
115
106
  // Empty selection maps to "" (the placeholder option's value), never
116
107
  // undefined — otherwise the native <select> goes uncontrolled and a
117
108
  // programmatic reset to null leaves the prior DOM selection in place.
118
109
  selectedValue={value?.toString() ?? ""}
119
- onValueChange={handleStandardPickerChange}
120
- style={[styles.picker, getInputTextStyle(), style as StyleProp<TextStyle>]}
110
+ onValueChange={handleChange}
111
+ style={[styles.picker, CONTROL_TRANSITION, getInputTextStyle(), style as StyleProp<TextStyle>, hovered && !disabled ? { borderColor: HOVER_BORDER } : null, focusVisible ? { boxShadow: FOCUS_RING } : null]}
121
112
  placeholder={placeholder}
122
113
  enabled={!disabled}
123
114
  >
@@ -143,168 +134,6 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
143
134
  );
144
135
  }
145
136
 
146
- function CustomPicker<T extends string, MULTI extends boolean = false>(
147
- props: PickerProps<T, MULTI>,
148
- ) {
149
- const {
150
- testID,
151
- options = [],
152
- value,
153
- placeholder,
154
- accessibilityLabel,
155
- multi = false as MULTI,
156
- renderOptionContent,
157
- includeEmptyOption,
158
- style,
159
- disabled = false,
160
- autoFocus = false,
161
- enableSelectAll = false,
162
- onValueChange,
163
- onClose,
164
- selectAllLabel,
165
- deselectAllLabel,
166
- } = props;
167
-
168
- const [open, setOpen] = useState(autoFocus);
169
-
170
- const handleToggle = useCallback(() => {
171
- if (!disabled) {
172
- setOpen((prev) => !prev);
173
- }
174
- }, [disabled]);
175
-
176
- const selectedItems = useSelectedItems(multi, value, options);
177
-
178
- const handleClose = useCallback(() => {
179
- setOpen(false);
180
- }, []);
181
-
182
- return (
183
- <Popover
184
- open={open && !disabled}
185
- onOpenChange={setOpen}
186
- side="bottom"
187
- align="start"
188
- inheritTriggerWidth={true}
189
- >
190
- <PopoverTrigger>
191
- <PickerTrigger
192
- testID={testID}
193
- open={open}
194
- style={style}
195
- onPress={handleToggle}
196
- renderOptionContent={renderOptionContent}
197
- selectedItems={selectedItems}
198
- placeholder={placeholder}
199
- accessibilityLabel={accessibilityLabel}
200
- disabled={disabled}
201
- />
202
- </PopoverTrigger>
203
- <PopoverContent>
204
- <PickerMenu
205
- options={options}
206
- multi={multi}
207
- value={value}
208
- onValueChange={onValueChange}
209
- onClose={onClose}
210
- onRequestClose={handleClose}
211
- renderOptionContent={renderOptionContent}
212
- enableSelectAll={enableSelectAll}
213
- includeEmptyOption={includeEmptyOption}
214
- selectAllLabel={selectAllLabel}
215
- deselectAllLabel={deselectAllLabel}
216
- />
217
- </PopoverContent>
218
- </Popover>
219
- );
220
- }
221
-
222
- function useSelectedItems<T extends string>(
223
- multi: boolean,
224
- value: T | T[] | undefined | null,
225
- options: (PickerOption<T> | undefined | false)[],
226
- ): PickerOption<T>[] {
227
- return useMemo(() => {
228
- if (multi) {
229
- const multiValue = Array.isArray(value) ? value : [];
230
- const optionsMap = new Map(
231
- options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
232
- );
233
- return multiValue
234
- .map((val) => optionsMap.get(val))
235
- .filter((opt): opt is PickerOption<T> => !!opt);
236
- } else {
237
- const singleValue = Array.isArray(value) ? value[0] : value;
238
- if (singleValue !== undefined && singleValue !== null) {
239
- const selected = options.find((option) => option && option.value === singleValue);
240
- return selected ? [selected] : [];
241
- }
242
- }
243
- return [];
244
- }, [multi, value, options]);
245
- }
246
-
247
- function PickerTrigger<T extends string>({
248
- ref,
249
- testID,
250
- open,
251
- style,
252
- onPress,
253
- renderOptionContent,
254
- selectedItems,
255
- placeholder,
256
- accessibilityLabel,
257
- disabled = false,
258
- }: {
259
- ref?: React.Ref<View>;
260
- testID?: string;
261
- open: boolean;
262
- style?: StyleProp<ViewStyle>;
263
- onPress: () => void;
264
- renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
265
- selectedItems: PickerOption<T>[];
266
- placeholder?: string;
267
- accessibilityLabel?: string;
268
- disabled?: boolean;
269
- }) {
270
- const hasSelection = selectedItems.length > 0;
271
-
272
- return (
273
- <Pressable
274
- ref={ref}
275
- testID={testID}
276
- // Without a role this Pressable renders as an unfocusable <div> on web —
277
- // the trigger drops out of the tab order and Enter/Space can't open it.
278
- // `button` makes it tab-focusable and maps keyboard activation to onPress;
279
- // `expanded` announces open/closed to assistive tech. The accessible name
280
- // comes from `accessibilityLabel` when given (an unlabeled control), else
281
- // the visible selection/placeholder text below.
282
- accessibilityRole="button"
283
- accessibilityLabel={accessibilityLabel}
284
- accessibilityState={{ expanded: open, disabled }}
285
- style={[styles.pressable, open && styles.opened, disabled && styles.disabled, style]}
286
- onPress={!disabled ? onPress : undefined}
287
- disabled={disabled}
288
- >
289
- {hasSelection ? (
290
- renderOptionContent ? (
291
- <View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap" }}>
292
- {selectedItems.map((item) => (
293
- <View key={item.value}>{renderOptionContent(item)}</View>
294
- ))}
295
- </View>
296
- ) : (
297
- <Text userSelect="none">{selectedItems.map((s) => s.label).join(", ")}</Text>
298
- )
299
- ) : (
300
- <Text color="zinc-500" userSelect="none">
301
- {placeholder}
302
- </Text>
303
- )}
304
- </Pressable>
305
- );
306
- }
307
-
308
137
  const styles = StyleSheet.create({
309
138
  picker: {
310
139
  height: 40,
@@ -318,22 +147,4 @@ const styles = StyleSheet.create({
318
147
  backgroundColor: "transparent",
319
148
  appearance: "none",
320
149
  },
321
- pressable: {
322
- flexDirection: "row",
323
- alignItems: "center",
324
- paddingHorizontal: 6,
325
- borderWidth: 1,
326
- borderColor: colors.border,
327
- borderRadius: CONTROL_RADIUS,
328
- cursor: "auto",
329
- height: 40,
330
- },
331
- opened: {
332
- // Mouse-opened, so no `:focus-visible` — wear the same 2px zinc ring a
333
- // focused input gets, over the unchanged 1px border (not a lone 1px edge).
334
- boxShadow: ACTIVE_RING,
335
- },
336
- disabled: {
337
- opacity: 0.5,
338
- },
339
150
  });
@@ -2,6 +2,7 @@ import {
2
2
  GestureResponderEvent,
3
3
  Pressable,
4
4
  PressableProps,
5
+ PressableStateCallbackType,
5
6
  View,
6
7
  StyleProp,
7
8
  ViewStyle,
@@ -9,23 +10,40 @@ import {
9
10
  import { Ref, useCallback } from "react";
10
11
  import { TooltipSide, useTooltip } from "./tooltip";
11
12
  import { colors } from "./colors";
13
+ import { FOCUS_RING } from "./control_surface";
14
+ import { composeHandler, useFocusRing } from "./use_focus_ring";
12
15
 
13
- function composeHandler<E>(
14
- a: ((event: E) => void) | undefined,
15
- b: ((event: E) => void) | undefined,
16
- ): ((event: E) => void) | undefined {
17
- if (!a) return b;
18
- if (!b) return a;
19
- return (event: E) => {
20
- a(event);
21
- b(event);
22
- };
23
- }
16
+ /** The pressable state, plus the web-only `hovered` flag react-native-web adds and
17
+ * the `focusVisible` flag PressableHighlight injects so a consumer's style-fn /
18
+ * children can paint their own focus treatment. */
19
+ export type PressableHighlightState = PressableStateCallbackType & {
20
+ hovered?: boolean;
21
+ focusVisible?: boolean;
22
+ };
24
23
 
25
24
  export interface PressableHighlightProps extends PressableProps {
26
25
  ref?: Ref<View>;
27
26
  tooltip?: string;
28
27
  tooltipSide?: TooltipSide;
28
+ /**
29
+ * Opt in to the kit `FOCUS_RING` on keyboard focus. Off by default: the focus
30
+ * treatment is the component's to OWN, not applied blindly — a compact control
31
+ * sets `focusRing`, while a control with its own ring (e.g. `CardSelectItem`, which
32
+ * rings on hover/press/focus) instead reads `state.focusVisible` in its `style` fn.
33
+ * EVERY interactive PressableHighlight must do one or the other; a focusable control
34
+ * with no focus treatment is a bug (the per-Pressable focus audit is the coverage
35
+ * gate). A surface that shouldn't ring is a non-control — give it `tabIndex={-1}`
36
+ * (like `PressableRow`), not a missing `focusRing`. The ring is a later `boxShadow`
37
+ * entry, so on a focused control it supersedes any resting box-shadow for the focus
38
+ * duration — read `state.focusVisible` and compose if both must show at once.
39
+ */
40
+ focusRing?: boolean;
41
+ /**
42
+ * Narrows the Pressable state-function `style` to `PressableHighlightState`, so a
43
+ * consumer's `style` fn sees `focusVisible` (and `hovered`) and can paint its own
44
+ * focus/hover treatment — the `state.focusVisible` contract referenced above.
45
+ */
46
+ style?: StyleProp<ViewStyle> | ((state: PressableHighlightState) => StyleProp<ViewStyle>);
29
47
  /**
30
48
  * Web-only keyboard handler. `react-native-web`'s `Pressable` forwards this
31
49
  * to the rendered DOM element; native platforms ignore it. Exposed
@@ -56,12 +74,14 @@ export function PressableHighlight(props: PressableHighlightProps) {
56
74
  tooltipSide = "top",
57
75
  onPress,
58
76
  userSelect,
77
+ focusRing,
59
78
  ...restPressableProps
60
79
  } = props;
61
80
  const tooltipProps = useTooltip({
62
81
  side: tooltipSide,
63
82
  text: tooltip,
64
83
  });
84
+ const { focusVisible, focusProps } = useFocusRing();
65
85
 
66
86
  const handlePress = useCallback(
67
87
  (event: GestureResponderEvent) => {
@@ -74,10 +94,8 @@ export function PressableHighlight(props: PressableHighlightProps) {
74
94
  <Pressable
75
95
  ref={ref}
76
96
  disabled={disabled}
77
- // @ts-ignore hovered is a react-native-web extension not in base RN types
78
- style={(state) => {
79
- const { pressed } = state;
80
- const hovered = (state as { hovered?: boolean }).hovered;
97
+ style={(state: PressableHighlightState) => {
98
+ const { pressed, hovered } = state;
81
99
  return [
82
100
  {
83
101
  ...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
@@ -85,22 +103,23 @@ export function PressableHighlight(props: PressableHighlightProps) {
85
103
  {
86
104
  backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
87
105
  },
88
- typeof style === "function" ? style(state) : style,
106
+ typeof style === "function" ? style({ ...state, focusVisible }) : style,
107
+ focusRing && focusVisible ? { boxShadow: FOCUS_RING } : null,
89
108
  ] as StyleProp<ViewStyle>;
90
109
  }}
91
110
  onPress={handlePress}
92
111
  {...restPressableProps}
93
112
  // `onFocus` / `onBlur` are part of `PressableProps`, so a trailing
94
113
  // `{...tooltipProps}` spread would silently overwrite caller-provided
95
- // handlers. Compose instead. Mouse handlers (not in the RN types but
96
- // passed through on web) come along with the tooltipProps spread.
114
+ // handlers. Compose instead caller + tooltip + the focus-ring tracker.
115
+ // Mouse handlers (not in the RN types but passed through on web) come along
116
+ // with the tooltipProps spread.
97
117
  {...tooltipProps}
98
- onFocus={composeHandler(restPressableProps.onFocus ?? undefined, tooltipProps.onFocus)}
99
- onBlur={composeHandler(restPressableProps.onBlur ?? undefined, tooltipProps.onBlur)}
118
+ onFocus={composeHandler(composeHandler(restPressableProps.onFocus ?? undefined, tooltipProps.onFocus), focusProps.onFocus)}
119
+ onBlur={composeHandler(composeHandler(restPressableProps.onBlur ?? undefined, tooltipProps.onBlur), focusProps.onBlur)}
100
120
  >
101
- {(state) => {
102
- const hovered = (state as { hovered?: boolean }).hovered;
103
- return <>{typeof children === "function" ? children({ ...state, hovered } as typeof state) : children}</>;
121
+ {(state: PressableHighlightState) => {
122
+ return <>{typeof children === "function" ? children({ ...state, focusVisible } as PressableStateCallbackType) : children}</>;
104
123
  }}
105
124
  </Pressable>
106
125
  );
@@ -115,8 +115,8 @@ function RadioOption<T extends string | number | symbol>(
115
115
 
116
116
  return (
117
117
  <PressableHighlight
118
- ref={ref}
119
- testID={testID}
118
+ focusRing
119
+ ref={ref} testID={testID}
120
120
  style={{
121
121
  flexDirection: "row",
122
122
  alignItems: "center",