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