@lotics/ui 5.9.0 → 5.10.1

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 +131 -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 +456 -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/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",
@@ -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) => {