@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 +1 -1
- package/docs/catalog.md +11 -6
- package/docs/data_entry.md +32 -18
- package/docs/templates.md +20 -0
- package/examples/tpl_record.tsx +53 -12
- package/package.json +3 -1
- package/src/back_button.tsx +51 -13
- package/src/file_thumbnail.tsx +19 -4
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
|
|
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
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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`
|
|
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).
|
package/docs/data_entry.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 (
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
635
|
-
{pageWidth == null ? null :
|
|
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={{
|
|
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
|
+
"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",
|
package/src/back_button.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -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
|
|
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
|