@civitai/blocks-react 0.59.0 → 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.
package/README.md CHANGED
@@ -1445,6 +1445,7 @@ what went stale in [#334](https://github.com/civitai/civitai-app-starters/issues
1445
1445
  | `createMockHost` | A framework-agnostic fake of the embedding host — answers every `*_RESULT` message, with knobs for generation cost/latency/failure, Buzz balance, app + shared storage, consent, maturity. Returns a `MockHost`; call `.install()` and keep the returned teardown. **No network, no Buzz.** |
1446
1446
  | `readMockHostUrlOptions` | Reads the harness URL toggles (`?viewer` `?consent` `?fail` `?theme` `?pick` `?balance` `?latency` `?seed` …) into a `Partial<MockHostOptions>`. `Harness` applies it for you; call it directly only in a hand-rolled harness. |
1447
1447
  | `Harness` | The React wrapper: installs a `createMockHost` on mount, tears it down on unmount, and renders an optional on-screen outbound-message log. Takes every `MockHostOptions` field plus `applyUrlToggles` and `showLog`. |
1448
+ | `installPopoverShim` | Stands in for the HTML popover API, which neither `jsdom` nor `happy-dom` implements at any version. Needed only if your own code calls `showPopover`/`hidePopover`/`togglePopover` or queries `:popover-open` — `@civitai/components`' own elements do not. Returns a `PopoverShimHandle`; inert (`installed: false`) in a real browser. **Read [Testing overlay elements](#testing-overlay-elements) first: it does not make trigger clicks work.** |
1448
1449
 
1449
1450
  <!-- TESTING-SURFACE:VALUES:END -->
1450
1451
 
@@ -1472,6 +1473,8 @@ MockHostScenarioPatch
1472
1473
  MockSharedScenario
1473
1474
  MockSharedSeed
1474
1475
  MockStorageScenario
1476
+ PopoverShimHandle
1477
+ PopoverShimOptions
1475
1478
  ```
1476
1479
 
1477
1480
  <!-- TESTING-SURFACE:TYPES:END -->
@@ -1501,6 +1504,57 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
1501
1504
  uninstall();
1502
1505
  ```
1503
1506
 
1507
+ ### Testing overlay elements
1508
+
1509
+ `<civitai-menu>`, and anything else that opens a panel, live in an environment
1510
+ that is **incomplete** rather than merely different. Two facts, both measured on
1511
+ happy-dom 20.9.0; neither is a bug in the components.
1512
+
1513
+ **1. There is no popover API.** `showPopover`, `hidePopover` and `togglePopover`
1514
+ are `undefined` on happy-dom 20.x and on jsdom 25 and 30 alike. `:popover-open`
1515
+ is worse than absent: it is **unreliable**, and the unreliability is not a
1516
+ property of your runner's version. It resolves through `nwsapi` under jsdom, so
1517
+ the same jsdom 25.0.1 both throws `DOMException: unknown pseudo-class selector`
1518
+ and returns `false` depending on which `nwsapi` your lockfile pulled in
1519
+ (measured: `false` on nwsapi 2.2.28). `@civitai/components`' own elements no
1520
+ longer read it, which is what takes that variable off the table for them. Install
1521
+ the shim if **your** code touches the API:
1522
+
1523
+ ```ts
1524
+ import { installPopoverShim } from '@civitai/blocks-react/testing';
1525
+
1526
+ const shim = installPopoverShim();
1527
+ // …
1528
+ shim.uninstall();
1529
+ ```
1530
+
1531
+ It is inert in a real browser (`installed: false`), so it is safe to call from a
1532
+ setup file shared between a happy-dom project and a browser-mode project.
1533
+
1534
+ **2. 🔴 UNDER happy-dom A TRIGGER CLICK DOES NOTHING, and the shim does not change
1535
+ that.** A click on light-DOM content assigned to a `<slot>` reaches the **host** (a
1536
+ listener there fires once) but **not** a listener on the `<slot>` element — and
1537
+ that is the node Lit binds `@click` to. So the click dispatches, bubbles, and
1538
+ then the handler is never called: no throw, no state change, a test that quietly
1539
+ does nothing. jsdom (25 and 30) *does* deliver it, so this one is happy-dom's
1540
+ alone — which is exactly why the shim **measures** it rather than asserting it:
1541
+ `installPopoverShim` probes on install, reports the answer as
1542
+ `handle.slottedClicksReachSlots`, and `console.warn`s when it is `false`.
1543
+
1544
+ **Drive overlay elements through their methods:**
1545
+
1546
+ ```ts
1547
+ menu.show(); // ✅ works in every DOM
1548
+ await menu.updateComplete;
1549
+
1550
+ triggerButton.click(); // ❌ silently does nothing under happy-dom
1551
+ ```
1552
+
1553
+ Also absent, because they need layout and a hit-testing event path: the top
1554
+ layer, anchor positioning, and **light dismiss** (a click outside a shown panel
1555
+ does not close it). If what you are testing is one of those, use a real browser —
1556
+ this repo's own `browser` vitest project is the worked example.
1557
+
1504
1558
  ### In a dev harness
1505
1559
 
1506
1560
  ```tsx
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@civitai/theme": "^0.4.0",
41
- "@civitai/components": "^0.8.1"
40
+ "@civitai/components": "^0.9.0",
41
+ "@civitai/theme": "^0.4.0"
42
42
  },
43
43
  "comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
44
44
  "peerDependencies": {
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.52.0"
61
+ "@civitai/app-sdk": "^0.53.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"