Make workload tag filters keyboard accessible

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-31 17:54:38 +01:00
parent 5754d924d7
commit c8ee0ba74d
4 changed files with 180 additions and 61 deletions
@@ -3837,6 +3837,17 @@ That same owner now also holds the CSP-safe tag-dot rendering contract: tag
color and active-state emphasis must travel through SVG fill/stroke attributes
or stable classes, not inline `background-color`, `box-shadow`, or other
`style=` mutations that break the hosted demo CSP.
The shared tag owner also carries the complete accessibility contract for
compact tag disclosure. Every rendered tag must expose its name without
depending on pointer hover. Callers that provide filtering behavior must get
named native toggle buttons with current selection represented by
`aria-pressed`; informational tags and collapsed tag counts must remain
keyboard focusable so the same tooltip content is available on focus. Pointer
leave, blur, and Escape must dismiss those tooltips, focus must remain visibly
identifiable, and the compact dot presentation must use spaced 20px targets
rather than restoring an 8px click-only hit area. Feature rows and detail
surfaces must extend these shared semantics instead of wrapping tag dots in
feature-local click or tooltip handlers.
TagBadges also owns Proxmox tag color fidelity. When a caller supplies a
source instance, the primitive must read that instance's `pveTagStyles` entry
before the legacy aggregate `pveTagColors` map, and it must honor the Proxmox
+18 -19
View File
@@ -1,20 +1,17 @@
{
"version": 1,
"base_sha": "70c1d0a178f5c093a74c99000dc18e91d11e301f",
"verified_at": "2026-08-31T10:34:29Z",
"base_sha": "8f877bee14263289e6f18b501221f579214b89f9",
"verified_at": "2026-08-31T16:50:09Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/updatesSettingsModel.ts",
"frontend-modern/src/content/help/updates.ts",
"frontend-modern/src/utils/updatesPresentation.ts"
"frontend-modern/src/components/shared/TagBadges.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Settings/updatesSettingsModel.ts": "c574825033e3a90459b7acadd4bff81b109dfbdfa1e4e83ba58dc6a138471d06",
"frontend-modern/src/content/help/updates.ts": "c08f943d9f8db7b33d6f6ab5212d6d2dbe29eda07d07b4f2749426f3b03d6fb3",
"frontend-modern/src/utils/updatesPresentation.ts": "13a9254170cde398994443de7f1555e7120d4e5a1151ff76174b80d66a729f8c"
"frontend-modern/src/components/shared/TagBadges.tsx": "ce3f8112a72a8f1c772f3cf78ebb21e7de0abf858df6b7a922762c49f6933f53"
},
"routes": [
"/settings/system-updates"
"/proxmox?cols=name:280,tags:160",
"/proxmox"
],
"viewports": [
{
@@ -27,17 +24,19 @@
}
],
"states": [
"Stable selected with production-ready guidance and automatic stable updates available",
"Preview selected with beta-versus-release-candidate guidance and manual-channel notice",
"Preview selected with automatic stable updates disabled and the reason visible",
"saved Preview selection retained after a full browser reload",
"Stable and Preview cards, guidance, and update history visible at desktop and phone widths"
"unselected workload tag filters rendered as named native buttons",
"focused tag tooltip visible with a visible focus ring and no clipping",
"tag filter selected and then cleared through keyboard activation",
"tooltip dismissed by pointer leave and Escape while focus remained on the tag button",
"multiple adjacent tag targets remained compact at 20px with 4px spacing",
"390px responsive workload layout omitted wide-only tag controls and remained within the viewport"
],
"interactions": [
"navigated from Settings to Pulse server updates",
"changed the release channel from Stable to Preview and saved it",
"reloaded the page and confirmed the saved Preview selection persisted",
"changed back to Stable and confirmed the stable controls and copy returned",
"verified the complete update-channel surface at desktop and 390px phone widths"
"opened the Proxmox workload table with a supported shareable Name-and-Tags column layout",
"hovered and left a tag target to show and dismiss its tooltip",
"focused a tag target, dismissed its tooltip with Escape, and confirmed focus remained",
"pressed Enter twice to select and clear the same tag filter through aria-pressed state",
"inspected tag target geometry and tooltip placement in current-build Chromium",
"inspected the responsive Proxmox workload table at 390px and confirmed the existing wide-only tag omission without clipping"
]
}
@@ -39,49 +39,89 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
const ringClass = () =>
isActive() ? (isDark() ? 'text-white/90' : 'text-black/80') : 'text-transparent';
return (
<div
class="relative"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(dotProps.tag, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => {
hideTooltip();
}}
onClick={(e) => {
e.stopPropagation();
props.onTagClick?.(dotProps.tag);
}}
const showTagTooltip = (element: HTMLElement) => {
const rect = element.getBoundingClientRect();
showTooltip(dotProps.tag, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
};
const dot = () => (
<svg
data-tag-dot="true"
data-active={isActive() ? 'true' : 'false'}
viewBox="0 0 10 10"
aria-hidden="true"
class={`h-2 w-2 overflow-visible transition-transform duration-200 ease-out group-hover/tag:scale-150 group-focus-visible/tag:scale-150 ${ringClass()}`}
>
<svg
data-tag-dot="true"
data-active={isActive() ? 'true' : 'false'}
viewBox="0 0 10 10"
aria-hidden="true"
class={`h-2 w-2 overflow-visible hover:scale-150 transition-transform duration-200 ease-out cursor-pointer ${ringClass()}`}
>
<circle
data-tag-dot-ring="true"
cx="5"
cy="5"
r="4"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<circle data-tag-dot-fill="true" cx="5" cy="5" r="3" fill={colors().bg} />
</svg>
</div>
<circle
data-tag-dot-ring="true"
cx="5"
cy="5"
r="4"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<circle data-tag-dot-fill="true" cx="5" cy="5" r="3" fill={colors().bg} />
</svg>
);
const sharedClass =
'group/tag relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
return (
<Show
when={props.onTagClick}
fallback={
<span
class={`${sharedClass} cursor-help`}
role="img"
aria-label={`Tag: ${dotProps.tag}`}
tabIndex={0}
title={dotProps.tag}
onMouseEnter={(event) => showTagTooltip(event.currentTarget)}
onMouseLeave={hideTooltip}
onFocus={(event) => showTagTooltip(event.currentTarget)}
onBlur={hideTooltip}
onKeyDown={(event) => {
if (event.key === 'Escape') hideTooltip();
}}
>
{dot()}
</span>
}
>
{(onTagClick) => (
<button
type="button"
class={`${sharedClass} cursor-pointer`}
aria-label={`Filter by tag ${dotProps.tag}`}
aria-pressed={isActive()}
title={dotProps.tag}
onMouseEnter={(event) => showTagTooltip(event.currentTarget)}
onMouseLeave={hideTooltip}
onFocus={(event) => showTagTooltip(event.currentTarget)}
onBlur={hideTooltip}
onKeyDown={(event) => {
if (event.key === 'Escape') hideTooltip();
}}
onClick={(event) => {
event.stopPropagation();
onTagClick()(dotProps.tag);
}}
>
{dot()}
</button>
)}
</Show>
);
};
return (
<Show when={props.tags && props.tags.length > 0}>
<div class="inline-flex items-center gap-1 ml-2">
<div class="ml-2 inline-flex items-center gap-1" role="group" aria-label="Tags">
<For each={visibleTags()}>{(tag) => <TagDot tag={tag} />}</For>
{/* Show the final dot if only one hidden tag remains */}
@@ -91,8 +131,12 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
{/* Show +X more indicator if there are multiple hidden tags */}
<Show when={hiddenTags().length > 1}>
<div
class="relative"
<span
class="relative inline-flex h-5 min-w-5 cursor-help items-center justify-center rounded px-1 text-[10px] leading-none text-muted transition-colors hover:text-base-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
role="img"
aria-label={`${hiddenTags().length} more tags: ${hiddenTags().join(', ')}`}
tabIndex={0}
title={hiddenTags().join(', ')}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const content = hiddenTags().join('\n');
@@ -107,11 +151,26 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
onMouseLeave={() => {
hideTooltip();
}}
onFocus={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const content = hiddenTags().join('\n');
if (content) {
showTooltip(content, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
maxWidth: 260,
});
}
}}
onBlur={hideTooltip}
onKeyDown={(event) => {
if (event.key === 'Escape') hideTooltip();
}}
>
<div class="inline-flex items-center text-[10px] text-muted whitespace-nowrap leading-none cursor-help hover:text-base-content hover:scale-125 transition-transform duration-200 ease-out">
<span aria-hidden="true" class="whitespace-nowrap">
+{hiddenTags().length}
</div>
</div>
</span>
</span>
</Show>
</div>
</Show>
@@ -261,6 +261,19 @@ describe('TagBadges', () => {
});
describe('click interaction', () => {
it('renders clickable tags as named native toggle buttons', () => {
render(() => <TagBadges tags={['web', 'db']} activeSearch="tags:web" onTagClick={vi.fn()} />);
const activeTag = screen.getByRole('button', { name: 'Filter by tag web' });
const inactiveTag = screen.getByRole('button', { name: 'Filter by tag db' });
expect(activeTag).toHaveAttribute('type', 'button');
expect(activeTag).toHaveAttribute('aria-pressed', 'true');
expect(inactiveTag).toHaveAttribute('aria-pressed', 'false');
expect(activeTag).toHaveClass('h-5', 'w-5');
expect(activeTag.parentElement).toHaveClass('gap-1');
});
it('calls onTagClick with the tag name when a dot is clicked', () => {
const onTagClick = vi.fn();
render(() => <TagBadges tags={['web', 'db']} onTagClick={onTagClick} />);
@@ -298,6 +311,14 @@ describe('TagBadges', () => {
const dot = getTagDots()[0] as HTMLElement;
expect(() => fireEvent.click(dot.parentElement!)).not.toThrow();
});
it('exposes non-clickable tags as named, focusable images instead of inert color dots', () => {
render(() => <TagBadges tags={['web']} />);
const tag = screen.getByRole('img', { name: 'Tag: web' });
expect(tag).toHaveAttribute('tabindex', '0');
expect(tag).toHaveAttribute('title', 'web');
});
});
describe('tooltip behavior', () => {
@@ -319,6 +340,22 @@ describe('TagBadges', () => {
expect(hideTooltipMock).toHaveBeenCalledTimes(1);
});
it('shows and hides a clickable tag tooltip through keyboard focus', () => {
render(() => <TagBadges tags={['web']} onTagClick={vi.fn()} />);
const tag = screen.getByRole('button', { name: 'Filter by tag web' });
fireEvent.focus(tag);
expect(showTooltipMock).toHaveBeenCalledWith('web', expect.any(Number), expect.any(Number), {
align: 'center',
direction: 'up',
});
fireEvent.keyDown(tag, { key: 'Escape' });
expect(hideTooltipMock).toHaveBeenCalledTimes(1);
fireEvent.blur(tag);
expect(hideTooltipMock).toHaveBeenCalledTimes(2);
});
it('shows tooltip with joined hidden tags on +X hover', () => {
render(() => <TagBadges tags={['a', 'b', 'c', 'd', 'e']} />);
const overflowIndicator = screen.getByText('+2');
@@ -336,6 +373,19 @@ describe('TagBadges', () => {
fireEvent.mouseLeave(overflowIndicator.parentElement!);
expect(hideTooltipMock).toHaveBeenCalledTimes(1);
});
it('names the overflow disclosure and reveals its tags on focus', () => {
render(() => <TagBadges tags={['a', 'b', 'c', 'd', 'e']} />);
const overflow = screen.getByRole('img', { name: '2 more tags: d, e' });
expect(overflow).toHaveAttribute('tabindex', '0');
fireEvent.focus(overflow);
expect(showTooltipMock).toHaveBeenCalledWith('d\ne', expect.any(Number), expect.any(Number), {
align: 'center',
direction: 'up',
maxWidth: 260,
});
});
});
describe('edge cases', () => {