Merge pull request #32571 from backstage/docs-ui-props-updates

Update spacing props display with collapsible groups
This commit is contained in:
Charles de Dreuille
2026-02-03 11:16:25 +00:00
committed by GitHub
15 changed files with 513 additions and 86 deletions
-7
View File
@@ -9,7 +9,6 @@ import {
boxResponsiveSnippet,
} from './snippets';
import { Default, Surface, Responsive } from './components';
import { spacingPropDefs } from '@/utils/propDefs';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { BoxDefinition } from '../../../utils/definitions';
@@ -31,12 +30,6 @@ import { ChangelogComponent } from '@/components/ChangelogComponent';
<PropsTable data={boxPropDefs} />
Padding and margin are used to create space around your component using our
predefined spacing tokens. We would recommend to use padding over margin to
avoid collapsing margins but both are available.
<PropsTable data={spacingPropDefs} />
## Examples
### Surface
@@ -4,6 +4,7 @@ import {
positionPropDefs,
stylePropDefs,
widthPropDefs,
spacingGroupAll,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
@@ -43,4 +44,5 @@ export const boxPropDefs: Record<string, PropDef> = {
},
...classNamePropDefs,
...stylePropDefs,
spacing: spacingGroupAll,
};
@@ -1,7 +1,7 @@
import {
classNamePropDefs,
stylePropDefs,
spacingValues,
createSpacingGroup,
type PropDef,
} from '@/utils/propDefs';
@@ -11,42 +11,10 @@ export const containerPropDefs: Record<string, PropDef> = {
values: ['ReactNode'],
description: 'Content to render inside the container.',
},
my: {
type: 'enum | string',
values: spacingValues,
responsive: true,
description: 'Vertical margin (top and bottom).',
},
mt: {
type: 'enum | string',
values: spacingValues,
responsive: true,
description: 'Top margin.',
},
mb: {
type: 'enum | string',
values: spacingValues,
responsive: true,
description: 'Bottom margin.',
},
py: {
type: 'enum | string',
values: spacingValues,
responsive: true,
description: 'Vertical padding (top and bottom).',
},
pt: {
type: 'enum | string',
values: spacingValues,
responsive: true,
description: 'Top padding.',
},
pb: {
type: 'enum | string',
values: spacingValues,
responsive: true,
description: 'Bottom padding.',
},
...classNamePropDefs,
...stylePropDefs,
spacing: createSpacingGroup(
['my', 'mt', 'mb', 'py', 'pt', 'pb'],
'Vertical spacing properties for controlling margin and padding.',
),
};
-5
View File
@@ -15,7 +15,6 @@ import {
AlignExample,
DirectionExample,
} from './components';
import { spacingPropDefs } from '@/utils/propDefs';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { FlexDefinition } from '../../../utils/definitions';
@@ -37,10 +36,6 @@ import { ChangelogComponent } from '@/components/ChangelogComponent';
<PropsTable data={flexPropDefs} />
The Flex component also accepts all the spacing props from the Box component.
<PropsTable data={spacingPropDefs} />
## Examples
### Direction
@@ -2,6 +2,7 @@ import {
classNamePropDefs,
stylePropDefs,
gapPropDefs,
spacingGroupAll,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
@@ -61,4 +62,5 @@ export const flexPropDefs: Record<string, PropDef> = {
},
...classNamePropDefs,
...stylePropDefs,
spacing: spacingGroupAll,
};
-5
View File
@@ -9,7 +9,6 @@ import {
gridItemSnippet,
} from './snippets';
import { Default, ResponsiveExample, GridItemExample } from './components';
import { spacingPropDefs } from '@/utils/propDefs';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { GridDefinition } from '../../../utils/definitions';
@@ -35,10 +34,6 @@ The grid container. Defines column count and gap between items.
<PropsTable data={gridPropDefs} />
Grid.Root also accepts all spacing props from the Box component.
<PropsTable data={spacingPropDefs} />
### Grid.Item
A grid child with column and row spanning control.
@@ -3,6 +3,7 @@ import {
classNamePropDefs,
gapPropDefs,
stylePropDefs,
spacingGroupAll,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
@@ -67,6 +68,7 @@ export const gridPropDefs: Record<string, PropDef> = {
...childrenPropDefs,
...classNamePropDefs,
...stylePropDefs,
spacing: spacingGroupAll,
};
export const gridItemPropDefs: Record<string, PropDef> = {
+4
View File
@@ -49,6 +49,10 @@ export default async function RootLayout({
data-theme-name="backstage"
suppressHydrationWarning
>
<head>
<link rel="stylesheet" href="/theme-backstage.css" />
<link rel="stylesheet" href="/theme-spotify.css" />
</head>
<body>
<Providers>
<Sidebar />
@@ -3,6 +3,8 @@
import * as Table from '../Table';
import { Chip } from '../Chip';
import { TypePopup } from './TypePopup';
import { SpacingPopup } from './SpacingPopup';
import { SpacingGroupRow } from './SpacingGroupRow';
import { PropDef } from '@/utils/propDefs';
@@ -59,11 +61,8 @@ export const PropsTable = <T extends Record<string, PropData>>({
{propData.type === 'number' && <Chip>number</Chip>}
{propData.type === 'boolean' && <Chip>boolean</Chip>}
{propData.type === 'enum' && enumValues}
{propData.type === 'spacing' && (
<>
<Chip>0.5, 1, 1.5, 2, 3, ..., 14</Chip>
<Chip>string</Chip>
</>
{propData.type === 'spacing' && Array.isArray(propData.values) && (
<SpacingPopup values={propData.values} />
)}
{propData.type === 'complex' && propData.complexType && (
<TypePopup
@@ -81,10 +80,12 @@ export const PropsTable = <T extends Record<string, PropData>>({
);
case 'default':
return propData.default ? <Chip>{propData.default}</Chip> : null;
return propData.default ? <Chip>{propData.default}</Chip> : '-';
case 'description':
return propData.description || null;
return propData.description ? (
<span style={{ fontSize: '14px' }}>{propData.description}</span>
) : null;
case 'responsive':
return <Chip>{propData.responsive ? 'Yes' : 'No'}</Chip>;
@@ -106,15 +107,32 @@ export const PropsTable = <T extends Record<string, PropData>>({
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{Object.keys(data).map(n => (
<Table.Row key={n}>
{columns.map(col => (
<Table.Cell key={col.key} style={{ width: col.width }}>
{renderCell(n, data[n], col.key)}
</Table.Cell>
))}
</Table.Row>
))}
{Object.keys(data).map(n => {
const propData = data[n];
// Handle spacing-group type
if (propData.type === 'spacing-group' && propData.spacingGroup) {
return (
<SpacingGroupRow
key={n}
spacingGroup={propData.spacingGroup}
description={propData.description}
columns={columns}
/>
);
}
// Handle regular props
return (
<Table.Row key={n}>
{columns.map(col => (
<Table.Cell key={col.key} style={{ width: col.width }}>
{renderCell(n, propData, col.key)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
);
@@ -0,0 +1,96 @@
.expandButton {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
color: inherit;
text-align: left;
gap: 0.5rem;
}
.expandButton:hover {
opacity: 0.8;
}
.propName {
font-family: monospace;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
flex: 1;
}
.count {
font-size: 12px;
color: var(--text-secondary);
font-weight: normal;
}
.icon {
color: var(--text-secondary);
flex-shrink: 0;
display: flex;
align-items: center;
}
.expandedContent {
padding: 0.5rem 0;
}
.twoColumnLayout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.columnTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.propsGrid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.propRow {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: start;
}
.propRowName {
min-width: 80px;
}
.propRowDescription {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.propRowDefault {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.25rem;
}
@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import * as Table from '../Table';
import { Chip } from '../Chip';
import { SpacingPopup } from './SpacingPopup';
import type { SpacingGroupDef } from '@/utils/propDefs';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import styles from './SpacingGroupRow.module.css';
interface SpacingGroupRowProps {
spacingGroup: SpacingGroupDef;
description?: React.ReactNode;
columns: Array<{ key: string; width: string }>;
}
export const SpacingGroupRow = ({
spacingGroup,
description,
columns,
}: SpacingGroupRowProps) => {
const [isExpanded, setIsExpanded] = useState(false);
// Separate padding and margin props
const paddingProps = spacingGroup.props.filter(prop =>
prop.name.startsWith('p'),
);
const marginProps = spacingGroup.props.filter(prop =>
prop.name.startsWith('m'),
);
const renderCell = (columnKey: string) => {
switch (columnKey) {
case 'prop':
return (
<button
className={styles.expandButton}
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
>
<span className={styles.propName}>
Spacing props{' '}
<span className={styles.count}>
({spacingGroup.props.length})
</span>
</span>
{isExpanded ? (
<RiArrowDownSLine className={styles.icon} size={16} />
) : (
<RiArrowRightSLine className={styles.icon} size={16} />
)}
</button>
);
case 'type':
return <SpacingPopup values={spacingGroup.values} />;
case 'default':
return '-';
case 'description':
return description ? (
<span style={{ fontSize: '14px' }}>{description}</span>
) : null;
case 'responsive':
return <Chip>{spacingGroup.responsive ? 'Yes' : 'No'}</Chip>;
default:
return null;
}
};
return (
<>
<Table.Row>
{columns.map(col => (
<Table.Cell key={col.key} style={{ width: col.width }}>
{renderCell(col.key)}
</Table.Cell>
))}
</Table.Row>
{isExpanded && (
<Table.Row>
<Table.Cell colSpan={columns.length}>
<div className={styles.expandedContent}>
<div className={styles.twoColumnLayout}>
{/* Padding Column */}
{paddingProps.length > 0 && (
<div className={styles.column}>
<div className={styles.columnTitle}>Padding</div>
<div className={styles.propsGrid}>
{paddingProps.map(prop => (
<div key={prop.name} className={styles.propRow}>
<div className={styles.propRowName}>
<Chip head>{prop.name}</Chip>
</div>
<div className={styles.propRowDescription}>
{prop.description}
</div>
{prop.default && (
<div className={styles.propRowDefault}>
Default: <Chip>{prop.default}</Chip>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Margin Column */}
{marginProps.length > 0 && (
<div className={styles.column}>
<div className={styles.columnTitle}>Margin</div>
<div className={styles.propsGrid}>
{marginProps.map(prop => (
<div key={prop.name} className={styles.propRow}>
<div className={styles.propRowName}>
<Chip head>{prop.name}</Chip>
</div>
<div className={styles.propRowDescription}>
{prop.description}
</div>
{prop.default && (
<div className={styles.propRowDefault}>
Default: <Chip>{prop.default}</Chip>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</Table.Cell>
</Table.Row>
)}
</>
);
};
@@ -0,0 +1,77 @@
.button {
display: inline-flex;
align-items: center;
font-family: monospace;
font-size: 13px;
border-radius: 6px;
padding: 0px 8px;
height: 24px;
margin-right: 4px;
background-color: #f0f0f0;
color: #5d5d5d;
cursor: pointer;
border: none;
outline: none;
box-shadow: none;
transition: background-color 150ms ease;
}
.button:hover {
background-color: #e0e0e0;
}
[data-theme-mode='dark'] .button {
background-color: #2c2c2c;
color: #fff;
}
[data-theme-mode='dark'] .button:hover {
background-color: #3c3c3c;
}
.popover {
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
border-radius: 6px;
background: var(--bg);
color: var(--primary);
outline: none;
transition: transform 200ms, opacity 200ms;
padding: 1rem;
margin-top: 6px;
max-width: 400px;
--origin: translateY(-8px);
&[data-entering],
&[data-exiting] {
transform: var(--origin);
opacity: 0;
}
}
.arrow svg {
display: block;
fill: var(--bg);
stroke: var(--border);
stroke-width: 1px;
transform: rotate(180deg);
}
.title {
font-size: 14px;
font-weight: 500;
margin-bottom: 0.75rem;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
}
.note {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
@@ -0,0 +1,47 @@
import { Chip } from '../Chip';
import {
Button,
Dialog,
DialogTrigger,
OverlayArrow,
Popover,
} from 'react-aria-components';
import styles from './SpacingPopup.module.css';
interface SpacingPopupProps {
values: string[];
}
export const SpacingPopup = ({ values }: SpacingPopupProps) => {
// Display abbreviated list: first, second, ..., last
const firstValue = values[0];
const secondValue = values[1];
const lastValue = values[values.length - 1];
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.375rem' }}>
<Chip>{firstValue}</Chip>
<Chip>{secondValue}</Chip>
<DialogTrigger>
<Button className={styles.button}>...</Button>
<Popover className={styles.popover}>
<OverlayArrow className={styles.arrow}>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</OverlayArrow>
<Dialog>
<div className={styles.title}>All spacing values</div>
<div className={styles.grid}>
{values.map(value => (
<Chip key={value}>{value}</Chip>
))}
</div>
<div className={styles.note}>Also accepts custom string values</div>
</Dialog>
</Popover>
</DialogTrigger>
<Chip>{lastValue}</Chip>
</div>
);
};
+3 -1
View File
@@ -45,12 +45,14 @@ export const Row = ({ children }: { children: ReactNode }) => {
export const Cell = ({
children,
style,
colSpan,
}: {
children: ReactNode;
style?: CSSProperties;
colSpan?: number;
}) => {
return (
<td className={styles.tableCell} style={style}>
<td className={styles.tableCell} style={style} colSpan={colSpan}>
{children}
</td>
);
+99 -15
View File
@@ -15,6 +15,18 @@ export type ComplexTypeDef = {
>;
};
export type SpacingProp = {
name: string;
description: ReactNode;
default?: string;
};
export type SpacingGroupDef = {
props: SpacingProp[];
values: string[];
responsive: boolean;
};
export type PropDef = {
type:
| 'string'
@@ -23,9 +35,11 @@ export type PropDef = {
| 'number'
| 'boolean'
| 'spacing'
| 'spacing-group'
| 'complex';
values?: string | string[];
complexType?: ComplexTypeDef;
spacingGroup?: SpacingGroupDef;
default?: string;
required?: boolean;
responsive?: boolean;
@@ -58,43 +72,43 @@ export const paddingPropDefs = (
spacingValues: string[],
): Record<string, PropDef> => ({
p: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Padding on all sides.',
},
px: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Horizontal padding (left and right).',
},
py: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Vertical padding (top and bottom).',
},
pt: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Padding on the top.',
},
pr: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Padding on the right.',
},
pb: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Padding on the bottom.',
},
pl: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Padding on the left.',
@@ -105,43 +119,43 @@ export const marginPropDefs = (
spacingValues: string[],
): Record<string, PropDef> => ({
m: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Margin on all sides.',
},
mx: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Horizontal margin (left and right).',
},
my: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Vertical margin (top and bottom).',
},
mt: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Margin on the top.',
},
mr: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Margin on the right.',
},
mb: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Margin on the bottom.',
},
ml: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
description: 'Margin on the left.',
@@ -153,6 +167,76 @@ export const spacingPropDefs = {
...marginPropDefs(spacingValues),
};
// Spacing prop metadata for creating groups
const spacingPropMetadata: Record<string, SpacingProp> = {
p: { name: 'p', description: 'Padding on all sides.' },
px: { name: 'px', description: 'Horizontal padding (left and right).' },
py: { name: 'py', description: 'Vertical padding (top and bottom).' },
pt: { name: 'pt', description: 'Padding on the top.' },
pr: { name: 'pr', description: 'Padding on the right.' },
pb: { name: 'pb', description: 'Padding on the bottom.' },
pl: { name: 'pl', description: 'Padding on the left.' },
m: { name: 'm', description: 'Margin on all sides.' },
mx: { name: 'mx', description: 'Horizontal margin (left and right).' },
my: { name: 'my', description: 'Vertical margin (top and bottom).' },
mt: { name: 'mt', description: 'Margin on the top.' },
mr: { name: 'mr', description: 'Margin on the right.' },
mb: { name: 'mb', description: 'Margin on the bottom.' },
ml: { name: 'ml', description: 'Margin on the left.' },
};
export const createSpacingGroup = (
propNames: string[],
description?: ReactNode,
): PropDef => {
const props = propNames
.map(name => spacingPropMetadata[name])
.filter(Boolean);
return {
type: 'spacing-group',
spacingGroup: {
props,
values: spacingValues,
responsive: true,
},
description:
description || 'Spacing properties for controlling padding and margin.',
responsive: true,
};
};
// Pre-built spacing groups
export const spacingGroupAll = createSpacingGroup(
[
'p',
'px',
'py',
'pt',
'pr',
'pb',
'pl',
'm',
'mx',
'my',
'mt',
'mr',
'mb',
'ml',
],
'Padding and margin properties for controlling spacing around the element.',
);
export const spacingGroupPadding = createSpacingGroup(
['p', 'px', 'py', 'pt', 'pr', 'pb', 'pl'],
'Padding properties for controlling internal spacing.',
);
export const spacingGroupMargin = createSpacingGroup(
['m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'],
'Margin properties for controlling external spacing.',
);
export const displayPropDefs: Record<string, PropDef> = {
display: {
type: 'enum',
@@ -164,7 +248,7 @@ export const displayPropDefs: Record<string, PropDef> = {
export const gapPropDefs: Record<string, PropDef> = {
gap: {
type: 'enum | string',
type: 'spacing',
values: spacingValues,
responsive: true,
default: '4',