diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b669a79b4..cfe7df867 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4ef6e8c6f..3584cd846 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/shared/TagBadges.tsx b/frontend-modern/src/components/shared/TagBadges.tsx index 66f7e3896..0bb93af75 100644 --- a/frontend-modern/src/components/shared/TagBadges.tsx +++ b/frontend-modern/src/components/shared/TagBadges.tsx @@ -39,49 +39,89 @@ export const TagBadges: Component = (props) => { const ringClass = () => isActive() ? (isDark() ? 'text-white/90' : 'text-black/80') : 'text-transparent'; - return ( -
{ - 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 = () => ( +
+ + + + ); + + 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 ( + showTagTooltip(event.currentTarget)} + onMouseLeave={hideTooltip} + onFocus={(event) => showTagTooltip(event.currentTarget)} + onBlur={hideTooltip} + onKeyDown={(event) => { + if (event.key === 'Escape') hideTooltip(); + }} + > + {dot()} + + } + > + {(onTagClick) => ( + + )} + ); }; return ( 0}> -
+
{(tag) => } {/* Show the final dot if only one hidden tag remains */} @@ -91,8 +131,12 @@ export const TagBadges: Component = (props) => { {/* Show +X more indicator if there are multiple hidden tags */} 1}> - + +
diff --git a/frontend-modern/src/components/shared/__tests__/TagBadges.test.tsx b/frontend-modern/src/components/shared/__tests__/TagBadges.test.tsx index b3f720a35..1f2373e5a 100644 --- a/frontend-modern/src/components/shared/__tests__/TagBadges.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/TagBadges.test.tsx @@ -261,6 +261,19 @@ describe('TagBadges', () => { }); describe('click interaction', () => { + it('renders clickable tags as named native toggle buttons', () => { + render(() => ); + + 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(() => ); @@ -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(() => ); + + 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(() => ); + 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(() => ); 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(() => ); + 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', () => {