@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 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`), `SectionCard`, `SectionHeading` (+ `SectionHeadingTitle`), `PageHeader` /
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`, `Drawer` (+ `DrawerFooter`), `Popover`, `Tooltip`, `OptionList`
119
- (the searchable list body host it in a `Popover`/`Dialog` for a command palette),
120
- `Alert` (the blocking confirm).
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`. Reach for the lower-level pieces below only when you
289
- need custom chrome:
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 · inline action buttons — download, optional
302
- `onOpenExternal`/`onRemove` · close-✕), prev/next, ESC, and rotate; `onDisplayRemove`/`onUploadRemove`
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.5.0",
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
- /** Usually `AccordionTitle` (+ optionally `AccordionMeta`, a Badge, or any
81
- * right-side node). The band is a layout slot. */
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 — content indents to align under the title, no tinted well; the
157
- * chevron + position already say "inside". Compose rows/dividers/actions
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
- // Aligns under the title text (16px icon + 12px gap = 28).
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
- <View style={styles.surface}>
195
- <View style={styles.toolbar}>
196
- <Text size="sm" weight="medium" numberOfLines={1} style={styles.filename}>
197
- {file.filename}
198
- </Text>
199
- {total > 1 ? (
200
- <Text size="xs" color="muted" style={styles.counter}>
201
- {`${activeIndex + 1} / ${total}`}
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
- ) : null}
204
- <View style={styles.spacer} />
205
-
206
- {isImage && rotatable ? (
207
- <>
208
- <IconButton
209
- size="lg"
210
- icon="rotate-ccw"
211
- tooltip={l.rotateLeft}
212
- color="secondary"
213
- onPress={() => rot.rotate(file.id, -1)}
214
- />
215
- <IconButton
216
- size="lg"
217
- icon="rotate-cw"
218
- tooltip={l.rotateRight}
219
- color="secondary"
220
- onPress={() => rot.rotate(file.id, 1)}
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
- ) : null}
234
- </>
235
- ) : null}
236
-
237
- <Button
238
- icon="download"
239
- title={l.download}
240
- color="secondary"
241
- onPress={handleDownload}
242
- />
243
- {onOpenExternal ? (
244
- <Button
245
- icon="external-link"
246
- title={l.openExternal}
247
- color="secondary"
248
- onPress={handleOpenExternal}
249
- />
250
- ) : null}
251
- {onRemove ? (
252
- <Button
253
- icon="trash"
254
- title={l.remove}
255
- color="danger-secondary"
256
- onPress={handleRemove}
257
- />
258
- ) : null}
259
-
260
- <IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
261
- </View>
262
-
263
- <View style={styles.previewArea}>
264
- {renderPreview ? (
265
- renderPreview(file, { rotation: rot.rotationFor(file.id) })
266
- ) : (
267
- <FilePreview
268
- file={file}
269
- labels={labels}
270
- onError={onError}
271
- rotation={rot.rotationFor(file.id)}
272
- credentials={credentials}
273
- />
274
- )}
275
-
276
- {activeIndex > 0 ? (
277
- <FocusRingPressable accessibilityRole="button"
278
- onPress={prev}
279
- accessibilityLabel={l.previous}
280
- style={[styles.nav, styles.navLeft]}
281
- >
282
- <Icon name="chevron-left" size={28} color={colors.white} />
283
- </FocusRingPressable>
284
- ) : null}
285
-
286
- {activeIndex < total - 1 ? (
287
- <FocusRingPressable accessibilityRole="button"
288
- onPress={next}
289
- accessibilityLabel={l.next}
290
- style={[styles.nav, styles.navRight]}
291
- >
292
- <Icon name="chevron-right" size={28} color={colors.white} />
293
- </FocusRingPressable>
294
- ) : null}
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
- </View>
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
  });
@@ -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";
@@ -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
- <FileGrid
154
- files={files}
155
- uploads={uploads}
156
- itemSize={itemSize}
157
- minItemWidth={minItemWidth}
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
+ });
@@ -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="medium" size="md">
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
- <Text
103
- weight={selected ? "medium" : "regular"}
104
- color={selected ? "zinc-900" : "zinc-700"}
105
- userSelect="none"
106
- >
107
- {option.label}
108
- </Text>
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 (