fix(ui): improve semantic markup and a11y across Header components

- Tags: replace div+Fragment with ul/li; move circle divider to CSS ::before pseudo-element, removing it from the DOM
- Metadata: replace div wrapper with dl/dt/dd for proper key-value semantics; reset dl and dd browser margins
- HeaderMetadataUsers: render multi-user stack as ul/li; simplify single-user branch into one ternary with a fragment
- HeaderMetadataStatus: add role="img" and aria-label to the status dot so screen readers announce its meaning
- Restore Fragment import from react

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-04-22 15:02:28 +01:00
parent ec109ce7fb
commit a69c2e29a2
6 changed files with 99 additions and 66 deletions
@@ -54,9 +54,19 @@
align-items: center;
gap: var(--bui-space-2);
flex-wrap: wrap;
list-style: none;
margin: 0;
padding: 0;
}
.bui-HeaderTagDivider {
.bui-HeaderTag {
display: flex;
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderTag + .bui-HeaderTag::before {
content: '';
width: 3px;
height: 3px;
border-radius: 50%;
@@ -70,6 +80,7 @@
align-items: center;
gap: var(--bui-space-5);
flex-wrap: wrap;
margin: 0;
}
.bui-HeaderMetaItem {
@@ -77,5 +88,9 @@
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
dd {
margin: 0;
}
}
}
+22 -16
View File
@@ -23,7 +23,7 @@ import { HeaderDefinition } from './definition';
import { Container } from '../Container';
import { Lexer } from 'marked';
import { Link } from '../Link';
import { Fragment } from 'react/jsx-runtime';
import { Fragment } from 'react';
// Reject javascript:/vbscript:/data: URIs to prevent XSS via description links.
const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i;
@@ -78,10 +78,12 @@ export const Header = (props: HeaderProps) => {
return (
<Container className={classes.root}>
{tags && tags.length > 0 && (
<div className={classes.tags}>
<ul className={classes.tags}>
{tags.map((tag, i) => (
<Fragment key={`${i}:${tag.label}:${tag.href ?? ''}`}>
{i > 0 && <span className={classes.tagDivider} aria-hidden />}
<li
key={`${i}:${tag.label}:${tag.href ?? ''}`}
className={classes.tag}
>
{tag.href ? (
<Link
href={tag.href}
@@ -96,9 +98,9 @@ export const Header = (props: HeaderProps) => {
{tag.label}
</Text>
)}
</Fragment>
</li>
))}
</div>
</ul>
)}
<div className={classes.content}>
<div className={classes.breadcrumbs}>
@@ -135,20 +137,24 @@ export const Header = (props: HeaderProps) => {
</Text>
)}
{metadata && metadata.length > 0 && (
<div className={classes.metaRow}>
<dl className={classes.metaRow}>
{metadata.map((item, i) => (
<div key={`${i}:${item.label}`} className={classes.metaItem}>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
<dt>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
</dt>
<dd>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
</dd>
</div>
))}
</div>
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
@@ -32,7 +32,11 @@ export const HeaderMetadataStatus = ({
}: HeaderMetadataStatusItem) => {
return (
<div className={styles.single}>
<span className={`${styles.dot} ${styles[`dot-${color}`]}`} />
<span
role="img"
aria-label={`${color} status`}
className={`${styles.dot} ${styles[`dot-${color}`]}`}
/>
<Text variant="body-medium">
{href ? (
<Link href={href} standalone>
@@ -29,6 +29,9 @@
flex-direction: row;
align-items: center;
gap: var(--bui-space-1);
list-style: none;
margin: 0;
padding: 0;
}
.avatarLink {
@@ -39,43 +39,10 @@ export const HeaderMetadataUsers = ({
if (users.length === 1) {
const user = users[0];
const avatar = (
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="decoration"
/>
);
return (
<div className={styles.single}>
{user.href ? (
<Link
href={user.href}
aria-label={user.name}
className={styles.avatarLink}
>
{avatar}
</Link>
) : (
avatar
)}
{user.href ? (
<Link href={user.href} variant="body-medium" standalone>
{user.name}
</Link>
) : (
<Text variant="body-medium">{user.name}</Text>
)}
</div>
);
}
return (
<div className={styles.stack}>
{users.map(user => (
<TooltipTrigger key={user.name}>
{user.href ? (
<>
<Link
href={user.href}
aria-label={user.name}
@@ -88,19 +55,57 @@ export const HeaderMetadataUsers = ({
purpose="decoration"
/>
</Link>
) : (
<Pressable>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="informative"
/>
</Pressable>
)}
<Tooltip>{user.name}</Tooltip>
</TooltipTrigger>
<Link href={user.href} variant="body-medium" standalone>
{user.name}
</Link>
</>
) : (
<>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="decoration"
/>
<Text variant="body-medium">{user.name}</Text>
</>
)}
</div>
);
}
return (
<ul className={styles.stack}>
{users.map(user => (
<li key={user.name}>
<TooltipTrigger>
{user.href ? (
<Link
href={user.href}
aria-label={user.name}
className={styles.avatarLink}
>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="decoration"
/>
</Link>
) : (
<Pressable>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="informative"
/>
</Pressable>
)}
<Tooltip>{user.name}</Tooltip>
</TooltipTrigger>
</li>
))}
</div>
</ul>
);
};
@@ -31,7 +31,7 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
tabsWrapper: 'bui-HeaderTabsWrapper',
controls: 'bui-HeaderControls',
tags: 'bui-HeaderTags',
tagDivider: 'bui-HeaderTagDivider',
tag: 'bui-HeaderTag',
description: 'bui-HeaderDescription',
metaRow: 'bui-HeaderMetaRow',
metaItem: 'bui-HeaderMetaItem',