@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.
- package/AGENTS.md +13 -1
- package/MIGRATION.md +37 -0
- package/docs/catalog.md +69 -14
- 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/breakdown.tsx +3 -1
- package/src/choice_list.tsx +3 -1
- package/src/date_calendar.tsx +3 -1
- package/src/deadline.ts +157 -0
- package/src/filter_chip.tsx +2 -2
- package/src/funnel.tsx +3 -1
- package/src/heatmap.tsx +3 -1
- package/src/locale.tsx +16 -0
- package/src/matrix.tsx +3 -1
- package/src/option_list.tsx +3 -3
- package/src/press_door.tsx +12 -1
- package/src/reference_field.tsx +174 -0
- package/src/status_grid.tsx +3 -1
- package/src/table.tsx +56 -4
- package/src/text_button.tsx +139 -0
- package/src/text_link.tsx +24 -14
|
@@ -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.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.
|
|
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
|
|
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]}
|
package/src/choice_list.tsx
CHANGED
|
@@ -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
|
|
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 }) => [
|
package/src/date_calendar.tsx
CHANGED
|
@@ -31,7 +31,9 @@ function DayCell(props: {
|
|
|
31
31
|
<Pressable
|
|
32
32
|
accessibilityRole="button"
|
|
33
33
|
accessibilityLabel={props.label}
|
|
34
|
-
accessibilityState
|
|
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,
|
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/funnel.tsx
CHANGED
|
@@ -91,7 +91,9 @@ export function Funnel(props: FunnelProps) {
|
|
|
91
91
|
return (
|
|
92
92
|
<PressableHighlight
|
|
93
93
|
accessibilityRole="button"
|
|
94
|
-
accessibilityState
|
|
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
|
|
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
|
|
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) => [
|
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}
|
package/src/press_door.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/status_grid.tsx
CHANGED
|
@@ -133,7 +133,9 @@ export function StatusLegend(props: StatusLegendProps) {
|
|
|
133
133
|
<PressableHighlight
|
|
134
134
|
focusRing key={state.key}
|
|
135
135
|
accessibilityRole="button"
|
|
136
|
-
accessibilityState
|
|
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]}
|