@lotics/ui 23.1.1 → 24.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,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
- import { TextLink } from "@lotics/ui/text_link";
4
+ import { TextButton } from "@lotics/ui/text_button";
5
5
  import { colors, solid, asColorName } from "@lotics/ui/colors";
6
6
  import { Icon } from "@lotics/ui/icon";
7
7
  import { CheckCircle } from "@lotics/ui/check_circle";
@@ -527,7 +527,7 @@ function RowActionCell({ task, result, onAttach, onApprove }: { task: Task; resu
527
527
  const onPress = a.kind === "attach" ? () => onAttach(task.id) : a.kind === "approve" ? () => onApprove(task.id) : () => {};
528
528
  return (
529
529
  <View style={styles.action}>
530
- <TextLink size="sm" numberOfLines={1} onPress={onPress}>{a.label}</TextLink>
530
+ <TextButton numberOfLines={1} onPress={onPress}>{a.label}</TextButton>
531
531
  </View>
532
532
  );
533
533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "23.1.1",
3
+ "version": "24.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "./tokens": "./src/tokens.ts",
11
11
  "./colors": "./src/colors.ts",
12
+ "./deadline": "./src/deadline.ts",
12
13
  "./option_badge": "./src/option_badge.tsx",
13
14
  "./member_chip": "./src/member_chip.tsx",
14
15
  "./member_select": "./src/member_select.tsx",
@@ -176,6 +177,8 @@
176
177
  "./slider_math": "./src/slider_math.ts",
177
178
  "./counter": "./src/counter.tsx",
178
179
  "./link": "./src/link.tsx",
180
+ "./reference_field": "./src/reference_field.tsx",
181
+ "./text_button": "./src/text_button.tsx",
179
182
  "./text_link": "./src/text_link.tsx",
180
183
  "./sort_header": "./src/sort_header.tsx",
181
184
  "./skeleton": "./src/skeleton.tsx",
@@ -266,7 +269,7 @@
266
269
  },
267
270
  "license": "SEE LICENSE IN LICENSE.md",
268
271
  "dependencies": {
269
- "@lotics/docx": "^0.2.0",
272
+ "@lotics/docx": "^0.3.0",
270
273
  "@lotics/xlsx": "^0.1.0",
271
274
  "ai": "^7.0.30",
272
275
  "mdast-util-from-markdown": "^2.0.3",
package/src/breakdown.tsx CHANGED
@@ -97,7 +97,9 @@ export function Breakdown(props: BreakdownProps) {
97
97
  <PressableHighlight
98
98
  focusRing key={item.key}
99
99
  accessibilityRole="button"
100
- accessibilityState={{ selected }}
100
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
101
+ // build, and `selected` has no meaning on a `button` role regardless.
102
+ aria-pressed={selected}
101
103
  accessibilityLabel={`${item.label}: ${formatValue(item.value)}`}
102
104
  onPress={() => onSelect(selected ? null : item.key)}
103
105
  style={[styles.row, styles.pressable, selected ? styles.selected : null]}
@@ -46,7 +46,9 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
46
46
  <Pressable
47
47
  accessibilityRole="button"
48
48
  accessibilityLabel={option.label}
49
- accessibilityState={{ selected }}
49
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
50
+ // build, and `selected` has no meaning on a `button` role regardless.
51
+ aria-pressed={selected}
50
52
  onPress={onSelect}
51
53
  {...focusProps}
52
54
  style={({ hovered, pressed }) => [
@@ -31,7 +31,9 @@ function DayCell(props: {
31
31
  <Pressable
32
32
  accessibilityRole="button"
33
33
  accessibilityLabel={props.label}
34
- accessibilityState={{ selected: props.isActive }}
34
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
35
+ // build, and `selected` has no meaning on a `button` role regardless.
36
+ aria-pressed={props.isActive}
35
37
  {...focusProps}
36
38
  style={({ hovered }) => [
37
39
  styles.dayCell,
@@ -0,0 +1,157 @@
1
+ import { type ColorName } from "./colors";
2
+
3
+ /**
4
+ * DEADLINES — the countdown vocabulary: how many days are left, what to CALL
5
+ * that number, and how loud it should read.
6
+ *
7
+ * It lives in the kit because all three are contracts, not layout. A screen
8
+ * that invents its own thresholds says "urgent" at a different distance than
9
+ * the screen beside it; one that invents its own wording says "2 days late" in
10
+ * four different phrasings; and one that renders the countdown as a trailing
11
+ * badge detaches it from the date it is about. Every departmental register
12
+ * ordered by "what is due next" needs the same three answers.
13
+ *
14
+ * RN-free on purpose: the rules are unit-testable, and a component renders
15
+ * their result rather than owning them.
16
+ */
17
+
18
+ /** What a deadline's proximity means. Directly usable as a `Text` `color` —
19
+ * text collapses to these tokens rather than wearing a hue (see `deadlineColor`
20
+ * for the badge/dot side, which keeps hue nuance). */
21
+ export type DeadlineTone = "danger" | "warning" | "muted";
22
+
23
+ /** Where "act now" and "act soon" start, in whole days.
24
+ *
25
+ * The defaults (1 / 3) come from freight, where a missed cut-off costs a
26
+ * sailing and demurrage accrues inside three days. A domain whose consequences
27
+ * arrive slower moves them out; the point is that ONE screen never disagrees
28
+ * with the next about where the line sits. */
29
+ export interface DeadlineThresholds {
30
+ /** ≤ this many days out reads `danger`. Default 1 (today and tomorrow). */
31
+ dangerWithinDays?: number;
32
+ /** ≤ this many days out reads `warning`. Default 3. */
33
+ warningWithinDays?: number;
34
+ }
35
+
36
+ /** The four things a countdown can say. Resolved from `LoticsLocale.deadline`
37
+ * (English defaults, Vietnamese pack shipped) or passed per instance. */
38
+ export interface DeadlineLabels {
39
+ /** Past due, given the count as a POSITIVE number of days. */
40
+ overdue: (days: number) => string;
41
+ /** Due today — the count is 0, and "0 days left" is not what a reader wants. */
42
+ today: string;
43
+ /** Due tomorrow. Named for the same reason as `today`. */
44
+ tomorrow: string;
45
+ /** Any distance further out, in whole days. */
46
+ inDays: (days: number) => string;
47
+ }
48
+
49
+ /** A milestone resolved against a clock: what it is, when, how far off. */
50
+ export interface Deadline {
51
+ label: string;
52
+ date: Date;
53
+ /** Calendar days from now — NEGATIVE when the date has passed. */
54
+ days: number;
55
+ }
56
+
57
+ const MS_DAY = 86_400_000;
58
+ const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
59
+
60
+ /**
61
+ * Whole CALENDAR days until `due`, negative once it has passed.
62
+ *
63
+ * Both ends collapse to midnight first, because the vocabulary this feeds is
64
+ * DAY-granular and so the arithmetic has to be. Diffing the raw instants and
65
+ * rounding lands on the wrong day whenever `now` and the deadline sit on
66
+ * opposite sides of noon — and carriers really do set cut-offs at 02:00:
67
+ *
68
+ * now 14:30, cut-off 02:00 TOMORROW → 1 "Tomorrow". Raw: 0, "Today".
69
+ * now 14:30, cut-off 02:00 TODAY → 0 "Today". Raw: -1, "1 day overdue"
70
+ * for something 12h old.
71
+ * now 00:30, cut-off 23:00 TONIGHT → 0 "Today". Raw: 1, "Tomorrow".
72
+ *
73
+ * Each wrong answer makes the countdown contradict the date printed beside it.
74
+ */
75
+ export function daysUntil(due: Date, now: Date): number {
76
+ return Math.round((startOfDay(due).getTime() - startOfDay(now).getTime()) / MS_DAY);
77
+ }
78
+
79
+ /** How loud a day-count reads. Overdue folds into `danger` — a missed deadline
80
+ * and one due today both mean ACT NOW, and the LABEL is what distinguishes
81
+ * them ("2 days overdue" vs "Today"), so the tone does not have to. */
82
+ export function deadlineTone(days: number, thresholds?: DeadlineThresholds): DeadlineTone {
83
+ const { dangerWithinDays = 1, warningWithinDays = 3 } = thresholds ?? {};
84
+ if (days <= dangerWithinDays) return "danger";
85
+ if (days <= warningWithinDays) return "warning";
86
+ return "muted";
87
+ }
88
+
89
+ /** The same decision as a `ColorName`, for a `Badge` / `Badge variant="dot"`
90
+ * where hue nuance survives. `muted` becomes `zinc`: a deadline comfortably
91
+ * out is a standing fact, and coloring it spends the page's attention budget
92
+ * on the one row that needs none. */
93
+ export function deadlineColor(days: number, thresholds?: DeadlineThresholds): ColorName {
94
+ const tone = deadlineTone(days, thresholds);
95
+ return tone === "danger" ? "red" : tone === "warning" ? "amber" : "zinc";
96
+ }
97
+
98
+ /** A day-count as words: "2 days overdue" / "Today" / "Tomorrow" / "5 days left".
99
+ *
100
+ * 0 and 1 get their own words because a countdown that renders them
101
+ * arithmetically ("0 days left") reads as broken at exactly the moment it
102
+ * matters most. */
103
+ export function countdownLabel(days: number, labels: DeadlineLabels): string {
104
+ if (days < 0) return labels.overdue(-days);
105
+ if (days === 0) return labels.today;
106
+ if (days === 1) return labels.tomorrow;
107
+ return labels.inDays(days);
108
+ }
109
+
110
+ /**
111
+ * The countdown as a FIELD ANNOTATION rather than a trailing badge — spread it
112
+ * onto the `DetailRow` holding the date.
113
+ *
114
+ * A deadline is a property OF the date it sits on, so it annotates that field
115
+ * the way every other field annotation does, and the three annotation slots
116
+ * carry the three urgency levels exactly: past due or due today is the field
117
+ * being WRONG (`error`), the warning window is "accepted, and something
118
+ * downstream is worse for it" (`warning`), and anything further out is the
119
+ * standing fact (`description`). A badge at the end of the row would say the
120
+ * same thing in a vocabulary the rest of the field grid does not speak.
121
+ *
122
+ * <DetailRow label="SI cut-off" {...deadlineAnnotation(days, words.deadline)}>
123
+ */
124
+ export function deadlineAnnotation(
125
+ days: number,
126
+ labels: DeadlineLabels,
127
+ thresholds?: DeadlineThresholds,
128
+ ): { description?: string; warning?: string; error?: string } {
129
+ const text = countdownLabel(days, labels);
130
+ const tone = deadlineTone(days, thresholds);
131
+ return tone === "danger" ? { error: text } : tone === "warning" ? { warning: text } : { description: text };
132
+ }
133
+
134
+ /**
135
+ * The nearest of several milestones — the one a desk is actually working to.
136
+ *
137
+ * Undated candidates drop out; the rest sort by date, so an OVERDUE milestone
138
+ * comes back FIRST. That is deliberate: a cut-off you missed yesterday is the
139
+ * most urgent thing on the record, and a "next deadline" that skips past it to
140
+ * the following one silently stops reporting the failure.
141
+ *
142
+ * Which means the CALLER passes only the milestones still OPEN. Done-ness is
143
+ * not knowable from a date — a met cut-off and a missed one are both in the
144
+ * past — and a milestone left in after it was satisfied would dominate this
145
+ * answer forever.
146
+ */
147
+ export function nearestDeadline(
148
+ candidates: ReadonlyArray<{ label: string; date: Date | null | undefined }>,
149
+ now: Date,
150
+ ): Deadline | null {
151
+ // `flatMap` hands back a FRESH array, so sorting it in place cannot reach the
152
+ // caller's — the copy a defensive `[...dated]` would add is dead weight.
153
+ const dated = candidates.flatMap((c) =>
154
+ c.date ? [{ label: c.label, date: c.date, days: daysUntil(c.date, now) }] : [],
155
+ );
156
+ return dated.sort((a, b) => a.date.getTime() - b.date.getTime())[0] ?? null;
157
+ }
@@ -3,7 +3,7 @@ import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
6
- import { TextLink } from "./text_link";
6
+ import { TextButton } from "./text_button";
7
7
  import { Chip } from "./chip";
8
8
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
9
9
  import type { PopoverSide, PopoverAlign } from "./popover";
@@ -110,7 +110,7 @@ export function FilterChip(props: FilterChipProps) {
110
110
  <PopoverFooter>{footer}</PopoverFooter>
111
111
  ) : showClear ? (
112
112
  <PopoverFooter align="start">
113
- <TextLink onPress={onClear}>{clearLabel}</TextLink>
113
+ <TextButton onPress={onClear}>{clearLabel}</TextButton>
114
114
  </PopoverFooter>
115
115
  ) : null}
116
116
  </PopoverContent>
package/src/funnel.tsx CHANGED
@@ -91,7 +91,9 @@ export function Funnel(props: FunnelProps) {
91
91
  return (
92
92
  <PressableHighlight
93
93
  accessibilityRole="button"
94
- accessibilityState={{ selected: selectedKey === key }}
94
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
95
+ // build, and `selected` has no meaning on a `button` role regardless.
96
+ aria-pressed={selectedKey === key}
95
97
  accessibilityLabel={label}
96
98
  onPress={() => onSelect(selectedKey === key ? null : key)}
97
99
  style={fill ? styles.pressFill : undefined}
package/src/heatmap.tsx CHANGED
@@ -108,7 +108,9 @@ function Cell(props: CellProps) {
108
108
  return (
109
109
  <Pressable
110
110
  accessibilityRole="button"
111
- accessibilityState={{ selected }}
111
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
112
+ // build, and `selected` has no meaning on a `button` role regardless.
113
+ aria-pressed={selected}
112
114
  accessibilityLabel={label}
113
115
  onPress={onPress}
114
116
  {...focusProps}
package/src/locale.tsx CHANGED
@@ -8,6 +8,7 @@ import { type RemainderMeterLabels } from "./remainder_meter";
8
8
  import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
9
9
  import { type GalleryLabels } from "./file_preview_types";
10
10
  import { type FindingLabels } from "./finding";
11
+ import { type DeadlineLabels } from "./deadline";
11
12
 
12
13
  /**
13
14
  * The kit's localizable strings, one slice per string-bearing component. A
@@ -69,6 +70,9 @@ export interface LoticsLocale {
69
70
  sequence: { moveUp: string; moveDown: string; remove: string };
70
71
  /** `TrendFooter`: the direction words of the trend sentence. */
71
72
  trendFooter: { up: string; down: string };
73
+ /** The countdown vocabulary — see `deadline.ts`. 0 and 1 get their own words
74
+ * because "0 days left" reads as broken exactly when it matters most. */
75
+ deadline: DeadlineLabels;
72
76
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
73
77
  suggestionChip: { add: (label: string) => string; dismiss: string };
74
78
  /** `Confidence`: the full level phrase ("High confidence" …). */
@@ -226,6 +230,12 @@ export const en: LoticsLocale = {
226
230
  chip: { remove: "Remove" },
227
231
  sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
228
232
  trendFooter: { up: "Up", down: "Down" },
233
+ deadline: {
234
+ overdue: (days) => `${days} ${days === 1 ? "day" : "days"} overdue`,
235
+ today: "Today",
236
+ tomorrow: "Tomorrow",
237
+ inDays: (days) => `${days} days left`,
238
+ },
229
239
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
230
240
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
231
241
  remainderMeter: {
@@ -378,6 +388,12 @@ export const vi: LoticsLocale = {
378
388
  chip: { remove: "Xóa" },
379
389
  sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
380
390
  trendFooter: { up: "Tăng", down: "Giảm" },
391
+ deadline: {
392
+ overdue: (days) => `Quá hạn ${days} ngày`,
393
+ today: "Hôm nay",
394
+ tomorrow: "Ngày mai",
395
+ inDays: (days) => `Còn ${days} ngày`,
396
+ },
381
397
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
382
398
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
383
399
  remainderMeter: {
package/src/matrix.tsx CHANGED
@@ -222,7 +222,9 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
222
222
  return (
223
223
  <FocusRingPressable
224
224
  accessibilityRole="button"
225
- accessibilityState={{ selected: isSelected }}
225
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
226
+ // build, and `selected` has no meaning on a `button` role regardless.
227
+ aria-pressed={isSelected}
226
228
  accessibilityLabel={label}
227
229
  onPress={() => onSelectCell?.(isSelected ? null : { row: row.key, col: col.key })}
228
230
  style={(state) => [
@@ -7,7 +7,7 @@ import { Checkbox } from "./checkbox";
7
7
  import { MenuButton } from "./menu_button";
8
8
  import { MenuListItem } from "./menu_list_item";
9
9
  import { Button } from "./button";
10
- import { TextLink } from "./text_link";
10
+ import { TextButton } from "./text_button";
11
11
  import { ActivityIndicator } from "./activity_indicator";
12
12
  import { TextInputField } from "./text_input_field";
13
13
  import { useScreenSize } from "./use_screen_size";
@@ -176,9 +176,9 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
176
176
 
177
177
  {list.showSelectAll || list.showDeselectAll ? (
178
178
  <View style={styles.selectAllContainer}>
179
- {list.showSelectAll ? <TextLink onPress={list.selectAll}>{selectAllLabel}</TextLink> : null}
179
+ {list.showSelectAll ? <TextButton onPress={list.selectAll}>{selectAllLabel}</TextButton> : null}
180
180
  {list.showDeselectAll ? (
181
- <TextLink onPress={list.deselectAll}>{deselectAllLabel}</TextLink>
181
+ <TextButton onPress={list.deselectAll}>{deselectAllLabel}</TextButton>
182
182
  ) : null}
183
183
  </View>
184
184
  ) : null}
@@ -10,6 +10,13 @@ export interface PressDoorProps {
10
10
  * without a label it is a nameless button. Name the destination ("Open ORD-1042").
11
11
  */
12
12
  accessibilityLabel: string;
13
+ /**
14
+ * Set ONLY when the press toggles a disclosure the door owns (`TableRow`'s
15
+ * `detail`), never when it navigates: it emits `aria-expanded`, which promises
16
+ * a screen-reader user that this control shows and hides something in place.
17
+ * Omit and no state is announced.
18
+ */
19
+ expanded?: boolean;
13
20
  /** Focus-ring corner radius — match the surface the door spans. Default 10 (the register wash). */
14
21
  radius?: number;
15
22
  }
@@ -44,12 +51,16 @@ export interface PressDoorProps {
44
51
  *
45
52
  * `TableRow` is the in-kit consumer.
46
53
  */
47
- export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
54
+ export function PressDoor({ onPress, accessibilityLabel, expanded, radius = 10 }: PressDoorProps) {
48
55
  const { focusVisible, focusProps } = useFocusRing();
49
56
  return (
50
57
  <Pressable
51
58
  accessibilityRole="button"
52
59
  accessibilityLabel={accessibilityLabel}
60
+ // The W3C prop, NOT `accessibilityState` — this react-native-web build drops
61
+ // the latter silently. Set only when the door TOGGLES something: on a door
62
+ // that navigates it would promise a disclosure the press does not perform.
63
+ aria-expanded={expanded}
53
64
  onPress={onPress}
54
65
  {...focusProps}
55
66
  style={[styles.door, { borderRadius: radius }, focusVisible && { boxShadow: FOCUS_RING }]}
@@ -0,0 +1,174 @@
1
+ import { useRef, useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Button } from "./button";
4
+ import { DetailRow, DetailTable } from "./detail_row";
5
+ import { Divider } from "./divider";
6
+ import { InlineEditView } from "./inline_edit";
7
+ import { Popover, PopoverContent } from "./popover";
8
+ import { DialogSectionHeadingTitle } from "./section_heading";
9
+ import { Text } from "./text";
10
+ import { TextButton } from "./text_button";
11
+ import { TextLink } from "./text_link";
12
+
13
+ /**
14
+ * A REFERENCE to another record, rendered as a FIELD VALUE.
15
+ *
16
+ * It wears the inline editor's resting surface — same 40px band, same zinc-50
17
+ * chip, same radius — so a pointer at another record sits in the value column
18
+ * exactly like the editors above and below it, rather than announcing itself as
19
+ * a different species of thing. (The chip's usual promise is "editable"; here it
20
+ * reads as "pressable", which is what `InlineSelect` and `InlineDatePicker`
21
+ * already mean by it — they open a popover too.)
22
+ *
23
+ * ONE act on the surface, the rest one layer in:
24
+ * press the value → the facts, in a popover (free, so it's the whole surface)
25
+ * Open, in the peek → the referenced record's own page
26
+ * Remove, in the peek → detach (destructive and rare)
27
+ *
28
+ * Open used to sit ON the field as an `InlineButton`, justified as "the common
29
+ * act". It was neither common nor an act about the value. `actions` is for verbs
30
+ * ABOUT the value, so they travel with what they act on — Call this number, Copy
31
+ * this reference, set this date to Today; "Open" is DEPARTURE to another record,
32
+ * and on a record page leaving is the rare move, not the frequent one. It also
33
+ * put two different destinations behind one word: press the box → a summary,
34
+ * press "Open" 8px away → navigate. And the kit had already ruled — `LedgerRow`
35
+ * IGNORES `reference` while `peek` is set, because a reference belongs inside
36
+ * the peek. Dropping it hands the field its plain anatomy back: the press target
37
+ * is the whole box again and the focus ring rings the field, not a narrower
38
+ * region inside it.
39
+ *
40
+ * Remove earns its depth twice over. It is the answer to the question the PEEK
41
+ * asks — "is this the right one?" — so the check and the correction are the same
42
+ * gesture; and a detach link repeated down a column of references is noise in the
43
+ * scan path for an act most readers never perform. Depth is not a hiding place
44
+ * here: pressing a field-shaped value is the first thing anyone tries, and the
45
+ * verb is plainly visible once open.
46
+ *
47
+ * What it encodes is the PEEK CONTRACT — the marker, the popover's grammar, and
48
+ * where each verb sits — not a layout convenience. It was a template composition
49
+ * until a second surface needed it (a register's workspace drawer lists the same
50
+ * references), which is the bar for lifting one here.
51
+ */
52
+ export interface ReferenceFieldProps {
53
+ name: string;
54
+ code?: string;
55
+ facts: { label: string; value: string }[];
56
+ /** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
57
+ accessibilityLabel: string;
58
+ onOpen: () => void;
59
+ openLabel: string;
60
+ /** Detach — omit where the link cannot be broken (a handoff's sibling record). */
61
+ onRemove?: () => void;
62
+ }
63
+
64
+ export function ReferenceField(props: ReferenceFieldProps) {
65
+ const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
66
+ const anchor = useRef<View>(null);
67
+ const [peekOpen, setPeekOpen] = useState(false);
68
+ return (
69
+ <Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
70
+ {/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
71
+ wearing copies of the field's border, radius, height and hover — and it
72
+ promptly fell out of step the moment the kit's field changed: it kept a
73
+ pointer cursor no editor has, and missed the hover TINT that every other
74
+ field gained. A copied surface always drifts; the fix is to stop copying.
75
+
76
+ `InlineEditView` gives all of it: the surface and its states, hover, and
77
+ focus-within. `anchorRef` hands the popover the FIELD's box, which is
78
+ what the peek must line up with; it happens to equal `ref` now that the
79
+ field carries no verbs, and stays correct if one is ever added.
80
+
81
+ OPEN, never toggle: the press target and the popover's own dismissal
82
+ would otherwise fight, and idempotent `true` makes the pair safe. The
83
+ popover closes on outside press / Escape. */}
84
+ <InlineEditView
85
+ anchorRef={anchor}
86
+ accessibilityLabel={accessibilityLabel}
87
+ onPress={() => setPeekOpen(true)}
88
+ active={peekOpen}
89
+ /* THE MARKER — what says this value is a RECORD and not typed text.
90
+ `TextLink` with neither `href` nor `onPress` is exactly that: the kit's
91
+ underline, non-interactive, so it drops inside the field's own press
92
+ target without a button landing inside a button. NOT a `Badge` (that
93
+ is STATUS only, and a reference is not a state) and NOT `Link`'s fixed
94
+ blue + role="link", which would promise navigation this press does not
95
+ perform — it opens a summary. The underline says "there is more here";
96
+ where to go is the peek's business. */
97
+ display={
98
+ <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6 }}>
99
+ <TextLink size="sm" numberOfLines={1}>{name}</TextLink>
100
+ {code ? <Text size="sm" color="muted" numberOfLines={1}>{code}</Text> : null}
101
+ </View>
102
+ }
103
+ />
104
+ {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
105
+ is dialog-scale, so the identity takes the ramp's dialog rung
106
+ (`DialogSectionHeadingTitle`, ####) with the code as its description
107
+ rather than a hand-picked font weight; the facts are `DetailRow`s, which
108
+ is what label-beside-value IS everywhere else on this page; and the
109
+ destructive verb is fenced off by a `Divider` instead of floating after
110
+ the last fact. Width matches `Peek`'s own content width so every peek in
111
+ an app is the same object.
112
+ `labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
113
+ STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
114
+ page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
115
+ the summary side-by-side, which is the whole point of a glance. */}
116
+ <PopoverContent style={{ width: 320 }} disableBodyScroll>
117
+ <View style={{ gap: 12 }}>
118
+ <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
119
+ {/* PLAIN `Text`, not `InlineStatic` — and `minHeight` 28, `DetailRow`'s
120
+ own default, instead of the `DetailTable`'s 40.
121
+
122
+ Both come from one fact: this grid has NO editors. `InlineStatic`
123
+ exists to align pixel-for-pixel with the `Inline*` controls — it
124
+ hard-sets `INLINE_CONTROL_HEIGHT` so a computed total sits flush
125
+ beside editable rows in a record. Here there is nothing to sit flush
126
+ WITH, so it only reserved a 40px control band for a 20px value:
127
+ three facts, 60px of air, in a popover whose whole job is a glance.
128
+ `minHeight` alone does not fix it (the value's own box still forces
129
+ 40) and `Text` alone does not either (the table's band re-imposes
130
+ it) — the row is the max of the two, so both have to go. */}
131
+ <DetailTable labelWidth={88} minHeight={28}>
132
+ {facts.map((f) => (
133
+ <DetailRow key={f.label} label={f.label} flat>
134
+ <Text size="sm">{f.value || "—"}</Text>
135
+ </DetailRow>
136
+ ))}
137
+ </DetailTable>
138
+ {/* THE FOOTER — destructive LEFT, go-to RIGHT, the kit's convention.
139
+ Open repeats the verb on the surface deliberately: the popover now
140
+ covers the button that opened it, and having read the facts, "take me
141
+ there" is the next move. That is the shape `Peek` prescribes — a
142
+ summary with one action to the full record. Remove closes the peek
143
+ first, because the surface it is anchored to is about to stop
144
+ existing. */}
145
+ <Divider />
146
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
147
+ {onRemove ? (
148
+ // `TextButton color="danger"` — this act wanted the component, not a
149
+ // Button wearing three corrections. It went `danger` (solid, too
150
+ // loud for the rarest verb here) → `danger-secondary` (a ghost, so
151
+ // its ink sat a padding's width inside the column every fact label
152
+ // starts on) → `+ flush` (that geometry, patched). A text-weight
153
+ // destructive act aligned to a column of text IS this component: red
154
+ // ink, no fill, its own bleed, and a hover wash and focus ring a bare
155
+ // pressable Text never had.
156
+ //
157
+ // Pairing a text action with the filled `secondary` "Open" is the
158
+ // standard footer shape, not a mismatch — Material and HIG both put
159
+ // the quiet/destructive verb at text weight beside a filled primary.
160
+ // No icon: an ✕ beside "Remove" says it twice, and the glyph alone
161
+ // made this read as the popover's dismiss rather than a decision.
162
+ <TextButton color="danger" onPress={() => { setPeekOpen(false); onRemove(); }}>Remove</TextButton>
163
+ ) : null}
164
+ <View style={{ flex: 1 }} />
165
+ {/* `openLabel` names the DESTINATION ("Open customer"): a page carries
166
+ four of these peeks, and four buttons announcing a bare "Open"
167
+ are four controls a screen reader cannot tell apart. */}
168
+ <Button title="Open" color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
169
+ </View>
170
+ </View>
171
+ </PopoverContent>
172
+ </Popover>
173
+ );
174
+ }
@@ -133,7 +133,9 @@ export function StatusLegend(props: StatusLegendProps) {
133
133
  <PressableHighlight
134
134
  focusRing key={state.key}
135
135
  accessibilityRole="button"
136
- accessibilityState={{ selected }}
136
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
137
+ // build, and `selected` has no meaning on a `button` role regardless.
138
+ aria-pressed={selected}
137
139
  accessibilityLabel={`${state.label}: ${count}`}
138
140
  onPress={() => onSelect(selected ? null : state.key)}
139
141
  style={[styles.legendItem, styles.legendPressable, selected ? styles.legendSelected : null]}