Skip to content

Commit 3ae279e

Browse files
authored
Added useRole (#45)
* Added useRole * Added docs * space * Started tests * Added tests * skip failing test for now * Added tests * Feedback * Merged dev, added changeset
1 parent 8159907 commit 3ae279e

File tree

5 files changed

+247
-1
lines changed

5 files changed

+247
-1
lines changed

.changeset/beige-flies-mate.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@skeletonlabs/floating-ui-svelte": minor
3+
---
4+
5+
Added `useRole` hook

README.md

+21-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,27 @@ This will ensure all event handlers will be registered rather being overruled by
163163

164164
### useRole
165165

166-
(tbd)
166+
#### Usage
167+
168+
```html
169+
<script>
170+
import { useFloating, useInteractions, useRole } from '@skeletonlabs/floating-ui-svelte';
171+
172+
const floating = useFloating();
173+
const role = useRole(floating.context, { role: 'tooltip' });
174+
const interactions = useInteractions([role]);
175+
</script>
176+
177+
<button {...interactions.getReferenceProps()}>Reference</button>
178+
<div {...interactions.getFloatingProps()}>Tooltip</div>
179+
```
180+
181+
#### Options
182+
183+
| Property | Description | Type | Default Value |
184+
| -------- | ----------- | ---- | ------------- |
185+
| enabled | Enables the interaction | boolean | true |
186+
| role | The role that the floating element should be | [AriaRole](https://floating-ui.com/docs/useRole#native-roles) \| [ComponentRole](https://floating-ui.com/docs/useRole#component-roles) | 'dialog' |
167187

168188
### useDismiss
169189

src/lib/hooks/useRole/App.test.svelte

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script lang="ts">
2+
import { autoUpdate } from '@floating-ui/dom';
3+
import { useFloating } from '../useFloating/index.svelte.js';
4+
import { useInteractions } from '../useInteractions/index.svelte.js';
5+
import { useRole, type UseRoleOptions } from '../useRole/index.svelte.js';
6+
interface Props extends UseRoleOptions {
7+
open?: boolean;
8+
}
9+
let { open = false, ...rest }: Props = $props();
10+
const elements: { reference: HTMLElement | null; floating: HTMLElement | null } = $state({
11+
reference: null,
12+
floating: null
13+
});
14+
const floating = useFloating({
15+
whileElementsMounted: autoUpdate,
16+
get open() {
17+
return open;
18+
},
19+
onOpenChange(v) {
20+
open = v;
21+
},
22+
elements
23+
});
24+
const role = useRole(floating.context, { ...rest });
25+
const interactions = useInteractions([role]);
26+
</script>
27+
28+
<p>{open}</p>
29+
<button bind:this={elements.reference} {...interactions.getReferenceProps()}> Reference </button>
30+
{#if open}
31+
<div
32+
bind:this={elements.floating}
33+
style={floating.floatingStyles}
34+
{...interactions.getFloatingProps()}
35+
>
36+
Floating
37+
</div>
38+
{/if}

src/lib/hooks/useRole/index.svelte.ts

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Map as ReactiveMap } from 'svelte/reactivity';
2+
import type { FloatingContext } from '../useFloating/index.svelte.js';
3+
import type { ElementProps } from '../useInteractions/index.svelte.js';
4+
5+
type AriaRole = 'tooltip' | 'dialog' | 'alertdialog' | 'menu' | 'listbox' | 'grid' | 'tree';
6+
type ComponentRole = 'select' | 'label' | 'combobox';
7+
8+
interface UseRoleOptions {
9+
/**
10+
* Whether the Hook is enabled, including all internal Effects and event
11+
* handlers.
12+
* @default true
13+
*/
14+
enabled?: boolean;
15+
/**
16+
* The role of the floating element.
17+
* @default 'dialog'
18+
*/
19+
role?: AriaRole | ComponentRole;
20+
}
21+
22+
const componentRoleToAriaRoleMap = new ReactiveMap<AriaRole | ComponentRole, AriaRole | false>([
23+
['select', 'listbox'],
24+
['combobox', 'listbox'],
25+
['label', false]
26+
]);
27+
28+
function useRole(context: FloatingContext, options: UseRoleOptions = {}): ElementProps {
29+
const enabled = $derived(options.enabled ?? true);
30+
const role = $derived(options.role ?? 'dialog');
31+
32+
const ariaRole = $derived(
33+
(componentRoleToAriaRoleMap.get(role) ?? role) as AriaRole | false | undefined
34+
);
35+
36+
// FIXME: Uncomment the commented code once useId and useFloatingParentNodeId are implemented.
37+
const referenceId = '123abc';
38+
const parentId = undefined;
39+
// const referenceId = useId();
40+
// const parentId = useFloatingParentNodeId();
41+
42+
const isNested = parentId != null;
43+
44+
const elementProps: ElementProps = $derived.by(() => {
45+
if (!enabled) {
46+
return {};
47+
}
48+
49+
const floatingProps = {
50+
id: context.floatingId,
51+
...(ariaRole && { role: ariaRole })
52+
};
53+
54+
if (ariaRole === 'tooltip' || role === 'label') {
55+
return {
56+
reference: {
57+
[`aria-${role === 'label' ? 'labelledby' : 'describedby'}`]: context.open
58+
? context.floatingId
59+
: undefined
60+
},
61+
floating: floatingProps
62+
};
63+
}
64+
65+
return {
66+
reference: {
67+
'aria-expanded': context.open ? 'true' : 'false',
68+
'aria-haspopup': ariaRole === 'alertdialog' ? 'dialog' : ariaRole,
69+
'aria-controls': context.open ? context.floatingId : undefined,
70+
...(ariaRole === 'listbox' && { role: 'combobox' }),
71+
...(ariaRole === 'menu' && { id: referenceId }),
72+
...(ariaRole === 'menu' && isNested && { role: 'menuitem' }),
73+
...(role === 'select' && { 'aria-autocomplete': 'none' }),
74+
...(role === 'combobox' && { 'aria-autocomplete': 'list' })
75+
},
76+
floating: {
77+
...floatingProps,
78+
...(ariaRole === 'menu' && { 'aria-labelledby': referenceId })
79+
},
80+
item({ active, selected }) {
81+
const commonProps = {
82+
role: 'option',
83+
...(active && { id: `${context.floatingId}-option` })
84+
};
85+
86+
// For `menu`, we are unable to tell if the item is a `menuitemradio`
87+
// or `menuitemcheckbox`. For backwards-compatibility reasons, also
88+
// avoid defaulting to `menuitem` as it may overwrite custom role props.
89+
switch (role) {
90+
case 'select':
91+
return {
92+
...commonProps,
93+
'aria-selected': active && selected
94+
};
95+
case 'combobox': {
96+
return {
97+
...commonProps,
98+
...(active && { 'aria-selected': true })
99+
};
100+
}
101+
}
102+
103+
return {};
104+
}
105+
};
106+
});
107+
108+
return elementProps;
109+
}
110+
111+
export { useRole, type UseRoleOptions };
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { cleanup, render, screen } from '@testing-library/svelte';
3+
import App from './App.test.svelte';
4+
5+
const ARIA_ROLES = ['grid', 'listbox', 'menu', 'tree', 'tooltip', 'alertdialog', 'dialog'] as const;
6+
7+
describe('useRole', () => {
8+
it('by default applies the "dialog" role to the floating element', () => {
9+
render(App, { role: undefined, open: true });
10+
expect(screen.queryByRole('dialog')).toBeInTheDocument();
11+
cleanup();
12+
});
13+
14+
for (const role of ARIA_ROLES) {
15+
it(`applies the "${role}" role to the floating element`, () => {
16+
render(App, { role, open: true });
17+
expect(screen.queryByRole(role)).toBeInTheDocument();
18+
cleanup();
19+
});
20+
}
21+
22+
describe('tooltip', () => {
23+
it.skip('sets correct aria attributes based on the open state', async () => {
24+
const { rerender } = render(App, { role: 'tooltip', open: true });
25+
26+
expect(screen.getByRole('button')).toHaveAttribute(
27+
'aria-describedby',
28+
screen.getByRole('tooltip').getAttribute('id')
29+
);
30+
31+
await rerender({ role: 'tooltip', open: false });
32+
33+
expect(screen.getByRole('buton')).not.toHaveAttribute('aria-describedby');
34+
35+
cleanup();
36+
});
37+
});
38+
39+
describe('label', () => {
40+
it.skip('sets correct aria attributes based on the open state', async () => {
41+
const { rerender } = render(App, { role: 'label', open: true });
42+
43+
expect(screen.getByRole('button')).toHaveAttribute(
44+
'aria-labelledby',
45+
screen.getByRole('tooltip').getAttribute('id')
46+
);
47+
48+
await rerender({ role: 'tooltip', open: false });
49+
50+
expect(screen.getByRole('buton')).not.toHaveAttribute('aria-labelledby');
51+
52+
cleanup();
53+
});
54+
});
55+
56+
describe('dialog', () => {
57+
it.skip('sets correct aria attributes based on the open state', async () => {
58+
const { rerender } = render(App, { role: 'dialog', open: false });
59+
60+
expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'dialog');
61+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
62+
63+
await rerender({ role: 'dialog', open: true });
64+
65+
expect(screen.getByRole('dialog')).toBeInTheDocument();
66+
expect(screen.getByRole('button')).toHaveAttribute(
67+
'aria-controls',
68+
screen.getByRole('dialog').getAttribute('id')
69+
);
70+
});
71+
});
72+
});

0 commit comments

Comments
 (0)