@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 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`), `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`
@@ -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`. Reach for the lower-level pieces below only when you
290
- 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:
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 · inline action buttons — download, optional
303
- `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`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.6.0",
3
+ "version": "7.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
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
  />
@@ -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 (