@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.
- package/AGENTS.md +8 -1
- package/MIGRATION.md +40 -3
- package/docs/catalog.md +62 -13
- package/docs/composition.md +63 -3
- package/docs/data_entry.md +19 -1
- package/docs/templates.md +24 -28
- package/examples/tpl_item_list.tsx +19 -768
- package/examples/tpl_record.tsx +515 -415
- package/examples/tpl_task_board.tsx +2 -2
- package/package.json +5 -2
- package/src/deadline.ts +157 -0
- package/src/filter_chip.tsx +2 -2
- package/src/locale.tsx +23 -0
- package/src/option_list.tsx +3 -3
- package/src/reference_field.tsx +178 -0
- package/src/table.tsx +9 -0
- package/src/text_button.tsx +139 -0
- package/src/text_link.tsx +24 -14
- package/src/use_section_nav.ts +31 -1
|
@@ -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 {
|
|
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
|
-
<
|
|
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": "
|
|
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.
|
|
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/deadline.ts
ADDED
|
@@ -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
|
+
}
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
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: {
|
package/src/option_list.tsx
CHANGED
|
@@ -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 {
|
|
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 ? <
|
|
179
|
+
{list.showSelectAll ? <TextButton onPress={list.selectAll}>{selectAllLabel}</TextButton> : null}
|
|
180
180
|
{list.showDeselectAll ? (
|
|
181
|
-
<
|
|
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
|
+
});
|