Skip to content

Commit 6e2b679

Browse files
authored
fix(ui): add proper aria attributes to tooltip (podman-desktop#18090)
1 parent 171c81a commit 6e2b679

8 files changed

Lines changed: 134 additions & 21 deletions

File tree

packages/renderer/src/lib/preferences/PreferencesCliTool.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ function getLoggerHandler(_cliToolId: string): ConnectionCallback {
238238
{#if cliTool.version}
239239
<div
240240
class="flex flex-row justify-between align-center bg-[var(--pd-invert-content-bg)] p-2 rounded-lg min-w-[320px] w-fit">
241-
<Tooltip containerClass="relative inline-block my-auto" aria-label="cli-full-path" bottomRight={true} tip="Path: {cliTool.path}">
241+
<Tooltip containerClass="relative inline-block my-auto" bottomRight={true} tip="Path: {cliTool.path}">
242242
<div
243243
class="flex text-[var(--pd-invert-content-card-text)] font-bold text-sm items-center"
244244
aria-label="cli-version">

packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ test('Expect tooltip text shows info when input is less than minimum', async ()
360360
const tooltipTrigger = screen.getByTestId('tooltip-trigger');
361361
await userEvent.hover(tooltipTrigger);
362362

363-
const tooltip = await screen.findByLabelText('tooltip');
363+
const tooltip = await screen.findByRole('tooltip');
364364
expect(tooltip).toBeInTheDocument();
365365
expect(tooltip.textContent).toBe('The value cannot be less than 1');
366366
});
@@ -385,7 +385,7 @@ test('Expect tooltip text shows info when input is higher than maximum', async (
385385
const tooltipTrigger = screen.getByTestId('tooltip-trigger');
386386
await userEvent.hover(tooltipTrigger);
387387

388-
const tooltip = await screen.findByLabelText('tooltip');
388+
const tooltip = await screen.findByRole('tooltip');
389389
expect(tooltip).toBeInTheDocument();
390390
expect(tooltip.textContent).toBe('The value cannot be greater than 34');
391391
});

packages/renderer/src/lib/preferences/item-formats/FloatNumberItem.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ test('Expect tooltip if value input is NaN', async () => {
4747
const tooltipTrigger = screen.getByTestId('tooltip-trigger');
4848
await userEvent.hover(tooltipTrigger);
4949

50-
const tooltip = await screen.findByLabelText('tooltip');
50+
const tooltip = await screen.findByRole('tooltip');
5151
expect(tooltip).toBeInTheDocument();
5252
expect(tooltip.textContent).toContain('Expecting a number');
5353
});
@@ -71,7 +71,7 @@ test('Expect number with dot is valid but onChange is not called if dot is the l
7171
await userEvent.click(input);
7272
await userEvent.keyboard('.');
7373

74-
const tooltip = screen.queryByLabelText('tooltip');
74+
const tooltip = screen.queryByRole('tooltip');
7575
expect(tooltip).not.toBeInTheDocument();
7676

7777
expect(onChange).not.toBeCalled();
@@ -96,7 +96,7 @@ test('Expect onChange to be called with float number', async () => {
9696
await userEvent.click(input);
9797
await userEvent.keyboard('.2');
9898

99-
const tooltip = screen.queryByLabelText('tooltip');
99+
const tooltip = screen.queryByRole('tooltip');
100100
expect(tooltip).not.toBeInTheDocument();
101101

102102
await new Promise(resolve => setTimeout(resolve, 600));
@@ -127,7 +127,7 @@ test('Expect only one dot is added to input', async () => {
127127
await userEvent.keyboard('.');
128128
await userEvent.keyboard('2');
129129

130-
const tooltip = screen.queryByLabelText('tooltip');
130+
const tooltip = screen.queryByRole('tooltip');
131131
expect(tooltip).not.toBeInTheDocument();
132132

133133
await new Promise(resolve => setTimeout(resolve, 600));

packages/renderer/src/lib/preferences/item-formats/NumberItem.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ test('Expect tooltip if value input is invalid', async () => {
5151
const tooltipTrigger = screen.getByTestId('tooltip-trigger');
5252
await userEvent.hover(tooltipTrigger);
5353

54-
const tooltip = await screen.findByLabelText('tooltip');
54+
const tooltip = await screen.findByRole('tooltip');
5555
expect(tooltip).toBeInTheDocument();
5656
expect(tooltip.textContent).toContain('The value cannot be less than 10');
5757
});

packages/renderer/src/lib/ui/KubernetesCurrentContextConnectionBadge.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ describe.each<{
180180
}
181181

182182
await vi.waitFor(() => {
183-
const tooltip = screen.queryByLabelText('tooltip');
183+
const tooltip = screen.queryByRole('tooltip');
184184
expect(tooltip).toBeNull();
185185
});
186186
});
@@ -197,7 +197,7 @@ describe.each<{
197197
await fireEvent.mouseEnter(tooltipTrigger);
198198

199199
await vi.waitFor(() => {
200-
const tooltip = screen.getByLabelText('tooltip');
200+
const tooltip = screen.getByRole('tooltip');
201201
expect(tooltip).toBeInTheDocument();
202202
});
203203
});
@@ -216,7 +216,7 @@ describe.each<{
216216
await fireEvent.mouseEnter(tooltipTrigger);
217217

218218
await vi.waitFor(() => {
219-
const tooltip = screen.getByLabelText('tooltip');
219+
const tooltip = screen.getByRole('tooltip');
220220
expect(tooltip).toBeInTheDocument();
221221
expect(tooltip).toHaveTextContent('connection lost, resources may be out of sync');
222222
});

packages/ui/src/lib/tooltip/Tooltip.spec.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,10 @@ describe('Tooltip', () => {
321321
await fireEvent.mouseEnter(slot);
322322

323323
await waitFor(() => {
324-
expect(screen.getByLabelText('tooltip')).toBeInTheDocument();
324+
expect(screen.getByRole('tooltip')).toBeInTheDocument();
325325
});
326326

327-
return screen.getByLabelText('tooltip');
327+
return screen.getByRole('tooltip');
328328
}
329329

330330
function expectTooltipStyling(element: HTMLElement): void {
@@ -352,7 +352,7 @@ describe('Tooltip', () => {
352352
await fireEvent.mouseEnter(slot);
353353

354354
await waitFor(() => {
355-
const slotElement = screen.getByLabelText('tooltip');
355+
const slotElement = screen.getByRole('tooltip');
356356
expect(slotElement).toHaveClass('my-[5px] mx-[10px]');
357357
});
358358
});
@@ -361,4 +361,75 @@ describe('Tooltip', () => {
361361
const { container } = render(TooltipTestComponent, { containerClass: 'w-full' });
362362
expect(container.childNodes[0]).toHaveClass('w-full');
363363
});
364+
365+
test('tooltip has role="tooltip" when visible', async () => {
366+
render(TooltipTestComponent, { tip: 'accessible tooltip' });
367+
368+
const slot = screen.getByTestId('tooltip-trigger');
369+
await fireEvent.mouseEnter(slot);
370+
371+
await waitFor(() => {
372+
expect(screen.getByRole('tooltip')).toBeInTheDocument();
373+
expect(screen.getByRole('tooltip')).toHaveTextContent('accessible tooltip');
374+
});
375+
});
376+
377+
test('trigger has aria-describedby matching tooltip id when visible', async () => {
378+
render(TooltipTestComponent, { tip: 'linked tooltip' });
379+
380+
const slot = screen.getByTestId('tooltip-trigger');
381+
await fireEvent.mouseEnter(slot);
382+
383+
await waitFor(() => {
384+
const tooltip = screen.getByRole('tooltip');
385+
expect(slot).toHaveAttribute('aria-describedby', tooltip.id);
386+
expect(tooltip.id).toMatch(/^pd-tooltip-/);
387+
});
388+
});
389+
390+
test('each tooltip instance gets a unique id', async () => {
391+
render(TooltipTestComponent, { tip: 'first tooltip' });
392+
render(TooltipTestComponent, { tip: 'second tooltip' });
393+
394+
const slots = screen.getAllByTestId('tooltip-trigger');
395+
await fireEvent.mouseEnter(slots[0]);
396+
await fireEvent.mouseEnter(slots[1]);
397+
398+
await waitFor(() => {
399+
const tooltips = screen.getAllByRole('tooltip');
400+
expect(tooltips).toHaveLength(2);
401+
expect(tooltips[0].id).not.toBe(tooltips[1].id);
402+
});
403+
});
404+
405+
test('trigger aria-describedby is removed when tooltip hides', async () => {
406+
render(TooltipTestComponent, { tip: 'hide tooltip' });
407+
408+
const slot = screen.getByTestId('tooltip-trigger');
409+
await fireEvent.mouseEnter(slot);
410+
411+
await waitFor(() => {
412+
expect(slot).toHaveAttribute('aria-describedby');
413+
});
414+
415+
await fireEvent.mouseLeave(slot);
416+
417+
await waitFor(() => {
418+
expect(slot).not.toHaveAttribute('aria-describedby');
419+
});
420+
});
421+
422+
test('aria-describedby is set on nested focusable trigger', async () => {
423+
render(TooltipTestComponent, { tip: 'button tooltip', useButton: true });
424+
425+
const slot = screen.getByTestId('tooltip-trigger');
426+
const button = screen.getByRole('button', { name: 'Hover me' });
427+
await fireEvent.mouseEnter(slot);
428+
429+
await waitFor(() => {
430+
const tooltip = screen.getByRole('tooltip');
431+
expect(button).toHaveAttribute('aria-describedby', tooltip.id);
432+
expect(slot).not.toHaveAttribute('aria-describedby');
433+
});
434+
});
364435
});

packages/ui/src/lib/tooltip/Tooltip.svelte

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
}
1616
</style>
1717

18+
<script module lang="ts">
19+
let tooltipIdCounter = 0;
20+
21+
function nextTooltipId(): string {
22+
tooltipIdCounter += 1;
23+
return `pd-tooltip-${tooltipIdCounter}`;
24+
}
25+
</script>
26+
1827
<script lang="ts">
1928
import type { Placement } from '@floating-ui/dom';
2029
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
@@ -36,7 +45,6 @@ interface Props {
3645
containerClass?: string;
3746
tipSnippet?: Snippet;
3847
children?: Snippet;
39-
'aria-label'?: string;
4048
}
4149
4250
let {
@@ -53,9 +61,10 @@ let {
5361
containerClass,
5462
tipSnippet,
5563
children,
56-
'aria-label': ariaLabel,
5764
}: Props = $props();
5865
66+
const tooltipId = nextTooltipId();
67+
5968
let referenceElement: HTMLElement | undefined = $state(undefined);
6069
let tooltipElement: HTMLElement | undefined = $state(undefined);
6170
let isVisible = $state(false);
@@ -65,6 +74,25 @@ let cleanupAutoUpdate: (() => void) | undefined;
6574
const tooltipInnerClasses =
6675
'pt-[4px] pb-[5px] px-[8px] rounded-[9px] bg-[var(--pd-tooltip-bg)] text-[var(--pd-tooltip-text)] border-[1px] border-[var(--pd-tooltip-inner-border)] backdrop-blur-sm';
6776
77+
const FOCUSABLE_SELECTOR =
78+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
79+
80+
function getTooltipAriaTarget(root: HTMLElement | undefined): HTMLElement | undefined {
81+
if (!root) return undefined;
82+
return root.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? root;
83+
}
84+
85+
function setTooltipAriaDescribedBy(root: HTMLElement | undefined, id: string | undefined): void {
86+
const target = getTooltipAriaTarget(root);
87+
if (!target) return;
88+
89+
if (id) {
90+
target.setAttribute('aria-describedby', id);
91+
} else {
92+
target.removeAttribute('aria-describedby');
93+
}
94+
}
95+
6896
function getPreferredPlacement(): Placement {
6997
if (top) return 'top';
7098
if (topLeft) return 'top-start';
@@ -150,11 +178,19 @@ $effect((): (() => void) => {
150178
}
151179
};
152180
});
181+
182+
$effect(() => {
183+
const shouldDescribe = isVisible && !$tooltipHidden && (tip ?? tipSnippet);
184+
setTooltipAriaDescribedBy(referenceElement, shouldDescribe ? tooltipId : undefined);
185+
186+
return (): void => {
187+
setTooltipAriaDescribedBy(referenceElement, undefined);
188+
};
189+
});
153190
</script>
154191

155-
<div class={containerClass ?? 'relative inline-block'} aria-label={ariaLabel}>
192+
<div class={containerClass ?? 'relative inline-block'}>
156193
<span
157-
role="none"
158194
data-testid="tooltip-trigger"
159195
class="group tooltip-slot {className}"
160196
bind:this={referenceElement}
@@ -171,12 +207,12 @@ $effect((): (() => void) => {
171207
class:opacity-0={!isPositioned}
172208
style="left: 0; top: 0;">
173209
{#if tip}
174-
<div class="{tooltipInnerClasses} {className}" aria-label="tooltip">
210+
<div class="{tooltipInnerClasses} {className}" role="tooltip" id={tooltipId}>
175211
{tip}
176212
</div>
177213
{/if}
178214
{#if tipSnippet && !tip}
179-
<div class="{tooltipInnerClasses} {className}" aria-label="tooltip">
215+
<div class="{tooltipInnerClasses} {className}" role="tooltip" id={tooltipId}>
180216
{@render tipSnippet?.()}
181217
</div>
182218
{/if}

packages/ui/src/lib/tooltip/TooltipTestComponent.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface Props {
1414
bottomRight?: boolean;
1515
classStyle?: string;
1616
containerClass?: string;
17+
useButton?: boolean;
1718
}
1819
1920
let {
@@ -29,6 +30,7 @@ let {
2930
bottomRight,
3031
classStyle,
3132
containerClass,
33+
useButton = false,
3234
}: Props = $props();
3335
</script>
3436

@@ -49,5 +51,9 @@ let {
4951
{tipSlot}
5052
{/if}
5153
{/snippet}
52-
<div>Hover me</div>
54+
{#if useButton}
55+
<button type="button">Hover me</button>
56+
{:else}
57+
<div>Hover me</div>
58+
{/if}
5359
</Tooltip>

0 commit comments

Comments
 (0)