diff --git a/.changeset/proud-walls-flash.md b/.changeset/proud-walls-flash.md new file mode 100644 index 0000000000..ab31bf63fe --- /dev/null +++ b/.changeset/proud-walls-flash.md @@ -0,0 +1,10 @@ +--- +"@digdir/designsystemet-css": patch +"@digdir/designsystemet-react": patch +--- + +DropdownMenu: +- Rename from `DropdownMenu` to `Dropdown` +- Change API and structure +- Rename `.Root` to `.Context` +- Rename `.Content` to `Dropdown` diff --git a/apps/storefront/app/komponenter/component-list.ts b/apps/storefront/app/komponenter/component-list.ts index d2f614bdd9..ab515f63f8 100644 --- a/apps/storefront/app/komponenter/component-list.ts +++ b/apps/storefront/app/komponenter/component-list.ts @@ -56,8 +56,8 @@ export const data = [ }, { title: 'Dropdown Menu', - image: 'DropdownMenu.svg', - url: 'https://storybook.designsystemet.no/?path=/docs/komponenter-dropdownmenu--docs', + image: 'Dropdown.svg', + url: 'https://storybook.designsystemet.no/?path=/docs/komponenter-dropdown--docs', }, { title: 'Error Summary', diff --git a/apps/storefront/components/Tokens/TokenList/TokenList.tsx b/apps/storefront/components/Tokens/TokenList/TokenList.tsx index 4e74647e20..25304bf2aa 100644 --- a/apps/storefront/components/Tokens/TokenList/TokenList.tsx +++ b/apps/storefront/components/Tokens/TokenList/TokenList.tsx @@ -1,6 +1,6 @@ 'use client'; import { - DropdownMenu, + Dropdown, Heading, Link, Paragraph, @@ -220,40 +220,40 @@ const TokenList = ({ {(showThemePicker || showModeSwitcher) && (
{showThemePicker && ( - - + + Brand: {capitalizeString(brand)} - - - setBrand('digdir')}> + + + setBrand('digdir')}> Digdir - - setBrand('altinn')}> + + setBrand('altinn')}> Altinn - - setBrand('tilsynet')}> + + setBrand('tilsynet')}> Tilsynet - - setBrand('portal')}> + + setBrand('portal')}> Brreg - - - + + + )} {showModeSwitcher && ( - - + + Mode: {capitalizeString(mode)} - - - setMode('light')}> + + + setMode('light')}> Light - - setMode('dark')}> + + setMode('dark')}> Dark - - - + + + )}
)} diff --git a/apps/storefront/public/img/component-previews/DropdownMenu.svg b/apps/storefront/public/img/component-previews/Dropdown.svg similarity index 100% rename from apps/storefront/public/img/component-previews/DropdownMenu.svg rename to apps/storefront/public/img/component-previews/Dropdown.svg diff --git a/apps/theme/components/Previews/Components/Components.tsx b/apps/theme/components/Previews/Components/Components.tsx index 1c2ea9d5df..51ed80bbef 100644 --- a/apps/theme/components/Previews/Components/Components.tsx +++ b/apps/theme/components/Previews/Components/Components.tsx @@ -6,7 +6,7 @@ import { Checkbox, Chip, Combobox, - DropdownMenu, + Dropdown, Fieldset, Heading, HelpText, @@ -390,18 +390,18 @@ export const Components = () => {
- - Velg språk - - Norsk - Engelsk - Spansk - Fransk - + + Velg språk + + Norsk + Engelsk + Spansk + Fransk + Velg språk for å endre innholdet på siden - +
diff --git a/packages/css/dropdown.css b/packages/css/dropdown.css new file mode 100644 index 0000000000..f3881b085f --- /dev/null +++ b/packages/css/dropdown.css @@ -0,0 +1,45 @@ +.ds-dropdown { + --dsc-dropdown-padding: var(--ds-spacing-3) var(--ds-spacing-2); + --dsc-dropdown-min-width: 16rem; + --dsc-dropdown-item-padding: 0 var(--ds-spacing-4); + --dsc-dropdown-header-padding: var(--ds-spacing-2) var(--ds-spacing-4); + + padding: var(--dsc-dropdown-padding); + list-style: none; + border-radius: min(1rem, var(--ds-border-radius-md)); + box-shadow: var(--ds-shadow-md); + background-color: var(--ds-color-neutral-background-default); + border: 1px solid var(--ds-color-neutral-border-subtle); + min-width: var(--dsc-dropdown-min-width); + + /* Remove popover arrow */ + &::before { + display: none; + } + + &[data-size='sm'] { + --dsc-dropdown-padding: var(--ds-spacing-2); + --dsc-dropdown-min-width: 15rem; + } + + &[data-size='lg'] { + --dsc-dropdown-padding: var(--ds-spacing-4) var(--ds-spacing-2); + --dsc-dropdown-min-width: 18rem; + } + + & :is(a, button, [role='button']) { + justify-content: start; + padding: var(--dsc-dropdown-item-padding); + width: 100%; + } + + .ds-dropdown__list { + margin: 0; + padding: 0; + list-style: none; + } + + .ds-dropdown__heading { + padding: var(--dsc-dropdown-header-padding); + } +} diff --git a/packages/css/dropdownmenu.css b/packages/css/dropdownmenu.css deleted file mode 100644 index 206d581e38..0000000000 --- a/packages/css/dropdownmenu.css +++ /dev/null @@ -1,43 +0,0 @@ -.ds-dropdownmenu { - --dsc-dropdownmenu-padding: var(--ds-spacing-3) var(--ds-spacing-2); - --dsc-dropdownmenu-min-width: 16rem; - --dsc-dropdownmenu-item-padding: 0 var(--ds-spacing-4); - --dsc-dropdownmenu-header-padding: var(--ds-spacing-2) var(--ds-spacing-4); - - position: relative; - padding: var(--dsc-dropdownmenu-padding); - z-index: 1500; - margin: 0; - list-style: none; - border-radius: min(1rem, var(--ds-border-radius-md)); - box-shadow: var(--ds-shadow-md); - background-color: var(--ds-color-neutral-background-default); - border: 1px solid var(--ds-color-neutral-border-subtle); - min-width: var(--dsc-dropdownmenu-min-width); - - &[data-size='sm'] { - --dsc-dropdownmenu-padding: var(--ds-spacing-2); - --dsc-dropdownmenu-min-width: 15rem; - } - - &[data-size='lg'] { - --dsc-dropdownmenu-padding: var(--ds-spacing-4) var(--ds-spacing-2); - --dsc-dropdownmenu-min-width: 18rem; - } -} - -.ds-dropdownmenu__item { - justify-content: start; - padding: var(--dsc-dropdownmenu-item-padding); - width: 100%; -} - -.ds-dropdownmenu__group { - margin: 0; - padding: 0; - list-style: none; -} - -.ds-dropdownmenu__heading { - padding: var(--dsc-dropdownmenu-header-padding); -} diff --git a/packages/css/index.css b/packages/css/index.css index f60c02ac0f..fef99b2e5f 100644 --- a/packages/css/index.css +++ b/packages/css/index.css @@ -12,6 +12,7 @@ @import url('./utilities.css') layer(ds.utilities); @import url('./button.css') layer(ds.components); @import url('./alert.css') layer(ds.components); +@import url('./popover.css') layer(ds.components); @import url('./skiplink.css') layer(ds.components); @import url('./accordion.css') layer(ds.components); @import url('./switch.css') layer(ds.components); @@ -27,12 +28,11 @@ @import url('./card.css') layer(ds.components); @import url('./link.css') layer(ds.components); @import url('./fieldset.css') layer(ds.components); -@import url('./dropdownmenu.css') layer(ds.components); +@import url('./dropdown.css') layer(ds.components); @import url('./chip') layer(ds.components); @import url('./divider.css') layer(ds.components); @import url('./tabs.css') layer(ds.components); @import url('./pagination.css') layer(ds.components); -@import url('./popover.css') layer(ds.components); @import url('./skeleton.css') layer(ds.components); @import url('./tag.css') layer(ds.components); @import url('./error-summary.css') layer(ds.components); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index bd1ba9ecaa..fcd334652a 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -529,7 +529,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Bug Fixes -- **DropDownMenuItem:** add list style none ([#1190](https://github.com/digdir/designsystemet/issues/1190)) ([11bd19b](https://github.com/digdir/designsystemet/commit/11bd19bfb6ac76b2c697a22e876117c4128be3bd)) +- **DropdownMenuItem:** add list style none ([#1190](https://github.com/digdir/designsystemet/issues/1190)) ([11bd19b](https://github.com/digdir/designsystemet/commit/11bd19bfb6ac76b2c697a22e876117c4128be3bd)) - **List:** Wrap in `div` to allow access to `Heading` ([#1217](https://github.com/digdir/designsystemet/issues/1217)) ([afcadb7](https://github.com/digdir/designsystemet/commit/afcadb7c4cb4b368d247af0c41ed8debf53c4b66)) - **Pagination:** Only use needed space for buttons ([#1220](https://github.com/digdir/designsystemet/issues/1220)) ([4bf3d74](https://github.com/digdir/designsystemet/commit/4bf3d745888f500259df5aadf4edee97ec4f95bc)) - **Select:** Select not working properly in Modal ([#1195](https://github.com/digdir/designsystemet/issues/1195)) ([fb8be6a](https://github.com/digdir/designsystemet/commit/fb8be6a647ba0da8b5b23e65813508f34e09c8c1)) diff --git a/packages/react/src/components/Avatar/Avatar.stories.tsx b/packages/react/src/components/Avatar/Avatar.stories.tsx index a6653f6cdb..bdceb4b65d 100644 --- a/packages/react/src/components/Avatar/Avatar.stories.tsx +++ b/packages/react/src/components/Avatar/Avatar.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryFn } from '@storybook/react'; import { BriefcaseIcon } from '@navikt/aksel-icons'; import { Avatar } from '.'; -import { Badge, DropdownMenu } from '../'; +import { Badge, Dropdown } from '../'; type Story = StoryFn; @@ -93,33 +93,34 @@ export const WithImage: Story = () => ( ); -export const InDropdownMenu: Story = () => ( - - +export const InDropdown: Story = () => ( + + ON Velg Profil - - - - + + + Alle kontoer + + ON Ola Nordmann - - + + Sogndal kommune - - - - + + + + ); export const AsLink: Story = () => ( diff --git a/packages/react/src/components/Dropdown/Dropdown.mdx b/packages/react/src/components/Dropdown/Dropdown.mdx new file mode 100644 index 0000000000..22dedbccc9 --- /dev/null +++ b/packages/react/src/components/Dropdown/Dropdown.mdx @@ -0,0 +1,81 @@ +import { Meta, Canvas, Controls, Primary, ArgTypes } from '@storybook/blocks'; + +import * as DropdownStories from './Dropdown.stories'; + +import { Dropdown } from './'; + + + +# Dropdown + + + + +## Slik bruker du `Dropdown` + +```tsx +import { Dropdown } from '@digdir/designsystemet-react'; + +// med context + + Trigger + + Heading + + Item + + + + +// uten context + + + Heading + + Item + + +``` + +## Eksempler på bruk + +### Kontrollert + +Dersom du sender inn `open`, så bruker du `Dropdown` kontrollert. Du kan bruke `onClose` for å få beskjed når `Dropdown` vil lukkes. + + + +### Ikoner + +Du kan legge ikon rett inn i `Dropdown.Item`, dersom det blir mye mellomrom til kanten kan du legge på din egen klasse og endre på `padding`. + + + +### Uten Trigger + +`Dropdown` bruker popover APIet, så du kan bruke `Dropdown` uten `Dropdown.Trigger`. +Du må da legge til `popovertarget={id}` på `Dropdown`, og `id` på `Dropdown`. + + + +## Tilgjengelighet + +Det er innebygd tilgjengelighet i `Dropdown.Trigger` med `aria-expanded={true/false}` i henhold til åpne/lukket tilstand, og `aria-haspopup='menu'`. + +### `Dropdown.List` + + + +### `Dropdown.Trigger` + +Triggeren er en [Button](/docs/komponenter-button--docs) som standard. + +Bruk `Dropdown.Trigger` til å aktivere `Dropdown`. Du kan bruke `asChild` for å endre `Dropdown.Trigger` elementet. +Dersom du skal legge på funksjoner som `onClick`, legg det på ditt element, og legg `asChild` på `Dropdown.Trigger`. + +### Referanser + +Vi bruker `ul` og `li` tags i dropdownen, valget er basert på denne: + +- https://www.w3.org/WAI/tutorials/menus/flyout/#flyoutnavkbbtn +- https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/../Dropdown.stories. diff --git a/packages/react/src/components/Dropdown/Dropdown.stories.tsx b/packages/react/src/components/Dropdown/Dropdown.stories.tsx new file mode 100644 index 0000000000..2f4e813e96 --- /dev/null +++ b/packages/react/src/components/Dropdown/Dropdown.stories.tsx @@ -0,0 +1,117 @@ +import { LinkIcon } from '@navikt/aksel-icons'; +import type { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import { Dropdown } from '.'; +import { Button } from '../Button'; + +export default { + title: 'Komponenter/Dropdown', + component: Dropdown, +} as Meta; + +export const Preview: StoryFn = (args) => { + return ( + + Dropdown + + Heading 1 + + Button 1.1 + Button 1.2 + + Heading 2 + + Button 2.1 + Button 2.2 + + + + ); +}; + +Preview.args = { + placement: 'bottom-end', + size: 'md', +}; + +export const Icons: StoryFn = (args) => { + return ( + + Dropdown + + + + + + Github + + + + + + Designsystemet.no + + + + + + ); +}; + +export const Controlled: StoryFn = () => { + const [open, setOpen] = useState(false); + + return ( + + setOpen(!open)}> + Dropdown + + setOpen(false)}> + + + + + Github + + + + + + Designsystemet.no + + + + + + ); +}; + +export const WithoutTrigger: StoryFn = () => { + return ( + <> + + + + Item + + + + ); +}; diff --git a/packages/react/src/components/Dropdown/Dropdown.test.tsx b/packages/react/src/components/Dropdown/Dropdown.test.tsx new file mode 100644 index 0000000000..d9c283151c --- /dev/null +++ b/packages/react/src/components/Dropdown/Dropdown.test.tsx @@ -0,0 +1,63 @@ +import { render as renderRtl, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react'; + +import type { DropdownContextProps } from './DropdownContext'; + +import { Dropdown } from '.'; + +const Comp = (args: Partial) => { + return ( + + Dropdown + + Links + + Item + {args.children} + + + + ); +}; + +const render = async (props: Partial = {}) => { + /* Flush microtasks */ + await act(async () => {}); + const user = userEvent.setup(); + + return { + user, + ...renderRtl(), + }; +}; + +describe('Dropdown', () => { + /* We are testing closing and opening in Popover.tests.tsx */ + it('should render children', async () => { + const { user } = await render({ + children: Item 2, + }); + const dropdownTrigger = screen.getByRole('button'); + + await act(async () => await user.click(dropdownTrigger)); + + expect(screen.queryByText('Item 2')).toBeInTheDocument(); + }); + + it('should be able to render `Dropdown.Item` as a anchor element using asChild', async () => { + const { user } = await render({ + children: ( + + Anchor + + ), + }); + const dropdownTrigger = screen.getByRole('button'); + + await act(async () => await user.click(dropdownTrigger)); + + expect(screen.getByText('Anchor')).toHaveAttribute('href', '/'); + expect(screen.getByText('Anchor').tagName).toBe('A'); + }); +}); diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000000..039b7705f7 --- /dev/null +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,54 @@ +import cl from 'clsx/lite'; +import { createContext, forwardRef, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; + +import type { Placement } from '@floating-ui/react'; +import { Popover } from '../Popover'; +import type { PopoverProps } from '../Popover'; + +export type DropdownProps = { + /** The placement of the dropdown + * @default bottom-end + */ + placement?: Placement; + children: ReactNode; +} & Omit; + +export const Dropdown = forwardRef( + function DropddownMenuContent( + { placement = 'bottom-end', className, ...rest }, + ref, + ) { + const [size, setSize] = useState>( + rest.size || 'md', + ); + + useEffect(() => { + setSize(rest.size || 'md'); + }, [rest.size]); + + return ( + + + + ); + }, +); + +type DropdownMenuCtxType = { + size: NonNullable; +}; + +export const DropdownCtx = createContext({ + size: 'md', +}); diff --git a/packages/react/src/components/Dropdown/DropdownContext.tsx b/packages/react/src/components/Dropdown/DropdownContext.tsx new file mode 100644 index 0000000000..0ed577de32 --- /dev/null +++ b/packages/react/src/components/Dropdown/DropdownContext.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; + +import { PopoverContext } from '../Popover'; + +export type DropdownContextProps = { + children: ReactNode; +}; + +/** + * DropdownContext is the root component for the DropdownMenu component. + * @example + * + * Dropdown + * + * Heading + * + * Button 1 + * + * + * + */ +export const DropdownContext = ({ children }: DropdownContextProps) => { + return {children}; +}; + +DropdownContext.displayName = 'DropdownContext'; diff --git a/packages/react/src/components/Dropdown/DropdownHeading.tsx b/packages/react/src/components/Dropdown/DropdownHeading.tsx new file mode 100644 index 0000000000..666acef743 --- /dev/null +++ b/packages/react/src/components/Dropdown/DropdownHeading.tsx @@ -0,0 +1,21 @@ +import cl from 'clsx/lite'; +import { type HTMLAttributes, forwardRef, useContext } from 'react'; +import { Paragraph } from '../Typography'; +import { DropdownCtx } from './Dropdown'; + +export type DropdownHeadingProps = HTMLAttributes; + +export const DropdownHeading = forwardRef< + HTMLHeadingElement, + DropdownHeadingProps +>(function DropdownHeading({ children, className, ...rest }, ref) { + const { size } = useContext(DropdownCtx); + + return ( + +

+ {children} +

+
+ ); +}); diff --git a/packages/react/src/components/Dropdown/DropdownItem.tsx b/packages/react/src/components/Dropdown/DropdownItem.tsx new file mode 100644 index 0000000000..a3b41d6e60 --- /dev/null +++ b/packages/react/src/components/Dropdown/DropdownItem.tsx @@ -0,0 +1,26 @@ +import { forwardRef, useContext } from 'react'; + +import type { ButtonProps } from '../Button'; +import { Button } from '../Button'; + +import { DropdownCtx } from './Dropdown'; + +export type DropdownItemProps = Omit; + +export const DropdownItem = forwardRef( + function DropdownItem({ className, style, ...rest }, ref) { + const { size } = useContext(DropdownCtx); + + return ( +
  • +
  • + ); + }, +); diff --git a/packages/react/src/components/Dropdown/DropdownList.tsx b/packages/react/src/components/Dropdown/DropdownList.tsx new file mode 100644 index 0000000000..4fa1b4f1f2 --- /dev/null +++ b/packages/react/src/components/Dropdown/DropdownList.tsx @@ -0,0 +1,13 @@ +import cl from 'clsx/lite'; +import { forwardRef } from 'react'; +import type { HTMLAttributes } from 'react'; + +export type DropdownListProps = HTMLAttributes; + +export const DropdownList = forwardRef( + function DropdownList({ className, ...rest }, ref) { + return ( +
      + ); + }, +); diff --git a/packages/react/src/components/Dropdown/DropdownTrigger.tsx b/packages/react/src/components/Dropdown/DropdownTrigger.tsx new file mode 100644 index 0000000000..bdc42dfc0a --- /dev/null +++ b/packages/react/src/components/Dropdown/DropdownTrigger.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react'; +import type { ComponentPropsWithRef } from 'react'; + +import { PopoverTrigger } from '../Popover'; + +export type DropdownTriggerProps = ComponentPropsWithRef; + +export const DropdownTrigger = forwardRef< + HTMLButtonElement, + DropdownTriggerProps +>(function DropdownTrigger({ asChild, ...rest }, ref) { + return ; +}); diff --git a/packages/react/src/components/Dropdown/index.ts b/packages/react/src/components/Dropdown/index.ts new file mode 100644 index 0000000000..5f496ab8d8 --- /dev/null +++ b/packages/react/src/components/Dropdown/index.ts @@ -0,0 +1,46 @@ +import { Dropdown as DropdownRoot } from './Dropdown'; +import { DropdownContext } from './DropdownContext'; +import { DropdownHeading } from './DropdownHeading'; +import { DropdownItem } from './DropdownItem'; +import { DropdownList } from './DropdownList'; +import { DropdownTrigger } from './DropdownTrigger'; + +/** + * @example + * + * Dropdown + * + * Heading + * + * Button 1 + * + * + * + */ +const Dropdown = Object.assign(DropdownRoot, { + Context: DropdownContext, + Heading: DropdownHeading, + List: DropdownList, + Item: DropdownItem, + Trigger: DropdownTrigger, +}); + +Dropdown.Context.displayName = 'Dropdown.Context'; +Dropdown.List.displayName = 'Dropdown.List'; +Dropdown.Heading.displayName = 'Dropdown.Heading'; +Dropdown.Item.displayName = 'Dropdown.Item'; +Dropdown.Trigger.displayName = 'Dropdown.Trigger'; + +export type { DropdownContextProps } from './DropdownContext'; +export type { DropdownListProps } from './DropdownList'; +export type { DropdownHeadingProps } from './DropdownHeading'; +export type { DropdownItemProps } from './DropdownItem'; +export type { DropdownProps } from './Dropdown'; +export { + Dropdown, + DropdownContext, + DropdownList, + DropdownHeading, + DropdownItem, + DropdownTrigger, +}; diff --git a/packages/react/src/components/DropdownMenu/DropdownMenu.mdx b/packages/react/src/components/DropdownMenu/DropdownMenu.mdx deleted file mode 100644 index a223cb1572..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenu.mdx +++ /dev/null @@ -1,69 +0,0 @@ -import { Meta, Canvas, Controls, Primary, ArgTypes } from '@storybook/blocks'; - -import * as DropdownMenuStories from './DropdownMenu.stories'; - -import { DropdownMenu } from './'; - - - -# Dropdown - - - - -## Slik bruker du `DropdownMenu` - -```tsx -import { DropdownMenu } from '@digdir/designsystemet-react'; - - - Trigger - - - Item - - -; -``` - -## Eksempler på bruk - -### Kontrollert - -Dersom du sender inn `open`, så bruker du `DropdownMenu` kontrollert. Du kan bruke `onClose` for å få beskjed når `DropdownMenu` vil lukkes. - - - -### Ikoner - -Du kan legge ikon rett inn i `DropdownMenu.Item`, dersom det blir mye mellomrom til kanten kan du legge på din egen klasse og endre på `padding`. - - - -### I portal - -Legg på `portal` for å rendere `DropdownMenu` i en portal. Dette flytter dropdownen til `body`. - - - -## Tilgjengelighet - -Det er innebygd tilgjengelighet i `DropdownMenu.Trigger` med `aria-expanded={true/false}` i henhold til åpne/lukket tilstand, og `aria-haspopup='menu'`. - -### `DropdownMenu.Group` - - - -### `DropdownMenu.Trigger` - -Triggeren er en [Button](/docs/komponenter-button--docs) som standard. - -Bruk `DropdownMenu.Trigger` til å aktivere `DropdownMenu`. Du kan bruke `asChild` for å endre `DropdownMenu.Trigger` elementet. -Dersom du skal legge på funksjoner som `onClick`, legg det på ditt element, og legg `asChild` på `DropdownMenu.Trigger`. - -### Referanser - -Vi bruker `ul` og `li` tags i dropdownen, valget er basert på denne: - -- https://www.w3.org/WAI/tutorials/menus/flyout/#flyoutnavkbbtn -- https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ diff --git a/packages/react/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/react/src/components/DropdownMenu/DropdownMenu.stories.tsx deleted file mode 100644 index b4cea2f7f2..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { LinkIcon } from '@navikt/aksel-icons'; -import type { Meta, StoryFn } from '@storybook/react'; -import { useState } from 'react'; - -import { DropdownMenu } from '.'; - -const marginDecorator = (Story: StoryFn) => ( -
      - -
      -); - -export default { - title: 'Komponenter/DropdownMenu', - component: DropdownMenu.Root, -} as Meta; - -export const Preview: StoryFn = (args) => { - return ( - <> - - Dropdown - - - Button 1 - Button 2 - - - - - ); -}; - -Preview.args = { - placement: 'bottom-end', - size: 'md', -}; - -Preview.decorators = [marginDecorator]; - -export const Icons: StoryFn = () => { - return ( - - Dropdown - - - - - - Github - - - - - - Designsystemet.no - - - - - - ); -}; - -Icons.decorators = [marginDecorator]; - -export const InPortal: StoryFn = () => { - return ( - - Dropdown - - - - - - Github - - - - - - Designsystemet.no - - - - - - ); -}; - -export const Controlled: StoryFn = () => { - const [open, setOpen] = useState(false); - - return ( - <> - setOpen(false)} portal> - setOpen(!open)}> - Dropdown - - - - - - - Github - - - - - - Designsystemet.no - - - - - - - ); -}; diff --git a/packages/react/src/components/DropdownMenu/DropdownMenu.test.tsx b/packages/react/src/components/DropdownMenu/DropdownMenu.test.tsx deleted file mode 100644 index 4467d20303..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenu.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { render as renderRtl, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { act } from 'react'; - -import type { DropdownMenuRootProps } from './DropdownMenuRoot'; - -import { DropdownMenu } from '.'; - -const Comp = (args: Partial) => { - return ( - - Dropdown - - - Item - {args.children} - - - - ); -}; - -const render = async (props: Partial = {}) => { - /* Flush microtasks */ - await act(async () => {}); - const user = userEvent.setup(); - - return { - user, - ...renderRtl(), - }; -}; - -describe('Dropdown', () => { - it('should render dropdown on trigger-click when closed', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - expect(screen.queryByText('Item')).not.toBeInTheDocument(); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.queryByText('Item')).toBeInTheDocument(); - }); - - it('should close when we click the button twitce', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.queryByText('Item')).not.toBeInTheDocument(); - }); - - it('should render children', async () => { - const { user } = await render({ - children: Item 2, - }); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.queryByText('Item 2')).toBeInTheDocument(); - }); - - it('should close when we click outside', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => { - await user.click(dropdownTrigger); - }); - - expect(screen.queryByText('Item')).toBeInTheDocument(); - - await act(async () => { - await user.click(document.body); - }); - - expect(screen.queryByText('Item')).not.toBeInTheDocument(); - }); - - it('should close when we press ESC', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.queryByText('Item')).toBeInTheDocument(); - - await act(async () => await user.keyboard('[Escape]')); - - expect(screen.queryByText('Item')).not.toBeInTheDocument(); - }); - - it('should not close if we click inisde the dropdown', async () => { - const { user } = await render({ - children: Item 2, - }); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.queryByText('Item')).toBeInTheDocument(); - - await act(async () => await user.click(screen.getByText('Item 2'))); - - expect(screen.queryByText('Item')).toBeInTheDocument(); - }); - - it('should be able to render `Dropdown.Item` as a anchor element using asChild', async () => { - const { user } = await render({ - children: ( - - Anchor - - ), - }); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.getByText('Anchor')).toHaveAttribute('href', '/'); - expect(screen.getByText('Anchor').tagName).toBe('A'); - }); - - it('Item should have role="menuitem"', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.getByText('Item')).toHaveAttribute('role', 'menuitem'); - }); - - it('Group should have role="group"', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.getByRole('group')).toBeInTheDocument(); - }); - - it('Group should be described by heading', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => user.click(dropdownTrigger)); - - expect(screen.getByRole('group')).toHaveAttribute('aria-labelledby'); - }); - - it('should focus the first item when we open the dropdown', async () => { - const { user } = await render(); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); - - await vi.waitFor(() => { - expect(document.activeElement).toBe(screen.getByText('Item')); - }); - }); -}); diff --git a/packages/react/src/components/DropdownMenu/DropdownMenuContent.tsx b/packages/react/src/components/DropdownMenu/DropdownMenuContent.tsx deleted file mode 100644 index d172e5188f..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenuContent.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - FloatingFocusManager, - FloatingPortal, - autoUpdate, - offset, - shift, - useClick, - useDismiss, - useFloating, - useFocus, - useInteractions, - useMergeRefs, - useRole, -} from '@floating-ui/react'; -import cl from 'clsx/lite'; -import { Fragment, forwardRef, useContext, useRef } from 'react'; -import type { HTMLAttributes, ReactNode } from 'react'; - -import { useIsomorphicLayoutEffect } from '../../utilities'; - -import { DropdownMenuContext } from './DropdownMenuRoot'; - -const GAP = 4; - -export type DropdownMenuContentProps = { - children: ReactNode; -} & HTMLAttributes; - -export const DropdownMenuContent = forwardRef< - HTMLUListElement, - DropdownMenuContentProps ->(({ className, children, ...rest }, ref) => { - const { - size, - placement, - portal, - anchorEl, - isControlled, - internalOpen, - setInternalOpen, - onClose, - } = useContext(DropdownMenuContext); - - const Container = portal ? FloatingPortal : Fragment; - const floatingEl = useRef(null); - - const { - context, - update, - refs, - placement: flPlacement, - floatingStyles, - } = useFloating({ - placement, - open: internalOpen, - onOpenChange: (localOpen) => { - if (!localOpen) onClose?.(); - if (!isControlled) setInternalOpen(localOpen); - }, - elements: { - reference: anchorEl, - floating: floatingEl.current, - }, - whileElementsMounted: autoUpdate, - middleware: [offset(GAP), shift()], - }); - - const { getFloatingProps } = useInteractions([ - useFocus(context), - useClick(context), - useDismiss(context), - useRole(context), - ]); - - useIsomorphicLayoutEffect(() => { - refs.setReference(anchorEl); - if (!refs.reference.current || !refs.floating.current || !internalOpen) - return; - const cleanup = autoUpdate( - refs.reference.current, - refs.floating.current, - update, - ); - return () => cleanup(); - }, [refs.floating, refs.reference, update, anchorEl, refs, internalOpen]); - - const floatingRef = useMergeRefs([refs.setFloating, ref]); - - return ( - <> - {internalOpen && ( - - -
        - {children} -
      -
      -
      - )} - - ); -}); - -DropdownMenuContent.displayName = 'DropdownMenuContent'; diff --git a/packages/react/src/components/DropdownMenu/DropdownMenuGroup.tsx b/packages/react/src/components/DropdownMenu/DropdownMenuGroup.tsx deleted file mode 100644 index e55150845b..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenuGroup.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { forwardRef, useContext, useId } from 'react'; -import type { HTMLAttributes, ReactNode } from 'react'; - -import { Paragraph } from '../Typography'; - -import { DropdownMenuContext } from './DropdownMenuRoot'; - -export type DropdownMenuGroupProps = { - /** - * Heading of the group - */ - heading?: ReactNode; -} & HTMLAttributes; - -export const DropdownMenuGroup = forwardRef< - HTMLUListElement, - DropdownMenuGroupProps ->(({ children, heading, className, style, ...rest }, ref) => { - const { size } = useContext(DropdownMenuContext); - const headingId = useId(); - - return ( -
    • -
        - {heading && ( - -

        - {heading} -

        -
        - )} - {children} -
      -
    • - ); -}); - -DropdownMenuGroup.displayName = 'DropdownMenuGroup'; diff --git a/packages/react/src/components/DropdownMenu/DropdownMenuItem.tsx b/packages/react/src/components/DropdownMenu/DropdownMenuItem.tsx deleted file mode 100644 index 0bb517df54..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenuItem.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { forwardRef, useContext } from 'react'; - -import type { ButtonProps } from '../Button'; -import { Button } from '../Button'; - -import { DropdownMenuContext } from './DropdownMenuRoot'; - -export type DropdownMenuItemProps = Omit< - ButtonProps, - 'variant' | 'size' | 'color' ->; - -export const DropdownMenuItem = forwardRef< - HTMLButtonElement, - DropdownMenuItemProps ->(({ children, className, style, ...rest }, ref) => { - const { size } = useContext(DropdownMenuContext); - - return ( -
    • - -
    • - ); -}); - -DropdownMenuItem.displayName = 'DropdownMenuItem'; diff --git a/packages/react/src/components/DropdownMenu/DropdownMenuRoot.tsx b/packages/react/src/components/DropdownMenu/DropdownMenuRoot.tsx deleted file mode 100644 index f135439d29..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenuRoot.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { Placement } from '@floating-ui/react'; -import { createContext, useEffect, useRef, useState } from 'react'; -import type { ReactNode, RefObject } from 'react'; - -import type { PortalProps } from '../../types/Portal'; - -export type DropdownMenuRootProps = { - /** Whether the dropdown is open or not. - * @default false - */ - open?: boolean; - /** Callback function when dropdown closes */ - onClose?: () => void; - /** The placement of the dropdown - * @default bottom-end - */ - placement?: Placement; - /** - * The size of the dropdown - * @default md - **/ - size?: 'sm' | 'md' | 'lg'; - children: ReactNode; -} & PortalProps; - -/** - * DropdownMenuRoot is the root component for the DropdownMenu component. - * @example - * - * Dropdown - * - * - * Button 1 - * - * - * - */ -export const DropdownMenuRoot = ({ - open, - onClose, - placement = 'bottom-end', - portal, - size = 'md', - children, -}: DropdownMenuRootProps) => { - const triggerRef = useRef(null); - const [internalOpen, setInternalOpen] = useState(open ?? false); - - const anchorEl = triggerRef.current; - const isControlled = typeof open === 'boolean'; - - useEffect(() => { - setInternalOpen(open ?? false); - }, [open]); - - return ( - - {children} - - ); -}; - -type DropdownMenuContextType = { - anchorEl: Element | null; - triggerRef: RefObject; - size: NonNullable; - portal?: PortalProps['portal']; - placement?: DropdownMenuRootProps['placement']; - internalOpen: boolean; - isControlled?: boolean; - setInternalOpen: (open: boolean) => void; - onClose?: DropdownMenuRootProps['onClose']; -}; - -export const DropdownMenuContext = createContext({ - triggerRef: { current: null }, - size: 'md', - anchorEl: null, - internalOpen: false, - setInternalOpen: () => {}, -}); - -DropdownMenuRoot.displayName = 'DropdownMenuRoot'; diff --git a/packages/react/src/components/DropdownMenu/DropdownMenuTrigger.tsx b/packages/react/src/components/DropdownMenu/DropdownMenuTrigger.tsx deleted file mode 100644 index 019869c9f0..0000000000 --- a/packages/react/src/components/DropdownMenu/DropdownMenuTrigger.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useMergeRefs } from '@floating-ui/react'; -import { Slot } from '@radix-ui/react-slot'; -import { forwardRef, useContext } from 'react'; -import type { ComponentPropsWithRef } from 'react'; - -import { Button } from '../Button'; - -import { DropdownMenuContext } from './DropdownMenuRoot'; - -export type DropdownMenuTriggerProps = ComponentPropsWithRef; - -export const DropdownMenuTrigger = forwardRef< - HTMLButtonElement, - DropdownMenuTriggerProps ->(({ asChild, ...rest }, ref) => { - const { triggerRef, internalOpen, setInternalOpen, isControlled } = - useContext(DropdownMenuContext); - const mergedRefs = useMergeRefs([ref, triggerRef]); - - const Component = asChild ? Slot : Button; - - return ( - { - if (!isControlled) setInternalOpen(!internalOpen); - }} - aria-haspopup='menu' - aria-expanded={internalOpen} - {...rest} - /> - ); -}); - -DropdownMenuTrigger.displayName = 'DropdownMenuTrigger'; diff --git a/packages/react/src/components/DropdownMenu/index.ts b/packages/react/src/components/DropdownMenu/index.ts deleted file mode 100644 index 3e4fe4b58c..0000000000 --- a/packages/react/src/components/DropdownMenu/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { DropdownMenuContent } from './DropdownMenuContent'; -import { DropdownMenuGroup } from './DropdownMenuGroup'; -import { DropdownMenuItem } from './DropdownMenuItem'; -import { DropdownMenuRoot } from './DropdownMenuRoot'; -import { DropdownMenuTrigger } from './DropdownMenuTrigger'; - -type DropdownMenuComponent = { - Root: typeof DropdownMenuRoot; - Content: typeof DropdownMenuContent; - Group: typeof DropdownMenuGroup; - Item: typeof DropdownMenuItem; - Trigger: typeof DropdownMenuTrigger; -}; - -/** - * @example - * - * Dropdown - * - * - * Button 1 - * - * - * - */ -const DropdownMenu = {} as DropdownMenuComponent; - -DropdownMenu.Root = DropdownMenuRoot; -DropdownMenu.Content = DropdownMenuContent; -DropdownMenu.Group = DropdownMenuGroup; -DropdownMenu.Item = DropdownMenuItem; -DropdownMenu.Trigger = DropdownMenuTrigger; - -DropdownMenu.Root.displayName = 'DropdownMenu.Root'; -DropdownMenu.Content.displayName = 'DropdownMenu.Content'; -DropdownMenu.Group.displayName = 'DropdownMenu.Group'; -DropdownMenu.Item.displayName = 'DropdownMenu.Item'; -DropdownMenu.Trigger.displayName = 'DropdownMenu.Trigger'; - -export type { DropdownMenuRootProps } from './DropdownMenuRoot'; -export type { DropdownMenuGroupProps } from './DropdownMenuGroup'; -export type { DropdownMenuItemProps } from './DropdownMenuItem'; -export type { DropdownMenuContentProps } from './DropdownMenuContent'; -export { - DropdownMenu, - DropdownMenuRoot, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, -}; diff --git a/packages/react/src/components/Popover/Popover.tsx b/packages/react/src/components/Popover/Popover.tsx index 50729e56f8..cd2365724a 100644 --- a/packages/react/src/components/Popover/Popover.tsx +++ b/packages/react/src/components/Popover/Popover.tsx @@ -7,6 +7,7 @@ import { } from '@floating-ui/dom'; import type { MiddlewareState, Placement } from '@floating-ui/dom'; import { useMergeRefs } from '@floating-ui/react'; +import { Slot } from '@radix-ui/react-slot'; import cl from 'clsx/lite'; import { forwardRef, useContext, useRef, useState } from 'react'; import type { HTMLAttributes } from 'react'; @@ -65,6 +66,8 @@ export type PopoverProps = { * Callback when the popover wants to close. */ onClose?: () => void; + + asChild?: boolean; } & HTMLAttributes; export const Popover = forwardRef( @@ -78,10 +81,13 @@ export const Popover = forwardRef( placement = 'top', size = 'md', variant = 'default', + asChild = false, ...rest }, ref, ) { + const Component = asChild ? Slot : 'div'; + const popoverRef = useRef(null); const mergedRefs = useMergeRefs([popoverRef, ref]); const { popoverId, setPopoverId } = useContext(Context); @@ -154,7 +160,7 @@ export const Popover = forwardRef( return ( -