@enigmax/primitives 0.14.0 → 0.15.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.
@@ -16,6 +16,7 @@ var DEFAULTS = {
16
16
  manageStyles: true,
17
17
  copies: "clone"
18
18
  };
19
+ var MAX_COPIES = 24;
19
20
  function createMarquee(lane, track, options = {}) {
20
21
  let opts = { ...DEFAULTS, ...options };
21
22
  let period = 0;
@@ -80,7 +81,7 @@ function createMarquee(lane, track, options = {}) {
80
81
  }
81
82
  function requiredCopies(measured) {
82
83
  if (measured <= 0) return 2;
83
- return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
84
+ return Math.min(MAX_COPIES, Math.max(2, Math.ceil(axisSize(lane) / measured) + 1));
84
85
  }
85
86
  function syncClones(count) {
86
87
  const source = track.children[0];
@@ -241,6 +242,19 @@ function createMarquee(lane, track, options = {}) {
241
242
  if (first) resizeObserver?.observe(first);
242
243
  measure();
243
244
  void document.fonts?.ready.then(() => measure());
245
+ remeasureOnImageLoad();
246
+ }
247
+ function remeasureOnImageLoad() {
248
+ for (const image of track.querySelectorAll("img")) {
249
+ if (image.complete) continue;
250
+ const done = () => {
251
+ image.removeEventListener("load", done);
252
+ image.removeEventListener("error", done);
253
+ if (!destroyed) measure();
254
+ };
255
+ image.addEventListener("load", done);
256
+ image.addEventListener("error", done);
257
+ }
244
258
  }
245
259
  init();
246
260
  return {
@@ -1,4 +1,4 @@
1
- import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createNotifications, createButton, createMarquee, createInput, createNetworkMonitor, SERVER_NETWORK_STATE, relativeTimeView, relativeTimeAttributes } from './chunk-DD2X5B2J.js';
1
+ import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createNotifications, createButton, createMarquee, createInput, createNetworkMonitor, SERVER_NETWORK_STATE, relativeTimeView, relativeTimeAttributes } from './chunk-53DQY6LP.js';
2
2
  import { createSearch } from './chunk-UZFEEFMF.js';
3
3
  import { forwardRef, createElement, useRef, useState, useId, useCallback, useEffect, useMemo, useSyncExternalStore, useLayoutEffect } from 'react';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
@@ -680,6 +680,11 @@ function RelativeTime({
680
680
  ...rest
681
681
  }) {
682
682
  useEffect(loadElement, []);
683
+ const applyPrefix = useCallback((node) => {
684
+ if (!node) return;
685
+ if (prefix === void 0 || prefix === "") node.removeAttribute("prefix");
686
+ else node.setAttribute("prefix", prefix);
687
+ }, [prefix]);
683
688
  const options = {
684
689
  format,
685
690
  tense,
@@ -712,7 +717,8 @@ function RelativeTime({
712
717
  if (view.absoluteOnly) {
713
718
  return createElement("time", { dateTime: view.iso, title: noTitle ? void 0 : view.exact, ...marks, ...rest }, view.label);
714
719
  }
715
- return createElement("relative-time", { ...relativeTimeAttributes(view, options), ...marks, ...rest }, view.label);
720
+ const { prefix: prefixAttribute, ...attributes } = relativeTimeAttributes(view, options);
721
+ return createElement("relative-time", { ...attributes, ...marks, ...rest, ref: applyPrefix }, view.label);
716
722
  }
717
723
 
718
724
  export { Button, Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch };
package/dist/index.d.ts CHANGED
@@ -78,13 +78,6 @@ interface MarqueeInstance {
78
78
  resume(): void;
79
79
  destroy(): void;
80
80
  }
81
- /**
82
- * Wire a lane and its track into a draggable infinite marquee.
83
- *
84
- * @param lane The clipping viewport. Receives the pointer and the click guard.
85
- * @param track The moved element. Its children are the repeated copies; child 0
86
- * is the source content and must already be rendered.
87
- */
88
81
  declare function createMarquee(lane: HTMLElement, track: HTMLElement, options?: MarqueeOptions): MarqueeInstance;
89
82
 
90
83
  /**
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { INPUT_ICON_PATHS, SERVER_NETWORK_STATE, createButton, createInput, createMarquee, createNetworkMonitor, createNotifications, ensureZone, estimatePasswordStrength, generatePassword, iconMarkup, normalizeDate, parseDuration, relativeTimeAttributes, relativeTimeView } from './chunk-DD2X5B2J.js';
1
+ export { INPUT_ICON_PATHS, SERVER_NETWORK_STATE, createButton, createInput, createMarquee, createNetworkMonitor, createNotifications, ensureZone, estimatePasswordStrength, generatePassword, iconMarkup, normalizeDate, parseDuration, relativeTimeAttributes, relativeTimeView } from './chunk-53DQY6LP.js';
2
2
  export { createSearch } from './chunk-UZFEEFMF.js';
@@ -1,7 +1,7 @@
1
1
  "use client";
2
- import { Button } from '../chunk-YFM2FWHQ.js';
3
- export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-YFM2FWHQ.js';
4
- export { estimatePasswordStrength, generatePassword } from '../chunk-DD2X5B2J.js';
2
+ import { Button } from '../chunk-U2KYYMBJ.js';
3
+ export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-U2KYYMBJ.js';
4
+ export { estimatePasswordStrength, generatePassword } from '../chunk-53DQY6LP.js';
5
5
  import '../chunk-UZFEEFMF.js';
6
6
  import Link from 'next/link';
7
7
  import { forwardRef, createElement } from 'react';
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Button, Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-YFM2FWHQ.js';
3
- export { estimatePasswordStrength, generatePassword } from '../chunk-DD2X5B2J.js';
2
+ export { Button, Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-U2KYYMBJ.js';
3
+ export { estimatePasswordStrength, generatePassword } from '../chunk-53DQY6LP.js';
4
4
  import '../chunk-UZFEEFMF.js';
@@ -1,7 +1,7 @@
1
1
  "use client";
2
- import { Button } from '../chunk-YFM2FWHQ.js';
3
- export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-YFM2FWHQ.js';
4
- export { estimatePasswordStrength, generatePassword } from '../chunk-DD2X5B2J.js';
2
+ import { Button } from '../chunk-U2KYYMBJ.js';
3
+ export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-U2KYYMBJ.js';
4
+ export { estimatePasswordStrength, generatePassword } from '../chunk-53DQY6LP.js';
5
5
  import '../chunk-UZFEEFMF.js';
6
6
  import { Link } from 'react-router';
7
7
  import { forwardRef, createElement } from 'react';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,8 @@
30
30
  "./relative-time.css": "./recipes/relative-time/styles.css",
31
31
  "./toast.css": "./recipes/toast/styles.css",
32
32
  "./input.css": "./recipes/input/styles.css",
33
- "./marquee.css": "./recipes/marquee/styles.css"
33
+ "./marquee.css": "./recipes/marquee/styles.css",
34
+ "./logo-marquee.css": "./recipes/logo-marquee/styles.css"
34
35
  },
35
36
  "scripts": {
36
37
  "build": "tsup",
@@ -0,0 +1,103 @@
1
+ import "./styles.css";
2
+ import type { ReactNode, Ref } from "react";
3
+ import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
4
+
5
+ /**
6
+ * The "trusted by" logo wall, yours to edit.
7
+ *
8
+ * A logo wall is a marquee with one extra job: the logos arrive at wildly different aspect
9
+ * ratios and optical weights, and a row that just drops them in reads as random. Normalising
10
+ * them to a common height is most of what makes it look designed.
11
+ *
12
+ * Some of the logos in any real wall are not images - Bloomberg, NASA and Harvard are set in
13
+ * type - so an item is either an image or a node.
14
+ */
15
+
16
+ export type LogoItem =
17
+ | { kind: "img"; src: string; alt?: string; /** Override the shared height for one that reads small. */ height?: number; }
18
+ | { kind: "node"; node: ReactNode; key?: string; };
19
+
20
+ export interface LogoMarqueeProps {
21
+ logos: LogoItem[];
22
+ /**
23
+ * Pixels per second. NEVER a duration: a lap is as long as its content, so seconds-per-
24
+ * loop makes the speed content/duration and the row runs faster every time a logo is
25
+ * added. Measured on one rail: 45, 67 and 87 px/s at 10, 15 and 20 items.
26
+ */
27
+ speed?: number;
28
+ /** Space between logos, in px. */
29
+ gap?: number;
30
+ /** Fade both ends, so logos enter and leave instead of being cut. */
31
+ fade?: boolean;
32
+ /**
33
+ * What a pointer resting on the row does: "off", "pause", a multiplier (0.15 crawls),
34
+ * or `{ speed }` for an absolute px/s.
35
+ */
36
+ hover?: MarqueeHover;
37
+ reverse?: boolean;
38
+ /** The shared optical height every image is scaled to. */
39
+ logoHeight?: number;
40
+ /**
41
+ * `mono` drains the colour so twelve brand palettes stop fighting each other, and
42
+ * restores it under the pointer. `brand` leaves them alone.
43
+ */
44
+ tone?: "brand" | "mono";
45
+ /** Names the row for a screen reader, e.g. "Trusted by". Untranslated by default. */
46
+ label?: string;
47
+ className?: string;
48
+ }
49
+
50
+ export function LogoMarquee({
51
+ logos,
52
+ speed = 40,
53
+ gap = 56,
54
+ fade = true,
55
+ hover = "off",
56
+ reverse = false,
57
+ logoHeight = 28,
58
+ tone = "mono",
59
+ label,
60
+ className = ""
61
+ }: LogoMarqueeProps) {
62
+ const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
63
+
64
+ const render = (item: LogoItem, index: number): ReactNode => (
65
+ <span className="logo-marquee__item" key={item.kind === "node" ? item.key ?? index : index}>
66
+ {item.kind === "img"
67
+ ? <img
68
+ src={item.src}
69
+ // Empty by default: a wall of brand names read out one after another is
70
+ // noise, and `label` already says what the row is.
71
+ alt={item.alt ?? ""}
72
+ style={item.height ? { height: `${item.height}px` } : undefined}
73
+ draggable={false}
74
+ // Never lazy: the row measures its own lap from the rendered width, and
75
+ // an image with no size yet measures as nothing.
76
+ decoding="async"
77
+ />
78
+ : item.node}
79
+ </span>
80
+ );
81
+
82
+ return (
83
+ <div
84
+ ref={laneRef as Ref<HTMLDivElement>}
85
+ className={["logo-marquee", fade ? "is-faded" : "", className].filter(Boolean).join(" ")}
86
+ style={{ "--logo-marquee-gap": `${gap}px`, "--logo-marquee-height": `${logoHeight}px` } as React.CSSProperties}
87
+ data-tone={tone}
88
+ data-grabbing={dragging ? "" : undefined}
89
+ role={label ? "group" : undefined}
90
+ aria-label={label}
91
+ >
92
+ <div ref={trackRef as Ref<HTMLDivElement>} className="logo-marquee__track">
93
+ {Array.from({ length: copies }, (unused, copy) => (
94
+ // Only the first copy is read: the rest are the same logos again, and a
95
+ // screen reader announcing the wall three times is the bug this prevents.
96
+ <div key={copy} aria-hidden={copy > 0} className="logo-marquee__copy">
97
+ {logos.map(render)}
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,74 @@
1
+ /*
2
+ * The logo wall's look. Yours to edit - the behaviour above ships none of this.
3
+ *
4
+ * The two custom properties are set from the props, so the component never writes a class
5
+ * per gap or per height.
6
+ */
7
+
8
+ .logo-marquee {
9
+ --logo-marquee-gap: 56px;
10
+ --logo-marquee-height: 28px;
11
+ position: relative;
12
+ overflow: hidden;
13
+ /* The lane's width must come from its container, never from its content. `width: 100%`
14
+ and `min-width: 0` say so explicitly, because a lane that sizes to max-content - a
15
+ grid cell with `justify-items: center`, a flex item without `min-width: 0` - grows to
16
+ fit the track, and a wider lane needs more copies, which makes it wider again. */
17
+ width: 100%;
18
+ min-width: 0;
19
+ /* Set by the primitive too, but declared here so the row still behaves before the
20
+ script runs: the page scrolls vertically, the drag takes the horizontal. */
21
+ touch-action: pan-y;
22
+ cursor: grab;
23
+ }
24
+
25
+ .logo-marquee[data-grabbing] { cursor: grabbing; }
26
+
27
+ /* Both ends fade out, so a logo leaves rather than being sliced by the container edge. */
28
+ .logo-marquee.is-faded {
29
+ -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
30
+ mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
31
+ }
32
+
33
+ .logo-marquee__track { display: flex; width: max-content; }
34
+ .logo-marquee__copy { display: flex; align-items: center; gap: var(--logo-marquee-gap); padding-right: var(--logo-marquee-gap); }
35
+
36
+ .logo-marquee__item {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ flex: 0 0 auto;
40
+ /* The whole trick of a logo wall: one optical height for everything, whatever the
41
+ source images measure, so a row of twelve brands reads as one row. */
42
+ height: var(--logo-marquee-height);
43
+ font-size: calc(var(--logo-marquee-height) * 0.72);
44
+ font-weight: 600;
45
+ line-height: 1;
46
+ white-space: nowrap;
47
+ user-select: none;
48
+ }
49
+
50
+ .logo-marquee__item img {
51
+ height: 100%;
52
+ /* Never stretched: a logo is the one image where the aspect ratio is the brand. */
53
+ width: auto;
54
+ object-fit: contain;
55
+ display: block;
56
+ }
57
+
58
+ /* Twelve brand palettes side by side fight each other and pull the eye off the copy, so
59
+ the default drains them - and gives the colour back to whichever one you point at. */
60
+ .logo-marquee[data-tone="mono"] .logo-marquee__item {
61
+ filter: grayscale(1);
62
+ opacity: 0.65;
63
+ transition: filter 160ms ease-out, opacity 160ms ease-out;
64
+ }
65
+
66
+ .logo-marquee[data-tone="mono"] .logo-marquee__item:hover {
67
+ filter: grayscale(0);
68
+ opacity: 1;
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ /* The primitive already stops the autoplay; this stops the colour from animating too. */
73
+ .logo-marquee__item { transition: none; }
74
+ }
@@ -3,22 +3,29 @@
3
3
  *
4
4
  * import "@enigmax/utils/relative-time.css";
5
5
  *
6
+ * The element renders its output into a SHADOW ROOT, which is why these work the way they
7
+ * do: `text-transform` is an inherited property, so it crosses the boundary from the host
8
+ * and reaches text no outer selector could ever match. A descendant selector cannot - the
9
+ * light-DOM children are the fallback, not what you are looking at.
10
+ *
6
11
  * Both are opt-in per timestamp - the attributes only appear when you ask for them - and
7
- * both are marked !important on purpose: the element writes its own text content, so a
8
- * `text-transform` from a reset or a typography plugin lands on the same node and would
9
- * otherwise win on cascade order alone.
12
+ * both are !important on purpose: the transform lands on the host, where a reset or a
13
+ * typography plugin would otherwise win on cascade order alone.
10
14
  */
11
15
 
12
16
  /* Title Case Every Word. Reads oddly on "2 Days Ago"; usually you want the rule below. */
13
- [data-relative-time-capitalize="true"],
14
- [data-relative-time-capitalize="true"] * {
17
+ [data-relative-time-capitalize="true"] {
15
18
  text-transform: capitalize !important;
16
19
  }
17
20
 
18
- /* Just the first letter, so a standalone "yesterday" starts a line properly. The nested
19
- selectors cover the element wrapping its text in a child, which it does for some formats. */
20
- [data-relative-time-capitalize-first-letter="true"]::first-letter,
21
- [data-relative-time-capitalize-first-letter="true"] *::first-letter,
22
- [data-relative-time-capitalize-first-letter="true"] * *::first-letter {
21
+ /* Just the first letter, so a standalone "yesterday" starts a line properly.
22
+ `::first-letter` only applies to a BLOCK container and the element is inline, so the
23
+ host is made inline-block for it - scoped to the hook, so a timestamp that did not ask
24
+ for this keeps its inline layout. */
25
+ [data-relative-time-capitalize-first-letter="true"] {
26
+ display: inline-block;
27
+ }
28
+
29
+ [data-relative-time-capitalize-first-letter="true"]::first-letter {
23
30
  text-transform: uppercase !important;
24
31
  }
package/registry.json CHANGED
@@ -91,6 +91,68 @@
91
91
  "css"
92
92
  ]
93
93
  },
94
+ {
95
+ "name": "logo-marquee",
96
+ "title": "Logo wall",
97
+ "description": "The \"trusted by\" row: a marquee that also does the job a logo wall needs doing - one optical height for logos that arrive at a dozen different sizes, colour drained so twelve brand palettes stop fighting, and text logos like NASA or Bloomberg alongside the images.",
98
+ "targets": [
99
+ "react"
100
+ ],
101
+ "entry": {
102
+ "react": "@enigmax/primitives/react"
103
+ },
104
+ "exports": {
105
+ "react": [
106
+ "LogoMarquee"
107
+ ]
108
+ },
109
+ "files": [
110
+ {
111
+ "path": "src/core/marquee.ts",
112
+ "dest": "logo-marquee/marquee.ts",
113
+ "targets": [
114
+ "react"
115
+ ]
116
+ },
117
+ {
118
+ "path": "src/react/use-marquee.ts",
119
+ "dest": "logo-marquee/use-marquee.ts",
120
+ "targets": [
121
+ "react"
122
+ ],
123
+ "rewrite": {
124
+ "@/core/marquee": "./marquee"
125
+ }
126
+ },
127
+ {
128
+ "path": "recipes/logo-marquee/styles.css",
129
+ "dest": "logo-marquee/styles.css",
130
+ "targets": [
131
+ "react"
132
+ ]
133
+ },
134
+ {
135
+ "path": "recipes/logo-marquee/index.tsx",
136
+ "dest": "logo-marquee/index.tsx",
137
+ "main": true,
138
+ "targets": [
139
+ "react"
140
+ ],
141
+ "rewrite": {
142
+ "@enigmax/primitives/react": "./use-marquee"
143
+ }
144
+ }
145
+ ],
146
+ "styles": true,
147
+ "themeHooks": [
148
+ "[data-tone=mono]",
149
+ ".logo-marquee",
150
+ ".logo-marquee.is-faded",
151
+ ".logo-marquee[data-grabbing]",
152
+ ".logo-marquee__item"
153
+ ],
154
+ "docs": "docs/notes/primitives.md#logo-marquee"
155
+ },
94
156
  {
95
157
  "name": "input",
96
158
  "title": "Input, with what a password field needs",
@@ -121,6 +121,9 @@ const DEFAULTS: ResolvedOptions = {
121
121
  * @param track The moved element. Its children are the repeated copies; child 0
122
122
  * is the source content and must already be rendered.
123
123
  */
124
+ /** Ceiling on the copy count. See requiredCopies for why a ceiling is needed at all. */
125
+ const MAX_COPIES = 24;
126
+
124
127
  export function createMarquee(lane: HTMLElement, track: HTMLElement, options: MarqueeOptions = {}): MarqueeInstance {
125
128
  let opts: ResolvedOptions = { ...DEFAULTS, ...options };
126
129
 
@@ -211,7 +214,14 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
211
214
  /** Non-negotiable 2: the lane plus one whole period, so there is always one to wrap into. */
212
215
  function requiredCopies(measured: number): number {
213
216
  if (measured <= 0) return 2;
214
- return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
217
+ // Capped, because the measurement can be transiently TINY rather than zero: a row
218
+ // of images measured before they load is a few pixels wide, and lane/period then
219
+ // asks for hundreds of copies. In React that renders hundreds of subtrees before
220
+ // the images arrive to correct it, which trips the update-depth limit and takes the
221
+ // page down - the zero guard above does not catch it because the width is not zero.
222
+ // The cap is far past any real wall; a copy narrow enough to need more than this is
223
+ // a measurement that has not settled yet, and the ResizeObserver will come back.
224
+ return Math.min(MAX_COPIES, Math.max(2, Math.ceil(axisSize(lane) / measured) + 1));
215
225
  }
216
226
 
217
227
  function syncClones(count: number): void {
@@ -437,6 +447,27 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
437
447
  measure();
438
448
  // A late font changes every width the lap was measured from.
439
449
  void document.fonts?.ready.then(() => measure());
450
+ // So does a late IMAGE, and that one is the common case: a logo wall measured
451
+ // before its logos have loaded reads as a few pixels wide, which asks for far more
452
+ // copies than the row needs. The ResizeObserver above sees the first copy resize,
453
+ // but only while that exact node survives - a framework re-rendering the copies
454
+ // replaces it - so the images are waited on directly.
455
+ remeasureOnImageLoad();
456
+ }
457
+
458
+ /** Re-measure once each image that had not loaded at mount finishes. */
459
+ function remeasureOnImageLoad(): void {
460
+ for (const image of track.querySelectorAll("img")) {
461
+ if (image.complete) continue;
462
+ const done = (): void => {
463
+ image.removeEventListener("load", done);
464
+ image.removeEventListener("error", done);
465
+ if (!destroyed) measure();
466
+ };
467
+ // `error` too: a logo that 404s still settles the layout it was holding open.
468
+ image.addEventListener("load", done);
469
+ image.addEventListener("error", done);
470
+ }
440
471
  }
441
472
 
442
473
  init();
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { createElement, useEffect, type HTMLAttributes, type ReactNode } from "react";
3
+ import { createElement, useCallback, useEffect, type HTMLAttributes, type ReactNode } from "react";
4
4
  import { relativeTimeView, relativeTimeAttributes, type RelativeTimeOptions } from "@/core/relative-time";
5
5
 
6
6
  /**
@@ -75,6 +75,22 @@ export function RelativeTime({
75
75
  }: RelativeTimeProps): ReactNode {
76
76
  useEffect(loadElement, []);
77
77
 
78
+ /**
79
+ * `prefix` is set through the DOM, never as a React prop.
80
+ *
81
+ * `Element.prototype.prefix` is a read-only accessor (the XML namespace prefix), and
82
+ * React sets a KNOWN property rather than an attribute - so passing it through throws
83
+ * "Cannot set property prefix of #<Element> which has only a getter" and takes the
84
+ * whole tree down with it. It is the only attribute here that collides; the rest are
85
+ * either kebab-cased or writable. Applied on the ref, which runs before the custom
86
+ * element upgrades, so the element still reads it.
87
+ */
88
+ const applyPrefix = useCallback((node: HTMLElement | null) => {
89
+ if (!node) return;
90
+ if (prefix === undefined || prefix === "") node.removeAttribute("prefix");
91
+ else node.setAttribute("prefix", prefix);
92
+ }, [prefix]);
93
+
78
94
  const options: RelativeTimeOptions = {
79
95
  format, tense, precision, threshold, prefix, formatStyle, locale, timeZone,
80
96
  second, minute, hour, weekday, day, month, year, timeZoneName, noTitle, numericBeyondThreshold
@@ -99,5 +115,6 @@ export function RelativeTime({
99
115
  // createElement rather than JSX: `<relative-time>` is not a known intrinsic element, and
100
116
  // declaring it means augmenting React's JSX namespace from inside a published package -
101
117
  // which collides with any consumer that declared it too, differently.
102
- return createElement("relative-time", { ...relativeTimeAttributes(view, options), ...marks, ...rest }, view.label);
118
+ const { prefix: prefixAttribute, ...attributes } = relativeTimeAttributes(view, options);
119
+ return createElement("relative-time", { ...attributes, ...marks, ...rest, ref: applyPrefix }, view.label);
103
120
  }