diff --git a/.changeset/beige-flies-mate.md b/.changeset/beige-flies-mate.md new file mode 100644 index 00000000..3806497b --- /dev/null +++ b/.changeset/beige-flies-mate.md @@ -0,0 +1,5 @@ +--- +"@skeletonlabs/floating-ui-svelte": minor +--- + +Added `useRole` hook diff --git a/README.md b/README.md index 9c4717c3..0b7275f1 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,27 @@ This will ensure all event handlers will be registered rather being overruled by ### useRole -(tbd) +#### Usage + +```html + + + +
Tooltip
+``` + +#### Options + +| Property | Description | Type | Default Value | +| -------- | ----------- | ---- | ------------- | +| enabled | Enables the interaction | boolean | true | +| 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' | ### useDismiss diff --git a/src/lib/hooks/useRole/App.test.svelte b/src/lib/hooks/useRole/App.test.svelte new file mode 100644 index 00000000..8cd3bf2a --- /dev/null +++ b/src/lib/hooks/useRole/App.test.svelte @@ -0,0 +1,38 @@ + + +

{open}

+ +{#if open} +
+ Floating +
+{/if} diff --git a/src/lib/hooks/useRole/index.svelte.ts b/src/lib/hooks/useRole/index.svelte.ts new file mode 100644 index 00000000..394add91 --- /dev/null +++ b/src/lib/hooks/useRole/index.svelte.ts @@ -0,0 +1,111 @@ +import { Map as ReactiveMap } from 'svelte/reactivity'; +import type { FloatingContext } from '../useFloating/index.svelte.js'; +import type { ElementProps } from '../useInteractions/index.svelte.js'; + +type AriaRole = 'tooltip' | 'dialog' | 'alertdialog' | 'menu' | 'listbox' | 'grid' | 'tree'; +type ComponentRole = 'select' | 'label' | 'combobox'; + +interface UseRoleOptions { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean; + /** + * The role of the floating element. + * @default 'dialog' + */ + role?: AriaRole | ComponentRole; +} + +const componentRoleToAriaRoleMap = new ReactiveMap([ + ['select', 'listbox'], + ['combobox', 'listbox'], + ['label', false] +]); + +function useRole(context: FloatingContext, options: UseRoleOptions = {}): ElementProps { + const enabled = $derived(options.enabled ?? true); + const role = $derived(options.role ?? 'dialog'); + + const ariaRole = $derived( + (componentRoleToAriaRoleMap.get(role) ?? role) as AriaRole | false | undefined + ); + + // FIXME: Uncomment the commented code once useId and useFloatingParentNodeId are implemented. + const referenceId = '123abc'; + const parentId = undefined; + // const referenceId = useId(); + // const parentId = useFloatingParentNodeId(); + + const isNested = parentId != null; + + const elementProps: ElementProps = $derived.by(() => { + if (!enabled) { + return {}; + } + + const floatingProps = { + id: context.floatingId, + ...(ariaRole && { role: ariaRole }) + }; + + if (ariaRole === 'tooltip' || role === 'label') { + return { + reference: { + [`aria-${role === 'label' ? 'labelledby' : 'describedby'}`]: context.open + ? context.floatingId + : undefined + }, + floating: floatingProps + }; + } + + return { + reference: { + 'aria-expanded': context.open ? 'true' : 'false', + 'aria-haspopup': ariaRole === 'alertdialog' ? 'dialog' : ariaRole, + 'aria-controls': context.open ? context.floatingId : undefined, + ...(ariaRole === 'listbox' && { role: 'combobox' }), + ...(ariaRole === 'menu' && { id: referenceId }), + ...(ariaRole === 'menu' && isNested && { role: 'menuitem' }), + ...(role === 'select' && { 'aria-autocomplete': 'none' }), + ...(role === 'combobox' && { 'aria-autocomplete': 'list' }) + }, + floating: { + ...floatingProps, + ...(ariaRole === 'menu' && { 'aria-labelledby': referenceId }) + }, + item({ active, selected }) { + const commonProps = { + role: 'option', + ...(active && { id: `${context.floatingId}-option` }) + }; + + // For `menu`, we are unable to tell if the item is a `menuitemradio` + // or `menuitemcheckbox`. For backwards-compatibility reasons, also + // avoid defaulting to `menuitem` as it may overwrite custom role props. + switch (role) { + case 'select': + return { + ...commonProps, + 'aria-selected': active && selected + }; + case 'combobox': { + return { + ...commonProps, + ...(active && { 'aria-selected': true }) + }; + } + } + + return {}; + } + }; + }); + + return elementProps; +} + +export { useRole, type UseRoleOptions }; diff --git a/src/lib/hooks/useRole/index.test.svelte.ts b/src/lib/hooks/useRole/index.test.svelte.ts new file mode 100644 index 00000000..0e4d59c1 --- /dev/null +++ b/src/lib/hooks/useRole/index.test.svelte.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/svelte'; +import App from './App.test.svelte'; + +const ARIA_ROLES = ['grid', 'listbox', 'menu', 'tree', 'tooltip', 'alertdialog', 'dialog'] as const; + +describe('useRole', () => { + it('by default applies the "dialog" role to the floating element', () => { + render(App, { role: undefined, open: true }); + expect(screen.queryByRole('dialog')).toBeInTheDocument(); + cleanup(); + }); + + for (const role of ARIA_ROLES) { + it(`applies the "${role}" role to the floating element`, () => { + render(App, { role, open: true }); + expect(screen.queryByRole(role)).toBeInTheDocument(); + cleanup(); + }); + } + + describe('tooltip', () => { + it.skip('sets correct aria attributes based on the open state', async () => { + const { rerender } = render(App, { role: 'tooltip', open: true }); + + expect(screen.getByRole('button')).toHaveAttribute( + 'aria-describedby', + screen.getByRole('tooltip').getAttribute('id') + ); + + await rerender({ role: 'tooltip', open: false }); + + expect(screen.getByRole('buton')).not.toHaveAttribute('aria-describedby'); + + cleanup(); + }); + }); + + describe('label', () => { + it.skip('sets correct aria attributes based on the open state', async () => { + const { rerender } = render(App, { role: 'label', open: true }); + + expect(screen.getByRole('button')).toHaveAttribute( + 'aria-labelledby', + screen.getByRole('tooltip').getAttribute('id') + ); + + await rerender({ role: 'tooltip', open: false }); + + expect(screen.getByRole('buton')).not.toHaveAttribute('aria-labelledby'); + + cleanup(); + }); + }); + + describe('dialog', () => { + it.skip('sets correct aria attributes based on the open state', async () => { + const { rerender } = render(App, { role: 'dialog', open: false }); + + expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'dialog'); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false'); + + await rerender({ role: 'dialog', open: true }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute( + 'aria-controls', + screen.getByRole('dialog').getAttribute('id') + ); + }); + }); +});