Repository: skeletonlabs/floating-ui-svelte (ts)

Issue:
Add: useRole hook
## Reference

https://floating-ui.com/docs/useRole

## Description

Adds base screen reader props to the reference and floating elements for a given role. This is useful to automatically apply ARIA props to the reference and floating elements to ensure they’re accessible to assistive technology, including item elements if narrowly specified.

## Props

- [x] `enabled`
- [x] `role`
- [x] `Component roles`
- [x] `Native roles`

Pull request description:
Added useRole
Closes #7 

TODO:
- [x] Write docs
- [x] Write tests (delayed until #43 is merged)

Patch:
diff --git a/.changeset/beige-flies-mate.md b/.changeset/beige-flies-mate.md
new file mode 100644
index 0000000..3806497
--- /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 9c4717c..0b7275f 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
+<script>
+	import { useFloating, useInteractions, useRole } from '@skeletonlabs/floating-ui-svelte';
+
+	const floating = useFloating();
+	const role = useRole(floating.context, { role: 'tooltip' });
+	const interactions = useInteractions([role]);
+</script>
+
+<button {...interactions.getReferenceProps()}>Reference</button>
+<div {...interactions.getFloatingProps()}>Tooltip</div>
+```
+
+#### 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/index.svelte.ts b/src/lib/hooks/useRole/index.svelte.ts
new file mode 100644
index 0000000..394add9
--- /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<AriaRole | ComponentRole, AriaRole | false>([
+	['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 };


Test patch:
diff --git a/src/lib/hooks/useRole/App.test.svelte b/src/lib/hooks/useRole/App.test.svelte
new file mode 100644
index 0000000..8cd3bf2
--- /dev/null
+++ b/src/lib/hooks/useRole/App.test.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+	import { autoUpdate } from '@floating-ui/dom';
+	import { useFloating } from '../useFloating/index.svelte.js';
+	import { useInteractions } from '../useInteractions/index.svelte.js';
+	import { useRole, type UseRoleOptions } from '../useRole/index.svelte.js';
+	interface Props extends UseRoleOptions {
+		open?: boolean;
+	}
+	let { open = false, ...rest }: Props = $props();
+	const elements: { reference: HTMLElement | null; floating: HTMLElement | null } = $state({
+		reference: null,
+		floating: null
+	});
+	const floating = useFloating({
+		whileElementsMounted: autoUpdate,
+		get open() {
+			return open;
+		},
+		onOpenChange(v) {
+			open = v;
+		},
+		elements
+	});
+	const role = useRole(floating.context, { ...rest });
+	const interactions = useInteractions([role]);
+</script>
+
+<p>{open}</p>
+<button bind:this={elements.reference} {...interactions.getReferenceProps()}> Reference </button>
+{#if open}
+	<div
+		bind:this={elements.floating}
+		style={floating.floatingStyles}
+		{...interactions.getFloatingProps()}
+	>
+		Floating
+	</div>
+{/if}
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 0000000..0e4d59c
--- /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')
+			);
+		});
+	});
+});