@lotics/ui 7.6.0 → 7.8.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`
@@ -225,7 +227,10 @@ parts render the CHROME, composed as children — never a render-prop pile:
225
227
 
226
228
  It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
227
229
  search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
228
- large/remote set). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
230
+ large/remote set). For a select that the user can RESET, pass `clearable` + `onClear` (deselect):
231
+ the chevron shows while the field is empty and a clear ✕ replaces it once there's a value/query;
232
+ pressing ✕ clears the text and reopens the full-browse list, so `onClear` lands back on the whole
233
+ set (on focus an empty field already browses everything — `recentOptions ?? all`). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
229
234
  matches no option, emitting the typed text as the value — so a value not in the known set means
230
235
  CREATE. Wire that branch to a create overlay (a modal `Dialog` for 4+ fields, an anchored popover
231
236
  for 1–3) that builds the new record and attaches it; existing matches attach directly. The create
@@ -286,8 +291,12 @@ marked + disabled), not a generic text menu. A composition (Badge + Button + Pop
286
291
  ### Attachments — a full add / preview / DELETE field
287
292
  **Default: `<FilesEditor files onAdd onRemove>`** — it bundles the grid + a toolbar (Upload · Select ·
288
293
  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:
294
+ wires `onAdd` (picked → its upload) / `onRemove`. The destructive per-tile shows only in SELECT mode
295
+ (the default view is a clean preview — no stray-tap deletes); the built-in gallery is Download +
296
+ inline preview (no "open in new tab" — it's redundant once everything previews inline). In a
297
+ height-bounded container (a popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar
298
+ pins below it; omit it in free-flow layouts (a form field) where the grid grows. Reach for the
299
+ lower-level pieces below only when you need custom chrome:
291
300
  Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
292
301
  accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`** ABOVE
293
302
  the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add more"
@@ -299,8 +308,13 @@ hand-map an upload to a `FileThumbnail`. Localize the labels with `labels.upload
299
308
  `UploadStatusLabels`; the Lotics `@lotics/ui-internal` adapter already injects vi/en). Make it CRUDable
300
309
  by wiring its callbacks: `onFilePress`
301
310
  → 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`
311
+ FULL-SCREEN viewer with a toolbar (filename · counter · actions — download, optional
312
+ `onOpenExternal`/`onRemove` · close-✕), prev/next, ESC, and rotate. The toolbar is RESPONSIVE: on a
313
+ phone (`useScreenSize().small`) the actions collapse into a `⋯` `ActionMenu` so the close-✕ never
314
+ overflows off-screen (its popover portals inside the modal's own `PortalHost` — a raw `<Modal>` lacks
315
+ one, which is why an earlier inline-less popover rendered behind the overlay). The 90° rotate controls
316
+ for images are NOT in the toolbar — they FLOAT as a pill overlaid on the image (every screen size).
317
+ `onDisplayRemove`/`onUploadRemove`
304
318
  → drop the file / cancel the upload (the grid renders a ✕ on each tile automatically); `onRetry` /
305
319
  `onRetryAll` for failed uploads. In a sandboxed custom-code app the toolbar's "open in new tab" can't
306
320
  pop a window — pass `onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the
@@ -619,7 +633,11 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
619
633
  the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
620
634
  Expansion happens IMMEDIATELY below the pressed element — the composable `Accordion` family
621
635
  (`AccordionHeader`/`AccordionTitle`/`AccordionMeta`/`AccordionContent`); header-only = a plain row
622
- of identical rhythm, so lists mix expandable + static rows.
636
+ of identical rhythm, so lists mix expandable + static rows. The body is **flush** with the
637
+ header's left edge (aligns with the title — like a `Section` body, no indent). `AccordionHeader`
638
+ is a layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but for a
639
+ **collapsible Section** (configurable font/level/description + count) compose the SAME
640
+ `SectionHeadingTitle`/`SectionHeadingMeta` inside it — one heading family across static + collapsible.
623
641
  - **No dead rows.** Every listed record is actionable. PRIMARY entity rows press-open the workspace
624
642
  `Drawer` (sequenced); read-only drill-downs expand via `Accordion` or glance via `Peek`; every
625
643
  other row gets an `ActionMenu` (⋯ → MenuButton items: destructive last + **confirmed** via
@@ -680,7 +698,7 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
680
698
  port, a customer) rather than a short fixed list. Pass `recentOptions` (the same option list) so
681
699
  a click opens the full list, then typing filters. The **From → To route picker** is two of them
682
700
  (`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.
701
+ · `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
702
  `SegmentedControl` chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
685
703
  - **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter`/`DrawerFooter` default
686
704
  `align="end"` — commit at the right edge, secondary to its left. Never left-flow a Save/Submit.
@@ -789,11 +807,11 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
789
807
  text ·
790
808
  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
809
  card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
792
- section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
810
+ 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
811
  option_badge (OptionBadge — a select value as its configured colored badge) ·
794
812
  member_chip (MemberChip — avatar + name; the universal person render) ·
795
813
  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 ·
814
+ 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
815
  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
816
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
799
817
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
@@ -811,7 +829,7 @@ CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip
811
829
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
812
830
  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
831
  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 ·
832
+ 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
833
  file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
816
834
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
817
835
  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.8.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
  });
package/src/combobox.tsx CHANGED
@@ -182,7 +182,13 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
182
182
  const single = value ?? null;
183
183
  const reflectedText = single ? (single.label ?? single.value) : "";
184
184
 
185
- const [query, setQuery] = useState(reflectSelection && single ? reflectedText : "");
185
+ // The live edit buffer. `null` = the user isn't editing: the input reflects the
186
+ // committed selection's label and the list browses the FULL set. A string (incl.
187
+ // "") = the user is searching: the input shows it and the list filters by it.
188
+ // This one nullable carries the bit that (value, inputText) alone can't — after
189
+ // selecting FPP, the input reads "FPP" whether the user just reopened (browse)
190
+ // or retyped it (filter); `draft` is null in the first case, "FPP" in the second.
191
+ const [draft, setDraft] = useState<string | null>(null);
186
192
  const [open, setOpen] = useState(autoFocus);
187
193
  const triggerRef = useRef<View>(null);
188
194
  const inputRef = useRef<RNTextInput>(null);
@@ -197,9 +203,11 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
197
203
  const isServer = onSearchChange !== undefined;
198
204
  const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
199
205
 
200
- // The displayed text equals the reflected selection until the user types show
201
- // ALL options then (browse), not the list filtered to the selected label.
202
- const filterQuery = reflectSelection && single && query === reflectedText ? "" : query;
206
+ // What the input shows: the live draft while editing, else the reflected label.
207
+ const inputText = draft ?? (reflectSelection && single ? reflectedText : "");
208
+ // What the list filters by: nothing while reflecting a selection (browse), the
209
+ // draft while editing — so retyping the selected label still filters to it.
210
+ const filterQuery = draft ?? "";
203
211
 
204
212
  const findOption = useCallback(
205
213
  (val: T): PickerOption<T, D> =>
@@ -215,7 +223,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
215
223
  debouncedSearch.cancel();
216
224
  onSearchChange?.("");
217
225
  }
218
- setQuery(reflectSelection ? (opt.label ?? opt.value) : "");
226
+ // Stop editing — the input reflects the new label (or clears, when
227
+ // reflectSelection is off) and the next open browses the full set.
228
+ setDraft(null);
219
229
  setOpen(false);
220
230
  // The refocus below must not reopen the just-closed menu.
221
231
  suppressFocusOpenRef.current = true;
@@ -247,7 +257,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
247
257
 
248
258
  const handleChangeText = useCallback(
249
259
  (text: string) => {
250
- setQuery(text);
260
+ setDraft(text);
251
261
  if (!disabled) setOpen(true);
252
262
  if (isServer) debouncedSearch(text);
253
263
  },
@@ -285,7 +295,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
285
295
  // Boundary cast: the engine is generic per-instance, the context type is not.
286
296
  // `ComboboxContent` casts back to its own `<T, D>` before touching rows.
287
297
  list: list as unknown as UseOptionList<string, unknown>,
288
- query,
298
+ query: inputText,
289
299
  open,
290
300
  showList,
291
301
  loading,
@@ -349,9 +359,14 @@ export interface ComboboxInputProps {
349
359
  export function ComboboxInput(props: ComboboxInputProps) {
350
360
  const { icon, placeholder, clearable = false, onClear, clearLabel, accessibilityLabel, testID } = props;
351
361
  const ctx = useComboboxContext("ComboboxInput");
352
- // Select mode (no leading icon): a trailing chevron so the field reads as a
353
- // picker, not a free-text search. The clear owns the right slot when present.
354
- const showChevron = !icon && !clearable;
362
+ // Select mode (no leading icon): the right slot holds the clear when the field
363
+ // carries text (a reflected selection or a live query) AND it's clearable;
364
+ // otherwise the trailing chevron, so an EMPTY picker still reads as a picker.
365
+ // The two never show together — ✕ replaces the chevron once there's something to
366
+ // clear, and pressing it resets the query + reopens the browse list (via
367
+ // `handleChangeText("")`) so the consumer's `onClear` lands back on the full set.
368
+ const showClear = clearable && ctx.query.length > 0;
369
+ const showChevron = !icon && !showClear;
355
370
 
356
371
  return (
357
372
  <View ref={ctx.triggerRef}>
@@ -375,7 +390,9 @@ export function ComboboxInput(props: ComboboxInputProps) {
375
390
  autoCorrect={false}
376
391
  accessibilityLabel={accessibilityLabel}
377
392
  style={[
378
- showChevron ? styles.selectInput : undefined,
393
+ // Select mode reserves the right gutter for whichever trailing affordance
394
+ // is up (chevron when empty, ✕ when there's text) so neither overlaps it.
395
+ !icon ? styles.selectInput : undefined,
379
396
  // Gate on `showList`, not `open`: an empty query with no options has
380
397
  // `open=true` but renders NO dropdown, so a ring tied to `open` would
381
398
  // show with nothing below it AND linger after blur (no popover fires a
@@ -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 (