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

Issue:
Chore: Port missing `useRole` tests from React.
When #7 was closed it didn't have *all* the tests ported quite yet, this still needs to be done.

Pull request:
Chore: Cleanup `useRole`
## Linked Issue

Partially closes #53

## Description

Adds missing tests and general code style.
Also fixed a nasty bug in all interactions hooks that caused reactivity not to go through entirely

## Changsets

We use [Changesets](https://github.com/changesets/changesets) to automatically create our changelog per each release. Any changes or additions to the Library assets in `/lib` must be documented with a new Changeset. This can be done as follows:

2. Navigate to the root of the project on your feature branch.
3. Run `pnpm changeset` to trigger the Changeset CLI.
4. Follow the instructions when prompted.
    - Changesets should be either `minor` or `patch`. Never `major`.
    - Prefix your Changeset description using: `feature:`, `chore:` or `bugfix:`.
5. Changeset `.md` files are added to the `/.changeset` directory.
6. Commit and push the the new changeset file.

## Checklist

Please read and apply all [contribution requirements](https://github.com/skeletonlabs/floating-ui-svelte/blob/chore/main/CONTRIBUTING.md).

- [x] PR targets the `dev` branch (NEVER `master`)
- [x] All website documentation is current with your changes
- [x] Ensure Prettier formatting is current - run `pnpm format`
- [x] Ensure Prettier linting is current - run `pnpm format`
- [x] All test cases are passing - run `pnpm test`
- [ ] Includes a changeset (if relevant; see above)


Patch:
diff --git a/src/lib/hooks/useClick/index.svelte.ts b/src/lib/hooks/useClick/index.svelte.ts
index def7823..1757f46 100644
--- a/src/lib/hooks/useClick/index.svelte.ts
+++ b/src/lib/hooks/useClick/index.svelte.ts
@@ -71,13 +71,12 @@ function useClick(context: FloatingContext, options: UseClickOptions = {}): Elem
 	let pointerType: PointerEvent['pointerType'] | undefined = undefined;
 	let didKeyDown = false;
 
-	const elementProps = $derived.by(() => {
-		if (!enabled) {
-			return {};
-		}
-
-		return {
-			reference: {
+	return {
+		get reference() {
+			if (!enabled) {
+				return {};
+			}
+			return {
 				onpointerdown: (event: PointerEvent) => {
 					pointerType = event.pointerType;
 				},
@@ -159,11 +158,9 @@ function useClick(context: FloatingContext, options: UseClickOptions = {}): Elem
 						}
 					}
 				},
-			},
-		};
-	});
-
-	return elementProps;
+			};
+		},
+	};
 }
 
 export { useClick, type UseClickOptions };
diff --git a/src/lib/hooks/useHover/index.svelte.ts b/src/lib/hooks/useHover/index.svelte.ts
index 310bdbd..db7fdf9 100644
--- a/src/lib/hooks/useHover/index.svelte.ts
+++ b/src/lib/hooks/useHover/index.svelte.ts
@@ -248,35 +248,34 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}): Elem
 		};
 	});
 
-	const elementProps = $derived.by(() => {
-		if (!enabled) {
-			return {};
-		}
-
-		const onmouseenter = (event: MouseEvent) => {
-			clearTimeout(timeout);
-			blockMouseMove = false;
-
-			if (
-				(mouseOnly && !isMouseLikePointerType(pointerType)) ||
-				(restMs > 0 && !getDelay(delay, 'open'))
-			) {
-				return;
+	return {
+		get reference() {
+			if (!enabled) {
+				return {};
 			}
 
-			const openDelay = getDelay(delay, 'open', pointerType);
-
-			if (openDelay) {
-				timeout = window.setTimeout(() => {
+			const onmouseenter = (event: MouseEvent) => {
+				clearTimeout(timeout);
+				blockMouseMove = false;
+
+				if (
+					(mouseOnly && !isMouseLikePointerType(pointerType)) ||
+					(restMs > 0 && !getDelay(delay, 'open'))
+				) {
+					return;
+				}
+
+				const openDelay = getDelay(delay, 'open', pointerType);
+
+				if (openDelay) {
+					timeout = window.setTimeout(() => {
+						onOpenChange(true, event, 'hover');
+					}, openDelay);
+				} else {
 					onOpenChange(true, event, 'hover');
-				}, openDelay);
-			} else {
-				onOpenChange(true, event, 'hover');
-			}
-		};
-
-		return {
-			reference: {
+				}
+			};
+			return {
 				onpointerdown: (event: PointerEvent) => (pointerType = event.pointerType),
 				onpointerenter: (event: PointerEvent) => (pointerType = event.pointerType),
 				onmouseenter,
@@ -367,8 +366,14 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}): Elem
 						})(event);
 					}
 				},
-			},
-			floating: {
+			};
+		},
+
+		get floating() {
+			if (!enabled) {
+				return {};
+			}
+			return {
 				onmouseenter() {
 					clearTimeout(timeout);
 				},
@@ -388,11 +393,9 @@ function useHover(context: FloatingContext, options: UseHoverOptions = {}): Elem
 					}
 					closeWithDelay(event, false);
 				},
-			},
-		};
-	});
-
-	return elementProps;
+			};
+		},
+	};
 }
 
 export { useHover, type UseHoverOptions };
diff --git a/src/lib/hooks/useInteractions/index.svelte.ts b/src/lib/hooks/useInteractions/index.svelte.ts
index 9296c81..e8880a4 100644
--- a/src/lib/hooks/useInteractions/index.svelte.ts
+++ b/src/lib/hooks/useInteractions/index.svelte.ts
@@ -84,21 +84,21 @@ function mergeProps<Key extends keyof ElementProps>(
 }
 
 function useInteractions(propsList: Array<ElementProps | void> = []): UseInteractionsReturn {
-	const getReferenceProps = (userProps?: HTMLAttributes<Element>) => {
+	const getReferenceProps = $derived((userProps?: HTMLAttributes<Element>) => {
 		return mergeProps(userProps, propsList, 'reference');
-	};
+	});
 
-	const getFloatingProps = (userProps?: HTMLAttributes<Element>) => {
+	const getFloatingProps = $derived((userProps?: HTMLAttributes<Element>) => {
 		return mergeProps(userProps, propsList, 'floating');
-	};
+	});
 
-	const getItemProps = (
-		userProps?: Omit<HTMLAttributes<Element>, 'selected' | 'active'> & ExtendedUserProps,
-	) => {
-		return mergeProps(userProps, propsList, 'item');
-	};
+	const getItemProps = $derived(
+		(userProps?: Omit<HTMLAttributes<Element>, 'selected' | 'active'> & ExtendedUserProps) => {
+			return mergeProps(userProps, propsList, 'item');
+		},
+	);
 
 	return { getReferenceProps, getFloatingProps, getItemProps };
 }
 
-export { useInteractions, type UseInteractionsReturn, type ElementProps };
+export { useInteractions, type UseInteractionsReturn, type ElementProps, type ExtendedUserProps };
diff --git a/src/lib/hooks/useRole/index.svelte.ts b/src/lib/hooks/useRole/index.svelte.ts
index b7905a3..6799904 100644
--- a/src/lib/hooks/useRole/index.svelte.ts
+++ b/src/lib/hooks/useRole/index.svelte.ts
@@ -1,6 +1,6 @@
 import { Map as ReactiveMap } from 'svelte/reactivity';
 import type { FloatingContext } from '../useFloating/index.svelte.js';
-import type { ElementProps } from '../useInteractions/index.svelte.js';
+import type { ElementProps, ExtendedUserProps } from '../useInteractions/index.svelte.js';
 import { useId } from '../useId/index.js';
 
 type AriaRole = 'tooltip' | 'dialog' | 'alertdialog' | 'menu' | 'listbox' | 'grid' | 'tree';
@@ -42,29 +42,12 @@ function useRole(context: FloatingContext, options: UseRoleOptions = {}): Elemen
 
 	const isNested = parentId != null;
 
-	const elementProps: ElementProps = $derived.by(() => {
-		if (!enabled) {
-			return {};
-		}
-
-		const floatingProps = {
-			id: floatingId,
-			...(ariaRole && { role: ariaRole }),
-		};
-
-		if (ariaRole === 'tooltip' || role === 'label') {
+	return {
+		get reference() {
+			if (!enabled) {
+				return {};
+			}
 			return {
-				reference: {
-					[`aria-${role === 'label' ? 'labelledby' : 'describedby'}`]: open
-						? floatingId
-						: undefined,
-				},
-				floating: floatingProps,
-			};
-		}
-
-		return {
-			reference: {
 				'aria-expanded': open ? 'true' : 'false',
 				'aria-haspopup': ariaRole === 'alertdialog' ? 'dialog' : ariaRole,
 				'aria-controls': open ? floatingId : undefined,
@@ -73,12 +56,22 @@ function useRole(context: FloatingContext, options: UseRoleOptions = {}): Elemen
 				...(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 }) {
+			};
+		},
+		get floating() {
+			if (!enabled) {
+				return {};
+			}
+			return {
+				id: floatingId,
+				...(ariaRole && { role: ariaRole }),
+			};
+		},
+		get item() {
+			if (!enabled) {
+				return {};
+			}
+			return ({ active, selected }: ExtendedUserProps) => {
 				const commonProps = {
 					role: 'option',
 					...(active && { id: `${context.floatingId}-option` }),
@@ -102,11 +95,9 @@ function useRole(context: FloatingContext, options: UseRoleOptions = {}): Elemen
 				}
 
 				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
index 392eece..9e0d075 100644
--- a/src/lib/hooks/useRole/App.test.svelte
+++ b/src/lib/hooks/useRole/App.test.svelte
@@ -7,32 +7,30 @@
 	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,
-	});
+	let { open = false, ...useRoleOptions }: Props = $props();
 	const floating = useFloating({
 		whileElementsMounted: autoUpdate,
 		get open() {
 			return open;
 		},
-		onOpenChange(v) {
-			open = v;
-		},
-		elements,
+		onOpenChange: (v) => (open = v),
 	});
-	const role = useRole(floating.context, { ...rest });
+	const role = useRole(floating.context, useRoleOptions);
 	const interactions = useInteractions([role]);
 </script>
 
-<button data-testid="reference" bind:this={elements.reference} {...interactions.getReferenceProps()}
-	>Reference</button
+<div
+	data-testid="reference"
+	bind:this={floating.elements.reference}
+	{...interactions.getReferenceProps()}
 >
+	Reference
+</div>
+
 {#if open}
 	<div
 		data-testid="floating"
-		bind:this={elements.floating}
+		bind:this={floating.elements.floating}
 		style={floating.floatingStyles}
 		{...interactions.getFloatingProps()}
 	>
diff --git a/src/lib/hooks/useRole/index.test.svelte.ts b/src/lib/hooks/useRole/index.test.svelte.ts
index 40ab9b4..4afec50 100644
--- a/src/lib/hooks/useRole/index.test.svelte.ts
+++ b/src/lib/hooks/useRole/index.test.svelte.ts
@@ -2,65 +2,51 @@ import { describe, it, expect } from 'vitest';
 import { render, screen } from '@testing-library/svelte/svelte5';
 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, { open: true });
-		expect(screen.queryByRole('dialog')).toBeInTheDocument();
-	});
+	describe('default', () => {
+		it('applies the `dialog` role to the floating element', () => {
+			render(App, { open: true });
 
-	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();
+			expect(screen.getByTestId('floating')).toHaveAttribute('role', 'dialog');
 		});
-	}
-
-	describe('tooltip', () => {
-		it.skip('sets correct aria attributes based on the open state', async () => {
-			const { rerender } = render(App, { role: 'tooltip', open: true });
+	});
 
-			expect(screen.getByTestId('reference')).toHaveAttribute(
-				'aria-describedby',
-				screen.getByRole('tooltip').getAttribute('id'),
-			);
+	describe('enabled', () => {
+		it('does apply the role when set to `true`', () => {
+			render(App, { role: 'listbox', open: true });
 
-			await rerender({ role: 'tooltip', open: false });
+			expect(screen.getByTestId('floating')).toHaveAttribute('role', 'listbox');
+		});
+		it('does not apply the role when set to `false`', () => {
+			render(App, { role: 'listbox', open: true, enabled: false });
 
-			expect(screen.getByTestId('reference')).not.toHaveAttribute('aria-describedby');
+			expect(screen.getByTestId('floating')).not.toHaveAttribute('role', 'listbox');
 		});
 	});
 
-	describe('label', () => {
-		it.skip('sets correct aria attributes based on the open state', async () => {
-			const { rerender } = render(App, { role: 'label', open: true });
+	describe('role', () => {
+		describe('dialog', () => {
+			it('applies the `aria-haspopup` attribute to the reference element', () => {
+				render(App, { role: 'dialog' });
 
-			expect(screen.getByTestId('reference')).toHaveAttribute(
-				'aria-labelledby',
-				screen.getByRole('tooltip').getAttribute('id'),
-			);
+				expect(screen.getByTestId('reference')).toHaveAttribute('aria-haspopup', 'dialog');
+			});
 
-			await rerender({ role: 'tooltip', open: false });
+			it('applies the `dialog` role to the floating element', () => {
+				render(App, { role: 'dialog', open: true });
 
-			expect(screen.getByRole('buton')).not.toHaveAttribute('aria-labelledby');
-		});
-	});
+				expect(screen.getByTestId('floating')).toHaveAttribute('role', 'dialog');
+			});
 
-	describe('dialog', () => {
-		it.skip('sets correct aria attributes based on the open state', async () => {
-			const { rerender } = render(App, { role: 'dialog', open: false });
+			it('applies the `aria-expanded` attribute to the reference element based on `open` state', async () => {
+				const { rerender } = render(App, { role: 'dialog', open: false });
 
-			expect(screen.getByTestId('reference')).toHaveAttribute('aria-haspopup', 'dialog');
-			expect(screen.getByTestId('reference')).toHaveAttribute('aria-expanded', 'false');
+				expect(screen.getByTestId('reference')).toHaveAttribute('aria-expanded', 'false');
 
-			await rerender({ role: 'dialog', open: true });
+				await rerender({ role: 'dialog', open: true });
 
-			expect(screen.getByRole('dialog')).toBeInTheDocument();
-			expect(screen.getByTestId('reference')).toHaveAttribute(
-				'aria-controls',
-				screen.getByRole('dialog').getAttribute('id'),
-			);
+				expect(screen.getByTestId('reference')).toHaveAttribute('aria-expanded', 'true');
+			});
 		});
 	});
 });