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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user