@lotics/ui 17.0.2 → 18.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.
@@ -6,21 +6,21 @@ import { Text } from "./text";
6
6
  import { Icon, type IconName } from "./icon";
7
7
  import { colors } from "./colors";
8
8
 
9
- interface LinkedRecordBoxProps {
9
+ /** One reference fact. `V` is the value the variant admits — text under a door, any node without one. */
10
+ interface LinkedRecordFact<V = ReactNode> {
11
+ label: string;
12
+ value: V;
13
+ }
14
+
15
+ interface LinkedRecordBoxBaseProps {
10
16
  /** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
11
17
  icon: IconName;
12
18
  /** The linked record's display name. */
13
19
  name: string;
14
20
  /** A secondary identity line — a code, tax id, country, or "<desk> record". */
15
21
  subtitle?: string;
16
- /** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). */
17
- facts?: { label: string; value: string }[];
18
22
  /** Width of the facts' label column (default 80). Widen for longer labels. */
19
23
  factLabelWidth?: number;
20
- /** Accessible name for opening the record (e.g. "Acme Corp — details"). */
21
- doorLabel: string;
22
- /** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
23
- onOpen: () => void;
24
24
  /** The verbs, in a hairline-fenced footer at the bottom of the box, lifted above the door's
25
25
  * hit area. Convention: destructive LEFT, go-to RIGHT (a `<View style={{ flex: 1 }} />`
26
26
  * spacer between). The divider is drawn automatically when actions are present. Supplied by
@@ -28,22 +28,61 @@ interface LinkedRecordBoxProps {
28
28
  actions?: ReactNode;
29
29
  }
30
30
 
31
+ /** The box that OPENS — the whole surface is the door into the linked record's own detail. */
32
+ interface LinkedRecordBoxDoorProps extends LinkedRecordBoxBaseProps {
33
+ /** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
34
+ onOpen: () => void;
35
+ /** Accessible name for opening the record (e.g. "Acme Corp — details"). */
36
+ doorLabel: string;
37
+ /** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). TEXT only
38
+ * (`""` renders "—"): the box is one press target, so the only controls it may hold are the
39
+ * `actions` verbs, which the footer fences off and lifts above the door. A fact the reader
40
+ * must EDIT belongs on the linked record's own page — that is what the door is for. */
41
+ facts?: LinkedRecordFact<string>[];
42
+ }
43
+
44
+ /** The box with nothing to open — the linked record has no detail surface of its own. */
45
+ interface LinkedRecordBoxStaticProps extends LinkedRecordBoxBaseProps {
46
+ /** Absent by construction. Typed `never` rather than left off so `doorLabel` can never arrive
47
+ * without a handler — a named door that goes nowhere. */
48
+ onOpen?: never;
49
+ doorLabel?: never;
50
+ /** Reference facts, stacked VERTICALLY. A string renders as text (`""` → "—"); a NODE renders
51
+ * as given (no placeholder), so a fact can carry its own inline editor — safe here precisely
52
+ * because nothing about this box presses. */
53
+ facts?: LinkedRecordFact[];
54
+ }
55
+
56
+ type LinkedRecordBoxProps = LinkedRecordBoxDoorProps | LinkedRecordBoxStaticProps;
57
+
31
58
  /**
32
59
  * A LINKED RECORD shown for reference — a bordered box scoping ANOTHER record's data
33
- * (identity glyph · name · facts), the WHOLE box a keyboard-accessible door into that record's
34
- * detail. Use it wherever one record points at another (a shipment's customer, an invoice's
35
- * party, a case's sibling); the picker / empty-state that REASSIGNS the link is the consumer's,
36
- * shown in place of the box.
60
+ * (identity glyph · name · facts). Use it wherever one record points at another (a shipment's
61
+ * customer, an invoice's party, a case's sibling); the picker / empty-state that REASSIGNS the
62
+ * link is the consumer's, shown in place of the box.
63
+ *
64
+ * `onOpen` picks the variant:
65
+ *
66
+ * - **With `onOpen`** (+ `doorLabel`) the WHOLE box is a keyboard-accessible door into the
67
+ * record's detail. Its facts are TEXT, and the `actions` footer is the one place a control
68
+ * may live — everything else is press-through to the door.
69
+ * - **Without it** the box is a STATIC reference card: no door, no tab stop, no pointer, nothing
70
+ * announced as a button; the only interactive parts are `actions` and whatever a fact node
71
+ * holds. This is the shape for a record with NO page of its own — there is nothing to open, so
72
+ * the box IS where its values are read and edited, and an inline editor in a fact is honest.
37
73
  *
38
- * The a11y contract it enforces (the error-prone part, hence a primitive): a container with
39
- * interactive descendants must NEVER be `role="button"` (invalid HTML). So the box is a
40
- * role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible name /
41
- * focus ring, and the interior verbs lift above it via `zIndex`.
74
+ * The a11y contract the door variant enforces (the error-prone part, hence a primitive): a
75
+ * container with interactive descendants must NEVER be `role="button"` (invalid HTML). So the
76
+ * box is a role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible
77
+ * name / focus ring, and the interior verbs lift above it via `zIndex`.
42
78
  */
43
- export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth = 80, doorLabel, onOpen, actions }: LinkedRecordBoxProps) {
44
- return (
45
- <PressableRow onPress={onOpen} style={styles.box}>
46
- <PressDoor accessibilityLabel={doorLabel} onPress={onOpen} />
79
+ export function LinkedRecordBox(props: LinkedRecordBoxProps) {
80
+ const { icon, name, subtitle, factLabelWidth = 80, actions } = props;
81
+ // A door box's facts are strings, and a string IS a ReactNode — so both variants render
82
+ // through one path typed at the wider value.
83
+ const facts: LinkedRecordFact[] = props.facts ?? [];
84
+ const body = (
85
+ <>
47
86
  <View style={styles.identity}>
48
87
  <View style={styles.glyph}>
49
88
  <Icon name={icon} size={17} color={colors.zinc[600]} />
@@ -53,12 +92,18 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
53
92
  {subtitle ? <Text size="xs" color="muted" numberOfLines={1}>{subtitle}</Text> : null}
54
93
  </View>
55
94
  </View>
56
- {facts && facts.length > 0 ? (
95
+ {facts.length > 0 ? (
57
96
  <View style={{ gap: 6 }}>
58
97
  {facts.map((f) => (
59
98
  <View key={f.label} style={styles.fact}>
60
99
  <Text size="sm" color="muted" style={{ width: factLabelWidth }}>{f.label}</Text>
61
- <Text size="sm" style={{ flex: 1 }}>{f.value || ""}</Text>
100
+ {typeof f.value === "string" ? (
101
+ <Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
102
+ ) : (
103
+ // A node prints as authored — the "—" placeholder is a TEXT affordance, and a
104
+ // control renders its own empty state.
105
+ <View style={{ flex: 1, minWidth: 0 }}>{f.value}</View>
106
+ )}
62
107
  </View>
63
108
  ))}
64
109
  </View>
@@ -70,6 +115,17 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
70
115
  <View style={styles.actions}>{actions}</View>
71
116
  </>
72
117
  ) : null}
118
+ </>
119
+ );
120
+
121
+ // Nothing to open → a plain View, so the box takes no tab stop, announces no role, and paints
122
+ // no pointer/hover promise of a destination it doesn't have.
123
+ if (!props.onOpen) return <View style={styles.box}>{body}</View>;
124
+
125
+ return (
126
+ <PressableRow onPress={props.onOpen} style={styles.box}>
127
+ <PressDoor accessibilityLabel={props.doorLabel} onPress={props.onOpen} />
128
+ {body}
73
129
  </PressableRow>
74
130
  );
75
131
  }
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" },
@@ -12,7 +12,23 @@ export interface MenuButtonProps {
12
12
  right?: React.ReactNode;
13
13
  onPress?: () => void;
14
14
  onHoverIn?: () => void;
15
+ /**
16
+ * Persistent SELECTION highlight — the listbox pattern, where the row is an
17
+ * `option` and `aria-selected` is the property that carries its state. On any
18
+ * other role it is the highlight only: `aria-selected` is valid on
19
+ * option/tab/row/gridcell and nowhere else, so it is not emitted there (a menu
20
+ * carries state in the `right` slot — a check glyph).
21
+ */
15
22
  selected?: boolean;
23
+ /**
24
+ * This row is the CURRENT item in a set of NAVIGATION items — the section an
25
+ * outline rail sits on (`useSectionNav`'s `activeKey`), the open pane of a
26
+ * settings dialog. Renders `aria-current`, which is global (valid on any
27
+ * role): `true` is the generic "this is the current one", or name the set it
28
+ * is current WITHIN. Navigation is not selection — a rail item is `current`,
29
+ * a listbox row is `selected`. Both paint the same highlight.
30
+ */
31
+ current?: boolean | "page" | "step" | "location";
16
32
  focused?: boolean;
17
33
  disabled?: boolean;
18
34
  danger?: boolean;
@@ -42,6 +58,7 @@ export function MenuButton(props: MenuButtonProps) {
42
58
  onPress,
43
59
  onHoverIn,
44
60
  selected,
61
+ current,
45
62
  focused,
46
63
  disabled,
47
64
  danger,
@@ -84,18 +101,26 @@ export function MenuButton(props: MenuButtonProps) {
84
101
  </>
85
102
  );
86
103
 
104
+ // Selection and current-ness are different states with the same visual weight
105
+ // — a row is at most one of them, and either owns the resting highlight.
106
+ const highlighted = !!selected || !!current;
87
107
  const containerStyle = [
88
108
  styles.container,
89
- selected && styles.selected,
90
- focused && !selected && styles.focused,
109
+ highlighted && styles.highlighted,
110
+ focused && !highlighted && styles.focused,
91
111
  style,
92
112
  ];
93
113
 
114
+ // `aria-current` takes a TOKEN, and absence — never `false` — is how the other
115
+ // items in the set say "not this one".
116
+ const ariaCurrent = current === true ? "true" : current === false ? undefined : current;
117
+
94
118
  if (onPress) {
95
119
  return (
96
120
  <PressableHighlight
97
121
  focusRing
98
- ref={ref} testID={testID}
122
+ ref={ref}
123
+ testID={testID}
99
124
  nativeID={nativeID}
100
125
  onPress={() => {
101
126
  onPress?.();
@@ -106,7 +131,9 @@ export function MenuButton(props: MenuButtonProps) {
106
131
  style={containerStyle}
107
132
  role={role}
108
133
  accessibilityLabel={resolvedLabel}
109
- aria-selected={!!selected} aria-disabled={disabled || undefined}
134
+ aria-selected={role === "option" ? !!selected : undefined}
135
+ aria-current={ariaCurrent}
136
+ aria-disabled={disabled || undefined}
110
137
  >
111
138
  {inner}
112
139
  </PressableHighlight>
@@ -133,7 +160,7 @@ const styles = StyleSheet.create({
133
160
  flex: 1,
134
161
  alignItems: "flex-start",
135
162
  },
136
- selected: {
163
+ highlighted: {
137
164
  backgroundColor: colors.zinc["100"],
138
165
  },
139
166
  focused: {
@@ -50,6 +50,13 @@ export interface PressableHighlightProps extends PressableProps {
50
50
  * explicitly because the base React Native `PressableProps` type omits it.
51
51
  */
52
52
  onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
53
+ /**
54
+ * The item is the current one in a set of navigation items. Exposed
55
+ * explicitly because React Native's accessibility props carry only the ARIA
56
+ * attributes with a native counterpart, and `aria-current` has none —
57
+ * react-native-web forwards it to the DOM like any other `aria-*` prop.
58
+ */
59
+ "aria-current"?: "true" | "page" | "step" | "location";
53
60
  /**
54
61
  * Pass "none" on row/card surfaces: a pressable surface is a button, not a
55
62
  * text-selection surface — drag jitter on selectable text starts a
@@ -53,7 +53,8 @@ export interface SubsectionProps {
53
53
  * One named group INSIDE a `Section` — the level below `Section` on a long
54
54
  * record surface (`###` in the heading ramp). Compose a `SubsectionHeading`
55
55
  * then the group's rows; sibling subsections stack in a `SubsectionStack`
56
- * (24 + hairline between groups — no margins, no hand-rolled dividers). A
56
+ * (32, space-only while the groups are short — no margins, no hand-rolled
57
+ * dividers; see `SubsectionStack` for when length earns `divided`). A
57
58
  * headingless `Subsection` is fine for the section's lead group.
58
59
  *
59
60
  * <Section>
@@ -4,8 +4,9 @@ import { Divider } from "./divider";
4
4
 
5
5
  interface StackProps {
6
6
  children: React.ReactNode;
7
- /** Hairline between blocks (default true). Turn off for a short surface
8
- * where the beat alone is enough. */
7
+ /** Hairline between blocks. Each stack defaults to what its altitude wants
8
+ * (on for sections, off for subsections); the rule for overriding it is on
9
+ * `SubsectionStack`. */
9
10
  divided?: boolean;
10
11
  style?: StyleProp<ViewStyle>;
11
12
  }
@@ -49,12 +50,17 @@ export function SectionStack(props: SectionStackProps) {
49
50
  export type SubsectionStackProps = StackProps;
50
51
 
51
52
  /**
52
- * The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat,
53
- * SPACE-ONLY: subsection titles carry the grouping; hairlines belong to the
54
- * SECTION level (`SectionStack`), one rule per altitude. Compose it as the
55
- * section's body (after the `SectionHeading`); a section's groups divide
56
- * without hand-rolled gaps. `divided` stays an explicit opt-in for a rare
57
- * headingless stack that still needs a rule.
53
+ * The stack of `Subsection` groups INSIDE a `Section` — a fixed 32px beat.
54
+ * Compose it as the section's body (after the `SectionHeading`); a section's
55
+ * groups divide without hand-rolled gaps.
56
+ *
57
+ * SPACE-ONLY while the groups are SHORT (a handful of rows, taken in at a
58
+ * glance): the titles carry the grouping, and hairlines belong to the SECTION
59
+ * level (`SectionStack`), one rule per altitude. Turn `divided` on once the
60
+ * groups are LONG — past a screenful of rows, 32px reads as one more row gap
61
+ * and the next title arrives with nothing marking that a new group started, so
62
+ * the hairline becomes the boundary. LENGTH is the discriminator, not whether
63
+ * the groups are titled: a headingless stack follows the same rule.
58
64
  *
59
65
  * <Section>
60
66
  * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
@@ -3,12 +3,13 @@ import {
3
3
  TextInput as RNTextInput,
4
4
  TextInputProps as RNTextInputProps,
5
5
  View,
6
+ type LayoutChangeEvent,
6
7
  } from "react-native";
7
8
  import { colors } from "./colors";
8
9
  import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
10
  import { useFocusRing, composeHandler } from "./use_focus_ring";
10
11
  import { useHover } from "./use_hover";
11
- import { Ref, useCallback } from "react";
12
+ import { Ref, useCallback, useState } from "react";
12
13
  import { Icon, IconName } from "./icon";
13
14
  import { IconButton } from "./icon_button";
14
15
  import { ShortcutBadge } from "./shortcut_badge";
@@ -99,6 +100,23 @@ export function TextInputField(props: TextInputFieldProps) {
99
100
 
100
101
  const editable = !!(!disabled || inputProps.editable);
101
102
 
103
+ // The ONE source for what occupies the trailing slot — the same flags decide
104
+ // which affordance renders AND how much the text reserves for it. Derived once
105
+ // so the two can never disagree (a gutter gated on anything else is how a long
106
+ // value ends up running underneath the ✕).
107
+ const showClear = !!clearable && !!value;
108
+ // The badge is a hint for reaching an EMPTY field; once there's a value the
109
+ // clear ✕ owns the slot and the hint is spent.
110
+ const showShortcut = !!shortcut && !value;
111
+ // The badge's width is content- AND screen-dependent (⌘B vs Ctrl+Shift+S;
112
+ // `ShortcutBadge` renders NOTHING on small screens), so unlike the fixed-size
113
+ // ✕ its gutter cannot be a constant — it is measured, which also makes the
114
+ // small-screen reserve correctly zero.
115
+ const [shortcutWidth, setShortcutWidth] = useState(0);
116
+ const onShortcutLayout = useCallback((e: LayoutChangeEvent) => {
117
+ setShortcutWidth(e.nativeEvent.layout.width);
118
+ }, []);
119
+
102
120
  const { measure, inputRef } = autoGrowResult;
103
121
 
104
122
  const handleChangeText = useCallback(
@@ -145,6 +163,8 @@ export function TextInputField(props: TextInputFieldProps) {
145
163
  autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
146
164
  !editable && styles.disabled,
147
165
  icon && styles.withIcon,
166
+ showClear && styles.withClear,
167
+ showShortcut && shortcutWidth > 0 && { paddingRight: SHORTCUT_INSET + shortcutWidth },
148
168
  hovered && editable && { borderColor: HOVER_BORDER },
149
169
  style,
150
170
  focusVisible && { boxShadow: FOCUS_RING },
@@ -157,7 +177,7 @@ export function TextInputField(props: TextInputFieldProps) {
157
177
  scrollEnabled={autoGrow ? autoGrowResult.scrollEnabled : undefined}
158
178
  onContentSizeChange={autoGrow ? autoGrowResult.onContentSizeChange : undefined}
159
179
  />
160
- {!!clearable && !!value ? (
180
+ {showClear ? (
161
181
  <IconButton
162
182
  icon="x"
163
183
  tooltip={clearLabel}
@@ -167,8 +187,8 @@ export function TextInputField(props: TextInputFieldProps) {
167
187
  }}
168
188
  style={styles.clear}
169
189
  />
170
- ) : shortcut ? (
171
- <View style={styles.shortcut}>
190
+ ) : showShortcut ? (
191
+ <View style={styles.shortcut} onLayout={onShortcutLayout}>
172
192
  <ShortcutBadge shortcut={shortcut} />
173
193
  </View>
174
194
  ) : null}
@@ -176,6 +196,20 @@ export function TextInputField(props: TextInputFieldProps) {
176
196
  );
177
197
  }
178
198
 
199
+ /**
200
+ * The trailing slot (the clear ✕, the shortcut badge) is ABSOLUTELY POSITIONED —
201
+ * out of flow, so the input's text does not stop at it. Whichever affordance is
202
+ * up, the text reserves the span it occupies; these constants drive BOTH the
203
+ * affordance's `right` offset and the reserved padding, so the two cannot drift
204
+ * apart. A slot standing empty reserves nothing, so an input with no trailing
205
+ * affordance is untouched.
206
+ */
207
+ const CLEAR_INSET = 6;
208
+ /** `IconButton size="md"` is a 28px box around an 18px glyph, so the button's own
209
+ * interior padding IS the visual gap — text may sit against the box edge. */
210
+ const CLEAR_GUTTER = CLEAR_INSET + 28;
211
+ const SHORTCUT_INSET = 12;
212
+
179
213
  const styles = StyleSheet.create({
180
214
  input: {
181
215
  borderRadius: CONTROL_RADIUS,
@@ -200,19 +234,24 @@ const styles = StyleSheet.create({
200
234
  withIcon: {
201
235
  paddingLeft: 34,
202
236
  },
237
+ withClear: {
238
+ paddingRight: CLEAR_GUTTER,
239
+ },
203
240
  icon: {
204
241
  top: 9,
205
242
  left: 8,
206
243
  position: "absolute",
207
244
  },
208
245
  clear: {
209
- top: 6,
210
- right: 6,
246
+ // Pinned to the TOP, not centred: on a multiline/auto-grow field the ✕ must
247
+ // stay reachable at the first line, not float in the middle of a tall box.
248
+ top: CLEAR_INSET,
249
+ right: CLEAR_INSET,
211
250
  position: "absolute",
212
251
  },
213
252
  shortcut: {
214
253
  position: "absolute",
215
- right: 12,
254
+ right: SHORTCUT_INSET,
216
255
  top: 0,
217
256
  bottom: 0,
218
257
  justifyContent: "center",
@@ -1,56 +1,114 @@
1
- import type { KeyboardEvent } from "react";
2
- import { colors } from "@lotics/ui/colors";
3
- import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
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 { value, onValueChange, onBlur, onKeyDown, autoFocus, disabled, accessibilityLabel } = props;
20
- const { focusVisible, focusProps } = useFocusRing({ always: true });
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
- <input
25
- value={value}
26
- onChange={(e) => {
27
- onValueChange(e.target.value);
28
- }}
29
- type="time"
30
- {...hoverProps}
31
- onFocus={focusProps.onFocus}
32
- onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
33
- onKeyDown={onKeyDown}
34
- autoFocus={autoFocus}
35
- disabled={disabled}
36
- aria-label={accessibilityLabel}
37
- style={{
38
- height: 40,
39
- paddingLeft: 8,
40
- paddingRight: 8,
41
- borderRadius: CONTROL_RADIUS,
42
- borderWidth: 1,
43
- borderStyle: "solid",
44
- borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
45
- backgroundColor: colors.background,
46
- fontFamily: fontFamilyRegular,
47
- ...inputTextStyleWeb,
48
- letterSpacing: -0.4,
49
- boxShadow: focusVisible ? FOCUS_RING : "none",
50
- outline: "none",
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
+ });