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
This commit is contained in:
viggy
2026-06-04 17:59:00 +02:00
committed by GitHub
parent e7bcf1c3a8
commit 42b2d60e57
17 changed files with 421 additions and 226 deletions
@@ -35,23 +35,27 @@
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<Button <!-- prevent another nested button element -->
{variant} {#snippet child({ props })}
{size} <Button
{disabled} {...props}
onclick={(e: MouseEvent) => { {variant}
if (stopPropagationOnClick) e.stopPropagation(); {size}
{disabled}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
onclick?.(e); onclick?.(e);
}} }}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!" class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
aria-label={ariaLabel || tooltip} aria-label={ariaLabel || tooltip}
> >
{#if icon} {#if icon}
{@const IconComponent = icon} {@const IconComponent = icon}
<IconComponent class={iconSize} /> <IconComponent class={iconSize} />
{/if} {/if}
</Button> </Button>
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side={tooltipSide}> <Tooltip.Content side={tooltipSide}>
@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props { interface Props extends HTMLButtonAttributes {
children: Snippet; children: Snippet;
class?: string; class?: string;
icon?: Snippet; icon?: Snippet;
onclick?: () => void;
} }
let { children, class: className = '', icon, onclick }: Props = $props(); let { children, class: className = '', icon, ...rest }: Props = $props();
</script> </script>
<button <button
{...rest}
class={[ class={[
'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75', 'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
className className
]} ]}
{onclick}
> >
{#if icon} {#if icon}
{@render icon()} {@render icon()}
@@ -97,7 +97,9 @@
{/snippet} {/snippet}
{#snippet removeButton()} {#snippet removeButton()}
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"> <div
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} /> <ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
</div> </div>
{/snippet} {/snippet}
@@ -51,7 +51,7 @@
{#if !readonly} {#if !readonly}
<div <div
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100" class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
> >
<ActionIcon <ActionIcon
class="text-white" class="text-white"
@@ -6,6 +6,7 @@
import type { ChatMessageAgenticTimings } from '$lib/types/chat'; import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils'; import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants'; import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
import type { Component } from 'svelte';
interface Props { interface Props {
predictedTokens?: number; predictedTokens?: number;
@@ -114,101 +115,79 @@
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs)); let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
</script> </script>
{#snippet viewButton(opts: {
view: ChatMessageStatsView;
icon: Component;
label: string;
tooltipText: string;
disabled?: boolean;
})}
{@const IconComponent = opts.icon}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<button
{...props}
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
opts.view
? 'bg-background text-foreground shadow-sm'
: opts.disabled
? 'cursor-not-allowed opacity-40'
: 'hover:text-foreground'}"
onclick={() => !opts.disabled && (activeView = opts.view)}
disabled={opts.disabled}
>
<IconComponent class="h-3 w-3" />
<span class="sr-only">{opts.label}</span>
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{opts.tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
<div class="inline-flex items-center text-xs text-muted-foreground"> <div class="inline-flex items-center text-xs text-muted-foreground">
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5"> <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
{#if hasPromptStats || isLive} {#if hasPromptStats || isLive}
<Tooltip.Root> {@render viewButton({
<Tooltip.Trigger> view: ChatMessageStatsView.READING,
<button icon: BookOpenText,
type="button" label: 'Reading',
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === tooltipText: 'Reading (prompt processing)'
ChatMessageStatsView.READING })}
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.READING)}
>
<BookOpenText class="h-3 w-3" />
<span class="sr-only">Reading</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reading (prompt processing)</p>
</Tooltip.Content>
</Tooltip.Root>
{/if} {/if}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.GENERATION
? 'bg-background text-foreground shadow-sm'
: isGenerationDisabled
? 'cursor-not-allowed opacity-40'
: 'hover:text-foreground'}"
onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
disabled={isGenerationDisabled}
>
<Sparkles class="h-3 w-3" />
<span class="sr-only">Generation</span> {@render viewButton({
</button> view: ChatMessageStatsView.GENERATION,
</Tooltip.Trigger> icon: Sparkles,
label: 'Generation',
<Tooltip.Content> tooltipText: isGenerationDisabled
<p> ? 'Generation (waiting for tokens...)'
{isGenerationDisabled : 'Generation (token output)',
? 'Generation (waiting for tokens...)' disabled: isGenerationDisabled
: 'Generation (token output)'} })}
</p>
</Tooltip.Content>
</Tooltip.Root>
{#if hasAgenticStats} {#if hasAgenticStats}
<Tooltip.Root> {@render viewButton({
<Tooltip.Trigger> view: ChatMessageStatsView.TOOLS,
<button icon: Wrench,
type="button" label: 'Tools',
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === tooltipText: 'Tool calls'
ChatMessageStatsView.TOOLS })}
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
>
<Wrench class="h-3 w-3" />
<span class="sr-only">Tools</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool calls</p>
</Tooltip.Content>
</Tooltip.Root>
{#if !hideSummary} {#if !hideSummary}
<Tooltip.Root> {@render viewButton({
<Tooltip.Trigger> view: ChatMessageStatsView.SUMMARY,
<button icon: Layers,
type="button" label: 'Summary',
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === tooltipText: 'Agentic summary'
ChatMessageStatsView.SUMMARY })}
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
>
<Layers class="h-3 w-3" />
<span class="sr-only">Summary</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Agentic summary</p>
</Tooltip.Content>
</Tooltip.Root>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -21,13 +21,16 @@
{#if tooltipLabel} {#if tooltipLabel}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<BadgeInfo class={className} onclick={handleClick}> <!-- prevent another nested button element -->
{#snippet icon()} {#snippet child({ props })}
<IconComponent class="h-3 w-3" /> <BadgeInfo {...props} class={className} onclick={handleClick}>
{/snippet} {#snippet icon()}
<IconComponent class="h-3 w-3" />
{/snippet}
{value} {value}
</BadgeInfo> </BadgeInfo>
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
<p>{tooltipLabel}</p> <p>{tooltipLabel}</p>
@@ -41,16 +41,13 @@
}); });
</script> </script>
<div <div class="relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center">
class="pointer-events-{show
? 'auto'
: 'none'} relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center"
>
<Button <Button
onclick={scrollToBottom} onclick={scrollToBottom}
variant="secondary" variant="secondary"
size="icon" size="icon"
class="pointer-events-all absolute h-10 w-10 rounded-full bg-background/80 shadow-lg backdrop-blur-sm transition-all duration-200 hover:bg-muted/80" disabled={!show}
class="pointer-events-auto absolute h-10 w-10 rounded-full bg-background/80 shadow-lg backdrop-blur-sm transition-all duration-200 hover:bg-muted/80"
style="bottom: {buttonBottom}; transform: translateY({show ? '0' : '2rem'}); opacity: {show style="bottom: {buttonBottom}; transform: translateY({show ? '0' : '2rem'}); opacity: {show
? 1 ? 1
: 0};" : 0};"
@@ -55,20 +55,20 @@
} }
$effect(() => { $effect(() => {
if (scrollContainer) { if (!scrollContainer) return;
setTimeout(() => {
updateScrollButtons(); const observer = new ResizeObserver(() => updateScrollButtons());
}, 0); observer.observe(scrollContainer);
}
return () => observer.disconnect();
}); });
</script> </script>
<div class="relative {className}"> <div class="relative {className}">
<button <button
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 {canScrollLeft class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0"
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollLeft} onclick={scrollLeft}
disabled={!canScrollLeft}
aria-label="Scroll left" aria-label="Scroll left"
> >
<ChevronLeft class="h-4 w-4" /> <ChevronLeft class="h-4 w-4" />
@@ -83,10 +83,9 @@
</div> </div>
<button <button
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 {canScrollRight class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0"
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollRight} onclick={scrollRight}
disabled={!canScrollRight}
aria-label="Scroll right" aria-label="Scroll right"
> >
<ChevronRight class="h-4 w-4" /> <ChevronRight class="h-4 w-4" />
@@ -27,8 +27,8 @@
let shouldShow = $derived(model && (modelProp !== undefined || isModelMode)); let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
</script> </script>
{#snippet badgeContent()} {#snippet badgeContent(triggerProps?: Record<string, unknown>)}
<BadgeInfo class={className} {onclick}> <BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
{#snippet icon()} {#snippet icon()}
<Package class="h-3 w-3" /> <Package class="h-3 w-3" />
{/snippet} {/snippet}
@@ -47,7 +47,10 @@
{#if showTooltip} {#if showTooltip}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
{@render badgeContent()} <!-- prevent another nested button element -->
{#snippet child({ props })}
{@render badgeContent(props)}
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
@@ -116,52 +116,54 @@
{#if ms.isRouter} {#if ms.isRouter}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}> <DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<DropdownMenu.Trigger <Tooltip.Root>
class={[ <Tooltip.Trigger>
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`, <!-- prevent another nested button element -->
!ms.isCurrentModelInCache {#snippet child({ props })}
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' <DropdownMenu.Trigger
: forceForegroundText {...props}
? 'text-foreground' class={[
: ms.isHighlightedCurrentModelActive `inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
? 'text-foreground' !ms.isCurrentModelInCache
: 'text-foreground', ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
isOpen && 'text-foreground', : forceForegroundText
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]' ? 'text-foreground'
]} : ms.isHighlightedCurrentModelActive
disabled={disabled || ms.updating} ? 'text-foreground'
> : 'text-foreground',
<Package class="h-3.5 w-3.5 shrink-0" /> isOpen && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption} {#if selectedOption}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<ModelId <ModelId
modelId={selectedOption.model} modelId={selectedOption.model}
class="min-w-0 overflow-hidden" class="min-w-0 overflow-hidden"
hideOrgName={false} hideOrgName={false}
hideQuantization hideQuantization
{...props}
/> />
{/snippet} {:else}
</Tooltip.Trigger> <span class="min-w-0 font-medium">Select model</span>
{/if}
<Tooltip.Content> {#if ms.updating || ms.isLoadingModel}
<p class="font-mono">{selectedOption.model}</p> <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
</Tooltip.Content> {:else}
</Tooltip.Root> <ChevronDown class="h-3 w-3.5 shrink-0" />
{:else} {/if}
<span class="min-w-0 font-medium">Select model</span> </DropdownMenu.Trigger>
{/if} {/snippet}
</Tooltip.Trigger>
{#if ms.updating || ms.isLoadingModel} {#if selectedOption}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" /> <Tooltip.Content>
{:else} <p class="font-mono">{selectedOption.model}</p>
<ChevronDown class="h-3 w-3.5 shrink-0" /> </Tooltip.Content>
{/if} {/if}
</DropdownMenu.Trigger> </Tooltip.Root>
<DropdownMenu.Content <DropdownMenu.Content
align="end" align="end"
@@ -234,49 +236,51 @@
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{:else} {:else}
<button <Tooltip.Root>
class={[ <Tooltip.Trigger>
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`, <!-- prevent another nested button element -->
!ms.isCurrentModelInCache {#snippet child({ props })}
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' <button
: forceForegroundText {...props}
? 'text-foreground' class={[
: ms.isHighlightedCurrentModelActive `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
? 'text-foreground' !ms.isCurrentModelInCache
: 'text-foreground', ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
isOpen && 'text-foreground' : forceForegroundText
]} ? 'text-foreground'
style="max-width: min(calc(100cqw - 6.5rem), 32rem)" : ms.isHighlightedCurrentModelActive
onclick={() => ms.handleOpenChange(true)} ? 'text-foreground'
disabled={disabled || ms.updating} : 'text-foreground',
> isOpen && 'text-foreground'
<Package class="h-3.5 w-3.5 shrink-0" /> ]}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption} {#if selectedOption}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<ModelId <ModelId
modelId={selectedOption.model} modelId={selectedOption.model}
class="min-w-0 overflow-hidden" class="min-w-0 overflow-hidden"
hideOrgName={false} hideOrgName={false}
hideQuantization hideQuantization
{...props}
/> />
{/snippet} {/if}
</Tooltip.Trigger>
<Tooltip.Content> {#if ms.updating}
<p class="font-mono">{selectedOption.model}</p> <Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
</Tooltip.Content> {/if}
</Tooltip.Root> </button>
{/if} {/snippet}
</Tooltip.Trigger>
{#if ms.updating} {#if selectedOption}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" /> <Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
{/if} {/if}
</button> </Tooltip.Root>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -34,24 +34,28 @@
</script> </script>
<DropdownMenu.Root bind:open> <DropdownMenu.Root bind:open>
<DropdownMenu.Trigger <Tooltip.Root>
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}" <Tooltip.Trigger>
onclick={(e) => e.stopPropagation()} <!-- prevent another nested button element -->
> {#snippet child({ props })}
{#if triggerTooltip} <DropdownMenu.Trigger
<Tooltip.Root> {...props}
<Tooltip.Trigger> class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
onclick={(e) => e.stopPropagation()}
>
{@render iconComponent(triggerIcon, 'h-3 w-3')} {@render iconComponent(triggerIcon, 'h-3 w-3')}
<span class="sr-only">{triggerTooltip}</span> {#if triggerTooltip}
</Tooltip.Trigger> <span class="sr-only">{triggerTooltip}</span>
<Tooltip.Content> {/if}
<p>{triggerTooltip}</p> </DropdownMenu.Trigger>
</Tooltip.Content> {/snippet}
</Tooltip.Root> </Tooltip.Trigger>
{:else} {#if triggerTooltip}
{@render iconComponent(triggerIcon, 'h-3 w-3')} <Tooltip.Content>
<p>{triggerTooltip}</p>
</Tooltip.Content>
{/if} {/if}
</DropdownMenu.Trigger> </Tooltip.Root>
<DropdownMenu.Content {align} class="z-[999999] w-48"> <DropdownMenu.Content {align} class="z-[999999] w-48">
{#each actions as action, index (action.label)} {#each actions as action, index (action.label)}
@@ -105,6 +105,12 @@
onclick={handleSelect} onclick={handleSelect}
onmouseover={handleMouseOver} onmouseover={handleMouseOver}
onmouseleave={handleMouseLeave} onmouseleave={handleMouseLeave}
onfocusin={handleMouseOver}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
handleMouseLeave();
}
}}
> >
<div <div
class="flex min-w-0 flex-1 items-center gap-2" class="flex min-w-0 flex-1 items-center gap-2"
@@ -113,12 +119,16 @@
{#if depth > 0} {#if depth > 0}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<a <!-- prevent another nested button element -->
href={RouterService.chat(conversation.forkedFromConversationId)} {#snippet child({ props })}
class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground" <a
> {...props}
<GitBranch class="h-3.5 w-3.5" /> href={RouterService.chat(conversation.forkedFromConversationId)}
</a> class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
>
<GitBranch class="h-3.5 w-3.5" />
</a>
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
@@ -195,7 +205,8 @@
opacity: 0; 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; opacity: 1;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -0,0 +1,34 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Copy } from '@lucide/svelte';
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
import { expect } from 'storybook/test';
const { Story } = defineMeta({
title: 'Components/ActionIcon/Accessibility',
component: ActionIcon,
parameters: {
layout: 'centered'
},
tags: ['!dev']
});
</script>
<Story
asChild
name="SingleTabStop"
play={async ({ canvas, userEvent }) => {
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();
}}
>
<div>
<button type="button">before</button>
<ActionIcon icon={Copy} tooltip="Copy" onclick={() => {}} />
</div>
</Story>
@@ -0,0 +1,50 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatMessageStatistics from '$lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte';
import { expect } from 'storybook/test';
const { Story } = defineMeta({
title: 'Components/ChatMessageStatistics/Accessibility',
component: ChatMessageStatistics,
parameters: {
layout: 'centered'
},
tags: ['!dev']
});
</script>
<Story
name="ViewButtonsSingleTabStop"
args={{
promptTokens: 100,
promptMs: 500,
predictedTokens: 200,
predictedMs: 1000,
agenticTimings: {
turns: 1,
toolCallsCount: 1,
toolsMs: 500,
llm: { predicted_n: 200, predicted_ms: 1000, prompt_n: 100, prompt_ms: 500 }
},
hideSummary: false,
isLive: false
}}
play={async ({ canvas, userEvent }) => {
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();
}}
/>
@@ -0,0 +1,69 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import HorizontalScrollCarousel from '$lib/components/app/misc/HorizontalScrollCarousel.svelte';
import { expect, waitFor } from 'storybook/test';
const { Story } = defineMeta({
title: 'Components/HorizontalScrollCarousel/Accessibility',
component: HorizontalScrollCarousel,
parameters: {
layout: 'centered'
},
tags: ['!dev']
});
</script>
<Story
asChild
name="ArrowsNotInTabOrderWhenNotScrollable"
play={async ({ canvas, userEvent }) => {
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();
}}
>
<div>
<button type="button">before</button>
<HorizontalScrollCarousel class="w-96">
<div class="h-12 w-12 shrink-0 bg-muted"></div>
<div class="h-12 w-12 shrink-0 bg-muted"></div>
</HorizontalScrollCarousel>
<button type="button">after</button>
</div>
</Story>
<Story
asChild
name="ArrowsInTabOrderWhenScrollable"
play={async ({ canvas, userEvent }) => {
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();
}}
>
<div>
<button type="button">before</button>
<HorizontalScrollCarousel class="w-48">
{#each [...Array(20).keys()] as i (i)}
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
{/each}
</HorizontalScrollCarousel>
</div>
</Story>
@@ -0,0 +1,36 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import SidebarNavigationConversationItem from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte';
import { expect } from 'storybook/test';
const mockForkedConversation: DatabaseConversation = {
id: 'conv-2',
name: 'Forked Conversation',
lastModified: Date.now(),
currNode: 'msg-2',
forkedFromConversationId: 'conv-1'
};
const { Story } = defineMeta({
title: 'Components/SidebarNavigationConversationItem/Accessibility',
component: SidebarNavigationConversationItem,
parameters: {
layout: 'centered'
},
tags: ['!dev']
});
</script>
<Story
name="ForkIconSingleTabStop"
args={{ conversation: mockForkedConversation, depth: 1 }}
play={async ({ canvas, userEvent }) => {
const row = await canvas.findByRole('button', { name: /Forked Conversation/ });
const forkIcon = await canvas.findByRole('link');
row.focus();
await userEvent.tab();
await expect(forkIcon).toHaveFocus();
}}
/>