diff --git a/packages/.storybook/components/Elements/DocsPage.tsx b/packages/.storybook/components/Elements/DocsPage.tsx new file mode 100644 index 0000000000..8ba1ef8a41 --- /dev/null +++ b/packages/.storybook/components/Elements/DocsPage.tsx @@ -0,0 +1,54 @@ +import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@skillsoft/gamut'; +import { + Controls, + Description, + Primary, + Stories, + Subtitle, + Title, + useOf, +} from '@storybook/addon-docs/blocks'; + +import { isSystemPropCategory } from '../../systemProps'; + +/** + * Storybook's default autodocs page, with system props moved out of the main + * props table into their own tab, and the primary story shown only at the top + * instead of again under Stories. + */ +export const DocsPage: React.FC = () => { + const { csfFile, preparedMeta } = useOf('meta', ['meta']); + const isSingleStory = Object.keys(csfFile.stories).length === 1; + const systemProps = Object.entries(preparedMeta.argTypes) + .filter(([, argType]) => isSystemPropCategory(argType.table?.category)) + .map(([name]) => name); + + return ( + <> + + <Subtitle /> + <Description of="meta" /> + {isSingleStory && <Description of="story" />} + <Primary /> + {systemProps.length > 0 ? ( + <Tabs defaultSelectedKey="props"> + <TabList aria-label="Props"> + <Tab id="props">Props</Tab> + <Tab id="system-props">System props</Tab> + </TabList> + <TabPanels> + <TabPanel id="props"> + <Controls exclude={systemProps} /> + </TabPanel> + <TabPanel id="system-props"> + <Controls include={systemProps} /> + </TabPanel> + </TabPanels> + </Tabs> + ) : ( + <Controls /> + )} + {!isSingleStory && <Stories includePrimary={false} />} + </> + ); +}; diff --git a/packages/.storybook/main.ts b/packages/.storybook/main.ts index 1e91b1f0ea..14253cdc63 100644 --- a/packages/.storybook/main.ts +++ b/packages/.storybook/main.ts @@ -51,6 +51,12 @@ const config: StorybookConfig = { typescript: { reactDocgen: 'react-docgen-typescript', reactDocgenTypescriptOptions: { + /* + * Needs `strictFunctionTypes: false` (unlike the root tsconfig), or + * `variance.compose(...)` fails to type-check and composed system props + * drop out of the props tables. + */ + tsconfigPath: resolve(__dirname, '../gamut/tsconfig.json'), skipChildrenPropWithoutDoc: false, shouldRemoveUndefinedFromOptional: true, propFilter: (prop) => { diff --git a/packages/.storybook/preview.ts b/packages/.storybook/preview.ts index b771e46bbd..93da0ea3e7 100644 --- a/packages/.storybook/preview.ts +++ b/packages/.storybook/preview.ts @@ -3,9 +3,12 @@ import { Preview } from '@storybook/react'; import theme from './theming/GamutTheme'; import { withEmotion } from './theming/GamutThemeProvider'; import { DocsContainer } from './components/Elements/DocsContainer'; +import { DocsPage } from './components/Elements/DocsPage'; import { breakpoints } from '@skillsoft/gamut-styles'; +import { enhanceSystemProps } from './systemProps'; const preview: Preview = { + argTypesEnhancers: [enhanceSystemProps], parameters: { a11y: { test: 'error', @@ -21,6 +24,7 @@ const preview: Preview = { }, docs: { container: DocsContainer, + page: DocsPage, theme, }, viewport: { diff --git a/packages/.storybook/systemProps.ts b/packages/.storybook/systemProps.ts new file mode 100644 index 0000000000..3ae6f55f5c --- /dev/null +++ b/packages/.storybook/systemProps.ts @@ -0,0 +1,161 @@ +import { coreTheme, system } from '@skillsoft/gamut-styles'; +import type { + ArgTypesEnhancer, + StrictInputType, +} from 'storybook/internal/types'; + +interface SystemPropConfig { + property?: unknown; + properties?: unknown; + scale?: unknown; +} + +interface SystemPropGroup { + propNames: readonly string[]; + config: Record<string, SystemPropConfig>; +} + +// The groups overlap (`space` contains `margin`/`padding`; flex and grid item +// props are also in `layout`), so the first group listing a prop wins. +const categoryGroups: [string, SystemPropGroup[]][] = [ + ['Spacing', [system.space]], + ['Flex & Grid', [system.flex, system.grid]], + ['Layout', [system.layout]], + ['Positioning', [system.positioning]], + ['Typography', [system.typography]], + ['Color', [system.color]], + ['Background', [system.background]], + ['Border', [system.border]], + ['Shadow', [system.shadow]], + ['List', [system.list]], +]; + +const systemProps = new Map< + string, + { category: string; config: SystemPropConfig } +>(); +categoryGroups.forEach(([category, groups]) => { + groups.forEach(({ propNames, config }) => { + propNames.forEach((prop) => { + if (!systemProps.has(prop)) { + systemProps.set(prop, { category, config: config[prop] }); + } + }); + }); +}); + +const categoryOrder = categoryGroups.map(([category]) => category); +const categoryRank = (category: string | undefined) => + category === undefined ? -1 : categoryOrder.indexOf(category); + +export const isSystemPropCategory = (category: string | undefined) => + categoryRank(category) >= 0; + +const themeScales = coreTheme as unknown as Record< + string, + Record<string, unknown> | undefined +>; + +// Token names come from the Core theme; other themes may define more or fewer. +const getScaleTokens = ({ scale }: SystemPropConfig) => + typeof scale === 'string' ? Object.keys(themeScales[scale] ?? {}) : []; + +const toKebabCase = (name: string) => + name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); + +type PropertyMode = 'physical' | 'logical'; + +// Property names, lists, and list items can each be a `{ physical, logical }` pair. +const resolveMode = (value: unknown, mode: PropertyMode): unknown => + typeof value === 'object' && value !== null && 'physical' in value + ? (value as Record<PropertyMode, unknown>)[mode] + : value; + +const getCssProperties = ( + { property, properties }: SystemPropConfig, + mode: PropertyMode +) => { + const resolved = resolveMode(properties ?? property, mode); + return (Array.isArray(resolved) ? resolved : [resolved]) + .map((name) => resolveMode(name, mode)) + .filter((name): name is string => typeof name === 'string') + .map(toKebabCase); +}; + +const describeCssProperties = (config: SystemPropConfig) => { + const format = (names: string[]) => + names.map((name) => `\`${name}\``).join(', '); + const physical = getCssProperties(config, 'physical'); + const logical = getCssProperties(config, 'logical'); + return physical.join() === logical.join() + ? `CSS: ${format(physical)}` + : `CSS: ${format(physical)} (logical: ${format(logical)})`; +}; + +const MAX_INLINE_TOKENS = 12; + +const getTypeSummary = (config: SystemPropConfig, tokens: string[]) => { + if (tokens.length === 0) return { summary: 'CSS value' }; + if (tokens.length <= MAX_INLINE_TOKENS) + return { summary: tokens.join(' | ') }; + return { summary: `theme.${config.scale}`, detail: tokens.join(' | ') }; +}; + +/* + * Components can declare their own props with system prop names, like a numeric + * `zIndex`. Docgen types real system props as `Scale<...>`, or, where two prop + * groups both define a prop, as csstype's open-ended `(string & {})` union. + */ +const hasSystemPropType = ({ table }: StrictInputType) => { + const docgenType = table?.type?.summary; + return ( + docgenType !== undefined && + (docgenType.startsWith('Scale<') || docgenType.includes('(string & {})')) + ); +}; + +const enhanceSystemProp = ( + argType: StrictInputType, + { category, config }: { category: string; config: SystemPropConfig } +): StrictInputType => { + const tokens = getScaleTokens(config); + return { + ...argType, + description: argType.description || describeCssProperties(config), + control: argType.control ?? { type: tokens.length > 0 ? 'select' : 'text' }, + ...(tokens.length > 0 && !argType.options && { options: tokens }), + table: { + ...argType.table, + category: argType.table?.category ?? category, + type: getTypeSummary(config, tokens), + }, + }; +}; + +/** + * Makes system props readable in the props table: groups them by prop family + * below the component's own props, summarizes their types as the theme tokens + * they accept, and gives them select or text controls. Anything set in a + * story's `argTypes` takes precedence. The table renders categories in + * first-seen order, so entries are sorted to keep the sections in the same + * order on every component. Storybook moves props with story args to the front + * after this runs, so a story that sets a system prop arg shows that section + * first. + */ +export const enhanceSystemProps: ArgTypesEnhancer = ({ argTypes }) => + Object.fromEntries( + Object.entries(argTypes) + .map(([name, argType]): [string, typeof argType] => { + const systemProp = systemProps.get(name); + return [ + name, + systemProp && hasSystemPropType(argType) + ? enhanceSystemProp(argType, systemProp) + : argType, + ]; + }) + .sort( + ([, a], [, b]) => + categoryRank(a.table?.category) - categoryRank(b.table?.category) + ) + );