@lotics/ui 7.5.0 → 7.7.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 +30 -14
- package/package.json +2 -1
- package/src/accordion.tsx +9 -7
- package/src/file_gallery_modal.tsx +152 -100
- package/src/file_preview.web.tsx +2 -0
- package/src/files_editor.tsx +30 -16
- package/src/modal.tsx +163 -0
- package/src/section_heading.tsx +51 -2
- package/src/tabs.tsx +16 -8
package/AGENTS.md
CHANGED
|
@@ -108,16 +108,19 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
108
108
|
`Matrix` (the PIVOT cross-tab: the NUMBER in each cell — optionally a heat wash behind it — plus
|
|
109
109
|
row/column/grand totals; press a cell to drill).
|
|
110
110
|
- **Surfaces & layout** — `Card` (+ `CardHeader`/`CardHeaderTitle`/`CardHeaderMeta`/`CardBody`/
|
|
111
|
-
`CardFooter`), `
|
|
111
|
+
`CardFooter`), `Section` (the card-less twin of `Card`: bare gap-spaced region + `SectionHeading`/
|
|
112
|
+
`SectionHeadingTitle`/`SectionHeadingMeta`; no body component — children are the body), `SectionCard`,
|
|
113
|
+
`PageHeader` /
|
|
112
114
|
`PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`.
|
|
113
115
|
- **Rows & registers** — `PressableRow` (THE register row), `ListItem`, `MenuButton`,
|
|
114
116
|
`MenuListItem`, `DetailRow` (label+value for drawers/peeks), `ActionMenu` (⋯), `FloatingActionBar`
|
|
115
117
|
(bulk-select bar).
|
|
116
118
|
- **Filters & view controls** — `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`,
|
|
117
119
|
`Counter`), `Chip`.
|
|
118
|
-
- **Overlays** — `Dialog
|
|
119
|
-
|
|
120
|
-
`
|
|
120
|
+
- **Overlays** — `Dialog` (centered card over a scrim), `Modal` (+ `ModalHeader`/`ModalBody`/
|
|
121
|
+
`ModalFooter` — a full-bleed, edge-to-edge takeover with NO scrim), `Drawer` (+ `DrawerFooter`),
|
|
122
|
+
`Popover`, `Tooltip`, `OptionList` (the searchable list body — host it in a `Popover`/`Dialog` for
|
|
123
|
+
a command palette), `Alert` (the blocking confirm).
|
|
121
124
|
- **Status / feedback** — `Badge` / `StatusBadge`, `Callout` (inline status), `EmptyState`,
|
|
122
125
|
`CompletionState`, `ActivityIndicator` / `Loading`, `Skeleton`.
|
|
123
126
|
- **Files** — `FilesEditor` (THE all-in-one attachment field: an upload-aware grid + a toolbar below
|
|
@@ -285,8 +288,12 @@ marked + disabled), not a generic text menu. A composition (Badge + Button + Pop
|
|
|
285
288
|
### Attachments — a full add / preview / DELETE field
|
|
286
289
|
**Default: `<FilesEditor files onAdd onRemove>`** — it bundles the grid + a toolbar (Upload · Select ·
|
|
287
290
|
Download all) + a batch SELECT mode + the gallery + confirmed-remove; the host only owns `files` and
|
|
288
|
-
wires `onAdd` (picked → its upload) / `onRemove`.
|
|
289
|
-
|
|
291
|
+
wires `onAdd` (picked → its upload) / `onRemove`. The destructive per-tile ✕ shows only in SELECT mode
|
|
292
|
+
(the default view is a clean preview — no stray-tap deletes); the built-in gallery is Download +
|
|
293
|
+
inline preview (no "open in new tab" — it's redundant once everything previews inline). In a
|
|
294
|
+
height-bounded container (a popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar
|
|
295
|
+
pins below it; omit it in free-flow layouts (a form field) where the grid grows. Reach for the
|
|
296
|
+
lower-level pieces below only when you need custom chrome:
|
|
290
297
|
Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
|
|
291
298
|
accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`** ABOVE
|
|
292
299
|
the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add more"
|
|
@@ -298,8 +305,13 @@ hand-map an upload to a `FileThumbnail`. Localize the labels with `labels.upload
|
|
|
298
305
|
`UploadStatusLabels`; the Lotics `@lotics/ui-internal` adapter already injects vi/en). Make it CRUDable
|
|
299
306
|
by wiring its callbacks: `onFilePress`
|
|
300
307
|
→ set a `number|null` index that drives `<FileGalleryModal files activeIndex onIndexChange>` — a
|
|
301
|
-
FULL-SCREEN viewer with a toolbar (filename · counter ·
|
|
302
|
-
`onOpenExternal`/`onRemove` · close-✕), prev/next, ESC, and rotate
|
|
308
|
+
FULL-SCREEN viewer with a toolbar (filename · counter · actions — download, optional
|
|
309
|
+
`onOpenExternal`/`onRemove` · close-✕), prev/next, ESC, and rotate. The toolbar is RESPONSIVE: on a
|
|
310
|
+
phone (`useScreenSize().small`) the actions collapse into a `⋯` `ActionMenu` so the close-✕ never
|
|
311
|
+
overflows off-screen (its popover portals inside the modal's own `PortalHost` — a raw `<Modal>` lacks
|
|
312
|
+
one, which is why an earlier inline-less popover rendered behind the overlay). The 90° rotate controls
|
|
313
|
+
for images are NOT in the toolbar — they FLOAT as a pill overlaid on the image (every screen size).
|
|
314
|
+
`onDisplayRemove`/`onUploadRemove`
|
|
303
315
|
→ drop the file / cancel the upload (the grid renders a ✕ on each tile automatically); `onRetry` /
|
|
304
316
|
`onRetryAll` for failed uploads. In a sandboxed custom-code app the toolbar's "open in new tab" can't
|
|
305
317
|
pop a window — pass `onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the
|
|
@@ -618,7 +630,11 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
618
630
|
the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
|
|
619
631
|
Expansion happens IMMEDIATELY below the pressed element — the composable `Accordion` family
|
|
620
632
|
(`AccordionHeader`/`AccordionTitle`/`AccordionMeta`/`AccordionContent`); header-only = a plain row
|
|
621
|
-
of identical rhythm, so lists mix expandable + static rows.
|
|
633
|
+
of identical rhythm, so lists mix expandable + static rows. The body is **flush** with the
|
|
634
|
+
header's left edge (aligns with the title — like a `Section` body, no indent). `AccordionHeader`
|
|
635
|
+
is a layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but for a
|
|
636
|
+
**collapsible Section** (configurable font/level/description + count) compose the SAME
|
|
637
|
+
`SectionHeadingTitle`/`SectionHeadingMeta` inside it — one heading family across static + collapsible.
|
|
622
638
|
- **No dead rows.** Every listed record is actionable. PRIMARY entity rows press-open the workspace
|
|
623
639
|
`Drawer` (sequenced); read-only drill-downs expand via `Accordion` or glance via `Peek`; every
|
|
624
640
|
other row gets an `ActionMenu` (⋯ → MenuButton items: destructive last + **confirmed** via
|
|
@@ -679,7 +695,7 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
679
695
|
port, a customer) rather than a short fixed list. Pass `recentOptions` (the same option list) so
|
|
680
696
|
a click opens the full list, then typing filters. The **From → To route picker** is two of them
|
|
681
697
|
(`icon="map-pin"`, `clearable`) with an `arrow-right` between — flight-search style.
|
|
682
|
-
· `Tabs` switch between VIEWS/sections (different content/layout) — never a subset of one list.
|
|
698
|
+
· `Tabs` switch between VIEWS/sections (different content/layout) — never a subset of one list. A tab earns a `status` dot (optional `TabOption.status`) only when its area needs attention (a blocker / missing item) — the resting state has none.
|
|
683
699
|
`SegmentedControl` chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
|
|
684
700
|
- **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter`/`DrawerFooter` default
|
|
685
701
|
`align="end"` — commit at the right edge, secondary to its left. Never left-flow a Save/Submit.
|
|
@@ -788,11 +804,11 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
|
|
|
788
804
|
text ·
|
|
789
805
|
markdown (Markdown — the single canonical markdown renderer for chat, apps, and `AgentRun`; rich on web via react-markdown/remark-gfm with copyable tables, plain-text on native; takes a markdown `children` string; import `@lotics/ui/markdown.css` once for styling) ·
|
|
790
806
|
card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
|
|
791
|
-
section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
|
|
807
|
+
section_heading (Section · SectionHeading · SectionHeadingTitle · SectionHeadingMeta — the card-less twin of the Card family, compound, owns no margin; spacing via the Section gap, no body component. `SectionHeadingTitle` takes `weight` medium|semibold — default medium, `semibold` for dense surfaces (a side panel, a stacked group list) where medium blends into the body, matching `CardHeaderTitle`; + `info` for an ⓘ provenance popover after the title, same as `CardHeaderTitle.info`) · badge ·
|
|
792
808
|
option_badge (OptionBadge — a select value as its configured colored badge) ·
|
|
793
809
|
member_chip (MemberChip — avatar + name; the universal person render) ·
|
|
794
810
|
member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
|
|
795
|
-
status_badge · button · icon_button · link · text_link (TextLink — underlined text that's optionally an `onPress` action or an `href` link, or plain underlined text to wrap in your own pressable; the neutral counterpart to the fixed-blue `Link`) · chip · tabs · segmented_control ·
|
|
811
|
+
status_badge · button · icon_button · link · text_link (TextLink — underlined text that's optionally an `onPress` action or an `href` link, or plain underlined text to wrap in your own pressable; the neutral counterpart to the fixed-blue `Link`) · chip · tabs (Tabs — switch between content sections; WAI-ARIA tablist + roving tabindex; each TabOption takes an optional `status` ColorName → a small attention dot before its label, for a tab whose area needs work) · segmented_control ·
|
|
796
812
|
picker (native `<select>`, plain label-only single) · select (Select — rich/custom-rendered, single/multi, select-all, chips via `renderSelected` + `searchable` + `allowCustom` — the tag field is just a multi Select; opens `OptionList`) · option_list (OptionList — the ONE shared searchable listbox body every selector opens: single/multi, optional internal search, create row, keyboard + native-`<select>` typeahead; host it directly in a `Popover`/`Dialog` for a command palette) · combobox (COMPOUND single-select editable search: `Combobox` root + `ComboboxInput` + `ComboboxContent`, optional `ComboboxEmpty`/`ComboboxFooter`, `useCombobox()`; over the shared `useOptionList` engine; browses on focus; no `multi` — multi-value chips → `Select multi`) ·
|
|
797
813
|
text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
|
|
798
814
|
radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
|
|
@@ -804,13 +820,13 @@ check_circle (CheckCircle — the completion ring: an empty ring that springs to
|
|
|
804
820
|
floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
|
|
805
821
|
columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
|
|
806
822
|
sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Examples: `tpl_task_board`, `tpl_pipeline`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
|
|
807
|
-
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
|
|
823
|
+
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · popover · tooltip ·
|
|
808
824
|
alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
|
|
809
825
|
CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
|
|
810
826
|
bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
|
|
811
827
|
funnel (Funnel — conversion funnel: narrowing bars + the step rate as a bold aligned headline row, count below; `orientation` vertical|horizontal, `onSelect`/`selectedKey` press-to-drill; the subset/drop-off sibling of stacked_progress_bar's whole-split) ·
|
|
812
828
|
status_grid (StatusGrid + StatusLegend) · heatmap (density cross-tab — colour-only, no numbers) · matrix (Matrix — the PIVOT cross-tab: band-compound `Matrix` root + `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) + `Matrix.Legend`; the data layer `matrixTotals`/`MatrixAxisItem`/`MatrixCellRef` import from `@lotics/ui/matrix_totals`. Pick over `Heatmap` when the NUMBER and totals matter, not just where it clusters) · legend_item · remainder_meter · allocation_row ·
|
|
813
|
-
scan_field · file_dropzone · files_editor (FilesEditor — THE all-in-one attachment field: FileGrid + a toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode (Select all · a Menu of Download/Share/Delete · Done) + built-in gallery + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `labels`, `galleryLabels`); mirrors the frontend cell_files_editor. Use FileGrid/FileRows bare only when you own the chrome) · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview ·
|
|
829
|
+
scan_field · file_dropzone · files_editor (FilesEditor — THE all-in-one attachment field: FileGrid + a toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode (Select all · a Menu of Download/Share/Delete · Done; the per-tile ✕ is select-mode-only, never in the default view) + built-in gallery (Download + inline preview; no "open in new tab") + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `labels`, `galleryLabels`, `gridMaxHeight` — cap the grid height so it scrolls and the toolbar pins, for a popover/drawer); mirrors the frontend cell_files_editor. Use FileGrid/FileRows bare only when you own the chrome) · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview (FilePreview — the universal inline preview: image/PDF/video/audio + Word via `@lotics/docx` + Excel/CSV via `@lotics/xlsx`; the heavy engines are LAZY (dynamic-imported, ~free until a doc of that type is opened) so custom-code APPS can use it too — they just add `@lotics/docx` + `@lotics/xlsx` to their deps. Renders to canvas/DOM, never a nested iframe — works in the sandboxed app iframe) ·
|
|
814
830
|
file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
|
|
815
831
|
spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
|
|
816
832
|
comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"./date_calendar": "./src/date_calendar.tsx",
|
|
194
194
|
"./dialog": "./src/dialog.tsx",
|
|
195
195
|
"./drawer": "./src/drawer.tsx",
|
|
196
|
+
"./modal": "./src/modal.tsx",
|
|
196
197
|
"./screen_router": "./src/screen_router.tsx",
|
|
197
198
|
"./route_matching": "./src/route_matching.ts",
|
|
198
199
|
"./menu_title": "./src/menu_title.tsx",
|
package/src/accordion.tsx
CHANGED
|
@@ -77,8 +77,10 @@ export function Accordion(props: AccordionProps) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export interface AccordionHeaderProps {
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
80
|
+
/** A layout slot. `AccordionTitle` (+ `AccordionMeta`) is the compact
|
|
81
|
+
* list-row heading; for a configurable section-style header, compose the
|
|
82
|
+
* SAME `SectionHeadingTitle`/`SectionHeadingMeta` you'd use in a `Section`
|
|
83
|
+
* (full font/level/description config). Plus a `Badge` or any right-side node. */
|
|
82
84
|
children: ReactNode;
|
|
83
85
|
accessibilityLabel?: string;
|
|
84
86
|
style?: StyleProp<ViewStyle>;
|
|
@@ -153,9 +155,9 @@ export interface AccordionContentProps {
|
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
/** The collapsible body, revealed in place under the header. Plain by
|
|
156
|
-
* design —
|
|
157
|
-
* chevron + position already say "inside". Compose
|
|
158
|
-
* directly. */
|
|
158
|
+
* design — flush with the header's left edge (aligns with the title), no
|
|
159
|
+
* tinted well; the chevron + position already say "inside". Compose
|
|
160
|
+
* rows/dividers/actions directly. */
|
|
159
161
|
export function AccordionContent(props: AccordionContentProps) {
|
|
160
162
|
const { expanded } = useAccordionContext();
|
|
161
163
|
if (!expanded) return null;
|
|
@@ -185,9 +187,9 @@ const styles = StyleSheet.create({
|
|
|
185
187
|
titleText: {
|
|
186
188
|
flex: 1,
|
|
187
189
|
},
|
|
188
|
-
//
|
|
190
|
+
// Flush with the header's left edge — aligns with the title, like a Section
|
|
191
|
+
// body. The chevron + position already say "inside"; no indent needed.
|
|
189
192
|
content: {
|
|
190
|
-
paddingLeft: 28,
|
|
191
193
|
paddingBottom: 10,
|
|
192
194
|
},
|
|
193
195
|
});
|
|
@@ -20,6 +20,9 @@ import { isImageMimeType } from "./mime";
|
|
|
20
20
|
import { useImageRotation, type ImageRotation } from "./use_image_rotation";
|
|
21
21
|
import { resolveGalleryLabels, type GalleryLabels } from "./file_preview_types";
|
|
22
22
|
import { downloadFileFromUrl } from "./download";
|
|
23
|
+
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
24
|
+
import { useScreenSize } from "./use_screen_size";
|
|
25
|
+
import { PortalHost } from "./portal";
|
|
23
26
|
|
|
24
27
|
export interface FileGalleryModalProps {
|
|
25
28
|
files: DisplayFile[];
|
|
@@ -107,6 +110,10 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
107
110
|
// Own the rotation when the host doesn't share one (standalone consumers).
|
|
108
111
|
const internalRotation = useImageRotation();
|
|
109
112
|
const rot = rotation ?? internalRotation;
|
|
113
|
+
// Rotate controls float over the image on every screen. On phones the toolbar
|
|
114
|
+
// additionally can't hold filename + every action inline (the close button gets
|
|
115
|
+
// pushed off-screen), so the remaining actions collapse into a ⋯ menu.
|
|
116
|
+
const { small } = useScreenSize();
|
|
110
117
|
|
|
111
118
|
const close = useCallback(() => onIndexChange(null), [onIndexChange]);
|
|
112
119
|
const next = useCallback(() => {
|
|
@@ -189,111 +196,139 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
189
196
|
]);
|
|
190
197
|
};
|
|
191
198
|
|
|
199
|
+
// Same actions the desktop toolbar shows inline, collapsed into the ⋯ menu on
|
|
200
|
+
// phones. Download is always present; open-external / remove follow their props.
|
|
201
|
+
const actionItems: ActionMenuItem[] = [
|
|
202
|
+
{ key: "download", label: l.download, icon: "download", onPress: handleDownload },
|
|
203
|
+
...(onOpenExternal
|
|
204
|
+
? [{ key: "open", label: l.openExternal, icon: "external-link", onPress: handleOpenExternal } as const]
|
|
205
|
+
: []),
|
|
206
|
+
...(onRemove
|
|
207
|
+
? [{ key: "remove", label: l.remove, icon: "trash", danger: true, onPress: handleRemove } as const]
|
|
208
|
+
: []),
|
|
209
|
+
];
|
|
210
|
+
|
|
192
211
|
return (
|
|
193
212
|
<Modal visible transparent onRequestClose={close} animationType="fade">
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
{
|
|
200
|
-
<Text size="
|
|
201
|
-
{
|
|
213
|
+
{/* PortalHost so the ⋯ menu's popover portals INSIDE the modal's stacking
|
|
214
|
+
context (on top) instead of to the app-root host behind the overlay —
|
|
215
|
+
the dead-menu bug that drove #1850's inline buttons. */}
|
|
216
|
+
<PortalHost>
|
|
217
|
+
<View style={styles.surface}>
|
|
218
|
+
<View style={styles.toolbar}>
|
|
219
|
+
<Text size="sm" weight="medium" numberOfLines={1} style={styles.filename}>
|
|
220
|
+
{file.filename}
|
|
202
221
|
</Text>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
{onPersistRotation && rotated ? (
|
|
223
|
-
<IconButton
|
|
224
|
-
size="lg"
|
|
225
|
-
icon="check"
|
|
226
|
-
tooltip={l.saveRotation}
|
|
227
|
-
color="primary"
|
|
228
|
-
disabled={persisting}
|
|
229
|
-
onPress={() => {
|
|
230
|
-
if (!persisting) onPersistRotation(file, rot.rotationFor(file.id));
|
|
231
|
-
}}
|
|
222
|
+
{total > 1 ? (
|
|
223
|
+
<Text size="xs" color="muted" style={styles.counter}>
|
|
224
|
+
{`${activeIndex + 1} / ${total}`}
|
|
225
|
+
</Text>
|
|
226
|
+
) : null}
|
|
227
|
+
<View style={styles.spacer} />
|
|
228
|
+
|
|
229
|
+
{small ? (
|
|
230
|
+
// Phone: actions collapse to a ⋯ menu.
|
|
231
|
+
<ActionMenu items={actionItems} accessibilityLabel={l.actions} />
|
|
232
|
+
) : (
|
|
233
|
+
// Wider screens: every action inline (download · open · remove).
|
|
234
|
+
// Rotate lives on the image overlay, not the toolbar.
|
|
235
|
+
<>
|
|
236
|
+
<Button
|
|
237
|
+
icon="download"
|
|
238
|
+
title={l.download}
|
|
239
|
+
color="secondary"
|
|
240
|
+
onPress={handleDownload}
|
|
232
241
|
/>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
242
|
+
{onOpenExternal ? (
|
|
243
|
+
<Button
|
|
244
|
+
icon="external-link"
|
|
245
|
+
title={l.openExternal}
|
|
246
|
+
color="secondary"
|
|
247
|
+
onPress={handleOpenExternal}
|
|
248
|
+
/>
|
|
249
|
+
) : null}
|
|
250
|
+
{onRemove ? (
|
|
251
|
+
<Button
|
|
252
|
+
icon="trash"
|
|
253
|
+
title={l.remove}
|
|
254
|
+
color="danger-secondary"
|
|
255
|
+
onPress={handleRemove}
|
|
256
|
+
/>
|
|
257
|
+
) : null}
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
|
|
262
|
+
</View>
|
|
263
|
+
|
|
264
|
+
<View style={styles.previewArea}>
|
|
265
|
+
{renderPreview ? (
|
|
266
|
+
renderPreview(file, { rotation: rot.rotationFor(file.id) })
|
|
267
|
+
) : (
|
|
268
|
+
<FilePreview
|
|
269
|
+
file={file}
|
|
270
|
+
labels={labels}
|
|
271
|
+
onError={onError}
|
|
272
|
+
rotation={rot.rotationFor(file.id)}
|
|
273
|
+
credentials={credentials}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{activeIndex > 0 ? (
|
|
278
|
+
<FocusRingPressable accessibilityRole="button"
|
|
279
|
+
onPress={prev}
|
|
280
|
+
accessibilityLabel={l.previous}
|
|
281
|
+
style={[styles.nav, styles.navLeft]}
|
|
282
|
+
>
|
|
283
|
+
<Icon name="chevron-left" size={28} color={colors.white} />
|
|
284
|
+
</FocusRingPressable>
|
|
285
|
+
) : null}
|
|
286
|
+
|
|
287
|
+
{activeIndex < total - 1 ? (
|
|
288
|
+
<FocusRingPressable accessibilityRole="button"
|
|
289
|
+
onPress={next}
|
|
290
|
+
accessibilityLabel={l.next}
|
|
291
|
+
style={[styles.nav, styles.navRight]}
|
|
292
|
+
>
|
|
293
|
+
<Icon name="chevron-right" size={28} color={colors.white} />
|
|
294
|
+
</FocusRingPressable>
|
|
295
|
+
) : null}
|
|
296
|
+
|
|
297
|
+
{isImage && rotatable ? (
|
|
298
|
+
<View style={styles.rotateOverlay} pointerEvents="box-none">
|
|
299
|
+
<View style={styles.rotateCluster}>
|
|
300
|
+
<IconButton
|
|
301
|
+
size="lg"
|
|
302
|
+
icon="rotate-ccw"
|
|
303
|
+
tooltip={l.rotateLeft}
|
|
304
|
+
color="white"
|
|
305
|
+
onPress={() => rot.rotate(file.id, -1)}
|
|
306
|
+
/>
|
|
307
|
+
<IconButton
|
|
308
|
+
size="lg"
|
|
309
|
+
icon="rotate-cw"
|
|
310
|
+
tooltip={l.rotateRight}
|
|
311
|
+
color="white"
|
|
312
|
+
onPress={() => rot.rotate(file.id, 1)}
|
|
313
|
+
/>
|
|
314
|
+
{onPersistRotation && rotated ? (
|
|
315
|
+
<IconButton
|
|
316
|
+
size="lg"
|
|
317
|
+
icon="check"
|
|
318
|
+
tooltip={l.saveRotation}
|
|
319
|
+
color="white"
|
|
320
|
+
disabled={persisting}
|
|
321
|
+
onPress={() => {
|
|
322
|
+
if (!persisting) onPersistRotation(file, rot.rotationFor(file.id));
|
|
323
|
+
}}
|
|
324
|
+
/>
|
|
325
|
+
) : null}
|
|
326
|
+
</View>
|
|
327
|
+
</View>
|
|
328
|
+
) : null}
|
|
329
|
+
</View>
|
|
295
330
|
</View>
|
|
296
|
-
</
|
|
331
|
+
</PortalHost>
|
|
297
332
|
</Modal>
|
|
298
333
|
);
|
|
299
334
|
}
|
|
@@ -343,4 +378,21 @@ const styles = StyleSheet.create({
|
|
|
343
378
|
navRight: {
|
|
344
379
|
right: 16,
|
|
345
380
|
},
|
|
381
|
+
rotateOverlay: {
|
|
382
|
+
position: "absolute",
|
|
383
|
+
bottom: 24,
|
|
384
|
+
left: 0,
|
|
385
|
+
right: 0,
|
|
386
|
+
flexDirection: "row",
|
|
387
|
+
justifyContent: "center",
|
|
388
|
+
},
|
|
389
|
+
rotateCluster: {
|
|
390
|
+
flexDirection: "row",
|
|
391
|
+
alignItems: "center",
|
|
392
|
+
gap: 4,
|
|
393
|
+
paddingHorizontal: 8,
|
|
394
|
+
paddingVertical: 6,
|
|
395
|
+
borderRadius: 28,
|
|
396
|
+
backgroundColor: "rgba(0, 0, 0, 0.55)",
|
|
397
|
+
},
|
|
346
398
|
});
|
package/src/file_preview.web.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// eslint-disable-next-line typescript/triple-slash-reference -- the ambient pdfjs_worker.d.ts must be referenced so its `declare module` reaches a consuming app's tsc; an ESM import can't pull in an ambient declaration, and inlining it errors (TS2665 — can't augment an untyped resolved module)
|
|
2
|
+
/// <reference path="./pdfjs_worker.d.ts" />
|
|
1
3
|
import { useEffect, useRef, useState } from "react";
|
|
2
4
|
import { View, StyleSheet } from "react-native";
|
|
3
5
|
import { Text } from "./text";
|
package/src/files_editor.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { View, ScrollView, StyleSheet } from "react-native";
|
|
3
3
|
import { Button } from "./button";
|
|
4
4
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
5
5
|
import { MenuButton } from "./menu_button";
|
|
@@ -80,6 +80,10 @@ export interface FilesEditorProps {
|
|
|
80
80
|
minItemWidth?: number;
|
|
81
81
|
/** Fixed column count for the grid (passed through to `FileGrid`). */
|
|
82
82
|
columns?: number;
|
|
83
|
+
/** Cap the grid's height (px) so it SCROLLS and the toolbar pins below it —
|
|
84
|
+
* for a height-bounded container (a popover, a drawer). Omit in free-flow
|
|
85
|
+
* layouts (a form field) where the grid should grow and the page scrolls. */
|
|
86
|
+
gridMaxHeight?: number;
|
|
83
87
|
/** Translated labels. Defaults to English. */
|
|
84
88
|
labels?: Partial<FilesEditorLabels>;
|
|
85
89
|
/** Credentials mode for the built-in gallery's preview fetches — `"include"`
|
|
@@ -118,6 +122,7 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
118
122
|
itemSize,
|
|
119
123
|
minItemWidth,
|
|
120
124
|
columns,
|
|
125
|
+
gridMaxHeight,
|
|
121
126
|
labels,
|
|
122
127
|
credentials,
|
|
123
128
|
galleryLabels,
|
|
@@ -148,22 +153,32 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
148
153
|
void pickFiles({ multiple: true, accept }).then((picked) => { if (picked.length > 0) onAdd(picked); });
|
|
149
154
|
};
|
|
150
155
|
|
|
156
|
+
const grid = (
|
|
157
|
+
<FileGrid
|
|
158
|
+
files={files}
|
|
159
|
+
uploads={uploads}
|
|
160
|
+
itemSize={itemSize}
|
|
161
|
+
minItemWidth={minItemWidth}
|
|
162
|
+
columns={columns}
|
|
163
|
+
selectedIds={selectMode ? selected : undefined}
|
|
164
|
+
onToggleSelect={selectMode ? toggle : undefined}
|
|
165
|
+
onFilePress={selectMode ? undefined : (f) => setGalleryIndex(files.findIndex((x) => x.id === f.id))}
|
|
166
|
+
// The per-tile ✕ is a SELECT-mode affordance — the default view stays a clean
|
|
167
|
+
// preview surface so a stray tap can't drop a file.
|
|
168
|
+
onDisplayRemove={!readOnly && selectMode && onRemove ? (id) => confirmRemove(() => onRemove(id)) : undefined}
|
|
169
|
+
onUploadRemove={onUploadRemove}
|
|
170
|
+
onRetry={onRetry}
|
|
171
|
+
onRetryAll={onRetryAll}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
|
|
151
175
|
return (
|
|
152
176
|
<View style={styles.root}>
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
columns={columns}
|
|
159
|
-
selectedIds={selectMode ? selected : undefined}
|
|
160
|
-
onToggleSelect={selectMode ? toggle : undefined}
|
|
161
|
-
onFilePress={selectMode ? undefined : (f) => setGalleryIndex(files.findIndex((x) => x.id === f.id))}
|
|
162
|
-
onDisplayRemove={!readOnly && !selectMode && onRemove ? (id) => confirmRemove(() => onRemove(id)) : undefined}
|
|
163
|
-
onUploadRemove={onUploadRemove}
|
|
164
|
-
onRetry={onRetry}
|
|
165
|
-
onRetryAll={onRetryAll}
|
|
166
|
-
/>
|
|
177
|
+
{gridMaxHeight !== undefined ? (
|
|
178
|
+
<ScrollView style={{ maxHeight: gridMaxHeight }}>{grid}</ScrollView>
|
|
179
|
+
) : (
|
|
180
|
+
grid
|
|
181
|
+
)}
|
|
167
182
|
|
|
168
183
|
{readOnly ? (
|
|
169
184
|
hasFiles ? (
|
|
@@ -216,7 +231,6 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
216
231
|
onIndexChange={setGalleryIndex}
|
|
217
232
|
onRemove={!readOnly && onRemove ? onRemove : undefined}
|
|
218
233
|
onDownload={onDownload}
|
|
219
|
-
onOpenExternal={(f) => { if (typeof window !== "undefined") window.open(f.url, "_blank"); }}
|
|
220
234
|
credentials={credentials}
|
|
221
235
|
labels={galleryLabels}
|
|
222
236
|
/>
|
package/src/modal.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { Modal as RNModal, StyleSheet, View, ScrollView, ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "@lotics/ui/colors";
|
|
4
|
+
import { IconButton } from "@lotics/ui/icon_button";
|
|
5
|
+
import { Text } from "@lotics/ui/text";
|
|
6
|
+
import { PortalHost } from "@lotics/ui/portal";
|
|
7
|
+
import { useOverlayScope } from "@lotics/ui/overlay_scope";
|
|
8
|
+
|
|
9
|
+
export interface ModalProps {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* The modal body. Lay it out as a flex column — a fixed `ModalHeader` and
|
|
14
|
+
* `ModalFooter` pin while a `ModalBody` scrolls between them.
|
|
15
|
+
*/
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
testID?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A full-bleed, edge-to-edge takeover: an OPAQUE surface that covers the entire
|
|
22
|
+
* screen. Unlike `Dialog` (a centered card over a scrim) or `Drawer` (a docked
|
|
23
|
+
* panel over a scrim), a `Modal` has nothing behind it to dim — it owns the whole
|
|
24
|
+
* viewport. Reach for it for a focused capture, a multi-step wizard, or a console
|
|
25
|
+
* the user steps INTO and out of, where the surrounding chrome is a distraction.
|
|
26
|
+
*
|
|
27
|
+
* Lays children as a flex column so a fixed `ModalHeader` + `ModalFooter` pin
|
|
28
|
+
* while a `ModalBody` scrolls between them. Controlled via `open`/`onClose`; the
|
|
29
|
+
* header close control dismisses it (Escape on web, the back gesture on Android).
|
|
30
|
+
*/
|
|
31
|
+
export function Modal(props: ModalProps) {
|
|
32
|
+
const { open, onClose, children, testID } = props;
|
|
33
|
+
useOverlayScope(open);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<RNModal visible={open} onRequestClose={onClose} animationType="fade">
|
|
37
|
+
<View testID={testID} style={styles.surface}>
|
|
38
|
+
<PortalHost>{children}</PortalHost>
|
|
39
|
+
</View>
|
|
40
|
+
</RNModal>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ModalHeaderProps {
|
|
45
|
+
/** Header title. A string renders as the standard title; a node renders as-is. */
|
|
46
|
+
title?: ReactNode;
|
|
47
|
+
/** A short caption above the title (a context label / category). */
|
|
48
|
+
eyebrow?: ReactNode;
|
|
49
|
+
/** Right-aligned controls placed before the close button (e.g. a save action). */
|
|
50
|
+
actions?: ReactNode;
|
|
51
|
+
onClose: () => void;
|
|
52
|
+
style?: ViewStyle;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The fixed top bar of a `Modal`: an optional eyebrow + title on the left, an
|
|
57
|
+
* optional `actions` slot, and a close control wired to `onClose` on the right.
|
|
58
|
+
* Render it as the FIRST child of the `Modal`; it never scrolls.
|
|
59
|
+
*/
|
|
60
|
+
export function ModalHeader(props: ModalHeaderProps) {
|
|
61
|
+
const { title, eyebrow, actions, onClose, style } = props;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View style={[styles.header, style]}>
|
|
65
|
+
<View style={styles.headerText}>
|
|
66
|
+
{eyebrow !== undefined ? (
|
|
67
|
+
typeof eyebrow === "string" ? (
|
|
68
|
+
<Text size="xs" weight="medium" color="muted">
|
|
69
|
+
{eyebrow}
|
|
70
|
+
</Text>
|
|
71
|
+
) : (
|
|
72
|
+
eyebrow
|
|
73
|
+
)
|
|
74
|
+
) : null}
|
|
75
|
+
{title !== undefined ? (
|
|
76
|
+
typeof title === "string" ? (
|
|
77
|
+
<Text size="lg" weight="semibold">
|
|
78
|
+
{title}
|
|
79
|
+
</Text>
|
|
80
|
+
) : (
|
|
81
|
+
title
|
|
82
|
+
)
|
|
83
|
+
) : null}
|
|
84
|
+
</View>
|
|
85
|
+
{actions !== undefined ? <View style={styles.headerActions}>{actions}</View> : null}
|
|
86
|
+
<IconButton icon="x" size="lg" accessibilityLabel="Close" onPress={onClose} />
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ModalBodyProps {
|
|
92
|
+
children: ReactNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The flex:1 scrolling content region between a `Modal`'s pinned header and
|
|
97
|
+
* footer. Fills the remaining height; its content scrolls.
|
|
98
|
+
*/
|
|
99
|
+
export function ModalBody(props: ModalBodyProps) {
|
|
100
|
+
const { children } = props;
|
|
101
|
+
return <ScrollView contentContainerStyle={styles.bodyContent}>{children}</ScrollView>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ModalFooterProps {
|
|
105
|
+
/** The action(s) — typically right-aligned `Button`s. Prepend a
|
|
106
|
+
* `<Text style={{ flex: 1 }}>` hint to push them right with a summary on the left. */
|
|
107
|
+
children: ReactNode;
|
|
108
|
+
align?: "start" | "end" | "space-between";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The pinned bottom action bar of a `Modal` — a hairline-topped band (the same
|
|
113
|
+
* chrome as `DialogFooter` / `DrawerFooter`) that commits the modal's work.
|
|
114
|
+
* Render it as the LAST child of the `Modal`, after the `ModalBody`; the body's
|
|
115
|
+
* `flex:1` pins it to the surface bottom.
|
|
116
|
+
*/
|
|
117
|
+
export function ModalFooter(props: ModalFooterProps) {
|
|
118
|
+
const { children, align = "end" } = props;
|
|
119
|
+
const justifyContent =
|
|
120
|
+
align === "end" ? "flex-end" : align === "space-between" ? "space-between" : "flex-start";
|
|
121
|
+
|
|
122
|
+
return <View style={[styles.footer, { justifyContent }]}>{children}</View>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const styles = StyleSheet.create({
|
|
126
|
+
surface: {
|
|
127
|
+
flex: 1,
|
|
128
|
+
backgroundColor: colors.white,
|
|
129
|
+
flexDirection: "column",
|
|
130
|
+
},
|
|
131
|
+
header: {
|
|
132
|
+
flexDirection: "row",
|
|
133
|
+
alignItems: "center",
|
|
134
|
+
gap: 12,
|
|
135
|
+
paddingLeft: 24,
|
|
136
|
+
paddingRight: 16,
|
|
137
|
+
paddingVertical: 16,
|
|
138
|
+
minHeight: 64,
|
|
139
|
+
borderBottomWidth: 1,
|
|
140
|
+
borderBottomColor: colors.border,
|
|
141
|
+
},
|
|
142
|
+
headerText: {
|
|
143
|
+
flex: 1,
|
|
144
|
+
gap: 2,
|
|
145
|
+
},
|
|
146
|
+
headerActions: {
|
|
147
|
+
flexDirection: "row",
|
|
148
|
+
alignItems: "center",
|
|
149
|
+
gap: 8,
|
|
150
|
+
},
|
|
151
|
+
bodyContent: {
|
|
152
|
+
padding: 24,
|
|
153
|
+
},
|
|
154
|
+
footer: {
|
|
155
|
+
flexDirection: "row",
|
|
156
|
+
alignItems: "center",
|
|
157
|
+
gap: 12,
|
|
158
|
+
paddingHorizontal: 24,
|
|
159
|
+
paddingVertical: 16,
|
|
160
|
+
borderTopWidth: 1,
|
|
161
|
+
borderTopColor: colors.border,
|
|
162
|
+
},
|
|
163
|
+
});
|
package/src/section_heading.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
2
|
import { Text, type HeadingLevel } from "./text";
|
|
3
3
|
import { Icon, type IconName } from "./icon";
|
|
4
|
+
import { InfoPopover } from "./info_popover";
|
|
4
5
|
|
|
5
6
|
// The card-less section header — the bare-canvas sibling of `CardHeader`, built
|
|
6
7
|
// the same compound way. A title (+ optional leading icon / description) on one
|
|
@@ -13,6 +14,31 @@ import { Icon, type IconName } from "./icon";
|
|
|
13
14
|
// <Button title="Add line" … /> {/* pushed right by the grow */}
|
|
14
15
|
// </SectionHeading>
|
|
15
16
|
|
|
17
|
+
export interface SectionProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
style?: StyleProp<ViewStyle>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The card-less twin of `Card`: a bare, gap-spaced region with no surface
|
|
24
|
+
* chrome. Compose a `SectionHeading` then the body content — the gap spaces the
|
|
25
|
+
* heading from the body (and body rows from each other), so there is no
|
|
26
|
+
* `SectionBody` (a card-less body has no padding to own). Reach for `Card` when
|
|
27
|
+
* the group needs a contained surface; reach for `Section` on the bare canvas.
|
|
28
|
+
*
|
|
29
|
+
* <Section>
|
|
30
|
+
* <SectionHeading>
|
|
31
|
+
* <SectionHeadingTitle>Containers</SectionHeadingTitle>
|
|
32
|
+
* <SectionHeadingMeta>3 cont</SectionHeadingMeta>
|
|
33
|
+
* <Button … />
|
|
34
|
+
* </SectionHeading>
|
|
35
|
+
* {body}
|
|
36
|
+
* </Section>
|
|
37
|
+
*/
|
|
38
|
+
export function Section(props: SectionProps) {
|
|
39
|
+
return <View style={[{ gap: 10 }, props.style]}>{props.children}</View>;
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
export interface SectionHeadingProps {
|
|
17
43
|
children: React.ReactNode;
|
|
18
44
|
style?: StyleProp<ViewStyle>;
|
|
@@ -32,18 +58,26 @@ export interface SectionHeadingTitleProps {
|
|
|
32
58
|
icon?: IconName;
|
|
33
59
|
/** Heading rank. Defaults to 2 — typical for page-level section titles. */
|
|
34
60
|
level?: HeadingLevel;
|
|
61
|
+
/** Title weight. Defaults to `medium`; `semibold` (matching `CardHeaderTitle`)
|
|
62
|
+
* for denser surfaces — a side panel, a stacked group list — where `medium`
|
|
63
|
+
* blends into the body rows. */
|
|
64
|
+
weight?: "medium" | "semibold";
|
|
65
|
+
/** An ⓘ popover after the title — a short "what this is / where it came from"
|
|
66
|
+
* gloss, mirroring `CardHeaderTitle`'s `info`. */
|
|
67
|
+
info?: string;
|
|
35
68
|
}
|
|
36
69
|
|
|
37
70
|
/** The title block — grows to push any sibling actions to the right edge. */
|
|
38
71
|
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
39
|
-
const { children, description, icon, level = 2 } = props;
|
|
72
|
+
const { children, description, icon, level = 2, weight = "medium", info } = props;
|
|
40
73
|
return (
|
|
41
74
|
<View style={{ flex: 1, gap: 2 }}>
|
|
42
75
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
43
76
|
{icon ? <Icon name={icon} size={18} /> : null}
|
|
44
|
-
<Text level={level} weight=
|
|
77
|
+
<Text level={level} weight={weight} size="md">
|
|
45
78
|
{children}
|
|
46
79
|
</Text>
|
|
80
|
+
{info ? <InfoPopover text={info} accessibilityLabel="Giải thích dữ liệu" /> : null}
|
|
47
81
|
</View>
|
|
48
82
|
{description ? (
|
|
49
83
|
<Text color="zinc-500" size="sm">
|
|
@@ -53,3 +87,18 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
53
87
|
</View>
|
|
54
88
|
);
|
|
55
89
|
}
|
|
90
|
+
|
|
91
|
+
export interface SectionHeadingMetaProps {
|
|
92
|
+
children: React.ReactNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Right-side context for `SectionHeading` — a count ("3 cont"), unit, or
|
|
96
|
+
* period. xs muted, tabular for numerals. The card-less twin of `CardHeaderMeta`;
|
|
97
|
+
* the title's `flex: 1` pushes it (and any actions) to the right edge. */
|
|
98
|
+
export function SectionHeadingMeta(props: SectionHeadingMetaProps) {
|
|
99
|
+
return (
|
|
100
|
+
<Text size="xs" color="muted" tabular>
|
|
101
|
+
{props.children}
|
|
102
|
+
</Text>
|
|
103
|
+
);
|
|
104
|
+
}
|
package/src/tabs.tsx
CHANGED
|
@@ -2,13 +2,16 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
-
import { colors } from "./colors";
|
|
5
|
+
import { colors, solid, type ColorName } from "./colors";
|
|
6
6
|
import { CONTROL_RADIUS } from "./control_surface";
|
|
7
7
|
|
|
8
8
|
export interface TabOption<T extends string> {
|
|
9
9
|
label: string;
|
|
10
10
|
value: T;
|
|
11
11
|
testID?: string;
|
|
12
|
+
/** A small status dot before the label — set only when the tab's area needs
|
|
13
|
+
* attention (a blocker / missing item). Omit for the resting state. */
|
|
14
|
+
status?: ColorName;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
interface TabsProps<T extends string> {
|
|
@@ -99,13 +102,18 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
99
102
|
}, [option.value, option.label, option.testID, onSelectTab]);
|
|
100
103
|
|
|
101
104
|
const inner = (
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
106
|
+
{option.status ? (
|
|
107
|
+
<View style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: solid(option.status) }} />
|
|
108
|
+
) : null}
|
|
109
|
+
<Text
|
|
110
|
+
weight={selected ? "medium" : "regular"}
|
|
111
|
+
color={selected ? "zinc-900" : "zinc-700"}
|
|
112
|
+
userSelect="none"
|
|
113
|
+
>
|
|
114
|
+
{option.label}
|
|
115
|
+
</Text>
|
|
116
|
+
</View>
|
|
109
117
|
);
|
|
110
118
|
|
|
111
119
|
return (
|