@lotics/ui 4.5.0 → 4.6.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
@@ -340,7 +340,9 @@ surfaces' sparkles/severity glyphs, not functional affordances.
340
340
  · **Gate header**: a `Dialog` uses `DialogHeaderTitle`; a popover form uses `Text size="sm"
341
341
  weight="semibold"` + an optional `xs muted` subtitle.
342
342
  - **Time-constrained data gets a period filter** in the header band — `DateRangeFilterField`, never
343
- a static period badge. Every period-dependent number MUST follow the selection.
343
+ a static period badge. Every period-dependent number MUST follow the selection. Pass `includeTime`
344
+ when the time-of-day matters: the trigger previews the chosen time (locale-aware 24h/12h) and the
345
+ hour/minute selects label themselves from the `labels` prop (`hour`/`minute`/`dayPeriod`).
344
346
  - **Keyboard & focus — use `tabIndex`, never `focusable`.** RN-Web's `Pressable` silently ignores
345
347
  `focusable` (it writes its own `tabIndex`), so set a pressable control's tab-stop status with
346
348
  `tabIndex={0 | -1}` (`focusable` only works on a plain `View`/`TextInput`). Roving widgets
@@ -367,8 +369,13 @@ surfaces' sparkles/severity glyphs, not functional affordances.
367
369
  can't show; if there are none, drop the strip. Card stat rails use `KPICard`.
368
370
  - **Numbers**: free-standing numerals `<Text tabular>`. Money: `formatMoney(n)` from
369
371
  `@lotics/ui/format_money` (`compact` for strip captions). Dates: `formatDate(value, opts)` from
370
- `@lotics/ui/format_date` (`format:"datetime"`, `compact` dd/MM). Never hand-roll grouping/₫ or
371
- dd/MM.
372
+ `@lotics/ui/format_date` — the date-VALUE formatter. `format` (date STYLE) `date` (22/05/2026) ·
373
+ `medium` (22 thg 5, 2026) · `long` (22 tháng 5, 2026) · `dayMonth` (22 thg 5) · `monthYear`
374
+ (Tháng 5 2026); **`time: true` is orthogonal** — prepends the 24h time to ANY style
375
+ (`14:30 22/05/2026`, `14:30 22 tháng 5, 2026`); `compact` drops the year on `date`. It's `Intl` +
376
+ the product's conventions (stable `/`, naive-ISO parse, 24h time-first, `emptyLabel`) — never
377
+ hand-roll a date via `Intl`/`padStart` for value display. (Exempt: a component's own internal
378
+ chrome — a calendar's header/weekday/a11y labels, a gantt axis — renders its own set.)
372
379
  - **Every number is a door** (except the KPI strip). A component that summarizes records leads to
373
380
  the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
374
381
  Expansion happens IMMEDIATELY below the pressed element — the composable `Accordion` family
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -9,6 +9,7 @@ import { Switch } from "./switch";
9
9
  import { useScreenSize } from "./use_screen_size";
10
10
  import { SegmentLabels } from "./date_segments";
11
11
  import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
12
+ import { formatDate } from "./format_date";
12
13
 
13
14
  type SelectionMode = "single" | "range";
14
15
 
@@ -93,18 +94,9 @@ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
93
94
  }
94
95
  }
95
96
 
97
+ /** The panel's readable date label for a selected bound — the canonical `medium` style. */
96
98
  function formatDateDisplay(date: Date | null, locale: string | undefined): string {
97
- if (!date) return "";
98
- const isoDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
99
- try {
100
- return new Intl.DateTimeFormat(locale, {
101
- month: "short",
102
- day: "numeric",
103
- year: "numeric",
104
- }).format(date);
105
- } catch {
106
- return isoDate;
107
- }
99
+ return formatDate(date, { format: "medium", locale });
108
100
  }
109
101
 
110
102
  // =============================================================================
@@ -7,6 +7,7 @@ import { Button } from "./button";
7
7
  import { PressableHighlight } from "./pressable_highlight";
8
8
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
9
9
  import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
10
+ import { formatDate } from "./format_date";
10
11
 
11
12
  // =============================================================================
12
13
  // DateRangeFilterField — the common filter composition over DateFilter:
@@ -33,7 +34,8 @@ export interface DateRangeFilterFieldProps {
33
34
  includeTime?: boolean;
34
35
  /** Translated labels (presets + footer + placeholder). Defaults to English. */
35
36
  labels?: Partial<DateRangeFilterFieldLabels>;
36
- /** BCP-47 locale for the calendar + trigger date display. Defaults to "en-US". */
37
+ /** BCP-47 locale for the calendar + trigger date display. Date display defaults to
38
+ * the kit's home market (vi-VN, via `formatDate`) when unset. */
37
39
  locale?: string;
38
40
  testID?: string;
39
41
  }
@@ -43,17 +45,18 @@ const EMPTY_VALUE: DateFilterValue = {
43
45
  end: { date: null, time: null },
44
46
  };
45
47
 
46
- function formatDate(date: Date | null, locale: string | undefined): string {
48
+ /**
49
+ * A bound's date + its separate "HH:mm" time → one localized string via the kit's
50
+ * canonical `formatDate` (time-first "HH:mm dd/MM/yyyy"). Date-only when there is no
51
+ * time; "" when there is no date.
52
+ */
53
+ function formatBound(date: Date | null, time: string | null, locale: string | undefined): string {
47
54
  if (!date) return "";
48
- try {
49
- return new Intl.DateTimeFormat(locale, {
50
- day: "2-digit",
51
- month: "2-digit",
52
- year: "numeric",
53
- }).format(date);
54
- } catch {
55
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
56
- }
55
+ const m = time ? /^(\d{1,2}):(\d{2})/.exec(time) : null;
56
+ if (!m) return formatDate(date, { locale });
57
+ const dt = new Date(date);
58
+ dt.setHours(Number(m[1]), Number(m[2]), 0, 0);
59
+ return formatDate(dt, { time: true, locale });
57
60
  }
58
61
 
59
62
  /**
@@ -64,21 +67,14 @@ function formatDate(date: Date | null, locale: string | undefined): string {
64
67
  * period rhythm, not date pairs.
65
68
  */
66
69
  function formatRangeDisplay(start: Date, end: Date, locale: string | undefined): string {
67
- if (start.toDateString() === end.toDateString()) return formatDate(start, locale);
70
+ if (start.toDateString() === end.toDateString()) return formatDate(start, { locale });
68
71
 
69
72
  const wholeMonth =
70
73
  start.getDate() === 1 &&
71
74
  start.getMonth() === end.getMonth() &&
72
75
  start.getFullYear() === end.getFullYear() &&
73
76
  end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
74
- if (wholeMonth) {
75
- try {
76
- const label = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(start);
77
- return label.charAt(0).toUpperCase() + label.slice(1);
78
- } catch {
79
- return `${start.getMonth() + 1}/${start.getFullYear()}`;
80
- }
81
- }
77
+ if (wholeMonth) return formatDate(start, { format: "monthYear", locale });
82
78
 
83
79
  const wholeYear =
84
80
  start.getFullYear() === end.getFullYear() &&
@@ -88,7 +84,27 @@ function formatRangeDisplay(start: Date, end: Date, locale: string | undefined):
88
84
  end.getDate() === 31;
89
85
  if (wholeYear) return String(start.getFullYear());
90
86
 
91
- return `${formatDate(start, locale)} – ${formatDate(end, locale)}`;
87
+ return `${formatDate(start, { locale })} – ${formatDate(end, { locale })}`;
88
+ }
89
+
90
+ /**
91
+ * The trigger text. An untimed range folds to a compact period (month / year /
92
+ * day) via `formatRangeDisplay`. Once a time is set (and `includeTime`) a timed
93
+ * window isn't a clean period, so it shows "time date – time date". "" when there
94
+ * is no value (caller shows the placeholder).
95
+ */
96
+ function formatTrigger(value: DateFilterValue, includeTime: boolean, locale: string | undefined): string {
97
+ const { start, end } = value;
98
+ const sTime = includeTime ? start.time : null;
99
+ const eTime = includeTime ? end.time : null;
100
+
101
+ if (start.date && end.date && !sTime && !eTime) {
102
+ return formatRangeDisplay(start.date, end.date, locale);
103
+ }
104
+ if (start.date || end.date) {
105
+ return `${formatBound(start.date, sTime, locale)} – ${formatBound(end.date, eTime, locale)}`;
106
+ }
107
+ return "";
92
108
  }
93
109
 
94
110
  export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
@@ -97,12 +113,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
97
113
  const [open, setOpen] = useState(false);
98
114
 
99
115
  const hasValue = Boolean(value.start.date || value.end.date);
100
- const display =
101
- value.start.date && value.end.date
102
- ? formatRangeDisplay(value.start.date, value.end.date, locale)
103
- : hasValue
104
- ? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
105
- : labels.placeholder;
116
+ const display = formatTrigger(value, Boolean(includeTime), locale) || labels.placeholder;
106
117
 
107
118
  return (
108
119
  <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
@@ -14,12 +14,40 @@ describe("formatDate", () => {
14
14
  expect(formatDate("2026-05-22", { locale: "vi-VN", compact: true })).toBe("22/05");
15
15
  });
16
16
 
17
- test("datetime includes 24h time", () => {
18
- expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", format: "datetime" })).toBe("22/05/2026 14:30");
17
+ test("time prepends the 24h time, then the locale date", () => {
18
+ expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", time: true })).toBe("14:30 22/05/2026");
19
19
  });
20
20
 
21
- test("compact datetime drops the year, keeps the time", () => {
22
- expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", format: "datetime", compact: true })).toBe("22/05 09:05");
21
+ test("time + compact is time-first and drops the year", () => {
22
+ expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", time: true, compact: true })).toBe("09:05 22/05");
23
+ });
24
+
25
+ test("time composes with a readable style — the orthogonal axis", () => {
26
+ expect(formatDate("2026-09-22T14:30", { format: "long", time: true, locale: "en-US" })).toBe("14:30 September 22, 2026");
27
+ });
28
+
29
+ test("medium — readable, abbreviated month", () => {
30
+ expect(formatDate("2026-05-22", { format: "medium", locale: "en-US" })).toBe("May 22, 2026");
31
+ // vi word output is ICU-dependent; assert the day + year are present (day-first locale).
32
+ expect(formatDate("2026-05-22", { format: "medium", locale: "vi-VN" })).toMatch(/22.*2026/);
33
+ });
34
+
35
+ test("long — readable, full month name", () => {
36
+ expect(formatDate("2026-09-22", { format: "long", locale: "en-US" })).toBe("September 22, 2026");
37
+ expect(formatDate("2026-09-22", { format: "long", locale: "vi-VN" })).toMatch(/22.*2026/);
38
+ });
39
+
40
+ test("dayMonth — day + abbreviated month, no year", () => {
41
+ expect(formatDate("2026-09-22", { format: "dayMonth", locale: "en-US" })).toBe("Sep 22");
42
+ expect(formatDate("2026-09-22", { format: "dayMonth", locale: "vi-VN" })).not.toMatch(/2026/);
43
+ });
44
+
45
+ test("monthYear — a period label, sentence-cased", () => {
46
+ expect(formatDate("2026-05-22", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
47
+ // Leading character is upper-cased even where the locale lowercases the month name.
48
+ const vi = formatDate("2026-05-22", { format: "monthYear", locale: "vi-VN" });
49
+ expect(vi.charAt(0)).toBe(vi.charAt(0).toUpperCase());
50
+ expect(vi).toMatch(/2026/);
23
51
  });
24
52
 
25
53
  test("date-only ISO does not drift across local timezone (wall-clock parse)", () => {
@@ -1,16 +1,36 @@
1
- export type DateFormatStyle = "date" | "datetime";
1
+ /**
2
+ * The date STYLE — orthogonal to time (pass `time: true` to prepend a 24h time to any of these).
3
+ * `date` reassembles dd/MM with a stable "/"; the readable styles use word months in locale order.
4
+ * NOT for a component's own internal chrome — a calendar's header / weekday / a11y labels, a gantt
5
+ * axis — which render their own internally-consistent label set with `Intl` directly.
6
+ */
7
+ export type DateFormatStyle =
8
+ | "date" // 22/05/2026 — numeric
9
+ | "medium" // 22 thg 5, 2026 / Sep 22, 2026 — readable, abbreviated month
10
+ | "long" // 22 tháng 5, 2026 / September 22, 2026 — readable, full month
11
+ | "dayMonth" // 22 thg 5 / Sep 22 — day + abbreviated month, no year
12
+ | "monthYear"; // Tháng 5 2026 / May 2026 — a period label (sentence-cased)
2
13
 
3
14
  export interface FormatDateOptions {
4
- /** "date" → 22/05/2026 · "datetime" → 22/05/2026 14:30. Default "date". */
15
+ /** The date style. Default "date". */
5
16
  format?: DateFormatStyle;
17
+ /** Prepend the 24h time — "14:30 <date>". Composes with ANY `format`. Default false. */
18
+ time?: boolean;
6
19
  /** BCP-47 locale. Defaults to the product's home market, "vi-VN". */
7
20
  locale?: string;
8
- /** Drop the year "22/05" instead of "22/05/2026" for dense rows / timelines. */
21
+ /** Drop the year ("22/05") on the numeric `date` style. */
9
22
  compact?: boolean;
10
23
  /** Rendered for null / empty / unparseable input. Default "". */
11
24
  emptyLabel?: string;
12
25
  }
13
26
 
27
+ const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.DateTimeFormatOptions> = {
28
+ medium: { day: "numeric", month: "short", year: "numeric" },
29
+ long: { day: "numeric", month: "long", year: "numeric" },
30
+ dayMonth: { day: "numeric", month: "short" },
31
+ monthYear: { month: "long", year: "numeric" },
32
+ };
33
+
14
34
  const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
15
35
 
16
36
  const pad2 = (n: number) => String(n).padStart(2, "0");
@@ -43,29 +63,47 @@ export function toISODate(value: Date | string | null | undefined): string {
43
63
  }
44
64
 
45
65
  /**
46
- * THE date formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string and
47
- * returns a localized display string; defaults to the home market: `22/05/2026` (date) /
48
- * `22/05/2026 14:30` (datetime). `compact` drops the year for dense rows (`22/05`). Never
49
- * hand-roll dd/MM with `padStart` / `getMonth` call this.
66
+ * THE date-value formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
67
+ * and returns a localized display string; defaults to the home market. `format` picks the date
68
+ * style: `date` → `22/05/2026` · `medium` `22 thg 5, 2026` · `long` → `22 tháng 5, 2026` ·
69
+ * `dayMonth` `22 thg 5` · `monthYear` `Tháng 5 2026`. **`time: true` prepends the 24h time to
70
+ * ANY style** — `14:30 22/05/2026`, `14:30 22 tháng 5, 2026` (time-first). `compact` drops the
71
+ * year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
72
+ * `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
73
+ * {@link DateFormatStyle}.)
50
74
  */
51
75
  export function formatDate(value: Date | string | null | undefined, options: FormatDateOptions = {}): string {
52
- const { format = "date", locale = "vi-VN", compact = false, emptyLabel = "" } = options;
76
+ const { format = "date", time = false, locale = "vi-VN", compact = false, emptyLabel = "" } = options;
53
77
  const date = parseDate(value);
54
78
  if (!date) return emptyLabel;
55
- // Use Intl only for the locale-aware part ORDER (dd/MM for vi-VN, MM/dd for en-US), then
56
- // reassemble with a consistent "/" — Intl's own separator is inconsistent across CLDR (vi-VN
57
- // uses "/" with a year but "-" without). The time is appended as a stable 24h " HH:mm" (no
58
- // locale comma, no AM/PM), matching the compact data convention.
59
- let parts: Intl.DateTimeFormatPart[];
60
- try {
61
- parts = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" }).formatToParts(date);
62
- } catch {
63
- return emptyLabel;
79
+
80
+ let dateStr: string;
81
+ if (format === "date") {
82
+ // Numeric: Intl only for the locale-aware part ORDER (dd/MM for vi-VN, MM/dd for en-US), then
83
+ // reassemble with a consistent "/" — Intl's own separator is inconsistent across CLDR (vi-VN
84
+ // uses "/" with a year but "-" without). `compact` drops the year.
85
+ let parts: Intl.DateTimeFormatPart[];
86
+ try {
87
+ parts = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" }).formatToParts(date);
88
+ } catch {
89
+ return emptyLabel;
90
+ }
91
+ dateStr = parts
92
+ .filter((p) => p.type === "day" || p.type === "month" || (!compact && p.type === "year"))
93
+ .map((p) => p.value)
94
+ .join("/");
95
+ } else {
96
+ // Readable styles: word months in locale order — Intl's output is correct here (the separator
97
+ // inconsistency that forces the numeric reassembly is specific to the all-numeric form).
98
+ try {
99
+ dateStr = new Intl.DateTimeFormat(locale, READABLE_OPTS[format]).format(date);
100
+ } catch {
101
+ return emptyLabel;
102
+ }
103
+ // A period label leads a line → sentence-case (a no-op where the locale already capitalizes).
104
+ if (format === "monthYear") dateStr = dateStr.charAt(0).toUpperCase() + dateStr.slice(1);
64
105
  }
65
- let out = parts
66
- .filter((p) => p.type === "day" || p.type === "month" || (!compact && p.type === "year"))
67
- .map((p) => p.value)
68
- .join("/");
69
- if (format === "datetime") out += ` ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
70
- return out;
106
+
107
+ // Time-first: a stable 24h "HH:mm " prepended (no locale comma, no AM/PM), per the product convention.
108
+ return time ? `${pad2(date.getHours())}:${pad2(date.getMinutes())} ${dateStr}` : dateStr;
71
109
  }
@@ -67,7 +67,9 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
67
67
  [value, commit],
68
68
  );
69
69
 
70
- const display = formatDate(value, { format, locale, emptyLabel: "" });
70
+ // `format` here is the field config (date vs datetime); the display formatter only needs
71
+ // whether to show a time → map it to the orthogonal `time` flag.
72
+ const display = formatDate(value, { time: format === "datetime", locale, emptyLabel: "" });
71
73
 
72
74
  return (
73
75
  <View>
@@ -10,7 +10,6 @@ import {
10
10
  fieldOrder,
11
11
  from12h,
12
12
  getTimeLayout,
13
- placeholderFor,
14
13
  to12h,
15
14
  } from "./date_segments";
16
15
 
@@ -119,13 +118,13 @@ export function TimeField(props: TimeFieldProps) {
119
118
  "hour" | "minute" | "dayPeriod",
120
119
  { options: Option[]; onSelect: (v: string) => void; label: string; width: number }
121
120
  > = {
122
- hour: { options: hourOptions, onSelect: setHour, label: segmentLabels.hour, width: 58 },
123
- minute: { options: minuteOptions, onSelect: setMinute, label: segmentLabels.minute, width: 58 },
121
+ hour: { options: hourOptions, onSelect: setHour, label: segmentLabels.hour, width: 78 },
122
+ minute: { options: minuteOptions, onSelect: setMinute, label: segmentLabels.minute, width: 78 },
124
123
  dayPeriod: {
125
124
  options: periodOptions,
126
125
  onSelect: setPeriod,
127
126
  label: segmentLabels.dayPeriod,
128
- width: 66,
127
+ width: 78,
129
128
  },
130
129
  };
131
130
 
@@ -142,7 +141,7 @@ export function TimeField(props: TimeFieldProps) {
142
141
  options={c.options}
143
142
  onSelect={c.onSelect}
144
143
  accessibilityLabel={c.label}
145
- placeholder={placeholderFor(type)}
144
+ placeholder={c.label}
146
145
  disabled={disabled}
147
146
  width={c.width}
148
147
  />