@lotics/ui 7.6.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 +25 -10
- package/package.json +1 -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/section_heading.tsx +51 -2
- package/src/tabs.tsx +16 -8
package/AGENTS.md
CHANGED
|
@@ -108,7 +108,9 @@ 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`
|
|
@@ -286,8 +288,12 @@ marked + disabled), not a generic text menu. A composition (Badge + Button + Pop
|
|
|
286
288
|
### Attachments — a full add / preview / DELETE field
|
|
287
289
|
**Default: `<FilesEditor files onAdd onRemove>`** — it bundles the grid + a toolbar (Upload · Select ·
|
|
288
290
|
Download all) + a batch SELECT mode + the gallery + confirmed-remove; the host only owns `files` and
|
|
289
|
-
wires `onAdd` (picked → its upload) / `onRemove`.
|
|
290
|
-
|
|
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:
|
|
291
297
|
Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
|
|
292
298
|
accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`** ABOVE
|
|
293
299
|
the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add more"
|
|
@@ -299,8 +305,13 @@ hand-map an upload to a `FileThumbnail`. Localize the labels with `labels.upload
|
|
|
299
305
|
`UploadStatusLabels`; the Lotics `@lotics/ui-internal` adapter already injects vi/en). Make it CRUDable
|
|
300
306
|
by wiring its callbacks: `onFilePress`
|
|
301
307
|
→ set a `number|null` index that drives `<FileGalleryModal files activeIndex onIndexChange>` — a
|
|
302
|
-
FULL-SCREEN viewer with a toolbar (filename · counter ·
|
|
303
|
-
`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`
|
|
304
315
|
→ drop the file / cancel the upload (the grid renders a ✕ on each tile automatically); `onRetry` /
|
|
305
316
|
`onRetryAll` for failed uploads. In a sandboxed custom-code app the toolbar's "open in new tab" can't
|
|
306
317
|
pop a window — pass `onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the
|
|
@@ -619,7 +630,11 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
619
630
|
the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
|
|
620
631
|
Expansion happens IMMEDIATELY below the pressed element — the composable `Accordion` family
|
|
621
632
|
(`AccordionHeader`/`AccordionTitle`/`AccordionMeta`/`AccordionContent`); header-only = a plain row
|
|
622
|
-
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.
|
|
623
638
|
- **No dead rows.** Every listed record is actionable. PRIMARY entity rows press-open the workspace
|
|
624
639
|
`Drawer` (sequenced); read-only drill-downs expand via `Accordion` or glance via `Peek`; every
|
|
625
640
|
other row gets an `ActionMenu` (⋯ → MenuButton items: destructive last + **confirmed** via
|
|
@@ -680,7 +695,7 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
680
695
|
port, a customer) rather than a short fixed list. Pass `recentOptions` (the same option list) so
|
|
681
696
|
a click opens the full list, then typing filters. The **From → To route picker** is two of them
|
|
682
697
|
(`icon="map-pin"`, `clearable`) with an `arrow-right` between — flight-search style.
|
|
683
|
-
· `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.
|
|
684
699
|
`SegmentedControl` chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
|
|
685
700
|
- **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter`/`DrawerFooter` default
|
|
686
701
|
`align="end"` — commit at the right edge, secondary to its left. Never left-flow a Save/Submit.
|
|
@@ -789,11 +804,11 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
|
|
|
789
804
|
text ·
|
|
790
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) ·
|
|
791
806
|
card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
|
|
792
|
-
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 ·
|
|
793
808
|
option_badge (OptionBadge — a select value as its configured colored badge) ·
|
|
794
809
|
member_chip (MemberChip — avatar + name; the universal person render) ·
|
|
795
810
|
member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
|
|
796
|
-
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 ·
|
|
797
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`) ·
|
|
798
813
|
text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
|
|
799
814
|
radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
|
|
@@ -811,7 +826,7 @@ CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip
|
|
|
811
826
|
bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
|
|
812
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) ·
|
|
813
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 ·
|
|
814
|
-
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) ·
|
|
815
830
|
file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
|
|
816
831
|
spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
|
|
817
832
|
comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
|
package/package.json
CHANGED
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/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 (
|