Skip to content

Commit 42b2d60

Browse files
authored
webui: [a11y] fix keyboard navigation issues in chat interface and sidebar (ggml-org#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
1 parent e7bcf1c commit 42b2d60

17 files changed

Lines changed: 415 additions & 220 deletions

tools/ui/src/lib/components/app/actions/ActionIcon.svelte

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,27 @@
3535

3636
<Tooltip.Root>
3737
<Tooltip.Trigger>
38-
<Button
39-
{variant}
40-
{size}
41-
{disabled}
42-
onclick={(e: MouseEvent) => {
43-
if (stopPropagationOnClick) e.stopPropagation();
38+
<!-- prevent another nested button element -->
39+
{#snippet child({ props })}
40+
<Button
41+
{...props}
42+
{variant}
43+
{size}
44+
{disabled}
45+
onclick={(e: MouseEvent) => {
46+
if (stopPropagationOnClick) e.stopPropagation();
4447

45-
onclick?.(e);
46-
}}
47-
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
48-
aria-label={ariaLabel || tooltip}
49-
>
50-
{#if icon}
51-
{@const IconComponent = icon}
52-
<IconComponent class={iconSize} />
53-
{/if}
54-
</Button>
48+
onclick?.(e);
49+
}}
50+
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
51+
aria-label={ariaLabel || tooltip}
52+
>
53+
{#if icon}
54+
{@const IconComponent = icon}
55+
<IconComponent class={iconSize} />
56+
{/if}
57+
</Button>
58+
{/snippet}
5559
</Tooltip.Trigger>
5660

5761
<Tooltip.Content side={tooltipSide}>

tools/ui/src/lib/components/app/badges/BadgeInfo.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
<script lang="ts">
22
import type { Snippet } from 'svelte';
3+
import type { HTMLButtonAttributes } from 'svelte/elements';
34
4-
interface Props {
5+
interface Props extends HTMLButtonAttributes {
56
children: Snippet;
67
class?: string;
78
icon?: Snippet;
8-
onclick?: () => void;
99
}
1010
11-
let { children, class: className = '', icon, onclick }: Props = $props();
11+
let { children, class: className = '', icon, ...rest }: Props = $props();
1212
</script>
1313

1414
<button
15+
{...rest}
1516
class={[
1617
'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
1718
className
1819
]}
19-
{onclick}
2020
>
2121
{#if icon}
2222
{@render icon()}

tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@
9797
{/snippet}
9898

9999
{#snippet removeButton()}
100-
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
100+
<div
101+
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
102+
>
101103
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
102104
</div>
103105
{/snippet}

tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151

5252
{#if !readonly}
5353
<div
54-
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
54+
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
5555
>
5656
<ActionIcon
5757
class="text-white"

tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte

Lines changed: 59 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
77
import { formatPerformanceTime } from '$lib/utils';
88
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
9+
import type { Component } from 'svelte';
910
1011
interface Props {
1112
predictedTokens?: number;
@@ -114,101 +115,79 @@
114115
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
115116
</script>
116117

117-
<div class="inline-flex items-center text-xs text-muted-foreground">
118-
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
119-
{#if hasPromptStats || isLive}
120-
<Tooltip.Root>
121-
<Tooltip.Trigger>
122-
<button
123-
type="button"
124-
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
125-
ChatMessageStatsView.READING
126-
? 'bg-background text-foreground shadow-sm'
127-
: 'hover:text-foreground'}"
128-
onclick={() => (activeView = ChatMessageStatsView.READING)}
129-
>
130-
<BookOpenText class="h-3 w-3" />
131-
132-
<span class="sr-only">Reading</span>
133-
</button>
134-
</Tooltip.Trigger>
135-
136-
<Tooltip.Content>
137-
<p>Reading (prompt processing)</p>
138-
</Tooltip.Content>
139-
</Tooltip.Root>
140-
{/if}
141-
<Tooltip.Root>
142-
<Tooltip.Trigger>
118+
{#snippet viewButton(opts: {
119+
view: ChatMessageStatsView;
120+
icon: Component;
121+
label: string;
122+
tooltipText: string;
123+
disabled?: boolean;
124+
})}
125+
{@const IconComponent = opts.icon}
126+
<Tooltip.Root>
127+
<Tooltip.Trigger>
128+
<!-- prevent another nested button element -->
129+
{#snippet child({ props })}
143130
<button
131+
{...props}
144132
type="button"
145133
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
146-
ChatMessageStatsView.GENERATION
134+
opts.view
147135
? 'bg-background text-foreground shadow-sm'
148-
: isGenerationDisabled
136+
: opts.disabled
149137
? 'cursor-not-allowed opacity-40'
150138
: 'hover:text-foreground'}"
151-
onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
152-
disabled={isGenerationDisabled}
139+
onclick={() => !opts.disabled && (activeView = opts.view)}
140+
disabled={opts.disabled}
153141
>
154-
<Sparkles class="h-3 w-3" />
142+
<IconComponent class="h-3 w-3" />
155143

156-
<span class="sr-only">Generation</span>
144+
<span class="sr-only">{opts.label}</span>
157145
</button>
158-
</Tooltip.Trigger>
146+
{/snippet}
147+
</Tooltip.Trigger>
159148

160-
<Tooltip.Content>
161-
<p>
162-
{isGenerationDisabled
163-
? 'Generation (waiting for tokens...)'
164-
: 'Generation (token output)'}
165-
</p>
166-
</Tooltip.Content>
167-
</Tooltip.Root>
149+
<Tooltip.Content>
150+
<p>{opts.tooltipText}</p>
151+
</Tooltip.Content>
152+
</Tooltip.Root>
153+
{/snippet}
168154

169-
{#if hasAgenticStats}
170-
<Tooltip.Root>
171-
<Tooltip.Trigger>
172-
<button
173-
type="button"
174-
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
175-
ChatMessageStatsView.TOOLS
176-
? 'bg-background text-foreground shadow-sm'
177-
: 'hover:text-foreground'}"
178-
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
179-
>
180-
<Wrench class="h-3 w-3" />
155+
<div class="inline-flex items-center text-xs text-muted-foreground">
156+
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
157+
{#if hasPromptStats || isLive}
158+
{@render viewButton({
159+
view: ChatMessageStatsView.READING,
160+
icon: BookOpenText,
161+
label: 'Reading',
162+
tooltipText: 'Reading (prompt processing)'
163+
})}
164+
{/if}
181165

182-
<span class="sr-only">Tools</span>
183-
</button>
184-
</Tooltip.Trigger>
166+
{@render viewButton({
167+
view: ChatMessageStatsView.GENERATION,
168+
icon: Sparkles,
169+
label: 'Generation',
170+
tooltipText: isGenerationDisabled
171+
? 'Generation (waiting for tokens...)'
172+
: 'Generation (token output)',
173+
disabled: isGenerationDisabled
174+
})}
185175

186-
<Tooltip.Content>
187-
<p>Tool calls</p>
188-
</Tooltip.Content>
189-
</Tooltip.Root>
176+
{#if hasAgenticStats}
177+
{@render viewButton({
178+
view: ChatMessageStatsView.TOOLS,
179+
icon: Wrench,
180+
label: 'Tools',
181+
tooltipText: 'Tool calls'
182+
})}
190183

191184
{#if !hideSummary}
192-
<Tooltip.Root>
193-
<Tooltip.Trigger>
194-
<button
195-
type="button"
196-
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
197-
ChatMessageStatsView.SUMMARY
198-
? 'bg-background text-foreground shadow-sm'
199-
: 'hover:text-foreground'}"
200-
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
201-
>
202-
<Layers class="h-3 w-3" />
203-
204-
<span class="sr-only">Summary</span>
205-
</button>
206-
</Tooltip.Trigger>
207-
208-
<Tooltip.Content>
209-
<p>Agentic summary</p>
210-
</Tooltip.Content>
211-
</Tooltip.Root>
185+
{@render viewButton({
186+
view: ChatMessageStatsView.SUMMARY,
187+
icon: Layers,
188+
label: 'Summary',
189+
tooltipText: 'Agentic summary'
190+
})}
212191
{/if}
213192
{/if}
214193
</div>

tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@
2121
{#if tooltipLabel}
2222
<Tooltip.Root>
2323
<Tooltip.Trigger>
24-
<BadgeInfo class={className} onclick={handleClick}>
25-
{#snippet icon()}
26-
<IconComponent class="h-3 w-3" />
27-
{/snippet}
24+
<!-- prevent another nested button element -->
25+
{#snippet child({ props })}
26+
<BadgeInfo {...props} class={className} onclick={handleClick}>
27+
{#snippet icon()}
28+
<IconComponent class="h-3 w-3" />
29+
{/snippet}
2830

29-
{value}
30-
</BadgeInfo>
31+
{value}
32+
</BadgeInfo>
33+
{/snippet}
3134
</Tooltip.Trigger>
3235
<Tooltip.Content>
3336
<p>{tooltipLabel}</p>

tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,13 @@
4141
});
4242
</script>
4343

44-
<div
45-
class="pointer-events-{show
46-
? 'auto'
47-
: 'none'} relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center"
48-
>
44+
<div class="relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center">
4945
<Button
5046
onclick={scrollToBottom}
5147
variant="secondary"
5248
size="icon"
53-
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"
49+
disabled={!show}
50+
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"
5451
style="bottom: {buttonBottom}; transform: translateY({show ? '0' : '2rem'}); opacity: {show
5552
? 1
5653
: 0};"

tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,20 @@
5555
}
5656
5757
$effect(() => {
58-
if (scrollContainer) {
59-
setTimeout(() => {
60-
updateScrollButtons();
61-
}, 0);
62-
}
58+
if (!scrollContainer) return;
59+
60+
const observer = new ResizeObserver(() => updateScrollButtons());
61+
observer.observe(scrollContainer);
62+
63+
return () => observer.disconnect();
6364
});
6465
</script>
6566

6667
<div class="relative {className}">
6768
<button
68-
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
69-
? 'opacity-100'
70-
: 'pointer-events-none opacity-0'}"
69+
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"
7170
onclick={scrollLeft}
71+
disabled={!canScrollLeft}
7272
aria-label="Scroll left"
7373
>
7474
<ChevronLeft class="h-4 w-4" />
@@ -83,10 +83,9 @@
8383
</div>
8484

8585
<button
86-
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
87-
? 'opacity-100'
88-
: 'pointer-events-none opacity-0'}"
86+
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"
8987
onclick={scrollRight}
88+
disabled={!canScrollRight}
9089
aria-label="Scroll right"
9190
>
9291
<ChevronRight class="h-4 w-4" />

tools/ui/src/lib/components/app/models/ModelBadge.svelte

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
let shouldShow = $derived(model && (modelProp !== undefined || isModelMode));
2828
</script>
2929

30-
{#snippet badgeContent()}
31-
<BadgeInfo class={className} {onclick}>
30+
{#snippet badgeContent(triggerProps?: Record<string, unknown>)}
31+
<BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
3232
{#snippet icon()}
3333
<Package class="h-3 w-3" />
3434
{/snippet}
@@ -47,7 +47,10 @@
4747
{#if showTooltip}
4848
<Tooltip.Root>
4949
<Tooltip.Trigger>
50-
{@render badgeContent()}
50+
<!-- prevent another nested button element -->
51+
{#snippet child({ props })}
52+
{@render badgeContent(props)}
53+
{/snippet}
5154
</Tooltip.Trigger>
5255

5356
<Tooltip.Content>

0 commit comments

Comments
 (0)