@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
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: "pointer", touchAction: "none" } as ViewStyle),
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: "pointer",
292
+ cursor: "auto",
293
293
  boxShadow: "0 1px 3px rgba(38,38,38,0.18)",
294
294
  } as ViewStyle),
295
295
  },
package/src/summary.tsx CHANGED
@@ -28,6 +28,16 @@ export interface SummaryBucket {
28
28
  color: string;
29
29
  /** What this bucket means — the ⓘ beside its count. */
30
30
  info?: string;
31
+ /**
32
+ * Valence on the bucket's COUNT, for a bucket that represents work someone has
33
+ * to do. Reserve it: a screen where every bucket is toned has said nothing, and
34
+ * the bucket holding the largest number is usually the one that needs it least
35
+ * (work that has left the building is not a queue).
36
+ *
37
+ * It colours the figure only. The segment keeps the bucket's own `color`, so
38
+ * the bar goes on reading as a distribution rather than as a heat map.
39
+ */
40
+ tone?: MetricTone;
31
41
  }
32
42
 
33
43
  interface SummaryContext {
@@ -99,13 +109,26 @@ function Header(props: { children: ReactNode }) {
99
109
  /**
100
110
  * The set's SIZE, at display scale — the band's headline.
101
111
  *
102
- * The number is the sum of the buckets and is never passed in. A page that
103
- * restates its own total eventually restates it wrongly: a bucket gets added,
112
+ * By DEFAULT it is the sum of the buckets, and that default is the point: a page
113
+ * that restates its own total eventually restates it wrongly a bucket is added,
104
114
  * the constant does not move, and the headline quietly disagrees with the bar
105
115
  * directly beneath it.
116
+ *
117
+ * `value` overrides it, for the one case that is not a restatement: a total with
118
+ * its OWN authority, counted by the same source that counted the buckets. A
119
+ * server-paginated register is the example — the buckets are per-stage `COUNT`s
120
+ * over the filtered view and the size is a fourth count over that same view, so
121
+ * summing the three client-side would replace an authoritative number with a
122
+ * derived one that can differ (a bucket query filtered a shade differently, or
123
+ * landed a moment later) and would be believed because it is the biggest figure
124
+ * on the screen.
125
+ *
126
+ * The line, then: pass `value` when something ELSE counted it, never to write a
127
+ * literal. A number typed into this prop is the defect the default prevents.
106
128
  */
107
- function Total(props: { label: string; formatValue?: (n: number) => string }) {
108
- const { total } = useSummary("Total");
129
+ function Total(props: { label: string; value?: number; formatValue?: (n: number) => string }) {
130
+ const { total: summed } = useSummary("Total");
131
+ const total = props.value ?? summed;
109
132
  const tag = useLocaleTag();
110
133
  // Grouped by the reader's locale by default — a set of 12,345 was rendering
111
134
  // "12345" in the one place on the screen sized to be read first, because the
@@ -187,6 +210,7 @@ function Facts(props: { extra?: readonly SummaryLineItem[] }) {
187
210
  value: b.value,
188
211
  color: b.color,
189
212
  info: b.info,
213
+ tone: b.tone,
190
214
  }));
191
215
  return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
192
216
  }
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/table.tsx CHANGED
@@ -17,6 +17,7 @@ import { Divider } from "./divider";
17
17
  import { DetailRow } from "./detail_row";
18
18
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
19
19
  import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
20
+ import { ROW_WASH_BLEED } from "./control_surface";
20
21
 
21
22
  /**
22
23
  * One column of a register — its width/flex/align/label/sortability defined ONCE,
@@ -120,8 +121,36 @@ export interface TableProps {
120
121
  * Positions are yours for the same reason — see `TableRowProps.ordinal`. This
121
122
  * prop only says "this register is counted", which is what reserves the gutter
122
123
  * on the header and on every row at once, so they cannot disagree.
124
+ *
125
+ * Pass {@link TableProps.counted} INSTEAD when the page already states the
126
+ * total somewhere the reader will see first.
123
127
  */
124
128
  count?: number;
129
+ /**
130
+ * Number the rows WITHOUT heading the gutter with a total.
131
+ *
132
+ * The total earns its place in the header by answering "how many" without
133
+ * scrolling to the last row — so it stops earning it the moment the page
134
+ * answers that louder. A register whose band opens with the count in display
135
+ * type was printing the same figure twice within a hundred pixels, at the same
136
+ * left edge, one huge and one muted, which reads as a mistake rather than as
137
+ * two deliberate statements.
138
+ *
139
+ * Ignored when `count` is given; the gutter is reserved either way, so header
140
+ * and rows can never disagree about its width.
141
+ */
142
+ counted?: boolean;
143
+ /**
144
+ * Paint the column-header band with the brand's wash.
145
+ *
146
+ * OPT-IN, and deliberately not the default: a filled header is a real restyle
147
+ * of every table in every app, and the kit's language is a white canvas where
148
+ * a hairline does the separating. It earns its place on a register that is the
149
+ * page's whole content, where the band gives the columns a lid and the table
150
+ * stops floating; it is noise on a small table inside a card that already has
151
+ * edges of its own.
152
+ */
153
+ headerFill?: boolean;
125
154
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
126
155
  trailing?: number;
127
156
  /** The `TableRow`s. */
@@ -143,8 +172,11 @@ export interface TableProps {
143
172
  * keeps its current order). See `computeTableFit`.
144
173
  */
145
174
  export function Table(props: TableProps) {
146
- const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
147
- const ordinal = count != null ? ORDINAL_W : 0;
175
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, children } = props;
176
+ // Either prop reserves the gutter; only `count` puts a number in its header.
177
+ // `LeadGutter` already renders an empty box when it has no number — the same
178
+ // path a group band takes — so nothing new is needed to draw it.
179
+ const ordinal = count != null || counted ? ORDINAL_W : 0;
148
180
  const rows = Children.toArray(children).filter(isValidElement);
149
181
 
150
182
  // Measure-then-REVEAL (the `DetailTable` contract): the unmeasured first
@@ -174,12 +206,12 @@ export function Table(props: TableProps) {
174
206
  // select-all checkbox keeps its band: it's the bulk-select entry point,
175
207
  // aligned over the rows' leading checkboxes.
176
208
  (selectAll != null && leading > 0) || ordinal > 0 ? (
177
- <View style={styles.headerBand}>
209
+ <View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
178
210
  <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
179
211
  </View>
180
212
  ) : null
181
213
  ) : (
182
- <View style={styles.headerBand}>
214
+ <View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
183
215
  {/* The TOTAL heads the column of positions — the one number that
184
216
  answers "how many" without the reader scrolling to the last row. */}
185
217
  <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
@@ -458,6 +490,16 @@ const styles = StyleSheet.create({
458
490
  },
459
491
  // A hairline under the column header anchors the columns; the rows below it are
460
492
  // Divider-separated.
493
+ headerBandFilled: {
494
+ backgroundColor: colors.accent_wash,
495
+ // The band bleeds to the row wash's width so its edges line up with a hovered
496
+ // row beneath it; without this the lid is narrower than the rows it caps.
497
+ marginHorizontal: -ROW_WASH_BLEED,
498
+ paddingHorizontal: ROW_GUTTER + ROW_WASH_BLEED,
499
+ paddingTop: 8,
500
+ borderTopLeftRadius: 8,
501
+ borderTopRightRadius: 8,
502
+ },
461
503
  headerBand: {
462
504
  paddingHorizontal: ROW_GUTTER,
463
505
  // NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
package/src/text.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import "./text.css";
2
2
  import React from "react";
3
3
  import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet } from "react-native";
4
+ import { fontFamilyDisplay } from "./display_font";
4
5
  import {
5
6
  fontFamilyMedium,
6
7
  fontFamilyRegular,
@@ -14,6 +15,16 @@ export interface TextProps {
14
15
  testID?: string;
15
16
  size?: TextSize;
16
17
  color?: TextColor;
18
+ /**
19
+ * `display` renders in the app's display face — a headline, a wordmark, a
20
+ * figure the page exists to show. Everything else stays the body face, which
21
+ * is not themeable (see `display_font.ts`).
22
+ *
23
+ * A ROLE, not a font name: the call site says what the text IS, and the theme
24
+ * decides what that looks like. Threading family strings through call sites is
25
+ * how one screen ends up in a different face from the next.
26
+ */
27
+ family?: "display";
17
28
  align?: TextAlign;
18
29
  weight?: TextWeight;
19
30
  numberOfLines?: number;
@@ -62,13 +73,14 @@ type TextUserSelect = "none" | "auto" | "text";
62
73
  */
63
74
  export function Text(props: TextProps) {
64
75
  const {
65
- userSelect = "text",
76
+ userSelect,
66
77
  align = "left",
67
78
  children,
68
79
  testID,
69
80
  color = "default",
70
81
  size = "sm",
71
82
  weight = "regular",
83
+ family,
72
84
  numberOfLines,
73
85
  decoration,
74
86
  tabular,
@@ -107,7 +119,14 @@ export function Text(props: TextProps) {
107
119
  styles.text,
108
120
  Platform.OS !== "web" && styles[size],
109
121
  styles[weight],
110
- styles[userSelect],
122
+ // AFTER the weight, because the weight ladder sets a family too — each
123
+ // rung is a distinct Inter family, not a `font-weight` — so the display
124
+ // face has to win the family while leaving the numeric weight alone.
125
+ family === "display" && { fontFamily: fontFamilyDisplay },
126
+ // Only when ASKED for. Left to inherit, a label inside a button is
127
+ // unselectable and a paragraph is selectable, each from its container —
128
+ // which is what a container setting `userSelect` is trying to say.
129
+ userSelect && selectStyles[userSelect],
111
130
  styles[align],
112
131
  decoration && styles[decoration],
113
132
  tabular && styles.tabular,
@@ -123,10 +142,20 @@ export function Text(props: TextProps) {
123
142
  );
124
143
  }
125
144
 
145
+ /**
146
+ * Selectability, applied only when a caller states it. Its own sheet because the
147
+ * value `"text"` shares a name with the BASE text style — mapped through the main
148
+ * sheet, asking for `userSelect="text"` resolved to that base and set nothing.
149
+ */
150
+ const selectStyles = StyleSheet.create({
151
+ text: { userSelect: "text" },
152
+ auto: { userSelect: "auto" },
153
+ none: { userSelect: "none" },
154
+ });
155
+
126
156
  const styles = StyleSheet.create({
127
157
  text: {
128
158
  letterSpacing: -0.4,
129
- userSelect: "text",
130
159
  },
131
160
 
132
161
  // Text size styles
@@ -197,14 +226,6 @@ const styles = StyleSheet.create({
197
226
  textDecorationLine: "underline line-through",
198
227
  },
199
228
 
200
- // User select styles
201
- auto: {
202
- userSelect: "auto",
203
- },
204
- none: {
205
- userSelect: "none",
206
- },
207
-
208
229
  tabular: {
209
230
  fontVariant: ["tabular-nums"],
210
231
  },
package/src/text_utils.ts CHANGED
@@ -47,17 +47,9 @@ export function getTextColor(color?: TextColor): string {
47
47
  }
48
48
  }
49
49
 
50
- export const fontFamilyRegular = `Inter_400Regular, "apple-system", "BlinkMacSystemFont",
51
- "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
52
- "Droid Sans", "Helvetica Neue", sans-serif`;
53
-
54
- export const fontFamilyMedium = `Inter_500Medium, "apple-system", "BlinkMacSystemFont",
55
- "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
56
- "Droid Sans", "Helvetica Neue", sans-serif`;
57
-
58
- export const fontFamilySemiBold = `Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
59
- "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
60
- "Droid Sans", "Helvetica Neue", sans-serif`;
50
+ // The three rungs live in their own platform-split module — see `font_family.ts`.
51
+ // Re-exported here so every existing consumer keeps one import.
52
+ export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
61
53
 
62
54
  /** Line height for mobile web (prevents Safari iOS auto-zoom) */
63
55
  export const INPUT_LINE_HEIGHT_MOBILE = 24;
package/src/theme.web.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useMemo, type CSSProperties, type ReactNode } from "react";
2
- import { LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
2
+ import { ACCENT_WASH_VAR, deriveAccentWash, FONT_MEDIUM_VAR, FONT_SEMIBOLD_VAR, LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
3
3
 
4
4
  export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
5
5
 
@@ -46,6 +46,21 @@ export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
46
46
  const value = theme[role as keyof LoticsTheme];
47
47
  if (value !== undefined) vars[name] = value;
48
48
  }
49
+ // The wash is DERIVED here, where the accent is still a literal — see
50
+ // `deriveAccentWash`. An accent the parser cannot read leaves it undefined,
51
+ // so those surfaces keep the neutral default instead of breaking.
52
+ if (theme.accent !== undefined) {
53
+ const wash = deriveAccentWash(theme.accent);
54
+ if (wash !== undefined) vars[ACCENT_WASH_VAR] = wash;
55
+ }
56
+ // ONE font value, THREE rungs. `THEME_VARS` already mapped `bodyFont` to the
57
+ // regular rung; medium and semibold follow it here, because a family set on
58
+ // only one rung would leave every medium and semibold run in Inter — the
59
+ // screen would come out in two typefaces and look like a loading bug.
60
+ if (theme.bodyFont !== undefined) {
61
+ vars[FONT_MEDIUM_VAR] = theme.bodyFont;
62
+ vars[FONT_SEMIBOLD_VAR] = theme.bodyFont;
63
+ }
49
64
  // `display: contents` — the provider generates NO box. Custom properties
50
65
  // inherit down the DOM tree rather than the box tree, so the variables still
51
66
  // reach every descendant while the element itself adds no layout at all.
@@ -19,8 +19,40 @@ export interface LoticsTheme {
19
19
  * on almost every app; set it only for a deliberately toned surface. */
20
20
  background?: string;
21
21
  /** Hairlines — 48 sites in the kit, and a register is mostly these lines, so a
22
- * small change here is felt across a whole screen. */
22
+ * small change here is felt against a whole screen. */
23
23
  border?: string;
24
+ /**
25
+ * THE TYPEFACE, for everything. One value — a CSS font stack — and the whole
26
+ * app follows it.
27
+ *
28
+ * It fans out to the three weight rungs (`font_family.ts`), because weight is
29
+ * a FAMILY in this kit rather than a `font-weight`. Themed, all three resolve
30
+ * to this family and the numeric weight already on every style makes the
31
+ * ladder; unthemed, each keeps its own Inter face.
32
+ *
33
+ * Pass a stack you know RESOLVES — a face nobody `@font-face`d falls silently
34
+ * through to system sans, which is the one failure here that looks like
35
+ * nothing happening. And check Vietnamese: a face without the diacritics turns
36
+ * "Thẩm định" into tofu.
37
+ *
38
+ * The kit's letter-spacing curve is tuned for Inter's glyphs, so another face
39
+ * inherits tracking chosen for a different drawing. It is a refinement, not a
40
+ * defect — worth an eye, not a blocker.
41
+ */
42
+ bodyFont?: string;
43
+ /**
44
+ * The typeface for DISPLAY text only — what `<Text family="display">` renders
45
+ * in. A headline, a wordmark, a figure the page exists to show.
46
+ *
47
+ * Defaults to `bodyFont` when that is set, so ONE parameter changes the whole
48
+ * app and a SECOND one is only needed to make display type differ from body —
49
+ * a serif masthead over a sans register, say.
50
+ */
51
+ displayFont?: string;
52
+ /** The PRIMARY action's fill. Defaults to the kit's near-black, so an app that
53
+ * themes nothing is unchanged; set it to put the brand on the one filled
54
+ * button a surface gets. Distinct from `accent`, which marks identity. */
55
+ primary?: string;
24
56
  }
25
57
 
26
58
  /**
@@ -31,8 +63,38 @@ export const THEME_VARS: Record<keyof LoticsTheme, string> = {
31
63
  accent: "--lotics-accent",
32
64
  background: "--lotics-background",
33
65
  border: "--lotics-border",
66
+ primary: "--lotics-primary",
67
+ bodyFont: "--lotics-font-regular",
68
+ displayFont: "--lotics-display-font",
34
69
  };
35
70
 
71
+ /** The extra variable the provider DERIVES — not a role an app sets, because it
72
+ * is the accent seen through alpha and two sources for one colour drift. */
73
+ export const ACCENT_WASH_VAR = "--lotics-accent-wash";
74
+
75
+ /** The two rungs `bodyFont` fans out to beyond its own `THEME_VARS` entry. */
76
+ export const FONT_MEDIUM_VAR = "--lotics-font-medium";
77
+ export const FONT_SEMIBOLD_VAR = "--lotics-font-semibold";
78
+
79
+ /**
80
+ * The accent at wash strength.
81
+ *
82
+ * Computed from the literal hex the app passed, because it CANNOT be computed
83
+ * downstream: on web the token is a `var()`, and the kit's alpha helpers parse
84
+ * `rgba()` strings. Returns undefined for a value it cannot parse (a named
85
+ * colour, an `oklch()`), so an unparseable accent falls back to the neutral wash
86
+ * rather than painting rows in garbage.
87
+ */
88
+ export function deriveAccentWash(accent: string): string | undefined {
89
+ const hex = accent.trim();
90
+ const m = /^#([0-9a-f]{6})$/i.exec(hex);
91
+ if (!m) return undefined;
92
+ const n = parseInt(m[1], 16);
93
+ // 0.07 — heavy enough to read as a tint on white, light enough that body text
94
+ // on top keeps its contrast; the neutral it replaces (zinc-100) sits about here.
95
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.07)`;
96
+ }
97
+
36
98
  export const LoticsThemeContext = createContext<LoticsTheme>({});
37
99
 
38
100
  /**
@@ -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
+ });