Repository: melt-ui/melt-ui
Pull request #884 (2024-01-06 05:57:15)

fix: disabled radio group keyboard navigation
 #820 

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 1c11290fabb7ceb1fabf45a89acc56f17225c698

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/huntabyte/melt-ui/new/fix/820?filename=.changeset/big-walls-yell.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0Afix%3A%20disabled%20radio%20group%20keyboard%20navigation%0A)



Patch:
diff --git a/.changeset/big-walls-yell.md b/.changeset/big-walls-yell.md
new file mode 100644
index 00000000..6fa6a5f3
--- /dev/null
+++ b/.changeset/big-walls-yell.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+Radio Group: fix keyboard navigation when disabled (closes #820)
diff --git a/.changeset/giant-buckets-bake.md b/.changeset/giant-buckets-bake.md
new file mode 100644
index 00000000..29cdea95
--- /dev/null
+++ b/.changeset/giant-buckets-bake.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+fix: missing tooltip data-attrs
diff --git a/src/docs/data/builders/tooltip.ts b/src/docs/data/builders/tooltip.ts
index cdda15c0..6afc60fb 100644
--- a/src/docs/data/builders/tooltip.ts
+++ b/src/docs/data/builders/tooltip.ts
@@ -95,6 +95,10 @@ const trigger = elementSchema('trigger', {
 const content = elementSchema('content', {
 	description: 'The tooltip content element.',
 	dataAttributes: [
+		{
+			name: 'data-state',
+			value: ATTRS.OPEN_CLOSED,
+		},
 		{
 			name: 'data-melt-tooltip-content',
 			value: ATTRS.MELT('tooltip content'),
diff --git a/src/lib/builders/radio-group/create.ts b/src/lib/builders/radio-group/create.ts
index be95c8b5..1c693bc4 100644
--- a/src/lib/builders/radio-group/create.ts
+++ b/src/lib/builders/radio-group/create.ts
@@ -128,7 +128,7 @@ export function createRadioGroup(props?: CreateRadioGroupProps) {
 					if (!isHTMLElement(root)) return;
 
 					const items = Array.from(root.querySelectorAll(selector('item'))).filter(
-						(el): el is HTMLElement => isHTMLElement(el)
+						(el): el is HTMLElement => isHTMLElement(el) && !el.hasAttribute('data-disabled')
 					);
 					const currentIndex = items.indexOf(el);
 
diff --git a/src/lib/builders/tooltip/create.ts b/src/lib/builders/tooltip/create.ts
index 5dc622a5..e81717c5 100644
--- a/src/lib/builders/tooltip/create.ts
+++ b/src/lib/builders/tooltip/create.ts
@@ -126,12 +126,17 @@ export function createTooltip(props?: CreateTooltipProps) {
 		}
 	}
 
+	const isVisible = derived([open, forceVisible], ([$open, $forceVisible]) => {
+		return $open || $forceVisible;
+	});
+
 	const trigger = builder(name('trigger'), {
-		stores: [ids.content, ids.trigger],
-		returned: ([$contentId, $triggerId]) => {
+		stores: [ids.content, ids.trigger, open],
+		returned: ([$contentId, $triggerId, $open]) => {
 			return {
 				'aria-describedby': $contentId,
 				id: $triggerId,
+				'data-state': $open ? 'open' : 'closed',
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<TooltipEvents['trigger']> => {
@@ -183,10 +188,6 @@ export function createTooltip(props?: CreateTooltipProps) {
 		},
 	});
 
-	const isVisible = derived([open, forceVisible], ([$open, $forceVisible]) => {
-		return $open || $forceVisible;
-	});
-
 	const content = builder(name('content'), {
 		stores: [isVisible, portal, ids.content],
 		returned: ([$isVisible, $portal, $contentId]) => {
@@ -199,6 +200,7 @@ export function createTooltip(props?: CreateTooltipProps) {
 				}),
 				id: $contentId,
 				'data-portal': $portal ? '' : undefined,
+				'data-state': $isVisible ? 'open' : 'closed',
 			};
 		},
 		action: (node: HTMLElement): MeltActionReturn<TooltipEvents['content']> => {


Test patch:
diff --git a/src/tests/radio-group/RadioGroup.spec.ts b/src/tests/radio-group/RadioGroup.spec.ts
new file mode 100644
index 00000000..095816fd
--- /dev/null
+++ b/src/tests/radio-group/RadioGroup.spec.ts
@@ -0,0 +1,87 @@
+import { render, waitFor } from '@testing-library/svelte';
+import { axe } from 'jest-axe';
+import { describe, it } from 'vitest';
+import RadioGroupTest from './RadioGroupTest.svelte';
+import userEvent from '@testing-library/user-event';
+import { testKbd as kbd } from '../utils.js';
+import type { CreateRadioGroupProps } from '$lib';
+
+const items = [
+	{
+		value: 'a',
+		disabled: false,
+	},
+	{
+		value: 'b',
+		disabled: false,
+	},
+	{
+		value: 'c',
+		disabled: false,
+	},
+	{
+		value: 'd',
+		disabled: false,
+	},
+];
+
+function setup(
+	props: CreateRadioGroupProps & { items?: { value: string; disabled: boolean }[] } = {}
+) {
+	const user = userEvent.setup();
+	return { ...render(RadioGroupTest, props), user };
+}
+
+describe('Radio Group', () => {
+	it('No accessibility violations', async () => {
+		const { container } = render(RadioGroupTest);
+		expect(await axe(container)).toHaveNoViolations();
+	});
+
+	it('doesnt require a default value', async () => {
+		const { getByTestId } = setup();
+		expect(getByTestId('value')).toHaveTextContent('undefined');
+	});
+
+	it('allows changing value by clicking on a radio button', async () => {
+		const { user, getByTestId } = setup();
+		const a = getByTestId('a');
+		const b = getByTestId('b');
+		const value = getByTestId('value');
+		await user.click(a);
+		expect(value).toHaveTextContent('a');
+		await user.click(b);
+		expect(value).toHaveTextContent('b');
+	});
+
+	it('navigates through the buttons with arrow keys', async () => {
+		const { user, getByTestId } = setup();
+
+		const els = ['a', 'b', 'c', 'd'].map((v) => getByTestId(v));
+
+		await user.click(els[0]);
+		expect(els[0]).toHaveFocus();
+		await user.keyboard(kbd.ARROW_DOWN);
+		expect(els[1]).toHaveFocus();
+		await user.keyboard(kbd.ARROW_DOWN);
+		expect(els[2]).toHaveFocus();
+		await user.keyboard(kbd.ARROW_DOWN);
+		expect(els[3]).toHaveFocus();
+		await user.keyboard(kbd.ARROW_DOWN);
+		expect(els[0]).toHaveFocus();
+	});
+
+	it('skips over disabled buttons', async () => {
+		const { user, getByTestId } = setup({
+			items: items.map((item) => ({ ...item, disabled: item.value === 'b' })),
+		});
+
+		const els = ['a', 'b', 'c', 'd'].map((v) => getByTestId(v));
+
+		await user.click(els[0]);
+		expect(els[0]).toHaveFocus();
+		await user.keyboard(kbd.ARROW_DOWN);
+		expect(els[2]).toHaveFocus();
+		expect(els[1]).not.toHaveFocus();
+	});
+});
diff --git a/src/tests/radio-group/RadioGroupTest.svelte b/src/tests/radio-group/RadioGroupTest.svelte
new file mode 100644
index 00000000..111c1785
--- /dev/null
+++ b/src/tests/radio-group/RadioGroupTest.svelte
@@ -0,0 +1,49 @@
+<script lang="ts">
+	import { createRadioGroup, melt, type CreateRadioGroupProps } from '$lib/index.js';
+	import { removeUndefined } from '../utils.js';
+
+	type $$Props = CreateRadioGroupProps & {
+		items?: Item[];
+	};
+
+	type Item = {
+		value: string;
+		disabled: boolean;
+	};
+
+	export let value: CreateRadioGroupProps['value'] = undefined;
+	export let defaultValue: CreateRadioGroupProps['defaultValue'] = undefined;
+	export let disabled: CreateRadioGroupProps['disabled'] = undefined;
+	export let onValueChange: CreateRadioGroupProps['onValueChange'] = undefined;
+	export let required: CreateRadioGroupProps['required'] = undefined;
+	export let items: Item[] = [
+		{ value: 'a', disabled: false },
+		{ value: 'b', disabled: false },
+		{ value: 'c', disabled: false },
+		{ value: 'd', disabled: false },
+	];
+
+	const {
+		elements: { root, item },
+		states: { value: localValue },
+	} = createRadioGroup({
+		value,
+		defaultValue,
+		onValueChange,
+		disabled,
+		required,
+		...removeUndefined($$restProps),
+	});
+</script>
+
+<main>
+	<div data-testid="value">{$localValue}</div>
+	<form>
+		<label id="radio-group-label" for="radio-group" data-testid="label"> Airplane mode </label>
+		<div use:melt={$root} data-testid="root">
+			{#each items as radioItem}
+				<button use:melt={$item(radioItem)} data-testid={radioItem.value}>{radioItem.value}</button>
+			{/each}
+		</div>
+	</form>
+</main>