From 161699f29e3c31e0f6ec74ff9b29b5f74fd7ddb4 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 7 Aug 2026 12:50:56 +0300 Subject: [PATCH 01/15] feat(components): add `DropdownMenu` component, first draft --- .storybook/components/Roadmap/data.ts | 6 + .../components/DropdownMenu/DropdownMenu.mdx | 219 ++++++ .../DropdownMenu/DropdownMenu.stories.tsx | 573 ++++++++++++++ .../DropdownMenu/DropdownMenu.test.tsx | 729 ++++++++++++++++++ .../components/DropdownMenu/DropdownMenu.tsx | 54 ++ .../DropdownMenu/__stories__/avatar.webp | Bin 0 -> 54790 bytes .../DropdownMenuContent.module.css | 30 + .../DropdownMenuContent.tsx | 78 ++ .../DropdownMenuContentInner.tsx | 125 +++ .../components/DropdownMenuContent/index.ts | 2 + .../components/DropdownMenuContent/types.ts | 84 ++ .../DropdownMenuHeader/DropdownMenuHeader.tsx | 15 + .../components/DropdownMenuHeader/index.ts | 2 + .../components/DropdownMenuHeader/types.ts | 3 + .../DropdownMenuItem.module.css | 53 ++ .../DropdownMenuItem/DropdownMenuItem.tsx | 57 ++ .../components/DropdownMenuItem/index.ts | 2 + .../components/DropdownMenuItem/types.ts | 18 + .../DropdownMenuSearch.module.css | 5 + .../DropdownMenuSearch/DropdownMenuSearch.tsx | 53 ++ .../components/DropdownMenuSearch/index.ts | 2 + .../components/DropdownMenuSearch/types.ts | 3 + .../DropdownMenuSection.tsx | 50 ++ .../components/DropdownMenuSection/index.ts | 2 + .../components/DropdownMenuSection/types.ts | 11 + .../DropdownMenuSeparator.module.css | 9 + .../DropdownMenuSeparator.tsx | 17 + .../components/DropdownMenuSeparator/index.ts | 2 + .../components/DropdownMenuSeparator/types.ts | 10 + .../DropdownMenuSubmenuTrigger.tsx | 17 + .../DropdownMenuSubmenuTrigger/index.ts | 2 + .../DropdownMenuSubmenuTrigger/types.ts | 14 + .../DropdownMenuTrigger.tsx | 16 + .../components/DropdownMenuTrigger/index.ts | 2 + .../components/DropdownMenuTrigger/types.ts | 6 + .../DropdownMenu/components/index.ts | 7 + .../src/components/DropdownMenu/index.ts | 3 + .../src/components/DropdownMenu/intl.ts | 12 + .../src/components/DropdownMenu/types.ts | 40 + .../src/components/DropdownMenu/utils.ts | 30 + .../components/src/components/Menu/Menu.mdx | 3 + packages/components/src/components/index.ts | 1 + packages/primitives/src/index.ts | 32 + tools/api-extractor/config.json | 1 + .../components/DropdownMenu.api.md | 189 +++++ .../public_api_guard/react-primitives.api.md | 54 ++ 46 files changed, 2643 insertions(+) create mode 100644 packages/components/src/components/DropdownMenu/DropdownMenu.mdx create mode 100644 packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx create mode 100644 packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx create mode 100644 packages/components/src/components/DropdownMenu/DropdownMenu.tsx create mode 100644 packages/components/src/components/DropdownMenu/__stories__/avatar.webp create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuContent/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/DropdownMenuHeader.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuHeader/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuItem/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.module.css create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSection/DropdownMenuSection.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSection/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSection/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/DropdownMenuSubmenuTrigger.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSubmenuTrigger/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/index.ts create mode 100644 packages/components/src/components/DropdownMenu/index.ts create mode 100644 packages/components/src/components/DropdownMenu/intl.ts create mode 100644 packages/components/src/components/DropdownMenu/types.ts create mode 100644 packages/components/src/components/DropdownMenu/utils.ts create mode 100644 tools/public_api_guard/components/DropdownMenu.api.md diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 7ab28a629..beaa9b04f 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -386,4 +386,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q3 2026', }, + { + component: 'DropdownMenu', + 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..dba87e925 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -0,0 +1,219 @@ +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. + +It is built on [React Aria Components](https://react-spectrum.adobe.com/react-aria/Menu.html) +and supersedes the [Menu](?path=/docs/components-menu--docs) component, +adding submenus and search on top of it. + +## 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.Trigger` — wraps the element that opens the menu +- `DropdownMenu.Content` — the popover holding the items, also used for a submenu +- `DropdownMenu.Item` — a single action +- `DropdownMenu.ItemText` — 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.Separator` — a line between groups of items +- `DropdownMenu.SubmenuTrigger` — wraps an item and the submenu it opens +- `DropdownMenu.Pressable` — makes custom markup usable as a trigger. + +## Trigger + +Any component built on the Koobiq `Button` works as a trigger without extra props — +`DropdownMenu.Trigger` passes the interactions down to it. + +For custom markup that is not a button, wrap it in `DropdownMenu.Pressable` +(see the [Separators](#separators) example). + +The `trigger` prop switches between opening on press (default) and on a long press: + + + +## Content + +The `DropdownMenu.Content` accepts both static and dynamic collections, +so it can display options you know ahead of time or ones that arrive or change later. +For dynamic collections, pass your items via the `items` prop and give each a unique `id`; +when an option is chosen, `onAction` is called with that `id`. + + + +## Item content + +An item is composed of slots: `DropdownMenu.ItemAddon` for icons and shortcuts, +and `DropdownMenu.ItemText` for the label and its `caption`. + + + +When an item has composed children rather than plain text, its text value is taken +from the first `DropdownMenu.ItemText`. Pass `textValue` explicitly when that text +is not what typeahead and search should match on, and `aria-label` when the item +needs a shorter accessible name than the sum of its slots. + +## Selection + +The component supports multiple selection modes. +By default, selection is disabled, however this can be changed using the `selectionMode` prop. +Use `defaultSelectedKeys` to provide a default set of selected items (uncontrolled) +and `selectedKeys` to set the selected items (controlled). +The value of the selected keys must match the `id` prop 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 + +By default, interacting with an item triggers `onAction` and optionally +`onSelectionChange` depending on the `selectionMode`. +Alternatively, items may be links to another page or website. +This can be achieved by passing the `href` prop to the `DropdownMenu.Item` component. +Link items in a menu are not selectable. + + + +## Client side routing + +The `DropdownMenu` component works with frameworks and client side routers like **Next.js** and **React Router**. +As with other React Aria components that support links, this works via the `RouterProvider` component at the root of your app. + +```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 a `title`. + +### Static items + + + +### Dynamic items + + + +## Separators + +Separators may be added between items or sections in order to create non-labeled groupings. +`DropdownMenu.Header` renders arbitrary content inside the menu. + + + +## Submenu + +Wrap an item and a nested `DropdownMenu.Content` in a `DropdownMenu.SubmenuTrigger` +to give the item a submenu. The chevron is added automatically. A submenu opens on +hover after `delay` (200 ms by default), on ArrowRight and on press, +and closes on ArrowLeft. Submenus can be nested. + + + +Submenu support relies on an API that React Aria still marks as alpha, +so its behaviour may change in a future release. + +## Search + +Set `isSearchable` to filter the items with a search field. +Matching is case- and accent-insensitive; pass `defaultFilter` to change it. + +Focus stays in the search field while the arrow keys move through the items. +An item that opens a submenu is kept when its own text matches, but the contents +of a submenu are not searched. + + + +Use `noItemsText` to change the message shown when nothing matches. + + + +## Dropdown Footer + +Use `dropdownFooter` to show text under the menu items. + + + +## Placement + +The menu's placement with respect to its trigger can be adjusted +using the `placement` prop of the content. Menus +will also automatically flip to the opposite direction if there isn't 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 menu's open state 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..22feee179 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -0,0 +1,573 @@ +import type { CSSProperties } from 'react'; +import { useState } from 'react'; + +import { useBoolean } from '@koobiq/react-core'; +import { + IconArrowRightToBracket16, + IconBell16, + IconFileMultipleO16, + IconDashboard16, + IconGear16, + IconMessage16, + IconPlus16, + IconScissors16, + IconTrash16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import type { Selection } from '../../index'; +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { spacing } from '../layout'; +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.Trigger': DropdownMenu.Trigger, + '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.Separator': DropdownMenu.Separator, + 'DropdownMenu.SubmenuTrigger': DropdownMenu.SubmenuTrigger, + }, + 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) { + const sections = [ + { + name: 'File', + children: [ + { id: 'new', name: 'New' }, + { id: 'open', name: 'Open' }, + ], + }, + { + name: 'Edit', + children: [ + { id: 'copy', name: 'Copy' }, + { id: 'cut', name: 'Cut' }, + { id: 'paste', name: 'Paste' }, + ], + }, + ]; + + return ( + + + + + alert(key)}> + {(section: (typeof sections)[number]) => ( + + {(item: { id: string; name: string }) => ( + {item.name} + )} + + )} + + + ); + }, +}; + +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 + + Socials + alert(key)}> + Telegram + VK + + + + + + 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) => ( + + + + + + New + Open + Save + + + ), +}; + +export const DropdownFooter: Story = { + render: (args) => ( + + + + + + New + Open + Save + + + ), +}; + +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..0424ccb50 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -0,0 +1,729 @@ +import { useState } from 'react'; + +import { 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 { DropdownMenu, dropdownMenuPropTrigger } from './index.js'; + +const onAction = vi.fn(); +const onOpenChange = vi.fn((value) => value); +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?: Record; + rootProps?: Record; +}; + +function Fixture({ contentProps, rootProps }: FixtureProps = {}) { + return ( + + + + + + Copy + Paste + Delete + + + ); +} + +describe('DropdownMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + 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 = { current: null } as { current: HTMLElement | null }; + + 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(); + }); + }); + + 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.mock.results[0]?.value).toBe(true); + + await userEvent.keyboard('{Escape}'); + + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(onOpenChange.mock.results[1]?.value).toBe(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() }); + + 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 separators', () => { + 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 separator', async () => { + renderSections(); + await open(); + + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + }); + + describe('item content', () => { + const renderComposed = (textValue?: string) => + render( + + + + + + + + + + + + Copy + + ⌘C + + + Cut + + + + ); + + it('should render addons and text inside an item', async () => { + renderComposed(); + await open(); + + expect(screen.getByTestId('start-addon')).toBeInTheDocument(); + expect(getItems()[0]).toHaveTextContent('Copy to clipboard'); + expect(getItems()[0]).toHaveTextContent('⌘C'); + }); + + it('should derive textValue from ItemText for typeahead', async () => { + renderComposed(); + await open(); + await userEvent.keyboard('cu'); + + expect(getItems()[1]).toHaveFocus(); + }); + + it('should keep an explicit textValue over the derived one', async () => { + renderComposed('Duplicate'); + await open(); + await userEvent.keyboard('du'); + + expect(getItems()[0]).toHaveFocus(); + }); + }); + + 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 = (contentProps?: Record) => + render( + + + + + + Copy + Paste + + Share + + Email + + + + + ); + + const getSearch = () => screen.getByRole('searchbox'); + + it('should not render the search input unless isSearchable', async () => { + render(); + await open(); + + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + }); + + it('should render the search input when isSearchable', 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 noItemsText', async () => { + renderSearchable({ noItemsText: '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 userEvent.keyboard('{Escape}'); + + 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. + await userEvent.keyboard('{Escape}{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 via slotProps', async () => { + renderSearchable({ + slotProps: { 'search-input': { placeholder: 'Find an action' } }, + }); + + await open(); + + expect(getSearch()).toHaveAttribute('placeholder', 'Find an action'); + }); + }); + + describe('dropdown footer', () => { + it('should render dropdownFooter content', async () => { + render(); + 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..032c21e71 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,54 @@ +'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 { + DropdownMenuContent, + DropdownMenuHeader, + DropdownMenuItem, + DropdownMenuSection, + DropdownMenuSeparator, + DropdownMenuSubmenuTrigger, + DropdownMenuTrigger, +} from './components'; +import type { DropdownMenuComponent, DropdownMenuProps } from './types'; + +function DropdownMenuComponentRender(props: DropdownMenuProps) { + return ; +} + +DropdownMenuComponentRender.displayName = 'DropdownMenu'; + +type CompoundedComponent = DropdownMenuComponent & { + Trigger: typeof DropdownMenuTrigger; + Content: typeof DropdownMenuContent; + Item: typeof DropdownMenuItem; + ItemText: typeof ListItemText; + ItemAddon: typeof ListItemAddon; + Section: typeof DropdownMenuSection; + Header: typeof DropdownMenuHeader; + Separator: typeof DropdownMenuSeparator; + SubmenuTrigger: typeof DropdownMenuSubmenuTrigger; + Pressable: typeof Pressable; +}; + +/** A dropdown menu displays a list of actions or options that a user can choose. */ +export const DropdownMenu: CompoundedComponent = Object.assign( + DropdownMenuComponentRender, + { + Trigger: DropdownMenuTrigger, + Content: DropdownMenuContent, + Item: DropdownMenuItem, + ItemText: ListItemText, + ItemAddon: ListItemAddon, + Section: DropdownMenuSection, + Header: DropdownMenuHeader, + Separator: DropdownMenuSeparator, + SubmenuTrigger: DropdownMenuSubmenuTrigger, + 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 0000000000000000000000000000000000000000..b59c98eb63910eaecc0b99c93d6af574e13dba7c GIT binary patch literal 54790 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
d!m!^|PJvj;e{#t5+~^?tAVRP7b$ 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%0D*izRz(X|)#V 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 VS=!-7%s%oHO@a?8vjfHN002N( + props: DropdownMenuContentProps +) { + const { + ref, + style, + className, + placement, + anchorRef, + shouldFlip, + isNonModal, + offset = 4, + crossOffset, + containerPadding = 12, + maxBlockSize = 480, + 'data-testid': testId, + slotProps, + ...other + } = props; + + const { className: popoverClassName, ...popoverSlotProps } = + slotProps?.popover ?? {}; + + // Left undefined unless set, so the trigger's context defaults survive: + // 'bottom start' for a menu, 'end top' for a submenu. + const popoverProps = mergeProps< + [AriaPopoverProps, Omit] + >( + { + style, + offset, + placement, + shouldFlip, + isNonModal, + crossOffset, + containerPadding, + triggerRef: anchorRef, + maxHeight: maxBlockSize, + className: composeRenderProps( + popoverClassName, + (popoverClassName, renderProps) => + clsx( + s.popover, + typeof className === 'function' + ? className(renderProps) + : className, + popoverClassName + ) + ), + }, + popoverSlotProps + ); + + return ( + + {...other} slotProps={slotProps} /> + + ); +} + +DropdownMenuContent.displayName = 'DropdownMenu.Content'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx new file mode 100644 index 000000000..87349154c --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { + clsx, + useFilter, + mergeProps, + useControlledState, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import type { MenuProps as AriaMenuProps } from '@koobiq/react-primitives'; +import { + Menu as AriaMenu, + Autocomplete as AriaAutocomplete, + composeRenderProps, +} from '@koobiq/react-primitives'; + +import { utilClasses } from '../../../../styles/utility'; +import { Divider } from '../../../Divider'; +import { DropdownFooter } from '../../../DropdownFooter'; +import intlMessages from '../../intl'; +import { DropdownMenuSearch } from '../DropdownMenuSearch'; + +import s from './DropdownMenuContent.module.css'; +import type { DropdownMenuContentProps } from './types'; + +const textVariant = utilClasses.typography; +const { list } = utilClasses; + +type DropdownMenuContentInnerProps = Omit< + DropdownMenuContentProps, + | 'ref' + | 'style' + | 'offset' + | 'anchorRef' + | 'placement' + | 'className' + | 'shouldFlip' + | 'isNonModal' + | 'crossOffset' + | 'data-testid' + | 'maxBlockSize' + | 'containerPadding' +>; + +/** + * The inside of the menu popover. Mounted and unmounted with the popover, so + * the search query resets on close on its own. + */ +export function DropdownMenuContentInner( + props: DropdownMenuContentInnerProps +) { + const { + children, + slotProps, + noItemsText, + inputValue, + isSearchable, + defaultFilter, + onInputChange, + dropdownFooter, + defaultInputValue, + ...other + } = props; + + const t = useLocalizedStringFormatter(intlMessages); + + const { contains } = useFilter({ sensitivity: 'base' }); + + const [query, setQuery] = useControlledState( + inputValue, + defaultInputValue ?? '', + onInputChange + ); + + const { className: menuClassName, ...menuSlotProps } = slotProps?.menu ?? {}; + + const menuProps = mergeProps< + [AriaMenuProps, Omit, 'className' | 'children'>] + >( + { + children, + renderEmptyState: () => ( +
+ {noItemsText ?? + t.format(query.trim() ? 'nothing found' : 'empty items')} +
+ ), + className: composeRenderProps(menuClassName, (menuClassName) => + clsx(s.menu, list, menuClassName) + ), + ...other, + }, + menuSlotProps + ); + + const menu = data-padded {...menuProps} />; + + const footer = ( + + {dropdownFooter} + + ); + + if (!isSearchable) + return ( + <> + {menu} + {footer} + + ); + + return ( + + {/* Must be a sibling of the menu: the menu clears the field context. */} + + + {menu} + {footer} + + ); +} 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..cb5abf9f6 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts @@ -0,0 +1,84 @@ +import type { CSSProperties, ReactNode, Ref, RefObject } from 'react'; + +import type { + MenuProps as AriaMenuProps, + PopoverProps as AriaPopoverProps, +} from '@koobiq/react-primitives'; + +import type { DividerProps } from '../../../Divider'; +import type { DropdownFooterProps } from '../../../DropdownFooter'; +import type { SearchInputProps } from '../../../SearchInput'; +import type { DropdownMenuPropPlacement } from '../../types'; + +export type DropdownMenuContentRef = HTMLElement; + +type DropdownMenuContentOwnProps = { + /** Additional CSS-classes. */ + className?: AriaPopoverProps['className']; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; + /** Ref to the popover element. */ + ref?: Ref; + + /** + * The placement of the menu 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 menu and its trigger. + * @default 4 + */ + offset?: number; + /** The additional offset along the cross axis between the menu and its trigger. */ + crossOffset?: number; + /** + * The padding that should be applied between the menu and its surrounding container. + * @default 12 + */ + containerPadding?: number; + /** Whether the menu should flip when it reaches the viewport boundary. */ + shouldFlip?: boolean; + /** + * The maximum block size of the menu. + * @default 480 + */ + maxBlockSize?: number; + /** Whether the menu should not block interaction with the rest of the page. */ + isNonModal?: boolean; + /** The ref for the element which the menu positions itself with respect to. */ + anchorRef?: RefObject; + + /** Whether the menu items can be filtered with a search input. */ + isSearchable?: boolean; + /** The search query (controlled). */ + inputValue?: string; + /** The search query (uncontrolled). */ + defaultInputValue?: string; + /** Handler that is called when the search query changes. */ + onInputChange?: (value: string) => void; + /** The filter used to decide whether an item is included in the search results. */ + defaultFilter?: (textValue: string, inputValue: string) => boolean; + /** Content to display when there are no items to show. */ + noItemsText?: ReactNode; + + /** Content to display at the bottom of the menu. */ + dropdownFooter?: ReactNode; + + /** The props used for each slot inside. */ + slotProps?: { + popover?: AriaPopoverProps; + menu?: Omit, 'children'>; + 'search-input'?: SearchInputProps; + divider?: DividerProps; + dropdownFooter?: DropdownFooterProps; + }; +}; + +export type DropdownMenuContentProps = Omit< + AriaMenuProps, + 'className' | 'style' | 'slot' | 'renderEmptyState' +> & + DropdownMenuContentOwnProps; 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..c0ab81669 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css @@ -0,0 +1,53 @@ +.base { + /* Keeps the chevron at the end for items without `ItemText`. */ + .chevron { + margin-inline-start: auto; + } + + /* Stay highlighted while the submenu is open. */ + &:where([data-open]) { + --list-item-bg-color: var(--kbq-states-background-transparent-hover); + } +} + +/* + * The multi-select look in `utility.module.css` is keyed off + * `aria-multiselectable`, which `role="menu"` does not carry. Repeat it from + * the per-item `data-selection-mode`, specific enough to win over the utility. + */ +.base:is([data-selection-mode='multiple'][data-selected='true']) { + --list-item-bg-color: var(--kbq-background-contrast-less); +} + +.base:is( + [data-selection-mode='multiple'][data-selected='true'][data-hovered='true'] +) { + --list-item-bg-color: var(--kbq-states-background-contrast-less-hover); +} + +.base:is( + [data-selection-mode='multiple'][data-selected='true'][data-pressed='true'] +) { + --list-item-bg-color: var(--kbq-states-background-contrast-less-active); +} + +.base:is([data-selection-mode='multiple']):is( + [data-selected='true'], + [data-focus-visible='true'] + ):has(+ :is([data-selected='true'], [data-focus-visible='true'])) { + &::before { + border-end-end-radius: 0; + border-end-start-radius: 0; + } +} + +.base:is([data-selection-mode='multiple']):is( + [data-selected='true'], + [data-focus-visible='true'] + ) + + :is([data-selected='true'], [data-focus-visible='true']) { + &::before { + border-start-end-radius: 0; + border-start-start-radius: 0; + } +} 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..aafa06f63 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx @@ -0,0 +1,57 @@ +'use client'; + +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 { getItemTextValue } from '../../utils'; + +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) { + 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..3e9c45c46 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts @@ -0,0 +1,18 @@ +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 = Partial< + AriaMenuItemProps +> & + DataAttributeProps & { + /** + * Vertical alignment of the item content. + * @default 'center' + */ + align?: DropdownMenuItemPropAlign; + }; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.module.css new file mode 100644 index 000000000..ae3aec475 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.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/DropdownMenuSearch/DropdownMenuSearch.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx new file mode 100644 index 000000000..547868ca9 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useRef } from 'react'; + +import { + mergeProps, + useMultiRef, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import { FieldInputContext, useSlottedContext } from '@koobiq/react-primitives'; + +import { SearchInput, type SearchInputProps } from '../../../SearchInput'; +import intlMessages from '../../intl'; + +import s from './DropdownMenuSearch.module.css'; +import type { DropdownMenuSearchProps } from './types'; + +/** + * The search field of a dropdown menu. React Aria passes the input props + * through a context, so this bridges them onto the Koobiq `SearchInput`. + */ +export function DropdownMenuSearch(props: DropdownMenuSearchProps) { + const t = useLocalizedStringFormatter(intlMessages); + + const domRef = useRef(null); + + const { ref: contextRef, ...autocompleteInputProps } = + useSlottedContext(FieldInputContext) ?? {}; + + const inputRef = useMultiRef([contextRef, domRef]); + + // Autocomplete props go last so its value, handlers and + // `aria-activedescendant` win over anything passed in. + const searchInputProps = mergeProps< + [SearchInputProps, SearchInputProps, SearchInputProps] + >( + { + autoFocus: true, + fullWidth: true, + isLabelHidden: true, + className: s.search, + variant: 'transparent', + placeholder: t.format('search'), + 'aria-label': t.format('search'), + }, + props, + autocompleteInputProps as SearchInputProps + ); + + return ; +} + +DropdownMenuSearch.displayName = 'DropdownMenuSearch'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts new file mode 100644 index 000000000..8014aa7d9 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuSearch'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts new file mode 100644 index 000000000..50c0f9fb5 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts @@ -0,0 +1,3 @@ +import type { SearchInputProps } from '../../../SearchInput'; + +export type DropdownMenuSearchProps = SearchInputProps; 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/DropdownMenuSeparator/DropdownMenuSeparator.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css new file mode 100644 index 000000000..741008d8a --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css @@ -0,0 +1,9 @@ +/* React Aria renders the separator, so only the `Divider` look is repeated. */ +.base { + border: 0; + block-size: 0; + flex-shrink: 0; + inline-size: 100%; + margin-block: var(--kbq-size-xxs); + border-block-end: 1px solid var(--kbq-line-contrast-less); +} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx new file mode 100644 index 000000000..dea7bfa3a --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { clsx } from '@koobiq/react-core'; +import { Separator as AriaSeparator } from '@koobiq/react-primitives'; + +import s from './DropdownMenuSeparator.module.css'; +import type { DropdownMenuSeparatorProps } from './types'; + +/** A line that visually separates groups of items inside a dropdown menu. */ +export function DropdownMenuSeparator({ + className, + ...props +}: DropdownMenuSeparatorProps) { + return ; +} + +DropdownMenuSeparator.displayName = 'DropdownMenu.Separator'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts new file mode 100644 index 000000000..44ce4377f --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuSeparator'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts new file mode 100644 index 000000000..77364db6c --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts @@ -0,0 +1,10 @@ +import type { CSSProperties } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type DropdownMenuSeparatorProps = { + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; +} & DataAttributeProps; 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/DropdownMenuTrigger/DropdownMenuTrigger.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx new file mode 100644 index 000000000..a15b5f874 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx @@ -0,0 +1,16 @@ +'use client'; + +import type { DropdownMenuTriggerProps } from './types'; + +/** + * A wrapper for the element that opens the menu. + * + * Renders no DOM: the child gets the trigger props from the press responder. + * Wrapping it in `Pressable` here would call `usePress` twice and toggle the + * menu open and closed in one press. + */ +export function DropdownMenuTrigger({ children }: DropdownMenuTriggerProps) { + return <>{children}; +} + +DropdownMenuTrigger.displayName = 'DropdownMenu.Trigger'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts new file mode 100644 index 000000000..c47717fab --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuTrigger'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts new file mode 100644 index 000000000..8cb7d77ce --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react'; + +export type DropdownMenuTriggerProps = { + /** The element that opens the menu. */ + children?: ReactNode; +}; 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..040b378ed --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/index.ts @@ -0,0 +1,7 @@ +export * from './DropdownMenuContent'; +export * from './DropdownMenuHeader'; +export * from './DropdownMenuItem'; +export * from './DropdownMenuSection'; +export * from './DropdownMenuSeparator'; +export * from './DropdownMenuSubmenuTrigger'; +export * from './DropdownMenuTrigger'; 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/DropdownMenu/utils.ts b/packages/components/src/components/DropdownMenu/utils.ts new file mode 100644 index 000000000..c23e1398f --- /dev/null +++ b/packages/components/src/components/DropdownMenu/utils.ts @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { Children, isValidElement } from 'react'; + +import { ListItemText } from '../List'; + +/** + * Derives a `textValue` for a menu item from the first `ItemText` slot. + * + * React Aria only infers it from string children, so an item built out of slots + * would get an empty one and break typeahead and search. + */ +export function getItemTextValue(children: ReactNode): string | undefined { + if (typeof children === 'string') return children; + + let textValue: string | undefined; + + Children.forEach(children, (child) => { + if (textValue !== undefined) return; + + if (!isValidElement<{ children?: ReactNode }>(child)) return; + if (child.type !== ListItemText) return; + + const text = child.props.children; + + if (typeof text === 'string') textValue = text; + else if (typeof text === 'number') textValue = String(text); + }); + + return textValue; +} diff --git a/packages/components/src/components/Menu/Menu.mdx b/packages/components/src/components/Menu/Menu.mdx index 3b679a0c3..4749d0c9a 100644 --- a/packages/components/src/components/Menu/Menu.mdx +++ b/packages/components/src/components/Menu/Menu.mdx @@ -15,6 +15,9 @@ import * as Stories from './Menu.stories'; A menu displays a list of actions or options that a user can choose. +For new code prefer [DropdownMenu](?path=/docs/components-dropdownmenu--docs): +it is built on React Aria Components and supports submenus and search. + ## Import ```tsx diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index d6d7948df..67d9e133b 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -61,6 +61,7 @@ export * from './Resizable'; export * from './FileUpload'; export * from './Username'; export * from './TopBar'; +export * from './DropdownMenu'; export * from './layout'; export { useListData, diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index ecd247d48..2e5a51607 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -155,6 +155,38 @@ export { export type { FileTriggerProps } from 'react-aria-components'; +/** + * RAC menu primitives. + * + * `SeparatorProps`, `MenuTriggerProps` and `SubmenuTriggerProps` are + * intentionally not re-exported: the same names already come from + * `@react-aria/separator` and `@react-stately/menu` above, and a named export + * would silently shadow them. + */ +export { + Menu, + Header, + Popover, + MenuItem, + Separator, + MenuSection, + MenuTrigger, + Autocomplete, + SubmenuTrigger, + FieldInputContext, +} from 'react-aria-components'; + +export type { + MenuProps, + HeaderProps, + PopoverProps, + MenuItemProps, + MenuSectionProps, + AutocompleteProps, + PopoverRenderProps, + MenuItemRenderProps, +} from 'react-aria-components'; + export * from './behaviors'; export * from './components'; export { removeDataAttributes } from './utils'; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index c6eefeba5..9db53abfd 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -18,6 +18,7 @@ "DateInput", "DatePicker", "Divider", + "DropdownMenu", "EmptyState", "FileUpload", "Flag", diff --git a/tools/public_api_guard/components/DropdownMenu.api.md b/tools/public_api_guard/components/DropdownMenu.api.md new file mode 100644 index 000000000..dfd68ae8d --- /dev/null +++ b/tools/public_api_guard/components/DropdownMenu.api.md @@ -0,0 +1,189 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { AriaSearchFieldProps } from '@koobiq/react-primitives'; +import type { ButtonBaseProps } from '@koobiq/react-primitives'; +import type { ComponentProps } from 'react'; +import type { ComponentPropsWithRef } from 'react'; +import type { CSSProperties } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { DOMAttributes } from '@koobiq/react-core'; +import type { ElementType } from 'react'; +import { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import type { ExtendableProps } from '@koobiq/react-core'; +import { ForwardRefExoticComponent } from 'react'; +import type { HeaderProps } from '@koobiq/react-primitives'; +import type { HTMLAttributes } from 'react'; +import { JSX } from 'react/jsx-runtime'; +import type { MenuItemProps } from '@koobiq/react-primitives'; +import type { MenuProps } from '@koobiq/react-primitives'; +import type { MenuSectionProps } from '@koobiq/react-primitives'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { PopoverProps } from '@koobiq/react-primitives'; +import { Pressable } from '@koobiq/react-core'; +import type { ReactElement } from 'react'; +import { ReactNode } from 'react'; +import { Ref } from 'react'; +import { RefAttributes } from 'react'; +import type { RefObject } from 'react'; +import type { SeparatorProps } from '@koobiq/react-primitives'; +import { TextProps } from '@koobiq/react-primitives'; +import { ValidationResult } from '@koobiq/react-core'; + +// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts +// +// @public +export const DropdownMenu: CompoundedComponent; + +// @public (undocumented) +export type DropdownMenuComponent = (props: DropdownMenuProps) => ReactElement | null; + +// @public +export function DropdownMenuContent(props: DropdownMenuContentProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuContent { + var // (undocumented) + displayName: string; +} + +// Warning: (ae-forgotten-export) The symbol "DropdownMenuContentOwnProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type DropdownMenuContentProps = Omit, 'className' | 'style' | 'slot' | 'renderEmptyState'> & DropdownMenuContentOwnProps; + +// @public (undocumented) +export type DropdownMenuContentRef = HTMLElement; + +// @public +export function DropdownMenuHeader(props: DropdownMenuHeaderProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuHeader { + var // (undocumented) + displayName: string; +} + +// @public (undocumented) +export type DropdownMenuHeaderProps = HeaderProps; + +// @public +export function DropdownMenuItem(input: DropdownMenuItemProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuItem { + var // (undocumented) + displayName: string; +} + +// Warning: (ae-forgotten-export) The symbol "ListItemAddonProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type DropdownMenuItemAddonProps = ListItemAddonProps; + +// @public (undocumented) +export type DropdownMenuItemPropAlign = (typeof dropdownMenuItemPropAlign)[number]; + +// @public (undocumented) +export const dropdownMenuItemPropAlign: readonly ["start", "center"]; + +// @public (undocumented) +export type DropdownMenuItemProps = Partial> & DataAttributeProps & { + align?: DropdownMenuItemPropAlign; +}; + +// Warning: (ae-forgotten-export) The symbol "ListItemTextProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type DropdownMenuItemTextProps = ListItemTextProps; + +// @public (undocumented) +export type DropdownMenuPressableProps = ComponentProps; + +// Warning: (ae-forgotten-export) The symbol "PopoverPropPlacement" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type DropdownMenuPropPlacement = PopoverPropPlacement; + +// @public (undocumented) +export const dropdownMenuPropPlacement: readonly ["bottom", "bottom start", "bottom end", "top", "top start", "top end", "start", "start top", "start bottom", "end", "end top", "end bottom"]; + +// @public (undocumented) +export type DropdownMenuProps = { + children: ReactNode; + isOpen?: boolean; + defaultOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + trigger?: DropdownMenuPropTrigger; +}; + +// @public (undocumented) +export type DropdownMenuPropTrigger = (typeof dropdownMenuPropTrigger)[number]; + +// @public (undocumented) +export const dropdownMenuPropTrigger: readonly ["press", "longPress"]; + +// @public +export function DropdownMenuSection(input: DropdownMenuSectionProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuSection { + var // (undocumented) + displayName: string; +} + +// @public (undocumented) +export type DropdownMenuSectionProps = MenuSectionProps & DataAttributeProps & { + title?: ReactNode; +}; + +// @public +export function DropdownMenuSeparator(input: DropdownMenuSeparatorProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuSeparator { + var // (undocumented) + displayName: string; +} + +// @public (undocumented) +export type DropdownMenuSeparatorProps = { + className?: string; + style?: CSSProperties; +} & DataAttributeProps; + +// @public +export function DropdownMenuSubmenuTrigger(props: DropdownMenuSubmenuTriggerProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuSubmenuTrigger { + var // (undocumented) + displayName: string; +} + +// @public (undocumented) +export type DropdownMenuSubmenuTriggerProps = { + children: ReactElement[]; + delay?: number; +}; + +// @public +export function DropdownMenuTrigger(input: DropdownMenuTriggerProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuTrigger { + var // (undocumented) + displayName: string; +} + +// @public (undocumented) +export type DropdownMenuTriggerProps = { + children?: ReactNode; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index 22e664efc..d0e8f92d1 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -33,7 +33,9 @@ import type { AriaTextFieldProps } from '@react-aria/textfield'; import { AriaToggleButtonGroupItemProps } from '@react-aria/button'; import { AriaToggleButtonGroupProps } from '@react-aria/button'; import { AsyncLoadable } from '@koobiq/react-core'; +import { Autocomplete } from 'react-aria-components'; import { AutocompleteAria } from '@react-aria/autocomplete'; +import { AutocompleteProps } from 'react-aria-components'; import { AutocompleteState } from '@react-stately/autocomplete'; import type { ButtonAria } from '@react-aria/button'; import { ButtonContext } from 'react-aria-components'; @@ -68,6 +70,7 @@ import { DragEventHandler } from 'react'; import { ElementType } from 'react'; import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; import type { ExtendableProps } from '@koobiq/react-core'; +import { FieldInputContext } from 'react-aria-components'; import { FileTrigger } from 'react-aria-components'; import { FileTriggerProps } from 'react-aria-components'; import { FocusableElement } from '@react-types/shared'; @@ -81,6 +84,8 @@ import type { ForwardedRef } from 'react'; import { ForwardRefExoticComponent } from 'react'; import { getItemCount } from '@react-stately/collections'; import type { GlobalDOMAttributes } from '@koobiq/react-core'; +import { Header } from 'react-aria-components'; +import { HeaderProps } from 'react-aria-components'; import type { HelpTextProps } from '@koobiq/react-core'; import type { HoverEvents } from '@koobiq/react-core'; import type { HTMLAttributes } from 'react'; @@ -98,6 +103,14 @@ import { LabelHTMLAttributes } from 'react'; import { CollectionBuilder as LegacyCollectionBuilder } from '@react-stately/collections'; import type { ListProps } from '@react-stately/list'; import type { ListState } from '@react-stately/list'; +import { Menu } from 'react-aria-components'; +import { MenuItem } from 'react-aria-components'; +import { MenuItemProps } from 'react-aria-components'; +import { MenuItemRenderProps } from 'react-aria-components'; +import { MenuProps } from 'react-aria-components'; +import { MenuSection } from 'react-aria-components'; +import { MenuSectionProps } from 'react-aria-components'; +import { MenuTrigger } from 'react-aria-components'; import type { MenuTriggerState } from '@react-stately/menu'; import { MouseEventHandler } from 'react'; import { MultipleSelection } from '@koobiq/react-core'; @@ -107,6 +120,9 @@ import type { OverlayTriggerProps } from '@react-types/overlays'; import { OverlayTriggerState } from '@react-stately/overlays'; import { PointerEventHandler } from 'react'; import { PolyForwardComponent } from '@koobiq/react-core'; +import { Popover } from 'react-aria-components'; +import { PopoverProps } from 'react-aria-components'; +import { PopoverRenderProps } from 'react-aria-components'; import type { PressEvents } from '@koobiq/react-core'; import { Provider } from 'react-aria-components'; import type { RadioGroupState } from '@react-stately/radio'; @@ -119,6 +135,8 @@ import { RefObject } from 'react'; import type { RenderProps as RenderProps_2 } from 'react-aria-components'; import type { RouterOptions } from '@koobiq/react-core'; import type { Selection as Selection_2 } from '@koobiq/react-core'; +import { Separator } from 'react-aria-components'; +import { SubmenuTrigger } from 'react-aria-components'; import type { TextFieldAria } from '@react-aria/textfield'; import { TextInputBase } from '@koobiq/react-core'; import { ToggleEventHandler } from 'react'; @@ -226,8 +244,12 @@ export type AriaTreeSelectOptions = TreeSelectStateOptions; +export { Autocomplete } + export { AutocompleteAria } +export { AutocompleteProps } + export { AutocompleteState } // @public @@ -328,6 +350,8 @@ export type FieldErrorProps = ComponentPropsWithRe // @public (undocumented) export type FieldErrorRenderProps = ValidationResult; +export { FieldInputContext } + export { FileTrigger } export { FileTriggerProps } @@ -376,6 +400,10 @@ export type GroupRenderProps = { isInvalid: boolean; }; +export { Header } + +export { HeaderProps } + // @public (undocumented) export const Input: ForwardRefExoticComponent & RefAttributes>; @@ -444,6 +472,22 @@ export type LinkRenderProps = { isFocusVisible: boolean; }; +export { Menu } + +export { MenuItem } + +export { MenuItemProps } + +export { MenuItemRenderProps } + +export { MenuProps } + +export { MenuSection } + +export { MenuSectionProps } + +export { MenuTrigger } + // @public (undocumented) export interface MultiSelectListProps extends CollectionBase, MultipleSelection { } @@ -488,6 +532,12 @@ export type NumberFieldRenderProps = { export { OverlayTriggerState } +export { Popover } + +export { PopoverProps } + +export { PopoverRenderProps } + // @public (undocumented) export const ProgressBar: PolyForwardComponent<"div", ProgressBarBaseProps, ElementType>; @@ -551,6 +601,10 @@ export function removeDataAttributes(props: T): T; type SelectionMode_2 = 'single' | 'multiple'; export { SelectionMode_2 as SelectionMode } +export { Separator } + +export { SubmenuTrigger } + // @public (undocumented) export const Switch: ForwardRefExoticComponent> & RenderProps & { inputRef?: RefObject; From 65d7a91541c6f57c24d78d80ddb770c3a74b7b1e Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 7 Aug 2026 17:40:16 +0300 Subject: [PATCH 02/15] refactor(DropdownMenu): require explicit textValue, share multiselect styles (DS-5055) Co-Authored-By: Claude Opus 5 --- .../components/DropdownMenu/DropdownMenu.mdx | 9 ++-- .../DropdownMenu/DropdownMenu.stories.tsx | 33 ++++++------ .../DropdownMenu/DropdownMenu.test.tsx | 35 +++++++++--- .../DropdownMenuItem.module.css | 42 --------------- .../DropdownMenuItem/DropdownMenuItem.tsx | 23 +++++--- .../src/components/DropdownMenu/utils.ts | 30 ----------- .../components/src/styles/utility.module.css | 54 ++++++++++--------- 7 files changed, 93 insertions(+), 133 deletions(-) delete mode 100644 packages/components/src/components/DropdownMenu/utils.ts diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index dba87e925..b72fb005a 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -78,10 +78,11 @@ and `DropdownMenu.ItemText` for the label and its `caption`. -When an item has composed children rather than plain text, its text value is taken -from the first `DropdownMenu.ItemText`. Pass `textValue` explicitly when that text -is not what typeahead and search should match on, and `aria-label` when the item -needs a shorter accessible name than the sum of its slots. +An item with composed children rather than plain text needs an explicit `textValue`: +it is what typeahead and search match on. Without it the item is unreachable by +keyboard and disappears on the first keystroke in a searchable menu, so the +component warns about it in development. Add `aria-label` when the item needs a +shorter accessible name than the sum of its slots. ## Selection diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx index 22feee179..2d07bf55d 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -19,6 +19,7 @@ import type { Selection } from '../../index'; import { Button } from '../Button'; import { FlexBox } from '../FlexBox'; import { spacing } from '../layout'; +import { SelectNext as Select } from '../SelectNext'; import { Typography } from '../Typography'; import avatar from './__stories__/avatar.webp'; @@ -98,7 +99,7 @@ export const ItemContent: Story = { alert(key)}> - + @@ -111,7 +112,7 @@ export const ItemContent: Story = { - + @@ -124,7 +125,7 @@ export const ItemContent: Story = { - + @@ -345,39 +346,39 @@ export const Separators: Story = { - + Dashboard - + Notifications - + Create team - + Settings - + Contact support - + @@ -506,18 +507,18 @@ export const Placement: Story = { return ( - + diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx index 0424ccb50..fd60f9876 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { once } from '@koobiq/logger'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -424,7 +425,7 @@ describe('DropdownMenu', () => { ⌘C - + Cut @@ -440,20 +441,38 @@ describe('DropdownMenu', () => { expect(getItems()[0]).toHaveTextContent('⌘C'); }); - it('should derive textValue from ItemText for typeahead', async () => { + 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(); - await userEvent.keyboard('cu'); - expect(getItems()[1]).toHaveFocus(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('DropdownMenu.Item') + ); + + warn.mockRestore(); }); - it('should keep an explicit textValue over the derived one', async () => { - renderComposed('Duplicate'); + it('should not warn when a textValue is given', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + once.clear(); + renderComposed('Copy'); await open(); - await userEvent.keyboard('du'); - expect(getItems()[0]).toHaveFocus(); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); }); }); diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css index c0ab81669..f1f579a3c 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css @@ -9,45 +9,3 @@ --list-item-bg-color: var(--kbq-states-background-transparent-hover); } } - -/* - * The multi-select look in `utility.module.css` is keyed off - * `aria-multiselectable`, which `role="menu"` does not carry. Repeat it from - * the per-item `data-selection-mode`, specific enough to win over the utility. - */ -.base:is([data-selection-mode='multiple'][data-selected='true']) { - --list-item-bg-color: var(--kbq-background-contrast-less); -} - -.base:is( - [data-selection-mode='multiple'][data-selected='true'][data-hovered='true'] -) { - --list-item-bg-color: var(--kbq-states-background-contrast-less-hover); -} - -.base:is( - [data-selection-mode='multiple'][data-selected='true'][data-pressed='true'] -) { - --list-item-bg-color: var(--kbq-states-background-contrast-less-active); -} - -.base:is([data-selection-mode='multiple']):is( - [data-selected='true'], - [data-focus-visible='true'] - ):has(+ :is([data-selected='true'], [data-focus-visible='true'])) { - &::before { - border-end-end-radius: 0; - border-end-start-radius: 0; - } -} - -.base:is([data-selection-mode='multiple']):is( - [data-selected='true'], - [data-focus-visible='true'] - ) - + :is([data-selected='true'], [data-focus-visible='true']) { - &::before { - border-start-end-radius: 0; - border-start-start-radius: 0; - } -} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx index aafa06f63..0a88713f2 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.tsx @@ -1,5 +1,6 @@ 'use client'; +import { once } from '@koobiq/logger'; import { clsx } from '@koobiq/react-core'; import { IconChevronRightS16 } from '@koobiq/react-icons'; import { @@ -9,7 +10,6 @@ import { import { utilClasses } from '../../../../styles/utility'; import { ListItemAddon } from '../../../List/components'; -import { getItemTextValue } from '../../utils'; import s from './DropdownMenuItem.module.css'; import type { DropdownMenuItemProps } from './types'; @@ -25,17 +25,24 @@ export function DropdownMenuItem({ 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) )} diff --git a/packages/components/src/components/DropdownMenu/utils.ts b/packages/components/src/components/DropdownMenu/utils.ts deleted file mode 100644 index c23e1398f..000000000 --- a/packages/components/src/components/DropdownMenu/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ReactNode } from 'react'; -import { Children, isValidElement } from 'react'; - -import { ListItemText } from '../List'; - -/** - * Derives a `textValue` for a menu item from the first `ItemText` slot. - * - * React Aria only infers it from string children, so an item built out of slots - * would get an empty one and break typeahead and search. - */ -export function getItemTextValue(children: ReactNode): string | undefined { - if (typeof children === 'string') return children; - - let textValue: string | undefined; - - Children.forEach(children, (child) => { - if (textValue !== undefined) return; - - if (!isValidElement<{ children?: ReactNode }>(child)) return; - if (child.type !== ListItemText) return; - - const text = child.props.children; - - if (typeof text === 'string') textValue = text; - else if (typeof text === 'number') textValue = String(text); - }); - - return textValue; -} diff --git a/packages/components/src/styles/utility.module.css b/packages/components/src/styles/utility.module.css index 0dc350de7..f00d01d1a 100644 --- a/packages/components/src/styles/utility.module.css +++ b/packages/components/src/styles/utility.module.css @@ -386,36 +386,40 @@ --list-item-bg-color: var(--kbq-background-theme-less); } -.list[aria-multiselectable='true'] .list-item:where([data-selected='true']) { - --list-item-bg-color: var(--kbq-background-contrast-less); -} +/* + * Multi-select look, keyed off the list or off the item. `role="menu"` cannot + * carry `aria-multiselectable`, so a menu is recognized by the + * `data-selection-mode` React Aria writes on every item instead. + */ +.list[aria-multiselectable='true'] .list-item, +.list-item:where([data-selection-mode='multiple']) { + &:where([data-selected='true']) { + --list-item-bg-color: var(--kbq-background-contrast-less); + } -.list[aria-multiselectable='true'] - .list-item:where([data-selected='true'][data-hovered='true']) { - --list-item-bg-color: var(--kbq-states-background-contrast-less-hover); -} + &:where([data-selected='true'][data-hovered='true']) { + --list-item-bg-color: var(--kbq-states-background-contrast-less-hover); + } -.list[aria-multiselectable='true'] - .list-item:where([data-selected='true'][data-pressed='true']) { - --list-item-bg-color: var(--kbq-states-background-contrast-less-active); -} + &:where([data-selected='true'][data-pressed='true']) { + --list-item-bg-color: var(--kbq-states-background-contrast-less-active); + } -.list[aria-multiselectable='true'] - .list-item:is([data-selected='true'], [data-focus-visible='true']):has( - + :is([data-selected='true'], [data-focus-visible='true']) - ) { - &::before { - border-end-end-radius: 0; - border-end-start-radius: 0; + &:is([data-selected='true'], [data-focus-visible='true']):has( + + :is([data-selected='true'], [data-focus-visible='true']) + ) { + &::before { + border-end-end-radius: 0; + border-end-start-radius: 0; + } } -} -.list[aria-multiselectable='true'] - .list-item:is([data-selected='true'], [data-focus-visible='true']) - + :is([data-selected='true'], [data-focus-visible='true']) { - &::before { - border-start-end-radius: 0; - border-start-start-radius: 0; + &:is([data-selected='true'], [data-focus-visible='true']) + + :is([data-selected='true'], [data-focus-visible='true']) { + &::before { + border-start-end-radius: 0; + border-start-start-radius: 0; + } } } From cbcc829594678441dd3be8e16d8475d313701adb Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Sat, 8 Aug 2026 10:50:00 +0300 Subject: [PATCH 03/15] refactor(DropdownMenu): build the popover on the Koobiq `Popover` --- .../components/DropdownMenu/DropdownMenu.mdx | 7 + .../DropdownMenu/DropdownMenu.stories.tsx | 130 ++++++++++++++++-- .../DropdownMenu/DropdownMenu.test.tsx | 37 +++++ .../DropdownMenuContent.module.css | 21 ++- .../DropdownMenuContent.tsx | 72 ++++++---- .../components/DropdownMenuContent/types.ts | 12 +- .../DropdownMenuSearch/DropdownMenuSearch.tsx | 2 +- .../src/components/Popover/PopoverInner.tsx | 2 + .../src/components/Popover/types.ts | 13 +- packages/primitives/src/index.ts | 5 +- .../components/DropdownMenu.api.md | 10 +- .../components/Popover.api.md | 6 +- .../public_api_guard/react-primitives.api.md | 11 +- 13 files changed, 253 insertions(+), 75 deletions(-) diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index b72fb005a..6f026d88f 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -158,6 +158,13 @@ Groups of items can be wrapped in a `DropdownMenu.Section` with a `title`. +### With Section Level Selection + +A section takes its own `selectionMode`, `selectedKeys` and `onSelectionChange`, +so one menu can mix plain actions with a multiple and a single choice. + + + ## Separators Separators may be added between items or sections in order to create non-labeled groupings. diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx index 2d07bf55d..0d1dd26ee 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -3,6 +3,9 @@ import { useState } from 'react'; import { useBoolean } from '@koobiq/react-core'; import { + IconAlignCenter16, + IconAlignLeft16, + IconAlignRight16, IconArrowRightToBracket16, IconBell16, IconFileMultipleO16, @@ -11,6 +14,9 @@ import { IconMessage16, IconPlus16, IconScissors16, + IconTextBold16, + IconTextItalic16, + IconTextUnderline16, IconTrash16, } from '@koobiq/react-icons'; import type { Meta, StoryObj } from '@storybook/react'; @@ -250,6 +256,7 @@ export const Sections: Story = { New Open + Copy Cut @@ -262,15 +269,19 @@ export const Sections: Story = { 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' }, @@ -286,17 +297,114 @@ export const SectionsDynamic: Story = { alert(key)}> - {(section: (typeof sections)[number]) => ( - - {(item: { id: string; name: string }) => ( - {item.name} - )} - - )} + {(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 + + ); diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx index fd60f9876..900bef192 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -406,6 +406,43 @@ describe('DropdownMenu', () => { 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', () => { diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css index cd327f397..bebafcad7 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css @@ -1,26 +1,23 @@ @import url('../../../../styles/mixins.css'); +/* The shell — shadow, background, fade — comes from `Popover`. */ .popover { - display: flex; - overflow: hidden; - box-sizing: border-box; - flex-direction: column; min-inline-size: 200px; max-inline-size: 640px; border-radius: var(--kbq-size-s); - box-shadow: var(--kbq-shadow-overlay); - background-color: var(--kbq-background-bg); } -/* - * No enter/exit animation on purpose. React Aria unmounts the popover only - * after every animation on it resolves, and swallows failures, so any - * keyframes here leave the closed popover in the DOM with its focus scope. - * A fade has to come from somewhere other than this element. - */ +.container { + overflow: hidden; + flex-direction: column; +} .menu { inline-size: 100%; + scrollbar-width: auto; + scrollbar-gutter: auto; + scrollbar-color: var(--kbq-scrollbar-thumb-default-background) + var(--kbq-background-transparent); } .empty { diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx index cf3f7bf5c..60909e8ca 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx @@ -1,12 +1,21 @@ 'use client'; +import { useContext } from 'react'; + import { clsx, mergeProps } from '@koobiq/react-core'; -import type { PopoverProps as AriaPopoverProps } from '@koobiq/react-primitives'; import { - Popover as AriaPopover, - composeRenderProps, + PopoverContext, + useSlottedContext, + OverlayTriggerStateContext, } from '@koobiq/react-primitives'; +import type { + PopoverProps, + PopoverInnerProps, + PopoverPropPlacement, +} from '../../../Popover'; +import { PopoverInner } from '../../../Popover/PopoverInner'; + import s from './DropdownMenuContent.module.css'; import { DropdownMenuContentInner } from './DropdownMenuContentInner'; import type { DropdownMenuContentProps } from './types'; @@ -35,43 +44,52 @@ export function DropdownMenuContent( ...other } = props; - const { className: popoverClassName, ...popoverSlotProps } = - slotProps?.popover ?? {}; + // 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) return null; + + const isSubmenu = context.trigger === 'SubmenuTrigger'; + + const defaultPlacement: PopoverPropPlacement = isSubmenu + ? 'end top' + : 'bottom start'; - // Left undefined unless set, so the trigger's context defaults survive: - // 'bottom start' for a menu, 'end top' for a submenu. const popoverProps = mergeProps< - [AriaPopoverProps, Omit] + [PopoverInnerProps, PopoverProps | undefined] >( { - style, + state, offset, - placement, shouldFlip, - isNonModal, crossOffset, + maxBlockSize, containerPadding, - triggerRef: anchorRef, - maxHeight: maxBlockSize, - className: composeRenderProps( - popoverClassName, - (popoverClassName, renderProps) => - clsx( - s.popover, - typeof className === 'function' - ? className(renderProps) - : className, - popoverClassName - ) - ), + 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.popover, className), + slotProps: { container: { className: s.container } }, + anchorRef: anchorRef ?? context.triggerRef, + isNonModal: isNonModal ?? context.isNonModal, + shouldCloseOnInteractOutside: context.shouldCloseOnInteractOutside, + placement: placement ?? defaultPlacement, }, - popoverSlotProps + slotProps?.popover ); return ( - + {...other} slotProps={slotProps} /> - + ); } diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts index cb5abf9f6..5bad6ecd7 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts @@ -1,20 +1,18 @@ import type { CSSProperties, ReactNode, Ref, RefObject } from 'react'; -import type { - MenuProps as AriaMenuProps, - PopoverProps as AriaPopoverProps, -} from '@koobiq/react-primitives'; +import type { MenuProps as AriaMenuProps } from '@koobiq/react-primitives'; import type { DividerProps } from '../../../Divider'; import type { DropdownFooterProps } from '../../../DropdownFooter'; +import type { PopoverProps } from '../../../Popover'; import type { SearchInputProps } from '../../../SearchInput'; import type { DropdownMenuPropPlacement } from '../../types'; -export type DropdownMenuContentRef = HTMLElement; +export type DropdownMenuContentRef = HTMLDivElement; type DropdownMenuContentOwnProps = { /** Additional CSS-classes. */ - className?: AriaPopoverProps['className']; + className?: string; /** Inline styles. */ style?: CSSProperties; /** Unique identifier for testing purposes. */ @@ -69,7 +67,7 @@ type DropdownMenuContentOwnProps = { /** The props used for each slot inside. */ slotProps?: { - popover?: AriaPopoverProps; + popover?: PopoverProps; menu?: Omit, 'children'>; 'search-input'?: SearchInputProps; divider?: DividerProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx index 547868ca9..408593f51 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx @@ -44,7 +44,7 @@ export function DropdownMenuSearch(props: DropdownMenuSearchProps) { 'aria-label': t.format('search'), }, props, - autocompleteInputProps as SearchInputProps + autocompleteInputProps ); return ; diff --git a/packages/components/src/components/Popover/PopoverInner.tsx b/packages/components/src/components/Popover/PopoverInner.tsx index 07437a691..bec8ab14f 100644 --- a/packages/components/src/components/Popover/PopoverInner.tsx +++ b/packages/components/src/components/Popover/PopoverInner.tsx @@ -27,6 +27,7 @@ export const PopoverInner: FC = (props) => { maxBlockSize = 480, type = 'dialog', hideArrow, + shouldFlip, state, control, children, @@ -68,6 +69,7 @@ export const PopoverInner: FC = (props) => { ...props, offset, isNonModal, + shouldFlip, crossOffset, containerPadding, popoverRef: domRef, diff --git a/packages/components/src/components/Popover/types.ts b/packages/components/src/components/Popover/types.ts index d1349801c..ba048e22e 100644 --- a/packages/components/src/components/Popover/types.ts +++ b/packages/components/src/components/Popover/types.ts @@ -119,6 +119,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; /** @@ -184,4 +190,9 @@ 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; +} & Omit; diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 2e5a51607..ea711c395 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -166,24 +166,23 @@ export type { FileTriggerProps } from 'react-aria-components'; export { Menu, Header, - Popover, MenuItem, Separator, MenuSection, MenuTrigger, Autocomplete, + PopoverContext, SubmenuTrigger, FieldInputContext, + OverlayTriggerStateContext, } from 'react-aria-components'; export type { MenuProps, HeaderProps, - PopoverProps, MenuItemProps, MenuSectionProps, AutocompleteProps, - PopoverRenderProps, MenuItemRenderProps, } from 'react-aria-components'; diff --git a/tools/public_api_guard/components/DropdownMenu.api.md b/tools/public_api_guard/components/DropdownMenu.api.md index dfd68ae8d..026882307 100644 --- a/tools/public_api_guard/components/DropdownMenu.api.md +++ b/tools/public_api_guard/components/DropdownMenu.api.md @@ -4,8 +4,10 @@ ```ts +import type { AriaDialogProps } from '@koobiq/react-primitives'; import type { AriaSearchFieldProps } from '@koobiq/react-primitives'; -import type { ButtonBaseProps } from '@koobiq/react-primitives'; +import type { ButtonBaseProps as ButtonBaseProps_2 } from '@koobiq/react-primitives'; +import type { ButtonOptions } from '@koobiq/react-primitives'; import type { ComponentProps } from 'react'; import type { ComponentPropsWithRef } from 'react'; import type { CSSProperties } from 'react'; @@ -22,7 +24,6 @@ import type { MenuItemProps } from '@koobiq/react-primitives'; import type { MenuProps } from '@koobiq/react-primitives'; import type { MenuSectionProps } from '@koobiq/react-primitives'; import { PolyForwardComponent } from '@koobiq/react-core'; -import type { PopoverProps } from '@koobiq/react-primitives'; import { Pressable } from '@koobiq/react-core'; import type { ReactElement } from 'react'; import { ReactNode } from 'react'; @@ -31,6 +32,7 @@ import { RefAttributes } from 'react'; import type { RefObject } from 'react'; import type { SeparatorProps } from '@koobiq/react-primitives'; import { TextProps } from '@koobiq/react-primitives'; +import type { TransitionProps } from 'react-transition-group/Transition'; import { ValidationResult } from '@koobiq/react-core'; // Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts @@ -42,7 +44,7 @@ export const DropdownMenu: CompoundedComponent; export type DropdownMenuComponent = (props: DropdownMenuProps) => ReactElement | null; // @public -export function DropdownMenuContent(props: DropdownMenuContentProps): JSX.Element; +export function DropdownMenuContent(props: DropdownMenuContentProps): JSX.Element | null; // @public (undocumented) export namespace DropdownMenuContent { @@ -56,7 +58,7 @@ export namespace DropdownMenuContent { export type DropdownMenuContentProps = Omit, 'className' | 'style' | 'slot' | 'renderEmptyState'> & DropdownMenuContentOwnProps; // @public (undocumented) -export type DropdownMenuContentRef = HTMLElement; +export type DropdownMenuContentRef = HTMLDivElement; // @public export function DropdownMenuHeader(props: DropdownMenuHeaderProps): JSX.Element; diff --git a/tools/public_api_guard/components/Popover.api.md b/tools/public_api_guard/components/Popover.api.md index 83358e81d..63d07248f 100644 --- a/tools/public_api_guard/components/Popover.api.md +++ b/tools/public_api_guard/components/Popover.api.md @@ -56,7 +56,8 @@ export type PopoverHeaderProps = DialogHeaderProps; export type PopoverInnerProps = { state: OverlayTriggerState; popoverRef?: Ref; -} & Omit; + anchorRef?: RefObject; +} & Omit; // @public (undocumented) export type PopoverPropContent = ReactNode | ((props: { @@ -92,6 +93,7 @@ export type PopoverProps = { 'data-testid'?: string | number; disableFocusManagement?: boolean; placement?: PopoverPropPlacement; + shouldFlip?: boolean; anchorRef?: RefObject; hideArrow?: boolean; isNonModal?: boolean; @@ -125,7 +127,7 @@ export const popoverPropType: readonly ["dialog", "menu", "listbox", "tree", "gr // Warnings were encountered during analysis: // -// packages/components/dist/components/Popover/types.d.ts:126:9 - (ae-forgotten-export) The symbol "DialogProps" needs to be exported by the entry point index.d.ts +// packages/components/dist/components/Popover/types.d.ts:132:9 - (ae-forgotten-export) The symbol "DialogProps" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index d0e8f92d1..33044c4fe 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -118,11 +118,10 @@ import type { Node as Node_2 } from '@koobiq/react-core'; import { NumberFieldAria } from 'react-aria'; import type { OverlayTriggerProps } from '@react-types/overlays'; import { OverlayTriggerState } from '@react-stately/overlays'; +import { OverlayTriggerStateContext } from 'react-aria-components'; import { PointerEventHandler } from 'react'; import { PolyForwardComponent } from '@koobiq/react-core'; -import { Popover } from 'react-aria-components'; -import { PopoverProps } from 'react-aria-components'; -import { PopoverRenderProps } from 'react-aria-components'; +import { PopoverContext } from 'react-aria-components'; import type { PressEvents } from '@koobiq/react-core'; import { Provider } from 'react-aria-components'; import type { RadioGroupState } from '@react-stately/radio'; @@ -532,11 +531,9 @@ export type NumberFieldRenderProps = { export { OverlayTriggerState } -export { Popover } +export { OverlayTriggerStateContext } -export { PopoverProps } - -export { PopoverRenderProps } +export { PopoverContext } // @public (undocumented) export const ProgressBar: PolyForwardComponent<"div", ProgressBarBaseProps, ElementType>; From 0499e657820d8b9db5721dbc23038fc724e74462 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 10 Aug 2026 21:35:02 +0300 Subject: [PATCH 04/15] refactor(DropdownMenu): compose the popover, the content and the search --- .../components/DropdownMenu/DropdownMenu.mdx | 45 +- .../DropdownMenu/DropdownMenu.stories.tsx | 765 +++++++++--------- .../DropdownMenu/DropdownMenu.test.tsx | 290 ++++--- .../components/DropdownMenu/DropdownMenu.tsx | 20 +- .../DropdownMenuAutocomplete.tsx | 20 + .../DropdownMenuAutocomplete/index.ts | 2 + .../DropdownMenuAutocomplete/types.ts | 3 + .../DropdownMenuContent.module.css | 16 +- .../DropdownMenuContent.tsx | 106 +-- .../DropdownMenuContentInner.tsx | 125 --- .../components/DropdownMenuContent/types.ts | 83 +- .../DropdownMenuFooter/DropdownMenuFooter.tsx | 12 + .../components/DropdownMenuFooter/index.ts | 2 + .../components/DropdownMenuFooter/types.ts | 3 + .../DropdownMenuPopover.module.css | 11 + .../DropdownMenuPopover.tsx | 89 ++ .../components/DropdownMenuPopover/index.ts | 2 + .../components/DropdownMenuPopover/types.ts | 53 ++ .../components/DropdownMenuSearch/index.ts | 2 - .../DropdownMenuSearchInput.module.css} | 0 .../DropdownMenuSearchInput.tsx} | 8 +- .../DropdownMenuSearchInput/index.ts | 2 + .../types.ts | 2 +- .../DropdownMenuTrigger.tsx | 16 - .../components/DropdownMenuTrigger/index.ts | 2 - .../components/DropdownMenuTrigger/types.ts | 6 - .../DropdownMenu/components/index.ts | 5 +- packages/primitives/src/index.ts | 1 + .../components/DropdownMenu.api.md | 98 ++- .../public_api_guard/react-primitives.api.md | 3 + 30 files changed, 925 insertions(+), 867 deletions(-) create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/types.ts delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/DropdownMenuFooter.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuFooter/types.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/index.ts create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts rename packages/components/src/components/DropdownMenu/components/{DropdownMenuSearch/DropdownMenuSearch.module.css => DropdownMenuSearchInput/DropdownMenuSearchInput.module.css} (100%) rename packages/components/src/components/DropdownMenu/components/{DropdownMenuSearch/DropdownMenuSearch.tsx => DropdownMenuSearchInput/DropdownMenuSearchInput.tsx} (83%) create mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts rename packages/components/src/components/DropdownMenu/components/{DropdownMenuSearch => DropdownMenuSearchInput}/types.ts (50%) delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index 6f026d88f..acd1200fe 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -39,8 +39,8 @@ A dropdown menu is composed of a trigger and its content: The component has the following helper components: -- `DropdownMenu.Trigger` — wraps the element that opens the menu -- `DropdownMenu.Content` — the popover holding the items, also used for a submenu +- `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` — text of an item, with an optional `caption` - `DropdownMenu.ItemAddon` — an addon of an item, such as an icon or a shortcut @@ -48,12 +48,18 @@ The component has the following helper components: - `DropdownMenu.Header` — a block of custom content, or the heading of a section - `DropdownMenu.Separator` — a line between groups of items - `DropdownMenu.SubmenuTrigger` — wraps an item and the submenu it opens +- `DropdownMenu.Autocomplete` — filters the menu with a field +- `DropdownMenu.SearchInput` — the search field for an `Autocomplete` +- `DropdownMenu.Footer` — text under the items, outside the scroll area - `DropdownMenu.Pressable` — makes custom markup usable as a trigger. +The popover and the content are separate on purpose: anything may sit between them, +which is how search is assembled instead of a prop on the menu. + ## Trigger -Any component built on the Koobiq `Button` works as a trigger without extra props — -`DropdownMenu.Trigger` passes the interactions down to it. +The first child of `DropdownMenu` is the trigger. Any component built on the Koobiq +`Button` works without extra props — the interactions reach it on their own. For custom markup that is not a button, wrap it in `DropdownMenu.Pressable` (see the [Separators](#separators) example). @@ -133,13 +139,13 @@ export default function App() { return ( - - - - - Page 1 - Page 2 - + + + + Page 1 + Page 2 + + ); @@ -174,7 +180,7 @@ Separators may be added between items or sections in order to create non-labeled ## Submenu -Wrap an item and a nested `DropdownMenu.Content` in a `DropdownMenu.SubmenuTrigger` +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 `delay` (200 ms by default), on ArrowRight and on press, and closes on ArrowLeft. Submenus can be nested. @@ -186,8 +192,10 @@ so its behaviour may change in a future release. ## Search -Set `isSearchable` to filter the items with a search field. -Matching is case- and accent-insensitive; pass `defaultFilter` to change it. +Wrap the content in a `DropdownMenu.Autocomplete` and put a `DropdownMenu.SearchInput` +next to it. 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. Matching is case- and +accent-insensitive; pass `filter` to the autocomplete to change it. Focus stays in the search field while the arrow keys move through the items. An item that opens a submenu is kept when its own text matches, but the contents @@ -195,20 +203,21 @@ of a submenu are not searched. -Use `noItemsText` to change the message shown when nothing matches. +Use `noItemsText` of the content to change the message shown when nothing matches. -## Dropdown Footer +## Footer -Use `dropdownFooter` to show text under the menu items. +`DropdownMenu.Footer` shows text under the items. It sits next to the menu rather +than inside it, so it stays put while the items scroll. ## Placement The menu's placement with respect to its trigger can be adjusted -using the `placement` prop of the content. Menus +using the `placement` prop of the popover. Menus will also automatically flip to the opposite direction if there isn't enough space. diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx index 0d1dd26ee..9b1178d85 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -23,6 +23,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import type { Selection } from '../../index'; import { Button } from '../Button'; +import { Divider } from '../Divider'; import { FlexBox } from '../FlexBox'; import { spacing } from '../layout'; import { SelectNext as Select } from '../SelectNext'; @@ -37,7 +38,7 @@ const meta = { title: 'Components/DropdownMenu', component: DropdownMenu, subcomponents: { - 'DropdownMenu.Trigger': DropdownMenu.Trigger, + 'DropdownMenu.Popover': DropdownMenu.Popover, 'DropdownMenu.Content': DropdownMenu.Content, 'DropdownMenu.Item': DropdownMenu.Item, 'DropdownMenu.ItemText': DropdownMenu.ItemText, @@ -46,6 +47,9 @@ const meta = { 'DropdownMenu.Header': DropdownMenu.Header, 'DropdownMenu.Separator': DropdownMenu.Separator, 'DropdownMenu.SubmenuTrigger': DropdownMenu.SubmenuTrigger, + 'DropdownMenu.Autocomplete': DropdownMenu.Autocomplete, + 'DropdownMenu.SearchInput': DropdownMenu.SearchInput, + 'DropdownMenu.Footer': DropdownMenu.Footer, }, parameters: { layout: 'centered', @@ -59,16 +63,16 @@ type Story = StoryObj; export const Base: Story = { render: (args) => ( - - - - alert(key)}> - New - Open - Save - Duplicate - Rename - + + + alert(key)}> + New + Open + Save + Duplicate + Rename + + ), }; @@ -85,14 +89,14 @@ export const Content: Story = { return ( - - - - alert(key)}> - {(item: (typeof items)[number]) => ( - {item.name} - )} - + + + alert(key)}> + {(item: (typeof items)[number]) => ( + {item.name} + )} + + ); }, @@ -101,50 +105,50 @@ export const Content: Story = { export const ItemContent: Story = { render: (args) => ( - - - - alert(key)}> - - - - - - Copy - - - - ⌘C - - - - - - - - - Cut - - - - ⌘X - - - - - - - - - Delete - - - - ⌫ - - - - + + + alert(key)}> + + + + + + Copy + + + + ⌘C + + + + + + + + + Cut + + + + ⌘X + + + + + + + + + Delete + + + + ⌫ + + + + + ), }; @@ -157,19 +161,19 @@ export const SelectionSingle: Story = { return ( - - - - - Small - Medium - Large - + + + + Small + Medium + Large + + ); }, @@ -183,18 +187,18 @@ export const SelectionMultiple: Story = { return ( - - - - - Sidebar - Search bar - Console - + + + + Sidebar + Search bar + Console + + ); }, @@ -203,20 +207,20 @@ export const SelectionMultiple: Story = { export const DisabledItems: Story = { render: (args) => ( - - - - alert(key)} - > - New - Open - - Save - - Rename - + + + alert(key)} + > + New + Open + + Save + + Rename + + ), }; @@ -224,23 +228,23 @@ export const DisabledItems: Story = { export const Links: Story = { render: (args) => ( - - - - - - Koobiq React - - - Koobiq - - - GitHub - - + + + + + Koobiq React + + + Koobiq + + + GitHub + + + ), }; @@ -248,21 +252,21 @@ export const Links: Story = { export const Sections: Story = { render: (args) => ( - - - - alert(key)}> - - New - Open - - - - Copy - Cut - Paste - - + + + alert(key)}> + + New + Open + + + + Copy + Cut + Paste + + + ), }; @@ -293,25 +297,25 @@ export const SectionsDynamic: Story = { return ( - - - - alert(key)}> - {(section: (typeof sections)[number]) => - 'children' in section ? ( - - {(item: { id: string; name: string }) => ( - {item.name} - )} - - ) : ( - - ) - } - + + + alert(key)}> + {(section: (typeof sections)[number]) => + 'children' in section ? ( + + {(item: { id: string; name: string }) => ( + {item.name} + )} + + ) : ( + + ) + } + + ); }, @@ -324,88 +328,88 @@ export const WithSectionLevelSelection: Story = { return ( - - - - - - - - - - Cut - - - ⌘X - - - - - - - - Copy - - - ⌘C - - - - - - - - - - - Bold - - - - - - Italic - - - - - - Underline - - - - - - - - - Left - - - - - - Center - - - - - - Right - - - + + + + + + + + + Cut + + + ⌘X + + + + + + + + Copy + + + ⌘C + + + + + + + + + + + Bold + + + + + + Italic + + + + + + Underline + + + + + + + + + Left + + + + + + Center + + + + + + Right + + + + ); }, @@ -433,66 +437,66 @@ export const Separators: Story = { return ( - - -
- Sophia Bellmont -
-
-
- alert(key)}> - - - - Sophia Bellmont - - @Sophia - - - - - - - - Dashboard - - - - - - Notifications - - - - - - Create team - - - - - - Settings - - - - - - - Contact support - - - - - - - Log out - - + +
+ Sophia Bellmont +
+
+ + alert(key)}> + + + + Sophia Bellmont + + @Sophia + + + + + + + + Dashboard + + + + + + Notifications + + + + + + Create team + + + + + + Settings + + + + + + + Contact support + + + + + + + Log out + + +
); }, @@ -501,29 +505,35 @@ export const Separators: Story = { export const Submenu: Story = { render: (args) => ( - - - - alert(key)}> - New - Rename - - Share - alert(key)}> - Email - SMS - - Socials + + + alert(key)}> + New + Rename + + Share + alert(key)}> - Telegram - VK + Email + SMS + + Socials + + alert(key)}> + + Telegram + + VK + + + - - - - - Delete - + + + + Delete + + ), }; @@ -556,23 +566,29 @@ export const Search: Story = { return ( - - - - alert(key)}> - {items.map((item) => ( - - {item} - - ))} - - Other cities + + + + + alert(key)}> - Tallinn - Vilnius + {items.map((item) => ( + + {item} + + ))} + + Other cities + + alert(key)}> + Tallinn + Vilnius + + + - - + + ); }, @@ -581,14 +597,18 @@ export const Search: Story = { export const SearchEmpty: Story = { render: (args) => ( - - - - - New - Open - Save - + + + + + + + New + Open + Save + + + ), }; @@ -596,14 +616,17 @@ export const SearchEmpty: Story = { export const DropdownFooter: Story = { render: (args) => ( - - - - - New - Open - Save - + + + + New + Open + Save + + + The text in the footer of the drop-down list. + + ), }; @@ -628,14 +651,14 @@ export const Placement: Story = { ))} - - - - - New - Open - Save - + + + + New + Open + Save + + ); @@ -652,14 +675,14 @@ export const Open: Story = { {isOpen ? 'Close' : 'Open'} the menu - - - - - New - Open - Save - + + + + New + Open + Save + + ); @@ -669,14 +692,14 @@ export const Open: Story = { export const LongPress: Story = { render: (args) => ( - - - - alert(key)}> - New - Open - Save - + + + 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 index 900bef192..9a218fad9 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -26,24 +26,21 @@ const open = async () => { type FixtureProps = { contentProps?: Record; + popoverProps?: Record; rootProps?: Record; }; -function Fixture({ contentProps, rootProps }: FixtureProps = {}) { +function Fixture({ contentProps, popoverProps, rootProps }: FixtureProps = {}) { return ( - - - - - Copy - Paste - Delete - + + + + Copy + Paste + Delete + + ); } @@ -82,7 +79,7 @@ describe('DropdownMenu', () => { it('should forward a ref to the popover element', async () => { const ref = { current: null } as { current: HTMLElement | null }; - render(); + render(); await open(); expect(ref.current).toBeInstanceOf(HTMLElement); @@ -90,7 +87,7 @@ describe('DropdownMenu', () => { }); it('should merge a custom className into the popover', async () => { - render(); + render(); await open(); expect(screen.getByTestId('popover')).toHaveClass('custom'); @@ -263,14 +260,14 @@ describe('DropdownMenu', () => { it('should not call onAction for an item with isDisabled', async () => { render( - - - - - - Copy - - + + + + + Copy + + + ); @@ -347,14 +344,14 @@ describe('DropdownMenu', () => { it('should render items with href as links', async () => { render( - - - - - - Koobiq - - + + + + + Koobiq + + + ); @@ -368,19 +365,19 @@ describe('DropdownMenu', () => { const renderSections = () => render( - - - - - - Copy - - - - Danger - Delete - - + + + + + Copy + + + + Danger + Delete + + + ); @@ -410,25 +407,28 @@ describe('DropdownMenu', () => { it('should keep a selection mode per section', async () => { render( - - - - - - Cut - - - Bold - Italic - - - Left - - + + + + + Cut + + + Bold + Italic + + + Left + + + ); @@ -449,23 +449,23 @@ describe('DropdownMenu', () => { const renderComposed = (textValue?: string) => render( - - - - - - - + - - - Copy - - ⌘C - - - Cut - - + + + + + + + + + + Copy + + ⌘C + + + Cut + + + ); @@ -517,19 +517,21 @@ describe('DropdownMenu', () => { const renderSubmenu = () => render( - - - - - Copy - - Share - - Email - SMS - - - + + + + Copy + + Share + + + Email + SMS + + + + + ); @@ -598,35 +600,40 @@ describe('DropdownMenu', () => { }); describe('search', () => { - const renderSearchable = (contentProps?: Record) => + const renderSearchable = (menuProps?: Record) => render( - - - - - Copy - Paste - - Share - - Email + + + + + + Copy + Paste + + Share + + + Email + + + - - + + ); const getSearch = () => screen.getByRole('searchbox'); - it('should not render the search input unless isSearchable', async () => { + 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 when isSearchable', async () => { + it('should render the search input inside an Autocomplete', async () => { renderSearchable(); await open(); @@ -721,20 +728,22 @@ describe('DropdownMenu', () => { return ( - - - - { - onInputChange(next); - setValue(next); - }} - > - Copy - Paste - + + + { + onInputChange(next); + setValue(next); + }} + > + + + Copy + Paste + + + ); } @@ -751,10 +760,20 @@ describe('DropdownMenu', () => { await waitFor(() => expect(getItems()).toHaveLength(2)); }); - it('should pass props to the search input via slotProps', async () => { - renderSearchable({ - slotProps: { 'search-input': { placeholder: 'Find an action' } }, - }); + it('should pass props to the search input', async () => { + render( + + + + + + + Copy + + + + + ); await open(); @@ -762,9 +781,20 @@ describe('DropdownMenu', () => { }); }); - describe('dropdown footer', () => { - it('should render dropdownFooter content', async () => { - render(); + describe('footer', () => { + it('should render the footer content', async () => { + render( + + + + + Copy + + Footer text + + + ); + await open(); expect(screen.getByText('Footer text')).toBeInTheDocument(); @@ -773,7 +803,7 @@ describe('DropdownMenu', () => { describe('placement', () => { it('should reflect the placement on the popover', async () => { - render(); + render(); await open(); expect(screen.getByTestId('popover')).toHaveAttribute( diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx index 032c21e71..6a1be0871 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx @@ -7,13 +7,16 @@ import { ListItemText } from '../List'; import { ListItemAddon } from '../List/components'; import { + DropdownMenuAutocomplete, DropdownMenuContent, + DropdownMenuFooter, DropdownMenuHeader, DropdownMenuItem, + DropdownMenuPopover, + DropdownMenuSearchInput, DropdownMenuSection, DropdownMenuSeparator, DropdownMenuSubmenuTrigger, - DropdownMenuTrigger, } from './components'; import type { DropdownMenuComponent, DropdownMenuProps } from './types'; @@ -24,7 +27,7 @@ function DropdownMenuComponentRender(props: DropdownMenuProps) { DropdownMenuComponentRender.displayName = 'DropdownMenu'; type CompoundedComponent = DropdownMenuComponent & { - Trigger: typeof DropdownMenuTrigger; + Popover: typeof DropdownMenuPopover; Content: typeof DropdownMenuContent; Item: typeof DropdownMenuItem; ItemText: typeof ListItemText; @@ -33,14 +36,20 @@ type CompoundedComponent = DropdownMenuComponent & { Header: typeof DropdownMenuHeader; Separator: typeof DropdownMenuSeparator; SubmenuTrigger: typeof DropdownMenuSubmenuTrigger; + Autocomplete: typeof DropdownMenuAutocomplete; + SearchInput: typeof DropdownMenuSearchInput; + Footer: typeof DropdownMenuFooter; Pressable: typeof Pressable; }; -/** A dropdown menu displays a list of actions or options that a user can choose. */ +/** + * 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, { - Trigger: DropdownMenuTrigger, + Popover: DropdownMenuPopover, Content: DropdownMenuContent, Item: DropdownMenuItem, ItemText: ListItemText, @@ -49,6 +58,9 @@ export const DropdownMenu: CompoundedComponent = Object.assign( Header: DropdownMenuHeader, Separator: DropdownMenuSeparator, SubmenuTrigger: DropdownMenuSubmenuTrigger, + Autocomplete: DropdownMenuAutocomplete, + SearchInput: DropdownMenuSearchInput, + Footer: DropdownMenuFooter, Pressable, } ); 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..2efefb44f --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useFilter } from '@koobiq/react-core'; +import { Autocomplete as AriaAutocomplete } from '@koobiq/react-primitives'; + +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' }); + + 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 index bebafcad7..8ca61fed7 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.module.css @@ -1,18 +1,4 @@ -@import url('../../../../styles/mixins.css'); - -/* The shell — shadow, background, fade — comes from `Popover`. */ -.popover { - min-inline-size: 200px; - max-inline-size: 640px; - border-radius: var(--kbq-size-s); -} - -.container { - overflow: hidden; - flex-direction: column; -} - -.menu { +.base { inline-size: 100%; scrollbar-width: auto; scrollbar-gutter: auto; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx index 60909e8ca..2b9d05f08 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx @@ -2,94 +2,48 @@ import { useContext } from 'react'; -import { clsx, mergeProps } from '@koobiq/react-core'; +import { clsx, useLocalizedStringFormatter } from '@koobiq/react-core'; import { - PopoverContext, - useSlottedContext, - OverlayTriggerStateContext, + Menu as AriaMenu, + composeRenderProps, + AutocompleteStateContext, } from '@koobiq/react-primitives'; -import type { - PopoverProps, - PopoverInnerProps, - PopoverPropPlacement, -} from '../../../Popover'; -import { PopoverInner } from '../../../Popover/PopoverInner'; +import { utilClasses } from '../../../../styles/utility'; +import intlMessages from '../../intl'; import s from './DropdownMenuContent.module.css'; -import { DropdownMenuContentInner } from './DropdownMenuContentInner'; import type { DropdownMenuContentProps } from './types'; -/** - * The popover of a dropdown menu, holding its items. - * Backs both the menu and any submenu. - */ -export function DropdownMenuContent( - props: DropdownMenuContentProps -) { - const { - ref, - style, - className, - placement, - anchorRef, - shouldFlip, - isNonModal, - offset = 4, - crossOffset, - containerPadding = 12, - maxBlockSize = 480, - 'data-testid': testId, - slotProps, - ...other - } = props; +const textVariant = utilClasses.typography; +const { list } = utilClasses; - // The trigger hands its popover the open state and the element to anchor to - // through context. - const state = useContext(OverlayTriggerStateContext); - const context = useSlottedContext(PopoverContext) ?? {}; +/** The list of items of a dropdown menu. */ +export function DropdownMenuContent({ + className, + noItemsText, + ...props +}: DropdownMenuContentProps) { + const t = useLocalizedStringFormatter(intlMessages); - if (!state) return null; + // Set once the menu is wrapped in a `DropdownMenu.Autocomplete`. + const autocomplete = useContext(AutocompleteStateContext); - const isSubmenu = context.trigger === 'SubmenuTrigger'; - - const defaultPlacement: PopoverPropPlacement = isSubmenu - ? 'end top' - : 'bottom start'; - - const popoverProps = mergeProps< - [PopoverInnerProps, PopoverProps | undefined] - >( - { - state, - offset, - shouldFlip, - 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.popover, className), - slotProps: { container: { className: s.container } }, - anchorRef: anchorRef ?? context.triggerRef, - isNonModal: isNonModal ?? context.isNonModal, - shouldCloseOnInteractOutside: context.shouldCloseOnInteractOutside, - placement: placement ?? defaultPlacement, - }, - slotProps?.popover - ); + const query = autocomplete?.inputValue.trim(); return ( - - {...other} slotProps={slotProps} /> - + + data-padded + renderEmptyState={() => ( +
+ {noItemsText ?? t.format(query ? 'nothing found' : 'empty items')} +
+ )} + className={composeRenderProps(className, (className) => + clsx(s.base, list, className) + )} + {...props} + /> ); } diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx deleted file mode 100644 index 87349154c..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContentInner.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; - -import { - clsx, - useFilter, - mergeProps, - useControlledState, - useLocalizedStringFormatter, -} from '@koobiq/react-core'; -import type { MenuProps as AriaMenuProps } from '@koobiq/react-primitives'; -import { - Menu as AriaMenu, - Autocomplete as AriaAutocomplete, - composeRenderProps, -} from '@koobiq/react-primitives'; - -import { utilClasses } from '../../../../styles/utility'; -import { Divider } from '../../../Divider'; -import { DropdownFooter } from '../../../DropdownFooter'; -import intlMessages from '../../intl'; -import { DropdownMenuSearch } from '../DropdownMenuSearch'; - -import s from './DropdownMenuContent.module.css'; -import type { DropdownMenuContentProps } from './types'; - -const textVariant = utilClasses.typography; -const { list } = utilClasses; - -type DropdownMenuContentInnerProps = Omit< - DropdownMenuContentProps, - | 'ref' - | 'style' - | 'offset' - | 'anchorRef' - | 'placement' - | 'className' - | 'shouldFlip' - | 'isNonModal' - | 'crossOffset' - | 'data-testid' - | 'maxBlockSize' - | 'containerPadding' ->; - -/** - * The inside of the menu popover. Mounted and unmounted with the popover, so - * the search query resets on close on its own. - */ -export function DropdownMenuContentInner( - props: DropdownMenuContentInnerProps -) { - const { - children, - slotProps, - noItemsText, - inputValue, - isSearchable, - defaultFilter, - onInputChange, - dropdownFooter, - defaultInputValue, - ...other - } = props; - - const t = useLocalizedStringFormatter(intlMessages); - - const { contains } = useFilter({ sensitivity: 'base' }); - - const [query, setQuery] = useControlledState( - inputValue, - defaultInputValue ?? '', - onInputChange - ); - - const { className: menuClassName, ...menuSlotProps } = slotProps?.menu ?? {}; - - const menuProps = mergeProps< - [AriaMenuProps, Omit, 'className' | 'children'>] - >( - { - children, - renderEmptyState: () => ( -
- {noItemsText ?? - t.format(query.trim() ? 'nothing found' : 'empty items')} -
- ), - className: composeRenderProps(menuClassName, (menuClassName) => - clsx(s.menu, list, menuClassName) - ), - ...other, - }, - menuSlotProps - ); - - const menu = data-padded {...menuProps} />; - - const footer = ( - - {dropdownFooter} - - ); - - if (!isSearchable) - return ( - <> - {menu} - {footer} - - ); - - return ( - - {/* Must be a sibling of the menu: the menu clears the field context. */} - - - {menu} - {footer} - - ); -} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts index 5bad6ecd7..e98fc89cd 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts @@ -1,82 +1,13 @@ -import type { CSSProperties, ReactNode, Ref, RefObject } from 'react'; +import type { ReactNode } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; import type { MenuProps as AriaMenuProps } from '@koobiq/react-primitives'; -import type { DividerProps } from '../../../Divider'; -import type { DropdownFooterProps } from '../../../DropdownFooter'; -import type { PopoverProps } from '../../../Popover'; -import type { SearchInputProps } from '../../../SearchInput'; -import type { DropdownMenuPropPlacement } from '../../types'; - -export type DropdownMenuContentRef = HTMLDivElement; - -type DropdownMenuContentOwnProps = { - /** 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 menu 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 menu and its trigger. - * @default 4 - */ - offset?: number; - /** The additional offset along the cross axis between the menu and its trigger. */ - crossOffset?: number; - /** - * The padding that should be applied between the menu and its surrounding container. - * @default 12 - */ - containerPadding?: number; - /** Whether the menu should flip when it reaches the viewport boundary. */ - shouldFlip?: boolean; - /** - * The maximum block size of the menu. - * @default 480 - */ - maxBlockSize?: number; - /** Whether the menu should not block interaction with the rest of the page. */ - isNonModal?: boolean; - /** The ref for the element which the menu positions itself with respect to. */ - anchorRef?: RefObject; - - /** Whether the menu items can be filtered with a search input. */ - isSearchable?: boolean; - /** The search query (controlled). */ - inputValue?: string; - /** The search query (uncontrolled). */ - defaultInputValue?: string; - /** Handler that is called when the search query changes. */ - onInputChange?: (value: string) => void; - /** The filter used to decide whether an item is included in the search results. */ - defaultFilter?: (textValue: string, inputValue: string) => boolean; - /** Content to display when there are no items to show. */ - noItemsText?: ReactNode; - - /** Content to display at the bottom of the menu. */ - dropdownFooter?: ReactNode; - - /** The props used for each slot inside. */ - slotProps?: { - popover?: PopoverProps; - menu?: Omit, 'children'>; - 'search-input'?: SearchInputProps; - divider?: DividerProps; - dropdownFooter?: DropdownFooterProps; - }; -}; - export type DropdownMenuContentProps = Omit< AriaMenuProps, - 'className' | 'style' | 'slot' | 'renderEmptyState' + 'renderEmptyState' | 'slot' > & - DropdownMenuContentOwnProps; + DataAttributeProps & { + /** Content to display when there are no items to show. */ + noItemsText?: ReactNode; + }; 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/DropdownMenuPopover/DropdownMenuPopover.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css new file mode 100644 index 000000000..4da8230ba --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css @@ -0,0 +1,11 @@ +/* The shell — shadow, background, fade — comes from `Popover`. */ +.base { + min-inline-size: 200px; + max-inline-size: 640px; + border-radius: var(--kbq-size-s); +} + +.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..fb1df4e99 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useContext } from 'react'; + +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, + placement, + anchorRef, + shouldFlip, + isNonModal, + offset = 4, + 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) return null; + + const isSubmenu = context.trigger === 'SubmenuTrigger'; + + const defaultPlacement: PopoverPropPlacement = isSubmenu + ? 'end top' + : 'bottom start'; + + const popoverProps = mergeProps< + [PopoverInnerProps, PopoverProps | undefined] + >( + { + state, + offset, + shouldFlip, + 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, + }, + 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..05e466184 --- /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 + */ + 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/DropdownMenuSearch/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts deleted file mode 100644 index 8014aa7d9..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DropdownMenuSearch'; -export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.module.css similarity index 100% rename from packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.module.css rename to packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.module.css diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx similarity index 83% rename from packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx rename to packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx index 408593f51..0fd247b89 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/DropdownMenuSearch.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx @@ -12,14 +12,14 @@ import { FieldInputContext, useSlottedContext } from '@koobiq/react-primitives'; import { SearchInput, type SearchInputProps } from '../../../SearchInput'; import intlMessages from '../../intl'; -import s from './DropdownMenuSearch.module.css'; -import type { DropdownMenuSearchProps } from './types'; +import s from './DropdownMenuSearchInput.module.css'; +import type { DropdownMenuSearchInputProps } from './types'; /** * The search field of a dropdown menu. React Aria passes the input props * through a context, so this bridges them onto the Koobiq `SearchInput`. */ -export function DropdownMenuSearch(props: DropdownMenuSearchProps) { +export function DropdownMenuSearchInput(props: DropdownMenuSearchInputProps) { const t = useLocalizedStringFormatter(intlMessages); const domRef = useRef(null); @@ -50,4 +50,4 @@ export function DropdownMenuSearch(props: DropdownMenuSearchProps) { return ; } -DropdownMenuSearch.displayName = 'DropdownMenuSearch'; +DropdownMenuSearchInput.displayName = 'DropdownMenu.SearchInput'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts new file mode 100644 index 000000000..512bb16a3 --- /dev/null +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownMenuSearchInput'; +export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts similarity index 50% rename from packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts rename to packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts index 50c0f9fb5..fd497f7dc 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearch/types.ts +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts @@ -1,3 +1,3 @@ import type { SearchInputProps } from '../../../SearchInput'; -export type DropdownMenuSearchProps = SearchInputProps; +export type DropdownMenuSearchInputProps = SearchInputProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx deleted file mode 100644 index a15b5f874..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/DropdownMenuTrigger.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -import type { DropdownMenuTriggerProps } from './types'; - -/** - * A wrapper for the element that opens the menu. - * - * Renders no DOM: the child gets the trigger props from the press responder. - * Wrapping it in `Pressable` here would call `usePress` twice and toggle the - * menu open and closed in one press. - */ -export function DropdownMenuTrigger({ children }: DropdownMenuTriggerProps) { - return <>{children}; -} - -DropdownMenuTrigger.displayName = 'DropdownMenu.Trigger'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts deleted file mode 100644 index c47717fab..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DropdownMenuTrigger'; -export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts deleted file mode 100644 index 8cb7d77ce..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuTrigger/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react'; - -export type DropdownMenuTriggerProps = { - /** The element that opens the menu. */ - children?: ReactNode; -}; diff --git a/packages/components/src/components/DropdownMenu/components/index.ts b/packages/components/src/components/DropdownMenu/components/index.ts index 040b378ed..0e3de32d3 100644 --- a/packages/components/src/components/DropdownMenu/components/index.ts +++ b/packages/components/src/components/DropdownMenu/components/index.ts @@ -1,7 +1,10 @@ +export * from './DropdownMenuAutocomplete'; export * from './DropdownMenuContent'; +export * from './DropdownMenuFooter'; export * from './DropdownMenuHeader'; export * from './DropdownMenuItem'; +export * from './DropdownMenuPopover'; +export * from './DropdownMenuSearchInput'; export * from './DropdownMenuSection'; export * from './DropdownMenuSeparator'; export * from './DropdownMenuSubmenuTrigger'; -export * from './DropdownMenuTrigger'; diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index ea711c395..695f813b5 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -174,6 +174,7 @@ export { PopoverContext, SubmenuTrigger, FieldInputContext, + AutocompleteStateContext, OverlayTriggerStateContext, } from 'react-aria-components'; diff --git a/tools/public_api_guard/components/DropdownMenu.api.md b/tools/public_api_guard/components/DropdownMenu.api.md index 026882307..38025ec48 100644 --- a/tools/public_api_guard/components/DropdownMenu.api.md +++ b/tools/public_api_guard/components/DropdownMenu.api.md @@ -6,6 +6,7 @@ import type { AriaDialogProps } from '@koobiq/react-primitives'; import type { AriaSearchFieldProps } from '@koobiq/react-primitives'; +import type { AutocompleteProps } from '@koobiq/react-primitives'; import type { ButtonBaseProps as ButtonBaseProps_2 } from '@koobiq/react-primitives'; import type { ButtonOptions } from '@koobiq/react-primitives'; import type { ComponentProps } from 'react'; @@ -18,7 +19,6 @@ import { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; import type { ExtendableProps } from '@koobiq/react-core'; import { ForwardRefExoticComponent } from 'react'; import type { HeaderProps } from '@koobiq/react-primitives'; -import type { HTMLAttributes } from 'react'; import { JSX } from 'react/jsx-runtime'; import type { MenuItemProps } from '@koobiq/react-primitives'; import type { MenuProps } from '@koobiq/react-primitives'; @@ -30,7 +30,6 @@ import { ReactNode } from 'react'; import { Ref } from 'react'; import { RefAttributes } from 'react'; import type { RefObject } from 'react'; -import type { SeparatorProps } from '@koobiq/react-primitives'; import { TextProps } from '@koobiq/react-primitives'; import type { TransitionProps } from 'react-transition-group/Transition'; import { ValidationResult } from '@koobiq/react-core'; @@ -40,11 +39,23 @@ import { ValidationResult } from '@koobiq/react-core'; // @public export const DropdownMenu: CompoundedComponent; +// @public +export function DropdownMenuAutocomplete(props: DropdownMenuAutocompleteProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuAutocomplete { + var // (undocumented) + displayName: string; +} + +// @public (undocumented) +export type DropdownMenuAutocompleteProps = AutocompleteProps; + // @public (undocumented) export type DropdownMenuComponent = (props: DropdownMenuProps) => ReactElement | null; // @public -export function DropdownMenuContent(props: DropdownMenuContentProps): JSX.Element | null; +export function DropdownMenuContent(input: DropdownMenuContentProps): JSX.Element; // @public (undocumented) export namespace DropdownMenuContent { @@ -52,13 +63,24 @@ export namespace DropdownMenuContent { displayName: string; } -// Warning: (ae-forgotten-export) The symbol "DropdownMenuContentOwnProps" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export type DropdownMenuContentProps = Omit, 'className' | 'style' | 'slot' | 'renderEmptyState'> & DropdownMenuContentOwnProps; +export type DropdownMenuContentProps = Omit, 'renderEmptyState' | 'slot'> & DataAttributeProps & { + noItemsText?: ReactNode; +}; + +// @public +export function DropdownMenuFooter(props: DropdownMenuFooterProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuFooter { + var // (undocumented) + displayName: string; +} +// Warning: (ae-forgotten-export) The symbol "DropdownFooterProps" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -export type DropdownMenuContentRef = HTMLDivElement; +export type DropdownMenuFooterProps = DropdownFooterProps; // @public export function DropdownMenuHeader(props: DropdownMenuHeaderProps): JSX.Element; @@ -102,6 +124,38 @@ export type DropdownMenuItemProps = Partial; + placement?: DropdownMenuPropPlacement; + offset?: number; + crossOffset?: number; + containerPadding?: number; + shouldFlip?: boolean; + maxBlockSize?: number; + isNonModal?: boolean; + anchorRef?: RefObject; + slotProps?: { + popover?: PopoverProps; + }; +}; + +// @public (undocumented) +export type DropdownMenuPopoverRef = HTMLDivElement; + // @public (undocumented) export type DropdownMenuPressableProps = ComponentProps; @@ -128,6 +182,20 @@ export type DropdownMenuPropTrigger = (typeof dropdownMenuPropTrigger)[number]; // @public (undocumented) export const dropdownMenuPropTrigger: readonly ["press", "longPress"]; +// @public +export function DropdownMenuSearchInput(props: DropdownMenuSearchInputProps): JSX.Element; + +// @public (undocumented) +export namespace DropdownMenuSearchInput { + var // (undocumented) + displayName: string; +} + +// Warning: (ae-forgotten-export) The symbol "SearchInputProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type DropdownMenuSearchInputProps = SearchInputProps; + // @public export function DropdownMenuSection(input: DropdownMenuSectionProps): JSX.Element; @@ -172,19 +240,9 @@ export type DropdownMenuSubmenuTriggerProps = { delay?: number; }; -// @public -export function DropdownMenuTrigger(input: DropdownMenuTriggerProps): JSX.Element; - -// @public (undocumented) -export namespace DropdownMenuTrigger { - var // (undocumented) - displayName: string; -} - -// @public (undocumented) -export type DropdownMenuTriggerProps = { - children?: ReactNode; -}; +// Warnings were encountered during analysis: +// +// packages/components/dist/components/DropdownMenu/components/DropdownMenuPopover/types.d.ts:46:9 - (ae-forgotten-export) The symbol "PopoverProps" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index 33044c4fe..b7291cc71 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -37,6 +37,7 @@ import { Autocomplete } from 'react-aria-components'; import { AutocompleteAria } from '@react-aria/autocomplete'; import { AutocompleteProps } from 'react-aria-components'; import { AutocompleteState } from '@react-stately/autocomplete'; +import { AutocompleteStateContext } from 'react-aria-components'; import type { ButtonAria } from '@react-aria/button'; import { ButtonContext } from 'react-aria-components'; import { CalendarAria } from '@react-aria/calendar'; @@ -251,6 +252,8 @@ export { AutocompleteProps } export { AutocompleteState } +export { AutocompleteStateContext } + // @public export const Button: PolyForwardComponent<"button", ButtonBaseProps, ElementType>; From d4e65b35a4ae16a2bc8111088da427f0c7e60541 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 11 Aug 2026 13:06:40 +0300 Subject: [PATCH 05/15] fix(DropdownMenu): dismiss the whole menu on an outside click, snap a submenu to it --- .../DropdownMenu/DropdownMenu.stories.tsx | 6 +- .../DropdownMenuPopover.tsx | 9 +- .../components/DropdownMenuPopover/types.ts | 2 +- .../components/Popover/PopoverGroupContext.ts | 12 +++ .../src/components/Popover/PopoverInner.tsx | 97 ++++++++++++------- .../src/components/Popover/types.ts | 11 ++- .../components/Popover.api.md | 1 + 7 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 packages/components/src/components/Popover/PopoverGroupContext.ts diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx index 9b1178d85..09fe46620 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -517,13 +517,15 @@ export const Submenu: Story = { Email SMS - Socials + + Messengers + alert(key)}> Telegram - VK + Signal diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx index fb1df4e99..992fec59a 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx @@ -29,11 +29,11 @@ export function DropdownMenuPopover(props: DropdownMenuPopoverProps) { style, children, className, + offset, placement, anchorRef, shouldFlip, isNonModal, - offset = 4, crossOffset, containerPadding = 12, maxBlockSize = 480, @@ -54,13 +54,17 @@ export function DropdownMenuPopover(props: DropdownMenuPopoverProps) { ? '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, - offset, shouldFlip, + // Marks the overlay as a submenu for React Aria. + trigger: context.trigger, crossOffset, maxBlockSize, containerPadding, @@ -79,6 +83,7 @@ export function DropdownMenuPopover(props: DropdownMenuPopoverProps) { isNonModal: isNonModal ?? context.isNonModal, shouldCloseOnInteractOutside: context.shouldCloseOnInteractOutside, placement: placement ?? defaultPlacement, + offset: offset ?? defaultOffset, }, slotProps?.popover ); diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts index 05e466184..a73bf2ed8 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/types.ts @@ -24,7 +24,7 @@ export type DropdownMenuPopoverProps = { placement?: DropdownMenuPropPlacement; /** * The additional offset along the main axis between the popover and its trigger. - * @default 4 + * @default 4 for a menu, -4 for a submenu */ offset?: number; /** The additional offset along the cross axis between the popover and its trigger. */ 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 bec8ab14f..35137b19d 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, mergeProps, useBoolean, useDOMRef } from '@koobiq/react-core'; import { @@ -13,6 +13,7 @@ import type { TransitionProps } from 'react-transition-group/Transition'; import { Dialog } from '../Dialog'; import s from './Popover.module.css'; +import { PopoverGroupContext } from './PopoverGroupContext'; import type { PopoverInnerProps } from './types'; import { normalizeInlineSize } from './utils'; @@ -26,6 +27,7 @@ export const PopoverInner: FC = (props) => { placement: placementProp = 'top', maxBlockSize = 480, type = 'dialog', + trigger, hideArrow, shouldFlip, state, @@ -50,6 +52,11 @@ export const PopoverInner: FC = (props) => { const controlRef = useRef(null); + // 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); @@ -78,6 +85,7 @@ export const PopoverInner: FC = (props) => { placement: placementProp, shouldCloseOnInteractOutside, triggerRef: anchorRef || controlRef, + groupRef: groupContainer ?? containerRef, isKeyboardDismissDisabled: disableExitOnEscapeKeyDown, }, { ...state, isOpen: openState || opened } @@ -124,6 +132,37 @@ export const PopoverInner: FC = (props) => { ? children({ close: state.close }) : children; + const renderPopover = (transition: string) => ( +
+ {showArrow &&
} +
+ {type === 'dialog' ? ( + {resolvedChildren} + ) : ( + resolvedChildren + )} +
+
+ ); + return ( <> {control?.({ @@ -131,42 +170,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 ba048e22e..11ef50431 100644 --- a/packages/components/src/components/Popover/types.ts +++ b/packages/components/src/components/Popover/types.ts @@ -26,8 +26,7 @@ export type PopoverBodyProps = DialogBodyProps; export type PopoverFooterProps = DialogFooterProps; export type PopoverPropContent = - | ReactNode - | ((props: { close(): void }) => ReactElement); + ReactNode | ((props: { close(): void }) => ReactElement); export type PopoverPropControl = ( props: ButtonOptions & { ref?: Ref } @@ -53,8 +52,7 @@ export type PopoverPropPlacement = (typeof popoverPropPlacement)[number]; export const popoverPropSize = ['small', 'medium', 'large'] as const; export type PopoverPropSize = - | (typeof popoverPropSize)[number] - | CSSProperties['inlineSize']; + (typeof popoverPropSize)[number] | CSSProperties['inlineSize']; export const popoverPropType = [ 'dialog', @@ -195,4 +193,9 @@ export type PopoverInnerProps = { * 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/tools/public_api_guard/components/Popover.api.md b/tools/public_api_guard/components/Popover.api.md index 63d07248f..3b90fa4a8 100644 --- a/tools/public_api_guard/components/Popover.api.md +++ b/tools/public_api_guard/components/Popover.api.md @@ -57,6 +57,7 @@ export type PopoverInnerProps = { state: OverlayTriggerState; popoverRef?: Ref; anchorRef?: RefObject; + trigger?: string; } & Omit; // @public (undocumented) From a352236b1658f864104539d9e56c39f890ac282c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 11 Aug 2026 14:12:24 +0300 Subject: [PATCH 06/15] test(DropdownMenu): fix async interaction warnings --- .../DropdownMenu/DropdownMenu.test.tsx | 70 +++++++++++++------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx index 9a218fad9..3eac0da9a 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -1,16 +1,22 @@ -import { useState } from 'react'; +import { createRef, useState } from 'react'; import { once } from '@koobiq/logger'; -import { render, screen, waitFor, within } from '@testing-library/react'; +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 { DropdownMenu, dropdownMenuPropTrigger } from './index.js'; +import { + DropdownMenu, + dropdownMenuPropTrigger, + type DropdownMenuContentProps, + type DropdownMenuPopoverProps, + type DropdownMenuProps, +} from './index.js'; const onAction = vi.fn(); -const onOpenChange = vi.fn((value) => value); +const onOpenChange = vi.fn(); const onSelectionChange = vi.fn(); const onInputChange = vi.fn(); @@ -25,9 +31,9 @@ const open = async () => { }; type FixtureProps = { - contentProps?: Record; - popoverProps?: Record; - rootProps?: Record; + contentProps?: Omit; + popoverProps?: Omit; + rootProps?: Omit; }; function Fixture({ contentProps, popoverProps, rootProps }: FixtureProps = {}) { @@ -51,6 +57,7 @@ describe('DropdownMenu', () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -77,7 +84,7 @@ describe('DropdownMenu', () => { }); it('should forward a ref to the popover element', async () => { - const ref = { current: null } as { current: HTMLElement | null }; + const ref = createRef(); render(); await open(); @@ -126,12 +133,12 @@ describe('DropdownMenu', () => { await open(); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange.mock.results[0]?.value).toBe(true); + expect(onOpenChange).toHaveBeenLastCalledWith(true); await userEvent.keyboard('{Escape}'); expect(onOpenChange).toHaveBeenCalledTimes(2); - expect(onOpenChange.mock.results[1]?.value).toBe(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false); }); it('should close on Escape', async () => { @@ -173,8 +180,12 @@ describe('DropdownMenu', () => { if (trigger === 'longPress') { await userEvent.pointer({ keys: '[TouchA>]', target: getControl() }); - await new Promise((resolve) => { - setTimeout(resolve, 600); + // 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() }); @@ -470,7 +481,7 @@ describe('DropdownMenu', () => { ); it('should render addons and text inside an item', async () => { - renderComposed(); + renderComposed('Copy'); await open(); expect(screen.getByTestId('start-addon')).toBeInTheDocument(); @@ -496,8 +507,6 @@ describe('DropdownMenu', () => { expect(warn).toHaveBeenCalledWith( expect.stringContaining('DropdownMenu.Item') ); - - warn.mockRestore(); }); it('should not warn when a textValue is given', async () => { @@ -508,8 +517,6 @@ describe('DropdownMenu', () => { await open(); expect(warn).not.toHaveBeenCalled(); - - warn.mockRestore(); }); }); @@ -626,6 +633,24 @@ describe('DropdownMenu', () => { 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(); @@ -701,7 +726,8 @@ describe('DropdownMenu', () => { await userEvent.type(getSearch(), 'pas'); await waitFor(() => expect(getItems()).toHaveLength(1)); - await userEvent.keyboard('{Escape}'); + await pressEscape(); + await waitFor(() => expect(getItems()).toHaveLength(3)); expect(getSearch()).toHaveValue(''); expect(getMenu()).toBeInTheDocument(); @@ -713,8 +739,12 @@ describe('DropdownMenu', () => { await userEvent.type(getSearch(), 'pas'); await waitFor(() => expect(getItems()).toHaveLength(1)); - // The first Escape clears the query, the second closes the menu. - await userEvent.keyboard('{Escape}{Escape}'); + // 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(); From a9f114ccd403dc381d008d0ab4cb437bbed839d8 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 11 Aug 2026 15:34:54 +0300 Subject: [PATCH 07/15] feat(DropdownMenu): use shared Divider and SearchInput components --- .../components/DropdownMenu/DropdownMenu.mdx | 14 +++-- .../DropdownMenu/DropdownMenu.stories.tsx | 23 ++++---- .../DropdownMenu/DropdownMenu.test.tsx | 14 ++--- .../components/DropdownMenu/DropdownMenu.tsx | 6 --- .../DropdownMenuAutocomplete.module.css} | 0 .../DropdownMenuAutocomplete.tsx | 29 ++++++++-- .../DropdownMenuSearchInput.tsx | 53 ------------------- .../DropdownMenuSearchInput/index.ts | 2 - .../DropdownMenuSearchInput/types.ts | 3 -- .../DropdownMenuSeparator.module.css | 9 ---- .../DropdownMenuSeparator.tsx | 17 ------ .../components/DropdownMenuSeparator/index.ts | 2 - .../components/DropdownMenuSeparator/types.ts | 10 ---- .../DropdownMenu/components/index.ts | 2 - .../components/SearchInput/SearchInput.tsx | 40 ++++++++++++-- .../SearchInput/SearchInputContext.ts | 15 ++++++ .../components/DropdownMenu.api.md | 37 +------------ 17 files changed, 104 insertions(+), 172 deletions(-) rename packages/components/src/components/DropdownMenu/components/{DropdownMenuSearchInput/DropdownMenuSearchInput.module.css => DropdownMenuAutocomplete/DropdownMenuAutocomplete.module.css} (100%) delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts delete mode 100644 packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts create mode 100644 packages/components/src/components/SearchInput/SearchInputContext.ts diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index acd1200fe..1d5cc7e7f 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -22,7 +22,7 @@ adding submenus and search on top of it. ## Import ```tsx -import { DropdownMenu } from '@koobiq/react-components'; +import { Divider, DropdownMenu, SearchInput } from '@koobiq/react-components'; ``` ## Usage @@ -46,10 +46,8 @@ The component has the following helper components: - `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.Separator` — a line between groups of items - `DropdownMenu.SubmenuTrigger` — wraps an item and the submenu it opens - `DropdownMenu.Autocomplete` — filters the menu with a field -- `DropdownMenu.SearchInput` — the search field for an `Autocomplete` - `DropdownMenu.Footer` — text under the items, outside the scroll area - `DropdownMenu.Pressable` — makes custom markup usable as a trigger. @@ -173,7 +171,7 @@ so one menu can mix plain actions with a multiple and a single choice. ## Separators -Separators may be added between items or sections in order to create non-labeled groupings. +Use `Divider` between items or sections to create non-labeled groupings. `DropdownMenu.Header` renders arbitrary content inside the menu. @@ -192,10 +190,10 @@ so its behaviour may change in a future release. ## Search -Wrap the content in a `DropdownMenu.Autocomplete` and put a `DropdownMenu.SearchInput` -next to it. 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. Matching is case- and -accent-insensitive; pass `filter` to the autocomplete to change it. +Wrap the content in a `DropdownMenu.Autocomplete` and put a `SearchInput` next to it. +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. Matching is case- and accent-insensitive; pass +`filter` to the autocomplete to change it. Focus stays in the search field while the arrow keys move through the items. An item that opens a submenu is kept when its own text matches, but the contents diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx index 09fe46620..767877660 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -26,6 +26,7 @@ import { Button } from '../Button'; import { Divider } from '../Divider'; import { FlexBox } from '../FlexBox'; import { spacing } from '../layout'; +import { SearchInput } from '../SearchInput'; import { SelectNext as Select } from '../SelectNext'; import { Typography } from '../Typography'; @@ -45,10 +46,8 @@ const meta = { 'DropdownMenu.ItemAddon': DropdownMenu.ItemAddon, 'DropdownMenu.Section': DropdownMenu.Section, 'DropdownMenu.Header': DropdownMenu.Header, - 'DropdownMenu.Separator': DropdownMenu.Separator, 'DropdownMenu.SubmenuTrigger': DropdownMenu.SubmenuTrigger, 'DropdownMenu.Autocomplete': DropdownMenu.Autocomplete, - 'DropdownMenu.SearchInput': DropdownMenu.SearchInput, 'DropdownMenu.Footer': DropdownMenu.Footer, }, parameters: { @@ -259,7 +258,7 @@ export const Sections: Story = { New Open - + Copy Cut @@ -311,7 +310,7 @@ export const SectionsDynamic: Story = { )} ) : ( - + ) } @@ -355,7 +354,7 @@ export const WithSectionLevelSelection: Story = { - + Underline - + @Sophia - + @@ -481,14 +480,14 @@ export const Separators: Story = { Settings - + Contact support - + @@ -532,7 +531,7 @@ export const Submenu: Story = { - + Delete @@ -571,7 +570,7 @@ export const Search: Story = { - + alert(key)}> {items.map((item) => ( @@ -602,7 +601,7 @@ export const SearchEmpty: Story = { - + New diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx index 3eac0da9a..9359761a5 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -6,6 +6,8 @@ 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, @@ -372,7 +374,7 @@ describe('DropdownMenu', () => { }); }); - describe('sections, headers and separators', () => { + describe('sections, headers and dividers', () => { const renderSections = () => render( @@ -382,7 +384,7 @@ describe('DropdownMenu', () => { Copy - + Danger Delete @@ -408,7 +410,7 @@ describe('DropdownMenu', () => { expect(screen.getByRole('group', { name: 'Danger' })).toBeInTheDocument(); }); - it('should render a separator', async () => { + it('should render a divider', async () => { renderSections(); await open(); @@ -613,7 +615,7 @@ describe('DropdownMenu', () => { - + Copy Paste @@ -767,7 +769,7 @@ describe('DropdownMenu', () => { setValue(next); }} > - + Copy Paste @@ -796,7 +798,7 @@ describe('DropdownMenu', () => { - + Copy diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx index 6a1be0871..09f52179f 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.tsx @@ -13,9 +13,7 @@ import { DropdownMenuHeader, DropdownMenuItem, DropdownMenuPopover, - DropdownMenuSearchInput, DropdownMenuSection, - DropdownMenuSeparator, DropdownMenuSubmenuTrigger, } from './components'; import type { DropdownMenuComponent, DropdownMenuProps } from './types'; @@ -34,10 +32,8 @@ type CompoundedComponent = DropdownMenuComponent & { ItemAddon: typeof ListItemAddon; Section: typeof DropdownMenuSection; Header: typeof DropdownMenuHeader; - Separator: typeof DropdownMenuSeparator; SubmenuTrigger: typeof DropdownMenuSubmenuTrigger; Autocomplete: typeof DropdownMenuAutocomplete; - SearchInput: typeof DropdownMenuSearchInput; Footer: typeof DropdownMenuFooter; Pressable: typeof Pressable; }; @@ -56,10 +52,8 @@ export const DropdownMenu: CompoundedComponent = Object.assign( ItemAddon: ListItemAddon, Section: DropdownMenuSection, Header: DropdownMenuHeader, - Separator: DropdownMenuSeparator, SubmenuTrigger: DropdownMenuSubmenuTrigger, Autocomplete: DropdownMenuAutocomplete, - SearchInput: DropdownMenuSearchInput, Footer: DropdownMenuFooter, Pressable, } diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.module.css similarity index 100% rename from packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.module.css rename to packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.module.css diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx index 2efefb44f..39dc23fee 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuAutocomplete/DropdownMenuAutocomplete.tsx @@ -1,8 +1,16 @@ 'use client'; -import { useFilter } from '@koobiq/react-core'; -import { Autocomplete as AriaAutocomplete } from '@koobiq/react-primitives'; +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'; /** @@ -13,8 +21,23 @@ import type { DropdownMenuAutocompleteProps } from './types'; */ 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 ; + return ( + + + + ); } DropdownMenuAutocomplete.displayName = 'DropdownMenu.Autocomplete'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx deleted file mode 100644 index 0fd247b89..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/DropdownMenuSearchInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import { useRef } from 'react'; - -import { - mergeProps, - useMultiRef, - useLocalizedStringFormatter, -} from '@koobiq/react-core'; -import { FieldInputContext, useSlottedContext } from '@koobiq/react-primitives'; - -import { SearchInput, type SearchInputProps } from '../../../SearchInput'; -import intlMessages from '../../intl'; - -import s from './DropdownMenuSearchInput.module.css'; -import type { DropdownMenuSearchInputProps } from './types'; - -/** - * The search field of a dropdown menu. React Aria passes the input props - * through a context, so this bridges them onto the Koobiq `SearchInput`. - */ -export function DropdownMenuSearchInput(props: DropdownMenuSearchInputProps) { - const t = useLocalizedStringFormatter(intlMessages); - - const domRef = useRef(null); - - const { ref: contextRef, ...autocompleteInputProps } = - useSlottedContext(FieldInputContext) ?? {}; - - const inputRef = useMultiRef([contextRef, domRef]); - - // Autocomplete props go last so its value, handlers and - // `aria-activedescendant` win over anything passed in. - const searchInputProps = mergeProps< - [SearchInputProps, SearchInputProps, SearchInputProps] - >( - { - autoFocus: true, - fullWidth: true, - isLabelHidden: true, - className: s.search, - variant: 'transparent', - placeholder: t.format('search'), - 'aria-label': t.format('search'), - }, - props, - autocompleteInputProps - ); - - return ; -} - -DropdownMenuSearchInput.displayName = 'DropdownMenu.SearchInput'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts deleted file mode 100644 index 512bb16a3..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DropdownMenuSearchInput'; -export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts deleted file mode 100644 index fd497f7dc..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSearchInput/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SearchInputProps } from '../../../SearchInput'; - -export type DropdownMenuSearchInputProps = SearchInputProps; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css deleted file mode 100644 index 741008d8a..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.module.css +++ /dev/null @@ -1,9 +0,0 @@ -/* React Aria renders the separator, so only the `Divider` look is repeated. */ -.base { - border: 0; - block-size: 0; - flex-shrink: 0; - inline-size: 100%; - margin-block: var(--kbq-size-xxs); - border-block-end: 1px solid var(--kbq-line-contrast-less); -} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx deleted file mode 100644 index dea7bfa3a..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/DropdownMenuSeparator.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -import { clsx } from '@koobiq/react-core'; -import { Separator as AriaSeparator } from '@koobiq/react-primitives'; - -import s from './DropdownMenuSeparator.module.css'; -import type { DropdownMenuSeparatorProps } from './types'; - -/** A line that visually separates groups of items inside a dropdown menu. */ -export function DropdownMenuSeparator({ - className, - ...props -}: DropdownMenuSeparatorProps) { - return ; -} - -DropdownMenuSeparator.displayName = 'DropdownMenu.Separator'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts deleted file mode 100644 index 44ce4377f..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './DropdownMenuSeparator'; -export * from './types'; diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts deleted file mode 100644 index 77364db6c..000000000 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuSeparator/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { CSSProperties } from 'react'; - -import type { DataAttributeProps } from '@koobiq/react-core'; - -export type DropdownMenuSeparatorProps = { - /** Additional CSS-classes. */ - className?: string; - /** Inline styles. */ - style?: CSSProperties; -} & DataAttributeProps; diff --git a/packages/components/src/components/DropdownMenu/components/index.ts b/packages/components/src/components/DropdownMenu/components/index.ts index 0e3de32d3..7c8ee41e1 100644 --- a/packages/components/src/components/DropdownMenu/components/index.ts +++ b/packages/components/src/components/DropdownMenu/components/index.ts @@ -4,7 +4,5 @@ export * from './DropdownMenuFooter'; export * from './DropdownMenuHeader'; export * from './DropdownMenuItem'; export * from './DropdownMenuPopover'; -export * from './DropdownMenuSearchInput'; export * from './DropdownMenuSection'; -export * from './DropdownMenuSeparator'; export * from './DropdownMenuSubmenuTrigger'; 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/tools/public_api_guard/components/DropdownMenu.api.md b/tools/public_api_guard/components/DropdownMenu.api.md index 38025ec48..937543b14 100644 --- a/tools/public_api_guard/components/DropdownMenu.api.md +++ b/tools/public_api_guard/components/DropdownMenu.api.md @@ -5,7 +5,6 @@ ```ts import type { AriaDialogProps } from '@koobiq/react-primitives'; -import type { AriaSearchFieldProps } from '@koobiq/react-primitives'; import type { AutocompleteProps } from '@koobiq/react-primitives'; import type { ButtonBaseProps as ButtonBaseProps_2 } from '@koobiq/react-primitives'; import type { ButtonOptions } from '@koobiq/react-primitives'; @@ -13,9 +12,8 @@ import type { ComponentProps } from 'react'; import type { ComponentPropsWithRef } from 'react'; import type { CSSProperties } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; -import type { DOMAttributes } from '@koobiq/react-core'; import type { ElementType } from 'react'; -import { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; import type { ExtendableProps } from '@koobiq/react-core'; import { ForwardRefExoticComponent } from 'react'; import type { HeaderProps } from '@koobiq/react-primitives'; @@ -27,12 +25,10 @@ import { PolyForwardComponent } from '@koobiq/react-core'; import { Pressable } from '@koobiq/react-core'; import type { ReactElement } from 'react'; import { ReactNode } from 'react'; -import { Ref } from 'react'; +import type { Ref } from 'react'; import { RefAttributes } from 'react'; import type { RefObject } from 'react'; -import { TextProps } from '@koobiq/react-primitives'; import type { TransitionProps } from 'react-transition-group/Transition'; -import { ValidationResult } from '@koobiq/react-core'; // Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts // @@ -182,20 +178,6 @@ export type DropdownMenuPropTrigger = (typeof dropdownMenuPropTrigger)[number]; // @public (undocumented) export const dropdownMenuPropTrigger: readonly ["press", "longPress"]; -// @public -export function DropdownMenuSearchInput(props: DropdownMenuSearchInputProps): JSX.Element; - -// @public (undocumented) -export namespace DropdownMenuSearchInput { - var // (undocumented) - displayName: string; -} - -// Warning: (ae-forgotten-export) The symbol "SearchInputProps" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type DropdownMenuSearchInputProps = SearchInputProps; - // @public export function DropdownMenuSection(input: DropdownMenuSectionProps): JSX.Element; @@ -210,21 +192,6 @@ export type DropdownMenuSectionProps = MenuSectionPro title?: ReactNode; }; -// @public -export function DropdownMenuSeparator(input: DropdownMenuSeparatorProps): JSX.Element; - -// @public (undocumented) -export namespace DropdownMenuSeparator { - var // (undocumented) - displayName: string; -} - -// @public (undocumented) -export type DropdownMenuSeparatorProps = { - className?: string; - style?: CSSProperties; -} & DataAttributeProps; - // @public export function DropdownMenuSubmenuTrigger(props: DropdownMenuSubmenuTriggerProps): JSX.Element; From 4276568172a1ddf2d7daa915686b329b26259167 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 11 Aug 2026 16:20:49 +0300 Subject: [PATCH 08/15] refactor(DropdownMenu): use RAC renderEmptyState prop --- .../src/components/DropdownMenu/DropdownMenu.mdx | 3 ++- .../DropdownMenu/DropdownMenu.stories.tsx | 9 ++++++++- .../components/DropdownMenu/DropdownMenu.test.tsx | 4 ++-- .../DropdownMenuContent/DropdownMenuContent.tsx | 15 +++++++++------ .../components/DropdownMenuContent/types.ts | 9 ++------- .../components/DropdownMenu.api.md | 4 +--- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index 1d5cc7e7f..5233b3e82 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -201,7 +201,8 @@ of a submenu are not searched. -Use `noItemsText` of the content to change the message shown when nothing matches. +Use `renderEmptyState` of the content to replace the default message with any custom +content, such as an `EmptyState`. diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx index 767877660..1040bc4c3 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -24,6 +24,7 @@ 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'; @@ -603,7 +604,13 @@ export const SearchEmpty: Story = { - + ( + + No such action + + )} + > New Open Save diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx index 9359761a5..931804cb1 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -702,8 +702,8 @@ describe('DropdownMenu', () => { expect(await screen.findByText('Nothing found')).toBeInTheDocument(); }); - it('should render a custom noItemsText', async () => { - renderSearchable({ noItemsText: 'No results' }); + it('should render a custom empty state', async () => { + renderSearchable({ renderEmptyState: () =>
No results
}); await open(); await userEvent.type(getSearch(), 'zzz'); diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx index 2b9d05f08..c4850436a 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/DropdownMenuContent.tsx @@ -21,7 +21,7 @@ const { list } = utilClasses; /** The list of items of a dropdown menu. */ export function DropdownMenuContent({ className, - noItemsText, + renderEmptyState, ...props }: DropdownMenuContentProps) { const t = useLocalizedStringFormatter(intlMessages); @@ -34,11 +34,14 @@ export function DropdownMenuContent({ return ( data-padded - renderEmptyState={() => ( -
- {noItemsText ?? t.format(query ? 'nothing found' : 'empty items')} -
- )} + renderEmptyState={ + renderEmptyState ?? + (() => ( +
+ {t.format(query ? 'nothing found' : 'empty items')} +
+ )) + } className={composeRenderProps(className, (className) => clsx(s.base, list, className) )} diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts index e98fc89cd..bc67f545a 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuContent/types.ts @@ -1,13 +1,8 @@ -import type { ReactNode } from 'react'; - import type { DataAttributeProps } from '@koobiq/react-core'; import type { MenuProps as AriaMenuProps } from '@koobiq/react-primitives'; export type DropdownMenuContentProps = Omit< AriaMenuProps, - 'renderEmptyState' | 'slot' + 'slot' > & - DataAttributeProps & { - /** Content to display when there are no items to show. */ - noItemsText?: ReactNode; - }; + DataAttributeProps; diff --git a/tools/public_api_guard/components/DropdownMenu.api.md b/tools/public_api_guard/components/DropdownMenu.api.md index 937543b14..ffa2ad031 100644 --- a/tools/public_api_guard/components/DropdownMenu.api.md +++ b/tools/public_api_guard/components/DropdownMenu.api.md @@ -60,9 +60,7 @@ export namespace DropdownMenuContent { } // @public (undocumented) -export type DropdownMenuContentProps = Omit, 'renderEmptyState' | 'slot'> & DataAttributeProps & { - noItemsText?: ReactNode; -}; +export type DropdownMenuContentProps = Omit, 'slot'> & DataAttributeProps; // @public export function DropdownMenuFooter(props: DropdownMenuFooterProps): JSX.Element; From aa58c474a94c8c88d75a653396f254f14f785ec0 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 11 Aug 2026 23:34:56 +0300 Subject: [PATCH 09/15] fix(DropdownMenu): support virtual focus for text fields --- .../components/src/components/Input/Input.tsx | 2 +- .../components/src/components/Input/types.ts | 2 +- .../src/components/Textarea/Textarea.tsx | 2 +- .../src/components/Textarea/types.ts | 1 - .../primitives/src/components/Input/Input.tsx | 26 +---------- .../src/components/Input/InputContext.tsx | 8 ++-- .../primitives/src/components/Input/types.ts | 8 +--- .../components/TextField/TextField.test.tsx | 46 +++++++++++++++++++ .../src/components/TextField/TextField.tsx | 41 ++++++++++++++--- .../src/components/TextField/types.ts | 1 - .../src/components/Textarea/Textarea.tsx | 30 +----------- .../components/Textarea/TextareaContext.tsx | 9 ++-- .../src/components/Textarea/types.ts | 8 +--- .../public_api_guard/components/Input.api.md | 4 +- .../components/Textarea.api.md | 4 +- .../public_api_guard/react-primitives.api.md | 42 ++++++++--------- 16 files changed, 119 insertions(+), 115 deletions(-) create mode 100644 packages/primitives/src/components/TextField/TextField.test.tsx 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/Input/types.ts b/packages/components/src/components/Input/types.ts index ef55ccf1d..e7ce17729 100644 --- a/packages/components/src/components/Input/types.ts +++ b/packages/components/src/components/Input/types.ts @@ -115,7 +115,7 @@ export type InputProps = ExtendableProps< } & InputDeprecatedProps, Omit< TextFieldProps, - 'description' | 'children' | 'inputElementType' | 'validationState' + 'description' | 'children' | 'validationState' > >; 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) => ( >; diff --git a/packages/primitives/src/components/Input/Input.tsx b/packages/primitives/src/components/Input/Input.tsx index 6a024ac4f..945a9906b 100644 --- a/packages/primitives/src/components/Input/Input.tsx +++ b/packages/primitives/src/components/Input/Input.tsx @@ -1,27 +1,3 @@ 'use client'; -import { forwardRef } from 'react'; - -import { mergeProps, useMultiRef } from '@koobiq/react-core'; - -import { type InputProps, type InputRef, useInputContext } from './index'; - -export const Input = forwardRef((props, ref) => { - const { children, ...other } = props; - - const defaultProps = useInputContext(); - const commonProps = mergeProps(defaultProps, other); - - const innerRef = useMultiRef([ - ref, - ...(defaultProps.ref ? [defaultProps.ref] : []), - ]); - - return ( - - {children} - - ); -}); - -Input.displayName = 'Input'; +export { Input } from 'react-aria-components'; diff --git a/packages/primitives/src/components/Input/InputContext.tsx b/packages/primitives/src/components/Input/InputContext.tsx index 9b6fa279d..e0aacea33 100644 --- a/packages/primitives/src/components/Input/InputContext.tsx +++ b/packages/primitives/src/components/Input/InputContext.tsx @@ -1,9 +1,7 @@ 'use client'; -import { createContext, useContext } from 'react'; +import { InputContext, useSlottedContext } from 'react-aria-components'; -import type { InputProps } from './index'; +export { InputContext }; -export const InputContext = createContext({}); - -export const useInputContext = () => useContext(InputContext); +export const useInputContext = () => useSlottedContext(InputContext) ?? {}; diff --git a/packages/primitives/src/components/Input/types.ts b/packages/primitives/src/components/Input/types.ts index ef9a9cfe9..f9542df66 100644 --- a/packages/primitives/src/components/Input/types.ts +++ b/packages/primitives/src/components/Input/types.ts @@ -1,9 +1,5 @@ -import type { ComponentRef, HTMLAttributes, ReactNode, Ref } from 'react'; +import type { ComponentRef } from 'react'; -export type InputProps = { - children?: ReactNode; - value?: string | number | readonly string[]; - ref?: Ref; -} & HTMLAttributes; +export type { InputProps } from 'react-aria-components'; export type InputRef = ComponentRef<'input'>; diff --git a/packages/primitives/src/components/TextField/TextField.test.tsx b/packages/primitives/src/components/TextField/TextField.test.tsx new file mode 100644 index 000000000..22656c08a --- /dev/null +++ b/packages/primitives/src/components/TextField/TextField.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Autocomplete, Menu, MenuItem } from 'react-aria-components'; +import { describe, expect, it } from 'vitest'; + +import { Input } from '../Input'; +import { Textarea } from '../Textarea'; + +import { TextField } from './TextField'; + +describe('TextField primitive', () => { + it.each([ + ['Input', () => ], + ['Textarea', () => - ); -}); - -Textarea.displayName = 'Textarea'; +export { TextArea as Textarea } from 'react-aria-components'; diff --git a/packages/primitives/src/components/Textarea/TextareaContext.tsx b/packages/primitives/src/components/Textarea/TextareaContext.tsx index a0e46ef58..2aee0b671 100644 --- a/packages/primitives/src/components/Textarea/TextareaContext.tsx +++ b/packages/primitives/src/components/Textarea/TextareaContext.tsx @@ -1,9 +1,8 @@ 'use client'; -import { createContext, useContext } from 'react'; +import { TextAreaContext, useSlottedContext } from 'react-aria-components'; -import type { TextareaProps } from './index'; +export { TextAreaContext as TextareaContext }; -export const TextareaContext = createContext({}); - -export const useTextareaContext = () => useContext(TextareaContext); +export const useTextareaContext = () => + useSlottedContext(TextAreaContext) ?? {}; diff --git a/packages/primitives/src/components/Textarea/types.ts b/packages/primitives/src/components/Textarea/types.ts index c47d0dc6d..aae73118a 100644 --- a/packages/primitives/src/components/Textarea/types.ts +++ b/packages/primitives/src/components/Textarea/types.ts @@ -1,9 +1,5 @@ -import type { ComponentRef, HTMLAttributes, ReactNode, Ref } from 'react'; +import type { ComponentRef } from 'react'; -export type TextareaProps = { - children?: ReactNode; - value?: string | number | readonly string[]; - ref?: Ref; -} & HTMLAttributes; +export type { TextAreaProps as TextareaProps } from 'react-aria-components'; export type TextareaRef = ComponentRef<'textarea'>; diff --git a/tools/public_api_guard/components/Input.api.md b/tools/public_api_guard/components/Input.api.md index e518aebeb..5d5a627aa 100644 --- a/tools/public_api_guard/components/Input.api.md +++ b/tools/public_api_guard/components/Input.api.md @@ -24,7 +24,7 @@ import { TextProps } from '@koobiq/react-primitives'; import { ValidationResult } from '@koobiq/react-core'; // @public -export const Input: ForwardRefExoticComponent, "children" | "description" | "validationState" | "inputElementType">, "caption" | "style" | "className" | "data-testid" | "startAddon" | "endAddon" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isClearable" | "isLabelHidden" | "onClear" | keyof { +export const Input: ForwardRefExoticComponent, "children" | "description" | "validationState">, "caption" | "style" | "className" | "data-testid" | "startAddon" | "endAddon" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isClearable" | "isLabelHidden" | "onClear" | keyof { disabled?: boolean; error?: boolean; required?: boolean; @@ -103,7 +103,7 @@ export type InputProps = ExtendableProps<{ clearButton?: IconButtonProps; input?: FormFieldInputProps; }; -} & InputDeprecatedProps, Omit, 'description' | 'children' | 'inputElementType' | 'validationState'>>; +} & InputDeprecatedProps, Omit, 'description' | 'children' | 'validationState'>>; // Warning: (ae-forgotten-export) The symbol "FormFieldControlGroupPropVariant" needs to be exported by the entry point index.d.ts // diff --git a/tools/public_api_guard/components/Textarea.api.md b/tools/public_api_guard/components/Textarea.api.md index ca6eb8ce6..92254b9f2 100644 --- a/tools/public_api_guard/components/Textarea.api.md +++ b/tools/public_api_guard/components/Textarea.api.md @@ -24,7 +24,7 @@ error?: boolean; required?: boolean; hiddenLabel?: boolean; readonly?: boolean; -} & Omit, "children" | "style" | "className" | "description" | "isClearable" | "validationState" | "inputElementType">, "caption" | "style" | "className" | "cols" | "data-testid" | "rows" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isLabelHidden" | "expand"> & { +} & Omit, "children" | "style" | "className" | "description" | "isClearable" | "validationState">, "caption" | "style" | "className" | "cols" | "data-testid" | "rows" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isLabelHidden" | "expand"> & { className?: string; style?: CSSProperties; variant?: TextareaPropVariant; @@ -89,7 +89,7 @@ export type TextareaProps = ExtendableProps<{ textarea?: FormFieldInputProps<'textarea'>; errorMessage?: FormFieldErrorProps; }; -}, TextareaDeprecatedProps & Omit, 'description' | 'validationState' | 'children' | 'style' | 'className' | 'inputElementType' | 'isClearable'>>; +}, TextareaDeprecatedProps & Omit, 'description' | 'validationState' | 'children' | 'style' | 'className' | 'isClearable'>>; // Warning: (ae-forgotten-export) The symbol "FormFieldControlGroupPropVariant" needs to be exported by the entry point index.d.ts // diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index b7291cc71..8206c262f 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -81,7 +81,7 @@ import type { FocusStrategy } from '@koobiq/react-core'; import { FormEventHandler } from 'react'; import type { FormProps as FormProps_2 } from '@koobiq/react-core'; import type { FormValidationState } from '@react-stately/form'; -import type { ForwardedRef } from 'react'; +import { ForwardedRef } from 'react'; import { ForwardRefExoticComponent } from 'react'; import { getItemCount } from '@react-stately/collections'; import type { GlobalDOMAttributes } from '@koobiq/react-core'; @@ -92,9 +92,12 @@ import type { HoverEvents } from '@koobiq/react-core'; import type { HTMLAttributes } from 'react'; import { HTMLInputAutoCompleteAttribute } from 'react'; import { HTMLInputTypeAttribute } from 'react'; +import { Input } from 'react-aria-components'; import { InputBase } from '@koobiq/react-core'; +import { InputContext } from 'react-aria-components'; import { InputEventHandler } from 'react'; import { InputHTMLAttributes } from 'react'; +import { InputProps } from 'react-aria-components'; import { Item } from '@react-stately/collections'; import type { Key } from 'react'; import type { Key as Key_2 } from '@koobiq/react-core'; @@ -137,6 +140,9 @@ import type { RouterOptions } from '@koobiq/react-core'; import type { Selection as Selection_2 } from '@koobiq/react-core'; import { Separator } from 'react-aria-components'; import { SubmenuTrigger } from 'react-aria-components'; +import { TextArea as Textarea } from 'react-aria-components'; +import { TextAreaContext as TextareaContext } from 'react-aria-components'; +import { TextAreaProps as TextareaProps } from 'react-aria-components'; import type { TextFieldAria } from '@react-aria/textfield'; import { TextInputBase } from '@koobiq/react-core'; import { ToggleEventHandler } from 'react'; @@ -406,18 +412,11 @@ export { Header } export { HeaderProps } -// @public (undocumented) -export const Input: ForwardRefExoticComponent & RefAttributes>; +export { Input } -// @public (undocumented) -export const InputContext: Context; +export { InputContext } -// @public (undocumented) -export type InputProps = { - children?: ReactNode; - value?: string | number | readonly string[]; - ref?: Ref; -} & HTMLAttributes; +export { InputProps } // @public (undocumented) export type InputRef = ComponentRef<'input'>; @@ -785,18 +784,11 @@ export type TagListState = ListState; const Text_2: PolyForwardComponent<"p", TextBaseProps, ElementType>; export { Text_2 as Text } -// @public (undocumented) -export const Textarea: ForwardRefExoticComponent & RefAttributes>; +export { Textarea } -// @public (undocumented) -export const TextareaContext: Context; +export { TextareaContext } -// @public (undocumented) -export type TextareaProps = { - children?: ReactNode; - value?: string | number | readonly string[]; - ref?: Ref; -} & HTMLAttributes; +export { TextareaProps } // @public (undocumented) export type TextareaRef = ComponentRef<'textarea'>; @@ -1000,7 +992,9 @@ export { useContextProps } export const useGroupContext: () => GroupProps; // @public (undocumented) -export const useInputContext: () => InputProps; +export const useInputContext: () => InputProps & { + ref?: ForwardedRef | undefined; +}; // @public (undocumented) export function useLink(props: UseLinkProps, ref: RefObject): { @@ -1433,7 +1427,9 @@ export function useTagListItem(props: AriaTagListItemProps, st export function useTagListState(props: AriaTagListStateProps): TagListState; // @public (undocumented) -export const useTextareaContext: () => TextareaProps; +export const useTextareaContext: () => TextareaProps & { + ref?: ForwardedRef | undefined; +}; export { useToggleButtonGroup } From 339d97aa6c547f10bdf2c02b3e5016f460c7263c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 12 Aug 2026 13:23:34 +0300 Subject: [PATCH 10/15] fix(DropdownMenu): correct chevron color --- .../components/DropdownMenuItem/DropdownMenuItem.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css index f1f579a3c..1d2b1e498 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.module.css @@ -2,6 +2,7 @@ /* 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. */ From b25fcecebf44dcb8be1a63ab5174b4f1c2933876 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 12 Aug 2026 15:26:01 +0300 Subject: [PATCH 11/15] docs(DropdownMenu): improve the documentation --- .../components/DropdownMenu/DropdownMenu.mdx | 122 +++++++++--------- .../components/src/components/Menu/Menu.mdx | 3 - 2 files changed, 59 insertions(+), 66 deletions(-) diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index 5233b3e82..5b034d30e 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -15,9 +15,8 @@ import * as Stories from './DropdownMenu.stories'; A dropdown menu displays a list of actions or options that a user can choose. -It is built on [React Aria Components](https://react-spectrum.adobe.com/react-aria/Menu.html) -and supersedes the [Menu](?path=/docs/components-menu--docs) component, -adding submenus and search on top of it. +Compared to the [Menu](?path=/docs/components-menu--docs) component, it adds +submenus and search. ## Import @@ -42,24 +41,24 @@ 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` — text of an item, with an optional `caption` +- `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` — text under the items, outside the scroll area +- `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 on purpose: anything may sit between them, -which is how search is assembled instead of a prop on the menu. +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 `DropdownMenu` is the trigger. Any component built on the Koobiq -`Button` works without extra props — the interactions reach it on their own. +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 `DropdownMenu.Pressable` +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: @@ -68,33 +67,36 @@ The `trigger` prop switches between opening on press (default) and on a long pre ## Content -The `DropdownMenu.Content` accepts both static and dynamic collections, -so it can display options you know ahead of time or ones that arrive or change later. -For dynamic collections, pass your items via the `items` prop and give each a unique `id`; -when an option is chosen, `onAction` is called with that `id`. +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: `DropdownMenu.ItemAddon` for icons and shortcuts, -and `DropdownMenu.ItemText` for the label and its `caption`. +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 with composed children rather than plain text needs an explicit `textValue`: -it is what typeahead and search match on. Without it the item is unreachable by -keyboard and disappears on the first keystroke in a searchable menu, so the -component warns about it in development. Add `aria-label` when the item needs a -shorter accessible name than the sum of its slots. +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 component supports multiple selection modes. -By default, selection is disabled, however this can be changed using the `selectionMode` prop. -Use `defaultSelectedKeys` to provide a default set of selected items (uncontrolled) -and `selectedKeys` to set the selected items (controlled). -The value of the selected keys must match the `id` prop of the items. +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 @@ -106,25 +108,22 @@ The value of the selected keys must match the `id` prop of the items. ## Disabled items -Items can be marked as disabled with the `disabledKeys` prop of the content, -or with the `isDisabled` prop of a single item. +Items can be marked as disabled with the `disabledKeys` prop of the content, or +with the `isDisabled` prop of a single item. ## Links -By default, interacting with an item triggers `onAction` and optionally -`onSelectionChange` depending on the `selectionMode`. -Alternatively, items may be links to another page or website. -This can be achieved by passing the `href` prop to the `DropdownMenu.Item` component. -Link items in a menu are not selectable. +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 -The `DropdownMenu` component works with frameworks and client side routers like **Next.js** and **React Router**. -As with other React Aria components that support links, this works via the `RouterProvider` component at the root of your app. +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'; @@ -152,7 +151,7 @@ export default function App() { ## Sections -Groups of items can be wrapped in a `DropdownMenu.Section` with a `title`. +Groups of items can be wrapped in a `DropdownMenu.Section` with the `title` prop. ### Static items @@ -164,60 +163,56 @@ Groups of items can be wrapped in a `DropdownMenu.Section` with a `title`. ### With Section Level Selection -A section takes its own `selectionMode`, `selectedKeys` and `onSelectionChange`, -so one menu can mix plain actions with a multiple and a single choice. +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 -Use `Divider` between items or sections to create non-labeled groupings. -`DropdownMenu.Header` renders arbitrary content inside the menu. +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 `delay` (200 ms by default), on ArrowRight and on press, -and closes on ArrowLeft. Submenus can be nested. +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. -Submenu support relies on an API that React Aria still marks as alpha, -so its behaviour may change in a future release. - ## Search -Wrap the content in a `DropdownMenu.Autocomplete` and put a `SearchInput` next to it. -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. Matching is case- and accent-insensitive; pass -`filter` to the autocomplete to change it. +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. -Focus stays in the search field while the arrow keys move through the items. -An item that opens a submenu is kept when its own text matches, but the contents -of a submenu are not searched. +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. -Use `renderEmptyState` of the content to replace the default message with any custom -content, such as an `EmptyState`. +The `renderEmptyState` prop of the content replaces the default message, with an +`EmptyState` for instance. ## Footer -`DropdownMenu.Footer` shows text under the items. It sits next to the menu rather -than inside it, so it stays put while the items scroll. +The `DropdownMenu.Footer` shows text under the items. It stays in place while the +items scroll. ## Placement -The menu's placement with respect to its trigger can be adjusted -using the `placement` prop of the popover. Menus -will also automatically flip to the opposite direction if there isn't enough space. +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. @@ -225,11 +220,12 @@ will also automatically flip to the opposite direction if there isn't enough spa ### Default open -The menu isn't opened by default. The `defaultOpen` prop can be used to set the default state. +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 menu's open state changes. +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/Menu/Menu.mdx b/packages/components/src/components/Menu/Menu.mdx index 4749d0c9a..3b679a0c3 100644 --- a/packages/components/src/components/Menu/Menu.mdx +++ b/packages/components/src/components/Menu/Menu.mdx @@ -15,9 +15,6 @@ import * as Stories from './Menu.stories'; A menu displays a list of actions or options that a user can choose. -For new code prefer [DropdownMenu](?path=/docs/components-dropdownmenu--docs): -it is built on React Aria Components and supports submenus and search. - ## Import ```tsx From 5c1219dff0b63eee8248590efd46a43576e90f1a Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 12 Aug 2026 15:43:01 +0300 Subject: [PATCH 12/15] fix(DropdownMenu): warn when the popover is rendered on its own --- .../components/DropdownMenu/DropdownMenu.mdx | 2 +- .../DropdownMenu/DropdownMenu.test.tsx | 20 +++++++++++++++++++ .../components/DropdownMenuItem/types.ts | 19 +++++++++--------- .../DropdownMenuPopover.tsx | 11 +++++++++- .../components/DropdownMenu.api.md | 2 +- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx index 5b034d30e..ce1a30ef7 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.mdx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.mdx @@ -21,7 +21,7 @@ submenus and search. ## Import ```tsx -import { Divider, DropdownMenu, SearchInput } from '@koobiq/react-components'; +import { DropdownMenu } from '@koobiq/react-components'; ``` ## Usage diff --git a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx index 931804cb1..242710753 100644 --- a/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/components/src/components/DropdownMenu/DropdownMenu.test.tsx @@ -108,6 +108,26 @@ describe('DropdownMenu', () => { 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', () => { diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts index 3e9c45c46..4bb0ffd51 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuItem/types.ts @@ -6,13 +6,12 @@ export const dropdownMenuItemPropAlign = ['start', 'center'] as const; export type DropdownMenuItemPropAlign = (typeof dropdownMenuItemPropAlign)[number]; -export type DropdownMenuItemProps = Partial< - AriaMenuItemProps -> & - DataAttributeProps & { - /** - * Vertical alignment of the item content. - * @default 'center' - */ - align?: DropdownMenuItemPropAlign; - }; +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.tsx b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx index 992fec59a..d33526596 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.tsx @@ -2,6 +2,7 @@ import { useContext } from 'react'; +import { once } from '@koobiq/logger'; import { clsx, mergeProps } from '@koobiq/react-core'; import { PopoverContext, @@ -46,7 +47,15 @@ export function DropdownMenuPopover(props: DropdownMenuPopoverProps) { const state = useContext(OverlayTriggerStateContext); const context = useSlottedContext(PopoverContext) ?? {}; - if (!state) return null; + 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'; diff --git a/tools/public_api_guard/components/DropdownMenu.api.md b/tools/public_api_guard/components/DropdownMenu.api.md index ffa2ad031..c0e09abba 100644 --- a/tools/public_api_guard/components/DropdownMenu.api.md +++ b/tools/public_api_guard/components/DropdownMenu.api.md @@ -109,7 +109,7 @@ export type DropdownMenuItemPropAlign = (typeof dropdownMenuItemPropAlign)[numbe export const dropdownMenuItemPropAlign: readonly ["start", "center"]; // @public (undocumented) -export type DropdownMenuItemProps = Partial> & DataAttributeProps & { +export type DropdownMenuItemProps = MenuItemProps & DataAttributeProps & { align?: DropdownMenuItemPropAlign; }; From 5108258fbd01f8c4dcf7f52f79959737234e1907 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 12 Aug 2026 16:24:27 +0300 Subject: [PATCH 13/15] test(TextField): fix accessibility warnings in Autocomplete tests --- .../primitives/src/components/TextField/TextField.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/primitives/src/components/TextField/TextField.test.tsx b/packages/primitives/src/components/TextField/TextField.test.tsx index 22656c08a..7989d1dc8 100644 --- a/packages/primitives/src/components/TextField/TextField.test.tsx +++ b/packages/primitives/src/components/TextField/TextField.test.tsx @@ -10,14 +10,14 @@ import { TextField } from './TextField'; describe('TextField primitive', () => { it.each([ - ['Input', () => ], - ['Textarea', () => + ); +}); + +Textarea.displayName = 'Textarea'; diff --git a/packages/primitives/src/components/Textarea/TextareaContext.tsx b/packages/primitives/src/components/Textarea/TextareaContext.tsx index 2aee0b671..a0e46ef58 100644 --- a/packages/primitives/src/components/Textarea/TextareaContext.tsx +++ b/packages/primitives/src/components/Textarea/TextareaContext.tsx @@ -1,8 +1,9 @@ 'use client'; -import { TextAreaContext, useSlottedContext } from 'react-aria-components'; +import { createContext, useContext } from 'react'; -export { TextAreaContext as TextareaContext }; +import type { TextareaProps } from './index'; -export const useTextareaContext = () => - useSlottedContext(TextAreaContext) ?? {}; +export const TextareaContext = createContext({}); + +export const useTextareaContext = () => useContext(TextareaContext); diff --git a/packages/primitives/src/components/Textarea/types.ts b/packages/primitives/src/components/Textarea/types.ts index aae73118a..c47d0dc6d 100644 --- a/packages/primitives/src/components/Textarea/types.ts +++ b/packages/primitives/src/components/Textarea/types.ts @@ -1,5 +1,9 @@ -import type { ComponentRef } from 'react'; +import type { ComponentRef, HTMLAttributes, ReactNode, Ref } from 'react'; -export type { TextAreaProps as TextareaProps } from 'react-aria-components'; +export type TextareaProps = { + children?: ReactNode; + value?: string | number | readonly string[]; + ref?: Ref; +} & HTMLAttributes; export type TextareaRef = ComponentRef<'textarea'>; diff --git a/tools/public_api_guard/components/Input.api.md b/tools/public_api_guard/components/Input.api.md index 5d5a627aa..e518aebeb 100644 --- a/tools/public_api_guard/components/Input.api.md +++ b/tools/public_api_guard/components/Input.api.md @@ -24,7 +24,7 @@ import { TextProps } from '@koobiq/react-primitives'; import { ValidationResult } from '@koobiq/react-core'; // @public -export const Input: ForwardRefExoticComponent, "children" | "description" | "validationState">, "caption" | "style" | "className" | "data-testid" | "startAddon" | "endAddon" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isClearable" | "isLabelHidden" | "onClear" | keyof { +export const Input: ForwardRefExoticComponent, "children" | "description" | "validationState" | "inputElementType">, "caption" | "style" | "className" | "data-testid" | "startAddon" | "endAddon" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isClearable" | "isLabelHidden" | "onClear" | keyof { disabled?: boolean; error?: boolean; required?: boolean; @@ -103,7 +103,7 @@ export type InputProps = ExtendableProps<{ clearButton?: IconButtonProps; input?: FormFieldInputProps; }; -} & InputDeprecatedProps, Omit, 'description' | 'children' | 'validationState'>>; +} & InputDeprecatedProps, Omit, 'description' | 'children' | 'inputElementType' | 'validationState'>>; // Warning: (ae-forgotten-export) The symbol "FormFieldControlGroupPropVariant" needs to be exported by the entry point index.d.ts // diff --git a/tools/public_api_guard/components/Textarea.api.md b/tools/public_api_guard/components/Textarea.api.md index 92254b9f2..ca6eb8ce6 100644 --- a/tools/public_api_guard/components/Textarea.api.md +++ b/tools/public_api_guard/components/Textarea.api.md @@ -24,7 +24,7 @@ error?: boolean; required?: boolean; hiddenLabel?: boolean; readonly?: boolean; -} & Omit, "children" | "style" | "className" | "description" | "isClearable" | "validationState">, "caption" | "style" | "className" | "cols" | "data-testid" | "rows" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isLabelHidden" | "expand"> & { +} & Omit, "children" | "style" | "className" | "description" | "isClearable" | "validationState" | "inputElementType">, "caption" | "style" | "className" | "cols" | "data-testid" | "rows" | "variant" | "slotProps" | "labelPlacement" | "labelAlign" | "fullWidth" | "isLabelHidden" | "expand"> & { className?: string; style?: CSSProperties; variant?: TextareaPropVariant; @@ -89,7 +89,7 @@ export type TextareaProps = ExtendableProps<{ textarea?: FormFieldInputProps<'textarea'>; errorMessage?: FormFieldErrorProps; }; -}, TextareaDeprecatedProps & Omit, 'description' | 'validationState' | 'children' | 'style' | 'className' | 'isClearable'>>; +}, TextareaDeprecatedProps & Omit, 'description' | 'validationState' | 'children' | 'style' | 'className' | 'inputElementType' | 'isClearable'>>; // Warning: (ae-forgotten-export) The symbol "FormFieldControlGroupPropVariant" needs to be exported by the entry point index.d.ts // diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index 8206c262f..b7291cc71 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -81,7 +81,7 @@ import type { FocusStrategy } from '@koobiq/react-core'; import { FormEventHandler } from 'react'; import type { FormProps as FormProps_2 } from '@koobiq/react-core'; import type { FormValidationState } from '@react-stately/form'; -import { ForwardedRef } from 'react'; +import type { ForwardedRef } from 'react'; import { ForwardRefExoticComponent } from 'react'; import { getItemCount } from '@react-stately/collections'; import type { GlobalDOMAttributes } from '@koobiq/react-core'; @@ -92,12 +92,9 @@ import type { HoverEvents } from '@koobiq/react-core'; import type { HTMLAttributes } from 'react'; import { HTMLInputAutoCompleteAttribute } from 'react'; import { HTMLInputTypeAttribute } from 'react'; -import { Input } from 'react-aria-components'; import { InputBase } from '@koobiq/react-core'; -import { InputContext } from 'react-aria-components'; import { InputEventHandler } from 'react'; import { InputHTMLAttributes } from 'react'; -import { InputProps } from 'react-aria-components'; import { Item } from '@react-stately/collections'; import type { Key } from 'react'; import type { Key as Key_2 } from '@koobiq/react-core'; @@ -140,9 +137,6 @@ import type { RouterOptions } from '@koobiq/react-core'; import type { Selection as Selection_2 } from '@koobiq/react-core'; import { Separator } from 'react-aria-components'; import { SubmenuTrigger } from 'react-aria-components'; -import { TextArea as Textarea } from 'react-aria-components'; -import { TextAreaContext as TextareaContext } from 'react-aria-components'; -import { TextAreaProps as TextareaProps } from 'react-aria-components'; import type { TextFieldAria } from '@react-aria/textfield'; import { TextInputBase } from '@koobiq/react-core'; import { ToggleEventHandler } from 'react'; @@ -412,11 +406,18 @@ export { Header } export { HeaderProps } -export { Input } +// @public (undocumented) +export const Input: ForwardRefExoticComponent & RefAttributes>; -export { InputContext } +// @public (undocumented) +export const InputContext: Context; -export { InputProps } +// @public (undocumented) +export type InputProps = { + children?: ReactNode; + value?: string | number | readonly string[]; + ref?: Ref; +} & HTMLAttributes; // @public (undocumented) export type InputRef = ComponentRef<'input'>; @@ -784,11 +785,18 @@ export type TagListState = ListState; const Text_2: PolyForwardComponent<"p", TextBaseProps, ElementType>; export { Text_2 as Text } -export { Textarea } +// @public (undocumented) +export const Textarea: ForwardRefExoticComponent & RefAttributes>; -export { TextareaContext } +// @public (undocumented) +export const TextareaContext: Context; -export { TextareaProps } +// @public (undocumented) +export type TextareaProps = { + children?: ReactNode; + value?: string | number | readonly string[]; + ref?: Ref; +} & HTMLAttributes; // @public (undocumented) export type TextareaRef = ComponentRef<'textarea'>; @@ -992,9 +1000,7 @@ export { useContextProps } export const useGroupContext: () => GroupProps; // @public (undocumented) -export const useInputContext: () => InputProps & { - ref?: ForwardedRef | undefined; -}; +export const useInputContext: () => InputProps; // @public (undocumented) export function useLink(props: UseLinkProps, ref: RefObject): { @@ -1427,9 +1433,7 @@ export function useTagListItem(props: AriaTagListItemProps, st export function useTagListState(props: AriaTagListStateProps): TagListState; // @public (undocumented) -export const useTextareaContext: () => TextareaProps & { - ref?: ForwardedRef | undefined; -}; +export const useTextareaContext: () => TextareaProps; export { useToggleButtonGroup } From a18333dcc40b87013d9844e453a004684116076b Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 14 Aug 2026 10:29:11 +0300 Subject: [PATCH 15/15] fix(DropdownMenu): fix css-layout --- .../DropdownMenuPopover/DropdownMenuPopover.module.css | 4 ++-- packages/components/src/components/Popover/Popover.module.css | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css index 4da8230ba..f80deeb90 100644 --- a/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css +++ b/packages/components/src/components/DropdownMenu/components/DropdownMenuPopover/DropdownMenuPopover.module.css @@ -1,8 +1,8 @@ -/* The shell — shadow, background, fade — comes from `Popover`. */ .base { + --kbq-popover-border-radius: var(--kbq-size-s); + min-inline-size: 200px; max-inline-size: 640px; - border-radius: var(--kbq-size-s); } .container { 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);