@lotics/ui 11.8.3 → 11.8.8

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  > app with two renames IN THIS ORDER: first `size="xxl"` → `size="xxxl"`, then `size="xl"` →
7
7
  > `size="xxl"` — every existing usage keeps its exact look. Also: `SectionHeadingTitle` is now
8
8
  > ALWAYS `##` (22/24), and disabled `Button`s fade their label to the zinc-400 disabled ink on
9
- > every variant.
9
+ > every variant EXCEPT `primary`, which keeps inverted ink over its washed surface.
10
10
 
11
11
  The component kit for Lotics custom-code apps and the product frontend: React-Native-Web
12
12
  primitives (renders on web **and** native), data-entry patterns, AI surfaces, a composition
package/docs/catalog.md CHANGED
@@ -36,7 +36,7 @@ Pick by capability, not by name. (→ the source file for the API.)
36
36
  `Button` (labelled; `title` is MANDATORY — the type blocks a title-less Button; `color` =
37
37
  emphasis/risk), `IconButton` (icon-ONLY — the circular affordance; an icon-only Button is a
38
38
  type error, so reach here. `size` `lg` 40px / `md` 28px / `sm` 24px — md/sm keep a 40px touch
39
- target via hitSlop — the SAME `color` palette as Button, plus `loading` and `elevated` (white
39
+ target via hitSlop — the SAME `color` palette as Button plus `none`/`white`, plus `loading` and `elevated` (white
40
40
  fill + border + shadow, for a button sitting ON imagery — a tile's remove ✕, an overlay's
41
41
  retry); needs `accessibilityLabel`/`tooltip`), `TextLink` (underlined text that's OPTIONALLY
42
42
  an action (`onPress`) or a link (`href` — a real web anchor) — or, with neither, plain
@@ -212,7 +212,9 @@ this first for "manage a record's attachments"), `FileDropzone`, `FileRows`
212
212
  trailing ⋯ menu = Download · Open-external · Remove; the default "here are some files"
213
213
  surface), `FileGrid` (the upload-aware grid: completed files + a live upload queue in one
214
214
  surface — `FilesEditor` is this + the toolbar; reach for `FileGrid` bare when you own the
215
- chrome), `FileThumbnail` / `FileThumbnailGrid` (square tiles, display-only),
215
+ chrome), `FileThumbnail` / `FileThumbnailGrid` (square tiles; `onPress`/`onFilePress` makes each
216
+ tile a pressable door — e.g. tap-to-preview — carrying an accessible button role + filename
217
+ label; `selectedIds` for a selection overlay),
216
218
  `UploadingThumbnail` (the single in-flight tile FileGrid renders — reach for it only when
217
219
  hand-rolling a non-grid upload layout), `FileRow` (a horizontal file/document LINE —
218
220
  badge-or-placeholder + name + meta + a composable `trailing` slot for a status badge /
@@ -390,9 +392,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
390
392
  name), `color` = emphasis/risk.
391
393
  - **`icon_button`** — `IconButton`: the icon-only circular action (see
392
394
  [Actions](#actions)).
393
- - **`back_button`** — `BackButton`: the chevron-left `IconButton` (lg, secondary) heading a
394
- screen/panel: `onPress` + `accessibilityLabel` (defaults to the locale's `nav.back`
395
- "Back" / "Quay lại"); the one go-back glyph don't hand-roll it.
395
+ - **`back_button`** — `BackButton`: the one go-back control don't hand-roll it (an
396
+ `arrow-left` `MenuButton` reads as another nav ITEM, not an exit). `onPress` +
397
+ `accessibilityLabel` (defaults to `label`, else the locale's `nav.back`"Back" /
398
+ "Quay lại"). Bare, it's the chevron-left `IconButton` (lg, secondary) heading a
399
+ screen/panel; pass **`label`** to name where back GOES ("Danh sách") and it becomes ONE
400
+ pressable over glyph + words — never an icon button beside unclickable prose.
396
401
  - **`link`** — `Link`: the EXTERNAL hyperlink — fixed underline+blue + `role="link"`;
397
402
  `onPress` only (the consumer wires the opener).
398
403
  - **`text_link`** — `TextLink`: underlined text that's optionally an `onPress` action or an
@@ -578,7 +583,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
578
583
  repeating the same labelWidth/trailingWidth. Worked example:
579
584
  [`tpl_record`](../examples/tpl_record.tsx).
580
585
  - **`record_summary`** — `RecordSummary`: the identity band of a record detail/drawer — ONE
581
- row: `title` xl semibold tabular · `subtitle` sm muted · `status` Badge slot · optional
586
+ row: `title` xxl semibold tabular · `subtitle` sm muted · `status` Badge slot · optional
582
587
  `metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS
583
588
  never live in the header: compose them as `DetailTable`s in the sections below. Replaces
584
589
  hand-rolled record headers (mixed scales, several competing figures, color noise).
@@ -46,7 +46,8 @@ click away onto non-focusable space) does NOT restore — a mouse user has no ne
46
46
  preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
47
47
  restore is gated on modality via `shouldRestoreFocusOnClose`).
48
48
 
49
- **Typed dates.** `InlineDatePicker`'s keyboard mode is the kit's segmented `DateField`: type
49
+ **Typed dates.** `InlineDatePicker`'s keyboard mode is an internal segmented date field
50
+ (`DateField` — not exported; reached only through `InlineDatePicker`): type
50
51
  the date in the locale's own field order (dd/MM/yyyy where the locale says so — pass
51
52
  `locale`), digits auto-advance, and a typed separator (`/` `.` `-`) advances a single-digit
52
53
  day/month; Enter or blur commits, Escape reverts, Alt+ArrowDown floats the calendar. The
@@ -82,9 +83,15 @@ One per type:
82
83
  CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
83
84
  field.
84
85
 
85
- All are built on **`useInlineEdit`** + **`InlineEditView`** (custom inputs join the family via
86
- those). `onSave` is async: the saving spinner sits INSIDE the control at its right edge (never a
87
- sibling that reflows); an error shows inline without losing the edit.
86
+ They split across two shared shells. The same-height **input-swap** editors (`InlineTextInput`,
87
+ `InlineNumberInput`, `InlineTimePicker`) render **`InlineEditFrame`** driven by the
88
+ **`useInlineEdit`** hook (the view⇄edit toggle, draft buffer, and async save) swap a custom
89
+ input into that pair to join the family. The **popover** editors (`InlineSelect`,
90
+ `InlineMemberSelect`, `InlineDatePicker`, `InlineTagSelect`) instead compose **`InlineEditView`**
91
+ (the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
92
+ error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
93
+ either way: the saving spinner sits INSIDE the control at its right edge (never a sibling — that
94
+ reflows); an error shows inline without losing the edit.
88
95
 
89
96
  To let a value be UNSET (a diff-write CLEAR — unassign, remove a due date, drop a select), pass
90
97
  **`onClear`** to `InlineSelect` / `InlineMemberSelect` / `InlineDatePicker`. It surfaces through
@@ -139,7 +146,8 @@ wrap their OWN `FormField` (and don't put a `style` on it) — for a grid cell u
139
146
 
140
147
  ## Find-or-create — the `Combobox` family IS the control
141
148
 
142
- `Combobox` is COMPOUND: a root holds the DATA + behaviour (the shared `useOptionList` engine —
149
+ `Combobox` is COMPOUND: a root holds the DATA + behaviour (its internal option-list engine —
150
+ `useOptionList`, not exported —
143
151
  `options`, `value`, `onValueChange`, `onSearchChange`, `allowCustom`, the per-row
144
152
  `getOptionDescription`/`renderOptionContent` so they're typed against the option `data`), and the
145
153
  parts render the CHROME, composed as children — never a render-prop pile:
@@ -198,17 +206,22 @@ When work crosses departments (sales → operations → accounting), the HANDOFF
198
206
  changing desks — never an inbox, a notification, or a copied task. Two types:
199
207
 
200
208
  **(A) Same entity** → a STAGE field on the shared record: sections carry OWNER dot tags, and the
201
- handoff is MANAGED AS TASKS — each desk's checklist on the record (the task-row vocabulary:
202
- sibling `CheckCircle` + struck transparent `InlineTextInput` + per-task `InlineMemberSelect`
203
- assignees + the `CaptureRow` add-affordance — never a decorative progress strip). A NEW record
204
- starts with an EMPTY checklist tasks truly vary. The commons split in two: MANDATORY tasks are
205
- seeded by the app (a workflow on create, per record type) no human types them; common-but-
206
- OPTIONAL tasks appear as `SuggestionChip`s under the list a PILL, never a row, so a suggestion
207
- can't be mistaken for a task (tap = materialize, = dismiss for this record; already-present
208
- labels filter out; suggestions never count in done/total and pause while a filter narrows the
209
- view) the same suggestion grammar as the billing Standard pill. Real rows carry a `menu` (an
210
- `ActionMenu`; Delete lives BEHIND it, danger-styled and last never a bare a stray tap can
211
- hit), completing the checklist's CRUD.
209
+ handoff is MANAGED AS TASKS — each desk's checklist on the record. The **`Checklist`** compound
210
+ OWNS the list geometry (row height, ring/title alignment, and the one trailing column, set once
211
+ via `trailingWidth`)compose it, never hand-roll the row. Each **`ChecklistRow`** takes a
212
+ `control` (a `CheckCircle` a read-only ring when it has no `onChange`), its title as children (a
213
+ struck transparent `InlineTextInput`, or plain `Text`), then EITHER a `trailing` cell on wide
214
+ surfaces (the per-task assignee `InlineMemberSelect`, sized by the list's `trailingWidth`) OR a
215
+ `meta` line under the title on NARROW surfaces (a drawer/popover checklist the two are mutually
216
+ exclusive), plus a `menu` for the row's options never a decorative progress strip. `CaptureRow`
217
+ closes the list as its add-affordance. A NEW record starts with an EMPTY checklist tasks truly
218
+ vary. The commons split in two: MANDATORY tasks are seeded by the app (a workflow on create, per
219
+ record type) no human types them; common-but-OPTIONAL tasks appear as `SuggestionChip`s under
220
+ the list — a PILL, never a row, so a suggestion can't be mistaken for a task (tap = materialize,
221
+ ✕ = dismiss for this record; already-present labels filter out; suggestions never count in
222
+ done/total and pause while a filter narrows the view) — the same suggestion grammar as the billing
223
+ Standard pill. The row's `menu` (an `ActionMenu`; Delete lives BEHIND it, danger-styled and last —
224
+ never a bare ✕ a stray tap can hit) completes the checklist's CRUD.
212
225
 
213
226
  Open tasks INFORM the handoff, they NEVER block it: the CTA stays enabled, the count warns, and
214
227
  open tasks carry over. The handoff CTA opens a DIALOG for the receiving desk (assignee
@@ -216,8 +229,9 @@ open tasks carry over. The handoff CTA opens a DIALOG for the receiving desk (as
216
229
  the record left this register.
217
230
 
218
231
  Tasks PEEK from the register: the done/total column is a pressable compact-`ProgressBar` trigger
219
- whose popover holds the checklist on FIXED columns — every row ring · struck `InlineTextInput`
220
- title (flex) · quick-reassign `InlineMemberSelect` (140) no expandable rows (tags/files depth is
232
+ whose popover holds the same `Checklist` on FIXED columns (`trailingWidth={140}`) — every
233
+ `ChecklistRow`'s ring `control` · struck `InlineTextInput` title (flex) · quick-reassign
234
+ `InlineMemberSelect` `trailing` — no expandable rows (tags/files depth is
221
235
  the Task list template's lesson, not the peek's); the popover body is `PopoverContent`'s own
222
236
  ScrollView (`disableBodyScroll` is ONLY for children that manage their own scroll, like
223
237
  `OptionList`). The DRAWER carries a real Tasks SECTION in the Record template's shape (heading +
package/docs/templates.md CHANGED
@@ -244,6 +244,26 @@ Top → bottom:
244
244
  - A LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`) jumps between the sections and
245
245
  scroll-spies the active one; on narrow containers it becomes the pinned current-section
246
246
  bar opening a section-picker `Modal`.
247
+ - **The page layout is the DOCS layout — the reading column is CENTRED IN THE VIEWPORT, and
248
+ the rail sits in its left gutter.** The `ScrollView` is FULL-WIDTH — **never cap it at
249
+ `CONTENT_MAX`, or the scroll container becomes the column and the gutters go wheel-dead**
250
+ (measured: a 720px scroller in a 1440px window leaves half the page ignoring the wheel).
251
+ Inside it sits one centred row (`width: "100%"`,
252
+ `maxWidth: GUTTER * 2 + CONTENT_MAX`, `alignSelf: "center"`, `flexDirection: "row"`) of
253
+ three parts: a gutter RESERVED for the rail (`width: GUTTER` — the rail's own `RAIL_W`
254
+ PLUS `RAIL_GAP`, the docs' 3rem of breathing room, so the rail never butts against the
255
+ column), the reading column (`flex: 1, minWidth: 0`), and an EMPTY right gutter of the
256
+ same `GUTTER` that BALANCES the rail — without it the column sits half a rail right of centre. (The public docs pages get
257
+ this balance for free: their right-hand TOC is the counterweight. A record surface has no
258
+ TOC, so the gutter is simply reserved.) The rail is NOT window chrome: no full-height
259
+ border, not glued to the window edge — it FLOATS over its reserved gutter
260
+ (`position: "absolute"`, `left` mirroring the row's centring maths), so it stays put while
261
+ the record scrolls **without** web-only `position: sticky` (which RN does not type —
262
+ `position` is `"absolute" | "relative" | "static"`) and without stealing the page's
263
+ scrollable width. The rail earns its place only while the centred column still has room
264
+ beside it (`pageWidth >= GUTTER * 2 + 560`); below that it collapses to the pinned bar.
265
+ Sizing the column is the SHELL's job — inner blocks are plain `width: "100%"`, never a
266
+ second `maxWidth` + `alignSelf: "center"`.
247
267
 
248
268
  ### `tpl_tasks` — the quick list
249
269
 
@@ -367,6 +367,17 @@ function InvoiceBand({
367
367
  );
368
368
  }
369
369
 
370
+ /** The section rail's width — and, mirrored as an empty right gutter, what
371
+ * keeps the reading column centred in the viewport. */
372
+ const RAIL_W = 208;
373
+ /** Breathing room between the rail and the reading column (the docs' 3rem). */
374
+ const RAIL_GAP = 48;
375
+ /** The reserved gutter: the rail plus its gap. Mirrored empty on the right, this
376
+ * is what centres the column. */
377
+ const GUTTER = RAIL_W + RAIL_GAP;
378
+ /** The reading column's ceiling. */
379
+ const CONTENT_MAX = 720;
380
+
370
381
  export function TplRecord() {
371
382
  const id = useRef(1);
372
383
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
@@ -626,21 +637,25 @@ export function TplRecord() {
626
637
  // it opens a full-page section picker (Escape / the close control dismiss).
627
638
  const [sectionsOpen, setSectionsOpen] = useState(false);
628
639
  const [pageWidth, setPageWidth] = useState<number | null>(null);
629
- const wide = pageWidth != null && pageWidth >= 760;
640
+ // The rail lives in the page's LEFT GUTTER and an equal spacer balances it on
641
+ // the right, so the reading column lands dead-centre in the viewport (the docs
642
+ // layout, where the right-hand TOC is the balancer). The rail only earns its
643
+ // place once the centred column still has room beside it: 2 × rail + a
644
+ // readable column — below that it collapses to the pinned bar.
645
+ const wide = pageWidth != null && pageWidth >= GUTTER * 2 + 560;
646
+ // The rail floats over its RESERVED gutter rather than sitting in the scroll
647
+ // row, so the ScrollView keeps the FULL page width — wheel anywhere and the
648
+ // record scrolls. (Capping the ScrollView at the column's width instead makes
649
+ // the gutters dead zones.) Mirrors the flex maths the row does below: the
650
+ // group is centred inside the 28px content padding.
651
+ const groupW = pageWidth == null ? 0 : Math.min(pageWidth - 28 * 2, GUTTER * 2 + CONTENT_MAX);
652
+ const railLeft = pageWidth == null ? 0 : 28 + Math.max(0, (pageWidth - 28 * 2 - groupW) / 2);
630
653
 
631
654
  return (
632
655
  // Measure-then-reveal: hidden until the width is known, so the rail/bar
633
656
  // choice never visibly reshuffles the page on first paint.
634
- <View style={{ flex: 1, flexDirection: wide ? "row" : "column", backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
635
- {pageWidth == null ? null : wide ? (
636
- <View style={{ width: 208, borderRightWidth: 1, borderRightColor: colors.zinc[200], padding: 8 }}>
637
- <View style={{ gap: 2 }}>
638
- {SECTIONS.map((sec) => (
639
- <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
640
- ))}
641
- </View>
642
- </View>
643
- ) : (
657
+ <View style={{ flex: 1, backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
658
+ {pageWidth == null || wide ? null : (
644
659
  // Narrow: a pinned bar names the CURRENT section (the spy keeps it
645
660
  // honest while scrolling); tapping opens the full-page section picker —
646
661
  // no tab strip the thumb has to scroll.
@@ -653,11 +668,19 @@ export function TplRecord() {
653
668
  />
654
669
  </View>
655
670
  )}
671
+ <View style={{ flex: 1 }}>
672
+ {/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
656
673
  <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
674
+ {/* The centred group: a gutter RESERVED for the rail, the reading column,
675
+ and an equal empty gutter that balances it — so the column's centre is
676
+ the window's centre. Both gutters live INSIDE the scroller, so they
677
+ scroll like everything else. */}
678
+ <View style={{ width: "100%", maxWidth: wide ? GUTTER * 2 + CONTENT_MAX : CONTENT_MAX, alignSelf: "center", flexDirection: "row" }}>
679
+ {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
657
680
  {/* the page column is a SectionStack — it owns the 56px beat + the
658
681
  hairline BETWEEN top-level blocks (a conditional block that renders
659
682
  null never leaves a stray divider) */}
660
- <SectionStack style={{ width: "100%", maxWidth: 720, alignSelf: "center" }}>
683
+ <SectionStack style={{ flex: 1, minWidth: 0 }}>
661
684
  {/* header band — app bar + summary + key facts read as ONE block */}
662
685
  <View style={{ gap: 32 }}>
663
686
  {/* app bar — CREATE is one click; the surface is where everything is refined */}
@@ -1342,7 +1365,25 @@ export function TplRecord() {
1342
1365
  <Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
1343
1366
  </DialogFooter>
1344
1367
  </Dialog>
1368
+ {/* Balances the rail so the reading column lands dead-centre. In the docs
1369
+ layout the right-hand TOC does this job; a record surface has no TOC,
1370
+ so the gutter is simply reserved. */}
1371
+ {wide ? <View style={{ width: GUTTER, flexShrink: 0 }} /> : null}
1372
+ </View>
1345
1373
  </ScrollView>
1374
+ {/* The rail floats over its reserved gutter — pinned while the record
1375
+ scrolls, and out of the scroll row so the page stays wheel-scrollable
1376
+ edge to edge. */}
1377
+ {pageWidth == null || !wide ? null : (
1378
+ <View style={{ position: "absolute", top: 28, left: railLeft, width: RAIL_W }}>
1379
+ <View style={{ gap: 2 }}>
1380
+ {SECTIONS.map((sec) => (
1381
+ <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
1382
+ ))}
1383
+ </View>
1384
+ </View>
1385
+ )}
1386
+ </View>
1346
1387
  </View>
1347
1388
  );
1348
1389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.8.3",
3
+ "version": "11.8.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -131,6 +131,8 @@
131
131
  "./use_screen_size": "./src/use_screen_size.ts",
132
132
  "./use_auto_grow_height": "./src/use_auto_grow_height.ts",
133
133
  "./use_focus_ring": "./src/use_focus_ring.ts",
134
+ "./interaction_modality": "./src/interaction_modality.ts",
135
+ "./inline_focus": "./src/inline_focus.ts",
134
136
  "./use_hover": "./src/use_hover.ts",
135
137
  "./focus_ring_pressable": "./src/focus_ring_pressable.tsx",
136
138
  "./control_surface": "./src/control_surface.ts",
@@ -1,26 +1,64 @@
1
1
  import { IconButton } from "./icon_button";
2
- import { View } from "react-native";
2
+ import { Pressable, View } from "react-native";
3
+ import { Icon } from "./icon";
4
+ import { Text } from "./text";
5
+ import { colors } from "./colors";
3
6
  import { useLoticsLocale } from "./locale";
4
7
 
5
8
  interface BackButtonProps {
6
9
  onPress: () => void;
7
- /** Accessible name. Defaults to the locale's `nav.back` ("Back" / "Quay lại"). */
10
+ /** Names where back GOES ("Orders", "Danh sách"). Renders beside the glyph as
11
+ * part of the SAME control — a label you can't press is a lie. Omit for the
12
+ * bare glyph. */
13
+ label?: string;
14
+ /** Accessible name. Defaults to `label`, else the locale's `nav.back`
15
+ * ("Back" / "Quay lại"). */
8
16
  accessibilityLabel?: string;
9
17
  }
10
18
 
11
19
  export function BackButton(props: BackButtonProps) {
12
20
  const locale = useLoticsLocale();
13
- const { onPress, accessibilityLabel = locale.nav.back } = props;
21
+ const { onPress, label, accessibilityLabel = label ?? locale.nav.back } = props;
22
+ // Bare glyph — the circular icon IS the control.
23
+ if (label === undefined) {
24
+ return (
25
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
26
+ <IconButton
27
+ icon="chevron-left"
28
+ size="lg"
29
+ color="secondary"
30
+ accessibilityLabel={accessibilityLabel}
31
+ onPress={onPress}
32
+ style={{ alignSelf: "flex-start" }}
33
+ />
34
+ </View>
35
+ );
36
+ }
37
+ // Labelled — ONE pressable over glyph + label, so the words are a target too
38
+ // (an IconButton beside a Text would be a control next to unclickable prose).
14
39
  return (
15
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
16
- <IconButton
17
- icon="chevron-left"
18
- size="lg"
19
- color="secondary"
20
- accessibilityLabel={accessibilityLabel}
21
- onPress={onPress}
22
- style={{ alignSelf: "flex-start" }}
23
- />
24
- </View>
40
+ <Pressable
41
+ accessibilityRole="button"
42
+ accessibilityLabel={accessibilityLabel}
43
+ onPress={onPress}
44
+ style={({ pressed, hovered }) => [
45
+ {
46
+ flexDirection: "row",
47
+ alignItems: "center",
48
+ gap: 6,
49
+ alignSelf: "flex-start",
50
+ paddingVertical: 6,
51
+ paddingLeft: 6,
52
+ paddingRight: 10,
53
+ borderRadius: 8,
54
+ },
55
+ (hovered || pressed) && { backgroundColor: colors.zinc[100] },
56
+ ]}
57
+ >
58
+ <Icon name="chevron-left" size={16} color={colors.zinc[500]} />
59
+ <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
60
+ {label}
61
+ </Text>
62
+ </Pressable>
25
63
  );
26
64
  }
@@ -108,6 +108,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
108
108
  mimeType={file.mimeType}
109
109
  size={size}
110
110
  isTemplate={isTemplate}
111
+ accessibilityLabel={file.filename}
111
112
  onPress={onPress ?? (() => Linking.openURL(file.url))}
112
113
  onLongPress={onLongPress}
113
114
  />
@@ -179,17 +180,27 @@ interface DocumentBadgeProps {
179
180
  isTemplate?: boolean;
180
181
  onPress?: () => void;
181
182
  onLongPress?: () => void;
183
+ /** Accessible name for the pressable badge — the filename it stands for.
184
+ * Falls back to the file-type label (e.g. "PDF") when omitted. */
185
+ accessibilityLabel?: string;
182
186
  }
183
187
 
184
188
  /**
185
189
  * Compact two-tone file badge with document lines and colored label.
186
190
  */
187
191
  export function DocumentBadge(props: DocumentBadgeProps) {
188
- const { mimeType, isTemplate, onPress, onLongPress } = props;
192
+ const { mimeType, isTemplate, onPress, onLongPress, accessibilityLabel } = props;
189
193
  const { focusVisible, focusProps } = useFocusRing();
190
194
 
191
195
  return (
192
- <Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}>
196
+ <Pressable
197
+ onPress={onPress}
198
+ onLongPress={onLongPress}
199
+ accessibilityRole="button"
200
+ accessibilityLabel={accessibilityLabel ?? resolveMime(mimeType).label}
201
+ {...focusProps}
202
+ style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}
203
+ >
193
204
  <FileBadge mimeType={mimeType} isTemplate={isTemplate} />
194
205
  </Pressable>
195
206
  );
@@ -234,6 +245,8 @@ export function DocumentCard(props: DocumentCardProps) {
234
245
  ]}
235
246
  onPress={onPress}
236
247
  onLongPress={onLongPress}
248
+ accessibilityRole="button"
249
+ accessibilityLabel={filename}
237
250
  {...focusProps}
238
251
  >
239
252
  <FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
@@ -280,6 +293,8 @@ export function MediaCard(props: MediaCardProps) {
280
293
  style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim, focusVisible && { boxShadow: FOCUS_RING }]}
281
294
  onPress={onPress}
282
295
  onLongPress={onLongPress}
296
+ accessibilityRole="button"
297
+ accessibilityLabel={filename}
283
298
  {...focusProps}
284
299
  >
285
300
  <Icon name={icon} size={iconSize} color={colors.white} />
@@ -292,7 +307,7 @@ export function MediaCard(props: MediaCardProps) {
292
307
  const backdrop = iconSize + 20;
293
308
 
294
309
  return (
295
- <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} {...focusProps}>
310
+ <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} accessibilityRole="button" accessibilityLabel={filename} {...focusProps}>
296
311
  {(state) => (
297
312
  <>
298
313
  <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
@@ -343,7 +358,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
343
358
  : { width: "100%" as const, height: "100%" as const };
344
359
 
345
360
  return (
346
- <Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
361
+ <Pressable onPress={onPress} onLongPress={onLongPress} accessibilityRole={onPress ? "button" : "image"} accessibilityLabel={file.filename} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
347
362
  {(state) => (
348
363
  <>
349
364
  <Image