@lotics/ui 5.9.0 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +127 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +10 -5
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +2 -2
- package/src/image_gallery.tsx +8 -10
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
import { type SortState, type SortHeaderLabels } from "./sort_header";
|
|
8
|
+
|
|
9
|
+
export interface DataGridColumn<T> {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** Fixed width in px; omit for the flexible primary column (usually the first). */
|
|
13
|
+
width?: number;
|
|
14
|
+
sortable?: boolean;
|
|
15
|
+
/** Render the cell for this column — any field: a `Text`, an inline editor, a badge. */
|
|
16
|
+
cell: (item: T) => ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DataGridGroup<T> {
|
|
20
|
+
key: string;
|
|
21
|
+
/** The group's header content — a status badge, a member chip, a label. Omit for a
|
|
22
|
+
* single ungrouped section (no header, no collapse). */
|
|
23
|
+
header?: ReactNode;
|
|
24
|
+
items: T[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DataGridProps<T> {
|
|
28
|
+
columns: DataGridColumn<T>[];
|
|
29
|
+
/** Pre-grouped, pre-sorted data (the consumer owns grouping/sort/filter). One headerless group = ungrouped. */
|
|
30
|
+
groups: DataGridGroup<T>[];
|
|
31
|
+
getRowKey: (item: T) => string;
|
|
32
|
+
/** A per-row leading slot (e.g. a `CheckCircle`) and the gutter it reserves so the header lines up. */
|
|
33
|
+
leading?: { width: number; render: (item: T) => ReactNode };
|
|
34
|
+
sort?: SortState | null;
|
|
35
|
+
/** Pair with `cycleSort` to cycle a column (none → asc → desc). Sorting itself is the consumer's (`sortBy`). */
|
|
36
|
+
onSort?: (key: string) => void;
|
|
37
|
+
/** Localize the sortable-header a11y text (`Sort by …` + `, ascending`/`, descending`). */
|
|
38
|
+
labels?: SortHeaderLabels;
|
|
39
|
+
/** Controlled collapse — the set of collapsed group keys. */
|
|
40
|
+
collapsed?: Set<string>;
|
|
41
|
+
onToggleCollapse?: (key: string) => void;
|
|
42
|
+
/** Rendered under each group's rows — typically a per-group add row. Align it with `gridRowStyle` + the column widths. */
|
|
43
|
+
renderGroupFooter?: (group: DataGridGroup<T>) => ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The INLINE-MANAGED grouped table — a presentational grid for a MODERATE dataset
|
|
48
|
+
* you manage in view (group/sort/filter/edit-in-place), the complement to the
|
|
49
|
+
* high-volume paginated register (`Table` + `Pagination` + `Drawer`). It owns the
|
|
50
|
+
* sortable header, the collapsible grouped sections, and the column-aligned rows;
|
|
51
|
+
* the consumer owns the DATA, the sort/group/filter/collapse STATE, the toolbar
|
|
52
|
+
* (search · group-by · filters), the per-group add (`renderGroupFooter`), and every
|
|
53
|
+
* CELL (`columns[].cell` renders ANY field). It renders ALL rows — no pagination /
|
|
54
|
+
* virtualization — so it's for hundreds, low-thousands; at 10k+ use the register.
|
|
55
|
+
* Worked examples: `tpl_task_board`, `tpl_pipeline`.
|
|
56
|
+
*/
|
|
57
|
+
export function DataGrid<T>(props: DataGridProps<T>) {
|
|
58
|
+
const { columns, groups, getRowKey, leading, sort, onSort, labels, collapsed, onToggleCollapse, renderGroupFooter } = props;
|
|
59
|
+
const lead = leading?.width ?? 0;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<View>
|
|
63
|
+
<View style={[styles.row, styles.head]}>
|
|
64
|
+
{lead > 0 ? <View style={{ width: lead }} /> : null}
|
|
65
|
+
{columns.map((c) => (
|
|
66
|
+
<SortLabel key={c.key} label={c.label} sortKey={c.key} sortable={c.sortable !== false && !!onSort} sort={sort ?? null} onSort={onSort} labels={labels} style={colStyle(c)} />
|
|
67
|
+
))}
|
|
68
|
+
</View>
|
|
69
|
+
|
|
70
|
+
{groups.map((g, i) => {
|
|
71
|
+
const isCollapsed = collapsed?.has(g.key) ?? false;
|
|
72
|
+
return (
|
|
73
|
+
<View key={g.key} style={i > 0 ? styles.groupSep : undefined}>
|
|
74
|
+
{g.header != null ? (
|
|
75
|
+
<FocusRingPressable
|
|
76
|
+
onPress={() => onToggleCollapse?.(g.key)}
|
|
77
|
+
accessibilityRole="button"
|
|
78
|
+
accessibilityState={{ expanded: !isCollapsed }}
|
|
79
|
+
disabled={!onToggleCollapse}
|
|
80
|
+
style={({ hovered }: { hovered?: boolean }) => [styles.section, hovered && onToggleCollapse ? styles.sectionHover : null]}
|
|
81
|
+
>
|
|
82
|
+
{onToggleCollapse ? <Icon name={isCollapsed ? "chevron-right" : "chevron-down"} size={15} color={colors.zinc[400]} /> : null}
|
|
83
|
+
{g.header}
|
|
84
|
+
<Text size="xs" color="muted" tabular>{g.items.length}</Text>
|
|
85
|
+
</FocusRingPressable>
|
|
86
|
+
) : null}
|
|
87
|
+
{!isCollapsed ? (
|
|
88
|
+
<>
|
|
89
|
+
{g.items.map((item) => (
|
|
90
|
+
<View key={getRowKey(item)} style={styles.row}>
|
|
91
|
+
{lead > 0 ? <View style={{ width: lead }}>{leading?.render(item)}</View> : null}
|
|
92
|
+
{columns.map((c) => (
|
|
93
|
+
<View key={c.key} style={colStyle(c)}>{c.cell(item)}</View>
|
|
94
|
+
))}
|
|
95
|
+
</View>
|
|
96
|
+
))}
|
|
97
|
+
{renderGroupFooter?.(g)}
|
|
98
|
+
</>
|
|
99
|
+
) : null}
|
|
100
|
+
</View>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function colStyle(c: { width?: number }): StyleProp<ViewStyle> {
|
|
108
|
+
return c.width != null ? { width: c.width } : styles.flexCol;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The aligned sortable header label — same 8px inset as the cells, so it lines up
|
|
112
|
+
// with the column content (the kit's `SortHeader` keeps its label flush, which
|
|
113
|
+
// wouldn't match inline-editor cells).
|
|
114
|
+
function SortLabel(props: { label: string; sortKey: string; sortable: boolean; sort: SortState | null; onSort?: (k: string) => void; labels?: SortHeaderLabels; style?: StyleProp<ViewStyle> }) {
|
|
115
|
+
const { label, sortKey, sortable, sort, onSort, labels, style } = props;
|
|
116
|
+
const active = sort?.key === sortKey;
|
|
117
|
+
const arrow = active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
|
|
118
|
+
if (!sortable) {
|
|
119
|
+
return (
|
|
120
|
+
<View style={[styles.sortLabel, style]}>
|
|
121
|
+
<Text size="xs" weight="semibold" color="muted" numberOfLines={1} style={styles.headLabel}>{label}</Text>
|
|
122
|
+
</View>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const sortByLabel = (labels?.sortBy ?? ((l: string) => `Sort by ${l}`))(label);
|
|
126
|
+
const dirText = active ? (sort?.dir === "asc" ? (labels?.ascending ?? ", ascending") : (labels?.descending ?? ", descending")) : "";
|
|
127
|
+
return (
|
|
128
|
+
<FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, hovered ? styles.sortLabelHover : null, style]}>
|
|
129
|
+
<Text size="xs" weight="semibold" color={active ? "default" : "muted"} numberOfLines={1} style={styles.headLabel}>{label}</Text>
|
|
130
|
+
{arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
|
|
131
|
+
</FocusRingPressable>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The row layout the grid uses — reuse it for an aligned per-group add row in `renderGroupFooter`. */
|
|
136
|
+
export const gridRowStyle: ViewStyle = {
|
|
137
|
+
flexDirection: "row",
|
|
138
|
+
alignItems: "center",
|
|
139
|
+
gap: 12,
|
|
140
|
+
minHeight: 44,
|
|
141
|
+
paddingHorizontal: 8,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const styles = StyleSheet.create({
|
|
145
|
+
row: gridRowStyle,
|
|
146
|
+
flexCol: { flex: 1, minWidth: 0 },
|
|
147
|
+
head: {
|
|
148
|
+
minHeight: 30,
|
|
149
|
+
borderBottomWidth: 1,
|
|
150
|
+
borderBottomColor: colors.zinc[100],
|
|
151
|
+
marginBottom: 4,
|
|
152
|
+
},
|
|
153
|
+
headLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
|
|
154
|
+
sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, cursor: "pointer" },
|
|
155
|
+
sortLabelHover: { backgroundColor: colors.zinc[50] },
|
|
156
|
+
groupSep: { borderTopWidth: 1, borderTopColor: colors.zinc[100], marginTop: 8, paddingTop: 8 },
|
|
157
|
+
section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, paddingVertical: 7, borderRadius: 8 },
|
|
158
|
+
sectionHover: { backgroundColor: colors.zinc[50] },
|
|
159
|
+
});
|
package/src/date_calendar.tsx
CHANGED
|
@@ -5,6 +5,45 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { useScreenSize } from "./use_screen_size";
|
|
6
6
|
import { Picker, PickerOption } from "./picker";
|
|
7
7
|
import { IconButton } from "./icon_button";
|
|
8
|
+
import { FOCUS_RING } from "./control_surface";
|
|
9
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
10
|
+
|
|
11
|
+
function DayCell(props: {
|
|
12
|
+
day: number;
|
|
13
|
+
label: string;
|
|
14
|
+
isActive: boolean;
|
|
15
|
+
inRange: boolean;
|
|
16
|
+
isToday: boolean;
|
|
17
|
+
onPress: () => void;
|
|
18
|
+
}) {
|
|
19
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
20
|
+
return (
|
|
21
|
+
<Pressable
|
|
22
|
+
accessibilityRole="button"
|
|
23
|
+
accessibilityLabel={props.label}
|
|
24
|
+
accessibilityState={{ selected: props.isActive }}
|
|
25
|
+
{...focusProps}
|
|
26
|
+
style={({ hovered }) => [
|
|
27
|
+
styles.dayCell,
|
|
28
|
+
props.inRange && styles.dayCellInRange,
|
|
29
|
+
props.isActive && styles.dayCellSelected,
|
|
30
|
+
hovered && !props.isActive && styles.dayCellHovered,
|
|
31
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
32
|
+
]}
|
|
33
|
+
onPress={props.onPress}
|
|
34
|
+
>
|
|
35
|
+
<Text
|
|
36
|
+
size="sm"
|
|
37
|
+
color={props.isActive ? "inverted" : "default"}
|
|
38
|
+
userSelect="none"
|
|
39
|
+
weight={props.isToday ? "semibold" : "regular"}
|
|
40
|
+
>
|
|
41
|
+
{props.day}
|
|
42
|
+
</Text>
|
|
43
|
+
{props.isToday && <View style={[styles.todayDot, props.isActive && styles.todayDotInverted]} />}
|
|
44
|
+
</Pressable>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
8
47
|
|
|
9
48
|
// =============================================================================
|
|
10
49
|
// Types
|
|
@@ -350,31 +389,15 @@ function CalendarMonth(props: CalendarMonthProps) {
|
|
|
350
389
|
const isActive = isSelected || isRangeStart || isRangeEnd;
|
|
351
390
|
|
|
352
391
|
return (
|
|
353
|
-
<
|
|
392
|
+
<DayCell
|
|
354
393
|
key={dayIndex}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
inRange && styles.dayCellInRange,
|
|
361
|
-
isActive && styles.dayCellSelected,
|
|
362
|
-
hovered && !isActive && styles.dayCellHovered,
|
|
363
|
-
]}
|
|
394
|
+
day={day}
|
|
395
|
+
label={names.formatDayLabel(date)}
|
|
396
|
+
isActive={isActive}
|
|
397
|
+
inRange={inRange}
|
|
398
|
+
isToday={isTodayDate}
|
|
364
399
|
onPress={() => onDateSelect(date)}
|
|
365
|
-
|
|
366
|
-
<Text
|
|
367
|
-
size="sm"
|
|
368
|
-
color={isActive ? "inverted" : "default"}
|
|
369
|
-
userSelect="none"
|
|
370
|
-
weight={isTodayDate ? "semibold" : "regular"}
|
|
371
|
-
>
|
|
372
|
-
{day}
|
|
373
|
-
</Text>
|
|
374
|
-
{isTodayDate && (
|
|
375
|
-
<View style={[styles.todayDot, isActive && styles.todayDotInverted]} />
|
|
376
|
-
)}
|
|
377
|
-
</Pressable>
|
|
400
|
+
/>
|
|
378
401
|
);
|
|
379
402
|
})}
|
|
380
403
|
</View>
|
package/src/date_field.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
4
|
+
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
5
|
+
import { useHover } from "./use_hover";
|
|
5
6
|
import { Text } from "./text";
|
|
6
7
|
import { DateSegments } from "./date_segments_field";
|
|
7
8
|
import { SegmentLabels, dateSegmentsConfig } from "./date_segments";
|
|
@@ -56,6 +57,7 @@ export function DateField(props: DateFieldProps) {
|
|
|
56
57
|
} = props;
|
|
57
58
|
|
|
58
59
|
const [focused, setFocused] = useState(false);
|
|
60
|
+
const { hovered, hoverProps } = useHover();
|
|
59
61
|
const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
60
62
|
|
|
61
63
|
useEffect(
|
|
@@ -96,8 +98,10 @@ export function DateField(props: DateFieldProps) {
|
|
|
96
98
|
<View
|
|
97
99
|
ref={triggerRef}
|
|
98
100
|
onPointerDown={handlePointerDown}
|
|
101
|
+
{...(hoverProps as object)}
|
|
99
102
|
style={[
|
|
100
103
|
styles.frame,
|
|
104
|
+
hovered && !disabled && styles.frameHovered,
|
|
101
105
|
focused && !disabled && styles.frameFocused,
|
|
102
106
|
disabled && styles.frameDisabled,
|
|
103
107
|
style,
|
|
@@ -149,9 +153,13 @@ const styles = StyleSheet.create({
|
|
|
149
153
|
borderWidth: 1,
|
|
150
154
|
borderColor: colors.border,
|
|
151
155
|
backgroundColor: colors.background,
|
|
156
|
+
...CONTROL_TRANSITION,
|
|
157
|
+
},
|
|
158
|
+
frameHovered: {
|
|
159
|
+
borderColor: HOVER_BORDER,
|
|
152
160
|
},
|
|
153
161
|
frameFocused: {
|
|
154
|
-
|
|
162
|
+
boxShadow: FOCUS_RING,
|
|
155
163
|
},
|
|
156
164
|
frameDisabled: {
|
|
157
165
|
backgroundColor: colors.zinc["50"],
|
package/src/date_filter.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { CONTROL_RADIUS } from "./control_surface";
|
|
6
6
|
import { MenuButton } from "./menu_button";
|
|
7
7
|
import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
|
|
8
|
-
import {
|
|
8
|
+
import { TimePicker } from "./time_picker";
|
|
9
9
|
import { Switch } from "./switch";
|
|
10
10
|
import { useScreenSize } from "./use_screen_size";
|
|
11
11
|
import { SegmentLabels } from "./date_segments";
|
|
@@ -270,11 +270,9 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
270
270
|
</Text>
|
|
271
271
|
{includeTime && (
|
|
272
272
|
<View style={{ marginTop: 4 }}>
|
|
273
|
-
<
|
|
273
|
+
<TimePicker
|
|
274
274
|
value={value.start.time || ""}
|
|
275
|
-
|
|
276
|
-
segmentLabels={labels}
|
|
277
|
-
locale={locale}
|
|
275
|
+
onValueChange={handleStartTimeChange}
|
|
278
276
|
/>
|
|
279
277
|
</View>
|
|
280
278
|
)}
|
|
@@ -292,11 +290,9 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
292
290
|
</Text>
|
|
293
291
|
{includeTime && value.end.date && (
|
|
294
292
|
<View style={{ marginTop: 4 }}>
|
|
295
|
-
<
|
|
293
|
+
<TimePicker
|
|
296
294
|
value={value.end.time || ""}
|
|
297
|
-
|
|
298
|
-
segmentLabels={labels}
|
|
299
|
-
locale={locale}
|
|
295
|
+
onValueChange={handleEndTimeChange}
|
|
300
296
|
/>
|
|
301
297
|
</View>
|
|
302
298
|
)}
|
package/src/date_picker.tsx
CHANGED
|
@@ -4,15 +4,18 @@ import { Text } from "./text";
|
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { Popover, PopoverContent } from "./popover";
|
|
6
6
|
import { Calendar, CalendarRangeValue } from "./date_calendar";
|
|
7
|
-
import {
|
|
7
|
+
import { TimePicker } from "./time_picker";
|
|
8
8
|
import { DateField } from "./date_field";
|
|
9
9
|
import { SegmentLabels } from "./date_segments";
|
|
10
10
|
import { Icon } from "./icon";
|
|
11
11
|
import { Button } from "./button";
|
|
12
|
+
import { IconButton } from "./icon_button";
|
|
12
13
|
import { Divider } from "./divider";
|
|
13
14
|
import {
|
|
14
15
|
DatePickerFormat,
|
|
16
|
+
dropTime,
|
|
15
17
|
hasTimeFormat,
|
|
18
|
+
isoHasTime,
|
|
16
19
|
isoToDate,
|
|
17
20
|
isRangeFormat,
|
|
18
21
|
nowIso,
|
|
@@ -42,6 +45,10 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
42
45
|
endTime: string;
|
|
43
46
|
/** Footer action: commit and close the popover. */
|
|
44
47
|
done: string;
|
|
48
|
+
/** Quick action: add a time to a date-only value (`optionalTime`). */
|
|
49
|
+
addTime: string;
|
|
50
|
+
/** Accessible name + tooltip: drop the time, keeping the date (`optionalTime`). */
|
|
51
|
+
removeTime: string;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
const DEFAULT_LABELS: DatePickerLabels = {
|
|
@@ -53,6 +60,8 @@ const DEFAULT_LABELS: DatePickerLabels = {
|
|
|
53
60
|
time: "Time",
|
|
54
61
|
startTime: "Start time",
|
|
55
62
|
endTime: "End time",
|
|
63
|
+
addTime: "Add time",
|
|
64
|
+
removeTime: "Remove time",
|
|
56
65
|
year: "Year",
|
|
57
66
|
month: "Month",
|
|
58
67
|
day: "Day",
|
|
@@ -65,6 +74,10 @@ export interface DatePickerProps {
|
|
|
65
74
|
value?: string | null;
|
|
66
75
|
onValueChange: (value: string) => void;
|
|
67
76
|
format?: DatePickerFormat;
|
|
77
|
+
/** Single `date` formats only: let the user OPTIONALLY add a time. The value's
|
|
78
|
+
* shape is the source of truth — date-only until the user adds a time, then a
|
|
79
|
+
* datetime — and the panel offers "Add time" / "Remove time". */
|
|
80
|
+
optionalTime?: boolean;
|
|
68
81
|
disabled?: boolean;
|
|
69
82
|
/** Fires when the text input loses focus, after any typed value is committed. */
|
|
70
83
|
onBlur?: () => void;
|
|
@@ -88,6 +101,8 @@ export interface DatePickerPanelProps {
|
|
|
88
101
|
value?: string | null;
|
|
89
102
|
onValueChange: (value: string) => void;
|
|
90
103
|
format: DatePickerFormat;
|
|
104
|
+
/** Single `date` formats only: time is optional and driven by the value's shape. */
|
|
105
|
+
optionalTime?: boolean;
|
|
91
106
|
labels?: Partial<DatePickerLabels>;
|
|
92
107
|
/** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
|
|
93
108
|
locale?: string;
|
|
@@ -96,10 +111,14 @@ export interface DatePickerPanelProps {
|
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
99
|
-
const { value, onValueChange, format, labels, locale, onRequestClose } = props;
|
|
114
|
+
const { value, onValueChange, format, optionalTime, labels, locale, onRequestClose } = props;
|
|
100
115
|
|
|
101
116
|
const isRange = isRangeFormat(format);
|
|
102
|
-
|
|
117
|
+
// In optionalTime mode the value's own shape says whether there's a time; the
|
|
118
|
+
// panel offers Add/Remove. Otherwise the format fixes it.
|
|
119
|
+
const optTime = !!optionalTime && !isRange;
|
|
120
|
+
const valueHasTime = isoHasTime(value);
|
|
121
|
+
const hasTime = optTime ? valueHasTime : hasTimeFormat(format);
|
|
103
122
|
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
104
123
|
|
|
105
124
|
const [startIso, endIso] = useMemo<[string, string]>(() => {
|
|
@@ -125,9 +144,11 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
125
144
|
const handleSingleCalendar = useCallback(
|
|
126
145
|
(date: Date | null) => {
|
|
127
146
|
onValueChange(date ? withCalendarDate(date, value ?? "", hasTime) : "");
|
|
128
|
-
|
|
147
|
+
// Date-only formats commit-and-close on pick; datetime and optionalTime stay
|
|
148
|
+
// open so a time can be entered/added.
|
|
149
|
+
if (date && format === "date" && !optTime) onRequestClose?.();
|
|
129
150
|
},
|
|
130
|
-
[value, hasTime, format, onValueChange, onRequestClose],
|
|
151
|
+
[value, hasTime, format, optTime, onValueChange, onRequestClose],
|
|
131
152
|
);
|
|
132
153
|
|
|
133
154
|
const handleRangeCalendar = useCallback(
|
|
@@ -194,7 +215,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
194
215
|
|
|
195
216
|
{showTimeRow && (
|
|
196
217
|
<>
|
|
197
|
-
<Divider paddingVertical={
|
|
218
|
+
<Divider paddingVertical={4} />
|
|
198
219
|
<View style={styles.timeRow}>
|
|
199
220
|
{isRange ? (
|
|
200
221
|
<>
|
|
@@ -202,38 +223,43 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
202
223
|
<Text size="sm" color="muted">
|
|
203
224
|
{mergedLabels.startTime}
|
|
204
225
|
</Text>
|
|
205
|
-
<
|
|
226
|
+
<TimePicker
|
|
206
227
|
accessibilityLabel={mergedLabels.startTime}
|
|
207
|
-
segmentLabels={mergedLabels}
|
|
208
228
|
value={timeText(startIso)}
|
|
209
|
-
|
|
210
|
-
locale={locale}
|
|
229
|
+
onValueChange={(t) => handleRangeTime("start", t)}
|
|
211
230
|
/>
|
|
212
231
|
</View>
|
|
213
232
|
<View style={styles.timeCol}>
|
|
214
233
|
<Text size="sm" color="muted">
|
|
215
234
|
{mergedLabels.endTime}
|
|
216
235
|
</Text>
|
|
217
|
-
<
|
|
236
|
+
<TimePicker
|
|
218
237
|
accessibilityLabel={mergedLabels.endTime}
|
|
219
|
-
segmentLabels={mergedLabels}
|
|
220
238
|
value={timeText(endIso)}
|
|
221
|
-
|
|
222
|
-
locale={locale}
|
|
239
|
+
onValueChange={(t) => handleRangeTime("end", t)}
|
|
223
240
|
/>
|
|
224
241
|
</View>
|
|
225
242
|
</>
|
|
226
243
|
) : (
|
|
227
244
|
<View style={styles.timeCol}>
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
245
|
+
<View style={styles.timeHead}>
|
|
246
|
+
<Text size="sm" color="muted">
|
|
247
|
+
{mergedLabels.time}
|
|
248
|
+
</Text>
|
|
249
|
+
{optTime ? (
|
|
250
|
+
<IconButton
|
|
251
|
+
icon="x"
|
|
252
|
+
size="sm"
|
|
253
|
+
tooltip={mergedLabels.removeTime}
|
|
254
|
+
accessibilityLabel={mergedLabels.removeTime}
|
|
255
|
+
onPress={() => onValueChange(dropTime(value ?? ""))}
|
|
256
|
+
/>
|
|
257
|
+
) : null}
|
|
258
|
+
</View>
|
|
259
|
+
<TimePicker
|
|
232
260
|
accessibilityLabel={mergedLabels.time}
|
|
233
|
-
segmentLabels={mergedLabels}
|
|
234
261
|
value={timeText(value ?? "")}
|
|
235
|
-
|
|
236
|
-
locale={locale}
|
|
262
|
+
onValueChange={handleSingleTime}
|
|
237
263
|
/>
|
|
238
264
|
</View>
|
|
239
265
|
)}
|
|
@@ -241,6 +267,19 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
241
267
|
</>
|
|
242
268
|
)}
|
|
243
269
|
|
|
270
|
+
{optTime && !!value && !valueHasTime && (
|
|
271
|
+
<>
|
|
272
|
+
<Divider paddingVertical={8} />
|
|
273
|
+
<View style={styles.addTimeRow}>
|
|
274
|
+
<Button
|
|
275
|
+
title={mergedLabels.addTime}
|
|
276
|
+
color="muted"
|
|
277
|
+
onPress={() => onValueChange(withTimeOfDay(value, timeText(nowIso(true))))}
|
|
278
|
+
/>
|
|
279
|
+
</View>
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
282
|
+
|
|
244
283
|
<Divider paddingVertical={8} />
|
|
245
284
|
<View style={styles.footer}>
|
|
246
285
|
<Button title={mergedLabels.clear} color="muted" onPress={() => closeAndSet("")} />
|
|
@@ -248,18 +287,18 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
248
287
|
{format === "date" && (
|
|
249
288
|
<Button
|
|
250
289
|
title={mergedLabels.today}
|
|
251
|
-
color="
|
|
290
|
+
color="muted"
|
|
252
291
|
onPress={() => closeAndSet(nowIso(false))}
|
|
253
292
|
/>
|
|
254
293
|
)}
|
|
255
294
|
{format === "datetime" && (
|
|
256
295
|
<Button
|
|
257
296
|
title={mergedLabels.now}
|
|
258
|
-
color="
|
|
297
|
+
color="muted"
|
|
259
298
|
onPress={() => closeAndSet(nowIso(true))}
|
|
260
299
|
/>
|
|
261
300
|
)}
|
|
262
|
-
{format !== "date" && (
|
|
301
|
+
{(format !== "date" || optTime) && (
|
|
263
302
|
<Button title={mergedLabels.done} color="secondary" onPress={() => onRequestClose?.()} />
|
|
264
303
|
)}
|
|
265
304
|
</View>
|
|
@@ -290,6 +329,7 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
290
329
|
value,
|
|
291
330
|
onValueChange,
|
|
292
331
|
format = "date",
|
|
332
|
+
optionalTime,
|
|
293
333
|
disabled,
|
|
294
334
|
onBlur,
|
|
295
335
|
testID,
|
|
@@ -299,7 +339,9 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
299
339
|
} = props;
|
|
300
340
|
|
|
301
341
|
const isRange = isRangeFormat(format);
|
|
302
|
-
const
|
|
342
|
+
const optTime = !!optionalTime && !isRange;
|
|
343
|
+
// The trigger's segments follow the value's shape in optionalTime mode.
|
|
344
|
+
const hasTime = optTime ? isoHasTime(value) : hasTimeFormat(format);
|
|
303
345
|
const mergedLabels = useMemo<DatePickerLabels>(
|
|
304
346
|
() => ({ ...DEFAULT_LABELS, ...labels }),
|
|
305
347
|
[labels],
|
|
@@ -347,7 +389,7 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
347
389
|
|
|
348
390
|
// Display-only — the whole field is pressable (DateField opens the calendar).
|
|
349
391
|
const calendarIcon = (
|
|
350
|
-
<Icon name="calendar" size={
|
|
392
|
+
<Icon name="calendar" size={20} color={disabled ? colors.zinc["300"] : colors.zinc["400"]} />
|
|
351
393
|
);
|
|
352
394
|
|
|
353
395
|
const trigger = (
|
|
@@ -385,6 +427,7 @@ export function DatePicker(props: DatePickerProps) {
|
|
|
385
427
|
value={value}
|
|
386
428
|
onValueChange={onValueChange}
|
|
387
429
|
format={format}
|
|
430
|
+
optionalTime={optionalTime}
|
|
388
431
|
labels={labels}
|
|
389
432
|
locale={locale}
|
|
390
433
|
onRequestClose={closeOverlay}
|
|
@@ -417,4 +460,13 @@ const styles = StyleSheet.create({
|
|
|
417
460
|
flex: 1,
|
|
418
461
|
gap: 4,
|
|
419
462
|
},
|
|
463
|
+
timeHead: {
|
|
464
|
+
flexDirection: "row",
|
|
465
|
+
alignItems: "center",
|
|
466
|
+
justifyContent: "space-between",
|
|
467
|
+
minHeight: 24,
|
|
468
|
+
},
|
|
469
|
+
addTimeRow: {
|
|
470
|
+
flexDirection: "row",
|
|
471
|
+
},
|
|
420
472
|
});
|
package/src/date_picker_value.ts
CHANGED
|
@@ -112,6 +112,17 @@ export function nowIso(hasTime: boolean): string {
|
|
|
112
112
|
return partsToIso(dateToParts(new Date()), hasTime);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Whether a value carries a time component (canonical datetime uses a "T" separator). */
|
|
116
|
+
export function isoHasTime(iso: string | null | undefined): boolean {
|
|
117
|
+
return !!iso && iso.includes("T");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The value with any time component dropped (date-only). Empty when unparseable. */
|
|
121
|
+
export function dropTime(iso: string): string {
|
|
122
|
+
const p = parsePart(iso);
|
|
123
|
+
return p ? partsToIso(p, false) : "";
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
|
|
116
127
|
|
|
117
128
|
/**
|
|
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
+
import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
6
6
|
import { Icon } from "./icon";
|
|
7
7
|
import { Button } from "./button";
|
|
8
8
|
import { PressableHighlight } from "./pressable_highlight";
|
|
@@ -120,20 +120,22 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
120
120
|
<Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
|
|
121
121
|
<PopoverTrigger>
|
|
122
122
|
<PressableHighlight
|
|
123
|
+
focusRing
|
|
123
124
|
testID={testID}
|
|
124
125
|
accessibilityRole="button"
|
|
125
126
|
accessibilityLabel={labels.selectDateRange}
|
|
126
|
-
style={{
|
|
127
|
+
style={(state) => ({
|
|
127
128
|
flexDirection: "row",
|
|
128
129
|
alignItems: "center",
|
|
129
130
|
gap: 8,
|
|
130
131
|
paddingVertical: 9,
|
|
131
132
|
paddingHorizontal: 12,
|
|
132
133
|
borderWidth: 1,
|
|
133
|
-
borderColor: colors.zinc[300],
|
|
134
|
+
borderColor: state.hovered ? HOVER_BORDER : colors.zinc[300],
|
|
134
135
|
borderRadius: CONTROL_RADIUS,
|
|
135
136
|
backgroundColor: colors.white,
|
|
136
|
-
|
|
137
|
+
...CONTROL_TRANSITION,
|
|
138
|
+
})}
|
|
137
139
|
>
|
|
138
140
|
<Icon name="calendar" size={16} color={hasValue ? colors.zinc[700] : colors.zinc[400]} />
|
|
139
141
|
<Text size="sm" color={hasValue ? "default" : "muted"} numberOfLines={1} style={{ flex: 1 }}>
|
package/src/drawer.tsx
CHANGED
|
@@ -79,7 +79,7 @@ export function Drawer(props: DrawerProps) {
|
|
|
79
79
|
<Modal visible={open} onRequestClose={handleClose} transparent>
|
|
80
80
|
<View style={styles.base}>
|
|
81
81
|
{/* Scrim is a sibling of the panel, so tapping the panel never closes. */}
|
|
82
|
-
<Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel="Close" />
|
|
82
|
+
<Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel="Close" tabIndex={-1} />
|
|
83
83
|
<View style={[styles.panel, { width: screenSize.small ? "100%" : width }]}>
|
|
84
84
|
<PortalHost>
|
|
85
85
|
<View style={styles.header}>
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { pickFiles } from "./file_picker";
|
|
7
|
+
import { FOCUS_RING } from "./control_surface";
|
|
8
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
7
9
|
|
|
8
10
|
export interface FileDropzoneProps {
|
|
9
11
|
/** Receives the picked/dropped files. Bytes/upload belong to the host —
|
|
@@ -67,6 +69,7 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
67
69
|
const dragDepth = useRef(0);
|
|
68
70
|
const [dragging, setDragging] = useState(false);
|
|
69
71
|
const [hovered, setHovered] = useState(false);
|
|
72
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
70
73
|
|
|
71
74
|
// RN-web exposes the View's underlying HTMLElement through the ref — attach
|
|
72
75
|
// the DOM drag events there. Native platforms skip this entirely (press-to-
|
|
@@ -124,6 +127,7 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
124
127
|
accessibilityLabel={accessibilityLabel ?? label}
|
|
125
128
|
disabled={disabled}
|
|
126
129
|
onPress={handlePick}
|
|
130
|
+
{...focusProps}
|
|
127
131
|
onHoverIn={() => setHovered(true)}
|
|
128
132
|
onHoverOut={() => setHovered(false)}
|
|
129
133
|
style={[
|
|
@@ -133,6 +137,7 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
133
137
|
dragging ? styles.zoneDragging : null,
|
|
134
138
|
disabled ? styles.zoneDisabled : null,
|
|
135
139
|
style,
|
|
140
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
136
141
|
]}
|
|
137
142
|
>
|
|
138
143
|
<View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
|