@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/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
|
@@ -21,6 +21,10 @@ interface IconButtonBase {
|
|
|
21
21
|
* hitSlop; `lg` is already 40. */
|
|
22
22
|
size?: "lg" | "md" | "sm";
|
|
23
23
|
iconColor?: string;
|
|
24
|
+
/** Floating variant: a white fill + hairline border + soft shadow, so the
|
|
25
|
+
* button reads as a distinct, tappable target sitting ON TOP of imagery or a
|
|
26
|
+
* colored surface (a tile's remove ✕, an overlay's retry). */
|
|
27
|
+
elevated?: boolean;
|
|
24
28
|
tooltipSide?: TooltipSide;
|
|
25
29
|
onPress?: (event: GestureResponderEvent) => void;
|
|
26
30
|
style?: StyleProp<ViewStyle>;
|
|
@@ -48,6 +52,7 @@ export function IconButton(props: IconButtonProps) {
|
|
|
48
52
|
onPress,
|
|
49
53
|
color = "none",
|
|
50
54
|
size = "md",
|
|
55
|
+
elevated,
|
|
51
56
|
tooltip,
|
|
52
57
|
tooltipSide,
|
|
53
58
|
accessibilityLabel,
|
|
@@ -70,13 +75,13 @@ export function IconButton(props: IconButtonProps) {
|
|
|
70
75
|
|
|
71
76
|
return (
|
|
72
77
|
<PressableHighlight
|
|
73
|
-
|
|
74
|
-
testID={testID}
|
|
78
|
+
focusRing
|
|
79
|
+
ref={ref} testID={testID}
|
|
75
80
|
tooltip={tooltip}
|
|
76
81
|
tooltipSide={tooltipSide}
|
|
77
82
|
accessibilityRole="button"
|
|
78
83
|
accessibilityLabel={accessibilityLabel ?? tooltip}
|
|
79
|
-
style={[styles.button, styles[size], styles[color], disabledOrLoading && styles.disabled, style]}
|
|
84
|
+
style={[styles.button, styles[size], styles[color], elevated && styles.elevated, disabledOrLoading && styles.disabled, style]}
|
|
80
85
|
onPress={handlePress}
|
|
81
86
|
disabled={disabledOrLoading}
|
|
82
87
|
// 40px touch target regardless of visual size (28 + 2×6 / 24 + 2×8)
|
|
@@ -128,4 +133,10 @@ const styles = StyleSheet.create({
|
|
|
128
133
|
muted: {},
|
|
129
134
|
"danger-secondary": {},
|
|
130
135
|
white: {},
|
|
136
|
+
elevated: {
|
|
137
|
+
backgroundColor: colors.white,
|
|
138
|
+
borderWidth: 1,
|
|
139
|
+
borderColor: colors.zinc[200],
|
|
140
|
+
...({ boxShadow: "0 1px 3px rgba(0, 0, 0, 0.18)" } as object),
|
|
141
|
+
},
|
|
131
142
|
});
|
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";
|
|
@@ -36,8 +37,6 @@ export interface ImageGalleryProps {
|
|
|
36
37
|
emptyText?: string;
|
|
37
38
|
/** Optional max width. Omit to fill the container. */
|
|
38
39
|
maxWidth?: number;
|
|
39
|
-
/** Hint shown in the full-screen zoom modal. Pass a translated string. */
|
|
40
|
-
captionHint?: string;
|
|
41
40
|
/** "bottom" stacks thumbnails under the main image; "right" puts the main
|
|
42
41
|
* image (~60% of the width) and the thumbnail grid side by side. */
|
|
43
42
|
thumbnailPosition?: "bottom" | "right";
|
|
@@ -55,7 +54,6 @@ export function ImageGallery({
|
|
|
55
54
|
loading = false,
|
|
56
55
|
emptyText = "No images.",
|
|
57
56
|
maxWidth,
|
|
58
|
-
captionHint = "ESC to close · ←/→ to navigate",
|
|
59
57
|
thumbnailPosition = "bottom",
|
|
60
58
|
mainAspectRatio = 4 / 3,
|
|
61
59
|
mainFraction = 0.6,
|
|
@@ -79,9 +77,8 @@ export function ImageGallery({
|
|
|
79
77
|
// The rotate controls sit OUTSIDE the zoom Pressable (an absolute sibling on
|
|
80
78
|
// top) so pressing them turns the image instead of opening the modal.
|
|
81
79
|
<View style={{ width: "100%" }}>
|
|
82
|
-
<
|
|
80
|
+
<FocusRingPressable accessibilityRole="button"
|
|
83
81
|
onPress={() => setZoomIdx(idx)}
|
|
84
|
-
accessibilityRole="button"
|
|
85
82
|
accessibilityLabel="Zoom image"
|
|
86
83
|
style={{
|
|
87
84
|
width: "100%",
|
|
@@ -95,25 +92,23 @@ export function ImageGallery({
|
|
|
95
92
|
}}
|
|
96
93
|
>
|
|
97
94
|
<RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
|
|
98
|
-
</
|
|
95
|
+
</FocusRingPressable>
|
|
99
96
|
{rotatable ? (
|
|
100
97
|
<View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
|
|
101
|
-
<
|
|
98
|
+
<FocusRingPressable accessibilityRole="button"
|
|
102
99
|
onPress={() => rotation.rotate(main.id, -1)}
|
|
103
|
-
accessibilityRole="button"
|
|
104
100
|
accessibilityLabel="Rotate left"
|
|
105
101
|
style={rotateButtonStyle}
|
|
106
102
|
>
|
|
107
103
|
<Icon name="rotate-ccw" size={16} color={colors.white} />
|
|
108
|
-
</
|
|
109
|
-
<
|
|
104
|
+
</FocusRingPressable>
|
|
105
|
+
<FocusRingPressable accessibilityRole="button"
|
|
110
106
|
onPress={() => rotation.rotate(main.id, 1)}
|
|
111
|
-
accessibilityRole="button"
|
|
112
107
|
accessibilityLabel="Rotate right"
|
|
113
108
|
style={rotateButtonStyle}
|
|
114
109
|
>
|
|
115
110
|
<Icon name="rotate-cw" size={16} color={colors.white} />
|
|
116
|
-
</
|
|
111
|
+
</FocusRingPressable>
|
|
117
112
|
</View>
|
|
118
113
|
) : null}
|
|
119
114
|
</View>
|
|
@@ -157,7 +152,6 @@ export function ImageGallery({
|
|
|
157
152
|
files={images}
|
|
158
153
|
activeIndex={zoomIdx}
|
|
159
154
|
onIndexChange={setZoomIdx}
|
|
160
|
-
captionHint={captionHint}
|
|
161
155
|
rotation={rotation}
|
|
162
156
|
rotatable={rotatable}
|
|
163
157
|
/>
|
package/src/index.css
CHANGED
|
@@ -336,18 +336,14 @@ html {
|
|
|
336
336
|
color: var(--color-zinc-900);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
/*
|
|
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
|
/>
|
package/src/inline_edit.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { View, StyleSheet, type GestureResponderEvent } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { ActivityIndicator } from "./activity_indicator";
|
|
6
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
|
-
import {
|
|
8
|
+
import { FOCUS_RING, CONTROL_RADIUS } from "./control_surface";
|
|
9
9
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
10
10
|
|
|
11
11
|
/** The kit's standard control height (TextInputField, NumberInput, Picker, …).
|
|
@@ -99,6 +99,8 @@ interface InlineEditFrameProps {
|
|
|
99
99
|
disabled?: boolean;
|
|
100
100
|
/** Accessible name for the view button when there's no enclosing FormField. */
|
|
101
101
|
accessibilityLabel?: string;
|
|
102
|
+
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
103
|
+
struck?: boolean;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
interface InlineEditViewProps {
|
|
@@ -115,6 +117,8 @@ interface InlineEditViewProps {
|
|
|
115
117
|
/** True while the field's popover (select/date) is open: wears the 2px active
|
|
116
118
|
* ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
|
|
117
119
|
active?: boolean;
|
|
120
|
+
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
121
|
+
struck?: boolean;
|
|
118
122
|
ref?: Ref<View>;
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -127,19 +131,28 @@ interface InlineEditViewProps {
|
|
|
127
131
|
* select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
|
|
128
132
|
*/
|
|
129
133
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
130
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, ref } = props;
|
|
134
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, ref } = props;
|
|
135
|
+
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
136
|
+
// row that expands on press) edits the field instead of triggering the row.
|
|
137
|
+
const handlePress = onPress
|
|
138
|
+
? (e: GestureResponderEvent) => {
|
|
139
|
+
e?.stopPropagation?.();
|
|
140
|
+
onPress();
|
|
141
|
+
}
|
|
142
|
+
: undefined;
|
|
131
143
|
return (
|
|
132
144
|
<PressableHighlight
|
|
145
|
+
focusRing
|
|
133
146
|
ref={ref}
|
|
134
147
|
disabled={disabled}
|
|
135
|
-
onPress={
|
|
148
|
+
onPress={handlePress}
|
|
136
149
|
accessibilityRole="button"
|
|
137
150
|
accessibilityLabel={accessibilityLabel}
|
|
138
151
|
userSelect="none"
|
|
139
152
|
style={[styles.view, active && styles.viewActive]}
|
|
140
153
|
>
|
|
141
154
|
{typeof display === "string" ? (
|
|
142
|
-
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
|
|
155
|
+
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
|
|
143
156
|
{display || placeholder || "—"}
|
|
144
157
|
</Text>
|
|
145
158
|
) : (
|
|
@@ -171,6 +184,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
171
184
|
error,
|
|
172
185
|
disabled,
|
|
173
186
|
accessibilityLabel,
|
|
187
|
+
struck,
|
|
174
188
|
} = props;
|
|
175
189
|
|
|
176
190
|
if (!editing) {
|
|
@@ -181,6 +195,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
181
195
|
onPress={onBegin}
|
|
182
196
|
disabled={disabled}
|
|
183
197
|
accessibilityLabel={accessibilityLabel}
|
|
198
|
+
struck={struck}
|
|
184
199
|
/>
|
|
185
200
|
);
|
|
186
201
|
}
|
|
@@ -233,9 +248,10 @@ const styles = StyleSheet.create({
|
|
|
233
248
|
// under the 2px active ring — identical to a focused input.
|
|
234
249
|
viewActive: {
|
|
235
250
|
borderColor: colors.border,
|
|
236
|
-
boxShadow:
|
|
251
|
+
boxShadow: FOCUS_RING,
|
|
237
252
|
},
|
|
238
253
|
placeholder: { color: colors.zinc[400] },
|
|
254
|
+
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
239
255
|
viewNode: { flex: 1, minWidth: 0 },
|
|
240
256
|
editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
|
|
241
257
|
editControl: { flex: 1, position: "relative" },
|
package/src/inline_select.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { Icon } from "./icon";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
6
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
|
-
import {
|
|
7
|
+
import { OptionList } from "./option_list";
|
|
8
8
|
import type { PickerOption } from "./picker";
|
|
9
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
10
|
import { InlineEditView } from "./inline_edit";
|
|
@@ -14,12 +14,12 @@ export interface InlineSelectProps<T extends string> {
|
|
|
14
14
|
onSave: (next: T) => void | Promise<void>;
|
|
15
15
|
options: PickerOption<T>[];
|
|
16
16
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
17
|
-
* Omit for a plain label list — both render through the same `
|
|
17
|
+
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
18
18
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
19
|
/** Render the SELECTED value in the resting view as a node (an avatar chip, a
|
|
20
20
|
* colored badge) instead of its plain label. Falls back to the label when
|
|
21
21
|
* omitted; the empty state always shows the placeholder. */
|
|
22
|
-
|
|
22
|
+
renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
|
|
23
23
|
placeholder?: string;
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
accessibilityLabel?: string;
|
|
@@ -27,13 +27,13 @@ export interface InlineSelectProps<T extends string> {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* An inline-editable single-select. The value reads as plain text; clicking it
|
|
30
|
-
* floats the option list (
|
|
30
|
+
* floats the option list (an `OptionList`) in a popover anchored to the view, so
|
|
31
31
|
* the row never changes height. Picking commits; dismissing reverts. Pass
|
|
32
32
|
* `renderOptionContent` for rich options, or omit it for a plain list — the
|
|
33
33
|
* standard and custom-rendered pickers are both supported here.
|
|
34
34
|
*/
|
|
35
35
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
36
|
-
const { value, onSave, options, renderOptionContent,
|
|
36
|
+
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel } = props;
|
|
37
37
|
const [open, setOpen] = useState(false);
|
|
38
38
|
const [saving, setSaving] = useState(false);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -62,7 +62,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
62
62
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
63
63
|
<PopoverTrigger>
|
|
64
64
|
<InlineEditView
|
|
65
|
-
display={selected ? (
|
|
65
|
+
display={selected ? (renderSelected ? renderSelected(selected) : (selected.label ?? "")) : ""}
|
|
66
66
|
placeholder={placeholder}
|
|
67
67
|
disabled={disabled}
|
|
68
68
|
active={open && !disabled}
|
|
@@ -76,8 +76,9 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
76
76
|
}
|
|
77
77
|
/>
|
|
78
78
|
</PopoverTrigger>
|
|
79
|
-
<PopoverContent>
|
|
80
|
-
<
|
|
79
|
+
<PopoverContent disableBodyScroll>
|
|
80
|
+
<OptionList
|
|
81
|
+
search={{ mode: "none" }}
|
|
81
82
|
options={options}
|
|
82
83
|
value={value}
|
|
83
84
|
onValueChange={(next) => void pick(next)}
|
|
@@ -13,6 +13,8 @@ export interface InlineTextInputProps {
|
|
|
13
13
|
* an explicit ✓ saves and ✕ reverts. */
|
|
14
14
|
controls?: InlineEditControls;
|
|
15
15
|
disabled?: boolean;
|
|
16
|
+
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
17
|
+
struck?: boolean;
|
|
16
18
|
accessibilityLabel?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -23,7 +25,7 @@ export interface InlineTextInputProps {
|
|
|
23
25
|
* value in a dense record / detail surface.
|
|
24
26
|
*/
|
|
25
27
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
26
|
-
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
|
|
28
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel } = props;
|
|
27
29
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
28
30
|
|
|
29
31
|
const onKeyPress = useCallback(
|
|
@@ -55,6 +57,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
55
57
|
saving={edit.saving}
|
|
56
58
|
error={edit.error}
|
|
57
59
|
disabled={disabled}
|
|
60
|
+
struck={struck}
|
|
58
61
|
accessibilityLabel={accessibilityLabel}
|
|
59
62
|
>
|
|
60
63
|
<TextInputField
|
package/src/link.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { Pressable } from "react-native";
|
|
3
3
|
import { Text, type TextSize } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { FOCUS_RING } from "./control_surface";
|
|
6
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
5
7
|
|
|
6
8
|
export interface LinkProps {
|
|
7
9
|
children: ReactNode;
|
|
@@ -22,8 +24,16 @@ export interface LinkProps {
|
|
|
22
24
|
* `onPress` to its opener.
|
|
23
25
|
*/
|
|
24
26
|
export function Link({ children, onPress, size = "sm", accessibilityLabel, testID }: LinkProps) {
|
|
27
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
25
28
|
return (
|
|
26
|
-
<Pressable
|
|
29
|
+
<Pressable
|
|
30
|
+
onPress={onPress}
|
|
31
|
+
{...focusProps}
|
|
32
|
+
accessibilityRole="link"
|
|
33
|
+
accessibilityLabel={accessibilityLabel}
|
|
34
|
+
testID={testID}
|
|
35
|
+
style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 2 } : undefined}
|
|
36
|
+
>
|
|
27
37
|
<Text size={size} decoration="underline" style={{ color: colors.blue[600] }}>
|
|
28
38
|
{children}
|
|
29
39
|
</Text>
|
package/src/link_button.tsx
CHANGED
|
@@ -23,7 +23,7 @@ export function LinkButton(props: LinkButtonProps) {
|
|
|
23
23
|
const { title, onPress, accessibilityLabel, testID } = props;
|
|
24
24
|
return (
|
|
25
25
|
<PressableHighlight
|
|
26
|
-
testID={testID}
|
|
26
|
+
focusRing testID={testID}
|
|
27
27
|
onPress={onPress}
|
|
28
28
|
accessibilityRole="button"
|
|
29
29
|
accessibilityLabel={accessibilityLabel ?? title}
|
package/src/list_item.tsx
CHANGED
package/src/member_select.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useMemo } from "react";
|
|
2
2
|
import { StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { Select } from "./select";
|
|
3
4
|
import {
|
|
4
|
-
Picker,
|
|
5
5
|
type PickerOption,
|
|
6
6
|
type PickerValue,
|
|
7
7
|
type PickerOnValueChange,
|
|
@@ -33,15 +33,20 @@ interface MemberSelectProps<MULTI extends boolean = false> {
|
|
|
33
33
|
disabled?: boolean;
|
|
34
34
|
autoFocus?: boolean;
|
|
35
35
|
includeEmptyOption?: boolean;
|
|
36
|
+
enableSelectAll?: boolean;
|
|
37
|
+
/** Label for the "select all" link in multi-select mode. Pass a translated string. */
|
|
38
|
+
selectAllLabel?: string;
|
|
39
|
+
/** Label for the "deselect all" link in multi-select mode. Pass a translated string. */
|
|
40
|
+
deselectAllLabel?: string;
|
|
36
41
|
style?: StyleProp<ViewStyle>;
|
|
37
42
|
testID?: string;
|
|
38
43
|
accessibilityLabel?: string;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
|
-
* Choose member(s) — a {@link
|
|
47
|
+
* Choose member(s) — a {@link Select} whose every option renders as a
|
|
43
48
|
* {@link MemberChip} (avatar + name). The one member-picker shape, so an app
|
|
44
|
-
* never re-wires `
|
|
49
|
+
* never re-wires `Select` + `renderOptionContent` + a directory by hand. PURE:
|
|
45
50
|
* pass the candidate `members` (an app feeds `useMembers`, the product any
|
|
46
51
|
* roster); this fetches nothing. Single or multi via `multi`. Options are
|
|
47
52
|
* single-line by design — a picker row identifies a person by name + avatar;
|
|
@@ -72,7 +77,7 @@ export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectP
|
|
|
72
77
|
);
|
|
73
78
|
|
|
74
79
|
return (
|
|
75
|
-
<
|
|
80
|
+
<Select<string, MULTI>
|
|
76
81
|
options={options}
|
|
77
82
|
renderOptionContent={renderOptionContent}
|
|
78
83
|
{...picker}
|
package/src/menu_button.tsx
CHANGED
package/src/menu_list_item.tsx
CHANGED
package/src/number_input.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { KeyboardEvent } from "react";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
3
|
+
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
|
|
4
4
|
import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
|
|
5
5
|
import { useFormField } from "./form_field";
|
|
6
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
7
|
+
import { useHover } from "./use_hover";
|
|
6
8
|
|
|
7
9
|
export interface NumberInputProps {
|
|
8
10
|
value: number | null;
|
|
@@ -27,6 +29,8 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
27
29
|
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
|
|
28
30
|
const binding = useFormField();
|
|
29
31
|
const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
32
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
33
|
+
const { hovered, hoverProps } = useHover();
|
|
30
34
|
|
|
31
35
|
return (
|
|
32
36
|
<input
|
|
@@ -44,7 +48,9 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
44
48
|
max={max}
|
|
45
49
|
type="number"
|
|
46
50
|
disabled={disabled}
|
|
47
|
-
|
|
51
|
+
{...hoverProps}
|
|
52
|
+
onFocus={focusProps.onFocus}
|
|
53
|
+
onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
|
|
48
54
|
onKeyDown={onKeyDown}
|
|
49
55
|
autoFocus={autoFocus}
|
|
50
56
|
style={{
|
|
@@ -54,14 +60,15 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
54
60
|
borderRadius: CONTROL_RADIUS,
|
|
55
61
|
borderWidth: 1,
|
|
56
62
|
borderStyle: "solid",
|
|
57
|
-
borderColor: colors.border,
|
|
63
|
+
borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
|
|
58
64
|
backgroundColor: colors.background,
|
|
59
65
|
fontFamily: fontFamilyRegular,
|
|
60
66
|
...inputTextStyleWeb,
|
|
61
67
|
letterSpacing: -0.4,
|
|
62
|
-
boxShadow: "none",
|
|
68
|
+
boxShadow: focusVisible ? FOCUS_RING : "none",
|
|
63
69
|
outline: "none",
|
|
64
70
|
boxSizing: "border-box",
|
|
71
|
+
transition: "border-color 0.12s, box-shadow 0.12s",
|
|
65
72
|
}}
|
|
66
73
|
/>
|
|
67
74
|
);
|