@lotics/ui 11.1.0 → 11.3.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 CHANGED
@@ -227,13 +227,27 @@ clicking floats a multi `OptionList` (checkbox rows), CLOSING commits the new se
227
227
  `onSave` — never a borderless `Select` posing as an inline field) — all
228
228
  on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
229
229
  saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
230
- without losing the edit. A STACK of rows lives in a `DetailTable` (label ·
230
+ without losing the edit. To let a value be UNSET (a diff-write CLEAR — unassign, remove a due
231
+ date, drop a select), pass **`onClear`** to `InlineSelect` / `InlineMemberSelect` /
232
+ `InlineDatePicker`. It surfaces through each popover's OWN clear affordance — `InlineSelect` renders
233
+ a compact left-aligned "Clear" `Button` below the `OptionList` (only while a value is set);
234
+ `InlineDatePicker` reuses the calendar's own footer "Clear" button. NOT a bolted-on sibling row: that
235
+ can't reach the option list's active-highlight (so the last-hovered option stays lit) and would
236
+ double the footer hairline;
237
+ and never a persistent ✕ on the resting cell (noise on a dense board, and a pressable nested in the
238
+ view's button trigger is invalid DOM). `onSave`'s `next` stays non-null so a caller opts in per field;
239
+ the clear fires `onClear`, which writes `null` (the app workflow must ACCEPT null on that input — a
240
+ `select`/`date`/`member` field clears on null). `InlineTagSelect` (multi) needs no `onClear` — an
241
+ empty set is already a valid `onSave`. A STACK of rows lives in a `DetailTable` (label ·
231
242
  value · trailing laid out like a TABLE: `labelWidth` / `trailingWidth` /
232
243
  `minHeight` set ONCE on the parent, plus the 6px row gap the zinc-50 chips
233
244
  need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a
234
245
  trailing action/badge, so EVERY row reserves the column and one row's
235
246
  `Copy` button never makes its editor narrower than its neighbours'. An editor AT REST sits
236
- on a zinc-50 chip — THE editability affordance: users see what's editable without hovering.
247
+ on a zinc-50 chip — THE editability affordance: users see what's editable without hovering; the
248
+ picker-opening editors (`InlineSelect`/`InlineDatePicker`/`InlineTimePicker`) additionally carry a
249
+ rest glyph (chevron / calendar / clock) marking them tappable pickers without hover — the
250
+ touch-critical cue plain text/number fields don't get.
237
251
  `background="transparent"` opts a field out of the chip — for DENSE, uniformly-editable
238
252
  surfaces (a task list/board where EVERY cell edits: the chip repeated everywhere is noise and
239
253
  distinguishes nothing; hover/focus still reveal the input). Keep the chip wherever editable and
@@ -1062,7 +1076,7 @@ check_circle (CheckCircle — the completion ring: an empty ring that springs to
1062
1076
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
1063
1077
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
1064
1078
  sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Example: `tpl_task_board`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
1065
- step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
1079
+ step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · screen_router (ScreenRouter + Screen + useScreenRouter — the SCREENS compound: a flat navigation stack (`navigate("/case/:id")` pushes, `goBack` pops, `canGoBack`, route `params`; stacked screens stay mounted `display:none` so scroll survives the round trip). Dialog BAKES a router in (`<Dialog><Screen route="">…`); ANY other container hosts the standalone `<ScreenRouter>` — and it wraps AROUND the container so the CHROME can read the stack: a Drawer drilling into a LINKED record swaps its header to a BACK IconButton + the pushed record's id while `canGoBack` (sequence ◀ ▶ hides — stepping the root from inside a linked record disorients), and the pushed `<Screen route="/case/:id">` is a REAL editable workspace with its own footer CTAs. Key the router by record id so stepping ◀ ▶ resets the stack. Worked example: `tpl_item_list` drawer) · popover (Popover + PopoverTrigger + PopoverContent — **PopoverContent already insets its body 12px; put content directly in it, NEVER add your own padding View (that double-pads). Title/actions via PopoverHeader / PopoverFooter**) · popover_nav (usePopoverNav + PopoverScreen + PopoverNavHeader — the popover's built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes, `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the root and screens render conditionally (unmounted when inactive — no scroll preservation), `PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack` (`right` slot, `backLabel`). For a multi-screen menu inside ONE popover (an avatar/settings menu drilling into a sub-panel); route PATTERNS, `params`, and stacked-alive screens are `screen_router`'s job. Distinct from `Popover`'s plain `PopoverHeader` children container) · tooltip ·
1066
1080
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
1067
1081
  CalloutText · CalloutActions) · kpi_card · kpi_strip · summary_line (SummaryLine — the light inline summary of a register/list's FILTERED view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band) · metric · trend_chip · sparkline ·
1068
1082
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
@@ -265,8 +265,8 @@ export function TplTaskBoard() {
265
265
  }
266
266
 
267
267
  const propertyCols: DataGridColumn<Task>[] = [
268
- { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect background="transparent" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
269
- { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} placeholder="No date" accessibilityLabel="Due date" /> },
268
+ { key: "assignee", label: "Assignee", width: 160, sortable: true, cell: (t) => <InlineMemberSelect background="transparent" members={MEMBERS} value={t.ownerId} onSave={(id) => patch(t.id, { ownerId: id })} onClear={() => patch(t.id, { ownerId: null })} placeholder="Unassigned" accessibilityLabel="Assignee" /> },
269
+ { key: "due", label: "Due", width: 140, sortable: true, cell: (t) => <InlineDatePicker background="transparent" value={t.due} optionalTime onSave={(v) => patch(t.id, { due: v })} onClear={() => patch(t.id, { due: null })} placeholder="No date" accessibilityLabel="Due date" /> },
270
270
  { key: "status", label: "Status", width: 130, sortable: true, cell: (t) => <InlineSelect background="transparent" value={t.status} options={STATUS_OPTIONS} onSave={(s) => patch(t.id, { status: s })} renderSelected={renderStatusBadge} renderOptionContent={renderStatusBadge} accessibilityLabel="Status" /> },
271
271
  { key: "tags", label: "Tags", width: 168, cell: (t) => <Select multi searchable allowCustom value={t.tags.map((tag) => tag.value)} onValueChange={(next) => patch(t.id, { tags: next.map(tagOf) })} options={TAG_OPTIONS} renderSelected={renderTagBadge} renderOptionContent={renderTagBadge} style={{ borderColor: "transparent" }} placeholder="Add tags" accessibilityLabel="Tags" /> },
272
272
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.1.0",
3
+ "version": "11.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -7,6 +7,15 @@ import { Picker, PickerOption } from "./picker";
7
7
  import { IconButton } from "./icon_button";
8
8
  import { FOCUS_RING } from "./control_surface";
9
9
  import { useFocusRing } from "./use_focus_ring";
10
+ import { useLoticsLocale } from "./locale";
11
+
12
+ /** Accessible names for the calendar's month-navigation arrows. Backs the
13
+ * `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
14
+ * `locale` prop, not from here.) */
15
+ export interface CalendarLabels {
16
+ previousMonth: string;
17
+ nextMonth: string;
18
+ }
10
19
 
11
20
  function DayCell(props: {
12
21
  day: number;
@@ -237,6 +246,7 @@ function CalendarMonth(props: CalendarMonthProps) {
237
246
  firstDayOfWeek = 1, // Default to Monday
238
247
  locale,
239
248
  } = props;
249
+ const navLabels = useLoticsLocale().calendar;
240
250
 
241
251
  const names = getLocalizedNames(locale);
242
252
  const daysInMonth = getDaysInMonth(year, month);
@@ -314,14 +324,14 @@ function CalendarMonth(props: CalendarMonthProps) {
314
324
  {showNavigation && (
315
325
  <IconButton
316
326
  icon="chevron-left"
317
- accessibilityLabel="Previous month"
327
+ accessibilityLabel={navLabels.previousMonth}
318
328
  onPress={handlePrevMonth}
319
329
  />
320
330
  )}
321
331
  {showLeftArrow && onPrevMonth && (
322
332
  <IconButton
323
333
  icon="chevron-left"
324
- accessibilityLabel="Previous month"
334
+ accessibilityLabel={navLabels.previousMonth}
325
335
  onPress={onPrevMonth}
326
336
  />
327
337
  )}
@@ -348,14 +358,14 @@ function CalendarMonth(props: CalendarMonthProps) {
348
358
  {showNavigation && (
349
359
  <IconButton
350
360
  icon="chevron-right"
351
- accessibilityLabel="Next month"
361
+ accessibilityLabel={navLabels.nextMonth}
352
362
  onPress={handleNextMonth}
353
363
  />
354
364
  )}
355
365
  {showRightArrow && onNextMonth && (
356
366
  <IconButton
357
367
  icon="chevron-right"
358
- accessibilityLabel="Next month"
368
+ accessibilityLabel={navLabels.nextMonth}
359
369
  onPress={onNextMonth}
360
370
  />
361
371
  )}
@@ -7,6 +7,7 @@ import { Calendar, CalendarRangeValue } from "./date_calendar";
7
7
  import { TimePicker } from "./time_picker";
8
8
  import { DateField } from "./date_field";
9
9
  import { SegmentLabels } from "./date_segments";
10
+ import { useLoticsLocale } from "./locale";
10
11
  import { Icon } from "./icon";
11
12
  import { Button } from "./button";
12
13
  import { IconButton } from "./icon_button";
@@ -28,6 +29,10 @@ import {
28
29
  // Types
29
30
  // =============================================================================
30
31
 
32
+ /** All translatable strings the date picker renders — the calendar popover's
33
+ * quick actions + accessible names, plus the segmented field's per-segment
34
+ * labels ({@link SegmentLabels}). Backs the `datePicker` locale slice; a caller
35
+ * may still override per-instance via a `labels` prop. */
31
36
  export interface DatePickerLabels extends SegmentLabels {
32
37
  /** Quick action: set the value to today. Shown for the `date` format. */
33
38
  today: string;
@@ -51,25 +56,6 @@ export interface DatePickerLabels extends SegmentLabels {
51
56
  removeTime: string;
52
57
  }
53
58
 
54
- const DEFAULT_LABELS: DatePickerLabels = {
55
- today: "Today",
56
- now: "Now",
57
- clear: "Clear",
58
- done: "Done",
59
- openCalendar: "Open calendar",
60
- time: "Time",
61
- startTime: "Start time",
62
- endTime: "End time",
63
- addTime: "Add time",
64
- removeTime: "Remove time",
65
- year: "Year",
66
- month: "Month",
67
- day: "Day",
68
- hour: "Hour",
69
- minute: "Minute",
70
- dayPeriod: "AM/PM",
71
- };
72
-
73
59
  export interface DatePickerProps {
74
60
  value?: string | null;
75
61
  onValueChange: (value: string) => void;
@@ -82,7 +68,9 @@ export interface DatePickerProps {
82
68
  /** Fires when the text input loses focus, after any typed value is committed. */
83
69
  onBlur?: () => void;
84
70
  testID?: string;
85
- /** Translated labels for the popover quick actions and accessible names. Defaults to English. */
71
+ /** Per-instance label overrides (quick actions + accessible names). Anything
72
+ * omitted resolves from the `datePicker` locale slice — English by default, or
73
+ * the active `LoticsLocaleProvider` pack. */
86
74
  labels?: Partial<DatePickerLabels>;
87
75
  /** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
88
76
  locale?: string;
@@ -119,7 +107,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
119
107
  const optTime = !!optionalTime && !isRange;
120
108
  const valueHasTime = isoHasTime(value);
121
109
  const hasTime = optTime ? valueHasTime : hasTimeFormat(format);
122
- const mergedLabels = { ...DEFAULT_LABELS, ...labels };
110
+ const mergedLabels = { ...useLoticsLocale().datePicker, ...labels };
123
111
 
124
112
  const [startIso, endIso] = useMemo<[string, string]>(() => {
125
113
  if (!value) return ["", ""];
@@ -342,9 +330,10 @@ export function DatePicker(props: DatePickerProps) {
342
330
  const optTime = !!optionalTime && !isRange;
343
331
  // The trigger's segments follow the value's shape in optionalTime mode.
344
332
  const hasTime = optTime ? isoHasTime(value) : hasTimeFormat(format);
333
+ const datePickerLabels = useLoticsLocale().datePicker;
345
334
  const mergedLabels = useMemo<DatePickerLabels>(
346
- () => ({ ...DEFAULT_LABELS, ...labels }),
347
- [labels],
335
+ () => ({ ...datePickerLabels, ...labels }),
336
+ [datePickerLabels, labels],
348
337
  );
349
338
 
350
339
  const [open, setOpen] = useState(false);
package/src/icon.tsx CHANGED
@@ -40,6 +40,7 @@ import Box from "lucide-react-native/dist/esm/icons/box";
40
40
  import Brackets from "lucide-react-native/dist/esm/icons/brackets";
41
41
  import Calculator from "lucide-react-native/dist/esm/icons/calculator";
42
42
  import Calendar from "lucide-react-native/dist/esm/icons/calendar";
43
+ import CalendarClock from "lucide-react-native/dist/esm/icons/calendar-clock";
43
44
  import CalendarOff from "lucide-react-native/dist/esm/icons/calendar-off";
44
45
  import Camera from "lucide-react-native/dist/esm/icons/camera";
45
46
  import Check from "lucide-react-native/dist/esm/icons/check";
@@ -234,6 +235,7 @@ const iconComponents = {
234
235
  brackets: Brackets,
235
236
  calculator: Calculator,
236
237
  calendar: Calendar,
238
+ "calendar-clock": CalendarClock,
237
239
  "calendar-off": CalendarOff,
238
240
  camera: Camera,
239
241
  check: Check,
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
+ import { Icon } from "./icon";
4
5
  import { colors } from "./colors";
5
6
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
6
7
  import { DatePickerPanel } from "./date_picker";
@@ -14,6 +15,11 @@ export interface InlineDatePickerProps {
14
15
  /** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
15
16
  value: string | null;
16
17
  onSave: (next: string) => void | Promise<void>;
18
+ /** Unset the date to empty. Provide it to make the value clearable: the
19
+ * calendar's own "Clear" button then unsets the field through this callback
20
+ * (without `onClear` an empty selection is ignored — a required date can't be
21
+ * emptied). Kept separate from `onSave` (whose next is a non-empty string). */
22
+ onClear?: () => void | Promise<void>;
17
23
  /** "date" (default) or "datetime". */
18
24
  format?: "date" | "datetime";
19
25
  /** Let the user OPTIONALLY add a time to a date (the value's shape decides — date
@@ -36,7 +42,7 @@ export interface InlineDatePickerProps {
36
42
  * dismissing without a change reverts.
37
43
  */
38
44
  export function InlineDatePicker(props: InlineDatePickerProps) {
39
- const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
45
+ const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
40
46
  const labels = useLoticsLocale().inline;
41
47
  const [open, setOpen] = useState(false);
42
48
  const [draft, setDraft] = useState<string | null>(value);
@@ -48,7 +54,23 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
48
54
 
49
55
  const commit = useCallback(async () => {
50
56
  const next = draftRef.current;
51
- if (next === value || !next) return;
57
+ if (next === value) return;
58
+ // The panel's "Clear" empties the draft (onValueChange("")). Treat that as an
59
+ // explicit unset when the field is clearable (onClear + a current value);
60
+ // otherwise ignore an empty draft — a required date can't be emptied.
61
+ if (!next) {
62
+ if (!onClear || !value) return;
63
+ setSaving(true);
64
+ setError(null);
65
+ try {
66
+ await onClear();
67
+ } catch (e) {
68
+ setError(e instanceof Error && e.message ? e.message : labels.saveError);
69
+ } finally {
70
+ setSaving(false);
71
+ }
72
+ return;
73
+ }
52
74
  setSaving(true);
53
75
  setError(null);
54
76
  try {
@@ -58,7 +80,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
58
80
  } finally {
59
81
  setSaving(false);
60
82
  }
61
- }, [value, onSave]);
83
+ }, [value, onSave, onClear, labels.saveError]);
62
84
 
63
85
  const onOpenChange = useCallback(
64
86
  (next: boolean) => {
@@ -91,7 +113,10 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
91
113
  disabled={disabled}
92
114
  active={open && !disabled}
93
115
  accessibilityLabel={accessibilityLabel}
94
- trailing={saving ? <ActivityIndicator size={16} color={colors.zinc[400]} /> : undefined}
116
+ // A rest affordance (like the select's chevron): a calendar glyph marks
117
+ // the field as a tappable date control even when empty — no hover / pointer
118
+ // cursor needed, so it reads as interactive on touch.
119
+ trailing={saving ? <ActivityIndicator size={16} color={colors.zinc[400]} /> : <Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />}
95
120
  />
96
121
  </PopoverTrigger>
97
122
  <PopoverContent>
@@ -104,7 +129,11 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
104
129
  format={format}
105
130
  optionalTime={optionalTime}
106
131
  locale={locale}
107
- onRequestClose={() => setOpen(false)}
132
+ // Commit on panel close. A single-date pick and the "Today" button
133
+ // auto-close through here — route it through onOpenChange so commit
134
+ // runs. A bare setOpen(false) is a controlled close the Popover never
135
+ // reports to onOpenChange, so the pick/clear would be silently dropped.
136
+ onRequestClose={() => onOpenChange(false)}
108
137
  />
109
138
  </PopoverContent>
110
139
  </Popover>
@@ -112,6 +112,10 @@ interface InlineEditFrameProps {
112
112
  struck?: boolean;
113
113
  /** Resting surface — see {@link InlineEditBackground}. Default "tint". */
114
114
  background?: InlineEditBackground;
115
+ /** Right-aligned rest affordance shown in VIEW mode (e.g. a clock for a time
116
+ * field) — a hover-independent cue the field opens a picker. Omit for plain
117
+ * text/number inputs (typing IS the affordance). */
118
+ affordance?: ReactNode;
115
119
  }
116
120
 
117
121
  interface InlineEditViewProps {
@@ -204,6 +208,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
204
208
  accessibilityLabel,
205
209
  struck,
206
210
  background,
211
+ affordance,
207
212
  } = props;
208
213
 
209
214
  if (!editing) {
@@ -216,6 +221,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
216
221
  disabled={disabled}
217
222
  accessibilityLabel={accessibilityLabel}
218
223
  struck={struck}
224
+ trailing={affordance}
219
225
  />
220
226
  );
221
227
  }
@@ -16,6 +16,10 @@ interface InlineMemberSelectProps {
16
16
  /** Commit a new assignment (the picked member id). Throwing surfaces the
17
17
  * inline error and reverts, like every inline editor. */
18
18
  onSave: (memberId: string) => void | Promise<void>;
19
+ /** Unassign — unset the field to empty. Provide it to make the assignment
20
+ * clearable: a compact "Clear" button appears below the picker options when a
21
+ * member is set. Kept separate from `onSave` (whose id is non-null). */
22
+ onClear?: () => void | Promise<void>;
19
23
  placeholder?: string;
20
24
  disabled?: boolean;
21
25
  accessibilityLabel?: string;
@@ -13,6 +13,11 @@ import { useLoticsLocale } from "./locale";
13
13
  export interface InlineSelectProps<T extends string> {
14
14
  value: T | null;
15
15
  onSave: (next: T) => void | Promise<void>;
16
+ /** Unset the field to empty. Provide it to make the value clearable: a compact
17
+ * "Clear" button appears below the options whenever there IS a value. Kept
18
+ * separate from `onSave` (whose next is a non-null `T`) so an app opts in
19
+ * without every caller having to handle null. */
20
+ onClear?: () => void | Promise<void>;
16
21
  options: PickerOption<T>[];
17
22
  /** Custom option content in the dropdown (icon + label, two-line, a badge…).
18
23
  * Omit for a plain label list — both render through the same `OptionList`. */
@@ -39,7 +44,7 @@ export interface InlineSelectProps<T extends string> {
39
44
  * Pass `renderOptionContent` for rich options, or omit it for a plain label list.
40
45
  */
41
46
  export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
42
- const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
47
+ const { value, onSave, onClear, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, background } = props;
43
48
  const labels = useLoticsLocale().inline;
44
49
  const [open, setOpen] = useState(false);
45
50
  const [saving, setSaving] = useState(false);
@@ -64,6 +69,20 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
64
69
  [value, onSave],
65
70
  );
66
71
 
72
+ const clear = useCallback(async () => {
73
+ setOpen(false);
74
+ if (value == null || !onClear) return;
75
+ setSaving(true);
76
+ setError(null);
77
+ try {
78
+ await onClear();
79
+ } catch (e) {
80
+ setError(e instanceof Error && e.message ? e.message : labels.saveError);
81
+ } finally {
82
+ setSaving(false);
83
+ }
84
+ }, [value, onClear]);
85
+
67
86
  return (
68
87
  <View>
69
88
  <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
@@ -90,6 +109,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
90
109
  options={options}
91
110
  value={value}
92
111
  onValueChange={(next) => void pick(next)}
112
+ onClear={onClear ? () => void clear() : undefined}
93
113
  onRequestClose={() => setOpen(false)}
94
114
  renderOptionContent={renderOptionContent}
95
115
  />
@@ -1,5 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { KeyboardEvent } from "react";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
3
5
  import { TimePicker } from "./time_picker";
4
6
  import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
5
7
 
@@ -53,6 +55,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
53
55
  error={edit.error}
54
56
  disabled={disabled}
55
57
  accessibilityLabel={accessibilityLabel}
58
+ affordance={<Icon name="clock" size={18} color={colors.zinc[400]} />}
56
59
  >
57
60
  <TimePicker
58
61
  value={edit.draft}
package/src/locale.tsx CHANGED
@@ -1,4 +1,6 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
+ import { type DatePickerLabels } from "./date_picker";
3
+ import { type CalendarLabels } from "./date_calendar";
2
4
  import { type PaginationLabels } from "./pagination";
3
5
  import { type SortHeaderLabels } from "./sort_header";
4
6
  import { type ConfidenceLabels } from "./confidence";
@@ -28,7 +30,9 @@ export interface LoticsLocale {
28
30
  /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
29
31
  * in-cell editors): the select-all/deselect-all links, the empty state, the
30
32
  * internal search-field placeholder, and the `Combobox` recents header. */
31
- optionList: { selectAll: string; deselectAll: string; noResults: string; recent: string; searchPlaceholder: string };
33
+ optionList: { selectAll: string; deselectAll: string; clear: string; noResults: string; recent: string; searchPlaceholder: string };
34
+ datePicker: DatePickerLabels;
35
+ calendar: CalendarLabels;
32
36
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
33
37
  * a call site doesn't pass a dimension-specific `clearLabel`. */
34
38
  filterChip: { clear: string };
@@ -99,7 +103,9 @@ export const en: LoticsLocale = {
99
103
  ascending: ", ascending",
100
104
  descending: ", descending",
101
105
  },
102
- optionList: { selectAll: "Select all", deselectAll: "Deselect all", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
106
+ optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
107
+ datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM" },
108
+ calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
103
109
  filterChip: { clear: "Clear" },
104
110
  formField: { optional: "Optional" },
105
111
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
@@ -176,7 +182,9 @@ export const vi: LoticsLocale = {
176
182
  ascending: " (tăng dần)",
177
183
  descending: " (giảm dần)",
178
184
  },
179
- optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
185
+ optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
186
+ datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH" },
187
+ calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
180
188
  filterChip: { clear: "Xóa" },
181
189
  formField: { optional: "Tùy chọn" },
182
190
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
@@ -5,6 +5,7 @@ import { Text } from "./text";
5
5
  import { Icon } from "./icon";
6
6
  import { Checkbox } from "./checkbox";
7
7
  import { MenuButton } from "./menu_button";
8
+ import { Button } from "./button";
8
9
  import { TextLink } from "./text_link";
9
10
  import { ActivityIndicator } from "./activity_indicator";
10
11
  import { TextInputField } from "./text_input_field";
@@ -25,6 +26,11 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
25
26
  accessibilityLabel?: string;
26
27
  selectAllLabel?: string;
27
28
  deselectAllLabel?: string;
29
+ /** Single-select only: unset the value. When provided AND a value is selected, a
30
+ * "Clear" row shows below the options (a real `MenuButton`, so it shares the
31
+ * option rows' height/hover/a11y). Hovering it moves the active highlight OFF
32
+ * the options — hence it lives inside the list, not as a sibling. */
33
+ onClear?: () => void;
28
34
  }
29
35
 
30
36
  /**
@@ -39,7 +45,7 @@ export interface OptionListProps<T extends string = string, MULTI extends boolea
39
45
  export function OptionList<T extends string, MULTI extends boolean = false, D = unknown>(
40
46
  props: OptionListProps<T, MULTI, D>,
41
47
  ) {
42
- const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search } = props;
48
+ const { testID, renderOptionContent, getOptionDescription, loading = false, accessibilityLabel, search, onClear } = props;
43
49
  const loc = useLoticsLocale().optionList;
44
50
  const emptyText = props.emptyText ?? loc.noResults;
45
51
  const selectAllLabel = props.selectAllLabel ?? loc.selectAll;
@@ -47,6 +53,12 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
47
53
  const list = useOptionList(props);
48
54
  const { small } = useScreenSize();
49
55
 
56
+ // The option rows set `activeIndex` on hover but have no hover-OUT, so entering
57
+ // the Clear footer clears it here — otherwise the last-hovered option stays lit
58
+ // while the pointer is on Clear. RN's types don't declare mouse handlers;
59
+ // rn-web forwards them to the DOM (the boundary cast pressable_row.tsx uses).
60
+ const clearHover = { onMouseEnter: () => list.setActiveIndex(-1, false) } as object;
61
+
50
62
  const onSearchKeyPress = (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
51
63
  if (list.handleKey(e.nativeEvent.key)) e.preventDefault();
52
64
  };
@@ -157,6 +169,14 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
157
169
  )}
158
170
  </ScrollView>
159
171
 
172
+ {onClear && !props.multi && list.hasSelection ? (
173
+ // A plain compact Button (matches the inline date picker's footer "Clear"),
174
+ // not a full-width row.
175
+ <View style={styles.clearRow} {...clearHover}>
176
+ <Button title={loc.clear} color="muted" onPress={onClear} />
177
+ </View>
178
+ ) : null}
179
+
160
180
  {list.showSelectAll || list.showDeselectAll ? (
161
181
  <View style={styles.selectAllContainer}>
162
182
  {list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
@@ -192,6 +212,14 @@ const styles = StyleSheet.create({
192
212
  option: {
193
213
  marginHorizontal: 0,
194
214
  },
215
+ // The single-select "Clear" footer — a left-aligned compact button, set off from
216
+ // the options above by the same hairline the select-all/deselect-all footer uses.
217
+ clearRow: {
218
+ flexDirection: "row",
219
+ paddingTop: 4,
220
+ borderTopWidth: 1,
221
+ borderTopColor: colors.border,
222
+ },
195
223
  statusRow: {
196
224
  alignItems: "center",
197
225
  justifyContent: "center",
@@ -86,6 +86,8 @@ export interface UseOptionList<T extends string, D> {
86
86
  deselectAll: () => void;
87
87
  showSelectAll: boolean;
88
88
  showDeselectAll: boolean;
89
+ /** Whether anything is selected — single: a non-empty value; multi: ≥1. */
90
+ hasSelection: boolean;
89
91
  listboxId: string;
90
92
  scrollRef: React.RefObject<ScrollView | null>;
91
93
  }
@@ -351,6 +353,7 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
351
353
  deselectAll,
352
354
  showSelectAll,
353
355
  showDeselectAll,
356
+ hasSelection,
354
357
  listboxId,
355
358
  scrollRef,
356
359
  };