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 08:59:00 -07:00
committed by GitHub
parent e7bcf1c3a8
commit 42b2d60e57
17 changed files with 421 additions and 226 deletions

View File

@@ -35,23 +35,27 @@
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{variant}
{size}
{disabled}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
<!-- prevent another nested button element -->
{#snippet child({ props })}
<Button
{...props}
{variant}
{size}
{disabled}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
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}
<IconComponent class={iconSize} />
{/if}
</Button>
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}
<IconComponent class={iconSize} />
{/if}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side={tooltipSide}>

View File

@@ -1,22 +1,22 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props {
interface Props extends HTMLButtonAttributes {
children: Snippet;
class?: string;
icon?: Snippet;
onclick?: () => void;
}
let { children, class: className = '', icon, onclick }: Props = $props();
let { children, class: className = '', icon, ...rest }: Props = $props();
</script>
<button
{...rest}
class={[
'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
className
]}
{onclick}
>
{#if icon}
{@render icon()}

View File

@@ -97,7 +97,9 @@
{/snippet}
{#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)} />
</div>
{/snippet}

View File

@@ -51,7 +51,7 @@
{#if !readonly}
<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
class="text-white"

View File

@@ -6,6 +6,7 @@
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
import type { Component } from 'svelte';
interface Props {
predictedTokens?: number;
@@ -114,101 +115,79 @@
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
</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 rounded-sm bg-muted-foreground/15 p-0.5">
{#if hasPromptStats || isLive}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
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>
{@render viewButton({
view: ChatMessageStatsView.READING,
icon: BookOpenText,
label: 'Reading',
tooltipText: 'Reading (prompt processing)'
})}
{/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>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>
{isGenerationDisabled
? 'Generation (waiting for tokens...)'
: 'Generation (token output)'}
</p>
</Tooltip.Content>
</Tooltip.Root>
{@render viewButton({
view: ChatMessageStatsView.GENERATION,
icon: Sparkles,
label: 'Generation',
tooltipText: isGenerationDisabled
? 'Generation (waiting for tokens...)'
: 'Generation (token output)',
disabled: isGenerationDisabled
})}
{#if hasAgenticStats}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
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>
{@render viewButton({
view: ChatMessageStatsView.TOOLS,
icon: Wrench,
label: 'Tools',
tooltipText: 'Tool calls'
})}
{#if !hideSummary}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
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>
{@render viewButton({
view: ChatMessageStatsView.SUMMARY,
icon: Layers,
label: 'Summary',
tooltipText: 'Agentic summary'
})}
{/if}
{/if}
</div>

View File

@@ -21,13 +21,16 @@
{#if tooltipLabel}
<Tooltip.Root>
<Tooltip.Trigger>
<BadgeInfo class={className} onclick={handleClick}>
{#snippet icon()}
<IconComponent class="h-3 w-3" />
{/snippet}
<!-- prevent another nested button element -->
{#snippet child({ props })}
<BadgeInfo {...props} class={className} onclick={handleClick}>
{#snippet icon()}
<IconComponent class="h-3 w-3" />
{/snippet}
{value}
</BadgeInfo>
{value}
</BadgeInfo>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipLabel}</p>

View File

@@ -41,16 +41,13 @@
});
</script>
<div
class="pointer-events-{show
? 'auto'
: 'none'} relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center"
>
<div class="relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center">
<Button
onclick={scrollToBottom}
variant="secondary"
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
? 1
: 0};"

View File

@@ -55,20 +55,20 @@
}
$effect(() => {
if (scrollContainer) {
setTimeout(() => {
updateScrollButtons();
}, 0);
}
if (!scrollContainer) return;
const observer = new ResizeObserver(() => updateScrollButtons());
observer.observe(scrollContainer);
return () => observer.disconnect();
});
</script>
<div class="relative {className}">
<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
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
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"
onclick={scrollLeft}
disabled={!canScrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class="h-4 w-4" />
@@ -83,10 +83,9 @@
</div>
<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
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
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"
onclick={scrollRight}
disabled={!canScrollRight}
aria-label="Scroll right"
>
<ChevronRight class="h-4 w-4" />

View File

@@ -27,8 +27,8 @@
let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
</script>
{#snippet badgeContent()}
<BadgeInfo class={className} {onclick}>
{#snippet badgeContent(triggerProps?: Record<string, unknown>)}
<BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
{#snippet icon()}
<Package class="h-3 w-3" />
{/snippet}
@@ -47,7 +47,10 @@
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
{@render badgeContent()}
<!-- prevent another nested button element -->
{#snippet child({ props })}
{@render badgeContent(props)}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>

View File

@@ -116,52 +116,54 @@
{#if ms.isRouter}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<DropdownMenu.Trigger
class={[
`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`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
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" />
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<DropdownMenu.Trigger
{...props}
class={[
`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`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
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}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
{...props}
/>
{/snippet}
</Tooltip.Trigger>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
{/if}
</DropdownMenu.Trigger>
{/snippet}
</Tooltip.Trigger>
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
{#if selectedOption}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
{/if}
</DropdownMenu.Trigger>
</Tooltip.Root>
<DropdownMenu.Content
align="end"
@@ -234,49 +236,51 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<button
class={[
`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`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
isOpen && 'text-foreground'
]}
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" />
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<button
{...props}
class={[
`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`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
isOpen && 'text-foreground'
]}
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}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
{...props}
/>
{/snippet}
</Tooltip.Trigger>
{/if}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
</button>
{/snippet}
</Tooltip.Trigger>
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{#if selectedOption}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
</Tooltip.Content>
{/if}
</button>
</Tooltip.Root>
{/if}
{/if}
</div>

View File

@@ -34,24 +34,28 @@
</script>
<DropdownMenu.Root bind:open>
<DropdownMenu.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()}
>
{#if triggerTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<DropdownMenu.Trigger
{...props}
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')}
<span class="sr-only">{triggerTooltip}</span>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{triggerTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render iconComponent(triggerIcon, 'h-3 w-3')}
{#if triggerTooltip}
<span class="sr-only">{triggerTooltip}</span>
{/if}
</DropdownMenu.Trigger>
{/snippet}
</Tooltip.Trigger>
{#if triggerTooltip}
<Tooltip.Content>
<p>{triggerTooltip}</p>
</Tooltip.Content>
{/if}
</DropdownMenu.Trigger>
</Tooltip.Root>
<DropdownMenu.Content {align} class="z-[999999] w-48">
{#each actions as action, index (action.label)}

View File

@@ -105,6 +105,12 @@
onclick={handleSelect}
onmouseover={handleMouseOver}
onmouseleave={handleMouseLeave}
onfocusin={handleMouseOver}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
handleMouseLeave();
}
}}
>
<div
class="flex min-w-0 flex-1 items-center gap-2"
@@ -113,12 +119,16 @@
{#if depth > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<a
href={RouterService.chat(conversation.forkedFromConversationId)}
class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
>
<GitBranch class="h-3.5 w-3.5" />
</a>
<!-- prevent another nested button element -->
{#snippet child({ props })}
<a
{...props}
href={RouterService.chat(conversation.forkedFromConversationId)}
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.Content>
@@ -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) {

View File

@@ -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>

View File

@@ -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();
}}
/>

View File

@@ -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>

View File

@@ -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();
}}
/>