@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
@@ -1,7 +1,26 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
2
- import { View, ScrollView, Pressable, StyleSheet } from "react-native";
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { View, ScrollView, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
3
3
  import { Text } from "../text";
4
4
  import { colors } from "../colors";
5
+ import { FocusRingPressable } from "../focus_ring_pressable";
6
+
7
+ function GanttButton(props: {
8
+ onPress?: () => void;
9
+ accessibilityLabel?: string;
10
+ style: StyleProp<ViewStyle>;
11
+ children: ReactNode;
12
+ }) {
13
+ return (
14
+ <FocusRingPressable
15
+ onPress={props.onPress}
16
+ accessibilityRole={props.onPress ? "button" : undefined}
17
+ accessibilityLabel={props.accessibilityLabel}
18
+ style={props.style}
19
+ >
20
+ {props.children}
21
+ </FocusRingPressable>
22
+ );
23
+ }
5
24
  import { addDays, dayDiff } from "../calendar/dates";
6
25
  import { usePointerDrag } from "../use_pointer_drag";
7
26
  import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
@@ -74,11 +93,11 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
74
93
  {title ? <Text size="sm" color="muted">{title}</Text> : <View />}
75
94
  <View style={styles.zoomSwitch}>
76
95
  {SCALES.map((s) => (
77
- <Pressable key={s} onPress={() => setScale(s)} accessibilityRole="button" style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
96
+ <GanttButton key={s} onPress={() => setScale(s)} style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
78
97
  <Text size="sm" weight={scale === s ? "medium" : "regular"} color={scale === s ? "default" : "muted"}>
79
98
  {L[s]}
80
99
  </Text>
81
- </Pressable>
100
+ </GanttButton>
82
101
  ))}
83
102
  </View>
84
103
  </View>
@@ -127,16 +146,15 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
127
146
  <View key={tk.date.toISOString()} style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] }} />
128
147
  ) : null,
129
148
  )}
130
- <Pressable
149
+ <GanttButton
131
150
  onPress={onTaskPress ? () => onTaskPress(t) : undefined}
132
- accessibilityRole={onTaskPress ? "button" : undefined}
133
151
  accessibilityLabel={t.label}
134
152
  style={{ position: "absolute", left: base.left, width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
135
153
  >
136
154
  <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8, opacity: dragging ? 0.85 : 1 }}>
137
155
  <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
138
156
  </View>
139
- </Pressable>
157
+ </GanttButton>
140
158
  {onTaskResize ? (
141
159
  <View
142
160
  ref={bind(t.id, "ew-resize")}
package/src/heatmap.tsx CHANGED
@@ -2,6 +2,8 @@ import { useState } from "react";
2
2
  import { Pressable, StyleSheet, View } from "react-native";
3
3
  import { colors, withAlpha } from "./colors";
4
4
  import { Text } from "./text";
5
+ import { FOCUS_RING } from "./control_surface";
6
+ import { useFocusRing } from "./use_focus_ring";
5
7
 
6
8
  export interface HeatmapAxisItem {
7
9
  key: string;
@@ -97,6 +99,7 @@ interface CellProps {
97
99
  function Cell(props: CellProps) {
98
100
  const { label, background, selected, onPress } = props;
99
101
  const [hovered, setHovered] = useState(false);
102
+ const { focusVisible, focusProps } = useFocusRing();
100
103
  const base = {
101
104
  backgroundColor: background,
102
105
  borderColor: selected ? colors.zinc[900] : hovered && onPress ? colors.zinc[400] : "transparent",
@@ -108,9 +111,10 @@ function Cell(props: CellProps) {
108
111
  accessibilityState={{ selected }}
109
112
  accessibilityLabel={label}
110
113
  onPress={onPress}
114
+ {...focusProps}
111
115
  onHoverIn={() => setHovered(true)}
112
116
  onHoverOut={() => setHovered(false)}
113
- style={[styles.cell, base]}
117
+ style={[styles.cell, base, focusVisible && { boxShadow: FOCUS_RING }]}
114
118
  />
115
119
  );
116
120
  }
@@ -21,6 +21,10 @@ interface IconButtonBase {
21
21
  * hitSlop; `lg` is already 40. */
22
22
  size?: "lg" | "md" | "sm";
23
23
  iconColor?: string;
24
+ /** Floating variant: a white fill + hairline border + soft shadow, so the
25
+ * button reads as a distinct, tappable target sitting ON TOP of imagery or a
26
+ * colored surface (a tile's remove ✕, an overlay's retry). */
27
+ elevated?: boolean;
24
28
  tooltipSide?: TooltipSide;
25
29
  onPress?: (event: GestureResponderEvent) => void;
26
30
  style?: StyleProp<ViewStyle>;
@@ -48,6 +52,7 @@ export function IconButton(props: IconButtonProps) {
48
52
  onPress,
49
53
  color = "none",
50
54
  size = "md",
55
+ elevated,
51
56
  tooltip,
52
57
  tooltipSide,
53
58
  accessibilityLabel,
@@ -70,13 +75,13 @@ export function IconButton(props: IconButtonProps) {
70
75
 
71
76
  return (
72
77
  <PressableHighlight
73
- ref={ref}
74
- testID={testID}
78
+ focusRing
79
+ ref={ref} testID={testID}
75
80
  tooltip={tooltip}
76
81
  tooltipSide={tooltipSide}
77
82
  accessibilityRole="button"
78
83
  accessibilityLabel={accessibilityLabel ?? tooltip}
79
- style={[styles.button, styles[size], styles[color], disabledOrLoading && styles.disabled, style]}
84
+ style={[styles.button, styles[size], styles[color], elevated && styles.elevated, disabledOrLoading && styles.disabled, style]}
80
85
  onPress={handlePress}
81
86
  disabled={disabledOrLoading}
82
87
  // 40px touch target regardless of visual size (28 + 2×6 / 24 + 2×8)
@@ -128,4 +133,10 @@ const styles = StyleSheet.create({
128
133
  muted: {},
129
134
  "danger-secondary": {},
130
135
  white: {},
136
+ elevated: {
137
+ backgroundColor: colors.white,
138
+ borderWidth: 1,
139
+ borderColor: colors.zinc[200],
140
+ ...({ boxShadow: "0 1px 3px rgba(0, 0, 0, 0.18)" } as object),
141
+ },
131
142
  });
@@ -8,8 +8,9 @@
8
8
  // relying on percentage/flex-basis resolution (which collapses inside an
9
9
  // auto-width parent).
10
10
  import { useState } from "react";
11
- import { View, Pressable, type ViewStyle } from "react-native";
11
+ import { View, type ViewStyle } from "react-native";
12
12
  import { Text } from "./text";
13
+ import { FocusRingPressable } from "./focus_ring_pressable";
13
14
  import { Icon } from "./icon";
14
15
  import { colors } from "./colors";
15
16
  import { ActivityIndicator } from "./activity_indicator";
@@ -36,8 +37,6 @@ export interface ImageGalleryProps {
36
37
  emptyText?: string;
37
38
  /** Optional max width. Omit to fill the container. */
38
39
  maxWidth?: number;
39
- /** Hint shown in the full-screen zoom modal. Pass a translated string. */
40
- captionHint?: string;
41
40
  /** "bottom" stacks thumbnails under the main image; "right" puts the main
42
41
  * image (~60% of the width) and the thumbnail grid side by side. */
43
42
  thumbnailPosition?: "bottom" | "right";
@@ -55,7 +54,6 @@ export function ImageGallery({
55
54
  loading = false,
56
55
  emptyText = "No images.",
57
56
  maxWidth,
58
- captionHint = "ESC to close · ←/→ to navigate",
59
57
  thumbnailPosition = "bottom",
60
58
  mainAspectRatio = 4 / 3,
61
59
  mainFraction = 0.6,
@@ -79,9 +77,8 @@ export function ImageGallery({
79
77
  // The rotate controls sit OUTSIDE the zoom Pressable (an absolute sibling on
80
78
  // top) so pressing them turns the image instead of opening the modal.
81
79
  <View style={{ width: "100%" }}>
82
- <Pressable
80
+ <FocusRingPressable accessibilityRole="button"
83
81
  onPress={() => setZoomIdx(idx)}
84
- accessibilityRole="button"
85
82
  accessibilityLabel="Zoom image"
86
83
  style={{
87
84
  width: "100%",
@@ -95,25 +92,23 @@ export function ImageGallery({
95
92
  }}
96
93
  >
97
94
  <RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
98
- </Pressable>
95
+ </FocusRingPressable>
99
96
  {rotatable ? (
100
97
  <View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
101
- <Pressable
98
+ <FocusRingPressable accessibilityRole="button"
102
99
  onPress={() => rotation.rotate(main.id, -1)}
103
- accessibilityRole="button"
104
100
  accessibilityLabel="Rotate left"
105
101
  style={rotateButtonStyle}
106
102
  >
107
103
  <Icon name="rotate-ccw" size={16} color={colors.white} />
108
- </Pressable>
109
- <Pressable
104
+ </FocusRingPressable>
105
+ <FocusRingPressable accessibilityRole="button"
110
106
  onPress={() => rotation.rotate(main.id, 1)}
111
- accessibilityRole="button"
112
107
  accessibilityLabel="Rotate right"
113
108
  style={rotateButtonStyle}
114
109
  >
115
110
  <Icon name="rotate-cw" size={16} color={colors.white} />
116
- </Pressable>
111
+ </FocusRingPressable>
117
112
  </View>
118
113
  ) : null}
119
114
  </View>
@@ -157,7 +152,6 @@ export function ImageGallery({
157
152
  files={images}
158
153
  activeIndex={zoomIdx}
159
154
  onIndexChange={setZoomIdx}
160
- captionHint={captionHint}
161
155
  rotation={rotation}
162
156
  rotatable={rotatable}
163
157
  />
package/src/index.css CHANGED
@@ -336,18 +336,14 @@ html {
336
336
  color: var(--color-zinc-900);
337
337
  }
338
338
 
339
- /* Focus ringkeyboard-driven only (`:focus-visible`), so pointer/touch
340
- interactions stay clutter-free (no stray ring stacked on a clicked button,
341
- tab, or segment that already shows its own state). A SELECT shows its own
342
- active edge on a mouse-open via `ACTIVE_RING` (control_surface.ts) that, not
343
- this rule, is what rings the combobox/picker on a pointer open. The ring sits
344
- flush against the border (offset 0, no gap) and, being an outline, never
345
- reflows layout. */
346
- :focus-visible {
347
- outline: 2px solid var(--color-zinc-900);
348
- outline-offset: 0;
349
- }
350
- :focus:not(:focus-visible) {
339
+ /* Native UA focus-outline reset purely subtractive. The kit has NO global focus
340
+ ring: each interactive control paints its OWN ring inline (a `FOCUS_RING`
341
+ box-shadow, applied on keyboard focus via `useFocusRing`; control_surface.ts).
342
+ That keeps rings surgical (only where a control opts in) instead of blanketing
343
+ every focusable container the browser would otherwise outline. This line only
344
+ removes the browser default so an un-opted element shows nothing it never adds
345
+ a ring. App authors opt their own raw focusable elements in with `useFocusRing`. */
346
+ :focus {
351
347
  outline: none;
352
348
  }
353
349
 
@@ -1,10 +1,10 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
2
  import { View } from "react-native";
3
- import { Icon } from "./icon";
4
3
  import { Text } from "./text";
5
4
  import { colors } from "./colors";
6
5
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
6
  import { DatePickerPanel } from "./date_picker";
7
+ import { isoHasTime } from "./date_picker_value";
8
8
  import { formatDate } from "./format_date";
9
9
  import { ActivityIndicator } from "./activity_indicator";
10
10
  import { InlineEditView } from "./inline_edit";
@@ -15,6 +15,9 @@ export interface InlineDatePickerProps {
15
15
  onSave: (next: string) => void | Promise<void>;
16
16
  /** "date" (default) or "datetime". */
17
17
  format?: "date" | "datetime";
18
+ /** Let the user OPTIONALLY add a time to a date (the value's shape decides — date
19
+ * until a time is added). Ignored when `format` is "datetime" (time always on). */
20
+ optionalTime?: boolean;
18
21
  placeholder?: string;
19
22
  /** BCP-47 locale for the calendar and the displayed date. */
20
23
  locale?: string;
@@ -30,7 +33,7 @@ export interface InlineDatePickerProps {
30
33
  * dismissing without a change reverts.
31
34
  */
32
35
  export function InlineDatePicker(props: InlineDatePickerProps) {
33
- const { value, onSave, format = "date", placeholder, locale, disabled, accessibilityLabel } = props;
36
+ const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel } = props;
34
37
  const [open, setOpen] = useState(false);
35
38
  const [draft, setDraft] = useState<string | null>(value);
36
39
  const [saving, setSaving] = useState(false);
@@ -68,8 +71,10 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
68
71
  );
69
72
 
70
73
  // `format` here is the field config (date vs datetime); the display formatter only needs
71
- // whether to show a time → map it to the orthogonal `time` flag.
72
- const display = formatDate(value, { time: format === "datetime", locale, emptyLabel: "" });
74
+ // whether to show a time → map it to the orthogonal `time` flag. In optionalTime mode the
75
+ // value's own shape decides.
76
+ const showTime = optionalTime ? isoHasTime(value) : format === "datetime";
77
+ const display = formatDate(value, { time: showTime, locale, emptyLabel: "" });
73
78
 
74
79
  return (
75
80
  <View>
@@ -81,13 +86,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
81
86
  disabled={disabled}
82
87
  active={open && !disabled}
83
88
  accessibilityLabel={accessibilityLabel}
84
- trailing={
85
- saving ? (
86
- <ActivityIndicator size={16} color={colors.zinc[400]} />
87
- ) : (
88
- <Icon name="calendar" size={18} color={colors.zinc[400]} />
89
- )
90
- }
89
+ trailing={saving ? <ActivityIndicator size={16} color={colors.zinc[400]} /> : undefined}
91
90
  />
92
91
  </PopoverTrigger>
93
92
  <PopoverContent>
@@ -98,6 +97,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
98
97
  setDraft(v);
99
98
  }}
100
99
  format={format}
100
+ optionalTime={optionalTime}
101
101
  locale={locale}
102
102
  onRequestClose={() => setOpen(false)}
103
103
  />
@@ -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
  );