@lotics/ui 42.4.0 → 43.1.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.
Files changed (54) hide show
  1. package/MIGRATION.md +74 -0
  2. package/docs/catalog.md +38 -6
  3. package/docs/composition.md +45 -3
  4. package/docs/data_entry.md +9 -4
  5. package/package.json +12 -2
  6. package/src/back_button.tsx +4 -1
  7. package/src/badge.tsx +10 -2
  8. package/src/button.tsx +69 -8
  9. package/src/check_circle.tsx +1 -2
  10. package/src/checkbox_input.tsx +2 -2
  11. package/src/choice_list.tsx +2 -2
  12. package/src/color_tokens.ts +27 -3
  13. package/src/colors.web.ts +4 -2
  14. package/src/comments_button.tsx +3 -1
  15. package/src/control_surface.ts +27 -0
  16. package/src/data_grid.tsx +1 -1
  17. package/src/date_calendar.tsx +210 -58
  18. package/src/date_filter.tsx +1 -5
  19. package/src/date_picker.tsx +2 -0
  20. package/src/date_range_selection.ts +18 -0
  21. package/src/date_segments.ts +15 -1
  22. package/src/display_font.ts +27 -0
  23. package/src/display_font.web.ts +31 -0
  24. package/src/file_dropzone.tsx +2 -1
  25. package/src/file_row.tsx +2 -2
  26. package/src/file_thumbnail.tsx +4 -1
  27. package/src/filter_chip.tsx +12 -2
  28. package/src/focus_ring_pressable.tsx +6 -1
  29. package/src/font_family.ts +26 -0
  30. package/src/font_family.web.ts +29 -0
  31. package/src/icon_button.tsx +3 -1
  32. package/src/image_gallery.tsx +1 -1
  33. package/src/index.css +0 -2
  34. package/src/inline_button.tsx +3 -1
  35. package/src/inline_time_picker.tsx +97 -48
  36. package/src/list_item.tsx +109 -11
  37. package/src/locale.tsx +2 -2
  38. package/src/menu_button.tsx +19 -0
  39. package/src/option_list.tsx +8 -0
  40. package/src/pressable_highlight.tsx +20 -8
  41. package/src/pressable_row.tsx +7 -3
  42. package/src/scroll_to_bottom.tsx +3 -0
  43. package/src/slider.tsx +2 -2
  44. package/src/summary.tsx +28 -4
  45. package/src/switch.tsx +3 -1
  46. package/src/table.tsx +46 -4
  47. package/src/text.tsx +32 -11
  48. package/src/text_utils.ts +3 -11
  49. package/src/theme.web.tsx +16 -1
  50. package/src/theme_context.ts +63 -1
  51. package/src/time_columns.tsx +225 -0
  52. package/src/time_options.ts +138 -0
  53. package/src/time_picker.tsx +102 -64
  54. package/src/use_option_list.ts +19 -0
@@ -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
+ }
@@ -1,114 +1,152 @@
1
- import { useMemo, useState } from "react";
1
+ import { useRef, useState } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
- import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
5
- import { useHover } from "./use_hover";
6
- import { DateSegments } from "./date_segments_field";
7
- import { SegmentLabels, timeSegmentsConfig } from "./date_segments";
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. Defaults to the active
20
- * `LoticsLocaleProvider` locale.
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 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
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
- /** 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;
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. 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).
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 segmentLabels = props.segmentLabels ?? loc;
59
- const config = useMemo(() => timeSegmentsConfig(localeTag), [localeTag]);
60
- const { hovered, hoverProps } = useHover();
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
- <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}
77
+ <>
78
+ <PressableHighlight
79
+ focusRing
80
+ ref={triggerRef}
81
+ testID={testID}
79
82
  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>
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
- frame: {
129
+ trigger: {
96
130
  flexDirection: "row",
97
131
  alignItems: "center",
98
- height: 40,
132
+ gap: 6,
99
133
  paddingHorizontal: 8,
100
- borderRadius: CONTROL_RADIUS,
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
- frameHovered: {
106
- borderColor: HOVER_BORDER,
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
- frameDisabled: {
148
+ disabled: {
112
149
  backgroundColor: colors.zinc[50],
150
+ opacity: 0.5,
113
151
  },
114
152
  });
@@ -252,6 +252,25 @@ export function useOptionList<T extends string, MULTI extends boolean = false, D
252
252
  if (index >= 0) scrollRef.current?.scrollTo({ y: index * OPTION_HEIGHT - 80, animated: false });
253
253
  }, []);
254
254
 
255
+ // Scroll the OPENING seat into view.
256
+ //
257
+ // `initialIndex` seats the highlight through `useState`, which fires no
258
+ // `onActiveChange` — so the row it lands on was highlighted and never scrolled
259
+ // to. On a list short enough to fit that is invisible; past a screenful the
260
+ // list opens at the top with the current value somewhere below the fold, which
261
+ // is the one row it was opened to show. A day of times at 15-minute steps is 96
262
+ // rows, so an afternoon value sat two thirds of the way down an unscrolled list.
263
+ //
264
+ // Gated on rows ARRIVING rather than on mount, so an async list (options still
265
+ // loading when the popover opens) seats on its real value instead of on the
266
+ // empty list's index 0. Once only: after that the member owns the scroll.
267
+ const scrollSeatedRef = useRef(false);
268
+ useEffect(() => {
269
+ if (scrollSeatedRef.current || rows.length === 0) return;
270
+ scrollSeatedRef.current = true;
271
+ scrollToIndex(initialActiveIndex);
272
+ }, [rows.length, initialActiveIndex, scrollToIndex]);
273
+
255
274
  const requestClose = useCallback(() => {
256
275
  onRequestClose?.();
257
276
  if (onClose) {