@lotics/ui 42.3.0 → 43.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 +115 -0
- package/docs/catalog.md +49 -7
- package/docs/composition.md +21 -1
- package/docs/data_entry.md +9 -4
- package/package.json +3 -1
- package/src/back_button.tsx +4 -1
- package/src/check_circle.tsx +1 -2
- package/src/checkbox_input.tsx +2 -2
- package/src/choice_list.tsx +2 -2
- package/src/comments_button.tsx +3 -1
- package/src/control_surface.ts +27 -0
- package/src/data_grid.tsx +1 -1
- package/src/date_calendar.tsx +210 -58
- package/src/date_filter.tsx +1 -5
- package/src/date_picker.tsx +2 -0
- package/src/date_range_selection.ts +18 -0
- package/src/date_segments.ts +15 -1
- package/src/file_dropzone.tsx +2 -1
- package/src/file_row.tsx +2 -2
- package/src/file_thumbnail.tsx +4 -1
- package/src/focus_ring_pressable.tsx +6 -1
- package/src/icon_button.tsx +3 -1
- package/src/image_gallery.tsx +1 -1
- package/src/inline_button.tsx +3 -1
- package/src/inline_time_picker.tsx +97 -48
- package/src/list_item.tsx +109 -11
- package/src/locale.tsx +9 -2
- package/src/menu_button.tsx +19 -0
- package/src/option_list.tsx +8 -0
- package/src/picker.tsx +50 -5
- package/src/picker_empty_option.ts +30 -0
- package/src/pressable_highlight.tsx +20 -8
- package/src/pressable_row.tsx +2 -2
- package/src/scroll_to_bottom.tsx +3 -0
- package/src/slider.tsx +2 -2
- package/src/switch.tsx +3 -1
- package/src/text.tsx +16 -11
- package/src/time_columns.tsx +225 -0
- package/src/time_options.ts +138 -0
- package/src/time_picker.tsx +102 -64
- package/src/use_option_list.ts +19 -0
package/src/slider.tsx
CHANGED
|
@@ -263,7 +263,7 @@ const styles = StyleSheet.create({
|
|
|
263
263
|
trackArea: {
|
|
264
264
|
height: 28,
|
|
265
265
|
justifyContent: "center",
|
|
266
|
-
...({ cursor: "
|
|
266
|
+
...({ cursor: "auto", touchAction: "none" } as ViewStyle),
|
|
267
267
|
},
|
|
268
268
|
trackBg: {
|
|
269
269
|
position: "absolute",
|
|
@@ -289,7 +289,7 @@ const styles = StyleSheet.create({
|
|
|
289
289
|
backgroundColor: colors.white,
|
|
290
290
|
borderWidth: 2,
|
|
291
291
|
...({
|
|
292
|
-
cursor: "
|
|
292
|
+
cursor: "auto",
|
|
293
293
|
boxShadow: "0 1px 3px rgba(38,38,38,0.18)",
|
|
294
294
|
} as ViewStyle),
|
|
295
295
|
},
|
package/src/switch.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { Animated, StyleSheet, Pressable, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
|
-
import { FOCUS_RING } from "./control_surface";
|
|
5
|
+
import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
|
|
6
6
|
import { useFocusRing } from "./use_focus_ring";
|
|
7
7
|
export interface SwitchProps {
|
|
8
8
|
testID?: string;
|
|
@@ -89,6 +89,8 @@ export function Switch(props: SwitchProps) {
|
|
|
89
89
|
|
|
90
90
|
const styles = StyleSheet.create({
|
|
91
91
|
root: {
|
|
92
|
+
// A switch SETS A VALUE — the arrow, see `CURSOR_DEFAULT`.
|
|
93
|
+
cursor: CURSOR_DEFAULT,
|
|
92
94
|
borderRadius: 999,
|
|
93
95
|
width: 48,
|
|
94
96
|
height: 32,
|
package/src/text.tsx
CHANGED
|
@@ -62,7 +62,7 @@ type TextUserSelect = "none" | "auto" | "text";
|
|
|
62
62
|
*/
|
|
63
63
|
export function Text(props: TextProps) {
|
|
64
64
|
const {
|
|
65
|
-
userSelect
|
|
65
|
+
userSelect,
|
|
66
66
|
align = "left",
|
|
67
67
|
children,
|
|
68
68
|
testID,
|
|
@@ -107,7 +107,10 @@ export function Text(props: TextProps) {
|
|
|
107
107
|
styles.text,
|
|
108
108
|
Platform.OS !== "web" && styles[size],
|
|
109
109
|
styles[weight],
|
|
110
|
-
|
|
110
|
+
// Only when ASKED for. Left to inherit, a label inside a button is
|
|
111
|
+
// unselectable and a paragraph is selectable, each from its container —
|
|
112
|
+
// which is what a container setting `userSelect` is trying to say.
|
|
113
|
+
userSelect && selectStyles[userSelect],
|
|
111
114
|
styles[align],
|
|
112
115
|
decoration && styles[decoration],
|
|
113
116
|
tabular && styles.tabular,
|
|
@@ -123,10 +126,20 @@ export function Text(props: TextProps) {
|
|
|
123
126
|
);
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Selectability, applied only when a caller states it. Its own sheet because the
|
|
131
|
+
* value `"text"` shares a name with the BASE text style — mapped through the main
|
|
132
|
+
* sheet, asking for `userSelect="text"` resolved to that base and set nothing.
|
|
133
|
+
*/
|
|
134
|
+
const selectStyles = StyleSheet.create({
|
|
135
|
+
text: { userSelect: "text" },
|
|
136
|
+
auto: { userSelect: "auto" },
|
|
137
|
+
none: { userSelect: "none" },
|
|
138
|
+
});
|
|
139
|
+
|
|
126
140
|
const styles = StyleSheet.create({
|
|
127
141
|
text: {
|
|
128
142
|
letterSpacing: -0.4,
|
|
129
|
-
userSelect: "text",
|
|
130
143
|
},
|
|
131
144
|
|
|
132
145
|
// Text size styles
|
|
@@ -197,14 +210,6 @@ const styles = StyleSheet.create({
|
|
|
197
210
|
textDecorationLine: "underline line-through",
|
|
198
211
|
},
|
|
199
212
|
|
|
200
|
-
// User select styles
|
|
201
|
-
auto: {
|
|
202
|
-
userSelect: "auto",
|
|
203
|
-
},
|
|
204
|
-
none: {
|
|
205
|
-
userSelect: "none",
|
|
206
|
-
},
|
|
207
|
-
|
|
208
213
|
tabular: {
|
|
209
214
|
fontVariant: ["tabular-nums"],
|
|
210
215
|
},
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { ScrollView, StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
+
import { MenuButton } from "./menu_button";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
8
|
+
import {
|
|
9
|
+
composeTime,
|
|
10
|
+
dayPeriodOptions,
|
|
11
|
+
decomposeTime,
|
|
12
|
+
hourOptions,
|
|
13
|
+
minuteOptions,
|
|
14
|
+
} from "./time_options";
|
|
15
|
+
import type { PickerOption } from "./picker";
|
|
16
|
+
|
|
17
|
+
/** Row height, and the unit the seat scroll counts in. */
|
|
18
|
+
const ROW_HEIGHT = 36;
|
|
19
|
+
/** How many rows a column shows before it scrolls. Odd, so the seated row can
|
|
20
|
+
* sit near the middle with context above and below it rather than at an edge. */
|
|
21
|
+
const VISIBLE_ROWS = 7;
|
|
22
|
+
|
|
23
|
+
export interface TimeColumnsProps {
|
|
24
|
+
/** Canonical 24-hour `"HH:mm"`, `""` when empty (the columns then sit on midnight). */
|
|
25
|
+
value: string;
|
|
26
|
+
/** Fires on every pick, with a complete canonical time — one column at a time,
|
|
27
|
+
* so the value is live rather than staged behind a confirm. */
|
|
28
|
+
onValueChange: (value: string) => void;
|
|
29
|
+
/** BCP-47 locale deciding 12- vs 24-hour, and whether there is a period column
|
|
30
|
+
* at all. Defaults to the active `LoticsLocaleProvider` locale. */
|
|
31
|
+
locale?: string;
|
|
32
|
+
/** Accessible names for the three columns. Defaults to the `datePicker` slice. */
|
|
33
|
+
labels?: { hour: string; minute: string; dayPeriod: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The picking face of a time: an hour column, a minute column, and — only where
|
|
38
|
+
* the locale uses one — a day period.
|
|
39
|
+
*
|
|
40
|
+
* Split by UNIT rather than offered as composed times, which is what a native
|
|
41
|
+
* picker does and what makes every time reachable: a list of whole times has to
|
|
42
|
+
* choose a step, and any step makes the times between its rungs pickable only by
|
|
43
|
+
* typing. It also puts AM/PM where it belongs. A period is a choice between two
|
|
44
|
+
* named things and never a thing to spell — leaving it on a keyboard was the
|
|
45
|
+
* clearest sign the composed list was the wrong model.
|
|
46
|
+
*
|
|
47
|
+
* Each column commits on its own, so the value is always a real time and the
|
|
48
|
+
* face can be read back from it. There is no draft to confirm and nothing
|
|
49
|
+
* half-entered to guard against.
|
|
50
|
+
*/
|
|
51
|
+
export function TimeColumns(props: TimeColumnsProps) {
|
|
52
|
+
const { value, onValueChange } = props;
|
|
53
|
+
const localeTag = useLocaleTag(props.locale);
|
|
54
|
+
const loc = useLoticsLocale().datePicker;
|
|
55
|
+
const labels = props.labels ?? loc;
|
|
56
|
+
|
|
57
|
+
const parts = useMemo(() => decomposeTime(value, localeTag), [value, localeTag]);
|
|
58
|
+
const hours = useMemo(() => hourOptions(localeTag), [localeTag]);
|
|
59
|
+
const minutes = useMemo(() => minuteOptions(), []);
|
|
60
|
+
const periods = useMemo(() => dayPeriodOptions(localeTag), [localeTag]);
|
|
61
|
+
|
|
62
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View style={styles.columns}>
|
|
66
|
+
<TimeColumn
|
|
67
|
+
accessibilityLabel={labels.hour}
|
|
68
|
+
options={hours}
|
|
69
|
+
value={pad(parts.displayHour)}
|
|
70
|
+
onValueChange={(next) => onValueChange(composeTime({ ...parts, displayHour: Number(next) }))}
|
|
71
|
+
/>
|
|
72
|
+
<TimeColumn
|
|
73
|
+
accessibilityLabel={labels.minute}
|
|
74
|
+
options={minutes}
|
|
75
|
+
value={pad(parts.minute)}
|
|
76
|
+
onValueChange={(next) => onValueChange(composeTime({ ...parts, minute: Number(next) }))}
|
|
77
|
+
/>
|
|
78
|
+
{periods.length > 0 && (
|
|
79
|
+
<TimeColumn
|
|
80
|
+
accessibilityLabel={labels.dayPeriod}
|
|
81
|
+
options={periods}
|
|
82
|
+
value={parts.pm ? "pm" : "am"}
|
|
83
|
+
onValueChange={(next) => onValueChange(composeTime({ ...parts, pm: next === "pm" }))}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TimeColumnProps {
|
|
91
|
+
options: PickerOption<string>[];
|
|
92
|
+
value: string;
|
|
93
|
+
onValueChange: (value: string) => void;
|
|
94
|
+
accessibilityLabel: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One unit's column: a listbox that scrolls to its selection on open and moves
|
|
99
|
+
* on arrows.
|
|
100
|
+
*
|
|
101
|
+
* Roving tabindex, per the composite-widget rule — the selected row is the one
|
|
102
|
+
* tab stop and the rest are reached with arrows, so Tab crosses the three
|
|
103
|
+
* columns in three stops instead of sixty. Not `OptionList`: that body owns a
|
|
104
|
+
* hidden autofocus input for its typeahead, and three of them side by side would
|
|
105
|
+
* fight over focus the moment the popover opened.
|
|
106
|
+
*/
|
|
107
|
+
function TimeColumn(props: TimeColumnProps) {
|
|
108
|
+
const { options, value, onValueChange, accessibilityLabel } = props;
|
|
109
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
110
|
+
const rowRefs = useRef<(View | null)[]>([]);
|
|
111
|
+
|
|
112
|
+
const selectedIndex = options.findIndex((option) => option.value === value);
|
|
113
|
+
// A value the column does not offer still needs a tab stop, or the column
|
|
114
|
+
// becomes unreachable by keyboard.
|
|
115
|
+
const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
|
|
116
|
+
|
|
117
|
+
const scrollToIndex = useCallback((index: number) => {
|
|
118
|
+
if (index < 0) return;
|
|
119
|
+
// Centre the row: subtract half the visible window, floored at the top.
|
|
120
|
+
const offset = index * ROW_HEIGHT - ((VISIBLE_ROWS - 1) / 2) * ROW_HEIGHT;
|
|
121
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, offset), animated: false });
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// Seat on OPEN only. The column mounts with the popover, so this runs once per
|
|
125
|
+
// opening; re-running it on every pick would yank the list back under the
|
|
126
|
+
// pointer while someone is scanning it.
|
|
127
|
+
const seatedRef = useRef(false);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (seatedRef.current) return;
|
|
130
|
+
seatedRef.current = true;
|
|
131
|
+
scrollToIndex(tabStopIndex);
|
|
132
|
+
}, [tabStopIndex, scrollToIndex]);
|
|
133
|
+
|
|
134
|
+
const handleKeyDown = useCallback(
|
|
135
|
+
(event: { key: string; preventDefault?: () => void }, index: number) => {
|
|
136
|
+
const last = options.length - 1;
|
|
137
|
+
let next = index;
|
|
138
|
+
switch (event.key) {
|
|
139
|
+
case "ArrowDown":
|
|
140
|
+
next = index === last ? 0 : index + 1;
|
|
141
|
+
break;
|
|
142
|
+
case "ArrowUp":
|
|
143
|
+
next = index === 0 ? last : index - 1;
|
|
144
|
+
break;
|
|
145
|
+
case "Home":
|
|
146
|
+
next = 0;
|
|
147
|
+
break;
|
|
148
|
+
case "End":
|
|
149
|
+
next = last;
|
|
150
|
+
break;
|
|
151
|
+
case "Enter":
|
|
152
|
+
case " ":
|
|
153
|
+
event.preventDefault?.();
|
|
154
|
+
onValueChange(options[index].value);
|
|
155
|
+
return;
|
|
156
|
+
default:
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
event.preventDefault?.();
|
|
160
|
+
// Selection follows focus, the listbox pattern for a single-select column:
|
|
161
|
+
// arrowing IS choosing, so the value under the cursor is always the value.
|
|
162
|
+
onValueChange(options[next].value);
|
|
163
|
+
scrollToIndex(next);
|
|
164
|
+
rowRefs.current[next]?.focus();
|
|
165
|
+
},
|
|
166
|
+
[options, onValueChange, scrollToIndex],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<ScrollView
|
|
171
|
+
ref={scrollRef}
|
|
172
|
+
style={styles.column}
|
|
173
|
+
contentContainerStyle={styles.columnContent}
|
|
174
|
+
showsVerticalScrollIndicator={false}
|
|
175
|
+
accessibilityLabel={accessibilityLabel}
|
|
176
|
+
// `listbox` is valid ARIA but absent from RN's Role enum; rn-web forwards it.
|
|
177
|
+
role={"listbox" as "list"}
|
|
178
|
+
>
|
|
179
|
+
{options.map((option, index) => (
|
|
180
|
+
<MenuButton
|
|
181
|
+
key={option.value}
|
|
182
|
+
ref={(node: View | null) => {
|
|
183
|
+
rowRefs.current[index] = node;
|
|
184
|
+
}}
|
|
185
|
+
role="option"
|
|
186
|
+
selected={index === selectedIndex}
|
|
187
|
+
title={
|
|
188
|
+
<Text size="sm" userSelect="none">
|
|
189
|
+
{option.label}
|
|
190
|
+
</Text>
|
|
191
|
+
}
|
|
192
|
+
accessibilityLabel={option.label}
|
|
193
|
+
onPress={() => onValueChange(option.value)}
|
|
194
|
+
onKeyDown={(event) => handleKeyDown(event, index)}
|
|
195
|
+
// Exactly one stop per column; the rest are reached with arrows.
|
|
196
|
+
// tabIndex, NOT focusable — RN-Web's Pressable ignores `focusable`.
|
|
197
|
+
tabIndex={index === tabStopIndex ? 0 : -1}
|
|
198
|
+
style={styles.row}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
</ScrollView>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const styles = StyleSheet.create({
|
|
206
|
+
columns: {
|
|
207
|
+
flexDirection: "row",
|
|
208
|
+
gap: 4,
|
|
209
|
+
},
|
|
210
|
+
column: {
|
|
211
|
+
maxHeight: ROW_HEIGHT * VISIBLE_ROWS,
|
|
212
|
+
borderRadius: CONTROL_RADIUS,
|
|
213
|
+
backgroundColor: colors.white,
|
|
214
|
+
},
|
|
215
|
+
columnContent: {
|
|
216
|
+
// Padding rather than a spacer row, so the first and last values can still
|
|
217
|
+
// reach the middle of the window when the column is scrolled to an end.
|
|
218
|
+
paddingVertical: 2,
|
|
219
|
+
},
|
|
220
|
+
row: {
|
|
221
|
+
height: ROW_HEIGHT,
|
|
222
|
+
minWidth: 56,
|
|
223
|
+
justifyContent: "center",
|
|
224
|
+
},
|
|
225
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { TIME_FORMAT_OPTIONS, getTimeLayout, to12h } from "./date_segments";
|
|
2
|
+
import type { PickerOption } from "./picker";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A time of day split into the parts a person PICKS.
|
|
6
|
+
*
|
|
7
|
+
* `displayHour` is what the locale shows on the hour column — 0–23 where the
|
|
8
|
+
* locale uses a 24-hour clock, 12/1–11 where it uses a 12-hour one. It is not
|
|
9
|
+
* the canonical hour: in a 12-hour locale the same displayed hour means two
|
|
10
|
+
* different times and only `pm` says which, which is why composing takes both.
|
|
11
|
+
*/
|
|
12
|
+
export interface TimeParts {
|
|
13
|
+
displayHour: number;
|
|
14
|
+
minute: number;
|
|
15
|
+
/** `null` in a 24-hour locale — there is no period to choose. */
|
|
16
|
+
pm: boolean | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MINUTES_PER_HOUR = 60;
|
|
20
|
+
|
|
21
|
+
/** Whether `locale` puts a day period on the clock, and what it calls one. */
|
|
22
|
+
export function dayPeriodLabels(locale: string): { am: string; pm: string } | null {
|
|
23
|
+
const layout = getTimeLayout(locale);
|
|
24
|
+
return layout.hour12 ? { am: layout.amText, pm: layout.pmText } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Minute-of-day for a canonical `"HH:mm"`, or `null` when it is not one. */
|
|
28
|
+
export function timeToMinuteOfDay(value: string): number | null {
|
|
29
|
+
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
const hour = Number(match[1]);
|
|
32
|
+
const minute = Number(match[2]);
|
|
33
|
+
if (hour > 23 || minute > 59) return null;
|
|
34
|
+
return hour * MINUTES_PER_HOUR + minute;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split a canonical `"HH:mm"` into the parts the columns show.
|
|
39
|
+
*
|
|
40
|
+
* TOTAL: a missing or unreadable value decomposes to midnight rather than
|
|
41
|
+
* returning null, because the columns must always have a row to sit on. An empty
|
|
42
|
+
* field therefore opens at 12:00 AM / 00:00 and the first pick composes a real
|
|
43
|
+
* time from it — there is no partial state to represent, which is the whole
|
|
44
|
+
* reason a picked time needs no "incomplete" signal.
|
|
45
|
+
*/
|
|
46
|
+
export function decomposeTime(value: string, locale: string): TimeParts {
|
|
47
|
+
const minuteOfDay = timeToMinuteOfDay(value) ?? 0;
|
|
48
|
+
const hour24 = Math.floor(minuteOfDay / MINUTES_PER_HOUR);
|
|
49
|
+
const minute = minuteOfDay % MINUTES_PER_HOUR;
|
|
50
|
+
if (!dayPeriodLabels(locale)) return { displayHour: hour24, minute, pm: null };
|
|
51
|
+
const { h12, pm } = to12h(hour24);
|
|
52
|
+
return { displayHour: h12, minute, pm };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build a canonical 24-hour `"HH:mm"` back out of the parts. */
|
|
56
|
+
export function composeTime(parts: TimeParts): string {
|
|
57
|
+
const { displayHour, minute, pm } = parts;
|
|
58
|
+
let hour24 = displayHour;
|
|
59
|
+
if (pm !== null) {
|
|
60
|
+
// 12 AM is 00 and 12 PM is 12 — the one place the 12-hour clock is not
|
|
61
|
+
// simply "add twelve".
|
|
62
|
+
const base = displayHour % 12;
|
|
63
|
+
hour24 = pm ? base + 12 : base;
|
|
64
|
+
}
|
|
65
|
+
return `${String(hour24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
69
|
+
|
|
70
|
+
function formatter(locale: string): Intl.DateTimeFormat {
|
|
71
|
+
const cached = formatters.get(locale);
|
|
72
|
+
if (cached) return cached;
|
|
73
|
+
const next = new Intl.DateTimeFormat(locale, TIME_FORMAT_OPTIONS);
|
|
74
|
+
formatters.set(locale, next);
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How a time of day READS in `locale` — 12- or 24-hour, in that locale's own
|
|
80
|
+
* digits and separator.
|
|
81
|
+
*
|
|
82
|
+
* Formats under the same {@link TIME_FORMAT_OPTIONS} the date field's segments
|
|
83
|
+
* lay themselves out from, so a time shown on a picker's face and the same time
|
|
84
|
+
* shown in a datetime field can never disagree. A value that is not a time comes
|
|
85
|
+
* back unchanged rather than as "Invalid Date".
|
|
86
|
+
*/
|
|
87
|
+
export function formatTimeOfDay(value: string, locale: string): string {
|
|
88
|
+
const minuteOfDay = timeToMinuteOfDay(value);
|
|
89
|
+
if (minuteOfDay === null) return value;
|
|
90
|
+
// Any date works — only the time fields are formatted. Fixed rather than
|
|
91
|
+
// `new Date()` so the function is pure and testable.
|
|
92
|
+
const at = new Date(2023, 0, 2, Math.floor(minuteOfDay / MINUTES_PER_HOUR), minuteOfDay % MINUTES_PER_HOUR);
|
|
93
|
+
return formatter(locale).format(at);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The hours a locale offers, in clock order.
|
|
100
|
+
*
|
|
101
|
+
* A 12-hour locale leads with 12 rather than 1 — that is the order on a clock
|
|
102
|
+
* face and on every native picker, and starting at 1 puts midnight and noon at
|
|
103
|
+
* the bottom of a list you scroll from the top.
|
|
104
|
+
*/
|
|
105
|
+
export function hourOptions(locale: string): PickerOption<string>[] {
|
|
106
|
+
if (!dayPeriodLabels(locale)) {
|
|
107
|
+
return Array.from({ length: 24 }, (_, hour) => ({ value: pad(hour), label: pad(hour) }));
|
|
108
|
+
}
|
|
109
|
+
return [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((hour) => ({
|
|
110
|
+
value: pad(hour),
|
|
111
|
+
label: pad(hour),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every minute of the hour.
|
|
117
|
+
*
|
|
118
|
+
* Not a coarser step: a step turns the picker into a list of ALLOWED times, and
|
|
119
|
+
* a gate cut-off at 13:07 is then unreachable by picking — which is what forced
|
|
120
|
+
* the old flat list to splice odd values back in. Sixty rows scroll and seat
|
|
121
|
+
* exactly like the hours beside them.
|
|
122
|
+
*/
|
|
123
|
+
export function minuteOptions(): PickerOption<string>[] {
|
|
124
|
+
return Array.from({ length: MINUTES_PER_HOUR }, (_, minute) => ({
|
|
125
|
+
value: pad(minute),
|
|
126
|
+
label: pad(minute),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The two periods, named as `locale` names them. Empty where it has none. */
|
|
131
|
+
export function dayPeriodOptions(locale: string): PickerOption<string>[] {
|
|
132
|
+
const labels = dayPeriodLabels(locale);
|
|
133
|
+
if (!labels) return [];
|
|
134
|
+
return [
|
|
135
|
+
{ value: "am", label: labels.am },
|
|
136
|
+
{ value: "pm", label: labels.pm },
|
|
137
|
+
];
|
|
138
|
+
}
|
package/src/time_picker.tsx
CHANGED
|
@@ -1,114 +1,152 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import {
|
|
5
|
+
CONTROL_HEIGHT,
|
|
6
|
+
CONTROL_PADDING_V,
|
|
7
|
+
CONTROL_RADIUS,
|
|
8
|
+
CONTROL_TRANSITION,
|
|
9
|
+
FOCUS_RING,
|
|
10
|
+
HOVER_BORDER,
|
|
11
|
+
} from "./control_surface";
|
|
12
|
+
import { Icon } from "./icon";
|
|
13
|
+
import { Popover, PopoverContent } from "./popover";
|
|
14
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
15
|
+
import { Text } from "./text";
|
|
16
|
+
import { TimeColumns } from "./time_columns";
|
|
17
|
+
import { formatTimeOfDay } from "./time_options";
|
|
8
18
|
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
9
19
|
|
|
10
20
|
export interface TimePickerProps {
|
|
11
21
|
/** Canonical 24-hour "HH:mm", `""` when empty — independent of how it displays. */
|
|
12
22
|
value?: string;
|
|
13
23
|
onValueChange: (value: string) => void;
|
|
14
|
-
onBlur?: () => void;
|
|
15
|
-
autoFocus?: boolean;
|
|
16
24
|
disabled?: boolean;
|
|
17
25
|
accessibilityLabel?: string;
|
|
18
26
|
/**
|
|
19
|
-
* BCP-47 locale deciding 12- vs 24-hour DISPLAY
|
|
20
|
-
* `LoticsLocaleProvider`
|
|
27
|
+
* BCP-47 locale deciding 12- vs 24-hour DISPLAY, and whether the picker offers
|
|
28
|
+
* a day-period column at all. Defaults to the active `LoticsLocaleProvider`
|
|
29
|
+
* locale.
|
|
21
30
|
*
|
|
22
|
-
* This is the whole reason the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
31
|
+
* This is the whole reason the face is ours and not `<input type="time">`: a
|
|
32
|
+
* native time input takes its 12/24-hour form from the BROWSER's UI locale and
|
|
33
|
+
* ignores `lang` entirely, so a Vietnamese screen on an en-US browser read
|
|
25
34
|
* "01:45 PM" next to its own 24-hour "13:45 02/01/2023".
|
|
26
35
|
*/
|
|
27
36
|
locale?: string;
|
|
28
|
-
/**
|
|
29
|
-
|
|
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;
|
|
37
|
+
/** Shown when no time is set. Defaults to the `datePicker` slice's `chooseTime`. */
|
|
38
|
+
placeholder?: string;
|
|
35
39
|
testID?: string;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
|
-
* A time of day
|
|
40
|
-
*
|
|
41
|
-
*
|
|
43
|
+
* A time of day: the value on a pressable field, and an hour / minute / period
|
|
44
|
+
* picker behind it.
|
|
45
|
+
*
|
|
46
|
+
* There is no text entry. A time used to be TYPED into segments here, with the
|
|
47
|
+
* picker bolted on as a small glyph inside the field, and both halves of that
|
|
48
|
+
* were wrong. The glyph sat exactly where `DateField` puts a display-only
|
|
49
|
+
* calendar icon — same slot, same size, same colour, one pressable and one not —
|
|
50
|
+
* so the only affordance for picking was one a reader had no way to identify.
|
|
51
|
+
* And typing is simply the wrong input for part of a time: a day period is a
|
|
52
|
+
* choice between two named things, and asking someone to spell "PM" is asking
|
|
53
|
+
* them to type an answer to a yes/no question.
|
|
54
|
+
*
|
|
55
|
+
* The trigger is the kit's "current value, opens a list" surface — the bordered
|
|
56
|
+
* control a `Select` uses — so a time field is recognisable as pickable by the
|
|
57
|
+
* same shape as every other field that opens something.
|
|
58
|
+
*
|
|
59
|
+
* The value stays canonical 24-hour "HH:mm" whatever the locale shows.
|
|
42
60
|
*/
|
|
43
61
|
export function TimePicker(props: TimePickerProps) {
|
|
44
62
|
const {
|
|
45
63
|
value = "",
|
|
46
64
|
onValueChange,
|
|
47
|
-
onBlur,
|
|
48
|
-
autoFocus,
|
|
49
65
|
disabled,
|
|
50
66
|
accessibilityLabel,
|
|
51
67
|
locale,
|
|
52
|
-
onEscape,
|
|
53
|
-
onIncompleteChange,
|
|
54
68
|
testID,
|
|
55
69
|
} = props;
|
|
56
70
|
const localeTag = useLocaleTag(locale);
|
|
57
71
|
const loc = useLoticsLocale().datePicker;
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const [focused, setFocused] = useState(false);
|
|
72
|
+
const placeholder = props.placeholder ?? loc.chooseTime;
|
|
73
|
+
const [open, setOpen] = useState(false);
|
|
74
|
+
const triggerRef = useRef<View>(null);
|
|
62
75
|
|
|
63
76
|
return (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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}
|
|
77
|
+
<>
|
|
78
|
+
<PressableHighlight
|
|
79
|
+
focusRing
|
|
80
|
+
ref={triggerRef}
|
|
81
|
+
testID={testID}
|
|
79
82
|
disabled={disabled}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
// A field whose value comes from a list it opens — the same role the
|
|
84
|
+
// kit's other "pick one" triggers carry.
|
|
85
|
+
accessibilityRole="combobox"
|
|
86
|
+
accessibilityLabel={accessibilityLabel ?? loc.chooseTime}
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
aria-disabled={disabled}
|
|
89
|
+
style={(state) => [
|
|
90
|
+
styles.trigger,
|
|
91
|
+
CONTROL_TRANSITION,
|
|
92
|
+
open && styles.opened,
|
|
93
|
+
disabled && styles.disabled,
|
|
94
|
+
state.hovered && !disabled && { borderColor: HOVER_BORDER },
|
|
95
|
+
]}
|
|
96
|
+
onPress={disabled ? undefined : () => setOpen(true)}
|
|
97
|
+
>
|
|
98
|
+
<Icon
|
|
99
|
+
name="clock"
|
|
100
|
+
size={18}
|
|
101
|
+
color={disabled ? colors.zinc["300"] : colors.zinc["400"]}
|
|
102
|
+
/>
|
|
103
|
+
{value ? (
|
|
104
|
+
<Text size="sm" userSelect="none">
|
|
105
|
+
{formatTimeOfDay(value, localeTag)}
|
|
106
|
+
</Text>
|
|
107
|
+
) : (
|
|
108
|
+
<Text size="sm" color="zinc-500" userSelect="none">
|
|
109
|
+
{placeholder}
|
|
110
|
+
</Text>
|
|
111
|
+
)}
|
|
112
|
+
</PressableHighlight>
|
|
113
|
+
<Popover
|
|
114
|
+
open={open && !disabled}
|
|
115
|
+
onOpenChange={setOpen}
|
|
116
|
+
triggerRef={triggerRef}
|
|
117
|
+
side="bottom"
|
|
118
|
+
align="start"
|
|
119
|
+
>
|
|
120
|
+
<PopoverContent testID={testID ? `${testID}_time_columns` : undefined} disableBodyScroll>
|
|
121
|
+
<TimeColumns value={value} onValueChange={onValueChange} locale={locale} />
|
|
122
|
+
</PopoverContent>
|
|
123
|
+
</Popover>
|
|
124
|
+
</>
|
|
91
125
|
);
|
|
92
126
|
}
|
|
93
127
|
|
|
94
128
|
const styles = StyleSheet.create({
|
|
95
|
-
|
|
129
|
+
trigger: {
|
|
96
130
|
flexDirection: "row",
|
|
97
131
|
alignItems: "center",
|
|
98
|
-
|
|
132
|
+
gap: 6,
|
|
99
133
|
paddingHorizontal: 8,
|
|
100
|
-
|
|
134
|
+
// Shared with every other control trigger, so a time field seats at exactly
|
|
135
|
+
// `CONTROL_HEIGHT` beside the date field it usually sits under.
|
|
136
|
+
paddingVertical: CONTROL_PADDING_V,
|
|
137
|
+
minHeight: CONTROL_HEIGHT,
|
|
101
138
|
borderWidth: 1,
|
|
102
139
|
borderColor: colors.border,
|
|
140
|
+
borderRadius: CONTROL_RADIUS,
|
|
103
141
|
backgroundColor: colors.background,
|
|
104
142
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
frameFocused: {
|
|
143
|
+
opened: {
|
|
144
|
+
// Mouse-opened, so the trigger never takes keyboard focus — wear the same
|
|
145
|
+
// ring a focused control gets, over the unchanged 1px border.
|
|
109
146
|
boxShadow: FOCUS_RING,
|
|
110
147
|
},
|
|
111
|
-
|
|
148
|
+
disabled: {
|
|
112
149
|
backgroundColor: colors.zinc[50],
|
|
150
|
+
opacity: 0.5,
|
|
113
151
|
},
|
|
114
152
|
});
|