Skip to content

Commit f6ecc7c

Browse files
frankieyanclaude
andauthored
docs: add Figma design links and real Badges to Storybook toolbar (#1076)
* feat: add Figma design-link toolbar tool Co-Authored-By: Claude <noreply@anthropic.com> * chore: add Figma parameter to plop story template Co-Authored-By: Claude <noreply@anthropic.com> * docs: link Figma designs from component stories Co-Authored-By: Claude <noreply@anthropic.com> * refactor(storybook): render a11y badges with Reactist Badge Co-Authored-By: Claude <noreply@anthropic.com> * refactor(storybook): rename figma label to path * refactor(storybook): drop the figma link string shorthand * build(storybook): type-check the figma and badges tools * refactor(storybook): guard badge tones against type drift * feat(storybook): support figma:false to hide the figma tool * docs: opt non-design pages out of the figma tool * build(storybook): add manager-api path for react18 type-check --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68c7e8b commit f6ecc7c

45 files changed

Lines changed: 339 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.plop/templates/component/component.stories.tsx.hbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ const meta = {
88
component: {{pascalCase name}},
99
parameters: {
1010
badges: ['accessible'],
11+
// Link a Figma design to show it in the toolbar, or set `figma: false`
12+
// to hide the tool on pages with no matching design (e.g. hook docs):
13+
// figma: { path: 'Figma file › Page › Section › Component', url: 'https://www.figma.com/design/…?node-id=…' },
1114
},
1215
} satisfies Meta<typeof {{pascalCase name}}>
1316

.storybook/badges/BadgesTool.tsx

Lines changed: 0 additions & 57 deletions
This file was deleted.

.storybook/badges/badges-tool.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as React from 'react'
2+
3+
import { useParameter } from 'storybook/manager-api'
4+
5+
import { Badge } from '../../src/badge/badge'
6+
import { Inline } from '../../src/inline'
7+
8+
import { resolveBadges } from './badges'
9+
import { BADGES_CONFIG_PARAMETER, BADGES_PARAMETER } from './constants'
10+
11+
const emptyBadges: unknown[] = []
12+
const emptyBadgesConfig: Record<string, unknown> = {}
13+
14+
export const BadgesTool = React.memo(function BadgesTool() {
15+
const badgesParameter = useParameter(BADGES_PARAMETER, emptyBadges)
16+
const badgesConfigParameter = useParameter(BADGES_CONFIG_PARAMETER, emptyBadgesConfig)
17+
18+
const badges = React.useMemo(
19+
() => resolveBadges(badgesParameter, badgesConfigParameter),
20+
[badgesParameter, badgesConfigParameter],
21+
)
22+
23+
if (badges.length === 0) {
24+
return null
25+
}
26+
27+
return (
28+
<Inline space="xsmall">
29+
{badges.map(({ id, title, tone }) => (
30+
<Badge key={id} tone={tone} label={title} />
31+
))}
32+
</Inline>
33+
)
34+
})

.storybook/badges/badges.test.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,46 @@ describe('resolveBadges', () => {
66
resolveBadges(['accessible', 'deprecated'], {
77
accessible: {
88
title: 'Accessible',
9-
styles: { color: 'green' },
9+
tone: 'positive',
1010
},
1111
deprecated: {
1212
title: 'Deprecated',
13-
styles: { color: 'red' },
13+
tone: 'attention',
1414
},
1515
}),
1616
).toEqual([
1717
{
1818
id: 'accessible',
1919
title: 'Accessible',
20-
styles: { color: 'green' },
20+
tone: 'positive',
2121
},
2222
{
2323
id: 'deprecated',
2424
title: 'Deprecated',
25-
styles: { color: 'red' },
25+
tone: 'attention',
2626
},
2727
])
2828
})
2929

3030
it('filters unknown badges and invalid badge entries', () => {
3131
expect(
32-
resolveBadges(['accessible', 'missing', 42, 'untitled'], {
32+
resolveBadges(['accessible', 'missing', 42, 'untitled', 'untoned'], {
3333
accessible: {
3434
title: 'Accessible',
35+
tone: 'positive',
3536
},
3637
untitled: {
37-
styles: { color: 'orange' },
38+
tone: 'positive',
39+
},
40+
untoned: {
41+
title: 'Untoned',
3842
},
3943
}),
4044
).toEqual([
4145
{
4246
id: 'accessible',
4347
title: 'Accessible',
44-
styles: undefined,
48+
tone: 'positive',
4549
},
4650
])
4751
})
@@ -53,20 +57,14 @@ describe('resolveBadges', () => {
5357
expect(resolveBadges(['accessible'], [])).toEqual([])
5458
})
5559

56-
it('ignores non-object styles', () => {
60+
it('rejects an unknown tone', () => {
5761
expect(
5862
resolveBadges(['accessible'], {
5963
accessible: {
6064
title: 'Accessible',
61-
styles: 'color: green',
65+
tone: 'rainbow',
6266
},
6367
}),
64-
).toEqual([
65-
{
66-
id: 'accessible',
67-
title: 'Accessible',
68-
styles: undefined,
69-
},
70-
])
68+
).toEqual([])
7169
})
7270
})

.storybook/badges/badges.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
1-
import type { CSSProperties } from 'react'
1+
import type { BadgeProps } from '../../src/badge/badge'
22

3-
type UnknownRecord = Record<string, unknown>
4-
5-
type BadgeConfig = {
6-
title?: unknown
7-
styles?: unknown
8-
}
3+
type BadgeTone = BadgeProps['tone']
94

105
type ResolvedBadge = {
116
id: string
127
title: string
13-
styles: CSSProperties | undefined
8+
tone: BadgeTone
149
}
1510

16-
function isRecord(value: unknown): value is UnknownRecord {
17-
return typeof value === 'object' && value !== null && !Array.isArray(value)
11+
// Ensure valid tones are checked against the Badge's tone prop
12+
const TONE_SET: Record<BadgeTone, true> = {
13+
info: true,
14+
positive: true,
15+
promote: true,
16+
attention: true,
17+
warning: true,
1818
}
1919

20-
function isBadgeConfig(value: unknown): value is BadgeConfig {
21-
return isRecord(value)
20+
const VALID_TONES = Object.keys(TONE_SET) as BadgeTone[]
21+
22+
function isRecord(value: unknown): value is Record<string, unknown> {
23+
return typeof value === 'object' && value !== null && !Array.isArray(value)
2224
}
2325

24-
function resolveStyles(styles: unknown): CSSProperties | undefined {
25-
return isRecord(styles) ? (styles as CSSProperties) : undefined
26+
function isBadgeTone(value: unknown): value is BadgeTone {
27+
return typeof value === 'string' && (VALID_TONES as readonly string[]).includes(value)
2628
}
2729

2830
function resolveBadges(badges: unknown, badgesConfig: unknown): ResolvedBadge[] {
@@ -37,15 +39,19 @@ function resolveBadges(badges: unknown, badgesConfig: unknown): ResolvedBadge[]
3739

3840
const badgeConfig = badgesConfig[badge]
3941

40-
if (!isBadgeConfig(badgeConfig) || typeof badgeConfig.title !== 'string') {
42+
if (!isRecord(badgeConfig) || typeof badgeConfig.title !== 'string') {
43+
return []
44+
}
45+
46+
if (!isBadgeTone(badgeConfig.tone)) {
4147
return []
4248
}
4349

4450
return [
4551
{
4652
id: badge,
4753
title: badgeConfig.title,
48-
styles: resolveStyles(badgeConfig.styles),
54+
tone: badgeConfig.tone,
4955
},
5056
]
5157
})

.storybook/badges/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { BadgesTool } from './BadgesTool'
1+
export { BadgesTool } from './badges-tool'
22
export { ADDON_ID, TOOL_ID } from './constants'

.storybook/figma/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const ADDON_ID = 'reactist/figma'
2+
export const TOOL_ID = `${ADDON_ID}/tool`
3+
export const FIGMA_PARAMETER = 'figma'

.storybook/figma/figma-icon.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as React from 'react'
2+
3+
// https://www.figma.com/using-the-figma-brand/
4+
export function FigmaIcon() {
5+
return (
6+
<svg width="11" height="16" viewBox="0 0 38 57" fill="none" aria-hidden="true">
7+
<path d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0Z" fill="#1ABCFE" />
8+
<path d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0Z" fill="#0ACF83" />
9+
<path d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19Z" fill="#FF7262" />
10+
<path d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5Z" fill="#F24E1E" />
11+
<path d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5Z" fill="#A259FF" />
12+
</svg>
13+
)
14+
}

.storybook/figma/figma-tool.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as React from 'react'
2+
3+
import { useParameter } from 'storybook/manager-api'
4+
5+
import { Badge } from '../../src/badge/badge'
6+
import { IconButton } from '../../src/button/button'
7+
import { Inline } from '../../src/inline'
8+
9+
import { FIGMA_PARAMETER } from './constants'
10+
import { resolveFigmaLinks } from './figma'
11+
import { FigmaIcon } from './figma-icon'
12+
13+
export const FigmaTool = React.memo(function FigmaTool() {
14+
const figmaParameter = useParameter(FIGMA_PARAMETER, undefined)
15+
const links = React.useMemo(() => resolveFigmaLinks(figmaParameter), [figmaParameter])
16+
17+
if (figmaParameter === false) {
18+
return null
19+
}
20+
21+
return (
22+
<Inline space="xsmall">
23+
{links.length === 0 ? (
24+
<Badge tone="info" label="No Figma link" />
25+
) : (
26+
links.map((link) => (
27+
<IconButton
28+
key={link.url}
29+
variant="quaternary"
30+
icon={<FigmaIcon />}
31+
aria-label={`View in Figma: ${link.path}`}
32+
tooltip={link.path}
33+
render={<a href={link.url} target="_blank" rel="noopener noreferrer" />}
34+
/>
35+
))
36+
)}
37+
</Inline>
38+
)
39+
})

.storybook/figma/figma.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { resolveFigmaLinks } from './figma'
2+
3+
describe('resolveFigmaLinks', () => {
4+
it('resolves an object with path and url', () => {
5+
expect(
6+
resolveFigmaLinks({
7+
path: 'Web › Buttons › Button',
8+
url: 'https://figma.com/design/abc?node-id=1-2',
9+
}),
10+
).toEqual([
11+
{
12+
path: 'Web › Buttons › Button',
13+
url: 'https://figma.com/design/abc?node-id=1-2',
14+
},
15+
])
16+
})
17+
18+
it('falls back to the url as path when path is missing', () => {
19+
expect(resolveFigmaLinks({ url: 'https://figma.com/design/abc' })).toEqual([
20+
{ path: 'https://figma.com/design/abc', url: 'https://figma.com/design/abc' },
21+
])
22+
})
23+
24+
it('resolves an array, dropping malformed entries', () => {
25+
expect(
26+
resolveFigmaLinks([
27+
{ path: 'B', url: 'https://figma.com/b' },
28+
{ path: 'C' },
29+
{ url: 42 },
30+
'',
31+
null,
32+
]),
33+
).toEqual([{ path: 'B', url: 'https://figma.com/b' }])
34+
})
35+
36+
it('treats malformed and empty params as no links', () => {
37+
expect(resolveFigmaLinks(undefined)).toEqual([])
38+
expect(resolveFigmaLinks(null)).toEqual([])
39+
expect(resolveFigmaLinks(false)).toEqual([])
40+
expect(resolveFigmaLinks('https://figma.com/x')).toEqual([])
41+
expect(resolveFigmaLinks('')).toEqual([])
42+
expect(resolveFigmaLinks({})).toEqual([])
43+
expect(resolveFigmaLinks({ path: 'no url' })).toEqual([])
44+
expect(resolveFigmaLinks({ url: 42 })).toEqual([])
45+
expect(resolveFigmaLinks([])).toEqual([])
46+
})
47+
})

0 commit comments

Comments
 (0)