@fohte/storybook-addon 0.1.0 → 0.1.2

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
@@ -35,6 +35,18 @@ export default config
35
35
 
36
36
  This wires up `overflow-check` and `external-resource-check` — no further setup needed.
37
37
 
38
+ ### overflow-check
39
+
40
+ To exempt a selector across every story (e.g. a component whose oversized hit target never visibly clips anything), set `parameters.overflowCheck.globalIgnoreSelectors` once in `.storybook/preview.ts`. It's additive with a story's own `parameters.overflowCheck.ignoreSelectors` — both apply, since they're separate keys and Storybook deep-merges parameter objects by key (only arrays at the same key replace wholesale, so a story-level `ignoreSelectors` can't drop the global list):
41
+
42
+ ```ts
43
+ import type { Parameters } from 'storybook/internal/types'
44
+
45
+ export const parameters: Parameters = {
46
+ overflowCheck: { globalIgnoreSelectors: ['[data-slot="checkbox"]'] },
47
+ }
48
+ ```
49
+
38
50
  ### unhandled-api-request-check
39
51
 
40
52
  This check needs to know which paths count as "API requests" (that's app-specific), and needs to be fed unhandled requests from your MSW setup. Configure it once and call `reportUnhandledApiRequest()` from `onUnhandledRequest` in `.storybook/preview.ts`:
@@ -1,6 +1,6 @@
1
1
  export type StorybookCheck = {
2
2
  reset: () => void;
3
- assert: (storyParameters?: unknown) => void;
3
+ assert: (storyParameters?: unknown, canvasElement?: Element) => void;
4
4
  };
5
5
  export declare function throwIfNotEmpty(urls: string[], message: string): void;
6
6
  //# sourceMappingURL=check.d.ts.map
@@ -1,32 +1,4 @@
1
1
  import { throwIfNotEmpty } from '#checks/check.js';
2
- // The consuming app's preview.ts calls reset()/assert() around each story
3
- // render (see preview.ts's beforeEach/afterEach). The story's container is a
4
- // bare <div> appended to document.body before anything is mounted into it —
5
- // reset() never gets `context.canvasElement` itself, so this is the only way
6
- // to reach the same element. Portal-based components (dialogs, popovers,
7
- // selects, etc.) append their own <div>s directly to document.body too, but
8
- // only *after* the story has mounted — so the container is reliably the
9
- // *first* element appended to body once a story starts, not the last. A
10
- // MutationObserver set up in reset() (which runs before the story mounts)
11
- // captures it.
12
- let storyRoot = null;
13
- let bodyObserver = null;
14
- function watchStoryRoot() {
15
- storyRoot = null;
16
- bodyObserver?.disconnect();
17
- bodyObserver = new MutationObserver((mutations) => {
18
- for (const mutation of mutations) {
19
- for (const node of mutation.addedNodes) {
20
- if (node instanceof Element) {
21
- storyRoot = node;
22
- bodyObserver?.disconnect();
23
- return;
24
- }
25
- }
26
- }
27
- });
28
- bodyObserver.observe(document.body, { childList: true });
29
- }
30
2
  function describeElement(el) {
31
3
  const id = el.id ? `#${el.id}` : '';
32
4
  const classAttr = el.getAttribute('class');
@@ -143,20 +115,43 @@ function ignoreSelectorsOf(overflowCheck) {
143
115
  return [];
144
116
  return ignoreSelectors.filter((s) => typeof s === 'string');
145
117
  }
118
+ // A separate key from `ignoreSelectors`, set once by the consuming app in
119
+ // its top-level `preview.ts` `parameters` export (not via a module-level
120
+ // setter — a story's own `parameters.overflowCheck` and the app's global one
121
+ // live in different Storybook parameter scopes, which Storybook deep-merges
122
+ // by object key, so a story setting `ignoreSelectors` can't drop this list).
123
+ // This also sidesteps bundlers that resolve this addon's module twice for
124
+ // one consumer (once from the app's own import, once via Storybook's addon
125
+ // loader) — `context.parameters` comes from Storybook itself, not from
126
+ // either module instance, so it's unaffected either way.
127
+ function globalIgnoreSelectorsOf(overflowCheck) {
128
+ if (overflowCheck === undefined)
129
+ return [];
130
+ if (!('globalIgnoreSelectors' in overflowCheck))
131
+ return [];
132
+ const { globalIgnoreSelectors } = overflowCheck;
133
+ if (!Array.isArray(globalIgnoreSelectors))
134
+ return [];
135
+ return globalIgnoreSelectors.filter((s) => typeof s === 'string');
136
+ }
146
137
  export const overflowCheck = {
147
- reset: watchStoryRoot,
148
- assert: (storyParameters) => {
138
+ reset: () => { },
139
+ assert: (storyParameters, canvasElement) => {
149
140
  const params = overflowCheckParameters(storyParameters);
150
141
  if (isDisabled(params))
151
142
  return;
152
- if (storyRoot == null) {
153
- // The MutationObserver in watchStoryRoot() hasn't seen an element
154
- // appended to body yet (e.g. the story renders nothing). Skip rather
155
- // than fail, since there is no root to scan.
156
- console.warn('[overflow-check] story root not detected; skipping overflow scan');
143
+ if (canvasElement == null) {
144
+ // Real Storybook runs always pass canvasElement (it's the story's
145
+ // mount container); this only triggers a caller invoking assert()
146
+ // directly without one. Skip rather than fail, since there is no root
147
+ // to scan.
148
+ console.warn('[overflow-check] no canvasElement in story context; skipping overflow scan');
157
149
  return;
158
150
  }
159
- throwIfNotEmpty(findOverflows(storyRoot, ignoreSelectorsOf(params)), 'Story has element(s) overflowing their container (clipped and invisible)');
151
+ throwIfNotEmpty(findOverflows(canvasElement, [
152
+ ...globalIgnoreSelectorsOf(params),
153
+ ...ignoreSelectorsOf(params),
154
+ ]), 'Story has element(s) overflowing their container (clipped and invisible)');
160
155
  },
161
156
  };
162
157
  //# sourceMappingURL=overflow-check.js.map
@@ -1,7 +1,15 @@
1
1
  import type { StorybookCheck } from '#checks/check.js';
2
+ interface UnhandledApiRequestState {
3
+ pathPrefixes: string[];
4
+ unhandledApiRequestUrls: string[];
5
+ }
6
+ declare global {
7
+ var __fohteStorybookAddonUnhandledApiRequestState__: UnhandledApiRequestState | undefined;
8
+ }
2
9
  export declare function configureUnhandledApiRequestCheck(options: {
3
10
  pathPrefixes: string[];
4
11
  }): void;
5
12
  export declare function reportUnhandledApiRequest(url: string): boolean;
6
13
  export declare const unhandledApiRequestCheck: StorybookCheck;
14
+ export {};
7
15
  //# sourceMappingURL=unhandled-api-request-check.d.ts.map
@@ -1,15 +1,13 @@
1
1
  import { throwIfNotEmpty } from '#checks/check.js';
2
- // Populated by reportUnhandledApiRequest(), which the consuming app's MSW
3
- // `onUnhandledRequest` callback calls — a story hitting an API endpoint with
4
- // no MSW handler gets MSW's error response instead of real data, so the
5
- // screenshot captures a broken UI state without failing otherwise.
6
- const unhandledApiRequestUrls = [];
7
- // Which paths count as "API requests" is app-specific (tq uses `/api/`, a
8
- // future app might use a different prefix or none), so it's configured once
9
- // by the consuming app rather than hardcoded here.
10
- let pathPrefixes = [];
2
+ function globalState() {
3
+ globalThis.__fohteStorybookAddonUnhandledApiRequestState__ ??= {
4
+ pathPrefixes: [],
5
+ unhandledApiRequestUrls: [],
6
+ };
7
+ return globalThis.__fohteStorybookAddonUnhandledApiRequestState__;
8
+ }
11
9
  export function configureUnhandledApiRequestCheck(options) {
12
- pathPrefixes = options.pathPrefixes;
10
+ globalState().pathPrefixes = options.pathPrefixes;
13
11
  }
14
12
  // Returns whether the request was recorded, so the caller can decide whether
15
13
  // to also print MSW's own error for it.
@@ -17,18 +15,19 @@ export function reportUnhandledApiRequest(url) {
17
15
  const parsed = new URL(url);
18
16
  if (parsed.origin !== window.location.origin)
19
17
  return false;
20
- if (!pathPrefixes.some((prefix) => parsed.pathname.startsWith(prefix))) {
18
+ const state = globalState();
19
+ if (!state.pathPrefixes.some((prefix) => parsed.pathname.startsWith(prefix))) {
21
20
  return false;
22
21
  }
23
- unhandledApiRequestUrls.push(parsed.pathname);
22
+ state.unhandledApiRequestUrls.push(parsed.pathname);
24
23
  return true;
25
24
  }
26
25
  export const unhandledApiRequestCheck = {
27
26
  reset: () => {
28
- unhandledApiRequestUrls.length = 0;
27
+ globalState().unhandledApiRequestUrls.length = 0;
29
28
  },
30
29
  assert: () => {
31
- throwIfNotEmpty(unhandledApiRequestUrls, 'Story made unhandled API request(s); add an MSW handler for');
30
+ throwIfNotEmpty(globalState().unhandledApiRequestUrls, 'Story made unhandled API request(s); add an MSW handler for');
32
31
  },
33
32
  };
34
33
  //# sourceMappingURL=unhandled-api-request-check.js.map
package/lib/preview.js CHANGED
@@ -37,7 +37,8 @@ export const beforeEach = () => {
37
37
  check.reset();
38
38
  };
39
39
  export const afterEach = (context) => {
40
- for (const check of checks)
41
- check.assert(context.parameters);
40
+ for (const check of checks) {
41
+ check.assert(context.parameters, context.canvasElement);
42
+ }
42
43
  };
43
44
  //# sourceMappingURL=preview.js.map
package/package.json CHANGED
@@ -51,7 +51,7 @@
51
51
  "typescript": "6.0.3",
52
52
  "vitest": "4.1.10"
53
53
  },
54
- "version": "0.1.0",
54
+ "version": "0.1.2",
55
55
  "scripts": {
56
56
  "clean": "rimraf lib tsconfig.tsbuildinfo",
57
57
  "prebuild": "pnpm run clean",