@enigmax/primitives 0.24.0 → 0.26.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 (59) hide show
  1. package/dist/chunk-4SZ5N7BA.js +121 -0
  2. package/dist/chunk-EYZ366LP.js +189 -0
  3. package/dist/chunk-ISXB5RUH.js +380 -0
  4. package/dist/chunk-JJVZYW5C.js +946 -0
  5. package/dist/chunk-KVLSOQTI.js +66 -0
  6. package/dist/chunk-QIIV2QSV.js +108 -0
  7. package/dist/{chunk-QJJ34E4G.js → chunk-RMNXVWQ4.js} +32 -15
  8. package/dist/chunk-RUEQ3TGP.js +155 -0
  9. package/dist/clipboard-menu-BvXJCSFN.d.ts +93 -0
  10. package/dist/{color-OPV3BJV6.js → color-T63FLJNH.js} +183 -120
  11. package/dist/context-BfjYGPnH.d.ts +54 -0
  12. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-D3FtTn7v.d.ts} +1 -91
  13. package/dist/{index-ZPvlf9vs.d.ts → index-qXOkYQCU.d.ts} +4 -0
  14. package/dist/index.d.ts +266 -2
  15. package/dist/index.js +4 -2
  16. package/dist/menu-MSQSE6U5.js +54 -0
  17. package/dist/menu-PNQRTRNV.js +33 -0
  18. package/dist/next/index.d.ts +7 -3
  19. package/dist/next/index.js +11 -7
  20. package/dist/react/context-menu.d.ts +8 -54
  21. package/dist/react/image.d.ts +109 -0
  22. package/dist/react/image.js +3 -0
  23. package/dist/react/index.d.ts +8 -4
  24. package/dist/react/index.js +11 -7
  25. package/dist/react/input.d.ts +1 -1
  26. package/dist/react/input.js +2 -2
  27. package/dist/react/video.d.ts +120 -0
  28. package/dist/react/video.js +3 -0
  29. package/dist/react-router/index.d.ts +7 -3
  30. package/dist/react-router/index.js +11 -7
  31. package/dist/viewer-73LSE6RR.js +3 -0
  32. package/package.json +15 -2
  33. package/recipes/color/styles.css +20 -0
  34. package/recipes/image/styles.css +182 -0
  35. package/recipes/video/styles.css +154 -0
  36. package/registry.json +477 -3
  37. package/src/core/image-viewer.ts +222 -0
  38. package/src/core/player.ts +303 -0
  39. package/src/index.ts +52 -0
  40. package/src/react/image/icons.tsx +55 -0
  41. package/src/react/image/index.tsx +185 -0
  42. package/src/react/image/menu.tsx +76 -0
  43. package/src/react/image/styles.ts +213 -0
  44. package/src/react/image/types.ts +113 -0
  45. package/src/react/image/viewer.tsx +571 -0
  46. package/src/react/index.ts +3 -0
  47. package/src/react/input/color-styles.ts +20 -0
  48. package/src/react/input/color-swatch.tsx +96 -0
  49. package/src/react/input/color.tsx +84 -23
  50. package/src/react/input/index.tsx +27 -4
  51. package/src/react/input/types.ts +4 -0
  52. package/src/react/video/icons.tsx +135 -0
  53. package/src/react/video/index.tsx +724 -0
  54. package/src/react/video/menu.tsx +66 -0
  55. package/src/react/video/rail.tsx +99 -0
  56. package/src/react/video/styles.ts +161 -0
  57. package/src/react/video/types.ts +135 -0
  58. package/dist/chunk-S7EE57YB.js +0 -9
  59. /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { toItem } from "@/react/image/types";
4
+ import { injectImageStyles } from "@/react/image/styles";
5
+ import type { ImageItem, ImageProps, ZoomOptions } from "@/react/image/types";
6
+ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
+
8
+ /**
9
+ * `<Image>` - the picture in the page, and the viewer a press on it opens.
10
+ *
11
+ * ```tsx
12
+ * <Image src="/shot.png" alt="The dashboard" />
13
+ * <Image src={shot} alt="..." images={gallery} navigation thumbnails menu discardable />
14
+ * ```
15
+ *
16
+ * WHAT IS ON BY DEFAULT is the API: click to enlarge, and zoom with the wheel. The arrows,
17
+ * the strip of previews, the menu and the discard action are each a decision about the page
18
+ * this sits in - a product gallery wants all four, an avatar wants none - so each is a prop
19
+ * you turn on rather than one you have to remember to turn off.
20
+ *
21
+ * WHAT LOADS. This module is the `<img>` and the press. The lightbox is its own chunk, and
22
+ * its menu another under that, so a page of images nobody enlarges downloads neither. The
23
+ * chunk is fetched on INTENT - a pointer over the image, or focus reaching it - so by the
24
+ * time the press lands it is usually already here; a press that beats it opens the viewer as
25
+ * soon as it arrives, rather than being dropped.
26
+ */
27
+
28
+ const ImageViewer = lazy(() => import("@/react/image/viewer").then((module) => ({ default: module.ImageViewer })));
29
+
30
+ /** Kept out of the render so the hover prefetch fires once per session rather than per event. */
31
+ let prefetched = false;
32
+
33
+ function prefetchViewer(): void {
34
+ if (prefetched) return;
35
+ prefetched = true;
36
+ void import("@/react/image/viewer");
37
+ }
38
+
39
+ const ZOOM_DEFAULTS = { min: 1, max: 8, wheel: true, doubleClick: true } as const;
40
+
41
+ export function Image(props: ImageProps): ReactNode {
42
+ const {
43
+ src,
44
+ alt,
45
+ images,
46
+ index,
47
+ lightbox = true,
48
+ zoom = true,
49
+ animate = true,
50
+ navigation = false,
51
+ thumbnails = false,
52
+ menu = false,
53
+ discardable = false,
54
+ onDiscard,
55
+ loop = true,
56
+ caption,
57
+ onOpenChange,
58
+ onIndexChange,
59
+ styles = true,
60
+ labels = {},
61
+ wrapperProps,
62
+ ...rest
63
+ } = props;
64
+
65
+ // Before paint, and from HERE rather than only from the viewer: the picture in the page is
66
+ // styled from the first frame, so it says it enlarges before anybody has opened one.
67
+ useLayoutEffect(() => { if (styles) injectImageStyles(); }, [styles]);
68
+
69
+ const [open, setOpen] = useState(false);
70
+
71
+ const items = useMemo<ImageItem[]>(() => {
72
+ const set = (images ?? [src]).map(toItem);
73
+ // A set that does not contain this image would open the viewer on somebody else's
74
+ // picture, so the one that was pressed is always in it.
75
+ return set.some((entry) => entry.src === src) ? set : [{ src, alt }, ...set];
76
+ }, [images, src, alt]);
77
+
78
+ const initial = useMemo(() => {
79
+ if (typeof index === "number" && index >= 0 && index < items.length) return index;
80
+ const found = items.findIndex((entry) => entry.src === src);
81
+ return found === -1 ? 0 : found;
82
+ }, [index, items, src]);
83
+
84
+ const [current, setCurrent] = useState(initial);
85
+ useEffect(() => setCurrent(initial), [initial]);
86
+
87
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
88
+ const thumbnailRef = useRef<HTMLImageElement | null>(null);
89
+
90
+ const shown = useRef(current);
91
+ shown.current = current;
92
+
93
+ /**
94
+ * Where this picture is in the page, read at the moment the viewer needs it.
95
+ *
96
+ * Read live rather than captured on open: the flight back happens whenever the viewer is
97
+ * closed, and by then the page may have scrolled. A rectangle taken at open would land the
98
+ * picture where the thumbnail used to be.
99
+ *
100
+ * Null once the reader has moved through a set to a different image. This component knows
101
+ * where ITS picture is and nothing about anyone else's, so flying the fourth image back
102
+ * onto the first one's thumbnail would be a lie; the viewer then closes without one.
103
+ */
104
+ const origin = useCallback((): DOMRect | null => (
105
+ shown.current === initial ? thumbnailRef.current?.getBoundingClientRect() ?? null : null
106
+ ), [initial]);
107
+
108
+ const zoomOptions = useMemo(() => {
109
+ if (zoom === false) return null;
110
+ const given: ZoomOptions = zoom === true ? {} : zoom;
111
+ return { ...ZOOM_DEFAULTS, ...given, doubleClick: given.doubleClick !== false, wheel: given.wheel !== false };
112
+ }, [zoom]);
113
+
114
+ const menuOptions = useMemo(() => (menu === false ? null : menu === true ? {} : menu), [menu]);
115
+
116
+ const show = useCallback(() => {
117
+ if (!lightbox) return;
118
+ // A press before the chunk lands is not lost: this is state, and the viewer mounts
119
+ // with it the moment its code is here.
120
+ setOpen(true);
121
+ onOpenChange?.(true);
122
+ }, [lightbox, onOpenChange]);
123
+
124
+ const close = useCallback(() => {
125
+ setOpen(false);
126
+ onOpenChange?.(false);
127
+ // Back where the press came from: closing a dialog that leaves focus on the body
128
+ // sends the next Tab to the top of the page.
129
+ triggerRef.current?.focus();
130
+ }, [onOpenChange]);
131
+
132
+ const goTo = useCallback((next: number) => {
133
+ setCurrent(next);
134
+ const item = items[next];
135
+ if (item) onIndexChange?.(next, item);
136
+ }, [items, onIndexChange]);
137
+
138
+ const image = <img ref={thumbnailRef} src={src} alt={alt} {...rest} />;
139
+
140
+ return (
141
+ <span {...wrapperProps} data-enigma-image="" data-clickable={lightbox ? "" : undefined}>
142
+ {lightbox ? (
143
+ <button
144
+ ref={triggerRef}
145
+ type="button"
146
+ data-enigma-image-trigger=""
147
+ // The image IS the label: a second name here would have a screen reader
148
+ // read the picture twice, once for the button and once for the alt.
149
+ aria-label={labels.open ? `${labels.open}: ${alt}` : alt}
150
+ aria-haspopup="dialog"
151
+ onClick={show}
152
+ onPointerEnter={prefetchViewer}
153
+ onFocus={prefetchViewer}
154
+ >
155
+ {image}
156
+ </button>
157
+ ) : image}
158
+
159
+ {open && (
160
+ <Suspense fallback={null}>
161
+ <ImageViewer
162
+ items={items}
163
+ index={current}
164
+ onIndex={goTo}
165
+ onClose={close}
166
+ zoom={zoomOptions}
167
+ navigation={navigation}
168
+ thumbnails={thumbnails}
169
+ menu={menuOptions}
170
+ discardable={discardable}
171
+ onDiscard={onDiscard}
172
+ loop={loop}
173
+ caption={caption}
174
+ origin={origin}
175
+ animate={animate}
176
+ styles={styles}
177
+ labels={labels}
178
+ />
179
+ </Suspense>
180
+ )}
181
+ </span>
182
+ );
183
+ }
184
+
185
+ export type { ImageProps, ImageItem, ImageSource, ImageLabels, ImageMenuOptions, ZoomOptions } from "@/react/image/types";
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import * as icons from "@/react/image/icons";
4
+ import { useRef, type ReactNode } from "react";
5
+ import { MenuButton } from "@/react/image/viewer";
6
+ import { downloadFile } from "@/core/image-viewer";
7
+ import type { ImageItem, ImageLabels, ImageMenuOptions } from "@/react/image/types";
8
+ import { ContextMenu, useContextMenuContext, type ContextMenuNode } from "@/react/context-menu";
9
+
10
+ /**
11
+ * The three dots, and the rows under them.
12
+ *
13
+ * The MENU is the context menu component, opened from a left press at the button's corner
14
+ * rather than from a right-click - which is the composition its own docs describe. Writing a
15
+ * second popup here would mean a second keyboard model, a second set of roles and a second
16
+ * thing to fix, and the `fe-context-menu-hand-rolled` guardrail exists to say so.
17
+ *
18
+ * Its own chunk under the viewer's: the menu is off by default, so a viewer that was never
19
+ * given one downloads neither this module nor the component it composes.
20
+ */
21
+
22
+ export interface ImageMenuProps {
23
+ item: ImageItem;
24
+ index: number;
25
+ options: ImageMenuOptions;
26
+ labels: ImageLabels;
27
+ }
28
+
29
+ export function ImageMenu({ item, index, options, labels }: ImageMenuProps): ReactNode {
30
+ const rows: ContextMenuNode[] = [];
31
+ if (options.download !== false) {
32
+ rows.push({ id: "download", label: labels.download ?? "Download", icon: <icons.Download /> });
33
+ }
34
+ if (options.items?.length) {
35
+ if (rows.length > 0) rows.push({ type: "separator" });
36
+ rows.push(...options.items);
37
+ }
38
+
39
+ return (
40
+ <ContextMenu.Root
41
+ items={rows}
42
+ // The rows act on the picture, not on a selection: Copy, Cut and Paste are built
43
+ // from what was right-clicked, and there is nothing writable in a lightbox.
44
+ clipboard={false}
45
+ onSelect={(row) => {
46
+ if (row.id === "download") void downloadFile(item.download ?? item.src, item.filename);
47
+ options.onSelect?.(row.id, item, index);
48
+ }}
49
+ >
50
+ <Trigger label={labels.menu ?? "More"} />
51
+ <ContextMenu.Content />
52
+ </ContextMenu.Root>
53
+ );
54
+ }
55
+
56
+ /** The button, wired to open the menu under itself the way a toolbar's overflow does. */
57
+ function Trigger({ label }: { label: string; }): ReactNode {
58
+ const menu = useContextMenuContext("Image.Menu");
59
+ const ref = useRef<HTMLSpanElement | null>(null);
60
+
61
+ return (
62
+ <span ref={ref} style={{ display: "contents" }}>
63
+ <MenuButton
64
+ label={label}
65
+ expanded={menu.state.open}
66
+ onPress={() => {
67
+ const box = ref.current?.firstElementChild?.getBoundingClientRect();
68
+ if (!box) return;
69
+ // Under the button and aligned to its right edge, which is where a toolbar
70
+ // menu belongs - the panel flips itself if the window has no room.
71
+ menu.open({ x: box.right, y: box.bottom + 6 });
72
+ }}
73
+ />
74
+ </span>
75
+ );
76
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The image viewer's look.
3
+ *
4
+ * It ships one, for the reason the colour picker and the toast do: there is no useful
5
+ * "unstyled" lightbox. A dialog with no backdrop, no size and no stacking is not a plain
6
+ * viewer, it is the page with an image lying across it - so the panel arrives dressed and
7
+ * `styles={false}` takes it away for anyone drawing their own.
8
+ *
9
+ * A string because the component injects it. `scripts/sync-recipes.mjs` generates
10
+ * `recipes/image/styles.css` from here and CI fails when the two drift, so this module is the
11
+ * source and the stylesheet is the copy for anyone who prefers an import.
12
+ *
13
+ * Everything below is a custom property or an attribute selector: the sheet is PREPENDED to
14
+ * `<head>`, so anything the document already has wins at equal specificity.
15
+ */
16
+
17
+ export const IMAGE_STYLES = `
18
+ :root {
19
+ --enigma-image-backdrop: rgba(0, 0, 0, 0.92);
20
+ --enigma-image-text: #f5f5f5;
21
+ --enigma-image-muted: #a3a3a3;
22
+ --enigma-image-control-bg: rgba(23, 23, 23, 0.72);
23
+ --enigma-image-control-hover: rgba(64, 64, 64, 0.82);
24
+ --enigma-image-control-size: 2.25rem;
25
+ --enigma-image-radius: 0.625rem;
26
+ --enigma-image-gap: 0.75rem;
27
+ --enigma-image-thumb-size: 3.5rem;
28
+ --enigma-image-focus: #f5f5f5;
29
+ /* Above the page's own chrome, not merely above its content: a header or a sticky sidebar
30
+ with a z-index of its own would otherwise paint over the dialog covering it. */
31
+ --enigma-image-z: 9999;
32
+ /* How long the picture takes to fly out of the page and back into it, and the curve it
33
+ flies on. They are the numbers core/image-viewer.ts waits for, so a project that
34
+ changes one here changes it there - see the note above the keyframes. */
35
+ --enigma-image-flight: 300ms;
36
+ --enigma-image-flight-ease: cubic-bezier(0.2, 0, 0.2, 1);
37
+ }
38
+
39
+ /* The thumbnail in the page. Only what the behaviour needs: the pointer says it opens, and
40
+ the box is the caller's to size. */
41
+ [data-enigma-image] { display: inline-flex; max-width: 100%; }
42
+ [data-enigma-image][data-clickable] { cursor: zoom-in; }
43
+ /* Stated on the picture as well as inherited: a project that restyles the trigger button is
44
+ one cursor: pointer away from a thumbnail that no longer says it enlarges. */
45
+ [data-enigma-image][data-clickable] img { cursor: zoom-in; }
46
+ [data-enigma-image] img { display: block; max-width: 100%; height: auto; }
47
+ /* A real box, never display:contents: an element without one cannot be focused in Chromium,
48
+ so a viewer closed with Escape would hand focus to the body instead of back to the picture
49
+ it was opened from, and the next Tab would start at the top of the page. */
50
+ [data-enigma-image-trigger] {
51
+ display: inline-flex; max-width: 100%; padding: 0; margin: 0;
52
+ background: none; border: 0; color: inherit; font: inherit; cursor: inherit;
53
+ }
54
+ [data-enigma-image-trigger]:focus-visible img { outline: 2px solid var(--enigma-image-focus); outline-offset: 3px; }
55
+
56
+ /* The viewer. Fixed and portalled: a lightbox inside the page's own stacking context is a
57
+ lightbox some ancestor's overflow or transform will clip. */
58
+ [data-enigma-image-viewer] {
59
+ position: fixed; inset: 0; z-index: var(--enigma-image-z);
60
+ display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
61
+ background: var(--enigma-image-backdrop);
62
+ color: var(--enigma-image-text);
63
+ }
64
+
65
+ /* The opening: the backdrop arrives while the picture flies out of the page, and the toolbar
66
+ and the strip arrive with it rather than being there first over a picture that is still on
67
+ its way. data-state is only ever "opening" or "closing" while an animation is meant to be
68
+ running, so a viewer that skipped the flight - reduced motion, animate={false} - matches
69
+ none of these rules and simply exists. */
70
+ @keyframes enigma-image-in { from { background-color: transparent; } to { background-color: var(--enigma-image-backdrop); } }
71
+ @keyframes enigma-image-chrome { from { opacity: 0; } to { opacity: 1; } }
72
+ [data-enigma-image-viewer][data-state="opening"] { animation: enigma-image-in var(--enigma-image-flight) ease-out; }
73
+ [data-enigma-image-viewer][data-state="opening"] [data-enigma-image-bar],
74
+ [data-enigma-image-viewer][data-state="opening"] [data-enigma-image-foot],
75
+ [data-enigma-image-viewer][data-state="opening"] [data-enigma-image-nav] {
76
+ animation: enigma-image-chrome var(--enigma-image-flight) ease-out;
77
+ }
78
+ /* On the way out the backdrop drains, and the dialog stops taking presses: it is over the
79
+ page again, so a click through it would land on whatever is now under the pointer. */
80
+ [data-enigma-image-viewer][data-state="closing"] {
81
+ background-color: transparent; pointer-events: none;
82
+ transition: background-color var(--enigma-image-flight) var(--enigma-image-flight-ease);
83
+ }
84
+ [data-enigma-image-viewer][data-state="closing"] [data-enigma-image-bar],
85
+ [data-enigma-image-viewer][data-state="closing"] [data-enigma-image-foot],
86
+ [data-enigma-image-viewer][data-state="closing"] [data-enigma-image-nav] {
87
+ opacity: 0; transition: opacity calc(var(--enigma-image-flight) / 2) ease-in;
88
+ }
89
+
90
+ [data-enigma-image-bar] {
91
+ display: flex; align-items: center; gap: 0.5rem;
92
+ padding: var(--enigma-image-gap);
93
+ }
94
+ [data-enigma-image-counter] {
95
+ font-size: 0.8125rem; font-variant-numeric: tabular-nums; color: var(--enigma-image-muted);
96
+ margin-inline-end: auto;
97
+ }
98
+
99
+ [data-enigma-image-button] {
100
+ display: grid; place-items: center; flex: none;
101
+ width: var(--enigma-image-control-size); height: var(--enigma-image-control-size);
102
+ padding: 0; color: var(--enigma-image-text);
103
+ background: var(--enigma-image-control-bg); border: 0; border-radius: 999px;
104
+ cursor: pointer;
105
+ }
106
+ [data-enigma-image-button]:hover { background: var(--enigma-image-control-hover); }
107
+ [data-enigma-image-button]:focus-visible { outline: 2px solid var(--enigma-image-focus); outline-offset: 2px; }
108
+ [data-enigma-image-button][disabled] { opacity: 0.35; cursor: not-allowed; }
109
+
110
+ /* The frame the picture is fitted into, and the surface every gesture is measured against. */
111
+ [data-enigma-image-frame] {
112
+ position: relative; overflow: hidden;
113
+ display: grid; place-items: center;
114
+ /* Room for the arrows down each side, so they sit beside the picture rather than on it. */
115
+ padding: 0 calc(var(--enigma-image-control-size) + var(--enigma-image-gap) * 1.5);
116
+ /* The drag IS the control once zoomed, so the page must not scroll under it. */
117
+ touch-action: none;
118
+ -webkit-user-select: none; user-select: none;
119
+ }
120
+ [data-enigma-image-frame] img {
121
+ max-width: 100%; max-height: 100%;
122
+ -webkit-user-drag: none;
123
+ will-change: transform;
124
+ }
125
+ /* Only while it is MOVING, which is not the same as "during the flight".
126
+ A transition left on permanently would put 300ms of lag between the wheel and the zoom, and
127
+ turn every pan into a drag the picture follows late. And one left on during "pending" is
128
+ worse than useless: putting the picture on the thumbnail is itself a transform change, so it
129
+ animated there over 300ms - behind a hidden element - and by the time anybody could see it
130
+ the flight was nearly over. */
131
+ [data-enigma-image-frame][data-flying="moving"] img { transition: transform var(--enigma-image-flight) var(--enigma-image-flight-ease); }
132
+ /* Not drawn until there is somewhere to fly from - see the note on the attribute. The spinner
133
+ is what stands in for it, the same one a slow picture gets. */
134
+ [data-enigma-image-frame][data-flying="pending"] img { visibility: hidden; }
135
+ @media (prefers-reduced-motion: reduce) {
136
+ [data-enigma-image-frame][data-flying="moving"] img { transition: none; }
137
+ }
138
+
139
+ /* The cursor says what a press does, which in a lightbox is three different things. The empty
140
+ part of the frame IS the backdrop and closes it; the picture at rest takes a double press to
141
+ enlarge; once it is enlarged the gesture is a drag. */
142
+ [data-enigma-image-frame] { cursor: zoom-out; }
143
+ [data-enigma-image-frame][data-zoom] img { cursor: zoom-in; }
144
+ [data-enigma-image-frame][data-zoomed],
145
+ [data-enigma-image-frame][data-zoomed] img { cursor: grab; }
146
+ [data-enigma-image-frame][data-panning],
147
+ [data-enigma-image-frame][data-panning] img { cursor: grabbing; }
148
+ [data-enigma-image-frame][data-loading] img { opacity: 0.35; }
149
+
150
+ /* Left and right sit over the picture rather than beside it: the frame is the biggest thing
151
+ on screen and the arrows have to be reachable without hunting for an edge. */
152
+ [data-enigma-image-nav] {
153
+ position: absolute; top: 50%; translate: 0 -50%;
154
+ width: var(--enigma-image-control-size); height: var(--enigma-image-control-size);
155
+ }
156
+ [data-enigma-image-nav="previous"] { left: var(--enigma-image-gap); }
157
+ [data-enigma-image-nav="next"] { right: var(--enigma-image-gap); }
158
+
159
+ [data-enigma-image-spinner] {
160
+ position: absolute; width: 1.75rem; height: 1.75rem;
161
+ border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: var(--enigma-image-text);
162
+ border-radius: 999px; animation: enigma-image-spin 700ms linear infinite;
163
+ }
164
+ @keyframes enigma-image-spin { to { rotate: 360deg; } }
165
+ @media (prefers-reduced-motion: reduce) {
166
+ [data-enigma-image-spinner] { animation-duration: 2s; }
167
+ }
168
+
169
+ [data-enigma-image-foot] { display: grid; gap: 0.5rem; padding: var(--enigma-image-gap); }
170
+ [data-enigma-image-caption] {
171
+ margin: 0; text-align: center; font-size: 0.8125rem; line-height: 1.5;
172
+ color: var(--enigma-image-muted);
173
+ }
174
+
175
+ /* The strip. Scrolls on its own rather than wrapping: a gallery of forty is one row you drag
176
+ through, not five rows that push the picture off the screen. */
177
+ [data-enigma-image-strip] {
178
+ display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: thin;
179
+ justify-content: safe center;
180
+ padding-bottom: 0.25rem;
181
+ }
182
+ [data-enigma-image-thumb] {
183
+ flex: none; width: var(--enigma-image-thumb-size); height: var(--enigma-image-thumb-size);
184
+ padding: 0; overflow: hidden;
185
+ background: none; border: 0; border-radius: 0.375rem;
186
+ opacity: 0.55; cursor: pointer;
187
+ }
188
+ [data-enigma-image-thumb] img { width: 100%; height: 100%; object-fit: cover; }
189
+ [data-enigma-image-thumb]:hover { opacity: 0.85; }
190
+ [data-enigma-image-thumb][aria-current="true"] { opacity: 1; box-shadow: 0 0 0 2px var(--enigma-image-focus); }
191
+ [data-enigma-image-thumb]:focus-visible { outline: 2px solid var(--enigma-image-focus); outline-offset: 2px; }
192
+ `;
193
+
194
+ let injected = false;
195
+
196
+ /**
197
+ * Put the sheet in the document, once.
198
+ *
199
+ * It lives here rather than in the component because BOTH halves need it and they are in
200
+ * different chunks: the thumbnail is in the page from the first paint, and the viewer arrives
201
+ * with its own module later. Injecting only from the viewer left the picture in the page
202
+ * unstyled until somebody opened one - so the cursor did not say it enlarges, and the focus
203
+ * ring around it did not exist, until after the first press.
204
+ */
205
+ export function injectImageStyles(): void {
206
+ if (injected || typeof document === "undefined") return;
207
+ injected = true;
208
+ if (document.querySelector("[data-enigma-image-styles]")) return;
209
+ const element = document.createElement("style");
210
+ element.setAttribute("data-enigma-image-styles", "");
211
+ element.textContent = IMAGE_STYLES;
212
+ document.head.prepend(element);
213
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The prop shapes for `<Image>`, split from the component so the viewer's chunk can import
3
+ * them without pulling the component in - the same arrangement `<Input>` is in.
4
+ *
5
+ * WHAT IS ON BY DEFAULT is the whole design of this API: an image that opens and zooms, and
6
+ * nothing else. A gallery's arrows, its thumbnails, a menu and a discard action are each a
7
+ * decision about the page they sit in, so each is a prop you turn on rather than one you
8
+ * remember to turn off.
9
+ */
10
+
11
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
12
+ import type { ContextMenuNode } from "@/react/context-menu/context";
13
+
14
+ /** One image in a set: a URL, or the URL plus what is known about it. */
15
+ export type ImageSource = string | ImageItem;
16
+
17
+ export interface ImageItem {
18
+ src: string;
19
+ alt?: string;
20
+ /** A smaller file for the strip and for the frame while the full one loads. */
21
+ thumbnail?: string;
22
+ /** The URL to save, when the file to download is not the one being shown. */
23
+ download?: string;
24
+ /** Saved under this name instead of the one in the URL. */
25
+ filename?: string;
26
+ caption?: ReactNode;
27
+ }
28
+
29
+ export interface ZoomOptions {
30
+ /** How far out. 1 is the fitted image; below it the picture is smaller than its frame. */
31
+ min?: number;
32
+ max?: number;
33
+ /** Zoom on the wheel. On by default - it is the gesture every image viewer answers. */
34
+ wheel?: boolean;
35
+ /** A double press toggles between fitted and this. `false` leaves the gesture alone. */
36
+ doubleClick?: boolean;
37
+ }
38
+
39
+ export interface ImageMenuOptions {
40
+ /** The download row. On whenever the menu is. */
41
+ download?: boolean;
42
+ /** Rows of your own, after the built-in ones. */
43
+ items?: readonly ContextMenuNode[];
44
+ onSelect?: (id: string, item: ImageItem, index: number) => void;
45
+ }
46
+
47
+ /** Every string the viewer says, for a UI that is not in English. */
48
+ export interface ImageLabels {
49
+ open?: string;
50
+ close?: string;
51
+ zoomIn?: string;
52
+ zoomOut?: string;
53
+ reset?: string;
54
+ previous?: string;
55
+ next?: string;
56
+ menu?: string;
57
+ download?: string;
58
+ discard?: string;
59
+ thumbnails?: string;
60
+ /** The frame itself, announced as the dialog's name. Default "Image viewer". */
61
+ viewer?: string;
62
+ /** `{index}` and `{total}` are replaced. Default "{index} of {total}". */
63
+ counter?: string;
64
+ }
65
+
66
+ export interface ImageProps extends Omit<ComponentPropsWithoutRef<"img">, "children" | "onSelect"> {
67
+ src: string;
68
+ alt: string;
69
+ /**
70
+ * The set this image belongs to. Without it the viewer shows this one image; with it the
71
+ * arrows, the strip and the discard action have something to move through.
72
+ */
73
+ images?: readonly ImageSource[];
74
+ /** Where in `images` this one is. Found by `src` when it is left out. */
75
+ index?: number;
76
+ /** Click to see it larger. ON by default: it is what an image in a page is expected to do. */
77
+ lightbox?: boolean;
78
+ /** Zoom, with the wheel and the keyboard. ON by default. */
79
+ zoom?: boolean | ZoomOptions;
80
+ /**
81
+ * The picture flies out of the page into the viewer, and back into it on the way out. ON
82
+ * by default, and skipped anyway for a reader who has asked for less movement.
83
+ */
84
+ animate?: boolean;
85
+ /** Arrows, the counter, and Left/Right on the keyboard. Off by default. */
86
+ navigation?: boolean;
87
+ /** The strip of previews along the bottom. Off by default. */
88
+ thumbnails?: boolean;
89
+ /** The three dots, and what is under them. Off by default. */
90
+ menu?: boolean | ImageMenuOptions;
91
+ /**
92
+ * Take an image out of the set and move to the next one. Off by default, because it is
93
+ * destructive and only the caller knows whether it means anything on their page.
94
+ */
95
+ discardable?: boolean;
96
+ onDiscard?: (item: ImageItem, index: number) => void;
97
+ /** Whether the arrows and the strip wrap around at the ends. On by default. */
98
+ loop?: boolean;
99
+ /** The caption under the frame, when the item carries none of its own. */
100
+ caption?: ReactNode;
101
+ onOpenChange?: (open: boolean) => void;
102
+ onIndexChange?: (index: number, item: ImageItem) => void;
103
+ /** The viewer's stylesheet, injected once. There is no useful unstyled lightbox. */
104
+ styles?: boolean;
105
+ labels?: ImageLabels;
106
+ /** Props for the element wrapping the thumbnail - this is what you position. */
107
+ wrapperProps?: ComponentPropsWithoutRef<"span">;
108
+ }
109
+
110
+ /** A source in either spelling, as the one the viewer works in. */
111
+ export function toItem(source: ImageSource): ImageItem {
112
+ return typeof source === "string" ? { src: source } : source;
113
+ }