<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}>
<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()}
{/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}
{#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"
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;
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
</script>
-<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>
- {/if}
- <Tooltip.Root>
- <Tooltip.Trigger>
+{#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 ===
- ChatMessageStatsView.GENERATION
+ opts.view
? 'bg-background text-foreground shadow-sm'
- : isGenerationDisabled
+ : opts.disabled
? 'cursor-not-allowed opacity-40'
: 'hover:text-foreground'}"
- onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
- disabled={isGenerationDisabled}
+ onclick={() => !opts.disabled && (activeView = opts.view)}
+ disabled={opts.disabled}
>
- <Sparkles class="h-3 w-3" />
+ <IconComponent class="h-3 w-3" />
- <span class="sr-only">Generation</span>
+ <span class="sr-only">{opts.label}</span>
</button>
- </Tooltip.Trigger>
+ {/snippet}
+ </Tooltip.Trigger>
- <Tooltip.Content>
- <p>
- {isGenerationDisabled
- ? 'Generation (waiting for tokens...)'
- : 'Generation (token output)'}
- </p>
- </Tooltip.Content>
- </Tooltip.Root>
+ <Tooltip.Content>
+ <p>{opts.tooltipText}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+{/snippet}
- {#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" />
+<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}
+ {@render viewButton({
+ view: ChatMessageStatsView.READING,
+ icon: BookOpenText,
+ label: 'Reading',
+ tooltipText: 'Reading (prompt processing)'
+ })}
+ {/if}
- <span class="sr-only">Tools</span>
- </button>
- </Tooltip.Trigger>
+ {@render viewButton({
+ view: ChatMessageStatsView.GENERATION,
+ icon: Sparkles,
+ label: 'Generation',
+ tooltipText: isGenerationDisabled
+ ? 'Generation (waiting for tokens...)'
+ : 'Generation (token output)',
+ disabled: isGenerationDisabled
+ })}
- <Tooltip.Content>
- <p>Tool calls</p>
- </Tooltip.Content>
- </Tooltip.Root>
+ {#if hasAgenticStats}
+ {@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>
{#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>
});
</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};"
}
$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" />
</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" />
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}
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
- {@render badgeContent()}
+ <!-- prevent another nested button element -->
+ {#snippet child({ props })}
+ {@render badgeContent(props)}
+ {/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
{#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"
</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>
</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)}
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"
{#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>
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) {
+++ /dev/null
-<script module lang="ts">
- import { defineMeta } from '@storybook/addon-svelte-csf';
- import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
- import { expect, screen, waitFor } from 'storybook/test';
- import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
-
- const { Story } = defineMeta({
- title: 'Components/ChatScreen/ChatScreenForm/Accessibility',
- component: ChatScreenForm,
- parameters: {
- layout: 'centered'
- },
- tags: ['!dev']
- });
-</script>
-
-<Story
- name="AddButtonSingleTabStop"
- args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
- play={async ({ canvas, userEvent }) => {
- const textarea = await canvas.findByRole('textbox');
- await userEvent.clear(textarea);
- await userEvent.type(textarea, 'What is the meaning of life?');
-
- const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
-
- trigger.focus();
- await expect(trigger).toHaveFocus();
-
- await userEvent.tab();
-
- await expect(trigger).not.toHaveFocus();
- }}
-/>
-
-<Story
- name="AddDropdownFocusesFirstEnabled"
- args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
- play={async ({ canvas, userEvent }) => {
- const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
-
- trigger.focus();
- await userEvent.keyboard('{Enter}');
- await screen.findByRole('menu');
-
- await waitFor(() => {
- expect(document.activeElement).toHaveTextContent('Add files');
- });
- }}
-/>
--- /dev/null
+<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>
--- /dev/null
+<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();
+ }}
+/>
--- /dev/null
+<script module lang="ts">
+ import { defineMeta } from '@storybook/addon-svelte-csf';
+ import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
+ import { expect, screen, waitFor } from 'storybook/test';
+ import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
+
+ const { Story } = defineMeta({
+ title: 'Components/ChatScreen/ChatScreenForm/Accessibility',
+ component: ChatScreenForm,
+ parameters: {
+ layout: 'centered'
+ },
+ tags: ['!dev']
+ });
+</script>
+
+<Story
+ name="AddButtonSingleTabStop"
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ play={async ({ canvas, userEvent }) => {
+ const textarea = await canvas.findByRole('textbox');
+ await userEvent.clear(textarea);
+ await userEvent.type(textarea, 'What is the meaning of life?');
+
+ const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
+
+ trigger.focus();
+ await expect(trigger).toHaveFocus();
+
+ await userEvent.tab();
+
+ await expect(trigger).not.toHaveFocus();
+ }}
+/>
+
+<Story
+ name="AddDropdownFocusesFirstEnabled"
+ args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
+ play={async ({ canvas, userEvent }) => {
+ const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
+
+ trigger.focus();
+ await userEvent.keyboard('{Enter}');
+ await screen.findByRole('menu');
+
+ await waitFor(() => {
+ expect(document.activeElement).toHaveTextContent('Add files');
+ });
+ }}
+/>
--- /dev/null
+<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>
--- /dev/null
+<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();
+ }}
+/>