@lotics/ui 42.4.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
@@ -272,7 +272,7 @@ export const en: LoticsLocale = {
272
272
  referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
273
273
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
274
274
  picker: { emptyOption: "None" },
275
- 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" },
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" },
276
276
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
277
277
  filterChip: { clear: "Clear" },
278
278
  floatingActionBar: { clear: "Clear" },
@@ -444,7 +444,7 @@ export const vi: LoticsLocale = {
444
444
  referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
445
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…" },
446
446
  picker: { emptyOption: "Không có" },
447
- 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 đủ" },
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 đủ" },
448
448
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
449
449
  filterChip: { clear: "Xóa" },
450
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),
@@ -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,
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/switch.tsx CHANGED
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from "react";
2
2
  import { Animated, StyleSheet, Pressable, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Icon } from "./icon";
5
- import { FOCUS_RING } from "./control_surface";
5
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
6
6
  import { useFocusRing } from "./use_focus_ring";
7
7
  export interface SwitchProps {
8
8
  testID?: string;
@@ -89,6 +89,8 @@ export function Switch(props: SwitchProps) {
89
89
 
90
90
  const styles = StyleSheet.create({
91
91
  root: {
92
+ // A switch SETS A VALUE — the arrow, see `CURSOR_DEFAULT`.
93
+ cursor: CURSOR_DEFAULT,
92
94
  borderRadius: 999,
93
95
  width: 48,
94
96
  height: 32,
package/src/text.tsx CHANGED
@@ -62,7 +62,7 @@ type TextUserSelect = "none" | "auto" | "text";
62
62
  */
63
63
  export function Text(props: TextProps) {
64
64
  const {
65
- userSelect = "text",
65
+ userSelect,
66
66
  align = "left",
67
67
  children,
68
68
  testID,
@@ -107,7 +107,10 @@ export function Text(props: TextProps) {
107
107
  styles.text,
108
108
  Platform.OS !== "web" && styles[size],
109
109
  styles[weight],
110
- styles[userSelect],
110
+ // Only when ASKED for. Left to inherit, a label inside a button is
111
+ // unselectable and a paragraph is selectable, each from its container —
112
+ // which is what a container setting `userSelect` is trying to say.
113
+ userSelect && selectStyles[userSelect],
111
114
  styles[align],
112
115
  decoration && styles[decoration],
113
116
  tabular && styles.tabular,
@@ -123,10 +126,20 @@ export function Text(props: TextProps) {
123
126
  );
124
127
  }
125
128
 
129
+ /**
130
+ * Selectability, applied only when a caller states it. Its own sheet because the
131
+ * value `"text"` shares a name with the BASE text style — mapped through the main
132
+ * sheet, asking for `userSelect="text"` resolved to that base and set nothing.
133
+ */
134
+ const selectStyles = StyleSheet.create({
135
+ text: { userSelect: "text" },
136
+ auto: { userSelect: "auto" },
137
+ none: { userSelect: "none" },
138
+ });
139
+
126
140
  const styles = StyleSheet.create({
127
141
  text: {
128
142
  letterSpacing: -0.4,
129
- userSelect: "text",
130
143
  },
131
144
 
132
145
  // Text size styles
@@ -197,14 +210,6 @@ const styles = StyleSheet.create({
197
210
  textDecorationLine: "underline line-through",
198
211
  },
199
212
 
200
- // User select styles
201
- auto: {
202
- userSelect: "auto",
203
- },
204
- none: {
205
- userSelect: "none",
206
- },
207
-
208
213
  tabular: {
209
214
  fontVariant: ["tabular-nums"],
210
215
  },