@lotics/ui 5.9.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.
- package/AGENTS.md +127 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +10 -5
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +2 -2
- package/src/image_gallery.tsx +8 -10
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- 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
|
+
});
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
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`, `
|
|
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 (`<
|
|
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`, `
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
package/src/form_picker.tsx
CHANGED
|
@@ -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
|
|
6
|
-
props: PickerProps<T
|
|
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
|
|
package/src/gantt/gantt_view.tsx
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { View, ScrollView,
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
}
|
package/src/icon_button.tsx
CHANGED
package/src/image_gallery.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
<
|
|
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
|
-
</
|
|
95
|
+
</FocusRingPressable>
|
|
96
96
|
{rotatable ? (
|
|
97
97
|
<View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
|
|
98
|
-
<
|
|
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
|
-
</
|
|
106
|
-
<
|
|
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
|
-
</
|
|
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
|
-
/*
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
:focus
|
|
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
|
-
|
|
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
|
/>
|