Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/ten-buckets-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storefront-ui/vue': major
---

- **[FIXED][BREAKING]** From now on, `onClose` callback in `useDropdown` will be triggered only if outside click triggers closing of the dropdown. Previously onClose was also triggered when the dropdown was already closed.
4 changes: 4 additions & 0 deletions apps/docs/components/content/_components/dropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ By default, the floating content of `SfDropdown` will be placed below your trigg

The floating content area has an `aria-hidden` attribute that reflects the visibility of the dropdown (`modelValue`). When the dropdown is not open (`modelValue` is `false`), the `aria-hidden` attribute is set to `true`, ensuring that the content is hidden from assistive technologies.

::tip
This component lets you create many types of custom dropdowns, such as navigation menus, comboboxes, or select lists. However, accessibility depends on how you implement it in your use case. Always ensure users can move through items in the dropdown using arrow keys and/or the Tab key. Additionally, after the dropdown is closed, make sure focus returns to the trigger element.
::

## Playground

<Generate style="height: 450px" />
Expand Down
60 changes: 11 additions & 49 deletions apps/docs/components/content/_hooks/useDropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,13 @@

## Usage

::react-only
For a minimal example, we can implement a floating element using two properties returned by the `useDropdown` hook.

1. `refs` - An object that contains a `setReference` and `setFloating` function. These functions should be bound to the element that the floating element will be positioned relative to and the floating element itself, respectively.
2. `style` - An object containing the position styles for your floating element.

By binding these properties to the appropriate elements, we can create a dropdown menu that opens when a button is clicked.

::react-only
```tsx
import * as React from 'react';
import { useDropdown, SfButton } from '@storefront-ui/react';

function Dropdown() {
const [isOpen, setOpen] = React.useState(false);

const close = () => setOpen(false);
const toggle = () => setOpen((isOpen) => !isOpen);

const { refs, style } = useDropdown({ isOpen, onClose: close });

return (
<div ref={refs.setReference} className="w-max">
<SfButton onClick={toggle}>Toggle</SfButton>
{isOpen && (
<ul ref={refs.setFloating} style={style.floating} className="absolute p-2 w-max rounded-sm bg-gray-100">
<li>More</li>
<li>About</li>
<li>Settings</li>
</ul>
)}
</div>
);
}
```
::

::vue-only
Expand All @@ -52,30 +25,19 @@ For a minimal example, we can implement a floating element using three propertie
3. `style` - An object containing the position styles for your floating element.

By binding these properties to the appropriate elements, we can create a dropdown menu that opens when a button is clicked.
::

<Showcase showcase-name="useDropdown/UseDropdown">

```vue
<script lang="ts" setup>
import { ref } from 'vue';
import { useDropdown, SfButton } from '@storefront-ui/vue';

const isOpen = ref(false);

const { referenceRef, floatingRef, style } = useDropdown({ isOpen, onClose: () => isOpen.value = false });
</script>

<template>
<div ref="referenceRef" class="w-max">
<SfButton @click="isOpen = !isOpen">Toggle</SfButton>
<ul v-if="isOpen" ref="floatingRef" :style="style" class="absolute p-2 w-max rounded-sm bg-gray-100">
<li>More</li>
<li>About</li>
<li>Settings</li>
</ul>
</div>
</template>
```
::react-only
<<<../../../../preview/next/pages/showcases/useDropdown/UseDropdown.tsx#source
::
::vue-only
<<<../../../../preview/nuxt/pages/showcases/useDropdown/UseDropdown.vue
::

</Showcase>

::tip There are more options!
For a full list of the possible parameters and return values, see the API section.
::
Expand Down
23 changes: 19 additions & 4 deletions apps/preview/next/pages/showcases/Dropdown/BasicDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import { ShowcasePageLayout } from '../../showcases';

// #region source
import { useRef } from 'react';
import { SfButton, SfDropdown, useDisclosure } from '@storefront-ui/react';

export default function BasicDropdown() {
const { isOpen, toggle, close } = useDisclosure();
const triggerRef = useRef<HTMLButtonElement>(null);

const handleClose = () => {
close();
triggerRef.current?.focus();
};
return (
<SfDropdown trigger={<SfButton onClick={toggle}>Toggle</SfButton>} open={isOpen} onClose={close}>
<SfDropdown
trigger={
<SfButton ref={triggerRef} onClick={toggle}>
Toggle
</SfButton>
}
open={isOpen}
onClose={handleClose}
>
<ul className="p-2 rounded-sm bg-gray-100">
<li>More</li>
<li>About</li>
<li>Settings</li>
<li tabIndex={0}>More</li>
<li tabIndex={0}>About</li>
<li tabIndex={0}>Settings</li>
</ul>
</SfDropdown>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,8 @@ export default function MegaMenuNavigation() {
const { close, open, isOpen } = useDisclosure();
const { refs, style } = useDropdown({
isOpen,
onClose: (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose: (event) => {
if ('key' in event && event.key === 'Escape') {
refsByKey[activeNode[0]]?.current?.focus();
}
close();
Expand Down
36 changes: 36 additions & 0 deletions apps/preview/next/pages/showcases/useDropdown/UseDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { ShowcasePageLayout } from '../../showcases';
// #region source
import * as React from 'react';
import { useDropdown, SfButton } from '@storefront-ui/react';

export default function Dropdown() {
const [isOpen, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);

const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const toggle = () => setOpen((isOpen) => !isOpen);

const { refs, style } = useDropdown({ isOpen, onClose: close });

return (
<div ref={refs.setReference} className="w-max">
<SfButton ref={triggerRef} onClick={toggle}>
Toggle
</SfButton>
{isOpen && (
<ul ref={refs.setFloating} style={style} className="absolute p-2 w-max rounded-sm bg-gray-100">
<li tabIndex={0}>More</li>
<li tabIndex={0}>About</li>
<li tabIndex={0}>Settings</li>
</ul>
)}
</div>
);
}

// #endregion source
Dropdown.getLayout = ShowcasePageLayout;
18 changes: 13 additions & 5 deletions apps/preview/nuxt/pages/showcases/Dropdown/BasicDropdown.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
<template>
<SfDropdown v-model="isOpen">
<SfDropdown v-model="isOpen" @update:model-value="onToggle">
<template #trigger>
<SfButton @click="toggle()">Toggle</SfButton>
<SfButton ref="triggerRef" @click="toggle()">Toggle</SfButton>
</template>
<ul class="p-2 rounded-sm bg-gray-100">
<li>More</li>
<li>About</li>
<li>Settings</li>
<li tabIndex="0">More</li>
<li tabIndex="0">About</li>
<li tabIndex="0">Settings</li>
</ul>
</SfDropdown>
</template>

<script lang="ts" setup>
import { SfDropdown, useDisclosure, SfButton } from '@storefront-ui/vue';
import { templateRef, unrefElement } from '@vueuse/core';

const { isOpen, toggle } = useDisclosure();
const triggerRef = templateRef('triggerRef');

const onToggle = (isOpen: boolean) => {
if (!isOpen) {
unrefElement(triggerRef.value)?.focus();
}
};
</script>
26 changes: 26 additions & 0 deletions apps/preview/nuxt/pages/showcases/useDropdown/UseDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<template>
<div ref="referenceRef" class="w-max">
<SfButton ref="triggerRef" @click="isOpen = !isOpen">Toggle</SfButton>
<ul v-if="isOpen" ref="floatingRef" :style="style" class="absolute p-2 w-max rounded-sm bg-gray-100">
<li tabIndex="0">More</li>
<li tabIndex="0">About</li>
<li tabIndex="0">Settings</li>
</ul>
</div>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import { useDropdown, SfButton } from '@storefront-ui/vue';
import { templateRef, unrefElement } from '@vueuse/core';

const isOpen = ref(false);
const triggerRef = templateRef('triggerRef');

const close = () => {
isOpen.value = false;
unrefElement(triggerRef.value)?.focus();
};

const { referenceRef, floatingRef, style } = useDropdown({ isOpen, onClose: close });
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { UsePopoverOptions } from '../usePopover';

export type UseDropdownOptions = Prettify<
UsePopoverOptions & {
onClose: (event: KeyboardEvent) => void;
onClose: (event: KeyboardEvent | PointerEvent | MouseEvent | TouchEvent) => void;
onCloseDeps?: unknown[];
}
>;
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ export function useDropdown(options: UseDropdownOptions) {
onCloseDeps,
placement = 'bottom',
middleware = [offset(8), shift(), flip()],
isOpen,
...popoverOptions
} = options;

const { refs, style } = usePopover({ placement, middleware, ...popoverOptions });
const { refs, style } = usePopover({ placement, middleware, isOpen, ...popoverOptions });

useClickAway(refs.reference, onClose);
useClickAway<PointerEvent | MouseEvent | TouchEvent>(refs.reference, (e) => {
if (isOpen) {
onClose?.(e);
}
});
useKey('Escape', onClose, { target: refs.reference.current }, onCloseDeps);

return { refs, style };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export function useDropdown(options: UseDropdownOptions) {
...popoverOptions,
});

onClickOutside(referenceRef as MaybeElementRef, onClose);
onClickOutside(referenceRef as MaybeElementRef, () => {
if (toValue(isOpen)) {
onClose?.();
}
});
onKeyStroke('Escape', onClose, { target: referenceRef as MaybeRefOrGetter<EventTarget | null | undefined> });

return { floatingRef, referenceRef, style };
Expand Down
2 changes: 1 addition & 1 deletion packages/tests/components/SfDropdown/SfDropdown.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('SfDropdown', () => {

describe('When click away', () => {
it('Should call onClose', () => {
const props = { onClose: cy.spy() };
const props = { onClose: cy.spy(), modelValue: ref(true) };
initializeComponent(props);

page().clickAway(props.onClose);
Expand Down
Loading