@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
@@ -0,0 +1,232 @@
1
+ import { useState } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
5
+ import { MenuButton } from "./menu_button";
6
+ import { Alert } from "./alert";
7
+ import { FileGrid, type FileUpload } from "./file_grid";
8
+ import { FileGalleryModal } from "./file_gallery_modal";
9
+ import type { GalleryLabels } from "./file_preview_types";
10
+ import { type DisplayFile } from "./file_thumbnail";
11
+ import { pickFiles } from "./file_picker";
12
+ import { downloadFileFromUrl } from "./download";
13
+
14
+ export interface FilesEditorLabels {
15
+ upload: string;
16
+ select: string;
17
+ selectAll: string;
18
+ deselectAll: string;
19
+ done: string;
20
+ /** The select-mode actions menu trigger. */
21
+ menu: string;
22
+ downloadAll: string;
23
+ /** Download-selected menu item, given the count. */
24
+ downloadSelected: (n: number) => string;
25
+ /** Download-as-ZIP menu item (shown when `onDownloadZipSelected` + 2+ selected). */
26
+ downloadZip: string;
27
+ share: string;
28
+ delete: string;
29
+ removeTitle: string;
30
+ removeMessage: string;
31
+ removeCancel: string;
32
+ removeConfirm: string;
33
+ }
34
+
35
+ const DEFAULT_LABELS: FilesEditorLabels = {
36
+ upload: "Upload",
37
+ select: "Select",
38
+ selectAll: "Select all",
39
+ deselectAll: "Deselect all",
40
+ done: "Done",
41
+ menu: "Menu",
42
+ downloadAll: "Download all",
43
+ downloadSelected: (n) => `Download (${n})`,
44
+ downloadZip: "Download as ZIP",
45
+ share: "Share",
46
+ delete: "Delete",
47
+ removeTitle: "Remove attachment?",
48
+ removeMessage: "This removes the file.",
49
+ removeCancel: "Cancel",
50
+ removeConfirm: "Remove",
51
+ };
52
+
53
+ export interface FilesEditorProps {
54
+ /** Completed/saved files. */
55
+ files: DisplayFile[];
56
+ /** The live add-queue (in-flight uploads) — pass through from your upload mechanism. */
57
+ uploads?: FileUpload[];
58
+ /** Picked files → the host uploads + appends to `files`. Omit to hide Upload. */
59
+ onAdd?: (files: File[]) => void;
60
+ /** Override the Upload action's picker (e.g. a native document picker). When
61
+ * set, the Upload button calls this instead of the built-in web `pickFiles`. */
62
+ onUpload?: () => void;
63
+ /** Show a "Download as ZIP" action in select mode (2+ selected), given the files. */
64
+ onDownloadZipSelected?: (files: DisplayFile[]) => void;
65
+ /** Remove a completed file. Omit (or `readOnly`) for a view-only surface. */
66
+ onRemove?: (id: string) => void;
67
+ /** Cancel an in-flight upload / retry a failed one (when using `uploads`). */
68
+ onUploadRemove?: (id: string) => void;
69
+ onRetry?: (id: string) => void;
70
+ onRetryAll?: () => void;
71
+ /** Show a Share action in select mode, given the selected files. Omit to hide it. */
72
+ onShareSelected?: (files: DisplayFile[]) => void;
73
+ /** Override the default download (`downloadFileFromUrl`). */
74
+ onDownload?: (file: DisplayFile) => void;
75
+ /** Download + preview only (no Upload / Select / remove). */
76
+ readOnly?: boolean;
77
+ /** Native `accept` filter for the file picker. */
78
+ accept?: string;
79
+ itemSize?: number;
80
+ minItemWidth?: number;
81
+ /** Fixed column count for the grid (passed through to `FileGrid`). */
82
+ columns?: number;
83
+ /** Translated labels. Defaults to English. */
84
+ labels?: Partial<FilesEditorLabels>;
85
+ /** Credentials mode for the built-in gallery's preview fetches — `"include"`
86
+ * for the host's auth-gated proxy URLs, omitted for an app's presigned URLs.
87
+ * See `FilePreviewProps`. */
88
+ credentials?: RequestCredentials;
89
+ /** Translated labels for the built-in full-screen gallery (its toolbar action
90
+ * buttons + confirm dialog). Defaults to English. */
91
+ galleryLabels?: Partial<GalleryLabels>;
92
+ }
93
+
94
+ /**
95
+ * The all-in-one attachment surface: an upload-aware thumbnail grid (`FileGrid`)
96
+ * with a toolbar BELOW it, a batch SELECT mode, full-screen preview
97
+ * (`FileGalleryModal`), and download / share / Alert-confirmed remove. Agnostic —
98
+ * the host wires `onAdd` (picked files → its upload) and `onRemove`; select mode,
99
+ * the gallery, download and the remove confirm are all internal. Mirrors the
100
+ * frontend `files_editor`. For a plain row list use `FileRows`; for the bare
101
+ * upload grid use `FileGrid`.
102
+ */
103
+ export function FilesEditor(props: FilesEditorProps) {
104
+ const {
105
+ files,
106
+ uploads,
107
+ onAdd,
108
+ onUpload,
109
+ onDownloadZipSelected,
110
+ onRemove,
111
+ onUploadRemove,
112
+ onRetry,
113
+ onRetryAll,
114
+ onShareSelected,
115
+ onDownload,
116
+ readOnly = false,
117
+ accept,
118
+ itemSize,
119
+ minItemWidth,
120
+ columns,
121
+ labels,
122
+ credentials,
123
+ galleryLabels,
124
+ } = props;
125
+ const l = { ...DEFAULT_LABELS, ...labels };
126
+
127
+ const [selectMode, setSelectMode] = useState(false);
128
+ const [selected, setSelected] = useState<Set<string>>(() => new Set());
129
+ const [menuOpen, setMenuOpen] = useState(false);
130
+ const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
131
+
132
+ const hasFiles = files.length > 0;
133
+ const allSelected = hasFiles && selected.size === files.length;
134
+ const selectedFiles = files.filter((f) => selected.has(f.id));
135
+
136
+ const toggle = (id: string) =>
137
+ setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
138
+ const exitSelect = () => { setSelectMode(false); setSelected(new Set()); };
139
+ const download = (fs: DisplayFile[]) =>
140
+ fs.forEach((f) => (onDownload ? onDownload(f) : void downloadFileFromUrl(f.url, f.filename, { credentials })));
141
+ const confirmRemove = (onConfirm: () => void) =>
142
+ Alert.alert(l.removeTitle, l.removeMessage, [
143
+ { text: l.removeCancel, style: "cancel" },
144
+ { text: l.removeConfirm, style: "destructive", onPress: onConfirm },
145
+ ]);
146
+ const upload = () => {
147
+ if (!onAdd) return;
148
+ void pickFiles({ multiple: true, accept }).then((picked) => { if (picked.length > 0) onAdd(picked); });
149
+ };
150
+
151
+ return (
152
+ <View style={styles.root}>
153
+ <FileGrid
154
+ files={files}
155
+ uploads={uploads}
156
+ itemSize={itemSize}
157
+ minItemWidth={minItemWidth}
158
+ columns={columns}
159
+ selectedIds={selectMode ? selected : undefined}
160
+ onToggleSelect={selectMode ? toggle : undefined}
161
+ onFilePress={selectMode ? undefined : (f) => setGalleryIndex(files.findIndex((x) => x.id === f.id))}
162
+ onDisplayRemove={!readOnly && !selectMode && onRemove ? (id) => confirmRemove(() => onRemove(id)) : undefined}
163
+ onUploadRemove={onUploadRemove}
164
+ onRetry={onRetry}
165
+ onRetryAll={onRetryAll}
166
+ />
167
+
168
+ {readOnly ? (
169
+ hasFiles ? (
170
+ <View style={styles.bar}>
171
+ <View style={styles.spacer} />
172
+ <Button title={l.downloadAll} color="secondary" onPress={() => download(files)} />
173
+ </View>
174
+ ) : null
175
+ ) : selectMode ? (
176
+ <View style={styles.bar}>
177
+ <Button
178
+ title={allSelected ? l.deselectAll : l.selectAll}
179
+ color="muted"
180
+ onPress={() => setSelected(allSelected ? new Set() : new Set(files.map((f) => f.id)))}
181
+ />
182
+ <Popover open={menuOpen} onOpenChange={setMenuOpen} align="start">
183
+ <PopoverTrigger>
184
+ <Button title={l.menu} color="secondary" disabled={selected.size === 0} onPress={() => setMenuOpen(true)} />
185
+ </PopoverTrigger>
186
+ <PopoverContent>
187
+ <View style={styles.menu}>
188
+ <MenuButton icon="download" title={l.downloadSelected(selected.size)} onPress={() => { setMenuOpen(false); download(selectedFiles); }} />
189
+ {onDownloadZipSelected && selected.size > 1 ? (
190
+ <MenuButton icon="file-down" title={l.downloadZip} onPress={() => { setMenuOpen(false); onDownloadZipSelected(selectedFiles); }} />
191
+ ) : null}
192
+ {onShareSelected ? (
193
+ <MenuButton icon="share" title={l.share} onPress={() => { setMenuOpen(false); onShareSelected(selectedFiles); }} />
194
+ ) : null}
195
+ {onRemove ? (
196
+ <MenuButton icon="trash" title={l.delete} danger onPress={() => { setMenuOpen(false); confirmRemove(() => { selectedFiles.forEach((f) => onRemove(f.id)); exitSelect(); }); }} />
197
+ ) : null}
198
+ </View>
199
+ </PopoverContent>
200
+ </Popover>
201
+ <View style={styles.spacer} />
202
+ <Button title={l.done} color="secondary" onPress={exitSelect} />
203
+ </View>
204
+ ) : (
205
+ <View style={styles.bar}>
206
+ {onAdd || onUpload ? <Button title={l.upload} color="primary" onPress={onUpload ?? upload} /> : null}
207
+ {hasFiles ? <Button title={l.select} color="secondary" onPress={() => setSelectMode(true)} /> : null}
208
+ <View style={styles.spacer} />
209
+ {hasFiles ? <Button title={l.downloadAll} color="secondary" onPress={() => download(files)} /> : null}
210
+ </View>
211
+ )}
212
+
213
+ <FileGalleryModal
214
+ files={files}
215
+ activeIndex={galleryIndex}
216
+ onIndexChange={setGalleryIndex}
217
+ onRemove={!readOnly && onRemove ? onRemove : undefined}
218
+ onDownload={onDownload}
219
+ onOpenExternal={(f) => { if (typeof window !== "undefined") window.open(f.url, "_blank"); }}
220
+ credentials={credentials}
221
+ labels={galleryLabels}
222
+ />
223
+ </View>
224
+ );
225
+ }
226
+
227
+ const styles = StyleSheet.create({
228
+ root: { gap: 10 },
229
+ bar: { flexDirection: "row", alignItems: "center", gap: 8 },
230
+ spacer: { flex: 1 },
231
+ menu: { minWidth: 200, gap: 2, padding: 4 },
232
+ });
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode, useState } from "react";
2
- import { StyleSheet } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
@@ -12,20 +12,21 @@ export interface FilterChipProps {
12
12
  /** The dimension name — shown alone when inactive ("Owner"), prefixed when
13
13
  * active ("Owner: Maria, James"). */
14
14
  label: string;
15
- /** A short human summary of the active selection ("Maria, James", "≥ 10").
16
- * Empty / undefined renders the inactive pill (label + chevron, no clear).
17
- * Build it with `rangeSummary` / `selectSummary` for a consistent preview. */
18
- summary?: string;
15
+ /** The active selection preview, after "Label: ". A string ("Maria, James",
16
+ * "≥ 10" build with `rangeSummary` / `selectSummary`), OR a node to render the
17
+ * selection RICHLY in the trigger (member avatars, colour dots). Empty /
18
+ * undefined renders the inactive pill (label + chevron, no clear). */
19
+ summary?: ReactNode;
19
20
  /** Clears the dimension — renders the × on the pill AND a Clear in the popover
20
21
  * footer whenever a summary is present. */
21
22
  onClear?: () => void;
22
23
  /** Label for the clear affordances (pass a translated string). */
23
24
  clearLabel?: string;
24
25
  /** The editor revealed on press — a premium primitive (`RangeSlider`,
25
- * `Counter`, `PickerMenu` multi) or any composed control. `FilterChip` owns
26
+ * `Counter`, `OptionList` multi) or any composed control. `FilterChip` owns
26
27
  * the pill + popover chrome; the consumer owns the value and applies it.
27
28
  * Pass a render function to receive `close` — the one editor that closes on
28
- * action is single-select (`<PickerMenu>` → close on pick); range/counter/
29
+ * action is single-select (`<OptionList>` → close on pick); range/counter/
29
30
  * multi stay open and dismiss on outside-press, so they pass a plain node. */
30
31
  children: ReactNode | ((api: { close: () => void }) => ReactNode);
31
32
  side?: PopoverSide;
@@ -62,14 +63,14 @@ export function selectSummary(
62
63
  * × to clear. `FilterChip` bakes the consistent chrome — the preview pill, a
63
64
  * padded popover body for the composed editor, and a Clear footer when active —
64
65
  * so every filter looks and behaves the same. Drop a premium primitive inside
65
- * (`RangeSlider`, `Counter`, `PickerMenu` multi). With `footer` (+ controlled
66
+ * (`RangeSlider`, `Counter`, `OptionList` multi). With `footer` (+ controlled
66
67
  * `open`/`onOpenChange`) the same shell wraps a non-filter VALUE pill — a
67
68
  * setting gate with Cancel/Save — keeping it on the one pill surface. The table
68
69
  * toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
69
70
  */
70
71
  export function FilterChip(props: FilterChipProps) {
71
72
  const { label, summary, onClear, clearLabel = "Clear", children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
72
- const active = summary != null && summary.length > 0;
73
+ const active = summary != null && (typeof summary !== "string" || summary.length > 0);
73
74
  // The clear × / Clear footer only when there's a clearable selection AND no
74
75
  // custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
75
76
  const showClear = active && !!onClear && !footer;
@@ -88,9 +89,16 @@ export function FilterChip(props: FilterChipProps) {
88
89
  <Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
89
90
  <PopoverTrigger>
90
91
  <Chip onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
91
- <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
92
- {active ? `${label}: ${summary}` : label}
93
- </Text>
92
+ {active && typeof summary !== "string" ? (
93
+ <View style={styles.summaryRow}>
94
+ <Text size="sm" weight="medium" color="zinc-700">{`${label}:`}</Text>
95
+ {summary}
96
+ </View>
97
+ ) : (
98
+ <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
99
+ {active ? `${label}: ${summary}` : label}
100
+ </Text>
101
+ )}
94
102
  {!showClear ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
95
103
  </Chip>
96
104
  </PopoverTrigger>
@@ -116,4 +124,9 @@ const styles = StyleSheet.create({
116
124
  body: {
117
125
  gap: 8,
118
126
  },
127
+ summaryRow: {
128
+ flexDirection: "row",
129
+ alignItems: "center",
130
+ gap: 5,
131
+ },
119
132
  });
@@ -0,0 +1,34 @@
1
+ import { Pressable, type PressableProps, type StyleProp, type View, type ViewStyle } from "react-native";
2
+ import { type Ref } from "react";
3
+ import { FOCUS_RING } from "./control_surface";
4
+ import { useFocusRing, composeHandler } from "./use_focus_ring";
5
+
6
+ export interface FocusRingPressableProps extends PressableProps {
7
+ ref?: Ref<View>;
8
+ }
9
+
10
+ /**
11
+ * A `Pressable` that paints the kit `FOCUS_RING` on keyboard focus — the raw-control
12
+ * counterpart to `PressableHighlight`'s `focusRing` prop, for interactive elements
13
+ * that DON'T want the hover wash (cells, tiles, nav buttons, menu options). Forwards
14
+ * every `PressableProps` (onPress, accessibilityRole/Label/State, ref, …) and merges
15
+ * the ring with the caller's `style` (object or state-function) + onFocus/onBlur.
16
+ * Reach for this before hand-rolling a one-off Pressable + `useFocusRing`.
17
+ */
18
+ export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: FocusRingPressableProps) {
19
+ const { focusVisible, focusProps } = useFocusRing();
20
+ return (
21
+ <Pressable
22
+ ref={ref}
23
+ {...rest}
24
+ onFocus={composeHandler(onFocus ?? undefined, focusProps.onFocus)}
25
+ onBlur={composeHandler(onBlur ?? undefined, focusProps.onBlur)}
26
+ style={(state) =>
27
+ [
28
+ typeof style === "function" ? style(state) : style,
29
+ focusVisible ? { boxShadow: FOCUS_RING } : null,
30
+ ] as StyleProp<ViewStyle>
31
+ }
32
+ />
33
+ );
34
+ }
@@ -1,9 +1,9 @@
1
1
  import { FormField, FormFieldProps } from "./form_field";
2
2
  import { Picker, PickerProps } from "./picker";
3
3
 
4
- /** `FormField` wrapping a `Picker` — one labeled select. */
5
- export function FormPicker<T extends string, MULTI extends boolean = false>(
6
- props: PickerProps<T, MULTI> & FormFieldProps,
4
+ /** `FormField` wrapping a `Picker` — one labeled native select. */
5
+ export function FormPicker<T extends string>(
6
+ props: PickerProps<T> & FormFieldProps,
7
7
  ) {
8
8
  const { label, description, optional, optionalLabel, error, ...pickerProps } = props;
9
9
 
@@ -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
  }
@@ -75,8 +75,8 @@ export function IconButton(props: IconButtonProps) {
75
75
 
76
76
  return (
77
77
  <PressableHighlight
78
- ref={ref}
79
- testID={testID}
78
+ focusRing
79
+ ref={ref} testID={testID}
80
80
  tooltip={tooltip}
81
81
  tooltipSide={tooltipSide}
82
82
  accessibilityRole="button"
@@ -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";
@@ -76,9 +77,8 @@ export function ImageGallery({
76
77
  // The rotate controls sit OUTSIDE the zoom Pressable (an absolute sibling on
77
78
  // top) so pressing them turns the image instead of opening the modal.
78
79
  <View style={{ width: "100%" }}>
79
- <Pressable
80
+ <FocusRingPressable accessibilityRole="button"
80
81
  onPress={() => setZoomIdx(idx)}
81
- accessibilityRole="button"
82
82
  accessibilityLabel="Zoom image"
83
83
  style={{
84
84
  width: "100%",
@@ -92,25 +92,23 @@ export function ImageGallery({
92
92
  }}
93
93
  >
94
94
  <RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
95
- </Pressable>
95
+ </FocusRingPressable>
96
96
  {rotatable ? (
97
97
  <View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
98
- <Pressable
98
+ <FocusRingPressable accessibilityRole="button"
99
99
  onPress={() => rotation.rotate(main.id, -1)}
100
- accessibilityRole="button"
101
100
  accessibilityLabel="Rotate left"
102
101
  style={rotateButtonStyle}
103
102
  >
104
103
  <Icon name="rotate-ccw" size={16} color={colors.white} />
105
- </Pressable>
106
- <Pressable
104
+ </FocusRingPressable>
105
+ <FocusRingPressable accessibilityRole="button"
107
106
  onPress={() => rotation.rotate(main.id, 1)}
108
- accessibilityRole="button"
109
107
  accessibilityLabel="Rotate right"
110
108
  style={rotateButtonStyle}
111
109
  >
112
110
  <Icon name="rotate-cw" size={16} color={colors.white} />
113
- </Pressable>
111
+ </FocusRingPressable>
114
112
  </View>
115
113
  ) : null}
116
114
  </View>
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
  />