@civitai/blocks-react 0.58.1 → 0.60.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.
@@ -0,0 +1,253 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { generateIdempotencyKey } from '../transport/transport.js';
3
+ import { useBlockToken } from './useBlockToken.js';
4
+ import { useBuzzPurchase } from './useBuzzPurchase.js';
5
+ import { useHostOrigin } from './useHostOrigin.js';
6
+ /**
7
+ * Backstop timeout for the direct REST purchase POST. Like {@link useTip} (and
8
+ * unlike the postMessage hooks), this talks to the HTTP API directly, so it
9
+ * needs its OWN bound — otherwise a hung request never rejects.
10
+ */
11
+ const GOOD_PURCHASE_TIMEOUT_MS = 30_000;
12
+ /**
13
+ * A refusal the server produced deliberately, as opposed to a transport
14
+ * failure.
15
+ *
16
+ * `reason` is the machine-readable discriminator where one exists — branch on it
17
+ * rather than on `message`, which is viewer-facing copy and will be reworded.
18
+ *
19
+ * 🔴 `reason` IS OFTEN `undefined`, AND A CONSUMER MUST HANDLE THAT. Only the
20
+ * SERVICE-level refusals carry one (`insufficient_funds`, `ledger_conflict`,
21
+ * `charge_failed`, `charge_unknown`, the price-disagreement and
22
+ * already-owned cases). The ENDPOINT's own refusals return `{ error }` alone:
23
+ * the 404 for an unavailable good, the 429 rate limit, the daily-cap 400 and
24
+ * every idempotency refusal (409 / 422 / 503). So a `switch (reason)` with no
25
+ * default silently swallows a material fraction of real failures — always fall
26
+ * back to `status` plus `message`.
27
+ */
28
+ export class GoodPurchaseRefusal extends Error {
29
+ status;
30
+ reason;
31
+ constructor(message, status, reason) {
32
+ super(message);
33
+ this.name = 'GoodPurchaseRefusal';
34
+ this.status = status;
35
+ this.reason = reason;
36
+ }
37
+ }
38
+ /**
39
+ * Buy a DIGITAL GOOD for the viewer through the block-token-gated
40
+ * `POST /api/v1/blocks/goods/purchase` REST endpoint (scope
41
+ * `goods:purchase:self`).
42
+ *
43
+ * Direct-fetch against the VALIDATED host origin (`useHostOrigin()`) with the
44
+ * block bearer token, the same security-reviewed pattern as {@link useTip}. The
45
+ * BUYER is always the token subject — the server self-binds it and the block
46
+ * never supplies a user id, so there is no client-supplied value on the money
47
+ * path.
48
+ *
49
+ * 🔴 BUYING A GOOD AND BUYING BUZZ ARE OPPOSITE DIRECTIONS, AND THIS HOOK
50
+ * TOUCHES BOTH. A good is Buzz flowing FROM the viewer TO the app owner. The
51
+ * top-up modal this hook can open ({@link useBuzzPurchase}) is fiat flowing INTO
52
+ * the viewer's balance. They are different rails with different ledgers; the
53
+ * only thing they share is that one can unblock the other.
54
+ *
55
+ * ⚠️ THE PLATFORM RENDERS NO CONFIRMATION FOR THE PURCHASE ITSELF. This is a
56
+ * plain authed POST, so whatever confirm UI the viewer sees is the APP's. Spend
57
+ * is bounded by the good's manifest-reviewed price and the viewer's daily cap,
58
+ * but a good can be priced near that cap where a tip cannot — so show the price
59
+ * and get an explicit action before calling this.
60
+ *
61
+ * @example
62
+ * const { purchase, loading, error } = useGoodPurchase();
63
+ * const { entitlement } = await purchase(
64
+ * { goodId: 'extra-slots', expectedPriceBuzz: 250 },
65
+ * { topUpOnInsufficientFunds: true },
66
+ * );
67
+ */
68
+ export function useGoodPurchase() {
69
+ const host = useHostOrigin();
70
+ const { raw } = useBlockToken();
71
+ const { openPurchaseModal } = useBuzzPurchase();
72
+ const [loading, setLoading] = useState(false);
73
+ const [error, setError] = useState(null);
74
+ const mountedRef = useRef(true);
75
+ const inFlight = useRef(new Set());
76
+ useEffect(() => {
77
+ mountedRef.current = true;
78
+ const controllers = inFlight.current;
79
+ return () => {
80
+ mountedRef.current = false;
81
+ for (const c of controllers)
82
+ c.abort();
83
+ controllers.clear();
84
+ };
85
+ }, []);
86
+ const postOnce = useCallback(async (params, idempotencyKey) => {
87
+ const controller = new AbortController();
88
+ inFlight.current.add(controller);
89
+ // Which abort fired is not recoverable from the signal — both a timeout
90
+ // and the unmount cleanup set `aborted` — so record it at the source.
91
+ // The two need OPPOSITE handling below, and conflating them is how a
92
+ // money-path timeout gets swallowed by an unmount-ignoring caller.
93
+ let timedOut = false;
94
+ const timeoutId = setTimeout(() => {
95
+ timedOut = true;
96
+ controller.abort();
97
+ }, GOOD_PURCHASE_TIMEOUT_MS);
98
+ try {
99
+ const res = await fetch(`${host}/api/v1/blocks/goods/purchase`, {
100
+ method: 'POST',
101
+ headers: { Authorization: `Bearer ${raw}`, 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ goodId: params.goodId,
104
+ ...(params.expectedPriceBuzz != null
105
+ ? { expectedPriceBuzz: params.expectedPriceBuzz }
106
+ : {}),
107
+ idempotencyKey,
108
+ }),
109
+ signal: controller.signal,
110
+ });
111
+ // 🔴 AN ABORT DURING THE BODY READ MUST NOT BE SWALLOWED AS "no body".
112
+ // With a real `fetch` the response headers can arrive and the BODY still
113
+ // be in flight; aborting then rejects `res.json()` with an `AbortError`.
114
+ // A blanket `.catch(() => null)` turned that into `bodyJson === null`,
115
+ // and the classification below — which reads the BODY rather than the
116
+ // abort state — then produced exactly the wrong answer twice:
117
+ //
118
+ // 200 + unmount mid-body → a `malformed_success` refusal saying "the
119
+ // charge may have landed", reported to a caller the docs told to
120
+ // IGNORE navigate-away aborts. A purchase that SUCCEEDED, surfaced as
121
+ // a possible-charge failure.
122
+ //
123
+ // 4xx + timeout mid-body → `purchase request failed (<status>)` with
124
+ // `reason` lost, so the documented timeout error and its same-key
125
+ // retry advice never reached the caller.
126
+ //
127
+ // So a parse failure is only "no body" when the request was NOT aborted;
128
+ // otherwise it is rethrown and the `catch` below classifies it by the
129
+ // abort state, which is the only thing that can tell a timeout from an
130
+ // unmount. A genuinely unparseable body on a live request still yields
131
+ // `null` and the malformed/refusal handling below is unchanged.
132
+ const bodyJson = (await res.json().catch((err) => {
133
+ if (controller.signal.aborted)
134
+ throw err;
135
+ return null;
136
+ }));
137
+ if (!res.ok) {
138
+ throw new GoodPurchaseRefusal(bodyJson?.error ?? `purchase request failed (${res.status})`, res.status, bodyJson?.reason);
139
+ }
140
+ // 🔴 VALIDATE THE 2xx, because the declared return type promises an
141
+ // entitlement. Without this an unparseable 200 resolved as `null` and a
142
+ // wrong-shaped one resolved with `entitlement === undefined` — and the
143
+ // documented usage destructures it, so both surfaced as a TypeError in
144
+ // the app rather than as a failed purchase. On this hook the body IS the
145
+ // granted entitlement, so a caller cannot recover from a silent absence.
146
+ // `useEntitlements` in this same package performs the equivalent check.
147
+ //
148
+ // 🔴 `purchase` IS CHECKED TOO, and for the same reason `entitlement` is:
149
+ // `GoodPurchaseResult` declares it REQUIRED, so `result.purchase.id` is
150
+ // typed as safe and a body of `{ ok: true, entitlement }` alone would
151
+ // resolve and then TypeError at the dereference. The live 200 path
152
+ // (`src/pages/api/v1/blocks/goods/purchase.ts`) always sends all three,
153
+ // so this is the type's promise being kept rather than a reachable
154
+ // server bug — but an `as`-cast past an unvalidated required field is
155
+ // exactly how the `entitlement` case reached an app in the first place.
156
+ if (bodyJson == null ||
157
+ bodyJson.ok !== true ||
158
+ bodyJson.entitlement == null ||
159
+ bodyJson.purchase == null) {
160
+ throw new GoodPurchaseRefusal(`purchase succeeded (${res.status}) but the response was not a purchase result — the charge may have landed; do not retry without the same idempotency key`, res.status, 'malformed_success');
161
+ }
162
+ return bodyJson;
163
+ }
164
+ catch (err) {
165
+ // A timeout or an unmount surfaces as a bare `AbortError`, which on a
166
+ // money path is the least informative wording available: the caller
167
+ // cannot tell it from a refusal, and the charge may have landed. Named
168
+ // and bounded, as `useTip` and `useEntitlements` both do.
169
+ //
170
+ // 🔴 THE `!(err instanceof GoodPurchaseRefusal)` HALF IS NOT DEFENSIVE
171
+ // PADDING — but it does NOT cover a refusal cut off mid-parse, which an
172
+ // earlier version of this comment claimed. That case no longer reaches
173
+ // here at all: an abort during the body read is rethrown above and
174
+ // classified as an abort, because a body nobody could read carries no
175
+ // `reason` to preserve.
176
+ //
177
+ // What it does cover is the race the other way round: the body had
178
+ // ALREADY ARRIVED and parsed into an `insufficient_funds` 400 when the
179
+ // abort fired underneath it — the 30s bound elapsing in the same tick
180
+ // the refusal was thrown, or an unmount landing there. `signal.aborted`
181
+ // is then true while we hold a complete, deliberate server refusal.
182
+ // Rewriting it into the abort `Error` would strip `reason`, so the
183
+ // top-up branch in `purchase` below would stop recognising it and the
184
+ // viewer would be shown a raw failure instead of the Buzz modal. A
185
+ // refusal we actually read always wins over an abort that raced it.
186
+ if (controller.signal.aborted && !(err instanceof GoodPurchaseRefusal)) {
187
+ if (timedOut) {
188
+ // The BOUND fired. This is a real failure the caller must handle,
189
+ // so it deliberately does NOT get the `AbortError` name below:
190
+ // callers routinely ignore `AbortError` as "we navigated away", and
191
+ // a silently-ignored money-path timeout is the worst outcome here.
192
+ throw new Error(`useGoodPurchase: request aborted (timed out after ${GOOD_PURCHASE_TIMEOUT_MS}ms). The charge may or may not have landed — retry with the SAME idempotencyKey to find out safely.`);
193
+ }
194
+ // 🔴 UNMOUNT. `name` stays `AbortError` on purpose: that is the
195
+ // discriminator a caller uses to IGNORE a rejection its component no
196
+ // longer cares about, and flattening it to a plain `Error` turns every
197
+ // navigate-away into a reported failure. The MESSAGE still says what
198
+ // happened — informative wording and a usable `name` are not a
199
+ // trade-off. `useBuzzWorkflow`'s `watch()` does the same.
200
+ const aborted = new Error('useGoodPurchase: request aborted (the hook unmounted before the response arrived). The charge may or may not have landed — retry with the SAME idempotencyKey to find out safely.');
201
+ aborted.name = 'AbortError';
202
+ throw aborted;
203
+ }
204
+ throw err;
205
+ }
206
+ finally {
207
+ clearTimeout(timeoutId);
208
+ inFlight.current.delete(controller);
209
+ }
210
+ }, [host, raw]);
211
+ const purchase = useCallback(async (params, options) => {
212
+ if (!host) {
213
+ throw new Error('useGoodPurchase: host origin not established yet (wait for BLOCK_INIT).');
214
+ }
215
+ if (mountedRef.current) {
216
+ setLoading(true);
217
+ setError(null);
218
+ }
219
+ const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
220
+ try {
221
+ try {
222
+ return await postOnce(params, idempotencyKey);
223
+ }
224
+ catch (first) {
225
+ const canTopUp = options?.topUpOnInsufficientFunds === true &&
226
+ first instanceof GoodPurchaseRefusal &&
227
+ first.reason === 'insufficient_funds';
228
+ if (!canTopUp)
229
+ throw first;
230
+ // Only retry if the viewer ACTUALLY bought Buzz. `purchased: false`
231
+ // is the ordinary "they closed the modal" case, and retrying it would
232
+ // just reproduce the same refusal — so the original refusal is what
233
+ // the app should see.
234
+ const { purchased } = await openPurchaseModal(params.expectedPriceBuzz);
235
+ if (!purchased)
236
+ throw first;
237
+ return await postOnce(params, idempotencyKey);
238
+ }
239
+ }
240
+ catch (err) {
241
+ const e = err instanceof Error ? err : new Error(String(err));
242
+ if (mountedRef.current)
243
+ setError(e);
244
+ throw e;
245
+ }
246
+ finally {
247
+ if (mountedRef.current)
248
+ setLoading(false);
249
+ }
250
+ }, [host, openPurchaseModal, postOnce]);
251
+ return { purchase, loading, error };
252
+ }
253
+ //# sourceMappingURL=useGoodPurchase.js.map
package/dist/index.d.ts CHANGED
@@ -26,6 +26,10 @@ export { useHostOrigin } from './hooks/useHostOrigin.js';
26
26
  export type { UseHostOrigin } from './hooks/useHostOrigin.js';
27
27
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
28
28
  export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
29
+ export { useEntitlements } from './hooks/useEntitlements.js';
30
+ export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
31
+ export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
32
+ export type { UseGoodPurchase, GoodPurchaseParams, GoodPurchaseOptions, GoodPurchaseResult, } from './hooks/useGoodPurchase.js';
29
33
  export { useTip } from './hooks/useTip.js';
30
34
  export type { TipParams, TipOptions, TipResult, UseTip } from './hooks/useTip.js';
31
35
  export { useTipAllowance } from './hooks/useTipAllowance.js';
package/dist/index.js CHANGED
@@ -24,6 +24,8 @@ export { useBlockSettings } from './hooks/useBlockSettings.js';
24
24
  export { useBlockToken } from './hooks/useBlockToken.js';
25
25
  export { useHostOrigin } from './hooks/useHostOrigin.js';
26
26
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
27
+ export { useEntitlements } from './hooks/useEntitlements.js';
28
+ export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
27
29
  export { useTip } from './hooks/useTip.js';
28
30
  export { useTipAllowance } from './hooks/useTipAllowance.js';
29
31
  export { useBlockResize } from './hooks/useBlockResize.js';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A minimal stand-in for the HTML popover API, for NON-BROWSER DOMs.
3
+ *
4
+ * WHY IT EXISTS (#485). `jsdom` and `happy-dom` do not implement the popover
5
+ * API at ANY version we could find: `showPopover`, `hidePopover` and
6
+ * `togglePopover` are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike.
7
+ * `:popover-open` is worse than absent — it is unreliable in a way that is NOT a
8
+ * property of the runner's version: under jsdom it resolves through `nwsapi`, so
9
+ * the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
10
+ * and returns `false` depending on which `nwsapi` a lockfile pulled in (measured
11
+ * `false` on nwsapi 2.2.28). Anything that calls into that API therefore explodes,
12
+ * or lies, in the one environment an App Block's own test suite runs in.
13
+ *
14
+ * 🔴 WHAT THIS DOES **NOT** DO, and you must read this before using it.
15
+ *
16
+ * It does not make a shadow-DOM component fully driveable under happy-dom.
17
+ * Measured on happy-dom 20.9.0: a click on a light-DOM child assigned to a
18
+ * `<slot>` bubbles to the HOST (a listener there fires 1x) but a listener on the
19
+ * `<slot>` ELEMENT ITSELF fires **0x**. Lit binds `@click` to the `<slot>`, so
20
+ * `<civitai-menu>`'s trigger click is SILENT there: no throw, no open, nothing.
21
+ * That is a property of happy-dom's event path through the flattened tree and
22
+ * this shim cannot fix it — installing it changes the count from 0 to 0. (jsdom
23
+ * 25 and 30 both DO deliver it, which is why this is probed rather than asserted.)
24
+ *
25
+ * So under a shimmed DOM you must drive overlay elements through their METHODS
26
+ * (`menu.show()` / `menu.hide()`), never by clicking the trigger. {@link
27
+ * installPopoverShim} PROBES for this on install and returns the result as
28
+ * {@link PopoverShimHandle.slottedClicksReachSlots}, warning loudly when it is
29
+ * false, because a shim that quietly made `show()` work while `click()` no-ops
30
+ * would read as "this element is testable now" while delivering half of it.
31
+ *
32
+ * Other deliberate deviations from the platform, all of them narrow:
33
+ * - `toggle` is dispatched in a MICROTASK, where the platform queues a task.
34
+ * A microtask flushes before the next `await`, which is what makes it
35
+ * observable after `await el.updateComplete` in a test; a real task would
36
+ * need a `setTimeout` round-trip. `beforetoggle` is not dispatched at all.
37
+ * - There is no top layer, no anchor positioning, and no LIGHT DISMISS: a
38
+ * click outside a shown popover does not close it. Those need layout and a
39
+ * hit-testing event path, neither of which a non-browser DOM has. Light
40
+ * dismiss is one of the two reasons `<civitai-menu>` uses popover at all, so
41
+ * if that is what you are testing, use a real browser.
42
+ * - `popover="manual"` vs `"auto"` is not distinguished (there being no light
43
+ * dismiss to distinguish them by).
44
+ */
45
+ /** What {@link installPopoverShim} hands back. */
46
+ export interface PopoverShimHandle {
47
+ /**
48
+ * `true` when this shim was needed — i.e. the DOM did not already have a
49
+ * popover API. `false` means nothing was patched, which is the correct result
50
+ * in a real browser, and makes the call safe to make unconditionally in a
51
+ * setup file shared between a happy-dom project and a browser-mode project.
52
+ */
53
+ installed: boolean;
54
+ /**
55
+ * 🔴 Whether a click on a slotted light-DOM element reaches a listener on the
56
+ * `<slot>` it is assigned to — measured, on install, with a throwaway element.
57
+ *
58
+ * `true` in a real browser. `false` on happy-dom 20.9.0, and while it is
59
+ * `false` an element that binds its handlers to a `<slot>` (every Lit
60
+ * component that does, `<civitai-menu>`'s trigger included) cannot be driven
61
+ * by clicking. Drive it through its methods instead.
62
+ */
63
+ slottedClicksReachSlots: boolean;
64
+ /** Restores whatever was on the prototypes before. Idempotent. */
65
+ uninstall(): void;
66
+ }
67
+ /** Options for {@link installPopoverShim}. */
68
+ export interface PopoverShimOptions {
69
+ /**
70
+ * Suppress the `console.warn` fired when slotted clicks do not reach slot
71
+ * listeners. The measurement is still returned on the handle. Default `false`
72
+ * — the warning is the point, so silence it only once you have read it.
73
+ */
74
+ quiet?: boolean;
75
+ }
76
+ /**
77
+ * Install the popover shim on the current global DOM. Call it once, in a vitest
78
+ * `setupFiles` entry or at the top of a test file, BEFORE the elements render.
79
+ *
80
+ * Safe and inert in a real browser: it detects a working popover API and patches
81
+ * nothing (`installed: false`).
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { installPopoverShim } from '@civitai/blocks-react/testing';
86
+ *
87
+ * const shim = installPopoverShim();
88
+ * // Drive overlay elements through their methods — NOT by clicking the trigger.
89
+ * menu.show();
90
+ * await menu.updateComplete;
91
+ * ```
92
+ */
93
+ export declare function installPopoverShim(options?: PopoverShimOptions): PopoverShimHandle;
94
+ //# sourceMappingURL=popoverShim.d.ts.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A minimal stand-in for the HTML popover API, for NON-BROWSER DOMs.
3
+ *
4
+ * WHY IT EXISTS (#485). `jsdom` and `happy-dom` do not implement the popover
5
+ * API at ANY version we could find: `showPopover`, `hidePopover` and
6
+ * `togglePopover` are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike.
7
+ * `:popover-open` is worse than absent — it is unreliable in a way that is NOT a
8
+ * property of the runner's version: under jsdom it resolves through `nwsapi`, so
9
+ * the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
10
+ * and returns `false` depending on which `nwsapi` a lockfile pulled in (measured
11
+ * `false` on nwsapi 2.2.28). Anything that calls into that API therefore explodes,
12
+ * or lies, in the one environment an App Block's own test suite runs in.
13
+ *
14
+ * 🔴 WHAT THIS DOES **NOT** DO, and you must read this before using it.
15
+ *
16
+ * It does not make a shadow-DOM component fully driveable under happy-dom.
17
+ * Measured on happy-dom 20.9.0: a click on a light-DOM child assigned to a
18
+ * `<slot>` bubbles to the HOST (a listener there fires 1x) but a listener on the
19
+ * `<slot>` ELEMENT ITSELF fires **0x**. Lit binds `@click` to the `<slot>`, so
20
+ * `<civitai-menu>`'s trigger click is SILENT there: no throw, no open, nothing.
21
+ * That is a property of happy-dom's event path through the flattened tree and
22
+ * this shim cannot fix it — installing it changes the count from 0 to 0. (jsdom
23
+ * 25 and 30 both DO deliver it, which is why this is probed rather than asserted.)
24
+ *
25
+ * So under a shimmed DOM you must drive overlay elements through their METHODS
26
+ * (`menu.show()` / `menu.hide()`), never by clicking the trigger. {@link
27
+ * installPopoverShim} PROBES for this on install and returns the result as
28
+ * {@link PopoverShimHandle.slottedClicksReachSlots}, warning loudly when it is
29
+ * false, because a shim that quietly made `show()` work while `click()` no-ops
30
+ * would read as "this element is testable now" while delivering half of it.
31
+ *
32
+ * Other deliberate deviations from the platform, all of them narrow:
33
+ * - `toggle` is dispatched in a MICROTASK, where the platform queues a task.
34
+ * A microtask flushes before the next `await`, which is what makes it
35
+ * observable after `await el.updateComplete` in a test; a real task would
36
+ * need a `setTimeout` round-trip. `beforetoggle` is not dispatched at all.
37
+ * - There is no top layer, no anchor positioning, and no LIGHT DISMISS: a
38
+ * click outside a shown popover does not close it. Those need layout and a
39
+ * hit-testing event path, neither of which a non-browser DOM has. Light
40
+ * dismiss is one of the two reasons `<civitai-menu>` uses popover at all, so
41
+ * if that is what you are testing, use a real browser.
42
+ * - `popover="manual"` vs `"auto"` is not distinguished (there being no light
43
+ * dismiss to distinguish them by).
44
+ */
45
+ /** The marker attribute a shown popover carries. Internal to the shim. */
46
+ const SHOWN_ATTR = 'data-civitai-popover-open';
47
+ /**
48
+ * Does a click on a slotted child reach a listener bound to the `<slot>`?
49
+ *
50
+ * Built as its own throwaway tree rather than asked of the component under test,
51
+ * so the answer is about the DOM implementation and not about one element's
52
+ * wiring. Returns `false` if anything in the probe is unsupported — an
53
+ * environment that cannot even run the probe certainly cannot deliver the event.
54
+ */
55
+ function probeSlottedClickReachesSlot(doc) {
56
+ let host;
57
+ try {
58
+ host = doc.createElement('div');
59
+ const root = host.attachShadow({ mode: 'open' });
60
+ const slot = doc.createElement('slot');
61
+ root.append(slot);
62
+ const child = doc.createElement('button');
63
+ host.append(child);
64
+ doc.body.append(host);
65
+ let slotSaw = 0;
66
+ slot.addEventListener('click', () => {
67
+ slotSaw += 1;
68
+ });
69
+ child.click();
70
+ return slotSaw > 0;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ finally {
76
+ host?.remove();
77
+ }
78
+ }
79
+ /**
80
+ * Install the popover shim on the current global DOM. Call it once, in a vitest
81
+ * `setupFiles` entry or at the top of a test file, BEFORE the elements render.
82
+ *
83
+ * Safe and inert in a real browser: it detects a working popover API and patches
84
+ * nothing (`installed: false`).
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * import { installPopoverShim } from '@civitai/blocks-react/testing';
89
+ *
90
+ * const shim = installPopoverShim();
91
+ * // Drive overlay elements through their methods — NOT by clicking the trigger.
92
+ * menu.show();
93
+ * await menu.updateComplete;
94
+ * ```
95
+ */
96
+ export function installPopoverShim(options = {}) {
97
+ const doc = globalThis.document;
98
+ const win = globalThis;
99
+ if (!doc || !win.Element || !win.HTMLElement) {
100
+ throw new Error('installPopoverShim() needs a DOM. Run it under a vitest `environment` of ' +
101
+ '`happy-dom` or `jsdom`, not `node`.');
102
+ }
103
+ const slottedClicksReachSlots = probeSlottedClickReachesSlot(doc);
104
+ if (!slottedClicksReachSlots && !options.quiet) {
105
+ // Loud on purpose. The popover half being fixed is what makes this the
106
+ // remaining reason a test "does nothing", and a silent no-op click is a far
107
+ // worse diagnostic than a throw.
108
+ console.warn('[civitai] popover shim installed, but THIS DOM DOES NOT DELIVER CLICKS ON SLOTTED ' +
109
+ 'CONTENT TO LISTENERS ON THE <slot> (measured on install; happy-dom 20.x behaves this ' +
110
+ 'way). Clicking a component\'s trigger will do nothing at all — no throw, no state ' +
111
+ 'change. Drive overlay elements through their methods instead: `menu.show()` / ' +
112
+ '`menu.hide()`. See @civitai/blocks-react README § "Testing overlay elements".');
113
+ }
114
+ const proto = win.HTMLElement.prototype;
115
+ const already = typeof proto.showPopover === 'function';
116
+ if (already) {
117
+ return { installed: false, slottedClicksReachSlots, uninstall: () => { } };
118
+ }
119
+ const shown = new WeakSet();
120
+ const fireToggle = (el, from, to) => {
121
+ queueMicrotask(() => {
122
+ // `ToggleEvent` is undefined in both jsdom and happy-dom, so the two state
123
+ // fields are attached to a plain Event. Consumers read `event.newState`,
124
+ // which is what `<civitai-menu>`'s own handler does.
125
+ const event = new Event('toggle', { bubbles: false, cancelable: false });
126
+ event.oldState = from;
127
+ event.newState = to;
128
+ el.dispatchEvent(event);
129
+ });
130
+ };
131
+ function assertPopover(el) {
132
+ if (!el.hasAttribute('popover')) {
133
+ throw new Error('InvalidStateError: showPopover/hidePopover called on an element without a `popover` attribute');
134
+ }
135
+ }
136
+ proto.showPopover = function showPopover() {
137
+ assertPopover(this);
138
+ if (shown.has(this))
139
+ throw new Error('InvalidStateError: popover is already showing');
140
+ shown.add(this);
141
+ this.setAttribute(SHOWN_ATTR, '');
142
+ fireToggle(this, 'closed', 'open');
143
+ };
144
+ proto.hidePopover = function hidePopover() {
145
+ assertPopover(this);
146
+ if (!shown.has(this))
147
+ throw new Error('InvalidStateError: popover is not showing');
148
+ shown.delete(this);
149
+ this.removeAttribute(SHOWN_ATTR);
150
+ fireToggle(this, 'open', 'closed');
151
+ };
152
+ proto.togglePopover = function togglePopover(force) {
153
+ const want = force ?? !shown.has(this);
154
+ if (want && !shown.has(this))
155
+ this.showPopover();
156
+ else if (!want && shown.has(this))
157
+ this.hidePopover();
158
+ return shown.has(this);
159
+ };
160
+ // `:popover-open` is a SELECTOR, so it cannot be shimmed by adding a method —
161
+ // it has to be rewritten before the engine sees it. `matches` is the entry
162
+ // point the pseudo-class is reached through in practice; the marker attribute
163
+ // the two methods above maintain is what it rewrites to, which makes
164
+ // `:not(:popover-open)` work for free.
165
+ const nativeMatches = win.Element.prototype.matches;
166
+ const POPOVER_OPEN = /:popover-open\b/g;
167
+ win.Element.prototype.matches = function matches(selector) {
168
+ return nativeMatches.call(this, selector.replace(POPOVER_OPEN, `[${SHOWN_ATTR}]`));
169
+ };
170
+ return {
171
+ installed: true,
172
+ slottedClicksReachSlots,
173
+ uninstall() {
174
+ delete proto.showPopover;
175
+ delete proto.hidePopover;
176
+ delete proto.togglePopover;
177
+ win.Element.prototype.matches = nativeMatches;
178
+ },
179
+ };
180
+ }
181
+ //# sourceMappingURL=popoverShim.js.map
package/dist/testing.d.ts CHANGED
@@ -20,6 +20,7 @@ import { type ReactNode } from 'react';
20
20
  import { __resetTransport } from './transport/singleton.js';
21
21
  import { type MockHostOptions } from './internal/mockHost.js';
22
22
  export { __resetTransport as resetTransport };
23
+ export { installPopoverShim, type PopoverShimHandle, type PopoverShimOptions, } from './internal/popoverShim.js';
23
24
  export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
24
25
  /**
25
26
  * Props for the dev {@link Harness}.
package/dist/testing.js CHANGED
@@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react';
21
21
  import { __resetTransport } from './transport/singleton.js';
22
22
  import { createMockHost, readMockHostUrlOptions, } from './internal/mockHost.js';
23
23
  export { __resetTransport as resetTransport };
24
+ export { installPopoverShim, } from './internal/popoverShim.js';
24
25
  export { createMockHost, readMockHostUrlOptions, } from './internal/mockHost.js';
25
26
  /**
26
27
  * What the harness chrome's `consent=` readout says, from the TWO INDEPENDENT