@enigmax/primitives 0.26.0 → 0.28.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 (58) hide show
  1. package/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
  2. package/dist/{chunk-ISXB5RUH.js → chunk-CB7K6VW4.js} +10 -3
  3. package/dist/{chunk-QIIV2QSV.js → chunk-HUUINHPR.js} +28 -1
  4. package/dist/{chunk-4SZ5N7BA.js → chunk-IQMHDRJF.js} +2 -2
  5. package/dist/{chunk-RUEQ3TGP.js → chunk-JLZDS6CV.js} +23 -1
  6. package/dist/{chunk-JJVZYW5C.js → chunk-JPBVKXOL.js} +10 -3
  7. package/dist/{chunk-DZMHY3SU.js → chunk-RRPWJAIJ.js} +41 -17
  8. package/dist/chunk-RXEO4WPI.js +111 -0
  9. package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
  10. package/dist/{chunk-Z3VDE7OA.js → chunk-YQV5XZIA.js} +2 -2
  11. package/dist/{clipboard-menu-BvXJCSFN.d.ts → clipboard-menu-eemzEBqB.d.ts} +1 -1
  12. package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
  13. package/dist/{context-BfjYGPnH.d.ts → context-DR1dpO8b.d.ts} +1 -1
  14. package/dist/{context-menu-D3FtTn7v.d.ts → context-menu-qqpGBkny.d.ts} +6 -0
  15. package/dist/index.d.ts +29 -3
  16. package/dist/index.js +3 -3
  17. package/dist/{menu-PNQRTRNV.js → menu-5JZMGNOT.js} +3 -2
  18. package/dist/{menu-MSQSE6U5.js → menu-O3G4LM5O.js} +9 -4
  19. package/dist/next/index.d.ts +3 -3
  20. package/dist/next/index.js +8 -7
  21. package/dist/react/context-menu.d.ts +13 -7
  22. package/dist/react/context-menu.js +3 -2
  23. package/dist/react/image.d.ts +5 -2
  24. package/dist/react/image.js +1 -1
  25. package/dist/react/index.d.ts +3 -3
  26. package/dist/react/index.js +8 -7
  27. package/dist/react/input.js +1 -1
  28. package/dist/react/select.d.ts +6 -1
  29. package/dist/react/select.js +2 -1
  30. package/dist/react/video.d.ts +9 -3
  31. package/dist/react/video.js +2 -2
  32. package/dist/react-router/index.d.ts +3 -3
  33. package/dist/react-router/index.js +8 -7
  34. package/dist/viewer-C323RYNZ.js +3 -0
  35. package/package.json +1 -1
  36. package/recipes/color/styles.css +12 -7
  37. package/recipes/context-menu/styles.css +15 -1
  38. package/recipes/select/styles.css +24 -7
  39. package/registry.json +119 -10
  40. package/src/core/context-menu.ts +22 -3
  41. package/src/core/floating.ts +71 -0
  42. package/src/core/image-viewer.ts +47 -0
  43. package/src/core/player.ts +42 -0
  44. package/src/index.ts +3 -0
  45. package/src/react/context-menu/root.tsx +57 -20
  46. package/src/react/context-menu/styles.ts +15 -1
  47. package/src/react/floating.ts +185 -0
  48. package/src/react/image/icons.tsx +4 -0
  49. package/src/react/image/menu.tsx +5 -1
  50. package/src/react/image/types.ts +3 -0
  51. package/src/react/input/color-styles.ts +12 -7
  52. package/src/react/input/color.tsx +30 -27
  53. package/src/react/select/context.ts +5 -0
  54. package/src/react/select/root.tsx +33 -21
  55. package/src/react/select/styles.ts +24 -7
  56. package/src/react/video/index.tsx +8 -1
  57. package/src/react/video/types.ts +7 -1
  58. package/dist/viewer-73LSE6RR.js +0 -3
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { Slot } from "@/react/slot";
4
+ import { createPortal } from "react-dom";
4
5
  import { groupRows } from "@/core/palette";
5
6
  import { shortenQuery } from "@/core/search";
6
7
  import { SELECT_STYLES } from "@/react/select/styles";
8
+ import { tabOut, useFloating } from "@/react/floating";
7
9
  import { SelectContext, useSelectContext, type SelectItem } from "@/react/select/context";
8
10
  import { createSelect, type SelectInstance, type SelectMoveKey, type SelectOptions, type SelectState } from "@/core/select";
9
11
  import {
@@ -174,6 +176,9 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
174
176
  const triggerRef = useRef<HTMLButtonElement | null>(null);
175
177
  const fieldRef = useRef<HTMLInputElement | null>(null);
176
178
  const rootRef = useRef<HTMLDivElement | null>(null);
179
+ const contentRef = useRef<HTMLDivElement | null>(null);
180
+ /** The root or the portaled panel: both are the select, only one is its DOM subtree. */
181
+ const owns = useCallback((node: Node | null) => Boolean(node && (rootRef.current?.contains(node) || contentRef.current?.contains(node))), []);
177
182
 
178
183
  // Kept in a ref so the instance - built once - always calls the CURRENT props rather
179
184
  // than the ones it closed over on the first render.
@@ -304,10 +309,10 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
304
309
  // Whatever closed it was inside the panel - a row, or Enter in the search field -
305
310
  // and the panel is about to unmount with the focus still in it. Focus goes back to
306
311
  // the trigger rather than to the body, exactly as Escape does it.
307
- const held = rootRef.current?.contains(document.activeElement);
312
+ const held = owns(document.activeElement);
308
313
  setOpen(false);
309
314
  if (held) triggerRef.current?.focus();
310
- }, [state.open, open, setOpen]);
315
+ }, [state.open, open, setOpen, owns]);
311
316
 
312
317
  const close = useCallback(() => {
313
318
  setOpen(false);
@@ -321,12 +326,12 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
321
326
  useEffect(() => {
322
327
  if (!open) return;
323
328
  const onPointerDown = (event: PointerEvent) => {
324
- if (rootRef.current?.contains(event.target as Node)) return;
329
+ if (owns(event.target as Node)) return;
325
330
  setOpen(false);
326
331
  };
327
332
  document.addEventListener("pointerdown", onPointerDown, true);
328
333
  return () => document.removeEventListener("pointerdown", onPointerDown, true);
329
- }, [open, setOpen]);
334
+ }, [open, setOpen, owns]);
330
335
 
331
336
  const onListKeyDown = useCallback((event: KeyboardEvent) => {
332
337
  const move = MOVE_KEYS[event.key];
@@ -351,6 +356,9 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
351
356
  return;
352
357
  }
353
358
  if (event.key === "Tab" && open) {
359
+ // The search field is in the portaled panel, at the end of the page: off it, Tab
360
+ // goes where it would have from beside the trigger, not to the browser chrome.
361
+ if (contentRef.current?.contains(event.target as Node)) tabOut(event, contentRef.current, triggerRef.current);
354
362
  setOpen(false);
355
363
  return;
356
364
  }
@@ -399,6 +407,7 @@ export function SelectRoot(props: SelectRootProps): ReactNode {
399
407
  optionId: (index: number) => `${id}-option-${index}`,
400
408
  triggerRef,
401
409
  fieldRef,
410
+ contentRef,
402
411
  close,
403
412
  onListKeyDown
404
413
  }), [instance, state, setOpen, disabled, clearable, multiple, isSearchable, isEmpty, loading, emptyLabel, loadingLabel, ids, id, close, onListKeyDown]);
@@ -614,9 +623,7 @@ export interface SelectContentProps extends ComponentPropsWithoutRef<"div"> {
614
623
 
615
624
  export function SelectContent({ closeDuration = 120, children, ...props }: SelectContentProps): ReactNode {
616
625
  const select = useSelectContext("Select.Content");
617
- const ref = useRef<HTMLDivElement | null>(null);
618
626
  const [mounted, setMounted] = useState(select.state.open);
619
- const [side, setSide] = useState<"top" | "bottom">("bottom");
620
627
 
621
628
  useEffect(() => {
622
629
  if (select.state.open) { setMounted(true); return; }
@@ -625,33 +632,38 @@ export function SelectContent({ closeDuration = 120, children, ...props }: Selec
625
632
  return () => clearTimeout(timer);
626
633
  }, [select.state.open, closeDuration]);
627
634
 
628
- // Which way it opens is measured, not assumed: a select near the bottom of the window
629
- // opens upwards, or its list is off the screen and unreachable.
630
- useLayoutEffect(() => {
631
- if (!select.state.open || !ref.current) return;
632
- const trigger = select.triggerRef.current?.getBoundingClientRect();
633
- if (!trigger) return;
634
- const height = ref.current.offsetHeight;
635
- const below = window.innerHeight - trigger.bottom;
636
- setSide(below < height && trigger.top > below ? "top" : "bottom");
637
- }, [select.state.open, select.state.visible.length, select.triggerRef]);
635
+ /**
636
+ * Portaled and placed against the trigger, never left in the tree.
637
+ *
638
+ * An absolute panel inside the root is clipped by any `overflow: hidden` ancestor and
639
+ * stacked under any dialog - which is where a select usually sits. So it goes to `<body>`
640
+ * (or into the dialog it was opened from - see `react/floating.ts`), measured against the
641
+ * trigger: below it, flipped above when there is more room there, and capped to the room
642
+ * it has with the list scrolling inside, because a list off the screen is unreachable.
643
+ */
644
+ const container = useFloating({
645
+ open: mounted && select.state.open,
646
+ anchorRef: select.triggerRef,
647
+ panelRef: select.contentRef,
648
+ deps: [select.state.visible.length]
649
+ });
638
650
 
639
- if (!mounted) return null;
651
+ if (!mounted || !container) return null;
640
652
 
641
- return (
653
+ return createPortal(
642
654
  <div
643
655
  {...props}
644
- ref={ref}
656
+ ref={select.contentRef}
645
657
  data-enigma-select-content=""
646
658
  data-state={select.state.open ? "open" : "closed"}
647
- data-side={side}
648
659
  onKeyDown={(event) => {
649
660
  props.onKeyDown?.(event);
650
661
  if (!event.defaultPrevented) select.onListKeyDown(event);
651
662
  }}
652
663
  >
653
664
  {children}
654
- </div>
665
+ </div>,
666
+ container
655
667
  );
656
668
  }
657
669
 
@@ -27,12 +27,17 @@ export const SELECT_STYLES = `
27
27
  --enigma-select-panel-border: #333333;
28
28
  --enigma-select-panel-radius: 0.625rem;
29
29
  --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
30
- --enigma-select-panel-width: 100%;
31
30
  --enigma-select-list-height: 15rem;
32
31
 
33
32
  --enigma-select-active-bg: #2a2a2a;
34
33
  --enigma-select-accent: #fbbf24;
35
34
  --enigma-select-tag-bg: #2a2a2a;
35
+
36
+ /* The layer every portaled popup shares: above the lightbox (9999), because a popup is
37
+ opened FROM whatever is on top. The select, the colour picker and the context menu
38
+ each declare it with the same value, so overriding it once moves all three. Here and
39
+ not in the light-scheme block, or half the readers get an invalid z-index. */
40
+ --enigma-floating-z: 10000;
36
41
  }
37
42
 
38
43
  @media (prefers-color-scheme: light) {
@@ -137,11 +142,24 @@ export const SELECT_STYLES = `
137
142
  [data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }
138
143
  [data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
139
144
 
145
+ /* Portaled to <body> and placed in viewport coordinates against the trigger (see
146
+ react/floating.ts), so no ancestor's overflow can clip it and no dialog can stack over it.
147
+ left, top, max-height and data-side are written by the placement: below the trigger,
148
+ flipped above when there is more room there, and capped to the room it has. The column
149
+ is what makes the cap shrink the LIST, which scrolls, instead of cutting off the panel. */
140
150
  [data-enigma-select-content] {
141
- position: absolute; z-index: 50;
142
- top: calc(100% + 0.25rem); left: 0;
143
- min-width: var(--enigma-select-panel-width); box-sizing: border-box;
151
+ position: fixed; z-index: var(--enigma-floating-z);
152
+ display: flex; flex-direction: column;
153
+ /* As wide as the trigger, measured - 100% would now be a percentage of the window - and
154
+ wider only for content that cannot shrink. min-content is what keeps the rest at the
155
+ trigger's width: fixed, the panel's room is the whole window, and a long label or a
156
+ long "Nothing matches" would otherwise widen it to max-width. --enigma-select-panel-width
157
+ overrides the trigger's width. */
158
+ width: min-content;
159
+ min-width: var(--enigma-select-panel-width, var(--enigma-anchor-width, auto)); box-sizing: border-box;
144
160
  padding: 0.25rem;
161
+ color: var(--enigma-select-text);
162
+ font-size: var(--enigma-select-font-size);
145
163
  background: var(--enigma-select-panel-bg);
146
164
  border: 1px solid var(--enigma-select-panel-border);
147
165
  border-radius: var(--enigma-select-panel-radius);
@@ -152,9 +170,6 @@ export const SELECT_STYLES = `
152
170
  trigger wider than this, which is what it is for. */
153
171
  max-width: min(28rem, calc(100vw - 2rem));
154
172
  }
155
- /* Opening upwards is not a variant, it is the same panel measured against the window: near
156
- the bottom of the screen the list would otherwise be unreachable. */
157
- [data-enigma-select-content][data-side="top"] { top: auto; bottom: calc(100% + 0.25rem); }
158
173
  [data-enigma-select-content][data-state="closed"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }
159
174
 
160
175
  @keyframes enigma-select-in {
@@ -186,6 +201,8 @@ export const SELECT_STYLES = `
186
201
 
187
202
  [data-enigma-select-list] {
188
203
  max-height: var(--enigma-select-list-height);
204
+ /* Allowed below its content, so the panel's placement cap shrinks it and it scrolls. */
205
+ min-height: 0; flex: 0 1 auto;
189
206
  margin-top: 0.25rem;
190
207
  overflow-y: auto; overscroll-behavior: contain;
191
208
  }
@@ -531,7 +531,14 @@ export function Video(props: VideoProps): ReactNode {
531
531
  playsInline={rest.playsInline ?? true}
532
532
  onClick={() => { if (clickToPlay) toggle(); }}
533
533
  >
534
- {typeof src !== "string" && sources.map((source) => <source key={source.src} src={source.src} type={source.type} />)}
534
+ {typeof src !== "string" && sources.map((source) => (
535
+ // The type is what lets the browser SKIP a file it cannot play without
536
+ // fetching it, which is the only reason to list several. Read from the
537
+ // extension when it was not given, and left off when the extension means
538
+ // nothing - a wrong type is worse than none, because the source is then
539
+ // skipped and the video does not play at all.
540
+ <source key={source.src} src={source.src} type={source.type ?? player.sourceType(source.src)} />
541
+ ))}
535
542
  {tracks?.map((track) => (
536
543
  <track
537
544
  key={track.src}
@@ -12,7 +12,13 @@ import type { ContextMenuItem, ContextMenuNode } from "@/react/context-menu/cont
12
12
 
13
13
  export interface VideoSource {
14
14
  src: string;
15
- /** `video/mp4`, `video/webm`. The browser picks the first one it can play. */
15
+ /**
16
+ * `video/mp4`, `video/webm`. The browser picks the first one it can play.
17
+ *
18
+ * Inferred from the file extension when it is left out, and left off entirely when the
19
+ * extension means nothing - the browser then fetches and sniffs, which always works. A
20
+ * WRONG type is the expensive mistake: the source is skipped without being tried.
21
+ */
16
22
  type?: string;
17
23
  }
18
24
 
@@ -1,3 +0,0 @@
1
- export { ImageViewer, MenuButton } from './chunk-ISXB5RUH.js';
2
- import './chunk-QIIV2QSV.js';
3
- import './chunk-EYZ366LP.js';