@lotics/ui 4.5.0 → 4.6.1
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 +25 -9
- package/examples/tpl_dieline.tsx +22 -5
- package/package.json +1 -1
- package/src/date_filter.tsx +3 -11
- package/src/date_range_filter_field.tsx +38 -27
- package/src/format_date.test.ts +32 -4
- package/src/format_date.ts +61 -23
- package/src/inline_date_picker.tsx +3 -1
- package/src/time_field.tsx +4 -5
package/AGENTS.md
CHANGED
|
@@ -222,8 +222,15 @@ accepts / edits / dismisses, the deterministic app applies. The agent owns judgm
|
|
|
222
222
|
(recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
|
|
223
223
|
Compose the surfaces as a loop, and reach for the right one by job:
|
|
224
224
|
|
|
225
|
-
- **Command** — `PromptField`,
|
|
226
|
-
|
|
225
|
+
- **Command** — `PromptField`, a single-row pill for a TEXT-ONLY trigger (Enter sends; the field
|
|
226
|
+
locks + the send spins while it runs). The imperative "do this" surface. Its `onAttach` is a bare
|
|
227
|
+
trigger with NO review step, so it CANNOT do attach→review→submit — **never auto-run an agent on
|
|
228
|
+
attach.** When a run needs a FILE + a prompt together (attach a photo, review/remove it, add a
|
|
229
|
+
note, THEN send), reach for **`Composer`** instead: fill `actionsButton` with the attach button,
|
|
230
|
+
the `files` slot with `FileThumbnail`s (each `onRemove`-able), and set `sendDisabled` so Send fires
|
|
231
|
+
with an attachment and/or text (`onSend(text)` — the host already holds the uploaded file id). The
|
|
232
|
+
canvas (`tpl_dieline`) uses `Composer` for exactly this; `Composer` morphs into `AgentProgress`
|
|
233
|
+
while running just like the pill.
|
|
227
234
|
- **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
|
|
228
235
|
spine, the running node a pulsing ink dot (a done step settles to a hollow ring). Transparent
|
|
229
236
|
work, NEVER a bare spinner — the
|
|
@@ -252,8 +259,9 @@ Compose the surfaces as a loop, and reach for the right one by job:
|
|
|
252
259
|
Ten shapes prove the range. The first four PRODUCE — pick by whether the work is a living artifact,
|
|
253
260
|
a conversation, a single pass, or generated prose:
|
|
254
261
|
- **Canvas** (`tpl_dieline`) — the page IS the design on a pannable/zoomable surface: it stays
|
|
255
|
-
CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom
|
|
256
|
-
|
|
262
|
+
CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom (a
|
|
263
|
+
`Composer`: attach a photo → review/remove it → add a note → send, with `sendDisabled` allowing a
|
|
264
|
+
file-and/or-text submit — NOT auto-run on attach) morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (the `RecordReview` field-card
|
|
257
265
|
in live-edit mode, carrying the single Download; minimizes to a pill) floats centre-right. Panel, zoom pill and composer
|
|
258
266
|
float ON TOP — they never shift the design. (Floating layers use a `pointerEvents:"none"` wrapper
|
|
259
267
|
with the interactive child set `"auto"`; RN-Web ignores `"box-none"` in style, so a full-width
|
|
@@ -309,8 +317,9 @@ controls — while **status/severity/diffs use functional colour** the way the r
|
|
|
309
317
|
|
|
310
318
|
Card chrome (borders, microlabels) stays neutral — colour marks the *state*, never the container.
|
|
311
319
|
The **composer keeps its icons**: `PromptField` is a single-row pill with circular attach + send
|
|
312
|
-
`Button`s; `
|
|
313
|
-
|
|
320
|
+
`Button`s; `Composer` (the file+prompt variant) shows an attach `actionsButton` + a `FileThumbnail`
|
|
321
|
+
attachment row (each `onRemove`-able) + send; `AgentProgress` is the `WaveAvatar` pill. "No icons"
|
|
322
|
+
was only ever about the review surfaces' sparkles/severity glyphs, not functional affordances.
|
|
314
323
|
|
|
315
324
|
---
|
|
316
325
|
|
|
@@ -340,7 +349,9 @@ surfaces' sparkles/severity glyphs, not functional affordances.
|
|
|
340
349
|
· **Gate header**: a `Dialog` uses `DialogHeaderTitle`; a popover form uses `Text size="sm"
|
|
341
350
|
weight="semibold"` + an optional `xs muted` subtitle.
|
|
342
351
|
- **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.
|
|
352
|
+
a static period badge. Every period-dependent number MUST follow the selection. Pass `includeTime`
|
|
353
|
+
when the time-of-day matters: the trigger previews the chosen time (locale-aware 24h/12h) and the
|
|
354
|
+
hour/minute selects label themselves from the `labels` prop (`hour`/`minute`/`dayPeriod`).
|
|
344
355
|
- **Keyboard & focus — use `tabIndex`, never `focusable`.** RN-Web's `Pressable` silently ignores
|
|
345
356
|
`focusable` (it writes its own `tabIndex`), so set a pressable control's tab-stop status with
|
|
346
357
|
`tabIndex={0 | -1}` (`focusable` only works on a plain `View`/`TextInput`). Roving widgets
|
|
@@ -367,8 +378,13 @@ surfaces' sparkles/severity glyphs, not functional affordances.
|
|
|
367
378
|
can't show; if there are none, drop the strip. Card stat rails use `KPICard`.
|
|
368
379
|
- **Numbers**: free-standing numerals `<Text tabular>`. Money: `formatMoney(n)` from
|
|
369
380
|
`@lotics/ui/format_money` (`compact` for strip captions). Dates: `formatDate(value, opts)` from
|
|
370
|
-
`@lotics/ui/format_date`
|
|
371
|
-
|
|
381
|
+
`@lotics/ui/format_date` — the date-VALUE formatter. `format` (date STYLE) ∈ `date` (22/05/2026) ·
|
|
382
|
+
`medium` (22 thg 5, 2026) · `long` (22 tháng 5, 2026) · `dayMonth` (22 thg 5) · `monthYear`
|
|
383
|
+
(Tháng 5 2026); **`time: true` is orthogonal** — prepends the 24h time to ANY style
|
|
384
|
+
(`14:30 22/05/2026`, `14:30 22 tháng 5, 2026`); `compact` drops the year on `date`. It's `Intl` +
|
|
385
|
+
the product's conventions (stable `/`, naive-ISO parse, 24h time-first, `emptyLabel`) — never
|
|
386
|
+
hand-roll a date via `Intl`/`padStart` for value display. (Exempt: a component's own internal
|
|
387
|
+
chrome — a calendar's header/weekday/a11y labels, a gantt axis — renders its own set.)
|
|
372
388
|
- **Every number is a door** (except the KPI strip). A component that summarizes records leads to
|
|
373
389
|
the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
|
|
374
390
|
Expansion happens IMMEDIATELY below the pressed element — the composable `Accordion` family
|
package/examples/tpl_dieline.tsx
CHANGED
|
@@ -8,9 +8,19 @@ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
|
|
|
8
8
|
import { DotsIndicator } from "@lotics/ui/dots_indicator";
|
|
9
9
|
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
10
10
|
import { AgentProgress } from "@lotics/ui/agent_progress";
|
|
11
|
-
import {
|
|
11
|
+
import { Composer } from "@lotics/ui/composer";
|
|
12
|
+
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
12
13
|
import { type AgentRunStep } from "@lotics/ui/agent_run";
|
|
13
14
|
|
|
15
|
+
// A real app fills the attachment from `useFileUpload` (the uploaded file's URL);
|
|
16
|
+
// this mock uses an inline placeholder so the example needs no network image.
|
|
17
|
+
const MOCK_PHOTO: DisplayFile = {
|
|
18
|
+
id: "att-carton",
|
|
19
|
+
filename: "carton.jpg",
|
|
20
|
+
mimeType: "image/jpeg",
|
|
21
|
+
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
22
|
+
};
|
|
23
|
+
|
|
14
24
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
25
|
// Template · AI design canvas (the carton "dieline" app) — the FLAGSHIP shape:
|
|
16
26
|
// the page IS the design. The dieline fills the centre, a FLOATING composer sits
|
|
@@ -56,6 +66,7 @@ export function TplDieline() {
|
|
|
56
66
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
57
67
|
const [dragging, setDragging] = useState(false);
|
|
58
68
|
const [prompt, setPrompt] = useState("");
|
|
69
|
+
const [attachment, setAttachment] = useState<DisplayFile | null>(null);
|
|
59
70
|
|
|
60
71
|
// Drag-to-pan — like a design tool: grab anywhere on the canvas and move it.
|
|
61
72
|
// The committed pan is read at grant via a ref (the responder is built once).
|
|
@@ -116,6 +127,7 @@ export function TplDieline() {
|
|
|
116
127
|
const submitPrompt = (text: string) => {
|
|
117
128
|
setPrompt("");
|
|
118
129
|
if (!designReady) {
|
|
130
|
+
setAttachment(null); // the attached photo is consumed into the run
|
|
119
131
|
startRecognize();
|
|
120
132
|
return;
|
|
121
133
|
}
|
|
@@ -136,6 +148,7 @@ export function TplDieline() {
|
|
|
136
148
|
|
|
137
149
|
const newSession = () => {
|
|
138
150
|
setRun(null);
|
|
151
|
+
setAttachment(null);
|
|
139
152
|
setRevealed(0);
|
|
140
153
|
setDesignReady(false);
|
|
141
154
|
setDims({ L: 320, W: 230, H: 150 });
|
|
@@ -197,12 +210,16 @@ export function TplDieline() {
|
|
|
197
210
|
{phase === "working" ? (
|
|
198
211
|
<AgentProgress steps={liveSteps} state="streaming" />
|
|
199
212
|
) : (
|
|
200
|
-
<
|
|
213
|
+
<Composer
|
|
201
214
|
value={prompt}
|
|
202
215
|
onChangeText={setPrompt}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
placeholder={designReady ? "Describe a change — “make it 5 mm taller”" : "Attach a photo
|
|
216
|
+
onSend={submitPrompt}
|
|
217
|
+
sendDisabled={prompt.trim().length === 0 && !attachment}
|
|
218
|
+
placeholder={designReady ? "Describe a change — “make it 5 mm taller”" : "Attach a photo, add a note, then send…"}
|
|
219
|
+
actionsButton={
|
|
220
|
+
<Button icon="paperclip" color="secondary" accessibilityLabel="Attach a photo" onPress={() => setAttachment(MOCK_PHOTO)} />
|
|
221
|
+
}
|
|
222
|
+
files={attachment ? <FileThumbnail file={attachment} size={56} onRemove={() => setAttachment(null)} /> : null}
|
|
206
223
|
/>
|
|
207
224
|
)}
|
|
208
225
|
</View>
|
package/package.json
CHANGED
package/src/date_filter.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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">
|
package/src/format_date.test.ts
CHANGED
|
@@ -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("
|
|
18
|
-
expect(formatDate("2026-05-22T14:30", { locale: "vi-VN",
|
|
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
|
|
22
|
-
expect(formatDate("2026-05-22T09:05", { locale: "vi-VN",
|
|
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)", () => {
|
package/src/format_date.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
47
|
-
* returns a localized display string; defaults to the home market
|
|
48
|
-
* `22/05/2026
|
|
49
|
-
*
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
parts
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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>
|
package/src/time_field.tsx
CHANGED
|
@@ -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:
|
|
123
|
-
minute: { options: minuteOptions, onSelect: setMinute, label: segmentLabels.minute, width:
|
|
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:
|
|
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={
|
|
144
|
+
placeholder={c.label}
|
|
146
145
|
disabled={disabled}
|
|
147
146
|
width={c.width}
|
|
148
147
|
/>
|