@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.
Files changed (97) hide show
  1. package/AGENTS.md +127 -44
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_dossier.tsx +5 -4
  4. package/examples/tpl_pipeline.tsx +400 -0
  5. package/examples/tpl_record.tsx +5 -4
  6. package/examples/tpl_shifts.tsx +7 -5
  7. package/examples/tpl_task_board.tsx +473 -0
  8. package/examples/tpl_tasks.tsx +440 -0
  9. package/package.json +10 -5
  10. package/src/accordion.tsx +1 -1
  11. package/src/agent_progress.tsx +1 -1
  12. package/src/agent_run.tsx +1 -1
  13. package/src/breakdown.tsx +2 -2
  14. package/src/button.tsx +12 -3
  15. package/src/calendar/calendar_view.tsx +10 -10
  16. package/src/calendar/month_view.tsx +30 -11
  17. package/src/calendar/time_grid_view.tsx +25 -7
  18. package/src/card_select_item.tsx +11 -13
  19. package/src/check_circle.tsx +88 -0
  20. package/src/checkbox_input.tsx +5 -1
  21. package/src/chip.tsx +1 -1
  22. package/src/chip_group.tsx +1 -1
  23. package/src/column_filter.tsx +3 -2
  24. package/src/combobox.tsx +84 -219
  25. package/src/confidence.tsx +14 -3
  26. package/src/control_surface.ts +22 -7
  27. package/src/data_grid.tsx +159 -0
  28. package/src/date_calendar.tsx +46 -23
  29. package/src/date_field.tsx +10 -2
  30. package/src/date_filter.tsx +5 -9
  31. package/src/date_picker.tsx +78 -26
  32. package/src/date_picker_value.ts +11 -0
  33. package/src/date_range_filter_field.tsx +6 -4
  34. package/src/drawer.tsx +1 -1
  35. package/src/file_dropzone.tsx +5 -0
  36. package/src/file_gallery_modal.tsx +43 -42
  37. package/src/file_preview.web.tsx +25 -11
  38. package/src/file_preview_types.ts +9 -0
  39. package/src/file_row.tsx +5 -1
  40. package/src/file_rows.tsx +6 -2
  41. package/src/file_thumbnail.tsx +13 -4
  42. package/src/file_thumbnail_grid.tsx +5 -0
  43. package/src/files_editor.tsx +232 -0
  44. package/src/filter_chip.tsx +25 -12
  45. package/src/focus_ring_pressable.tsx +34 -0
  46. package/src/form_picker.tsx +3 -3
  47. package/src/gantt/gantt_view.tsx +25 -7
  48. package/src/heatmap.tsx +5 -1
  49. package/src/icon_button.tsx +2 -2
  50. package/src/image_gallery.tsx +8 -10
  51. package/src/index.css +8 -12
  52. package/src/inline_date_picker.tsx +11 -11
  53. package/src/inline_edit.tsx +22 -6
  54. package/src/inline_member_select.tsx +1 -1
  55. package/src/inline_select.tsx +9 -8
  56. package/src/inline_text_input.tsx +4 -1
  57. package/src/link.tsx +11 -1
  58. package/src/link_button.tsx +1 -1
  59. package/src/list_item.tsx +2 -2
  60. package/src/member_select.tsx +9 -4
  61. package/src/menu_button.tsx +2 -2
  62. package/src/menu_list_item.tsx +2 -2
  63. package/src/number_input.tsx +11 -4
  64. package/src/option_list.tsx +211 -0
  65. package/src/peek.tsx +1 -1
  66. package/src/picker.tsx +24 -213
  67. package/src/pressable_highlight.tsx +42 -23
  68. package/src/radio_picker.tsx +2 -2
  69. package/src/range_slider.tsx +35 -7
  70. package/src/react_native.d.ts +3 -0
  71. package/src/scroll_to_bottom.tsx +1 -1
  72. package/src/section.tsx +7 -2
  73. package/src/segmented_control.tsx +2 -2
  74. package/src/select.tsx +299 -0
  75. package/src/sort_header.tsx +1 -1
  76. package/src/sources.tsx +1 -1
  77. package/src/spreadsheet_view.tsx +43 -17
  78. package/src/status_grid.tsx +4 -3
  79. package/src/stepper.tsx +1 -1
  80. package/src/switch.tsx +5 -1
  81. package/src/switch_button.tsx +1 -1
  82. package/src/switcher.tsx +1 -1
  83. package/src/table.tsx +5 -1
  84. package/src/tabs.tsx +2 -2
  85. package/src/text_input_field.tsx +15 -4
  86. package/src/time_picker.tsx +11 -4
  87. package/src/timeline.tsx +1 -1
  88. package/src/use_focus_ring.ts +80 -0
  89. package/src/use_hover.ts +26 -0
  90. package/src/use_list_keyboard_nav.test.ts +71 -0
  91. package/src/use_list_keyboard_nav.ts +52 -5
  92. package/src/use_option_list.test.ts +193 -0
  93. package/src/use_option_list.ts +354 -0
  94. package/src/command_menu.tsx +0 -205
  95. package/src/picker_menu.tsx +0 -355
  96. package/src/tag_input.tsx +0 -203
  97. 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
+ });
@@ -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
- <Pressable
392
+ <DayCell
354
393
  key={dayIndex}
355
- accessibilityRole="button"
356
- accessibilityLabel={names.formatDayLabel(date)}
357
- accessibilityState={{ selected: isActive }}
358
- style={({ hovered }) => [
359
- styles.dayCell,
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>
@@ -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
- borderColor: colors.zinc["900"],
162
+ boxShadow: FOCUS_RING,
155
163
  },
156
164
  frameDisabled: {
157
165
  backgroundColor: colors.zinc["50"],
@@ -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 { TimeField } from "./time_field";
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
- <TimeField
273
+ <TimePicker
274
274
  value={value.start.time || ""}
275
- onChange={handleStartTimeChange}
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
- <TimeField
293
+ <TimePicker
296
294
  value={value.end.time || ""}
297
- onChange={handleEndTimeChange}
298
- segmentLabels={labels}
299
- locale={locale}
295
+ onValueChange={handleEndTimeChange}
300
296
  />
301
297
  </View>
302
298
  )}
@@ -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 { TimeField } from "./time_field";
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
- const hasTime = hasTimeFormat(format);
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
- if (date && format === "date") onRequestClose?.();
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={8} />
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
- <TimeField
226
+ <TimePicker
206
227
  accessibilityLabel={mergedLabels.startTime}
207
- segmentLabels={mergedLabels}
208
228
  value={timeText(startIso)}
209
- onChange={(t) => handleRangeTime("start", t)}
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
- <TimeField
236
+ <TimePicker
218
237
  accessibilityLabel={mergedLabels.endTime}
219
- segmentLabels={mergedLabels}
220
238
  value={timeText(endIso)}
221
- onChange={(t) => handleRangeTime("end", t)}
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
- <Text size="sm" color="muted">
229
- {mergedLabels.time}
230
- </Text>
231
- <TimeField
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
- onChange={handleSingleTime}
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="secondary"
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="secondary"
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 hasTime = hasTimeFormat(format);
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={18} color={disabled ? colors.zinc["300"] : colors.zinc["400"]} />
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
  });
@@ -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}>
@@ -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]}>