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