@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.
package/MIGRATION.md CHANGED
@@ -4,6 +4,121 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 43.0.0 — a time is PICKED, and there is nothing to type
8
+
9
+ `TimePicker` is no longer a segmented text field. It is the value on a pressable
10
+ control — the bordered surface a `Select` uses — that opens an **hour / minute /
11
+ day-period** picker. `InlineTimePicker` is the same thing on the inline shell.
12
+
13
+ **Why the segments went.** A time was typed, with a picker bolted on as a small
14
+ glyph inside the field, and each half of that was wrong:
15
+
16
+ - **The glyph was undiscoverable, and worse than undiscoverable.** It sat exactly
17
+ where `DateField` puts a display-only calendar icon — same slot, same size,
18
+ same colour — so the one affordance for picking was indistinguishable from
19
+ decoration. A control nobody can identify is not a control.
20
+ - **A day period is not something to spell.** AM/PM is a choice between two named
21
+ things; asking someone to type "PM" is asking them to type an answer to a
22
+ yes/no question. It is now a column.
23
+ - **Composed times cannot cover the clock.** The old list offered whole times at
24
+ a fixed step, so anything between the rungs — a 13:07 cut-off — was reachable
25
+ only by typing, and the list had to splice odd values back in to stay honest.
26
+ Split by UNIT, every minute is offered and that whole class of special case is
27
+ gone.
28
+
29
+ The value is unchanged: canonical 24-hour `"HH:mm"`. The locale still decides 12-
30
+ vs 24-hour, and now also decides the SHAPE — a 24-hour locale gets two columns
31
+ and hours 00–23, a 12-hour locale gets a clock face starting at 12 plus the
32
+ period column. Tab reaches each column once and arrows move within it (roving
33
+ tabindex), so sixty minutes are not sixty tab stops.
34
+
35
+ **To migrate:**
36
+
37
+ - **`TimePicker` drops `onBlur`, `onEscape`, `onIncompleteChange`, `autoFocus`
38
+ and `segmentLabels`.** They all served typed entry. A pick is always a complete
39
+ time, so there is no half-entered state to report and nothing to cancel back
40
+ to; `placeholder` replaces them as the empty-state control. Delete the props —
41
+ the compiler will point at each one.
42
+ - **`InlineTimePicker` gains `locale` and drops `controls`.** It commits when the
43
+ popover closes, not on blur, so a `"buttons"` mode has nothing to confirm.
44
+ - **Add `chooseTime` to any complete `DatePickerLabels` you build** — it names the
45
+ control and doubles as the empty placeholder. A `labels` prop passed to
46
+ `DatePicker` is `Partial`, so only a hand-built full set breaks.
47
+
48
+ **Two kit-wide `OptionList` fixes ride along**, both pre-existing and both
49
+ invisible until a long list met them:
50
+
51
+ - **A chosen option carries `aria-selected`.** It had only the check GLYPH, so
52
+ which option was selected was information available to whoever could see the
53
+ row and to nobody else. It comes with `MenuButton`'s resting `zinc.100`
54
+ highlight — one weight above the `zinc.50` the keyboard/hover row takes — so
55
+ **every** `Select`, `Combobox` and option list now tints its selected row.
56
+ Nothing to change; expect the pixels to move.
57
+ - **An opening list scrolls to its seat.** The initial highlight was seated
58
+ without scrolling to it, so any list longer than its own box opened at the top
59
+ with the current value below the fold.
60
+
61
+ `MenuButton` gains `tabIndex` + `onKeyDown` so a listbox row can hold a roving
62
+ tab stop. Additive.
63
+
64
+ **A pressable `ListItem` presses BESIDE its `right` slot.** The slot is documented as holding
65
+ a control, and a button may not contain one: `<button>` inside `<button>` is invalid HTML and
66
+ buries a second tab stop and a second accessible name inside one control. The anatomy carries
67
+ this rather than a prop opting into safety, so every call site already passing a control there
68
+ is fixed without being touched, and it applies whether or not a slot is passed — `right` is
69
+ routinely conditional, and an anatomy that flipped on that would make two rows in one list
70
+ press differently. The one visible consequence: a DECORATIVE `right` (a badge, a chevron) is no
71
+ longer part of the press target — the row still presses everywhere else.
72
+
73
+ **The pointer cursor is now reserved for controls that ACT** — buttons, links, disclosures.
74
+ Rows, menu options, cards, tabs, field triggers, checkboxes, switches, chips and calendar
75
+ days all show the arrow. No API changes; every affected component is one you already use, so
76
+ the only thing to expect is that the cursor stops changing over most of a screen. The rule and
77
+ its reasoning are in `docs/composition.md` § The pointer cursor. If a component of your own
78
+ needs the pointer, set `CURSOR_ACTION` in its style — and if it is built on the bare
79
+ react-native `Pressable`, note that the pointer is what you get by DEFAULT, from
80
+ react-native-web, unless you set `CURSOR_DEFAULT`.
81
+ ## 42.4.0 — `Picker` is named by its `FormField`, and its way back to empty has a name
82
+
83
+ Two fixes to the same control, both about a thing on screen that the control did
84
+ not actually say.
85
+
86
+ ### A `FormPicker` rendered a label the select was never associated with
87
+
88
+ `Picker` never consumed `useFormField()`, so the label above it was decoration
89
+ for sighted readers: the select announced its current value and no name.
90
+ `NumberInput` and `TextInputField` had always consumed the binding;
91
+ `FormDatePicker` and `FormSwitch` work around it by passing
92
+ `accessibilityLabel={label}` down. `FormPicker` passed nothing, so it was the one
93
+ wrapper whose control was anonymous.
94
+
95
+ `Picker` now takes `id` / `aria-labelledby` / `aria-describedby` / `aria-invalid`
96
+ from the enclosing field, exactly as the other two inputs do — so a `FormField`'s
97
+ description, warning and error are announced with it as well. An explicit
98
+ `accessibilityLabel` still applies OUTSIDE a field and is ignored inside one,
99
+ because an aria-label diverging from the visible text is what breaks
100
+ label-in-name. Nothing to change at a call site; a bare `Picker` is unaffected.
101
+
102
+ ### The way back to empty has a name
103
+
104
+ Not a type break, but it changes text you will see: every `Picker` with
105
+ `includeEmptyOption` used to render its empty row **unlabelled once a value was selected**,
106
+ and that row is the only way to clear the field. So the control offered a blank line as its
107
+ escape, at precisely the moment the reader needed it — and every select cell editor, form
108
+ select and filter condition in an app inherited that.
109
+
110
+ The row is now named. `includeEmptyOption` declares empty a **choice**, and a choice the
111
+ reader picks the way they pick any other row has to read like one: it takes the locale pack's
112
+ word (`picker.emptyOption` — "None" / "Không có") and keeps it whether or not something is
113
+ selected. Override per instance with the new **`emptyOptionLabel`** when the field has a
114
+ truer word ("No skill", "Unassigned").
115
+
116
+ `placeholder` is unchanged and stays a different job: the hint shown while nothing is chosen,
117
+ which disappears on selection. It is no longer consulted when the row is a choice, so a call
118
+ site passing `placeholder` **as** the empty option's name should move that string to
119
+ `emptyOptionLabel`. Nothing else has to change — no existing caller in this repo passed one,
120
+ which is why they were all rendering blanks.
121
+
7
122
  ## 42.0.0 — `DateFilter`'s presets are a listbox, and "Custom" is not one of them
8
123
 
9
124
  Three defects with one root: the preset row used EMPHASIS to mean "current state", inside a
package/docs/catalog.md CHANGED
@@ -765,7 +765,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
765
765
  ### Pickers & selection controls
766
766
 
767
767
  - **`picker`** — `Picker`: native `<select>`, plain label-only single, native typeahead;
768
- also home of the shared `PickerOption` type.
768
+ also home of the shared `PickerOption` type. Its empty row does one of two jobs and they
769
+ are not interchangeable: `includeEmptyOption` makes empty a **choice** the reader picks to
770
+ clear the field, so it is NAMED ("None", from the locale pack; override per instance with
771
+ `emptyOptionLabel`) and keeps that name after a selection — a name that disappears once
772
+ something is chosen is missing exactly when it is the only route back to empty. Without
773
+ that flag the row is a **placeholder**: it carries `placeholder` and leaves on selection.
774
+ Don't reach for `placeholder` to name a choice — a hint and a value read differently.
775
+ Inside a `FormField` (so inside `FormPicker`) it takes its NAME from the visible label
776
+ via the field's binding, along with the description/warning/error — pass `accessibilityLabel`
777
+ only for a bare `Picker` with no label beside it, since inside a field an aria-label that
778
+ differs from the visible text is what breaks label-in-name.
769
779
  - **`select`** — `Select`: rich/custom-rendered, single/multi, select-all, chips via
770
780
  `renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
771
781
  a multi Select; opens `OptionList`. Its trigger is `role="combobox"` (a `<div>`), never
@@ -778,7 +788,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
778
788
  shell-owned `actions`).
779
789
  - **`option_list`** — `OptionList`: the ONE shared searchable listbox body every selector
780
790
  opens — single/multi, optional internal search, create row, keyboard + native-`<select>`
781
- typeahead; host it directly in a `Popover`/`Dialog` for a command palette.
791
+ typeahead; host it directly in a `Popover`/`Dialog` for a command palette. A chosen row is
792
+ `selected`: the kit's `zinc.100` resting highlight AND `aria-selected`, one weight above the
793
+ `zinc.50` the keyboard/hover row takes — a check glyph alone said nothing to a screen reader.
794
+ It opens SCROLLED to its seat (the selected row in `search.mode:"none"`, else the first real
795
+ option), which only shows on a list longer than its own box and is the difference between a
796
+ 96-row list opening on your value and opening at the top.
782
797
  - **`combobox`** — COMPOUND single-select editable search: `Combobox` root +
783
798
  `ComboboxInput` + `ComboboxContent`, optional `ComboboxEmpty`/`ComboboxFooter`,
784
799
  `useCombobox()`; over the shared option-list engine; browses on focus; no `multi` —
@@ -855,6 +870,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
855
870
  localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
856
871
  `ref.navigateToMonth`. The engine `DatePicker`/`DateFilter` wrap in field chrome — reach
857
872
  for it bare only when the calendar lives permanently on the surface, not behind a field.
873
+ While a range is OPEN (a start, no end), hovering a day fills the span it would close on,
874
+ across both months — including the day under the pointer, which a committed range draws
875
+ dark and a previewed one has nothing else to mark. It shares its open-ness test with the
876
+ click (`previewEndFor` beside `nextRangeSelection`), so the span shown is always the span
877
+ the next click produces; hovering backwards from the start previews just as well.
858
878
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
859
879
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
860
880
  **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
@@ -870,10 +890,25 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
870
890
  end that is still open.
871
891
  - **`date_range_filter_field`** — `DateRangeFilterField`: the register's period filter field
872
892
  (presets + footer + time-segment a11y; localized via the `dateRange` locale slice).
873
- - **`time_picker`** — `TimePicker`: the time-of-day field. Segments (hour / minute / AM-PM),
874
- **12- vs 24-hour derived from `locale`** never from the browser, which is why this is not
875
- an `<input type="time">`. The value is always canonical 24-hour `"HH:mm"`; `locale` changes
876
- only what is displayed and typed.
893
+ - **`time_picker`** — `TimePicker`: the time-of-day FIELD. The value on a pressable
894
+ control (the bordered surface a `Select` uses, so it reads as pickable by the same
895
+ shape as every other field that opens something), and `TimeColumns` behind it.
896
+ **No text entry** — a glyph inside a field is indistinguishable from `DateField`'s
897
+ display-only calendar icon, and a day period is a choice between two named things
898
+ rather than a word to spell. `placeholder` covers the empty state (defaults to the
899
+ `chooseTime` label). The value is always canonical 24-hour `"HH:mm"`; `locale`
900
+ decides what is displayed AND the picker's shape.
901
+ - **`time_columns`** — `TimeColumns`: the picking face — hour, minute, and a day
902
+ period **only where the locale uses one** (a 24-hour locale gets two columns and
903
+ hours 00–23). Every minute is offered, so no time is unreachable and no step has
904
+ to be chosen; each column commits on its own, so the value is always a real time
905
+ and there is no draft to confirm. Roving tabindex per column — Tab reaches a
906
+ column once, arrows move within it, and selection follows focus.
907
+ - **`time_options`** — the columns' vocabulary, RN-free and pure: `decomposeTime` /
908
+ `composeTime` (the split into `displayHour` / `minute` / `pm` and back — a
909
+ 12-hour locale's displayed hour means two different times and only the period
910
+ says which, which is why composing takes both), `hourOptions`, `minuteOptions`,
911
+ `dayPeriodOptions`, `dayPeriodLabels`, `formatTimeOfDay`, `timeToMinuteOfDay`.
877
912
  - **`deadline`** — the COUNTDOWN vocabulary, RN-free and pure: `daysUntil`, `countdownLabel`,
878
913
  `deadlineTone` / `deadlineColor`, `deadlineAnnotation`, `nearestDeadline` (+ `DeadlineLabels`,
879
914
  `DeadlineThresholds`, `Deadline`). Three contracts a screen must not invent for itself — how
@@ -1162,7 +1197,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1162
1197
  ### Lists, tables & registers
1163
1198
 
1164
1199
  - **`list`** — `List`: children separated by hairline `Divider`s.
1165
- - **`list_item`** — `ListItem`: the plain list row.
1200
+ - **`list_item`** — `ListItem`: the plain list row. A pressable row with a `right` slot
1201
+ presses BESIDE it, never around it: the slot holds a control, and a button may not contain
1202
+ one — `<button>` inside `<button>` is invalid HTML and buries a second tab stop and name
1203
+ inside one control. So the surface is a wrapper, the press target is `left` + the text, and
1204
+ the slot sits outside it (the anatomy `InlineEditView`'s `actions` uses). ALWAYS, not only
1205
+ when a slot is passed: `right` is routinely conditional, and an anatomy that flipped on that
1206
+ would make two rows in one list press differently. Nothing to opt into — a decorative
1207
+ `right` simply stops being part of the press target.
1166
1208
  - **`table`** — `Table` + `TableRow` + `TableCell`: the paginated high-volume register
1167
1209
  (columns defined once, `sortLabels` localizable; rows are `PressableRow`-based).
1168
1210
  CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
@@ -396,7 +396,7 @@ shows a zinc-100 highlight; `marked` (a bulk-ticked row) a resting blue-50 tint.
396
396
  band + every row's cell widths derive from it and can't drift. ONE hairline under the header; the
397
397
  rows below it Divider-separated. A sortable column shows its `SortHeader` glyph ALWAYS (a neutral
398
398
  up/down glyph when unsorted) so it reads as sortable; pressing cycles none → asc → desc → none.
399
- A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash, no pointer cursor) —
399
+ A `TableRow` with no `onPress` renders a STATIC read-only row (no hover wash) —
400
400
  so `Table` also serves read-only tabular data (a fee breakdown, a spec sheet), not just interactive
401
401
  registers. A pressable `TableRow` REQUIRES `accessibilityLabel` ("Open …") — its keyboard door is
402
402
  an empty overlay with no content to derive a name from.
@@ -966,6 +966,26 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
966
966
  a mix of "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker
967
967
  re-prompts, so the destructive verb covers both required anchors and optional adds.
968
968
 
969
+ ## The pointer cursor
970
+
971
+ **Only a control that ACTS ON ITS OWN shows a pointer** — a button, a link, a disclosure.
972
+ Everything else shows the arrow: a row, a menu option, a card, a tab, a field trigger, a
973
+ checkbox, a switch, a chip. The line is not "is it pressable" (nearly everything is) but
974
+ "does pressing it DO something, or does it set a value / open the thing you are already
975
+ looking at".
976
+
977
+ Drawn tightly because a signal on half the screen is not a signal. A pointer everywhere says
978
+ only "this is a web page"; reserved, it marks the few things that act. What carries "you can
979
+ press this" elsewhere is the hover treatment the surface already owns — a row's wash, a
980
+ field's border, a chip's fill — which is what a native list or menu uses too, where the
981
+ cursor stays an arrow throughout.
982
+
983
+ The arrow is never the default and always has to be asked for: react-native-web ships
984
+ `cursor: pointer` inside `Pressable`'s own base style, so a component that says nothing gets
985
+ a pointer. `PressableHighlight` and `FocusRingPressable` set the arrow for everything built
986
+ on them (`CURSOR_DEFAULT`); a component on the bare `Pressable` sets it itself, and the few
987
+ that act set `CURSOR_ACTION` back over the top.
988
+
969
989
  ## Color discipline — solid / tint / ramp + ONE accent
970
990
 
971
991
  Every status / data-viz / accent color comes from a NAMED helper (`@lotics/ui/colors`), never a
@@ -123,7 +123,7 @@ dialog save, a debounced autosave).
123
123
  ### Keyboard entry — type → Tab → type
124
124
 
125
125
  Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
126
- SWAP editor — `InlineNumberInput`, `InlineTimePicker`, `InlineDatePicker` — opens edit mode
126
+ SWAP editor — `InlineNumberInput`, `InlineDatePicker` — opens edit mode
127
127
  immediately with the input focused; commit-on-blur then makes Tab itself the commit, so the chain
128
128
  is type → Tab → type with the next editor already open. Pointer focus never auto-opens (mousedown
129
129
  records "pointer" modality before focus fires — `interaction_modality.ts`), so the click path is
@@ -172,21 +172,26 @@ One per type:
172
172
  - **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
173
173
  ADD/REMOVE a time — the value's own shape, date vs datetime, is the source of truth
174
174
  (`optionalTime` is ignored when `format="datetime"`).
175
- - **`InlineTimePicker`** — a time-only value.
175
+ - **`InlineTimePicker`** — a time-only value, PICKED: the value at rest, the hour /
176
+ minute / period columns in a popover anchored to the field, committing when the
177
+ popover closes. On the POPOVER shell beside `InlineDatePicker`, not the typed one
178
+ — `useInlineEdit` commits when the field blurs, and a picker cannot live there,
179
+ because opening one moves focus into it, which reads as a blur and unmounts the
180
+ editor before the picker can paint.
176
181
  - **`InlineSelect multi`** — the MULTI mode of `InlineSelect` (there's no separate tag component):
177
182
  a tag SET (`value: T[]`), selected tags render as badges inside the standard chip, clicking floats
178
183
  a multi `OptionList` (checkbox rows), CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
179
184
  field.
180
185
 
181
186
  They split across two shared shells. The same-height **typed** editors (`InlineTextInput`,
182
- `InlineNumberInput`, `InlineTimePicker`) render **`InlineEditFrame`** driven by the
187
+ `InlineNumberInput`) render **`InlineEditFrame`** driven by the
183
188
  **`useInlineEdit`** hook (the draft buffer, async save, and — for the two that swap — the view⇄edit
184
189
  toggle) — pass a custom input to that pair to join the family. On the frame, **`editing` means an
185
190
  input is MOUNTED** (which element renders, and whether the surface drops its resting padding to
186
191
  host one); an always-mounted editor passes it constant and passes the real state as **`editOpen`**,
187
192
  which the ✓/✕ verbs read — they assert "there is an edit to accept or reject", so on a resting
188
193
  field they would be both a false claim and dead buttons. The **popover** editors (`InlineSelect`,
189
- `InlineMemberSelect`, `InlineDatePicker`) instead compose **`InlineEditView`**
194
+ `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`) instead compose **`InlineEditView`**
190
195
  (the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
191
196
  error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
192
197
  either way: the saving spinner sits INSIDE the control at its right edge (never a sibling — that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "42.3.0",
3
+ "version": "43.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -244,6 +244,8 @@
244
244
  "./date_filter": "./src/date_filter.tsx",
245
245
  "./date_range_filter_field": "./src/date_range_filter_field.tsx",
246
246
  "./time_picker": "./src/time_picker.tsx",
247
+ "./time_options": "./src/time_options.ts",
248
+ "./time_columns": "./src/time_columns.tsx",
247
249
  "./date_calendar": "./src/date_calendar.tsx",
248
250
  "./dialog": "./src/dialog.tsx",
249
251
  "./drawer": "./src/drawer.tsx",
@@ -3,6 +3,7 @@ import { View } from "react-native";
3
3
  import { Icon } from "./icon";
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
+ import { CURSOR_ACTION } from "./control_surface";
6
7
  import { PressableHighlight } from "./pressable_highlight";
7
8
  import { useLoticsLocale } from "./locale";
8
9
 
@@ -44,7 +45,9 @@ export function BackButton(props: BackButtonProps) {
44
45
  accessibilityLabel={accessibilityLabel}
45
46
  userSelect="none"
46
47
  onPress={onPress}
47
- style={{ flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", borderRadius: 999, paddingRight: 14 }}
48
+ // Going back ACTS, exactly as the bare variant's `IconButton` does
49
+ // without this, one component shows two cursors decided by a label.
50
+ style={{ cursor: CURSOR_ACTION, flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", borderRadius: 999, paddingRight: 14 }}
48
51
  >
49
52
  <View style={{ width: 40, height: 40, borderRadius: 999, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
50
53
  <Icon name="chevron-left" size={20} color={colors.zinc[900]} />
@@ -118,8 +118,7 @@ const styles = StyleSheet.create({
118
118
  tap: {
119
119
  alignItems: "center",
120
120
  justifyContent: "center",
121
- // @ts-ignore rn-web
122
- cursor: "pointer",
121
+ cursor: "auto",
123
122
  },
124
123
  disabled: { opacity: 0.4 },
125
124
  ring: {
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { Pressable } from "react-native";
3
3
  import { Checkbox } from "./checkbox";
4
- import { FOCUS_RING } from "./control_surface";
4
+ import { CURSOR_DEFAULT, FOCUS_RING } from "./control_surface";
5
5
  import { useFocusRing } from "./use_focus_ring";
6
6
  interface CheckboxInputProps {
7
7
  /**
@@ -32,7 +32,7 @@ export function CheckboxInput(props: CheckboxInputProps) {
32
32
  onPress={handlePress}
33
33
  disabled={disabled}
34
34
  {...focusProps}
35
- style={[{ opacity: disabled ? 0.5 : 1, borderRadius: 6 }, focusVisible && { boxShadow: FOCUS_RING }]}
35
+ style={[{ opacity: disabled ? 0.5 : 1, borderRadius: 6, cursor: CURSOR_DEFAULT }, focusVisible && { boxShadow: FOCUS_RING }]}
36
36
  // Use the W3C ARIA props directly: RN-web 0.21 no longer maps
37
37
  // `accessibilityState={{ checked }}` to `aria-checked`, which dropped the
38
38
  // checked state from the accessibility tree (a role="checkbox" with no
@@ -6,7 +6,7 @@ import { Icon } from "./icon";
6
6
  import { Divider } from "./divider";
7
7
  import { composeHandler, useFocusRing } from "./use_focus_ring";
8
8
  import { useHover } from "./use_hover";
9
- import { CONTROL_RADIUS, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
9
+ import { CONTROL_RADIUS, CURSOR_DEFAULT, FOCUS_RING, ROW_WASH_BLEED } from "./control_surface";
10
10
  import { useAutoGrowHeight } from "./use_auto_grow_height";
11
11
  import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
12
12
  import { useLoticsLocale } from "./locale";
@@ -55,7 +55,7 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
55
55
  // Bleeds like every other washed row, so the option TEXT sits on the
56
56
  // container's edge — level with the question above it, which no longer
57
57
  // has to hand-pad itself to match.
58
- { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
58
+ { cursor: CURSOR_DEFAULT, flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: ROW_WASH_BLEED, marginHorizontal: -ROW_WASH_BLEED, borderRadius: CONTROL_RADIUS },
59
59
  // Selected reads as a persistent tint (zinc-100); an unselected row only
60
60
  // washes on hover (zinc-50). The check below reserves its slot always.
61
61
  selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
@@ -2,7 +2,7 @@ import { StyleSheet, type GestureResponderEvent } from "react-native";
2
2
  import { colors } from "./colors";
3
3
  import { Icon } from "./icon";
4
4
  import { PressableHighlight } from "./pressable_highlight";
5
- import { ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
5
+ import { CURSOR_ACTION, ROW_CONTROL_HOVER, ROW_CONTROL_PRESS } from "./control_surface";
6
6
  import { Text } from "./text";
7
7
  import { useLoticsLocale } from "./locale";
8
8
 
@@ -105,6 +105,8 @@ export function CommentsButton(props: CommentsButtonProps) {
105
105
 
106
106
  const styles = StyleSheet.create({
107
107
  button: {
108
+ // Opening the thread ACTS — see `CURSOR_ACTION`.
109
+ cursor: CURSOR_ACTION,
108
110
  flexDirection: "row",
109
111
  alignItems: "center",
110
112
  // Tight: the glyph and its number are ONE word, not two things in a row.
@@ -159,3 +159,30 @@ export function chipSurfaceStyle(
159
159
  boxShadow: selected ? `0 0 0 1px ${colors.zinc[900]}` : undefined,
160
160
  };
161
161
  }
162
+
163
+ /**
164
+ * Which controls show a POINTER, and which show the arrow.
165
+ *
166
+ * **Only a control that ACTS ON ITS OWN gets the pointer** — a button, a link, a
167
+ * disclosure. Everything else takes the arrow: a row, a menu option, a card, a
168
+ * tab, a field trigger, a checkbox, a switch, a chip. The line is not "is it
169
+ * pressable" (nearly everything here is) but "does pressing it DO something, or
170
+ * does it set a value / open the thing you are already looking at".
171
+ *
172
+ * The reason to draw it tightly is that a signal on half the screen is not a
173
+ * signal. A pointer everywhere says only "this is a web page"; reserved, it
174
+ * marks the handful of things that act. What carries "you can press this"
175
+ * elsewhere is the hover treatment each surface already owns — a row's wash, a
176
+ * field's border, a chip's fill — which is the affordance a native list or menu
177
+ * uses too, where the cursor stays an arrow throughout.
178
+ *
179
+ * **The arrow is never the default and always has to be asked for.**
180
+ * react-native-web ships `cursor: pointer` inside `Pressable`'s own base style,
181
+ * so a component that says nothing gets a pointer. `PressableHighlight` and
182
+ * `FocusRingPressable` set the arrow for everything built on them, and a
183
+ * component on the bare `Pressable` sets it itself; the few that act set
184
+ * {@link CURSOR_ACTION} back over the top.
185
+ */
186
+ export const CURSOR_DEFAULT = "auto";
187
+ /** For a button, a link, a disclosure — see {@link CURSOR_DEFAULT}. */
188
+ export const CURSOR_ACTION = "pointer";
package/src/data_grid.tsx CHANGED
@@ -185,7 +185,7 @@ const styles = StyleSheet.create({
185
185
  // Padded for the hover pill but zero-width in flow (negative margin), so the label
186
186
  // sits on its cell's edge instead of 8px inside it.
187
187
  sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, marginHorizontal: -6, paddingVertical: 2, borderRadius: 6 },
188
- sortLabelPressable: { cursor: "pointer" },
188
+ sortLabelPressable: { cursor: "auto" },
189
189
  sortLabelHover: { backgroundColor: colors.zinc[50] },
190
190
  groupSep: { borderTopWidth: 1, borderTopColor: colors.zinc[100], marginTop: 8, paddingTop: 8 },
191
191
  section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, marginHorizontal: -8, paddingVertical: 7, borderRadius: 8 },