@lotics/ui 14.0.0 → 14.2.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 +5 -0
- package/docs/ai_patterns.md +9 -2
- package/docs/catalog.md +36 -6
- package/docs/composition.md +8 -0
- package/package.json +3 -1
- package/src/agent_progress.tsx +6 -3
- package/src/clarify_wizard.tsx +5 -4
- package/src/file_badge.tsx +6 -3
- package/src/file_badge_fit.test.ts +49 -0
- package/src/file_badge_fit.ts +34 -0
- package/src/file_thumbnail.tsx +31 -8
- package/src/follow_scroll.tsx +58 -0
- package/src/linked_record_box.tsx +5 -23
- package/src/press_door.tsx +68 -0
- package/src/pressable_highlight.tsx +7 -0
- package/src/table.tsx +7 -28
package/AGENTS.md
CHANGED
|
@@ -56,6 +56,11 @@ doc before building any screen, **never from memory**. Exact props are the shipp
|
|
|
56
56
|
hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
|
|
57
57
|
- **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
|
|
58
58
|
callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
|
|
59
|
+
- **A button never contains a control** — a row that presses open AND carries its own controls
|
|
60
|
+
(a CTA, ⋯, a checkbox, a `Link`) is a role-less `PressableRow` + a **`PressDoor`** sibling
|
|
61
|
+
(the tab stop, name, focus ring), never `PressableHighlight`, which wraps its children in the
|
|
62
|
+
button. `Table`/`TableRow` and `LinkedRecordBox` already do this. →
|
|
63
|
+
[composition.md](./docs/composition.md).
|
|
59
64
|
- **Every state designed** — skeleton (mirroring layout), empty, error; no layout shift.
|
|
60
65
|
- **Copy states content, not mechanics** — a `description` names the section's content or one
|
|
61
66
|
domain rule; widget behavior ("edits in place, saves on its own") and selling points never
|
package/docs/ai_patterns.md
CHANGED
|
@@ -136,11 +136,18 @@ move on, so the feed doesn't dramatize it. A BREAKING error that terminates the
|
|
|
136
136
|
OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
|
|
137
137
|
pass it as the `error` prop and it renders as a terminal danger row under the transcript.
|
|
138
138
|
|
|
139
|
+
**In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
|
|
140
|
+
drawer, or fixed-height panel grows BELOW the fold — a plain scroll container doesn't follow. Wrap
|
|
141
|
+
the feed: `<FollowScroll style={{ maxHeight: … }}><AgentRun … /></FollowScroll>` — pinned to the
|
|
142
|
+
newest content while streaming, unpinned when the human scrolls up to read (`AgentProgress`'s
|
|
143
|
+
expanded panel already does this). A full-page inverted list (chat) follows by construction and
|
|
144
|
+
doesn't need it.
|
|
145
|
+
|
|
139
146
|
Fed natively by `@lotics/app-sdk`'s `useAgentRun().parts` (reasoning + per-tool I/O + state come
|
|
140
147
|
for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={run.error} />` — see
|
|
141
148
|
[the SDK doc](../../app-sdk/docs/ai.md) for the hook. Requires `ai` as an optional (type-only) peer.
|
|
142
149
|
**Reasoning only shows when the model produces it** — the declared model must support thinking
|
|
143
|
-
(Sonnet
|
|
150
|
+
(Sonnet / Opus, not Haiku); the renderer shows every reasoning part it's handed.
|
|
144
151
|
|
|
145
152
|
**Limitation:** the collapsed reasoning row's "Thinking" label and the expanded detail's
|
|
146
153
|
"Input"/"Output"/"Error" panel titles are currently English-only (not on the locale provider and
|
|
@@ -268,7 +275,7 @@ reads as the custom answer).
|
|
|
268
275
|
|
|
269
276
|
`ClarifyWizard` (`@lotics/ui/clarify_wizard`): a SEQUENCE of clarify questions worked one at a time —
|
|
270
277
|
Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebrow ("1 / 3") above the
|
|
271
|
-
question — no progress bar. Each step is a `Clarify`; advance only once the
|
|
278
|
+
question — no progress bar, and no eyebrow at all for a single question. Each step is a `Clarify`; advance only once the
|
|
272
279
|
current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
|
|
273
280
|
`custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
|
|
274
281
|
locale-wired (the `clarify` slice). The multi-question form of the ask-back.
|
package/docs/catalog.md
CHANGED
|
@@ -179,10 +179,19 @@ of a record/settings surface).
|
|
|
179
179
|
|
|
180
180
|
`PressableRow` (THE register row; forwards its `ref`, so wrapping it in a `PopoverTrigger`
|
|
181
181
|
anchors a row-triggered peek Popover — without the ref the trigger is unmeasurable and the
|
|
182
|
-
popover renders off-screen), `
|
|
182
|
+
popover renders off-screen), `PressDoor` (the keyboard door that pairs with it — see
|
|
183
|
+
below), `ListItem`, `MenuButton`, `MenuListItem`, `DetailRow`
|
|
183
184
|
(label+value for drawers/peeks; optional `trailing` slot for a right-side
|
|
184
185
|
action/badge/unit), `ActionMenu` (⋯), `FloatingActionBar` (bulk-select bar).
|
|
185
186
|
|
|
187
|
+
A DESIGNED row (your own pile, card, or panel) that presses open AND carries its own
|
|
188
|
+
controls is `PressableRow` + **`PressDoor`** — never `PressableHighlight`, which IS a
|
|
189
|
+
button and wraps its children (a button must not contain interactive descendants). The
|
|
190
|
+
door is an empty absolutely-positioned SIBLING of the content carrying the tab stop,
|
|
191
|
+
accessible name, and focus ring; lift the content above it with `zIndex: 1`. `Table`/
|
|
192
|
+
`TableRow` and `LinkedRecordBox` do exactly this internally, so a columnar register needs
|
|
193
|
+
no assembly — reach for `PressDoor` only outside them.
|
|
194
|
+
|
|
186
195
|
### Filters & view controls
|
|
187
196
|
|
|
188
197
|
`SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`, `Counter`), `Chip`,
|
|
@@ -212,8 +221,9 @@ trailing ⋯ menu = Download · Open-external · Remove; the default "here are s
|
|
|
212
221
|
surface), `FileGrid` (the upload-aware grid: completed files + a live upload queue in one
|
|
213
222
|
surface — `FilesEditor` is this + the toolbar; reach for `FileGrid` bare when you own the
|
|
214
223
|
chrome), `FileThumbnail` / `FileThumbnailGrid` (square tiles; `onPress`/`onFilePress` makes each
|
|
215
|
-
tile a pressable door — e.g. tap-to-preview — carrying an accessible button role +
|
|
216
|
-
|
|
224
|
+
tile a pressable door — e.g. tap-to-preview — carrying an accessible button role + the
|
|
225
|
+
filename as its name, overridable per tile with `accessibilityLabel` when what the press
|
|
226
|
+
DOES reads better than a raw filename; `selectedIds` for a selection overlay),
|
|
217
227
|
`UploadingThumbnail` (the single in-flight tile FileGrid renders — reach for it only when
|
|
218
228
|
hand-rolling a non-grid upload layout), `FileRow` (a horizontal file/document LINE —
|
|
219
229
|
badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge /
|
|
@@ -443,6 +453,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
443
453
|
- **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
|
|
444
454
|
`selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
|
|
445
455
|
popover anchoring.
|
|
456
|
+
- **`press_door`** — `PressDoor`: THE keyboard door — an EMPTY absolutely-positioned
|
|
457
|
+
`Pressable` sibling that carries the `role="button"` tab stop, the (REQUIRED)
|
|
458
|
+
`accessibilityLabel`, and the focus ring for a pressable surface whose content holds its
|
|
459
|
+
own controls. Pair it with `PressableRow` (surface takes the mouse + the wash, door takes
|
|
460
|
+
the keyboard, nested controls keep their own presses); the parent owns the positioning
|
|
461
|
+
context and the content lifts above the door with `zIndex: 1`. `radius` (default 10)
|
|
462
|
+
matches the surface it spans. `TableRow` / `LinkedRecordBox` are the in-kit consumers —
|
|
463
|
+
reach for it when designing a row those don't cover.
|
|
446
464
|
- **`linked_record_box`** — `LinkedRecordBox`: a bordered box scoping ANOTHER record's data
|
|
447
465
|
(`icon` · `name` · `subtitle` · vertical `facts`), the WHOLE box a keyboard door into its
|
|
448
466
|
detail (`onOpen`/`doorLabel`); verbs ride an `actions` slot in a hairline-fenced footer at the
|
|
@@ -906,7 +924,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
906
924
|
- **`file_thumbnail`** — `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
|
|
907
925
|
`COMPACT_THUMBNAIL_SIZE` + `getMediaIcon`: the completed tile — the right surface per
|
|
908
926
|
MIME: image thumbnail · a doc tile with the `FileBadge` centered + a single-line filename
|
|
909
|
-
· media card; `isTemplate` overlays a TMPL marker.
|
|
927
|
+
· media card; `isTemplate` overlays a TMPL marker. The tile's accessible name is the
|
|
928
|
+
filename; pass `accessibilityLabel` to say what pressing it DOES instead. The per-surface
|
|
929
|
+
pieces are exported for a hand-rolled layout: `DocumentBadge` (a bare pressable badge —
|
|
930
|
+
its `size` is the square SLOT side, like every other tile here, and the badge is fitted to
|
|
931
|
+
it), `DocumentCard`, `MediaCard`, `RemoveButton`.
|
|
910
932
|
- **`file_thumbnail_grid`** — `FileThumbnailGrid` (+ the generic `ThumbnailGrid`):
|
|
911
933
|
display-only square-tile grid; `selectedIds` + `onFilePress` pair with
|
|
912
934
|
`useSelectionMode()` for gated select-mode CRUD.
|
|
@@ -919,7 +941,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
919
941
|
+ a ⋯ Download/Open-external/Remove menu; composes `FileRow` + `ActionMenu` +
|
|
920
942
|
`FileGalleryModal`; localized via the `gallery` locale slice.
|
|
921
943
|
- **`file_badge`** — `FileBadge`: the two-tone file-type mark (PDF / XLSX / DOCX / video /
|
|
922
|
-
audio …) driven by MIME.
|
|
944
|
+
audio …) driven by MIME. Its `size` is the badge's WIDTH and everything scales off it —
|
|
945
|
+
the mark is TALLER than it is wide (26 × 32 by default), so a square slot takes the
|
|
946
|
+
fitted width, not the slot side (that conversion is what `DocumentBadge` does).
|
|
923
947
|
- **`file_preview`** — `FilePreview`: the universal inline preview — image/PDF/video/audio +
|
|
924
948
|
Word via `@lotics/docx` + Excel/CSV via `@lotics/xlsx`; the heavy engines (pdf.js ·
|
|
925
949
|
`@lotics/docx` · `@lotics/xlsx`) are LAZY (dynamic-imported, ~free until a doc of that
|
|
@@ -976,6 +1000,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
976
1000
|
`AgentRun` and `AgentProgress`. The sole file permitted to `import type` from `ai`.
|
|
977
1001
|
- **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
|
|
978
1002
|
`parts` prop) — a composer's working state.
|
|
1003
|
+
- **`follow_scroll`** — `FollowScroll`: a scroll container that FOLLOWS its growing content —
|
|
1004
|
+
pinned to the bottom as children stream in; scrolling up unpins (read history), scrolling back
|
|
1005
|
+
re-pins. Wrap a streaming `AgentRun` with it in any bounded container (a dialog, a drawer,
|
|
1006
|
+
a panel — put the height bound on `style`); `AgentProgress`'s expanded panel uses it. Chat
|
|
1007
|
+
doesn't need it (an inverted message list follows by construction).
|
|
979
1008
|
- **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: calibrated
|
|
980
1009
|
high/med/low; localized via the provider.
|
|
981
1010
|
- **`change_review`** — the COMPOUND review family — frame: `ChangeReview` provider/stack ·
|
|
@@ -995,7 +1024,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
995
1024
|
`{ value, label, description }` — the description is MANDATORY (every answer explains itself).
|
|
996
1025
|
`allowCustom` adds an always-visible borderless multiline custom-answer field below the options.
|
|
997
1026
|
- **`clarify_wizard`** — `ClarifyWizard` + `ClarifyWizardQuestion`/`ClarifyWizardAnswer`: a SEQUENCE
|
|
998
|
-
of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator
|
|
1027
|
+
of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator (omitted
|
|
1028
|
+
for a single question); advance
|
|
999
1029
|
only when the current is answered, `onSubmit` returns one `{ value, custom }` per question.
|
|
1000
1030
|
Navigation chrome is locale-wired (the `clarify` locale slice).
|
|
1001
1031
|
- **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
|
package/docs/composition.md
CHANGED
|
@@ -287,6 +287,14 @@ body is arbitrary app content — `TableRow`'s cells, which legitimately carry a
|
|
|
287
287
|
trigger — the door is an EMPTY absolutely-positioned sibling under the cells (tab stop + name +
|
|
288
288
|
focus ring; the mouse rides the surface), so a control in a cell never nests inside it.
|
|
289
289
|
|
|
290
|
+
That empty door is the **`PressDoor`** primitive (`@lotics/ui/press_door`) — don't hand-roll it
|
|
291
|
+
when you design a row `Table` doesn't cover (a mobile pile with a per-row control, a bespoke
|
|
292
|
+
card): `<PressableRow onPress>` + `<PressDoor onPress accessibilityLabel="Open …" />` + your
|
|
293
|
+
content on `zIndex: 1` (the door hit-tests above in-flow siblings, so the content must lift back
|
|
294
|
+
over it). `PressableHighlight` is the wrong base for such a row — it IS a button and wraps its
|
|
295
|
+
children, so any nested control lands inside it; it stays for surfaces whose content is
|
|
296
|
+
non-interactive by construction. `TableRow` and `LinkedRecordBox` already compose `PressDoor`.
|
|
297
|
+
|
|
290
298
|
Actions stay **visible** — a hover-only action is invisible to keyboard and touch users. The ONLY
|
|
291
299
|
exception is a DENSE tree/register where a persistent per-row action would clutter: reveal on
|
|
292
300
|
hover **OR focus-within**, keeping the action in the DOM + tab order and gating only its opacity,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"./activity_indicator": "./src/activity_indicator.tsx",
|
|
81
81
|
"./agent_run": "./src/agent_run.tsx",
|
|
82
82
|
"./agent_progress": "./src/agent_progress.tsx",
|
|
83
|
+
"./follow_scroll": "./src/follow_scroll.tsx",
|
|
83
84
|
"./markdown": {
|
|
84
85
|
"react-native": "./src/markdown.tsx",
|
|
85
86
|
"default": "./src/markdown.web.tsx"
|
|
@@ -112,6 +113,7 @@
|
|
|
112
113
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
113
114
|
"./pressable_highlight": "./src/pressable_highlight.tsx",
|
|
114
115
|
"./pressable_row": "./src/pressable_row.tsx",
|
|
116
|
+
"./press_door": "./src/press_door.tsx",
|
|
115
117
|
"./floating_action_bar": "./src/floating_action_bar.tsx",
|
|
116
118
|
"./icon_button": "./src/icon_button.tsx",
|
|
117
119
|
"./info_popover": "./src/info_popover.tsx",
|
package/src/agent_progress.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { WaveAvatar } from "./wave_avatar";
|
|
6
|
+
import { FollowScroll } from "./follow_scroll";
|
|
6
7
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
8
|
import { AgentRun, resolveToolMeta } from "./agent_run";
|
|
8
9
|
import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
|
|
@@ -46,9 +47,11 @@ export function AgentProgress(props: AgentProgressProps) {
|
|
|
46
47
|
<View style={{ gap: 8 }}>
|
|
47
48
|
{expanded ? (
|
|
48
49
|
<View style={styles.panel}>
|
|
49
|
-
|
|
50
|
+
{/* FollowScroll keeps the newest step in view as the run streams —
|
|
51
|
+
a plain ScrollView would let new content grow below the fold. */}
|
|
52
|
+
<FollowScroll style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
|
|
50
53
|
<AgentRun parts={parts} state={state} error={error} labelForTool={labelForTool} stepsLabel={stepsLabel} />
|
|
51
|
-
</
|
|
54
|
+
</FollowScroll>
|
|
52
55
|
</View>
|
|
53
56
|
) : null}
|
|
54
57
|
|
package/src/clarify_wizard.tsx
CHANGED
|
@@ -64,10 +64,11 @@ export function ClarifyWizard(props: ClarifyWizardProps) {
|
|
|
64
64
|
|
|
65
65
|
return (
|
|
66
66
|
<View style={{ gap: 16 }}>
|
|
67
|
-
{/* The step position is an eyebrow above the question (no progress bar
|
|
68
|
-
footer aligns to the same 8px inset
|
|
69
|
-
index so each step's custom-answer
|
|
70
|
-
|
|
67
|
+
{/* The step position is an eyebrow above the question (no progress bar — and
|
|
68
|
+
none at all for a single question); the footer aligns to the same 8px inset
|
|
69
|
+
as the question and answers. Keyed by index so each step's custom-answer
|
|
70
|
+
draft is its own (no bleed across steps). */}
|
|
71
|
+
<Clarify key={index} eyebrow={total > 1 ? `${index + 1} / ${total}` : undefined} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
|
|
71
72
|
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
|
|
72
73
|
<Button title={labels.cancel} color="muted" onPress={onCancel} />
|
|
73
74
|
<View style={{ flexDirection: "row", gap: 8 }}>
|
package/src/file_badge.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Text, View } from "react-native";
|
|
|
2
2
|
import { fontFamilySemiBold } from "./text_utils";
|
|
3
3
|
import { Icon, type IconName } from "./icon";
|
|
4
4
|
import { isVideoMimeType, isAudioMimeType } from "./mime";
|
|
5
|
+
import { FILE_BADGE_DEFAULT_WIDTH, fileBadgeHeight } from "./file_badge_fit";
|
|
5
6
|
|
|
6
7
|
const VIDEO_COLOR = "#ea580c";
|
|
7
8
|
const AUDIO_COLOR = "#db2777";
|
|
@@ -32,12 +33,14 @@ export function resolveMime(mimeType: string): { label: string; color: string }
|
|
|
32
33
|
return MIME_MAP[mimeType] ?? DEFAULT_BADGE;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const DEFAULT_SIZE =
|
|
36
|
+
const DEFAULT_SIZE = FILE_BADGE_DEFAULT_WIDTH;
|
|
36
37
|
|
|
37
38
|
interface FileBadgeProps {
|
|
38
39
|
/** Omit only with `placeholder` — a ghost slot has no resolved type yet. */
|
|
39
40
|
mimeType?: string;
|
|
40
|
-
/** Base
|
|
41
|
+
/** Base WIDTH in pixels. Height, radii, font, and padding scale proportionally — the
|
|
42
|
+
* badge is TALLER than it is wide (26 × 32 at the default), so a SQUARE slot takes the
|
|
43
|
+
* fitted width, not the slot side (`DocumentBadge` does that conversion). Default: 26 */
|
|
41
44
|
size?: number;
|
|
42
45
|
/** Show a "TMPL" overlay to distinguish templates from regular files. */
|
|
43
46
|
isTemplate?: boolean;
|
|
@@ -58,7 +61,7 @@ function getMediaIcon(mimeType: string): IconName | undefined {
|
|
|
58
61
|
|
|
59
62
|
export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate, placeholder }: FileBadgeProps) {
|
|
60
63
|
const scale = size / DEFAULT_SIZE;
|
|
61
|
-
const height =
|
|
64
|
+
const height = fileBadgeHeight(size);
|
|
62
65
|
const radius = Math.round(4 * scale);
|
|
63
66
|
|
|
64
67
|
if (placeholder) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
FILE_BADGE_BOX,
|
|
4
|
+
FILE_BADGE_DEFAULT_WIDTH,
|
|
5
|
+
fileBadgeHeight,
|
|
6
|
+
fileBadgeWidthForBox,
|
|
7
|
+
} from "./file_badge_fit";
|
|
8
|
+
|
|
9
|
+
describe("FileBadge geometry", () => {
|
|
10
|
+
it("scales the badge with the requested width instead of pinning the default", () => {
|
|
11
|
+
// The bug this covers: a caller's `size` was dropped and every badge rendered at
|
|
12
|
+
// the 26x32 default. A requested width must produce a proportionally smaller badge.
|
|
13
|
+
expect(fileBadgeHeight(FILE_BADGE_DEFAULT_WIDTH)).toBe(FILE_BADGE_BOX);
|
|
14
|
+
expect(fileBadgeHeight(20)).toBe(25);
|
|
15
|
+
expect(fileBadgeHeight(13)).toBe(16);
|
|
16
|
+
expect(fileBadgeHeight(52)).toBe(64);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fills the compact thumbnail slot exactly at the default width", () => {
|
|
20
|
+
// Why DEFAULT_WIDTH is 26: a default badge is exactly as tall as the 32px compact
|
|
21
|
+
// tile it sits in. A slot of 32 must therefore resolve to the default, unchanged.
|
|
22
|
+
expect(fileBadgeWidthForBox(FILE_BADGE_BOX)).toBe(FILE_BADGE_DEFAULT_WIDTH);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("resolves a SMALLER slot to a smaller badge, not the default", () => {
|
|
26
|
+
// The reported bug: a sub-32 tile still rendered the 26x32 default — wider AND taller
|
|
27
|
+
// than its own tile. The requested slot now drives the badge.
|
|
28
|
+
expect(fileBadgeWidthForBox(30)).toBe(24);
|
|
29
|
+
expect(fileBadgeHeight(fileBadgeWidthForBox(30))).toBe(30);
|
|
30
|
+
expect(fileBadgeWidthForBox(24)).toBe(19);
|
|
31
|
+
expect(fileBadgeHeight(fileBadgeWidthForBox(24))).toBe(23);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("never overflows its square slot, at any slot size", () => {
|
|
35
|
+
// The badge is taller than it is wide, so height is the binding dimension and the
|
|
36
|
+
// rounding in `fileBadgeHeight` can push a naive floor over the edge.
|
|
37
|
+
for (let box = 8; box <= 200; box++) {
|
|
38
|
+
const width = fileBadgeWidthForBox(box);
|
|
39
|
+
expect(width).toBeLessThanOrEqual(box);
|
|
40
|
+
expect(fileBadgeHeight(width)).toBeLessThanOrEqual(box);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("grows monotonically with the slot", () => {
|
|
45
|
+
for (let box = 9; box <= 200; box++) {
|
|
46
|
+
expect(fileBadgeWidthForBox(box)).toBeGreaterThanOrEqual(fileBadgeWidthForBox(box - 1));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FileBadge` geometry — RN-free so it is unit-testable (a component file importing
|
|
3
|
+
* `react-native` cannot be loaded by Vitest).
|
|
4
|
+
*
|
|
5
|
+
* A `FileBadge`'s `size` is its WIDTH; every other dimension scales off it, so the badge
|
|
6
|
+
* has a FIXED aspect (26 × 32 at the default) and is TALLER than it is wide. A caller that
|
|
7
|
+
* owns a SQUARE slot — the thumbnail family, whose `size` is the tile side — therefore
|
|
8
|
+
* cannot pass its tile side through as a width: the badge would overflow the tile's
|
|
9
|
+
* bottom. `fileBadgeWidthForBox` converts a square slot to the width that fits it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The default badge width. A default badge is exactly `FILE_BADGE_BOX` tall. */
|
|
13
|
+
export const FILE_BADGE_DEFAULT_WIDTH = 26;
|
|
14
|
+
|
|
15
|
+
/** The height of a default-width badge — and the square slot it exactly fills. */
|
|
16
|
+
export const FILE_BADGE_BOX = 32;
|
|
17
|
+
|
|
18
|
+
/** The badge's height at a given width. The aspect is fixed. */
|
|
19
|
+
export function fileBadgeHeight(width: number): number {
|
|
20
|
+
return Math.round((FILE_BADGE_BOX * width) / FILE_BADGE_DEFAULT_WIDTH);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The widest badge that fits inside a `box`×`box` square slot — height is the binding
|
|
25
|
+
* dimension, since the badge is taller than it is wide. `fileBadgeWidthForBox(32)` is
|
|
26
|
+
* `FILE_BADGE_DEFAULT_WIDTH`: the default badge fills the compact thumbnail slot exactly.
|
|
27
|
+
*/
|
|
28
|
+
export function fileBadgeWidthForBox(box: number): number {
|
|
29
|
+
let width = Math.max(1, Math.floor((box * FILE_BADGE_DEFAULT_WIDTH) / FILE_BADGE_BOX));
|
|
30
|
+
// Guard the rounding in `fileBadgeHeight`: the floored width can still round UP past
|
|
31
|
+
// the box by a pixel.
|
|
32
|
+
while (width > 1 && fileBadgeHeight(width) > box) width--;
|
|
33
|
+
return width;
|
|
34
|
+
}
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FileBadge, resolveMime } from "./file_badge";
|
|
2
|
+
import { fileBadgeWidthForBox } from "./file_badge_fit";
|
|
2
3
|
import { fontFamilySemiBold } from "./text_utils";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
import { Icon, type IconName } from "./icon";
|
|
@@ -76,6 +77,9 @@ interface FileThumbnailProps {
|
|
|
76
77
|
uploading?: boolean;
|
|
77
78
|
/** Marks the file as a document TEMPLATE — adds a "TMPL" badge (any file type). */
|
|
78
79
|
isTemplate?: boolean;
|
|
80
|
+
/** Accessible name for the tile — what pressing it DOES, when the raw filename isn't
|
|
81
|
+
* the useful name ("Xem chứng từ"). Defaults to `file.filename`. */
|
|
82
|
+
accessibilityLabel?: string;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
/**
|
|
@@ -84,6 +88,7 @@ interface FileThumbnailProps {
|
|
|
84
88
|
*/
|
|
85
89
|
export function FileThumbnail(props: FileThumbnailProps) {
|
|
86
90
|
const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate } = props;
|
|
91
|
+
const accessibilityLabel = props.accessibilityLabel ?? file.filename;
|
|
87
92
|
|
|
88
93
|
const rootStyle =
|
|
89
94
|
size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
|
|
@@ -91,7 +96,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
91
96
|
if (isImageMimeType(file.mimeType)) {
|
|
92
97
|
return (
|
|
93
98
|
<View style={rootStyle}>
|
|
94
|
-
<ImageThumbnail file={file} size={size} onPress={onPress} onLongPress={onLongPress} />
|
|
99
|
+
<ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={onPress} onLongPress={onLongPress} />
|
|
95
100
|
{uploading && <UploadingOverlay size={size} />}
|
|
96
101
|
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
97
102
|
{selected !== undefined && <SelectionOverlay selected={selected} />}
|
|
@@ -108,7 +113,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
108
113
|
mimeType={file.mimeType}
|
|
109
114
|
size={size}
|
|
110
115
|
isTemplate={isTemplate}
|
|
111
|
-
accessibilityLabel={
|
|
116
|
+
accessibilityLabel={accessibilityLabel}
|
|
112
117
|
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
113
118
|
onLongPress={onLongPress}
|
|
114
119
|
/>
|
|
@@ -126,6 +131,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
126
131
|
filename={file.filename}
|
|
127
132
|
icon={mediaIcon}
|
|
128
133
|
size={size}
|
|
134
|
+
accessibilityLabel={accessibilityLabel}
|
|
129
135
|
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
130
136
|
onLongPress={onLongPress}
|
|
131
137
|
/>
|
|
@@ -135,6 +141,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
135
141
|
filename={file.filename}
|
|
136
142
|
size={size}
|
|
137
143
|
isTemplate={isTemplate}
|
|
144
|
+
accessibilityLabel={accessibilityLabel}
|
|
138
145
|
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
139
146
|
onLongPress={onLongPress}
|
|
140
147
|
/>
|
|
@@ -176,6 +183,9 @@ const uploadingOverlayStyles = StyleSheet.create({
|
|
|
176
183
|
|
|
177
184
|
interface DocumentBadgeProps {
|
|
178
185
|
mimeType: string;
|
|
186
|
+
/** The square SLOT side the badge fills — the same unit as `DocumentCard`/`MediaCard`
|
|
187
|
+
* `size` and the thumbnail tile it sits in. A `FileBadge` is TALLER than it is wide,
|
|
188
|
+
* so the slot is converted to a fitting badge width; omit for the default 32px slot. */
|
|
179
189
|
size?: number;
|
|
180
190
|
isTemplate?: boolean;
|
|
181
191
|
onPress?: () => void;
|
|
@@ -189,7 +199,7 @@ interface DocumentBadgeProps {
|
|
|
189
199
|
* Compact two-tone file badge with document lines and colored label.
|
|
190
200
|
*/
|
|
191
201
|
export function DocumentBadge(props: DocumentBadgeProps) {
|
|
192
|
-
const { mimeType, isTemplate, onPress, onLongPress, accessibilityLabel } = props;
|
|
202
|
+
const { mimeType, size, isTemplate, onPress, onLongPress, accessibilityLabel } = props;
|
|
193
203
|
const { focusVisible, focusProps } = useFocusRing();
|
|
194
204
|
|
|
195
205
|
return (
|
|
@@ -201,7 +211,11 @@ export function DocumentBadge(props: DocumentBadgeProps) {
|
|
|
201
211
|
{...focusProps}
|
|
202
212
|
style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}
|
|
203
213
|
>
|
|
204
|
-
<FileBadge
|
|
214
|
+
<FileBadge
|
|
215
|
+
mimeType={mimeType}
|
|
216
|
+
size={size !== undefined ? fileBadgeWidthForBox(size) : undefined}
|
|
217
|
+
isTemplate={isTemplate}
|
|
218
|
+
/>
|
|
205
219
|
</Pressable>
|
|
206
220
|
);
|
|
207
221
|
}
|
|
@@ -214,6 +228,8 @@ interface DocumentCardProps {
|
|
|
214
228
|
onPress?: () => void;
|
|
215
229
|
onLongPress?: () => void;
|
|
216
230
|
overlay?: React.ReactNode;
|
|
231
|
+
/** Accessible name for the tile. Defaults to `filename` (which the tile also shows). */
|
|
232
|
+
accessibilityLabel?: string;
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
/**
|
|
@@ -224,6 +240,7 @@ interface DocumentCardProps {
|
|
|
224
240
|
*/
|
|
225
241
|
export function DocumentCard(props: DocumentCardProps) {
|
|
226
242
|
const { mimeType, filename, size, isTemplate, onPress, onLongPress, overlay } = props;
|
|
243
|
+
const a11yLabel = props.accessibilityLabel ?? filename;
|
|
227
244
|
|
|
228
245
|
const sizeStyle =
|
|
229
246
|
size !== undefined
|
|
@@ -246,7 +263,7 @@ export function DocumentCard(props: DocumentCardProps) {
|
|
|
246
263
|
onPress={onPress}
|
|
247
264
|
onLongPress={onLongPress}
|
|
248
265
|
accessibilityRole="button"
|
|
249
|
-
accessibilityLabel={
|
|
266
|
+
accessibilityLabel={a11yLabel}
|
|
250
267
|
{...focusProps}
|
|
251
268
|
>
|
|
252
269
|
<FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
|
|
@@ -267,6 +284,8 @@ interface MediaCardProps {
|
|
|
267
284
|
onPress?: () => void;
|
|
268
285
|
onLongPress?: () => void;
|
|
269
286
|
overlay?: React.ReactNode;
|
|
287
|
+
/** Accessible name for the tile. Defaults to `filename` (which the card also shows). */
|
|
288
|
+
accessibilityLabel?: string;
|
|
270
289
|
}
|
|
271
290
|
|
|
272
291
|
/**
|
|
@@ -276,6 +295,7 @@ interface MediaCardProps {
|
|
|
276
295
|
*/
|
|
277
296
|
export function MediaCard(props: MediaCardProps) {
|
|
278
297
|
const { mimeType, filename, size, onPress, onLongPress, overlay, icon } = props;
|
|
298
|
+
const a11yLabel = props.accessibilityLabel ?? filename;
|
|
279
299
|
const { label, color } = resolveMime(mimeType);
|
|
280
300
|
|
|
281
301
|
const sizeStyle =
|
|
@@ -294,7 +314,7 @@ export function MediaCard(props: MediaCardProps) {
|
|
|
294
314
|
onPress={onPress}
|
|
295
315
|
onLongPress={onLongPress}
|
|
296
316
|
accessibilityRole="button"
|
|
297
|
-
accessibilityLabel={
|
|
317
|
+
accessibilityLabel={a11yLabel}
|
|
298
318
|
{...focusProps}
|
|
299
319
|
>
|
|
300
320
|
<Icon name={icon} size={iconSize} color={colors.white} />
|
|
@@ -307,7 +327,7 @@ export function MediaCard(props: MediaCardProps) {
|
|
|
307
327
|
const backdrop = iconSize + 20;
|
|
308
328
|
|
|
309
329
|
return (
|
|
310
|
-
<Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button" accessibilityLabel={
|
|
330
|
+
<Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button" accessibilityLabel={a11yLabel} {...focusProps}>
|
|
311
331
|
{(state) => (
|
|
312
332
|
<>
|
|
313
333
|
<View style={[styles.mediaCardBody, { backgroundColor: color }]}>
|
|
@@ -342,10 +362,13 @@ interface ImageThumbnailProps {
|
|
|
342
362
|
size?: number;
|
|
343
363
|
onPress?: () => void;
|
|
344
364
|
onLongPress?: () => void;
|
|
365
|
+
/** Accessible name for the tile. Defaults to `file.filename`. */
|
|
366
|
+
accessibilityLabel?: string;
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
function ImageThumbnail(props: ImageThumbnailProps) {
|
|
348
370
|
const { file, size, onPress, onLongPress } = props;
|
|
371
|
+
const a11yLabel = props.accessibilityLabel ?? file.filename;
|
|
349
372
|
const [useFallback, setUseFallback] = useState(false);
|
|
350
373
|
const { focusVisible, focusProps } = useFocusRing();
|
|
351
374
|
|
|
@@ -358,7 +381,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
|
|
|
358
381
|
: { width: "100%" as const, height: "100%" as const };
|
|
359
382
|
|
|
360
383
|
return (
|
|
361
|
-
<Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={
|
|
384
|
+
<Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={a11yLabel} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
|
|
362
385
|
{(state) => (
|
|
363
386
|
<>
|
|
364
387
|
<Image
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useRef, type ReactNode } from "react";
|
|
2
|
+
import { ScrollView, StyleProp, ViewStyle, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface FollowScrollProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** The frame — put the height bound here (`maxHeight` / `height` / `flex`);
|
|
7
|
+
* without one the container never overflows and there is nothing to follow. */
|
|
8
|
+
style?: StyleProp<ViewStyle>;
|
|
9
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// "At the bottom" tolerance: within this many px of the end still counts as
|
|
13
|
+
// pinned, so sub-pixel scroll positions and streaming-growth rounding never
|
|
14
|
+
// silently unpin the follow.
|
|
15
|
+
const PIN_THRESHOLD = 40;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A scroll container that FOLLOWS its growing content — pinned to the bottom, it
|
|
19
|
+
* keeps the newest content in view as children stream in (an `AgentRun` in a
|
|
20
|
+
* dialog, a log feed). Scrolling up UNPINS it so the human can read history while
|
|
21
|
+
* the content keeps growing; scrolling back to the bottom re-pins. Chat doesn't
|
|
22
|
+
* need this (its message list is inverted, which follows by construction) — this
|
|
23
|
+
* is for the plain top-down containers everywhere else: dialogs, drawers, panels.
|
|
24
|
+
*/
|
|
25
|
+
export function FollowScroll(props: FollowScrollProps) {
|
|
26
|
+
const { children, style, contentContainerStyle } = props;
|
|
27
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
28
|
+
// Pinned state lives in a ref — it changes on every scroll tick and must never
|
|
29
|
+
// re-render the (streaming, already-busy) subtree.
|
|
30
|
+
const pinnedRef = useRef(true);
|
|
31
|
+
|
|
32
|
+
const followIfPinned = () => {
|
|
33
|
+
if (pinnedRef.current) scrollRef.current?.scrollToEnd({ animated: false });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
37
|
+
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
|
38
|
+
const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
|
|
39
|
+
pinnedRef.current = distanceFromBottom < PIN_THRESHOLD;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ScrollView
|
|
44
|
+
ref={scrollRef}
|
|
45
|
+
style={style}
|
|
46
|
+
contentContainerStyle={contentContainerStyle}
|
|
47
|
+
// Content grew (a new part streamed in) or the frame resized (dialog
|
|
48
|
+
// re-measured) — both move the bottom edge, so both re-follow when pinned.
|
|
49
|
+
onContentSizeChange={followIfPinned}
|
|
50
|
+
onLayout={followIfPinned}
|
|
51
|
+
onScroll={onScroll}
|
|
52
|
+
scrollEventThrottle={16}
|
|
53
|
+
nestedScrollEnabled
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</ScrollView>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import { View,
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { PressableRow } from "./pressable_row";
|
|
4
|
+
import { PressDoor } from "./press_door";
|
|
4
5
|
import { Text } from "./text";
|
|
5
6
|
import { Icon, type IconName } from "./icon";
|
|
6
7
|
import { colors } from "./colors";
|
|
7
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
8
|
-
import { FOCUS_RING } from "./control_surface";
|
|
9
8
|
|
|
10
9
|
interface LinkedRecordBoxProps {
|
|
11
10
|
/** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
|
|
@@ -38,13 +37,13 @@ interface LinkedRecordBoxProps {
|
|
|
38
37
|
*
|
|
39
38
|
* The a11y contract it enforces (the error-prone part, hence a primitive): a container with
|
|
40
39
|
* interactive descendants must NEVER be `role="button"` (invalid HTML). So the box is a
|
|
41
|
-
* role-less `PressableRow`,
|
|
42
|
-
*
|
|
40
|
+
* role-less `PressableRow`, a `PressDoor` sibling carries the tab stop / accessible name /
|
|
41
|
+
* focus ring, and the interior verbs lift above it via `zIndex`.
|
|
43
42
|
*/
|
|
44
43
|
export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth = 80, doorLabel, onOpen, actions }: LinkedRecordBoxProps) {
|
|
45
44
|
return (
|
|
46
45
|
<PressableRow onPress={onOpen} style={styles.box}>
|
|
47
|
-
<
|
|
46
|
+
<PressDoor accessibilityLabel={doorLabel} onPress={onOpen} />
|
|
48
47
|
<View style={styles.identity}>
|
|
49
48
|
<View style={styles.glyph}>
|
|
50
49
|
<Icon name={icon} size={17} color={colors.zinc[600]} />
|
|
@@ -75,25 +74,8 @@ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth =
|
|
|
75
74
|
);
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
// The keyboard DOOR: an empty absolutely-positioned sibling beneath the box content — a
|
|
79
|
-
// button must NOT wrap the box's interactive descendants, so the door alone carries the tab
|
|
80
|
-
// stop, accessible name, and focus ring; mouse presses ride the PressableRow surface.
|
|
81
|
-
function Door({ label, onPress }: { label: string; onPress: () => void }) {
|
|
82
|
-
const { focusVisible, focusProps } = useFocusRing();
|
|
83
|
-
return (
|
|
84
|
-
<Pressable
|
|
85
|
-
accessibilityRole="button"
|
|
86
|
-
accessibilityLabel={label}
|
|
87
|
-
onPress={onPress}
|
|
88
|
-
{...focusProps}
|
|
89
|
-
style={[styles.door, focusVisible ? { boxShadow: FOCUS_RING } : null]}
|
|
90
|
-
/>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
77
|
const styles = StyleSheet.create({
|
|
95
78
|
box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
|
|
96
|
-
door: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
|
|
97
79
|
identity: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
98
80
|
glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
|
|
99
81
|
fact: { flexDirection: "row", gap: 10 },
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Pressable, StyleSheet } from "react-native";
|
|
2
|
+
import { FOCUS_RING } from "./control_surface";
|
|
3
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
4
|
+
|
|
5
|
+
export interface PressDoorProps {
|
|
6
|
+
/** Opens the thing the surface stands for — the same handler the surface presses. */
|
|
7
|
+
onPress: () => void;
|
|
8
|
+
/**
|
|
9
|
+
* REQUIRED. The door is EMPTY, so it has no text content to derive a name from —
|
|
10
|
+
* without a label it is a nameless button. Name the destination ("Open ORD-1042").
|
|
11
|
+
*/
|
|
12
|
+
accessibilityLabel: string;
|
|
13
|
+
/** Focus-ring corner radius — match the surface the door spans. Default 10 (the register wash). */
|
|
14
|
+
radius?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* THE keyboard door: an empty, absolutely-positioned `Pressable` that carries the
|
|
19
|
+
* `role="button"` tab stop, the accessible name, and the focus ring for a pressable
|
|
20
|
+
* surface whose CONTENT can hold its own controls.
|
|
21
|
+
*
|
|
22
|
+
* A button must not contain interactive descendants — nesting `<button>` in `<button>`
|
|
23
|
+
* is invalid HTML (React hydration warning) and collapses the inner control's press and
|
|
24
|
+
* tab stop. So the door is never an ANCESTOR of the content: it is a SIBLING beneath it.
|
|
25
|
+
*
|
|
26
|
+
* Composition (the contract) — the role-less `PressableRow` takes the mouse and paints the
|
|
27
|
+
* hover wash, the door takes the keyboard, the content keeps its own controls:
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <PressableRow onPress={open}>
|
|
30
|
+
* <PressDoor onPress={open} accessibilityLabel="Open ORD-1042" />
|
|
31
|
+
* <View style={{ zIndex: 1 }}>…cells, CTAs, overflow menus…</View>
|
|
32
|
+
* </PressableRow>
|
|
33
|
+
* ```
|
|
34
|
+
* The parent must establish the positioning context (`PressableRow` and any
|
|
35
|
+
* `position: "relative"` box do), and the in-flow content must lift above the door with
|
|
36
|
+
* `zIndex: 1` — an absolutely-positioned sibling paints AND hit-tests above in-flow
|
|
37
|
+
* content, which would otherwise swallow every nested control's press. (A negative z on
|
|
38
|
+
* the door instead would escape the row — no stacking context — and bury the focus ring
|
|
39
|
+
* under the card background.) With that, the door takes only the keyboard, the mouse
|
|
40
|
+
* rides the surface, and nested controls claim their own presses.
|
|
41
|
+
*
|
|
42
|
+
* Wrap the content in the pressable itself (`PressableHighlight`, `FileRow`'s door) ONLY
|
|
43
|
+
* when that content is non-interactive by construction.
|
|
44
|
+
*
|
|
45
|
+
* `TableRow` and `LinkedRecordBox` are the in-kit consumers.
|
|
46
|
+
*/
|
|
47
|
+
export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
|
|
48
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
49
|
+
return (
|
|
50
|
+
<Pressable
|
|
51
|
+
accessibilityRole="button"
|
|
52
|
+
accessibilityLabel={accessibilityLabel}
|
|
53
|
+
onPress={onPress}
|
|
54
|
+
{...focusProps}
|
|
55
|
+
style={[styles.door, { borderRadius: radius }, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const styles = StyleSheet.create({
|
|
61
|
+
door: {
|
|
62
|
+
position: "absolute",
|
|
63
|
+
top: 0,
|
|
64
|
+
right: 0,
|
|
65
|
+
bottom: 0,
|
|
66
|
+
left: 0,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -63,6 +63,13 @@ export interface PressableHighlightProps extends PressableProps {
|
|
|
63
63
|
/**
|
|
64
64
|
* A pressable component that highlights when hovered and pressed.
|
|
65
65
|
* It will darken the `backgroundColor` prop when hovered and pressed.
|
|
66
|
+
*
|
|
67
|
+
* It IS a button and it WRAPS its children — so its content must be non-interactive by
|
|
68
|
+
* construction. A row whose content carries its own controls (a CTA, a ⋯ menu, a
|
|
69
|
+
* checkbox, a `Link`) must not be built on it: a button cannot contain interactive
|
|
70
|
+
* descendants. Compose the role-less `PressableRow` + a `PressDoor` sibling instead —
|
|
71
|
+
* the surface takes the mouse, the door takes the keyboard, the controls stay their own
|
|
72
|
+
* tab stops (`TableRow` and `LinkedRecordBox` are the references).
|
|
66
73
|
*/
|
|
67
74
|
export function PressableHighlight(props: PressableHighlightProps) {
|
|
68
75
|
const {
|
package/src/table.tsx
CHANGED
|
@@ -8,15 +8,14 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
type ReactElement,
|
|
10
10
|
} from "react";
|
|
11
|
-
import { StyleSheet, View,
|
|
11
|
+
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
12
12
|
import { Text } from "./text";
|
|
13
13
|
import { colors } from "./colors";
|
|
14
14
|
import { PressableRow } from "./pressable_row";
|
|
15
|
+
import { PressDoor } from "./press_door";
|
|
15
16
|
import { Divider } from "./divider";
|
|
16
17
|
import { DetailRow } from "./detail_row";
|
|
17
18
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
18
|
-
import { FOCUS_RING } from "./control_surface";
|
|
19
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
20
19
|
import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
21
20
|
|
|
22
21
|
/**
|
|
@@ -182,7 +181,7 @@ export type TableRowProps = {
|
|
|
182
181
|
|
|
183
182
|
/**
|
|
184
183
|
* A register row: a full-bleed `PressableRow` (its hover wash spans the whole row,
|
|
185
|
-
* incl. the leading/trailing controls) holding the cells, with
|
|
184
|
+
* incl. the leading/trailing controls) holding the cells, with a `PressDoor` — the
|
|
186
185
|
* keyboard-accessible `role="button"` open target — as an EMPTY absolutely-
|
|
187
186
|
* positioned SIBLING beneath them, never their ancestor. A button must not
|
|
188
187
|
* contain interactive descendants, and cells legitimately carry their own
|
|
@@ -209,7 +208,6 @@ export function TableRow(props: TableRowProps) {
|
|
|
209
208
|
// the declared columns stay — same tolerance as the width mapping.
|
|
210
209
|
.filter(({ column }) => ctx.stacked || column == null || ctx.visibleKeys.has(column.key))
|
|
211
210
|
.map(({ cell, column }) => cloneElement(cell, { _column: column, _stacked: ctx.stacked }));
|
|
212
|
-
const { focusVisible, focusProps } = useFocusRing();
|
|
213
211
|
|
|
214
212
|
const body = ctx.stacked ? (
|
|
215
213
|
<View style={styles.stackedBody}>
|
|
@@ -254,18 +252,10 @@ export function TableRow(props: TableRowProps) {
|
|
|
254
252
|
return (
|
|
255
253
|
<PressableRow onPress={onPress} selected={selected} marked={marked} style={styles.row}>
|
|
256
254
|
{!ctx.stacked && ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
|
|
257
|
-
{/*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
(no stacking context) and bury the focus ring under the Card background. */}
|
|
262
|
-
<Pressable
|
|
263
|
-
accessibilityRole="button"
|
|
264
|
-
accessibilityLabel={accessibilityLabel}
|
|
265
|
-
onPress={onPress}
|
|
266
|
-
{...focusProps}
|
|
267
|
-
style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
268
|
-
/>
|
|
255
|
+
{/* The door hit-tests above in-flow content; the cells/slots lift back above it
|
|
256
|
+
via zIndex 1 (see `PressDoor`), so the door gets only the keyboard. Radius
|
|
257
|
+
matches the register wash. */}
|
|
258
|
+
<PressDoor onPress={onPress} accessibilityLabel={accessibilityLabel} />
|
|
269
259
|
{ctx.stacked ? (
|
|
270
260
|
body
|
|
271
261
|
) : (
|
|
@@ -348,17 +338,6 @@ const styles = StyleSheet.create({
|
|
|
348
338
|
flexDirection: "row",
|
|
349
339
|
paddingHorizontal: ROW_GUTTER,
|
|
350
340
|
},
|
|
351
|
-
// The keyboard door: an empty overlay spanning the row — the tab stop + focus
|
|
352
|
-
// ring live here, the cells are its SIBLINGS (see TableRow's doc). Radius
|
|
353
|
-
// matches the register wash.
|
|
354
|
-
door: {
|
|
355
|
-
position: "absolute",
|
|
356
|
-
top: 0,
|
|
357
|
-
right: 0,
|
|
358
|
-
bottom: 0,
|
|
359
|
-
left: 0,
|
|
360
|
-
borderRadius: 10,
|
|
361
|
-
},
|
|
362
341
|
cells: {
|
|
363
342
|
flex: 1,
|
|
364
343
|
flexDirection: "row",
|