mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Make workload tag filters keyboard accessible
Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user