@enigmax/primitives 0.25.0 → 0.27.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 (55) hide show
  1. package/dist/chunk-2RIO4VDV.js +121 -0
  2. package/dist/{chunk-DZMHY3SU.js → chunk-CPJJ3FPW.js} +17 -7
  3. package/dist/chunk-EYZ366LP.js +189 -0
  4. package/dist/chunk-HUUINHPR.js +135 -0
  5. package/dist/chunk-JLZDS6CV.js +177 -0
  6. package/dist/chunk-O2UJ5ZIX.js +387 -0
  7. package/dist/chunk-PVDU6AZU.js +953 -0
  8. package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
  9. package/dist/clipboard-menu-eemzEBqB.d.ts +93 -0
  10. package/dist/context-DR1dpO8b.d.ts +54 -0
  11. package/dist/{clipboard-menu-B_ouitfS.d.ts → context-menu-qqpGBkny.d.ts} +7 -91
  12. package/dist/index.d.ts +292 -2
  13. package/dist/index.js +5 -3
  14. package/dist/menu-52PXC5PL.js +33 -0
  15. package/dist/menu-YIGMPFUP.js +58 -0
  16. package/dist/next/index.d.ts +6 -2
  17. package/dist/next/index.js +12 -8
  18. package/dist/react/context-menu.d.ts +15 -55
  19. package/dist/react/context-menu.js +2 -2
  20. package/dist/react/image.d.ts +112 -0
  21. package/dist/react/image.js +3 -0
  22. package/dist/react/index.d.ts +6 -2
  23. package/dist/react/index.js +12 -8
  24. package/dist/react/input.js +1 -1
  25. package/dist/react/video.d.ts +126 -0
  26. package/dist/react/video.js +3 -0
  27. package/dist/react-router/index.d.ts +6 -2
  28. package/dist/react-router/index.js +12 -8
  29. package/dist/viewer-N5WLFB24.js +3 -0
  30. package/package.json +15 -2
  31. package/recipes/context-menu/styles.css +8 -1
  32. package/recipes/image/styles.css +182 -0
  33. package/recipes/video/styles.css +154 -0
  34. package/registry.json +486 -5
  35. package/src/core/context-menu.ts +22 -3
  36. package/src/core/image-viewer.ts +269 -0
  37. package/src/core/player.ts +345 -0
  38. package/src/index.ts +55 -0
  39. package/src/react/context-menu/root.tsx +27 -5
  40. package/src/react/context-menu/styles.ts +8 -1
  41. package/src/react/image/icons.tsx +59 -0
  42. package/src/react/image/index.tsx +185 -0
  43. package/src/react/image/menu.tsx +80 -0
  44. package/src/react/image/styles.ts +213 -0
  45. package/src/react/image/types.ts +116 -0
  46. package/src/react/image/viewer.tsx +571 -0
  47. package/src/react/index.ts +3 -0
  48. package/src/react/video/icons.tsx +135 -0
  49. package/src/react/video/index.tsx +731 -0
  50. package/src/react/video/menu.tsx +66 -0
  51. package/src/react/video/rail.tsx +99 -0
  52. package/src/react/video/styles.ts +161 -0
  53. package/src/react/video/types.ts +141 -0
  54. /package/dist/{chunk-3MGBZOAU.js → chunk-RMNXVWQ4.js} +0 -0
  55. /package/dist/{chunk-MSOCCQGH.js → chunk-T7I6WJSN.js} +0 -0
@@ -146,6 +146,12 @@ export interface ContextMenuRootProps {
146
146
  * selection, Cut over a selection in something writable, Paste in anything writable.
147
147
  */
148
148
  clipboard?: boolean | ClipboardMenuOptions;
149
+ /**
150
+ * A filter field over the rows. `"auto"` (the default) grows one only once there are
151
+ * enough rows to be worth filtering; `true` asks for one whatever the count - which is
152
+ * what a menu whose rows are fetched wants - and `false` never draws one.
153
+ */
154
+ searchable?: boolean | "auto";
149
155
  /** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
150
156
  fuse?: ContextMenuOptions["fuse"];
151
157
  fuseOptions?: Record<string, unknown>;
@@ -169,6 +175,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
169
175
  const {
170
176
  items,
171
177
  title,
178
+ searchable,
172
179
  disabled = false,
173
180
  fuse,
174
181
  fuseOptions,
@@ -208,6 +215,7 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
208
215
  const instance = useMemo<ContextMenuInstance>(() => createContextMenu({
209
216
  items: items as readonly ContextMenuEntry[],
210
217
  title,
218
+ searchable,
211
219
  fuse,
212
220
  fuseOptions,
213
221
  matcher,
@@ -307,8 +315,8 @@ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
307
315
  }, []);
308
316
 
309
317
  useEffect(() => {
310
- instance.update({ items: withClipboard(currentItems.current), title });
311
- }, [instance, signature, title, withClipboard]);
318
+ instance.update({ items: withClipboard(currentItems.current), title, searchable });
319
+ }, [instance, signature, title, searchable, withClipboard]);
312
320
 
313
321
  const cancelClose = useCallback(() => { window.clearTimeout(closing.current); }, []);
314
322
 
@@ -883,6 +891,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
883
891
  return <p {...props} data-enigma-menu-label="" aria-hidden="true">{entry.label}</p>;
884
892
  }
885
893
 
894
+ /**
895
+ * Whether the level RESERVES the tick column, which is not the same as this row having a
896
+ * tick.
897
+ *
898
+ * A checkable row carries a check and, usually, an icon; its neighbours carry only the
899
+ * icon. Drawn per row, the ticked one's label therefore sat a column further right than
900
+ * everyone else's and the menu read as broken. Every desktop menu reserves the column for
901
+ * the whole level instead, so this asks the LEVEL. Read from `entries` rather than from
902
+ * what is visible: filtering a menu must not shift its rows sideways.
903
+ */
904
+ const reserveCheck = state?.entries.some((entry) => isAction(entry) && (entry as ContextMenuItem).checked !== undefined) ?? false;
905
+
886
906
  const checkable = item.checked !== undefined;
887
907
  // A checkable row inside a group is a radio: choosing one is choosing INSTEAD of its
888
908
  // siblings, and a screen reader announces the two differently.
@@ -942,16 +962,18 @@ export function ContextMenuRow({ level, index, entry, children, ...props }: Cont
942
962
  menu.instance.select(level, index);
943
963
  }}
944
964
  >
945
- {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} />}
965
+ {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} reserveCheck={reserveCheck} />}
946
966
  </div>
947
967
  );
948
968
  }
949
969
 
950
970
  /** The default row: what a desktop menu draws, in the order it draws it. */
951
- function ContextMenuRowContent({ item, submenu }: { item: ContextMenuItem; submenu: boolean; }): ReactNode {
971
+ function ContextMenuRowContent({ item, submenu, reserveCheck }: { item: ContextMenuItem; submenu: boolean; reserveCheck: boolean; }): ReactNode {
952
972
  return (
953
973
  <>
954
- {item.checked !== undefined && <span data-enigma-menu-check="" aria-hidden="true" />}
974
+ {/* Empty when this row is not checkable and something else in the level is: the
975
+ column is the level's, not the row's. */}
976
+ {(item.checked !== undefined || reserveCheck) && <span data-enigma-menu-check="" aria-hidden="true" />}
955
977
  {item.icon ? <span data-enigma-menu-icon="">{item.icon}</span> : null}
956
978
  <span data-enigma-menu-item-text="">
957
979
  <span data-enigma-menu-item-label="">{item.label}</span>
@@ -29,6 +29,13 @@ export const CONTEXT_MENU_STYLES = `
29
29
  --enigma-menu-accent: #fbbf24;
30
30
  --enigma-menu-danger: #f87171;
31
31
  --enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
32
+
33
+ /* Above the lightbox, which sits at 9999, because a menu is opened FROM whatever is on
34
+ top: the image viewer's own three-dot menu was portalled to the same body and painted
35
+ underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
36
+ transient surface on a page, and this is the number that says so. It belongs here and
37
+ not in the light-scheme block, or half the readers get an invalid z-index. */
38
+ --enigma-menu-z: 10000;
32
39
  }
33
40
 
34
41
  @media (prefers-color-scheme: light) {
@@ -49,7 +56,7 @@ export const CONTEXT_MENU_STYLES = `
49
56
  and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
50
57
  ancestor cannot drag it away from the place it was opened. */
51
58
  [data-enigma-menu-panel] {
52
- position: fixed; z-index: 60;
59
+ position: fixed; z-index: var(--enigma-menu-z);
53
60
  box-sizing: border-box;
54
61
  min-width: var(--enigma-menu-min-width);
55
62
  max-width: min(22rem, calc(100vw - 1rem));
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * The viewer's icons, drawn rather than loaded.
5
+ *
6
+ * A sprite or an SVG file would be a request, and an icon package would be a dependency for
7
+ * eight paths. They sit in one module so the viewer and its menu share them without either
8
+ * pulling the other's chunk.
9
+ */
10
+
11
+ function Glyph({ children }: { children: ReactNode; }): ReactNode {
12
+ return (
13
+ <svg viewBox="0 0 24 24" width="1.125em" height="1.125em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
14
+ {children}
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ export function Close(): ReactNode {
20
+ return <Glyph><path d="M6 6 18 18M18 6 6 18" /></Glyph>;
21
+ }
22
+
23
+ export function Plus(): ReactNode {
24
+ return <Glyph><path d="M12 5v14M5 12h14" /></Glyph>;
25
+ }
26
+
27
+ export function Minus(): ReactNode {
28
+ return <Glyph><path d="M5 12h14" /></Glyph>;
29
+ }
30
+
31
+ export function ChevronLeft(): ReactNode {
32
+ return <Glyph><path d="m15 5-7 7 7 7" /></Glyph>;
33
+ }
34
+
35
+ export function ChevronRight(): ReactNode {
36
+ return <Glyph><path d="m9 5 7 7-7 7" /></Glyph>;
37
+ }
38
+
39
+ export function Trash(): ReactNode {
40
+ return <Glyph><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></Glyph>;
41
+ }
42
+
43
+ export function Dots(): ReactNode {
44
+ return (
45
+ <svg viewBox="0 0 24 24" width="1.125em" height="1.125em" fill="currentColor" aria-hidden="true">
46
+ <circle cx="12" cy="5" r="1.75" />
47
+ <circle cx="12" cy="12" r="1.75" />
48
+ <circle cx="12" cy="19" r="1.75" />
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ export function Download(): ReactNode {
54
+ return <Glyph><path d="M12 3v12M7 11l5 5 5-5M4 20h16" /></Glyph>;
55
+ }
56
+
57
+ export function NewTab(): ReactNode {
58
+ return <Glyph><path d="M14 4h6v6" /><path d="M20 4 11 13" /><path d="M18 14v5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 19V7.5A1.5 1.5 0 0 1 5.5 6H10" /></Glyph>;
59
+ }
@@ -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,80 @@
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, openInNewTab } 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.newTab !== false) {
35
+ rows.push({ id: "new-tab", label: labels.newTab ?? "Open in a new tab", icon: <icons.NewTab /> });
36
+ }
37
+ if (options.items?.length) {
38
+ if (rows.length > 0) rows.push({ type: "separator" });
39
+ rows.push(...options.items);
40
+ }
41
+
42
+ return (
43
+ <ContextMenu.Root
44
+ items={rows}
45
+ // The rows act on the picture, not on a selection: Copy, Cut and Paste are built
46
+ // from what was right-clicked, and there is nothing writable in a lightbox.
47
+ clipboard={false}
48
+ onSelect={(row) => {
49
+ if (row.id === "download") void downloadFile(item.download ?? item.src, item.filename);
50
+ if (row.id === "new-tab") openInNewTab(item.src);
51
+ options.onSelect?.(row.id, item, index);
52
+ }}
53
+ >
54
+ <Trigger label={labels.menu ?? "More"} />
55
+ <ContextMenu.Content />
56
+ </ContextMenu.Root>
57
+ );
58
+ }
59
+
60
+ /** The button, wired to open the menu under itself the way a toolbar's overflow does. */
61
+ function Trigger({ label }: { label: string; }): ReactNode {
62
+ const menu = useContextMenuContext("Image.Menu");
63
+ const ref = useRef<HTMLSpanElement | null>(null);
64
+
65
+ return (
66
+ <span ref={ref} style={{ display: "contents" }}>
67
+ <MenuButton
68
+ label={label}
69
+ expanded={menu.state.open}
70
+ onPress={() => {
71
+ const box = ref.current?.firstElementChild?.getBoundingClientRect();
72
+ if (!box) return;
73
+ // Under the button and aligned to its right edge, which is where a toolbar
74
+ // menu belongs - the panel flips itself if the window has no room.
75
+ menu.open({ x: box.right, y: box.bottom + 6 });
76
+ }}
77
+ />
78
+ </span>
79
+ );
80
+ }
@@ -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
+ }