@karimsa/mdxserve 0.2.0 → 0.4.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.
Files changed (57) hide show
  1. package/README.md +60 -10
  2. package/client/CodeBlock.tsx +24 -0
  3. package/client/DocView.tsx +9 -2
  4. package/client/ListingView.tsx +14 -10
  5. package/client/MarkdownTable.tsx +190 -0
  6. package/client/MdSection.tsx +56 -21
  7. package/client/MdSectionEditor.tsx +149 -2
  8. package/client/Mermaid.tsx +33 -7
  9. package/client/Settings.css +17 -0
  10. package/client/Settings.tsx +132 -0
  11. package/client/Table.tsx +2 -14
  12. package/client/app.css +339 -1
  13. package/client/block-state/README.md +39 -0
  14. package/client/block-state/storage.ts +73 -0
  15. package/client/block-state/useBlockState.ts +120 -0
  16. package/client/builtins/DataTable.tsx +269 -0
  17. package/client/builtins/chart-data.ts +22 -10
  18. package/client/builtins/index.ts +10 -0
  19. package/client/data-table/ColumnMenu.tsx +168 -0
  20. package/client/data-table/ColumnOptions.tsx +141 -0
  21. package/client/data-table/TableCell.tsx +76 -0
  22. package/client/data-table/TableHeaderCell.tsx +94 -0
  23. package/client/data-table/model.ts +194 -0
  24. package/client/data-table/navigation.ts +34 -0
  25. package/client/data-table/table-state.ts +196 -0
  26. package/client/data-table/useColumnMenu.ts +84 -0
  27. package/client/data-table/useTableState.ts +36 -0
  28. package/client/design/base/prose.css +4 -0
  29. package/client/diagrams/Dialog.tsx +577 -0
  30. package/client/diagrams/EditDialog.tsx +67 -0
  31. package/client/diagrams/Preferences.tsx +213 -0
  32. package/client/diagrams/edit-block.ts +93 -0
  33. package/client/diagrams/edit-context.ts +4 -0
  34. package/client/diagrams/session-id.ts +8 -0
  35. package/client/diagrams/slash.ts +94 -0
  36. package/client/diagrams/source.ts +73 -0
  37. package/client/diagrams/style.css +599 -0
  38. package/client/entry.tsx +2 -0
  39. package/client/mdx-components-base.ts +2 -2
  40. package/client/router.ts +8 -1
  41. package/client/shell/AppShell.tsx +38 -15
  42. package/client/shell/Sidebar.tsx +20 -2
  43. package/client/shell/StandaloneShell.tsx +19 -6
  44. package/client/shell/TocRail.tsx +4 -3
  45. package/client/shell/TopBar.tsx +17 -5
  46. package/client/state.ts +14 -0
  47. package/client/ui/ConfirmDeleteDialog.tsx +48 -91
  48. package/client/ui/ExpandModal.tsx +63 -104
  49. package/client/ui/Modal.tsx +123 -0
  50. package/client/ui/ResizeHandle.tsx +1 -1
  51. package/client/ui/SearchDialog.tsx +81 -88
  52. package/client/ui/modal.css +42 -0
  53. package/dist/cli.js +2191 -778
  54. package/dist/diagram-worker.js +17 -0
  55. package/dist/registry.json +297 -0
  56. package/package.json +42 -2
  57. package/skills/mdxserve/SKILL.md +56 -1
@@ -1,3 +1,4 @@
1
+ import { DiagramPreferences } from "../diagrams/Preferences";
1
2
  import { AnimatePresence, motion, useSpring } from "framer-motion";
2
3
  import { useAtomValue } from "jotai";
3
4
  import { keepPreviousData, useQuery } from "@tanstack/react-query";
@@ -30,17 +31,26 @@ import { TocRail } from "./TocRail";
30
31
  import { TopBar } from "./TopBar";
31
32
  import { useContentWidth, useDocMaxWidth } from "./use-doc-width";
32
33
  import { DESKTOP_MEDIA } from "../platform";
33
- import { docWidthAtom, LISTING_MAX_WIDTH, LISTING_MIN_WIDTH, listingWidthAtom } from "../state";
34
+ import {
35
+ contentLayoutAtom,
36
+ docWidthAtom,
37
+ LISTING_MAX_WIDTH,
38
+ LISTING_MIN_WIDTH,
39
+ listingWidthAtom,
40
+ } from "../state";
34
41
 
35
- const SIDEBAR_STORAGE_KEY = "mdxserve-sidebar";
42
+ function sidebarStorageKey(): string {
43
+ // Keep hosted navigation preferences separate from local reader defaults.
44
+ return shellInfo.immutableSite ? "mdxserve-site-sidebar" : "mdxserve-sidebar";
45
+ }
36
46
  const SEARCH_DEBOUNCE_MS = 120;
37
47
 
38
48
  function readStoredSidebarOpen(): boolean {
39
49
  try {
40
- const value = localStorage.getItem(SIDEBAR_STORAGE_KEY);
41
- return value === null ? true : value === "1";
50
+ const value = localStorage.getItem(sidebarStorageKey());
51
+ return value === null ? !shellInfo.immutableSite : value === "1";
42
52
  } catch {
43
- return true;
53
+ return !shellInfo.immutableSite;
44
54
  }
45
55
  }
46
56
 
@@ -132,6 +142,7 @@ function useListingMaxWidth(contentWidth: number, stored: number | null) {
132
142
  }
133
143
 
134
144
  export function AppShell({ route, navigate }: { route: Route; navigate: (path: string) => void }) {
145
+ const [diagramPreferences, setDiagramPreferences] = useState(false);
135
146
  const { theme, toggle } = useTheme();
136
147
  const { roots } = useTree();
137
148
 
@@ -171,6 +182,7 @@ export function AppShell({ route, navigate }: { route: Route; navigate: (path: s
171
182
  const [exporting, setExporting] = useState(false);
172
183
  const listingWidth = useAtomValue(listingWidthAtom);
173
184
  const docWidth = useAtomValue(docWidthAtom);
185
+ const fullWidth = useAtomValue(contentLayoutAtom) === "full-width";
174
186
  const contentRef = useRef<HTMLDivElement>(null);
175
187
  const contentWidth = useContentWidth(contentRef);
176
188
  const listingMaxWidth = useListingMaxWidth(contentWidth, listingWidth);
@@ -203,7 +215,7 @@ export function AppShell({ route, navigate }: { route: Route; navigate: (path: s
203
215
 
204
216
  useEffect(() => {
205
217
  try {
206
- localStorage.setItem(SIDEBAR_STORAGE_KEY, desktopOpen ? "1" : "0");
218
+ localStorage.setItem(sidebarStorageKey(), desktopOpen ? "1" : "0");
207
219
  } catch {
208
220
  // private mode / storage disabled: the choice just won't persist
209
221
  }
@@ -301,13 +313,22 @@ export function AppShell({ route, navigate }: { route: Route; navigate: (path: s
301
313
 
302
314
  return (
303
315
  <div className="min-h-screen bg-surface-page">
316
+ <AnimatePresence>
317
+ {diagramPreferences && (
318
+ <DiagramPreferences
319
+ canEditDiagrams={shellInfo.sameMachine && shellInfo.permissions !== "restricted"}
320
+ onClose={() => setDiagramPreferences(false)}
321
+ />
322
+ )}
323
+ </AnimatePresence>
304
324
  <TopBar
325
+ onDiagramPreferences={() => setDiagramPreferences(true)}
305
326
  homeHref="/"
306
327
  hostLabel={location.host}
307
- sidebar={{ open: showSidebar, onToggle: handleToggleSidebar }}
328
+ sidebar={{ open: showSidebar || mobileOpen, onToggle: handleToggleSidebar }}
308
329
  search={{ onOpen: () => setSearchOpen(true) }}
309
330
  exportDoc={
310
- route.kind === "doc" && shellInfo.sameMachine
331
+ route.kind === "doc" && shellInfo.permissions !== "restricted" && shellInfo.sameMachine
311
332
  ? { pending: exporting, onExport: handleExport }
312
333
  : undefined
313
334
  }
@@ -324,7 +345,7 @@ export function AppShell({ route, navigate }: { route: Route; navigate: (path: s
324
345
  mobileOpen={mobileOpen}
325
346
  onCloseMobile={() => setMobileOpen(false)}
326
347
  />
327
- <main className="flex flex-1 min-w-0 px-8 pt-10 pb-24">
348
+ <main className="flex flex-1 min-w-0 px-5 pt-6 pb-16 md:px-8 md:pt-10 md:pb-24">
328
349
  {/* The resize handles clamp drags to this area, and the max-width
329
350
  springs track it, so content never runs under the toc rail. */}
330
351
  <div ref={contentRef} data-content-area className="flex flex-1 min-w-0 justify-center">
@@ -336,16 +357,18 @@ export function AppShell({ route, navigate }: { route: Route; navigate: (path: s
336
357
  animate="enter"
337
358
  exit="exit"
338
359
  className={
339
- route.kind === "listing" || (route.kind === "doc" && docMaxWidth)
360
+ fullWidth || route.kind === "listing" || (route.kind === "doc" && docMaxWidth)
340
361
  ? "w-full"
341
362
  : "w-full max-w-prose"
342
363
  }
343
364
  style={
344
- route.kind === "listing"
345
- ? { maxWidth: listingMaxWidth }
346
- : route.kind === "doc" && docMaxWidth
347
- ? { maxWidth: docMaxWidth }
348
- : undefined
365
+ fullWidth
366
+ ? { maxWidth: "100%" }
367
+ : route.kind === "listing"
368
+ ? { maxWidth: listingMaxWidth }
369
+ : route.kind === "doc" && docMaxWidth
370
+ ? { maxWidth: docMaxWidth }
371
+ : undefined
349
372
  }
350
373
  >
351
374
  <Breadcrumb items={breadcrumbItems(route, rootCount)} />
@@ -1,8 +1,9 @@
1
1
  import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
2
- import { useRef } from "react";
2
+ import { useEffect, useRef } from "react";
3
3
  import { useAtom } from "jotai";
4
4
  import { VARIANTS } from "../motion";
5
5
  import { Icon } from "../ui/Icon";
6
+ import { IconButton } from "../ui/IconButton";
6
7
  import { ResizeHandle } from "../ui/ResizeHandle";
7
8
  import { ListingView } from "../ListingView";
8
9
  import { useFolderListing } from "../router";
@@ -95,6 +96,15 @@ export function Sidebar({
95
96
  const [width, setWidth] = useAtom(sidebarWidthAtom);
96
97
  const asideRef = useRef<HTMLDivElement>(null);
97
98
 
99
+ useEffect(() => {
100
+ if (!mobileOpen) return;
101
+ function onKeyDown(event: KeyboardEvent) {
102
+ if (event.key === "Escape") onCloseMobile();
103
+ }
104
+ window.addEventListener("keydown", onKeyDown);
105
+ return () => window.removeEventListener("keydown", onKeyDown);
106
+ }, [mobileOpen, onCloseMobile]);
107
+
98
108
  return (
99
109
  <>
100
110
  {/* Mobile overlay: fixed drawer + scrim, closed by default. */}
@@ -121,9 +131,17 @@ export function Sidebar({
121
131
  if ((event.target as HTMLElement).closest("a[href]")) onCloseMobile();
122
132
  }}
123
133
  className={
124
- "fixed inset-y-0 left-0 z-[var(--z-modal)] overflow-y-auto border-r border-border-subtle bg-surface-raised px-3 py-6 shadow-lg md:hidden w-sidebar"
134
+ "fixed inset-y-0 left-0 z-[var(--z-modal)] overflow-y-auto border-r border-border-subtle bg-surface-raised px-3 py-6 shadow-lg md:hidden w-sidebar max-w-[calc(100vw-3rem)]"
125
135
  }
126
136
  >
137
+ <div className="mb-3 flex justify-end">
138
+ <IconButton
139
+ icon="x"
140
+ label="Close navigation"
141
+ className="h-11 w-11"
142
+ onClick={onCloseMobile}
143
+ />
144
+ </div>
127
145
  <SidebarBody
128
146
  rootDir={rootDir}
129
147
  activePath={activePath}
@@ -1,11 +1,12 @@
1
- import { motion } from "framer-motion";
1
+ import { AnimatePresence, motion } from "framer-motion";
2
2
  import { useAtomValue } from "jotai";
3
3
  import { useCallback, useRef, useState } from "react";
4
+ import { Settings } from "../Settings";
4
5
  import { DocView } from "../DocView";
5
6
  import { fadeRise } from "../motion";
6
7
  import { useTheme } from "../theme";
7
8
  import { docModuleCache } from "../doc-module-cache";
8
- import { docWidthAtom } from "../state";
9
+ import { contentLayoutAtom, docWidthAtom } from "../state";
9
10
  import { Footer } from "./Footer";
10
11
  import { TocRail } from "./TocRail";
11
12
  import { TopBar } from "./TopBar";
@@ -20,8 +21,10 @@ import type { StandaloneMeta } from "../standalone-entry";
20
21
  */
21
22
  export function StandaloneShell({ meta }: { meta: StandaloneMeta }) {
22
23
  const { theme, toggle } = useTheme();
24
+ const [settingsOpen, setSettingsOpen] = useState(false);
23
25
  const [tocVersion, setTocVersion] = useState(0);
24
26
  const docWidth = useAtomValue(docWidthAtom);
27
+ const fullWidth = useAtomValue(contentLayoutAtom) === "full-width";
25
28
  const contentRef = useRef<HTMLDivElement>(null);
26
29
  const contentWidth = useContentWidth(contentRef);
27
30
  const docMaxWidth = useDocMaxWidth(contentWidth, docWidth);
@@ -40,15 +43,25 @@ export function StandaloneShell({ meta }: { meta: StandaloneMeta }) {
40
43
 
41
44
  return (
42
45
  <div className="min-h-screen bg-surface-page">
43
- <TopBar theme={theme} onToggleTheme={toggle} hostLabel={meta.label} />
44
- <main className="flex flex-1 min-w-0 px-8 pt-10 pb-24">
46
+ <AnimatePresence>
47
+ {settingsOpen && <Settings onClose={() => setSettingsOpen(false)} />}
48
+ </AnimatePresence>
49
+ <TopBar
50
+ theme={theme}
51
+ onToggleTheme={toggle}
52
+ hostLabel={meta.label}
53
+ onDiagramPreferences={() => setSettingsOpen(true)}
54
+ />
55
+ <main className="flex flex-1 min-w-0 px-5 pt-6 pb-16 md:px-8 md:pt-10 md:pb-24">
45
56
  <div ref={contentRef} data-content-area className="flex flex-1 min-w-0 justify-center">
46
57
  <motion.div
47
58
  variants={fadeRise}
48
59
  initial="initial"
49
60
  animate="enter"
50
- className={docMaxWidth ? "w-full" : "w-full max-w-prose"}
51
- style={docMaxWidth ? { maxWidth: docMaxWidth } : undefined}
61
+ className={fullWidth || docMaxWidth ? "w-full" : "w-full max-w-prose"}
62
+ style={
63
+ fullWidth ? { maxWidth: "100%" } : docMaxWidth ? { maxWidth: docMaxWidth } : undefined
64
+ }
52
65
  >
53
66
  <DocView
54
67
  path={meta.path}
@@ -1,8 +1,8 @@
1
- import { useAtom } from "jotai";
1
+ import { useAtom, useAtomValue } from "jotai";
2
2
  import { useRef } from "react";
3
3
  import { ResizeHandle } from "../ui/ResizeHandle";
4
4
  import { TocList } from "../ui/TocList";
5
- import { TOC_MAX_WIDTH, TOC_MIN_WIDTH, tocWidthAtom } from "../state";
5
+ import { TOC_MAX_WIDTH, TOC_MIN_WIDTH, tocVisibleAtom, tocWidthAtom } from "../state";
6
6
  import { useToc } from "./useToc";
7
7
 
8
8
  function clampWidth(px: number): number {
@@ -13,9 +13,10 @@ function clampWidth(px: number): number {
13
13
  export function TocRail({ path, version }: { path: string; version: number }) {
14
14
  const { items, activeId, setActiveId } = useToc(path, version);
15
15
  const [width, setWidth] = useAtom(tocWidthAtom);
16
+ const visible = useAtomValue(tocVisibleAtom);
16
17
  const railRef = useRef<HTMLDivElement>(null);
17
18
  const hasH2 = items.some((item) => item.level === 2);
18
- if (!hasH2) return null;
19
+ if (!visible || !hasH2) return null;
19
20
 
20
21
  function handleSelect(id: string) {
21
22
  setActiveId(id);
@@ -44,6 +44,7 @@ export function TopBar({
44
44
  exportDoc,
45
45
  theme,
46
46
  onToggleTheme,
47
+ onDiagramPreferences,
47
48
  }: {
48
49
  /** Wordmark's link target; undefined renders it as a plain (non-link) span. */
49
50
  homeHref?: string;
@@ -57,13 +58,14 @@ export function TopBar({
57
58
  exportDoc?: { pending: boolean; onExport: (format: ExportFormat) => void };
58
59
  theme: Theme;
59
60
  onToggleTheme: () => void;
61
+ onDiagramPreferences?: () => void;
60
62
  }) {
61
63
  const mac = isApplePlatform();
62
64
 
63
65
  return (
64
66
  <header
65
67
  data-print-hide
66
- className="sticky top-0 z-10 flex h-topbar shrink-0 items-center gap-3 border-b border-border-subtle px-4"
68
+ className="sticky top-0 z-10 flex h-topbar shrink-0 items-center gap-1 border-b border-border-subtle px-2 md:gap-3 md:px-4 [&>button]:max-md:h-11 [&>button]:max-md:w-11 [&>button]:shrink-0"
67
69
  style={{
68
70
  background: "color-mix(in oklab, var(--surface-page) 82%, transparent)",
69
71
  backdropFilter: "var(--blur-chrome)",
@@ -80,7 +82,10 @@ export function TopBar({
80
82
  ) : null}
81
83
  <Wordmark href={homeHref} />
82
84
  {hostLabel ? (
83
- <span className="rounded-sm border border-border-default px-1.5 py-0.5 font-mono text-[length:var(--size-2xs)] text-text-subtle">
85
+ <span
86
+ title={hostLabel}
87
+ className="hidden min-w-0 max-w-48 truncate md:block rounded-sm border border-border-default px-1.5 py-0.5 font-mono text-[length:var(--size-2xs)] text-text-subtle"
88
+ >
84
89
  {hostLabel}
85
90
  </span>
86
91
  ) : null}
@@ -89,11 +94,15 @@ export function TopBar({
89
94
  <button
90
95
  type="button"
91
96
  onClick={search.onOpen}
92
- className="flex h-[30px] w-60 items-center gap-2 rounded-md border border-border-default bg-surface-card px-2.5 text-[13px] text-text-subtle shadow-xs cursor-pointer"
97
+ aria-label="Search docs"
98
+ title="Search docs"
99
+ className="flex h-11 w-11 shrink-0 justify-center md:h-[30px] md:w-60 md:justify-start items-center gap-2 rounded-md border border-border-default bg-surface-card px-2.5 text-[13px] text-text-subtle shadow-xs cursor-pointer"
93
100
  >
94
101
  <Icon name="search" size="sm" />
95
- <span className="flex-1 text-left">Search docs</span>
96
- <Kbd>{mac ? "⌘K" : "Ctrl K"}</Kbd>
102
+ <span className="hidden md:block flex-1 text-left">Search docs</span>
103
+ <span className="hidden md:contents">
104
+ <Kbd>{mac ? "⌘K" : "Ctrl K"}</Kbd>
105
+ </span>
97
106
  </button>
98
107
  ) : null}
99
108
  {exportDoc ? (
@@ -106,6 +115,9 @@ export function TopBar({
106
115
  onChange={(format) => exportDoc.onExport(format as ExportFormat)}
107
116
  />
108
117
  ) : null}
118
+ {onDiagramPreferences && (
119
+ <IconButton icon="settings" label="Settings" size="sm" onClick={onDiagramPreferences} />
120
+ )}
109
121
  <IconButton
110
122
  icon={theme === "dark" ? "sun" : "moon"}
111
123
  label={theme === "dark" ? "Light mode" : "Dark mode"}
package/client/state.ts CHANGED
@@ -88,3 +88,17 @@ export const docWidthAtom = atomWithStorage<number | null>("mdxserve.doc.width",
88
88
  * back to read mode, discarding whatever it had open (see client/MdSection.tsx).
89
89
  */
90
90
  export const openSectionAtom = atom<string | null>(null);
91
+
92
+ export type ContentLayout = "flexible" | "full-width";
93
+
94
+ /** Content layout preference; saved widths are retained when full width is active. */
95
+ export const contentLayoutAtom = atomWithStorage<ContentLayout>(
96
+ "mdxserve.content.layout",
97
+ "flexible",
98
+ undefined,
99
+ { getOnInit: true },
100
+ );
101
+
102
+ export const tocVisibleAtom = atomWithStorage<boolean>("mdxserve.toc.visible", true, undefined, {
103
+ getOnInit: true,
104
+ });
@@ -1,11 +1,8 @@
1
- import { useEffect } from "react";
2
- import { createPortal } from "react-dom";
3
- import { AnimatePresence, motion } from "framer-motion";
4
1
  import { formatModified, formatSize } from "../format";
5
2
  import Button from "../builtins/Button";
6
3
  import type { ListingEntry } from "../router";
7
4
  import { Icon } from "./Icon";
8
- import { VARIANTS } from "../motion";
5
+ import { Modal } from "./Modal";
9
6
 
10
7
  export function ConfirmDeleteDialog({
11
8
  open,
@@ -21,93 +18,53 @@ export function ConfirmDeleteDialog({
21
18
  onCancel: () => void;
22
19
  onConfirm: () => void;
23
20
  }) {
24
- // Document-level, not panel-level: while a delete is pending both buttons
25
- // are disabled and the browser drops focus to <body>, so a panel onKeyDown
26
- // would stop hearing Escape (e.g. after a failed request leaves the dialog
27
- // open).
28
- useEffect(() => {
29
- if (!open) return;
30
- function onKeyDown(event: KeyboardEvent) {
31
- if (event.key === "Escape" && !pending) {
32
- event.preventDefault();
33
- onCancel();
34
- }
35
- }
36
- document.addEventListener("keydown", onKeyDown);
37
- return () => document.removeEventListener("keydown", onKeyDown);
38
- }, [open, pending, onCancel]);
39
-
40
- // Portal to <body>: this renders inside the route wrapper, whose fadeRise
41
- // transform would otherwise turn `fixed` into "fixed to the column" and let
42
- // the scrim miss the top bar and sidebar.
43
- return createPortal(
44
- <AnimatePresence>
45
- {open ? (
46
- <motion.div
47
- key="scrim"
48
- onClick={pending ? undefined : onCancel}
49
- {...VARIANTS.scrim}
50
- className="fixed inset-0 z-[var(--z-modal)] flex justify-center bg-[var(--scrim)] pt-[10vh] backdrop-blur-sm"
51
- >
52
- <motion.div
53
- onClick={(event) => event.stopPropagation()}
54
- {...VARIANTS.pop}
55
- role="alertdialog"
56
- aria-modal="true"
57
- aria-labelledby="confirm-delete-title"
58
- className="w-[560px] max-w-[92vw] self-start overflow-hidden rounded-xl border border-border-default bg-surface-raised shadow-lg"
21
+ return (
22
+ <Modal
23
+ open={open}
24
+ onClose={onCancel}
25
+ dismissible={!pending}
26
+ placement="top"
27
+ role="alertdialog"
28
+ aria-labelledby="confirm-delete-title"
29
+ className="w-[560px] overflow-hidden"
30
+ >
31
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
32
+ <Icon name="trash-2" size="md" className="text-status-danger-fg" />
33
+ <div className="flex flex-col">
34
+ <span
35
+ id="confirm-delete-title"
36
+ className="font-sans font-semibold text-text-heading text-[length:var(--size-md)]"
59
37
  >
60
- <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
61
- <Icon name="trash-2" size="md" className="text-status-danger-fg" />
62
- <div className="flex flex-col">
63
- <span
64
- id="confirm-delete-title"
65
- className="font-sans font-semibold text-text-heading text-[length:var(--size-md)]"
66
- >
67
- Delete {files.length} {files.length === 1 ? "file" : "files"}?
68
- </span>
69
- <span className="font-sans text-[length:var(--size-sm)] text-text-muted">
70
- They&rsquo;ll be moved to the system Trash — this can be undone from there.
71
- </span>
72
- </div>
73
- </div>
74
- <div className="max-h-72 overflow-y-auto p-2">
75
- {files.map((file) => (
76
- <div key={file.name} className="flex items-center gap-2 rounded-md px-2 py-1.5">
77
- <Icon name="file-text" size="sm" className="shrink-0 text-text-subtle" />
78
- <span className="truncate font-mono text-[length:var(--size-sm)]">
79
- {file.name}
80
- </span>
81
- <span className="ml-auto flex shrink-0 gap-4 pl-4 font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
82
- {typeof file.mtime === "number" ? (
83
- <span>{formatModified(file.mtime)}</span>
84
- ) : null}
85
- {typeof file.size === "number" ? (
86
- <span className="w-16 text-right">{formatSize(file.size)}</span>
87
- ) : null}
88
- </span>
89
- </div>
90
- ))}
91
- </div>
92
- <div className="flex justify-end gap-2 border-t border-border-subtle px-4 py-3">
93
- {/* Start keyboard focus on the safe action. */}
94
- <Button variant="secondary" size="sm" autoFocus disabled={pending} onClick={onCancel}>
95
- Cancel
96
- </Button>
97
- <Button
98
- variant="danger"
99
- size="sm"
100
- icon="trash-2"
101
- disabled={pending}
102
- onClick={onConfirm}
103
- >
104
- {pending ? "Moving…" : "Move to Trash"}
105
- </Button>
106
- </div>
107
- </motion.div>
108
- </motion.div>
109
- ) : null}
110
- </AnimatePresence>,
111
- document.body,
38
+ Delete {files.length} {files.length === 1 ? "file" : "files"}?
39
+ </span>
40
+ <span className="font-sans text-[length:var(--size-sm)] text-text-muted">
41
+ They&rsquo;ll be moved to the system Trash — this can be undone from there.
42
+ </span>
43
+ </div>
44
+ </div>
45
+ <div className="max-h-72 overflow-y-auto p-2">
46
+ {files.map((file) => (
47
+ <div key={file.name} className="flex items-center gap-2 rounded-md px-2 py-1.5">
48
+ <Icon name="file-text" size="sm" className="shrink-0 text-text-subtle" />
49
+ <span className="truncate font-mono text-[length:var(--size-sm)]">{file.name}</span>
50
+ <span className="ml-auto flex shrink-0 gap-4 pl-4 font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
51
+ {typeof file.mtime === "number" ? <span>{formatModified(file.mtime)}</span> : null}
52
+ {typeof file.size === "number" ? (
53
+ <span className="w-16 text-right">{formatSize(file.size)}</span>
54
+ ) : null}
55
+ </span>
56
+ </div>
57
+ ))}
58
+ </div>
59
+ <div className="flex justify-end gap-2 border-t border-border-subtle px-4 py-3">
60
+ {/* Start keyboard focus on the safe action. */}
61
+ <Button variant="secondary" size="sm" autoFocus disabled={pending} onClick={onCancel}>
62
+ Cancel
63
+ </Button>
64
+ <Button variant="danger" size="sm" icon="trash-2" disabled={pending} onClick={onConfirm}>
65
+ {pending ? "Moving…" : "Move to Trash"}
66
+ </Button>
67
+ </div>
68
+ </Modal>
112
69
  );
113
70
  }