Stabilize desktop nav accessible names

This commit is contained in:
rcourtman
2026-04-26 13:00:06 +01:00
parent 250bcb1f47
commit d22d60cc4f
5 changed files with 43 additions and 2 deletions
@@ -66,6 +66,11 @@ runtime cost control, and shared AI transport surfaces.
cache for assistant context and suggestions, but it must not reopen
`useResources()` or trigger a second unfiltered `all-resources` REST fetch
just because the drawer component is present in the app shell.
The same app-shell boundary keeps Patrol/Assistant utility navigation
accessible-name safe: labelled icon SVGs may remain meaningful when rendered
standalone, but `frontend-modern/src/AppLayout.tsx` must treat them as
decorative inside tabs so the announced tab name comes from product chrome
and meaningful badge text.
## Forbidden Paths
@@ -278,6 +278,10 @@ Community limit enforcement.
invitation flow must therefore refresh org bootstrap through the shared
`organizations_changed` app-shell path instead of forking a second hosted
org bootstrap or pricing-aware shell reload.
App-shell navigation rendered by `frontend-modern/src/AppLayout.tsx` must
also keep decorative icon titles out of tab accessible names, so hosted and
self-hosted chrome announce the canonical tab label plus meaningful badge
counts rather than duplicating branded SVG titles.
22. Keep the hosted account portal shell task-first and compact. Section
headers, billing action rows, and the maintained portal bundle under
`internal/cloudcp/portal/` may surface the facts an operator needs, but the
+18 -2
View File
@@ -48,6 +48,16 @@ const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
const AI_CHAT_LAUNCHER_BUTTON_CLASS =
'fixed right-4 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface text-blue-600 shadow-lg transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300 lg:right-0 lg:top-1/2 lg:bottom-auto lg:h-auto lg:w-auto lg:min-h-9 lg:min-w-10 lg:-translate-y-1/2 lg:rounded-l-lg lg:rounded-r-none lg:border-r-0 lg:px-2.5 lg:py-2.5 lg:shadow-none';
function getDesktopUtilityTabAriaLabel(tab: UtilityTab): string {
if (tab.id === 'alerts') {
const count = tab.count ?? 0;
if (count > 0) {
return `${count} ${tab.label}`;
}
}
return tab.label;
}
export interface AppLayoutProps {
connectionStatus: () => AppConnectionStatus;
lastUpdateText: () => string;
@@ -604,12 +614,15 @@ export function AppLayout(props: AppLayoutProps) {
<div
class={className()}
role="tab"
aria-label={platform.label}
aria-disabled={disabled()}
onMouseEnter={() => warmNavigationTarget(getPlatformTargetRoute(platform))}
onClick={() => handlePlatformClick(platform)}
title={title()}
>
<Icon class={NAV_TAB_ICON_CLASS} />
<span aria-hidden="true" class="inline-flex items-center justify-center">
<Icon class={NAV_TAB_ICON_CLASS} />
</span>
<span class="hidden xs:inline-flex items-center gap-1">
<span>{platform.label}</span>
<Show when={platform.badge}>
@@ -644,12 +657,15 @@ export function AppLayout(props: AppLayoutProps) {
<div
class={className()}
role="tab"
aria-label={getDesktopUtilityTabAriaLabel(tab)}
aria-disabled={false}
onMouseEnter={() => warmNavigationTarget(tab.route)}
onClick={() => handleUtilityClick(tab)}
title={tab.tooltip}
>
<Icon class={NAV_TAB_ICON_CLASS} />
<span aria-hidden="true" class="inline-flex items-center justify-center">
<Icon class={NAV_TAB_ICON_CLASS} />
</span>
<span class="flex items-center gap-1">
<span class="hidden xs:inline">{tab.label}</span>
<span class="xs:hidden">{tab.label.charAt(0)}</span>
@@ -130,9 +130,14 @@ describe('App architecture', () => {
"type MobileNavBarPlatformTab as PlatformTab,\n type MobileNavBarUtilityTab as UtilityTab,",
);
expect(appLayoutSource).toContain("const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';");
expect(appLayoutSource).toContain('function getDesktopUtilityTabAriaLabel(tab: UtilityTab)');
expect(appLayoutSource).toContain('return `${count} ${tab.label}`;');
expect(appLayoutSource).toContain('const platformTabs = createMemo<PlatformTab[]>(() =>');
expect(appLayoutSource).toContain('const Icon = platform.icon;');
expect(appLayoutSource).toContain('const Icon = tab.icon;');
expect(appLayoutSource).toContain('aria-label={platform.label}');
expect(appLayoutSource).toContain('aria-label={getDesktopUtilityTabAriaLabel(tab)}');
expect(appLayoutSource).toContain('<span aria-hidden="true" class="inline-flex items-center justify-center">');
expect(appLayoutSource).toContain('<Icon class={NAV_TAB_ICON_CLASS} />');
expect(appLayoutSource).not.toContain('type PlatformTab = {');
expect(appLayoutSource).not.toContain('type UtilityTab = {');
@@ -77,6 +77,15 @@ describe('AppLayout navigation icons', () => {
desktopTabs.forEach((tab) => {
expect(tab.querySelector('svg')).toBeTruthy();
});
const desktopPatrolTab = within(systemGroup as HTMLElement).getByRole('tab', {
name: 'Patrol',
});
expect(desktopPatrolTab.querySelector('svg')).toBeTruthy();
expect(within(systemGroup as HTMLElement).getByRole('tab', { name: '1 Alerts' })).toBeTruthy();
expect(
within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Pulse Patrol Patrol' }),
).toBeNull();
expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Patrol P' })).toBeNull();
const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' });
['alerts', 'ai', 'settings'].forEach((tabId) => {
@@ -84,6 +93,8 @@ describe('AppLayout navigation icons', () => {
expect(button).toBeTruthy();
expect(button?.querySelector('svg')).toBeTruthy();
});
const mobilePatrolTab = within(mobileTablist).getByRole('button', { name: 'Patrol' });
expect(mobilePatrolTab.querySelector('svg')).toBeTruthy();
expect(container).toHaveTextContent('Dashboard body');
});