@repobuddy/storybook 2.11.0 → 2.12.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/esm/index.d.ts +8 -1
- package/esm/index.js +105 -81
- package/esm/storybook-addon-tag-badges/index.js +3 -3
- package/package.json +1 -1
- package/readme.md +13 -16
- package/src/contexts/story_card_registry_context.tsx +17 -0
- package/src/contexts/story_card_scope.tsx +83 -0
- package/src/decorators/show_doc_source.tsx +45 -23
- package/src/decorators/with_story_card.tsx +4 -96
- package/src/storybook-addon-tag-badges/tag_badges.ts +3 -3
package/esm/index.d.ts
CHANGED
|
@@ -99,11 +99,18 @@ declare function StoryCard({
|
|
|
99
99
|
* @param options.showOriginalSource - Whether to show the original source code in a card
|
|
100
100
|
* @param options.className - Class name to apply to the card
|
|
101
101
|
* @param options.source - Source code to show if provided.
|
|
102
|
-
* @
|
|
102
|
+
* @param options.placement - Where to show the source code relative to the story.
|
|
103
|
+
* @returns A decorator function that shows the source code of a story above or below the rendered story
|
|
103
104
|
*/
|
|
104
105
|
declare function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Args>(options?: Pick<StoryCardProps, 'className'> & {
|
|
105
106
|
source?: string | undefined;
|
|
106
107
|
showOriginalSource?: boolean | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Where to show the source code relative to the story.
|
|
110
|
+
*
|
|
111
|
+
* @default 'after'
|
|
112
|
+
*/
|
|
113
|
+
placement?: 'before' | 'after' | undefined;
|
|
107
114
|
}): DecoratorFunction<TRenderer, TArgs>;
|
|
108
115
|
//#endregion
|
|
109
116
|
//#region src/decorators/with_story_card.d.ts
|
package/esm/index.js
CHANGED
|
@@ -71,6 +71,84 @@ function StoryCard({ status, className, children, title }) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/utils/generate_key.ts
|
|
76
|
+
/**
|
|
77
|
+
* Generates a key for React collections, falling back to a simple counter-based ID if crypto.randomUUID is unavailable.
|
|
78
|
+
* crypto.randomUUID() requires a secure context (HTTPS, localhost, or 127.0.0.1).
|
|
79
|
+
*
|
|
80
|
+
* This can be moved to `@just-web` in the future.
|
|
81
|
+
*/
|
|
82
|
+
function generateKey(prefix) {
|
|
83
|
+
const randomId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
84
|
+
return prefix ? `${prefix}-${randomId}` : randomId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/contexts/story_card_registry_context.tsx
|
|
89
|
+
const StoryCardRegistryContext = createContext(null);
|
|
90
|
+
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/contexts/story_card_scope.tsx
|
|
93
|
+
/**
|
|
94
|
+
* Ensures a story-card collection scope: creates the root container when no context exists,
|
|
95
|
+
* otherwise renders the collector so this card participates in the existing scope.
|
|
96
|
+
*/
|
|
97
|
+
function StoryCardScope({ Story, ...props }) {
|
|
98
|
+
const context = useContext(StoryCardRegistryContext);
|
|
99
|
+
const collector = /* @__PURE__ */ jsx(StoryCardCollector, {
|
|
100
|
+
Story,
|
|
101
|
+
...props
|
|
102
|
+
});
|
|
103
|
+
if (context === null) return /* @__PURE__ */ jsx(StoryCardContainer, { children: collector });
|
|
104
|
+
return collector;
|
|
105
|
+
}
|
|
106
|
+
function StoryCardContainer({ children }) {
|
|
107
|
+
const [cards, setCards] = useState([]);
|
|
108
|
+
const contextValue = useMemo(() => ({
|
|
109
|
+
add(card) {
|
|
110
|
+
const key = generateKey("story-card");
|
|
111
|
+
setCards((cards) => [...cards, {
|
|
112
|
+
...card,
|
|
113
|
+
key
|
|
114
|
+
}]);
|
|
115
|
+
return key;
|
|
116
|
+
},
|
|
117
|
+
remove(key) {
|
|
118
|
+
setCards((cards) => cards.filter((card) => card.key !== key));
|
|
119
|
+
}
|
|
120
|
+
}), []);
|
|
121
|
+
return /* @__PURE__ */ jsx(StoryCardRegistryContext.Provider, {
|
|
122
|
+
value: contextValue,
|
|
123
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
124
|
+
className: "rbsb:flex rbsb:flex-col rbsb:gap-2",
|
|
125
|
+
children: [cards.map(({ content, key, ...rest }) => /* @__PURE__ */ jsx(StoryCard, {
|
|
126
|
+
...rest,
|
|
127
|
+
children: content
|
|
128
|
+
}, key)), children]
|
|
129
|
+
})
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function StoryCardCollector({ Story, title, status, className, content }) {
|
|
133
|
+
const context = useContext(StoryCardRegistryContext);
|
|
134
|
+
const cardIdRef = useRef(null);
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
if (cardIdRef.current === null) cardIdRef.current = context.add({
|
|
137
|
+
title,
|
|
138
|
+
status,
|
|
139
|
+
className,
|
|
140
|
+
content
|
|
141
|
+
});
|
|
142
|
+
return () => {
|
|
143
|
+
if (cardIdRef.current !== null) {
|
|
144
|
+
context.remove(cardIdRef.current);
|
|
145
|
+
cardIdRef.current = null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}, []);
|
|
149
|
+
return /* @__PURE__ */ jsx(Story, {});
|
|
150
|
+
}
|
|
151
|
+
|
|
74
152
|
//#endregion
|
|
75
153
|
//#region src/decorators/show_doc_source.tsx
|
|
76
154
|
const channel = addons.getChannel();
|
|
@@ -82,7 +160,8 @@ const channel = addons.getChannel();
|
|
|
82
160
|
* @param options.showOriginalSource - Whether to show the original source code in a card
|
|
83
161
|
* @param options.className - Class name to apply to the card
|
|
84
162
|
* @param options.source - Source code to show if provided.
|
|
85
|
-
* @
|
|
163
|
+
* @param options.placement - Where to show the source code relative to the story.
|
|
164
|
+
* @returns A decorator function that shows the source code of a story above or below the rendered story
|
|
86
165
|
*/
|
|
87
166
|
function showDocSource(options) {
|
|
88
167
|
return (Story, { parameters: { docs, darkMode } }) => {
|
|
@@ -96,12 +175,31 @@ function showDocSource(options) {
|
|
|
96
175
|
const code = options?.source ?? (options?.showOriginalSource ? docs?.source?.originalSource : docs?.source?.code ?? docs?.source?.originalSource);
|
|
97
176
|
const language = code === docs?.source?.originalSource ? void 0 : docs?.source?.language;
|
|
98
177
|
const isOriginalSource = code === docs?.source?.originalSource;
|
|
99
|
-
const
|
|
178
|
+
const sourceContent = /* @__PURE__ */ jsx(SyntaxHighlighter, {
|
|
100
179
|
language,
|
|
101
180
|
children: code
|
|
102
181
|
});
|
|
182
|
+
const showBefore = options?.placement === "before";
|
|
183
|
+
const sourceCardClassName = (state) => {
|
|
184
|
+
const modifiedState = {
|
|
185
|
+
...state,
|
|
186
|
+
defaultClassName: twJoin(state.defaultClassName, isOriginalSource ? "rbsb:bg-transparent rbsb:dark:bg-transparent" : "rbsb:bg-gray-100 rbsb:dark:bg-gray-900")
|
|
187
|
+
};
|
|
188
|
+
const className = options?.className;
|
|
189
|
+
return typeof className === "function" ? className(modifiedState) : twJoin(modifiedState.defaultClassName, className);
|
|
190
|
+
};
|
|
191
|
+
const theme = convert(docs?.theme ?? (isDark ? themes.dark : themes.light));
|
|
192
|
+
if (showBefore) return /* @__PURE__ */ jsx(ThemeProvider, {
|
|
193
|
+
theme,
|
|
194
|
+
children: /* @__PURE__ */ jsx(StoryCardScope, {
|
|
195
|
+
Story,
|
|
196
|
+
content: sourceContent,
|
|
197
|
+
className: sourceCardClassName,
|
|
198
|
+
status: "info"
|
|
199
|
+
})
|
|
200
|
+
});
|
|
103
201
|
return /* @__PURE__ */ jsx(ThemeProvider, {
|
|
104
|
-
theme
|
|
202
|
+
theme,
|
|
105
203
|
children: /* @__PURE__ */ jsxs("section", {
|
|
106
204
|
style: {
|
|
107
205
|
display: "flex",
|
|
@@ -109,34 +207,15 @@ function showDocSource(options) {
|
|
|
109
207
|
gap: "1rem"
|
|
110
208
|
},
|
|
111
209
|
children: [/* @__PURE__ */ jsx(Story, {}), /* @__PURE__ */ jsx(StoryCard, {
|
|
112
|
-
className:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
defaultClassName: twJoin(state.defaultClassName, isOriginalSource ? "rbsb:bg-transparent rbsb:dark:bg-transparent" : "rbsb:bg-gray-100 rbsb:dark:bg-gray-900")
|
|
116
|
-
};
|
|
117
|
-
const className = options?.className;
|
|
118
|
-
return typeof className === "function" ? className(modifiedState) : twJoin(modifiedState.defaultClassName, className);
|
|
119
|
-
},
|
|
120
|
-
children: content
|
|
210
|
+
className: sourceCardClassName,
|
|
211
|
+
status: "info",
|
|
212
|
+
children: sourceContent
|
|
121
213
|
})]
|
|
122
214
|
})
|
|
123
215
|
});
|
|
124
216
|
};
|
|
125
217
|
}
|
|
126
218
|
|
|
127
|
-
//#endregion
|
|
128
|
-
//#region src/utils/generate_key.ts
|
|
129
|
-
/**
|
|
130
|
-
* Generates a key for React collections, falling back to a simple counter-based ID if crypto.randomUUID is unavailable.
|
|
131
|
-
* crypto.randomUUID() requires a secure context (HTTPS, localhost, or 127.0.0.1).
|
|
132
|
-
*
|
|
133
|
-
* This can be moved to `@just-web` in the future.
|
|
134
|
-
*/
|
|
135
|
-
function generateKey(prefix) {
|
|
136
|
-
const randomId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
137
|
-
return prefix ? `${prefix}-${randomId}` : randomId;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
219
|
//#endregion
|
|
141
220
|
//#region src/decorators/with_story_card.tsx
|
|
142
221
|
/**
|
|
@@ -227,7 +306,7 @@ function withStoryCard({ title, status, content: contentProp, className, ...rest
|
|
|
227
306
|
const finalClassName = className ?? storyCardParam?.className;
|
|
228
307
|
const content = finalContent ?? parameters.docs?.description?.story ?? parameters.docs?.description?.component;
|
|
229
308
|
if (!content && !finalTitle) return /* @__PURE__ */ jsx(Story, {});
|
|
230
|
-
return /* @__PURE__ */ jsx(
|
|
309
|
+
return /* @__PURE__ */ jsx(StoryCardScope, {
|
|
231
310
|
Story,
|
|
232
311
|
content,
|
|
233
312
|
title: finalTitle,
|
|
@@ -237,61 +316,6 @@ function withStoryCard({ title, status, content: contentProp, className, ...rest
|
|
|
237
316
|
});
|
|
238
317
|
};
|
|
239
318
|
}
|
|
240
|
-
function StoryCardContainerWrapper({ Story, ...props }) {
|
|
241
|
-
const context = useContext(StoryCardContext);
|
|
242
|
-
const collector = /* @__PURE__ */ jsx(StoryCardCollector, {
|
|
243
|
-
Story,
|
|
244
|
-
...props
|
|
245
|
-
});
|
|
246
|
-
if (context === null) return /* @__PURE__ */ jsx(StoryCardContainer, { children: collector });
|
|
247
|
-
return collector;
|
|
248
|
-
}
|
|
249
|
-
function StoryCardContainer({ children }) {
|
|
250
|
-
const [cards, setCards] = useState([]);
|
|
251
|
-
const contextValue = useMemo(() => ({
|
|
252
|
-
addCard(card) {
|
|
253
|
-
const key = generateKey("story-card");
|
|
254
|
-
setCards((cards) => [...cards, {
|
|
255
|
-
...card,
|
|
256
|
-
key
|
|
257
|
-
}]);
|
|
258
|
-
return key;
|
|
259
|
-
},
|
|
260
|
-
removeCard(key) {
|
|
261
|
-
setCards((cards) => cards.filter((card) => card.key !== key));
|
|
262
|
-
}
|
|
263
|
-
}), []);
|
|
264
|
-
return /* @__PURE__ */ jsx(StoryCardContext.Provider, {
|
|
265
|
-
value: contextValue,
|
|
266
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
267
|
-
className: "rbsb:flex rbsb:flex-col rbsb:gap-2",
|
|
268
|
-
children: [cards.map(({ content, key, ...rest }) => /* @__PURE__ */ jsx(StoryCard, {
|
|
269
|
-
...rest,
|
|
270
|
-
children: content
|
|
271
|
-
}, key)), children]
|
|
272
|
-
})
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
function StoryCardCollector({ Story, title, status, className, content }) {
|
|
276
|
-
const context = useContext(StoryCardContext);
|
|
277
|
-
const cardIdRef = useRef(null);
|
|
278
|
-
useLayoutEffect(() => {
|
|
279
|
-
if (cardIdRef.current === null) cardIdRef.current = context.addCard({
|
|
280
|
-
title,
|
|
281
|
-
status,
|
|
282
|
-
className,
|
|
283
|
-
content
|
|
284
|
-
});
|
|
285
|
-
return () => {
|
|
286
|
-
if (cardIdRef.current !== null) {
|
|
287
|
-
context.removeCard(cardIdRef.current);
|
|
288
|
-
cardIdRef.current = null;
|
|
289
|
-
}
|
|
290
|
-
};
|
|
291
|
-
}, []);
|
|
292
|
-
return /* @__PURE__ */ jsx(Story, {});
|
|
293
|
-
}
|
|
294
|
-
const StoryCardContext = createContext(null);
|
|
295
319
|
|
|
296
320
|
//#endregion
|
|
297
321
|
//#region src/parameters/define_actions_param.ts
|
|
@@ -150,7 +150,7 @@ const unitBadge = {
|
|
|
150
150
|
},
|
|
151
151
|
tooltip: "Unit Test"
|
|
152
152
|
},
|
|
153
|
-
display: { sidebar:
|
|
153
|
+
display: { sidebar: true }
|
|
154
154
|
};
|
|
155
155
|
const integrationBadge = {
|
|
156
156
|
tags: "integration",
|
|
@@ -199,9 +199,9 @@ const tagBadges = [
|
|
|
199
199
|
propsBadge,
|
|
200
200
|
todoBadge,
|
|
201
201
|
codeOnlyBadge,
|
|
202
|
+
versionBadge,
|
|
202
203
|
internalBadge,
|
|
203
|
-
snapshotBadge
|
|
204
|
-
versionBadge
|
|
204
|
+
snapshotBadge
|
|
205
205
|
];
|
|
206
206
|
|
|
207
207
|
//#endregion
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -139,6 +139,9 @@ export const preview: Preview = {
|
|
|
139
139
|
|
|
140
140
|
[`@repobuddy/storybook`][`@repobuddy/storybook`] uses Tailwind CSS 4 with the prefix `rbsb:` and support `dark` variant.
|
|
141
141
|
|
|
142
|
+
While we provide a pre-built `@repobuddy/storybook/styles.css` for you,
|
|
143
|
+
it uses the default `dark` variant which is based on the `(prefers-color-scheme: dark)` media query.
|
|
144
|
+
|
|
142
145
|
Since how to control the dark variant is different in different projects,
|
|
143
146
|
the pre-built `@repobuddy/storybook/styles.css` might not work for you.
|
|
144
147
|
|
|
@@ -149,38 +152,32 @@ you need to separate your tailwind config and import them in your storybook.
|
|
|
149
152
|
|
|
150
153
|
```tsx
|
|
151
154
|
// .storybook/preview.tsx
|
|
152
|
-
import './tailwind.css'
|
|
153
|
-
import './tailwind.repobuddy-storybook.css'
|
|
154
155
|
import '../tailwind.css'
|
|
156
|
+
import './tailwind.repobuddy-storybook.css'
|
|
155
157
|
```
|
|
156
158
|
|
|
157
159
|
```css
|
|
158
|
-
/*
|
|
160
|
+
/* tailwind.css */
|
|
161
|
+
/* add `@repobuddy/storybook/tailwind.css` to a separate layer "repobuddy-storybook" to control the layer order */
|
|
162
|
+
@layer theme, base, repobuddy-storybook, components, utilities;
|
|
163
|
+
@import "tailwindcss";
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
@layer theme, base, components, rbsb, utilities;
|
|
162
|
-
@import "tailwindcss/preflight.css" layer(base);
|
|
165
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
163
166
|
```
|
|
164
167
|
|
|
165
168
|
```css
|
|
166
169
|
/* .storybook/tailwind.repobuddy-storybook.css */
|
|
167
|
-
|
|
168
|
-
@import "@repobuddy/storybook/tailwind.css" layer(rbsb);
|
|
170
|
+
@import "@repobuddy/storybook/tailwind.css" layer(repobuddy-storybook);
|
|
169
171
|
|
|
170
172
|
@source "../node_modules/@repobuddy/storybook/src/**";
|
|
171
173
|
|
|
172
174
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
173
175
|
```
|
|
174
176
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
@import "tailwindcss/theme.css" layer(theme) prefix(app);
|
|
178
|
-
@import "tailwindcss/utilities.css" layer(utilities) prefix(app);
|
|
179
|
-
|
|
180
|
-
@custom-variant dark (&:where(.dark, .dark *));
|
|
181
|
-
```
|
|
177
|
+
You may notice that the `@custom-variant dark` is duplicated in both files.
|
|
178
|
+
If you want to avoid this, you can extract it to a separate file and import it in both files.
|
|
182
179
|
|
|
183
|
-
|
|
180
|
+
Also note that `@repobuddy/storybook/tailwind` is deprecated in favor of `@repobuddy/storybook/tailwind.css`.
|
|
184
181
|
That convention better aligns with the Tailwind CSS 4 convention.
|
|
185
182
|
|
|
186
183
|
[`@repobuddy/storybook`]: https://github.com/repobuddy/storybook
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { createContext } from 'react'
|
|
3
|
+
import type { RequiredPick } from 'type-plus'
|
|
4
|
+
import type { StoryCardProps } from '../components/story_card.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Payload for adding a card to the story card registry.
|
|
8
|
+
* Matches the shape of card props with `status` required.
|
|
9
|
+
*/
|
|
10
|
+
export type StoryCardEntry = RequiredPick<Omit<StoryCardProps, 'children'> & { content?: ReactNode }, 'status'>
|
|
11
|
+
|
|
12
|
+
export interface StoryCardRegistryContextValue {
|
|
13
|
+
add: (card: StoryCardEntry) => string
|
|
14
|
+
remove: (id: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const StoryCardRegistryContext = createContext<StoryCardRegistryContextValue | null>(null)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { StoryCard } from '../components/story_card.js'
|
|
3
|
+
import { generateKey } from '../utils/generate_key.js'
|
|
4
|
+
import {
|
|
5
|
+
type StoryCardEntry,
|
|
6
|
+
StoryCardRegistryContext,
|
|
7
|
+
type StoryCardRegistryContextValue
|
|
8
|
+
} from './story_card_registry_context.js'
|
|
9
|
+
|
|
10
|
+
export type StoryCardScopeProps = { Story: ComponentType } & StoryCardEntry
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ensures a story-card collection scope: creates the root container when no context exists,
|
|
14
|
+
* otherwise renders the collector so this card participates in the existing scope.
|
|
15
|
+
*/
|
|
16
|
+
export function StoryCardScope({ Story, ...props }: StoryCardScopeProps) {
|
|
17
|
+
const context = useContext(StoryCardRegistryContext)
|
|
18
|
+
const collector = <StoryCardCollector Story={Story} {...props} />
|
|
19
|
+
|
|
20
|
+
if (context === null) {
|
|
21
|
+
return <StoryCardContainer>{collector}</StoryCardContainer>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return collector
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function StoryCardContainer({ children }: { children: ReactNode }) {
|
|
28
|
+
const [cards, setCards] = useState<StoryCardEntryWithKey[]>([])
|
|
29
|
+
|
|
30
|
+
const contextValue: StoryCardRegistryContextValue = useMemo(
|
|
31
|
+
() => ({
|
|
32
|
+
add(card) {
|
|
33
|
+
const key = generateKey('story-card')
|
|
34
|
+
setCards((cards) => [...cards, { ...card, key }])
|
|
35
|
+
return key
|
|
36
|
+
},
|
|
37
|
+
remove(key) {
|
|
38
|
+
setCards((cards) => cards.filter((card) => card.key !== key))
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
[]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<StoryCardRegistryContext.Provider value={contextValue}>
|
|
46
|
+
<div className="rbsb:flex rbsb:flex-col rbsb:gap-2">
|
|
47
|
+
{cards.map(({ content, key, ...rest }) => (
|
|
48
|
+
<StoryCard key={key} {...rest}>
|
|
49
|
+
{content}
|
|
50
|
+
</StoryCard>
|
|
51
|
+
))}
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
</StoryCardRegistryContext.Provider>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type StoryCardEntryWithKey = StoryCardEntry & { key: string }
|
|
59
|
+
|
|
60
|
+
interface StoryCardCollectorProps extends StoryCardScopeProps {}
|
|
61
|
+
|
|
62
|
+
function StoryCardCollector({ Story, title, status, className, content }: StoryCardCollectorProps) {
|
|
63
|
+
// StoryCardCollector is an internal component. Context is guaranteed to be not null by `StoryCardContainer`.
|
|
64
|
+
const context = useContext(StoryCardRegistryContext)!
|
|
65
|
+
const cardIdRef = useRef<string | null>(null)
|
|
66
|
+
|
|
67
|
+
// Collect this card once into the collection
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
// Only add if not already added (handles Strict Mode double-render)
|
|
70
|
+
if (cardIdRef.current === null) {
|
|
71
|
+
cardIdRef.current = context.add({ title, status, className, content })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
if (cardIdRef.current !== null) {
|
|
76
|
+
context.remove(cardIdRef.current)
|
|
77
|
+
cardIdRef.current = null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
return <Story />
|
|
83
|
+
}
|
|
@@ -5,6 +5,7 @@ import { addons } from 'storybook/preview-api'
|
|
|
5
5
|
import { convert, ThemeProvider, themes } from 'storybook/theming'
|
|
6
6
|
import { twJoin } from 'tailwind-merge'
|
|
7
7
|
import { StoryCard, type StoryCardProps } from '../components/story_card'
|
|
8
|
+
import { StoryCardScope } from '../contexts/story_card_scope'
|
|
8
9
|
|
|
9
10
|
const channel = addons.getChannel()
|
|
10
11
|
|
|
@@ -16,12 +17,19 @@ const channel = addons.getChannel()
|
|
|
16
17
|
* @param options.showOriginalSource - Whether to show the original source code in a card
|
|
17
18
|
* @param options.className - Class name to apply to the card
|
|
18
19
|
* @param options.source - Source code to show if provided.
|
|
19
|
-
* @
|
|
20
|
+
* @param options.placement - Where to show the source code relative to the story.
|
|
21
|
+
* @returns A decorator function that shows the source code of a story above or below the rendered story
|
|
20
22
|
*/
|
|
21
23
|
export function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Args>(
|
|
22
24
|
options?: Pick<StoryCardProps, 'className'> & {
|
|
23
25
|
source?: string | undefined
|
|
24
26
|
showOriginalSource?: boolean | undefined
|
|
27
|
+
/**
|
|
28
|
+
* Where to show the source code relative to the story.
|
|
29
|
+
*
|
|
30
|
+
* @default 'after'
|
|
31
|
+
*/
|
|
32
|
+
placement?: 'before' | 'after' | undefined
|
|
25
33
|
}
|
|
26
34
|
): DecoratorFunction<TRenderer, TArgs> {
|
|
27
35
|
return (Story, { parameters: { docs, darkMode } }) => {
|
|
@@ -46,10 +54,43 @@ export function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Arg
|
|
|
46
54
|
|
|
47
55
|
const isOriginalSource = code === docs?.source?.originalSource
|
|
48
56
|
|
|
49
|
-
const
|
|
57
|
+
const sourceContent = <SyntaxHighlighter language={language}>{code}</SyntaxHighlighter>
|
|
58
|
+
|
|
59
|
+
const showBefore = options?.placement === 'before'
|
|
60
|
+
|
|
61
|
+
const sourceCardClassName = (state: Pick<StoryCardProps, 'status'> & { defaultClassName: string }) => {
|
|
62
|
+
const modifiedState = {
|
|
63
|
+
...state,
|
|
64
|
+
defaultClassName: twJoin(
|
|
65
|
+
state.defaultClassName,
|
|
66
|
+
isOriginalSource ? 'rbsb:bg-transparent rbsb:dark:bg-transparent' : 'rbsb:bg-gray-100 rbsb:dark:bg-gray-900'
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const className = options?.className
|
|
71
|
+
return typeof className === 'function'
|
|
72
|
+
? className(modifiedState)
|
|
73
|
+
: twJoin(modifiedState.defaultClassName, className)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const theme = convert(docs?.theme ?? (isDark ? themes.dark : themes.light))
|
|
77
|
+
|
|
78
|
+
if (showBefore) {
|
|
79
|
+
return (
|
|
80
|
+
<ThemeProvider theme={theme}>
|
|
81
|
+
<StoryCardScope Story={Story} content={sourceContent} className={sourceCardClassName} status="info" />
|
|
82
|
+
</ThemeProvider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const storyCard = (
|
|
87
|
+
<StoryCard className={sourceCardClassName} status="info">
|
|
88
|
+
{sourceContent}
|
|
89
|
+
</StoryCard>
|
|
90
|
+
)
|
|
50
91
|
|
|
51
92
|
return (
|
|
52
|
-
<ThemeProvider theme={
|
|
93
|
+
<ThemeProvider theme={theme}>
|
|
53
94
|
<section
|
|
54
95
|
style={{
|
|
55
96
|
display: 'flex',
|
|
@@ -58,26 +99,7 @@ export function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Arg
|
|
|
58
99
|
}}
|
|
59
100
|
>
|
|
60
101
|
<Story />
|
|
61
|
-
|
|
62
|
-
className={(state) => {
|
|
63
|
-
const modifiedState = {
|
|
64
|
-
...state,
|
|
65
|
-
defaultClassName: twJoin(
|
|
66
|
-
state.defaultClassName,
|
|
67
|
-
isOriginalSource
|
|
68
|
-
? 'rbsb:bg-transparent rbsb:dark:bg-transparent'
|
|
69
|
-
: 'rbsb:bg-gray-100 rbsb:dark:bg-gray-900'
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const className = options?.className
|
|
74
|
-
return typeof className === 'function'
|
|
75
|
-
? className(modifiedState)
|
|
76
|
-
: twJoin(modifiedState.defaultClassName, className)
|
|
77
|
-
}}
|
|
78
|
-
>
|
|
79
|
-
{content}
|
|
80
|
-
</StoryCard>
|
|
102
|
+
{storyCard}
|
|
81
103
|
</section>
|
|
82
104
|
</ThemeProvider>
|
|
83
105
|
)
|
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ComponentType,
|
|
3
|
-
createContext,
|
|
4
|
-
type ReactNode,
|
|
5
|
-
useContext,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState
|
|
10
|
-
} from 'react'
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
11
2
|
import type { DecoratorFunction, Renderer } from 'storybook/internal/csf'
|
|
12
|
-
import type {
|
|
13
|
-
import {
|
|
3
|
+
import type { StoryCardProps } from '../components/story_card.js'
|
|
4
|
+
import { StoryCardScope } from '../contexts/story_card_scope.js'
|
|
14
5
|
import type { StoryCardParam } from '../parameters/define_story_card_param.js'
|
|
15
|
-
import { generateKey } from '../utils/generate_key.js'
|
|
16
6
|
|
|
17
7
|
export type WithStoryCardProps = Omit<StoryCardProps, 'children' | 'className'> & {
|
|
18
8
|
/**
|
|
@@ -137,7 +127,7 @@ export function withStoryCard<TRenderer extends Renderer = Renderer>({
|
|
|
137
127
|
if (!content && !finalTitle) return <Story />
|
|
138
128
|
|
|
139
129
|
return (
|
|
140
|
-
<
|
|
130
|
+
<StoryCardScope
|
|
141
131
|
Story={Story}
|
|
142
132
|
content={content}
|
|
143
133
|
title={finalTitle}
|
|
@@ -148,85 +138,3 @@ export function withStoryCard<TRenderer extends Renderer = Renderer>({
|
|
|
148
138
|
)
|
|
149
139
|
}
|
|
150
140
|
}
|
|
151
|
-
|
|
152
|
-
interface StoryCardContainerWrapperProps extends RequiredPick<WithStoryCardProps, 'status'> {
|
|
153
|
-
Story: ComponentType
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function StoryCardContainerWrapper({ Story, ...props }: StoryCardContainerWrapperProps) {
|
|
157
|
-
const context = useContext(StoryCardContext)
|
|
158
|
-
const collector = <StoryCardCollector Story={Story} {...props} />
|
|
159
|
-
|
|
160
|
-
if (context === null) {
|
|
161
|
-
return <StoryCardContainer>{collector}</StoryCardContainer>
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return collector
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function StoryCardContainer({ children }: { children: ReactNode }) {
|
|
168
|
-
const [cards, setCards] = useState<StoryCardWithKey[]>([])
|
|
169
|
-
|
|
170
|
-
const contextValue: StoryCardContextValue = useMemo(
|
|
171
|
-
() => ({
|
|
172
|
-
addCard(card) {
|
|
173
|
-
const key = generateKey('story-card')
|
|
174
|
-
setCards((cards) => [...cards, { ...card, key }])
|
|
175
|
-
return key
|
|
176
|
-
},
|
|
177
|
-
removeCard(key) {
|
|
178
|
-
setCards((cards) => cards.filter((card) => card.key !== key))
|
|
179
|
-
}
|
|
180
|
-
}),
|
|
181
|
-
[]
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
return (
|
|
185
|
-
<StoryCardContext.Provider value={contextValue}>
|
|
186
|
-
<div className="rbsb:flex rbsb:flex-col rbsb:gap-2">
|
|
187
|
-
{cards.map(({ content, key, ...rest }) => (
|
|
188
|
-
<StoryCard key={key} {...rest}>
|
|
189
|
-
{content}
|
|
190
|
-
</StoryCard>
|
|
191
|
-
))}
|
|
192
|
-
{children}
|
|
193
|
-
</div>
|
|
194
|
-
</StoryCardContext.Provider>
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
type StoryCardWithKey = RequiredPick<WithStoryCardProps, 'status'> & { key: string }
|
|
199
|
-
|
|
200
|
-
interface StoryCardCollectorProps extends RequiredPick<WithStoryCardProps, 'status'> {
|
|
201
|
-
Story: ComponentType
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function StoryCardCollector({ Story, title, status, className, content }: StoryCardCollectorProps) {
|
|
205
|
-
// StoryCardCollector is an internal component. Context is guaranteed to be not null by `StoryCardContainer`.
|
|
206
|
-
const context = useContext(StoryCardContext)!
|
|
207
|
-
const cardIdRef = useRef<string | null>(null)
|
|
208
|
-
|
|
209
|
-
// Collect this card once into the collection
|
|
210
|
-
useLayoutEffect(() => {
|
|
211
|
-
// Only add if not already added (handles Strict Mode double-render)
|
|
212
|
-
if (cardIdRef.current === null) {
|
|
213
|
-
cardIdRef.current = context.addCard({ title, status, className, content })
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return () => {
|
|
217
|
-
if (cardIdRef.current !== null) {
|
|
218
|
-
context.removeCard(cardIdRef.current)
|
|
219
|
-
cardIdRef.current = null
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}, [])
|
|
223
|
-
|
|
224
|
-
return <Story />
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
interface StoryCardContextValue {
|
|
228
|
-
addCard: (card: RequiredPick<WithStoryCardProps, 'status'>) => string
|
|
229
|
-
removeCard: (id: string) => void
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const StoryCardContext = createContext<StoryCardContextValue | null>(null)
|
|
@@ -185,7 +185,7 @@ export const unitBadge: TagBadgeParameter = {
|
|
|
185
185
|
tooltip: 'Unit Test'
|
|
186
186
|
},
|
|
187
187
|
display: {
|
|
188
|
-
sidebar:
|
|
188
|
+
sidebar: true
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
@@ -241,7 +241,7 @@ export const tagBadges: TagBadgeParameters = [
|
|
|
241
241
|
propsBadge,
|
|
242
242
|
todoBadge,
|
|
243
243
|
codeOnlyBadge,
|
|
244
|
+
versionBadge,
|
|
244
245
|
internalBadge,
|
|
245
|
-
snapshotBadge
|
|
246
|
-
versionBadge
|
|
246
|
+
snapshotBadge
|
|
247
247
|
]
|