@lotics/ui 23.2.0 → 24.0.2

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.2.0",
3
+ "version": "24.0.2",
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",
@@ -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/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
@@ -36,6 +37,11 @@ export interface LoticsLocale {
36
37
  pagination: Required<PaginationLabels>;
37
38
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
38
39
  sortHeader: Required<SortHeaderLabels>;
40
+ /** `ReferenceField`'s peek footer — the go-to and detach verbs. `openLabel`
41
+ * stays a per-instance PROP because it names the DESTINATION ("Open
42
+ * customer"), which is a11y text only a caller knows; these two are the
43
+ * VISIBLE chrome, which the pack owns. */
44
+ referenceField: { open: string; remove: string };
39
45
  /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
40
46
  * in-cell editors): the select-all/deselect-all links, the empty state, the
41
47
  * internal search-field placeholder, and the `Combobox` recents header. */
@@ -69,6 +75,9 @@ export interface LoticsLocale {
69
75
  sequence: { moveUp: string; moveDown: string; remove: string };
70
76
  /** `TrendFooter`: the direction words of the trend sentence. */
71
77
  trendFooter: { up: string; down: string };
78
+ /** The countdown vocabulary — see `deadline.ts`. 0 and 1 get their own words
79
+ * because "0 days left" reads as broken exactly when it matters most. */
80
+ deadline: DeadlineLabels;
72
81
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
73
82
  suggestionChip: { add: (label: string) => string; dismiss: string };
74
83
  /** `Confidence`: the full level phrase ("High confidence" …). */
@@ -211,6 +220,7 @@ export const en: LoticsLocale = {
211
220
  ascending: ", ascending",
212
221
  descending: ", descending",
213
222
  },
223
+ referenceField: { open: "Open", remove: "Remove" },
214
224
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
215
225
  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" },
216
226
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
@@ -226,6 +236,12 @@ export const en: LoticsLocale = {
226
236
  chip: { remove: "Remove" },
227
237
  sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
228
238
  trendFooter: { up: "Up", down: "Down" },
239
+ deadline: {
240
+ overdue: (days) => `${days} ${days === 1 ? "day" : "days"} overdue`,
241
+ today: "Today",
242
+ tomorrow: "Tomorrow",
243
+ inDays: (days) => `${days} days left`,
244
+ },
229
245
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
230
246
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
231
247
  remainderMeter: {
@@ -363,6 +379,7 @@ export const vi: LoticsLocale = {
363
379
  ascending: " (tăng dần)",
364
380
  descending: " (giảm dần)",
365
381
  },
382
+ referenceField: { open: "Mở", remove: "Gỡ" },
366
383
  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…" },
367
384
  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 đủ" },
368
385
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
@@ -378,6 +395,12 @@ export const vi: LoticsLocale = {
378
395
  chip: { remove: "Xóa" },
379
396
  sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
380
397
  trendFooter: { up: "Tăng", down: "Giảm" },
398
+ deadline: {
399
+ overdue: (days) => `Quá hạn ${days} ngày`,
400
+ today: "Hôm nay",
401
+ tomorrow: "Ngày mai",
402
+ inDays: (days) => `Còn ${days} ngày`,
403
+ },
381
404
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
382
405
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
383
406
  remainderMeter: {
@@ -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}
@@ -0,0 +1,178 @@
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
+ import { useLoticsLocale } from "./locale";
13
+
14
+ /**
15
+ * A REFERENCE to another record, rendered as a FIELD VALUE.
16
+ *
17
+ * It wears the inline editor's resting surface — same 40px band, same zinc-50
18
+ * chip, same radius — so a pointer at another record sits in the value column
19
+ * exactly like the editors above and below it, rather than announcing itself as
20
+ * a different species of thing. (The chip's usual promise is "editable"; here it
21
+ * reads as "pressable", which is what `InlineSelect` and `InlineDatePicker`
22
+ * already mean by it — they open a popover too.)
23
+ *
24
+ * ONE act on the surface, the rest one layer in:
25
+ * press the value → the facts, in a popover (free, so it's the whole surface)
26
+ * Open, in the peek → the referenced record's own page
27
+ * Remove, in the peek → detach (destructive and rare)
28
+ *
29
+ * Open used to sit ON the field as an `InlineButton`, justified as "the common
30
+ * act". It was neither common nor an act about the value. `actions` is for verbs
31
+ * ABOUT the value, so they travel with what they act on — Call this number, Copy
32
+ * this reference, set this date to Today; "Open" is DEPARTURE to another record,
33
+ * and on a record page leaving is the rare move, not the frequent one. It also
34
+ * put two different destinations behind one word: press the box → a summary,
35
+ * press "Open" 8px away → navigate. And the kit had already ruled — `LedgerRow`
36
+ * IGNORES `reference` while `peek` is set, because a reference belongs inside
37
+ * the peek. Dropping it hands the field its plain anatomy back: the press target
38
+ * is the whole box again and the focus ring rings the field, not a narrower
39
+ * region inside it.
40
+ *
41
+ * Remove earns its depth twice over. It is the answer to the question the PEEK
42
+ * asks — "is this the right one?" — so the check and the correction are the same
43
+ * gesture; and a detach link repeated down a column of references is noise in the
44
+ * scan path for an act most readers never perform. Depth is not a hiding place
45
+ * here: pressing a field-shaped value is the first thing anyone tries, and the
46
+ * verb is plainly visible once open.
47
+ *
48
+ * What it encodes is the PEEK CONTRACT — the marker, the popover's grammar, and
49
+ * where each verb sits — not a layout convenience. It was a template composition
50
+ * until a second surface needed it (a register's workspace drawer lists the same
51
+ * references), which is the bar for lifting one here.
52
+ */
53
+ export interface ReferenceFieldProps {
54
+ name: string;
55
+ code?: string;
56
+ facts: { label: string; value: string }[];
57
+ /** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
58
+ accessibilityLabel: string;
59
+ onOpen: () => void;
60
+ openLabel: string;
61
+ /** Detach — omit where the link cannot be broken (a handoff's sibling record). */
62
+ onRemove?: () => void;
63
+ }
64
+
65
+ export function ReferenceField(props: ReferenceFieldProps) {
66
+ const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
67
+ // The peek's two verbs are the component's OWN chrome, so they come from the
68
+ // pack — hardcoding them shipped "Open"/"Remove" into every localized app.
69
+ const t = useLoticsLocale().referenceField;
70
+ const anchor = useRef<View>(null);
71
+ const [peekOpen, setPeekOpen] = useState(false);
72
+ return (
73
+ <Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
74
+ {/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
75
+ wearing copies of the field's border, radius, height and hover — and it
76
+ promptly fell out of step the moment the kit's field changed: it kept a
77
+ pointer cursor no editor has, and missed the hover TINT that every other
78
+ field gained. A copied surface always drifts; the fix is to stop copying.
79
+
80
+ `InlineEditView` gives all of it: the surface and its states, hover, and
81
+ focus-within. `anchorRef` hands the popover the FIELD's box, which is
82
+ what the peek must line up with; it happens to equal `ref` now that the
83
+ field carries no verbs, and stays correct if one is ever added.
84
+
85
+ OPEN, never toggle: the press target and the popover's own dismissal
86
+ would otherwise fight, and idempotent `true` makes the pair safe. The
87
+ popover closes on outside press / Escape. */}
88
+ <InlineEditView
89
+ anchorRef={anchor}
90
+ accessibilityLabel={accessibilityLabel}
91
+ onPress={() => setPeekOpen(true)}
92
+ active={peekOpen}
93
+ /* THE MARKER — what says this value is a RECORD and not typed text.
94
+ `TextLink` with neither `href` nor `onPress` is exactly that: the kit's
95
+ underline, non-interactive, so it drops inside the field's own press
96
+ target without a button landing inside a button. NOT a `Badge` (that
97
+ is STATUS only, and a reference is not a state) and NOT `Link`'s fixed
98
+ blue + role="link", which would promise navigation this press does not
99
+ perform — it opens a summary. The underline says "there is more here";
100
+ where to go is the peek's business. */
101
+ display={
102
+ <View style={{ flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 6 }}>
103
+ <TextLink size="sm" numberOfLines={1}>{name}</TextLink>
104
+ {code ? <Text size="sm" color="muted" numberOfLines={1}>{code}</Text> : null}
105
+ </View>
106
+ }
107
+ />
108
+ {/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. A popover
109
+ is dialog-scale, so the identity takes the ramp's dialog rung
110
+ (`DialogSectionHeadingTitle`, ####) with the code as its description
111
+ rather than a hand-picked font weight; the facts are `DetailRow`s, which
112
+ is what label-beside-value IS everywhere else on this page; and the
113
+ destructive verb is fenced off by a `Divider` instead of floating after
114
+ the last fact. Width matches `Peek`'s own content width so every peek in
115
+ an app is the same object.
116
+ `labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
117
+ STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
118
+ page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
119
+ the summary side-by-side, which is the whole point of a glance. */}
120
+ <PopoverContent style={{ width: 320 }} disableBodyScroll>
121
+ <View style={{ gap: 12 }}>
122
+ <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
123
+ {/* PLAIN `Text`, not `InlineStatic` — and `minHeight` 28, `DetailRow`'s
124
+ own default, instead of the `DetailTable`'s 40.
125
+
126
+ Both come from one fact: this grid has NO editors. `InlineStatic`
127
+ exists to align pixel-for-pixel with the `Inline*` controls — it
128
+ hard-sets `INLINE_CONTROL_HEIGHT` so a computed total sits flush
129
+ beside editable rows in a record. Here there is nothing to sit flush
130
+ WITH, so it only reserved a 40px control band for a 20px value:
131
+ three facts, 60px of air, in a popover whose whole job is a glance.
132
+ `minHeight` alone does not fix it (the value's own box still forces
133
+ 40) and `Text` alone does not either (the table's band re-imposes
134
+ it) — the row is the max of the two, so both have to go. */}
135
+ <DetailTable labelWidth={88} minHeight={28}>
136
+ {facts.map((f) => (
137
+ <DetailRow key={f.label} label={f.label} flat>
138
+ <Text size="sm">{f.value || "—"}</Text>
139
+ </DetailRow>
140
+ ))}
141
+ </DetailTable>
142
+ {/* THE FOOTER — destructive LEFT, go-to RIGHT, the kit's convention.
143
+ Open repeats the verb on the surface deliberately: the popover now
144
+ covers the button that opened it, and having read the facts, "take me
145
+ there" is the next move. That is the shape `Peek` prescribes — a
146
+ summary with one action to the full record. Remove closes the peek
147
+ first, because the surface it is anchored to is about to stop
148
+ existing. */}
149
+ <Divider />
150
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
151
+ {onRemove ? (
152
+ // `TextButton color="danger"` — this act wanted the component, not a
153
+ // Button wearing three corrections. It went `danger` (solid, too
154
+ // loud for the rarest verb here) → `danger-secondary` (a ghost, so
155
+ // its ink sat a padding's width inside the column every fact label
156
+ // starts on) → `+ flush` (that geometry, patched). A text-weight
157
+ // destructive act aligned to a column of text IS this component: red
158
+ // ink, no fill, its own bleed, and a hover wash and focus ring a bare
159
+ // pressable Text never had.
160
+ //
161
+ // Pairing a text action with the filled `secondary` "Open" is the
162
+ // standard footer shape, not a mismatch — Material and HIG both put
163
+ // the quiet/destructive verb at text weight beside a filled primary.
164
+ // No icon: an ✕ beside "Remove" says it twice, and the glyph alone
165
+ // made this read as the popover's dismiss rather than a decision.
166
+ <TextButton color="danger" onPress={() => { setPeekOpen(false); onRemove(); }}>{t.remove}</TextButton>
167
+ ) : null}
168
+ <View style={{ flex: 1 }} />
169
+ {/* `openLabel` names the DESTINATION ("Open customer"): a page carries
170
+ four of these peeks, and four buttons announcing a bare "Open"
171
+ are four controls a screen reader cannot tell apart. */}
172
+ <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
173
+ </View>
174
+ </View>
175
+ </PopoverContent>
176
+ </Popover>
177
+ );
178
+ }
package/src/table.tsx CHANGED
@@ -339,9 +339,18 @@ const styles = StyleSheet.create({
339
339
  // The expanded detail. Indented to the identity column's text edge so it reads
340
340
  // as belonging to the row above rather than as a new row, and given the row's
341
341
  // own gutter so its content lines up with the cells it explains.
342
+ //
343
+ // Vertical padding is measured from the row's WASH, not its text: an expanded
344
+ // row wears the `selected` highlight, so the band's edge is the hard line the
345
+ // eye reads. With no top padding the detail's first control sat 3px off that
346
+ // edge while the bottom had a designed 16 — visibly cramped for an editor
347
+ // detail. Matching 16 both ends frames the detail evenly inside the band, and
348
+ // equals a `DetailTable`'s own row gap so a field grid keeps one rhythm from
349
+ // the row above it to the hairline below.
342
350
  detail: {
343
351
  paddingLeft: ROW_GUTTER,
344
352
  paddingRight: ROW_GUTTER,
353
+ paddingTop: 16,
345
354
  paddingBottom: 16,
346
355
  },
347
356
  // A hairline under the column header anchors the columns; the rows below it are
@@ -0,0 +1,139 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet } from "react-native";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+ import { Text } from "./text";
5
+
6
+ /** Two only. Deliberately NOT the whole `TextColor` palette: `muted` would mute the
7
+ * one thing carrying the act's weight, and the rest (warning/success/inverted) name
8
+ * states, not verbs. Reach for `Button color="muted"` when an act must be quieter. */
9
+ export type TextButtonColor = "default" | "danger";
10
+
11
+ export interface TextButtonProps {
12
+ /** The verb, as words. */
13
+ children: ReactNode;
14
+ /** The act. REQUIRED — a text button with nothing to do is just `Text`. */
15
+ onPress: () => void;
16
+ /** `default` = neutral ink; `danger` = destructive. */
17
+ color?: TextButtonColor;
18
+ /**
19
+ * Nothing to act on. Pass it UNCONDITIONALLY and disable it rather than
20
+ * rendering the verb only once its target exists — an action that appears and
21
+ * disappears reflows the line it sits in, which is the one thing a control in
22
+ * a row of text must never do.
23
+ */
24
+ disabled?: boolean;
25
+ /** Announced name, when the words alone are ambiguous ("Open" → "Open customer"). */
26
+ accessibilityLabel?: string;
27
+ /** Truncate at this many lines (a label in a narrow row). */
28
+ numberOfLines?: number;
29
+ tooltip?: string;
30
+ }
31
+
32
+ /**
33
+ * A low-chrome ACTION rendered at text weight — "Select all", "Clear",
34
+ * "Add stop". The third member of the action family, by CHROME:
35
+ * `Button` (a 40px control with a surface) → `InlineButton` (28px, filled, living
36
+ * on a field's own surface) → this (no surface AT REST, sitting in a line of text).
37
+ *
38
+ * UNDERLINED, in NEUTRAL ink — what Airbnb ships for a text action, and the shape
39
+ * that keeps this kit's own colour rule intact:
40
+ *
41
+ * underline = INTERACTIVE blue = NAVIGATION
42
+ * `Link`/`TextLink` blue + underline → navigates
43
+ * `TextButton` zinc + underline → acts
44
+ *
45
+ * The underline is the affordance and it is not optional. With no surface and no
46
+ * tint, nothing else says at rest that the words can be pressed: POSITION is a
47
+ * learned convention rather than an affordance (invisible to a first-time reader),
48
+ * and hover is not one either — absent on touch, and revealed only once you are
49
+ * already there. A tint is the other way to say it, the one Material and HIG take,
50
+ * but blue is spoken for here and a second accent for "actionable" would collide
51
+ * with the one-accent-per-purpose rule.
52
+ *
53
+ * This is also why it holds INSIDE a run of prose, where colour alone could not
54
+ * distinguish an embedded control at all (WCAG 1.4.1 / F73).
55
+ *
56
+ * Built on `PressableHighlight` rather than a pressable `Text`, because a `Text`
57
+ * with `onPress` gets NEITHER hover nor a focus ring: it renders a real
58
+ * `<button>` with `tabIndex=0`, `outline: none` and no box-shadow, so a keyboard
59
+ * user lands on it with no indication they have. A focusable control with no focus
60
+ * treatment is a bug, and hand-rolling hover + focus onto `Text` would duplicate
61
+ * machinery this already owns. The inherited hover wash is Material's state layer
62
+ * by another name, and it costs nothing at rest, so the chrome ladder above holds.
63
+ *
64
+ * The padding/negative-margin bleed is `Peek`'s: the wash and the focus ring need
65
+ * room off the glyphs, and the margins give it back so the line's layout never
66
+ * shifts — `OptionList` depends on that, insetting 8px so its select-all verbs
67
+ * line up under the option labels above.
68
+ *
69
+ * ONE SIZE (`Text`'s own `sm`) and no icon: it sits in a line of text and matches
70
+ * it. A verb needing an icon or a real hit target is a `Button`.
71
+ *
72
+ * It is TEXT-height, not control-height, and that is deliberate: `marginVertical`
73
+ * absorbs the touch box so the height IN FLOW stays 24 and dropping one into a
74
+ * dense band (`OptionList`'s select-all, `FilterChip`'s Clear) cannot grow that
75
+ * band. Do NOT match `Button`'s 40 to make a mixed row line up — that inflates
76
+ * every inline use and collapses the chrome ladder above, turning this into a
77
+ * Button without a fill. A row that mixes rungs aligns them the way such a row
78
+ * should anyway: `alignItems: "center"`, which lands a centred 32px box's text on
79
+ * the same line as a centred 40px one (measured: 0px apart in a peek footer).
80
+ *
81
+ * It sets NO `alignSelf`, so it obeys its parent — which is what a row wants, and
82
+ * a baked `flex-start` top-aligned it against a taller sibling and silently
83
+ * overrode a footer's `alignItems: center`. In a COLUMN container a `Pressable`
84
+ * stretches, so the hover wash would run the full width for a two-word verb: wrap
85
+ * it in a `flexDirection: "row"` View there, the same thing a `Button` needs.
86
+ */
87
+ export function TextButton(props: TextButtonProps) {
88
+ const { children, onPress, color = "default", disabled, accessibilityLabel, numberOfLines, tooltip } = props;
89
+ // `TextColor` tokens throughout, no raw palette access: neutral ink IS `default`,
90
+ // and `zinc-400` is `Button`'s own disabled ink — an inert control should read the
91
+ // same whatever its chrome, where muting to zinc-600 left it looking merely quiet.
92
+ const ink = disabled === true ? "zinc-400" : color === "danger" ? "danger" : "default";
93
+ return (
94
+ <PressableHighlight
95
+ focusRing
96
+ accessibilityRole="button"
97
+ accessibilityLabel={accessibilityLabel}
98
+ aria-disabled={disabled === true ? true : undefined}
99
+ disabled={disabled}
100
+ tooltip={tooltip}
101
+ // 32px box + 4px slop = the 40px target `Peek` keeps for the same shape. A
102
+ // text action measured 24px on its own, which clears WCAG 2.5.8's 24×24
103
+ // floor and nothing else — and it sits in dense rows, exactly where a thumb
104
+ // needs the margin most.
105
+ hitSlop={4}
106
+ onPress={onPress}
107
+ // After the inherited wash, so a disabled verb does not light up under the
108
+ // pointer — `hovered` still fires on a disabled Pressable.
109
+ style={[styles.trigger, disabled === true ? styles.dead : null]}
110
+ >
111
+ <Text decoration="underline" weight="medium" color={ink} numberOfLines={numberOfLines}>
112
+ {children}
113
+ </Text>
114
+ </PressableHighlight>
115
+ );
116
+ }
117
+
118
+ const styles = StyleSheet.create({
119
+ trigger: {
120
+ flexDirection: "row",
121
+ alignItems: "center",
122
+ // NO `alignSelf` here — see the prop. A baked `flex-start` is a CROSS-axis
123
+ // instruction, so it means "hug the words" only in a column; in a row it means
124
+ // top-align, and it silently overrode a footer's `alignItems: center`.
125
+ borderRadius: 6,
126
+ // Room for the wash + focus ring, given straight back as margin so adding a
127
+ // TextButton to a line cannot move anything around it (`Peek`'s idiom). The
128
+ // 32px box carries the touch target; `marginVertical` absorbs 8 of it, so the
129
+ // height IN FLOW stays 24 and a row's rhythm is unchanged.
130
+ minHeight: 32,
131
+ marginVertical: -4,
132
+ // 6 rather than `Peek`'s 8: these come in GROUPS (OptionList's select-all /
133
+ // deselect-all sit 16 apart), and an 8px bleed each side would leave adjacent
134
+ // washes touching.
135
+ paddingHorizontal: 6,
136
+ marginHorizontal: -6,
137
+ },
138
+ dead: { backgroundColor: "transparent" },
139
+ });