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

Issue:
Add: useClick hook
## Reference

https://floating-ui.com/docs/useClick

## Description

Opens or closes the floating element when clicking the reference element.

## Props

-[ ] `enabled`
-[ ] `event`
-[ ] `toggle`
-[ ] `ignoreMouse`
-[ ] `keyboardHandlers`

Pull request:
Feat: `useClick` hook
Closes #6 

TODO:
- [x] Write tests
- [x] Write docs

Patch:
diff --git a/src/docs/components/Navigation/Navigation.svelte b/src/docs/components/Navigation/Navigation.svelte
index f6a6b74..d049d0b 100644
--- a/src/docs/components/Navigation/Navigation.svelte
+++ b/src/docs/components/Navigation/Navigation.svelte
@@ -12,7 +12,7 @@
 	import IconUseFloating from 'lucide-svelte/icons/cloud';
 	import IconUseInteractions from 'lucide-svelte/icons/pointer';
 	import IconUseHover from 'lucide-svelte/icons/square-mouse-pointer';
-	// import IconUseClick from 'lucide-svelte/icons/mouse-pointer-2';
+	import IconUseClick from 'lucide-svelte/icons/mouse-pointer-2';
 	import IconUseRole from 'lucide-svelte/icons/person-standing';
 	import IconFloatingArrow from 'lucide-svelte/icons/triangle';
 	import IconUtils from 'lucide-svelte/icons/wand-sparkles';
@@ -43,7 +43,7 @@
 				{ icon: IconUseFloating, href: '/api/use-floating', label: 'useFloating' },
 				{ icon: IconUseInteractions, href: '/api/use-interactions', label: 'useInteractions' },
 				{ icon: IconUseHover, href: '/api/use-hover', label: 'useHover' },
-				// { icon: IconUseClick, href: '/api/use-click', label: 'useClick' },
+				{ icon: IconUseClick, href: '/api/use-click', label: 'useClick' },
 				{ icon: IconUseRole, href: '/api/use-role', label: 'useRole' },
 				{ icon: IconFloatingArrow, href: '/api/floating-arrow', label: 'Floating Arrow' },
 				{ icon: IconUtils, href: '/api/utilities', label: 'Utilities' },
diff --git a/src/lib/hooks/useClick/index.svelte.ts b/src/lib/hooks/useClick/index.svelte.ts
new file mode 100644
index 0000000..adb5110
--- /dev/null
+++ b/src/lib/hooks/useClick/index.svelte.ts
@@ -0,0 +1,169 @@
+import { isHTMLElement } from '@floating-ui/utils/dom';
+import { isTypeableElement } from '$lib/utils/is-typable-element.js';
+import { isMouseLikePointerType } from '$lib/utils/is-mouse-like-pointer-type.js';
+import type { FloatingContext } from '../useFloating/index.svelte.js';
+import type { ElementProps } from '../useInteractions/index.svelte.js';
+
+interface UseClickOptions {
+	/**
+	 * Whether the Hook is enabled, including all internal Effects and event
+	 * handlers.
+	 * @default true
+	 */
+	enabled?: boolean;
+
+	/**
+	 * The type of event to use to determine a “click” with mouse input.
+	 * Keyboard clicks work as normal.
+	 * @default 'click'
+	 */
+	event?: 'click' | 'mousedown';
+
+	/**
+	 * Whether to toggle the open state with repeated clicks.
+	 * @default true
+	 */
+	toggle?: boolean;
+
+	/**
+	 * Whether to ignore the logic for mouse input (for example, if `useHover()`
+	 * is also being used).
+	 * When `useHover()` and `useClick()` are used together, clicking the
+	 * reference element after hovering it will keep the floating element open
+	 * even once the cursor leaves. This may be not be desirable in some cases.
+	 * @default false
+	 */
+	ignoreMouse?: boolean;
+
+	/**
+	 * Whether to add keyboard handlers (Enter and Space key functionality) for
+	 * non-button elements (to open/close the floating element via keyboard
+	 * “click”).
+	 * @default true
+	 */
+	keyboardHandlers?: boolean;
+}
+
+function isButtonTarget(event: KeyboardEvent) {
+	return isHTMLElement(event.target) && event.target.tagName === 'BUTTON';
+}
+
+function isSpaceIgnored(element: Element | null) {
+	return isTypeableElement(element);
+}
+
+function useClick(context: FloatingContext, options: UseClickOptions = {}): ElementProps {
+	const {
+		enabled = true,
+		event: eventOption = 'click',
+		toggle = true,
+		ignoreMouse = false,
+		keyboardHandlers = true,
+	} = $derived(options);
+
+	const {
+		open,
+		onOpenChange,
+		data,
+		elements: { reference },
+	} = $derived(context);
+
+	let pointerType: PointerEvent['pointerType'] | undefined = undefined;
+	let didKeyDown = false;
+
+	const elementProps = $derived.by(() => {
+		if (!enabled) {
+			return {};
+		}
+
+		return {
+			reference: {
+				onpointerdown: (event: PointerEvent) => {
+					pointerType = event.pointerType;
+				},
+				onmousedown: (event: MouseEvent) => {
+					if (event.button !== 0) {
+						return;
+					}
+
+					if (isMouseLikePointerType(pointerType, true) && ignoreMouse) {
+						return;
+					}
+
+					if (eventOption === 'click') {
+						return;
+					}
+
+					if (open && toggle && (data.openEvent ? data.openEvent.type === 'mousedown' : true)) {
+						onOpenChange(false, event, 'click');
+					} else {
+						// Prevent stealing focus from the floating element
+						event.preventDefault();
+						onOpenChange(true, event, 'click');
+					}
+				},
+				onclick: (event: MouseEvent) => {
+					if (eventOption === 'mousedown' && pointerType) {
+						pointerType = undefined;
+						return;
+					}
+
+					if (isMouseLikePointerType(pointerType, true) && ignoreMouse) {
+						return;
+					}
+
+					if (open && toggle && (data.openEvent ? data.openEvent.type === 'click' : true)) {
+						onOpenChange(false, event, 'click');
+					} else {
+						onOpenChange(true, event, 'click');
+					}
+				},
+				onkeydown: (event: KeyboardEvent) => {
+					pointerType = undefined;
+
+					if (event.defaultPrevented || !keyboardHandlers || isButtonTarget(event)) {
+						return;
+					}
+					// @ts-expect-error - Fix types
+					if (event.key === ' ' && !isSpaceIgnored(reference)) {
+						// Prevent scrolling
+						event.preventDefault();
+						didKeyDown = true;
+					}
+
+					if (event.key === 'Enter') {
+						if (open && toggle) {
+							onOpenChange(false, event, 'click');
+						} else {
+							onOpenChange(true, event, 'click');
+						}
+					}
+				},
+				onkeyup: (event: KeyboardEvent) => {
+					if (
+						event.defaultPrevented ||
+						!keyboardHandlers ||
+						isButtonTarget(event) ||
+						// @ts-expect-error - Fix types
+						isSpaceIgnored(reference)
+					) {
+						return;
+					}
+
+					if (event.key === ' ' && didKeyDown) {
+						didKeyDown = false;
+						if (open && toggle) {
+							onOpenChange(false, event, 'click');
+						} else {
+							onOpenChange(true, event, 'click');
+						}
+					}
+				},
+			},
+		};
+	});
+
+	return elementProps;
+}
+
+export { useClick, type UseClickOptions };
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 2f8e13b..4d9b2b7 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -4,6 +4,7 @@ export * from '@floating-ui/dom';
 export * from '$lib/hooks/useFloating/index.svelte.js';
 export * from '$lib/hooks/useInteractions/index.svelte.js';
 export * from '$lib/hooks/useHover/index.svelte.js';
+export * from '$lib/hooks/useClick/index.svelte.js';
 export * from '$lib/hooks/useRole/index.svelte.js';
 export * from '$lib/hooks/useId/index.js';
 
diff --git a/src/lib/utils/is-typable-element.ts b/src/lib/utils/is-typable-element.ts
new file mode 100644
index 0000000..f6b6df6
--- /dev/null
+++ b/src/lib/utils/is-typable-element.ts
@@ -0,0 +1,9 @@
+import { isHTMLElement } from '@floating-ui/utils/dom';
+
+export const TYPEABLE_SELECTOR =
+	"input:not([type='hidden']):not([disabled])," +
+	"[contenteditable]:not([contenteditable='false']),textarea:not([disabled])";
+
+export function isTypeableElement(element: unknown): boolean {
+	return isHTMLElement(element) && element.matches(TYPEABLE_SELECTOR);
+}
diff --git a/src/routes/(inner)/api/use-click/+page.svelte b/src/routes/(inner)/api/use-click/+page.svelte
new file mode 100644
index 0000000..c5ab613
--- /dev/null
+++ b/src/routes/(inner)/api/use-click/+page.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+	import CodeBlock from '$docs/components/CodeBlock/CodeBlock.svelte';
+	import ExampleRaw from './Example.svelte?raw';
+	import Table from '$docs/components/Table/Table.svelte';
+	import { tableOptions } from './data.js';
+</script>
+
+<div class="space-y-10">
+	<!-- Header -->
+	<header class="card card-gradient space-y-8">
+		<h1 class="h1"><span>useClick</span></h1>
+		<p>Opens or closes the floating element when clicking the reference element.</p>
+		<CodeBlock lang="ts" code={`import { useClick } from '@skeletonlabs/floating-ui-svelte';`} />
+	</header>
+	<!-- Usage -->
+	<section class="space-y-8">
+		<h2 class="h2">Usage</h2>
+		<CodeBlock code={ExampleRaw} lang="svelte" />
+	</section>
+	<!-- Table: Options -->
+	<section class="space-y-8">
+		<h2 class="h2">Options</h2>
+		<Table data={tableOptions} />
+	</section>
+	<!-- Compare -->
+	<section class="space-y-8">
+		<h2 class="h2">Compare</h2>
+		<!-- prettier-ignore -->
+		<p>
+			Compare to <a class="anchor" href="https://floating-ui.com/docs/useClick" target="_blank">Floating UI React</a>.
+		</p>
+	</section>
+</div>
diff --git a/src/routes/(inner)/api/use-click/Example.svelte b/src/routes/(inner)/api/use-click/Example.svelte
new file mode 100644
index 0000000..7f2a568
--- /dev/null
+++ b/src/routes/(inner)/api/use-click/Example.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+	import { useFloating, useInteractions, useClick } from '@skeletonlabs/floating-ui-svelte';
+
+	const floating = useFloating();
+	const click = useClick(floating.context);
+	const interactions = useInteractions([click]);
+</script>
+
+<!-- Reference Element -->
+<button bind:this={floating.elements.reference} {...interactions.getReferenceProps()}>
+	Reference
+</button>
+
+<!-- Floating Element -->
+<div
+	bind:this={floating.elements.floating}
+	style={floating.floatingStyles}
+	{...interactions.getFloatingProps()}
+>
+	Tooltip
+</div>
diff --git a/src/routes/(inner)/api/use-click/data.ts b/src/routes/(inner)/api/use-click/data.ts
new file mode 100644
index 0000000..ed5ae6a
--- /dev/null
+++ b/src/routes/(inner)/api/use-click/data.ts
@@ -0,0 +1,38 @@
+import type { TableData } from '$docs/types.js';
+
+// Options
+export const tableOptions: TableData[] = [
+	{
+		property: 'enabled',
+		description: 'Whether the Hook is enabled, including all internal Effects and event handlers.',
+		type: 'boolean',
+		default: 'true',
+	},
+	{
+		property: 'event',
+		description:
+			'The type of event to use to determine a "click" with mouse input. Keyboard clicks work as normal.',
+		type: '"click" | "mousedown"',
+		default: "'click'",
+	},
+	{
+		property: 'toggle',
+		description: 'Whether to toggle the open state with repeated clicks.',
+		type: 'boolean',
+		default: 'true',
+	},
+	{
+		property: 'ignoreMouse',
+		description:
+			'Whether to ignore the logic for mouse input (for example, if `useHover()` is also being used). When `useHover()` and `useClick()` are used together, clicking the reference element after hovering it will keep the floating element open even once the cursor leaves. This may not be desirable in some cases.',
+		type: 'boolean',
+		default: 'false',
+	},
+	{
+		property: 'keyboardHandlers',
+		description:
+			'Whether to add keyboard handlers (Enter and Space key functionality) for non-button elements (to open/close the floating element via keyboard "click").',
+		type: 'boolean',
+		default: 'true',
+	},
+];
diff --git a/svelte.config.js b/svelte.config.js
index bc4b74c..6284f19 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -9,6 +9,7 @@ const config = {
 		adapter: adapter(),
 		alias: {
 			$docs: './src/docs',
+			'@skeletonlabs/floating-ui-svelte': './src/lib/index.js',
 		},
 		version: {
 			name: JSON.parse(readFileSync('./package.json')).version ?? '0.0.0',


Test patch:
diff --git a/src/lib/hooks/useClick/App.test.svelte b/src/lib/hooks/useClick/App.test.svelte
new file mode 100644
index 0000000..a48b5f8
--- /dev/null
+++ b/src/lib/hooks/useClick/App.test.svelte
@@ -0,0 +1,57 @@
+<!-- Component used to test `useClick` -->
+<script lang="ts">
+	import { autoUpdate } from '@floating-ui/dom';
+	import { useClick, type UseClickOptions } from './index.svelte.js';
+	import { useFloating } from '../useFloating/index.svelte.js';
+	import { useInteractions } from '../useInteractions/index.svelte.js';
+	import { useHover } from '../useHover/index.svelte.js';
+	interface Props extends UseClickOptions {
+		open?: boolean;
+		element?: string;
+		enableHover?: boolean;
+	}
+	let {
+		open = false,
+		element = 'button',
+		enableHover = false,
+		...useClickOptions
+	}: 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 click = useClick(floating.context, useClickOptions);
+	const hover = useHover(floating.context, { enabled: enableHover });
+	const interactions = useInteractions([click, hover]);
+</script>
+
+<svelte:element
+	this={element}
+	role="button"
+	data-testid="reference"
+	bind:this={elements.reference}
+	{...interactions.getReferenceProps()}
+>
+	Reference
+</svelte:element>
+
+{#if open}
+	<div
+		role="tooltip"
+		bind:this={elements.floating}
+		style={floating.floatingStyles}
+		{...interactions.getFloatingProps()}
+	>
+		Floating
+	</div>
+{/if}
diff --git a/src/lib/hooks/useClick/index.test.svelte.ts b/src/lib/hooks/useClick/index.test.svelte.ts
new file mode 100644
index 0000000..e6597f3
--- /dev/null
+++ b/src/lib/hooks/useClick/index.test.svelte.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/svelte';
+import App from './App.test.svelte';
+
+describe('useClick', () => {
+	describe('enabled', () => {
+		it('is set to `true` by default', async () => {
+			render(App);
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+		});
+
+		it('enables the hook when `enabled` is `true`', async () => {
+			render(App, { enabled: true });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+		it('disables the hook when `enabled` is `false`', async () => {
+			render(App, { enabled: false });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+	});
+
+	describe('event', () => {
+		it('is set to `click` by default', async () => {
+			render(App);
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+
+		it('opens on click when `event` is set to `click`', async () => {
+			render(App, { event: 'click' });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+
+		it('opens on mousedown when `event` is set to `mousedown`', async () => {
+			render(App, { event: 'mousedown' });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.mouseDown(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+	});
+
+	describe('toggle', () => {
+		it('is set to `true` by default', async () => {
+			render(App);
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+
+		it('when set to `true` changes `open` state to both `true` and `false`', async () => {
+			render(App, { toggle: true });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+
+		it('when set to `true` changes `open` state to `false` when `open` is initially set to `true`', async () => {
+			render(App, { toggle: true, open: true });
+
+			expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+
+		it('when set to `false` changes `open` state to `true` and not back to `false`', async () => {
+			render(App, { toggle: false });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+		it('when set to `false` does not change `open` state back to `false` when `open` is initially set to `true`', async () => {
+			render(App, { toggle: false, open: true });
+
+			expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+
+			await fireEvent.click(screen.getByRole('button'));
+
+			await vi.waitFor(() => {
+				expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+			});
+
+			cleanup();
+		});
+	});
+
+	describe('keyboardHandlers', () => {
+		it('when set to `true` returns a `Space` keyup event handler', async () => {
+			render(App, { element: 'div' });
+
+			await fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+
+			expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+
+			cleanup();
+		});
+
+		it('when set to `true` returns a `Space` keyup event handler', async () => {
+			render(App, { element: 'div' });
+
+			await fireEvent.keyDown(screen.getByRole('button'), { key: ' ' });
+			await fireEvent.keyUp(screen.getByRole('button'), { key: ' ' });
+
+			expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+
+			cleanup();
+		});
+
+		it('when applied to a typable reference does not return a `Space` key event handler', async () => {
+			render(App, { element: 'input' });
+
+			await fireEvent.keyDown(screen.getByRole('button'), { key: ' ' });
+			await fireEvent.keyUp(screen.getByRole('button'), { key: ' ' });
+
+			expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+			cleanup();
+		});
+
+		it('when applied to a typable reference does not return a `Enter` key event handler', async () => {
+			render(App, { element: 'input' });
+
+			await fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
+
+			expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+
+			cleanup();
+		});
+	});
+	it('does not change `open` state to `false` on mouseleave when paired with `useHover`', async () => {
+		render(App, { enableHover: true });
+
+		await fireEvent.mouseEnter(screen.getByRole('button'));
+		await fireEvent.click(screen.getByRole('button'));
+		await fireEvent.mouseLeave(screen.getByRole('button'));
+
+		expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+
+		cleanup();
+	});
+});
diff --git a/src/lib/hooks/useHover/App.test.svelte b/src/lib/hooks/useHover/App.test.svelte
index 99f86a0..d5afec6 100644
--- a/src/lib/hooks/useHover/App.test.svelte
+++ b/src/lib/hooks/useHover/App.test.svelte
@@ -1,3 +1,4 @@
+<!-- Component used to test `useHover` -->
 <script lang="ts">
 	import { autoUpdate } from '@floating-ui/dom';
 	import { useFloating } from '../useFloating/index.svelte.js';
@@ -8,7 +9,7 @@
 		showReference?: boolean;
 	}
 
-	const { showReference = true, ...rest }: Props = $props();
+	const { showReference = true, ...useHoverOptions }: Props = $props();
 
 	let open = $state(false);
 	const elements: { reference: HTMLElement | null; floating: HTMLElement | null } = $state({
@@ -26,7 +27,7 @@
 		elements,
 	});
 
-	const hover = useHover(floating.context, { ...rest });
+	const hover = useHover(floating.context, useHoverOptions);
 
 	const interactions = useInteractions([hover]);
 </script>
diff --git a/src/lib/hooks/useRole/App.test.svelte b/src/lib/hooks/useRole/App.test.svelte
index 64fdb2f..8172336 100644
--- a/src/lib/hooks/useRole/App.test.svelte
+++ b/src/lib/hooks/useRole/App.test.svelte
@@ -1,3 +1,4 @@
+<!-- Component used to test `useRole` -->
 <script lang="ts">
 	import { autoUpdate } from '@floating-ui/dom';
 	import { useFloating } from '../useFloating/index.svelte.js';