@nibapp/ui 0.0.0-stage → 0.1.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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/TRADEMARKS.md +22 -0
- package/dist/_bundled/money/index.d.ts +98 -0
- package/dist/_bundled/money/index.js +152 -0
- package/dist/_bundled/tokens/index.d.ts +2 -0
- package/dist/_bundled/tokens/index.js +2 -0
- package/dist/_bundled/tokens/palette.gen.d.ts +51 -0
- package/dist/_bundled/tokens/palette.gen.js +206 -0
- package/dist/_bundled/tokens/spec.d.ts +291 -0
- package/dist/_bundled/tokens/spec.js +221 -0
- package/dist/components/accordion.d.ts +27 -0
- package/dist/components/accordion.js +66 -0
- package/dist/components/alert-dialog.d.ts +59 -0
- package/dist/components/alert-dialog.js +82 -0
- package/dist/components/alert.d.ts +141 -0
- package/dist/components/alert.js +55 -0
- package/dist/components/appear.d.ts +21 -0
- package/dist/components/appear.js +42 -0
- package/dist/components/avatar.d.ts +196 -0
- package/dist/components/avatar.js +135 -0
- package/dist/components/badge.d.ts +183 -0
- package/dist/components/badge.js +68 -0
- package/dist/components/block-stack.d.ts +33 -0
- package/dist/components/block-stack.js +197 -0
- package/dist/components/button.d.ts +327 -0
- package/dist/components/button.js +101 -0
- package/dist/components/card.d.ts +131 -0
- package/dist/components/card.js +100 -0
- package/dist/components/checkbox.d.ts +34 -0
- package/dist/components/checkbox.js +37 -0
- package/dist/components/choice-row.d.ts +104 -0
- package/dist/components/choice-row.js +56 -0
- package/dist/components/clamped-text.d.ts +17 -0
- package/dist/components/clamped-text.js +47 -0
- package/dist/components/code-block.d.ts +48 -0
- package/dist/components/code-block.js +124 -0
- package/dist/components/code-input.d.ts +49 -0
- package/dist/components/code-input.js +59 -0
- package/dist/components/combobox.d.ts +194 -0
- package/dist/components/combobox.js +176 -0
- package/dist/components/command-palette.d.ts +57 -0
- package/dist/components/command-palette.js +83 -0
- package/dist/components/context-menu.d.ts +24 -0
- package/dist/components/context-menu.js +30 -0
- package/dist/components/copy-button.d.ts +9 -0
- package/dist/components/copy-button.js +20 -0
- package/dist/components/copy-command.d.ts +18 -0
- package/dist/components/copy-command.js +19 -0
- package/dist/components/data-table.d.ts +240 -0
- package/dist/components/data-table.js +63 -0
- package/dist/components/dialog.d.ts +80 -0
- package/dist/components/dialog.js +85 -0
- package/dist/components/diff-lines.d.ts +20 -0
- package/dist/components/diff-lines.js +10 -0
- package/dist/components/diff-view.d.ts +128 -0
- package/dist/components/diff-view.js +675 -0
- package/dist/components/disclosure.d.ts +33 -0
- package/dist/components/disclosure.js +51 -0
- package/dist/components/drawn-check.d.ts +8 -0
- package/dist/components/drawn-check.js +16 -0
- package/dist/components/edge-resizer.d.ts +26 -0
- package/dist/components/edge-resizer.js +53 -0
- package/dist/components/empty-state.d.ts +112 -0
- package/dist/components/empty-state.js +56 -0
- package/dist/components/expand-toggle.d.ts +19 -0
- package/dist/components/expand-toggle.js +10 -0
- package/dist/components/field.d.ts +48 -0
- package/dist/components/field.js +60 -0
- package/dist/components/filter-chip.d.ts +47 -0
- package/dist/components/filter-chip.js +33 -0
- package/dist/components/group.d.ts +10 -0
- package/dist/components/group.js +9 -0
- package/dist/components/icon-text.d.ts +11 -0
- package/dist/components/icon-text.js +10 -0
- package/dist/components/image-sources.d.ts +57 -0
- package/dist/components/image-sources.js +58 -0
- package/dist/components/image-thumbnail.d.ts +34 -0
- package/dist/components/image-thumbnail.js +58 -0
- package/dist/components/image-viewer.d.ts +60 -0
- package/dist/components/image-viewer.js +411 -0
- package/dist/components/inline-edit.d.ts +22 -0
- package/dist/components/inline-edit.js +51 -0
- package/dist/components/input.d.ts +155 -0
- package/dist/components/input.js +109 -0
- package/dist/components/item-label.d.ts +11 -0
- package/dist/components/item-label.js +5 -0
- package/dist/components/item-row.d.ts +23 -0
- package/dist/components/item-row.js +19 -0
- package/dist/components/kbd.d.ts +55 -0
- package/dist/components/kbd.js +86 -0
- package/dist/components/link-policy.d.ts +24 -0
- package/dist/components/link-policy.js +32 -0
- package/dist/components/linked-text.d.ts +15 -0
- package/dist/components/linked-text.js +48 -0
- package/dist/components/loading-region.d.ts +18 -0
- package/dist/components/loading-region.js +17 -0
- package/dist/components/markdown.d.ts +34 -0
- package/dist/components/markdown.js +307 -0
- package/dist/components/menu.d.ts +132 -0
- package/dist/components/menu.js +102 -0
- package/dist/components/message-bubble.d.ts +57 -0
- package/dist/components/message-bubble.js +28 -0
- package/dist/components/meta-line.d.ts +28 -0
- package/dist/components/meta-line.js +36 -0
- package/dist/components/money-input.d.ts +23 -0
- package/dist/components/money-input.js +34 -0
- package/dist/components/native-select.d.ts +120 -0
- package/dist/components/native-select.js +85 -0
- package/dist/components/page-trail.d.ts +30 -0
- package/dist/components/page-trail.js +43 -0
- package/dist/components/page.d.ts +80 -0
- package/dist/components/page.js +60 -0
- package/dist/components/path-text.d.ts +8 -0
- package/dist/components/path-text.js +11 -0
- package/dist/components/percent-input.d.ts +20 -0
- package/dist/components/percent-input.js +32 -0
- package/dist/components/popover.d.ts +52 -0
- package/dist/components/popover.js +36 -0
- package/dist/components/progress.d.ts +389 -0
- package/dist/components/progress.js +142 -0
- package/dist/components/radio.d.ts +65 -0
- package/dist/components/radio.js +54 -0
- package/dist/components/remote-image-gate.d.ts +34 -0
- package/dist/components/remote-image-gate.js +60 -0
- package/dist/components/resizable.d.ts +23 -0
- package/dist/components/resizable.js +30 -0
- package/dist/components/rolling-number.d.ts +13 -0
- package/dist/components/rolling-number.js +69 -0
- package/dist/components/scroll-area.d.ts +17 -0
- package/dist/components/scroll-area.js +25 -0
- package/dist/components/section-label.d.ts +10 -0
- package/dist/components/section-label.js +21 -0
- package/dist/components/section-nav.d.ts +22 -0
- package/dist/components/section-nav.js +80 -0
- package/dist/components/segmented-control.d.ts +125 -0
- package/dist/components/segmented-control.js +101 -0
- package/dist/components/select.d.ts +120 -0
- package/dist/components/select.js +89 -0
- package/dist/components/separator.d.ts +10 -0
- package/dist/components/separator.js +12 -0
- package/dist/components/sheet.d.ts +101 -0
- package/dist/components/sheet.js +129 -0
- package/dist/components/shortcut-recorder.d.ts +31 -0
- package/dist/components/shortcut-recorder.js +67 -0
- package/dist/components/skeleton.d.ts +31 -0
- package/dist/components/skeleton.js +33 -0
- package/dist/components/slider.d.ts +138 -0
- package/dist/components/slider.js +98 -0
- package/dist/components/spinner.d.ts +37 -0
- package/dist/components/spinner.js +16 -0
- package/dist/components/switch.d.ts +59 -0
- package/dist/components/switch.js +56 -0
- package/dist/components/tab-strip.d.ts +29 -0
- package/dist/components/tab-strip.js +42 -0
- package/dist/components/tabs.d.ts +13 -0
- package/dist/components/tabs.js +68 -0
- package/dist/components/text-link.d.ts +54 -0
- package/dist/components/text-link.js +38 -0
- package/dist/components/textarea.d.ts +75 -0
- package/dist/components/textarea.js +90 -0
- package/dist/components/toast.d.ts +74 -0
- package/dist/components/toast.js +128 -0
- package/dist/components/tooltip.d.ts +50 -0
- package/dist/components/tooltip.js +46 -0
- package/dist/components/truncated-text.d.ts +15 -0
- package/dist/components/truncated-text.js +19 -0
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +7 -0
- package/dist/i18n/format.d.ts +84 -0
- package/dist/i18n/format.js +169 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/locales/ru-formatters.d.ts +3 -0
- package/dist/i18n/locales/ru-formatters.js +22 -0
- package/dist/i18n/locales/ru.d.ts +5 -0
- package/dist/i18n/locales/ru.js +185 -0
- package/dist/i18n/plural.d.ts +20 -0
- package/dist/i18n/plural.js +26 -0
- package/dist/i18n/provider.d.ts +36 -0
- package/dist/i18n/provider.js +39 -0
- package/dist/i18n/rich.d.ts +14 -0
- package/dist/i18n/rich.js +44 -0
- package/dist/i18n/ru-plural.d.ts +3 -0
- package/dist/i18n/ru-plural.js +4 -0
- package/dist/i18n/strings/alert-dialog.d.ts +5 -0
- package/dist/i18n/strings/alert-dialog.js +5 -0
- package/dist/i18n/strings/block-stack.d.ts +17 -0
- package/dist/i18n/strings/block-stack.js +11 -0
- package/dist/i18n/strings/code-block.d.ts +18 -0
- package/dist/i18n/strings/code-block.js +13 -0
- package/dist/i18n/strings/combobox.d.ts +12 -0
- package/dist/i18n/strings/combobox.js +10 -0
- package/dist/i18n/strings/command-palette.d.ts +17 -0
- package/dist/i18n/strings/command-palette.js +11 -0
- package/dist/i18n/strings/diff-view.d.ts +92 -0
- package/dist/i18n/strings/diff-view.js +68 -0
- package/dist/i18n/strings/image-viewer.d.ts +43 -0
- package/dist/i18n/strings/image-viewer.js +32 -0
- package/dist/i18n/strings/index.d.ts +314 -0
- package/dist/i18n/strings/index.js +43 -0
- package/dist/i18n/strings/kbd.d.ts +42 -0
- package/dist/i18n/strings/kbd.js +39 -0
- package/dist/i18n/strings/markdown.d.ts +23 -0
- package/dist/i18n/strings/markdown.js +18 -0
- package/dist/i18n/strings/money.d.ts +7 -0
- package/dist/i18n/strings/money.js +5 -0
- package/dist/i18n/strings/primitives.d.ts +25 -0
- package/dist/i18n/strings/primitives.js +18 -0
- package/dist/i18n/strings/progress.d.ts +4 -0
- package/dist/i18n/strings/progress.js +4 -0
- package/dist/i18n/strings/shortcut-recorder.d.ts +15 -0
- package/dist/i18n/strings/shortcut-recorder.js +11 -0
- package/dist/i18n/strings/time.d.ts +41 -0
- package/dist/i18n/strings/time.js +20 -0
- package/dist/i18n/strings/toast.d.ts +6 -0
- package/dist/i18n/strings/toast.js +6 -0
- package/dist/i18n/time-zones.d.ts +20 -0
- package/dist/i18n/time-zones.js +64 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +88 -0
- package/dist/lib/ansi.d.ts +55 -0
- package/dist/lib/ansi.js +438 -0
- package/dist/lib/clock.d.ts +32 -0
- package/dist/lib/clock.js +106 -0
- package/dist/lib/cn.d.ts +12 -0
- package/dist/lib/cn.js +28 -0
- package/dist/lib/control-props.d.ts +21 -0
- package/dist/lib/control-props.js +14 -0
- package/dist/lib/direction.d.ts +13 -0
- package/dist/lib/direction.js +25 -0
- package/dist/lib/image-size.d.ts +12 -0
- package/dist/lib/image-size.js +66 -0
- package/dist/lib/keycaps.d.ts +21 -0
- package/dist/lib/keycaps.js +62 -0
- package/dist/lib/keyed.d.ts +9 -0
- package/dist/lib/keyed.js +14 -0
- package/dist/lib/keys.d.ts +49 -0
- package/dist/lib/keys.js +159 -0
- package/dist/lib/kit-motion.d.ts +9 -0
- package/dist/lib/kit-motion.js +12 -0
- package/dist/lib/link-check-dialog.d.ts +11 -0
- package/dist/lib/link-check-dialog.js +18 -0
- package/dist/lib/motion-features.d.ts +1 -0
- package/dist/lib/motion-features.js +4 -0
- package/dist/lib/motion-provider.d.ts +5 -0
- package/dist/lib/motion-provider.js +8 -0
- package/dist/lib/motion.d.ts +39 -0
- package/dist/lib/motion.js +49 -0
- package/dist/lib/number-format.d.ts +114 -0
- package/dist/lib/number-format.js +313 -0
- package/dist/lib/patch.d.ts +72 -0
- package/dist/lib/patch.js +344 -0
- package/dist/lib/path.d.ts +34 -0
- package/dist/lib/path.js +85 -0
- package/dist/lib/search.d.ts +10 -0
- package/dist/lib/search.js +20 -0
- package/dist/lib/shiki-themes.d.ts +7 -0
- package/dist/lib/shiki-themes.js +115 -0
- package/dist/lib/shiki.d.ts +101 -0
- package/dist/lib/shiki.js +353 -0
- package/dist/lib/shortcut-context.d.ts +23 -0
- package/dist/lib/shortcut-context.js +29 -0
- package/dist/lib/text.d.ts +56 -0
- package/dist/lib/text.js +149 -0
- package/dist/lib/theme-scope.d.ts +18 -0
- package/dist/lib/theme-scope.js +24 -0
- package/dist/lib/theme.d.ts +40 -0
- package/dist/lib/theme.js +96 -0
- package/dist/lib/tree-keys.d.ts +72 -0
- package/dist/lib/tree-keys.js +126 -0
- package/dist/lib/url.d.ts +17 -0
- package/dist/lib/url.js +38 -0
- package/dist/lib/use-copy-to-clipboard.d.ts +13 -0
- package/dist/lib/use-copy-to-clipboard.js +40 -0
- package/dist/lib/use-delayed-loading.d.ts +11 -0
- package/dist/lib/use-delayed-loading.js +31 -0
- package/dist/lib/use-file-picker.d.ts +26 -0
- package/dist/lib/use-file-picker.js +43 -0
- package/dist/lib/use-hydrated.d.ts +6 -0
- package/dist/lib/use-hydrated.js +11 -0
- package/dist/lib/use-latest.d.ts +12 -0
- package/dist/lib/use-latest.js +22 -0
- package/dist/lib/use-resolved-theme.d.ts +12 -0
- package/dist/lib/use-resolved-theme.js +25 -0
- package/dist/styles/base.css +393 -0
- package/dist/styles/code.css +35 -0
- package/dist/styles/fonts.css +12 -0
- package/dist/styles/index.css +13 -0
- package/dist/styles/tokens.gen.css +494 -0
- package/package.json +208 -4
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
4
|
+
import { useDirection } from "@base-ui/react/direction-provider";
|
|
5
|
+
import { ChevronLeft, ChevronRight, Copy, Download, ImageOff, Minus, Plus } from "lucide-react";
|
|
6
|
+
import { AnimatePresence } from "motion/react";
|
|
7
|
+
import * as m from "motion/react-m";
|
|
8
|
+
import { createContext, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
9
|
+
import { useFormatters, useUiStrings } from "../i18n/provider.js";
|
|
10
|
+
import { cn } from "../lib/cn.js";
|
|
11
|
+
import { KitMotion } from "../lib/kit-motion.js";
|
|
12
|
+
import { spring } from "../lib/motion.js";
|
|
13
|
+
import { usePortalContainer } from "../lib/theme-scope.js";
|
|
14
|
+
import { useLatest } from "../lib/use-latest.js";
|
|
15
|
+
import { Button, IconButton } from "./button.js";
|
|
16
|
+
import { DialogBackdrop, DialogCloseButton } from "./dialog.js";
|
|
17
|
+
import { DrawnCheck } from "./drawn-check.js";
|
|
18
|
+
import { Spinner } from "./spinner.js";
|
|
19
|
+
import { Tooltip } from "./tooltip.js";
|
|
20
|
+
/** How far the viewer zooms in: eight screen pixels to an image pixel. */
|
|
21
|
+
const MAX_SCALE = 8;
|
|
22
|
+
/** One step of the zoom buttons and keys. */
|
|
23
|
+
const STEP = 1.25;
|
|
24
|
+
/** How much one pixel of wheel travel zooms (a trackpad pinch arrives as a ctrl+wheel). */
|
|
25
|
+
const WHEEL_ZOOM = 0.002;
|
|
26
|
+
const FIT = { zoom: "fit", x: 0, y: 0 };
|
|
27
|
+
/** Save's name for an image without one: its type's extension, read from inline data. */
|
|
28
|
+
function fallbackName(src) {
|
|
29
|
+
const type = /^data:image\/([a-z0-9.+-]+)[;,]/iu.exec(src)?.[1]?.toLowerCase();
|
|
30
|
+
const extension = type === undefined ? "png" : type === "jpeg" ? "jpg" : type.replace(/\+xml$/u, "");
|
|
31
|
+
return `image.${extension}`;
|
|
32
|
+
}
|
|
33
|
+
/** The image as a PNG, the type every clipboard takes, drawn from the picture already on screen. */
|
|
34
|
+
function pngOf(img) {
|
|
35
|
+
const canvas = document.createElement("canvas");
|
|
36
|
+
canvas.width = img.naturalWidth;
|
|
37
|
+
canvas.height = img.naturalHeight;
|
|
38
|
+
canvas.getContext("2d")?.drawImage(img, 0, 0);
|
|
39
|
+
return new Promise((resolve, reject) => canvas.toBlob((blob) => (blob === null ? reject(new Error("no image data")) : resolve(blob)), "image/png"));
|
|
40
|
+
}
|
|
41
|
+
/** Another site's image: a link to it can't download, it navigates. */
|
|
42
|
+
const isRemote = (src) => /^https?:/iu.test(src);
|
|
43
|
+
function save(src, name) {
|
|
44
|
+
const link = document.createElement("a");
|
|
45
|
+
link.href = src;
|
|
46
|
+
link.download = name ?? fallbackName(src);
|
|
47
|
+
link.click();
|
|
48
|
+
}
|
|
49
|
+
const NO_PICTURE = { kind: "failed" };
|
|
50
|
+
/** The image's picture, loading it when it has only `load`; undefined while it does. */
|
|
51
|
+
function usePicture(image) {
|
|
52
|
+
const [loaded, setLoaded] = useState();
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (image === undefined || image.src !== undefined || image.load === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
let live = true;
|
|
57
|
+
const { key, load } = image;
|
|
58
|
+
const get = async () => {
|
|
59
|
+
let picture = NO_PICTURE;
|
|
60
|
+
try {
|
|
61
|
+
const src = await load();
|
|
62
|
+
if (src !== undefined)
|
|
63
|
+
picture = { kind: "ready", src };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// It couldn't be had: the viewer says so.
|
|
67
|
+
}
|
|
68
|
+
if (live)
|
|
69
|
+
setLoaded({ key, picture });
|
|
70
|
+
};
|
|
71
|
+
void get();
|
|
72
|
+
return () => {
|
|
73
|
+
live = false;
|
|
74
|
+
};
|
|
75
|
+
}, [image]);
|
|
76
|
+
if (image === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (image.src !== undefined)
|
|
79
|
+
return { kind: "ready", src: image.src };
|
|
80
|
+
if (image.load === undefined)
|
|
81
|
+
return NO_PICTURE;
|
|
82
|
+
return loaded?.key === image.key ? loaded.picture : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Keeps the image over the window: centred along an axis where it's smaller, edge to edge where larger. */
|
|
85
|
+
const limit = (value, max) => Math.min(max, Math.max(-max, value));
|
|
86
|
+
function clamp(view, scale, natural, stage) {
|
|
87
|
+
const room = (image, window) => Math.max(0, (image * scale - window) / 2);
|
|
88
|
+
return {
|
|
89
|
+
...view,
|
|
90
|
+
x: limit(view.x, room(natural.width, stage.width)),
|
|
91
|
+
y: limit(view.y, room(natural.height, stage.height)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function useSize(element) {
|
|
95
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
if (element === null)
|
|
98
|
+
return undefined;
|
|
99
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
100
|
+
if (entry !== undefined)
|
|
101
|
+
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
|
102
|
+
});
|
|
103
|
+
observer.observe(element);
|
|
104
|
+
return () => observer.disconnect();
|
|
105
|
+
}, [element]);
|
|
106
|
+
return size;
|
|
107
|
+
}
|
|
108
|
+
/** Copy: the image as a PNG on the clipboard, and how it went on the button itself. */
|
|
109
|
+
function CopyImageButton({ image, disabled, }) {
|
|
110
|
+
const t = useUiStrings().imageViewer;
|
|
111
|
+
const [copied, setCopied] = useState();
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (copied === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
const timer = setTimeout(() => setCopied(undefined), COPY_FEEDBACK_MS);
|
|
116
|
+
return () => clearTimeout(timer);
|
|
117
|
+
}, [copied]);
|
|
118
|
+
const copy = () => {
|
|
119
|
+
const img = image.current;
|
|
120
|
+
if (img === null)
|
|
121
|
+
return;
|
|
122
|
+
// The item takes the promise, so the write keeps the click's permission while the PNG is drawn.
|
|
123
|
+
navigator.clipboard
|
|
124
|
+
.write([new ClipboardItem({ "image/png": pngOf(img) })])
|
|
125
|
+
.then(() => setCopied("copied"))
|
|
126
|
+
.catch(() => setCopied("failed"));
|
|
127
|
+
};
|
|
128
|
+
const label = copied === "copied" ? t.copied : t.copy;
|
|
129
|
+
return (_jsxs(_Fragment, { children: [copied === "failed" && (_jsx("span", { role: "alert", className: "shrink-0 px-1 text-xs text-danger-fg", children: t.copyFailed })), _jsx(Tooltip, { content: label, children: _jsxs(IconButton, { "aria-label": label, size: "sm", disabled: disabled, onClick: copy, children: [_jsx(KitMotion, { children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(m.span, { initial: { opacity: 0, scale: 0.6 }, animate: { opacity: 1, scale: 1, transition: spring.snappy }, exit: { opacity: 0, scale: 0.6, transition: { duration: 0.08 } }, className: "grid place-items-center", children: copied === "copied" ? _jsx(DrawnCheck, { className: "text-fg" }) : _jsx(Copy, {}) }, copied === "copied" ? "check" : "copy") }) }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: copied === "copied" ? t.copied : "" })] }) })] }));
|
|
130
|
+
}
|
|
131
|
+
/** The stage's picture: the image (placed by `style`), on its way, or why there is none. */
|
|
132
|
+
function StagePicture({ picture, alt, imgRef, onSize, onError, style, }) {
|
|
133
|
+
const t = useUiStrings().imageViewer;
|
|
134
|
+
if (picture === undefined)
|
|
135
|
+
return (_jsx("span", { role: "status", "aria-label": t.loading, className: "absolute inset-0 grid place-items-center", children: _jsx(Spinner, { size: "md", className: "text-fg-muted" }) }));
|
|
136
|
+
if (picture.kind === "failed")
|
|
137
|
+
return (_jsxs("p", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-fg-muted", children: [_jsx(ImageOff, { "aria-hidden": true, className: "size-6 text-fg-subtle" }), t.failed] }));
|
|
138
|
+
return (_jsx("img", { ref: imgRef, src: picture.src, alt: alt, draggable: false, onLoad: (event) => onSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }), onError: onError, style: style, className: "absolute start-1/2 top-1/2 max-w-none origin-center" }, picture.src));
|
|
139
|
+
}
|
|
140
|
+
/** Previous and next, on the stage's sides: in reading order, so mirrored in a right-to-left language. */
|
|
141
|
+
function StepButtons({ rtl, onStep }) {
|
|
142
|
+
const t = useUiStrings().imageViewer;
|
|
143
|
+
const Previous = rtl ? ChevronRight : ChevronLeft;
|
|
144
|
+
const Next = rtl ? ChevronLeft : ChevronRight;
|
|
145
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": t.previous, "aria-keyshortcuts": rtl ? "ArrowRight" : "ArrowLeft", variant: "secondary", onClick: () => onStep(-1), className: "absolute start-3 top-1/2 -translate-y-1/2", children: _jsx(Previous, {}) }), _jsx(IconButton, { "aria-label": t.next, "aria-keyshortcuts": rtl ? "ArrowLeft" : "ArrowRight", variant: "secondary", onClick: () => onStep(1), className: "absolute end-3 top-1/2 -translate-y-1/2", children: _jsx(Next, {}) })] }));
|
|
146
|
+
}
|
|
147
|
+
/** How long Copy shows how it went before it's Copy again. */
|
|
148
|
+
const COPY_FEEDBACK_MS = 1600;
|
|
149
|
+
/**
|
|
150
|
+
* An image full size over the page: the whole image in the window at first, then zoom (the wheel, a
|
|
151
|
+
* pinch, + and −, a double click) and drag to look around; ← and → step through the other images in
|
|
152
|
+
* reading order (so in a right-to-left language → goes back), Esc closes. Copy puts it on the
|
|
153
|
+
* clipboard as a PNG and says so on the button; Save downloads it. Zoom and panning move the picture
|
|
154
|
+
* by `transform` only.
|
|
155
|
+
*/
|
|
156
|
+
export function ImageViewer({ images, index, onIndexChange, onClose, returnFocus }) {
|
|
157
|
+
const container = usePortalContainer();
|
|
158
|
+
const t = useUiStrings().imageViewer;
|
|
159
|
+
const formatters = useFormatters();
|
|
160
|
+
const rtl = useDirection() === "rtl";
|
|
161
|
+
const titleId = useId();
|
|
162
|
+
const image = images[index];
|
|
163
|
+
// The stage mounts with the dialog's portal, after this component: a state, so its effects follow it.
|
|
164
|
+
const [stageElement, setStageElement] = useState(null);
|
|
165
|
+
const imgRef = useRef(null);
|
|
166
|
+
const stage = useSize(stageElement);
|
|
167
|
+
const [natural, setNatural] = useState(undefined);
|
|
168
|
+
const [failed, setFailed] = useState(false);
|
|
169
|
+
const [view, setView] = useState(FIT);
|
|
170
|
+
const [dragging, setDragging] = useState(false);
|
|
171
|
+
const picture = usePicture(image);
|
|
172
|
+
const src = picture?.kind === "ready" ? picture.src : undefined;
|
|
173
|
+
// Another image: its own size, from the whole of it again.
|
|
174
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: resets on a new image, which `src` names
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
setNatural(undefined);
|
|
177
|
+
setFailed(false);
|
|
178
|
+
setView(FIT);
|
|
179
|
+
}, [src]);
|
|
180
|
+
const fitScale = natural === undefined || stage.width === 0
|
|
181
|
+
? 1
|
|
182
|
+
: Math.min(1, stage.width / natural.width, stage.height / natural.height);
|
|
183
|
+
const scale = view.zoom === "fit" ? fitScale : view.zoom;
|
|
184
|
+
const minScale = Math.min(fitScale, 1);
|
|
185
|
+
/** Zooms to `next`, keeping the point `at` (from the stage's centre) where it is. */
|
|
186
|
+
const zoomTo = useCallback((next, at = { x: 0, y: 0 }) => {
|
|
187
|
+
if (natural === undefined)
|
|
188
|
+
return;
|
|
189
|
+
setView((current) => {
|
|
190
|
+
const from = current.zoom === "fit" ? fitScale : current.zoom;
|
|
191
|
+
const to = Math.min(MAX_SCALE, Math.max(minScale, next));
|
|
192
|
+
const ratio = to / from;
|
|
193
|
+
const moved = {
|
|
194
|
+
zoom: to,
|
|
195
|
+
x: at.x - (at.x - current.x) * ratio,
|
|
196
|
+
y: at.y - (at.y - current.y) * ratio,
|
|
197
|
+
};
|
|
198
|
+
return to === fitScale ? FIT : clamp(moved, to, natural, stage);
|
|
199
|
+
});
|
|
200
|
+
}, [natural, fitScale, minScale, stage]);
|
|
201
|
+
const toggle = () => (view.zoom === "fit" ? zoomTo(1) : setView(FIT));
|
|
202
|
+
const go = useCallback((step) => {
|
|
203
|
+
if (images.length < 2)
|
|
204
|
+
return;
|
|
205
|
+
onIndexChange((index + step + images.length) % images.length);
|
|
206
|
+
}, [images.length, index, onIndexChange]);
|
|
207
|
+
/** The viewer's keys, wherever the focus is in it: ← → step through the images, + − 0 zoom. */
|
|
208
|
+
const onKeyDown = (event) => {
|
|
209
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
210
|
+
return;
|
|
211
|
+
const keys = {
|
|
212
|
+
ArrowLeft: () => go(rtl ? 1 : -1),
|
|
213
|
+
ArrowRight: () => go(rtl ? -1 : 1),
|
|
214
|
+
"+": () => zoomTo(scale * STEP),
|
|
215
|
+
"=": () => zoomTo(scale * STEP),
|
|
216
|
+
"-": () => zoomTo(scale / STEP),
|
|
217
|
+
"0": () => setView(FIT),
|
|
218
|
+
};
|
|
219
|
+
const action = keys[event.key];
|
|
220
|
+
if (action === undefined)
|
|
221
|
+
return;
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
action();
|
|
224
|
+
};
|
|
225
|
+
const pannable = natural !== undefined && scale > fitScale;
|
|
226
|
+
// The stage's own listeners read the newest view through this, so they're added once per stage.
|
|
227
|
+
const live = useRef({ natural, scale, fitScale, pannable, stage, zoomTo });
|
|
228
|
+
useLayoutEffect(() => {
|
|
229
|
+
live.current = { natural, scale, fitScale, pannable, stage, zoomTo };
|
|
230
|
+
});
|
|
231
|
+
// Native listeners: a wheel that zooms must cancel the page's scroll (React's wheel is passive),
|
|
232
|
+
// and WebKit's trackpad pinch arrives as gesture events, not ctrl+wheel.
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (stageElement === null)
|
|
235
|
+
return undefined;
|
|
236
|
+
const element = stageElement;
|
|
237
|
+
const centre = (event) => {
|
|
238
|
+
const box = element.getBoundingClientRect();
|
|
239
|
+
return { x: event.clientX - box.left - box.width / 2, y: event.clientY - box.top - box.height / 2 };
|
|
240
|
+
};
|
|
241
|
+
const pan = (dx, dy) => {
|
|
242
|
+
const { natural: size, scale: now, stage: room } = live.current;
|
|
243
|
+
if (size === undefined)
|
|
244
|
+
return;
|
|
245
|
+
setView((current) => clamp({ ...current, x: current.x + dx, y: current.y + dy }, now, size, room));
|
|
246
|
+
};
|
|
247
|
+
const onWheel = (event) => {
|
|
248
|
+
const { natural: size, scale: now, pannable: canPan, zoomTo: zoom } = live.current;
|
|
249
|
+
if (size === undefined)
|
|
250
|
+
return;
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
if (event.ctrlKey || !canPan)
|
|
253
|
+
zoom(now * Math.exp(-event.deltaY * WHEEL_ZOOM), centre(event));
|
|
254
|
+
else
|
|
255
|
+
pan(-event.deltaX, -event.deltaY);
|
|
256
|
+
};
|
|
257
|
+
let drag;
|
|
258
|
+
const onDown = (event) => {
|
|
259
|
+
if (!live.current.pannable || event.button !== 0)
|
|
260
|
+
return;
|
|
261
|
+
element.setPointerCapture(event.pointerId);
|
|
262
|
+
drag = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
263
|
+
setDragging(true);
|
|
264
|
+
};
|
|
265
|
+
const onMove = (event) => {
|
|
266
|
+
if (drag === undefined || drag.id !== event.pointerId)
|
|
267
|
+
return;
|
|
268
|
+
pan(event.clientX - drag.x, event.clientY - drag.y);
|
|
269
|
+
drag.x = event.clientX;
|
|
270
|
+
drag.y = event.clientY;
|
|
271
|
+
};
|
|
272
|
+
const onUp = () => {
|
|
273
|
+
drag = undefined;
|
|
274
|
+
setDragging(false);
|
|
275
|
+
};
|
|
276
|
+
const onDouble = (event) => {
|
|
277
|
+
const { scale: now, fitScale: whole, zoomTo: zoom } = live.current;
|
|
278
|
+
if (now > whole)
|
|
279
|
+
setView(FIT);
|
|
280
|
+
else
|
|
281
|
+
zoom(1, centre(event));
|
|
282
|
+
};
|
|
283
|
+
let pinchFrom = 1;
|
|
284
|
+
const onGestureStart = (event) => {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
pinchFrom = live.current.scale;
|
|
287
|
+
};
|
|
288
|
+
const onGesture = (event) => {
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
const factor = Reflect.get(event, "scale");
|
|
291
|
+
if (typeof factor === "number")
|
|
292
|
+
live.current.zoomTo(pinchFrom * factor);
|
|
293
|
+
};
|
|
294
|
+
element.addEventListener("wheel", onWheel, { passive: false });
|
|
295
|
+
element.addEventListener("pointerdown", onDown);
|
|
296
|
+
element.addEventListener("pointermove", onMove);
|
|
297
|
+
element.addEventListener("pointerup", onUp);
|
|
298
|
+
element.addEventListener("pointercancel", onUp);
|
|
299
|
+
element.addEventListener("dblclick", onDouble);
|
|
300
|
+
element.addEventListener("gesturestart", onGestureStart);
|
|
301
|
+
element.addEventListener("gesturechange", onGesture);
|
|
302
|
+
return () => {
|
|
303
|
+
element.removeEventListener("wheel", onWheel);
|
|
304
|
+
element.removeEventListener("pointerdown", onDown);
|
|
305
|
+
element.removeEventListener("pointermove", onMove);
|
|
306
|
+
element.removeEventListener("pointerup", onUp);
|
|
307
|
+
element.removeEventListener("pointercancel", onUp);
|
|
308
|
+
element.removeEventListener("dblclick", onDouble);
|
|
309
|
+
element.removeEventListener("gesturestart", onGestureStart);
|
|
310
|
+
element.removeEventListener("gesturechange", onGesture);
|
|
311
|
+
};
|
|
312
|
+
}, [stageElement]);
|
|
313
|
+
if (image === undefined)
|
|
314
|
+
return null;
|
|
315
|
+
const broken = failed || picture?.kind === "failed";
|
|
316
|
+
const alt = image.alt === "" ? t.untitled : image.alt;
|
|
317
|
+
const percent = formatters.percent(Math.round(scale * 100));
|
|
318
|
+
const zoomLabel = view.zoom === "fit" ? t.actualSize : t.fit;
|
|
319
|
+
return (_jsx(BaseDialog.Root, { open: true, onOpenChange: (open) => {
|
|
320
|
+
if (!open)
|
|
321
|
+
onClose();
|
|
322
|
+
}, children: _jsxs(BaseDialog.Portal, { container: container, children: [_jsx(DialogBackdrop, {}), _jsxs(BaseDialog.Popup, { "data-slot": "image-viewer", "aria-labelledby": titleId,
|
|
323
|
+
// Before the controls inside: a focused button's own arrow handling must not take them.
|
|
324
|
+
onKeyDownCapture: onKeyDown,
|
|
325
|
+
// Back to the thumbnail that opened it, while it's on screen; else wherever the page puts it.
|
|
326
|
+
finalFocus: () => {
|
|
327
|
+
const opener = returnFocus?.();
|
|
328
|
+
return opener?.isConnected === true ? opener : true;
|
|
329
|
+
}, className: cn("fixed inset-3 z-(--z-overlay) flex flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface-overlay text-fg outline-none sm:inset-6", "[box-shadow:var(--shadow-xl),var(--shadow-inset-edge)]", "transition-[opacity,scale] duration-(--duration-base) ease-(--ease-out)", "data-starting-style:scale-[0.98] data-starting-style:opacity-0", "data-ending-style:opacity-0 data-ending-style:duration-(--duration-fast)", "motion-reduce:data-starting-style:scale-100"), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-1 border-b border-border-subtle py-2 ps-4 pe-2", children: [_jsx(BaseDialog.Title, { id: titleId, className: "min-w-0 flex-1 truncate text-sm font-medium text-fg", title: alt, children: alt }), images.length > 1 && (_jsx("span", { "aria-live": "polite", className: "shrink-0 px-2 text-xs text-fg-subtle tabular", children: t.position({ index: index + 1, total: images.length }) })), _jsx(Tooltip, { content: t.zoomOut, children: _jsx(IconButton, { "aria-label": t.zoomOut, "aria-keyshortcuts": "-", size: "sm", disabled: natural === undefined || scale <= minScale, onClick: () => zoomTo(scale / STEP), children: _jsx(Minus, {}) }) }), _jsx(Tooltip, { content: zoomLabel, children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": `${zoomLabel} (${percent})`, "aria-keyshortcuts": view.zoom === "fit" ? undefined : "0", disabled: natural === undefined, onClick: toggle, className: "min-w-14 tabular", children: percent }) }), _jsx(Tooltip, { content: t.zoomIn, children: _jsx(IconButton, { "aria-label": t.zoomIn, "aria-keyshortcuts": "+", size: "sm", disabled: natural === undefined || scale >= MAX_SCALE, onClick: () => zoomTo(scale * STEP), children: _jsx(Plus, {}) }) }), _jsx("span", { "aria-hidden": true, className: "mx-1 h-4 w-px bg-border-subtle" }), _jsx(CopyImageButton, { image: imgRef, disabled: natural === undefined }), _jsx(Tooltip, { content: t.save, children: _jsx(IconButton, { "aria-label": t.save, size: "sm", disabled: broken || src === undefined || isRemote(src), onClick: () => src !== undefined && save(src, image.name), children: _jsx(Download, {}) }) }), _jsx(DialogCloseButton, { className: "static" })] }), _jsxs("div", { ref: setStageElement, "data-slot": "image-viewer-stage", className: cn("relative min-h-0 flex-1 touch-none overflow-hidden bg-bg-subtle select-none", pannable && (dragging ? "cursor-grabbing" : "cursor-grab")), children: [_jsx(StagePicture, { picture: broken ? NO_PICTURE : src === undefined ? undefined : { kind: "ready", src }, alt: alt, imgRef: imgRef, onSize: setNatural, onError: () => setFailed(true), style: natural === undefined
|
|
330
|
+
? { opacity: 0 }
|
|
331
|
+
: {
|
|
332
|
+
width: natural.width,
|
|
333
|
+
height: natural.height,
|
|
334
|
+
marginInlineStart: -natural.width / 2,
|
|
335
|
+
marginTop: -natural.height / 2,
|
|
336
|
+
transform: `translate(${view.x}px, ${view.y}px) scale(${scale})`,
|
|
337
|
+
} }), images.length > 1 && _jsx(StepButtons, { rtl: rtl, onStep: go })] })] })] }) }));
|
|
338
|
+
}
|
|
339
|
+
const GalleryContext = createContext(undefined);
|
|
340
|
+
const GroupContext = createContext(undefined);
|
|
341
|
+
/** The registered images of `group` on screen, in reading order (when the host can't list them). */
|
|
342
|
+
function onScreen(entries, group) {
|
|
343
|
+
return [...entries.values()]
|
|
344
|
+
.filter((entry) => entry.group === group && entry.element() !== null)
|
|
345
|
+
.toSorted((a, b) => {
|
|
346
|
+
const first = a.element();
|
|
347
|
+
const second = b.element();
|
|
348
|
+
if (first === null || second === null)
|
|
349
|
+
return 0;
|
|
350
|
+
return first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Where images open together: a thumbnail's click opens the viewer on it, and ← → step through the
|
|
355
|
+
* other images of its group (`ImageGroup`, a turn), in reading order. Without one, an image opens on
|
|
356
|
+
* its own.
|
|
357
|
+
*/
|
|
358
|
+
export function ImageGallery({ imagesOf, children }) {
|
|
359
|
+
const entries = useRef(new Map());
|
|
360
|
+
const listed = useLatest(imagesOf);
|
|
361
|
+
const [showing, setShowing] = useState();
|
|
362
|
+
const gallery = useMemo(() => ({
|
|
363
|
+
register: (id, entry) => {
|
|
364
|
+
entries.current.set(id, entry);
|
|
365
|
+
return () => {
|
|
366
|
+
entries.current.delete(id);
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
open: (id) => {
|
|
370
|
+
const chosen = entries.current.get(id);
|
|
371
|
+
if (chosen === undefined)
|
|
372
|
+
return;
|
|
373
|
+
const all = chosen.group === undefined ? undefined : listed.current?.(chosen.group);
|
|
374
|
+
const at = all?.findIndex((image) => image.key === chosen.image.key) ?? -1;
|
|
375
|
+
if (all !== undefined && at >= 0) {
|
|
376
|
+
setShowing({ images: all, index: at, opener: chosen.element });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const together = onScreen(entries.current, chosen.group);
|
|
380
|
+
const index = Math.max(0, together.indexOf(chosen));
|
|
381
|
+
setShowing({ images: together.map((entry) => entry.image), index, opener: chosen.element });
|
|
382
|
+
},
|
|
383
|
+
}), []);
|
|
384
|
+
return (_jsxs(GalleryContext, { value: gallery, children: [children, showing !== undefined && (_jsx(ImageViewer, { images: showing.images, index: showing.index, onIndexChange: (index) => setShowing({ ...showing, index }), onClose: () => setShowing(undefined), returnFocus: showing.opener }))] }));
|
|
385
|
+
}
|
|
386
|
+
/** The images below open together in the gallery (a turn's). */
|
|
387
|
+
export function ImageGroup({ id, children }) {
|
|
388
|
+
return _jsx(GroupContext, { value: id, children: children });
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* An image that opens in the viewer: in the gallery around it with its group, else on its own.
|
|
392
|
+
* `element` is what puts it in reading order and gets the focus back. Returns the open action and,
|
|
393
|
+
* without a gallery, the viewer to render.
|
|
394
|
+
*/
|
|
395
|
+
export function useViewerImage(image, element) {
|
|
396
|
+
const gallery = use(GalleryContext);
|
|
397
|
+
const group = use(GroupContext);
|
|
398
|
+
const id = useId();
|
|
399
|
+
const [alone, setAlone] = useState(false);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (gallery === undefined || image === undefined)
|
|
402
|
+
return undefined;
|
|
403
|
+
return gallery.register(id, { image, group, element: () => element.current });
|
|
404
|
+
}, [gallery, image, group, id, element]);
|
|
405
|
+
if (gallery !== undefined)
|
|
406
|
+
return { open: () => gallery.open(id), viewer: null };
|
|
407
|
+
return {
|
|
408
|
+
open: () => setAlone(true),
|
|
409
|
+
viewer: alone && image !== undefined ? (_jsx(ImageViewer, { images: [image], index: 0, onIndexChange: () => undefined, onClose: () => setAlone(false), returnFocus: () => element.current })) : null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type InputProps } from "./input.js";
|
|
2
|
+
export interface InlineEditProps extends Omit<InputProps, "value" | "defaultValue" | "onChange" | "onValueChange" | "onKeyDown" | "onBlur" | "onFocus" | "onClick" | "autoFocus" | "aria-label" | "leading" | "trailing"> {
|
|
3
|
+
/** The text the field opens with, selected. */
|
|
4
|
+
readonly initial: string;
|
|
5
|
+
/** The field's name for assistive technology ("Document title"). */
|
|
6
|
+
readonly "aria-label": string;
|
|
7
|
+
/** Enter, or focus leaving the field: the text as typed. The caller trims it or refuses an empty one. */
|
|
8
|
+
readonly onCommit: (value: string) => void;
|
|
9
|
+
/** Escape: the edit is dropped. */
|
|
10
|
+
readonly onCancel: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Renames something where its name is shown: a sidebar row, a page title, a tab. The caller mounts
|
|
14
|
+
* the field when the edit starts; it opens focused with its text selected, and the first of Enter,
|
|
15
|
+
* Escape or focus leaving it ends the edit. Enter and leaving commit, Escape cancels. While an input
|
|
16
|
+
* method composes (Chinese, Japanese, Korean…), Enter and Escape are its own and end nothing.
|
|
17
|
+
*
|
|
18
|
+
* Plain keys stay in the field, so a tab strip's arrow keys or a pane's Escape don't act while the
|
|
19
|
+
* name is being typed; shortcuts with ⌘, Ctrl or Alt still reach the app. A click in the field stays
|
|
20
|
+
* there too, so the row or tab around it doesn't take it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function InlineEdit({ initial, onCommit, onCancel, size, ...props }: InlineEditProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { isComposing } from "../lib/keys.js";
|
|
5
|
+
import { Input } from "./input.js";
|
|
6
|
+
/**
|
|
7
|
+
* Renames something where its name is shown: a sidebar row, a page title, a tab. The caller mounts
|
|
8
|
+
* the field when the edit starts; it opens focused with its text selected, and the first of Enter,
|
|
9
|
+
* Escape or focus leaving it ends the edit. Enter and leaving commit, Escape cancels. While an input
|
|
10
|
+
* method composes (Chinese, Japanese, Korean…), Enter and Escape are its own and end nothing.
|
|
11
|
+
*
|
|
12
|
+
* Plain keys stay in the field, so a tab strip's arrow keys or a pane's Escape don't act while the
|
|
13
|
+
* name is being typed; shortcuts with ⌘, Ctrl or Alt still reach the app. A click in the field stays
|
|
14
|
+
* there too, so the row or tab around it doesn't take it.
|
|
15
|
+
*/
|
|
16
|
+
export function InlineEdit({ initial, onCommit, onCancel, size = "sm", ...props }) {
|
|
17
|
+
const ref = useRef(null);
|
|
18
|
+
const [value, setValue] = useState(initial);
|
|
19
|
+
// One outcome per edit: the Enter that commits also unmounts the field, and the blur that follows
|
|
20
|
+
// must not commit a second time (or after Escape).
|
|
21
|
+
const ended = useRef(false);
|
|
22
|
+
const end = (commit) => {
|
|
23
|
+
if (ended.current)
|
|
24
|
+
return;
|
|
25
|
+
ended.current = true;
|
|
26
|
+
if (commit)
|
|
27
|
+
onCommit(value);
|
|
28
|
+
else
|
|
29
|
+
onCancel();
|
|
30
|
+
};
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
ref.current?.focus();
|
|
33
|
+
ref.current?.select();
|
|
34
|
+
}, []);
|
|
35
|
+
const onKeyDown = (event) => {
|
|
36
|
+
if (!(event.metaKey || event.ctrlKey || event.altKey))
|
|
37
|
+
event.stopPropagation();
|
|
38
|
+
if (isComposing(event))
|
|
39
|
+
return;
|
|
40
|
+
if (event.key === "Enter") {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
end(true);
|
|
43
|
+
}
|
|
44
|
+
else if (event.key === "Escape") {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
end(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return (_jsx(Input, { ref: ref, size: size, value: value, onValueChange: (next) => setValue(next), onKeyDown: onKeyDown, onBlur: () => end(true), onClick: (event) => event.stopPropagation(), ...props }));
|
|
51
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Input as BaseInput } from "@base-ui/react/input";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
import { type VariantProps } from "../lib/cn.js";
|
|
4
|
+
/**
|
|
5
|
+
* The shared "field chrome" for every text-entry surface (Input, Textarea, Select trigger,
|
|
6
|
+
* Combobox): quiet surface, hairline border, firmer border on hover. Each consumer adds its own
|
|
7
|
+
* focus selector with `border-border-strong ring-3` — the halo colour (focus blue, or danger when
|
|
8
|
+
* invalid) is preset here. Invalid follows Base UI's `data-invalid` (inside <Field>) or
|
|
9
|
+
* `aria-invalid`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const fieldChrome: string;
|
|
12
|
+
declare const inputStyles: import("tailwind-variants").TVReturnType<{
|
|
13
|
+
size: {
|
|
14
|
+
sm: {
|
|
15
|
+
root: string;
|
|
16
|
+
input: string;
|
|
17
|
+
};
|
|
18
|
+
md: {
|
|
19
|
+
root: string;
|
|
20
|
+
input: string;
|
|
21
|
+
};
|
|
22
|
+
lg: {
|
|
23
|
+
root: string;
|
|
24
|
+
input: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* `wash`: a resting field on chrome (a sidebar's search): ink over whatever is beneath and no
|
|
29
|
+
* border until it has focus, when it takes the field's surface. Paper's own surface reads yellow
|
|
30
|
+
* over light vibrancy.
|
|
31
|
+
*/
|
|
32
|
+
appearance: {
|
|
33
|
+
field: {};
|
|
34
|
+
wash: {
|
|
35
|
+
root: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* `bare`: text you edit in place, inside a heading or a card (a task's title): no chrome at rest
|
|
39
|
+
* and the surrounding type (size, weight, line height), a wash on hover and focus, and the focus
|
|
40
|
+
* halo. Touch screens still get at least 16 px, so iOS doesn't zoom.
|
|
41
|
+
*/
|
|
42
|
+
bare: {
|
|
43
|
+
root: string[];
|
|
44
|
+
input: string;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
}, {
|
|
48
|
+
root: string[];
|
|
49
|
+
input: string[];
|
|
50
|
+
adornment: string;
|
|
51
|
+
}, undefined, {
|
|
52
|
+
size: {
|
|
53
|
+
sm: {
|
|
54
|
+
root: string;
|
|
55
|
+
input: string;
|
|
56
|
+
};
|
|
57
|
+
md: {
|
|
58
|
+
root: string;
|
|
59
|
+
input: string;
|
|
60
|
+
};
|
|
61
|
+
lg: {
|
|
62
|
+
root: string;
|
|
63
|
+
input: string;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* `wash`: a resting field on chrome (a sidebar's search): ink over whatever is beneath and no
|
|
68
|
+
* border until it has focus, when it takes the field's surface. Paper's own surface reads yellow
|
|
69
|
+
* over light vibrancy.
|
|
70
|
+
*/
|
|
71
|
+
appearance: {
|
|
72
|
+
field: {};
|
|
73
|
+
wash: {
|
|
74
|
+
root: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* `bare`: text you edit in place, inside a heading or a card (a task's title): no chrome at rest
|
|
78
|
+
* and the surrounding type (size, weight, line height), a wash on hover and focus, and the focus
|
|
79
|
+
* halo. Touch screens still get at least 16 px, so iOS doesn't zoom.
|
|
80
|
+
*/
|
|
81
|
+
bare: {
|
|
82
|
+
root: string[];
|
|
83
|
+
input: string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
}, {
|
|
87
|
+
root: string[];
|
|
88
|
+
input: string[];
|
|
89
|
+
adornment: string;
|
|
90
|
+
}, import("tailwind-variants").TVReturnTypeLike<{
|
|
91
|
+
size: {
|
|
92
|
+
sm: {
|
|
93
|
+
root: string;
|
|
94
|
+
input: string;
|
|
95
|
+
};
|
|
96
|
+
md: {
|
|
97
|
+
root: string;
|
|
98
|
+
input: string;
|
|
99
|
+
};
|
|
100
|
+
lg: {
|
|
101
|
+
root: string;
|
|
102
|
+
input: string;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* `wash`: a resting field on chrome (a sidebar's search): ink over whatever is beneath and no
|
|
107
|
+
* border until it has focus, when it takes the field's surface. Paper's own surface reads yellow
|
|
108
|
+
* over light vibrancy.
|
|
109
|
+
*/
|
|
110
|
+
appearance: {
|
|
111
|
+
field: {};
|
|
112
|
+
wash: {
|
|
113
|
+
root: string;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* `bare`: text you edit in place, inside a heading or a card (a task's title): no chrome at rest
|
|
117
|
+
* and the surrounding type (size, weight, line height), a wash on hover and focus, and the focus
|
|
118
|
+
* halo. Touch screens still get at least 16 px, so iOS doesn't zoom.
|
|
119
|
+
*/
|
|
120
|
+
bare: {
|
|
121
|
+
root: string[];
|
|
122
|
+
input: string;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
}, {
|
|
126
|
+
root: string[];
|
|
127
|
+
input: string[];
|
|
128
|
+
adornment: string;
|
|
129
|
+
}>>;
|
|
130
|
+
type InputVariants = VariantProps<typeof inputStyles>;
|
|
131
|
+
export interface InputProps extends Omit<ComponentProps<typeof BaseInput>, "size" | "className">, InputVariants {
|
|
132
|
+
/** Icon, Kbd or IconButton rendered before the text. */
|
|
133
|
+
leading?: ReactNode;
|
|
134
|
+
/** Icon, Kbd, unit or IconButton rendered after the text. */
|
|
135
|
+
trailing?: ReactNode;
|
|
136
|
+
/** Marks the input invalid outside of a <Field> (inside one, Field drives it). */
|
|
137
|
+
invalid?: boolean;
|
|
138
|
+
/** Classes for the outer frame (width, margins…). */
|
|
139
|
+
className?: string | undefined;
|
|
140
|
+
/** Classes for the inner `<input>` element. */
|
|
141
|
+
inputClassName?: ComponentProps<typeof BaseInput>["className"];
|
|
142
|
+
}
|
|
143
|
+
export declare function Input({ size, appearance, leading, trailing, invalid, className, inputClassName, ...props }: InputProps): import("react").JSX.Element;
|
|
144
|
+
export interface SearchInputProps extends Omit<InputProps, "type" | "leading" | "aria-label"> {
|
|
145
|
+
/** What it searches ("Search models"): its name for assistive technology, and its placeholder unless one is given. */
|
|
146
|
+
readonly label: string;
|
|
147
|
+
/**
|
|
148
|
+
* Empties a controlled field: while it has text, a clear button takes `trailing`'s place and Escape
|
|
149
|
+
* clears it.
|
|
150
|
+
*/
|
|
151
|
+
readonly onClear?: () => void;
|
|
152
|
+
}
|
|
153
|
+
/** A search field: the search glyph before the text, no autocomplete or spellcheck. */
|
|
154
|
+
export declare function SearchInput({ label, placeholder, onClear, trailing, onKeyDown, ...props }: SearchInputProps): import("react").JSX.Element;
|
|
155
|
+
export {};
|