@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
@@ -1,11 +1,11 @@
1
1
  import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
2
- import { View, StyleSheet } from "react-native";
2
+ import { View, StyleSheet, type GestureResponderEvent } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
5
5
  import { ActivityIndicator } from "./activity_indicator";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
7
  import { colors } from "./colors";
8
- import { ACTIVE_RING, CONTROL_RADIUS } from "./control_surface";
8
+ import { FOCUS_RING, CONTROL_RADIUS } from "./control_surface";
9
9
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
10
10
 
11
11
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
@@ -99,6 +99,8 @@ interface InlineEditFrameProps {
99
99
  disabled?: boolean;
100
100
  /** Accessible name for the view button when there's no enclosing FormField. */
101
101
  accessibilityLabel?: string;
102
+ /** Strike + mute the resting value (a completed item that stays editable). */
103
+ struck?: boolean;
102
104
  }
103
105
 
104
106
  interface InlineEditViewProps {
@@ -115,6 +117,8 @@ interface InlineEditViewProps {
115
117
  /** True while the field's popover (select/date) is open: wears the 2px active
116
118
  * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
117
119
  active?: boolean;
120
+ /** Strike + mute the resting value (a completed/superseded item that stays editable). */
121
+ struck?: boolean;
118
122
  ref?: Ref<View>;
119
123
  }
120
124
 
@@ -127,19 +131,28 @@ interface InlineEditViewProps {
127
131
  * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
128
132
  */
129
133
  export function InlineEditView(props: InlineEditViewProps) {
130
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, ref } = props;
134
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, ref } = props;
135
+ // Stop the press here so an inline editor nested in a pressable row (a task
136
+ // row that expands on press) edits the field instead of triggering the row.
137
+ const handlePress = onPress
138
+ ? (e: GestureResponderEvent) => {
139
+ e?.stopPropagation?.();
140
+ onPress();
141
+ }
142
+ : undefined;
131
143
  return (
132
144
  <PressableHighlight
145
+ focusRing
133
146
  ref={ref}
134
147
  disabled={disabled}
135
- onPress={onPress}
148
+ onPress={handlePress}
136
149
  accessibilityRole="button"
137
150
  accessibilityLabel={accessibilityLabel}
138
151
  userSelect="none"
139
152
  style={[styles.view, active && styles.viewActive]}
140
153
  >
141
154
  {typeof display === "string" ? (
142
- <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
155
+ <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
143
156
  {display || placeholder || "—"}
144
157
  </Text>
145
158
  ) : (
@@ -171,6 +184,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
171
184
  error,
172
185
  disabled,
173
186
  accessibilityLabel,
187
+ struck,
174
188
  } = props;
175
189
 
176
190
  if (!editing) {
@@ -181,6 +195,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
181
195
  onPress={onBegin}
182
196
  disabled={disabled}
183
197
  accessibilityLabel={accessibilityLabel}
198
+ struck={struck}
184
199
  />
185
200
  );
186
201
  }
@@ -233,9 +248,10 @@ const styles = StyleSheet.create({
233
248
  // under the 2px active ring — identical to a focused input.
234
249
  viewActive: {
235
250
  borderColor: colors.border,
236
- boxShadow: ACTIVE_RING,
251
+ boxShadow: FOCUS_RING,
237
252
  },
238
253
  placeholder: { color: colors.zinc[400] },
254
+ struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
239
255
  viewNode: { flex: 1, minWidth: 0 },
240
256
  editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
241
257
  editControl: { flex: 1, position: "relative" },
@@ -55,7 +55,7 @@ export function InlineMemberSelect(props: InlineMemberSelectProps) {
55
55
  <InlineSelect<string>
56
56
  options={options}
57
57
  renderOptionContent={renderMember}
58
- renderValue={renderMember}
58
+ renderSelected={renderMember}
59
59
  {...inline}
60
60
  />
61
61
  );
@@ -4,7 +4,7 @@ import { Icon } from "./icon";
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
6
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
- import { PickerMenu } from "./picker_menu";
7
+ import { OptionList } from "./option_list";
8
8
  import type { PickerOption } from "./picker";
9
9
  import { ActivityIndicator } from "./activity_indicator";
10
10
  import { InlineEditView } from "./inline_edit";
@@ -14,12 +14,12 @@ export interface InlineSelectProps<T extends string> {
14
14
  onSave: (next: T) => void | Promise<void>;
15
15
  options: PickerOption<T>[];
16
16
  /** Custom option content in the dropdown (icon + label, two-line, a badge…).
17
- * Omit for a plain label list — both render through the same `PickerMenu`. */
17
+ * Omit for a plain label list — both render through the same `OptionList`. */
18
18
  renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
19
19
  /** Render the SELECTED value in the resting view as a node (an avatar chip, a
20
20
  * colored badge) instead of its plain label. Falls back to the label when
21
21
  * omitted; the empty state always shows the placeholder. */
22
- renderValue?: (selected: PickerOption<T>) => React.ReactNode;
22
+ renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
23
23
  placeholder?: string;
24
24
  disabled?: boolean;
25
25
  accessibilityLabel?: string;
@@ -27,13 +27,13 @@ export interface InlineSelectProps<T extends string> {
27
27
 
28
28
  /**
29
29
  * An inline-editable single-select. The value reads as plain text; clicking it
30
- * floats the option list (a `PickerMenu`) in a popover anchored to the view, so
30
+ * floats the option list (an `OptionList`) in a popover anchored to the view, so
31
31
  * the row never changes height. Picking commits; dismissing reverts. Pass
32
32
  * `renderOptionContent` for rich options, or omit it for a plain list — the
33
33
  * standard and custom-rendered pickers are both supported here.
34
34
  */
35
35
  export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
36
- const { value, onSave, options, renderOptionContent, renderValue, placeholder, disabled, accessibilityLabel } = props;
36
+ const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel } = props;
37
37
  const [open, setOpen] = useState(false);
38
38
  const [saving, setSaving] = useState(false);
39
39
  const [error, setError] = useState<string | null>(null);
@@ -62,7 +62,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
62
62
  <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
63
63
  <PopoverTrigger>
64
64
  <InlineEditView
65
- display={selected ? (renderValue ? renderValue(selected) : (selected.label ?? "")) : ""}
65
+ display={selected ? (renderSelected ? renderSelected(selected) : (selected.label ?? "")) : ""}
66
66
  placeholder={placeholder}
67
67
  disabled={disabled}
68
68
  active={open && !disabled}
@@ -76,8 +76,9 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
76
76
  }
77
77
  />
78
78
  </PopoverTrigger>
79
- <PopoverContent>
80
- <PickerMenu
79
+ <PopoverContent disableBodyScroll>
80
+ <OptionList
81
+ search={{ mode: "none" }}
81
82
  options={options}
82
83
  value={value}
83
84
  onValueChange={(next) => void pick(next)}
@@ -13,6 +13,8 @@ export interface InlineTextInputProps {
13
13
  * an explicit ✓ saves and ✕ reverts. */
14
14
  controls?: InlineEditControls;
15
15
  disabled?: boolean;
16
+ /** Strike + mute the resting value (a completed item that stays editable). */
17
+ struck?: boolean;
16
18
  accessibilityLabel?: string;
17
19
  }
18
20
 
@@ -23,7 +25,7 @@ export interface InlineTextInputProps {
23
25
  * value in a dense record / detail surface.
24
26
  */
25
27
  export function InlineTextInput(props: InlineTextInputProps) {
26
- const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
28
+ const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel } = props;
27
29
  const edit = useInlineEdit<string>({ value, onSave });
28
30
 
29
31
  const onKeyPress = useCallback(
@@ -55,6 +57,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
55
57
  saving={edit.saving}
56
58
  error={edit.error}
57
59
  disabled={disabled}
60
+ struck={struck}
58
61
  accessibilityLabel={accessibilityLabel}
59
62
  >
60
63
  <TextInputField
package/src/link.tsx CHANGED
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
2
2
  import { Pressable } from "react-native";
3
3
  import { Text, type TextSize } 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
  export interface LinkProps {
7
9
  children: ReactNode;
@@ -22,8 +24,16 @@ export interface LinkProps {
22
24
  * `onPress` to its opener.
23
25
  */
24
26
  export function Link({ children, onPress, size = "sm", accessibilityLabel, testID }: LinkProps) {
27
+ const { focusVisible, focusProps } = useFocusRing();
25
28
  return (
26
- <Pressable onPress={onPress} accessibilityRole="link" accessibilityLabel={accessibilityLabel} testID={testID}>
29
+ <Pressable
30
+ onPress={onPress}
31
+ {...focusProps}
32
+ accessibilityRole="link"
33
+ accessibilityLabel={accessibilityLabel}
34
+ testID={testID}
35
+ style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 2 } : undefined}
36
+ >
27
37
  <Text size={size} decoration="underline" style={{ color: colors.blue[600] }}>
28
38
  {children}
29
39
  </Text>
@@ -23,7 +23,7 @@ export function LinkButton(props: LinkButtonProps) {
23
23
  const { title, onPress, accessibilityLabel, testID } = props;
24
24
  return (
25
25
  <PressableHighlight
26
- testID={testID}
26
+ focusRing testID={testID}
27
27
  onPress={onPress}
28
28
  accessibilityRole="button"
29
29
  accessibilityLabel={accessibilityLabel ?? title}
package/src/list_item.tsx CHANGED
@@ -57,8 +57,8 @@ export function ListItem(props: ListItemProps) {
57
57
  if (onPress) {
58
58
  return (
59
59
  <PressableHighlight
60
- ref={ref}
61
- testID={testID}
60
+ focusRing
61
+ ref={ref} testID={testID}
62
62
  onPress={handlePress}
63
63
  disabled={disabled}
64
64
  style={containerStyle}
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useMemo } from "react";
2
2
  import { StyleProp, ViewStyle } from "react-native";
3
+ import { Select } from "./select";
3
4
  import {
4
- Picker,
5
5
  type PickerOption,
6
6
  type PickerValue,
7
7
  type PickerOnValueChange,
@@ -33,15 +33,20 @@ interface MemberSelectProps<MULTI extends boolean = false> {
33
33
  disabled?: boolean;
34
34
  autoFocus?: boolean;
35
35
  includeEmptyOption?: boolean;
36
+ enableSelectAll?: boolean;
37
+ /** Label for the "select all" link in multi-select mode. Pass a translated string. */
38
+ selectAllLabel?: string;
39
+ /** Label for the "deselect all" link in multi-select mode. Pass a translated string. */
40
+ deselectAllLabel?: string;
36
41
  style?: StyleProp<ViewStyle>;
37
42
  testID?: string;
38
43
  accessibilityLabel?: string;
39
44
  }
40
45
 
41
46
  /**
42
- * Choose member(s) — a {@link Picker} whose every option renders as a
47
+ * Choose member(s) — a {@link Select} whose every option renders as a
43
48
  * {@link MemberChip} (avatar + name). The one member-picker shape, so an app
44
- * never re-wires `Picker` + `renderOptionContent` + a directory by hand. PURE:
49
+ * never re-wires `Select` + `renderOptionContent` + a directory by hand. PURE:
45
50
  * pass the candidate `members` (an app feeds `useMembers`, the product any
46
51
  * roster); this fetches nothing. Single or multi via `multi`. Options are
47
52
  * single-line by design — a picker row identifies a person by name + avatar;
@@ -72,7 +77,7 @@ export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectP
72
77
  );
73
78
 
74
79
  return (
75
- <Picker<string, MULTI>
80
+ <Select<string, MULTI>
76
81
  options={options}
77
82
  renderOptionContent={renderOptionContent}
78
83
  {...picker}
@@ -94,8 +94,8 @@ export function MenuButton(props: MenuButtonProps) {
94
94
  if (onPress) {
95
95
  return (
96
96
  <PressableHighlight
97
- ref={ref}
98
- testID={testID}
97
+ focusRing
98
+ ref={ref} testID={testID}
99
99
  nativeID={nativeID}
100
100
  onPress={() => {
101
101
  onPress?.();
@@ -81,8 +81,8 @@ export function MenuListItem(props: MenuListItemProps) {
81
81
  if (onPress) {
82
82
  return (
83
83
  <PressableHighlight
84
- ref={ref}
85
- testID={testID}
84
+ focusRing
85
+ ref={ref} testID={testID}
86
86
  nativeID={nativeID}
87
87
  onPress={onPress}
88
88
  onHoverIn={onHoverIn}
@@ -1,8 +1,10 @@
1
1
  import type { KeyboardEvent } from "react";
2
2
  import { colors } from "./colors";
3
- import { CONTROL_RADIUS } from "./control_surface";
3
+ import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
4
4
  import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
5
5
  import { useFormField } from "./form_field";
6
+ import { useFocusRing } from "./use_focus_ring";
7
+ import { useHover } from "./use_hover";
6
8
 
7
9
  export interface NumberInputProps {
8
10
  value: number | null;
@@ -27,6 +29,8 @@ export function NumberInput(props: NumberInputProps) {
27
29
  const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
28
30
  const binding = useFormField();
29
31
  const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
32
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
33
+ const { hovered, hoverProps } = useHover();
30
34
 
31
35
  return (
32
36
  <input
@@ -44,7 +48,9 @@ export function NumberInput(props: NumberInputProps) {
44
48
  max={max}
45
49
  type="number"
46
50
  disabled={disabled}
47
- onBlur={onBlur}
51
+ {...hoverProps}
52
+ onFocus={focusProps.onFocus}
53
+ onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
48
54
  onKeyDown={onKeyDown}
49
55
  autoFocus={autoFocus}
50
56
  style={{
@@ -54,14 +60,15 @@ export function NumberInput(props: NumberInputProps) {
54
60
  borderRadius: CONTROL_RADIUS,
55
61
  borderWidth: 1,
56
62
  borderStyle: "solid",
57
- borderColor: colors.border,
63
+ borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
58
64
  backgroundColor: colors.background,
59
65
  fontFamily: fontFamilyRegular,
60
66
  ...inputTextStyleWeb,
61
67
  letterSpacing: -0.4,
62
- boxShadow: "none",
68
+ boxShadow: focusVisible ? FOCUS_RING : "none",
63
69
  outline: "none",
64
70
  boxSizing: "border-box",
71
+ transition: "border-color 0.12s, box-shadow 0.12s",
65
72
  }}
66
73
  />
67
74
  );
@@ -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
+ });
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.