From 42b2d60e5729dcac9810c631533faae3ed74b761 Mon Sep 17 00:00:00 2001 From: viggy <70774793+vignesh191@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:59:00 -0700 Subject: [PATCH] webui: [a11y] fix keyboard navigation issues in chat interface and sidebar (#23132) * use child snippets for landing and chat message elements * make ... icon visible in conversation history menu * conversation history forward tab fix * add snippet fix for fork icon in conversation history * focus/keyboard fix for attachment x icon and scroll left/right * formatting * fix scroll down issue * simply Statistics and pointer events in scrolldown * create storybook tests and move to folder * improve tests to actually assert on element --- .../components/app/actions/ActionIcon.svelte | 36 +++-- .../components/app/badges/BadgeInfo.svelte | 8 +- ...hatAttachmentsListItemThumbnailFile.svelte | 4 +- ...atAttachmentsListItemThumbnailImage.svelte | 2 +- .../ChatMessageStatistics.svelte | 151 ++++++++---------- .../ChatMessageStatisticsBadge.svelte | 15 +- .../ChatScreenActionScrollDown.svelte | 9 +- .../app/misc/HorizontalScrollCarousel.svelte | 21 ++- .../components/app/models/ModelBadge.svelte | 9 +- .../app/models/ModelsSelectorDropdown.svelte | 142 ++++++++-------- .../app/navigation/DropdownMenuActions.svelte | 36 +++-- .../SidebarNavigationConversationItem.svelte | 25 ++- .../a11y/ActionIcon.a11y.stories.svelte | 34 ++++ .../ChatMessageStatistics.a11y.stories.svelte | 50 ++++++ .../ChatScreenForm.a11y.stories.svelte | 0 ...rizontalScrollCarousel.a11y.stories.svelte | 69 ++++++++ ...gationConversationItem.a11y.stories.svelte | 36 +++++ 17 files changed, 421 insertions(+), 226 deletions(-) create mode 100644 tools/ui/tests/stories/a11y/ActionIcon.a11y.stories.svelte create mode 100644 tools/ui/tests/stories/a11y/ChatMessageStatistics.a11y.stories.svelte rename tools/ui/tests/stories/{ => a11y}/ChatScreenForm.a11y.stories.svelte (100%) create mode 100644 tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte create mode 100644 tools/ui/tests/stories/a11y/SidebarNavigationConversationItem.a11y.stories.svelte diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte index 849b83b19c..f156df6699 100644 --- a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte +++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte @@ -35,23 +35,27 @@ - + onclick?.(e); + }} + class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!" + aria-label={ariaLabel || tooltip} + > + {#if icon} + {@const IconComponent = icon} + + {/if} + + {/snippet} diff --git a/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte b/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte index 25986082be..c87c94bc47 100644 --- a/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte +++ b/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte @@ -1,22 +1,22 @@ + {/snippet} + + + +

{opts.tooltipText}

+
+
+{/snippet} +
{#if hasPromptStats || isLive} - - - - - - -

Reading (prompt processing)

-
-
+ {@render viewButton({ + view: ChatMessageStatsView.READING, + icon: BookOpenText, + label: 'Reading', + tooltipText: 'Reading (prompt processing)' + })} {/if} - - - - - - -

- {isGenerationDisabled - ? 'Generation (waiting for tokens...)' - : 'Generation (token output)'} -

-
-
+ {@render viewButton({ + view: ChatMessageStatsView.GENERATION, + icon: Sparkles, + label: 'Generation', + tooltipText: isGenerationDisabled + ? 'Generation (waiting for tokens...)' + : 'Generation (token output)', + disabled: isGenerationDisabled + })} {#if hasAgenticStats} - - - - - - -

Tool calls

-
-
+ {@render viewButton({ + view: ChatMessageStatsView.TOOLS, + icon: Wrench, + label: 'Tools', + tooltipText: 'Tool calls' + })} {#if !hideSummary} - - - - - - -

Agentic summary

-
-
+ {@render viewButton({ + view: ChatMessageStatsView.SUMMARY, + icon: Layers, + label: 'Summary', + tooltipText: 'Agentic summary' + })} {/if} {/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte index eea7da7b2f..db7d01690a 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte @@ -21,13 +21,16 @@ {#if tooltipLabel} - - {#snippet icon()} - - {/snippet} + + {#snippet child({ props })} + + {#snippet icon()} + + {/snippet} - {value} - + {value} + + {/snippet}

{tooltipLabel}

diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte index c43bee3e3c..a22c491ada 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte @@ -41,16 +41,13 @@ }); -
+
+ {/snippet} + - {#if ms.updating} - + {#if selectedOption} + +

{selectedOption.model}

+
{/if} - + {/if} {/if}
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte index 83d856d10e..951831149f 100644 --- a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte +++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte @@ -34,24 +34,28 @@ - e.stopPropagation()} - > - {#if triggerTooltip} - - + + + + {#snippet child({ props })} + e.stopPropagation()} + > {@render iconComponent(triggerIcon, 'h-3 w-3')} - {triggerTooltip} - - -

{triggerTooltip}

-
-
- {:else} - {@render iconComponent(triggerIcon, 'h-3 w-3')} + {#if triggerTooltip} + {triggerTooltip} + {/if} +
+ {/snippet} + + {#if triggerTooltip} + +

{triggerTooltip}

+
{/if} - +
{#each actions as action, index (action.label)} diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte index dad8d954cb..e38a937385 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte @@ -105,6 +105,12 @@ onclick={handleSelect} onmouseover={handleMouseOver} onmouseleave={handleMouseLeave} + onfocusin={handleMouseOver} + onfocusout={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + handleMouseLeave(); + } + }} >
0} - - - + + {#snippet child({ props })} + + + + {/snippet} @@ -195,7 +205,8 @@ opacity: 0; } - &:is(:hover) :global([data-slot='dropdown-menu-trigger']) { + &:is(:hover) :global([data-slot='dropdown-menu-trigger']), + &:focus-within :global([data-slot='dropdown-menu-trigger']) { opacity: 1; } @media (max-width: 768px) { diff --git a/tools/ui/tests/stories/a11y/ActionIcon.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ActionIcon.a11y.stories.svelte new file mode 100644 index 0000000000..20f5e057b0 --- /dev/null +++ b/tools/ui/tests/stories/a11y/ActionIcon.a11y.stories.svelte @@ -0,0 +1,34 @@ + + + { + const before = await canvas.findByRole('button', { name: 'before' }); + const target = await canvas.findByRole('button', { name: 'Copy' }); + + before.focus(); + await userEvent.tab(); + + await expect(target).toHaveFocus(); + }} +> +
+ + {}} /> +
+
diff --git a/tools/ui/tests/stories/a11y/ChatMessageStatistics.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatMessageStatistics.a11y.stories.svelte new file mode 100644 index 0000000000..4aaf60cd65 --- /dev/null +++ b/tools/ui/tests/stories/a11y/ChatMessageStatistics.a11y.stories.svelte @@ -0,0 +1,50 @@ + + + { + const reading = await canvas.findByRole('button', { name: 'Reading' }); + const generation = await canvas.findByRole('button', { name: 'Generation' }); + const tools = await canvas.findByRole('button', { name: 'Tools' }); + const summary = await canvas.findByRole('button', { name: 'Summary' }); + + reading.focus(); + await expect(reading).toHaveFocus(); + + await userEvent.tab(); + await expect(generation).toHaveFocus(); + + await userEvent.tab(); + await expect(tools).toHaveFocus(); + + await userEvent.tab(); + await expect(summary).toHaveFocus(); + }} +/> diff --git a/tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte similarity index 100% rename from tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte rename to tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte diff --git a/tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte b/tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte new file mode 100644 index 0000000000..937d7ab109 --- /dev/null +++ b/tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte @@ -0,0 +1,69 @@ + + + { + const before = await canvas.findByRole('button', { name: 'before' }); + const after = await canvas.findByRole('button', { name: 'after' }); + const leftArrow = await canvas.findByRole('button', { name: 'Scroll left' }); + + await waitFor(() => { + expect(leftArrow).toBeDisabled(); + }); + + before.focus(); + await userEvent.tab(); + + await expect(after).toHaveFocus(); + }} +> +
+ + +
+
+
+ +
+
+ + { + const before = await canvas.findByRole('button', { name: 'before' }); + const rightArrow = await canvas.findByRole('button', { name: 'Scroll right' }); + + await waitFor(() => { + expect(rightArrow).not.toBeDisabled(); + }); + + before.focus(); + await userEvent.tab(); + + await expect(rightArrow).toHaveFocus(); + }} +> +
+ + + {#each [...Array(20).keys()] as i (i)} +
{i}
+ {/each} +
+
+
diff --git a/tools/ui/tests/stories/a11y/SidebarNavigationConversationItem.a11y.stories.svelte b/tools/ui/tests/stories/a11y/SidebarNavigationConversationItem.a11y.stories.svelte new file mode 100644 index 0000000000..1fc42608f7 --- /dev/null +++ b/tools/ui/tests/stories/a11y/SidebarNavigationConversationItem.a11y.stories.svelte @@ -0,0 +1,36 @@ + + + { + const row = await canvas.findByRole('button', { name: /Forked Conversation/ }); + const forkIcon = await canvas.findByRole('link'); + + row.focus(); + await userEvent.tab(); + + await expect(forkIcon).toHaveFocus(); + }} +/>