@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
package/src/select.tsx
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
5
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
8
|
+
import { OptionList } from "./option_list";
|
|
9
|
+
import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
10
|
+
|
|
11
|
+
export interface SelectProps<T extends string = string, MULTI extends boolean = false> {
|
|
12
|
+
options?: (PickerOption<T> | undefined | false)[];
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
/** Accessible name for the control. Required in spirit whenever there's no
|
|
15
|
+
* visible label beside it — the selected text describes the value, not the control. */
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
/** How each OPTION renders in the menu (a colour-dot badge, a member chip).
|
|
18
|
+
* Also the default for the trigger's selection display when `renderSelected`
|
|
19
|
+
* is omitted. */
|
|
20
|
+
renderOptionContent?: (option: PickerOption<T>) => ReactNode;
|
|
21
|
+
/** How each SELECTED item renders in the anchor — a render FUNCTION, not a preset.
|
|
22
|
+
* Use `controls.remove` to detach it in place: render `<Chip onDismiss={remove}>`
|
|
23
|
+
* for a removable chip box, an `OptionBadge`, or any custom display. Omit it for
|
|
24
|
+
* a comma summary (single: the one value, `remove` clears it). The single seam
|
|
25
|
+
* for "how the selection looks" — there's no `display` mode. */
|
|
26
|
+
renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
|
|
27
|
+
/** A single-line subtitle under each option's label in the menu. */
|
|
28
|
+
getOptionDescription?: (option: PickerOption<T>) => string | undefined;
|
|
29
|
+
/** Show a search field in the menu to filter the options. */
|
|
30
|
+
searchable?: boolean;
|
|
31
|
+
/** Offer a "create" row when the query matches no option — picking it adds the
|
|
32
|
+
* typed value. Implies `searchable` (there has to be a query to create from). */
|
|
33
|
+
allowCustom?: boolean;
|
|
34
|
+
/** Label for the create row (default: `Add "<query>"`). */
|
|
35
|
+
customOptionLabel?: (query: string) => string | null;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
testID?: string;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
autoFocus?: boolean;
|
|
40
|
+
enableSelectAll?: boolean;
|
|
41
|
+
multi?: MULTI;
|
|
42
|
+
includeEmptyOption?: boolean;
|
|
43
|
+
value?: PickerValue<T, MULTI> | null;
|
|
44
|
+
onValueChange?: PickerOnValueChange<T, MULTI>;
|
|
45
|
+
onClose?: PickerOnClose<T, MULTI>;
|
|
46
|
+
/** Label for the "select all" link in multi-select mode. Default: "Select all" */
|
|
47
|
+
selectAllLabel?: string;
|
|
48
|
+
/** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
|
|
49
|
+
deselectAllLabel?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Choose from a known set with RICH options. The trigger shows the current
|
|
54
|
+
* selection — a comma summary by default, or your own `renderSelected` (render
|
|
55
|
+
* removable chips via `controls.remove` for a chip box, an `OptionBadge`, any
|
|
56
|
+
* custom look) — and opens an `OptionList` popover. Single or multi, with an
|
|
57
|
+
* optional in-menu search (`searchable`), create-new (`allowCustom`), select-all,
|
|
58
|
+
* and a leading clear row (`includeEmptyOption`). For a plain label-only single
|
|
59
|
+
* select use the native `Picker`; to SEARCH a large/remote set with the input AS
|
|
60
|
+
* the control (type-in-place, free text reflected in the field) use `Combobox`.
|
|
61
|
+
*/
|
|
62
|
+
export function Select<T extends string, MULTI extends boolean = false>(props: SelectProps<T, MULTI>) {
|
|
63
|
+
const {
|
|
64
|
+
testID,
|
|
65
|
+
options = [],
|
|
66
|
+
value,
|
|
67
|
+
placeholder,
|
|
68
|
+
accessibilityLabel,
|
|
69
|
+
multi = false as MULTI,
|
|
70
|
+
renderOptionContent,
|
|
71
|
+
renderSelected,
|
|
72
|
+
getOptionDescription,
|
|
73
|
+
searchable = false,
|
|
74
|
+
allowCustom = false,
|
|
75
|
+
customOptionLabel,
|
|
76
|
+
includeEmptyOption,
|
|
77
|
+
style,
|
|
78
|
+
disabled = false,
|
|
79
|
+
autoFocus = false,
|
|
80
|
+
enableSelectAll = false,
|
|
81
|
+
onValueChange,
|
|
82
|
+
onClose,
|
|
83
|
+
selectAllLabel,
|
|
84
|
+
deselectAllLabel,
|
|
85
|
+
} = props;
|
|
86
|
+
|
|
87
|
+
const [open, setOpen] = useState(autoFocus);
|
|
88
|
+
const handleToggle = useCallback(() => {
|
|
89
|
+
if (!disabled) setOpen((prev) => !prev);
|
|
90
|
+
}, [disabled]);
|
|
91
|
+
const selectedItems = useSelectedItems(multi, value, options);
|
|
92
|
+
const handleClose = useCallback(() => setOpen(false), []);
|
|
93
|
+
|
|
94
|
+
const multiValue = useMemo(() => (multi && Array.isArray(value) ? value : []) as T[], [multi, value]);
|
|
95
|
+
|
|
96
|
+
// Detach a selected item — multi removes it from the array, single clears.
|
|
97
|
+
const handleRemove = useCallback(
|
|
98
|
+
(v: T) => {
|
|
99
|
+
if (!onValueChange) return;
|
|
100
|
+
if (multi) (onValueChange as (x: T[]) => void)(multiValue.filter((x) => x !== v));
|
|
101
|
+
else (onValueChange as (x: T) => void)("" as T);
|
|
102
|
+
},
|
|
103
|
+
[multi, multiValue, onValueChange],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// The create row was picked — add the typed value (multi appends; single sets + closes).
|
|
107
|
+
const handleCustomCommit = useCallback(
|
|
108
|
+
(raw: string) => {
|
|
109
|
+
const created = raw.trim() as T;
|
|
110
|
+
if (!created || !onValueChange) return;
|
|
111
|
+
if (multi) {
|
|
112
|
+
if (!multiValue.includes(created)) (onValueChange as (x: T[]) => void)([...multiValue, created]);
|
|
113
|
+
} else {
|
|
114
|
+
(onValueChange as (x: T) => void)(created);
|
|
115
|
+
setOpen(false);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
[multi, multiValue, onValueChange],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
123
|
+
<PopoverTrigger>
|
|
124
|
+
<SelectTrigger
|
|
125
|
+
testID={testID}
|
|
126
|
+
open={open}
|
|
127
|
+
style={style}
|
|
128
|
+
onPress={handleToggle}
|
|
129
|
+
renderOptionContent={renderOptionContent}
|
|
130
|
+
renderSelected={renderSelected}
|
|
131
|
+
onRemove={handleRemove}
|
|
132
|
+
selectedItems={selectedItems}
|
|
133
|
+
placeholder={placeholder}
|
|
134
|
+
accessibilityLabel={accessibilityLabel}
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
/>
|
|
137
|
+
</PopoverTrigger>
|
|
138
|
+
{/* disableBodyScroll: OptionList owns its own row scroll + keeps the search
|
|
139
|
+
field/footer OUTSIDE it. The default body ScrollView would be a redundant
|
|
140
|
+
second scroll that also clips the autofocused search field's outset ring. */}
|
|
141
|
+
<PopoverContent disableBodyScroll>
|
|
142
|
+
<OptionList<T, MULTI>
|
|
143
|
+
search={{ mode: searchable || allowCustom ? "internal" : "none" }}
|
|
144
|
+
options={options}
|
|
145
|
+
multi={multi}
|
|
146
|
+
value={value}
|
|
147
|
+
onValueChange={onValueChange}
|
|
148
|
+
onClose={onClose}
|
|
149
|
+
onRequestClose={handleClose}
|
|
150
|
+
allowCustom={allowCustom}
|
|
151
|
+
customOptionLabel={customOptionLabel}
|
|
152
|
+
onCustomCommit={handleCustomCommit}
|
|
153
|
+
renderOptionContent={renderOptionContent}
|
|
154
|
+
getOptionDescription={getOptionDescription}
|
|
155
|
+
enableSelectAll={enableSelectAll}
|
|
156
|
+
includeEmptyOption={includeEmptyOption}
|
|
157
|
+
accessibilityLabel={accessibilityLabel}
|
|
158
|
+
selectAllLabel={selectAllLabel}
|
|
159
|
+
deselectAllLabel={deselectAllLabel}
|
|
160
|
+
/>
|
|
161
|
+
</PopoverContent>
|
|
162
|
+
</Popover>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function useSelectedItems<T extends string>(
|
|
167
|
+
multi: boolean,
|
|
168
|
+
value: T | T[] | undefined | null,
|
|
169
|
+
options: (PickerOption<T> | undefined | false)[],
|
|
170
|
+
): PickerOption<T>[] {
|
|
171
|
+
return useMemo(() => {
|
|
172
|
+
const optionsMap = new Map(
|
|
173
|
+
options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
|
|
174
|
+
);
|
|
175
|
+
// A value not in `options` (e.g. an `allowCustom` creation) still shows —
|
|
176
|
+
// fall back to a {value, label} so it renders.
|
|
177
|
+
const resolve = (val: T): PickerOption<T> => optionsMap.get(val) ?? { value: val, label: val };
|
|
178
|
+
if (multi) {
|
|
179
|
+
const multiValue = Array.isArray(value) ? value : [];
|
|
180
|
+
return multiValue.map(resolve);
|
|
181
|
+
}
|
|
182
|
+
const singleValue = Array.isArray(value) ? value[0] : value;
|
|
183
|
+
if (singleValue !== undefined && singleValue !== null && singleValue !== "") {
|
|
184
|
+
return [resolve(singleValue)];
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
187
|
+
}, [multi, value, options]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function SelectTrigger<T extends string>({
|
|
191
|
+
ref,
|
|
192
|
+
testID,
|
|
193
|
+
open,
|
|
194
|
+
style,
|
|
195
|
+
onPress,
|
|
196
|
+
renderOptionContent,
|
|
197
|
+
renderSelected,
|
|
198
|
+
onRemove,
|
|
199
|
+
selectedItems,
|
|
200
|
+
placeholder,
|
|
201
|
+
accessibilityLabel,
|
|
202
|
+
disabled = false,
|
|
203
|
+
}: {
|
|
204
|
+
ref?: Ref<View>;
|
|
205
|
+
testID?: string;
|
|
206
|
+
open: boolean;
|
|
207
|
+
style?: StyleProp<ViewStyle>;
|
|
208
|
+
onPress: () => void;
|
|
209
|
+
renderOptionContent?: (option: PickerOption<T>) => ReactNode;
|
|
210
|
+
renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
|
|
211
|
+
onRemove?: (value: T) => void;
|
|
212
|
+
selectedItems: PickerOption<T>[];
|
|
213
|
+
placeholder?: string;
|
|
214
|
+
accessibilityLabel?: string;
|
|
215
|
+
disabled?: boolean;
|
|
216
|
+
}) {
|
|
217
|
+
const hasSelection = selectedItems.length > 0;
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
// PressableHighlight owns the hover wash + keyboard focus ring (layered AROUND
|
|
221
|
+
// the caller's `style`, so a borderless override — `borderColor:"transparent"`
|
|
222
|
+
// for an in-cell Select — keeps the wash and reads like its inline neighbours).
|
|
223
|
+
// The hover-border sits BEFORE `style` so a transparent border suppresses it
|
|
224
|
+
// (wash only), while a bordered Select darkens its edge.
|
|
225
|
+
<PressableHighlight
|
|
226
|
+
focusRing
|
|
227
|
+
ref={ref}
|
|
228
|
+
testID={testID}
|
|
229
|
+
// Without a role this Pressable renders as an unfocusable <div> on web — the
|
|
230
|
+
// trigger drops out of the tab order and Enter/Space can't open it. `button`
|
|
231
|
+
// makes it tab-focusable and maps keyboard activation to onPress; `expanded`
|
|
232
|
+
// announces open/closed to assistive tech.
|
|
233
|
+
accessibilityRole="button"
|
|
234
|
+
accessibilityLabel={accessibilityLabel}
|
|
235
|
+
accessibilityState={{ expanded: open, disabled }}
|
|
236
|
+
style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, state.hovered && !disabled && { borderColor: HOVER_BORDER }, style]}
|
|
237
|
+
onPress={!disabled ? onPress : undefined}
|
|
238
|
+
disabled={disabled}
|
|
239
|
+
>
|
|
240
|
+
{hasSelection ? (
|
|
241
|
+
renderSelected ? (
|
|
242
|
+
<View style={styles.selectionRow}>
|
|
243
|
+
{selectedItems.map((item) => (
|
|
244
|
+
<View key={item.value}>
|
|
245
|
+
{renderSelected(item, {
|
|
246
|
+
// The chip's ✕ press reaches here via IconButton's forwarded event;
|
|
247
|
+
// stop it so detaching doesn't ALSO open the menu.
|
|
248
|
+
remove: (e?: GestureResponderEvent) => {
|
|
249
|
+
e?.stopPropagation?.();
|
|
250
|
+
onRemove?.(item.value);
|
|
251
|
+
},
|
|
252
|
+
})}
|
|
253
|
+
</View>
|
|
254
|
+
))}
|
|
255
|
+
</View>
|
|
256
|
+
) : renderOptionContent ? (
|
|
257
|
+
<View style={styles.selectionRow}>
|
|
258
|
+
{selectedItems.map((item) => (
|
|
259
|
+
<View key={item.value}>{renderOptionContent(item)}</View>
|
|
260
|
+
))}
|
|
261
|
+
</View>
|
|
262
|
+
) : (
|
|
263
|
+
<Text userSelect="none">{selectedItems.map((s) => s.label).join(", ")}</Text>
|
|
264
|
+
)
|
|
265
|
+
) : (
|
|
266
|
+
<Text color="zinc-500" userSelect="none">
|
|
267
|
+
{placeholder}
|
|
268
|
+
</Text>
|
|
269
|
+
)}
|
|
270
|
+
</PressableHighlight>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const styles = StyleSheet.create({
|
|
275
|
+
pressable: {
|
|
276
|
+
flexDirection: "row",
|
|
277
|
+
alignItems: "center",
|
|
278
|
+
paddingHorizontal: 6,
|
|
279
|
+
paddingVertical: 4,
|
|
280
|
+
borderWidth: 1,
|
|
281
|
+
borderColor: colors.border,
|
|
282
|
+
borderRadius: CONTROL_RADIUS,
|
|
283
|
+
minHeight: 40,
|
|
284
|
+
},
|
|
285
|
+
opened: {
|
|
286
|
+
// Mouse-opened, so the trigger never takes keyboard focus — wear the same 2px
|
|
287
|
+
// zinc ring a focused input gets, over the unchanged 1px border (not a lone 1px edge).
|
|
288
|
+
boxShadow: FOCUS_RING,
|
|
289
|
+
},
|
|
290
|
+
disabled: {
|
|
291
|
+
opacity: 0.5,
|
|
292
|
+
},
|
|
293
|
+
selectionRow: {
|
|
294
|
+
flexDirection: "row",
|
|
295
|
+
gap: 4,
|
|
296
|
+
flexWrap: "wrap",
|
|
297
|
+
flex: 1,
|
|
298
|
+
},
|
|
299
|
+
});
|
package/src/sort_header.tsx
CHANGED
|
@@ -85,7 +85,7 @@ export function SortHeader(props: SortHeaderProps) {
|
|
|
85
85
|
|
|
86
86
|
return (
|
|
87
87
|
<PressableHighlight
|
|
88
|
-
accessibilityRole="button"
|
|
88
|
+
focusRing accessibilityRole="button"
|
|
89
89
|
accessibilityLabel={`${sortByLabel}${dirText}`}
|
|
90
90
|
onPress={() => onSort(sortKey)}
|
|
91
91
|
style={[styles.header, align === "right" ? styles.right : null, style]}
|
package/src/sources.tsx
CHANGED
|
@@ -51,7 +51,7 @@ export function Sources(props: SourcesProps) {
|
|
|
51
51
|
<View style={styles.row}>
|
|
52
52
|
{props.sources.map((s) =>
|
|
53
53
|
props.onOpen ? (
|
|
54
|
-
<PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
|
|
54
|
+
<PressableHighlight focusRing key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
|
|
55
55
|
<SourceChip source={s} openable />
|
|
56
56
|
</PressableHighlight>
|
|
57
57
|
) : (
|
package/src/spreadsheet_view.tsx
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { View, StyleSheet
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Button } from "./button";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
7
|
+
|
|
8
|
+
function SheetTab(props: { selected: boolean; onPress: () => void; name: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<FocusRingPressable
|
|
11
|
+
accessibilityRole="tab"
|
|
12
|
+
accessibilityState={{ selected: props.selected }}
|
|
13
|
+
onPress={props.onPress}
|
|
14
|
+
style={[styles.tab, props.selected ? styles.tabActive : null]}
|
|
15
|
+
>
|
|
16
|
+
<Text size="xs" color={props.selected ? "default" : "muted"} numberOfLines={1}>
|
|
17
|
+
{props.name}
|
|
18
|
+
</Text>
|
|
19
|
+
</FocusRingPressable>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
6
22
|
import { isExcelMimeType } from "./mime";
|
|
7
23
|
import { downloadFileFromUrl } from "./download";
|
|
8
24
|
import { type PreviewLabels } from "./file_preview_types";
|
|
@@ -48,8 +64,12 @@ async function loadXlsx(): Promise<XlsxApi> {
|
|
|
48
64
|
};
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
async function fetchArrayBuffer(
|
|
52
|
-
|
|
67
|
+
async function fetchArrayBuffer(
|
|
68
|
+
url: string,
|
|
69
|
+
signal: AbortSignal,
|
|
70
|
+
credentials: RequestCredentials | undefined,
|
|
71
|
+
): Promise<ArrayBuffer> {
|
|
72
|
+
const response = await fetch(url, { signal, credentials });
|
|
53
73
|
if (!response.ok) throw new Error(`Spreadsheet fetch failed (${response.status})`);
|
|
54
74
|
return response.arrayBuffer();
|
|
55
75
|
}
|
|
@@ -58,6 +78,9 @@ interface SpreadsheetViewProps {
|
|
|
58
78
|
file: DisplayFile;
|
|
59
79
|
labels: PreviewLabels;
|
|
60
80
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
81
|
+
/** Forwarded to the byte fetch — `"include"` for the host's auth-gated proxy
|
|
82
|
+
* URLs, omitted for an app's presigned URLs. See `FilePreviewProps`. */
|
|
83
|
+
credentials?: RequestCredentials;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
interface ParseState {
|
|
@@ -72,7 +95,7 @@ interface ParseState {
|
|
|
72
95
|
* transparent scroll layer (sized to the sheet) sits over a viewport-sized
|
|
73
96
|
* canvas that redraws on scroll. No editing, ribbon, or selection.
|
|
74
97
|
*/
|
|
75
|
-
export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps) {
|
|
98
|
+
export function SpreadsheetView({ file, labels, onError, credentials }: SpreadsheetViewProps) {
|
|
76
99
|
const apiRef = useRef<XlsxApi | null>(null);
|
|
77
100
|
const [state, setState] = useState<ParseState>({ loading: true, workbook: undefined, error: undefined });
|
|
78
101
|
const [sheetIndex, setSheetIndex] = useState(0);
|
|
@@ -91,8 +114,8 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
|
|
|
91
114
|
const run = async () => {
|
|
92
115
|
const api = apiRef.current ?? (apiRef.current = await loadXlsx());
|
|
93
116
|
const parsed = isExcelMimeType(file.mimeType)
|
|
94
|
-
? api.parseExcelBuffer(await fetchArrayBuffer(file.url, controller.signal))
|
|
95
|
-
: await api.parseCsvFile(file.url, controller.signal);
|
|
117
|
+
? api.parseExcelBuffer(await fetchArrayBuffer(file.url, controller.signal, credentials))
|
|
118
|
+
: await api.parseCsvFile(file.url, controller.signal, credentials);
|
|
96
119
|
if (controller.signal.aborted) return;
|
|
97
120
|
const wb = api.loadWorkbookFromSnapshot(parsed);
|
|
98
121
|
api.recomputeIfStale(wb);
|
|
@@ -112,7 +135,15 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
|
|
|
112
135
|
});
|
|
113
136
|
});
|
|
114
137
|
return () => controller.abort();
|
|
115
|
-
}, [
|
|
138
|
+
}, [
|
|
139
|
+
file.url,
|
|
140
|
+
file.id,
|
|
141
|
+
file.mimeType,
|
|
142
|
+
labels.loadFailed,
|
|
143
|
+
labels.passwordProtected,
|
|
144
|
+
onError,
|
|
145
|
+
credentials,
|
|
146
|
+
]);
|
|
116
147
|
|
|
117
148
|
// Track the viewport size.
|
|
118
149
|
useEffect(() => {
|
|
@@ -242,7 +273,7 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
|
|
|
242
273
|
icon="download"
|
|
243
274
|
title={labels.download}
|
|
244
275
|
color="secondary"
|
|
245
|
-
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
276
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
|
|
246
277
|
/>
|
|
247
278
|
</View>
|
|
248
279
|
</View>
|
|
@@ -264,21 +295,16 @@ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps)
|
|
|
264
295
|
{sheets.length > 1 ? (
|
|
265
296
|
<View style={styles.tabs}>
|
|
266
297
|
{sheets.map((s, i) => (
|
|
267
|
-
<
|
|
298
|
+
<SheetTab
|
|
268
299
|
key={i}
|
|
269
|
-
|
|
270
|
-
|
|
300
|
+
selected={i === sheetIndex}
|
|
301
|
+
name={s.name}
|
|
271
302
|
onPress={() => {
|
|
272
303
|
setSheetIndex(i);
|
|
273
304
|
setScroll({ x: 0, y: 0 });
|
|
274
305
|
if (scrollRef.current) scrollRef.current.scrollTo({ left: 0, top: 0 });
|
|
275
306
|
}}
|
|
276
|
-
|
|
277
|
-
>
|
|
278
|
-
<Text size="xs" color={i === sheetIndex ? "default" : "muted"} numberOfLines={1}>
|
|
279
|
-
{s.name}
|
|
280
|
-
</Text>
|
|
281
|
-
</Pressable>
|
|
307
|
+
/>
|
|
282
308
|
))}
|
|
283
309
|
</View>
|
|
284
310
|
) : null}
|
package/src/status_grid.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { colors, solid, tint, type ColorName } from "./colors";
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
5
6
|
import { Text } from "./text";
|
|
6
7
|
|
|
7
8
|
export interface StatusGridState {
|
|
@@ -84,7 +85,7 @@ function Cell(props: CellProps) {
|
|
|
84
85
|
};
|
|
85
86
|
if (!onPress) return <View style={[styles.cell, base]} />;
|
|
86
87
|
return (
|
|
87
|
-
<
|
|
88
|
+
<FocusRingPressable
|
|
88
89
|
accessibilityRole="button"
|
|
89
90
|
accessibilityLabel={label}
|
|
90
91
|
onPress={onPress}
|
|
@@ -130,7 +131,7 @@ export function StatusLegend(props: StatusLegendProps) {
|
|
|
130
131
|
);
|
|
131
132
|
return onSelect ? (
|
|
132
133
|
<PressableHighlight
|
|
133
|
-
key={state.key}
|
|
134
|
+
focusRing key={state.key}
|
|
134
135
|
accessibilityRole="button"
|
|
135
136
|
accessibilityState={{ selected }}
|
|
136
137
|
accessibilityLabel={`${state.label}: ${count}`}
|
package/src/stepper.tsx
CHANGED
|
@@ -141,7 +141,7 @@ export function Step(props: StepProps) {
|
|
|
141
141
|
</View>
|
|
142
142
|
<View style={[styles.vContent, !_last ? styles.vGap : null]}>
|
|
143
143
|
{onPress ? (
|
|
144
|
-
<PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
|
|
144
|
+
<PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
|
|
145
145
|
{body}
|
|
146
146
|
</PressableHighlight>
|
|
147
147
|
) : (
|
package/src/switch.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { Animated, StyleSheet, Pressable, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
|
+
import { FOCUS_RING } from "./control_surface";
|
|
6
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
5
7
|
export interface SwitchProps {
|
|
6
8
|
testID?: string;
|
|
7
9
|
/**
|
|
@@ -17,6 +19,7 @@ export interface SwitchProps {
|
|
|
17
19
|
export function Switch(props: SwitchProps) {
|
|
18
20
|
const { testID, accessibilityLabel, value, disabled, onChange } = props;
|
|
19
21
|
const checked = useRef(new Animated.Value(value ? 1 : 0)).current;
|
|
22
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
20
23
|
|
|
21
24
|
const handlePress = useCallback(() => {
|
|
22
25
|
if (onChange !== undefined) {
|
|
@@ -72,7 +75,8 @@ export function Switch(props: SwitchProps) {
|
|
|
72
75
|
<Pressable
|
|
73
76
|
testID={testID}
|
|
74
77
|
onPress={handlePress}
|
|
75
|
-
|
|
78
|
+
{...focusProps}
|
|
79
|
+
style={[styles.root, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
76
80
|
disabled={disabled}
|
|
77
81
|
accessibilityRole="switch"
|
|
78
82
|
accessibilityLabel={accessibilityLabel}
|
package/src/switch_button.tsx
CHANGED
package/src/switcher.tsx
CHANGED
|
@@ -69,7 +69,7 @@ export function Switcher(props: SwitcherProps) {
|
|
|
69
69
|
return (
|
|
70
70
|
<Popover open={open} onOpenChange={setOpen} side={side} align={align}>
|
|
71
71
|
<PopoverTrigger>
|
|
72
|
-
<PressableHighlight style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
|
|
72
|
+
<PressableHighlight focusRing style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
|
|
73
73
|
<Text size="sm" weight="medium" color="zinc-700" numberOfLines={1}>
|
|
74
74
|
{label}
|
|
75
75
|
</Text>
|
package/src/table.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import { Text } from "./text";
|
|
|
12
12
|
import { Divider } from "./divider";
|
|
13
13
|
import { PressableRow } from "./pressable_row";
|
|
14
14
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
15
|
+
import { FOCUS_RING } from "./control_surface";
|
|
16
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* One column of a register — its width/flex/align/label/sortability defined ONCE,
|
|
@@ -132,6 +134,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
132
134
|
const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[]).map(
|
|
133
135
|
(cell, i) => cloneElement(cell, { _column: ctx.columns[i] }),
|
|
134
136
|
);
|
|
137
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
135
138
|
|
|
136
139
|
return (
|
|
137
140
|
<PressableRow onPress={onPress ?? noop} selected={selected} style={styles.row}>
|
|
@@ -140,7 +143,8 @@ export function TableRow(props: TableRowProps) {
|
|
|
140
143
|
accessibilityRole={onPress ? "button" : undefined}
|
|
141
144
|
accessibilityLabel={accessibilityLabel}
|
|
142
145
|
onPress={onPress}
|
|
143
|
-
|
|
146
|
+
{...focusProps}
|
|
147
|
+
style={[styles.door, { minHeight }, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
144
148
|
>
|
|
145
149
|
{cells}
|
|
146
150
|
</Pressable>
|
package/src/tabs.tsx
CHANGED
|
@@ -110,8 +110,8 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
110
110
|
|
|
111
111
|
return (
|
|
112
112
|
<PressableHighlight
|
|
113
|
-
|
|
114
|
-
style={{
|
|
113
|
+
focusRing
|
|
114
|
+
ref={ref} style={{
|
|
115
115
|
borderBottomWidth: 3,
|
|
116
116
|
borderBottomColor: selected ? colors.zinc["700"] : "transparent",
|
|
117
117
|
backgroundColor: "transparent",
|
package/src/text_input_field.tsx
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
View,
|
|
6
6
|
} from "react-native";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
8
|
+
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
9
|
+
import { useFocusRing, composeHandler } from "./use_focus_ring";
|
|
10
|
+
import { useHover } from "./use_hover";
|
|
9
11
|
import { Ref, useCallback } from "react";
|
|
10
12
|
import { Icon, IconName } from "./icon";
|
|
11
13
|
import { IconButton } from "./icon_button";
|
|
@@ -68,6 +70,12 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
68
70
|
const { small } = useScreenSize();
|
|
69
71
|
const lineHeight = getInputLineHeight(small);
|
|
70
72
|
const binding = useFormField();
|
|
73
|
+
// Text-like input: ring on ANY focus (browsers treat typing-capable fields as
|
|
74
|
+
// focus-visible even on pointer focus), so a clicked field shows its ring.
|
|
75
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
76
|
+
// Premium hover affordance: the resting border darkens, signalling interactivity.
|
|
77
|
+
// Tracked on the wrapping View (RN-Web forwards mouse events there reliably).
|
|
78
|
+
const { hovered, hoverProps } = useHover();
|
|
71
79
|
|
|
72
80
|
// Describedby chains description and error so both are read. We join them
|
|
73
81
|
// explicitly here because React Native Web does not flatten array attrs.
|
|
@@ -108,7 +116,7 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
108
116
|
);
|
|
109
117
|
|
|
110
118
|
return (
|
|
111
|
-
<View>
|
|
119
|
+
<View {...(hoverProps as object)}>
|
|
112
120
|
{icon && (
|
|
113
121
|
<View style={styles.icon}>
|
|
114
122
|
<Icon name={icon} size={20} color={colors.zinc[400]} />
|
|
@@ -120,20 +128,23 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
120
128
|
ref={mergedRef}
|
|
121
129
|
value={value}
|
|
122
130
|
onChangeText={handleChangeText}
|
|
123
|
-
onFocus={inputProps.onFocus}
|
|
124
|
-
onBlur={inputProps.onBlur}
|
|
131
|
+
onFocus={composeHandler(inputProps.onFocus, focusProps.onFocus)}
|
|
132
|
+
onBlur={composeHandler(inputProps.onBlur, focusProps.onBlur)}
|
|
125
133
|
nativeID={binding?.inputId ?? inputProps.nativeID}
|
|
126
134
|
accessibilityLabelledBy={binding?.labelId ?? inputProps.accessibilityLabelledBy}
|
|
127
135
|
aria-describedby={describedBy}
|
|
128
136
|
aria-invalid={binding?.invalid || undefined}
|
|
129
137
|
style={[
|
|
130
138
|
styles.input,
|
|
139
|
+
CONTROL_TRANSITION,
|
|
131
140
|
getInputTextStyle(),
|
|
132
141
|
height ? { height } : undefined,
|
|
133
142
|
autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
|
|
134
143
|
!editable && styles.disabled,
|
|
135
144
|
icon && styles.withIcon,
|
|
145
|
+
hovered && editable && { borderColor: HOVER_BORDER },
|
|
136
146
|
style,
|
|
147
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
137
148
|
]}
|
|
138
149
|
multiline={(numberOfLines && numberOfLines > 1) || multiline}
|
|
139
150
|
focusable={editable}
|
package/src/time_picker.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { KeyboardEvent } from "react";
|
|
2
2
|
import { colors } from "@lotics/ui/colors";
|
|
3
3
|
import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
|
|
4
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
4
|
+
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
|
|
5
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
6
|
+
import { useHover } from "./use_hover";
|
|
5
7
|
export interface TimePickerProps {
|
|
6
8
|
value?: string;
|
|
7
9
|
onValueChange: (value: string) => void;
|
|
@@ -15,6 +17,8 @@ export interface TimePickerProps {
|
|
|
15
17
|
|
|
16
18
|
export function TimePicker(props: TimePickerProps) {
|
|
17
19
|
const { value, onValueChange, onBlur, onKeyDown, autoFocus, disabled, accessibilityLabel } = props;
|
|
20
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
21
|
+
const { hovered, hoverProps } = useHover();
|
|
18
22
|
|
|
19
23
|
return (
|
|
20
24
|
<input
|
|
@@ -23,7 +27,9 @@ export function TimePicker(props: TimePickerProps) {
|
|
|
23
27
|
onValueChange(e.target.value);
|
|
24
28
|
}}
|
|
25
29
|
type="time"
|
|
26
|
-
|
|
30
|
+
{...hoverProps}
|
|
31
|
+
onFocus={focusProps.onFocus}
|
|
32
|
+
onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
|
|
27
33
|
onKeyDown={onKeyDown}
|
|
28
34
|
autoFocus={autoFocus}
|
|
29
35
|
disabled={disabled}
|
|
@@ -35,14 +41,15 @@ export function TimePicker(props: TimePickerProps) {
|
|
|
35
41
|
borderRadius: CONTROL_RADIUS,
|
|
36
42
|
borderWidth: 1,
|
|
37
43
|
borderStyle: "solid",
|
|
38
|
-
borderColor: colors.border,
|
|
44
|
+
borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
|
|
39
45
|
backgroundColor: colors.background,
|
|
40
46
|
fontFamily: fontFamilyRegular,
|
|
41
47
|
...inputTextStyleWeb,
|
|
42
48
|
letterSpacing: -0.4,
|
|
43
|
-
boxShadow: "none",
|
|
49
|
+
boxShadow: focusVisible ? FOCUS_RING : "none",
|
|
44
50
|
outline: "none",
|
|
45
51
|
boxSizing: "border-box",
|
|
52
|
+
transition: "border-color 0.12s, box-shadow 0.12s",
|
|
46
53
|
}}
|
|
47
54
|
/>
|
|
48
55
|
);
|