@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
package/src/table.tsx CHANGED
@@ -12,6 +12,8 @@ import { Text } from "./text";
12
12
  import { Divider } from "./divider";
13
13
  import { PressableRow } from "./pressable_row";
14
14
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
15
+ import { FOCUS_RING } from "./control_surface";
16
+ import { useFocusRing } from "./use_focus_ring";
15
17
 
16
18
  /**
17
19
  * One column of a register — its width/flex/align/label/sortability defined ONCE,
@@ -132,6 +134,7 @@ export function TableRow(props: TableRowProps) {
132
134
  const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[]).map(
133
135
  (cell, i) => cloneElement(cell, { _column: ctx.columns[i] }),
134
136
  );
137
+ const { focusVisible, focusProps } = useFocusRing();
135
138
 
136
139
  return (
137
140
  <PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
@@ -140,7 +143,8 @@ export function TableRow(props: TableRowProps) {
140
143
  accessibilityRole={onPress ? "button" : undefined}
141
144
  accessibilityLabel={accessibilityLabel}
142
145
  onPress={onPress}
143
- style={[styles.door, { minHeight }]}
146
+ {...focusProps}
147
+ style={[styles.door, { minHeight }, focusVisible && { boxShadow: FOCUS_RING }]}
144
148
  >
145
149
  {cells}
146
150
  </Pressable>
package/src/tabs.tsx CHANGED
@@ -110,8 +110,8 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
110
110
 
111
111
  return (
112
112
  <PressableHighlight
113
- ref={ref}
114
- style={{
113
+ focusRing
114
+ ref={ref} style={{
115
115
  borderBottomWidth: 3,
116
116
  borderBottomColor: selected ? colors.zinc["700"] : "transparent",
117
117
  backgroundColor: "transparent",
@@ -5,7 +5,9 @@ import {
5
5
  View,
6
6
  } from "react-native";
7
7
  import { colors } from "./colors";
8
- import { CONTROL_RADIUS } from "./control_surface";
8
+ import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
+ import { useFocusRing, composeHandler } from "./use_focus_ring";
10
+ import { useHover } from "./use_hover";
9
11
  import { Ref, useCallback } from "react";
10
12
  import { Icon, IconName } from "./icon";
11
13
  import { IconButton } from "./icon_button";
@@ -68,6 +70,12 @@ export function TextInputField(props: TextInputFieldProps) {
68
70
  const { small } = useScreenSize();
69
71
  const lineHeight = getInputLineHeight(small);
70
72
  const binding = useFormField();
73
+ // Text-like input: ring on ANY focus (browsers treat typing-capable fields as
74
+ // focus-visible even on pointer focus), so a clicked field shows its ring.
75
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
76
+ // Premium hover affordance: the resting border darkens, signalling interactivity.
77
+ // Tracked on the wrapping View (RN-Web forwards mouse events there reliably).
78
+ const { hovered, hoverProps } = useHover();
71
79
 
72
80
  // Describedby chains description and error so both are read. We join them
73
81
  // explicitly here because React Native Web does not flatten array attrs.
@@ -108,7 +116,7 @@ export function TextInputField(props: TextInputFieldProps) {
108
116
  );
109
117
 
110
118
  return (
111
- <View>
119
+ <View {...(hoverProps as object)}>
112
120
  {icon && (
113
121
  <View style={styles.icon}>
114
122
  <Icon name={icon} size={20} color={colors.zinc[400]} />
@@ -120,20 +128,23 @@ export function TextInputField(props: TextInputFieldProps) {
120
128
  ref={mergedRef}
121
129
  value={value}
122
130
  onChangeText={handleChangeText}
123
- onFocus={inputProps.onFocus}
124
- onBlur={inputProps.onBlur}
131
+ onFocus={composeHandler(inputProps.onFocus, focusProps.onFocus)}
132
+ onBlur={composeHandler(inputProps.onBlur, focusProps.onBlur)}
125
133
  nativeID={binding?.inputId ?? inputProps.nativeID}
126
134
  accessibilityLabelledBy={binding?.labelId ?? inputProps.accessibilityLabelledBy}
127
135
  aria-describedby={describedBy}
128
136
  aria-invalid={binding?.invalid || undefined}
129
137
  style={[
130
138
  styles.input,
139
+ CONTROL_TRANSITION,
131
140
  getInputTextStyle(),
132
141
  height ? { height } : undefined,
133
142
  autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
134
143
  !editable && styles.disabled,
135
144
  icon && styles.withIcon,
145
+ hovered && editable && { borderColor: HOVER_BORDER },
136
146
  style,
147
+ focusVisible && { boxShadow: FOCUS_RING },
137
148
  ]}
138
149
  multiline={(numberOfLines && numberOfLines > 1) || multiline}
139
150
  focusable={editable}
@@ -1,7 +1,9 @@
1
1
  import type { KeyboardEvent } from "react";
2
2
  import { colors } from "@lotics/ui/colors";
3
3
  import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
4
- import { CONTROL_RADIUS } from "./control_surface";
4
+ import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
5
+ import { useFocusRing } from "./use_focus_ring";
6
+ import { useHover } from "./use_hover";
5
7
  export interface TimePickerProps {
6
8
  value?: string;
7
9
  onValueChange: (value: string) => void;
@@ -15,6 +17,8 @@ export interface TimePickerProps {
15
17
 
16
18
  export function TimePicker(props: TimePickerProps) {
17
19
  const { value, onValueChange, onBlur, onKeyDown, autoFocus, disabled, accessibilityLabel } = props;
20
+ const { focusVisible, focusProps } = useFocusRing({ always: true });
21
+ const { hovered, hoverProps } = useHover();
18
22
 
19
23
  return (
20
24
  <input
@@ -23,7 +27,9 @@ export function TimePicker(props: TimePickerProps) {
23
27
  onValueChange(e.target.value);
24
28
  }}
25
29
  type="time"
26
- onBlur={onBlur}
30
+ {...hoverProps}
31
+ onFocus={focusProps.onFocus}
32
+ onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
27
33
  onKeyDown={onKeyDown}
28
34
  autoFocus={autoFocus}
29
35
  disabled={disabled}
@@ -35,14 +41,15 @@ export function TimePicker(props: TimePickerProps) {
35
41
  borderRadius: CONTROL_RADIUS,
36
42
  borderWidth: 1,
37
43
  borderStyle: "solid",
38
- borderColor: colors.border,
44
+ borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
39
45
  backgroundColor: colors.background,
40
46
  fontFamily: fontFamilyRegular,
41
47
  ...inputTextStyleWeb,
42
48
  letterSpacing: -0.4,
43
- boxShadow: "none",
49
+ boxShadow: focusVisible ? FOCUS_RING : "none",
44
50
  outline: "none",
45
51
  boxSizing: "border-box",
52
+ transition: "border-color 0.12s, box-shadow 0.12s",
46
53
  }}
47
54
  />
48
55
  );
package/src/timeline.tsx CHANGED
@@ -85,7 +85,7 @@ export function Timeline(props: TimelineProps) {
85
85
 
86
86
  <View style={styles.contentColumn}>
87
87
  {hasDetails ? (
88
- <PressableHighlight onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
88
+ <PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
89
89
  {row}
90
90
  </PressableHighlight>
91
91
  ) : (
@@ -0,0 +1,212 @@
1
+ // UploadingThumbnail — the in-flight upload tile (loading / paused / retrying /
2
+ // error states). Pure: reuses the file display primitives (DocumentCard,
3
+ // MediaCard, RemoveButton) and is i18n-free (status text comes in via `labels`).
4
+ // Pairs with FileGrid, which interleaves these with completed FileThumbnails.
5
+
6
+ import { Icon } from "./icon";
7
+ import { Text } from "./text";
8
+ import { IconButton } from "./icon_button";
9
+ import { colors } from "./colors";
10
+ import { DocumentCard, MediaCard, RemoveButton, getMediaIcon } from "./file_thumbnail";
11
+ import { ActivityIndicator, Image, StyleSheet, View } from "react-native";
12
+ import { isImageMimeType } from "./mime";
13
+
14
+ /** In-flight upload states (everything except a completed file). */
15
+ export type UploadStatus = "preparing" | "queued" | "uploading" | "paused_offline" | "retrying" | "error";
16
+
17
+ /** Status text for the non-happy-path states. i18n-free defaults (English); the
18
+ * Lotics host injects translations. The common `uploading` state stays text-free
19
+ * (a spinner is self-evident) — only states that need attention get a word. */
20
+ export interface UploadStatusLabels {
21
+ /** Upload paused because the device is offline. Default "Paused". */
22
+ paused?: string;
23
+ /** Upload is being retried. Default "Retrying". */
24
+ retrying?: string;
25
+ /** Upload failed but can be retried. Default "Upload failed". */
26
+ failed?: string;
27
+ /** Upload failed and retry is futile (dead file ref / empty bytes — re-pick). Default "Can't upload". */
28
+ unavailable?: string;
29
+ /** Accessible label on the retry control. Default "Retry upload". */
30
+ retry?: string;
31
+ }
32
+
33
+ interface UploadingThumbnailProps {
34
+ filename: string;
35
+ mimeType: string;
36
+ previewUrl?: string;
37
+ status: UploadStatus;
38
+ /** True when retry would hit the same failure on the same input: the picker
39
+ * File reference is dead (Android SAF eviction) or the file has zero bytes
40
+ * (empty clipboard paste). The user must re-pick or re-copy the file. */
41
+ staleFile?: boolean;
42
+ /** Explicit pixel size. When omitted, fills container. */
43
+ size?: number;
44
+ onRemove?: () => void;
45
+ onRetry?: () => void;
46
+ labels?: UploadStatusLabels;
47
+ }
48
+
49
+ /** Below this tile size the status word can't fit — show the icon/spinner alone. */
50
+ const LABEL_MIN_SIZE = 72;
51
+
52
+ /**
53
+ * Displays a file during upload with loading/error states.
54
+ */
55
+ export function UploadingThumbnail(props: UploadingThumbnailProps) {
56
+ const { filename, mimeType, previewUrl, status, staleFile, size, onRemove, onRetry, labels } = props;
57
+ const compact = size !== undefined && size < LABEL_MIN_SIZE;
58
+
59
+ const rootStyle =
60
+ size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
61
+
62
+ const overlay = (
63
+ <UploadOverlay
64
+ status={status}
65
+ staleFile={staleFile === true}
66
+ compact={compact}
67
+ labels={labels}
68
+ onRetry={onRetry}
69
+ />
70
+ );
71
+
72
+ if (isImageMimeType(mimeType) && previewUrl) {
73
+ return (
74
+ <View style={[styles.uploadingContainer, rootStyle]}>
75
+ <Image source={{ uri: previewUrl }} style={styles.uploadingImage} resizeMode="cover" />
76
+ {overlay}
77
+ {onRemove && <RemoveButton onPress={onRemove} />}
78
+ </View>
79
+ );
80
+ }
81
+
82
+ const mediaIcon = getMediaIcon(mimeType);
83
+
84
+ return (
85
+ <View style={rootStyle}>
86
+ {mediaIcon !== undefined ? (
87
+ <MediaCard mimeType={mimeType} filename={filename} icon={mediaIcon} overlay={overlay} />
88
+ ) : (
89
+ <DocumentCard mimeType={mimeType} filename={filename} overlay={overlay} />
90
+ )}
91
+ {onRemove && <RemoveButton onPress={onRemove} />}
92
+ </View>
93
+ );
94
+ }
95
+
96
+ function UploadOverlay(props: {
97
+ status: UploadStatus;
98
+ staleFile: boolean;
99
+ compact: boolean;
100
+ labels?: UploadStatusLabels;
101
+ onRetry?: () => void;
102
+ }) {
103
+ const { status, staleFile, compact, labels, onRetry } = props;
104
+
105
+ if (status === "preparing" || status === "queued" || status === "uploading") {
106
+ return (
107
+ <View style={styles.overlay} pointerEvents="none">
108
+ <ActivityIndicator size="small" color={colors.white} />
109
+ </View>
110
+ );
111
+ }
112
+
113
+ if (status === "paused_offline") {
114
+ return (
115
+ <OverlayStack compact={compact} label={labels?.paused ?? "Paused"}>
116
+ <Icon name="pause" size={20} color={colors.white} />
117
+ </OverlayStack>
118
+ );
119
+ }
120
+
121
+ if (status === "retrying") {
122
+ return (
123
+ <OverlayStack compact={compact} label={labels?.retrying ?? "Retrying"}>
124
+ <ActivityIndicator size="small" color={colors.white} />
125
+ </OverlayStack>
126
+ );
127
+ }
128
+
129
+ // error, retry is futile — the user must re-pick the file
130
+ if (staleFile) {
131
+ return (
132
+ <OverlayStack tone="error" compact={compact} label={labels?.unavailable ?? "Can't upload"}>
133
+ <Icon name="circle-alert" size={20} color={colors.white} />
134
+ </OverlayStack>
135
+ );
136
+ }
137
+
138
+ // error, retryable
139
+ return (
140
+ <View style={[styles.overlay, styles.overlayError]} pointerEvents="box-none">
141
+ <View style={styles.stack}>
142
+ {onRetry && (
143
+ <IconButton
144
+ icon="rotate-cw"
145
+ size={compact ? "sm" : "md"}
146
+ elevated
147
+ accessibilityLabel={labels?.retry ?? "Retry upload"}
148
+ onPress={(e) => {
149
+ e.stopPropagation();
150
+ onRetry();
151
+ }}
152
+ />
153
+ )}
154
+ {!compact && <OverlayLabel>{labels?.failed ?? "Upload failed"}</OverlayLabel>}
155
+ </View>
156
+ </View>
157
+ );
158
+ }
159
+
160
+ function OverlayStack(props: { tone?: "error"; compact: boolean; label: string; children: React.ReactNode }) {
161
+ return (
162
+ <View style={[styles.overlay, props.tone === "error" && styles.overlayError]} pointerEvents="none">
163
+ <View style={styles.stack}>
164
+ {props.children}
165
+ {!props.compact && <OverlayLabel>{props.label}</OverlayLabel>}
166
+ </View>
167
+ </View>
168
+ );
169
+ }
170
+
171
+ function OverlayLabel({ children }: { children: string }) {
172
+ return (
173
+ <Text size="xs" weight="medium" color="inverted" numberOfLines={2} style={styles.overlayLabel} userSelect="none">
174
+ {children}
175
+ </Text>
176
+ );
177
+ }
178
+
179
+ const styles = StyleSheet.create({
180
+ uploadingContainer: {
181
+ position: "relative",
182
+ },
183
+ uploadingImage: {
184
+ width: "100%",
185
+ height: "100%",
186
+ borderRadius: 10,
187
+ },
188
+ overlay: {
189
+ position: "absolute",
190
+ top: 0,
191
+ left: 0,
192
+ right: 0,
193
+ bottom: 0,
194
+ borderRadius: 10,
195
+ backgroundColor: "rgba(24, 24, 27, 0.55)",
196
+ justifyContent: "center",
197
+ alignItems: "center",
198
+ padding: 8,
199
+ },
200
+ // Error states get a red wash — the at-a-glance "this didn't work" signal —
201
+ // dark enough that the white label + elevated retry button stay legible.
202
+ overlayError: {
203
+ backgroundColor: "rgba(153, 27, 27, 0.66)",
204
+ },
205
+ stack: {
206
+ alignItems: "center",
207
+ gap: 6,
208
+ },
209
+ overlayLabel: {
210
+ textAlign: "center",
211
+ },
212
+ });
@@ -0,0 +1,80 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /**
4
+ * Combine two optional event handlers into one. Either may be undefined.
5
+ * Used to layer a focus-ring handler on top of a caller's (or a tooltip's)
6
+ * `onFocus`/`onBlur` so neither silently clobbers the other.
7
+ */
8
+ export function composeHandler<E>(
9
+ a: ((event: E) => void) | undefined,
10
+ b: ((event: E) => void) | undefined,
11
+ ): ((event: E) => void) | undefined {
12
+ if (!a) return b;
13
+ if (!b) return a;
14
+ return (event: E) => {
15
+ a(event);
16
+ b(event);
17
+ };
18
+ }
19
+
20
+ // `:focus-visible` is a MODALITY heuristic: a control shows its ring on keyboard
21
+ // focus but not pointer focus. The kit no longer carries a global `:focus-visible`
22
+ // CSS rule, so every control paints its own ring — and needs that same signal. One
23
+ // focus event carries no modality, so it can only be read at the document level:
24
+ // a single module-level tracker records the last interaction. Web-only — on native
25
+ // / SSR there is no `document`, so it stays "pointer" and the ring is keyboard-gated
26
+ // off (text-like inputs opt back in with `always`).
27
+ type Modality = "keyboard" | "pointer";
28
+ let lastModality: Modality = "pointer";
29
+ let listenersInstalled = false;
30
+
31
+ function ensureModalityListeners(): void {
32
+ if (listenersInstalled) return;
33
+ if (typeof document === "undefined") return;
34
+ listenersInstalled = true;
35
+ // Capture phase so the modality is recorded BEFORE any control's focus handler
36
+ // runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
37
+ // heuristic listens the same way) — per-mount add/remove would be the bug.
38
+ const opts = { capture: true, passive: true } as const;
39
+ document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
40
+ document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
41
+ document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
42
+ document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
43
+ }
44
+
45
+ export interface UseFocusRingOptions {
46
+ /**
47
+ * Ring on ANY focus, not just keyboard focus. For text-like inputs, which the
48
+ * browser treats as focus-visible even on pointer focus, so a clicked field shows
49
+ * its ring immediately. Plain controls omit this — they ring on keyboard only.
50
+ */
51
+ always?: boolean;
52
+ }
53
+
54
+ export interface FocusRingState {
55
+ /**
56
+ * True while the element should show its focus ring: focused AND (unless
57
+ * `always`) the last interaction was the keyboard. Drive a `boxShadow` off this,
58
+ * composing with any existing one — e.g. `focusVisible ? FOCUS_RING : undefined`.
59
+ */
60
+ focusVisible: boolean;
61
+ /** Raw focus state, regardless of modality. */
62
+ focused: boolean;
63
+ /** Spread onto the focusable element, or compose with `composeHandler`. */
64
+ focusProps: { onFocus: () => void; onBlur: () => void };
65
+ }
66
+
67
+ /**
68
+ * Keyboard-aware focus state for painting a control's own focus ring inline.
69
+ * Replaces the kit's removed global `:focus-visible` CSS rule: each interactive
70
+ * primitive calls this and renders `FOCUS_RING` (`control_surface.ts`) when
71
+ * `focusVisible`. App authors do the same for their own raw focusable elements.
72
+ */
73
+ export function useFocusRing(options?: UseFocusRingOptions): FocusRingState {
74
+ ensureModalityListeners();
75
+ const [focused, setFocused] = useState(false);
76
+ const onFocus = useCallback(() => setFocused(true), []);
77
+ const onBlur = useCallback(() => setFocused(false), []);
78
+ const focusVisible = focused && (options?.always === true || lastModality === "keyboard");
79
+ return { focusVisible, focused, focusProps: { onFocus, onBlur } };
80
+ }
@@ -0,0 +1,26 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ export interface HoverState {
4
+ hovered: boolean;
5
+ /**
6
+ * Spread onto the element (or its wrapping View). `onMouseEnter`/`onMouseLeave`
7
+ * are forwarded to the DOM by react-native-web on View/TextInput/etc.; on native
8
+ * they're ignored, so `hovered` stays false (no hover on touch — correct).
9
+ */
10
+ hoverProps: { onMouseEnter: () => void; onMouseLeave: () => void };
11
+ }
12
+
13
+ /**
14
+ * Pointer-hover state for a premium hover affordance (a border darken / shadow on
15
+ * bordered inputs, selects, and other interactive surfaces — signalling "this is
16
+ * interactive" the same way the focus ring signals "this is focused"). Mirrors
17
+ * `useFocusRing`. For `Pressable`-based controls prefer the built-in `hovered`
18
+ * style state; reach for this on TextInput / the native `<select>` / raw DOM inputs
19
+ * that don't expose it.
20
+ */
21
+ export function useHover(): HoverState {
22
+ const [hovered, setHovered] = useState(false);
23
+ const onMouseEnter = useCallback(() => setHovered(true), []);
24
+ const onMouseLeave = useCallback(() => setHovered(false), []);
25
+ return { hovered, hoverProps: { onMouseEnter, onMouseLeave } };
26
+ }
@@ -26,6 +26,11 @@ describe("useListKeyboardNav", () => {
26
26
  expect(view.result.current.activeIndex).toBe(0);
27
27
  });
28
28
 
29
+ it("seats the initial active row at initialIndex", () => {
30
+ const { view } = setup({ initialIndex: 2 });
31
+ expect(view.result.current.activeIndex).toBe(2);
32
+ });
33
+
29
34
  it("ArrowDown / ArrowUp move within bounds and report handled", () => {
30
35
  const { view } = setup();
31
36
  let handled = false;
@@ -111,3 +116,69 @@ describe("useListKeyboardNav", () => {
111
116
  expect(onActiveChange).not.toHaveBeenCalled();
112
117
  });
113
118
  });
119
+
120
+ describe("useListKeyboardNav typeahead", () => {
121
+ const labels = ["Apple", "Banana", "Blueberry", "Cherry"];
122
+ function setupTypeahead(overrides: Partial<ListKeyboardNavOptions> = {}) {
123
+ return setup({
124
+ count: labels.length,
125
+ typeahead: { labelAt: (i) => labels[i] },
126
+ ...overrides,
127
+ });
128
+ }
129
+
130
+ it("jumps to the first label-prefix match and reports the key handled", () => {
131
+ const { view } = setupTypeahead();
132
+ let handled = false;
133
+ act(() => {
134
+ handled = view.result.current.handleKey("c");
135
+ });
136
+ expect(handled).toBe(true);
137
+ expect(view.result.current.activeIndex).toBe(3); // Cherry
138
+ });
139
+
140
+ it("accumulates the buffer while typing", () => {
141
+ vi.useFakeTimers();
142
+ try {
143
+ const { view } = setupTypeahead();
144
+ act(() => void view.result.current.handleKey("b")); // "b" → Banana
145
+ expect(view.result.current.activeIndex).toBe(1);
146
+ act(() => void view.result.current.handleKey("l")); // "bl" → Blueberry
147
+ expect(view.result.current.activeIndex).toBe(2);
148
+ } finally {
149
+ vi.useRealTimers();
150
+ }
151
+ });
152
+
153
+ it("resets the buffer after the idle timeout", () => {
154
+ vi.useFakeTimers();
155
+ try {
156
+ const { view } = setupTypeahead();
157
+ act(() => void view.result.current.handleKey("b")); // "b" → Banana
158
+ expect(view.result.current.activeIndex).toBe(1);
159
+ act(() => {
160
+ vi.advanceTimersByTime(700);
161
+ });
162
+ act(() => void view.result.current.handleKey("c")); // fresh "c" → Cherry, not "bc"
163
+ expect(view.result.current.activeIndex).toBe(3);
164
+ } finally {
165
+ vi.useRealTimers();
166
+ }
167
+ });
168
+
169
+ it("skips disabled rows when jumping", () => {
170
+ const { view } = setupTypeahead({ isDisabled: (i) => i === 1 }); // Banana disabled
171
+ act(() => void view.result.current.handleKey("b"));
172
+ expect(view.result.current.activeIndex).toBe(2); // Blueberry, the first enabled b*
173
+ });
174
+
175
+ it("without a typeahead option, a printable key is not handled and does not move the row", () => {
176
+ const { view } = setup(); // no typeahead
177
+ let handled = true;
178
+ act(() => {
179
+ handled = view.result.current.handleKey("b");
180
+ });
181
+ expect(handled).toBe(false);
182
+ expect(view.result.current.activeIndex).toBe(0);
183
+ });
184
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  export interface ListKeyboardNavOptions {
4
4
  /** Number of rows in the list. */
@@ -11,6 +11,21 @@ export interface ListKeyboardNavOptions {
11
11
  onClose?: () => void;
12
12
  /** Fires when the active row changes — use it to scroll the row into view. */
13
13
  onActiveChange?: (index: number) => void;
14
+ /** The row active on first render (default 0). Read once — seat the initial
15
+ * highlight here instead of via a post-mount effect, so an open list shows the
16
+ * right row with no flash. */
17
+ initialIndex?: number;
18
+ /** Opt-in native-`<select>`-style typeahead: a printable key jumps the active
19
+ * row to the first enabled label that starts with the typed buffer (the buffer
20
+ * accumulates as you keep typing, then resets after `timeoutMs` of inactivity).
21
+ * Absent ⇒ printable keys are reported NOT handled, so a list driven by a real
22
+ * search field lets those keys flow to the input instead of jumping rows. */
23
+ typeahead?: {
24
+ /** The label to match for row `index` (undefined ⇒ the row is skipped). */
25
+ labelAt: (index: number) => string | undefined;
26
+ /** Idle window before the buffer resets, in ms. Default 600 (native parity). */
27
+ timeoutMs?: number;
28
+ };
14
29
  }
15
30
 
16
31
  export interface ListKeyboardNav {
@@ -33,8 +48,18 @@ export interface ListKeyboardNav {
33
48
  * Focus never leaves the input — this only tracks the active index.
34
49
  */
35
50
  export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNav {
36
- const { count, isDisabled, onSelect, onClose, onActiveChange } = opts;
37
- const [activeIndex, setActiveIndexState] = useState(0);
51
+ const { count, isDisabled, onSelect, onClose, onActiveChange, typeahead, initialIndex } = opts;
52
+ const [activeIndex, setActiveIndexState] = useState(() => initialIndex ?? 0);
53
+ // Typeahead buffer + its idle-reset timer. Refs so they survive renders and the
54
+ // handler stays referentially stable; the timer is cleared on unmount.
55
+ const typeaheadBufferRef = useRef("");
56
+ const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
57
+ useEffect(
58
+ () => () => {
59
+ if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
60
+ },
61
+ [],
62
+ );
38
63
 
39
64
  const setActiveIndex = useCallback(
40
65
  (index: number, scrollIntoView = true) => {
@@ -86,11 +111,33 @@ export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNa
86
111
  // Let focus move on, but dismiss the list.
87
112
  onClose?.();
88
113
  return false;
89
- default:
114
+ default: {
115
+ // Native-select typeahead — only when opted in. A printable key
116
+ // (single character) extends the buffer and jumps to the first enabled
117
+ // label that starts with it; otherwise the key isn't ours (a real
118
+ // search field, if any, consumes it).
119
+ if (typeahead && key.length === 1) {
120
+ if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
121
+ typeaheadBufferRef.current += key.toLowerCase();
122
+ const buffer = typeaheadBufferRef.current;
123
+ for (let i = 0; i < count; i++) {
124
+ if (!enabled(i)) continue;
125
+ const label = typeahead.labelAt(i);
126
+ if (label && label.toLowerCase().startsWith(buffer)) {
127
+ setActiveIndex(i);
128
+ break;
129
+ }
130
+ }
131
+ typeaheadTimerRef.current = setTimeout(() => {
132
+ typeaheadBufferRef.current = "";
133
+ }, typeahead.timeoutMs ?? 600);
134
+ return true;
135
+ }
90
136
  return false;
137
+ }
91
138
  }
92
139
  },
93
- [activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex],
140
+ [activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex, typeahead],
94
141
  );
95
142
 
96
143
  return { activeIndex, setActiveIndex, handleKey };