]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: [a11y] fix keyboard navigation issues in chat interface and sidebar (#23132)
authorviggy <redacted>
Thu, 4 Jun 2026 15:59:00 +0000 (08:59 -0700)
committerGitHub <redacted>
Thu, 4 Jun 2026 15:59:00 +0000 (17:59 +0200)
* 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

18 files changed:
tools/ui/src/lib/components/app/actions/ActionIcon.svelte
tools/ui/src/lib/components/app/badges/BadgeInfo.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte
tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte
tools/ui/src/lib/components/app/models/ModelBadge.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte [deleted file]
tools/ui/tests/stories/a11y/ActionIcon.a11y.stories.svelte [new file with mode: 0644]
tools/ui/tests/stories/a11y/ChatMessageStatistics.a11y.stories.svelte [new file with mode: 0644]
tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte [new file with mode: 0644]
tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte [new file with mode: 0644]
tools/ui/tests/stories/a11y/SidebarNavigationConversationItem.a11y.stories.svelte [new file with mode: 0644]

index 849b83b19c8192b2328112f8f19b2f6655b14c6a..f156df66998a8848af36b1c38632c1527202ca27 100644 (file)
 
 <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}>
index 25986082bea5653ab12c932fcc1e0f50fec41bca..c87c94bc4770012ea414be65c503dd37ca678575 100644 (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()}
index df49dd4673f5ecf18b2751a97992ff9438981b2a..2e824ebd4164703e1b8d6c45da08b88954c13708 100644 (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}
index b78a659161915fe19b68681aea9ea7465b0cf14a..de080f5b779120242f11b0b21bad4b7908305881 100644 (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"
index 34362e026fbdd044c854202e2fe3fd4e90f85a9a..6906adbb1734e0a8c057e9fbb2a66094cec679ff 100644 (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;
        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>
index eea7da7b2f100061572ae398a01a6a11c6d8fcc7..db7d01690a56993bb6cf9d4c49b25cc0885deb17 100644 (file)
 {#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>
index c43bee3e3c3dcc3c61c266557f1a7b0c499da0ae..a22c491adac62c62da2a294ae010d2bfd1cbc5c0 100644 (file)
        });
 </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};"
index 06d0e3a058894c0e745a0905a6245e32993846cc..a04f3956f8a5f5cea1f626a54121c91198360550 100644 (file)
        }
 
        $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" />
index cc1d1848e4b03e56091794fbe2c64b474c71e913..b840687d4ef059b0c25abc96ebae32c23f2ccd79 100644 (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}
        {#if showTooltip}
                <Tooltip.Root>
                        <Tooltip.Trigger>
-                               {@render badgeContent()}
+                               <!-- prevent another nested button element -->
+                               {#snippet child({ props })}
+                                       {@render badgeContent(props)}
+                               {/snippet}
                        </Tooltip.Trigger>
 
                        <Tooltip.Content>
index 0f1fba880979328fa00c6561a224c694e66b47e1..40006a4c935908802f847263570cbc6862a09b41 100644 (file)
 
                {#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>
index 83d856d10eaa17db30fdf9d0e0059e42bbb4002b..951831149fc9e1c087032b7d6eba07781c9b7962 100644 (file)
 </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)}
index dad8d954cbb2c7e7baa7fb4cf2ab10e98ba0387b..e38a937385a350c947ccdcf22c7bab2188ec2564 100644 (file)
        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) {
diff --git a/tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte
deleted file mode 100644 (file)
index 46f852c..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-<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');
-               });
-       }}
-/>
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 (file)
index 0000000..20f5e05
--- /dev/null
@@ -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>
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 (file)
index 0000000..4aaf60c
--- /dev/null
@@ -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();
+       }}
+/>
diff --git a/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
new file mode 100644 (file)
index 0000000..46f852c
--- /dev/null
@@ -0,0 +1,50 @@
+<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');
+               });
+       }}
+/>
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 (file)
index 0000000..937d7ab
--- /dev/null
@@ -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>
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 (file)
index 0000000..1fc4260
--- /dev/null
@@ -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();
+       }}
+/>