Skip to content

Added useRole #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,29 @@ 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 | 'dialog' |

### useDismiss

Expand Down
2 changes: 1 addition & 1 deletion src/lib/hooks/useFloating/index.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,4 +322,4 @@ function useFloating(options: UseFloatingOptions = {}): UseFloatingReturn {
};
}

export { useFloating, type UseFloatingOptions, type UseFloatingReturn };
export { useFloating, type UseFloatingOptions, type UseFloatingReturn, type FloatingContext };
38 changes: 38 additions & 0 deletions src/lib/hooks/useRole/App.test.svelte
Original file line number Diff line number Diff line change
@@ -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}
111 changes: 111 additions & 0 deletions src/lib/hooks/useRole/index.svelte.ts
Original file line number Diff line number Diff line change
@@ -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 };
72 changes: 72 additions & 0 deletions src/lib/hooks/useRole/index.test.svelte.ts
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
});