├── .husky ├── pre-commit └── commit-msg ├── .github ├── FUNDING.yml ├── dco.yml ├── dependabot.yml └── workflows │ ├── chromatic.yml │ ├── automerge-dependabot.yml │ ├── release.yml │ └── continuous-integration.yml ├── manager.js ├── preview.js ├── src ├── __tests__ │ ├── setup.ts │ └── useBadgesToDisplay.spec.ts ├── types │ ├── ArrayElement.ts │ ├── TagPattern.ts │ ├── TagBadgeParameters.ts │ ├── Badge.ts │ └── DisplayOptions.ts ├── index.ts ├── stories │ ├── PageLayout.mdx │ ├── Action.stories.ts │ ├── header.css │ ├── Header.stories.ts │ ├── Frog.stories.ts │ ├── button.css │ ├── Button.mdx │ ├── Page.stories.ts │ ├── Issue53.stories.tsx │ ├── Issue86.stories.ts │ ├── Button.tsx │ ├── Button.stories.ts │ ├── assets │ │ ├── direction.svg │ │ ├── flow.svg │ │ ├── code-brackets.svg │ │ ├── comments.svg │ │ ├── repo.svg │ │ ├── plugin.svg │ │ ├── stackalt.svg │ │ └── colors.svg │ ├── frog.css │ ├── page.css │ ├── Frog.tsx │ ├── Header.tsx │ ├── Page.tsx │ └── Introduction.mdx ├── constants.ts ├── examples │ ├── __fixtures__ │ │ └── HashEntry.ts │ ├── BadgeDefaults.stories.tsx │ ├── Badge.stories.ts │ ├── sampleWorkflows.ts │ └── BadgeWorkflows.stories.tsx ├── components │ ├── CustomBadge.tsx │ ├── Tool.tsx │ ├── MDXBadges.tsx │ ├── Sidebar.tsx │ ├── __tests__ │ │ └── Badge.spec.tsx │ └── Badge.tsx ├── preview.ts ├── manager-helpers.ts ├── renderLabel.tsx ├── manager.tsx ├── defaultConfig.ts ├── useBadgesToDisplay.ts └── utils │ ├── tag.ts │ ├── display.ts │ └── __tests__ │ ├── display.spec.ts │ └── tag.spec.ts ├── .storybook ├── preview-head.html ├── vitest.setup.ts ├── local-preset.ts ├── preview.ts ├── manager.tsx ├── main.ts ├── tagBadges.tsx └── ThemeProvider.tsx ├── codecov.yml ├── static ├── icon.png ├── badge-new.png ├── badge-beta.png ├── badge-danger.png ├── badge-static.png ├── addon-example.avif ├── addon-example.png ├── badge-outdated.png ├── badge-version.png ├── badge-code-only.png ├── badge-deprecated.png ├── entry-group.svg ├── entry-story.svg ├── entry-component.svg └── entry-docs.svg ├── .prettierrc.js ├── .lintstagedrc.js ├── chromatic.config.json ├── .gitignore ├── .prettierignore ├── commitlint.config.js ├── vite.config.ts ├── tsconfig.json ├── release.config.js ├── vitest.workspace.ts ├── LICENSE ├── eslint.config.js ├── tsup.config.ts ├── scripts └── prepublish-checks.js ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Sidnioulz 2 | -------------------------------------------------------------------------------- /manager.js: -------------------------------------------------------------------------------- 1 | export * from './dist/manager' 2 | -------------------------------------------------------------------------------- /preview.js: -------------------------------------------------------------------------------- 1 | export * from './dist/preview' 2 | -------------------------------------------------------------------------------- /.github/dco.yml: -------------------------------------------------------------------------------- 1 | require: 2 | members: false 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | node_modules/.bin/commitlint --edit ${1} 2 | -------------------------------------------------------------------------------- /src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest' 2 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | component_management: 2 | default_rules: 3 | paths: 4 | - '.' 5 | -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/icon.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | semi: false, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | } 6 | -------------------------------------------------------------------------------- /static/badge-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-new.png -------------------------------------------------------------------------------- /static/badge-beta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-beta.png -------------------------------------------------------------------------------- /static/badge-danger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-danger.png -------------------------------------------------------------------------------- /static/badge-static.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-static.png -------------------------------------------------------------------------------- /static/addon-example.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/addon-example.avif -------------------------------------------------------------------------------- /static/addon-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/addon-example.png -------------------------------------------------------------------------------- /static/badge-outdated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-outdated.png -------------------------------------------------------------------------------- /static/badge-version.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-version.png -------------------------------------------------------------------------------- /static/badge-code-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-code-only.png -------------------------------------------------------------------------------- /static/badge-deprecated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/HEAD/static/badge-deprecated.png -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '*.{js,jsx,ts,tsx}': ['pnpm lint:fix', 'pnpm format:fix'], 3 | '*.{json,md,scss,css,html,yml}': ['pnpm format:fix'], 4 | } 5 | -------------------------------------------------------------------------------- /src/types/ArrayElement.ts: -------------------------------------------------------------------------------- 1 | export type ArrayElement = 2 | ArrayType extends readonly (infer ElementType)[] ? ElementType : never 3 | -------------------------------------------------------------------------------- /chromatic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildScriptName": "build:storybook", 3 | "onlyChanged": true, 4 | "projectId": "Project:6897cdc9b07a03973ba647b9", 5 | "zip": true 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { definePreviewAddon } from 'storybook/internal/csf' 2 | import addonAnnotations from './preview' 3 | 4 | export default () => definePreviewAddon(addonAnnotations) 5 | -------------------------------------------------------------------------------- /src/stories/PageLayout.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks' 2 | 3 | 4 | 5 | # Layout 6 | 7 | This is a page about page layouts. 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | \*.tgz 3 | node_modules/ 4 | storybook-static/ 5 | build-storybook.log 6 | .DS_Store 7 | .env 8 | .idea 9 | .vscode 10 | .eslintcache 11 | coverage 12 | chromatic.log 13 | chromatic-build-*.xml 14 | chromatic-diagnostics.json 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .eslintignore 3 | .prettierignore 4 | 5 | pnpm-lock.yaml 6 | dist/ 7 | \*.tgz 8 | coverage/ 9 | node_modules/ 10 | storybook-static/ 11 | build-storybook.log 12 | .DS_Store 13 | .env 14 | .idea 15 | .vscode 16 | 17 | README.md 18 | *.mdx -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ADDON_ID = 'storybook/addon-tag-badges' 2 | export const TOOL_ID = `${ADDON_ID}/tool` 3 | export const KEY = `tagBadges` 4 | export const EVENTS = { 5 | REQUEST_CONFIG: `${ADDON_ID}/requestConfig`, 6 | CONFIG_READY: `${ADDON_ID}/configReady`, 7 | } 8 | -------------------------------------------------------------------------------- /src/examples/__fixtures__/HashEntry.ts: -------------------------------------------------------------------------------- 1 | import { API_ComponentEntry } from 'storybook/internal/types' 2 | 3 | export const mockEntry: API_ComponentEntry = { 4 | type: 'component', 5 | id: 'foo', 6 | name: '', 7 | children: [], 8 | tags: [], 9 | depth: 0, 10 | } 11 | -------------------------------------------------------------------------------- /src/components/CustomBadge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Badge } from './Badge' 4 | import type { Badge as BadgeConfigType } from '../types/Badge' 5 | 6 | export const CustomBadge: React.FC = (props) => { 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /.storybook/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll } from 'vitest' 2 | import { setProjectAnnotations } from '@storybook/react-vite' 3 | 4 | import * as previewAnnotations from './preview' 5 | 6 | const annotations = setProjectAnnotations([previewAnnotations]) 7 | 8 | beforeAll(annotations.beforeAll) 9 | -------------------------------------------------------------------------------- /static/entry-group.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [2, 'always', ['sentence-case']], 5 | 'type-enum': [ 6 | 2, 7 | 'always', 8 | ['feat', 'fix', 'docs', 'refactor', 'style', 'test', 'revert', 'chore'], 9 | ], 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /static/entry-story.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/local-preset.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | 3 | export function previewAnnotations(entry = []) { 4 | return [...entry, fileURLToPath(import.meta.resolve('../dist/preview.js'))] 5 | } 6 | 7 | export function managerEntries(entry = []) { 8 | return [...entry, fileURLToPath(import.meta.resolve('../dist/manager.js'))] 9 | } 10 | -------------------------------------------------------------------------------- /static/entry-component.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/types/TagPattern.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a pattern for matching tags. 3 | * It can be a string, a RegExp, or an object where only the prefix 4 | * or suffix of the tag is matched. 5 | */ 6 | export type TagPattern = 7 | | string 8 | | RegExp 9 | | { 10 | prefix?: string | RegExp 11 | suffix?: string | RegExp 12 | } 13 | 14 | /** 15 | * Collection of tag patterns. 16 | */ 17 | export type TagPatterns = TagPattern | TagPattern[] 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directories: 5 | - '/' 6 | schedule: 7 | interval: 'weekly' 8 | versioning-strategy: increase 9 | assignees: 10 | - 'sidnioulz' 11 | reviewers: 12 | - 'sidnioulz' 13 | groups: 14 | all: 15 | patterns: 16 | - '*' 17 | ignore: 18 | - dependency-name: '@storybook/*' 19 | - dependency-name: 'storybook' 20 | -------------------------------------------------------------------------------- /static/entry-docs.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | coverage: { 9 | include: [ 10 | 'src/**/*.{mjs,mjsx,js,jsx,ts,tsx}', 11 | '!src/stories/**', 12 | '!src/examples/**', 13 | '!src/**/*.stories.{ts,tsx}', 14 | ], 15 | provider: 'istanbul', 16 | }, 17 | globals: true, 18 | environment: 'jsdom', 19 | setupFiles: ['./src/__tests__/setup.ts'], 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/stories/Action.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | import { fn } from 'storybook/test' 3 | 4 | import { Button } from './Button' 5 | 6 | const meta: Meta = { 7 | title: 'Example/Action', 8 | component: Button, 9 | argTypes: { 10 | backgroundColor: { control: 'color' }, 11 | onClick: fn(), 12 | }, 13 | tags: ['code-only'], 14 | } 15 | 16 | export default meta 17 | type Story = StoryObj 18 | 19 | export const Action: Story = { 20 | args: { 21 | primary: true, 22 | label: 'Button', 23 | size: 'large', 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "incremental": false, 8 | "isolatedModules": true, 9 | "jsx": "react", 10 | "lib": ["esnext", "dom", "dom.iterable"], 11 | "moduleResolution": "bundler", 12 | "module": "esnext", 13 | "noImplicitAny": true, 14 | "rootDir": "./src", 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "target": "ES2022", 18 | "types": ["vitest/globals"] 19 | }, 20 | "include": ["src/**/*", "tsup.config.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react-vite' 2 | import { themes } from 'storybook/theming' 3 | 4 | import ThemeProvider from './ThemeProvider' 5 | 6 | export const decorators = [ThemeProvider] 7 | 8 | const preview: Preview = { 9 | parameters: { 10 | controls: { 11 | matchers: { 12 | color: /(background|color)$/i, 13 | date: /Date$/, 14 | }, 15 | }, 16 | docs: { 17 | codePanel: true, 18 | theme: themes.dark, 19 | toc: true, 20 | }, 21 | }, 22 | initialGlobals: { 23 | background: { value: 'dark' }, 24 | }, 25 | } 26 | 27 | export default preview 28 | -------------------------------------------------------------------------------- /src/preview.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectAnnotations, Renderer } from 'storybook/internal/types' 2 | import { addons } from 'storybook/internal/preview-api' 3 | import { EVENTS } from './constants' 4 | import { TagBadgeParameters } from './types/TagBadgeParameters' 5 | 6 | declare global { 7 | interface Window { 8 | tagBadges: TagBadgeParameters 9 | } 10 | } 11 | 12 | const preview: ProjectAnnotations = {} 13 | 14 | const channel = addons.getChannel() 15 | window.tagBadges = window.parent.tagBadges 16 | 17 | channel.emit(EVENTS.REQUEST_CONFIG) 18 | channel.on(EVENTS.CONFIG_READY, () => { 19 | window.tagBadges = window.parent.tagBadges 20 | }) 21 | 22 | export default preview 23 | -------------------------------------------------------------------------------- /.storybook/manager.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { addons } from 'storybook/manager-api' 3 | import { API_HashEntry } from 'storybook/internal/types' 4 | 5 | import tagBadges from './tagBadges' 6 | import { renderLabel, Sidebar } from '../src/manager-helpers' 7 | 8 | addons.setConfig({ 9 | sidebar: { 10 | renderLabel: (item: API_HashEntry) => { 11 | if (item.name.startsWith('Addon')) { 12 | return 🌟 Addon 13 | } 14 | 15 | return renderLabel(item) 16 | }, 17 | filters: { 18 | patterns: (item) => { 19 | return !item.tags?.includes('chromatic-only') 20 | }, 21 | }, 22 | }, 23 | tagBadges, 24 | }) 25 | -------------------------------------------------------------------------------- /src/stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 700; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | 28 | .welcome { 29 | color: #333; 30 | font-size: 14px; 31 | margin-right: 10px; 32 | } 33 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite' 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | 6 | addons: [ 7 | import.meta.resolve('./local-preset.ts'), 8 | '@storybook/addon-docs', 9 | '@storybook/addon-vitest', 10 | '@chromatic-com/storybook', 11 | ], 12 | framework: { 13 | name: '@storybook/react-vite', 14 | options: {}, 15 | }, 16 | managerHead: async (head) => { 17 | return ` 18 | ${head} 19 | 20 | ` 21 | }, 22 | } 23 | 24 | export default config 25 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | branches: ['main'], 3 | plugins: [ 4 | [ 5 | '@semantic-release/commit-analyzer', 6 | { 7 | releaseRules: [ 8 | { breaking: true, release: 'major' }, 9 | { type: 'feat', release: 'minor' }, 10 | { type: 'docs', release: 'patch' }, 11 | { type: 'refactor', release: 'patch' }, 12 | { type: 'fix', release: 'patch' }, 13 | ], 14 | parserOpts: { 15 | noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES'], 16 | }, 17 | }, 18 | ], 19 | '@semantic-release/release-notes-generator', 20 | '@semantic-release/github', 21 | '@semantic-release/npm', 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /src/stories/Header.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | import { fn } from 'storybook/test' 3 | 4 | import { Header } from './Header' 5 | 6 | const meta: Meta = { 7 | title: 'Example/Header', 8 | component: Header, 9 | argTypes: { 10 | onLogin: fn(), 11 | onLogout: fn(), 12 | onCreateAccount: fn(), 13 | }, 14 | parameters: { 15 | layout: 'fullscreen', 16 | }, 17 | tags: ['new'], 18 | } 19 | 20 | export default meta 21 | type Story = StoryObj 22 | 23 | export const LoggedIn: Story = { 24 | args: { 25 | user: { 26 | name: 'Jane Doe', 27 | }, 28 | }, 29 | } 30 | 31 | export const LoggedOut: Story = {} 32 | -------------------------------------------------------------------------------- /src/manager-helpers.ts: -------------------------------------------------------------------------------- 1 | export * from './utils/tag' 2 | export { defaultConfig } from './defaultConfig' 3 | export { DISPLAY_DEFAULTS as defaultDisplay } from './utils/display' 4 | export { renderLabel } from './renderLabel' 5 | export { Sidebar } from './components/Sidebar' 6 | export { CustomBadge } from './components/CustomBadge' 7 | export { MDXBadges } from './components/MDXBadges' 8 | 9 | export type { Badge, BadgeOrBadgeFn } from './types/Badge' 10 | export type { 11 | Display, 12 | MDXDisplayOptions, 13 | SidebarDisplayOptions, 14 | ToolbarDisplayOptions, 15 | } from './types/DisplayOptions' 16 | export type { TagBadgeParameters } from './types/TagBadgeParameters' 17 | export type { TagPattern, TagPatterns } from './types/TagPattern' 18 | -------------------------------------------------------------------------------- /src/stories/Frog.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | 3 | import { Frog } from './Frog' 4 | 5 | const meta: Meta = { 6 | title: 'Example/Frog', 7 | component: Frog, 8 | argTypes: {}, 9 | parameters: { 10 | layout: 'fullscreen', 11 | }, 12 | tags: ['frog'], 13 | } 14 | 15 | export default meta 16 | type Story = StoryObj 17 | 18 | export const Big: Story = { 19 | args: { 20 | size: 'big', 21 | }, 22 | tags: ['big-if-true', 'frog'], 23 | } 24 | 25 | export const Small: Story = { 26 | args: { 27 | size: 'small', 28 | }, 29 | } 30 | 31 | export const AntEater: Story = { 32 | args: { 33 | preferredInsects: ['ants', 'termites'], 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /src/stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from 'vitest/config' 2 | import { storybookTest } from '@storybook/addon-vitest/vitest-plugin' 3 | 4 | export default defineWorkspace([ 5 | { 6 | extends: './vite.config.ts', 7 | test: { 8 | include: ['**/__tests__/**/*.spec.{js,ts,jsx,tsx}'], 9 | }, 10 | }, 11 | { 12 | extends: './vite.config.ts', 13 | plugins: [storybookTest({ storybookScript: 'pnpm storybook --ci' })], 14 | test: { 15 | name: 'storybook', 16 | browser: { 17 | enabled: true, 18 | headless: true, 19 | provider: 'playwright', 20 | instances: [ 21 | { 22 | browser: 'chromium', 23 | }, 24 | ], 25 | }, 26 | setupFiles: ['./.storybook/vitest.setup.ts'], 27 | }, 28 | }, 29 | ]) 30 | -------------------------------------------------------------------------------- /src/stories/Button.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Heading, Meta, Title } from '@storybook/addon-docs/blocks' 2 | 3 | import ButtonStoriesMeta, * as BS from './Button.stories' 4 | import { CustomBadge } from '../components/CustomBadge' 5 | import { MDXBadges } from '../components/MDXBadges' 6 | 7 | 8 | 9 | <MDXBadges of={ButtonStoriesMeta} /> 10 | 11 | This is a custom MDX page about buttons. 12 | 13 | <section> 14 | <h2>Custom Section</h2> 15 | This custom section contains custom badges. <CustomBadge text="Custom" style="turquoise" /> The custom badges contain arbitrary content. <CustomBadge text="Indeed" style="green" /> 16 | 17 | </section> 18 | 19 | {BS.__namedExportsOrder.map((storyName) => <section key={storyName}> 20 | <Heading>{storyName} <MDXBadges of={BS[storyName]} /></Heading> 21 | <Canvas of={BS[storyName]} /> 22 | </section>)} 23 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | name: 'Chromatic' 2 | 3 | on: push 4 | 5 | jobs: 6 | chromatic: 7 | name: Run Chromatic 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [22] 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'pnpm' 24 | - name: Install dependencies 25 | run: pnpm install 26 | - name: Build addon 27 | run: pnpm build 28 | - name: Run Chromatic 29 | uses: chromaui/action@latest 30 | with: 31 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 32 | -------------------------------------------------------------------------------- /src/stories/Page.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | // import { within, userEvent } from 'storybook/test' 3 | 4 | import { Page } from './Page' 5 | 6 | const meta: Meta<typeof Page> = { 7 | title: 'Example/Page/Component', 8 | component: Page, 9 | parameters: { 10 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | tags: ['frog'], 14 | } 15 | 16 | export default meta 17 | type Story = StoryObj<typeof Page> 18 | 19 | export const LoggedOut: Story = {} 20 | 21 | // More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing 22 | export const LoggedIn: Story = { 23 | // play: async ({ canvasElement }) => { 24 | // const canvas = within(canvasElement) 25 | // const loginButton = await canvas.getByRole('button', { 26 | // name: /Log in/i, 27 | // }) 28 | // await userEvent.click(loginButton) 29 | // }, 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/Issue53.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { Meta, StoryObj } from '@storybook/react-vite' 3 | 4 | import { Badge } from '../components/Badge' 5 | 6 | const meta: Meta<typeof Badge> = { 7 | title: 'NRT/issue-53/Badge', 8 | component: Badge, 9 | parameters: { 10 | layout: 'centered', 11 | }, 12 | } 13 | export default meta 14 | type Story = StoryObj<typeof Badge> 15 | 16 | export const TightSpace: Story = { 17 | args: { 18 | context: 'sidebar', 19 | text: 'Multi-word Badge', 20 | }, 21 | tags: ['very-tight-space'], 22 | decorators: [ 23 | (story) => ( 24 | <div style={{ width: '60px', background: 'orange', overflow: 'hidden' }}> 25 | {story()} 26 | </div> 27 | ), 28 | ], 29 | } 30 | 31 | export const VeryTightSpaceWithNoSpaceForABadge: Story = { 32 | args: { 33 | context: 'sidebar', 34 | text: 'Multi-word Badge', 35 | }, 36 | tags: ['very-tight-space'], 37 | decorators: [ 38 | (story) => ( 39 | <div style={{ width: '20px', background: 'red' }}>{story()}</div> 40 | ), 41 | ], 42 | } 43 | -------------------------------------------------------------------------------- /src/types/TagBadgeParameters.ts: -------------------------------------------------------------------------------- 1 | import { BadgeOrBadgeFn } from './Badge' 2 | import { Display } from './DisplayOptions' 3 | import { TagPatterns } from './TagPattern' 4 | 5 | /** 6 | * Configuration that maps a tag or tag matching pattern to the config 7 | * that can render a badge, and to display conditions. 8 | */ 9 | export interface TagBadgeParameter { 10 | /** 11 | * Controls where badges are rendered and for what type of content. 12 | */ 13 | display?: Display 14 | /** 15 | * Defines the string, RegExp or tag structures to match against 16 | * for this badge config to be used. 17 | */ 18 | tags: TagPatterns 19 | /** 20 | * Defines the appearance and content of the badge to display. Can be 21 | * a function that dynamically generates a badge based on the matched 22 | * content and tag. 23 | */ 24 | badge: BadgeOrBadgeFn 25 | } 26 | 27 | /** 28 | * Parameters of this addon. Each item maps a tag or tag matching pattern 29 | * to the config that can render a badge, and to display conditions. 30 | */ 31 | export type TagBadgeParameters = TagBadgeParameter[] 32 | -------------------------------------------------------------------------------- /src/stories/Issue86.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | 3 | import { Button } from './Button' 4 | 5 | const meta: Meta<typeof Button> = { 6 | title: 'NRT/issue-86/Button', 7 | component: Button, 8 | tags: ['dd-privacy:allow'], 9 | } 10 | 11 | export default meta 12 | type Story = StoryObj<typeof Button> 13 | 14 | export const Primary: Story = { 15 | args: { 16 | primary: true, 17 | label: 'Button', 18 | }, 19 | } 20 | 21 | export const Secondary: Story = { 22 | args: { 23 | label: 'Button', 24 | }, 25 | } 26 | 27 | export const Large: Story = { 28 | args: { 29 | size: 'large', 30 | label: 'Button', 31 | }, 32 | } 33 | 34 | export const Medium: Story = { 35 | args: { 36 | size: 'medium', 37 | label: 'Button', 38 | }, 39 | } 40 | 41 | export const MediumSizeButton: Story = { 42 | args: { 43 | size: 'medium', 44 | label: 'Button', 45 | }, 46 | } 47 | MediumSizeButton.tags = ['deprecated'] 48 | 49 | export const Small: Story = { 50 | args: { 51 | size: 'small', 52 | label: 'Button', 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Storybook contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import prettierRecommended from 'eslint-plugin-prettier/recommended' 3 | import reactPlugin from 'eslint-plugin-react' 4 | import tseslint from 'typescript-eslint' 5 | import globals from 'globals' 6 | 7 | export default [ 8 | { 9 | ignores: [ 10 | '.github/dependabot.yml', 11 | '!.*', 12 | 'dist/', 13 | 'scripts/', 14 | '*.tgz', 15 | 'coverage/', 16 | 'node_modules/', 17 | 'storybook-static/', 18 | 'build-storybook.log', 19 | '.DS_Store', 20 | '.env', 21 | '.idea', 22 | '.vscode', 23 | ], 24 | }, 25 | js.configs.recommended, 26 | reactPlugin.configs.flat.recommended, 27 | { 28 | settings: { 29 | react: { 30 | version: 'detect', 31 | }, 32 | }, 33 | }, 34 | ...tseslint.configs.recommended, 35 | { 36 | files: ['preset.js', '.storybook/local-preset.js'], 37 | languageOptions: { 38 | sourceType: 'commonjs', 39 | globals: globals.node, 40 | }, 41 | rules: { 42 | '@typescript-eslint/no-require-imports': 'off', 43 | }, 44 | }, 45 | prettierRecommended, 46 | ] 47 | -------------------------------------------------------------------------------- /src/renderLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { API_HashEntry, type StatusByTypeId } from 'storybook/internal/types' 3 | import { experimental_useStatusStore } from 'storybook/manager-api' 4 | 5 | import { Sidebar } from './components/Sidebar' 6 | 7 | function hasStatusWithUI(itemStatuses: StatusByTypeId): boolean { 8 | if (!itemStatuses) { 9 | return false 10 | } 11 | 12 | if (itemStatuses['storybook/component-test']) { 13 | return true 14 | } 15 | 16 | // Add future statuses with a UI element here. 17 | 18 | return false 19 | } 20 | 21 | function RenderLabelContent({ item }: { item: API_HashEntry }) { 22 | const itemStatuses = experimental_useStatusStore((all) => all[item.id]) 23 | 24 | return ( 25 | <Sidebar item={item} hasStatusWithUI={hasStatusWithUI(itemStatuses)}> 26 | {item.name} 27 | </Sidebar> 28 | ) 29 | } 30 | 31 | export function renderLabel(item: API_HashEntry) { 32 | if ( 33 | item.type !== 'story' && 34 | item.type !== 'group' && 35 | item.type !== 'docs' && 36 | item.type !== 'component' 37 | ) { 38 | return 39 | } 40 | 41 | return <RenderLabelContent item={item} /> 42 | } 43 | -------------------------------------------------------------------------------- /src/stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './button.css' 3 | 4 | interface ButtonProps { 5 | /** 6 | * Is this the principal call to action on the page? 7 | */ 8 | primary?: boolean 9 | /** 10 | * What background color to use 11 | */ 12 | backgroundColor?: string 13 | /** 14 | * How large should the button be? 15 | */ 16 | size?: 'small' | 'medium' | 'large' 17 | /** 18 | * Button contents 19 | */ 20 | label: string 21 | /** 22 | * Optional click handler 23 | */ 24 | onClick?: () => void 25 | } 26 | 27 | /** 28 | * Primary UI component for user interaction 29 | */ 30 | export const Button = ({ 31 | primary = false, 32 | size = 'medium', 33 | backgroundColor, 34 | label, 35 | ...props 36 | }: ButtonProps) => { 37 | const mode = primary 38 | ? 'storybook-button--primary' 39 | : 'storybook-button--secondary' 40 | 41 | return ( 42 | <button 43 | type="button" 44 | className={['storybook-button', `storybook-button--${size}`, mode].join( 45 | ' ', 46 | )} 47 | style={{ backgroundColor }} 48 | {...props} 49 | > 50 | {label} 51 | </button> 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/stories/Button.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | import { fn } from 'storybook/test' 3 | 4 | import { Button } from './Button' 5 | 6 | const meta: Meta<typeof Button> = { 7 | title: 'Example/Button', 8 | component: Button, 9 | argTypes: { 10 | backgroundColor: { control: 'color' }, 11 | onClick: fn(), 12 | }, 13 | tags: ['frog', 'version:1.0.0'], 14 | } 15 | 16 | export default meta 17 | type Story = StoryObj<typeof Button> 18 | 19 | export const Primary: Story = { 20 | args: { 21 | primary: true, 22 | label: 'Button', 23 | }, 24 | } 25 | 26 | export const Secondary: Story = { 27 | args: { 28 | label: 'Button', 29 | }, 30 | } 31 | 32 | export const Large: Story = { 33 | args: { 34 | size: 'large', 35 | label: 'Button', 36 | }, 37 | } 38 | 39 | export const Medium: Story = { 40 | args: { 41 | size: 'medium', 42 | label: 'Button', 43 | }, 44 | } 45 | 46 | export const MediumSizeButton: Story = { 47 | args: { 48 | size: 'medium', 49 | label: 'Button', 50 | }, 51 | } 52 | MediumSizeButton.tags = ['deprecated'] 53 | 54 | export const Small: Story = { 55 | args: { 56 | size: 'small', 57 | label: 'Button', 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /src/stories/assets/direction.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/direction -------------------------------------------------------------------------------- /.storybook/tagBadges.tsx: -------------------------------------------------------------------------------- 1 | import { defaultConfig, type TagBadgeParameters } from '../src/manager-helpers' 2 | 3 | export default [ 4 | ...defaultConfig, 5 | { 6 | tags: 'frog', 7 | badge: { 8 | text: 'Frog 🐸', 9 | style: { 10 | backgroundColor: '#001c13', 11 | color: '#e0eb0b', 12 | }, 13 | tooltip: 'This component can catch flies!', 14 | }, 15 | display: { 16 | sidebar: [ 17 | { skipInherited: true, type: 'component' }, 18 | { skipInherited: true, type: 'group' }, 19 | ], 20 | toolbar: true, 21 | }, 22 | }, 23 | { 24 | tags: 'big-if-true', 25 | badge: { 26 | text: 'Big Frog!', 27 | style: { 28 | backgroundColor: '#33001c', 29 | color: '#0be0b5', 30 | }, 31 | tooltip: 'This is one big frog!', 32 | }, 33 | display: { 34 | sidebar: true, 35 | toolbar: true, 36 | }, 37 | }, 38 | { 39 | tags: 'very-tight-space', 40 | badge: { 41 | text: 'Multi-word Badge, badgeofmanyletters', 42 | style: { 43 | backgroundColor: '#1c0033', 44 | color: '#e00b53', 45 | }, 46 | }, 47 | display: { 48 | sidebar: true, 49 | toolbar: false, 50 | }, 51 | }, 52 | ] satisfies TagBadgeParameters 53 | -------------------------------------------------------------------------------- /src/stories/assets/flow.svg: -------------------------------------------------------------------------------- 1 | illustration/flow -------------------------------------------------------------------------------- /src/stories/frog.css: -------------------------------------------------------------------------------- 1 | .frog { 2 | display: flex; 3 | align-items: start; 4 | } 5 | 6 | .frog--size_small .frog__frog { 7 | font-size: 1.5rem; 8 | } 9 | .frog--size_big .frog__frog { 10 | font-size: 6rem; 11 | } 12 | 13 | .frog:has(.frog__diet:hover) .frog__frog { 14 | animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; 15 | transform: translate3d(0, 0, 0); 16 | } 17 | 18 | .frog__diet { 19 | anchor-name: var(--anchor-id); 20 | cursor: pointer; 21 | border: none; 22 | background: none; 23 | font-size: 1.5rem; 24 | margin: 0; 25 | } 26 | 27 | .frog--size_big .frog__diet { 28 | margin-top: 1rem; 29 | } 30 | 31 | .frog__diet-menu { 32 | background-color: white; 33 | border: 1px solid #ccc; 34 | padding: 0.5em; 35 | font-size: 1em; 36 | border-radius: 0.5em; 37 | 38 | margin: unset; 39 | position-anchor: var(--anchor-id); 40 | position-area: bottom span-right; 41 | } 42 | 43 | .frog__diet-menu ul { 44 | margin: 0; 45 | padding-inline-start: 1.5em; 46 | } 47 | 48 | @keyframes shake { 49 | 10%, 50 | 90% { 51 | transform: translate3d(-1px, 0, 0); 52 | } 53 | 54 | 20%, 55 | 80% { 56 | transform: translate3d(2px, 0, 0); 57 | } 58 | 59 | 30%, 60 | 50%, 61 | 70% { 62 | transform: translate3d(-4px, 0, 0); 63 | } 64 | 65 | 40%, 66 | 60% { 67 | transform: translate3d(4px, 0, 0); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/stories/page.css: -------------------------------------------------------------------------------- 1 | section { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 14px; 4 | line-height: 24px; 5 | padding: 48px 20px; 6 | margin: 0 auto; 7 | max-width: 600px; 8 | color: #333; 9 | } 10 | 11 | section h2 { 12 | font-weight: 700; 13 | font-size: 32px; 14 | line-height: 1; 15 | margin: 0 0 4px; 16 | display: inline-block; 17 | vertical-align: top; 18 | } 19 | 20 | section p { 21 | margin: 1em 0; 22 | } 23 | 24 | section a { 25 | text-decoration: none; 26 | color: #1ea7fd; 27 | } 28 | 29 | section ul { 30 | padding-left: 30px; 31 | margin: 1em 0; 32 | } 33 | 34 | section li { 35 | margin-bottom: 8px; 36 | } 37 | 38 | section .tip { 39 | display: inline-block; 40 | border-radius: 1em; 41 | font-size: 11px; 42 | line-height: 12px; 43 | font-weight: 700; 44 | background: #e7fdd8; 45 | color: #66bf3c; 46 | padding: 4px 12px; 47 | margin-right: 10px; 48 | vertical-align: top; 49 | } 50 | 51 | section .tip-wrapper { 52 | font-size: 13px; 53 | line-height: 20px; 54 | margin-top: 40px; 55 | margin-bottom: 40px; 56 | } 57 | 58 | section .tip-wrapper svg { 59 | display: inline-block; 60 | height: 12px; 61 | width: 12px; 62 | margin-right: 4px; 63 | vertical-align: top; 64 | margin-top: 3px; 65 | } 66 | 67 | section .tip-wrapper svg path { 68 | fill: #1ea7fd; 69 | } 70 | -------------------------------------------------------------------------------- /src/stories/assets/code-brackets.svg: -------------------------------------------------------------------------------- 1 | illustration/code-brackets -------------------------------------------------------------------------------- /src/stories/Frog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './frog.css' 3 | 4 | interface FrogProps { 5 | /** 6 | * How big is your frog!? 7 | */ 8 | size?: 'small' | 'big' 9 | 10 | /** 11 | * What does your frog prefer to eat? 12 | */ 13 | preferredInsects?: string[] 14 | } 15 | 16 | /** 17 | * Primary UI component for user interaction 18 | */ 19 | export const Frog = ({ 20 | size = 'big', 21 | preferredInsects = ['flies', 'butterflies', 'aphids'], 22 | }: FrogProps) => { 23 | const id = React.useId() 24 | const anchorId = '--' + id.replace(/:/g, '') + '-insect-anchor' 25 | const popoverId = id + '-insect-popover' 26 | 27 | return ( 28 | 34 | 🐸 35 | {preferredInsects.length > 0 && ( 36 | <> 37 | 44 |
45 |
    46 | {preferredInsects.map((insect, index) => ( 47 |
  • {insect}
  • 48 | ))} 49 |
50 |
51 | 52 | )} 53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/stories/assets/comments.svg: -------------------------------------------------------------------------------- 1 | illustration/comments -------------------------------------------------------------------------------- /src/types/Badge.ts: -------------------------------------------------------------------------------- 1 | import type { TooltipMessage } from 'storybook/internal/components' 2 | import type { HashEntry } from 'storybook/manager-api' 3 | import type { CSSObject } from 'storybook/theming' 4 | 5 | import { getTagParts, getTagPrefix, getTagSuffix } from '../utils/tag' 6 | 7 | /** 8 | * Properties of a badge to be displayed. 9 | */ 10 | export interface Badge { 11 | /** The text content of the badge. */ 12 | text: string 13 | 14 | /** Either a style preset provided by the addon, or a custom emotion object. */ 15 | style?: 16 | | 'grey' 17 | | 'green' 18 | | 'turquoise' 19 | | 'blue' 20 | | 'purple' 21 | | 'pink' 22 | | 'red' 23 | | 'orange' 24 | | 'yellow' 25 | | CSSObject 26 | 27 | /** The tooltip text to display when hovering over the badge (optional). */ 28 | tooltip?: string | React.ComponentProps 29 | } 30 | 31 | /** 32 | * Either a Badge config or a function that generates it from a tag and HashEntry. 33 | * The function receives the current HashEntry and tag content, and a `getTagParts` 34 | * helper function to extract the prefix and suffix of the tag. It must return a 35 | * Badge config object. 36 | */ 37 | export type BadgeOrBadgeFn = 38 | | ((params: { 39 | context: 'mdx' | 'sidebar' | 'toolbar' 40 | entry: HashEntry | undefined 41 | getTagParts: typeof getTagParts 42 | getTagPrefix: typeof getTagPrefix 43 | getTagSuffix: typeof getTagSuffix 44 | tag: string 45 | }) => Badge) 46 | | Badge 47 | -------------------------------------------------------------------------------- /.github/workflows/automerge-dependabot.yml: -------------------------------------------------------------------------------- 1 | # Inspired by https://github.com/vercel/turborepo/blob/main/.github/workflows/examples-autoapprove-and-automerge.yml 2 | # Auto-approves and auto-merges Dependabot PRs. 3 | name: Dependabot automerge 4 | on: pull_request 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | dependabot-approve: 12 | runs-on: ubuntu-latest 13 | if: | 14 | always() && 15 | !contains(needs.*.result, 'failure') && 16 | !contains(needs.*.result, 'cancelled') && 17 | github.actor == 'dependabot[bot]' 18 | steps: 19 | - name: Dependabot metadata 20 | id: metadata 21 | uses: dependabot/fetch-metadata@v2 22 | with: 23 | github-token: '${{ secrets.GITHUB_TOKEN }}' 24 | - name: Approve a PR 25 | run: gh pr review --approve "$PR_URL" 26 | env: 27 | PR_URL: ${{github.event.pull_request.html_url}} 28 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | dependabot-merge: 30 | needs: [dependabot-approve] 31 | runs-on: ubuntu-latest 32 | if: github.actor == 'dependabot[bot]' 33 | steps: 34 | - name: Dependabot metadata 35 | id: metadata 36 | uses: dependabot/fetch-metadata@v2 37 | with: 38 | github-token: '${{ secrets.GITHUB_TOKEN }}' 39 | - name: Enable auto-merge for Dependabot PRs 40 | run: gh pr merge --auto --rebase "$PR_URL" 41 | env: 42 | PR_URL: ${{github.event.pull_request.html_url}} 43 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 44 | -------------------------------------------------------------------------------- /src/stories/assets/repo.svg: -------------------------------------------------------------------------------- 1 | illustration/repo -------------------------------------------------------------------------------- /src/components/Tool.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC } from 'react' 2 | import { addons, type API } from 'storybook/manager-api' 3 | import { styled } from 'storybook/theming' 4 | 5 | import { KEY, TOOL_ID } from '../constants' 6 | import { type TagBadgeParameters } from '../types/TagBadgeParameters' 7 | import { WithBadge } from './Badge' 8 | import { useBadgesToDisplay } from '../useBadgesToDisplay' 9 | interface ToolProps { 10 | api: API 11 | } 12 | 13 | const Separator = styled.div` 14 | content: ' '; 15 | width: 1px; 16 | height: 20px; 17 | background: rgba(255, 255, 255, 0.1); 18 | margin-left: 2px; 19 | margin-right: 2px; 20 | display: inline-block; 21 | ` 22 | 23 | const Root = styled.div` 24 | display: flex; 25 | align-items: center; 26 | gap: 6px; 27 | 28 | &:last-child div:last-child { 29 | display: none; 30 | } 31 | ` 32 | 33 | export const Tool: FC = function Tool({ api }) { 34 | const { [KEY]: parameters } = addons.getConfig() as { 35 | [KEY]: TagBadgeParameters 36 | } 37 | const storyData = api.getCurrentStoryData() 38 | const { tags, type } = storyData ?? {} 39 | 40 | const badgesToDisplay = useBadgesToDisplay({ 41 | context: 'toolbar', 42 | parameters, 43 | tags, 44 | type, 45 | }) 46 | 47 | return badgesToDisplay.length ? ( 48 | 49 | {badgesToDisplay.map(({ badge, tag }) => ( 50 | 57 | ))} 58 | 59 | 60 | ) : ( 61 | '' 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | id-token: write 14 | 15 | jobs: 16 | release: 17 | runs-on: ubuntu-latest 18 | if: | 19 | (github.event_name == 'push' && !contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')) || 20 | (github.event_name == 'pull_request') 21 | strategy: 22 | matrix: 23 | node-version: [22] 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Prepare repository 29 | run: git fetch --unshallow --tags 30 | 31 | - name: Install pnpm 32 | uses: pnpm/action-setup@v4 33 | 34 | - name: Use Node.js ${{ matrix.node-version }} 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: 'pnpm' 39 | 40 | - name: Install dependencies 41 | run: pnpm install --ignore-scripts 42 | 43 | - name: Audit dependencies 44 | run: pnpm audit 45 | 46 | - name: Create production release 47 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | run: pnpm release 52 | 53 | - name: Create canary release 54 | if: github.event_name == 'pull_request' 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | run: pnpm release:canary 59 | -------------------------------------------------------------------------------- /src/examples/BadgeDefaults.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | 3 | import { Badge, getBadgeProps } from '../components/Badge' 4 | import { defaultConfig } from '../defaultConfig' 5 | import { mockEntry } from './__fixtures__/HashEntry' 6 | 7 | const meta: Meta = { 8 | title: 'Addon/Default Config', 9 | component: Badge, 10 | tags: ['autodocs'], 11 | parameters: { 12 | layout: 'centered', 13 | }, 14 | } 15 | 16 | export default meta 17 | type Story = StoryObj 18 | 19 | export const PresetNew: Story = { 20 | args: getBadgeProps(defaultConfig[0].badge, mockEntry, 'new'), 21 | } 22 | export const PresetAlpha: Story = { 23 | args: getBadgeProps(defaultConfig[1].badge, mockEntry, 'alpha'), 24 | } 25 | export const PresetBeta: Story = { 26 | args: getBadgeProps(defaultConfig[1].badge, mockEntry, 'beta'), 27 | } 28 | export const PresetRC: Story = { 29 | args: getBadgeProps(defaultConfig[1].badge, mockEntry, 'rc'), 30 | } 31 | export const PresetExperimental: Story = { 32 | args: getBadgeProps(defaultConfig[1].badge, mockEntry, 'experimental'), 33 | } 34 | export const PresetDeprecated: Story = { 35 | args: getBadgeProps(defaultConfig[2].badge, mockEntry, 'deprecated'), 36 | } 37 | export const PresetOutdated: Story = { 38 | args: getBadgeProps(defaultConfig[3].badge, mockEntry, 'outdated'), 39 | } 40 | export const PresetDanger: Story = { 41 | args: getBadgeProps(defaultConfig[4].badge, mockEntry, 'danger'), 42 | } 43 | export const PresetCodeOnly: Story = { 44 | args: getBadgeProps(defaultConfig[5].badge, mockEntry, 'code-only'), 45 | } 46 | export const PresetVersion: Story = { 47 | args: getBadgeProps(defaultConfig[6].badge, mockEntry, 'version:1.0.0'), 48 | } 49 | export const PresetExperimentalVersion: Story = { 50 | args: getBadgeProps(defaultConfig[6].badge, mockEntry, 'version:0.2.1'), 51 | } 52 | -------------------------------------------------------------------------------- /src/manager.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { addons, types } from 'storybook/manager-api' 3 | 4 | import { Tool } from './components/Tool' 5 | import { ADDON_ID, EVENTS, KEY, TOOL_ID } from './constants' 6 | import { defaultConfig } from './defaultConfig' 7 | import { renderLabel } from './renderLabel' 8 | import { SET_CONFIG } from 'storybook/internal/core-events' 9 | import { TagBadgeParameters } from './types/TagBadgeParameters' 10 | import { API_SidebarOptions } from 'storybook/internal/types' 11 | 12 | declare global { 13 | interface Window { 14 | tagBadges: TagBadgeParameters 15 | } 16 | } 17 | 18 | function readConfig(config = addons.getConfig()): TagBadgeParameters { 19 | return config?.tagBadges 20 | } 21 | 22 | function readSidebarConfig( 23 | config = addons.getConfig(), 24 | ): API_SidebarOptions['renderLabel'] | undefined { 25 | return config?.sidebar?.renderLabel 26 | } 27 | 28 | addons.register(ADDON_ID, (api) => { 29 | // Config has functions and would get serialised if we sent it directly, 30 | // so instead we pass it to our child frame via window. 31 | api.on(EVENTS.REQUEST_CONFIG, () => { 32 | window.tagBadges = readConfig() ?? [] 33 | api.emit(EVENTS.CONFIG_READY) 34 | }) 35 | 36 | api.on(SET_CONFIG, (config) => { 37 | window.tagBadges = readConfig(config) ?? [] 38 | api.emit(EVENTS.CONFIG_READY) 39 | }) 40 | 41 | // We now initialise the manager, both through window for preview and 42 | // through the addons singleton for manager. 43 | const userConfig = readConfig() 44 | window.tagBadges = userConfig ?? defaultConfig 45 | 46 | addons.setConfig({ 47 | [KEY]: userConfig ?? defaultConfig, 48 | sidebar: { renderLabel: readSidebarConfig() ?? renderLabel }, 49 | }) 50 | 51 | // Register tools. 52 | addons.add(TOOL_ID, { 53 | type: types.TOOL, 54 | title: 'Tag Badges', 55 | render: () => , 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/stories/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Button } from './Button' 4 | import './header.css' 5 | 6 | type User = { 7 | name: string 8 | } 9 | 10 | interface HeaderProps { 11 | user?: User 12 | onLogin: () => void 13 | onLogout: () => void 14 | onCreateAccount: () => void 15 | } 16 | 17 | export const Header = ({ 18 | user, 19 | onLogin, 20 | onLogout, 21 | onCreateAccount, 22 | }: HeaderProps) => ( 23 |
24 |
25 |
26 | 32 | 33 | 37 | 41 | 45 | 46 | 47 |

Acme

48 |
49 |
50 | {user ? ( 51 | <> 52 | 53 | Welcome, {user.name}! 54 | 55 |
69 |
70 |
71 | ) 72 | -------------------------------------------------------------------------------- /src/components/MDXBadges.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC } from 'react' 2 | import { useOf, type Of } from '@storybook/addon-docs/blocks' 3 | import { styled } from 'storybook/theming' 4 | 5 | import { KEY } from '../constants' 6 | import { WithBadge } from './Badge' 7 | import { useBadgesToDisplay } from '../useBadgesToDisplay' 8 | import { type TagBadgeParameters } from '../types/TagBadgeParameters' 9 | 10 | declare global { 11 | interface Window { 12 | tagBadges: TagBadgeParameters 13 | } 14 | } 15 | 16 | export interface MDXBadgesProps { 17 | /** 18 | * Specify where to get the Badge tags from. Must be a CSF file's default export or a CSF story. 19 | * If not specified, the tags will be extracted from the meta of the attached CSF file. 20 | */ 21 | of?: Of 22 | } 23 | 24 | const BadgeContainer = styled.span` 25 | vertical-align: middle; 26 | display: inline-flex; 27 | gap: 0.25em; 28 | ` 29 | 30 | export const MDXBadges: FC = (props) => { 31 | const { of } = props 32 | if ('of' in props && of === undefined) { 33 | throw new Error( 34 | 'Unexpected `of={undefined}`, did you mistype a CSF file reference?', 35 | ) 36 | } 37 | 38 | const fetchedOf = useOf(of || 'meta', ['meta', 'story']) 39 | const tags = 40 | fetchedOf.type === 'meta' 41 | ? fetchedOf.preparedMeta.tags 42 | : fetchedOf.story.tags 43 | 44 | const badgesToDisplay = useBadgesToDisplay({ 45 | context: 'mdx', 46 | parameters: window[KEY], 47 | tags: tags || [], 48 | type: fetchedOf.type === 'meta' ? 'component' : 'story', 49 | }) 50 | 51 | return badgesToDisplay.length ? ( 52 | 53 | {badgesToDisplay.map(({ badge, tag }) => ( 54 | 61 | ))} 62 | 63 | ) : ( 64 | '' 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/defaultConfig.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TagBadgeParameter, 3 | TagBadgeParameters, 4 | } from './types/TagBadgeParameters' 5 | 6 | export const newBadge: TagBadgeParameter = { 7 | tags: 'new', 8 | badge: { 9 | text: 'New', 10 | style: 'green', 11 | }, 12 | } 13 | 14 | export const preReleaseBadge: TagBadgeParameter = { 15 | tags: ['alpha', 'beta', 'rc', 'experimental'], 16 | badge: ({ tag }) => { 17 | const upperFirst = (str: string): string => 18 | str[0].toUpperCase() + str.slice(1) 19 | 20 | return { 21 | text: tag === 'rc' ? 'Release candidate' : upperFirst(tag), 22 | style: 'purple', 23 | } 24 | }, 25 | } 26 | 27 | export const deprecatedBadge: TagBadgeParameter = { 28 | tags: 'deprecated', 29 | badge: { 30 | text: 'Deprecated', 31 | style: 'yellow', 32 | }, 33 | } 34 | 35 | export const outdatedBadge: TagBadgeParameter = { 36 | tags: 'outdated', 37 | badge: { 38 | text: 'Outdated', 39 | style: 'orange', 40 | }, 41 | } 42 | 43 | export const dangerBadge: TagBadgeParameter = { 44 | tags: 'danger', 45 | badge: { 46 | text: 'Danger', 47 | style: 'red', 48 | }, 49 | } 50 | 51 | export const codeOnlyBadge: TagBadgeParameter = { 52 | tags: ['code-only'], 53 | badge: { 54 | text: 'Code Only', 55 | style: 'grey', 56 | }, 57 | } 58 | 59 | export const versionBadge: TagBadgeParameter = { 60 | tags: [ 61 | { 62 | prefix: 'v', 63 | }, 64 | { 65 | prefix: 'version', 66 | }, 67 | ], 68 | badge: ({ getTagSuffix, tag }) => { 69 | const version = getTagSuffix(tag) 70 | const isExperimental = version?.startsWith('0') 71 | 72 | return { 73 | text: `${version}`, 74 | style: isExperimental ? 'turquoise' : 'blue', 75 | } 76 | }, 77 | } 78 | 79 | export const defaultConfig: TagBadgeParameters = [ 80 | newBadge, 81 | preReleaseBadge, 82 | deprecatedBadge, 83 | outdatedBadge, 84 | dangerBadge, 85 | codeOnlyBadge, 86 | versionBadge, 87 | ] 88 | -------------------------------------------------------------------------------- /.storybook/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { 3 | convert, 4 | createReset, 5 | Global, 6 | styled, 7 | ThemeProvider, 8 | themes, 9 | } from 'storybook/theming' 10 | 11 | const ThemeBlock = styled.div<{ side: 'left' | 'right'; layout: string }>( 12 | { 13 | position: 'absolute', 14 | top: 0, 15 | left: 0, 16 | right: '50vw', 17 | width: '50vw', 18 | height: '100vh', 19 | bottom: 0, 20 | overflow: 'auto', 21 | }, 22 | ({ layout }) => ({ 23 | padding: layout === 'fullscreen' ? 0 : '1rem', 24 | display: layout === 'centered' ? 'flex' : 'block', 25 | alignItems: 'center', 26 | justifyContent: 'center', 27 | }), 28 | ({ theme }) => ({ 29 | background: theme.background.content, 30 | color: theme.color.defaultText, 31 | }), 32 | ({ side }) => 33 | side === 'left' 34 | ? { 35 | left: 0, 36 | right: '50vw', 37 | } 38 | : { 39 | right: 0, 40 | left: '50vw', 41 | }, 42 | ) 43 | 44 | const AddonThemeProvider = (StoryFn, { context: { viewMode }, parameters }) => { 45 | if (viewMode === 'docs') { 46 | return ( 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | return ( 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | export default AddonThemeProvider 74 | -------------------------------------------------------------------------------- /src/types/DisplayOptions.ts: -------------------------------------------------------------------------------- 1 | import { API_HashEntry } from 'storybook/internal/types' 2 | 3 | /** 4 | * Display options for badges in MDX pages. Only applicable to components 5 | * and stories referenced in MDX files. 6 | */ 7 | export type MDXDisplayOptionItem = 8 | | boolean 9 | | Exclude 10 | export type MDXDisplayOptions = MDXDisplayOptionItem | MDXDisplayOptionItem[] 11 | 12 | /** 13 | * Display options for badges in the sidebar. Each item in the array must be an object with a 14 | * skipInherited property to describe inheritance behaviour and an item type. 15 | */ 16 | export type SidebarDisplayOptionItem = 17 | | boolean 18 | | { 19 | skipInherited: boolean 20 | type: Exclude 21 | } 22 | export type SidebarDisplayOptions = 23 | | SidebarDisplayOptionItem 24 | | SidebarDisplayOptionItem[] 25 | 26 | /** 27 | * Display options for badges in MDX pages. Only applicable to docs and stories 28 | * which are the two types that have a preview canvas, and thus, a toolbar. 29 | */ 30 | export type ToolbarDisplayOptionItem = 31 | | boolean 32 | | Exclude 33 | export type ToolbarDisplayOptions = 34 | | ToolbarDisplayOptionItem 35 | | ToolbarDisplayOptionItem[] 36 | 37 | /** 38 | * The types of HashEntries for which badges will be displayed in different parts of the Storybook UI. 39 | */ 40 | export interface Display { 41 | /** 42 | * Controls the display of badges in MDX pages with associated components. 43 | */ 44 | mdx?: MDXDisplayOptions 45 | /** 46 | * Controls the display of badges in the sidebar. 47 | */ 48 | sidebar?: SidebarDisplayOptions 49 | /** 50 | * Controls the display of badges in the toolbar. 51 | */ 52 | toolbar?: ToolbarDisplayOptions 53 | } 54 | 55 | export interface NormalisedDisplay { 56 | mdx: Exclude 57 | sidebar: SidebarDisplayOptions 58 | toolbar: Exclude 59 | } 60 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from 'tsup' 2 | 3 | const NODE_TARGET = 'node20.19' 4 | 5 | export default defineConfig(async () => { 6 | const packageJson = ( 7 | await import('./package.json', { with: { type: 'json' } }) 8 | ).default 9 | const { 10 | bundler: { 11 | managerEntries = [], 12 | previewEntries = [], 13 | nodeEntries = [], 14 | } = {}, 15 | } = packageJson 16 | 17 | const commonConfig: Options = { 18 | splitting: true, 19 | format: ['esm'], 20 | treeshake: true, 21 | clean: false, 22 | external: ['react', 'react-dom', '@storybook/icons'], 23 | } 24 | 25 | const configs: Options[] = [] 26 | 27 | // manager entries are entries meant to be loaded into the manager UI 28 | // they'll have manager-specific packages externalized and they won't be usable in node 29 | if (managerEntries.length) { 30 | configs.push({ 31 | ...commonConfig, 32 | entry: managerEntries, 33 | platform: 'browser', 34 | target: 'esnext', 35 | dts: true, 36 | }) 37 | } 38 | 39 | // preview entries are entries meant to be loaded into the preview iframe 40 | // they'll have preview-specific packages externalized and they won't be usable in node 41 | // they'll have types generated for them so they can be imported when setting up Portable Stories 42 | if (previewEntries.length) { 43 | configs.push({ 44 | ...commonConfig, 45 | entry: previewEntries, 46 | platform: 'browser', 47 | target: 'esnext', 48 | dts: true, 49 | }) 50 | } 51 | 52 | // node entries are entries meant to be used in node-only 53 | // this is useful for presets, which are loaded by Storybook when setting up configurations 54 | // they won't have types generated for them as they're usually loaded automatically by Storybook 55 | if (nodeEntries.length) { 56 | configs.push({ 57 | ...commonConfig, 58 | entry: nodeEntries, 59 | platform: 'node', 60 | target: NODE_TARGET, 61 | }) 62 | } 63 | 64 | return configs 65 | }) 66 | -------------------------------------------------------------------------------- /src/stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [22] 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'pnpm' 23 | - name: Install dependencies 24 | run: pnpm install 25 | - name: Check for linter errors 26 | run: pnpm lint 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | node-version: [22] 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | with: 37 | fetch-depth: 0 38 | - name: Install pnpm 39 | uses: pnpm/action-setup@v4 40 | - name: Use Node.js ${{ matrix.node-version }} 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | cache: 'pnpm' 45 | - name: Install dependencies 46 | run: pnpm install 47 | - name: Install Playwright browsers 48 | run: pnpm exec playwright install 49 | - name: Run tests 50 | run: pnpm test:coverage 51 | - name: Upload coverage reports to Codecov 52 | uses: codecov/codecov-action@v4 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | 56 | build: 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | node-version: [22] 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v4 64 | with: 65 | fetch-depth: 0 66 | - name: Install pnpm 67 | uses: pnpm/action-setup@v4 68 | - name: Use Node.js ${{ matrix.node-version }} 69 | uses: actions/setup-node@v4 70 | with: 71 | node-version: ${{ matrix.node-version }} 72 | cache: 'pnpm' 73 | - name: Install dependencies 74 | run: pnpm install 75 | - name: Run build 76 | run: pnpm build 77 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC, type ReactNode } from 'react' 2 | import { addons } from 'storybook/manager-api' 3 | import type { API_HashEntry } from 'storybook/internal/types' 4 | import { styled } from 'storybook/theming' 5 | 6 | import { KEY } from '../constants' 7 | import { TagBadgeParameters } from '../types/TagBadgeParameters' 8 | import { useBadgesToDisplay } from '../useBadgesToDisplay' 9 | import { WithBadge } from './Badge' 10 | 11 | export interface SidebarProps { 12 | children: ReactNode 13 | item: API_HashEntry 14 | hasStatusWithUI?: boolean 15 | } 16 | 17 | const Container = styled.div<{ 18 | hasParentPadding: boolean 19 | hasStatusWithUI: boolean 20 | }>( 21 | ({ hasParentPadding, hasStatusWithUI }) => ` 22 | display: flex; 23 | flex: 1; 24 | align-items: flex-start; 25 | flex-wrap: wrap; 26 | text-wrap-style: balance; 27 | gap: 4px; 28 | margin-right: ${hasStatusWithUI ? '6px' : hasParentPadding ? '28px' : '34px'}; 29 | `, 30 | ) 31 | 32 | const FirstLineAlignedLabel = styled.div` 33 | display: flex; 34 | align-items: center; 35 | min-height: 19px; 36 | ` 37 | 38 | const Spacer = styled.div` 39 | flex: 1; 40 | ` 41 | 42 | export const Sidebar: FC = ({ 43 | children, 44 | item, 45 | hasStatusWithUI, 46 | }) => { 47 | const { [KEY]: parameters } = addons.getConfig() as { 48 | [KEY]: TagBadgeParameters 49 | } 50 | 51 | if ( 52 | item.type !== 'component' && 53 | item.type !== 'group' && 54 | item.type !== 'docs' && 55 | item.type !== 'story' 56 | ) { 57 | return children 58 | } 59 | 60 | const badgesToDisplay = useBadgesToDisplay({ 61 | context: 'sidebar', 62 | parameters, 63 | parent: item.parent, 64 | tags: item.tags, 65 | type: item.type, 66 | }) 67 | 68 | return ( 69 | 73 | {children} 74 | 75 | {badgesToDisplay.length ? ( 76 | 82 | ) : ( 83 | '' 84 | )} 85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /src/examples/Badge.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react-vite' 2 | 3 | import { Badge } from '../components/Badge' 4 | 5 | const meta: Meta = { 6 | title: 'Addon/Badge', 7 | component: Badge, 8 | argTypes: { 9 | style: { 10 | control: 'select', 11 | options: [ 12 | 'grey', 13 | 'green', 14 | 'turquoise', 15 | 'blue', 16 | 'purple', 17 | 'pink', 18 | 'red', 19 | 'orange', 20 | 'yellow', 21 | ], 22 | description: 23 | 'Color preset (a CSS object can also be passed for more customisation)', 24 | }, 25 | context: { 26 | control: 'select', 27 | options: ['sidebar', 'toolbar'], 28 | description: 'Whether the badge renders in the sidebar or toolbar', 29 | }, 30 | tooltip: { 31 | description: 'Tooltip content; either as string or TooltipMessage props', 32 | control: false, 33 | }, 34 | }, 35 | tags: ['autodocs'], 36 | parameters: { 37 | layout: 'centered', 38 | }, 39 | } 40 | 41 | export default meta 42 | type Story = StoryObj 43 | 44 | export const Default: Story = { 45 | args: { 46 | context: 'toolbar', 47 | text: 'Text', 48 | }, 49 | } 50 | 51 | export const Colors: Story = { 52 | args: { 53 | context: 'toolbar', 54 | text: 'Text', 55 | style: { 56 | backgroundColor: 'hsl(110, 100%, 64%)', 57 | color: 'hsl(110, 100%, 8%)', 58 | }, 59 | }, 60 | } 61 | 62 | export const ColorAndBorders: Story = { 63 | args: { 64 | context: 'toolbar', 65 | text: 'Text', 66 | style: { 67 | backgroundColor: 'hsl(110, 100%, 64%)', 68 | borderColor: 'hsl(110, 100%, 24%)', 69 | color: 'hsl(110, 100%, 8%)', 70 | }, 71 | }, 72 | } 73 | 74 | export const SimpleTooltip: Story = { 75 | args: { 76 | context: 'toolbar', 77 | text: 'Text', 78 | tooltip: 'This badge has a simple string tooltip!', 79 | }, 80 | } 81 | 82 | export const RichTooltip: Story = { 83 | args: { 84 | context: 'toolbar', 85 | text: 'Text', 86 | tooltip: { 87 | title: 'Rich Tooltip', 88 | desc: 'This badge uses a TooltipMessage component with title and description', 89 | }, 90 | }, 91 | } 92 | 93 | export const RichTooltipWithLink: Story = { 94 | args: { 95 | context: 'toolbar', 96 | text: 'Text', 97 | tooltip: { 98 | title: 'Rich Tooltip', 99 | desc: 'This badge uses a TooltipMessage component with title and description', 100 | links: [ 101 | { 102 | title: 'Storybook', 103 | href: 'https://storybook.js.org/', 104 | }, 105 | ], 106 | }, 107 | }, 108 | } 109 | 110 | export const SidebarBadge: Story = { 111 | args: { 112 | context: 'sidebar', 113 | text: 'Text', 114 | tooltip: 'Tooltips are disabled in the sidebar', 115 | }, 116 | } 117 | -------------------------------------------------------------------------------- /scripts/prepublish-checks.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | import boxen from 'boxen' 4 | import dedent from 'dedent' 5 | import { readFile } from 'node:fs/promises' 6 | import { globalPackages as globalManagerPackages } from 'storybook/internal/manager/globals' 7 | import { globalPackages as globalPreviewPackages } from 'storybook/internal/preview/globals' 8 | 9 | const packageJson = await readFile('./package.json', 'utf8').then(JSON.parse) 10 | 11 | const name = packageJson.name 12 | const displayName = packageJson.storybook.displayName 13 | 14 | let exitCode = 0 15 | $.verbose = false 16 | 17 | /** 18 | * Check that meta data has been updated 19 | */ 20 | if (name.includes('addon-kit') || displayName.includes('Addon Kit')) { 21 | console.error( 22 | boxen( 23 | dedent` 24 | ${chalk.red.bold('Missing metadata')} 25 | 26 | ${chalk.red(dedent`Your package name and/or displayName includes default values from the Addon Kit. 27 | The addon gallery filters out all such addons. 28 | 29 | Please configure appropriate metadata before publishing your addon. For more info, see: 30 | https://storybook.js.org/docs/react/addons/addon-catalog#addon-metadata`)}`, 31 | { padding: 1, borderColor: 'red' }, 32 | ), 33 | ) 34 | 35 | exitCode = 1 36 | } 37 | 38 | /** 39 | * Check that README has been updated 40 | */ 41 | const readmeTestStrings = 42 | '# Storybook Addon Kit|Click the \\*\\*Use this template\\*\\* button to get started.|https://user-images.githubusercontent.com/42671/106809879-35b32000-663a-11eb-9cdc-89f178b5273f.gif' 43 | 44 | if ((await $`cat README.md | grep -E ${readmeTestStrings}`.exitCode) == 0) { 45 | console.error( 46 | boxen( 47 | dedent` 48 | ${chalk.red.bold('README not updated')} 49 | 50 | ${chalk.red(dedent`You are using the default README.md file that comes with the addon kit. 51 | Please update it to provide info on what your addon does and how to use it.`)} 52 | `, 53 | { padding: 1, borderColor: 'red' }, 54 | ), 55 | ) 56 | 57 | exitCode = 1 58 | } 59 | 60 | /** 61 | * Check that globalized packages are not incorrectly listed as peer dependencies 62 | */ 63 | const peerDependencies = Object.keys(packageJson.peerDependencies || {}) 64 | const globalPackages = [...globalManagerPackages, ...globalPreviewPackages] 65 | peerDependencies.forEach((dependency) => { 66 | if (globalPackages.includes(dependency)) { 67 | console.error( 68 | boxen( 69 | dedent` 70 | ${chalk.red.bold('Unnecessary peer dependency')} 71 | 72 | ${chalk.red(dedent`You have a peer dependency on ${chalk.bold(dependency)} which is most likely unnecessary 73 | as that is provided by Storybook directly. 74 | Check the "bundling" section in README.md for more information. 75 | If you are absolutely sure you are doing it correct, you should remove this check from scripts/prepublish-checks.js.`)} 76 | `, 77 | { padding: 1, borderColor: 'red' }, 78 | ), 79 | ) 80 | 81 | exitCode = 1 82 | } 83 | }) 84 | 85 | process.exit(exitCode) 86 | -------------------------------------------------------------------------------- /src/stories/Page.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { Header } from './Header' 4 | import './page.css' 5 | 6 | type User = { 7 | name: string 8 | } 9 | 10 | export const Page: React.FC = () => { 11 | const [user, setUser] = useState() 12 | 13 | return ( 14 |
15 |
setUser({ name: 'Jane Doe' })} 18 | onLogout={() => setUser(undefined)} 19 | onCreateAccount={() => setUser({ name: 'Jane Doe' })} 20 | /> 21 | 22 |
23 |

Pages in Storybook

24 |

25 | We recommend building UIs with a{' '} 26 | 31 | component-driven 32 | {' '} 33 | process starting with atomic components and ending with pages. 34 |

35 |

36 | Render pages with mock data. This makes it easy to build and review 37 | page states without needing to navigate to them in your app. Here are 38 | some handy patterns for managing page data in Storybook: 39 |

40 |
    41 |
  • 42 | Use a higher-level connected component. Storybook helps you compose 43 | such data from the "args" of child component stories 44 |
  • 45 |
  • 46 | Assemble data in the page component from your services. You can mock 47 | these services out using Storybook. 48 |
  • 49 |
50 |

51 | Get a guided tutorial on component-driven development at{' '} 52 | 57 | Storybook tutorials 58 | 59 | . Read more in the{' '} 60 | 65 | docs 66 | 67 | . 68 |

69 |
70 | Tip Adjust the width of the canvas with 71 | the{' '} 72 | 78 | 79 | 84 | 85 | 86 | Viewports addon in the toolbar 87 |
88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/useBadgesToDisplay.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { type API, useStorybookApi } from 'storybook/manager-api' 3 | 4 | import { DisplayOutcome, shouldDisplay } from './utils/display' 5 | import { matchTags } from './utils/tag' 6 | import type { 7 | API_ComponentEntry, 8 | API_GroupEntry, 9 | API_HashEntry, 10 | API_LeafEntry, 11 | } from 'storybook/internal/types' 12 | import { TagBadgeParameters } from './types/TagBadgeParameters' 13 | import { BadgeOrBadgeFn } from './types/Badge' 14 | 15 | interface UseBadgesToDisplayOptions { 16 | context: 'mdx' | 'sidebar' | 'toolbar' 17 | parameters: TagBadgeParameters 18 | parent?: string 19 | tags: string[] 20 | type: 21 | | API_ComponentEntry['type'] 22 | | API_GroupEntry['type'] 23 | | API_LeafEntry['type'] 24 | } 25 | 26 | type BadgesToDisplay = { badge: BadgeOrBadgeFn; tag: string }[] 27 | 28 | function _useBadgesToDisplay({ 29 | api, 30 | context, 31 | parameters, 32 | parent, 33 | tags, 34 | type, 35 | }: UseBadgesToDisplayOptions & { 36 | api?: API 37 | }): BadgesToDisplay { 38 | /* Handle potentially missing data from callees. */ 39 | if (!tags || !type) { 40 | return [] 41 | } 42 | 43 | let parentTags: string[] | undefined 44 | let resolvedParent: API_HashEntry | undefined 45 | if (api && parent) { 46 | resolvedParent = api.resolveStory(parent) 47 | if (resolvedParent && resolvedParent.type !== 'root') { 48 | parentTags = resolvedParent.tags 49 | } 50 | } 51 | 52 | return (parameters || []) 53 | .map((config) => ({ 54 | ...config, 55 | displayOutcome: shouldDisplay({ context, config, type }), 56 | })) 57 | .filter(({ displayOutcome }) => displayOutcome !== DisplayOutcome.NEVER) 58 | .flatMap((config) => 59 | matchTags(tags, config.tags).map((tag) => ({ 60 | badge: config.badge, 61 | displayOutcome: config.displayOutcome, 62 | tag, 63 | })), 64 | ) 65 | .reduce((acc: BadgesToDisplay, current) => { 66 | if ( 67 | current.displayOutcome === DisplayOutcome.SKIP_INHERITED && 68 | resolvedParent && 69 | resolvedParent.type !== 'root' && 70 | parentTags?.includes(current.tag) 71 | ) { 72 | const displayParent = _useBadgesToDisplay({ 73 | api, 74 | context, 75 | parameters, 76 | parent: resolvedParent.parent, 77 | tags: parentTags, 78 | type: resolvedParent.type, 79 | }) 80 | 81 | if (displayParent.find(({ tag }) => tag === current.tag)) { 82 | return acc 83 | } 84 | } 85 | 86 | if (acc.every(({ tag }) => tag !== current.tag)) { 87 | acc.push(current) 88 | } 89 | return acc 90 | }, []) 91 | } 92 | 93 | export function useBadgesToDisplay({ 94 | context, 95 | parameters, 96 | parent, 97 | tags, 98 | type, 99 | }: UseBadgesToDisplayOptions): BadgesToDisplay { 100 | const api = useStorybookApi() 101 | 102 | return useMemo( 103 | () => 104 | _useBadgesToDisplay({ 105 | api, 106 | context, 107 | parameters, 108 | parent, 109 | tags, 110 | type, 111 | }), 112 | [context, parameters, parent, tags, type], 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /src/utils/tag.ts: -------------------------------------------------------------------------------- 1 | import type { TagPatterns } from '../types/TagPattern' 2 | 3 | /** 4 | * Splits a tag into a prefix and suffix, separated by a colon. 5 | * If no colons are present, `suffix` is `null`. If more than one 6 | * colons are present, `suffix` is a concatenation of all the parts 7 | * beyond the first one. 8 | * @param tag The tag being queried. 9 | * @returns The prefix and suffix. 10 | */ 11 | export function getTagParts(tag: string): { 12 | prefix: string 13 | suffix: string | null 14 | } { 15 | const [prefix, ...rest] = tag.split(':') 16 | return { prefix, suffix: rest.join(':') || null } 17 | } 18 | 19 | /** 20 | * Gets the prefix of a tag, i.e. the part before the 21 | * colon if it contains one, or the whole tag otherwise. 22 | * @param tag The tag being queried. 23 | * @returns The prefix or the whole tag. 24 | */ 25 | export function getTagPrefix(tag: string): string { 26 | return getTagParts(tag).prefix 27 | } 28 | 29 | /** 30 | * Gets the suffix of a tag, i.e. the part after the 31 | * colon if it contains one, or `null` otherwise. 32 | * @param tag The tag being queried. 33 | * @returns The suffix if it exists. 34 | */ 35 | export function getTagSuffix(tag: string): string | null { 36 | return getTagParts(tag).suffix 37 | } 38 | 39 | function normalisePattern( 40 | pattern: string | RegExp | undefined, 41 | ): string | RegExp { 42 | if (pattern === undefined) { 43 | return /.*/ 44 | } 45 | 46 | if (typeof pattern === 'string') { 47 | let patternWithBoundaries = pattern 48 | if (!patternWithBoundaries.startsWith('^')) { 49 | patternWithBoundaries = `^${patternWithBoundaries}` 50 | } 51 | if (!patternWithBoundaries.endsWith('$')) { 52 | patternWithBoundaries += '$' 53 | } 54 | 55 | return new RegExp(patternWithBoundaries) 56 | } 57 | 58 | return pattern 59 | } 60 | 61 | /** 62 | * Checks if a given tag matches any of the provided patterns. 63 | * Patterns can be regular expressions, strings, or objects with prefix and suffix patterns. 64 | * @param tag The tag to match against the patterns. 65 | * @param patterns The pattern or patterns to match the tag against. 66 | * @returns `true` if the tag matches any of the patterns, `false` otherwise. 67 | */ 68 | export function matchTag(tag: string, patterns: TagPatterns): boolean { 69 | const normalisedPatterns = [patterns].flat() 70 | for (const pattern of normalisedPatterns) { 71 | if (pattern instanceof RegExp) { 72 | if (tag.match(pattern)) { 73 | return true 74 | } 75 | } else if (typeof pattern === 'string') { 76 | if (tag === pattern) { 77 | return true 78 | } 79 | } else { 80 | const { prefix, suffix } = getTagParts(tag) 81 | const prefixPattern = normalisePattern(pattern.prefix) 82 | const suffixPattern = normalisePattern(pattern.suffix) 83 | 84 | const matchesPrefix = prefix.match(prefixPattern) 85 | const matchesSuffix = suffix && suffix.match(suffixPattern) 86 | 87 | if (matchesPrefix && matchesSuffix) { 88 | return true 89 | } 90 | } 91 | } 92 | 93 | return false 94 | } 95 | 96 | /** 97 | * Filters an array of tags based on the provided pattern configuration. 98 | * @param tags An array of tags to filter. 99 | * @param config The pattern configuration to match tags against. 100 | * @returns An array of tags that match the given pattern configuration. 101 | */ 102 | export function matchTags(tags: string[], config: TagPatterns): string[] { 103 | return tags.filter((tag) => matchTag(tag, config)) 104 | } 105 | -------------------------------------------------------------------------------- /src/examples/sampleWorkflows.ts: -------------------------------------------------------------------------------- 1 | import { themes } from 'storybook/theming' 2 | import type { TagBadgeParameters } from '../types/TagBadgeParameters' 3 | 4 | export const byMarket: TagBadgeParameters = [ 5 | { 6 | tags: { prefix: 'market', suffix: /.+/ }, 7 | badge: ({ getTagSuffix, tag }) => { 8 | const market = getTagSuffix(tag) ?? '' 9 | 10 | const shorthands: Record = { 11 | b2b: 'B2B', 12 | b2c: 'B2C', 13 | finance: 'FIN', 14 | government: 'GOV', 15 | health: 'MED', 16 | all: 'ALL', 17 | } 18 | 19 | const emojis: Record = { 20 | b2b: '🏢', 21 | b2c: '🛍️', 22 | finance: '💸', 23 | government: '🏛️', 24 | health: '⚕️', 25 | all: '', 26 | } 27 | 28 | return { 29 | text: [shorthands[market], emojis[market]].filter(Boolean).join(' '), 30 | style: { 31 | backgroundColor: '#cceeff', 32 | borderColor: '#330099', 33 | color: '#110033', 34 | }, 35 | tooltip: `For use in products destined to the ${market} market/industry.`, 36 | } 37 | }, 38 | }, 39 | ] satisfies TagBadgeParameters 40 | 41 | export const brandComponents: TagBadgeParameters = [ 42 | { 43 | tags: 'brand', 44 | badge: { 45 | text: 'Brand', 46 | style: { 47 | background: 48 | 'linear-gradient(to right in lch, rgba(255,255,0,.7) 0%, rgba(32,254,62,1) 100%)', 49 | borderColor: 'transparent', 50 | color: themes.dark.textInverseColor, 51 | }, 52 | tooltip: `This component can help create strong brand moments.`, 53 | }, 54 | }, 55 | { 56 | tags: 'ui', 57 | badge: { 58 | text: 'UI', 59 | tooltip: `This component is lightly branded and serves a functional purpose.`, 60 | }, 61 | }, 62 | ] satisfies TagBadgeParameters 63 | 64 | export const composition: TagBadgeParameters = [ 65 | { 66 | tags: { prefix: 'compose' }, 67 | badge: ({ getTagSuffix, tag }) => ({ 68 | text: `🧩 ${getTagSuffix(tag)}`, 69 | style: { 70 | background: 'linear-gradient(to bottom in lch, #1f1f24 0%, #22222c)', 71 | color: '#e0e0eb', 72 | }, 73 | }), 74 | }, 75 | ] 76 | 77 | export const compliance: TagBadgeParameters = [ 78 | { 79 | tags: { suffix: 'fail' }, 80 | badge: ({ getTagPrefix, tag }) => ({ 81 | text: `${getTagPrefix(tag)} ✗`, 82 | style: { 83 | backgroundColor: '#aa0000', 84 | color: '#fff', 85 | }, 86 | }), 87 | }, 88 | { 89 | tags: { suffix: 'success' }, 90 | badge: ({ getTagPrefix, tag }) => ({ 91 | text: `${getTagPrefix(tag)} ✓`, 92 | style: { 93 | backgroundColor: '#006633', 94 | color: '#fff', 95 | }, 96 | }), 97 | }, 98 | ] 99 | 100 | export const dependencies: TagBadgeParameters = [ 101 | { 102 | tags: { prefix: 'uses' }, 103 | badge: ({ getTagSuffix, tag }) => ({ 104 | text: `🔗 ${getTagSuffix(tag)}`, 105 | style: { 106 | background: 'linear-gradient(to bottom in lch, #1b1816 0%, #22201e)', 107 | borderColor: '#eee1', 108 | color: '#eeebe0', 109 | }, 110 | }), 111 | }, 112 | ] 113 | export const smartComponents: TagBadgeParameters = [ 114 | { 115 | tags: { prefix: 'smart' }, 116 | badge: ({ getTagSuffix, tag }) => ({ 117 | text: `🧠 ${getTagSuffix(tag)}`, 118 | style: { 119 | background: 'linear-gradient(to bottom in lch, #161622 0%, #1c1c29)', 120 | borderColor: '#eee1', 121 | color: '#eeeefe', 122 | }, 123 | }), 124 | }, 125 | ] 126 | -------------------------------------------------------------------------------- /src/utils/display.ts: -------------------------------------------------------------------------------- 1 | import { HashEntry } from 'storybook/manager-api' 2 | import type { ArrayElement } from '../types/ArrayElement' 3 | import type { TagBadgeParameters } from '../types/TagBadgeParameters' 4 | import type { 5 | Display, 6 | NormalisedDisplay, 7 | MDXDisplayOptionItem, 8 | SidebarDisplayOptionItem, 9 | ToolbarDisplayOptionItem, 10 | } from '../types/DisplayOptions' 11 | 12 | export interface ShouldDisplayOptions { 13 | config: Partial> 14 | context: 'mdx' | 'sidebar' | 'toolbar' 15 | type: HashEntry['type'] 16 | } 17 | 18 | export const DISPLAY_DEFAULTS = { 19 | mdx: ['story', 'component'], 20 | sidebar: [ 21 | { type: 'story', skipInherited: true }, 22 | { type: 'docs', skipInherited: true }, 23 | { type: 'component', skipInherited: false }, 24 | { type: 'group', skipInherited: false }, 25 | ], 26 | toolbar: ['docs', 'story'], 27 | } satisfies NormalisedDisplay 28 | 29 | function toArray(value: T | T[]): T[] { 30 | return Array.isArray(value) ? value : [value] 31 | } 32 | 33 | export function normaliseDisplay(display?: Display): { 34 | mdx: MDXDisplayOptionItem[] 35 | sidebar: SidebarDisplayOptionItem[] 36 | toolbar: ToolbarDisplayOptionItem[] 37 | } { 38 | return { 39 | mdx: toArray(display?.mdx ?? DISPLAY_DEFAULTS.mdx), 40 | sidebar: toArray(display?.sidebar ?? DISPLAY_DEFAULTS.sidebar), 41 | toolbar: toArray(display?.toolbar ?? DISPLAY_DEFAULTS.toolbar), 42 | } 43 | } 44 | 45 | export enum DisplayOutcome { 46 | NEVER = 'never', 47 | SKIP_INHERITED = 'skip-inherited', 48 | ALWAYS = 'always', 49 | } 50 | 51 | /** 52 | * Determines whether a badge should be displayed based on the provided config 53 | * and based on the display context (toolbar, sidebar). 54 | * 55 | * @param options The options to determine display. 56 | * @param options.config The configuration for the badge. 57 | * @param options.context The context where the badge might be displayed. 58 | * @param options.type The type of the current entry. 59 | * 60 | * @returns {DisplayOutcome} `ALWAYS` if the badge should be displayed, `NEVER` if 61 | * it shouldn't, and `SKIP_INHERITED` if it should only when the parent entry doesn't 62 | * show a badge for the same tag already. 63 | */ 64 | export function shouldDisplay({ 65 | config, 66 | context, 67 | type, 68 | }: ShouldDisplayOptions): DisplayOutcome { 69 | if (type === 'root') { 70 | return DisplayOutcome.NEVER 71 | } 72 | 73 | for (const condition of normaliseDisplay(config.display)[context]) { 74 | // If options contain the value `true`, we must show badges for all types. 75 | if (condition === true) { 76 | return DisplayOutcome.ALWAYS 77 | } 78 | 79 | // Inversely, if `false` is found, we must never display badges. 80 | if (condition === false) { 81 | return DisplayOutcome.NEVER 82 | } 83 | 84 | // For MDX and toolbar badges, we may have strings that match a content type. 85 | // If a condition matches the type in parameters, we know we can show the badge. 86 | // NOTE: we don't actually check for context here to account for users who don't 87 | // use TypeScript and mistakenly pass strings to the 'sidebar' context options. 88 | if (condition === type) { 89 | // If the type is found, we must show badges for this type. 90 | return DisplayOutcome.ALWAYS 91 | } 92 | 93 | // For sidebar badges, we must account for the `skipInherited` property. 94 | if (context === 'sidebar' && typeof condition === 'object') { 95 | // When a type is defined, it must always match the type of the HashEntry. 96 | // If it doesn't, we don't return true yet. 97 | if (condition.type === type) { 98 | return condition.skipInherited 99 | ? DisplayOutcome.SKIP_INHERITED 100 | : DisplayOutcome.ALWAYS 101 | } 102 | } 103 | } 104 | 105 | return DisplayOutcome.NEVER 106 | } 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook-addon-tag-badges", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Display Storybook tags as badges in the sidebar and toolbar.", 5 | "keywords": [ 6 | "storybook-addon", 7 | "storybook", 8 | "badges", 9 | "tags", 10 | "organize" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Sidnioulz/storybook-addon-tag-badges.git" 15 | }, 16 | "type": "module", 17 | "author": "Steve Dodier-Lazaro ", 18 | "license": "MIT", 19 | "exports": { 20 | ".": { 21 | "types": "./dist/index.d.ts", 22 | "default": "./dist/index.js" 23 | }, 24 | "./manager-helpers": "./dist/manager-helpers.js", 25 | "./manager": "./dist/manager.js", 26 | "./preview": { 27 | "types": "./dist/index.d.ts", 28 | "default": "./dist/preview.js" 29 | }, 30 | "./package.json": "./package.json" 31 | }, 32 | "files": [ 33 | "dist/**/*", 34 | "README.md", 35 | "manager.js", 36 | "preview.js" 37 | ], 38 | "bundler": { 39 | "managerEntries": [ 40 | "src/manager.tsx", 41 | "src/manager-helpers.ts" 42 | ], 43 | "nodeEntries": [], 44 | "previewEntries": [ 45 | "src/preview.ts", 46 | "src/index.ts" 47 | ] 48 | }, 49 | "scripts": { 50 | "build": "tsup", 51 | "build:storybook": "storybook build", 52 | "build:watch": "pnpm build --watch", 53 | "clean": "rimraf ./dist", 54 | "format": "prettier --check .", 55 | "format:fix": "prettier --write .", 56 | "lint": "eslint --cache .", 57 | "lint:fix": "pnpm lint --fix", 58 | "pack": "pnpm pack --out storybook-addon-tag-badges-$(date +%s).tgz", 59 | "prebuild": "pnpm clean", 60 | "prepare": "husky", 61 | "prerelease": "zx scripts/prepublish-checks.js", 62 | "release": "pnpm build && pnpm semantic-release", 63 | "release:canary": "pnpm build && auto canary", 64 | "start": "run-p build:watch \"storybook --quiet\"", 65 | "storybook": "storybook dev -p 6006", 66 | "test": "vitest", 67 | "test:coverage": "vitest --coverage" 68 | }, 69 | "devDependencies": { 70 | "@chromatic-com/storybook": "^4.1.3", 71 | "@commitlint/config-conventional": "^20.2.0", 72 | "@eslint/js": "^9.39.2", 73 | "@storybook/addon-docs": "next", 74 | "@storybook/addon-vitest": "next", 75 | "@storybook/icons": "^2.0.0", 76 | "@storybook/react-vite": "next", 77 | "@testing-library/jest-dom": "^6.9.1", 78 | "@testing-library/react": "^16.3.1", 79 | "@types/node": "^25.0.3", 80 | "@types/react": "^19.2.7", 81 | "@types/react-dom": "^19.2.3", 82 | "@vitejs/plugin-react": "^5.1.2", 83 | "@vitest/browser": "4.0.16", 84 | "@vitest/coverage-istanbul": "4.0.16", 85 | "auto": "^11.3.6", 86 | "boxen": "^8.0.1", 87 | "chromatic": "^13.3.4", 88 | "commitlint": "^20.2.0", 89 | "dedent": "^1.7.1", 90 | "eslint": "^9.39.2", 91 | "eslint-config-prettier": "^10.1.8", 92 | "eslint-plugin-prettier": "^5.5.4", 93 | "eslint-plugin-react": "^7.37.5", 94 | "globals": "^16.5.0", 95 | "husky": "^9.1.7", 96 | "jsdom": "^27.3.0", 97 | "lint-staged": "^16.2.7", 98 | "npm-run-all": "^4.1.5", 99 | "playwright": "^1.57.0", 100 | "prettier": "^3.7.4", 101 | "prompts": "^2.4.2", 102 | "react": "^19.2.3", 103 | "react-dom": "^19.2.3", 104 | "rimraf": "^6.1.2", 105 | "semantic-release": "^25.0.2", 106 | "storybook": "next", 107 | "tosource": "2.0.0-alpha.3", 108 | "tsup": "^8.5.1", 109 | "typescript": "^5.9.3", 110 | "typescript-eslint": "^8.50.0", 111 | "vite": "^7.3.0", 112 | "vitest": "^4.0.16", 113 | "zx": "^8.8.5" 114 | }, 115 | "peerDependencies": { 116 | "storybook": "^10.0.0" 117 | }, 118 | "resolutions": { 119 | "@octokit/core": "^7", 120 | "@octokit/request-error": "^7", 121 | "@octokit/request": "^10", 122 | "@octokit/plugin-paginate-rest": "^13" 123 | }, 124 | "publishConfig": { 125 | "access": "public", 126 | "provenance": true, 127 | "registry": "https://registry.npmjs.org/" 128 | }, 129 | "packageManager": "pnpm@10.7.0", 130 | "engines": { 131 | "node": ">=20" 132 | }, 133 | "storybook": { 134 | "displayName": "Tag Badges", 135 | "supportedFrameworks": [ 136 | "supported-frameworks" 137 | ], 138 | "icon": "https://raw.githubusercontent.com/Sidnioulz/storybook-addon-tag-badges/main/static/icon.png" 139 | }, 140 | "pnpm": { 141 | "overrides": { 142 | "vite@>=7.1.0 <=7.1.10": ">=7.1.11", 143 | "js-yaml@>=4.0.0 <4.1.1": ">=4.1.1" 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/components/__tests__/Badge.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import '@testing-library/jest-dom/vitest' 4 | import { ThemeProvider, convert, themes } from 'storybook/theming' 5 | import type { HashEntry } from 'storybook/manager-api' 6 | 7 | import { Badge, WithBadge } from '../Badge' 8 | import { getTagParts } from '../../utils/tag' 9 | 10 | const mockEntry: HashEntry = { 11 | id: 'example-story', 12 | type: 'story', 13 | title: 'Example/Story', 14 | importPath: './example.stories.tsx', 15 | tags: ['example', 'story'], 16 | name: 'Example Story', 17 | prepared: true, 18 | args: {}, 19 | argTypes: {}, 20 | initialArgs: {}, 21 | parameters: {}, 22 | depth: 0, 23 | parent: '', 24 | } 25 | 26 | const renderWithTheme = (ui: React.ReactElement) => { 27 | return render( 28 | {ui}, 29 | ) 30 | } 31 | 32 | describe('Badge', () => { 33 | describe('Badge component', () => { 34 | it('renders with basic props', () => { 35 | renderWithTheme() 36 | expect(screen.getByText('Test Badge')).toBeInTheDocument() 37 | }) 38 | 39 | it('renders in sidebar context', () => { 40 | renderWithTheme() 41 | const badge = screen.getByText('Sidebar Badge') 42 | expect(badge).toBeInTheDocument() 43 | }) 44 | 45 | it('renders in toolbar context', () => { 46 | renderWithTheme() 47 | const badge = screen.getByText('Toolbar Badge') 48 | expect(badge).toBeInTheDocument() 49 | }) 50 | 51 | it('renders as a button in the toolbar when tooltip is set as string', async () => { 52 | const text = 'Tooltip Badge' 53 | const tooltip = 'This is a tooltip' 54 | renderWithTheme() 55 | 56 | const badge = await screen.findByText(text) 57 | expect(badge).toBeInTheDocument() 58 | expect(badge.tagName).toBe('BUTTON') 59 | }) 60 | 61 | it('renders as a button in the toolbar when tooltip is set as TooltipMessage props', async () => { 62 | const text = 'Tooltip Badge' 63 | const tooltip = { 64 | title: 'Tooltip Title', 65 | desc: 'Tooltip Description', 66 | } 67 | renderWithTheme() 68 | 69 | const badge = await screen.findByText(text) 70 | expect(badge).toBeInTheDocument() 71 | expect(badge.tagName).toBe('BUTTON') 72 | }) 73 | 74 | it('ignores tooltip in the sidebar', async () => { 75 | const text = 'Tooltip Badge' 76 | const tooltip = 'This is a tooltip' 77 | renderWithTheme() 78 | 79 | const badge = await screen.findByText(text) 80 | expect(badge).toBeInTheDocument() 81 | expect(badge.tagName).not.toBe('BUTTON') 82 | }) 83 | 84 | it('renders with custom colors', () => { 85 | renderWithTheme( 86 | , 95 | ) 96 | const badge = screen.getByText('Custom Colors') 97 | expect(badge).toHaveStyle({ 98 | background: '#ff0000', 99 | color: '#0000ff', 100 | 'box-shadow': 'inset 0 0 0 1px #00ff00', 101 | }) 102 | }) 103 | }) 104 | 105 | describe('WithBadge component', () => { 106 | it('renders with a basic config object', () => { 107 | const config = { 108 | text: 'Config Badge', 109 | style: { 110 | background: '#cccccc', 111 | }, 112 | } 113 | renderWithTheme( 114 | , 120 | ) 121 | const badge = screen.getByText('Config Badge') 122 | expect(badge).toBeInTheDocument() 123 | expect(badge).toHaveStyle({ background: '#cccccc' }) 124 | }) 125 | 126 | it('renders with a config function', () => { 127 | const configFn = vi.fn() 128 | configFn.mockImplementation( 129 | ({ entry, tag }: { entry: HashEntry; tag: string }) => ({ 130 | text: `${entry.type}-${tag}`, 131 | style: { 132 | background: '#eeeeee', 133 | }, 134 | }), 135 | ) 136 | renderWithTheme( 137 | , 143 | ) 144 | const badge = screen.getByText('story-test') 145 | expect(configFn).toHaveBeenCalledWith( 146 | expect.objectContaining({ getTagParts }), 147 | ) 148 | expect(badge).toBeInTheDocument() 149 | expect(badge).toHaveStyle({ background: '#eeeeee' }) 150 | }) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /src/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import type { HashEntry } from 'storybook/manager-api' 3 | import { WithTooltip, TooltipMessage } from 'storybook/internal/components' 4 | import { CSSObject, styled, useTheme } from 'storybook/theming' 5 | 6 | import type { Badge as BadgeConfigType, BadgeOrBadgeFn } from '../types/Badge' 7 | import { getTagParts, getTagPrefix, getTagSuffix } from '../utils/tag' 8 | 9 | interface BadgeProps extends BadgeConfigType { 10 | context: 'mdx' | 'sidebar' | 'toolbar' 11 | } 12 | 13 | interface WithBadgeProps { 14 | config: BadgeOrBadgeFn 15 | entry: HashEntry | undefined 16 | tag: string 17 | context: 'mdx' | 'sidebar' | 'toolbar' 18 | } 19 | 20 | const WithTooltipPatched = styled(WithTooltip)` 21 | line-height: 1px; 22 | ` 23 | 24 | const BadgeUI = styled.div< 25 | Pick & { extraStyle: CSSObject; hasLongText: boolean } 26 | >(({ as, context, extraStyle, hasLongText, theme }) => ({ 27 | display: 'inline-block', 28 | fontSize: 11, 29 | lineHeight: '.75rem', 30 | alignSelf: 'center', 31 | padding: context === 'sidebar' ? '3px 8px' : '4px 12px', 32 | border: 'none', 33 | cursor: 34 | as === 'button' ? 'help' : context === 'sidebar' ? 'cursor' : 'initial', 35 | borderRadius: '3em', 36 | fontWeight: theme.typography.weight.bold, 37 | boxShadow: 38 | theme.base === 'light' 39 | ? `inset 0 0 0 1px ${extraStyle.borderColor ?? `color-mix(in oklab, ${extraStyle.color ?? theme.color.dark} 10%, transparent 90%)`}` 40 | : `inset 0 0 0 1px ${extraStyle.borderColor ?? 'none'}`, 41 | backgroundColor: theme.color.mediumlight, 42 | color: theme.color.dark, 43 | wordBreak: 'normal', 44 | width: hasLongText ? 'min-content' : 'fit-content', 45 | flexShrink: 0, 46 | textWrapStyle: 'pretty', 47 | textAlign: 'center', 48 | ...extraStyle, 49 | borderColor: undefined, 50 | })) 51 | 52 | const TooltipUI = styled.div(({ theme }) => ({ 53 | padding: '8px 12px', 54 | boxSizing: 'border-box', 55 | color: theme.color.defaultText, 56 | lineHeight: '1.125rem', 57 | })) 58 | 59 | export const Badge: React.FC = ({ 60 | context, 61 | style, 62 | text, 63 | tooltip, 64 | }) => { 65 | const theme = useTheme() 66 | 67 | let extraStyle 68 | if (style === 'green') { 69 | extraStyle = { 70 | backgroundColor: 'hsl(130, 100%, 74%)', 71 | borderColor: 'hsl(130, 100%, 34%)', 72 | color: 'hsl(130, 100%, 6%)', 73 | } 74 | } else if (style === 'purple') { 75 | extraStyle = { 76 | backgroundColor: 'hsl(257, 100%, 84%)', 77 | borderColor: 'hsl(257, 100%, 64%)', 78 | color: 'hsl(257, 100%, 12%)', 79 | } 80 | } else if (style === 'blue') { 81 | extraStyle = { 82 | backgroundColor: 'hsl(194, 100%, 74%)', 83 | borderColor: 'hsl(194, 100%, 34%)', 84 | color: 'hsl(194, 100%, 12%)', 85 | } 86 | } else if (style === 'grey') { 87 | extraStyle = { 88 | backgroundColor: 'hsl(0, 0%, 84%)', 89 | borderColor: 'hsl(0, 0%, 34%)', 90 | color: 'hsl(0, 0%, 12%)', 91 | } 92 | } else if (style === 'orange') { 93 | extraStyle = { 94 | backgroundColor: 'hsl(16, 100%, 74%)', 95 | borderColor: 'hsl(16, 100%, 34%)', 96 | color: 'hsl(16, 100%, 12%)', 97 | } 98 | } else if (style === 'red') { 99 | extraStyle = { 100 | backgroundColor: 'hsl(0, 100%, 44%)', 101 | borderColor: 'hsl(0, 100%, 64%)', 102 | color: 'hsl(0, 100%, 94%)', 103 | } 104 | } else if (style === 'yellow') { 105 | extraStyle = { 106 | backgroundColor: 'hsl(36, 100%, 74%)', 107 | borderColor: 'hsl(36, 100%, 34%)', 108 | color: 'hsl(36, 100%, 12%)', 109 | } 110 | } else if (style === 'pink') { 111 | extraStyle = { 112 | backgroundColor: 'hsl(330, 100%, 74%)', 113 | borderColor: 'hsl(330, 100%, 34%)', 114 | color: 'hsl(330, 100%, 12%)', 115 | } 116 | } else if (style === 'turquoise') { 117 | extraStyle = { 118 | backgroundColor: 'hsl(157, 100%, 74%)', 119 | borderColor: 'hsl(157, 100%, 34%)', 120 | color: 'hsl(157, 100%, 12%)', 121 | } 122 | } else if (typeof style === 'object') { 123 | extraStyle = { 124 | ...style, 125 | } 126 | } 127 | 128 | if (typeof text !== 'string') { 129 | throw new Error( 130 | 'Badge: the text prop must be defined and must be a string.', 131 | ) 132 | } 133 | 134 | const hasLongText = text.length > 15 135 | 136 | return ( 137 | 138 | {!tooltip || context == 'sidebar' ? ( 139 | 145 | {text} 146 | 147 | ) : ( 148 | {tooltip} 154 | ) : ( 155 | 156 | ) 157 | } 158 | > 159 | 166 | {text} 167 | 168 | 169 | )} 170 | 171 | ) 172 | } 173 | 174 | export function getBadgeProps( 175 | config: BadgeOrBadgeFn, 176 | entry: HashEntry | undefined, 177 | tag: string, 178 | context: 'mdx' | 'sidebar' | 'toolbar', 179 | ): Omit { 180 | const props = 181 | typeof config === 'function' 182 | ? config({ context, entry, getTagParts, getTagPrefix, getTagSuffix, tag }) 183 | : config 184 | 185 | return props 186 | } 187 | 188 | export const WithBadge: React.FC = ({ 189 | context, 190 | config, 191 | entry, 192 | tag, 193 | ...restProps 194 | }) => { 195 | const cfg = getBadgeProps(config, entry, tag, context) 196 | 197 | return 198 | } 199 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, colour, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behaviour that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behaviour include: 29 | 30 | - The use of sexualised language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Project maintainers are responsible for clarifying and enforcing our standards of 42 | acceptable behaviour and will take appropriate and fair corrective action in 43 | response to any behaviour that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Project maintainers have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be 62 | reported to Steve Dodier-Lazaro, the project owner responsible for enforcement, at 63 | sidnioulz@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All project maintainers are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | Should you want to make a report about the people responsible for enforcement of 70 | this code of conduct, we currently do not have a process in place and encourage 71 | you to pursue legal options if applicable. 72 | 73 | ## Enforcement Guidelines 74 | 75 | Project maintainers will follow these Community Impact Guidelines in determining 76 | the consequences for any action they deem in violation of this Code of Conduct: 77 | 78 | ### 1. Correction 79 | 80 | **Community Impact**: Use of inappropriate language or other behaviour deemed 81 | unprofessional or unwelcome in the community. 82 | 83 | **Consequence**: A private, written warning from project maintainers, providing 84 | clarity around the nature of the violation and an explanation of why the 85 | behaviour was inappropriate. A public apology may be requested. 86 | 87 | ### 2. Warning 88 | 89 | **Community Impact**: A violation through a single incident or series of 90 | actions. 91 | 92 | **Consequence**: A warning with consequences for continued behaviour. No 93 | interaction with the people involved, including unsolicited interaction with 94 | those enforcing the Code of Conduct, for a specified period of time. This 95 | includes avoiding interactions in community spaces as well as external channels 96 | like social media. Violating these terms may lead to a temporary or permanent 97 | ban. 98 | 99 | ### 3. Temporary Ban 100 | 101 | **Community Impact**: A serious violation of community standards, including 102 | sustained inappropriate behaviour. 103 | 104 | **Consequence**: A temporary ban from any sort of interaction or public 105 | communication with the community for a specified period of time. No public or 106 | private interaction with the people involved, including unsolicited interaction 107 | with those enforcing the Code of Conduct, is allowed during this period. 108 | Violating these terms may lead to a permanent ban. 109 | 110 | ### 4. Permanent Ban 111 | 112 | **Community Impact**: Demonstrating a pattern of violation of community 113 | standards, including sustained inappropriate behaviour, harassment of an 114 | individual, or aggression toward or disparagement of classes of individuals. 115 | 116 | **Consequence**: A permanent ban from any sort of public interaction within the 117 | community. 118 | 119 | ## Attribution 120 | 121 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 122 | version 2.1, available at 123 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 124 | 125 | Community Impact Guidelines were inspired by 126 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 127 | 128 | For answers to common questions about this code of conduct, see the FAQ at 129 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 130 | [https://www.contributor-covenant.org/translations][translations]. 131 | 132 | [homepage]: https://www.contributor-covenant.org 133 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 134 | [Mozilla CoC]: https://github.com/mozilla/diversity 135 | [FAQ]: https://www.contributor-covenant.org/faq 136 | [translations]: https://www.contributor-covenant.org/translations 137 | -------------------------------------------------------------------------------- /src/stories/Introduction.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks' 2 | import Code from './assets/code-brackets.svg' 3 | import Colors from './assets/colors.svg' 4 | import Comments from './assets/comments.svg' 5 | import Direction from './assets/direction.svg' 6 | import Flow from './assets/flow.svg' 7 | import Plugin from './assets/plugin.svg' 8 | import Repo from './assets/repo.svg' 9 | import StackAlt from './assets/stackalt.svg' 10 | 11 | 12 | 13 | 116 | 117 | # Welcome to Storybook 118 | 119 | Storybook helps you build UI components in isolation from your app's business logic, data, and context. 120 | That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA. 121 | 122 | Browse example stories now by navigating to them in the sidebar. 123 | View their code in the `stories` directory to learn how they work. 124 | We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages. 125 | 126 |
Configure
127 | 128 | 174 | 175 |
Learn
176 | 177 | 215 | 216 |
217 | TipEdit the Markdown in{' '} 218 | stories/Introduction.stories.mdx 219 |
220 | -------------------------------------------------------------------------------- /src/utils/__tests__/display.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DISPLAY_DEFAULTS, 3 | DisplayOutcome, 4 | normaliseDisplay, 5 | shouldDisplay, 6 | type ShouldDisplayOptions, 7 | } from '../display' 8 | 9 | describe('display', () => { 10 | describe('normaliseDisplay', () => { 11 | it('should return defaults when display is not set', () => { 12 | expect(normaliseDisplay(undefined)).toMatchObject(DISPLAY_DEFAULTS) 13 | }) 14 | 15 | it('should return the default sidebar when sidebar is omitted', () => { 16 | expect(normaliseDisplay({ toolbar: false })).toMatchObject({ 17 | mdx: DISPLAY_DEFAULTS.mdx, 18 | sidebar: DISPLAY_DEFAULTS.sidebar, 19 | toolbar: [false], 20 | }) 21 | }) 22 | 23 | it('should return the default toolbar when toolbar is omitted', () => { 24 | expect(normaliseDisplay({ sidebar: false })).toMatchObject({ 25 | mdx: DISPLAY_DEFAULTS.mdx, 26 | sidebar: [false], 27 | toolbar: DISPLAY_DEFAULTS.toolbar, 28 | }) 29 | }) 30 | 31 | it('should return arrays when elements are not arrays', () => { 32 | expect( 33 | normaliseDisplay({ sidebar: false, toolbar: false }), 34 | ).toMatchObject({ 35 | mdx: DISPLAY_DEFAULTS.mdx, 36 | sidebar: [false], 37 | toolbar: [false], 38 | }) 39 | }) 40 | 41 | it('should return elements when they already are arrays', () => { 42 | expect( 43 | normaliseDisplay({ sidebar: [false, true], toolbar: ['story'] }), 44 | ).toMatchObject({ 45 | mdx: DISPLAY_DEFAULTS.mdx, 46 | sidebar: [false, true], 47 | toolbar: ['story'], 48 | }) 49 | }) 50 | }) 51 | 52 | describe('shouldDisplay', () => { 53 | it('should only display non-inherited tags for stories in the sidebar by default', () => { 54 | expect( 55 | shouldDisplay({ 56 | config: { 57 | display: DISPLAY_DEFAULTS, 58 | }, 59 | type: 'story', 60 | context: 'sidebar', 61 | }), 62 | ).toBe(DisplayOutcome.SKIP_INHERITED) 63 | }) 64 | 65 | it('should display stories in the toolbar by default', () => { 66 | expect( 67 | shouldDisplay({ 68 | config: { display: DISPLAY_DEFAULTS }, 69 | type: 'story', 70 | context: 'toolbar', 71 | }), 72 | ).toBe(DisplayOutcome.ALWAYS) 73 | }) 74 | 75 | it('should only display non-inherited tags for docs in the sidebar by default', () => { 76 | expect( 77 | shouldDisplay({ 78 | config: { display: DISPLAY_DEFAULTS }, 79 | type: 'docs', 80 | context: 'sidebar', 81 | }), 82 | ).toBe(DisplayOutcome.SKIP_INHERITED) 83 | }) 84 | 85 | it('should display docs in the toolbar by default', () => { 86 | expect( 87 | shouldDisplay({ 88 | config: { display: DISPLAY_DEFAULTS }, 89 | type: 'docs', 90 | context: 'toolbar', 91 | }), 92 | ).toBe(DisplayOutcome.ALWAYS) 93 | }) 94 | 95 | it('should display component in the sidebar by default', () => { 96 | expect( 97 | shouldDisplay({ 98 | config: { display: DISPLAY_DEFAULTS }, 99 | type: 'component', 100 | context: 'sidebar', 101 | }), 102 | ).toBe(DisplayOutcome.ALWAYS) 103 | }) 104 | 105 | it('should NOT display component in the toolbar by default', () => { 106 | expect( 107 | shouldDisplay({ 108 | config: { display: DISPLAY_DEFAULTS }, 109 | type: 'component', 110 | context: 'toolbar', 111 | }), 112 | ).toBe(DisplayOutcome.NEVER) 113 | }) 114 | 115 | it('should NOT display component in the toolbar by default', () => { 116 | expect( 117 | shouldDisplay({ 118 | config: { display: DISPLAY_DEFAULTS }, 119 | type: 'group', 120 | context: 'sidebar', 121 | }), 122 | ).toBe(DisplayOutcome.ALWAYS) 123 | }) 124 | 125 | it('should display group in the sidebar by default', () => { 126 | expect( 127 | shouldDisplay({ 128 | config: { display: DISPLAY_DEFAULTS }, 129 | type: 'group', 130 | context: 'toolbar', 131 | }), 132 | ).toBe(DisplayOutcome.NEVER) 133 | }) 134 | 135 | it('should return false on root type', () => { 136 | expect( 137 | shouldDisplay({ 138 | config: { display: DISPLAY_DEFAULTS }, 139 | type: 'root', 140 | context: 'toolbar', 141 | }), 142 | ).toBe(DisplayOutcome.NEVER) 143 | }) 144 | 145 | it.each( 146 | ['sidebar', 'toolbar'].flatMap((context) => 147 | ['component', 'docs', 'story', 'group'].map((type) => ({ 148 | context, 149 | type, 150 | })), 151 | ), 152 | )( 153 | 'should always return false when config is false (type %s context %s)', 154 | ({ type, context }) => { 155 | expect( 156 | shouldDisplay({ 157 | config: { display: { sidebar: false, toolbar: false } }, 158 | type, 159 | context, 160 | } as ShouldDisplayOptions), 161 | ).toBe(DisplayOutcome.NEVER) 162 | }, 163 | ) 164 | 165 | it.each( 166 | ['sidebar', 'toolbar'].flatMap((context) => 167 | ['component', 'docs', 'story', 'group'].map((type) => ({ 168 | context, 169 | type, 170 | })), 171 | ), 172 | )( 173 | 'should always return ALWAYS when config is true (type %s context %s)', 174 | ({ type, context }) => { 175 | expect( 176 | shouldDisplay({ 177 | config: { display: { sidebar: true, toolbar: true } }, 178 | type, 179 | context, 180 | } as ShouldDisplayOptions), 181 | ).toBe(DisplayOutcome.ALWAYS) 182 | }, 183 | ) 184 | 185 | it.each( 186 | ['component', 'docs', 'story', 'group'].map((type) => ({ 187 | type, 188 | })), 189 | )( 190 | 'should always return ALWAYS when config matches the input for the toolbar (type %s)', 191 | ({ type }) => { 192 | expect( 193 | shouldDisplay({ 194 | config: { display: { toolbar: type } }, 195 | type, 196 | context: 'toolbar', 197 | } as ShouldDisplayOptions), 198 | ).toBe(DisplayOutcome.ALWAYS) 199 | }, 200 | ) 201 | 202 | it.each( 203 | ['component', 'docs', 'story', 'group'].map((type) => ({ 204 | type, 205 | })), 206 | )( 207 | 'should always return SKIP_INHERITED when config matches the input for the sidebar and skipInherited is true (type %s)', 208 | ({ type }) => { 209 | expect( 210 | shouldDisplay({ 211 | config: { display: { sidebar: { type, skipInherited: true } } }, 212 | type, 213 | context: 'sidebar', 214 | } as ShouldDisplayOptions), 215 | ).toBe(DisplayOutcome.SKIP_INHERITED) 216 | }, 217 | ) 218 | 219 | it.each( 220 | ['component', 'docs', 'story', 'group'].map((type) => ({ 221 | type, 222 | })), 223 | )( 224 | 'should always return ALWAYS when config matches the input for the sidebar and skipInherited is false (type %s)', 225 | ({ type }) => { 226 | expect( 227 | shouldDisplay({ 228 | config: { display: { sidebar: { type, skipInherited: false } } }, 229 | type, 230 | context: 'sidebar', 231 | } as ShouldDisplayOptions), 232 | ).toBe(DisplayOutcome.ALWAYS) 233 | }, 234 | ) 235 | }) 236 | }) 237 | -------------------------------------------------------------------------------- /src/examples/BadgeWorkflows.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { Meta, StoryObj } from '@storybook/react-vite' 3 | import { Title, Stories } from '@storybook/addon-docs/blocks' 4 | 5 | import { Badge, getBadgeProps } from '../components/Badge' 6 | import { 7 | brandComponents, 8 | byMarket, 9 | compliance, 10 | composition, 11 | dependencies, 12 | smartComponents, 13 | } from './sampleWorkflows' 14 | import { mockEntry } from './__fixtures__/HashEntry' 15 | import { TagBadgeParameters } from 'src/types/TagBadgeParameters' 16 | import { styled } from 'storybook/theming' 17 | import toSource from 'tosource' 18 | 19 | const disabledArgType = { 20 | control: { 21 | disable: true, 22 | }, 23 | table: { 24 | disable: true, 25 | }, 26 | } 27 | 28 | interface SampleBadgeProps { 29 | sample: TagBadgeParameters 30 | tags: { tag: string; index?: number }[] 31 | } 32 | 33 | const List = styled.ul` 34 | list-style-type: none; 35 | display: flex; 36 | gap: 4px; 37 | ` 38 | 39 | const SampleBadgeList = ({ sample, tags }: SampleBadgeProps) => ( 40 | 41 | {tags.map(({ tag, index = 0 }) => ( 42 |
  • 43 | 47 |
  • 48 | ))} 49 |
    50 | ) 51 | 52 | const meta: Meta = { 53 | title: 'Addon/Sample Workflows', 54 | parameters: { 55 | docs: { 56 | description: { 57 | component: 58 | 'This page showcases various workflows that can be implemented with this addon.', 59 | }, 60 | page: () => ( 61 | <> 62 | 63 | <Stories /> 64 | </> 65 | ), 66 | }, 67 | layout: 'centered', 68 | }, 69 | 70 | tags: ['autodocs'], 71 | argTypes: { 72 | text: disabledArgType, 73 | style: disabledArgType, 74 | tooltip: disabledArgType, 75 | }, 76 | } 77 | 78 | export default meta 79 | type Story = StoryObj<typeof Badge> 80 | 81 | export const MarketSegmentation: Story = { 82 | parameters: { 83 | docs: { 84 | description: { 85 | story: 86 | 'You could highlight components intended for a specific type of product or market, e.g. because of different privacy or accessibility regulatory practices (government), or to account for preferred layout density (B2B vs B2C).', 87 | }, 88 | source: { 89 | code: toSource(byMarket), 90 | language: 'js', 91 | }, 92 | }, 93 | }, 94 | render: () => ( 95 | <SampleBadgeList 96 | sample={byMarket} 97 | tags={[ 98 | { tag: 'market:b2b' }, 99 | { tag: 'market:b2c' }, 100 | { tag: 'market:finance' }, 101 | { tag: 'market:health' }, 102 | { tag: 'market:government' }, 103 | { tag: 'market:all' }, 104 | ]} 105 | /> 106 | ), 107 | } 108 | 109 | export const BrandExpressiveness: Story = { 110 | parameters: { 111 | docs: { 112 | description: { 113 | story: 114 | 'In some Design Systems, some components are made purely for brand expression, to help create highly-recognisable interfaces for e.g. acquisition or onboarding. Tagging those components (or variants) can help designers balance the amount of brand expression in a page more purposefully.', 115 | }, 116 | source: { 117 | code: toSource(brandComponents), 118 | language: 'js', 119 | }, 120 | }, 121 | }, 122 | render: () => ( 123 | <SampleBadgeList 124 | sample={brandComponents} 125 | tags={[{ tag: 'brand' }, { tag: 'ui', index: 1 }]} 126 | /> 127 | ), 128 | } 129 | 130 | export const Compliance: Story = { 131 | parameters: { 132 | docs: { 133 | description: { 134 | story: 135 | 'You may track the status of various checks using badges, ideally with a script that keeps this content up-to-date automatically. Checks from external teams can include accessibility compliance (WCAG), brand or content reviews, QA or SEO reviews.', 136 | }, 137 | source: { 138 | code: toSource(composition), 139 | language: 'js', 140 | }, 141 | }, 142 | }, 143 | render: () => ( 144 | <SampleBadgeList 145 | sample={compliance} 146 | tags={[ 147 | { tag: 'a11y:fail', index: 0 }, 148 | { tag: 'brand:fail', index: 0 }, 149 | { tag: 'content:fail', index: 0 }, 150 | { tag: 'qa:fail', index: 0 }, 151 | { tag: 'seo:fail', index: 0 }, 152 | { tag: 'a11y:success', index: 1 }, 153 | { tag: 'brand:success', index: 1 }, 154 | { tag: 'content:success', index: 1 }, 155 | { tag: 'qa:success', index: 1 }, 156 | { tag: 'seo:success', index: 1 }, 157 | ]} 158 | /> 159 | ), 160 | } 161 | 162 | export const Composition: Story = { 163 | parameters: { 164 | docs: { 165 | description: { 166 | story: 167 | "You may use badges to indicate components intended to be used together as a form of [component composition](https://lit.dev/docs/composition/component-composition/). Some types of components, like cards, dialogs or forms elements, are particularly suited to this pattern. Some basic components like Labels or Buttons can be composed with several other groups of components, so you could apply multiple badges to those.\n\nStorybook 8.4 allows you to filter the sidebar by tags, allowing your users to get a bird's-eye view of all the content available to compose cards, dialogs, etc.", 168 | }, 169 | source: { 170 | code: toSource(composition), 171 | language: 'js', 172 | }, 173 | }, 174 | }, 175 | render: () => ( 176 | <SampleBadgeList 177 | sample={composition} 178 | tags={[ 179 | { tag: 'compose:card' }, 180 | { tag: 'compose:dialog' }, 181 | { tag: 'compose:form' }, 182 | { tag: 'compose:all' }, 183 | ]} 184 | /> 185 | ), 186 | } 187 | 188 | export const ExternalDependencies: Story = { 189 | parameters: { 190 | docs: { 191 | description: { 192 | story: 193 | "You may highlight the external services a component depends on to function properly, to help your users avoid common issues. This could include external data stores like a cache, database or localStorage, or features that involve a context provider or app store to be initialised, like i18n, theming or access to the end user's profile and permissions.", 194 | }, 195 | source: { 196 | code: toSource(composition), 197 | language: 'js', 198 | }, 199 | }, 200 | }, 201 | render: () => ( 202 | <SampleBadgeList 203 | sample={dependencies} 204 | tags={[ 205 | { tag: 'uses:cache' }, 206 | { tag: 'uses:i18n' }, 207 | { tag: 'uses:theme' }, 208 | { tag: 'uses:localStorage' }, 209 | { tag: 'uses:database' }, 210 | { tag: 'uses:auth' }, 211 | { tag: 'uses:store' }, 212 | ]} 213 | /> 214 | ), 215 | } 216 | 217 | export const SmartComponents: Story = { 218 | parameters: { 219 | docs: { 220 | description: { 221 | story: 222 | 'If your team provides specialised versions of components optimised for use with a third-party service, aka. [smart components](https://bradfrost.com/blog/post/the-design-system-ecosystem/), you may highlight the different available flavours with a badge.\n\nFor instance, a Form component could be published to integrate with Zod schemas, and another to integrate with a Redux store. Another one could be a pure HTML form for contexts without frontend frameworks.', 223 | }, 224 | source: { 225 | code: toSource(smartComponents), 226 | language: 'js', 227 | }, 228 | }, 229 | }, 230 | render: () => ( 231 | <SampleBadgeList 232 | sample={smartComponents} 233 | tags={[ 234 | { tag: 'smart:redux' }, 235 | { tag: 'smart:html' }, 236 | { tag: 'smart:zod' }, 237 | ]} 238 | /> 239 | ), 240 | } 241 | -------------------------------------------------------------------------------- /src/stories/assets/colors.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/colors -------------------------------------------------------------------------------- /src/utils/__tests__/tag.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getTagParts, 3 | getTagPrefix, 4 | getTagSuffix, 5 | matchTag, 6 | matchTags, 7 | } from '../tag' 8 | 9 | describe('tag', () => { 10 | describe('getTagParts', () => { 11 | it('should return only a prefix on a single-part tag', () => { 12 | expect(getTagParts('foo')).toMatchObject({ 13 | prefix: 'foo', 14 | suffix: null, 15 | }) 16 | }) 17 | 18 | it('should return prefix and suffix on a two-part tag', () => { 19 | expect(getTagParts('foo:bar')).toMatchObject({ 20 | prefix: 'foo', 21 | suffix: 'bar', 22 | }) 23 | }) 24 | 25 | it('should return all parts beyond the prefix in the suffix', () => { 26 | expect(getTagParts('foo:bar:ter')).toMatchObject({ 27 | prefix: 'foo', 28 | suffix: 'bar:ter', 29 | }) 30 | }) 31 | }) 32 | 33 | describe('getTagPrefix', () => { 34 | it('should return only a prefix on a single-part tag', () => { 35 | expect(getTagPrefix('foo')).toBe('foo') 36 | }) 37 | 38 | it('should return prefix on a two-part tag', () => { 39 | expect(getTagPrefix('foo:bar')).toBe('foo') 40 | }) 41 | 42 | it('should return prefix on a many-part tag', () => { 43 | expect(getTagPrefix('foo:bar:ter')).toBe('foo') 44 | }) 45 | }) 46 | 47 | describe('getTagSuffix', () => { 48 | it('should return null on a single-part tag', () => { 49 | expect(getTagSuffix('foo')).toBe(null) 50 | }) 51 | 52 | it('should return suffix on a two-part tag', () => { 53 | expect(getTagSuffix('foo:bar')).toBe('bar') 54 | }) 55 | 56 | it('should return suffix on a many-part tag', () => { 57 | expect(getTagSuffix('foo:bar:ter')).toBe('bar:ter') 58 | }) 59 | }) 60 | 61 | describe('matchTag', () => { 62 | it('string - matches when the tag is identical', () => { 63 | expect(matchTag('foo', 'foo')).toBe(true) 64 | }) 65 | 66 | it('string - fails when the tag is different', () => { 67 | expect(matchTag('foo', 'bar')).toBe(false) 68 | }) 69 | 70 | it('string array - matches when the tag is included', () => { 71 | expect(matchTag('foo', ['foo'])).toBe(true) 72 | }) 73 | 74 | it('string array - does not partial match', () => { 75 | expect(matchTag('weasel', ['ease'])).toBe(false) 76 | expect(matchTag('weasel', ['easel'])).toBe(false) 77 | expect(matchTag('weasel', ['we'])).toBe(false) 78 | }) 79 | 80 | it('string array - fails when the tag is not included', () => { 81 | expect(matchTag('foo', ['bar'])).toBe(false) 82 | }) 83 | 84 | it('regex - matches when the tag is included', () => { 85 | expect(matchTag('foo', /^fo+/)).toBe(true) 86 | }) 87 | 88 | it('regex - fails when the tag is not included', () => { 89 | expect(matchTag('foo', /^ba?r$/)).toBe(false) 90 | }) 91 | 92 | it('regex array - matches when the tag is included', () => { 93 | expect(matchTag('foo', [/^fo+/])).toBe(true) 94 | }) 95 | 96 | it('regex array - fails when the tag is not included', () => { 97 | expect(matchTag('foo', [/^ba?r$/])).toBe(false) 98 | }) 99 | 100 | it('object - matches string prefix only', () => { 101 | expect(matchTag('foo:x', { prefix: 'foo' })).toBe(true) 102 | }) 103 | 104 | it('object - matches string suffix only', () => { 105 | expect(matchTag('x:foo', { suffix: 'foo' })).toBe(true) 106 | }) 107 | 108 | it('object - matches both string prefix and suffix', () => { 109 | expect(matchTag('foo:bar', { prefix: 'foo', suffix: 'bar' })).toBe(true) 110 | }) 111 | 112 | it('object - matches regex prefix only', () => { 113 | expect(matchTag('foo:bar', { prefix: /fo+/ })).toBe(true) 114 | }) 115 | 116 | it('object - matches regex suffix only', () => { 117 | expect(matchTag('foo:bar', { suffix: /ba+/ })).toBe(true) 118 | }) 119 | 120 | it('object - matches both regex prefix and suffix', () => { 121 | expect(matchTag('foo:bar', { prefix: /fo+/, suffix: /bar+/ })).toBe(true) 122 | }) 123 | 124 | it('object - matches regex prefix with a starting character', () => { 125 | expect(matchTag('foo:bar', { prefix: /^fo+/ })).toBe(true) 126 | }) 127 | 128 | it('object - matches regex suffix with a starting character', () => { 129 | expect(matchTag('foo:bar', { suffix: /^ba+r?/ })).toBe(true) 130 | }) 131 | 132 | it('object - matches both regex prefix and suffix with a starting character', () => { 133 | expect(matchTag('foo:bar', { prefix: /^fo+/, suffix: /^ba+r?/ })).toBe( 134 | true, 135 | ) 136 | }) 137 | 138 | it('object - matches regex prefix with an ending character', () => { 139 | expect(matchTag('foo:bar', { prefix: /fo+$/ })).toBe(true) 140 | }) 141 | 142 | it('object - matches regex suffix with an ending character', () => { 143 | expect(matchTag('foo:bar', { suffix: /ba+r?$/ })).toBe(true) 144 | }) 145 | 146 | it('object - matches both regex prefix and suffix with an ending character', () => { 147 | expect(matchTag('foo:bar', { prefix: /fo+$/, suffix: /ba+r?$/ })).toBe( 148 | true, 149 | ) 150 | }) 151 | 152 | it('object - fails to match a suffixless tag when a suffix regex is passed', () => { 153 | expect(matchTag('foo', { suffix: /ba+r?$/ })).toBe(false) 154 | }) 155 | 156 | it('object - fails to match prefix if the string is not multipart', () => { 157 | expect(matchTag('foo', { prefix: 'foo' })).toBe(false) 158 | }) 159 | 160 | it('object - fails to match suffix if the string is not multipart', () => { 161 | expect(matchTag('foo', { suffix: 'foo' })).toBe(false) 162 | }) 163 | 164 | it('object - fails non-matching string prefix', () => { 165 | expect(matchTag('foo:bar', { prefix: 'boo' })).toBe(false) 166 | }) 167 | 168 | it('object - fails non-matching string suffix', () => { 169 | expect(matchTag('foo:bar', { suffix: 'baa' })).toBe(false) 170 | }) 171 | 172 | it('object - fails non-matching string prefix and suffix', () => { 173 | expect(matchTag('foo:bar', { prefix: 'boo', suffix: 'baa' })).toBe(false) 174 | }) 175 | 176 | it('object - fails non-matching regex prefix', () => { 177 | expect(matchTag('foo:bar', { prefix: /bo+/ })).toBe(false) 178 | }) 179 | 180 | it('object - fails non-matching regex suffix', () => { 181 | expect(matchTag('foo:bar', { suffix: /ba+$/ })).toBe(false) 182 | }) 183 | 184 | it('object - fails non-matching regex prefix and suffix', () => { 185 | expect(matchTag('foo:bar', { prefix: /bo+/, suffix: /ba+$/ })).toBe(false) 186 | }) 187 | 188 | it('object - fails a partial match where only prefix is correct', () => { 189 | expect(matchTag('foo:bar', { prefix: 'foo', suffix: 'wrong' })).toBe( 190 | false, 191 | ) 192 | }) 193 | 194 | it('object - fails a partial match where only suffix is correct', () => { 195 | expect(matchTag('foo:bar', { prefix: 'wrong', suffix: 'bar' })).toBe( 196 | false, 197 | ) 198 | }) 199 | 200 | it('object - string prefix requires exact match', () => { 201 | expect(matchTag('foobar:test', { prefix: 'foo' })).toBe(false) 202 | expect(matchTag('foo:test', { prefix: 'foo' })).toBe(true) 203 | expect(matchTag('xfoo:test', { prefix: 'foo' })).toBe(false) 204 | expect(matchTag('foox:test', { prefix: 'foo' })).toBe(false) 205 | }) 206 | 207 | it('object - string suffix requires exact match', () => { 208 | expect(matchTag('test:foobar', { suffix: 'foo' })).toBe(false) 209 | expect(matchTag('test:foo', { suffix: 'foo' })).toBe(true) 210 | expect(matchTag('test:xfoo', { suffix: 'foo' })).toBe(false) 211 | expect(matchTag('test:foox', { suffix: 'foo' })).toBe(false) 212 | }) 213 | 214 | it('object - RegExp prefix allows partial matches', () => { 215 | expect(matchTag('foobar:test', { prefix: /foo/ })).toBe(true) 216 | expect(matchTag('xfoo:test', { prefix: /foo/ })).toBe(true) 217 | expect(matchTag('foox:test', { prefix: /foo/ })).toBe(true) 218 | expect(matchTag('bar:test', { prefix: /foo/ })).toBe(false) 219 | }) 220 | 221 | it('object - RegExp suffix allows partial matches', () => { 222 | expect(matchTag('test:foobar', { suffix: /foo/ })).toBe(true) 223 | expect(matchTag('test:xfoo', { suffix: /foo/ })).toBe(true) 224 | expect(matchTag('test:foox', { suffix: /foo/ })).toBe(true) 225 | expect(matchTag('test:bar', { suffix: /foo/ })).toBe(false) 226 | }) 227 | 228 | it('object - prefix is case sensitive', () => { 229 | expect(matchTag('FOO:test', { prefix: 'foo' })).toBe(false) 230 | expect(matchTag('FOO:test', { prefix: /foo/ })).toBe(false) 231 | }) 232 | 233 | it('object - RegExp prefix with case insensitive flag', () => { 234 | expect(matchTag('FOO:test', { prefix: /foo/i })).toBe(true) 235 | expect(matchTag('FoO:test', { prefix: /foo/i })).toBe(true) 236 | expect(matchTag('foo:test', { prefix: /foo/i })).toBe(true) 237 | }) 238 | 239 | it('object - suffix is case sensitive', () => { 240 | expect(matchTag('test:FOO', { suffix: 'foo' })).toBe(false) 241 | expect(matchTag('test:FOO', { suffix: /foo/ })).toBe(false) 242 | }) 243 | 244 | it('object - RegExp suffix with case insensitive flag', () => { 245 | expect(matchTag('test:FOO', { suffix: /foo/i })).toBe(true) 246 | expect(matchTag('test:FoO', { suffix: /foo/i })).toBe(true) 247 | expect(matchTag('test:foo', { suffix: /foo/i })).toBe(true) 248 | }) 249 | }) 250 | 251 | describe('matchTags', () => { 252 | it('should return an empty array when receiving an empty array', () => { 253 | const input: string[] = [] 254 | const output = matchTags(input, /.*/) 255 | expect(output).toHaveLength(0) 256 | }) 257 | it('should return an empty array when nothing matches', () => { 258 | const input = ['version:1.0.0', 'foo', 'bar', 'version:2.0.0'] 259 | const output = matchTags(input, { suffix: 'weasels' }) 260 | expect(output).toHaveLength(0) 261 | }) 262 | 263 | it('should return a partial match when not every tag matches', () => { 264 | const input = ['version:1.0.0', 'foo', 'bar', 'version:2.0.0'] 265 | const output = matchTags(input, { prefix: 'version' }) 266 | expect(output).toHaveLength(2) 267 | expect(output).toContain('version:1.0.0') 268 | expect(output).toContain('version:2.0.0') 269 | }) 270 | 271 | it('should return a full match when every tag matches', () => { 272 | const input = ['version:1.0.0', 'version:2.0.0'] 273 | const output = matchTags(input, { prefix: 'version' }) 274 | expect(input).toEqual(output) 275 | }) 276 | }) 277 | }) 278 | -------------------------------------------------------------------------------- /src/__tests__/useBadgesToDisplay.spec.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react' 2 | 3 | import { useBadgesToDisplay } from '../useBadgesToDisplay' 4 | import { TagBadgeParameters } from '../types/TagBadgeParameters' 5 | import { BadgeOrBadgeFn } from '../types/Badge' 6 | import { defaultConfig } from '../defaultConfig' 7 | 8 | vi.mock('storybook/manager-api', () => ({ 9 | useStorybookApi: vi.fn(() => ({ 10 | resolveStory: vi.fn().mockImplementation((id) => { 11 | if (id === 'mock-component') { 12 | return { type: 'component', tags: ['test1', 'test2', 'test3'] } 13 | } 14 | return { type: 'component', tags: [] } 15 | }), 16 | })), 17 | })) 18 | 19 | describe('useBadgesToDisplay', () => { 20 | describe('Hook', () => { 21 | describe('matching', () => { 22 | const parameters: TagBadgeParameters = [ 23 | { 24 | tags: ['test1', 'test2'], 25 | badge: { text: 'Test Badge' }, 26 | display: { sidebar: true, toolbar: true }, 27 | }, 28 | ] 29 | 30 | it('returns an empty array when parameters are empty', () => { 31 | const { result } = renderHook(() => 32 | useBadgesToDisplay({ 33 | context: 'sidebar', 34 | parameters: [], 35 | tags: [], 36 | type: 'story', 37 | }), 38 | ) 39 | 40 | expect(result.current).toEqual([]) 41 | }) 42 | 43 | it("returns no match when input tags don't match parameters", () => { 44 | const { result } = renderHook(() => 45 | useBadgesToDisplay({ 46 | context: 'sidebar', 47 | parameters, 48 | tags: ['other'], 49 | type: 'story', 50 | }), 51 | ) 52 | 53 | expect(result.current).toHaveLength(0) 54 | }) 55 | 56 | it('returns a match when input tags match parameters', () => { 57 | const tag = 'test1' 58 | const { result } = renderHook(() => 59 | useBadgesToDisplay({ 60 | context: 'sidebar', 61 | parameters, 62 | tags: [tag], 63 | type: 'story', 64 | }), 65 | ) 66 | 67 | expect(result.current).toHaveLength(1) 68 | expect(result.current[0].tag).toEqual(tag) 69 | expect(result.current[0].badge).toEqual(parameters[0].badge) 70 | }) 71 | 72 | it('handles multiple matching tags', () => { 73 | const parameters: TagBadgeParameters = [ 74 | { 75 | tags: ['test1', 'test2'], 76 | badge: { text: 'Test Badge' }, 77 | display: { sidebar: true, toolbar: true }, 78 | }, 79 | ] 80 | 81 | const { result } = renderHook(() => 82 | useBadgesToDisplay({ 83 | context: 'sidebar', 84 | parameters, 85 | tags: ['test1', 'test2', 'other'], 86 | type: 'story', 87 | }), 88 | ) 89 | 90 | expect(result.current).toHaveLength(2) 91 | expect(result.current[0].tag).toBe('test1') 92 | expect(result.current[0].badge).toBe(parameters[0].badge) 93 | expect(result.current[1].tag).toBe('test2') 94 | expect(result.current[1].badge).toBe(parameters[0].badge) 95 | }) 96 | }) 97 | 98 | describe('shouldDisplay', () => { 99 | const parameters: TagBadgeParameters = [ 100 | { 101 | tags: ['test1'], 102 | badge: { text: 'Test1' }, 103 | display: { sidebar: true, toolbar: false }, 104 | }, 105 | { 106 | tags: ['test2'], 107 | badge: { text: 'Test2' }, 108 | display: { sidebar: false, toolbar: true }, 109 | }, 110 | { 111 | tags: ['test3'], 112 | badge: { text: 'Test3' }, 113 | display: { 114 | mdx: 'component', 115 | sidebar: [{ type: 'story', skipInherited: false }], 116 | toolbar: 'docs', 117 | }, 118 | }, 119 | ] 120 | 121 | it('only shows badges whose config matches the sidebar context', () => { 122 | const { result: sidebarResult } = renderHook(() => 123 | useBadgesToDisplay({ 124 | context: 'sidebar', 125 | parameters, 126 | tags: ['test1', 'test2'], 127 | type: 'story', 128 | }), 129 | ) 130 | 131 | expect(sidebarResult.current).toHaveLength(1) 132 | expect(sidebarResult.current[0].badge).toEqual({ text: 'Test1' }) 133 | }) 134 | 135 | it('only shows badges whose config matches the toolbar context', () => { 136 | const { result: toolbarResult } = renderHook(() => 137 | useBadgesToDisplay({ 138 | context: 'toolbar', 139 | parameters, 140 | tags: ['test1', 'test2'], 141 | type: 'story', 142 | }), 143 | ) 144 | 145 | expect(toolbarResult.current).toHaveLength(1) 146 | expect(toolbarResult.current[0].badge).toEqual({ text: 'Test2' }) 147 | }) 148 | 149 | it('only shows badges whose config matches the story hash entry type', () => { 150 | const { result: toolbarResult } = renderHook(() => 151 | useBadgesToDisplay({ 152 | context: 'toolbar', 153 | parameters, 154 | tags: ['test3'], 155 | type: 'story', 156 | }), 157 | ) 158 | 159 | const { result: mdxResult } = renderHook(() => 160 | useBadgesToDisplay({ 161 | context: 'mdx', 162 | parameters, 163 | tags: ['test3'], 164 | type: 'story', 165 | }), 166 | ) 167 | 168 | const { result: sidebarResult } = renderHook(() => 169 | useBadgesToDisplay({ 170 | context: 'sidebar', 171 | parameters, 172 | tags: ['test3'], 173 | type: 'story', 174 | }), 175 | ) 176 | 177 | expect(toolbarResult.current).toHaveLength(0) 178 | expect(mdxResult.current).toHaveLength(0) 179 | expect(sidebarResult.current).toHaveLength(1) 180 | expect(sidebarResult.current[0].badge).toEqual({ text: 'Test3' }) 181 | }) 182 | 183 | it('only shows badges whose config matches the component hash entry type', () => { 184 | const { result: toolbarResult } = renderHook(() => 185 | useBadgesToDisplay({ 186 | context: 'toolbar', 187 | parameters, 188 | tags: ['test3'], 189 | type: 'component', 190 | }), 191 | ) 192 | 193 | const { result: mdxResult } = renderHook(() => 194 | useBadgesToDisplay({ 195 | context: 'mdx', 196 | parameters, 197 | tags: ['test3'], 198 | type: 'component', 199 | }), 200 | ) 201 | 202 | const { result: sidebarResult } = renderHook(() => 203 | useBadgesToDisplay({ 204 | context: 'sidebar', 205 | parameters, 206 | tags: ['test3'], 207 | type: 'component', 208 | }), 209 | ) 210 | 211 | expect(toolbarResult.current).toHaveLength(0) 212 | expect(mdxResult.current).toHaveLength(1) 213 | expect(mdxResult.current[0].badge).toEqual({ text: 'Test3' }) 214 | expect(sidebarResult.current).toHaveLength(0) 215 | }) 216 | 217 | it('only shows badges whose config matches the docs hash entry type', () => { 218 | const { result: toolbarResult } = renderHook(() => 219 | useBadgesToDisplay({ 220 | context: 'toolbar', 221 | parameters, 222 | tags: ['test3'], 223 | type: 'docs', 224 | }), 225 | ) 226 | 227 | const { result: mdxResult } = renderHook(() => 228 | useBadgesToDisplay({ 229 | context: 'mdx', 230 | parameters, 231 | tags: ['test3'], 232 | type: 'docs', 233 | }), 234 | ) 235 | 236 | const { result: sidebarResult } = renderHook(() => 237 | useBadgesToDisplay({ 238 | context: 'sidebar', 239 | parameters, 240 | tags: ['test3'], 241 | type: 'docs', 242 | }), 243 | ) 244 | 245 | expect(toolbarResult.current).toHaveLength(1) 246 | expect(toolbarResult.current[0].badge).toEqual({ text: 'Test3' }) 247 | expect(mdxResult.current).toHaveLength(0) 248 | expect(sidebarResult.current).toHaveLength(0) 249 | }) 250 | }) 251 | 252 | describe('output correctness', () => { 253 | it('handles function form of badge parameter', () => { 254 | const badgeFunction: BadgeOrBadgeFn = () => ({ text: 'Test Badge' }) 255 | const parameters: TagBadgeParameters = [ 256 | { 257 | tags: ['test'], 258 | badge: badgeFunction, 259 | display: { sidebar: true, toolbar: true }, 260 | }, 261 | ] 262 | 263 | const { result } = renderHook(() => 264 | useBadgesToDisplay({ 265 | context: 'sidebar', 266 | parameters, 267 | tags: ['test'], 268 | type: 'story', 269 | }), 270 | ) 271 | 272 | expect(result.current).toHaveLength(1) 273 | expect(result.current[0].badge).toBe(badgeFunction) 274 | expect(result.current[0].tag).toBe('test') 275 | }) 276 | 277 | it('does not return two badge entries for the same matched tag', () => { 278 | const parameters: TagBadgeParameters = [ 279 | { 280 | tags: ['test'], 281 | badge: { text: 'Test Badge 1' }, 282 | display: { sidebar: true, toolbar: true }, 283 | }, 284 | { 285 | tags: ['test'], 286 | badge: { text: 'Test Badge 2' }, 287 | display: { sidebar: true, toolbar: true }, 288 | }, 289 | ] 290 | 291 | const { result } = renderHook(() => 292 | useBadgesToDisplay({ 293 | context: 'sidebar', 294 | parameters, 295 | tags: ['test'], 296 | type: 'story', 297 | }), 298 | ) 299 | 300 | expect(result.current).toHaveLength(1) 301 | expect(result.current[0].badge).toEqual(parameters[0].badge) 302 | }) 303 | }) 304 | }) 305 | 306 | describe('skipInherited', () => { 307 | const parameters: TagBadgeParameters = [ 308 | { 309 | tags: ['test1'], 310 | badge: { text: 'Test1' }, 311 | display: { 312 | sidebar: [{ type: 'story', skipInherited: false }], 313 | }, 314 | }, 315 | { 316 | tags: ['test2'], 317 | badge: { text: 'Test2' }, 318 | display: { 319 | sidebar: [ 320 | { type: 'story', skipInherited: true }, 321 | { type: 'component', skipInherited: true }, 322 | ], 323 | }, 324 | }, 325 | { 326 | tags: ['test3'], 327 | badge: { text: 'Test3' }, 328 | display: { 329 | sidebar: [{ type: 'story', skipInherited: true }], 330 | }, 331 | }, 332 | ] 333 | 334 | beforeEach(() => { 335 | vi.clearAllMocks() 336 | }) 337 | 338 | afterEach(() => { 339 | vi.resetAllMocks() 340 | }) 341 | 342 | it('always displays tags when skipInherited is false', () => { 343 | const { result } = renderHook(() => 344 | useBadgesToDisplay({ 345 | context: 'sidebar', 346 | parameters, 347 | parent: 'mock-component', 348 | tags: ['test1'], 349 | type: 'story', 350 | }), 351 | ) 352 | 353 | expect(result.current).toHaveLength(1) 354 | expect(result.current[0].tag).toBe('test1') 355 | }) 356 | 357 | it('does not display tags when skipInherited is true and the parent displays it', () => { 358 | const { result } = renderHook(() => 359 | useBadgesToDisplay({ 360 | context: 'sidebar', 361 | parameters, 362 | parent: 'mock-component', 363 | tags: ['test2'], 364 | type: 'story', 365 | }), 366 | ) 367 | 368 | expect(result.current).toHaveLength(0) 369 | }) 370 | 371 | it('displays tags when skipInherited is true but the parent does not display it', () => { 372 | const { result } = renderHook(() => 373 | useBadgesToDisplay({ 374 | context: 'sidebar', 375 | parameters, 376 | parent: 'mock-component', 377 | tags: ['test3'], 378 | type: 'story', 379 | }), 380 | ) 381 | 382 | expect(result.current).toHaveLength(1) 383 | }) 384 | }) 385 | 386 | describe.todo('Default config', () => { 387 | it('displays beta tags for components in the sidebar', () => { 388 | const { result } = renderHook(() => 389 | useBadgesToDisplay({ 390 | context: 'sidebar', 391 | parameters: defaultConfig, 392 | tags: ['beta'], 393 | type: 'component', 394 | }), 395 | ) 396 | 397 | expect(result.current).toHaveLength(1) 398 | expect(result.current[0].tag).toBe('beta') 399 | expect(typeof result.current[0].badge).toBe('function') 400 | }) 401 | }) 402 | }) 403 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | Example of the addon in use, showing badges next to component entries in the sidebar. 11 | 12 | 13 |

    Storybook Addon - Tag Badges

    14 | 15 |

    16 | This addon displays badges in the sidebar and toolbar of the Storybook UI, next to component, docs or story entries, based on the tags defined in your content. Badges can be customised to support your team's workflows. 17 |

    18 | 19 |

    20 | Status: Stable 21 | commit activity 22 | last commit 23 | open issues 24 | CodeQL status 25 | CI status 26 | code coverage 27 | contributors 28 | code of conduct: contributor covenant 2.1 29 | license 30 | forks 31 | stars 32 | sponsor this project 33 |

    34 |
    35 | 36 | --- 37 | 38 | ## 📔 Table of Contents 39 | 40 | 41 | - [Table of Contents](#-table-of-contents) 42 | - [Installation](#-installation) 43 | - [Default Config](#-default-config) 44 | - [Usage](#-usage) 45 | - [Customise Badge Config](#-customise-badge-config) 46 | - [Sidebar Config](#-sidebar-config) 47 | - [Workflow Examples](#-workflow-examples) 48 | - [Limitations](#-limitations) 49 | - [Contributing](#-contributing) 50 | - [Support](#-support) 51 | - [Contact](#-contact) 52 | - [Acknowledgments](#-acknowledgments) 53 | 54 | ## 📦 Installation 55 | 56 | ```sh 57 | yarn add -D storybook-addon-tag-badges 58 | ``` 59 | 60 | ```sh 61 | npm install -D storybook-addon-tag-badges 62 | ``` 63 | 64 | ```sh 65 | pnpm install -D storybook-addon-tag-badges 66 | ``` 67 | 68 | In your `.storybook/main.ts` file, add the following: 69 | 70 | ```ts 71 | // .storybook/main.ts 72 | export default { 73 | addons: ['storybook-addon-tag-badges'], 74 | } 75 | ``` 76 | 77 | ## 🏁 Default Config 78 | 79 | This addon comes with a default config, allowing you to get started immediately by adding tags to your content. 80 | 81 | ### Preconfigured Badges 82 | 83 | | Preview | Tag patterns | Suggested use | 84 | | ---------------------------------: | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | 85 | | ![](./static/badge-new.png) | `new` | Recently added components or props/features | 86 | | ![](./static/badge-beta.png) | `alpha`, `beta`, `rc`, `experimental` | Warn that a component or prop is not stable yet | 87 | | ![](./static/badge-deprecated.png) | `deprecated` | Components or props that should be avoided in new code | 88 | | ![](./static/badge-outdated.png) | `outdated` | Components with design changes that weren't yet implemented, which can incur extra development costs to your users | 89 | | ![](./static/badge-danger.png) | `danger` | Components that require particular attention when configuring them (e.g. for with security concerns) | 90 | | ![](./static/badge-code-only.png) | `code-only` | Components that only exist in code, and not in design | 91 | | ![](./static/badge-version.png) | `version:*`, `v:*` | Per-component versioning | 92 | 93 | ### Display Logic 94 | 95 | By default, all tags are always displayed on the toolbar, but they're only displayed in the sidebar for component entries, and for docs or story entries that appear at the top-level. They are not displayed in docs or story entries inside a component or group entry. 96 | 97 | Besides, the addon is limited to one badge per entry in the sidebar. Badges placed first in the configuration will be displayed in priority. For example, the `new` badge will be displayed before the `code-only` badge. 98 | 99 | > [!WARNING] 100 | > This means that when you customise this addon's configuration, you should include your customisations **before** spreading the default config object, so that they have a higher priority. 101 | 102 | ## 👀 Usage 103 | 104 | To display preconfigured badges, add the relevant tags to your components, stories, or docs entries. 105 | 106 | ### Component Badges 107 | 108 | To set badges for a component (and its child stories), define `tags` in the component's meta: 109 | 110 | ```ts 111 | // src/components/Button.stories.ts 112 | import type { Meta, StoryObj } from '@storybook/react' 113 | import { Button } from './Button' 114 | 115 | const meta: Meta = { 116 | title: 'Example/Button', 117 | component: Button, 118 | tags: ['autodocs', 'version:1.0.0', 'new'], 119 | } 120 | ``` 121 | 122 | ### Story Badges 123 | 124 | To add badges to a specific story, add `tags` to the story object itself: 125 | 126 | ```ts 127 | // src/components/Button.stories.ts 128 | export const Tertiary: StoryObj = { 129 | args: { 130 | variant: 'tertiary', 131 | size: 'md', 132 | }, 133 | tags: ['experimental'], 134 | } 135 | ``` 136 | 137 | ### Docs Badges 138 | 139 | To set badges for a docs entry, pass a `tags` array to the [`docs` parameter](https://storybook.js.org/docs/writing-stories/parameters): 140 | 141 | ```ts 142 | // src/components/Button.stories.ts 143 | import type { Meta, StoryObj } from '@storybook/react' 144 | import { Button } from './Button' 145 | 146 | const meta: Meta = { 147 | title: 'Example/Button', 148 | component: Button, 149 | parameters: { 150 | docs: { 151 | tags: ['outdated'], 152 | }, 153 | }, 154 | } 155 | ``` 156 | 157 | ## 🛠️ Customise Badge Config 158 | 159 | In your manager file, you may redefine the config object used to map tags to badges. Each tag is only rendered once, with the first badge configuration it matches; therefore, make sure to place your overrides to the config first if you also want to keep the default config in place. 160 | 161 | ```ts 162 | // .storybook/manager.ts 163 | import { addons } from '@storybook/manager-api' 164 | import { 165 | defaultConfig, 166 | type TagBadgeParameters, 167 | } from 'storybook-addon-tag-badges/manager-helpers' 168 | 169 | addons.setConfig({ 170 | tagBadges: [ 171 | // Add an entry that matches 'frog' and displays a cool badge in the sidebar only 172 | { 173 | tags: 'frog', 174 | badge: { 175 | text: 'Frog 🐸', 176 | style: { 177 | backgroundColor: '#001c13', 178 | color: '#e0eb0b', 179 | }, 180 | tooltip: 'This component can catch flies!', 181 | }, 182 | display: { 183 | sidebar: [{ 184 | type: 'component', 185 | skipInherited: true, 186 | }], 187 | toolbar: false, 188 | mdx: true, 189 | }, 190 | }, 191 | // Place the default config after your custom matchers. 192 | ...defaultConfig, 193 | ] satisfies TagBadgeParameters, 194 | }) 195 | ``` 196 | 197 | Let's now walk through the different properties of `tagBadges`. Each object in `tagBadges` represents a list of tags to match, and where a match is found, a badge configuration to use and places where the badge should be displayed. 198 | 199 | ### Tags 200 | 201 | The `tags` property defines the tag patterns for which a badge will be displayed. It can be a single pattern or an array of patterns. 202 | 203 | A tag pattern can be: 204 | 205 | | Pattern type | Description | Example pattern | Match outcome | 206 | | ------------------------------ | ------------------------------------------ | ---------------------- | ------------------ | 207 | | `string` | Exact match | `'new'` | `'new'` | 208 | | `RegExp` | Regular Expression | `/v\d+\d+\d+/` | `'v1.0.0'` | 209 | | `{ prefix: string \| RegExp }` | Match part of a tag before a `:` separator | `{ prefix: 'status' }` | `'status:done'` | 210 | | `{ prefix: string \| RegExp }` | Match part of a tag after a `:` separator | `{ suffix: 'a11y' }` | `'compliant:a11y'` | 211 | 212 | --- 213 | 214 | When `prefix` or `suffix` are used, the tag is split in two on the first `:` separator character. The left-hand side of the tag is compared to `prefix` and the right-hand side (including any additional `:`) to the `suffix`. If you've used a string as `prefix` or `suffix`, the addon will search for an exact match. If you used a `RegExp`, you must add `^` and `$` regular expression delimiters yourself to avoid accidentally matching other tags. 215 | 216 | ```ts 217 | // As an example, consider the following tags on a story: 218 | const tags = ['v:1.0.0', 'private'] 219 | 220 | // ✅ The string prefix will only match 'v:1.0.0' 221 | { 222 | tags: { prefix: 'v' }, 223 | badge: { /* ... */ }, 224 | } 225 | 226 | // ❌ The RegExp prefix without delimiters will also match 'private' 227 | { 228 | tags: { prefix: /v/i }, 229 | badge: { /* ... */ }, 230 | } 231 | 232 | // ✅ The RegExp prefix with delimiters will only match 'v:1.0.0' 233 | { 234 | tags: { prefix: /^v$/i }, 235 | badge: { /* ... */ }, 236 | } 237 | ``` 238 | 239 | ### Display (advanced usage) 240 | 241 | The `display` property controls where and for what type of content the badges are rendered. It has three sub-properties: `sidebar`, `toolbar` and `mdx`. In the sidebar, tags may be displayed for component, group, docs or story entries. In the toolbar, they may be set for docs or story entries (as other entry types aren't displayable outside the sidebar). The `mdx` property controls the badges displayed by `MDXBadges` in a MDX file; in MDX, tags may be displayed for component or story entries (when importing CSF stories and using the `of` prop). 242 | 243 | The following entry types are rendered by Storybook: 244 | 245 | | Icon | Name | Description | 246 | | --------------------------------- | --------- | -------------------------------------------------------------------------- | 247 | | ![](./static/entry-story.svg) | story | One of the component stories written in your CSF files. | 248 | | ![](./static/entry-docs.svg) | docs | A documentation page generated through MDX files or autodocs. | 249 | | ![](./static/entry-component.svg) | component | The grouping of a component's stories and autodocs page. | 250 | | ![](./static/entry-group.svg) | group | A generic group containing unattached MDX docs, stories and/or components. | 251 | 252 | To control where badges are shown, you pass conditions to the `sidebar`, `toolbar` and `mdx` keys. You can either specify a single condition, or an array of conditions (in which case matching any condition for a given tag causes it to be used). 253 | 254 | For `mdx` and `toolbar` display properties, the conditions can either be a boolean (to always or never display the badge) or a string (matching an entry type): 255 | * `false`: the badge should never be displayed 256 | * `true`: the badge should always be displayed 257 | * `string`: the badge should be displayed 258 | 259 | | Type | Description | Example | MDX outcome | Toolbar outcome | 260 | | --------------- | ----------------------------------- | -------- | ------------------------ | ------------------- | 261 | | `ø` _(not set)_ | Use default behaviour | | `['component', 'story']` | `['docs', 'story']` | 262 | | `false` | Never display badge | `false` | `[]` | `[]` | 263 | | `true` | Always display badge | `true` | `['component', 'story']` | `['docs', 'story']` | 264 | | `string` | Display only for that type of entry | `'docs'` | `[]` | `['docs']` | 265 | 266 | For the `sidebar` property, things are more complicated. You must choose for which entries to display badges and whether a badge should be displayed for an entry when its parent entry already displays the same badge. 267 | 268 | For instance, of a component entry has a `new` badge, you must decide if you also want its individual stories to show the `new` badge. Tags inherited from parents are skipped in the default configuration. 269 | 270 | A condition for the sidebar takes two properties: 271 | 272 | | Property | Description | Type | Example value | 273 | | --------------- | ---------------------------------------------------------------------------------------------------- | --------- | ------------- | 274 | | `type` | The type of entry to match | `string` | `'docs'` | 275 | | `skipInherited` | Whether to skip showing the badge if a parent entry in the UI already shows a badge for the same tag | `boolean` | `true` | 276 | 277 | Using the default config for `display` is heavily recommended. It is defined as follows: 278 | ```ts 279 | const DISPLAY_DEFAULTS = { 280 | mdx: ['story', 'component'], 281 | sidebar: [ 282 | { type: 'story', skipInherited: true }, 283 | { type: 'docs', skipInherited: true }, 284 | { type: 'component', skipInherited: false }, 285 | { type: 'group', skipInherited: false }, 286 | ], 287 | toolbar: ['docs', 'story'], 288 | } 289 | ``` 290 | 291 | ### Badge 292 | 293 | The `badge` property defines the appearance and content of the badge to display. It can be either a static object or a function that dynamically generates the badge based on the matched content and tag. 294 | 295 | #### Static Badge Object 296 | 297 | The object has the following properties: 298 | 299 | | Name | Type | Description | Example | 300 | | ----------- | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------- | 301 | | **text** | `string` | The text displayed in the badge (required). | 'New' | 302 | | **style** | `string \| object` | Preset color, or a CSS properties object. | `'turquoise'` \| `{ color: 'red', fontSize: '1rem' }` | 303 | | **tooltip** | `string \| TooltipMessageProps?` | A tooltip shown on click in the toolbar only. | `{ title: 'New Component', desc: 'Recently added to the library' }` | 304 | 305 | #### Style presets 306 | 307 | The following preset colors are defined: 308 | * grey 309 | * green 310 | * turquoise 311 | * blue 312 | * purple 313 | * pink 314 | * red 315 | * orange 316 | * yellow 317 | 318 | #### Custom Style 319 | 320 | To customise the look of your badges, you may pass an object of CSS properties to the `style` prop. The object is consumed by [`@storybook/theming`](https://storybook.js.org/docs/configure/user-interface/theming#using-the-theme-for-addon-authors) and ultimately by [emotion](https://emotion.sh/docs/introduction). 321 | 322 | > [!NOTE] 323 | > The `borderColor` property, if set, will be passed to an inner box-shadow and then deleted. 324 | 325 | > [!NOTE] 326 | > The margin on the side of the badge cannot be removed. It is reserved for the Vitest addon and other future Storybook UI features. 327 | 328 | Example of a custom style: 329 | 330 | ```ts 331 | // .storybook/manager.ts 332 | import { addons } from '@storybook/manager-api' 333 | import { 334 | defaultConfig, 335 | type TagBadgeParameters, 336 | } from 'storybook-addon-tag-badges/manager-helpers' 337 | 338 | addons.setConfig({ 339 | tagBadges: [ 340 | { 341 | tags: 'stylish', 342 | badge: { 343 | text: 'Stylish!', 344 | style: { 345 | background: 346 | 'linear-gradient(to right in lch, rgb(255, 41, 91) 0%, rgb(177, 75, 255) 100%)', 347 | borderColor: 'transparent', 348 | borderRadius: 0, 349 | color: '#111', 350 | fontWeight: 'bold', 351 | fontFamily: 'monospace', 352 | letterSpacing: '0.05em', 353 | fontVariant: 'small-caps', 354 | padding: '0.5em', 355 | }, 356 | tooltip: `This component can help create strong brand moments.`, 357 | }, 358 | }, 359 | ...defaultConfig, 360 | ] satisfies TagBadgeParameters, 361 | }) 362 | ``` 363 | 364 | #### Dynamic Badge Functions 365 | 366 | Dynamic badge functions allow you to customize the badge based on the current entry and matched tag. They must return a valid badge object as documented above. They receive an object parameter with the following properties: 367 | 368 | - `context`: Whether the badge is being rendered in `mdx` or in the Storybook `sidebar` or `toolbar` 369 | - `entry`: The current HashEntry (component, story, etc.), with an `id` and/or `name`, a `type`, and `tags` (except if using badges in `mdx` with MdxBadges, as the `entry` is not loadable in that context) 370 | - `getTagParts`, `getTagPrefix`, `getTagSuffix`: Utility functions to extract parts of the tag 371 | - `tag`: The matched tag string 372 | 373 | Example of a dynamic badge function: 374 | 375 | ```ts 376 | // .storybook/manager.ts 377 | import { addons } from '@storybook/manager-api' 378 | import { 379 | defaultConfig, 380 | type TagBadgeParameters, 381 | } from 'storybook-addon-tag-badges/manager-helpers' 382 | 383 | addons.setConfig({ 384 | tagBadges: [ 385 | { 386 | tags: { prefix: 'version' }, 387 | badge: ({ entry, getTagSuffix, tag }) => { 388 | const version = getTagSuffix(tag) 389 | const isUnstable = version.startsWith('0') 390 | return { 391 | text: `v${version}`, 392 | style: version.startsWith('0') ? 'pink' : 'blue', 393 | tooltip: `Version ${version}${isUnstable ? ' (unstable)' : ''}`, 394 | } 395 | }, 396 | }, 397 | ...defaultConfig, 398 | ] satisfies TagBadgeParameters, 399 | }) 400 | ``` 401 | 402 | ### Tooltip 403 | 404 | Badges may have a tooltip when displayed in the toolbar. The tooltip is disabled in the sidebar to avoid conflicting with the sidebar's function, though feedback is welcome on this. 405 | 406 | You may pass a string to tooltips for a simple tooltip. You may also pass the same objects used by [Storybook's `TooltipMessage`](https://5ccbc373887ca40020446347-idzavsdems.chromatic.com/?path=/docs/tooltip-tooltipmessage--docs): 407 | 408 | * `title`: The title of the tooltip *[string]* 409 | * `desc`: Secondary text for the tooltip *[string]* 410 | * `links`: An optional array of link objects displayed as buttons *[object[]]* 411 | * `title`: The title of the link 412 | * `href`: The URL to which the link points (navigates in-place) 413 | * `onClick`: A callback when the link is clicked (can be used to navigate in a new browser tab) 414 | 415 | ## Sidebar Config 416 | 417 | This addon uses the [sidebar `renderLabel` feature](https://storybook.js.org/docs/configure/user-interface/sidebar-and-urls) to display badges in the sidebar. If you define it for other purposes in your Storybook instance, it will conflict with this addon and sidebar badges won't show. 418 | 419 | To show badges for items that aren't customised by your own `renderLabel` logic, you may import the addon's own `renderLabel` function and call it at the end of your function. 420 | 421 | ```tsx 422 | // .storybook/manager.ts 423 | import { addons } from '@storybook/manager-api' 424 | import type { API_HashEntry } from '@storybook/types' 425 | import { renderLabel, Sidebar } from 'storybook-addon-tag-badges/manager-helpers' 426 | 427 | addons.setConfig({ 428 | sidebar: { 429 | renderLabel: (item: API_HashEntry) => { 430 | // Customise your own items, with no badge support. 431 | if (item.name === 'Support') { 432 | return '🛟 Get Support' 433 | } 434 | 435 | // Customise items with badge support by wrapping in Sidebar. 436 | if (item.type === 'docs') { 437 | return {item.name} [doc] 438 | } 439 | 440 | // Badges for every item not customised by you. 441 | return renderLabel(item) 442 | }, 443 | } 444 | }) 445 | ``` 446 | 447 | Likewise, if you define configuration for the `sidebar` option without including `renderLabel`, the render function defined by this addon will be overwritten, and badges won't show in the sidebar. Import and add the `renderLabel` function like so: 448 | 449 | ```tsx 450 | // .storybook/manager.ts 451 | import { addons } from '@storybook/manager-api' 452 | import { renderLabel } from 'storybook-addon-tag-badges/manager-helpers' 453 | 454 | addons.setConfig({ 455 | sidebar: { 456 | /* your own changes here... */ 457 | renderLabel, 458 | } 459 | }) 460 | ``` 461 | 462 | ## Using Badges in MDX 463 | 464 | This addon provides two ways for you to include badges in your MDX files. A `MDXBadges` component takes a CSF meta or CSF story as a parameter and renders badges based on this parameter's tags. A `CustomBadge` component lets you create your own badge independently from tags. 465 | 466 | ### MDXBadges 467 | 468 | This component works like `@storybook/addon-docs/blocks` components `Title`, `Subtitle`, etc. It takes an `of` prop, which may receive either a CSF meta (the default export of a CSF file) or an individual story. Say you have a Button component implemented, the below example shows how to create your custom MDX page with automatic badges: 469 | 470 | ```mdx 471 | import { Canvas, Heading, Meta, Title } from '@storybook/addon-docs/blocks' 472 | import { MDXBadges } from 'storybook-addon-tag-badges/manager-helpers' 473 | 474 | import ButtonStoriesMeta, * as CSF from './Button.stories' 475 | 476 | 477 | 478 | 479 | 480 | 481 | {CSF.__namedExportsOrder.map((storyName) => <section key={storyName}> 482 | <Heading>{storyName} <MDXBadges of={CSF[storyName]} /></Heading> 483 | <Canvas of={CSF[storyName]} /> 484 | </section>)} 485 | ``` 486 | 487 | You can control which tags generate a badge in `MDXBadges` with the `mdx` sub-property of the `display` property in your addon configuration. 488 | 489 | ### CustomBadge 490 | 491 | If you want to create your own custom badges on the fly, you may import and use the `CustomBadge` component. 492 | 493 | ```tsx 494 | import { CustomBadge } from 'storybook-addon-tag-badges/manager-helpers' 495 | 496 | <CustomBadge text="My text" style="turquoise" /> 497 | ``` 498 | 499 | ## 📝 Workflow Examples 500 | 501 | This repository contains examples on how to support various workflows with Storybook badges: 502 | 503 | - Market segmentation 504 | - Separating functional from branded components 505 | - Compliance state for checks like a11y, brand, QA 506 | - Component composition patterns 507 | - Use of external dependencies 508 | - Smart components 509 | 510 | To see these in action, check out the repository and run the local Storybook instance: 511 | 512 | ```sh 513 | git clone https://github.com/Sidnioulz/storybook-addon-tag-badges.git 514 | cd storybook-addon-tag-badges 515 | pnpm i 516 | pnpm start 517 | ``` 518 | 519 | ## 🐌 Limitations 520 | 521 | ### Per-Story Config 522 | 523 | This addon does not support changing the badge config for a specific story, and never will. This is because parts of the Storybook UI, like the sidebar, are rendered in a context where story data is not loaded. Storybook has stopped preloading all story data in v7, to improve performance. 524 | 525 | As a result, we need to create sidebar tags without access to story-specific data. This addon uses the [core addon API](https://storybook.js.org/docs/addons/addons-api#core-addon-api) to read your configuration, and so the way to customise the rendering of a specific badge is to use [dynamic badge functions](<(#dynamic-badge-functions)>). Those functions can exploit a story's ID, title, or tag content to customise the rendered badge, as examples below will show. 526 | 527 | ### Component Tags 528 | 529 | In Storybook, your MDX and CSF files are converted to `docs`, `component`, `group` and `story` entries to render the sidebar, each with their own semantics. `docs` and `story` entries directly inherit the tags defined in `parameters.docs.tags` and in the [CSF `meta`](https://storybook.js.org/docs/api/csf#default-export), respectively. 530 | 531 | For `component` entries, tags are computed indirectly: they are the intersection of tags present on all of the component's stories. For example, for a component that defines the tag `version:1.2.0` in its `meta`, and has one story that defines an additional tag `deprecated`, the component entry will only have the `version:1.2.0` tag defined. 532 | 533 | In particular, if a component `meta` defines two tags `outdated` and `version:1.1.0`, but one story explicitly removes the tag `outdated` (by adding `!outdated`), then the component entry will only have tag `version:1.1.0`. 534 | 535 | ## 👩🏽‍💻 Contributing 536 | 537 | ### Code of Conduct 538 | 539 | Please read the [Code of Conduct](https://github.com/Sidnioulz/storybook-addon-tag-badges/blob/main/CODE_OF_CONDUCT.md) first. 540 | 541 | ### Developer Certificate of Origin 542 | 543 | To ensure that contributors are legally allowed to share the content they contribute under the license terms of this project, contributors must adhere to the [Developer Certificate of Origin](https://developercertificate.org/) (DCO). All contributions made must be signed to satisfy the DCO. This is handled by a Pull Request check. 544 | 545 | > By signing your commits, you attest to the following: 546 | > 547 | > 1. The contribution was created in whole or in part by you and you have the right to submit it under the open source license indicated in the file; or 548 | > 2. The contribution is based upon previous work that, to the best of your knowledge, is covered under an appropriate open source license and you have the right under that license to submit that work with modifications, whether created in whole or in part by you, under the same open source license (unless you are permitted to submit under a different license), as indicated in the file; or 549 | > 3. The contribution was provided directly to you by some other person who certified 1., 2. or 3. and you have not modified it. 550 | > 4. You understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information you submit with it, including your sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. 551 | 552 | ### Getting Started 553 | 554 | This project uses PNPM as a package manager. 555 | 556 | - See the [installation instructions for PNPM](https://pnpm.io/installation) 557 | - Run `pnpm i` 558 | 559 | ### Useful commands 560 | 561 | - `pnpm start` starts the local Storybook 562 | - `pnpm build` builds and packages the addon code 563 | - `pnpm pack` makes a local tarball to be used as a NPM dependency elsewhere 564 | - `pnpm test` runs unit tests 565 | 566 | ### Migrating to a later Storybook version 567 | 568 | If you want to migrate the addon to support the latest version of Storyboook, you can check out the [addon migration guide](https://storybook.js.org/docs/addons/addon-migration-guide). 569 | 570 | ### Release System 571 | 572 | This package auto-releases on pushes to `main` with [semantic-release](https://github.com/semantic-release/semantic-release). No changelog is maintained and the version number in `package.json` is not synchronised. 573 | 574 | ## 🆘 Support 575 | 576 | Please [open an issue](https://github.com/Sidnioulz/storybook-addon-tag-badges/issues/new) for bug reports or code suggestions. Make sure to include a working Minimal Working Example for bug reports. You may use [storybook.new](https://new-storybook.netlify.app/) to bootstrap a reproduction environment. 577 | 578 | ## ✉️ Contact 579 | 580 | Steve Dodier-Lazaro · `@Frog` on the [Storybook Discord](https://discord.gg/storybook) - [LinkedIn](https://www.linkedin.com/in/stevedodierlazaro/) 581 | 582 | Project Link: [https://github.com/Sidnioulz/storybook-addon-tag-badges](https://github.com/Sidnioulz/storybook-addon-tag-badges) 583 | 584 | ## 💛 Acknowledgments 585 | 586 | ### Thanks 587 | 588 | - [Jim Drury](https://github.com/geometricpanda) for his groundbreaking working on the original Badges Addon; I am a mere copy-cat 589 | - [Michael Shilman](https://github.com/shilman) for his help with addon internals and his feedback 590 | - All the contributors to the [Storybook addon kit](https://github.com/storybookjs/addon-kit) 591 | 592 | ### Built With 593 | 594 | [![Dependabot](https://img.shields.io/badge/Dependabot-025E8C?logo=dependabot&logoColor=white)](https://github.com/dependabot) 595 | [![ESLint](https://img.shields.io/badge/ESLint-4b32c3?logo=eslint&logoColor=white)](https://eslint.org/) 596 | [![GitHub](https://img.shields.io/badge/GitHub-0d1117?logo=github&logoColor=white)](https://github.com/solutions/ci-cd) 597 | [![Prettier](https://img.shields.io/badge/Prettier-f8bc45?logo=prettier&logoColor=black)](https://prettier.io/) 598 | [![Semantic-Release](https://img.shields.io/badge/semantic--release-cccccc?logo=semantic-release&logoColor=black)](https://github.com/semantic-release/semantic-release) 599 | [![Storybook](https://cdn.jsdelivr.net/gh/storybookjs/brand@main/badge/badge-storybook.svg)](https://storybook.js.org/) 600 | [![tsup](https://img.shields.io/badge/tsup-fde047)](https://tsup.egoist.dev/) 601 | [![TypeScript](https://img.shields.io/badge/TypeScript-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 602 | [![Vitest](https://img.shields.io/badge/Vitest-acd268?logo=vitest&logoColor=black)](https://https://vitest.dev/) 603 | --------------------------------------------------------------------------------