@lotics/ui 17.0.1 → 18.0.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/MIGRATION.md +20 -0
- package/docs/catalog.md +10 -2
- package/docs/templates.md +5 -3
- package/examples/tpl_record.tsx +20 -9
- package/package.json +1 -1
- package/src/date_calendar.tsx +6 -19
- package/src/date_filter.tsx +95 -102
- package/src/date_picker.tsx +5 -0
- package/src/date_range_selection.test.ts +60 -0
- package/src/date_range_selection.ts +32 -0
- package/src/date_segments.test.ts +48 -0
- package/src/date_segments.ts +34 -0
- package/src/inline_time_picker.tsx +11 -12
- package/src/locale.tsx +2 -2
- package/src/time_picker.tsx +97 -39
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,26 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## v18 from 17.x
|
|
8
|
+
|
|
9
|
+
**`TimePicker` is a segmented field, not `<input type="time">`.** A native time input takes
|
|
10
|
+
its 12- vs 24-hour form from the BROWSER's UI locale and ignores the `lang` attribute
|
|
11
|
+
entirely, so a Vietnamese screen on an en-US browser rendered "01:45 PM" beside its own
|
|
12
|
+
24-hour text. It now uses the same segment engine as `DateField`, with the format derived
|
|
13
|
+
from a `locale` prop (defaulting to the active `LoticsLocaleProvider` locale).
|
|
14
|
+
|
|
15
|
+
The value is unchanged — canonical 24-hour `"HH:mm"` either way. Two prop changes:
|
|
16
|
+
|
|
17
|
+
- **`onKeyDown` is GONE.** It typed the DOM input's key events, which no longer exist. Use
|
|
18
|
+
`onEscape` for cancel; a completed entry emits through `onValueChange` as before.
|
|
19
|
+
- **`onIncompleteChange` is NEW** and matters for inline editors: segments emit only a
|
|
20
|
+
COMPLETE `"HH:mm"`, so a half-typed entry (an hour, no minute) arrives as `""` and would
|
|
21
|
+
otherwise commit as a silent clear. Block the commit on it — `InlineTimePicker` does, and
|
|
22
|
+
surfaces the new `datePicker.invalidTime` label.
|
|
23
|
+
|
|
24
|
+
`DatePickerLabels` gains **`invalidTime`**; a caller that builds the label bag by hand (rather
|
|
25
|
+
than spreading the locale pack) must add it.
|
|
26
|
+
|
|
7
27
|
## v17 from 16.x
|
|
8
28
|
|
|
9
29
|
**`Checklist` / `ChecklistRow` are DELETED.** They are replaced by the `Task` compound
|
package/docs/catalog.md
CHANGED
|
@@ -600,11 +600,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
600
600
|
localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
|
|
601
601
|
`ref.navigateToMonth`. The engine `DatePicker`/`DateFilter` wrap in field chrome — reach
|
|
602
602
|
for it bare only when the calendar lives permanently on the surface, not behind a field.
|
|
603
|
-
- **`date_filter`** — `DateFilter`: the
|
|
603
|
+
- **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
|
|
604
604
|
(`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
|
|
605
|
+
**One selection rule, no modes**: a click OPENS a range, the next CLOSES it (in either
|
|
606
|
+
order — bounds come back sorted), and a single day is the same day clicked twice
|
|
607
|
+
(`start`/`end` equal — which is how a consumer detects "one day" and how the trigger
|
|
608
|
+
renders it as a single date). Both bounds always show, so a half-picked range names the
|
|
609
|
+
end that is still open.
|
|
605
610
|
- **`date_range_filter_field`** — `DateRangeFilterField`: the register's period filter field
|
|
606
611
|
(presets + footer + time-segment a11y; localized via the `dateRange` locale slice).
|
|
607
|
-
- **`time_picker`** — `TimePicker`: the time-of-day
|
|
612
|
+
- **`time_picker`** — `TimePicker`: the time-of-day field. Segments (hour / minute / AM-PM),
|
|
613
|
+
**12- vs 24-hour derived from `locale`** — never from the browser, which is why this is not
|
|
614
|
+
an `<input type="time">`. The value is always canonical 24-hour `"HH:mm"`; `locale` changes
|
|
615
|
+
only what is displayed and typed.
|
|
608
616
|
|
|
609
617
|
### Inline editing & record surfaces
|
|
610
618
|
|
package/docs/templates.md
CHANGED
|
@@ -355,9 +355,11 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
355
355
|
HOME stays their DATA section (the colocation law) with descriptions naming their
|
|
356
356
|
consumers ("printed on the delivery note"). The picker rows ride `TaskList`/`TaskItem`
|
|
357
357
|
(`controlWidth={24}` for the `CheckboxInput` in `TaskStatus`) — the compound owns the row
|
|
358
|
-
geometry and the indent; hand-rolling the anatomy is how alignment drifts. Warning and fix are CO-LOCATED on the row. The mark NAMES what's missing
|
|
359
|
-
|
|
360
|
-
gaps show WITHOUT checking, warning once checked because it now blocks
|
|
358
|
+
geometry and the indent; hand-rolling the anatomy is how alignment drifts. Warning and fix are CO-LOCATED on the row. The mark NAMES what's missing — a
|
|
359
|
+
**`TaskCaption`**, not a field cell: 13px `circle-alert` + "Needs: …" the actual field labels,
|
|
360
|
+
muted at rest so gaps show WITHOUT checking, warning once checked because it now blocks. It is
|
|
361
|
+
a SENTENCE about the row, and `TaskFields` is the column the reader edits and scans down, so
|
|
362
|
+
prose put there lines up with nothing and reads as a control it is not. The FIX sits
|
|
361
363
|
right under it in the row's `TaskDetail` — an `Inset` (a FORM surface, NEVER a `Callout`:
|
|
362
364
|
a warning Callout is an ARIA `alert`, wrong around a form) with a `FormTextInput` per gap
|
|
363
365
|
(label + the consumer hint as `description`) and a "Save fields" (secondary — the section
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -32,7 +32,7 @@ import { CheckCircle } from "@lotics/ui/check_circle";
|
|
|
32
32
|
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
33
33
|
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
34
34
|
import { CaptureRow } from "@lotics/ui/capture_row";
|
|
35
|
-
import { TaskActions, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
|
|
35
|
+
import { TaskActions, TaskCaption, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle, TASK_TEXT_INSET } from "@lotics/ui/task";
|
|
36
36
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
37
37
|
import { SuggestionChip } from "@lotics/ui/suggestion_chip";
|
|
38
38
|
import { OptionList } from "@lotics/ui/option_list";
|
|
@@ -1900,7 +1900,10 @@ export function TplRecord() {
|
|
|
1900
1900
|
<CheckCircle state={sub.done ? "done" : "none"} onChange={(on) => toggleSubtask(t.id, sub.key, on)} accessibilityLabel={sub.label} />
|
|
1901
1901
|
</TaskStatus>
|
|
1902
1902
|
<TaskTitle>
|
|
1903
|
-
|
|
1903
|
+
{/* The parent's title is an inline editor, which insets its
|
|
1904
|
+
own text; a plain `Text` child must add the same or the
|
|
1905
|
+
two columns of titles do not line up. */}
|
|
1906
|
+
<Text size="sm" color={sub.done ? "muted" : "default"} decoration={sub.done ? "lineThrough" : undefined} style={{ paddingHorizontal: TASK_TEXT_INSET }}>{sub.label}</Text>
|
|
1904
1907
|
</TaskTitle>
|
|
1905
1908
|
</TaskItem>
|
|
1906
1909
|
))}
|
|
@@ -2261,15 +2264,23 @@ export function TplRecord() {
|
|
|
2261
2264
|
<CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />
|
|
2262
2265
|
</TaskStatus>
|
|
2263
2266
|
<TaskTitle>
|
|
2264
|
-
|
|
2267
|
+
{/* A plain-`Text` title carries the cell inset itself, or it sits
|
|
2268
|
+
out of line with the editable titles elsewhere on the page and
|
|
2269
|
+
with its own caption below. */}
|
|
2270
|
+
<Text size="sm" style={{ flexShrink: 1, paddingHorizontal: TASK_TEXT_INSET }}>{f.label}</Text>
|
|
2265
2271
|
</TaskTitle>
|
|
2272
|
+
{/* "Needs: Tax ID, Delivery address" is a SENTENCE about this row, so it
|
|
2273
|
+
is a `TaskCaption` — its own line under the title. It was a
|
|
2274
|
+
`TaskFields` cell, which is the column the reader EDITS and scans
|
|
2275
|
+
down: prose there lines up with nothing, squeezes the cells beside
|
|
2276
|
+
it, and reads as a control it is not. */}
|
|
2266
2277
|
{missing.length > 0 ? (
|
|
2267
|
-
<
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
</
|
|
2278
|
+
<TaskCaption>
|
|
2279
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
|
|
2280
|
+
<Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
|
|
2281
|
+
<Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
|
|
2282
|
+
</View>
|
|
2283
|
+
</TaskCaption>
|
|
2273
2284
|
) : null}
|
|
2274
2285
|
{checked && missing.length > 0 ? (
|
|
2275
2286
|
<TaskDetail>
|
package/package.json
CHANGED
package/src/date_calendar.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { IconButton } from "./icon_button";
|
|
|
8
8
|
import { FOCUS_RING } from "./control_surface";
|
|
9
9
|
import { useFocusRing } from "./use_focus_ring";
|
|
10
10
|
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
11
|
+
import { nextRangeSelection } from "./date_range_selection";
|
|
11
12
|
|
|
12
13
|
/** Accessible names for the calendar's month-navigation arrows. Backs the
|
|
13
14
|
* `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
|
|
@@ -529,28 +530,14 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
|
|
|
529
530
|
const rightYear = leftMonth === 11 ? leftYear + 1 : leftYear;
|
|
530
531
|
const rightMonth = leftMonth === 11 ? 0 : leftMonth + 1;
|
|
531
532
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
533
|
+
// Which half of the range a click lands on is READ from the value (a range is
|
|
534
|
+
// open exactly when it has a start and no end), never tracked alongside it —
|
|
535
|
+
// a separate flag can disagree with the value the parent actually holds.
|
|
535
536
|
const handleDateSelect = useCallback(
|
|
536
537
|
(date: Date) => {
|
|
537
|
-
|
|
538
|
-
// Start new selection
|
|
539
|
-
onValueChange({ start: date, end: null });
|
|
540
|
-
setSelectingEnd(true);
|
|
541
|
-
} else {
|
|
542
|
-
// Complete selection
|
|
543
|
-
const start = value.start;
|
|
544
|
-
// Ensure start is before end
|
|
545
|
-
if (date < start) {
|
|
546
|
-
onValueChange({ start: date, end: start });
|
|
547
|
-
} else {
|
|
548
|
-
onValueChange({ start, end: date });
|
|
549
|
-
}
|
|
550
|
-
setSelectingEnd(false);
|
|
551
|
-
}
|
|
538
|
+
onValueChange(nextRangeSelection(value, date));
|
|
552
539
|
},
|
|
553
|
-
[value, onValueChange
|
|
540
|
+
[value, onValueChange],
|
|
554
541
|
);
|
|
555
542
|
|
|
556
543
|
const handleMonthChange = useCallback((year: number, month: number) => {
|
package/src/date_filter.tsx
CHANGED
|
@@ -6,15 +6,12 @@ import { CONTROL_RADIUS } from "./control_surface";
|
|
|
6
6
|
import { MenuButton } from "./menu_button";
|
|
7
7
|
import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
|
|
8
8
|
import { TimePicker } from "./time_picker";
|
|
9
|
-
import { Switch } from "./switch";
|
|
10
9
|
import { useScreenSize } from "./use_screen_size";
|
|
11
10
|
import { SegmentLabels } from "./date_segments";
|
|
12
11
|
import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
|
|
13
12
|
import { formatDate } from "./format_date";
|
|
14
13
|
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
15
14
|
|
|
16
|
-
type SelectionMode = "single" | "range";
|
|
17
|
-
|
|
18
15
|
// =============================================================================
|
|
19
16
|
// Types
|
|
20
17
|
// =============================================================================
|
|
@@ -39,7 +36,9 @@ export interface DateFilterLabels extends SegmentLabels {
|
|
|
39
36
|
custom: string;
|
|
40
37
|
from: string;
|
|
41
38
|
to: string;
|
|
39
|
+
/** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
|
|
42
40
|
selectDateRange: string;
|
|
41
|
+
/** Stands in for a bound that has no date yet — the open half of a range. */
|
|
43
42
|
selectDate: string;
|
|
44
43
|
}
|
|
45
44
|
|
|
@@ -83,6 +82,46 @@ function formatDateDisplay(date: Date | null, locale: string | undefined): strin
|
|
|
83
82
|
return formatDate(date, { format: "medium", locale });
|
|
84
83
|
}
|
|
85
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One end of the range: its name, its date (or a placeholder while it is still
|
|
87
|
+
* open), and — under `includeTime` — its time. The time field is rendered
|
|
88
|
+
* disabled rather than omitted while the bound has no date, so picking a date
|
|
89
|
+
* never shifts the panel under the pointer.
|
|
90
|
+
*/
|
|
91
|
+
function RangeBound(props: {
|
|
92
|
+
label: string;
|
|
93
|
+
placeholder: string;
|
|
94
|
+
bound: { date: Date | null; time: string | null };
|
|
95
|
+
includeTime: boolean;
|
|
96
|
+
localeTag: string;
|
|
97
|
+
segmentLabels: SegmentLabels;
|
|
98
|
+
onTimeChange: (time: string) => void;
|
|
99
|
+
}) {
|
|
100
|
+
const { label, placeholder, bound, includeTime, localeTag, segmentLabels, onTimeChange } = props;
|
|
101
|
+
return (
|
|
102
|
+
<View style={styles.rangeItem}>
|
|
103
|
+
<Text size="xs" color="zinc-500">
|
|
104
|
+
{label}
|
|
105
|
+
</Text>
|
|
106
|
+
<Text size="sm" weight="medium" color={bound.date ? "default" : "muted"}>
|
|
107
|
+
{bound.date ? formatDateDisplay(bound.date, localeTag) : placeholder}
|
|
108
|
+
</Text>
|
|
109
|
+
{includeTime && (
|
|
110
|
+
<View style={{ marginTop: 4 }}>
|
|
111
|
+
<TimePicker
|
|
112
|
+
value={bound.time || ""}
|
|
113
|
+
onValueChange={onTimeChange}
|
|
114
|
+
disabled={!bound.date}
|
|
115
|
+
accessibilityLabel={label}
|
|
116
|
+
locale={localeTag}
|
|
117
|
+
segmentLabels={segmentLabels}
|
|
118
|
+
/>
|
|
119
|
+
</View>
|
|
120
|
+
)}
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
// =============================================================================
|
|
87
126
|
// Main Component
|
|
88
127
|
// =============================================================================
|
|
@@ -97,52 +136,46 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
97
136
|
);
|
|
98
137
|
const screenSize = useScreenSize();
|
|
99
138
|
const calendarRef = useRef<CalendarRef>(null);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
139
|
+
|
|
140
|
+
// A half-picked range is held HERE and never emitted — the same rule
|
|
141
|
+
// `DatePickerPanel` follows. Emitting it would filter on an open-ended range
|
|
142
|
+
// the user never asked for (everything from that day onward) the moment they
|
|
143
|
+
// click a start, which reads as "filtered to that day" while being nothing of
|
|
144
|
+
// the kind. The panel still shows the pending bound; the filter does not move
|
|
145
|
+
// until the range is closed. The popover unmounts on close, so this starts
|
|
146
|
+
// fresh each session.
|
|
147
|
+
const [pendingStart, setPendingStart] = useState<Date | null>(null);
|
|
148
|
+
|
|
149
|
+
/** What the panel DISPLAYS: the pending pick if there is one, else the value. */
|
|
150
|
+
const shown = useMemo<DateFilterValue>(
|
|
151
|
+
() =>
|
|
152
|
+
pendingStart
|
|
153
|
+
? { start: { date: pendingStart, time: value.start.time }, end: { date: null, time: value.end.time } }
|
|
154
|
+
: value,
|
|
155
|
+
[pendingStart, value],
|
|
156
|
+
);
|
|
108
157
|
|
|
109
158
|
const calendarValue: CalendarRangeValue = useMemo(
|
|
110
|
-
() => ({ start:
|
|
111
|
-
[
|
|
159
|
+
() => ({ start: shown.start.date, end: shown.end.date }),
|
|
160
|
+
[shown.start.date, shown.end.date],
|
|
112
161
|
);
|
|
113
162
|
|
|
163
|
+
// The calendar owns which bound a click lands on (see `nextRangeSelection`);
|
|
164
|
+
// this decides whether that lands in the value or stays pending, and carries
|
|
165
|
+
// the times across, since a click never changes them.
|
|
114
166
|
const handleCalendarChange = useCallback(
|
|
115
167
|
(newValue: CalendarRangeValue) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
onValueChange({
|
|
119
|
-
start: { date: newValue.start, time: value.start.time },
|
|
120
|
-
end: { date: newValue.start, time: value.end.time },
|
|
121
|
-
});
|
|
168
|
+
if (newValue.start && !newValue.end) {
|
|
169
|
+
setPendingStart(newValue.start);
|
|
122
170
|
return;
|
|
123
171
|
}
|
|
172
|
+
setPendingStart(null);
|
|
124
173
|
onValueChange({
|
|
125
174
|
start: { date: newValue.start, time: value.start.time },
|
|
126
175
|
end: { date: newValue.end, time: value.end.time },
|
|
127
176
|
});
|
|
128
177
|
},
|
|
129
|
-
[onValueChange, value.start.time, value.end.time
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const handleModeChange = useCallback(
|
|
133
|
-
(isRange: boolean) => {
|
|
134
|
-
const newMode: SelectionMode = isRange ? "range" : "single";
|
|
135
|
-
setSelectionMode(newMode);
|
|
136
|
-
|
|
137
|
-
// Switching to single: collapse range to start date
|
|
138
|
-
if (newMode === "single" && value.start.date) {
|
|
139
|
-
onValueChange({
|
|
140
|
-
start: value.start,
|
|
141
|
-
end: { date: value.start.date, time: value.start.time },
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
},
|
|
145
|
-
[value, onValueChange],
|
|
178
|
+
[onValueChange, value.start.time, value.end.time],
|
|
146
179
|
);
|
|
147
180
|
|
|
148
181
|
const handleStartTimeChange = useCallback(
|
|
@@ -178,6 +211,8 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
178
211
|
|
|
179
212
|
const handlePresetSelect = useCallback(
|
|
180
213
|
(id: PresetId) => {
|
|
214
|
+
// A preset replaces the whole range, so any half-picked start is abandoned.
|
|
215
|
+
setPendingStart(null);
|
|
181
216
|
// Re-clicking the active preset clears the filter
|
|
182
217
|
if (activePresetId === id) {
|
|
183
218
|
onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
|
|
@@ -191,13 +226,6 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
191
226
|
return;
|
|
192
227
|
}
|
|
193
228
|
|
|
194
|
-
// Auto-switch to range mode for multi-day presets
|
|
195
|
-
if (presetValue.start.date && presetValue.end.date) {
|
|
196
|
-
const isSingleDay =
|
|
197
|
-
presetValue.start.date.toDateString() === presetValue.end.date.toDateString();
|
|
198
|
-
if (!isSingleDay && selectionMode === "single") setSelectionMode("range");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
229
|
onValueChange(presetValue);
|
|
202
230
|
|
|
203
231
|
if (presetValue.start.date) {
|
|
@@ -207,7 +235,7 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
207
235
|
);
|
|
208
236
|
}
|
|
209
237
|
},
|
|
210
|
-
[onValueChange, activePresetId
|
|
238
|
+
[onValueChange, activePresetId],
|
|
211
239
|
);
|
|
212
240
|
|
|
213
241
|
const renderPreset = (id: PresetId) => {
|
|
@@ -243,56 +271,28 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
243
271
|
locale={localeTag}
|
|
244
272
|
/>
|
|
245
273
|
|
|
246
|
-
{
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
{value.end.date ? (
|
|
269
|
-
<View style={styles.rangeItem}>
|
|
270
|
-
<Text size="xs" color="zinc-500">
|
|
271
|
-
{labels.to}
|
|
272
|
-
</Text>
|
|
273
|
-
<Text size="sm" weight="medium">
|
|
274
|
-
{formatDateDisplay(value.end.date, localeTag) || labels.selectDate}
|
|
275
|
-
</Text>
|
|
276
|
-
{includeTime && value.end.date && (
|
|
277
|
-
<View style={{ marginTop: 4 }}>
|
|
278
|
-
<TimePicker
|
|
279
|
-
value={value.end.time || ""}
|
|
280
|
-
onValueChange={handleEndTimeChange}
|
|
281
|
-
/>
|
|
282
|
-
</View>
|
|
283
|
-
)}
|
|
284
|
-
</View>
|
|
285
|
-
) : (
|
|
286
|
-
<View style={styles.rangeItem} />
|
|
287
|
-
)}
|
|
288
|
-
</View>
|
|
289
|
-
) : null}
|
|
290
|
-
|
|
291
|
-
<View style={styles.modeToggle}>
|
|
292
|
-
<Switch value={selectionMode === "range"} onChange={handleModeChange} />
|
|
293
|
-
<Text size="sm" color="muted">
|
|
294
|
-
{labels.selectDateRange}
|
|
295
|
-
</Text>
|
|
274
|
+
{/* Both bounds are ALWAYS shown: they are what tells the user a range is
|
|
275
|
+
half-picked and which end is still open. Hiding the empty one (or the
|
|
276
|
+
whole row) is how a click stops explaining itself. */}
|
|
277
|
+
<View style={styles.rangeDisplay}>
|
|
278
|
+
<RangeBound
|
|
279
|
+
label={labels.from}
|
|
280
|
+
placeholder={labels.selectDate}
|
|
281
|
+
bound={shown.start}
|
|
282
|
+
includeTime={includeTime}
|
|
283
|
+
localeTag={localeTag}
|
|
284
|
+
segmentLabels={labels}
|
|
285
|
+
onTimeChange={handleStartTimeChange}
|
|
286
|
+
/>
|
|
287
|
+
<RangeBound
|
|
288
|
+
label={labels.to}
|
|
289
|
+
placeholder={labels.selectDate}
|
|
290
|
+
bound={shown.end}
|
|
291
|
+
includeTime={includeTime}
|
|
292
|
+
localeTag={localeTag}
|
|
293
|
+
segmentLabels={labels}
|
|
294
|
+
onTimeChange={handleEndTimeChange}
|
|
295
|
+
/>
|
|
296
296
|
</View>
|
|
297
297
|
</View>
|
|
298
298
|
|
|
@@ -343,18 +343,11 @@ const styles = StyleSheet.create({
|
|
|
343
343
|
mainContent: {
|
|
344
344
|
flex: 1,
|
|
345
345
|
},
|
|
346
|
-
modeToggle: {
|
|
347
|
-
flexDirection: "row",
|
|
348
|
-
alignItems: "center",
|
|
349
|
-
gap: 8,
|
|
350
|
-
paddingTop: 8,
|
|
351
|
-
paddingBottom: 8,
|
|
352
|
-
paddingLeft: 8,
|
|
353
|
-
},
|
|
354
346
|
rangeDisplay: {
|
|
355
347
|
flexDirection: "row",
|
|
356
348
|
gap: 16,
|
|
357
349
|
paddingTop: 8,
|
|
350
|
+
paddingBottom: 8,
|
|
358
351
|
},
|
|
359
352
|
rangeItem: {
|
|
360
353
|
flex: 1,
|
package/src/date_picker.tsx
CHANGED
|
@@ -56,6 +56,8 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
56
56
|
done: string;
|
|
57
57
|
/** Inline error when a typed date is left incomplete/invalid (never committed). */
|
|
58
58
|
invalidDate: string;
|
|
59
|
+
/** Inline error when a typed time is left half-entered (never committed). */
|
|
60
|
+
invalidTime: string;
|
|
59
61
|
/** Quick action: add a time to a date-only value (`optionalTime`). */
|
|
60
62
|
addTime: string;
|
|
61
63
|
/** Accessible name + tooltip: drop the time, keeping the date (`optionalTime`). */
|
|
@@ -228,6 +230,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
228
230
|
accessibilityLabel={mergedLabels.startTime}
|
|
229
231
|
value={timeText(startIso)}
|
|
230
232
|
onValueChange={(t) => handleRangeTime("start", t)}
|
|
233
|
+
locale={locale}
|
|
231
234
|
/>
|
|
232
235
|
</View>
|
|
233
236
|
<View style={styles.timeCol}>
|
|
@@ -238,6 +241,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
238
241
|
accessibilityLabel={mergedLabels.endTime}
|
|
239
242
|
value={timeText(endIso)}
|
|
240
243
|
onValueChange={(t) => handleRangeTime("end", t)}
|
|
244
|
+
locale={locale}
|
|
241
245
|
/>
|
|
242
246
|
</View>
|
|
243
247
|
</>
|
|
@@ -261,6 +265,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
261
265
|
accessibilityLabel={mergedLabels.time}
|
|
262
266
|
value={timeText(value ?? "")}
|
|
263
267
|
onValueChange={handleSingleTime}
|
|
268
|
+
locale={locale}
|
|
264
269
|
/>
|
|
265
270
|
</View>
|
|
266
271
|
)}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { nextRangeSelection, type DateRangeSelection } from "./date_range_selection";
|
|
3
|
+
|
|
4
|
+
const d = (day: number): Date => new Date(2026, 6, day);
|
|
5
|
+
const ymd = (x: Date | null): string =>
|
|
6
|
+
x ? `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}` : "null";
|
|
7
|
+
const EMPTY: DateRangeSelection = { start: null, end: null };
|
|
8
|
+
|
|
9
|
+
describe("nextRangeSelection", () => {
|
|
10
|
+
it("opens a range on the first click", () => {
|
|
11
|
+
const next = nextRangeSelection(EMPTY, d(14));
|
|
12
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
13
|
+
expect(next.end).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("closes the open range on the second click", () => {
|
|
17
|
+
const next = nextRangeSelection({ start: d(14), end: null }, d(20));
|
|
18
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
19
|
+
expect(ymd(next.end)).toBe("2026-07-20");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("orders the bounds when the later day is clicked first", () => {
|
|
23
|
+
const next = nextRangeSelection({ start: d(20), end: null }, d(14));
|
|
24
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
25
|
+
expect(ymd(next.end)).toBe("2026-07-20");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The whole point of dropping the single/range mode: a range must be
|
|
29
|
+
// reachable from EVERY starting value, including one that is already a
|
|
30
|
+
// single day. Under the mode, a single-day value silently disabled range
|
|
31
|
+
// selection entirely — two clicks just moved the one day around.
|
|
32
|
+
it("reaches a range from a single-day value", () => {
|
|
33
|
+
const singleDay: DateRangeSelection = { start: d(31), end: d(31) };
|
|
34
|
+
const opened = nextRangeSelection(singleDay, d(1));
|
|
35
|
+
const closed = nextRangeSelection(opened, d(20));
|
|
36
|
+
expect(ymd(closed.start)).toBe("2026-07-01");
|
|
37
|
+
expect(ymd(closed.end)).toBe("2026-07-20");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("reaches a range from a closed multi-day range", () => {
|
|
41
|
+
const opened = nextRangeSelection({ start: d(1), end: d(5) }, d(10));
|
|
42
|
+
expect(ymd(opened.start)).toBe("2026-07-10");
|
|
43
|
+
expect(opened.end).toBeNull();
|
|
44
|
+
expect(ymd(nextRangeSelection(opened, d(12)).end)).toBe("2026-07-12");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Selecting one day is clicking it twice — the replacement for the mode.
|
|
48
|
+
it("selects a single day when the same day is clicked twice", () => {
|
|
49
|
+
const next = nextRangeSelection(nextRangeSelection(EMPTY, d(14)), d(14));
|
|
50
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
51
|
+
expect(ymd(next.end)).toBe("2026-07-14");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("never mutates the selection it is given", () => {
|
|
55
|
+
const current: DateRangeSelection = { start: d(14), end: null };
|
|
56
|
+
nextRangeSelection(current, d(20));
|
|
57
|
+
expect(ymd(current.start)).toBe("2026-07-14");
|
|
58
|
+
expect(current.end).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Pure click→range math for the range calendar. Kept free of React/RN so the
|
|
2
|
+
// selection contract can be unit-tested directly rather than through a
|
|
3
|
+
// component.
|
|
4
|
+
|
|
5
|
+
/** The two bounds a range calendar edits. `end` is null while a range is open. */
|
|
6
|
+
export interface DateRangeSelection {
|
|
7
|
+
start: Date | null;
|
|
8
|
+
end: Date | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The selection after clicking `date`.
|
|
13
|
+
*
|
|
14
|
+
* One rule, no modes: a click either OPENS a range (when none is open) or
|
|
15
|
+
* CLOSES the open one. Selecting a single day is clicking the same day twice —
|
|
16
|
+
* there is deliberately no separate single-day mode, because a mode that
|
|
17
|
+
* changes what a click means is a mode the user can get stuck in with no way
|
|
18
|
+
* to express a range at all.
|
|
19
|
+
*
|
|
20
|
+
* Bounds come back ordered, so clicking the later day first is just another way
|
|
21
|
+
* to select rather than an error the caller has to undo.
|
|
22
|
+
*/
|
|
23
|
+
export function nextRangeSelection(
|
|
24
|
+
current: DateRangeSelection,
|
|
25
|
+
date: Date,
|
|
26
|
+
): DateRangeSelection {
|
|
27
|
+
// Nothing picked yet, or the last range is already closed → open a new one.
|
|
28
|
+
if (!current.start || current.end) return { start: date, end: null };
|
|
29
|
+
return date < current.start
|
|
30
|
+
? { start: date, end: current.start }
|
|
31
|
+
: { start: current.start, end: date };
|
|
32
|
+
}
|
|
@@ -3,6 +3,8 @@ import { resolveLocaleTag, en as enLocale, vi as viLocale } from "./locale";
|
|
|
3
3
|
import {
|
|
4
4
|
getDateLayout,
|
|
5
5
|
getTimeLayout,
|
|
6
|
+
timeSegmentsConfig,
|
|
7
|
+
segmentsToTime,
|
|
6
8
|
fieldOrder,
|
|
7
9
|
to12h,
|
|
8
10
|
from12h,
|
|
@@ -285,3 +287,49 @@ describe("getTimeLayout", () => {
|
|
|
285
287
|
expect(vn.hour12).toBe(false);
|
|
286
288
|
});
|
|
287
289
|
});
|
|
290
|
+
|
|
291
|
+
// -----------------------------------------------------------------------------
|
|
292
|
+
// Time-only field. The point of the segmented time field is that 12/24-hour is
|
|
293
|
+
// OUR locale's decision — a native `<input type="time">` takes it from the
|
|
294
|
+
// browser's UI locale and ignores `lang`, so a Vietnamese screen on an en-US
|
|
295
|
+
// browser rendered "01:45 PM" beside its own 24-hour text.
|
|
296
|
+
// -----------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe("timeSegmentsConfig", () => {
|
|
299
|
+
it("derives 24-hour display from the locale, not the runtime", () => {
|
|
300
|
+
expect(timeSegmentsConfig("vi-VN").layout.hour12).toBe(false);
|
|
301
|
+
expect(timeSegmentsConfig("de-DE").layout.hour12).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("derives 12-hour display where the locale uses one", () => {
|
|
305
|
+
const layout = timeSegmentsConfig("en-US").layout;
|
|
306
|
+
expect(layout.hour12).toBe(true);
|
|
307
|
+
expect(layout.segments.some((s) => s.kind === "field" && s.type === "dayPeriod")).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("keeps the value canonical 24-hour under a 12-hour locale", () => {
|
|
311
|
+
const cfg = timeSegmentsConfig("en-US");
|
|
312
|
+
expect(cfg.toValue(cfg.toBuffer("13:45"))).toBe("13:45");
|
|
313
|
+
expect(cfg.toValue(cfg.toBuffer("00:30"))).toBe("00:30");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("round-trips every hour of the day", () => {
|
|
317
|
+
const cfg = timeSegmentsConfig("en-US");
|
|
318
|
+
for (let h = 0; h < 24; h++) {
|
|
319
|
+
const iso = `${String(h).padStart(2, "0")}:15`;
|
|
320
|
+
expect(cfg.toValue(cfg.toBuffer(iso))).toBe(iso);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("reads an empty or unparseable time as empty", () => {
|
|
325
|
+
const cfg = timeSegmentsConfig("vi-VN");
|
|
326
|
+
for (const bad of ["", "banana", "25:00", "12:99"]) {
|
|
327
|
+
expect(cfg.isEmpty(cfg.toBuffer(bad))).toBe(true);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("emits nothing while only half the time is typed", () => {
|
|
332
|
+
expect(segmentsToTime({ year: null, month: null, day: null, hour: 13, minute: null })).toBeNull();
|
|
333
|
+
expect(segmentsToTime({ year: null, month: null, day: null, hour: null, minute: 45 })).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
});
|
package/src/date_segments.ts
CHANGED
|
@@ -356,3 +356,37 @@ export function dateSegmentsConfig(locale: string, hasTime: boolean): SegmentsCo
|
|
|
356
356
|
parse: (text) => parseText(text, hasTime),
|
|
357
357
|
};
|
|
358
358
|
}
|
|
359
|
+
|
|
360
|
+
// -----------------------------------------------------------------------------
|
|
361
|
+
// Time-only field — same engine, no date part. The canonical value is 24-hour
|
|
362
|
+
// "HH:mm" REGARDLESS of how the locale displays it: `hour12` decides whether the
|
|
363
|
+
// user sees "01:45 PM" or "13:45", never what is stored or emitted.
|
|
364
|
+
// -----------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
const TIME_RE = /^(\d{1,2}):(\d{2})/;
|
|
367
|
+
|
|
368
|
+
/** "HH:mm" → buffer. Anything unparseable or out of range reads as empty. */
|
|
369
|
+
export function timeToSegments(time: string): SegmentBuffer {
|
|
370
|
+
const m = TIME_RE.exec(time);
|
|
371
|
+
if (!m) return emptyBuffer();
|
|
372
|
+
const hour = Number(m[1]);
|
|
373
|
+
const minute = Number(m[2]);
|
|
374
|
+
if (hour > 23 || minute > 59) return emptyBuffer();
|
|
375
|
+
return { year: null, month: null, day: null, hour, minute };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Buffer → canonical "HH:mm", or null while either half is still missing. */
|
|
379
|
+
export function segmentsToTime(buffer: SegmentBuffer): string | null {
|
|
380
|
+
if (buffer.hour == null || buffer.minute == null) return null;
|
|
381
|
+
return `${pad(buffer.hour)}:${pad(buffer.minute)}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function timeSegmentsConfig(locale: string): SegmentsConfig {
|
|
385
|
+
return {
|
|
386
|
+
layout: getTimeLayout(locale),
|
|
387
|
+
toBuffer: timeToSegments,
|
|
388
|
+
toValue: segmentsToTime,
|
|
389
|
+
isEmpty: (buffer) => buffer.hour == null && buffer.minute == null,
|
|
390
|
+
parse: (text) => segmentsToTime(timeToSegments(text.trim())),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import type { KeyboardEvent } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
3
2
|
import { Icon } from "./icon";
|
|
4
3
|
import { colors } from "./colors";
|
|
5
4
|
import { TimePicker } from "./time_picker";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
6
|
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
7
7
|
|
|
8
8
|
export interface InlineTimePickerProps {
|
|
@@ -27,17 +27,15 @@ export interface InlineTimePickerProps {
|
|
|
27
27
|
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
28
28
|
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , variant } = props;
|
|
29
29
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
[edit],
|
|
37
|
-
);
|
|
30
|
+
const dateLabels = useLoticsLocale().datePicker;
|
|
31
|
+
// Segments emit ONLY on a complete "HH:mm", so a half-typed entry (an hour and
|
|
32
|
+
// no minute) reaches the draft as "" and would otherwise commit as a silent
|
|
33
|
+
// clear. Blocking on it keeps the entry fixable instead of wiping the value.
|
|
34
|
+
const incomplete = useRef(false);
|
|
38
35
|
|
|
39
36
|
const onBlur = useCallback(() => {
|
|
40
37
|
if (controls === "buttons") return;
|
|
38
|
+
if (incomplete.current) return;
|
|
41
39
|
void edit.commit();
|
|
42
40
|
}, [controls, edit]);
|
|
43
41
|
|
|
@@ -52,7 +50,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
52
50
|
onCommit={() => void edit.commit()}
|
|
53
51
|
onCancel={edit.cancel}
|
|
54
52
|
saving={edit.saving}
|
|
55
|
-
error={edit.error}
|
|
53
|
+
error={edit.error ?? (incomplete.current ? dateLabels.invalidTime : null)}
|
|
56
54
|
disabled={disabled}
|
|
57
55
|
accessibilityLabel={accessibilityLabel}
|
|
58
56
|
affordance={<Icon name="clock" size={18} color={colors.zinc[400]} />}
|
|
@@ -61,7 +59,8 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
61
59
|
value={edit.draft}
|
|
62
60
|
onValueChange={edit.setDraft}
|
|
63
61
|
onBlur={onBlur}
|
|
64
|
-
|
|
62
|
+
onEscape={edit.cancel}
|
|
63
|
+
onIncompleteChange={(next) => { incomplete.current = next; }}
|
|
65
64
|
autoFocus
|
|
66
65
|
accessibilityLabel={accessibilityLabel}
|
|
67
66
|
/>
|
package/src/locale.tsx
CHANGED
|
@@ -162,7 +162,7 @@ export const en: LoticsLocale = {
|
|
|
162
162
|
descending: ", descending",
|
|
163
163
|
},
|
|
164
164
|
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
165
|
-
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
|
|
165
|
+
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
|
|
166
166
|
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
167
167
|
filterChip: { clear: "Clear" },
|
|
168
168
|
floatingActionBar: { clear: "Clear" },
|
|
@@ -260,7 +260,7 @@ export const vi: LoticsLocale = {
|
|
|
260
260
|
descending: " (giảm dần)",
|
|
261
261
|
},
|
|
262
262
|
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…" },
|
|
263
|
-
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", startDate: "Ngày bắt đầu", endDate: "Ngày 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", invalidDate: "Nhập ngày đầy đủ" },
|
|
263
|
+
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", startDate: "Ngày bắt đầu", endDate: "Ngày 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", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
|
|
264
264
|
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
|
265
265
|
filterChip: { clear: "Xóa" },
|
|
266
266
|
floatingActionBar: { clear: "Bỏ chọn" },
|
package/src/time_picker.tsx
CHANGED
|
@@ -1,56 +1,114 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
4
|
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
|
|
5
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
6
5
|
import { useHover } from "./use_hover";
|
|
6
|
+
import { DateSegments } from "./date_segments_field";
|
|
7
|
+
import { SegmentLabels, timeSegmentsConfig } from "./date_segments";
|
|
8
|
+
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
9
|
+
|
|
7
10
|
export interface TimePickerProps {
|
|
11
|
+
/** Canonical 24-hour "HH:mm", `""` when empty — independent of how it displays. */
|
|
8
12
|
value?: string;
|
|
9
13
|
onValueChange: (value: string) => void;
|
|
10
14
|
onBlur?: () => void;
|
|
11
|
-
/** Web key handler — e.g. an inline editor committing on Enter / reverting on Escape. */
|
|
12
|
-
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
13
15
|
autoFocus?: boolean;
|
|
14
16
|
disabled?: boolean;
|
|
15
17
|
accessibilityLabel?: string;
|
|
18
|
+
/**
|
|
19
|
+
* BCP-47 locale deciding 12- vs 24-hour DISPLAY. Defaults to the active
|
|
20
|
+
* `LoticsLocaleProvider` locale.
|
|
21
|
+
*
|
|
22
|
+
* This is the whole reason the field is segments and not `<input type="time">`:
|
|
23
|
+
* a native time input takes its 12/24-hour form from the BROWSER's UI locale
|
|
24
|
+
* and ignores `lang` entirely, so a Vietnamese screen on an en-US browser read
|
|
25
|
+
* "01:45 PM" next to its own 24-hour "13:45 02/01/2023".
|
|
26
|
+
*/
|
|
27
|
+
locale?: string;
|
|
28
|
+
/** Accessible names per segment. Defaults to the `datePicker` locale slice. */
|
|
29
|
+
segmentLabels?: SegmentLabels;
|
|
30
|
+
/** Escape pressed in a segment — an inline editor cancels its session. */
|
|
31
|
+
onEscape?: () => void;
|
|
32
|
+
/** True while the entry is non-empty but still half-typed (an hour, no minute).
|
|
33
|
+
* An inline editor blocks its commit on this. */
|
|
34
|
+
onIncompleteChange?: (incomplete: boolean) => void;
|
|
35
|
+
testID?: string;
|
|
16
36
|
}
|
|
17
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A time of day. The value is always canonical 24-hour "HH:mm"; the locale only
|
|
40
|
+
* decides what the user sees and types (an AM/PM segment appears only where the
|
|
41
|
+
* locale uses one).
|
|
42
|
+
*/
|
|
18
43
|
export function TimePicker(props: TimePickerProps) {
|
|
19
|
-
const {
|
|
20
|
-
|
|
44
|
+
const {
|
|
45
|
+
value = "",
|
|
46
|
+
onValueChange,
|
|
47
|
+
onBlur,
|
|
48
|
+
autoFocus,
|
|
49
|
+
disabled,
|
|
50
|
+
accessibilityLabel,
|
|
51
|
+
locale,
|
|
52
|
+
onEscape,
|
|
53
|
+
onIncompleteChange,
|
|
54
|
+
testID,
|
|
55
|
+
} = props;
|
|
56
|
+
const localeTag = useLocaleTag(locale);
|
|
57
|
+
const loc = useLoticsLocale().datePicker;
|
|
58
|
+
const segmentLabels = props.segmentLabels ?? loc;
|
|
59
|
+
const config = useMemo(() => timeSegmentsConfig(localeTag), [localeTag]);
|
|
21
60
|
const { hovered, hoverProps } = useHover();
|
|
61
|
+
const [focused, setFocused] = useState(false);
|
|
22
62
|
|
|
23
63
|
return (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
boxSizing: "border-box",
|
|
52
|
-
transition: "border-color 0.12s, box-shadow 0.12s",
|
|
53
|
-
}}
|
|
54
|
-
/>
|
|
64
|
+
<View
|
|
65
|
+
{...(hoverProps as object)}
|
|
66
|
+
style={[
|
|
67
|
+
styles.frame,
|
|
68
|
+
hovered && !disabled && styles.frameHovered,
|
|
69
|
+
focused && !disabled && styles.frameFocused,
|
|
70
|
+
disabled && styles.frameDisabled,
|
|
71
|
+
]}
|
|
72
|
+
testID={testID}
|
|
73
|
+
>
|
|
74
|
+
<DateSegments
|
|
75
|
+
value={value}
|
|
76
|
+
onChange={onValueChange}
|
|
77
|
+
config={config}
|
|
78
|
+
segmentLabels={segmentLabels}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
autoFocus={autoFocus}
|
|
81
|
+
accessibilityLabel={accessibilityLabel}
|
|
82
|
+
onFocus={() => setFocused(true)}
|
|
83
|
+
onBlur={() => {
|
|
84
|
+
setFocused(false);
|
|
85
|
+
onBlur?.();
|
|
86
|
+
}}
|
|
87
|
+
onEscape={onEscape}
|
|
88
|
+
onIncompleteChange={onIncompleteChange}
|
|
89
|
+
/>
|
|
90
|
+
</View>
|
|
55
91
|
);
|
|
56
92
|
}
|
|
93
|
+
|
|
94
|
+
const styles = StyleSheet.create({
|
|
95
|
+
frame: {
|
|
96
|
+
flexDirection: "row",
|
|
97
|
+
alignItems: "center",
|
|
98
|
+
height: 40,
|
|
99
|
+
paddingHorizontal: 8,
|
|
100
|
+
borderRadius: CONTROL_RADIUS,
|
|
101
|
+
borderWidth: 1,
|
|
102
|
+
borderColor: colors.border,
|
|
103
|
+
backgroundColor: colors.background,
|
|
104
|
+
},
|
|
105
|
+
frameHovered: {
|
|
106
|
+
borderColor: HOVER_BORDER,
|
|
107
|
+
},
|
|
108
|
+
frameFocused: {
|
|
109
|
+
boxShadow: FOCUS_RING,
|
|
110
|
+
},
|
|
111
|
+
frameDisabled: {
|
|
112
|
+
backgroundColor: colors.zinc[50],
|
|
113
|
+
},
|
|
114
|
+
});
|