New details component, simpler a11y color contrast warning, updated docs bottom nav, updated ref tables

This commit is contained in:
Mark Otto 2025-10-30 10:18:48 -07:00
parent a019886084
commit 6772e18070
23 changed files with 315 additions and 44 deletions

View File

@ -1,7 +1,13 @@
---
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
interface ReferenceItem {
class: string;
styles: string | string[] | Record<string, string>;
styles?: string | string[] | Record<string, string>;
description?: string;
comment?: string; // Optional manual comment to append
[key: string]: any; // Allow additional properties
}
@ -22,6 +28,68 @@ const {
// Use explicit reference prop or data prop
const referenceData = reference || data || [];
// Parse CSS variables from _root.scss at build time
function parseCSSVariables(): Record<string, string> {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootScssPath = join(__dirname, '../../../scss/_root.scss');
const scssContent = readFileSync(rootScssPath, 'utf-8');
const cssVarValues: Record<string, string> = {};
// Match CSS variable declarations: --#{$prefix}variable-name: value;
// This regex captures the variable name and its value
const varRegex = /--#\{\$prefix\}([a-z0-9-]+):\s*([^;]+);/gi;
let match;
while ((match = varRegex.exec(scssContent)) !== null) {
const varName = `--bs-${match[1]}`;
let value = match[2].trim();
// Clean up SCSS interpolation syntax (e.g., #{$variable})
value = value.replace(/#\{[^}]+\}/g, '').trim();
// Remove inline comments
value = value.replace(/\/\/.*$/gm, '').trim();
// Only store if we have a clean value (not empty after removing interpolations)
if (value) {
cssVarValues[varName] = value;
}
}
return cssVarValues;
} catch (error) {
console.warn('Could not parse CSS variables from _root.scss:', error);
return {};
}
}
const cssVarValues = parseCSSVariables();
// Function to add CSS variable value comments
function addVarComments(cssValue: string): string {
const comments: string[] = [];
// Collect resolved values for all CSS variables
cssValue.replace(/var\((--[a-z0-9-]+)\)/gi, (match, varName) => {
const resolvedValue = cssVarValues[varName];
if (resolvedValue) {
comments.push(`<span class="color-3">/* ${resolvedValue} */</span>`);
}
return match;
});
// Append comments after the last semicolon or at the end
if (comments.length > 0) {
const hasSemicolon = cssValue.trimEnd().endsWith(';');
return `${cssValue}${hasSemicolon ? '' : ';'} ${comments.join(' ')}`;
}
return cssValue;
}
// If no explicit columns provided, infer from the first data item
const inferredColumns = columns || (() => {
if (referenceData.length === 0) {
@ -32,10 +100,12 @@ const inferredColumns = columns || (() => {
}
const firstItem = referenceData[0];
return Object.keys(firstItem).map(key => ({
label: key.charAt(0).toUpperCase() + key.slice(1), // Capitalize first letter
key: key
}));
return Object.keys(firstItem)
.filter(key => key !== 'comment') // Exclude comment field from columns
.map(key => ({
label: key.charAt(0).toUpperCase() + key.slice(1),
key: key
}));
})();
// Transform frontmatter format to table format
@ -51,26 +121,40 @@ const tableData = referenceData.map((item: ReferenceItem) => {
}
if (key === 'styles') {
let processedStyles = '';
if (typeof value === 'string') {
transformedItem[key] = value;
processedStyles = addVarComments(value);
} else if (typeof value === 'object' && !Array.isArray(value)) {
// Handle object syntax: { prop: value, prop2: value2 }
transformedItem[key] = Object.entries(value)
.map(([prop, val]) => `${prop}: ${val};`)
processedStyles = Object.entries(value)
.map(([prop, val]) => {
const cssLine = `${prop}: ${val};`;
return addVarComments(cssLine);
})
.join('<br/>');
} else if (Array.isArray(value)) {
transformedItem[key] = value.map((style: any) => {
processedStyles = value.map((style: any) => {
if (typeof style === 'string') {
return style.includes(':') ? style + (style.endsWith(';') ? '' : ';') : style;
const formattedStyle = style.includes(':') ? style + (style.endsWith(';') ? '' : ';') : style;
return addVarComments(formattedStyle);
}
if (typeof style === 'object') {
return Object.entries(style).map(([prop, val]) => `${prop}: ${val};`).join(' ');
const cssLine = Object.entries(style).map(([prop, val]) => `${prop}: ${val};`).join(' ');
return addVarComments(cssLine);
}
return style;
}).join('<br/>');
} else {
transformedItem[key] = value || '';
processedStyles = value || '';
}
// Append manual comment if provided in frontmatter
if (item.comment) {
processedStyles += `<br/><span class="color-3">/* ${item.comment} */</span>`;
}
transformedItem[key] = processedStyles;
} else {
transformedItem[key] = value;
}

View File

@ -13,7 +13,6 @@ interface Props {
| 'info-npm-starter'
| 'info-prefersreducedmotion'
| 'info-sanitizer'
| 'warning-color-assistive-technologies'
| 'warning-data-bs-title-vs-title'
| 'warning-input-support'
/**
@ -28,13 +27,13 @@ const { name, type = 'info' } = Astro.props
let Content: MarkdownInstance<{}>['Content'] | undefined
if (name) {
const callout = await getCalloutByName(name)
const callout = await getCalloutByName(name) as any
if (!callout) {
throw new Error(`Could not find callout with name '${name}'.`)
}
const namedCallout = await callout.render()
const namedCallout = await callout.render() as any
Content = namedCallout.Content
}
---

View File

@ -0,0 +1,59 @@
---
import { getDetailsByName } from '@libs/content'
import type { MarkdownInstance } from 'astro'
interface Props {
/**
* The name of an existing details content to display located in `src/content/details`.
* This will override any content passed in via the default slot.
*/
name?:
| 'danger-example'
| 'info-example'
| 'warning-color-assistive-technologies'
| 'warning-example'
/**
* The summary text displayed before the details are expanded.
* If not provided and `name` is set, will use the `title` from the markdown frontmatter.
*/
summary?: string
}
const { name } = Astro.props
let { summary } = Astro.props
let Content: MarkdownInstance<{}>['Content'] | undefined
if (name) {
const details = await getDetailsByName(name) as any
if (!details) {
throw new Error(`Could not find details with name '${name}'.`)
}
// Use title from frontmatter if summary is not provided
if (!summary && details.data?.title) {
summary = details.data.title
}
const namedDetails = await details.render() as any
Content = namedDetails.Content
}
// Ensure summary is always provided
if (!summary) {
throw new Error('Details component requires either a `summary` prop or a `title` in the markdown frontmatter.')
}
---
<details class="bd-details">
<summary class="bd-details-summary">
<svg class="bd-details-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>
</svg>
{summary}
</summary>
<div class="bd-details-content">
{Content ? <Content /> : <slot />}
</div>
</details>

View File

@ -1 +0,0 @@
**Accessibility tip:** Using color to add meaning only provides a visual indication, which will not be conveyed to users of assistive technologies like screen readers. Please ensure the meaning is obvious from the content itself (e.g., the visible text with a [_sufficient_ color contrast](/docs/[[config:docs_version]]/getting-started/accessibility/#color-contrast)) or is included through alternative means, such as additional text hidden with the `.visually-hidden` class.

View File

@ -35,6 +35,14 @@ const docsSchema = z.object({
})
.array()
.optional(),
utility: z.union([z.string(), z.string().array()]).optional(),
classes: z
.object({
class: z.string(),
description: z.string()
})
.array()
.optional(),
sections: z
.object({
description: z.string(),
@ -57,7 +65,16 @@ const calloutsCollection = defineCollection({
schema: calloutsSchema
})
const detailsSchema = z.object({
title: z.string().optional()
})
const detailsCollection = defineCollection({
schema: detailsSchema
})
export const collections = {
docs: docsCollection,
callouts: calloutsCollection
callouts: calloutsCollection,
details: detailsCollection
}

View File

@ -0,0 +1,5 @@
---
title: "Accessibility Tip: Using color to convey meaning"
---
Using color to add meaning only provides a visual indication, which will not be conveyed to users of assistive technologies like screen readers. Please ensure the meaning is obvious from the content itself (e.g., the visible text with a [_sufficient_ color contrast](/docs/[[config:docs_version]]/getting-started/accessibility/#color-contrast)) or is included through alternative means, such as additional text hidden with the `.visually-hidden` class.

View File

@ -14,7 +14,7 @@ Alerts are available for any length of text, as well as an optional close button
A simple ${themeColor.name} alert—check it out!
</div>`)} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
### Live example

View File

@ -59,7 +59,7 @@ Set a `background-color` with contrasting foreground `color` with [our `.text-bg
<Example code={getData('theme-colors').map((themeColor) => `<span class="badge theme-${themeColor.name}">${themeColor.title}</span>`)} />
<Example code={getData('theme-colors').map((themeColor) => `<span class="badge badge-subtle theme-${themeColor.name}">${themeColor.title}</span>`)} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## Pill badges

View File

@ -29,7 +29,7 @@ Bootstrap includes several button variants, each serving its own semantic purpos
`), `
<button type="button" class="btn btn-link">Link</button>`]} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## Disable text wrapping

View File

@ -389,7 +389,7 @@ Set a `background-color` with contrasting foreground `color` with [our `.text-bg
</div>
</div>`)} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
### Border

View File

@ -146,7 +146,7 @@ Contextual classes also work with `.list-group-item-action` for `<a>` and `<butt
`</div>`
]} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## With badges

View File

@ -88,7 +88,7 @@ Use background utility classes to change the appearance of individual progress b
<div class="progress-bar bg-danger" style="width: 100%"></div>
</div>`} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
If youre adding labels to progress bars with a custom background color, make sure to also set an appropriate [text color]([[docsref:/utilities/colors#colors]]), so the labels remain readable and have sufficient contrast. We recommend using the [color and background]([[docsref:/helpers/color-background]]) helper classes.

View File

@ -59,7 +59,7 @@ Use theme-specific color classes to style tables, table rows, or individual cell
`</tr>`
]} lang="html" />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## Accented tables

View File

@ -26,6 +26,18 @@ robots: noindex,follow
Danger callout
</Callout>
## Details
<Details summary="Click to expand inline content">
This is inline details content. It supports **markdown** formatting and will be hidden until clicked.
</Details>
<Details name="info-example" />
<Details name="warning-example" />
<Details name="danger-example" />
## Code example
```scss

View File

@ -16,7 +16,7 @@ Color and background helpers combine the power of our [`.text-*` utilities]([[do
<Example code={getData('theme-colors').map((themeColor) => `<div class="text-bg-${themeColor.name} p-3">${themeColor.title} with contrasting color</div>`)} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## With components

View File

@ -19,7 +19,7 @@ You can use the `.link-*` classes to colorize links. Unlike the [`.text-*` class
`<p><a href="#" class="link-body-emphasis">Emphasis link</a></p>`
]} />
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## Link utilities

View File

@ -14,7 +14,7 @@ utility:
import { getData } from '@libs/data'
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## Colors

View File

@ -11,7 +11,7 @@ utility:
import { getData } from '@libs/data'
<Callout name="warning-color-assistive-technologies" />
<Details name="warning-color-assistive-technologies" />
## Colors

View File

@ -10,6 +10,8 @@ import BaseLayout from '@layouts/BaseLayout.astro'
import DocsSidebar from '@components/DocsSidebar.astro'
import TableOfContents from '@components/TableOfContents.astro'
import ReferenceTable from '@components/ReferenceTable.astro'
import UtilityReferenceTable from '@components/UtilityReferenceTable.astro'
import HelperReferenceTable from '@components/HelperReferenceTable.astro'
import { getData } from '@libs/data'
import GitHubIcon from '@components/icons/GitHubIcon.astro'
import MdnIcon from '@components/icons/MdnIcon.astro'
@ -47,14 +49,28 @@ if (frontmatter.toc) {
let prevPage: NavigationPage | undefined
let nextPage: NavigationPage | undefined
// Create a flat array of all pages with their groups
const allPages = sidebar.flatMap((group) => {
// Create a flat array of all pages with their groups, handling nested groups
const allPages: NavigationPage[] = sidebar.flatMap((group) => {
if (!group.pages) return []
return group.pages.map((page) => ({
title: page.title,
url: `/docs/${getConfig().docs_version}/${getSlug(group.title)}/${getSlug(page.title)}/`,
groupTitle: group.title
}))
return group.pages.flatMap((item: any) => {
// Handle nested groups (e.g., Utilities > Borders > Border)
if (item.group && item.pages) {
return item.pages.map((page: any) => ({
title: page.title,
url: `/docs/${getConfig().docs_version}/${getSlug(group.title)}/${getSlug(page.title)}/`,
groupTitle: item.group
}))
}
// Handle direct pages
if (item.title) {
return [{
title: item.title,
url: `/docs/${getConfig().docs_version}/${getSlug(group.title)}/${getSlug(item.title)}/`,
groupTitle: group.title
}]
}
return []
})
})
// Find the current page index
@ -204,25 +220,41 @@ if (currentPageIndex < allPages.length - 1) {
)
}
{
frontmatter.utility && (
<div class="mb-5">
<UtilityReferenceTable utility={frontmatter.utility} />
</div>
)
}
{
frontmatter.classes && (
<div class="mb-5">
<HelperReferenceTable classes={frontmatter.classes} />
</div>
)
}
<slot />
<nav class="bd-links-nav py-5 mt-5 border-top">
<div class="d-flex flex-column flex-md-row justify-content-between">
<div class="d-flex flex-column flex-md-row justify-content-stretch gap-3 gap-md-5">
{
prevPage && (
<a href={prevPage.url} class="d-block p-3 text-decoration-none rounded-3">
<div class="text-secondary small">← Previous</div>
<div class="fw-semibold">{prevPage.title}</div>
<div class="text-secondary small">{prevPage.groupTitle}</div>
<a href={prevPage.url} class="d-block p-3 text-decoration-none border rounded-3 flex-grow-1">
<div class="fg-3">← Previous</div>
<div class="fs-5 fw-semibold">{prevPage.title}</div>
<div class="fg-3">{prevPage.groupTitle}</div>
</a>
)
}
{
nextPage && (
<a href={nextPage.url} class="d-block p-3 text-decoration-none text-end bg-1 rounded-3">
<div class="color-3">Next →</div>
<a href={nextPage.url} class="d-block p-3 text-decoration-none text-end border rounded-3 flex-grow-1">
<div class="fg-3">Next →</div>
<div class="fs-5 fw-semibold">{nextPage.title}</div>
<div class="color-3">{nextPage.groupTitle}</div>
<div class="fg-3">{nextPage.groupTitle}</div>
</a>
)
}

View File

@ -1,7 +1,8 @@
import { getCollection, getEntryBySlug } from 'astro:content'
import { getCollection, getEntry, getEntryBySlug } from 'astro:content'
export const docsPages = await getCollection('docs')
export const callouts = await getCollection('callouts')
export const details = await getCollection('details')
export const aliasedDocsPages = await getCollection('docs', ({ data }) => {
return data.aliases !== undefined
@ -10,3 +11,7 @@ export const aliasedDocsPages = await getCollection('docs', ({ data }) => {
export function getCalloutByName(name: string) {
return getEntryBySlug('callouts', name)
}
export function getDetailsByName(name: string) {
return getEntry('details', name)
}

View File

@ -0,0 +1,57 @@
@use "../../../scss/config" as *;
@use "../../../scss/colors" as *;
@use "../../../scss/mixins/border-radius" as *;
@use "../../../scss/mixins/transition" as *;
@layer custom {
.bd-details {
margin-block: 1.25rem;
color: var(--bs-fg-3);
background-color: var(--bs-bg-1);
@include border-radius(var(--bs-border-radius-lg));
&:hover,
&[open] {
color: var(--bs-fg-body);
background-color: var(--bs-bg-2);
}
.bd-details-summary {
display: flex;
gap: .25rem;
align-items: center;
padding: 1rem 1.25rem;
font-weight: 600;
list-style: none;
cursor: pointer;
user-select: none;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
}
}
.bd-details-icon {
flex-shrink: 0;
@include transition(transform .2s ease-in-out);
}
&[open] .bd-details-icon {
transform: rotate(90deg);
}
.bd-details-content {
padding: 0 1.25rem 1.25rem;
a { font-weight: 500; }
> :last-child {
margin-bottom: 0;
}
}
}
}

View File

@ -49,6 +49,7 @@
@use "component-examples";
@use "buttons";
@use "callouts";
@use "details";
@use "brand";
@use "colors";
@use "clipboard-js";

View File

@ -12,6 +12,7 @@ export declare global {
export const CalloutDeprecatedDarkVariants: typeof import('@shortcodes/CalloutDeprecatedDarkVariants.astro').default
export const Code: typeof import('@shortcodes/Code.astro').default
export const DeprecatedIn: typeof import('@shortcodes/DeprecatedIn.astro').default
export const Details: typeof import('@shortcodes/Details.astro').default
export const Example: typeof import('@shortcodes/Example.astro').default
export const JsDismiss: typeof import('@shortcodes/JsDismiss.astro').default
export const JsDocs: typeof import('@shortcodes/JsDocs.astro').default