Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .plop/templates/component/component.stories.tsx.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const meta = {
component: {{pascalCase name}},
parameters: {
badges: ['accessible'],
// Link a Figma design to show it in the toolbar, or set `figma: false`
// to hide the tool on pages with no matching design (e.g. hook docs):
// figma: { path: 'Figma file › Page › Section › Component', url: 'https://www.figma.com/design/…?node-id=…' },
},
} satisfies Meta<typeof {{pascalCase name}}>

Expand Down
57 changes: 0 additions & 57 deletions .storybook/badges/BadgesTool.tsx

This file was deleted.

34 changes: 34 additions & 0 deletions .storybook/badges/badges-tool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as React from 'react'

import { useParameter } from 'storybook/manager-api'

import { Badge } from '../../src/badge/badge'
import { Inline } from '../../src/inline'

import { resolveBadges } from './badges'
import { BADGES_CONFIG_PARAMETER, BADGES_PARAMETER } from './constants'

const emptyBadges: unknown[] = []
const emptyBadgesConfig: Record<string, unknown> = {}

export const BadgesTool = React.memo(function BadgesTool() {
const badgesParameter = useParameter(BADGES_PARAMETER, emptyBadges)
const badgesConfigParameter = useParameter(BADGES_CONFIG_PARAMETER, emptyBadgesConfig)

const badges = React.useMemo(
() => resolveBadges(badgesParameter, badgesConfigParameter),
[badgesParameter, badgesConfigParameter],
)

if (badges.length === 0) {
return null
}

return (
<Inline space="xsmall">
{badges.map(({ id, title, tone }) => (
<Badge key={id} tone={tone} label={title} />
))}
</Inline>
)
})
30 changes: 14 additions & 16 deletions .storybook/badges/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,46 @@ describe('resolveBadges', () => {
resolveBadges(['accessible', 'deprecated'], {
accessible: {
title: 'Accessible',
styles: { color: 'green' },
tone: 'positive',
},
deprecated: {
title: 'Deprecated',
styles: { color: 'red' },
tone: 'attention',
},
}),
).toEqual([
{
id: 'accessible',
title: 'Accessible',
styles: { color: 'green' },
tone: 'positive',
},
{
id: 'deprecated',
title: 'Deprecated',
styles: { color: 'red' },
tone: 'attention',
},
])
})

it('filters unknown badges and invalid badge entries', () => {
expect(
resolveBadges(['accessible', 'missing', 42, 'untitled'], {
resolveBadges(['accessible', 'missing', 42, 'untitled', 'untoned'], {
accessible: {
title: 'Accessible',
tone: 'positive',
},
untitled: {
styles: { color: 'orange' },
tone: 'positive',
},
untoned: {
title: 'Untoned',
},
}),
).toEqual([
{
id: 'accessible',
title: 'Accessible',
styles: undefined,
tone: 'positive',
},
])
})
Expand All @@ -53,20 +57,14 @@ describe('resolveBadges', () => {
expect(resolveBadges(['accessible'], [])).toEqual([])
})

it('ignores non-object styles', () => {
it('rejects an unknown tone', () => {
expect(
resolveBadges(['accessible'], {
accessible: {
title: 'Accessible',
styles: 'color: green',
tone: 'rainbow',
},
}),
).toEqual([
{
id: 'accessible',
title: 'Accessible',
styles: undefined,
},
])
).toEqual([])
})
})
38 changes: 22 additions & 16 deletions .storybook/badges/badges.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import type { CSSProperties } from 'react'
import type { BadgeProps } from '../../src/badge/badge'

type UnknownRecord = Record<string, unknown>

type BadgeConfig = {
title?: unknown
styles?: unknown
}
type BadgeTone = BadgeProps['tone']

type ResolvedBadge = {
id: string
title: string
styles: CSSProperties | undefined
tone: BadgeTone
}

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

function isBadgeConfig(value: unknown): value is BadgeConfig {
return isRecord(value)
const VALID_TONES = Object.keys(TONE_SET) as BadgeTone[]

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function resolveStyles(styles: unknown): CSSProperties | undefined {
return isRecord(styles) ? (styles as CSSProperties) : undefined
function isBadgeTone(value: unknown): value is BadgeTone {
return typeof value === 'string' && (VALID_TONES as readonly string[]).includes(value)
}

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

const badgeConfig = badgesConfig[badge]

if (!isBadgeConfig(badgeConfig) || typeof badgeConfig.title !== 'string') {
if (!isRecord(badgeConfig) || typeof badgeConfig.title !== 'string') {
return []
}

if (!isBadgeTone(badgeConfig.tone)) {
return []
}

return [
{
id: badge,
title: badgeConfig.title,
styles: resolveStyles(badgeConfig.styles),
tone: badgeConfig.tone,
},
]
})
Expand Down
2 changes: 1 addition & 1 deletion .storybook/badges/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { BadgesTool } from './BadgesTool'
export { BadgesTool } from './badges-tool'
export { ADDON_ID, TOOL_ID } from './constants'
3 changes: 3 additions & 0 deletions .storybook/figma/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ADDON_ID = 'reactist/figma'
export const TOOL_ID = `${ADDON_ID}/tool`
export const FIGMA_PARAMETER = 'figma'
14 changes: 14 additions & 0 deletions .storybook/figma/figma-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from 'react'

// https://www.figma.com/using-the-figma-brand/
export function FigmaIcon() {
return (
<svg width="11" height="16" viewBox="0 0 38 57" fill="none" aria-hidden="true">
<path d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0Z" fill="#1ABCFE" />
<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" />
<path d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19Z" fill="#FF7262" />
<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" />
<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" />
</svg>
)
}
39 changes: 39 additions & 0 deletions .storybook/figma/figma-tool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from 'react'

import { useParameter } from 'storybook/manager-api'

import { Badge } from '../../src/badge/badge'
import { IconButton } from '../../src/button/button'
import { Inline } from '../../src/inline'

import { FIGMA_PARAMETER } from './constants'
import { resolveFigmaLinks } from './figma'
import { FigmaIcon } from './figma-icon'

export const FigmaTool = React.memo(function FigmaTool() {
const figmaParameter = useParameter(FIGMA_PARAMETER, undefined)
const links = React.useMemo(() => resolveFigmaLinks(figmaParameter), [figmaParameter])

if (figmaParameter === false) {
return null
}

return (
<Inline space="xsmall">
{links.length === 0 ? (
<Badge tone="info" label="No Figma link" />
Comment thread
frankieyan marked this conversation as resolved.
) : (
links.map((link) => (
<IconButton
key={link.url}
variant="quaternary"
icon={<FigmaIcon />}
aria-label={`View in Figma: ${link.path}`}
tooltip={link.path}
render={<a href={link.url} target="_blank" rel="noopener noreferrer" />}
/>
))
)}
</Inline>
)
})
47 changes: 47 additions & 0 deletions .storybook/figma/figma.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { resolveFigmaLinks } from './figma'

describe('resolveFigmaLinks', () => {
it('resolves an object with path and url', () => {
expect(
resolveFigmaLinks({
path: 'Web › Buttons › Button',
url: 'https://figma.com/design/abc?node-id=1-2',
}),
).toEqual([
{
path: 'Web › Buttons › Button',
url: 'https://figma.com/design/abc?node-id=1-2',
},
])
})

it('falls back to the url as path when path is missing', () => {
expect(resolveFigmaLinks({ url: 'https://figma.com/design/abc' })).toEqual([
{ path: 'https://figma.com/design/abc', url: 'https://figma.com/design/abc' },
])
})

it('resolves an array, dropping malformed entries', () => {
expect(
resolveFigmaLinks([
{ path: 'B', url: 'https://figma.com/b' },
{ path: 'C' },
{ url: 42 },
'',
null,
]),
).toEqual([{ path: 'B', url: 'https://figma.com/b' }])
})

it('treats malformed and empty params as no links', () => {
expect(resolveFigmaLinks(undefined)).toEqual([])
expect(resolveFigmaLinks(null)).toEqual([])
expect(resolveFigmaLinks(false)).toEqual([])
expect(resolveFigmaLinks('https://figma.com/x')).toEqual([])
expect(resolveFigmaLinks('')).toEqual([])
expect(resolveFigmaLinks({})).toEqual([])
expect(resolveFigmaLinks({ path: 'no url' })).toEqual([])
expect(resolveFigmaLinks({ url: 42 })).toEqual([])
expect(resolveFigmaLinks([])).toEqual([])
})
})
29 changes: 29 additions & 0 deletions .storybook/figma/figma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type ResolvedFigmaLink = {
path: string
url: string
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function resolveEntry(entry: unknown): ResolvedFigmaLink[] {
if (isRecord(entry) && typeof entry.url === 'string' && entry.url.length > 0) {
const path =
typeof entry.path === 'string' && entry.path.length > 0 ? entry.path : entry.url
return [{ path, url: entry.url }]
}

return []
}

function resolveFigmaLinks(param: unknown): ResolvedFigmaLink[] {
if (Array.isArray(param)) {
return param.flatMap(resolveEntry)
}

return resolveEntry(param)
}

export { resolveFigmaLinks }
export type { ResolvedFigmaLink }
2 changes: 2 additions & 0 deletions .storybook/figma/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { FigmaTool } from './figma-tool'
export { ADDON_ID, TOOL_ID } from './constants'
Loading
Loading