@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.
@@ -1,18 +1,23 @@
1
- import { useCallback, useRef, type ReactNode } from "react";
1
+ import { useCallback, useRef, useState, type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import { ActivityIndicator } from "./activity_indicator";
2
4
  import { Icon } from "./icon";
5
+ import { Text } from "./text";
3
6
  import { colors } from "./colors";
4
- import { TimePicker } from "./time_picker";
5
- import { useLoticsLocale } from "./locale";
6
- import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
8
+ import { TimeColumns } from "./time_columns";
9
+ import { formatTimeOfDay } from "./time_options";
10
+ import { type InlineEditVariant, InlineEditView } from "./inline_edit";
11
+ import { useLoticsLocale, useLocaleTag } from "./locale";
7
12
 
8
13
  export interface InlineTimePickerProps {
9
14
  /** Canonical 24-hour "HH:mm", "" when empty. */
10
15
  value: string;
11
16
  onSave: (next: string) => void | Promise<void>;
12
17
  placeholder?: string;
13
- /** "blur" (default): clicking away or Enter saves; Escape reverts. "buttons":
14
- * an explicit saves and reverts. */
15
- controls?: InlineEditControls;
18
+ /** BCP-47 locale deciding 12- vs 24-hour display and whether a period column
19
+ * shows. Defaults to the active `LoticsLocaleProvider` locale. */
20
+ locale?: string;
16
21
  disabled?: boolean;
17
22
  /** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
18
23
  variant?: InlineEditVariant;
@@ -25,51 +30,95 @@ export interface InlineTimePickerProps {
25
30
  }
26
31
 
27
32
  /**
28
- * An inline-editable time of day the `InlineTextInput` pattern over
29
- * `TimePicker`. The value reads as plain text; clicking swaps in the native time
30
- * field at the same height, so the form never reflows.
33
+ * An inline-editable time of day: the value at rest, the hour / minute / period
34
+ * columns in a popover anchored to the field. The pick commits when the popover
35
+ * closes; dismissing without changing anything writes nothing.
36
+ *
37
+ * On the POPOVER inline shell, beside `InlineDatePicker` — not the typed one it
38
+ * used to sit on. `useInlineEdit` commits when the field BLURS, and a picker
39
+ * cannot live there: opening one moves focus into it, which reads as a blur,
40
+ * commits and unmounts the editor before the picker can paint. A pick is not a
41
+ * keystroke, and the kit keeps two shells precisely to hold that line.
31
42
  */
32
43
  export function InlineTimePicker(props: InlineTimePickerProps) {
33
- const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel, variant, actions } = props;
34
- const edit = useInlineEdit<string>({ value, onSave });
35
- const dateLabels = useLoticsLocale().datePicker;
36
- // Segments emit ONLY on a complete "HH:mm", so a half-typed entry (an hour and
37
- // no minute) reaches the draft as "" and would otherwise commit as a silent
38
- // clear. Blocking on it keeps the entry fixable instead of wiping the value.
39
- const incomplete = useRef(false);
44
+ const { value, onSave, placeholder, locale, disabled, accessibilityLabel, variant, actions } = props;
45
+ const localeTag = useLocaleTag(locale);
46
+ const loc = useLoticsLocale().datePicker;
47
+ const anchorRef = useRef<View>(null);
40
48
 
41
- const onBlur = useCallback(() => {
42
- if (controls === "buttons") return;
43
- if (incomplete.current) return;
44
- void edit.commit();
45
- }, [controls, edit]);
49
+ const [open, setOpen] = useState(false);
50
+ const [draft, setDraft] = useState(value);
51
+ const [saving, setSaving] = useState(false);
52
+ const [error, setError] = useState<string | null>(null);
53
+
54
+ // Closing IS the commit — every column emits a COMPLETE time, so there is
55
+ // never a half-picked draft to reject and no "incomplete" state to guard. An
56
+ // unchanged draft writes nothing rather than sending a no-op update.
57
+ const handleOpenChange = useCallback(
58
+ (next: boolean) => {
59
+ if (next) {
60
+ setDraft(value);
61
+ setError(null);
62
+ setOpen(true);
63
+ return;
64
+ }
65
+ setOpen(false);
66
+ if (draft === value) return;
67
+ setSaving(true);
68
+ void (async () => {
69
+ try {
70
+ await onSave(draft);
71
+ setError(null);
72
+ } catch (err) {
73
+ setError(err instanceof Error ? err.message : String(err));
74
+ setDraft(value);
75
+ } finally {
76
+ setSaving(false);
77
+ }
78
+ })();
79
+ },
80
+ [draft, value, onSave],
81
+ );
82
+
83
+ const trailing = saving ? (
84
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
85
+ ) : (
86
+ <Icon name="clock" size={18} color={colors.zinc[400]} />
87
+ );
46
88
 
47
89
  return (
48
- <InlineEditFrame
49
- variant={variant}
50
- actions={actions}
51
- editing={edit.editing}
52
- display={value}
53
- placeholder={placeholder}
54
- onBegin={edit.begin}
55
- controls={controls}
56
- onCommit={() => void edit.commit()}
57
- onCancel={edit.cancel}
58
- saving={edit.saving}
59
- error={edit.error ?? (incomplete.current ? dateLabels.invalidTime : null)}
60
- disabled={disabled}
61
- accessibilityLabel={accessibilityLabel}
62
- affordance={<Icon name="clock" size={18} color={colors.zinc[400]} />}
63
- >
64
- <TimePicker
65
- value={edit.draft}
66
- onValueChange={edit.setDraft}
67
- onBlur={onBlur}
68
- onEscape={edit.cancel}
69
- onIncompleteChange={(next) => { incomplete.current = next; }}
70
- autoFocus
71
- accessibilityLabel={accessibilityLabel}
72
- />
73
- </InlineEditFrame>
90
+ <View>
91
+ <Popover
92
+ open={open && !disabled}
93
+ onOpenChange={handleOpenChange}
94
+ triggerRef={anchorRef}
95
+ side="bottom"
96
+ align="start"
97
+ >
98
+ <PopoverTrigger>
99
+ <InlineEditView
100
+ variant={variant}
101
+ actions={actions}
102
+ anchorRef={anchorRef}
103
+ display={value ? formatTimeOfDay(value, localeTag) : ""}
104
+ placeholder={placeholder ?? loc.chooseTime}
105
+ disabled={disabled}
106
+ active={open && !disabled}
107
+ accessibilityLabel={accessibilityLabel ?? loc.chooseTime}
108
+ // The resting clock glyph marks an empty field as a TIME control —
109
+ // the same promise the standalone picker's trigger makes.
110
+ trailing={trailing}
111
+ />
112
+ </PopoverTrigger>
113
+ <PopoverContent disableBodyScroll>
114
+ <TimeColumns value={draft} onValueChange={setDraft} locale={locale} />
115
+ </PopoverContent>
116
+ </Popover>
117
+ {error ? (
118
+ <Text size="xs" color="danger" style={{ marginTop: 4 }}>
119
+ {error}
120
+ </Text>
121
+ ) : null}
122
+ </View>
74
123
  );
75
124
  }
package/src/list_item.tsx CHANGED
@@ -1,8 +1,8 @@
1
- import { Ref, useCallback } from "react";
1
+ import { Ref, useCallback, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
- import { PressableHighlight } from "./pressable_highlight";
5
+ import { FocusRingPressable } from "./focus_ring_pressable";
6
6
 
7
7
  export interface ListItemProps {
8
8
  ref?: Ref<View>;
@@ -21,6 +21,29 @@ export interface ListItemProps {
21
21
  * A settings / detail ROW — optional `left` (icon/avatar), `title` + `description`, a `right`
22
22
  * control, optional `onPress`/`selected`. For settings lists and detail rows; NOT a data register
23
23
  * row (`PressableRow` / `Table`) and NOT a menu/listbox row (`MenuListItem`).
24
+ *
25
+ * **A pressable row always presses BESIDE its `right` slot, never around it.** The slot is
26
+ * documented as holding a control, and a button may not contain one: `<button>` inside
27
+ * `<button>` is invalid HTML that React warns about, and it puts a second tab stop and a
28
+ * second name inside a control that is supposed to be one thing. So the surface is a wrapper,
29
+ * the press target is the region holding `left` and the text, and the slot sits outside it —
30
+ * the anatomy `InlineEditView` uses for its `actions`.
31
+ *
32
+ * It does NOT fix a double press: react-native-web's press responder calls
33
+ * `stopPropagation`, so a nested control's press never reached the row in the first place.
34
+ * The nesting alone is the defect.
35
+ *
36
+ * ALWAYS, not only when a slot is passed. `right` is routinely conditional
37
+ * (`updateAvailable ? <Badge/> : undefined`), and an anatomy that flipped on that would make
38
+ * two rows in one list press differently — the badge row's right edge dead, its neighbour's
39
+ * live — for a reason a reader cannot see. One shape, whatever the data does. With no slot
40
+ * the region takes the whole row, so the press target is unchanged.
41
+ *
42
+ * That the anatomy carries this rather than a prop opting into safety is the point: every
43
+ * call site already passing a control here is fixed without being touched, and a caller
44
+ * cannot reintroduce the nesting by forgetting a flag. The cost is that a decorative `right`
45
+ * (a badge, a chevron) is not part of the press target — uniformly, which is what makes it
46
+ * readable rather than surprising.
24
47
  */
25
48
  export function ListItem(props: ListItemProps) {
26
49
  const { ref, left, title, description, right, onPress, selected, disabled, style, testID } =
@@ -30,7 +53,8 @@ export function ListItem(props: ListItemProps) {
30
53
  onPress?.();
31
54
  }, [title, testID, onPress]);
32
55
 
33
- const inner = (
56
+ // Everything the row is ABOUT — never the `right` slot, which may hold a control.
57
+ const body = (
34
58
  <>
35
59
  {left}
36
60
  <View style={styles.textContainer}>
@@ -48,7 +72,6 @@ export function ListItem(props: ListItemProps) {
48
72
  description
49
73
  ))}
50
74
  </View>
51
- {right}
52
75
  </>
53
76
  );
54
77
 
@@ -56,21 +79,79 @@ export function ListItem(props: ListItemProps) {
56
79
 
57
80
  if (onPress) {
58
81
  return (
59
- <PressableHighlight
60
- focusRing
61
- ref={ref} testID={testID}
82
+ <PressRow
83
+ ref={ref}
84
+ testID={testID}
85
+ containerStyle={containerStyle}
62
86
  onPress={handlePress}
63
87
  disabled={disabled}
64
- style={containerStyle}
88
+ right={right}
65
89
  >
66
- {inner}
67
- </PressableHighlight>
90
+ {body}
91
+ </PressRow>
68
92
  );
69
93
  }
70
94
 
71
95
  return (
72
96
  <View ref={ref} testID={testID} style={containerStyle}>
73
- {inner}
97
+ {body}
98
+ {right}
99
+ </View>
100
+ );
101
+ }
102
+
103
+ /**
104
+ * The pressable anatomy: a non-pressable surface, the press target inside it, the
105
+ * slot beside that.
106
+ *
107
+ * The wash lives on the WRAPPER and is driven by the press region's own hover and
108
+ * press, so the whole row lights up as one thing — the region alone would light up
109
+ * to the edge of the slot and stop, which reads as two rows. It uses
110
+ * `FocusRingPressable` for exactly that reason: the wash-free base, for a control
111
+ * that paints its own surface.
112
+ *
113
+ * `ref` and `testID` go on the REGION, not the wrapper, because they identify the
114
+ * thing that presses: a test that clicks the row by `testID` must land on the
115
+ * press target rather than on a box that happens to contain it. The region also
116
+ * keeps the focus ring and the accessible name, since the text it wraps is what
117
+ * names the row.
118
+ */
119
+ function PressRow(props: {
120
+ ref?: Ref<View>;
121
+ testID?: string;
122
+ containerStyle: StyleProp<ViewStyle>;
123
+ onPress: () => void;
124
+ disabled?: boolean;
125
+ right?: React.ReactNode;
126
+ children: React.ReactNode;
127
+ }) {
128
+ const { ref, testID, containerStyle, onPress, disabled, right, children } = props;
129
+ const [hovered, setHovered] = useState(false);
130
+ const [pressed, setPressed] = useState(false);
131
+
132
+ return (
133
+ <View
134
+ style={[
135
+ containerStyle,
136
+ !disabled && pressed && styles.pressed,
137
+ !disabled && !pressed && hovered && styles.hovered,
138
+ ]}
139
+ >
140
+ <FocusRingPressable
141
+ ref={ref}
142
+ testID={testID}
143
+ style={styles.pressRegion}
144
+ onPress={onPress}
145
+ disabled={disabled}
146
+ accessibilityRole="button"
147
+ onHoverIn={() => setHovered(true)}
148
+ onHoverOut={() => setHovered(false)}
149
+ onPressIn={() => setPressed(true)}
150
+ onPressOut={() => setPressed(false)}
151
+ >
152
+ {children}
153
+ </FocusRingPressable>
154
+ {right}
74
155
  </View>
75
156
  );
76
157
  }
@@ -93,4 +174,21 @@ const styles = StyleSheet.create({
93
174
  selected: {
94
175
  backgroundColor: colors.zinc["100"],
95
176
  },
177
+ // The wash `PressableHighlight` would have painted, moved to the wrapper so it
178
+ // covers the row rather than stopping at the slot.
179
+ hovered: {
180
+ backgroundColor: colors.zinc["100"],
181
+ },
182
+ pressed: {
183
+ backgroundColor: colors.zinc["200"],
184
+ },
185
+ // Takes the slack, so the press target reaches the slot and the row presses
186
+ // everywhere the row is ABOUT.
187
+ pressRegion: {
188
+ flex: 1,
189
+ flexDirection: "row",
190
+ alignItems: "center",
191
+ gap: 8,
192
+ alignSelf: "stretch",
193
+ },
96
194
  });
package/src/locale.tsx CHANGED
@@ -47,6 +47,11 @@ export interface LoticsLocale {
47
47
  * in-cell editors): the select-all/deselect-all links, the empty state, the
48
48
  * internal search-field placeholder, and the `Combobox` recents header. */
49
49
  optionList: { selectAll: string; deselectAll: string; clear: string; noResults: string; recent: string; searchPlaceholder: string };
50
+ /** `Picker`'s empty option, when the caller declares empty a CHOICE
51
+ * (`includeEmptyOption`). A value, not an action — hence "None" rather than
52
+ * the option list's "Clear": in a native `<select>` the reader picks it the
53
+ * same way they pick any other row, so it has to read like one. */
54
+ picker: { emptyOption: string };
50
55
  datePicker: DatePickerLabels;
51
56
  calendar: CalendarLabels;
52
57
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
@@ -266,7 +271,8 @@ export const en: LoticsLocale = {
266
271
  },
267
272
  referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
268
273
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
269
- 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" },
274
+ picker: { emptyOption: "None" },
275
+ datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", chooseTime: "Choose a time", 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" },
270
276
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
271
277
  filterChip: { clear: "Clear" },
272
278
  floatingActionBar: { clear: "Clear" },
@@ -437,7 +443,8 @@ export const vi: LoticsLocale = {
437
443
  },
438
444
  referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
439
445
  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…" },
440
- 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 đủ" },
446
+ picker: { emptyOption: "Không " },
447
+ datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", chooseTime: "Chọn giờ", 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 đủ" },
441
448
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
442
449
  filterChip: { clear: "Xóa" },
443
450
  floatingActionBar: { clear: "Bỏ chọn" },
@@ -47,6 +47,21 @@ export interface MenuButtonProps {
47
47
  * the common popover-menu usage.
48
48
  */
49
49
  role?: "menuitem" | "button" | "option";
50
+ /**
51
+ * Roving tabindex for a listbox this row belongs to: the selected row passes
52
+ * `0` and every other row `-1`, so Tab reaches the list ONCE and arrows move
53
+ * within it (the composite-widget rule — sixty rows must not be sixty stops).
54
+ *
55
+ * Here rather than hand-rolled beside it because this component already claims
56
+ * to BE the listbox row — it owns `selected` and the `aria-selected` that
57
+ * carries the state — and a row that cannot take the tab stop or the keys is
58
+ * only half of one. `OptionList` does not need it: that body drives keys from
59
+ * a hidden input, the combobox pattern, where the rows are never focused.
60
+ */
61
+ tabIndex?: 0 | -1;
62
+ /** Web-only key handler, forwarded to the DOM by react-native-web. Pairs with
63
+ * {@link MenuButtonProps.tabIndex} to move the roving focus. */
64
+ onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
50
65
  }
51
66
 
52
67
  export function MenuButton(props: MenuButtonProps) {
@@ -68,6 +83,8 @@ export function MenuButton(props: MenuButtonProps) {
68
83
  accessibilityLabel,
69
84
  role = "menuitem",
70
85
  nativeID,
86
+ tabIndex,
87
+ onKeyDown,
71
88
  } = props;
72
89
 
73
90
  const resolvedLabel = accessibilityLabel ?? (typeof title === "string" ? title : undefined) ?? tooltip;
@@ -126,6 +143,8 @@ export function MenuButton(props: MenuButtonProps) {
126
143
  onPress?.();
127
144
  }}
128
145
  onHoverIn={onHoverIn}
146
+ onKeyDown={onKeyDown}
147
+ tabIndex={tabIndex}
129
148
  disabled={disabled}
130
149
  tooltip={tooltip}
131
150
  style={containerStyle}
@@ -139,6 +139,14 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
139
139
  !props.multi && row.selected ? (
140
140
  <Icon name="check" size={18} color={colors.zinc["950"]} />
141
141
  ) : undefined,
142
+ // A chosen row is `selected`, which is what puts `aria-selected` on
143
+ // it — the property an `option` carries its state in. Until now the
144
+ // only signal was the check GLYPH beside it (and a checkbox in
145
+ // multi), so which option was chosen was information available to
146
+ // whoever could see the row and to nobody else. It also brings the
147
+ // resting `zinc.100` highlight, one weight above the `zinc.50` the
148
+ // keyboard/hover row takes, so the two states stay distinguishable.
149
+ selected: row.selected,
142
150
  focused: row.index === list.activeIndex,
143
151
  disabled: opt.disabled,
144
152
  onPress: () => list.pickRow(row.index),
package/src/picker.tsx CHANGED
@@ -7,6 +7,9 @@ import { useFocusRing } from "./use_focus_ring";
7
7
  import { useHover } from "./use_hover";
8
8
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
9
9
  import { Icon } from "./icon";
10
+ import { useLoticsLocale } from "./locale";
11
+ import { useFormField } from "./form_field";
12
+ import { pickerEmptyOptionLabel } from "./picker_empty_option";
10
13
 
11
14
  export interface PickerOption<T extends string = string, D = unknown> {
12
15
  label?: string;
@@ -41,7 +44,15 @@ export interface PickerProps<T extends string = string> {
41
44
  testID?: string;
42
45
  disabled?: boolean;
43
46
  autoFocus?: boolean;
47
+ /** Declares empty a CHOICE: the reader may pick it to clear the field, so the
48
+ * option stays in the list after something is selected. Because it is a value
49
+ * they pick, it is NAMED — see `emptyOptionLabel`. */
44
50
  includeEmptyOption?: boolean;
51
+ /** Overrides the name of that empty choice (locale default: "None"). Distinct
52
+ * from `placeholder`, which is the hint shown while nothing is chosen: a hint
53
+ * and a value are different jobs, and one string cannot do both — read as a
54
+ * prompt it invites input, read as a row it looks selectable. */
55
+ emptyOptionLabel?: string;
45
56
  value?: T | null;
46
57
  onValueChange?: (value: T) => void;
47
58
  }
@@ -61,12 +72,25 @@ export function Picker<T extends string>(props: PickerProps<T>) {
61
72
  includeEmptyOption,
62
73
  onValueChange,
63
74
  placeholder,
75
+ emptyOptionLabel,
64
76
  accessibilityLabel,
65
77
  style,
66
78
  disabled = false,
67
79
  autoFocus = false,
68
80
  } = props;
69
81
 
82
+ const binding = useFormField();
83
+ const describedBy =
84
+ [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") ||
85
+ undefined;
86
+ const locale = useLoticsLocale();
87
+ const emptyLabel = pickerEmptyOptionLabel({
88
+ includeEmptyOption,
89
+ emptyOptionLabel,
90
+ placeholder,
91
+ localeEmptyOption: locale.picker.emptyOption,
92
+ });
93
+
70
94
  const pickerRef = useRef<RNPicker<string>>(null);
71
95
  // The native <select> is a text-like control — ring on any focus (like the inputs).
72
96
  const { focusVisible, focusProps } = useFocusRing({ always: true });
@@ -100,7 +124,18 @@ export function Picker<T extends string>(props: PickerProps<T>) {
100
124
  <RNPicker
101
125
  ref={pickerRef}
102
126
  testID={testID}
103
- accessibilityLabel={accessibilityLabel}
127
+ // Inside a `FormField` the VISIBLE label names the control, the same way
128
+ // it does for `NumberInput` / `TextInputField`. Without this a
129
+ // `FormPicker` rendered a label the select was never associated with, so
130
+ // it announced its current value and no name at all — the label was
131
+ // there for sighted readers only. An explicit `accessibilityLabel` is
132
+ // used only OUTSIDE a field: inside one, an aria-label that differs from
133
+ // the visible text is what breaks label-in-name.
134
+ id={binding?.inputId}
135
+ aria-labelledby={binding?.labelId}
136
+ aria-label={!binding ? accessibilityLabel : undefined}
137
+ aria-describedby={describedBy}
138
+ aria-invalid={binding?.invalid || undefined}
104
139
  onFocus={focusProps.onFocus}
105
140
  onBlur={focusProps.onBlur}
106
141
  // Empty selection maps to "" (the placeholder option's value), never
@@ -113,10 +148,20 @@ export function Picker<T extends string>(props: PickerProps<T>) {
113
148
  enabled={!disabled}
114
149
  >
115
150
  {(!value || includeEmptyOption) && (
116
- // Show the placeholder as the empty option (the standard
117
- // `<option value="" selected>Placeholder</option>` pattern) so a
118
- // native select hints what to choose.
119
- <RNPicker.Item label={!value ? (placeholder ?? "") : ""} value="" />
151
+ // Two different jobs share this one row, and which one it is doing
152
+ // depends on `includeEmptyOption`:
153
+ //
154
+ // A CHOICE (`includeEmptyOption`) the reader picks it to clear the
155
+ // field, so it is named, and named the SAME whether or not something
156
+ // is currently selected. It previously went blank the moment a value
157
+ // existed, which is exactly when the reader needs it: every select in
158
+ // the product offered an unlabelled row as its only way back to empty.
159
+ //
160
+ // A PLACEHOLDER (otherwise) — it exists only because nothing is
161
+ // chosen yet, so it carries the caller's hint and disappears on
162
+ // selection. Naming it "None" here would turn a prompt into an
163
+ // apparent value.
164
+ <RNPicker.Item label={emptyLabel} value="" />
120
165
  )}
121
166
  {options.map((option) =>
122
167
  option ? (
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What `Picker`'s empty row is called — and it depends on which of two jobs that
3
+ * row is doing.
4
+ *
5
+ * A CHOICE (`includeEmptyOption`) is how the reader gets back to no value. They
6
+ * pick it the way they pick any other row, so it is NAMED, and named the same
7
+ * whether or not something is currently selected — a name that vanishes on
8
+ * selection vanishes exactly when it is needed.
9
+ *
10
+ * A PLACEHOLDER exists only because nothing is chosen yet. It carries the
11
+ * caller's hint and leaves on selection; naming it "None" would turn a prompt
12
+ * into an apparent value.
13
+ *
14
+ * RN-free so the rule is testable as the rule, rather than through a native
15
+ * `<select>` and its untranspiled dependency.
16
+ */
17
+ export function pickerEmptyOptionLabel(opts: {
18
+ includeEmptyOption?: boolean;
19
+ /** Per-instance override for the choice's name. */
20
+ emptyOptionLabel?: string;
21
+ /** The caller's hint, used only when the row is a placeholder. */
22
+ placeholder?: string;
23
+ /** The locale pack's word for "no value". */
24
+ localeEmptyOption: string;
25
+ }): string {
26
+ if (opts.includeEmptyOption) {
27
+ return opts.emptyOptionLabel ?? opts.localeEmptyOption;
28
+ }
29
+ return opts.placeholder ?? "";
30
+ }
@@ -10,7 +10,7 @@ import {
10
10
  import { Ref, useCallback } from "react";
11
11
  import { TooltipSide, useTooltip } from "./tooltip";
12
12
  import { colors } from "./colors";
13
- import { FOCUS_RING } from "./control_surface";
13
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
14
14
  import { composeHandler, useFocusRing } from "./use_focus_ring";
15
15
 
16
16
  /** The pressable state, plus the web-only `hovered` flag react-native-web adds and
@@ -58,11 +58,19 @@ export interface PressableHighlightProps extends PressableProps {
58
58
  */
59
59
  "aria-current"?: "true" | "page" | "step" | "location";
60
60
  /**
61
- * Pass "none" on row/card surfaces: a pressable surface is a button, not a
62
- * text-selection surface — drag jitter on selectable text starts a
63
- * selection and can swallow the click. Exposed as a prop because RN types
64
- * only carry `userSelect` on TextStyle, while react-native-web applies it
65
- * to any element.
61
+ * Defaults to `"none"`, because this component IS a button (see the role note
62
+ * below) and a button's label is not a text-selection surface — drag jitter on
63
+ * selectable text starts a selection and can swallow the click.
64
+ *
65
+ * It was opt-in, and the result was what an opt-in rule always produces:
66
+ * `Switcher`, `Chip`, `CardSelectItem` and `Stepper` had simply never been
67
+ * told, so their labels dragged into a selection. Pass `"auto"` where the
68
+ * content genuinely is text to read and copy — nothing in the kit does, and the
69
+ * register row that DOES want selectable text is `PressableRow`, which is a
70
+ * role-less bare `Pressable` and unaffected by this.
71
+ *
72
+ * Exposed as a prop at all because RN types only carry `userSelect` on
73
+ * TextStyle, while react-native-web applies it to any element.
66
74
  */
67
75
  userSelect?: "auto" | "none";
68
76
  }
@@ -89,7 +97,7 @@ export function PressableHighlight(props: PressableHighlightProps) {
89
97
  tooltip,
90
98
  tooltipSide = "top",
91
99
  onPress,
92
- userSelect,
100
+ userSelect = "none",
93
101
  focusRing,
94
102
  ...restPressableProps
95
103
  } = props;
@@ -114,7 +122,11 @@ export function PressableHighlight(props: PressableHighlightProps) {
114
122
  const { pressed, hovered } = state;
115
123
  return [
116
124
  {
117
- ...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
125
+ // The ARROW by default see `CURSOR_DEFAULT`. Rows, menu options and
126
+ // cards are what this base is for, and none of them act on their own;
127
+ // the handful that do set `CURSOR_ACTION` in their own style, which
128
+ // lands after this one.
129
+ ...({ touchAction: "manipulation", cursor: CURSOR_DEFAULT, transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
118
130
  },
119
131
  {
120
132
  backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
@@ -1,5 +1,5 @@
1
1
  import { ReactNode, Ref, useCallback, useState } from "react";
2
- import { ROW_WASH_BLEED } from "./control_surface";
2
+ import { CURSOR_DEFAULT, ROW_WASH_BLEED } from "./control_surface";
3
3
  import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
4
4
  import { colors } from "./colors";
5
5
  import { pressSelectedText } from "./press_selection";
@@ -107,7 +107,7 @@ const styles = StyleSheet.create({
107
107
  // furniture that must not be dragged over already opts out for itself
108
108
  // (`Badge`, `Avatar`, `Button` each set none), so the blanket only ever cost
109
109
  // the text it had no business covering.
110
- ...({ cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
110
+ ...({ cursor: CURSOR_DEFAULT, transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
111
111
  },
112
112
  // THE register row — FULL-WIDTH rounded highlight that BLEEDS past the content
113
113
  // (`ROW_WASH_BLEED`, net-zero margin+padding), so the row's content sits on the
@@ -1,5 +1,6 @@
1
1
  import { colors } from "./colors";
2
2
  import { StyleSheet } from "react-native";
3
+ import { CURSOR_ACTION } from "./control_surface";
3
4
  import { Icon } from "./icon";
4
5
  import { PressableHighlight } from "./pressable_highlight";
5
6
  import { useLoticsLocale } from "./locale";
@@ -30,6 +31,8 @@ export function ScrollToBottom(props: ScrollToBottomProps) {
30
31
 
31
32
  const styles = StyleSheet.create({
32
33
  button: {
34
+ // Jumping to the bottom ACTS — see `CURSOR_ACTION`.
35
+ cursor: CURSOR_ACTION,
33
36
  borderWidth: 1,
34
37
  borderColor: colors.border,
35
38
  backgroundColor: colors.background,