diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index e6dd1cd2f..1b13e3e41 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -412,7 +412,8 @@ export const rows: Rows = [ }, { component: 'DropdownMenu', - status: '🚧 Planned', + status: '✅ Done', + stage: '🔵 experimental', planned: 'Q3 2026', }, { diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx new file mode 100644 index 000000000..23d841ace --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -0,0 +1,231 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './DropdownMenu.stories'; + + + +# DropdownMenu + + + +A dropdown menu displays a list of actions or options that a user can choose. + +Compared to the [Menu](?path=/docs/components-menu--docs) component, it adds +submenus and search. + +## Import + +```tsx +import { DropdownMenu } from '@koobiq/react-components'; +``` + +## Usage + +A dropdown menu is composed of a trigger and its content: + + + +## Props + + + +## Subcomponents + +The component has the following helper components: + +- `DropdownMenu.Popover` — the overlay, also used for a submenu +- `DropdownMenu.Content` — the list of items inside the popover +- `DropdownMenu.Item` — a single action +- `DropdownMenu.ItemText` — the text of an item, with an optional caption +- `DropdownMenu.ItemAddon` — an addon of an item, such as an icon or a shortcut +- `DropdownMenu.Section` — a group of items +- `DropdownMenu.Header` — a block of custom content, or the heading of a section +- `DropdownMenu.SubmenuTrigger` — wraps an item and the submenu it opens +- `DropdownMenu.Autocomplete` — filters the menu with a field +- `DropdownMenu.Footer` — the text under the items, outside the scroll area +- `DropdownMenu.Pressable` — makes custom markup usable as a trigger + +The popover and the content are separate components, so anything can go between +them. That is how the search field is added. + +## Trigger + +The first child of the `DropdownMenu` is the trigger. A `Button` works as one +without extra props. + +For custom markup that is not a button, wrap it in the `DropdownMenu.Pressable` +(see the [Separators](#separators) example). + +The `trigger` prop switches between opening on press (default) and on a long press: + + + +## Content + +The items can be written out one by one, or passed as data through the `items` +prop. Give every item a unique `id`. When an item is chosen, the `onAction` +handler is called with that id. + + + +## Item content + +An item is composed of slots: the `DropdownMenu.ItemAddon` for icons and +shortcuts, and the `DropdownMenu.ItemText` for the label and its caption. + + + +An item built from slots needs the `textValue` prop, because the search and the +typeahead match on it. Without it the item cannot be found by typing, and it +disappears on the first keystroke in a searchable menu. The component warns +about this in development. + +Add the `aria-label` prop when an item needs a shorter name for screen readers +than all of its slots read together. + +The `align` prop lines the slots up by their top edge instead of centring them. +It is useful for a tall item with a caption. + +## Selection + +The items are not selectable by default. Selection is turned on with the +`selectionMode` prop. Use the `defaultSelectedKeys` prop to set the selected +items (uncontrolled), and the `selectedKeys` prop to control them. Both take the +`id` of the items. + +### Single + + + +### Multiple + + + +## Disabled items + +Items can be marked as disabled with the `disabledKeys` prop of the content, or +with the `isDisabled` prop of a single item. + + + +## Links + +An item with the `href` prop becomes a link. It navigates instead of calling the +`onAction` handler, and it is never selectable. + + + +## Client side routing + +Wrap the app in the `RouterProvider`, and the link items will navigate through +your router, be it **Next.js**, **React Router** or another one. + +```tsx +import { useRouter } from 'next/navigation'; + +import { RouterProvider } from '@koobiq/react-components'; + +export default function App() { + const router = useRouter(); + + return ( + + + + + + Page 1 + Page 2 + + + + + ); +} +``` + +## Sections + +Groups of items can be wrapped in a `DropdownMenu.Section` with the `title` prop. + +### Static items + + + +### Dynamic items + + + +### With Section Level Selection + +Each section takes its own `selectionMode`, `selectedKeys` and `onSelectionChange` +props, so one menu can mix plain actions with a single and a multiple choice. + + + +## Separators + +Put a `Divider` between items or sections to group them without a title. The +`DropdownMenu.Header` renders custom content inside the menu. + + + +## Submenu + +Wrap an item and a nested `DropdownMenu.Popover` in a `DropdownMenu.SubmenuTrigger` +to give the item a submenu. The chevron is added automatically. A submenu opens +on hover after the `delay` prop (200 ms by default), on ArrowRight and +on press, and closes on ArrowLeft. Submenus can be nested. + + + +## Search + +Wrap the content in a `DropdownMenu.Autocomplete` and put a `SearchInput` above +it. The field has to be next to the menu, not inside it. + +Matching ignores the case and the accents. Pass the `filter` prop to change it. +The focus stays in the field while the arrows move through the items. An item +that opens a submenu stays when its own text matches, but the items inside a +submenu are not searched. + + + +The `renderEmptyState` prop of the content replaces the default message, with an +`EmptyState` for instance. + + + +## Footer + +The `DropdownMenu.Footer` shows text under the items. It stays in place while the +items scroll. + + + +## Placement + +The placement of the menu with respect to its trigger can be adjusted using the +`placement` prop of the popover. The menu also flips to the opposite side when +there is not enough space. + + + +## Open + +### Default open + +The menu isn't opened by default. The `defaultOpen` prop can be used to set the +default state. + +### Controlled open + +The `isOpen` prop can be used to make the opened state controlled. The +`onOpenChange` event is fired when the open state of the menu changes. + + diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx new file mode 100644 index 000000000..1040bc4c3 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -0,0 +1,713 @@ +import type { CSSProperties } from 'react'; +import { useState } from 'react'; + +import { useBoolean } from '@koobiq/react-core'; +import { + IconAlignCenter16, + IconAlignLeft16, + IconAlignRight16, + IconArrowRightToBracket16, + IconBell16, + IconFileMultipleO16, + IconDashboard16, + IconGear16, + IconMessage16, + IconPlus16, + IconScissors16, + IconTextBold16, + IconTextItalic16, + IconTextUnderline16, + IconTrash16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import type { Selection } from '../../index'; +import { Button } from '../Button'; +import { Divider } from '../Divider'; +import { EmptyState } from '../EmptyState'; +import { FlexBox } from '../FlexBox'; +import { spacing } from '../layout'; +import { SearchInput } from '../SearchInput'; +import { SelectNext as Select } from '../SelectNext'; +import { Typography } from '../Typography'; + +import avatar from './__stories__/avatar.webp'; +import { DropdownMenu } from './DropdownMenu'; +import type { DropdownMenuProps, DropdownMenuPropPlacement } from './index'; +import { dropdownMenuPropPlacement } from './index'; + +const meta = { + title: 'Components/DropdownMenu', + component: DropdownMenu, + subcomponents: { + 'DropdownMenu.Popover': DropdownMenu.Popover, + 'DropdownMenu.Content': DropdownMenu.Content, + 'DropdownMenu.Item': DropdownMenu.Item, + 'DropdownMenu.ItemText': DropdownMenu.ItemText, + 'DropdownMenu.ItemAddon': DropdownMenu.ItemAddon, + 'DropdownMenu.Section': DropdownMenu.Section, + 'DropdownMenu.Header': DropdownMenu.Header, + 'DropdownMenu.SubmenuTrigger': DropdownMenu.SubmenuTrigger, + 'DropdownMenu.Autocomplete': DropdownMenu.Autocomplete, + 'DropdownMenu.Footer': DropdownMenu.Footer, + }, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-08-06'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Base: Story = { + render: (args) => ( + + + + alert(key)}> + New + Open + Save + Duplicate + Rename + + + + ), +}; + +export const Content: Story = { + render: function Render(args) { + const items = [ + { id: 'new', name: 'New' }, + { id: 'open', name: 'Open' }, + { id: 'save', name: 'Save' }, + { id: 'duplicate', name: 'Duplicate' }, + { id: 'rename', name: 'Rename' }, + ]; + + return ( + + + + alert(key)}> + {(item: (typeof items)[number]) => ( + {item.name} + )} + + + + ); + }, +}; + +export const ItemContent: Story = { + render: (args) => ( + + + + alert(key)}> + + + + + + Copy + + + + ⌘C + + + + + + + + + Cut + + + + ⌘X + + + + + + + + + Delete + + + + ⌫ + + + + + + + ), +}; + +export const SelectionSingle: Story = { + render: function Render(args) { + const [selectedKeys, setSelectedKeys] = useState( + new Set(['medium']) + ); + + return ( + + + + + Small + Medium + Large + + + + ); + }, +}; + +export const SelectionMultiple: Story = { + render: function Render(args) { + const [selectedKeys, setSelectedKeys] = useState( + new Set(['sidebar']) + ); + + return ( + + + + + Sidebar + Search bar + Console + + + + ); + }, +}; + +export const DisabledItems: Story = { + render: (args) => ( + + + + alert(key)} + > + New + Open + + Save + + Rename + + + + ), +}; + +export const Links: Story = { + render: (args) => ( + + + + + + Koobiq React + + + Koobiq + + + GitHub + + + + + ), +}; + +export const Sections: Story = { + render: (args) => ( + + + + alert(key)}> + + New + Open + + + + Copy + Cut + Paste + + + + + ), +}; + +export const SectionsDynamic: Story = { + render: function Render(args) { + // A separator is its own entry: the collection expects one node per item. + const sections = [ + { + id: 'file', + name: 'File', + children: [ + { id: 'new', name: 'New' }, + { id: 'open', name: 'Open' }, + ], + }, + { id: 'file-separator' }, + { + id: 'edit', + name: 'Edit', + children: [ + { id: 'copy', name: 'Copy' }, + { id: 'cut', name: 'Cut' }, + { id: 'paste', name: 'Paste' }, + ], + }, + ]; + + return ( + + + + alert(key)}> + {(section: (typeof sections)[number]) => + 'children' in section ? ( + + {(item: { id: string; name: string }) => ( + {item.name} + )} + + ) : ( + + ) + } + + + + ); + }, +}; + +export const WithSectionLevelSelection: Story = { + render: function Render(args) { + const [style, setStyle] = useState(new Set(['bold', 'italic'])); + const [align, setAlign] = useState(new Set(['left'])); + + return ( + + + + + + + + + + Cut + + + ⌘X + + + + + + + + Copy + + + ⌘C + + + + + + + + + + + Bold + + + + + + Italic + + + + + + Underline + + + + + + + + + Left + + + + + + Center + + + + + + Right + + + + + + ); + }, +}; + +export const Separators: Story = { + render: function Render(args) { + const buttonStyle: CSSProperties = { + width: 48, + height: 48, + borderRadius: '50%', + overflow: 'hidden', + padding: 0, + border: 'none', + background: 'transparent', + cursor: 'pointer', + }; + + const imgStyle: CSSProperties = { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', + }; + + return ( + + +
+ Sophia Bellmont +
+
+ + alert(key)}> + + + + Sophia Bellmont + + @Sophia + + + + + + + + Dashboard + + + + + + Notifications + + + + + + Create team + + + + + + Settings + + + + + + + Contact support + + + + + + + Log out + + + +
+ ); + }, +}; + +export const Submenu: Story = { + render: (args) => ( + + + + alert(key)}> + New + Rename + + Share + + alert(key)}> + Email + SMS + + + Messengers + + + alert(key)}> + + Telegram + + Signal + + + + + + + + Delete + + + + ), +}; + +export const Search: Story = { + render: function Render(args) { + const items = [ + 'Amsterdam', + 'Belgrade', + 'Berlin', + 'Bratislava', + 'Brussels', + 'Bucharest', + 'Budapest', + 'Copenhagen', + 'Dublin', + 'Helsinki', + 'Lisbon', + 'Ljubljana', + 'Madrid', + 'Oslo', + 'Prague', + 'Riga', + 'Rome', + 'Sofia', + 'Stockholm', + 'Vienna', + 'Warsaw', + ]; + + return ( + + + + + + + alert(key)}> + {items.map((item) => ( + + {item} + + ))} + + Other cities + + alert(key)}> + Tallinn + Vilnius + + + + + + + + ); + }, +}; + +export const SearchEmpty: Story = { + render: (args) => ( + + + + + + + ( + + No such action + + )} + > + New + Open + Save + + + + + ), +}; + +export const DropdownFooter: Story = { + render: (args) => ( + + + + + New + Open + Save + + + The text in the footer of the drop-down list. + + + + ), +}; + +export const Placement: Story = { + render: function Render(args) { + const [placement, setPlacement] = + useState('bottom start'); + + return ( + + + + + + + New + Open + Save + + + + + ); + }, +}; + +export const Open: Story = { + render: function Render(args) { + const [isOpen, { toggle, set }] = useBoolean(false); + + return ( + + + + + + + New + Open + Save + + + + + ); + }, +}; + +export const LongPress: Story = { + render: (args) => ( + + + + alert(key)}> + New + Open + Save + + + + ), +}; diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx new file mode 100644 index 000000000..242710753 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -0,0 +1,867 @@ +import { createRef, useState } from 'react'; + +import { once } from '@koobiq/logger'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Button } from '../Button'; +import { Divider } from '../Divider'; +import { SearchInput } from '../SearchInput'; + +import { + DropdownMenu, + dropdownMenuPropTrigger, + type DropdownMenuContentProps, + type DropdownMenuPopoverProps, + type DropdownMenuProps, +} from './index.js'; + +const onAction = vi.fn(); +const onOpenChange = vi.fn(); +const onSelectionChange = vi.fn(); +const onInputChange = vi.fn(); + +const getControl = () => screen.getByTestId('control'); +const getMenu = () => screen.getByRole('menu'); +const queryMenu = () => screen.queryByRole('menu'); +const getItems = () => screen.getAllByRole('menuitem'); + +const open = async () => { + await userEvent.click(getControl()); + await screen.findByRole('menu'); +}; + +type FixtureProps = { + contentProps?: Omit; + popoverProps?: Omit; + rootProps?: Omit; +}; + +function Fixture({ contentProps, popoverProps, rootProps }: FixtureProps = {}) { + return ( + + + + + Copy + Paste + Delete + + + + ); +} + +describe('DropdownMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('rendering', () => { + it('should not render the menu until the trigger is pressed', () => { + render(); + + expect(queryMenu()).not.toBeInTheDocument(); + }); + + it('should open on trigger press and render a menu', async () => { + render(); + await open(); + + expect(getMenu()).toBeInTheDocument(); + }); + + it('should render every item as a menuitem', async () => { + render(); + await open(); + + expect(getItems()).toHaveLength(3); + expect(getItems()[0]).toHaveTextContent('Copy'); + }); + + it('should forward a ref to the popover element', async () => { + const ref = createRef(); + + render(); + await open(); + + expect(ref.current).toBeInstanceOf(HTMLElement); + expect(ref.current).toContainElement(getMenu()); + }); + + it('should merge a custom className into the popover', async () => { + render(); + await open(); + + expect(screen.getByTestId('popover')).toHaveClass('custom'); + }); + + it('should apply data-testid to the popover', async () => { + render(); + await open(); + + expect(screen.getByTestId('popover')).toBeInTheDocument(); + }); + + it('should warn when the popover is rendered on its own', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + once.clear(); + + render( + + + Copy + + + ); + + expect(queryMenu()).not.toBeInTheDocument(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('DropdownMenu.Popover') + ); + }); + }); + + describe('open state', () => { + it('should be open by default with defaultOpen', async () => { + render(); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + }); + + it('should respect the controlled isOpen prop', async () => { + render(); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + }); + + it('should stay closed when isOpen is false', async () => { + render(); + await userEvent.click(getControl()); + + expect(queryMenu()).not.toBeInTheDocument(); + }); + + it('should call onOpenChange when opening and closing', async () => { + render(); + await open(); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + await userEvent.keyboard('{Escape}'); + + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + + it('should close on Escape', async () => { + render(); + await open(); + await userEvent.keyboard('{Escape}'); + + await waitFor(() => expect(queryMenu()).not.toBeInTheDocument()); + }); + + it('should close on an outside press', async () => { + render( + <> + + + + ); + + await open(); + await userEvent.click(screen.getByTestId('outside')); + + await waitFor(() => expect(queryMenu()).not.toBeInTheDocument()); + }); + + it.each(dropdownMenuPropTrigger)( + 'should open with the "%s" trigger', + async (trigger) => { + // jsdom has no PointerEvent and the long press behaviour dispatches + // one. Stub it only here: with it defined, `usePress` switches to the + // pointer event path for every interaction in the test. + if (trigger === 'longPress' && !('PointerEvent' in window)) { + vi.stubGlobal('PointerEvent', class extends Event {}); + } + + render(); + + if (trigger === 'longPress') { + await userEvent.pointer({ keys: '[TouchA>]', target: getControl() }); + + // The long press opens the menu on a timer, so React needs to flush + // the resulting render before the pointer is released. + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 600); + }); + }); + + await userEvent.pointer({ keys: '[/TouchA]', target: getControl() }); + } else { + await userEvent.click(getControl()); + } + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + } + ); + }); + + describe('keyboard navigation', () => { + it('should focus the first item on ArrowDown from the trigger', async () => { + render(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(getItems()[0]).toHaveFocus(); + }); + + it('should focus the last item on ArrowUp from the trigger', async () => { + render(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowUp}'); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(getItems()[2]).toHaveFocus(); + }); + + it('should move focus between items with the arrow keys', async () => { + render(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}{ArrowDown}'); + + expect(getItems()[1]).toHaveFocus(); + }); + + it('should skip disabled items during arrow navigation', async () => { + render(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}{ArrowDown}'); + + expect(getItems()[2]).toHaveFocus(); + }); + + it('should select the focused item on Enter and close the menu', async () => { + render(); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}{Enter}'); + + expect(onAction).toHaveBeenCalledWith('copy'); + await waitFor(() => expect(queryMenu()).not.toBeInTheDocument()); + }); + + it('should support typeahead using textValue', async () => { + render(); + await open(); + await userEvent.keyboard('pas'); + + expect(getItems()[1]).toHaveFocus(); + }); + }); + + describe('actions and selection', () => { + it('should call onAction with the item id', async () => { + render(); + await open(); + await userEvent.click(getItems()[1]!); + + expect(onAction).toHaveBeenCalledWith('paste'); + }); + + it('should not call onAction for an item in disabledKeys', async () => { + render(); + await open(); + await userEvent.click(getItems()[1]!); + + expect(onAction).not.toHaveBeenCalled(); + }); + + it('should not call onAction for an item with isDisabled', async () => { + render( + + + + + + Copy + + + + + ); + + await open(); + await userEvent.click(getItems()[0]!); + + expect(onAction).not.toHaveBeenCalled(); + }); + + it('should support single selection', async () => { + render( + + ); + + await open(); + + await userEvent.click( + screen.getByRole('menuitemradio', { name: 'Paste' }) + ); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect([...onSelectionChange.mock.calls[0]![0]]).toStrictEqual(['paste']); + }); + + it('should keep the menu open in multiple selection mode', async () => { + render( + + ); + + await open(); + + await userEvent.click( + screen.getByRole('menuitemcheckbox', { name: 'Copy' }) + ); + + await userEvent.click( + screen.getByRole('menuitemcheckbox', { name: 'Paste' }) + ); + + expect(getMenu()).toBeInTheDocument(); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + }); + + it('should mark the selected item with aria-checked', async () => { + render( + + ); + + await open(); + + expect( + screen.getByRole('menuitemradio', { name: 'Paste' }) + ).toBeChecked(); + }); + + it('should not close on select when shouldCloseOnSelect is false', async () => { + render(); + await open(); + await userEvent.click(getItems()[0]!); + + expect(onAction).toHaveBeenCalledWith('copy'); + expect(getMenu()).toBeInTheDocument(); + }); + + it('should render items with href as links', async () => { + render( + + + + + + Koobiq + + + + + ); + + await open(); + + expect(getItems()[0]).toHaveAttribute('href', 'https://koobiq.io'); + }); + }); + + describe('sections, headers and dividers', () => { + const renderSections = () => + render( + + + + + + Copy + + + + Danger + Delete + + + + + ); + + it('should render a section labelled by its title', async () => { + renderSections(); + await open(); + + const group = screen.getByRole('group', { name: 'Edit' }); + + expect(within(group).getByRole('menuitem')).toHaveTextContent('Copy'); + }); + + it('should use a custom Header as the group heading', async () => { + renderSections(); + await open(); + + expect(screen.getByRole('group', { name: 'Danger' })).toBeInTheDocument(); + }); + + it('should render a divider', async () => { + renderSections(); + await open(); + + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('should keep a selection mode per section', async () => { + render( + + + + + + Cut + + + Bold + Italic + + + Left + + + + + ); + + await open(); + + expect(screen.getByRole('menuitem')).toHaveTextContent('Cut'); + expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2); + expect(screen.getByRole('menuitemradio')).toHaveTextContent('Left'); + + await userEvent.click(screen.getAllByRole('menuitemcheckbox')[0]); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect([...onSelectionChange.mock.calls[0]![0]]).toStrictEqual(['bold']); + }); + }); + + describe('item content', () => { + const renderComposed = (textValue?: string) => + render( + + + + + + + + + + + Copy + + ⌘C + + + Cut + + + + + ); + + it('should render addons and text inside an item', async () => { + renderComposed('Copy'); + await open(); + + expect(screen.getByTestId('start-addon')).toBeInTheDocument(); + expect(getItems()[0]).toHaveTextContent('Copy to clipboard'); + expect(getItems()[0]).toHaveTextContent('⌘C'); + }); + + it('should support typeahead through an explicit textValue', async () => { + renderComposed('Duplicate'); + await open(); + await userEvent.keyboard('du'); + + expect(getItems()[0]).toHaveFocus(); + }); + + it('should warn when composed content comes without a textValue', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + once.clear(); + renderComposed(); + await open(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('DropdownMenu.Item') + ); + }); + + it('should not warn when a textValue is given', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + once.clear(); + renderComposed('Copy'); + await open(); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('submenu', () => { + const renderSubmenu = () => + render( + + + + + Copy + + Share + + + Email + SMS + + + + + + + ); + + it('should mark the trigger item as having a submenu', async () => { + renderSubmenu(); + await open(); + + const trigger = screen.getByRole('menuitem', { name: /Share/ }); + + expect(trigger).toHaveAttribute('aria-haspopup', 'menu'); + expect(trigger).toHaveAttribute('data-has-submenu', 'true'); + }); + + // Each key needs its own call: sent in one batch, ArrowRight lands before + // focus has settled on the submenu trigger. + const focusSubmenuTrigger = async () => { + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowDown}'); + }; + + const openSubmenu = async () => { + await focusSubmenuTrigger(); + await userEvent.keyboard('{ArrowRight}'); + await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2)); + }; + + it('should open the submenu on ArrowRight and focus its first item', async () => { + renderSubmenu(); + await open(); + await openSubmenu(); + + expect(screen.getByRole('menuitem', { name: 'Email' })).toHaveFocus(); + }); + + it('should close the submenu on ArrowLeft and keep the menu open', async () => { + renderSubmenu(); + await open(); + await openSubmenu(); + + await userEvent.keyboard('{ArrowLeft}'); + + await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(1)); + expect(screen.getByRole('menuitem', { name: /Share/ })).toHaveFocus(); + }); + + it('should open the submenu on hover after the delay', async () => { + renderSubmenu(); + await open(); + await userEvent.hover(screen.getByRole('menuitem', { name: /Share/ })); + + await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), { + timeout: 2000, + }); + }); + + it('should call onAction and close both menus on a submenu selection', async () => { + renderSubmenu(); + await open(); + await openSubmenu(); + + await userEvent.keyboard('{Enter}'); + + expect(onAction).toHaveBeenCalledWith('email'); + await waitFor(() => expect(queryMenu()).not.toBeInTheDocument()); + }); + }); + + describe('search', () => { + const renderSearchable = (menuProps?: Record) => + render( + + + + + + + Copy + Paste + + Share + + + Email + + + + + + + + ); + + const getSearch = () => screen.getByRole('searchbox'); + + // `userEvent.keyboard` dispatches events inside a synchronous `act`, but + // clearing the query suspends while React Aria rebuilds the collection. + const pressEscape = async () => { + await act(async () => { + getSearch().dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'Escape', + key: 'Escape', + }) + ); + + // Keep the scope asynchronous so `act` waits for that suspended render. + await Promise.resolve(); + }); + }; + + it('should not render a search input on its own', async () => { + render(); + await open(); + + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + }); + + it('should render the search input inside an Autocomplete', async () => { + renderSearchable(); + await open(); + + expect(getSearch()).toBeInTheDocument(); + }); + + it('should filter items by the query', async () => { + renderSearchable(); + await open(); + await userEvent.type(getSearch(), 'pas'); + + await waitFor(() => expect(getItems()).toHaveLength(1)); + expect(getItems()[0]).toHaveTextContent('Paste'); + }); + + it('should ignore case and match diacritics loosely', async () => { + renderSearchable(); + await open(); + await userEvent.type(getSearch(), 'COPY'); + + await waitFor(() => expect(getItems()).toHaveLength(1)); + expect(getItems()[0]).toHaveTextContent('Copy'); + }); + + it('should keep a submenu trigger whose text matches the query', async () => { + renderSearchable(); + await open(); + await userEvent.type(getSearch(), 'sha'); + + await waitFor(() => expect(getItems()).toHaveLength(1)); + expect(getItems()[0]).toHaveAttribute('data-has-submenu', 'true'); + }); + + it('should show the empty state when nothing matches', async () => { + renderSearchable(); + await open(); + await userEvent.type(getSearch(), 'zzz'); + + expect(await screen.findByText('Nothing found')).toBeInTheDocument(); + }); + + it('should render a custom empty state', async () => { + renderSearchable({ renderEmptyState: () =>
No results
}); + await open(); + await userEvent.type(getSearch(), 'zzz'); + + expect(await screen.findByText('No results')).toBeInTheDocument(); + }); + + it('should keep DOM focus in the search input while navigating', async () => { + renderSearchable(); + await open(); + await userEvent.keyboard('{ArrowDown}'); + + expect(getSearch()).toHaveFocus(); + + await waitFor(() => + expect(getSearch()).toHaveAttribute('aria-activedescendant') + ); + }); + + it('should clear the query on Escape before closing the menu', async () => { + renderSearchable(); + await open(); + await userEvent.type(getSearch(), 'pas'); + await waitFor(() => expect(getItems()).toHaveLength(1)); + + await pressEscape(); + await waitFor(() => expect(getItems()).toHaveLength(3)); + + expect(getSearch()).toHaveValue(''); + expect(getMenu()).toBeInTheDocument(); + }); + + it('should reset the query after the menu is reopened', async () => { + renderSearchable(); + await open(); + await userEvent.type(getSearch(), 'pas'); + await waitFor(() => expect(getItems()).toHaveLength(1)); + + // The first Escape clears the query, the second closes the menu. Waiting + // between them lets the rebuilt collection settle inside `act`. + await pressEscape(); + await waitFor(() => expect(getItems()).toHaveLength(3)); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(queryMenu()).not.toBeInTheDocument()); + await open(); + + expect(getSearch()).toHaveValue(''); + expect(getItems()).toHaveLength(3); + }); + + it('should support a controlled inputValue', async () => { + function Controlled() { + const [value, setValue] = useState('pas'); + + return ( + + + + { + onInputChange(next); + setValue(next); + }} + > + + + Copy + Paste + + + + + ); + } + + render(); + await screen.findByRole('menu'); + + expect(getSearch()).toHaveValue('pas'); + expect(getItems()).toHaveLength(1); + + await userEvent.clear(getSearch()); + + expect(onInputChange).toHaveBeenCalled(); + await waitFor(() => expect(getItems()).toHaveLength(2)); + }); + + it('should pass props to the search input', async () => { + render( + + + + + + + Copy + + + + + ); + + await open(); + + expect(getSearch()).toHaveAttribute('placeholder', 'Find an action'); + }); + }); + + describe('footer', () => { + it('should render the footer content', async () => { + render( + + + + + Copy + + Footer text + + + ); + + await open(); + + expect(screen.getByText('Footer text')).toBeInTheDocument(); + }); + }); + + describe('placement', () => { + it('should reflect the placement on the popover', async () => { + render(); + await open(); + + expect(screen.getByTestId('popover')).toHaveAttribute( + 'data-placement', + 'top' + ); + }); + }); +}); diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 000000000..09f52179f --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Pressable } from '@koobiq/react-core'; +import { MenuTrigger as AriaMenuTrigger } from '@koobiq/react-primitives'; + +import { ListItemText } from '../List'; +import { ListItemAddon } from '../List/components'; + +import { + DropdownMenuAutocomplete, + DropdownMenuContent, + DropdownMenuFooter, + DropdownMenuHeader, + DropdownMenuItem, + DropdownMenuPopover, + DropdownMenuSection, + DropdownMenuSubmenuTrigger, +} from './components'; +import type { DropdownMenuComponent, DropdownMenuProps } from './types'; + +function DropdownMenuComponentRender(props: DropdownMenuProps) { + return ; +} + +DropdownMenuComponentRender.displayName = 'DropdownMenu'; + +type CompoundedComponent = DropdownMenuComponent & { + Popover: typeof DropdownMenuPopover; + Content: typeof DropdownMenuContent; + Item: typeof DropdownMenuItem; + ItemText: typeof ListItemText; + ItemAddon: typeof ListItemAddon; + Section: typeof DropdownMenuSection; + Header: typeof DropdownMenuHeader; + SubmenuTrigger: typeof DropdownMenuSubmenuTrigger; + Autocomplete: typeof DropdownMenuAutocomplete; + Footer: typeof DropdownMenuFooter; + Pressable: typeof Pressable; +}; + +/** + * A dropdown menu displays a list of actions or options that a user can choose. + * The first child is the trigger, the rest is the popover. + */ +export const DropdownMenu: CompoundedComponent = Object.assign( + DropdownMenuComponentRender, + { + Popover: DropdownMenuPopover, + Content: DropdownMenuContent, + Item: DropdownMenuItem, + ItemText: ListItemText, + ItemAddon: ListItemAddon, + Section: DropdownMenuSection, + Header: DropdownMenuHeader, + SubmenuTrigger: DropdownMenuSubmenuTrigger, + Autocomplete: DropdownMenuAutocomplete, + Footer: DropdownMenuFooter, + Pressable, + } +); diff --git a/packages/components/src/components/DropdownMenu/__stories__/avatar.webp b/packages/components/src/components/DropdownMenu/__stories__/avatar.webp new file mode 100644 index 000000000..b59c98eb6 Binary files /dev/null and b/packages/components/src/components/DropdownMenu/__stories__/avatar.webp differ diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.module.css new file mode 100644 index 000000000..ae3aec475 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.module.css @@ -0,0 +1,5 @@ +.search { + box-sizing: border-box; + padding-block: var(--kbq-size-xxs); + padding-inline: var(--kbq-size-3xs); +} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx new file mode 100644 index 000000000..39dc23fee --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useFilter, useLocalizedStringFormatter } from '@koobiq/react-core'; +import { + Autocomplete as AriaAutocomplete, + Provider, +} from '@koobiq/react-primitives'; + +import type { SearchInputProps } from '../../../SearchInput'; +import { SearchInputContext } from '../../../SearchInput/SearchInputContext'; +import intlMessages from '../../intl'; + +import s from './DropdownMenuAutocomplete.module.css'; +import type { DropdownMenuAutocompleteProps } from './types'; + +/** + * Filters the items of a dropdown menu with a field. The field has to be a + * sibling of the menu, not a child of it: the menu clears the field context for + * its own subtree. The field may also live outside the popover, next to the + * whole `DropdownMenu`. + */ +export function DropdownMenuAutocomplete(props: DropdownMenuAutocompleteProps) { + const { contains } = useFilter({ sensitivity: 'base' }); + const t = useLocalizedStringFormatter(intlMessages); + + const searchInputProps: SearchInputProps = { + autoFocus: true, + fullWidth: true, + isLabelHidden: true, + className: s.search, + variant: 'transparent', + placeholder: t.format('search'), + 'aria-label': t.format('search'), + }; + + return ( + + + + ); +} + +DropdownMenuAutocomplete.displayName = 'DropdownMenu.Autocomplete'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/index.ts new file mode 100644 index 000000000..f447b7bb9 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuAutocomplete'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/types.ts new file mode 100644 index 000000000..450d1777b --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/types.ts @@ -0,0 +1,3 @@ +import type { AutocompleteProps as AriaAutocompleteProps } from '@koobiq/react-primitives'; + +export type DropdownMenuAutocompleteProps = AriaAutocompleteProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css new file mode 100644 index 000000000..8ca61fed7 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css @@ -0,0 +1,13 @@ +.base { + inline-size: 100%; + scrollbar-width: auto; + scrollbar-gutter: auto; + scrollbar-color: var(--kbq-scrollbar-thumb-default-background) + var(--kbq-background-transparent); +} + +.empty { + color: var(--kbq-foreground-contrast-secondary); + padding-block: var(--kbq-size-xs); + padding-inline: var(--kbq-size-m); +} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx new file mode 100644 index 000000000..c4850436a --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useContext } from 'react'; + +import { clsx, useLocalizedStringFormatter } from '@koobiq/react-core'; +import { + Menu as AriaMenu, + composeRenderProps, + AutocompleteStateContext, +} from '@koobiq/react-primitives'; + +import { utilClasses } from '../../../../styles/utility'; +import intlMessages from '../../intl'; + +import s from './DropdownMenuContent.module.css'; +import type { DropdownMenuContentProps } from './types'; + +const textVariant = utilClasses.typography; +const { list } = utilClasses; + +/** The list of items of a dropdown menu. */ +export function DropdownMenuContent({ + className, + renderEmptyState, + ...props +}: DropdownMenuContentProps) { + const t = useLocalizedStringFormatter(intlMessages); + + // Set once the menu is wrapped in a `DropdownMenu.Autocomplete`. + const autocomplete = useContext(AutocompleteStateContext); + + const query = autocomplete?.inputValue.trim(); + + return ( + + data-padded + renderEmptyState={ + renderEmptyState ?? + (() => ( +
+ {t.format(query ? 'nothing found' : 'empty items')} +
+ )) + } + className={composeRenderProps(className, (className) => + clsx(s.base, list, className) + )} + {...props} + /> + ); +} + +DropdownMenuContent.displayName = 'DropdownMenu.Content'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/index.ts new file mode 100644 index 000000000..21838dd3f --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuContent'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts new file mode 100644 index 000000000..bc67f545a --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts @@ -0,0 +1,8 @@ +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { MenuProps as AriaMenuProps } from '@koobiq/react-primitives'; + +export type DropdownMenuContentProps = Omit< + AriaMenuProps, + 'slot' +> & + DataAttributeProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/DropdownMenuFooter.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/DropdownMenuFooter.tsx new file mode 100644 index 000000000..4e2361e2b --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/DropdownMenuFooter.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { DropdownFooter } from '../../../DropdownFooter'; + +import type { DropdownMenuFooterProps } from './types'; + +/** Text under the items of a dropdown menu. Stays put while the menu scrolls. */ +export function DropdownMenuFooter(props: DropdownMenuFooterProps) { + return ; +} + +DropdownMenuFooter.displayName = 'DropdownMenu.Footer'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/index.ts new file mode 100644 index 000000000..c58759ae2 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuFooter'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/types.ts new file mode 100644 index 000000000..c56c84d0b --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/types.ts @@ -0,0 +1,3 @@ +import type { DropdownFooterProps } from '../../../DropdownFooter'; + +export type DropdownMenuFooterProps = DropdownFooterProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/DropdownMenuHeader.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/DropdownMenuHeader.tsx new file mode 100644 index 000000000..435c3b055 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/DropdownMenuHeader.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Header as AriaHeader } from '@koobiq/react-primitives'; + +import type { DropdownMenuHeaderProps } from './types'; + +/** + * A block of custom content inside a dropdown menu. + * Inside a section it acts as the heading of that group. + */ +export function DropdownMenuHeader(props: DropdownMenuHeaderProps) { + return ; +} + +DropdownMenuHeader.displayName = 'DropdownMenu.Header'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/index.ts new file mode 100644 index 000000000..44a4d5cc6 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuHeader'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/types.ts new file mode 100644 index 000000000..a71258b1e --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/types.ts @@ -0,0 +1,3 @@ +import type { HeaderProps as AriaHeaderProps } from '@koobiq/react-primitives'; + +export type DropdownMenuHeaderProps = AriaHeaderProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css new file mode 100644 index 000000000..1d2b1e498 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css @@ -0,0 +1,12 @@ +.base { + /* Keeps the chevron at the end for items without `ItemText`. */ + .chevron { + margin-inline-start: auto; + color: var(--kbq-icon-contrast-fade); + } + + /* Stay highlighted while the submenu is open. */ + &:where([data-open]) { + --list-item-bg-color: var(--kbq-states-background-transparent-hover); + } +} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx new file mode 100644 index 000000000..0a88713f2 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { once } from '@koobiq/logger'; +import { clsx } from '@koobiq/react-core'; +import { IconChevronRightS16 } from '@koobiq/react-icons'; +import { + MenuItem as AriaMenuItem, + composeRenderProps, +} from '@koobiq/react-primitives'; + +import { utilClasses } from '../../../../styles/utility'; +import { ListItemAddon } from '../../../List/components'; + +import s from './DropdownMenuItem.module.css'; +import type { DropdownMenuItemProps } from './types'; + +const textVariant = utilClasses.typography; +const { listItem } = utilClasses; + +/** An individual action inside a dropdown menu. */ +export function DropdownMenuItem({ + children, + className, + textValue, + align = 'center', + ...props +}: DropdownMenuItemProps) { + if ( + process.env.NODE_ENV !== 'production' && + !textValue && + !props['aria-label'] && + typeof children !== 'string' + ) { + once.warn( + 'DropdownMenu.Item: add a `textValue` prop when the content is not plain text, otherwise search and typeahead cannot match the item.' + ); + } + + return ( + + clsx(s.base, listItem, textVariant['text-normal'], className) + )} + > + {composeRenderProps(children, (children, { hasSubmenu }) => ( + <> + {children} + {hasSubmenu && ( + + + + )} + + ))} + + ); +} + +DropdownMenuItem.displayName = 'DropdownMenu.Item'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/index.ts new file mode 100644 index 000000000..0d54977f5 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuItem'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts new file mode 100644 index 000000000..4bb0ffd51 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts @@ -0,0 +1,17 @@ +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { MenuItemProps as AriaMenuItemProps } from '@koobiq/react-primitives'; + +export const dropdownMenuItemPropAlign = ['start', 'center'] as const; + +export type DropdownMenuItemPropAlign = + (typeof dropdownMenuItemPropAlign)[number]; + +export type DropdownMenuItemProps = + AriaMenuItemProps & + DataAttributeProps & { + /** + * Vertical alignment of the item content. + * @default 'center' + */ + align?: DropdownMenuItemPropAlign; + }; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css new file mode 100644 index 000000000..f80deeb90 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css @@ -0,0 +1,11 @@ +.base { + --kbq-popover-border-radius: var(--kbq-size-s); + + min-inline-size: 200px; + max-inline-size: 640px; +} + +.container { + overflow: hidden; + flex-direction: column; +} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx new file mode 100644 index 000000000..d33526596 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useContext } from 'react'; + +import { once } from '@koobiq/logger'; +import { clsx, mergeProps } from '@koobiq/react-core'; +import { + PopoverContext, + useSlottedContext, + OverlayTriggerStateContext, +} from '@koobiq/react-primitives'; + +import type { + PopoverProps, + PopoverInnerProps, + PopoverPropPlacement, +} from '../../../Popover'; +import { PopoverInner } from '../../../Popover/PopoverInner'; + +import s from './DropdownMenuPopover.module.css'; +import type { DropdownMenuPopoverProps } from './types'; + +/** + * The overlay of a dropdown menu. Holds the menu and anything next to it, + * such as a search field or a footer. Backs both the menu and any submenu. + */ +export function DropdownMenuPopover(props: DropdownMenuPopoverProps) { + const { + ref, + style, + children, + className, + offset, + placement, + anchorRef, + shouldFlip, + isNonModal, + crossOffset, + containerPadding = 12, + maxBlockSize = 480, + 'data-testid': testId, + slotProps, + } = props; + + // The trigger hands its popover the open state and the element to anchor to + // through context. + const state = useContext(OverlayTriggerStateContext); + const context = useSlottedContext(PopoverContext) ?? {}; + + if (!state) { + if (process.env.NODE_ENV !== 'production') { + once.warn( + 'DropdownMenu.Popover: render it inside a `DropdownMenu` or a `DropdownMenu.SubmenuTrigger`, they are what gives the popover its open state.' + ); + } + + return null; + } + + const isSubmenu = context.trigger === 'SubmenuTrigger'; + + const defaultPlacement: PopoverPropPlacement = isSubmenu + ? 'end top' + : 'bottom start'; + + // A submenu sits flush against its menu, a menu keeps a gap from its trigger. + const defaultOffset = isSubmenu ? -4 : 4; + + const popoverProps = mergeProps< + [PopoverInnerProps, PopoverProps | undefined] + >( + { + state, + shouldFlip, + // Marks the overlay as a submenu for React Aria. + trigger: context.trigger, + crossOffset, + maxBlockSize, + containerPadding, + hideArrow: true, + hideCloseButton: true, + // The menu is sized by its items, between the bounds in the CSS. + size: 'auto', + // Skips the dialog wrapper: the menu brings its own role and label. + type: 'menu', + popoverRef: ref, + 'data-testid': testId, + style: { ...context.style, ...style }, + className: clsx(s.base, className), + slotProps: { container: { className: s.container } }, + anchorRef: anchorRef ?? context.triggerRef, + isNonModal: isNonModal ?? context.isNonModal, + shouldCloseOnInteractOutside: context.shouldCloseOnInteractOutside, + placement: placement ?? defaultPlacement, + offset: offset ?? defaultOffset, + }, + slotProps?.popover + ); + + return {children}; +} + +DropdownMenuPopover.displayName = 'DropdownMenu.Popover'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/index.ts new file mode 100644 index 000000000..47d918f7d --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuPopover'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts new file mode 100644 index 000000000..a73bf2ed8 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts @@ -0,0 +1,53 @@ +import type { CSSProperties, ReactNode, Ref, RefObject } from 'react'; + +import type { PopoverProps } from '../../../Popover'; +import type { DropdownMenuPropPlacement } from '../../types'; + +export type DropdownMenuPopoverRef = HTMLDivElement; + +export type DropdownMenuPopoverProps = { + /** The content of the popover, usually a `DropdownMenu.Menu`. */ + children?: ReactNode; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; + /** Ref to the popover element. */ + ref?: Ref; + + /** + * The placement of the popover with respect to its trigger. + * @default 'bottom start' for a menu, 'end top' for a submenu + */ + placement?: DropdownMenuPropPlacement; + /** + * The additional offset along the main axis between the popover and its trigger. + * @default 4 for a menu, -4 for a submenu + */ + offset?: number; + /** The additional offset along the cross axis between the popover and its trigger. */ + crossOffset?: number; + /** + * The padding that should be applied between the popover and its surrounding container. + * @default 12 + */ + containerPadding?: number; + /** Whether the popover should flip when it reaches the viewport boundary. */ + shouldFlip?: boolean; + /** + * The maximum block size of the popover. + * @default 480 + */ + maxBlockSize?: number; + /** Whether the popover should not block interaction with the rest of the page. */ + isNonModal?: boolean; + /** The ref for the element which the popover positions itself with respect to. */ + anchorRef?: RefObject; + + /** The props used for each slot inside. */ + slotProps?: { + popover?: PopoverProps; + }; +}; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/DropdownMenuSection.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/DropdownMenuSection.tsx new file mode 100644 index 000000000..4ea9ea43c --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/DropdownMenuSection.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { clsx } from '@koobiq/react-core'; +import { + Collection, + Header as AriaHeader, + MenuSection as AriaMenuSection, +} from '@koobiq/react-primitives'; + +import { utilClasses } from '../../../../styles/utility'; + +import type { DropdownMenuSectionProps } from './types'; + +const textVariant = utilClasses.typography; +const { listHeading, color } = utilClasses; + +/** A group of related items inside a dropdown menu. */ +export function DropdownMenuSection({ + title, + items, + children, + className, + dependencies, + ...props +}: DropdownMenuSectionProps) { + return ( + + {title != null && ( + + {title} + + )} + {typeof children === 'function' ? ( + + {children} + + ) : ( + children + )} + + ); +} + +DropdownMenuSection.displayName = 'DropdownMenu.Section'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/index.ts new file mode 100644 index 000000000..10fc0ad5f --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuSection'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/types.ts new file mode 100644 index 000000000..8542da9a2 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSection/types.ts @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { MenuSectionProps as AriaMenuSectionProps } from '@koobiq/react-primitives'; + +export type DropdownMenuSectionProps = + AriaMenuSectionProps & + DataAttributeProps & { + /** The heading of the section. */ + title?: ReactNode; + }; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/DropdownMenuSubmenuTrigger.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/DropdownMenuSubmenuTrigger.tsx new file mode 100644 index 000000000..980d81d73 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/DropdownMenuSubmenuTrigger.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { SubmenuTrigger as AriaSubmenuTrigger } from '@koobiq/react-primitives'; + +import type { DropdownMenuSubmenuTriggerProps } from './types'; + +/** + * A wrapper around the item that opens a submenu and the submenu itself. + * The item gets its chevron automatically. + */ +export function DropdownMenuSubmenuTrigger( + props: DropdownMenuSubmenuTriggerProps +) { + return ; +} + +DropdownMenuSubmenuTrigger.displayName = 'DropdownMenu.SubmenuTrigger'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/index.ts new file mode 100644 index 000000000..133cda745 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuSubmenuTrigger'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/types.ts new file mode 100644 index 000000000..1042000b2 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/types.ts @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react'; + +export type DropdownMenuSubmenuTriggerProps = { + /** + * The item that opens the submenu, followed by the submenu itself + * (a `DropdownMenu.Content`). + */ + children: ReactElement[]; + /** + * The delay in milliseconds before the submenu opens on hover. + * @default 200 + */ + delay?: number; +}; diff --git a/packages/components/src/components/DropdownMenu/components/index.ts b/packages/components/src/components/DropdownMenu/components/index.ts new file mode 100644 index 000000000..7c8ee41e1 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/index.ts @@ -0,0 +1,8 @@ +export * from './DropdownMenuAutocomplete'; +export * from './DropdownMenuContent'; +export * from './DropdownMenuFooter'; +export * from './DropdownMenuHeader'; +export * from './DropdownMenuItem'; +export * from './DropdownMenuPopover'; +export * from './DropdownMenuSection'; +export * from './DropdownMenuSubmenuTrigger'; diff --git a/packages/components/src/components/DropdownMenu/index.ts b/packages/components/src/components/DropdownMenu/index.ts new file mode 100644 index 000000000..b032a3828 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/index.ts @@ -0,0 +1,3 @@ +export * from './DropdownMenu'; +export * from './types'; +export * from './components'; diff --git a/packages/components/src/components/DropdownMenu/intl.ts b/packages/components/src/components/DropdownMenu/intl.ts new file mode 100644 index 000000000..a8fcad20d --- /dev/null +++ b/packages/components/src/components/DropdownMenu/intl.ts @@ -0,0 +1,12 @@ +export default { + 'ru-RU': { + 'empty items': 'Нет вариантов выбора', + 'nothing found': 'Ничего не найдено', + search: 'Поиск', + }, + 'en-US': { + 'empty items': 'No options available', + 'nothing found': 'Nothing found', + search: 'Search', + }, +} as unknown as Record>; diff --git a/packages/components/src/components/DropdownMenu/types.ts b/packages/components/src/components/DropdownMenu/types.ts new file mode 100644 index 000000000..f5ddd1e83 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/types.ts @@ -0,0 +1,40 @@ +import type { ComponentProps, ReactElement, ReactNode } from 'react'; + +import type { Pressable } from '@koobiq/react-core'; + +import type { ListItemTextProps } from '../List'; +import type { ListItemAddonProps } from '../List/components'; +import type { PopoverPropPlacement } from '../Popover'; +import { popoverPropPlacement } from '../Popover'; + +export const dropdownMenuPropTrigger = ['press', 'longPress'] as const; + +export type DropdownMenuPropTrigger = (typeof dropdownMenuPropTrigger)[number]; + +export const dropdownMenuPropPlacement = popoverPropPlacement; + +export type DropdownMenuPropPlacement = PopoverPropPlacement; + +export type DropdownMenuItemTextProps = ListItemTextProps; +export type DropdownMenuItemAddonProps = ListItemAddonProps; +export type DropdownMenuPressableProps = ComponentProps; + +export type DropdownMenuProps = { + /** The trigger and the content of the menu. */ + children: ReactNode; + /** Whether the menu is open (controlled). */ + isOpen?: boolean; + /** Whether the menu is open by default (uncontrolled). */ + defaultOpen?: boolean; + /** Handler that is called when the open state of the menu changes. */ + onOpenChange?: (isOpen: boolean) => void; + /** + * How the menu is opened by the trigger. + * @default 'press' + */ + trigger?: DropdownMenuPropTrigger; +}; + +export type DropdownMenuComponent = ( + props: DropdownMenuProps +) => ReactElement | null; diff --git a/packages/components/src/components/Input/Input.tsx b/packages/components/src/components/Input/Input.tsx index a65053531..a2ea53add 100644 --- a/packages/components/src/components/Input/Input.tsx +++ b/packages/components/src/components/Input/Input.tsx @@ -110,7 +110,7 @@ export const Input = forwardRef((props, ref) => { ); return ( - + {({ isInvalid, isRequired, isDisabled, state }) => { const hasValue = state.value !== ''; const clearButtonIsHidden = !hasValue || isDisabled || isReadOnly; diff --git a/packages/components/src/components/Popover/Popover.module.css b/packages/components/src/components/Popover/Popover.module.css index 61d26206b..9a2231408 100644 --- a/packages/components/src/components/Popover/Popover.module.css +++ b/packages/components/src/components/Popover/Popover.module.css @@ -1,8 +1,9 @@ .base { --popover-inline-size: ; + --popover-border-radius: var(--kbq-size-m); display: flex; - border-radius: var(--kbq-size-m); + border-radius: var(--kbq-popover-border-radius, var(--popover-border-radius)); box-shadow: var(--kbq-shadow-overlay); background-color: var(--kbq-background-bg); inline-size: var(--popover-inline-size); diff --git a/packages/components/src/components/Popover/PopoverGroupContext.ts b/packages/components/src/components/Popover/PopoverGroupContext.ts new file mode 100644 index 000000000..1034d2549 --- /dev/null +++ b/packages/components/src/components/Popover/PopoverGroupContext.ts @@ -0,0 +1,12 @@ +'use client'; + +import type { RefObject } from 'react'; +import { createContext } from 'react'; + +/** + * The container of the outermost popover of a group, such as a menu and its + * submenus. A nested popover renders into it and shares its outside-interaction + * detection, so the whole group closes at once. + */ +export const PopoverGroupContext = + createContext | null>(null); diff --git a/packages/components/src/components/Popover/PopoverInner.tsx b/packages/components/src/components/Popover/PopoverInner.tsx index f59f20c90..21728dc9a 100644 --- a/packages/components/src/components/Popover/PopoverInner.tsx +++ b/packages/components/src/components/Popover/PopoverInner.tsx @@ -1,5 +1,5 @@ import type { ComponentRef, CSSProperties, FC } from 'react'; -import { useRef } from 'react'; +import { useContext, useRef } from 'react'; import { clsx, @@ -20,6 +20,7 @@ import { useOverlayArrowStyle } from '../../hooks/useOverlayArrowStyle'; import { Dialog } from '../Dialog'; import s from './Popover.module.css'; +import { PopoverGroupContext } from './PopoverGroupContext'; import type { PopoverInnerProps } from './types'; import { normalizeInlineSize } from './utils'; @@ -33,7 +34,9 @@ export const PopoverInner: FC = (props) => { placement: placementProp = 'top', maxBlockSize = 480, type = 'dialog', + trigger, hideArrow, + shouldFlip, state, control, children, @@ -58,6 +61,11 @@ export const PopoverInner: FC = (props) => { const targetRef = anchorRef || controlRef; const { direction } = useLocale(); + // A submenu joins the group of its menu instead of standing on its own. + const group = useContext(PopoverGroupContext); + const containerRef = useRef(null); + const groupContainer = trigger === 'SubmenuTrigger' ? group : null; + const openState = state.isOpen; const [opened, { on, off }] = useBoolean(openState); @@ -77,6 +85,7 @@ export const PopoverInner: FC = (props) => { ...props, offset, isNonModal, + shouldFlip, crossOffset, containerPadding, popoverRef: domRef, @@ -85,6 +94,7 @@ export const PopoverInner: FC = (props) => { placement: placementProp, shouldCloseOnInteractOutside, triggerRef: targetRef, + groupRef: groupContainer ?? containerRef, isKeyboardDismissDisabled: disableExitOnEscapeKeyDown, }, { ...state, isOpen: openState || opened } @@ -141,6 +151,37 @@ export const PopoverInner: FC = (props) => { ? children({ close: state.close }) : children; + const renderPopover = (transition: string) => ( +
+ {showArrow &&
} +
+ {type === 'dialog' ? ( + {resolvedChildren} + ) : ( + resolvedChildren + )} +
+
+ ); + return ( <> {control?.({ @@ -148,42 +189,32 @@ export const PopoverInner: FC = (props) => { ...triggerProps, })} - {(transition) => ( - -
-
+ groupContainer ? ( + // Inside the group container, so a click here is not an outside one. + + {renderPopover(transition)} + + ) : ( + - {showArrow &&
} -
- {type === 'dialog' ? ( - {resolvedChildren} - ) : ( - resolvedChildren - )} +
+ {/* Box-less anchor of the group, left out of the backdrop. */} +
+ + {renderPopover(transition)} +
-
- - )} + + ) + } ); diff --git a/packages/components/src/components/Popover/types.ts b/packages/components/src/components/Popover/types.ts index c5a9ca55c..cd9c636da 100644 --- a/packages/components/src/components/Popover/types.ts +++ b/packages/components/src/components/Popover/types.ts @@ -117,6 +117,12 @@ export type PopoverProps = { * @default 'top' */ placement?: PopoverPropPlacement; + /** + * Whether the popover should flip to the opposite side when it reaches the + * viewport boundary. + * @default true + */ + shouldFlip?: boolean; /** The ref for the element which the popover positions itself with respect to. */ anchorRef?: RefObject; /** @@ -182,4 +188,14 @@ export type PopoverProps = { export type PopoverInnerProps = { state: OverlayTriggerState; popoverRef?: Ref; -} & Omit; + /** + * Same as in `PopoverProps`, but as wide as `usePopover` itself: React Aria + * hands its overlays an `Element` ref. + */ + anchorRef?: RefObject; + /** + * What opened the popover. React Aria reads it to tell a submenu from any + * other overlay and treats it differently on outside clicks and scroll. + */ + trigger?: string; +} & Omit; diff --git a/packages/components/src/components/SearchInput/SearchInput.tsx b/packages/components/src/components/SearchInput/SearchInput.tsx index 4125ae43c..92b1596f8 100644 --- a/packages/components/src/components/SearchInput/SearchInput.tsx +++ b/packages/components/src/components/SearchInput/SearchInput.tsx @@ -1,8 +1,8 @@ 'use client'; -import { forwardRef } from 'react'; +import { forwardRef, useCallback } from 'react'; -import { clsx, mergeProps, useDOMRef } from '@koobiq/react-core'; +import { clsx, mergeProps, useDOMRef, useMultiRef } from '@koobiq/react-core'; import { IconMagnifyingGlass16 } from '@koobiq/react-icons'; import { removeDataAttributes, @@ -14,6 +14,8 @@ import { ButtonContext, DEFAULT_SLOT, Provider, + FieldInputContext, + useContextProps, } from '@koobiq/react-primitives'; import { useForm } from '../Form'; @@ -28,11 +30,39 @@ import type { import { FormField, FormFieldClearButton } from '../FormField'; import s from './SearchInput.module.css'; +import { + SearchInputContext, + type SearchInputContextProps, +} from './SearchInputContext'; import type { SearchInputProps, SearchInputRef } from './types'; /** A search input allows a user to enter and clear a search query. */ export const SearchInput = forwardRef( - (props, ref) => { + (inProps, inRef) => { + const contextInputProps: SearchInputContextProps = inProps; + + const [contextProps, contextRef] = useContextProps( + contextInputProps, + inRef, + SearchInputContext + ); + + const { ref: fieldInputRef, ...fieldInputProps } = + useSlottedContext(FieldInputContext) ?? {}; + + const props = mergeProps(contextProps, fieldInputProps); + + const setFieldInputRef = useCallback( + (node: SearchInputRef | null) => { + if (typeof fieldInputRef === 'function') { + fieldInputRef(node); + } else if (fieldInputRef) { + fieldInputRef.current = node; + } + }, + [fieldInputRef] + ); + const { startAddon = , variant = 'filled', @@ -63,7 +93,9 @@ export const SearchInput = forwardRef( removeDataAttributes({ ...props, isDisabled, isReadOnly }) ); - const inputRef = useDOMRef(ref); + const inputRef = useDOMRef( + useMultiRef([contextRef, setFieldInputRef]) + ); const { validationBehavior: formValidationBehavior } = useSlottedContext(FormContext) || {}; diff --git a/packages/components/src/components/SearchInput/SearchInputContext.ts b/packages/components/src/components/SearchInput/SearchInputContext.ts new file mode 100644 index 000000000..5b808f077 --- /dev/null +++ b/packages/components/src/components/SearchInput/SearchInputContext.ts @@ -0,0 +1,15 @@ +'use client'; + +import { createContext } from 'react'; + +import type { ContextValue } from '@koobiq/react-primitives'; + +import type { SearchInputProps, SearchInputRef } from './types'; + +export type SearchInputContextProps = SearchInputProps & { + slot?: string | null; +}; + +export const SearchInputContext = createContext< + ContextValue +>({}); diff --git a/packages/components/src/components/Textarea/Textarea.tsx b/packages/components/src/components/Textarea/Textarea.tsx index f58e12800..ffa8e6c56 100644 --- a/packages/components/src/components/Textarea/Textarea.tsx +++ b/packages/components/src/components/Textarea/Textarea.tsx @@ -102,7 +102,7 @@ export const Textarea = forwardRef((props, ref) => { ); return ( - + {(values) => ( { + it.each([ + ['Input', () => ], + ['Textarea', () =>