@lotics/ui 22.3.0 → 23.1.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 +9 -2
- package/MIGRATION.md +142 -2
- package/docs/ai_patterns.md +22 -22
- package/docs/catalog.md +131 -99
- package/docs/composition.md +134 -42
- package/docs/data_entry.md +67 -31
- package/docs/templates.md +86 -68
- package/examples/tpl_allocate.tsx +5 -5
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +6 -6
- package/examples/tpl_dashboard.tsx +9 -9
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_item_list.tsx +64 -37
- package/examples/tpl_lookup.tsx +5 -5
- package/examples/tpl_pick.tsx +6 -6
- package/examples/tpl_pivot.tsx +3 -3
- package/examples/tpl_record.tsx +919 -611
- package/examples/tpl_report.tsx +2 -2
- package/examples/tpl_rollup.tsx +5 -5
- package/examples/tpl_shifts.tsx +5 -5
- package/examples/tpl_statements.tsx +7 -7
- package/examples/tpl_stock.tsx +2 -2
- package/examples/tpl_task_board.tsx +42 -26
- package/examples/tpl_tower.tsx +6 -6
- package/package.json +4 -3
- package/src/agent_run.tsx +4 -4
- package/src/breakdown.tsx +1 -1
- package/src/calendar/calendar_view.tsx +1 -1
- package/src/change_review.tsx +9 -8
- package/src/chip_group.tsx +12 -2
- package/src/choice_list.tsx +2 -2
- package/src/confidence.tsx +2 -2
- package/src/data_grid.tsx +1 -1
- package/src/detail_row.tsx +50 -58
- package/src/file_dropzone.tsx +1 -1
- package/src/file_gallery_modal.tsx +3 -3
- package/src/file_row.tsx +29 -4
- package/src/file_rows.tsx +1 -1
- package/src/finding.tsx +4 -4
- package/src/form_field.tsx +1 -1
- package/src/format_date.ts +2 -2
- package/src/heatmap.tsx +1 -1
- package/src/inline_button.tsx +84 -0
- package/src/inline_date_picker.tsx +17 -10
- package/src/inline_edit.tsx +341 -60
- package/src/inline_member_select.tsx +8 -3
- package/src/inline_number_input.tsx +11 -4
- package/src/inline_select.tsx +26 -13
- package/src/inline_text_input.tsx +27 -6
- package/src/inline_time_picker.tsx +10 -4
- package/src/input_metrics.ts +17 -0
- package/src/ledger.tsx +2 -2
- package/src/locale.tsx +7 -3
- package/src/matrix.tsx +1 -1
- package/src/number_input.tsx +18 -7
- package/src/pipeline.tsx +1 -1
- package/src/popover.tsx +1 -1
- package/src/press_door.tsx +1 -1
- package/src/pressable_highlight.tsx +1 -1
- package/src/progress_bar.tsx +3 -3
- package/src/record_summary.tsx +2 -2
- package/src/result_header.tsx +2 -2
- package/src/sequence.tsx +170 -0
- package/src/share_or_download.ts +2 -2
- package/src/step_progress.tsx +7 -5
- package/src/stepper.tsx +1 -1
- package/src/task.tsx +6 -6
- package/src/text_input_field.tsx +21 -4
- package/src/text_utils.ts +7 -1
- package/src/linked_record_box.tsx +0 -157
package/src/share_or_download.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type ShareOrDownloadResult =
|
|
|
30
30
|
| { delivered: "share"; count: number }
|
|
31
31
|
| { delivered: "download"; count: number };
|
|
32
32
|
|
|
33
|
-
/** "shared" = the sheet completed
|
|
33
|
+
/** "shared" = the sheet completed, "cancelled" = the user dismissed it,
|
|
34
34
|
* "unsupported" = Web Share (files) unavailable. A genuine share failure THROWS. */
|
|
35
35
|
export type ShareFilesResult = "shared" | "cancelled" | "unsupported";
|
|
36
36
|
|
|
@@ -65,7 +65,7 @@ export async function prepareShareFiles(
|
|
|
65
65
|
/**
|
|
66
66
|
* Share already-fetched File objects via the OS sheet. Call SYNCHRONOUSLY from a
|
|
67
67
|
* user gesture (no awaited fetch before it) so iOS keeps the transient activation.
|
|
68
|
-
* "cancelled" = user dismissed
|
|
68
|
+
* "cancelled" = user dismissed, "unsupported" = no Web Share; a real failure THROWS.
|
|
69
69
|
*/
|
|
70
70
|
export async function shareFiles(fileObjs: File[], opts?: { title?: string; text?: string }): Promise<ShareFilesResult> {
|
|
71
71
|
const nav = typeof navigator !== "undefined" ? navigator : undefined;
|
package/src/step_progress.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { Text } from "./text";
|
|
|
4
4
|
import { useTooltip } from "./tooltip";
|
|
5
5
|
|
|
6
6
|
export interface StepProgressProps {
|
|
7
|
-
/** The stages: pass the NAMES (enables the built-in "4/7
|
|
7
|
+
/** The stages: pass the NAMES (enables the built-in "In (4/7)" caption
|
|
8
8
|
* and per-segment hover names) or a bare count (bar only). */
|
|
9
9
|
steps: number | string[];
|
|
10
10
|
/** 0-based index of the stage in progress; earlier segments render
|
|
@@ -17,7 +17,7 @@ export interface StepProgressProps {
|
|
|
17
17
|
* uppercase eyebrow row above the bar, caption right. Omit at card
|
|
18
18
|
* density where the context already names it. */
|
|
19
19
|
title?: string;
|
|
20
|
-
/** Caption override for when the current stage needs prose ("In production") instead of the derived "3/6
|
|
20
|
+
/** Caption override for when the current stage needs prose ("In production") instead of the derived "SX (3/6)". */
|
|
21
21
|
label?: string;
|
|
22
22
|
/** Tone for the caption — `danger` flags a stalled/overdue stage (a stuck
|
|
23
23
|
* production order) in the FIXED caption spot, so the bar stays consistent
|
|
@@ -35,7 +35,7 @@ export interface StepProgressProps {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* THE compact stage indicator — N equal segments filled through the current
|
|
38
|
-
* stage, with a built-in "4/7
|
|
38
|
+
* stage, with a built-in "In (4/7)" caption when stages are named (hovering
|
|
39
39
|
* a segment names it). For *countable* stages an item walks through (a
|
|
40
40
|
* production line, a checklist, a pipeline) shown at card/list density.
|
|
41
41
|
*
|
|
@@ -55,8 +55,10 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
55
55
|
label ??
|
|
56
56
|
(names
|
|
57
57
|
? isComplete
|
|
58
|
-
?
|
|
59
|
-
:
|
|
58
|
+
? `Complete (${count}/${count})`
|
|
59
|
+
: safe >= 0
|
|
60
|
+
? `${names[safe]} (${safe + 1}/${count})`
|
|
61
|
+
: `0/${count}`
|
|
60
62
|
: undefined);
|
|
61
63
|
|
|
62
64
|
// `progressbar` IS right here — the segments are decoration over one quantity,
|
package/src/stepper.tsx
CHANGED
|
@@ -68,7 +68,7 @@ export interface StepProps extends StepPositional {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Progress through an ordered sequence — done
|
|
71
|
+
* Progress through an ordered sequence — done, current, upcoming on a
|
|
72
72
|
* connecting track (horizontal) or spine (vertical). The node encodes STATUS,
|
|
73
73
|
* not identity: a filled dot once reached, a ring with a white centre at the
|
|
74
74
|
* current step (pulsing when `live`), a faint ring for what's ahead, a check for
|
package/src/task.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
|
|
|
14
14
|
* <TaskList>
|
|
15
15
|
* <TaskItem>
|
|
16
16
|
* <TaskStatus><CheckCircle …/></TaskStatus>
|
|
17
|
-
* <TaskTitle><InlineTextInput variant="
|
|
17
|
+
* <TaskTitle><InlineTextInput variant="bare" struck={done} …/></TaskTitle>
|
|
18
18
|
* <TaskActions><ActionMenu …/></TaskActions>
|
|
19
19
|
* <TaskCaption>Waiting on the signed copy</TaskCaption>
|
|
20
20
|
* <TaskSubRow label="Portal account"><InlineTextInput …/></TaskSubRow>
|
|
@@ -81,7 +81,7 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
|
|
|
81
81
|
* **A value and a sentence are different things.** A `TaskSubRow` holds a NAMED value the reader
|
|
82
82
|
* sets; `TaskCaption` holds a sentence ABOUT the row ("Missing 2 of 6") on its own line under the
|
|
83
83
|
* title. Prose given a label reads as a field nobody can edit. Which is why a sub-row annotates
|
|
84
|
-
* like a record's field does — `description
|
|
84
|
+
* like a record's field does — `description`, `warning`, `error`, the one anatomy in
|
|
85
85
|
* `field_annotations` — so a fault in ONE field says so on that field instead of being demoted
|
|
86
86
|
* into a sentence about the whole row.
|
|
87
87
|
*
|
|
@@ -131,11 +131,11 @@ const TaskListContext = createContext<TaskListContextValue>({
|
|
|
131
131
|
*/
|
|
132
132
|
const SUB_VALUE_MIN = 200;
|
|
133
133
|
/**
|
|
134
|
-
* How far
|
|
134
|
+
* How far an inline control insets its own text — the kit's `CONTROL_TEXT_INSET` under
|
|
135
135
|
* this family's name, because a task surface reaches for it constantly. Everything that hangs
|
|
136
136
|
* beneath a row — the caption, a sub-row's label, the detail block — adds the same, so it lines
|
|
137
137
|
* up with the WORDS rather than with the editor's invisible box. A STRING title gets it from
|
|
138
|
-
* `TaskTitle`; only a custom title NODE that is not
|
|
138
|
+
* `TaskTitle`; only a custom title NODE that is not an inline control has to add it, or
|
|
139
139
|
* it sits out of line with the editable titles around it.
|
|
140
140
|
*/
|
|
141
141
|
export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
|
|
@@ -282,7 +282,7 @@ type TaskTitleProps =
|
|
|
282
282
|
| { children: ReactNode; struck?: never };
|
|
283
283
|
|
|
284
284
|
/**
|
|
285
|
-
* The task's identity — an inline editor
|
|
285
|
+
* The task's identity — an inline editor, or the title text itself.
|
|
286
286
|
*
|
|
287
287
|
* Pass a STRING and the title renders on the cell inset with the band's slack above and below
|
|
288
288
|
* it, so it lines up with the editable titles around it AND a title that wraps keeps its first
|
|
@@ -379,7 +379,7 @@ interface TaskSubRowProps extends FieldAnnotationProps {
|
|
|
379
379
|
* own line under the label rather than compressing to a few characters, and on a wide one it
|
|
380
380
|
* FILLS the content box, ending on the same x as the title above it and the ⋯ gutter beside it.
|
|
381
381
|
*
|
|
382
|
-
* It annotates exactly like a `DetailRow` — `description
|
|
382
|
+
* It annotates exactly like a `DetailRow` — `description`, `warning`, `error`, one shared
|
|
383
383
|
* anatomy (`field_annotations`). A field-level fault says so ON THE FIELD; demoting it into a
|
|
384
384
|
* `TaskCaption`, which is a sentence about the ROW, says something different.
|
|
385
385
|
*
|
package/src/text_input_field.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { Ref, useCallback, useState } from "react";
|
|
|
13
13
|
import { Icon, IconName } from "./icon";
|
|
14
14
|
import { IconButton } from "./icon_button";
|
|
15
15
|
import { ShortcutBadge } from "./shortcut_badge";
|
|
16
|
-
import { fontFamilyRegular,
|
|
16
|
+
import { fontFamilyRegular, getInputTextStyle, getMultilineInputHeight } from "./text_utils";
|
|
17
17
|
import { useScreenSize } from "./use_screen_size";
|
|
18
18
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
19
19
|
import { useFormField } from "./form_field";
|
|
@@ -34,6 +34,17 @@ interface TextInputFieldProps extends RNTextInputProps {
|
|
|
34
34
|
/** Accessible name for the clear button. Defaults to the locale's
|
|
35
35
|
* `textInputField.clear` ("Clear" / "Xóa"). */
|
|
36
36
|
clearLabel?: string;
|
|
37
|
+
/** The HOST paints the surface — drop this input's own border, fill, focus ring
|
|
38
|
+
* AND horizontal padding, so the page draws one box and the text lands on the
|
|
39
|
+
* host's inset rather than 8px further in. Set by an inline editor whose field
|
|
40
|
+
* carries `actions`: the field's shell owns the surface (so the verbs never
|
|
41
|
+
* move between modes) and owns the ring with it.
|
|
42
|
+
*
|
|
43
|
+
* HEIGHT and VERTICAL padding stay — those are what keep the field the same
|
|
44
|
+
* size in both modes; the horizontal inset is what has to be surrendered,
|
|
45
|
+
* because the host already spent `CONTROL_TEXT_INSET` on it and two insets
|
|
46
|
+
* stacked is a visible jump to the right on focus. */
|
|
47
|
+
seamless?: boolean;
|
|
37
48
|
// DOM-only ARIA attrs not declared on React Native's TextInputProps. They
|
|
38
49
|
// are forwarded verbatim to the underlying web input.
|
|
39
50
|
"aria-controls"?: string;
|
|
@@ -45,6 +56,7 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
45
56
|
const locale = useLoticsLocale();
|
|
46
57
|
const {
|
|
47
58
|
style,
|
|
59
|
+
seamless,
|
|
48
60
|
icon,
|
|
49
61
|
clearable,
|
|
50
62
|
onClear,
|
|
@@ -72,7 +84,6 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
72
84
|
if (ariaAutocomplete !== undefined) webAriaAttrs["aria-autocomplete"] = ariaAutocomplete;
|
|
73
85
|
|
|
74
86
|
const { small } = useScreenSize();
|
|
75
|
-
const lineHeight = getInputLineHeight(small);
|
|
76
87
|
const binding = useFormField();
|
|
77
88
|
// Text-like input: ring on ANY focus (browsers treat typing-capable fields as
|
|
78
89
|
// focus-visible even on pointer focus), so a clicked field shows its ring.
|
|
@@ -87,7 +98,7 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
87
98
|
|
|
88
99
|
const minHeight =
|
|
89
100
|
numberOfLines && numberOfLines > 1
|
|
90
|
-
? numberOfLines
|
|
101
|
+
? getMultilineInputHeight(numberOfLines, small)
|
|
91
102
|
: undefined;
|
|
92
103
|
|
|
93
104
|
const autoGrowResult = useAutoGrowHeight({
|
|
@@ -167,7 +178,8 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
167
178
|
showShortcut && shortcutWidth > 0 && { paddingRight: SHORTCUT_INSET + shortcutWidth },
|
|
168
179
|
hovered && editable && { borderColor: HOVER_BORDER },
|
|
169
180
|
style,
|
|
170
|
-
focusVisible && { boxShadow: FOCUS_RING },
|
|
181
|
+
focusVisible && !seamless && { boxShadow: FOCUS_RING },
|
|
182
|
+
seamless && styles.seamless,
|
|
171
183
|
]}
|
|
172
184
|
multiline={(numberOfLines && numberOfLines > 1) || multiline}
|
|
173
185
|
focusable={editable}
|
|
@@ -227,6 +239,11 @@ const styles = StyleSheet.create({
|
|
|
227
239
|
fontFamily: fontFamilyRegular,
|
|
228
240
|
letterSpacing: -0.4,
|
|
229
241
|
},
|
|
242
|
+
// The HOST paints the surface AND owns the text inset. Height and vertical
|
|
243
|
+
// padding stay (they hold the field's size); the horizontal padding goes,
|
|
244
|
+
// because the host already spent CONTROL_TEXT_INSET and two insets stacked
|
|
245
|
+
// shift the text 8px right the moment the field focuses.
|
|
246
|
+
seamless: { borderColor: "transparent", backgroundColor: "transparent", paddingHorizontal: 0 },
|
|
230
247
|
disabled: {
|
|
231
248
|
color: colors.zinc["400"],
|
|
232
249
|
outlineStyle: "none" as unknown as "solid",
|
package/src/text_utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Platform, TextStyle } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
|
+
import { multilineInputHeight } from "./input_metrics";
|
|
3
4
|
|
|
4
5
|
export type TextColor =
|
|
5
6
|
| "default"
|
|
@@ -26,7 +27,7 @@ export function getTextColor(color?: TextColor): string {
|
|
|
26
27
|
case "zinc-500":
|
|
27
28
|
return colors.zinc["500"];
|
|
28
29
|
// The disabled ink — light enough that a control reads inert at a glance
|
|
29
|
-
// (muted/600 still reads as body ink on
|
|
30
|
+
// (muted/600 still reads as body ink on the control's resting surface).
|
|
30
31
|
case "zinc-400":
|
|
31
32
|
return colors.zinc["400"];
|
|
32
33
|
case "inverted":
|
|
@@ -112,6 +113,11 @@ export function getInputTextStyle(): TextStyle {
|
|
|
112
113
|
*
|
|
113
114
|
* @param isSmallScreen - Whether the screen is small (mobile). Get this from useScreenSize().small
|
|
114
115
|
*/
|
|
116
|
+
/** {@link multilineInputHeight} at this platform's input line height. */
|
|
117
|
+
export function getMultilineInputHeight(numberOfLines: number, isSmallScreen: boolean): number {
|
|
118
|
+
return multilineInputHeight(numberOfLines, getInputLineHeight(isSmallScreen));
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
export function getInputLineHeight(isSmallScreen: boolean): number {
|
|
116
122
|
if (Platform.OS !== "web") {
|
|
117
123
|
return INPUT_LINE_HEIGHT_DESKTOP;
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
3
|
-
import { PressableRow } from "./pressable_row";
|
|
4
|
-
import { PressDoor } from "./press_door";
|
|
5
|
-
import { Text } from "./text";
|
|
6
|
-
import { Icon, type IconName } from "./icon";
|
|
7
|
-
import { colors } from "./colors";
|
|
8
|
-
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
9
|
-
|
|
10
|
-
/** One reference fact. `V` is the value the variant admits — text under a door, any node without one. */
|
|
11
|
-
interface LinkedRecordFact<V = ReactNode> {
|
|
12
|
-
label: string;
|
|
13
|
-
value: V;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface LinkedRecordBoxBaseProps {
|
|
17
|
-
/** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
|
|
18
|
-
icon: IconName;
|
|
19
|
-
/** The linked record's display name. */
|
|
20
|
-
name: string;
|
|
21
|
-
/** A secondary identity line — a code, tax id, country, or "<desk> record". */
|
|
22
|
-
subtitle?: string;
|
|
23
|
-
/** Width of the facts' label column (default 80). Widen for longer labels. */
|
|
24
|
-
factLabelWidth?: number;
|
|
25
|
-
/** The verbs, in a hairline-fenced footer at the bottom of the box, lifted above the door's
|
|
26
|
-
* hit area. Convention: destructive LEFT, go-to RIGHT (a `<View style={{ flex: 1 }} />`
|
|
27
|
-
* spacer between). The divider is drawn automatically when actions are present. Supplied by
|
|
28
|
-
* the consumer so the labels stay localized and the verb set fits the link. */
|
|
29
|
-
actions?: ReactNode;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The box that OPENS — the whole surface is the door into the linked record's own detail. */
|
|
33
|
-
interface LinkedRecordBoxDoorProps extends LinkedRecordBoxBaseProps {
|
|
34
|
-
/** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
|
|
35
|
-
onOpen: () => void;
|
|
36
|
-
/** Accessible name for opening the record (e.g. "Acme Corp — details"). */
|
|
37
|
-
doorLabel: string;
|
|
38
|
-
/** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). TEXT only
|
|
39
|
-
* (`""` renders "—"): the box is one press target, so the only controls it may hold are the
|
|
40
|
-
* `actions` verbs, which the footer fences off and lifts above the door. A fact the reader
|
|
41
|
-
* must EDIT belongs on the linked record's own page — that is what the door is for. */
|
|
42
|
-
facts?: LinkedRecordFact<string>[];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** The box with nothing to open — the linked record has no detail surface of its own. */
|
|
46
|
-
interface LinkedRecordBoxStaticProps extends LinkedRecordBoxBaseProps {
|
|
47
|
-
/** Absent by construction. Typed `never` rather than left off so `doorLabel` can never arrive
|
|
48
|
-
* without a handler — a named door that goes nowhere. */
|
|
49
|
-
onOpen?: never;
|
|
50
|
-
doorLabel?: never;
|
|
51
|
-
/** Reference facts, stacked VERTICALLY. A string renders as text (`""` → "—"); a NODE renders
|
|
52
|
-
* as given (no placeholder), so a fact can carry its own inline editor — safe here precisely
|
|
53
|
-
* because nothing about this box presses. */
|
|
54
|
-
facts?: LinkedRecordFact[];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
type LinkedRecordBoxProps = LinkedRecordBoxDoorProps | LinkedRecordBoxStaticProps;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* A LINKED RECORD shown for reference — a bordered box scoping ANOTHER record's data
|
|
61
|
-
* (identity glyph · name · facts). Use it wherever one record points at another (a shipment's
|
|
62
|
-
* customer, an invoice's party, a case's sibling); the picker / empty-state that REASSIGNS the
|
|
63
|
-
* link is the consumer's, shown in place of the box.
|
|
64
|
-
*
|
|
65
|
-
* `onOpen` picks the variant:
|
|
66
|
-
*
|
|
67
|
-
* - **With `onOpen`** (+ `doorLabel`) the WHOLE box is a keyboard-accessible door into the
|
|
68
|
-
* record's detail. Its facts are TEXT, and the `actions` footer is the one place a control
|
|
69
|
-
* may live — everything else is press-through to the door.
|
|
70
|
-
* - **Without it** the box is a STATIC reference card: no door, no tab stop, no pointer, nothing
|
|
71
|
-
* announced as a button; the only interactive parts are `actions` and whatever a fact node
|
|
72
|
-
* holds. This is the shape for a record with NO page of its own — there is nothing to open, so
|
|
73
|
-
* the box IS where its values are read and edited, and an inline editor in a fact is honest.
|
|
74
|
-
*
|
|
75
|
-
* The a11y contract the door variant enforces (the error-prone part, hence a primitive): a
|
|
76
|
-
* container with interactive descendants must NEVER be `role="button"` (invalid HTML). So the
|
|
77
|
-
* box is a role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible
|
|
78
|
-
* name / focus ring, and the interior verbs lift above it via `zIndex`.
|
|
79
|
-
*/
|
|
80
|
-
export function LinkedRecordBox(props: LinkedRecordBoxProps) {
|
|
81
|
-
const { icon, name, subtitle, factLabelWidth = 80, actions } = props;
|
|
82
|
-
// A door box's facts are strings, and a string IS a ReactNode — so both variants render
|
|
83
|
-
// through one path typed at the wider value.
|
|
84
|
-
const facts: LinkedRecordFact[] = props.facts ?? [];
|
|
85
|
-
const body = (
|
|
86
|
-
<>
|
|
87
|
-
<View style={styles.identity}>
|
|
88
|
-
<View style={styles.glyph}>
|
|
89
|
-
<Icon name={icon} size={17} color={colors.zinc[600]} />
|
|
90
|
-
</View>
|
|
91
|
-
<View style={{ flex: 1, minWidth: 0 }}>
|
|
92
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{name}</Text>
|
|
93
|
-
{subtitle ? <Text size="xs" color="muted" numberOfLines={1}>{subtitle}</Text> : null}
|
|
94
|
-
</View>
|
|
95
|
-
</View>
|
|
96
|
-
{facts.length > 0 ? (
|
|
97
|
-
<View style={{ gap: 6 }}>
|
|
98
|
-
{facts.map((f) => {
|
|
99
|
-
// THE ALIGNMENT LAW, the same one `DetailRow` runs on: the fact TOP-aligns and each
|
|
100
|
-
// cell centres within the first CONTROL LINE. A node value brings the kit's control
|
|
101
|
-
// band (an inline editor is 40 tall, a chip less), so both cells claim it — without
|
|
102
|
-
// it the label `Text`, stretched to the row, printed its words at the band's TOP,
|
|
103
|
-
// a half-band above the value it names. A STRING value has no band: label and value
|
|
104
|
-
// are the same 20px line, so they sit at the row's top and a long value that wraps
|
|
105
|
-
// still tops out level with its label.
|
|
106
|
-
const banded = typeof f.value !== "string";
|
|
107
|
-
return (
|
|
108
|
-
<View key={f.label} style={styles.fact}>
|
|
109
|
-
<View style={[{ width: factLabelWidth }, banded && styles.bandedCell]}>
|
|
110
|
-
<Text size="sm" color="muted">{f.label}</Text>
|
|
111
|
-
</View>
|
|
112
|
-
{banded ? (
|
|
113
|
-
// A node prints as authored — the "—" placeholder is a TEXT affordance, and a
|
|
114
|
-
// control renders its own empty state.
|
|
115
|
-
<View style={[{ flex: 1, minWidth: 0 }, styles.bandedCell]}>{f.value}</View>
|
|
116
|
-
) : (
|
|
117
|
-
<Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
|
|
118
|
-
)}
|
|
119
|
-
</View>
|
|
120
|
-
);
|
|
121
|
-
})}
|
|
122
|
-
</View>
|
|
123
|
-
) : null}
|
|
124
|
-
{actions ? (
|
|
125
|
-
<>
|
|
126
|
-
{/* full-bleed hairline fencing the verb footer off from the facts */}
|
|
127
|
-
<View style={styles.divider} />
|
|
128
|
-
<View style={styles.actions}>{actions}</View>
|
|
129
|
-
</>
|
|
130
|
-
) : null}
|
|
131
|
-
</>
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
// Nothing to open → a plain View, so the box takes no tab stop, announces no role, and paints
|
|
135
|
-
// no pointer/hover promise of a destination it doesn't have.
|
|
136
|
-
if (!props.onOpen) return <View style={styles.box}>{body}</View>;
|
|
137
|
-
|
|
138
|
-
return (
|
|
139
|
-
<PressableRow onPress={props.onOpen} style={styles.box}>
|
|
140
|
-
<PressDoor accessibilityLabel={props.doorLabel} onPress={props.onOpen} />
|
|
141
|
-
{body}
|
|
142
|
-
</PressableRow>
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const styles = StyleSheet.create({
|
|
147
|
-
box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
|
|
148
|
-
identity: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
149
|
-
glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
|
|
150
|
-
fact: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
|
|
151
|
-
// A cell on the control band: exactly `minHeight` tall for a one-line label / a short chip,
|
|
152
|
-
// and taller content (a wrapped label, a stack of controls) tops out level instead of being
|
|
153
|
-
// dragged toward the block's centre.
|
|
154
|
-
bandedCell: { minHeight: INLINE_CONTROL_HEIGHT, justifyContent: "center" },
|
|
155
|
-
divider: { height: 1, backgroundColor: colors.zinc[100], marginHorizontal: -16 },
|
|
156
|
-
actions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 },
|
|
157
|
-
});
|