From cc908e05957783adf97b94d5e196bf9722523471 Mon Sep 17 00:00:00 2001 From: Sean Erik Scully Date: Fri, 15 Nov 2024 15:10:58 +0100 Subject: [PATCH] feat: search bar (#45) Co-authored-by: Inge Fossland --- biome.jsonc | 2 +- .../Attachment/AttachmentLink.stories.ts | 2 +- .../Attachment/AttachmentList.stories.ts | 2 +- lib/components/Button/Button.tsx | 23 +- lib/components/Button/ButtonBase.tsx | 2 +- lib/components/Button/ButtonIcon.tsx | 16 + lib/components/Button/ButtonLabel.tsx | 16 + lib/components/Button/Buttons.stories.tsx | 64 +++ lib/components/Button/ComboButton.tsx | 16 +- lib/components/Button/IconButton.stories.tsx | 47 ++ lib/components/Button/IconButton.tsx | 20 +- lib/components/Button/button.module.css | 51 +- lib/components/Button/buttonBase.module.css | 78 +++- lib/components/Button/buttonIcon.module.css | 17 + lib/components/Button/buttonLabel.module.css | 17 + lib/components/Button/comboButton.module.css | 80 +--- lib/components/Button/iconButton.module.css | 25 +- lib/components/Button/index.ts | 2 + .../ContextMenu/ContextMenu.stories.ts | 49 ++ lib/components/ContextMenu/ContextMenu.tsx | 32 +- .../ContextMenu/ContextMenuBase.tsx | 33 ++ ....module.css => contextMenuBase.module.css} | 0 lib/components/Dialog/Dialog.tsx | 2 + lib/components/Dialog/DialogGroup.tsx | 24 + lib/components/Dialog/DialogList.stories.ts | 24 +- lib/components/Dialog/DialogList.tsx | 37 +- lib/components/Dialog/DialogListItem.tsx | 14 +- lib/components/Dialog/DialogListItemBase.tsx | 6 +- lib/components/Dialog/DialogNav.stories.ts | 10 +- lib/components/Dialog/DialogNav.tsx | 8 +- lib/components/Dialog/DialogSelect.tsx | 2 +- lib/components/Dialog/dialogGroup.module.css | 35 ++ lib/components/Dropdown/Backdrop.tsx | 7 +- lib/components/Dropdown/DrawerBase.tsx | 7 +- lib/components/Dropdown/DrawerBody.tsx | 12 + lib/components/Dropdown/DrawerButton.tsx | 17 + lib/components/Dropdown/DrawerFooter.tsx | 12 + lib/components/Dropdown/DrawerHeader.tsx | 19 + lib/components/Dropdown/DrawerOrDropdown.tsx | 29 ++ lib/components/Dropdown/DropdownBase.tsx | 7 +- lib/components/Dropdown/backdrop.module.css | 3 + lib/components/Dropdown/drawerBase.module.css | 9 + lib/components/Dropdown/drawerBody.module.css | 5 + .../Dropdown/drawerButton.module.css | 6 + .../Dropdown/drawerFooter.module.css | 13 + .../Dropdown/drawerHeader.module.css | 17 + .../Dropdown/drawerOrDropdown.module.css | 19 + .../Dropdown/dropdownBase.module.css | 18 +- lib/components/Dropdown/index.ts | 8 +- lib/components/Footer/footerMenu.module.css | 5 + .../GlobalMenu/GlobalMenu.stories.tsx | 18 +- lib/components/GlobalMenu/GlobalMenu.tsx | 10 +- .../{Header.stories.ts => Header.stories.tsx} | 99 +++- lib/components/Header/Header.tsx | 61 +-- lib/components/Header/HeaderBase.tsx | 10 +- lib/components/Header/HeaderSearch.stories.ts | 20 - lib/components/Header/HeaderSearch.tsx | 44 -- lib/components/Header/header.module.css | 52 +-- lib/components/Header/headerBase.module.css | 43 ++ lib/components/Header/headerButton.module.css | 1 + lib/components/Header/headerSearch.module.css | 30 -- lib/components/Layout/Layout.stories.tsx | 115 +++-- lib/components/Layout/Layout.tsx | 8 +- lib/components/Layout/LayoutBase.tsx | 5 +- lib/components/Layout/layoutBase.module.css | 11 + lib/components/Layout/layoutBody.module.css | 1 + lib/components/LayoutAction/ActionHeader.tsx | 2 +- lib/components/LayoutAction/ActionMenu.tsx | 6 +- .../LayoutAction/actionMenu.module.css | 3 + lib/components/List/List.stories.tsx | 43 ++ lib/components/List/List.tsx | 12 +- lib/components/List/ListBase.tsx | 12 +- lib/components/List/ListItem.tsx | 5 +- lib/components/List/ListItemBase.tsx | 24 +- lib/components/List/listBase.module.css | 6 +- lib/components/List/listItemBase.module.css | 4 + lib/components/Menu/Menu.stories.ts | 92 ++-- lib/components/Menu/Menu.tsx | 105 +---- lib/components/Menu/MenuBase.tsx | 50 +- lib/components/Menu/MenuItem.tsx | 10 +- lib/components/Menu/MenuItemBase.tsx | 7 + lib/components/Menu/MenuItems.stories.ts | 438 ++++++++++++++++++ lib/components/Menu/MenuItems.tsx | 96 ++++ lib/components/Menu/MenuOption.tsx | 5 +- .../Menu/{MenuGroup.tsx => __MenuGroup.tsx} | 2 +- lib/components/Menu/index.ts | 2 +- lib/components/Menu/menu.module.css | 5 +- lib/components/Menu/menuBase.module.css | 25 + lib/components/Menu/menuItemBase.module.css | 9 +- lib/components/Meta/MetaItemBase.tsx | 2 +- lib/components/Meta/MetaItemLabel.tsx | 2 +- lib/components/Meta/MetaItemMedia.tsx | 2 +- lib/components/Page/PageBase.tsx | 14 + lib/components/Page/PageHeader.tsx | 21 + lib/components/Page/PageHeaderMedia.tsx | 25 + lib/components/Page/SectionBase.tsx | 52 +++ lib/components/Page/SectionFooter.tsx | 15 + lib/components/Page/SectionHeader.tsx | 16 + lib/components/Page/index.ts | 5 + lib/components/Page/pageHeader.module.css | 5 + lib/components/Page/sectionBase.module.css | 82 ++++ lib/components/Page/sectionFooter.module.css | 8 + lib/components/Page/sectionHeader.module.css | 9 + lib/components/RootProvider/RootProvider.tsx | 50 +- .../Searchbar/Autocomplete.stories.tsx | 77 +++ lib/components/Searchbar/Autocomplete.tsx | 44 ++ lib/components/Searchbar/AutocompleteBase.tsx | 16 + .../Searchbar/AutocompleteGroup.tsx | 17 + lib/components/Searchbar/AutocompleteItem.tsx | 23 + lib/components/Searchbar/SearchField.tsx | 78 ++++ .../Searchbar/Searchbar.stories.tsx | 151 ++++++ lib/components/Searchbar/Searchbar.tsx | 18 + lib/components/Searchbar/SearchbarBase.tsx | 23 + .../Searchbar/autocompleteBase.module.css | 17 + .../Searchbar/autocompleteGroup.module.css | 3 + .../Searchbar/autocompleteItem.module.css | 19 + lib/components/Searchbar/index.ts | 1 + .../Searchbar/searchField.module.css | 54 +++ .../Searchbar/searchbarBase.module.css | 20 + lib/components/Toolbar/Toolbar.stories.tsx | 20 +- lib/components/Toolbar/Toolbar.tsx | 38 +- lib/components/Toolbar/ToolbarAdd.tsx | 11 +- lib/components/Toolbar/ToolbarBase.tsx | 25 +- lib/components/Toolbar/ToolbarButton.tsx | 2 +- lib/components/Toolbar/ToolbarFilter.tsx | 17 +- lib/components/Toolbar/ToolbarMenu.tsx | 15 +- .../Toolbar/ToolbarOptions.stories.ts | 10 +- lib/components/Toolbar/ToolbarOptions.tsx | 55 ++- lib/components/Toolbar/toolbar.module.css | 43 -- lib/components/Toolbar/toolbarAdd.module.css | 7 + lib/components/Toolbar/toolbarBase.module.css | 19 + .../Toolbar/toolbarButton.module.css | 2 +- .../Toolbar/toolbarFilter.module.css | 25 + lib/components/Toolbar/toolbarMenu.module.css | 7 + lib/components/Typography/Heading.tsx | 23 + lib/components/Typography/Typography.tsx | 13 +- lib/components/Typography/heading.module.css | 21 + lib/components/Typography/index.ts | 1 + .../Typography/typography.module.css | 8 + lib/components/index.ts | 2 + lib/hooks/index.ts | 3 + .../Menu => hooks}/useClickOutside.ts | 0 .../Menu => hooks}/useEscapeKey.ts | 4 +- lib/hooks/useMenu.tsx | 80 ++++ lib/index.ts | 1 + lib/stories/Color/MenuItem.stories.tsx | 43 ++ lib/stories/Color/Swatches.stories.tsx | 19 + lib/stories/Color/Swatches.tsx | 42 ++ lib/stories/Color/colors.json | 62 +++ lib/stories/Color/swatches.module.css | 14 + lib/stories/Inbox/BookmarksPage.tsx | 52 +++ lib/stories/Inbox/DialogPage.tsx | 15 + lib/stories/Inbox/Inbox.stories.tsx | 55 +++ lib/stories/Inbox/Inbox.tsx | 12 + lib/stories/Inbox/InboxLayout.tsx | 50 ++ lib/stories/Inbox/InboxPage.tsx | 50 ++ lib/stories/Inbox/InboxProvider.tsx | 136 ++++++ lib/stories/Inbox/InboxSection.tsx | 39 ++ lib/stories/Inbox/InboxToolbar.tsx | 94 ++++ lib/stories/Inbox/ProfilePage.tsx | 35 ++ lib/stories/Inbox/SettingsPage.tsx | 19 + lib/stories/Inbox/accounts/accounts.ts | 24 + lib/stories/Inbox/accounts/index.ts | 1 + lib/stories/Inbox/actionMenu.ts | 24 + .../Inbox/dialogs/brreg-completed.json | 35 ++ lib/stories/Inbox/dialogs/brreg-draft.json | 45 ++ lib/stories/Inbox/dialogs/index.ts | 10 + lib/stories/Inbox/dialogs/skatt-2023.json | 33 ++ lib/stories/Inbox/groupBy.ts | 19 + lib/stories/Inbox/inboxSection.module.css | 19 + lib/stories/Inbox/index.ts | 15 + lib/stories/Inbox/layout/footer.ts | 27 ++ lib/stories/Inbox/layout/header.ts | 11 + lib/stories/Inbox/layout/index.ts | 3 + lib/stories/Inbox/layout/menu.ts | 64 +++ tsconfig.json | 9 +- 176 files changed, 4034 insertions(+), 823 deletions(-) create mode 100644 lib/components/Button/ButtonIcon.tsx create mode 100644 lib/components/Button/ButtonLabel.tsx create mode 100644 lib/components/Button/Buttons.stories.tsx create mode 100644 lib/components/Button/IconButton.stories.tsx create mode 100644 lib/components/Button/buttonIcon.module.css create mode 100644 lib/components/Button/buttonLabel.module.css create mode 100644 lib/components/ContextMenu/ContextMenu.stories.ts create mode 100644 lib/components/ContextMenu/ContextMenuBase.tsx rename lib/components/ContextMenu/{contextMenu.module.css => contextMenuBase.module.css} (100%) create mode 100644 lib/components/Dialog/DialogGroup.tsx create mode 100644 lib/components/Dialog/dialogGroup.module.css create mode 100644 lib/components/Dropdown/DrawerBody.tsx create mode 100644 lib/components/Dropdown/DrawerButton.tsx create mode 100644 lib/components/Dropdown/DrawerFooter.tsx create mode 100644 lib/components/Dropdown/DrawerHeader.tsx create mode 100644 lib/components/Dropdown/DrawerOrDropdown.tsx create mode 100644 lib/components/Dropdown/drawerBody.module.css create mode 100644 lib/components/Dropdown/drawerButton.module.css create mode 100644 lib/components/Dropdown/drawerFooter.module.css create mode 100644 lib/components/Dropdown/drawerHeader.module.css create mode 100644 lib/components/Dropdown/drawerOrDropdown.module.css rename lib/components/Header/{Header.stories.ts => Header.stories.tsx} (51%) delete mode 100644 lib/components/Header/HeaderSearch.stories.ts delete mode 100644 lib/components/Header/HeaderSearch.tsx delete mode 100644 lib/components/Header/headerSearch.module.css create mode 100644 lib/components/List/List.stories.tsx create mode 100644 lib/components/Menu/MenuItems.stories.ts create mode 100644 lib/components/Menu/MenuItems.tsx rename lib/components/Menu/{MenuGroup.tsx => __MenuGroup.tsx} (79%) create mode 100644 lib/components/Menu/menuBase.module.css create mode 100644 lib/components/Page/PageBase.tsx create mode 100644 lib/components/Page/PageHeader.tsx create mode 100644 lib/components/Page/PageHeaderMedia.tsx create mode 100644 lib/components/Page/SectionBase.tsx create mode 100644 lib/components/Page/SectionFooter.tsx create mode 100644 lib/components/Page/SectionHeader.tsx create mode 100644 lib/components/Page/index.ts create mode 100644 lib/components/Page/pageHeader.module.css create mode 100644 lib/components/Page/sectionBase.module.css create mode 100644 lib/components/Page/sectionFooter.module.css create mode 100644 lib/components/Page/sectionHeader.module.css create mode 100644 lib/components/Searchbar/Autocomplete.stories.tsx create mode 100644 lib/components/Searchbar/Autocomplete.tsx create mode 100644 lib/components/Searchbar/AutocompleteBase.tsx create mode 100644 lib/components/Searchbar/AutocompleteGroup.tsx create mode 100644 lib/components/Searchbar/AutocompleteItem.tsx create mode 100644 lib/components/Searchbar/SearchField.tsx create mode 100644 lib/components/Searchbar/Searchbar.stories.tsx create mode 100644 lib/components/Searchbar/Searchbar.tsx create mode 100644 lib/components/Searchbar/SearchbarBase.tsx create mode 100644 lib/components/Searchbar/autocompleteBase.module.css create mode 100644 lib/components/Searchbar/autocompleteGroup.module.css create mode 100644 lib/components/Searchbar/autocompleteItem.module.css create mode 100644 lib/components/Searchbar/index.ts create mode 100644 lib/components/Searchbar/searchField.module.css create mode 100644 lib/components/Searchbar/searchbarBase.module.css delete mode 100644 lib/components/Toolbar/toolbar.module.css create mode 100644 lib/components/Toolbar/toolbarAdd.module.css create mode 100644 lib/components/Toolbar/toolbarBase.module.css create mode 100644 lib/components/Toolbar/toolbarFilter.module.css create mode 100644 lib/components/Toolbar/toolbarMenu.module.css create mode 100644 lib/components/Typography/Heading.tsx create mode 100644 lib/components/Typography/heading.module.css create mode 100644 lib/hooks/index.ts rename lib/{components/Menu => hooks}/useClickOutside.ts (100%) rename lib/{components/Menu => hooks}/useEscapeKey.ts (79%) create mode 100644 lib/hooks/useMenu.tsx create mode 100644 lib/stories/Color/MenuItem.stories.tsx create mode 100644 lib/stories/Color/Swatches.stories.tsx create mode 100644 lib/stories/Color/Swatches.tsx create mode 100644 lib/stories/Color/colors.json create mode 100644 lib/stories/Color/swatches.module.css create mode 100644 lib/stories/Inbox/BookmarksPage.tsx create mode 100644 lib/stories/Inbox/DialogPage.tsx create mode 100644 lib/stories/Inbox/Inbox.stories.tsx create mode 100644 lib/stories/Inbox/Inbox.tsx create mode 100644 lib/stories/Inbox/InboxLayout.tsx create mode 100644 lib/stories/Inbox/InboxPage.tsx create mode 100644 lib/stories/Inbox/InboxProvider.tsx create mode 100644 lib/stories/Inbox/InboxSection.tsx create mode 100644 lib/stories/Inbox/InboxToolbar.tsx create mode 100644 lib/stories/Inbox/ProfilePage.tsx create mode 100644 lib/stories/Inbox/SettingsPage.tsx create mode 100644 lib/stories/Inbox/accounts/accounts.ts create mode 100644 lib/stories/Inbox/accounts/index.ts create mode 100644 lib/stories/Inbox/actionMenu.ts create mode 100644 lib/stories/Inbox/dialogs/brreg-completed.json create mode 100644 lib/stories/Inbox/dialogs/brreg-draft.json create mode 100644 lib/stories/Inbox/dialogs/index.ts create mode 100644 lib/stories/Inbox/dialogs/skatt-2023.json create mode 100644 lib/stories/Inbox/groupBy.ts create mode 100644 lib/stories/Inbox/inboxSection.module.css create mode 100644 lib/stories/Inbox/index.ts create mode 100644 lib/stories/Inbox/layout/footer.ts create mode 100644 lib/stories/Inbox/layout/header.ts create mode 100644 lib/stories/Inbox/layout/index.ts create mode 100644 lib/stories/Inbox/layout/menu.ts diff --git a/biome.jsonc b/biome.jsonc index f61a62c..9977bbd 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -60,6 +60,6 @@ } }, "files": { - "ignore": [".github", "node_modules", "dist", "build", ".storybook"] + "ignore": [".github", "node_modules", "dist", "build", ".storybook", "lib/stories"] } } diff --git a/lib/components/Attachment/AttachmentLink.stories.ts b/lib/components/Attachment/AttachmentLink.stories.ts index 28fbca9..7a60f87 100644 --- a/lib/components/Attachment/AttachmentLink.stories.ts +++ b/lib/components/Attachment/AttachmentLink.stories.ts @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { AttachmentLink } from './AttachmentLink'; const meta = { - title: 'Attachment/AttachmentLink', + title: 'Atoms/Attachment/AttachmentLink', component: AttachmentLink, tags: ['autodocs'], parameters: {}, diff --git a/lib/components/Attachment/AttachmentList.stories.ts b/lib/components/Attachment/AttachmentList.stories.ts index 6e2b1b4..ac0a28e 100644 --- a/lib/components/Attachment/AttachmentList.stories.ts +++ b/lib/components/Attachment/AttachmentList.stories.ts @@ -4,7 +4,7 @@ import { fn } from '@storybook/test'; import { AttachmentList } from './AttachmentList'; const meta = { - title: 'Attachment/AttachmentList', + title: 'Atoms/Attachment/AttachmentList', component: AttachmentList, tags: ['autodocs'], parameters: { diff --git a/lib/components/Button/Button.tsx b/lib/components/Button/Button.tsx index 8640047..8b88ce3 100644 --- a/lib/components/Button/Button.tsx +++ b/lib/components/Button/Button.tsx @@ -1,6 +1,8 @@ import cx from 'classnames'; -import { Icon, type IconName } from '../Icon'; +import type { IconName } from '../Icon'; import { ButtonBase, type ButtonBaseProps } from './ButtonBase'; +import { ButtonIcon } from './ButtonIcon'; +import { ButtonLabel } from './ButtonLabel'; import styles from './button.module.css'; export interface ButtonProps extends Partial { @@ -10,22 +12,27 @@ export interface ButtonProps extends Partial { } export const Button = ({ + variant = 'solid', + color = 'primary', size = 'md', reverse = false, selected = false, icon, href, children, + className, loading, ...rest }: ButtonProps) => { if (loading) { return ( @@ -38,20 +45,16 @@ export const Button = ({ return ( - - {children} - - {icon && ( - - - - )} + {children} + {icon && } ); }; diff --git a/lib/components/Button/ButtonBase.tsx b/lib/components/Button/ButtonBase.tsx index 266738d..e56e0ee 100644 --- a/lib/components/Button/ButtonBase.tsx +++ b/lib/components/Button/ButtonBase.tsx @@ -4,7 +4,7 @@ import type { ElementType, ReactNode } from 'react'; import styles from './buttonBase.module.css'; export type ButtonVariant = 'solid' | 'outline' | 'dotted' | 'text'; -export type ButtonSize = 'sm' | 'md' | 'lg'; +export type ButtonSize = 'sm' | 'md' | 'lg' | 'custom'; export type ButtonColor = 'primary' | 'secondary'; export interface ButtonBaseProps extends React.HTMLAttributes { diff --git a/lib/components/Button/ButtonIcon.tsx b/lib/components/Button/ButtonIcon.tsx new file mode 100644 index 0000000..a3e7b4a --- /dev/null +++ b/lib/components/Button/ButtonIcon.tsx @@ -0,0 +1,16 @@ +import { Icon, type IconName } from '../Icon'; +import type { ButtonSize } from './ButtonBase'; +import styles from './buttonIcon.module.css'; + +export interface ButtonIconProps { + icon: IconName; + size: ButtonSize; +} + +export const ButtonIcon = ({ size, icon }: ButtonIconProps) => { + return ( + + + + ); +}; diff --git a/lib/components/Button/ButtonLabel.tsx b/lib/components/Button/ButtonLabel.tsx new file mode 100644 index 0000000..e29160f --- /dev/null +++ b/lib/components/Button/ButtonLabel.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react'; +import type { ButtonSize } from './ButtonBase'; +import styles from './buttonLabel.module.css'; + +export interface ButtonLabelProps { + size: ButtonSize; + children: ReactNode; +} + +export const ButtonLabel = ({ size, children }: ButtonLabelProps) => { + return ( + + {children} + + ); +}; diff --git a/lib/components/Button/Buttons.stories.tsx b/lib/components/Button/Buttons.stories.tsx new file mode 100644 index 0000000..f70c608 --- /dev/null +++ b/lib/components/Button/Buttons.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button, ComboButton, IconButton, MetaItem } from '../'; + +const meta = { + title: 'Atoms/Button/Buttons', + tags: ['autodocs'], + parameters: {}, + args: { + color: 'primary', + }, + argTypes: { + color: { + control: 'radio', + options: ['primary', 'secondary'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const sizes = ['sm', 'md', 'lg']; +const variants = ['solid', 'outline', 'dotted', 'text']; + +export const VariantsAndSizes = (args: Story) => { + return ( +
+ {variants?.map((variant) => { + return ( +
+ {variant} + {sizes?.map((size) => { + return ( +
+ + + + + ComboButton + + {size} +
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/lib/components/Button/ComboButton.tsx b/lib/components/Button/ComboButton.tsx index 1dcbb17..f385088 100644 --- a/lib/components/Button/ComboButton.tsx +++ b/lib/components/Button/ComboButton.tsx @@ -1,7 +1,9 @@ import cx from 'classnames'; import type { MouseEventHandler } from 'react'; -import { Icon, type IconName } from '../Icon'; +import type { IconName } from '../Icon'; import { ButtonBase, type ButtonBaseProps } from './ButtonBase'; +import { ButtonIcon } from './ButtonIcon'; +import { ButtonLabel } from './ButtonLabel'; import styles from './comboButton.module.css'; @@ -13,9 +15,9 @@ export interface ComboButtonProps extends Omit { } export const ComboButton = ({ - size = 'md', variant = 'solid', - color, + color = 'primary', + size = 'md', selected = false, icon, children, @@ -32,12 +34,12 @@ export const ComboButton = ({ selected={selected} className={cx(styles.button, className)} > - - + + - - {children} + + {children} ); diff --git a/lib/components/Button/IconButton.stories.tsx b/lib/components/Button/IconButton.stories.tsx new file mode 100644 index 0000000..60cac68 --- /dev/null +++ b/lib/components/Button/IconButton.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MetaItem } from '../Meta'; +import { IconButton } from './IconButton'; + +const meta = { + title: 'Atoms/Button/IconButton', + component: IconButton, + tags: ['autodocs'], + parameters: {}, + args: { + children: 'IconButton', + icon: 'x-mark', + size: 'sm', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +const sizes = ['sm', 'md', 'lg']; +const variants = ['outline', 'solid', 'dotted', 'text']; + +export const Sizes = (args) => { + return ( +
+ {variants?.map((variant) => { + return ( +
+ {variant} + {sizes?.map((size) => { + return ( +
+ + {size} +
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/lib/components/Button/IconButton.tsx b/lib/components/Button/IconButton.tsx index e5aaba7..b3763c4 100644 --- a/lib/components/Button/IconButton.tsx +++ b/lib/components/Button/IconButton.tsx @@ -1,6 +1,9 @@ import cx from 'classnames'; -import { ButtonBase, type ButtonColor, type ButtonSize, type ButtonVariant } from '../Button'; -import { Icon, type IconName } from '../Icon'; +import type { MouseEventHandler } from 'react'; +import type { IconName } from '../Icon'; +import { ButtonBase } from './ButtonBase'; +import type { ButtonColor, ButtonSize, ButtonVariant } from './ButtonBase'; +import { ButtonIcon } from './ButtonIcon'; import styles from './iconButton.module.css'; interface IconButtonProps { @@ -9,13 +12,20 @@ interface IconButtonProps { size?: ButtonSize; variant?: ButtonVariant; className?: string; - onClick?: () => void; + onClick?: MouseEventHandler; } -export const IconButton = ({ className, variant, color, size, icon, onClick }: IconButtonProps) => { +export const IconButton = ({ + variant = 'solid', + color = 'primary', + size = 'md', + icon, + className, + onClick, +}: IconButtonProps) => { return ( - + ); }; diff --git a/lib/components/Button/button.module.css b/lib/components/Button/button.module.css index 08b222f..89cdde0 100644 --- a/lib/components/Button/button.module.css +++ b/lib/components/Button/button.module.css @@ -1,63 +1,22 @@ .button { display: inline-flex; align-items: center; + column-gap: 0.125em; + border-radius: 2px; } .reverse { flex-direction: row-reverse; } -.label { - line-height: 1rem; - font-weight: 600; -} - -.icon { - display: flex; - align-items: center; - justify-content: center; -} - -/* sm 36 */ - .button[data-size="sm"] { - border-radius: 2px; - padding: 0 6px; -} - -.label[data-size="sm"] { - font-size: 0.875rem; - padding: 9px 4px; -} - -.icon[data-size="sm"] { - font-size: 1.25rem; + padding: 0 0.375rem; } -/* md 44 */ - .button[data-size="md"] { - border-radius: 2px; - padding: 9px 6px; + padding: 0 0.5rem; } -.label[data-size="md"] { - font-size: 1rem; - padding: 4px 4px; -} - -.icon[data-size="md"] { - font-size: 1.5rem; -} - -/* lg 56 */ - .button[data-size="lg"] { - border-radius: 2px; - padding: 9px 6px; -} - -.label[data-size="lg"] { - font-size: 1.125rem; - padding: 10px; + padding: 0 0.625rem; } diff --git a/lib/components/Button/buttonBase.module.css b/lib/components/Button/buttonBase.module.css index 0ba8457..faae7b9 100644 --- a/lib/components/Button/buttonBase.module.css +++ b/lib/components/Button/buttonBase.module.css @@ -21,7 +21,7 @@ -moz-osx-font-smoothing: inherit; /* Corrects inability to style clickable `input` types in iOS */ - -webkit-appearance: none; + appearance: none; /* user select none */ user-select: none; @@ -45,7 +45,19 @@ border-color: var(--theme-base-active); } -/* solid */ +.button[data-size="sm"] { + height: 2.25rem; /* sm 36 */ +} + +.button[data-size="md"] { + height: 2.75rem; /* md 44 */ +} + +.button[data-size="lg"] { + height: 3.5rem; /* lg 56 */ +} + +/* primary solid */ .button[data-variant="solid"] { border-color: var(--theme-base-default); @@ -53,44 +65,52 @@ color: white; } -/* outline */ +.button[data-variant="solid"]:hover { + border-color: var(--theme-base-hover); + background-color: var(--theme-base-hover); +} + +.button[data-variant="solid"]:active { + border-color: var(--theme-base-active); + background-color: var(--theme-base-active); +} + +/* primary outline, dotted + text */ + +.button[data-variant="outline"], +.button[data-variant="dotted"] { + border-color: var(--theme-base-default); +} .button[data-variant="outline"] { border-style: solid; } -/* dotted */ - .button[data-variant="dotted"] { border-style: dotted; } -/* text */ - .button[data-variant="text"] { border-color: transparent; } -.button[aria-selected="true"] { - background-color: var(--theme-background-subtle); - color: var(--theme-text-default); +.button[data-variant="outline"]:hover, +.button[data-variant="dotted"]:hover, +.button[data-variant="text"]:hover { + background-color: var(--theme-surface-default); } -/* primary color */ - -.button[data-color="primary"] { - border-color: var(--theme-base-default); +.button[data-variant="outline"]:active, +.button[data-variant="dotted"]:active, +.button[data-variant="text"]:active { + background-color: var(--theme-surface-active); } -.button[data-color="primary"]:hover { - border-color: var(--theme-base-hover); -} +/* selected */ -.button[data-color="primary"][data-variant="solid"], -.button[data-color="primary"][data-variant="solid"] { - border-color: var(--theme-base-default); - background-color: var(--theme-base-default); - color: white; +.button[aria-selected="true"] { + background-color: var(--theme-background-subtle); + color: var(--theme-text-default); } /* secondary color */ @@ -103,8 +123,20 @@ border-color: var(--theme-surface-hover); } -.button[data-color="secondary"][data-variant="solid"], .button[data-color="secondary"][data-variant="solid"] { border-color: var(--theme-surface-default); background-color: var(--theme-surface-default); } + +.button[data-color="secondary"][data-variant="solid"]:hover { + border-color: var(--theme-surface-hover); + background-color: var(--theme-surface-hover); +} + +.button[data-color="secondary"][data-variant="solid"]:active, +.button[data-color="secondary"][data-variant="outline"]:active, +.button[data-color="secondary"][data-variant="dotted"]:active, +.button[data-color="secondary"][data-variant="text"]:active { + border-color: var(--theme-surface-active); + background-color: var(--theme-surface-active); +} diff --git a/lib/components/Button/buttonIcon.module.css b/lib/components/Button/buttonIcon.module.css new file mode 100644 index 0000000..1e2dd5a --- /dev/null +++ b/lib/components/Button/buttonIcon.module.css @@ -0,0 +1,17 @@ +.icon { + display: flex; + align-items: center; + justify-content: center; +} + +.icon[data-size="sm"] { + font-size: 1.25rem; +} + +.icon[data-size="md"] { + font-size: 1.5rem; +} + +.icon[data-size="lg"] { + font-size: 1.625rem; +} diff --git a/lib/components/Button/buttonLabel.module.css b/lib/components/Button/buttonLabel.module.css new file mode 100644 index 0000000..58ecc6a --- /dev/null +++ b/lib/components/Button/buttonLabel.module.css @@ -0,0 +1,17 @@ +.label { + line-height: 1; + font-weight: 500; + padding: 0 .25em; +} + +.label[data-size="sm"] { + font-size: 0.875rem; +} + +.label[data-size="md"] { + font-size: 1rem; +} + +.label[data-size="lg"] { + font-size: 1.125rem; +} diff --git a/lib/components/Button/comboButton.module.css b/lib/components/Button/comboButton.module.css index d84be06..c36dd5e 100644 --- a/lib/components/Button/comboButton.module.css +++ b/lib/components/Button/comboButton.module.css @@ -2,84 +2,34 @@ display: inline-flex; flex-direction: row-reverse; align-items: center; - border: 1px solid; border-radius: 2px; } -/* -.button[aria-selected="true"] { - background-color: var(--theme-background-subtle); - color: var(--theme-text-default); -} - */ - -.label { - line-height: 1rem; - font-weight: 600; -} - .divider { + align-self: stretch; border-left: 1px solid currentColor; width: 1px; - height: 1em; + margin: 0.375rem 0; } -.icon { - display: flex; +.primary, +.secondary { + display: inline-flex; align-items: center; - justify-content: center; -} - -/* variant */ - -.button[data-variant="solid"] { - border-color: var(--theme-base-hover); - background-color: var(--theme-base-hover); - color: white; -} - -/* sm 36px */ - -.label[data-size="sm"] { - font-size: 0.875rem; - padding: 8px 10px; + border: none; } -.divider[data-size="sm"] { - height: 20px; -} - -.icon[data-size="sm"] { - font-size: 1.25rem; - padding: 6px 5px; -} - -/* md 44px */ - -.button[data-size="md"] { - font-size: 1rem; -} - -.label[data-size="md"] { - padding: 10px; -} - -.icon[data-size="md"] { - font-size: 1.25rem; - padding: 12px 8px; -} - -/* lg 56 */ - -.button[data-size="lg"] { - font-size: 1.125rem; +.primary[data-size="sm"], +.secondary[data-size="sm"] { + padding: 0 0.375rem; } -.label[data-size="lg"] { - padding: 1rem; +.primary[data-size="md"], +.secondary[data-size="md"] { + padding: 0 0.5rem; } -.icon[data-size="lg"] { - font-size: 1.5rem; - padding: 16px 10px; +.primary[data-size="lg"], +.secondary[data-size="lg"] { + padding: 0 0.625rem; } diff --git a/lib/components/Button/iconButton.module.css b/lib/components/Button/iconButton.module.css index a4f1775..bdbe7a5 100644 --- a/lib/components/Button/iconButton.module.css +++ b/lib/components/Button/iconButton.module.css @@ -1,7 +1,4 @@ .button { - border: 1px solid; - width: 2.75rem; - height: 2.75rem; display: flex; align-items: center; justify-content: center; @@ -10,5 +7,25 @@ .icon { font-size: 1.5rem; - margin: 0.625rem; +} + +/* sm 36 */ + +.button[data-size="sm"] { + width: 2.25rem; + height: 2.25rem; +} + +/* md 44 */ + +.button[data-size="md"] { + width: 2.75rem; + height: 2.75rem; +} + +/* lg 56 */ + +.button[data-size="lg"] { + width: 3.5rem; + height: 3.5rem; } diff --git a/lib/components/Button/index.ts b/lib/components/Button/index.ts index c82cc8c..3076471 100644 --- a/lib/components/Button/index.ts +++ b/lib/components/Button/index.ts @@ -1,4 +1,6 @@ export * from './ButtonBase'; +export * from './ButtonLabel'; +export * from './ButtonIcon'; export * from './Button'; export * from './ComboButton'; export * from './IconButton'; diff --git a/lib/components/ContextMenu/ContextMenu.stories.ts b/lib/components/ContextMenu/ContextMenu.stories.ts new file mode 100644 index 0000000..865ae3c --- /dev/null +++ b/lib/components/ContextMenu/ContextMenu.stories.ts @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ContextMenu } from './ContextMenu'; + +const meta = { + title: 'ContextMenu/ContextMenu', + component: ContextMenu, + tags: ['autodocs'], + parameters: {}, + args: { + placement: 'left', + items: [ + { + id: '1', + groupId: '1', + icon: 'arrow-redo', + label: 'Del og gi tilgang', + }, + { + id: '2', + groupId: '1', + icon: 'eye-closed', + label: 'Marker som ny', + }, + { + id: '3', + groupId: '2', + icon: 'archive', + label: 'Flytt til arkiv', + }, + { + id: '4', + groupId: '2', + icon: 'trash', + label: 'Flytt til papirkurv', + }, + { + id: '5', + groupId: '3', + icon: 'clock-dashed', + label: 'Aktivitetslogg', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/lib/components/ContextMenu/ContextMenu.tsx b/lib/components/ContextMenu/ContextMenu.tsx index 2d2ef04..da0277d 100644 --- a/lib/components/ContextMenu/ContextMenu.tsx +++ b/lib/components/ContextMenu/ContextMenu.tsx @@ -1,28 +1,20 @@ -import type { MouseEventHandler } from 'react'; -import { ButtonBase } from '../Button'; -import { Icon } from '../Icon'; -import { Menu, type MenuGroups, type MenuItemProps } from '../Menu'; -import styles from './contextMenu.module.css'; +import type { DropdownPlacement, MenuItemProps } from '../'; +import { type MenuItemGroups, MenuItems } from '../'; +import { useRootContext } from '../RootProvider'; +import { ContextMenuBase } from './ContextMenuBase'; export interface ContextMenuProps { - onToggle?: MouseEventHandler; - label: string; - value: string | number; + id: string; items: MenuItemProps[]; - groups?: MenuGroups; - expanded?: boolean; - className?: string; + placement?: DropdownPlacement; + groups?: MenuItemGroups; } -export const ContextMenu = ({ expanded = true, onToggle, groups = {}, items }: ContextMenuProps) => { +export const ContextMenu = ({ id = 'context-menu', placement = 'right', groups = {}, items }: ContextMenuProps) => { + const { currentId, toggleId } = useRootContext(); return ( -
- - - -
- -
-
+ toggleId(id)}> + + ); }; diff --git a/lib/components/ContextMenu/ContextMenuBase.tsx b/lib/components/ContextMenu/ContextMenuBase.tsx new file mode 100644 index 0000000..dc595a3 --- /dev/null +++ b/lib/components/ContextMenu/ContextMenuBase.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react'; +import { DropdownBase, IconButton } from '../'; +import type { DropdownPlacement } from '../'; +import styles from './contextMenuBase.module.css'; + +export interface ContextMenuBaseProps { + placement: DropdownPlacement; + expanded: boolean; + onToggle?: () => void; + children?: ReactNode; +} + +export const ContextMenuBase = ({ + placement = 'right', + expanded = false, + onToggle, + children, +}: ContextMenuBaseProps) => { + return ( +
+ + + {children} + +
+ ); +}; diff --git a/lib/components/ContextMenu/contextMenu.module.css b/lib/components/ContextMenu/contextMenuBase.module.css similarity index 100% rename from lib/components/ContextMenu/contextMenu.module.css rename to lib/components/ContextMenu/contextMenuBase.module.css diff --git a/lib/components/Dialog/Dialog.tsx b/lib/components/Dialog/Dialog.tsx index 5f2323d..030deb1 100644 --- a/lib/components/Dialog/Dialog.tsx +++ b/lib/components/Dialog/Dialog.tsx @@ -19,6 +19,8 @@ import { type DialogBackButtonProps, DialogNav } from './DialogNav'; import type { DialogStatusProps } from './DialogStatus'; export interface DialogProps { + /** Dialog id */ + id: string; /** Title */ title: string; /** Back button */ diff --git a/lib/components/Dialog/DialogGroup.tsx b/lib/components/Dialog/DialogGroup.tsx new file mode 100644 index 0000000..b42fbe8 --- /dev/null +++ b/lib/components/Dialog/DialogGroup.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { Heading, ListBase, SectionBase, SectionHeader } from '../'; +import { Button } from '../Button'; + +export interface DialogGroupProps { + title?: string; + children?: ReactNode; +} + +export const DialogGroup = ({ title, children }: DialogGroupProps) => { + return ( + + {title && ( + + {title} + + + )} + {children} + + ); +}; diff --git a/lib/components/Dialog/DialogList.stories.ts b/lib/components/Dialog/DialogList.stories.ts index 4d35a63..5d38542 100644 --- a/lib/components/Dialog/DialogList.stories.ts +++ b/lib/components/Dialog/DialogList.stories.ts @@ -1,6 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { fn } from '@storybook/test'; - import { DialogList } from './DialogList'; const meta = { @@ -16,26 +14,31 @@ const meta = { title: 'Støtte til utbygging av solceller', summary: 'Din støtte er innvilget', status: { value: 'draft' }, + groupId: '2024-01', }, { title: 'Støtte til utbygging av solceller', summary: 'Din støtte er innvilget', status: { value: 'sent' }, + groupId: '2024-02', }, { title: 'Støtte til utbygging av solceller', summary: 'Din støtte er innvilget', status: { value: 'requires-attention' }, + groupId: '2024-01', }, { title: 'Støtte til utbygging av solceller', summary: 'Din støtte er innvilget', status: { value: 'in-progress' }, + groupId: '2024-02', }, { title: 'Støtte til utbygging av solceller', summary: 'Din støtte er innvilget.', status: { value: 'completed' }, + groupId: '2024-01', }, ], }, @@ -48,14 +51,15 @@ export const Default: Story = { args: {}, }; -export const Company: Story = { +export const Grouped: Story = { args: { - theme: 'company', - }, -}; - -export const Person: Story = { - args: { - theme: 'person', + groups: { + '2024-01': { + title: 'Januar 2024', + }, + '2024-02': { + title: 'Februar 2024', + }, + }, }, }; diff --git a/lib/components/Dialog/DialogList.tsx b/lib/components/Dialog/DialogList.tsx index a3b7378..7aed210 100644 --- a/lib/components/Dialog/DialogList.tsx +++ b/lib/components/Dialog/DialogList.tsx @@ -1,20 +1,35 @@ -import type { LayoutTheme } from '../Layout'; -import { ListBase } from '../List'; +import { SectionBase } from '../'; +import { useMenu } from '../../hooks'; +import { DialogGroup, type DialogGroupProps } from './DialogGroup'; import { DialogListItem, type DialogListItemProps } from './DialogListItem'; -import type { DialogListItemSize } from './DialogListItemBase'; export interface DialogListProps { - size?: DialogListItemSize; - theme?: LayoutTheme; - items?: DialogListItemProps[]; + items: DialogListItemProps[]; + groups?: Record; } -export const DialogList = ({ theme, size = 'md', items }: DialogListProps) => { +export const DialogList = ({ items, groups = {} }: DialogListProps) => { + const { menu } = useMenu({ + items, + groups, + groupByKey: 'groupId', + keyboardEvents: false, + }); + return ( - - {items?.map((item, index) => { - return ; + + {menu?.map((group, groupIndex) => { + const groupProps = group.props || {}; + + return ( + + {group?.items.map((item, index) => { + const itemProps = item.props || {}; + return ; + })} + + ); })} - + ); }; diff --git a/lib/components/Dialog/DialogListItem.tsx b/lib/components/Dialog/DialogListItem.tsx index d0b346d..b3ea57b 100644 --- a/lib/components/Dialog/DialogListItem.tsx +++ b/lib/components/Dialog/DialogListItem.tsx @@ -55,6 +55,8 @@ export type DialogListItemProps = { attachmentsCount?: number; /** OnClick handler */ onClick?: () => void; + /** Group id */ + groupId?: string; }; /** @@ -68,6 +70,7 @@ export const DialogListItem = ({ size = 'lg', variant = 'neutral', href, + onClick, select, selected, status, @@ -84,10 +87,17 @@ export const DialogListItem = ({ attachmentsCount, title, summary, - ...rest }: DialogListItemProps) => { return ( - +
diff --git a/lib/components/Dialog/DialogListItemBase.tsx b/lib/components/Dialog/DialogListItemBase.tsx index 7ee8d0f..ce4b073 100644 --- a/lib/components/Dialog/DialogListItemBase.tsx +++ b/lib/components/Dialog/DialogListItemBase.tsx @@ -20,6 +20,8 @@ export type DialogListItemBaseProps = { children?: ReactNode; /** Variant */ variant?: DialogListItemVariant; + /** OnClick handler */ + onClick?: () => void; }; /** @@ -35,13 +37,13 @@ export const DialogListItemBase = ({ select, selected, children, - ...rest + onClick, }: DialogListItemBaseProps) => { const Component = as || 'button'; return (
- + {children} {select && } diff --git a/lib/components/Dialog/DialogNav.stories.ts b/lib/components/Dialog/DialogNav.stories.ts index 96a8a95..ad00218 100644 --- a/lib/components/Dialog/DialogNav.stories.ts +++ b/lib/components/Dialog/DialogNav.stories.ts @@ -54,31 +54,31 @@ export const ContextMenu: Story = { items: [ { id: '1', - group: '1', + groupId: '1', icon: 'arrow-redo', label: 'Del og gi tilgang', }, { id: '2', - group: '1', + groupId: '1', icon: 'eye-closed', label: 'Marker som ny', }, { id: '3', - group: '2', + groupId: '2', icon: 'archive', label: 'Flytt til arkiv', }, { id: '4', - group: '2', + groupId: '2', icon: 'trash', label: 'Flytt til papirkurv', }, { id: '5', - group: '3', + groupId: '3', icon: 'clock-dashed', label: 'Aktivitetslogg', }, diff --git a/lib/components/Dialog/DialogNav.tsx b/lib/components/Dialog/DialogNav.tsx index eea15c5..d3849e5 100644 --- a/lib/components/Dialog/DialogNav.tsx +++ b/lib/components/Dialog/DialogNav.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState } from 'react'; import type { ElementType } from 'react'; import { Button } from '../Button'; -import { ContextMenu, type ContextMenuProps } from '../ContextMenu/ContextMenu.tsx'; +import { ContextMenu, type ContextMenuProps } from '../ContextMenu'; import { MetaTimestamp } from '../Meta'; import { DialogStatus, type DialogStatusProps } from './DialogStatus'; import { DialogTouchedBy, type DialogTouchedByActor } from './DialogTouchedBy'; @@ -37,9 +36,6 @@ export const DialogNav = ({ touchedBy, menu, }: DialogNavProps) => { - const [expandedItem, setexpandedItem] = useState(false); - const onToggle = () => setexpandedItem((expanded) => !expanded); - return ( ); diff --git a/lib/components/Dialog/DialogSelect.tsx b/lib/components/Dialog/DialogSelect.tsx index a091130..d2d7783 100644 --- a/lib/components/Dialog/DialogSelect.tsx +++ b/lib/components/Dialog/DialogSelect.tsx @@ -16,7 +16,7 @@ export type DialogSelectProps = { export const DialogSelect = ({ checked = false, onChange, className }: DialogSelectProps) => { return ( ); diff --git a/lib/components/Dialog/dialogGroup.module.css b/lib/components/Dialog/dialogGroup.module.css new file mode 100644 index 0000000..6b853bb --- /dev/null +++ b/lib/components/Dialog/dialogGroup.module.css @@ -0,0 +1,35 @@ +.section { + display: flex; + flex-direction: column; + row-gap: 0.5rem; + margin: 0.5rem 0; +} + +.header { + display: flex; + justify-content: space-between; + margin: 0.5rem 0; +} + +.title { + font-size: 1.25rem; + font-weight: 500; + line-height: 1.5rem; + margin: 0.375rem 0; +} + +.title { + padding: 0 1rem; +} + +@media (min-width: 1024px) { + .title { + padding: 0; + } +} + +.list { + display: flex; + flex-direction: column; + row-gap: 0.5rem; +} diff --git a/lib/components/Dropdown/Backdrop.tsx b/lib/components/Dropdown/Backdrop.tsx index 1244c15..6c83692 100644 --- a/lib/components/Dropdown/Backdrop.tsx +++ b/lib/components/Dropdown/Backdrop.tsx @@ -1,11 +1,12 @@ import cx from 'classnames'; +import type { MouseEventHandler } from 'react'; import styles from './backdrop.module.css'; export interface BackdropProps { - expanded?: boolean; className?: string; + onClick?: MouseEventHandler; } -export const Backdrop = ({ expanded = false, className }: BackdropProps) => { - return
; +export const Backdrop = ({ className, onClick }: BackdropProps) => { + return
; }; diff --git a/lib/components/Dropdown/DrawerBase.tsx b/lib/components/Dropdown/DrawerBase.tsx index 82c86bb..b5e1da5 100644 --- a/lib/components/Dropdown/DrawerBase.tsx +++ b/lib/components/Dropdown/DrawerBase.tsx @@ -2,15 +2,18 @@ import cx from 'classnames'; import type { ReactNode } from 'react'; import styles from './drawerBase.module.css'; +export type DrawerPlacement = 'inline' | 'bottom'; + export interface DrawerBaseProps { + placement?: DrawerPlacement; expanded?: boolean; className?: string; children?: ReactNode; } -export const DrawerBase = ({ expanded = false, className, children }: DrawerBaseProps) => { +export const DrawerBase = ({ placement = 'inline', expanded = false, className, children }: DrawerBaseProps) => { return ( -
+
{children}
); diff --git a/lib/components/Dropdown/DrawerBody.tsx b/lib/components/Dropdown/DrawerBody.tsx new file mode 100644 index 0000000..820176d --- /dev/null +++ b/lib/components/Dropdown/DrawerBody.tsx @@ -0,0 +1,12 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import styles from './drawerBody.module.css'; + +export interface DrawerBodyProps { + className?: string; + children?: ReactNode; +} + +export const DrawerBody = ({ className, children }: DrawerBodyProps) => { + return
{children}
; +}; diff --git a/lib/components/Dropdown/DrawerButton.tsx b/lib/components/Dropdown/DrawerButton.tsx new file mode 100644 index 0000000..769a82c --- /dev/null +++ b/lib/components/Dropdown/DrawerButton.tsx @@ -0,0 +1,17 @@ +import type { MouseEventHandler, ReactNode } from 'react'; +import { ButtonBase, ButtonLabel } from '../'; +import styles from './drawerButton.module.css'; + +export interface DrawerButtonProps { + label?: string; + children?: ReactNode; + onClick?: MouseEventHandler; +} + +export const DrawerButton = ({ label, children, onClick }: DrawerButtonProps) => { + return ( + + {children || label} + + ); +}; diff --git a/lib/components/Dropdown/DrawerFooter.tsx b/lib/components/Dropdown/DrawerFooter.tsx new file mode 100644 index 0000000..605425c --- /dev/null +++ b/lib/components/Dropdown/DrawerFooter.tsx @@ -0,0 +1,12 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import styles from './drawerFooter.module.css'; + +export interface DrawerFooterProps { + className?: string; + children?: ReactNode; +} + +export const DrawerFooter = ({ className, children }: DrawerFooterProps) => { + return
{children}
; +}; diff --git a/lib/components/Dropdown/DrawerHeader.tsx b/lib/components/Dropdown/DrawerHeader.tsx new file mode 100644 index 0000000..c4afa0d --- /dev/null +++ b/lib/components/Dropdown/DrawerHeader.tsx @@ -0,0 +1,19 @@ +import cx from 'classnames'; +import type { MouseEventHandler } from 'react'; +import { IconButton } from '../Button'; +import styles from './drawerHeader.module.css'; + +export interface DrawerHeaderProps { + className?: string; + title?: string; + onClose?: MouseEventHandler; +} + +export const DrawerHeader = ({ className, title, onClose }: DrawerHeaderProps) => { + return ( +
+

{title}

+ +
+ ); +}; diff --git a/lib/components/Dropdown/DrawerOrDropdown.tsx b/lib/components/Dropdown/DrawerOrDropdown.tsx new file mode 100644 index 0000000..84ed6b2 --- /dev/null +++ b/lib/components/Dropdown/DrawerOrDropdown.tsx @@ -0,0 +1,29 @@ +import type { MouseEventHandler, ReactNode } from 'react'; +import { DrawerBase, DrawerBody, DrawerButton, DrawerFooter, DrawerHeader, DropdownBase } from '../'; +import type { DrawerButtonProps } from '../'; +import { Backdrop } from './Backdrop'; +import styles from './drawerOrDropdown.module.css'; + +export interface DrawerOrDropdownProps { + title: string; + children: ReactNode; + expanded?: boolean; + onClose?: MouseEventHandler; + button?: DrawerButtonProps; +} + +export const DrawerOrDropdown = ({ expanded = false, title, onClose, button, children }: DrawerOrDropdownProps) => { + return ( + <> + {expanded && } + + {children} + + + + {children} + {button && {button?.label}} + + + ); +}; diff --git a/lib/components/Dropdown/DropdownBase.tsx b/lib/components/Dropdown/DropdownBase.tsx index 8322b2f..a54dc3e 100644 --- a/lib/components/Dropdown/DropdownBase.tsx +++ b/lib/components/Dropdown/DropdownBase.tsx @@ -2,15 +2,18 @@ import cx from 'classnames'; import type { ReactNode } from 'react'; import styles from './dropdownBase.module.css'; +export type DropdownPlacement = 'left' | 'right'; + export interface DropdownBaseProps { + placement?: DropdownPlacement; expanded?: boolean; className?: string; children?: ReactNode; } -export const DropdownBase = ({ expanded = false, className, children }: DropdownBaseProps) => { +export const DropdownBase = ({ placement = 'left', expanded = false, className, children }: DropdownBaseProps) => { return ( -
+
{children}
); diff --git a/lib/components/Dropdown/backdrop.module.css b/lib/components/Dropdown/backdrop.module.css index 409a32a..8392c89 100644 --- a/lib/components/Dropdown/backdrop.module.css +++ b/lib/components/Dropdown/backdrop.module.css @@ -1,8 +1,11 @@ .backdrop { + z-index: 1; background-color: rgba(17, 29, 70, 0.25); position: fixed; top: 0; right: 0; bottom: 0; left: 0; + width: 100%; + height: 100%; } diff --git a/lib/components/Dropdown/drawerBase.module.css b/lib/components/Dropdown/drawerBase.module.css index b4f59b6..b0779f4 100644 --- a/lib/components/Dropdown/drawerBase.module.css +++ b/lib/components/Dropdown/drawerBase.module.css @@ -3,6 +3,15 @@ } .drawer[aria-expanded="true"] { + background-color: var(--theme-background-default); display: block; width: 100%; } + +.drawer[data-placement="bottom"] { + position: fixed; + top: 5.5rem; + bottom: 0; + left: 0; + right: 0; +} diff --git a/lib/components/Dropdown/drawerBody.module.css b/lib/components/Dropdown/drawerBody.module.css new file mode 100644 index 0000000..3bc6b54 --- /dev/null +++ b/lib/components/Dropdown/drawerBody.module.css @@ -0,0 +1,5 @@ +.body { + width: 100%; + padding: 0 .5em; + overflow-y: scroll; +} diff --git a/lib/components/Dropdown/drawerButton.module.css b/lib/components/Dropdown/drawerButton.module.css new file mode 100644 index 0000000..157cdf9 --- /dev/null +++ b/lib/components/Dropdown/drawerButton.module.css @@ -0,0 +1,6 @@ +.button { + height: 3.5rem; /* lg 56 */ + border-radius: 1.75rem; + padding: 0 1rem; + margin: 1rem auto; +} diff --git a/lib/components/Dropdown/drawerFooter.module.css b/lib/components/Dropdown/drawerFooter.module.css new file mode 100644 index 0000000..9dc3b04 --- /dev/null +++ b/lib/components/Dropdown/drawerFooter.module.css @@ -0,0 +1,13 @@ +.footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.footer > * { + margin: 1rem auto; +} diff --git a/lib/components/Dropdown/drawerHeader.module.css b/lib/components/Dropdown/drawerHeader.module.css new file mode 100644 index 0000000..454a929 --- /dev/null +++ b/lib/components/Dropdown/drawerHeader.module.css @@ -0,0 +1,17 @@ +.header { + position: sticky; + top: 0; + left: 0; + right: 0; + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--theme-border-subtle); +} + +.title { + font-size: 1.125rem; + font-weight: 500; + margin: 0; +} diff --git a/lib/components/Dropdown/drawerOrDropdown.module.css b/lib/components/Dropdown/drawerOrDropdown.module.css new file mode 100644 index 0000000..1df4c27 --- /dev/null +++ b/lib/components/Dropdown/drawerOrDropdown.module.css @@ -0,0 +1,19 @@ +.dropdown[aria-expanded="true"] { + display: none; +} + +.drawer[aria-expanded="true"] { + display: block; + z-index: 2; +} + +@media (min-width: 1024px) { + .drawer[aria-expanded="true"] { + display: none; + } + + .dropdown[aria-expanded="true"] { + display: block; + z-index: 2; + } +} diff --git a/lib/components/Dropdown/dropdownBase.module.css b/lib/components/Dropdown/dropdownBase.module.css index a2d0ac5..63f7953 100644 --- a/lib/components/Dropdown/dropdownBase.module.css +++ b/lib/components/Dropdown/dropdownBase.module.css @@ -5,14 +5,26 @@ .dropdown[aria-expanded="true"] { display: block; position: absolute; - right: 0; z-index: 2; } +.dropdown[data-placement="left"] { + left: 0; +} + +.dropdown[data-placement="right"] { + right: 0; +} + .dropdown { + min-width: 14rem; margin-top: 0.5rem; padding: 0 0.5rem; background-color: var(--neutral-background-default); - border-radius: 2px; - box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.15), 0 1px 2px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.1); + border-radius: 0.375rem; + box-shadow: var(--ds-shadow-md); +} + +.drawer .button { + border-radius: 50%; } diff --git a/lib/components/Dropdown/index.ts b/lib/components/Dropdown/index.ts index 2ffd36c..f2c7854 100644 --- a/lib/components/Dropdown/index.ts +++ b/lib/components/Dropdown/index.ts @@ -1,3 +1,9 @@ -export * from './DrawerBase'; export * from './DropdownBase'; export * from './Backdrop'; + +export * from './DrawerBase'; +export * from './DrawerHeader'; +export * from './DrawerFooter'; +export * from './DrawerButton'; +export * from './DrawerBody'; +export * from './DrawerOrDropdown'; diff --git a/lib/components/Footer/footerMenu.module.css b/lib/components/Footer/footerMenu.module.css index 430fdc2..dd1e96f 100644 --- a/lib/components/Footer/footerMenu.module.css +++ b/lib/components/Footer/footerMenu.module.css @@ -5,6 +5,11 @@ } .list { + list-style: none; + padding: 0; + margin: 0; + text-indent: 0; + display: flex; flex-direction: column; row-gap: 1rem; diff --git a/lib/components/GlobalMenu/GlobalMenu.stories.tsx b/lib/components/GlobalMenu/GlobalMenu.stories.tsx index b7cda06..5a592b2 100644 --- a/lib/components/GlobalMenu/GlobalMenu.stories.tsx +++ b/lib/components/GlobalMenu/GlobalMenu.stories.tsx @@ -16,14 +16,14 @@ const meta = { items: [ { id: 'inbox', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'inbox', label: 'Innboks', }, { id: 'settings', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'cog', label: 'Settings', @@ -144,43 +144,43 @@ export const Accounts: Story = { }, accounts: [ { - group: 'primary', + groupId: 'primary', type: 'person', name: 'Aurora Mikalsen', selected: true, }, { - group: 'favourites', + groupId: 'favourites', type: 'person', name: 'Rakel Engelsvik', selected: false, }, { - group: 'favourites', + groupId: 'favourites', type: 'company', name: 'Auroras keeperskole', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Keeperhansker AS', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Stadion drift AS', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Sportsklubben Brann', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Landslaget', selected: false, diff --git a/lib/components/GlobalMenu/GlobalMenu.tsx b/lib/components/GlobalMenu/GlobalMenu.tsx index d3b4079..55fb159 100644 --- a/lib/components/GlobalMenu/GlobalMenu.tsx +++ b/lib/components/GlobalMenu/GlobalMenu.tsx @@ -1,7 +1,7 @@ 'use client'; import { type MouseEventHandler, useState } from 'react'; import type { AvatarType } from '../Avatar'; -import { Menu, type MenuGroups, MenuItem, type MenuItemProps, type MenuSearchProps } from '../Menu'; +import { Menu, MenuItem, type MenuItemGroups, type MenuItemProps, type MenuSearchProps } from '../Menu'; export type Account = { type: AvatarType; @@ -22,9 +22,9 @@ export interface GlobalMenuProps { expanded: boolean; onToggle: MouseEventHandler; items: MenuItemProps[]; - groups?: MenuGroups; + groups?: MenuItemGroups; accounts?: Account[]; - accountGroups?: MenuGroups; + accountGroups?: MenuItemGroups; accountSearch?: AccountSearch; menuLabel?: string; backLabel?: string; @@ -76,7 +76,7 @@ export const GlobalMenu = ({ .map((item) => { return { ...item, - group: 'search', + groupId: 'search', }; }) : accountMenu; @@ -106,7 +106,7 @@ export const GlobalMenu = ({ }; const accountSwitcher: MenuItemProps[] = [ - ...(filteredAccountMenu.length > 0 ? filteredAccountMenu : [{ id: 'search', group: 'search', hidden: true }]), + ...(filteredAccountMenu.length > 0 ? filteredAccountMenu : [{ id: 'search', groupId: 'search', hidden: true }]), ]; if (selectAccount) { diff --git a/lib/components/Header/Header.stories.ts b/lib/components/Header/Header.stories.tsx similarity index 51% rename from lib/components/Header/Header.stories.ts rename to lib/components/Header/Header.stories.tsx index 287e18f..0baeffd 100644 --- a/lib/components/Header/Header.stories.ts +++ b/lib/components/Header/Header.stories.tsx @@ -1,11 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; import { Header } from './Header'; const meta = { title: 'Header/Header', component: Header, tags: ['autodocs'], - parameters: {}, + parameters: { + layout: 'fullscreen', + }, args: { expanded: true, search: { @@ -22,43 +25,43 @@ const meta = { }, accounts: [ { - group: 'primary', + groupId: 'primary', type: 'person', name: 'Aurora Mikalsen', selected: true, }, { - group: 'favourites', + groupId: 'favourites', type: 'person', name: 'Rakel Engelsvik', selected: false, }, { - group: 'favourites', + groupId: 'favourites', type: 'company', name: 'Auroras keeperskole', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Keeperhansker AS', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Stadion drift AS', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Sportsklubben Brann', selected: false, }, { - group: 'secondary', + groupId: 'secondary', type: 'company', name: 'Landslaget', selected: false, @@ -93,21 +96,77 @@ type Story = StoryObj; export const Default: Story = {}; -export const Company: Story = { - args: { - menu: { - ...meta.args?.menu, - accounts: [ +export const ControlledState = (args) => { + const [q, setQ] = useState(''); + const onChange = (event) => { + setQ(event.target.value); + }; + + const scopes = [ + { + groupId: '1', + id: 'inbox', + href: '#', + label: q + ? () => { + return ( + + {q} i innboksen + + ); + } + : 'Alt i innboksen', + }, + { + groupId: '1', + id: 'global', + href: '#', + label: q + ? () => { + return ( + + {q} i hele Altinn + + ); + } + : 'Alt i hele Altinn', + }, + ]; + + const suggestions = q + ? [ { - type: 'company', - name: 'Bergen bar', - selected: true, + groupId: '2', + href: 'http://www.altinn.no', + label: 'Skattemelding 2024', }, { - type: 'person', - name: 'Aurora Mikalsen', + groupId: '2', + href: 'http://www.altinn.no', + label: 'Skattemelding 2025', }, - ], + ].filter((item) => item.label.toLowerCase().includes((q ?? '').toLowerCase())) + : []; + + const autocomplete = { + groups: { + 2: { + title: `${suggestions.length} treff i innboksen`, + }, }, - }, + items: [...scopes, ...suggestions], + }; + + return ( +
setQ(''), + autocomplete, + }} + /> + ); }; diff --git a/lib/components/Header/Header.tsx b/lib/components/Header/Header.tsx index 12dba01..82482be 100644 --- a/lib/components/Header/Header.tsx +++ b/lib/components/Header/Header.tsx @@ -1,83 +1,70 @@ 'use client'; -import cx from 'classnames'; -import { useState } from 'react'; -import { Backdrop, DrawerBase, DropdownBase } from '../Dropdown'; +import { useEscapeKey } from '../../hooks'; +import { DrawerBase, DropdownBase } from '../Dropdown'; import { GlobalMenu, type GlobalMenuProps } from '../GlobalMenu'; +import { useRootContext } from '../RootProvider'; +import { Searchbar, type SearchbarProps } from '../Searchbar'; import { HeaderBase } from './HeaderBase'; import { HeaderButton } from './HeaderButton'; import { HeaderLogo } from './HeaderLogo'; import { HeaderMenu } from './HeaderMenu'; -import { HeaderSearch, type HeaderSearchProps } from './HeaderSearch'; import styles from './header.module.css'; -export type HeaderExpandedType = 'search' | 'menu' | null; - export interface HeaderProps { menu: GlobalMenuProps; - search?: HeaderSearchProps; + search?: SearchbarProps; } export const Header = ({ search, menu }: HeaderProps) => { - const [expandedType, setExpandedType] = useState(null); - const selectedAccount = menu?.accounts?.find((account) => account.selected); + const { currentId, toggleId, openId, closeAll } = useRootContext(); + const selectedAccount = menu.accounts?.find((account) => account.selected); const selectedAvatar = selectedAccount && { type: selectedAccount.type, name: selectedAccount.name, }; - const onToggle = (type: HeaderExpandedType) => { - if (expandedType === type) { - setExpandedType(null); - } else { - setExpandedType(type); - } - }; + useEscapeKey(closeAll); const onSearchFocus = () => { - setExpandedType('search'); + openId('search'); + }; + + const onSearchClose = () => { + toggleId('search'); }; - const onSearchBlur = () => { - setExpandedType(null); + const onToggleMenu = () => { + toggleId('menu'); }; return ( - - + onToggle('menu')} - expanded={expandedType === 'menu'} + onClick={onToggleMenu} + expanded={currentId === 'menu'} label={menu?.menuLabel} /> {menu && ( - + )} - {search && ( - )} {menu && ( - - + + )} diff --git a/lib/components/Header/HeaderBase.tsx b/lib/components/Header/HeaderBase.tsx index fbb744d..79bb4aa 100644 --- a/lib/components/Header/HeaderBase.tsx +++ b/lib/components/Header/HeaderBase.tsx @@ -1,16 +1,20 @@ import cx from 'classnames'; import type { ReactNode } from 'react'; +import { Backdrop } from '../'; import styles from './headerBase.module.css'; export interface HeaderBaseProps { - expanded?: boolean; + currentId?: string; className?: string; children?: ReactNode; + open?: boolean; + onClose?: () => void; } -export const HeaderBase = ({ expanded, className, children }: HeaderBaseProps) => { +export const HeaderBase = ({ currentId, className, children, open, onClose }: HeaderBaseProps) => { return ( -
+
+ {open && } {children}
); diff --git a/lib/components/Header/HeaderSearch.stories.ts b/lib/components/Header/HeaderSearch.stories.ts deleted file mode 100644 index 4d52089..0000000 --- a/lib/components/Header/HeaderSearch.stories.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { HeaderSearch } from './HeaderSearch'; - -const meta = { - title: 'Header/HeaderSearch', - component: HeaderSearch, - tags: ['autodocs'], - parameters: {}, - args: {}, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - placeholder: 'Search', - name: 'search', - }, -}; diff --git a/lib/components/Header/HeaderSearch.tsx b/lib/components/Header/HeaderSearch.tsx deleted file mode 100644 index f3b391c..0000000 --- a/lib/components/Header/HeaderSearch.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import cx from 'classnames'; -import type { ChangeEventHandler, FocusEventHandler } from 'react'; -import { Icon } from '../Icon'; -import styles from './headerSearch.module.css'; - -export interface HeaderSearchProps { - className?: string; - expanded?: boolean; - placeholder?: string; - name: string; - value?: string; - onFocus?: FocusEventHandler; - onBlur?: FocusEventHandler; - onChange?: ChangeEventHandler; -} - -export const HeaderSearch = ({ - className, - expanded = false, - name = 'q', - value, - placeholder = 'Søk', - onFocus, - onBlur, - onChange, -}: HeaderSearchProps) => { - return ( -
-
- - -
-
- ); -}; diff --git a/lib/components/Header/header.module.css b/lib/components/Header/header.module.css index b6fb8a0..073ed0e 100644 --- a/lib/components/Header/header.module.css +++ b/lib/components/Header/header.module.css @@ -1,35 +1,26 @@ -.backdrop { - display: none; - z-index: 2; -} - -.header .drawer[aria-expanded="true"] { +.drawer[aria-expanded="true"] { display: block; } -.header .dropdown[aria-expanded="true"] { +.dropdown[aria-expanded="true"] { width: 320px; display: none; } -/* menu */ - -.header.menuExpanded { - background-color: white; +@media (min-width: 1024px) { + .dropdown[aria-expanded="true"] { + display: block; + } } @media (min-width: 1024px) { - .header.menuExpanded { - background-color: transparent; + .drawer[aria-expanded="true"] { + display: none; } - .header.menuExpanded .backdrop { + .dropdown[aria-expanded="true"] { display: block; } - - .header.menuExpanded .menu { - z-index: 2; - } } /* search */ @@ -38,31 +29,7 @@ width: 100%; } -.header.searchExpanded { - margin-top: -72px; -} - -.header.searchExpanded .search { - z-index: 2; -} - -.header.searchExpanded .backdrop { - display: block; -} - @media (min-width: 1024px) { - .header.searchExpanded { - margin-top: 0; - } - - .header .drawer[aria-expanded="true"] { - display: none; - } - - .header .dropdown[aria-expanded="true"] { - display: block; - } - .search { position: absolute; left: 0; @@ -72,6 +39,7 @@ } .search[aria-expanded="true"] { + z-index: 2; max-width: 640px; } } diff --git a/lib/components/Header/headerBase.module.css b/lib/components/Header/headerBase.module.css index 7c85ed5..b257ac1 100644 --- a/lib/components/Header/headerBase.module.css +++ b/lib/components/Header/headerBase.module.css @@ -1,4 +1,5 @@ .header { + z-index: 1; display: flex; align-items: center; justify-content: space-between; @@ -6,3 +7,45 @@ gap: 1rem; padding: 1rem; } + +.backdrop { + display: none; +} + +/* menu */ + +.header[data-current-id="menu"] { + background-color: white; +} + +@media (min-width: 1024px) { + .header[data-current-id="menu"] { + background-color: transparent; + } + + .header[data-current-id="menu"] .backdrop { + display: block; + } +} + +/* search */ + +.header { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: .15s; +} + +.header[data-current-id="search"] { + margin-top: -72px; +} + +.header[data-current-id="search"] .backdrop { + display: block; +} + +@media (min-width: 1024px) { + .header[data-current-id="search"] { + margin-top: 0; + } +} diff --git a/lib/components/Header/headerButton.module.css b/lib/components/Header/headerButton.module.css index 85e255e..060cf34 100644 --- a/lib/components/Header/headerButton.module.css +++ b/lib/components/Header/headerButton.module.css @@ -1,5 +1,6 @@ .button { position: relative; + z-index: 2; display: inline-flex; align-items: center; column-gap: 0.625rem; diff --git a/lib/components/Header/headerSearch.module.css b/lib/components/Header/headerSearch.module.css deleted file mode 100644 index 125cf88..0000000 --- a/lib/components/Header/headerSearch.module.css +++ /dev/null @@ -1,30 +0,0 @@ -.field { - position: relative; - display: flex; - align-items: center; - font-size: 1.125rem; - color: currentColor; -} - -.input { - font-size: inherit; - flex-grow: 1; - padding-left: 2.5rem; - padding-right: 1rem; - padding-top: 1rem; - padding-bottom: 1rem; - border-radius: 4px; - border: 2px solid; - background-color: transparent; -} - -.input[value] { - background-color: white; -} - -.icon { - position: absolute; - left: 0; - font-size: 1.25rem; - margin: 0 1rem; -} diff --git a/lib/components/Layout/Layout.stories.tsx b/lib/components/Layout/Layout.stories.tsx index 2e4cd3b..1f95d9c 100644 --- a/lib/components/Layout/Layout.stories.tsx +++ b/lib/components/Layout/Layout.stories.tsx @@ -53,7 +53,7 @@ const menu: MenuProps = { items: [ { id: '1', - group: 1, + groupId: 1, size: 'lg', icon: 'inbox', title: 'Innboks', @@ -61,7 +61,7 @@ const menu: MenuProps = { }, { id: '2', - group: 2, + groupId: 2, icon: 'doc-pencil', title: 'Utkast', }, @@ -74,19 +74,19 @@ const menu: MenuProps = { }, { id: '4', - group: 3, + groupId: 3, icon: 'bookmark', title: 'Lagrede søk', }, { id: '5', - group: 4, + groupId: 4, icon: 'archive', title: 'Arkivert', }, { id: '6', - group: 4, + groupId: 4, disabled: true, icon: 'trash', title: 'Papirkurv', @@ -102,6 +102,7 @@ const meta = { layout: 'fullscreen', }, args: { + theme: 'person', header, footer, sidebar: { @@ -117,44 +118,82 @@ export const Default: Story = { args: {}, }; -export const GlobalCompany: Story = { - args: { - sidebar: { - menu: { - ...menu, - defaultItemColor: 'company', - }, - }, - }, -}; +export const ControlledStateSearch = (args) => { + const [q, setQ] = useState(''); + const onChange = (event) => { + setQ(event.target.value); + }; -export const GlobalPerson: Story = { - args: { - sidebar: { - menu: { - ...menu, - defaultItemColor: 'person', - }, + const scopes = [ + { + groupId: '1', + id: 'inbox', + href: '#', + label: q + ? () => { + return ( + + {q} i innboksen + + ); + } + : 'Alt i innboksen', }, - }, -}; + { + groupId: '1', + id: 'global', + href: '#', + label: q + ? () => { + return ( + + {q} i hele Altinn + + ); + } + : 'Alt i hele Altinn', + }, + ]; -export const Neutral: Story = { - args: { - theme: 'neutral', - }, -}; + const suggestions = q + ? [ + { + groupId: '2', + href: 'http://www.altinn.no', + label: 'Skattemelding 2024', + }, + { + groupId: '2', + href: 'http://www.altinn.no', + label: 'Skattemelding 2025', + }, + ].filter((item) => item.label.toLowerCase().includes((q ?? '').toLowerCase())) + : []; -export const Company: Story = { - args: { - theme: 'company', - }, -}; + const autocomplete = { + groups: { + 2: { + title: `${suggestions.length} treff i innboksen`, + }, + }, + items: [...scopes, ...suggestions], + }; -export const Person: Story = { - args: { - theme: 'person', - }, + return ( + setQ(''), + autocomplete, + }, + }} + /> + ); }; export const InboxBulkMode = (args) => { diff --git a/lib/components/Layout/Layout.tsx b/lib/components/Layout/Layout.tsx index ca1c955..05a317b 100644 --- a/lib/components/Layout/Layout.tsx +++ b/lib/components/Layout/Layout.tsx @@ -3,6 +3,7 @@ import { LayoutBase, LayoutBody, LayoutContent, LayoutSidebar, type LayoutTheme import { Footer, type FooterProps } from '../Footer'; import { Header, type HeaderProps } from '../Header'; import { Menu, type MenuProps } from '../Menu'; +import { useRootContext } from '../RootProvider'; interface SidebarProps { theme?: LayoutTheme; @@ -19,15 +20,16 @@ export interface LayoutProps { theme?: LayoutTheme; header?: HeaderProps; footer?: FooterProps; - sidebar: SidebarProps; - content: ContentProps; + sidebar?: SidebarProps; + content?: ContentProps; children: ReactNode; } export const Layout = ({ theme = 'global', header, footer, sidebar = {}, content = {}, children }: LayoutProps) => { + const { currentId } = useRootContext(); const { menu, ...sideRestProps } = sidebar; return ( - + {header &&
} {sidebar && ( diff --git a/lib/components/Layout/LayoutBase.tsx b/lib/components/Layout/LayoutBase.tsx index ee854e1..05b2cbf 100644 --- a/lib/components/Layout/LayoutBase.tsx +++ b/lib/components/Layout/LayoutBase.tsx @@ -5,6 +5,7 @@ export type LayoutTheme = 'global' | 'global-dark' | 'neutral' | 'company' | 'pe export interface LayoutBaseProps { theme?: LayoutTheme; + currentId?: string; children?: ReactNode; } @@ -21,9 +22,9 @@ export interface LayoutBaseProps { * - Footer * */ -export const LayoutBase = ({ theme, children }: LayoutBaseProps) => { +export const LayoutBase = ({ currentId, theme, children }: LayoutBaseProps) => { return ( -
+
{children}
); diff --git a/lib/components/Layout/layoutBase.module.css b/lib/components/Layout/layoutBase.module.css index 2a46b77..12105af 100644 --- a/lib/components/Layout/layoutBase.module.css +++ b/lib/components/Layout/layoutBase.module.css @@ -9,3 +9,14 @@ .base[data-theme] { background-color: var(--theme-background-subtle); } + +.base[data-current-id="menu"] { + background-color: var(--theme-background-default); +} + +@media (max-width: 1024px) { + .base[data-current-id="menu"] header + *, + .base[data-current-id="menu"] footer { + display: none; + } +} diff --git a/lib/components/Layout/layoutBody.module.css b/lib/components/Layout/layoutBody.module.css index a9ff13d..1198820 100644 --- a/lib/components/Layout/layoutBody.module.css +++ b/lib/components/Layout/layoutBody.module.css @@ -3,6 +3,7 @@ width: 100%; max-width: 1280px; margin: 0 auto; + padding: 0 1rem; display: flex; column-gap: 2em; } diff --git a/lib/components/LayoutAction/ActionHeader.tsx b/lib/components/LayoutAction/ActionHeader.tsx index 3adf50d..25178f8 100644 --- a/lib/components/LayoutAction/ActionHeader.tsx +++ b/lib/components/LayoutAction/ActionHeader.tsx @@ -13,7 +13,7 @@ export const ActionHeader = ({ hidden = false, title, dismissable = true, onDism return (

{title}

- {dismissable && } + {dismissable && }
); }; diff --git a/lib/components/LayoutAction/ActionMenu.tsx b/lib/components/LayoutAction/ActionMenu.tsx index 303ff97..7f692e7 100644 --- a/lib/components/LayoutAction/ActionMenu.tsx +++ b/lib/components/LayoutAction/ActionMenu.tsx @@ -1,10 +1,8 @@ -import { MenuBase, MenuItem, type MenuItemProps } from '../Menu'; +import { MenuBase, MenuItem, type MenuItemProps, type MenuTheme } from '../Menu'; import styles from './actionMenu.module.css'; -type ActionMenuTheme = 'inherit' | 'global-dark'; - export interface ActionMenuProps { - theme?: ActionMenuTheme; + theme?: MenuTheme; items?: MenuItemProps[]; } diff --git a/lib/components/LayoutAction/actionMenu.module.css b/lib/components/LayoutAction/actionMenu.module.css index a62168f..9e5d8be 100644 --- a/lib/components/LayoutAction/actionMenu.module.css +++ b/lib/components/LayoutAction/actionMenu.module.css @@ -14,6 +14,9 @@ .list { display: flex; flex-direction: column; + list-style: none; + padding: 0; + margin: 0; } @media (min-width: 1024px) { diff --git a/lib/components/List/List.stories.tsx b/lib/components/List/List.stories.tsx new file mode 100644 index 0000000..a561cd7 --- /dev/null +++ b/lib/components/List/List.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Fragment, useState } from 'react'; + +import { MetaItem } from '../Meta'; +import { List, ListBase, ListItem } from './'; + +const sizes = ['lg', 'md', 'sm', 'xs']; + +const meta = { + title: 'List/List', + component: List, + tags: ['autodocs'], + parameters: {}, + args: { + items: [ + { + id: '1', + title: 'Title', + description: 'Description', + href: '#', + }, + { + id: '2', + title: 'Title', + description: 'Description', + href: '#', + }, + { + id: '3', + title: 'Title', + description: 'Description', + href: '#', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/List/List.tsx b/lib/components/List/List.tsx index e118fb6..8dd23d0 100644 --- a/lib/components/List/List.tsx +++ b/lib/components/List/List.tsx @@ -1,18 +1,18 @@ -import type { LayoutTheme } from '../Layout'; -import { ListBase } from '../List'; +import { ListBase, type ListSpacing, type ListTheme } from '../List'; import { ListItem, type ListItemProps } from './ListItem'; import type { ListItemSize } from './ListItemBase'; export interface ListProps { size?: ListItemSize; - theme?: LayoutTheme; + spacing?: ListSpacing; + theme?: ListTheme; items?: ListItemProps[]; } -export const List = ({ theme, size = 'md', items }: ListProps) => { +export const List = ({ theme, size = 'md', spacing = 'md', items = [] }: ListProps) => { return ( - - {items?.map((item, index) => { + + {items.map((item, index) => { return ; })} diff --git a/lib/components/List/ListBase.tsx b/lib/components/List/ListBase.tsx index e5c7355..e3acb6d 100644 --- a/lib/components/List/ListBase.tsx +++ b/lib/components/List/ListBase.tsx @@ -1,18 +1,18 @@ import type { ReactNode } from 'react'; import styles from './listBase.module.css'; -import type { LayoutTheme } from '../Layout'; -import type { ListItemSize } from './ListItemBase'; +export type ListTheme = 'inherit' | 'global' | 'neutral' | 'person' | 'company'; +export type ListSpacing = 'none' | 'sm' | 'md' | 'lg'; export interface ListBaseProps { - size?: ListItemSize; - theme?: LayoutTheme; + theme?: ListTheme; + spacing?: ListSpacing; children?: ReactNode; } -export const ListBase = ({ size = 'md', theme, children }: ListBaseProps) => { +export const ListBase = ({ theme = 'inherit', spacing = 'md', children }: ListBaseProps) => { return ( -
+
{children}
); diff --git a/lib/components/List/ListItem.tsx b/lib/components/List/ListItem.tsx index dfb442c..8284bec 100644 --- a/lib/components/List/ListItem.tsx +++ b/lib/components/List/ListItem.tsx @@ -13,7 +13,10 @@ export interface ListItemProps { as?: ElementType; color?: ListItemColor; href?: string; - onClick?: () => void; + onClick?(): void; + /** Item is active */ + active?: boolean; + /** Item should be hidden from view */ hidden?: boolean; /** Collapsible item, sets linkIcon to "chevron down" */ collapsible?: boolean; diff --git a/lib/components/List/ListItemBase.tsx b/lib/components/List/ListItemBase.tsx index c1dda87..a339df7 100644 --- a/lib/components/List/ListItemBase.tsx +++ b/lib/components/List/ListItemBase.tsx @@ -1,5 +1,5 @@ import cx from 'classnames'; -import type { ElementType, ReactNode } from 'react'; +import type { ElementType, KeyboardEvent, KeyboardEventHandler, ReactNode } from 'react'; import { Badge, type BadgeProps } from '../Badge'; import { Icon, type IconName } from '../Icon'; import styles from './listItemBase.module.css'; @@ -15,10 +15,15 @@ interface ListItemBaseProps { badge?: BadgeProps; href?: string; className?: string; + active?: boolean; + hidden?: boolean; collapsible?: boolean; selected?: boolean; expanded?: boolean; + onClick?: () => void; + onKeyPress?: KeyboardEventHandler; children?: ReactNode; + style?: React.CSSProperties; } export const ListItemBase = ({ @@ -28,27 +33,38 @@ export const ListItemBase = ({ href, size, color, + active = false, + hidden = false, collapsible, selected, expanded, linkIcon, badge, - ...rest + onClick, + onKeyPress, + style, }: ListItemBaseProps) => { const Component = as || 'a'; const applicableIcon = collapsible && expanded ? 'chevron-up' : collapsible ? 'chevron-down' : href ? 'chevron-right' : linkIcon; - return ( { + e.key === 'Enter' && onClick?.(); + onKeyPress?.(e); + }} + onClick={onClick} + tabIndex={-1} + style={style} >
{children} diff --git a/lib/components/List/listBase.module.css b/lib/components/List/listBase.module.css index 22b1fd2..bdcf478 100644 --- a/lib/components/List/listBase.module.css +++ b/lib/components/List/listBase.module.css @@ -3,14 +3,14 @@ flex-direction: column; } -.list[data-size="sm"] { +.list[data-spacing="sm"] { row-gap: 4px; } -.list[data-size="md"] { +.list[data-spacing="md"] { row-gap: 8px; } -.list[data-size="lg"] { +.list[data-spacing="lg"] { row-gap: 16px; } diff --git a/lib/components/List/listItemBase.module.css b/lib/components/List/listItemBase.module.css index 95d982a..a8605ec 100644 --- a/lib/components/List/listItemBase.module.css +++ b/lib/components/List/listItemBase.module.css @@ -77,6 +77,10 @@ box-shadow: var(--ds-shadow-xs); } +.item[data-active="true"] { + background-color: var(--theme-surface-default); +} + .item[data-color="accent"] { background-color: var(--theme-surface-default); } diff --git a/lib/components/Menu/Menu.stories.ts b/lib/components/Menu/Menu.stories.ts index 39a42c4..1f5b59b 100644 --- a/lib/components/Menu/Menu.stories.ts +++ b/lib/components/Menu/Menu.stories.ts @@ -22,7 +22,7 @@ export const GlobalMenu: Story = { items: [ { id: 'account', - group: 'account', + groupId: 'account', size: 'lg', avatar: { type: 'person', @@ -33,7 +33,7 @@ export const GlobalMenu: Story = { }, { id: 'inbox', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'inbox', title: 'Innboks', @@ -44,7 +44,7 @@ export const GlobalMenu: Story = { }, { id: 'access', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'bookmark', title: 'Tilganger', @@ -55,14 +55,14 @@ export const GlobalMenu: Story = { }, { id: 'access', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'menu-grid', title: 'Alle skjema', }, { id: 'settings', - group: 'settings', + groupId: 'settings', icon: 'cog', title: 'Innstillinger', }, @@ -80,7 +80,7 @@ export const CollapsibleGlobalMenu: Story = { items: [ { id: 'account', - group: 'account', + groupId: 'account', size: 'lg', avatar: { type: 'person', @@ -91,7 +91,7 @@ export const CollapsibleGlobalMenu: Story = { }, { id: 'innboks', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'inbox', title: 'Innboks', @@ -99,32 +99,32 @@ export const CollapsibleGlobalMenu: Story = { items: [ { id: 'utkast', - group: '1', + groupId: '1', icon: 'doc-pencil', title: 'Utkast', }, { id: 'sent', - group: '1', + groupId: '1', icon: 'file-checkmark', selected: true, title: 'Sendt', }, { id: 'bookmarks', - group: '3', + groupId: '3', icon: 'bookmark', title: 'Lagrede søk', }, { id: 'arkiv', - group: '4', + groupId: '4', icon: 'archive', title: 'Arkivert', }, { id: 'trash', - group: '4', + groupId: '4', icon: 'trash', title: 'Papirkurv', }, @@ -132,21 +132,21 @@ export const CollapsibleGlobalMenu: Story = { }, { id: 'tilganger', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'bookmark', title: 'Tilganger', }, { id: 'skjema', - group: 'apps', + groupId: 'apps', size: 'lg', icon: 'menu-grid', title: 'Alle skjema', }, { id: 'settings', - group: 'settings', + groupId: 'settings', icon: 'cog', title: 'Innstillinger', }, @@ -184,14 +184,14 @@ export const DrilldownMenu: Story = { items: [ { id: 'people', - group: 'level-1', + groupId: 'level-1', size: 'lg', icon: 'menu-grid', title: 'Alle skjema', expanded: true, items: [ { - group: 'level-2', + groupId: 'level-2', name: 'tema', icon: 'teddy-bear', title: 'Tema', @@ -199,16 +199,16 @@ export const DrilldownMenu: Story = { items: [ { id: 'c1', - group: 'level-3', + groupId: 'level-3', title: 'Kategori 1', }, { - group: 'level-3', + groupId: 'level-3', id: 'c2', title: 'Kategori 2', }, { - group: 'level-3', + groupId: 'level-3', id: 'c3', title: 'Kategori 3', }, @@ -226,7 +226,7 @@ export const InboxMenu: Story = { items: [ { id: 'innboks', - group: '1', + groupId: '1', size: 'lg', icon: 'inbox', title: 'Innboks', @@ -235,7 +235,7 @@ export const InboxMenu: Story = { }, { id: 'utkast', - group: '2', + groupId: '2', icon: 'doc-pencil', title: 'Utkast', badge: { @@ -244,7 +244,7 @@ export const InboxMenu: Story = { }, { id: 'sendt', - group: '2', + groupId: '2', icon: 'file-checkmark', selected: true, title: 'Sendt', @@ -254,7 +254,7 @@ export const InboxMenu: Story = { }, { id: 'lagret', - group: '3', + groupId: '3', icon: 'bookmark', title: 'Lagrede søk', badge: { @@ -263,7 +263,7 @@ export const InboxMenu: Story = { }, { id: 'arkivert', - group: '4', + groupId: '4', icon: 'archive', title: 'Arkivert', badge: { @@ -272,7 +272,7 @@ export const InboxMenu: Story = { }, { id: 'papirkurv', - group: '4', + groupId: '4', disabled: true, icon: 'trash', title: 'Papirkurv', @@ -299,13 +299,13 @@ export const InboxMenuWithShortcuts = { ...(InboxMenu?.args?.items ?? []), { id: 'users', - group: 'shortcuts', + groupId: 'shortcuts', icon: 'person-group', title: 'Brukere', }, { id: 'settings', - group: 'shortcuts', + groupId: 'shortcuts', icon: 'cog', title: 'Innstillinger', }, @@ -319,7 +319,7 @@ export const PersonMenu: Story = { items: [ { id: 'person', - group: '1', + groupId: '1', size: 'lg', avatar: { type: 'person', @@ -329,31 +329,31 @@ export const PersonMenu: Story = { }, { id: 'profil', - group: '2', + groupId: '2', icon: 'person-circle', title: 'Kontaktinformasjon', }, { id: 'varslinger', - group: '2', + groupId: '2', icon: 'bell', title: 'Varslingsinnstillinger', }, { id: 'bookmarks', - group: '3', + groupId: '3', icon: 'bookmark', title: 'Favoritter', }, { id: 'grupper', - group: '3', + groupId: '3', icon: 'hexagon-grid', title: 'Grupper', }, { id: 'logg', - group: '4', + groupId: '4', icon: 'clock-dashed', title: 'Aktivitetslogg', }, @@ -367,7 +367,7 @@ export const CompanyMenu: Story = { items: [ { id: 'company', - group: '1', + groupId: '1', size: 'lg', avatar: { type: 'company', @@ -377,25 +377,25 @@ export const CompanyMenu: Story = { }, { id: 'profil', - group: '2', + groupId: '2', icon: 'buildings2', title: 'Firmaprofil', }, { id: 'brukere', - group: '3', + groupId: '3', icon: 'person-group', title: 'Brukere', }, { id: 'grupper', - group: '3', + groupId: '3', icon: 'hexagon-grid', title: 'Grupper', }, { id: 'logg', - group: '4', + groupId: '4', icon: 'clock-dashed', title: 'Aktivitetslogg', }, @@ -417,7 +417,7 @@ export const AccountMenu: Story = { items: [ { id: '1', - group: 'a1', + groupId: 'a1', avatar: { type: 'person', name: 'Dolly Duck', @@ -429,7 +429,7 @@ export const AccountMenu: Story = { }, { id: '2', - group: 'a2', + groupId: 'a2', avatar: { type: 'company', name: 'Bergen Bar', @@ -441,7 +441,7 @@ export const AccountMenu: Story = { }, { id: '3', - group: 'a2', + groupId: 'a2', avatar: { type: 'company', name: 'Sportsklubben Brann', @@ -453,7 +453,7 @@ export const AccountMenu: Story = { }, { id: '4', - group: 'a3', + groupId: 'a3', avatarGroup: { type: 'company', items: [ @@ -472,7 +472,7 @@ export const AccountMenu: Story = { }, { id: '5', - group: 'b1', + groupId: 'b1', avatar: { type: 'company', name: 'Jensens Laks', @@ -481,7 +481,7 @@ export const AccountMenu: Story = { }, { id: '6', - group: 'b1', + groupId: 'b1', avatar: { type: 'company', name: 'Haralds gym', @@ -493,7 +493,7 @@ export const AccountMenu: Story = { }, { id: '7', - group: 'b1', + groupId: 'b1', avatar: { type: 'company', name: 'Trim og tran', diff --git a/lib/components/Menu/Menu.tsx b/lib/components/Menu/Menu.tsx index 26e62e3..2e18296 100644 --- a/lib/components/Menu/Menu.tsx +++ b/lib/components/Menu/Menu.tsx @@ -1,111 +1,12 @@ -import { MenuBase } from './MenuBase'; -import { MenuGroup } from './MenuGroup'; -import { MenuHeader } from './MenuHeader'; +import { MenuBase, type MenuTheme } from './MenuBase'; +import { MenuItems, type MenuItemsProps } from './MenuItems'; import { MenuSearch, type MenuSearchProps } from './MenuSearch'; -import type { MenuItemColor, MenuItemSize } from './MenuItemBase'; - -import { MenuItem, type MenuItemProps } from './MenuItem'; -import styles from './menu.module.css'; - -export type MenuTheme = 'inherit' | 'global' | 'neutral' | 'company' | 'person'; - -interface MenuItemsGroupProps { - title?: string; - divider?: boolean; - defaultItemColor?: MenuItemColor; - defaultItemSize?: MenuItemSize; -} - -export type MenuGroups = Record; - -export interface MenuProps { - items: MenuItemProps[]; +export interface MenuProps extends MenuItemsProps { theme?: MenuTheme; - defaultItemColor?: MenuItemColor; - defaultItemSize?: MenuItemSize; - groups?: MenuGroups; search?: MenuSearchProps; } -const groupMenuItems = (items: MenuItemProps[]) => { - const groups: Record = items?.reduce( - (acc: Record, item: MenuItemProps) => { - const group = item?.group || ''; - if (!acc[group]) { - acc[group] = []; - } - acc[group].push(item); - return acc; - }, - {} as Record, - ); - - return groups; -}; - -export const MenuItems = ({ - defaultItemColor, - defaultItemSize, - groups = {}, - items = [], -}: { - defaultItemColor: MenuItemColor; - defaultItemSize: MenuItemSize; - groups?: MenuGroups; - items: MenuItemProps[]; -}) => { - const sections = groupMenuItems(items); - - return Object.entries(sections)?.map(([key, options]) => { - const groupProps = groups?.[key]; - return ( - - {groupProps?.title && ( -
  • - -
  • - )} - {(options ?? []) - .filter((option) => !option.hidden) - .map((option, index) => { - if (option.expanded && option.items) { - return ( -
  • - - -
  • - ); - } - - return ( -
  • - -
  • - ); - })} -
    - ); - }); -}; - export const Menu = ({ theme = 'inherit', defaultItemColor = 'subtle', diff --git a/lib/components/Menu/MenuBase.tsx b/lib/components/Menu/MenuBase.tsx index 3b26b87..733ded8 100644 --- a/lib/components/Menu/MenuBase.tsx +++ b/lib/components/Menu/MenuBase.tsx @@ -1,10 +1,30 @@ import cx from 'classnames'; import type { ElementType, ReactNode } from 'react'; -import styles from './menu.module.css'; +import styles from './menuBase.module.css'; + +export type MenuTheme = 'inherit' | 'global' | 'neutral' | 'company' | 'person' | 'global-dark'; +export type MenuListRole = 'presentation' | 'group'; +export type MenuListItemRole = 'presentation' | 'group' | 'separator'; export interface MenuBaseProps { as?: ElementType; - theme?: string; + theme?: MenuTheme; + className?: string; + children?: ReactNode; +} + +export interface MenuListProps { + as?: ElementType; + role?: MenuListRole; + expanded?: boolean; + className?: string; + children?: ReactNode; +} + +export interface MenuListItemProps { + as?: ElementType; + role?: MenuListItemRole; + expanded?: boolean; className?: string; children?: ReactNode; } @@ -12,7 +32,31 @@ export interface MenuBaseProps { export const MenuBase = ({ as = 'nav', theme, className, children }: MenuBaseProps) => { const Component = as; return ( - + + {children} + + ); +}; + +export const MenuList = ({ as = 'ul', role = 'group', expanded, className, children }: MenuListProps) => { + const Component = as; + return ( + + {children} + + ); +}; + +export const MenuListItem = ({ + as = 'li', + role = 'presentation', + expanded, + className, + children, +}: MenuListItemProps) => { + const Component = as; + return ( + {children} ); diff --git a/lib/components/Menu/MenuItem.tsx b/lib/components/Menu/MenuItem.tsx index 1a2f832..d1c2682 100644 --- a/lib/components/Menu/MenuItem.tsx +++ b/lib/components/Menu/MenuItem.tsx @@ -8,18 +8,20 @@ import { MenuItemMedia } from './MenuItemMedia'; export interface MenuItemProps { id: string; + tabIndex?: number; type?: string; as?: ElementType; color?: MenuItemColor; + size?: MenuItemSize; href?: string; onClick?: MouseEventHandler; hidden?: boolean; + active?: boolean; collapsible?: boolean; expanded?: boolean; selected?: boolean; disabled?: boolean; - group?: string | number; - size?: MenuItemSize; + groupId?: string | number; title?: string; description?: string; label?: string; @@ -34,11 +36,11 @@ export interface MenuItemProps { export const MenuItem = ({ as = 'a', - color, + color = 'neutral', + size = 'sm', children, selected, disabled, - size = 'sm', icon, avatar, avatarGroup, diff --git a/lib/components/Menu/MenuItemBase.tsx b/lib/components/Menu/MenuItemBase.tsx index 0c8d984..5dad854 100644 --- a/lib/components/Menu/MenuItemBase.tsx +++ b/lib/components/Menu/MenuItemBase.tsx @@ -11,10 +11,12 @@ export interface MenuItemBaseProps { as?: ElementType; color?: MenuItemColor; children?: ReactNode; + tabIndex?: number; size?: MenuItemSize; linkIcon?: IconName; badge?: BadgeProps; collapsible?: boolean; + active?: boolean; expanded?: boolean; selected?: boolean; disabled?: boolean; @@ -27,6 +29,8 @@ export const MenuItemBase = ({ color, linkIcon, badge, + tabIndex = 0, + active = false, collapsible = false, expanded = false, selected = false, @@ -42,8 +46,11 @@ export const MenuItemBase = ({ return ( ; + +export default meta; +type Story = StoryObj; + +export const InboxMenu: Story = { + args: { + groups: { + shortcuts: { + title: 'Snarveier', + defaultItemColor: 'default', + }, + }, + defaultItemColor: 'subtle', + items: [ + { + id: 'innboks', + groupId: '1', + size: 'lg', + icon: 'inbox', + title: 'Innboks', + color: 'strong', + badge: { color: 'alert', label: '4' }, + }, + { + id: 'utkast', + groupId: '2', + icon: 'doc-pencil', + title: 'Utkast', + badge: { + label: '3', + }, + }, + { + id: 'sendt', + groupId: '2', + icon: 'file-checkmark', + selected: true, + title: 'Sendt', + badge: { + label: '2', + }, + }, + { + id: 'lagret', + groupId: '3', + icon: 'bookmark', + title: 'Lagrede søk', + badge: { + label: '5', + }, + }, + { + id: 'arkivert', + groupId: '4', + icon: 'archive', + title: 'Arkivert', + badge: { + label: '100+', + }, + }, + { + id: 'papirkurv', + groupId: '4', + disabled: true, + icon: 'trash', + title: 'Papirkurv', + badge: { + label: '45', + }, + }, + { + id: 'users', + groupId: 'shortcuts', + icon: 'person-group', + title: 'Brukere', + }, + { + id: 'settings', + groupId: 'shortcuts', + icon: 'cog', + title: 'Innstillinger', + }, + ], + }, +}; + +export const PersonMenu: Story = { + args: { + groups: {}, + defaultItemColor: 'subtle', + items: [ + { + id: 'person', + groupId: '1', + size: 'lg', + avatar: { + type: 'person', + name: 'Erik Huseklepp', + }, + title: 'Erik Huseklepp', + }, + { + id: 'profil', + groupId: '2', + icon: 'person-circle', + title: 'Kontaktinformasjon', + }, + { + id: 'varslinger', + groupId: '2', + icon: 'bell', + title: 'Varslingsinnstillinger', + }, + { + id: 'bookmarks', + groupId: '3', + icon: 'bookmark', + title: 'Favoritter', + }, + { + id: 'grupper', + groupId: '3', + icon: 'hexagon-grid', + title: 'Grupper', + }, + { + id: 'logg', + groupId: '4', + icon: 'clock-dashed', + title: 'Aktivitetslogg', + }, + ], + }, +}; + +export const CompanyMenu: Story = { + args: { + groups: {}, + defaultItemColor: 'subtle', + items: [ + { + id: 'company', + groupId: '1', + size: 'lg', + avatar: { + type: 'company', + name: 'Bergen Bar', + }, + title: 'Bergen Bar', + }, + { + id: 'profil', + groupId: '2', + icon: 'buildings2', + title: 'Firmaprofil', + }, + { + id: 'brukere', + groupId: '3', + icon: 'person-group', + title: 'Brukere', + }, + { + id: 'grupper', + groupId: '3', + icon: 'hexagon-grid', + title: 'Grupper', + }, + { + id: 'logg', + groupId: '4', + icon: 'clock-dashed', + title: 'Aktivitetslogg', + }, + ], + }, +}; + +export const AccountMenu: Story = { + args: { + groups: { + a1: { + title: 'Deg selv, favoritter og grupper', + }, + b1: { + id: 'companies', + title: 'Andre kontoer', + }, + }, + items: [ + { + id: '1', + groupId: 'a1', + avatar: { + type: 'person', + name: 'Dolly Duck', + }, + title: 'Dolly Duck', + badge: { + label: '15', + }, + }, + { + id: '2', + groupId: 'a2', + avatar: { + type: 'company', + name: 'Bergen Bar', + }, + title: 'Bergen Bar', + badge: { + label: '21', + }, + }, + { + id: '3', + groupId: 'a2', + avatar: { + type: 'company', + name: 'Sportsklubben Brann', + }, + title: 'Sportsklubben Brann', + badge: { + label: '4', + }, + }, + { + id: '4', + groupId: 'a3', + avatarGroup: { + type: 'company', + items: [ + { + name: 'Sportsklubben Brann', + }, + { + name: 'Bergen Bar', + }, + ], + }, + title: 'Alle virksomheter', + badge: { + label: '45', + }, + }, + { + id: '5', + groupId: 'b1', + avatar: { + type: 'company', + name: 'Jensens Laks', + }, + title: 'Jensens laks', + }, + { + id: '6', + groupId: 'b1', + avatar: { + type: 'company', + name: 'Haralds gym', + }, + title: 'Haralds gym', + badge: { + label: '2', + }, + }, + { + id: '7', + groupId: 'b1', + avatar: { + type: 'company', + name: 'Trim og tran', + }, + title: 'Trim og tran', + }, + ], + }, +}; + +export const CollapsibleGlobalMenu: Story = { + args: { + defaultItemColor: 'subtle', + groups: { + settings: { + defaultItemColor: 'neutral', + }, + }, + items: [ + { + id: 'account', + groupId: 'account', + size: 'lg', + avatar: { + type: 'person', + name: 'Herman Friele', + }, + title: 'Herman Friele', + description: 'Fødselsnr: XX.XX.XXXX', + }, + { + id: 'innboks', + groupId: 'apps', + size: 'lg', + icon: 'inbox', + title: 'Innboks', + collapsible: true, + items: [ + { + id: 'utkast', + groupId: '1', + icon: 'doc-pencil', + title: 'Utkast', + }, + { + id: 'sent', + groupId: '1', + icon: 'file-checkmark', + selected: true, + title: 'Sendt', + }, + { + id: 'bookmarks', + groupId: '3', + icon: 'bookmark', + title: 'Lagrede søk', + }, + { + id: 'arkiv', + groupId: '4', + icon: 'archive', + title: 'Arkivert', + }, + { + id: 'trash', + groupId: '4', + icon: 'trash', + title: 'Papirkurv', + }, + ], + }, + { + id: 'tilganger', + groupId: 'apps', + size: 'lg', + icon: 'bookmark', + title: 'Tilganger', + }, + { + id: 'skjema', + groupId: 'apps', + size: 'lg', + icon: 'menu-grid', + title: 'Alle skjema', + }, + { + id: 'settings', + groupId: 'settings', + icon: 'cog', + title: 'Innstillinger', + }, + ], + }, +}; + +export const ExpandedGlobalMenu: Story = { + args: { + ...CollapsibleGlobalMenu.args, + items: [...CollapsibleGlobalMenu.args.items].map((item) => { + if (item.collapsible) { + return { + ...item, + expanded: true, + }; + } + return item; + }), + }, +}; + +export const DrilldownMenu: Story = { + args: { + defaultItemColor: 'subtle', + groups: { + 'level-1': { + divider: true, + }, + 'level-2': { + divider: true, + }, + }, + items: [ + { + id: 'people', + groupId: 'level-1', + size: 'lg', + icon: 'menu-grid', + title: 'Alle skjema', + expanded: true, + items: [ + { + groupId: 'level-2', + name: 'tema', + icon: 'teddy-bear', + title: 'Tema', + expanded: true, + items: [ + { + id: 'c1', + groupId: 'level-3', + title: 'Kategori 1', + }, + { + groupId: 'level-3', + id: 'c2', + title: 'Kategori 2', + }, + { + groupId: 'level-3', + id: 'c3', + title: 'Kategori 3', + }, + ], + }, + ], + }, + ], + }, +}; diff --git a/lib/components/Menu/MenuItems.tsx b/lib/components/Menu/MenuItems.tsx new file mode 100644 index 0000000..1f537d5 --- /dev/null +++ b/lib/components/Menu/MenuItems.tsx @@ -0,0 +1,96 @@ +'use client'; +import { Fragment } from 'react'; +import { MenuHeader, MenuItem, MenuList, MenuListItem } from '../'; +import type { MenuItemColor, MenuItemProps, MenuItemSize } from '../'; +import { useMenu } from '../../hooks'; + +export interface MenuGroupProps { + title?: string; + divider?: boolean; + defaultItemColor?: MenuItemColor; + defaultItemSize?: MenuItemSize; +} + +export type MenuItemGroups = Record; + +export interface MenuItemsProps { + level?: number; + expanded?: boolean; + items: MenuItemProps[]; + groups?: MenuItemGroups; + defaultItemColor?: MenuItemColor; + defaultItemSize?: MenuItemSize; +} + +export const MenuItems = ({ + level = 0, + expanded, + items, + groups = {}, + defaultItemColor, + defaultItemSize, +}: MenuItemsProps) => { + const { menu } = useMenu({ + items, + groups, + groupByKey: 'groupId', + keyboardEvents: false, + }); + + return ( + + {menu.map((group, groupIndex) => { + const groupProps: MenuGroupProps = group?.props || {}; + const { title, divider = true } = groupProps; + const nextGroup = menu[groupIndex + 1]; + + return ( + + {/** Render a separator if this is a new group or a new level */} + + {(level > 0 || groupIndex) && divider ? : ''} + + {title && ( + + + + )} + + {group?.items.map((item, index) => { + const { active } = item; + const { groupId: _, ...itemProps } = item.props || {}; + const { expanded } = itemProps; + const nextItem = group?.items[index + 1]; + + return ( + + + {expanded && itemProps?.items && ( + <> + + {/** Render a separator if expanded and there are items underneath */} + {(nextGroup || nextItem) && } + + )} + + ); + })} + + ); + })} + + ); +}; diff --git a/lib/components/Menu/MenuOption.tsx b/lib/components/Menu/MenuOption.tsx index cf45fb6..9ebd197 100644 --- a/lib/components/Menu/MenuOption.tsx +++ b/lib/components/Menu/MenuOption.tsx @@ -1,4 +1,5 @@ import type { ChangeEventHandler } from 'react'; +import type { BadgeProps } from '../Badge'; import { CheckboxIcon, RadioIcon } from '../Icon'; import { MenuItemBase, type MenuItemSize } from './MenuItemBase'; import { MenuItemLabel } from './MenuItemLabel'; @@ -14,6 +15,7 @@ export interface MenuOptionProps { name?: string; title?: string; description?: string; + badge?: BadgeProps; checked?: boolean; disabled?: boolean; onChange?: ChangeEventHandler; @@ -28,12 +30,13 @@ export const MenuOption = ({ label, title, description, + badge, checked = false, disabled, onChange, }: MenuOptionProps) => { return ( - + {type === 'checkbox' && } {type === 'radio' && } diff --git a/lib/components/Menu/MenuGroup.tsx b/lib/components/Menu/__MenuGroup.tsx similarity index 79% rename from lib/components/Menu/MenuGroup.tsx rename to lib/components/Menu/__MenuGroup.tsx index 6aac3ef..1027541 100644 --- a/lib/components/Menu/MenuGroup.tsx +++ b/lib/components/Menu/__MenuGroup.tsx @@ -8,7 +8,7 @@ export interface MenuGroupProps { children?: ReactNode; } -export const MenuGroup = ({ as = 'ul', expanded, divider = true, children }: MenuGroupProps) => { +export const MenuGroup = ({ as = 'div', expanded, divider = true, children }: MenuGroupProps) => { const Component = as; return ( diff --git a/lib/components/Menu/index.ts b/lib/components/Menu/index.ts index 8d5031f..43bc48e 100644 --- a/lib/components/Menu/index.ts +++ b/lib/components/Menu/index.ts @@ -4,7 +4,7 @@ export * from './MenuItemMedia'; export * from './MenuItem'; export * from './MenuOption'; export * from './MenuSearch'; -export * from './MenuGroup'; export * from './MenuHeader'; +export * from './MenuItems'; export * from './MenuBase'; export * from './Menu'; diff --git a/lib/components/Menu/menu.module.css b/lib/components/Menu/menu.module.css index 059f67f..8b270a6 100644 --- a/lib/components/Menu/menu.module.css +++ b/lib/components/Menu/menu.module.css @@ -3,16 +3,15 @@ flex-direction: column; } -.menu ul { +.group { list-style: none; padding: 0; margin: 0; text-indent: 0; } -.menu li { +.menuItem { list-style: none; - list-style-type: none; } .group[data-divider="true"] + .group { diff --git a/lib/components/Menu/menuBase.module.css b/lib/components/Menu/menuBase.module.css new file mode 100644 index 0000000..43e21d5 --- /dev/null +++ b/lib/components/Menu/menuBase.module.css @@ -0,0 +1,25 @@ +.menu { + list-style: none; + padding: 0; + margin: 0; + text-indent: 0; + + display: flex; + flex-direction: column; +} + +.list { + list-style: none; + padding: 0; + margin: 0; +} + +.item { + list-style: none; + padding: 0; + margin: 0; +} + +.item[role="separator"] { + border-top: 1px solid var(--theme-border-subtle); +} diff --git a/lib/components/Menu/menuItemBase.module.css b/lib/components/Menu/menuItemBase.module.css index 4d0747c..c2a8950 100644 --- a/lib/components/Menu/menuItemBase.module.css +++ b/lib/components/Menu/menuItemBase.module.css @@ -47,14 +47,15 @@ color: var(--theme-text-default); } -.item:hover { - background-color: var(--theme-surface-hover); -} - .item[aria-selected="true"] { background-color: var(--theme-background-default); } +.item:hover, +.item:active { + background-color: var(--theme-surface-hover); +} + .media[data-color="subtle"] { background-color: var(--theme-background-default); color: var(--theme-text-default); diff --git a/lib/components/Meta/MetaItemBase.tsx b/lib/components/Meta/MetaItemBase.tsx index 381471e..294c8d6 100644 --- a/lib/components/Meta/MetaItemBase.tsx +++ b/lib/components/Meta/MetaItemBase.tsx @@ -3,7 +3,7 @@ import type { ElementType, ReactNode } from 'react'; import styles from './metaItem.module.css'; export type MetaItemVariant = 'solid' | 'outline' | 'dotted' | 'text'; -export type MetaItemSize = 'xs' | 'sm' | 'md'; +export type MetaItemSize = 'xs'; // | 'sm' | 'md'; export type MetaItemColor = 'subtle'; export interface MetaItemBaseProps { diff --git a/lib/components/Meta/MetaItemLabel.tsx b/lib/components/Meta/MetaItemLabel.tsx index 60f6d03..b62084c 100644 --- a/lib/components/Meta/MetaItemLabel.tsx +++ b/lib/components/Meta/MetaItemLabel.tsx @@ -11,7 +11,7 @@ export interface MetaItemLabelProps { children?: ReactNode; } -export const MetaItemLabel = ({ size = 'sm', variant = 'text', children }: MetaItemLabelProps) => { +export const MetaItemLabel = ({ size = 'xs', variant = 'text', children }: MetaItemLabelProps) => { return ( {children} diff --git a/lib/components/Meta/MetaItemMedia.tsx b/lib/components/Meta/MetaItemMedia.tsx index 7aed36f..bd79217 100644 --- a/lib/components/Meta/MetaItemMedia.tsx +++ b/lib/components/Meta/MetaItemMedia.tsx @@ -8,7 +8,7 @@ interface MetaItemMediaProps { icon?: IconName; } -export const MetaItemMedia = ({ size = 'sm', icon, progress }: MetaItemMediaProps) => { +export const MetaItemMedia = ({ size = 'xs', icon, progress }: MetaItemMediaProps) => { if (!icon && typeof progress !== 'number') { return false; } diff --git a/lib/components/Page/PageBase.tsx b/lib/components/Page/PageBase.tsx new file mode 100644 index 0000000..9c16724 --- /dev/null +++ b/lib/components/Page/PageBase.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { SectionBase, type SectionBaseProps } from './SectionBase'; + +export interface PageBaseProps extends SectionBaseProps { + children?: ReactNode; +} + +export const PageBase = ({ children, ...props }: PageBaseProps) => { + return ( + + {children} + + ); +}; diff --git a/lib/components/Page/PageHeader.tsx b/lib/components/Page/PageHeader.tsx new file mode 100644 index 0000000..a4a91b4 --- /dev/null +++ b/lib/components/Page/PageHeader.tsx @@ -0,0 +1,21 @@ +import { Heading } from '../'; +import { PageHeaderMedia, type PageHeaderMediaProps } from './PageHeaderMedia'; +import { SectionBase, type SectionBaseProps } from './SectionBase'; +import styles from './pageHeader.module.css'; + +export interface PageHeaderProps extends SectionBaseProps, PageHeaderMediaProps { + title?: string; +} + +export const PageHeader = ({ title, icon, avatar, avatarGroup, children, ...props }: PageHeaderProps) => { + return ( + +
    + + {title} +
    + + {children} +
    + ); +}; diff --git a/lib/components/Page/PageHeaderMedia.tsx b/lib/components/Page/PageHeaderMedia.tsx new file mode 100644 index 0000000..5f4f227 --- /dev/null +++ b/lib/components/Page/PageHeaderMedia.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps } from '../Avatar'; +import { Icon, type IconName } from '../Icon'; +import styles from './pageHeader.module.css'; + +export interface PageHeaderMediaProps { + icon?: IconName; + avatar?: AvatarProps; + avatarGroup?: AvatarGroupProps; + children?: ReactNode; +} + +export const PageHeaderMedia = ({ icon, avatar, avatarGroup }: PageHeaderMediaProps) => { + if (!icon && !avatar && !avatarGroup) { + return false; + } + + return ( +
    + {(icon && ) || + (avatar && ) || + (avatarGroup && )} +
    + ); +}; diff --git a/lib/components/Page/SectionBase.tsx b/lib/components/Page/SectionBase.tsx new file mode 100644 index 0000000..ccd9bfd --- /dev/null +++ b/lib/components/Page/SectionBase.tsx @@ -0,0 +1,52 @@ +import cx from 'classnames'; +import type { CSSProperties, ReactNode } from 'react'; +import styles from './sectionBase.module.css'; + +export type SectionElement = 'section' | 'main' | 'header' | 'footer' | 'div'; +export type SectionColor = 'transparent' | 'white' | 'subtle' | 'accent'; +export type SectionSpacing = 'none' | 'sm' | 'md' | 'lg'; +export type SectionMargin = 'none' | 'sm' | 'md' | 'lg'; +export type SectionShadow = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export interface SectionBaseProps { + as?: SectionElement; + color?: SectionColor; + padding?: boolean; + spacing?: SectionSpacing; + margin?: SectionMargin; + shadow?: SectionShadow; + inset?: boolean; + children?: ReactNode; + className?: string; + style?: CSSProperties; +} + +export const SectionBase = ({ + as = 'section', + color = 'transparent', + padding = false, + spacing = 'none', + margin = 'none', + shadow = 'none', + inset = false, + className, + style, + children, +}: SectionBaseProps) => { + const Component = as; + + return ( + + {children} + + ); +}; diff --git a/lib/components/Page/SectionFooter.tsx b/lib/components/Page/SectionFooter.tsx new file mode 100644 index 0000000..7787b0e --- /dev/null +++ b/lib/components/Page/SectionFooter.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react'; +import styles from './sectionFooter.module.css'; + +export interface SectionFooterProps { + margin?: boolean; + children?: ReactNode; +} + +export const SectionFooter = ({ margin = false, children }: SectionFooterProps) => { + return ( +
    + {children} +
    + ); +}; diff --git a/lib/components/Page/SectionHeader.tsx b/lib/components/Page/SectionHeader.tsx new file mode 100644 index 0000000..1fa880c --- /dev/null +++ b/lib/components/Page/SectionHeader.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react'; +import styles from './sectionHeader.module.css'; + +export interface SectionHeaderProps { + padding?: boolean; + margin?: boolean; + children?: ReactNode; +} + +export const SectionHeader = ({ margin = false, children }: SectionHeaderProps) => { + return ( +
    + {children} +
    + ); +}; diff --git a/lib/components/Page/index.ts b/lib/components/Page/index.ts new file mode 100644 index 0000000..01c5962 --- /dev/null +++ b/lib/components/Page/index.ts @@ -0,0 +1,5 @@ +export * from './PageBase'; +export * from './PageHeader'; +export * from './SectionBase'; +export * from './SectionHeader'; +export * from './SectionFooter'; diff --git a/lib/components/Page/pageHeader.module.css b/lib/components/Page/pageHeader.module.css new file mode 100644 index 0000000..cbc50ed --- /dev/null +++ b/lib/components/Page/pageHeader.module.css @@ -0,0 +1,5 @@ +.title { + display: flex; + align-items: center; + column-gap: 0.5rem; +} diff --git a/lib/components/Page/sectionBase.module.css b/lib/components/Page/sectionBase.module.css new file mode 100644 index 0000000..a167a96 --- /dev/null +++ b/lib/components/Page/sectionBase.module.css @@ -0,0 +1,82 @@ +.section { + display: flex; + flex-direction: column; +} + +/* inset on small screens */ + +@media (max-width: 1024px) { + .section[data-inset="true"] { + margin-left: -1rem; + margin-right: -1rem; + } +} + +/* spacing */ + +.section[data-spacing="sm"] { + row-gap: 0.25rem; +} + +.section[data-spacing="md"] { + row-gap: 0.5rem; +} + +.section[data-spacing="lg"] { + row-gap: 1rem; +} + +/* margin */ + +.section[data-margin="sm"] { + margin: 0.25rem 0; +} + +.section[data-margin="md"] { + margin: 0.5rem 0; +} + +.section[data-margin="lg"] { + margin: 1rem 0; +} + +/* padding */ + +.section[data-padding="true"] { + padding: 1rem; +} + +/* color */ + +.section[data-color="white"] { + background-color: #fff; +} + +.section[data-color="subtle"] { + background-color: var(--theme-surface-default); +} + +.section[data-color="accent"] { + background-color: var(--theme-base-default); +} + +/* size */ + +.section[data-shadow="xs"] { + box-shadow: var(--ds-shadow-xs); +} + +.section[data-shadow="sm"] { + box-shadow: var(--ds-shadow-sm); +} +.section[data-shadow="md"] { + box-shadow: var(--ds-shadow-md); +} + +.section[data-shadow="lg"] { + box-shadow: var(--ds-shadow-lg); +} + +.section[data-shadow="xl"] { + box-shadow: var(--ds-shadow-xl); +} diff --git a/lib/components/Page/sectionFooter.module.css b/lib/components/Page/sectionFooter.module.css new file mode 100644 index 0000000..116f7a1 --- /dev/null +++ b/lib/components/Page/sectionFooter.module.css @@ -0,0 +1,8 @@ +.footer { + display: flex; + justify-content: space-between; +} + +.header[data-margin="true"] { + margin: 0.5rem 0; +} diff --git a/lib/components/Page/sectionHeader.module.css b/lib/components/Page/sectionHeader.module.css new file mode 100644 index 0000000..1015c98 --- /dev/null +++ b/lib/components/Page/sectionHeader.module.css @@ -0,0 +1,9 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.header[data-margin="true"] { + margin: 0.5rem 0; +} diff --git a/lib/components/RootProvider/RootProvider.tsx b/lib/components/RootProvider/RootProvider.tsx index 09f6542..0a3bbf2 100644 --- a/lib/components/RootProvider/RootProvider.tsx +++ b/lib/components/RootProvider/RootProvider.tsx @@ -1,19 +1,55 @@ -import { type ReactNode, createContext } from 'react'; +import { type ReactNode, createContext, useContext, useState } from 'react'; + +type OpenElementId = 'search' | 'menu' | string; interface RootContextProvider { - showBackdrop: boolean; + currentId: OpenElementId; + toggleId: (elementId: OpenElementId) => void; + closeAll: () => void; + openId: (elementId: OpenElementId) => void; } const initialValue = { - showBackdrop: false, + currentId: '', }; -const RootContext = createContext(initialValue); + +interface RootContextInitialValue { + currentId: OpenElementId; + setCurrentId?: (elementId: OpenElementId) => void; +} + +const RootContext = createContext(initialValue); interface ProviderProps { children: ReactNode; - value?: RootContextProvider; + initialValue?: RootContextInitialValue; } -export const RootProvider = ({ children, value }: ProviderProps) => { - return {children}; +export const RootProvider = ({ children, initialValue }: ProviderProps) => { + const [currentId, setCurrentId] = useState(initialValue?.currentId || ''); + return ( + + {children} + + ); +}; + +export const useRootContext = (): RootContextProvider => { + const { currentId, setCurrentId } = useContext(RootContext); + const toggleId = (elementId: OpenElementId) => setCurrentId!(currentId === elementId ? '' : elementId); + const closeAll = () => { + setCurrentId!(''); + }; + const openId = (elementId: OpenElementId) => setCurrentId!(elementId); + return { + currentId, + toggleId, + closeAll, + openId, + }; }; diff --git a/lib/components/Searchbar/Autocomplete.stories.tsx b/lib/components/Searchbar/Autocomplete.stories.tsx new file mode 100644 index 0000000..5386703 --- /dev/null +++ b/lib/components/Searchbar/Autocomplete.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Autocomplete } from './Autocomplete'; + +const meta = { + title: 'Header/Autocomplete', + component: Autocomplete, + tags: ['autodocs'], + parameters: {}, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Scopes: Story = { + args: { + groups: {}, + items: [ + { + id: '1a', + groupId: '1', + href: '#', + label: 'Alt i innboks', + }, + { + id: '1b', + groupId: '1', + href: '#', + label: 'Alt i hele Altinn', + }, + ], + }, +}; + +export const ScopesAndSuggestions: Story = { + args: { + groups: { + '2': { + title: '2 treff i innboksen', + }, + }, + items: [ + { + id: '1a', + groupId: '1', + href: '#', + label: () => ( + + skatt i innboks + + ), + }, + { + id: '1b', + groupId: '1', + href: '#', + label: () => ( + + skatt i hele Altinn + + ), + }, + { + id: '2a', + groupId: '2', + href: '#', + label: 'Skattemeldingen 2023', + }, + { + id: '2b', + groupId: '2', + href: '#', + label: 'Skattemeldingen 2022', + }, + ], + }, +}; diff --git a/lib/components/Searchbar/Autocomplete.tsx b/lib/components/Searchbar/Autocomplete.tsx new file mode 100644 index 0000000..c39bf4e --- /dev/null +++ b/lib/components/Searchbar/Autocomplete.tsx @@ -0,0 +1,44 @@ +import { useMenu } from '../../hooks'; +import { AutocompleteBase } from './AutocompleteBase'; +import { AutocompleteGroup, type AutocompleteGroupProps } from './AutocompleteGroup'; +import { AutocompleteItem, type AutocompleteItemProps } from './AutocompleteItem'; + +export interface AutocompleteProps { + items: AutocompleteItemProps[]; + groups?: Record; + expanded?: boolean; + className?: string; +} + +export const Autocomplete = ({ className, items, groups = {}, expanded }: AutocompleteProps) => { + const { menu, setActiveIndex } = useMenu({ + items, + groups, + groupByKey: 'groupId', + keyboardEvents: true, + }); + return ( + + {menu.map((group, index) => { + return ( + +
      + {group.items.map((item, index) => { + const { + active, + menuIndex, + props: { groupId, ...itemProps }, + } = item; + return ( +
    • setActiveIndex(menuIndex)}> + +
    • + ); + })} +
    +
    + ); + })} +
    + ); +}; diff --git a/lib/components/Searchbar/AutocompleteBase.tsx b/lib/components/Searchbar/AutocompleteBase.tsx new file mode 100644 index 0000000..b720536 --- /dev/null +++ b/lib/components/Searchbar/AutocompleteBase.tsx @@ -0,0 +1,16 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import styles from './autocompleteBase.module.css'; +export interface AutocompleteBaseProps { + className?: string; + expanded?: boolean; + children?: ReactNode; +} + +export const AutocompleteBase = ({ expanded, children, className }: AutocompleteBaseProps) => { + return ( + + ); +}; diff --git a/lib/components/Searchbar/AutocompleteGroup.tsx b/lib/components/Searchbar/AutocompleteGroup.tsx new file mode 100644 index 0000000..218c932 --- /dev/null +++ b/lib/components/Searchbar/AutocompleteGroup.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; +import { MenuHeader } from '../Menu'; +import styles from './autocompleteGroup.module.css'; + +export interface AutocompleteGroupProps { + title?: string; + children?: ReactNode; +} + +export const AutocompleteGroup = ({ title, children }: AutocompleteGroupProps) => { + return ( +
    + {title && } + {children} +
    + ); +}; diff --git a/lib/components/Searchbar/AutocompleteItem.tsx b/lib/components/Searchbar/AutocompleteItem.tsx new file mode 100644 index 0000000..5669a6f --- /dev/null +++ b/lib/components/Searchbar/AutocompleteItem.tsx @@ -0,0 +1,23 @@ +import type { ElementType, ReactNode } from 'react'; +import { ListItemBase, ListItemLabel } from '../List/'; +import styles from './autocompleteItem.module.css'; + +export interface AutocompleteItemProps { + as?: ElementType; + href?: string; + onClick?: () => void; + loading?: boolean; + active?: boolean; + disabled?: boolean; + label?: string | (() => ReactNode); + groupId?: string; + style?: React.CSSProperties; +} + +export const AutocompleteItem = ({ as = 'a', label, active, ...rest }: AutocompleteItemProps) => { + return ( + + {typeof label === 'function' ? label() : label} + + ); +}; diff --git a/lib/components/Searchbar/SearchField.tsx b/lib/components/Searchbar/SearchField.tsx new file mode 100644 index 0000000..0464c88 --- /dev/null +++ b/lib/components/Searchbar/SearchField.tsx @@ -0,0 +1,78 @@ +import cx from 'classnames'; +import { type ChangeEventHandler, type FocusEventHandler, useRef } from 'react'; +import { IconButton } from '../Button'; +import { Icon } from '../Icon'; +import styles from './searchField.module.css'; + +export interface SearchFieldProps { + name: string; + // TODO: Should be required? + value?: string; + className?: string; + expanded?: boolean; + placeholder?: string; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; + onChange?: ChangeEventHandler; + onClear?: () => void; + onClose?: () => void; + onEnter?: () => void; +} + +export const SearchField = ({ + className, + expanded, + name = 'q', + value, + placeholder = 'Søk', + onFocus, + onBlur, + onChange, + onClear, + onClose, + onEnter, +}: SearchFieldProps) => { + const ref = useRef(null); + + const handleOnKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + ref.current?.blur(); + } + if (event.key === 'Enter') { + onEnter?.(); + } + }; + + return ( +
    + + + {(value && ( + + )) || + (expanded && )} +
    + ); +}; diff --git a/lib/components/Searchbar/Searchbar.stories.tsx b/lib/components/Searchbar/Searchbar.stories.tsx new file mode 100644 index 0000000..356cb73 --- /dev/null +++ b/lib/components/Searchbar/Searchbar.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Searchbar } from './Searchbar'; + +const meta = { + title: 'Header/Searchbar', + component: Searchbar, + tags: ['autodocs'], + parameters: {}, + args: { + placeholder: 'Search', + name: 'search', + expanded: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Query: Story = { + args: { + value: 'query', + }, +}; + +export const Expanded: Story = { + args: { + placeholder: 'Søk i innboks', + expanded: true, + autocomplete: { + groups: { + 2: { + title: '2 treff i innboks', + }, + }, + items: [ + { + id: '1a', + groupId: '1', + href: '#', + label: 'Alt i innboks', + }, + { + id: '1b', + groupId: '1', + href: '#', + label: 'Alt i hele Altinn', + }, + { + id: '2a', + groupId: '2', + href: '#', + label: 'Skattemelding 2024', + }, + { + id: '2b', + groupId: '2', + href: '#', + label: 'Skattemelding 2025', + }, + ], + }, + }, +}; + +export const ControlledState = (args) => { + const [expanded, setExpanded] = useState(false); + const [q, setQ] = useState(''); + const onChange = (event) => { + setQ(event.target.value); + }; + + const onFocus = () => { + setExpanded(true); + }; + + const scopes = [ + { + groupId: '1', + id: 'inbox', + href: '#', + label: q + ? () => { + return ( + + {q} i innboksen + + ); + } + : 'Alt i innboksen', + }, + { + groupId: '1', + id: 'global', + href: '#', + label: q + ? () => { + return ( + + {q} i hele Altinn + + ); + } + : 'Alt i hele Altinn', + }, + ]; + + const suggestions = q + ? [ + { + groupId: '2', + href: 'http://www.altinn.no', + label: 'Skattemelding 2024', + }, + { + groupId: '2', + onClick: () => { + alert('Skattemelding 2025 ble trykket på'); + }, + label: 'Skattemelding 2025', + }, + ].filter((item) => item.label.toLowerCase().includes((q ?? '').toLowerCase())) + : []; + + const autocomplete = { + groups: { + 2: { + title: `${suggestions.length} treff i innboksen`, + }, + }, + items: [...scopes, ...suggestions], + }; + + return ( + { + alert(`Søk etter ${q}`); + }} + /> + ); +}; diff --git a/lib/components/Searchbar/Searchbar.tsx b/lib/components/Searchbar/Searchbar.tsx new file mode 100644 index 0000000..892067a --- /dev/null +++ b/lib/components/Searchbar/Searchbar.tsx @@ -0,0 +1,18 @@ +import { Autocomplete, type AutocompleteProps } from './Autocomplete'; +import { SearchField, type SearchFieldProps } from './SearchField'; +import { SearchbarBase } from './SearchbarBase'; + +export interface SearchbarProps extends SearchFieldProps { + className?: string; + autocomplete?: AutocompleteProps; + expanded: boolean; +} + +export const Searchbar = ({ className, autocomplete, expanded = false, onClose, ...search }: SearchbarProps) => { + return ( + + + {autocomplete && } + + ); +}; diff --git a/lib/components/Searchbar/SearchbarBase.tsx b/lib/components/Searchbar/SearchbarBase.tsx new file mode 100644 index 0000000..aafd981 --- /dev/null +++ b/lib/components/Searchbar/SearchbarBase.tsx @@ -0,0 +1,23 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import styles from './searchbarBase.module.css'; + +export interface SearchbarBaseProps { + className?: string; + children: ReactNode; + expanded?: boolean; + autocomplete?: boolean; +} + +export const SearchbarBase = ({ className, children, expanded = false, autocomplete = false }: SearchbarBaseProps) => { + return ( +
    + {children} +
    + ); +}; diff --git a/lib/components/Searchbar/autocompleteBase.module.css b/lib/components/Searchbar/autocompleteBase.module.css new file mode 100644 index 0000000..9f304c7 --- /dev/null +++ b/lib/components/Searchbar/autocompleteBase.module.css @@ -0,0 +1,17 @@ +.autocomplete { + width: 100%; + border: 2px solid; + border-radius: 0.25rem; + background-color: var(--theme-background-default); +} + +.autocomplete ul { + list-style: none; + padding: 0; + margin: 0; +} + +.autocomplete li { + padding: 0; + margin: 0.5rem 0; +} diff --git a/lib/components/Searchbar/autocompleteGroup.module.css b/lib/components/Searchbar/autocompleteGroup.module.css new file mode 100644 index 0000000..2dc577b --- /dev/null +++ b/lib/components/Searchbar/autocompleteGroup.module.css @@ -0,0 +1,3 @@ +.group + .group { + border-top: 1px solid var(--theme-border-subtle); +} diff --git a/lib/components/Searchbar/autocompleteItem.module.css b/lib/components/Searchbar/autocompleteItem.module.css new file mode 100644 index 0000000..3ea14ce --- /dev/null +++ b/lib/components/Searchbar/autocompleteItem.module.css @@ -0,0 +1,19 @@ +.item { + box-shadow: none; +} + +.item mark { + background-color: transparent; + font-weight: 500; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 4px; +} + +.item mark:before { + content: "«"; +} + +.item mark:after { + content: "»"; +} diff --git a/lib/components/Searchbar/index.ts b/lib/components/Searchbar/index.ts new file mode 100644 index 0000000..e40eb69 --- /dev/null +++ b/lib/components/Searchbar/index.ts @@ -0,0 +1 @@ +export * from './Searchbar'; diff --git a/lib/components/Searchbar/searchField.module.css b/lib/components/Searchbar/searchField.module.css new file mode 100644 index 0000000..be7ed51 --- /dev/null +++ b/lib/components/Searchbar/searchField.module.css @@ -0,0 +1,54 @@ +.field { + position: relative; + display: flex; + align-items: center; + font-size: 1.125rem; + background-color: transparent; + color: currentColor; +} + +.icon { + position: absolute; + left: 0; + font-size: 1.5rem; + margin: 0 1rem; +} + +.input { + height: 3.5rem; /* 56px */ + background-color: transparent; + font-size: inherit; + flex-grow: 1; + padding-left: 3rem; + padding-right: 1rem; + padding-top: 1rem; + padding-bottom: 1rem; + border: 2px solid; + border-radius: 0.25rem; + outline: none; +} + +.input[type="search"]::-webkit-search-decoration, +.input[type="search"]::-webkit-search-cancel-button { + appearance: none; +} + +.input:focus, +.input[value] { + background-color: var(--theme-background-default); +} + +.dismiss { + position: absolute; + right: 0; + margin: 0.375rem; +} + +.clear { + position: absolute; + right: 0; + margin: 1rem; + border-radius: 100%; + width: 1.5rem; + height: 1.5rem; +} diff --git a/lib/components/Searchbar/searchbarBase.module.css b/lib/components/Searchbar/searchbarBase.module.css new file mode 100644 index 0000000..4f3ccfc --- /dev/null +++ b/lib/components/Searchbar/searchbarBase.module.css @@ -0,0 +1,20 @@ +.searchbar { + position: relative; +} + +.searchbar[data-autocomplete="true"][aria-expanded="true"] input { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.searchbar nav { + display: none; +} + +.searchbar[aria-expanded="true"] nav { + position: absolute; + display: block; + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: -2px; +} diff --git a/lib/components/Toolbar/Toolbar.stories.tsx b/lib/components/Toolbar/Toolbar.stories.tsx index c194713..d3adcb8 100644 --- a/lib/components/Toolbar/Toolbar.stories.tsx +++ b/lib/components/Toolbar/Toolbar.stories.tsx @@ -81,27 +81,27 @@ export const Default: Story = { label: 'Velg status', options: [ { - group: '1', + groupId: '1', value: 'draft', label: 'Utkast', }, { - group: '1', + groupId: '1', value: 'sent', label: 'Sendt', }, { - group: '2', + groupId: '2', value: 'in-progress', label: 'Under arbeid', }, { - group: '2', + groupId: '2', value: 'requires-attention', label: 'Krever handling', }, { - group: '2', + groupId: '2', value: 'completed', label: 'Avsluttet', }, @@ -157,27 +157,27 @@ export const FilterAndSearch: Story = { label: 'Velg status', options: [ { - group: '1', + groupId: '1', value: 'draft', label: 'Utkast', }, { - group: '1', + groupId: '1', value: 'sent', label: 'Sendt', }, { - group: '2', + groupId: '2', value: 'in-progress', label: 'Under arbeid', }, { - group: '2', + groupId: '2', value: 'requires-attention', label: 'Krever handling', }, { - group: '2', + groupId: '2', value: 'completed', label: 'Avsluttet', }, diff --git a/lib/components/Toolbar/Toolbar.tsx b/lib/components/Toolbar/Toolbar.tsx index 0543ee8..41b9c0a 100644 --- a/lib/components/Toolbar/Toolbar.tsx +++ b/lib/components/Toolbar/Toolbar.tsx @@ -1,5 +1,6 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useRootContext } from '../RootProvider'; import { ToolbarAdd } from './ToolbarAdd'; import { ToolbarBase } from './ToolbarBase'; import { ToolbarFilter, type ToolbarFilterProps } from './ToolbarFilter.tsx'; @@ -32,35 +33,52 @@ export const Toolbar = ({ menu, getFilterLabel, }: ToolbarProps) => { + const { currentId, openId, closeAll } = useRootContext(); const [expandedItem, setExpandedItem] = useState(null); const [localFilterState, setLocalFilterState] = useState>( filterState ?? {}, ); const changeFilterState = typeof onFilterStateChange === 'function' ? onFilterStateChange : setLocalFilterState; const applicableFilterState = filterState || localFilterState; - const [hiddenFilterNames, setHiddenFilterNames] = useState( + + const [visibleFilterNames, setVisibleFilterNames] = useState( filters - ?.filter((item) => item.removable && typeof applicableFilterState[item.name] === 'undefined') + ?.filter((item) => !(item.removable && typeof applicableFilterState[item.name] === 'undefined')) .map((item) => item.name) ?? [], ); + const hiddenFilterNames = filters?.filter((item) => !visibleFilterNames.includes(item.name)); + const visibleFilters = useMemo( - () => filters.filter((item) => !hiddenFilterNames.includes(item.name)), - [filters, hiddenFilterNames], + () => + visibleFilterNames + .map((name) => { + return filters.find((item) => item.name === name); + }) + .filter((item) => typeof item !== 'undefined'), + [filters, visibleFilterNames], ); + const hiddenFilters = useMemo( - () => filters.filter((item) => hiddenFilterNames.includes(item.name)), + () => filters.filter((item) => hiddenFilterNames.includes(item)), [filters, hiddenFilterNames], ); const onToggle = (type: ExpandedItemType, name: string) => { if (expandedItem?.name === name && expandedItem.type === type) { + closeAll(); setExpandedItem(null); } else { + openId('toolbar'); setExpandedItem({ name, type }); } }; + const onClose = () => { + setExpandedItem(null); + closeAll(); + }; + const onFilterChange = (name: string, value: ToolbarFilterProps['value'], optionType: ToolbarOptionType) => { if (optionType === 'radio') { changeFilterState({ @@ -81,7 +99,7 @@ export const Toolbar = ({ }; const onFilterRemove = (name: string) => { - setHiddenFilterNames((prevState) => [...prevState, name]); + setVisibleFilterNames((prevState) => prevState.filter((prevName) => prevName !== name)); changeFilterState({ ...applicableFilterState, [name]: undefined, @@ -89,19 +107,19 @@ export const Toolbar = ({ }; const onFilterAdd = (name: string) => { + setVisibleFilterNames((prevState) => [...prevState, name]); onToggle('filter', name); - setHiddenFilterNames((prevState) => prevState.filter((prevName) => prevName !== name)); }; return ( - setExpandedItem(null)}> + {menu && onToggle('menu', '')} expanded={expandedItem?.type === 'menu'} {...menu} />} {visibleFilters.map((item) => { return ( onToggle('filter', item.name)} - expanded={item.name === expandedItem?.name && expandedItem?.type === 'filter'} + expanded={currentId === 'toolbar' && item.name === expandedItem?.name && expandedItem?.type === 'filter'} onRemove={() => { onFilterRemove(item.name); }} @@ -120,7 +138,7 @@ export const Toolbar = ({ })} {hiddenFilters?.length > 0 && ( onToggle('add-filter', '')} items={hiddenFilters.map((item) => ({ id: item.name, diff --git a/lib/components/Toolbar/ToolbarAdd.tsx b/lib/components/Toolbar/ToolbarAdd.tsx index ddf731b..650ce93 100644 --- a/lib/components/Toolbar/ToolbarAdd.tsx +++ b/lib/components/Toolbar/ToolbarAdd.tsx @@ -1,7 +1,8 @@ import type { MouseEventHandler } from 'react'; +import { DrawerOrDropdown } from '../'; import { Menu, type MenuItemProps } from '../Menu'; import { ToolbarButton } from './ToolbarButton'; -import styles from './toolbar.module.css'; +import styles from './toolbarAdd.module.css'; export interface ToolbarAddProps { items: MenuItemProps[]; @@ -13,13 +14,13 @@ export interface ToolbarAddProps { export const ToolbarAdd = ({ expanded = false, onToggle, label = 'Legg til', items }: ToolbarAddProps) => { return ( -
    - +
    + {label} -
    + -
    +
    ); }; diff --git a/lib/components/Toolbar/ToolbarBase.tsx b/lib/components/Toolbar/ToolbarBase.tsx index 22bd610..00b7276 100644 --- a/lib/components/Toolbar/ToolbarBase.tsx +++ b/lib/components/Toolbar/ToolbarBase.tsx @@ -1,27 +1,12 @@ 'use client'; -import { type ReactNode, useRef } from 'react'; -import { useClickOutside } from '../Menu/useClickOutside.ts'; -import { useEscapeKey } from '../Menu/useEscapeKey.ts'; -import styles from './toolbar.module.css'; +import type { ReactNode } from 'react'; +import styles from './toolbarBase.module.css'; export interface ToolbarBaseProps { children?: ReactNode; + open?: boolean; onClose?: () => void; } -export const ToolbarBase = ({ children, onClose }: ToolbarBaseProps) => { - const ref = useRef(null); - - useClickOutside(ref, () => { - onClose?.(); - }); - - useEscapeKey(() => { - onClose?.(); - }); - - return ( -
    - {children} -
    - ); +export const ToolbarBase = ({ children }: ToolbarBaseProps) => { + return
    {children}
    ; }; diff --git a/lib/components/Toolbar/ToolbarButton.tsx b/lib/components/Toolbar/ToolbarButton.tsx index 810a324..f700e23 100644 --- a/lib/components/Toolbar/ToolbarButton.tsx +++ b/lib/components/Toolbar/ToolbarButton.tsx @@ -28,7 +28,7 @@ export const ToolbarButton = ({ if (removable) { return ( { }; export const ToolbarFilter = ({ - expanded, + expanded = false, removable, label, name, @@ -51,9 +52,8 @@ export const ToolbarFilter = ({ const valueLabel = getSelectedLabel?.(name, value) ?? defaultGetSelectedLabel(name, value); return ( -
    +
    0 : typeof value !== 'undefined'} @@ -62,9 +62,14 @@ export const ToolbarFilter = ({ > {valueLabel || label} -
    + -
    +
    ); }; diff --git a/lib/components/Toolbar/ToolbarMenu.tsx b/lib/components/Toolbar/ToolbarMenu.tsx index ca0a31a..de35769 100644 --- a/lib/components/Toolbar/ToolbarMenu.tsx +++ b/lib/components/Toolbar/ToolbarMenu.tsx @@ -1,14 +1,15 @@ import type { MouseEventHandler } from 'react'; -import { Menu, type MenuGroups, type MenuItemProps, type MenuSearchProps } from '../Menu'; +import { DrawerOrDropdown } from '../'; +import { Menu, type MenuItemGroups, type MenuItemProps, type MenuSearchProps } from '../Menu'; import { ToolbarButton } from './ToolbarButton'; -import styles from './toolbar.module.css'; +import styles from './toolbarMenu.module.css'; export interface ToolbarMenuProps { onToggle?: MouseEventHandler; label: string; value: string | number; items: MenuItemProps[]; - groups?: MenuGroups; + groups?: MenuItemGroups; search?: MenuSearchProps; expanded?: boolean; className?: string; @@ -16,13 +17,13 @@ export interface ToolbarMenuProps { export const ToolbarMenu = ({ expanded = false, onToggle, label, value, groups, search, items }: ToolbarMenuProps) => { return ( -
    - +
    + {label} -
    + -
    +
    ); }; diff --git a/lib/components/Toolbar/ToolbarOptions.stories.ts b/lib/components/Toolbar/ToolbarOptions.stories.ts index ee94870..56c16c1 100644 --- a/lib/components/Toolbar/ToolbarOptions.stories.ts +++ b/lib/components/Toolbar/ToolbarOptions.stories.ts @@ -74,31 +74,31 @@ export const RadioCheckbox: Story = { }, options: [ { - group: 'a', + groupId: 'a', name: 'animal', label: 'Katt', value: 'cat', checked: true, }, { - group: 'a', + groupId: 'a', name: 'animal', label: 'Mus', value: 'mouse', }, { - group: 'a', + groupId: 'a', name: 'animal', label: 'Veggdyr', value: 'spider', }, { - group: 'b', + groupId: 'b', label: 'Husarrest', value: 'digdir', }, { - group: 'b', + groupId: 'b', checked: true, label: 'Piskeslag', value: 'helse', diff --git a/lib/components/Toolbar/ToolbarOptions.tsx b/lib/components/Toolbar/ToolbarOptions.tsx index 185729d..4c33cb7 100644 --- a/lib/components/Toolbar/ToolbarOptions.tsx +++ b/lib/components/Toolbar/ToolbarOptions.tsx @@ -1,8 +1,9 @@ -import type { ChangeEventHandler } from 'react'; +import { type ChangeEventHandler, Fragment } from 'react'; import { MenuBase, - MenuGroup, MenuHeader, + MenuList, + MenuListItem, MenuOption, type MenuOptionProps, MenuSearch, @@ -13,6 +14,7 @@ export type ToolbarOptionType = 'checkbox' | 'radio'; export interface OptionGroup { title?: string; + divider?: boolean; optionType?: ToolbarOptionType; } @@ -24,7 +26,7 @@ export interface ToolbarOptionsProps { optionGroups?: { [key: string]: OptionGroup }; } -export const ToolbarOptions = ({ search, optionGroups, options, onChange, optionType }: ToolbarOptionsProps) => { +export const ToolbarOptions = ({ search, optionGroups = {}, options, onChange, optionType }: ToolbarOptionsProps) => { const sections = options.reduce( (acc, option) => { const group = option.group || ''; @@ -38,24 +40,35 @@ export const ToolbarOptions = ({ search, optionGroups, options, onChange, option return ( {search && } - {Object.keys(sections)?.map((key) => { - const headerTitle = optionGroups?.[key]?.title; - return ( - - {headerTitle && } - {sections[key]?.map((item) => ( - - ))} - - ); - })} + + {Object.keys(sections)?.map((key, groupIndex) => { + const groupProps = optionGroups[key] || {}; + const { title, divider = true } = groupProps; + return ( + + {groupIndex && divider ? : ''} + + {title && ( + + + + )} + {sections[key]?.map((item) => ( + + + + ))} + + ); + })} + ); }; diff --git a/lib/components/Toolbar/toolbar.module.css b/lib/components/Toolbar/toolbar.module.css deleted file mode 100644 index 5b973fb..0000000 --- a/lib/components/Toolbar/toolbar.module.css +++ /dev/null @@ -1,43 +0,0 @@ -.toolbar { - display: flex; - align-items: center; - flex-wrap: wrap; - width: 100%; - gap: 0.5rem; - padding: 0 0.5rem; - margin: 1.125rem 0; -} - -@media (min-width: 1024px) { - .toolbar { - padding: 0; - } -} - -/* TIDI; move styles below */ - -.button { - display: inline-block; -} - -.remove-button > button:hover + * + * { - text-decoration: line-through; -} - -.dropdown { - display: none; -} - -.dropdown[aria-expanded="true"] { - display: block; - position: absolute; - z-index: 2; -} - -.dropdown { - margin-top: 0.5rem; - padding: 0 0.5rem; - background-color: var(--neutral-background-default); - border-radius: 2px; - box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.15), 0 1px 2px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.1); -} diff --git a/lib/components/Toolbar/toolbarAdd.module.css b/lib/components/Toolbar/toolbarAdd.module.css new file mode 100644 index 0000000..93acd80 --- /dev/null +++ b/lib/components/Toolbar/toolbarAdd.module.css @@ -0,0 +1,7 @@ +.menu { + position: relative; +} + +.menu[aria-expanded="true"] { + z-index: 2; +} diff --git a/lib/components/Toolbar/toolbarBase.module.css b/lib/components/Toolbar/toolbarBase.module.css new file mode 100644 index 0000000..d02d171 --- /dev/null +++ b/lib/components/Toolbar/toolbarBase.module.css @@ -0,0 +1,19 @@ +.toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + width: 100%; + gap: 0.5rem; + /* padding: 0 1rem; */ +} + +@media (min-width: 1024px) { + .toolbar { + padding: 0; + /* margin: 1.125rem 0; */ + } + + .toolbar > * { + width: auto; + } +} diff --git a/lib/components/Toolbar/toolbarButton.module.css b/lib/components/Toolbar/toolbarButton.module.css index ac7f449..e84ced9 100644 --- a/lib/components/Toolbar/toolbarButton.module.css +++ b/lib/components/Toolbar/toolbarButton.module.css @@ -1,3 +1,3 @@ -.remove > button:hover + * + * { +.removeButton > button:hover + * + * { text-decoration: line-through; } diff --git a/lib/components/Toolbar/toolbarFilter.module.css b/lib/components/Toolbar/toolbarFilter.module.css new file mode 100644 index 0000000..cda542d --- /dev/null +++ b/lib/components/Toolbar/toolbarFilter.module.css @@ -0,0 +1,25 @@ +.filter { + position: relative; +} + +.filter[aria-expanded="true"] { + z-index: 2; +} + +.dropdown[aria-expanded="true"] { + display: none; +} + +.drawer[aria-expanded="true"] { + display: block; +} + +@media (min-width: 1024px) { + .drawer[aria-expanded="true"] { + display: none; + } + + .dropdown[aria-expanded="true"] { + display: block; + } +} diff --git a/lib/components/Toolbar/toolbarMenu.module.css b/lib/components/Toolbar/toolbarMenu.module.css new file mode 100644 index 0000000..93acd80 --- /dev/null +++ b/lib/components/Toolbar/toolbarMenu.module.css @@ -0,0 +1,7 @@ +.menu { + position: relative; +} + +.menu[aria-expanded="true"] { + z-index: 2; +} diff --git a/lib/components/Typography/Heading.tsx b/lib/components/Typography/Heading.tsx new file mode 100644 index 0000000..41d3313 --- /dev/null +++ b/lib/components/Typography/Heading.tsx @@ -0,0 +1,23 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import styles from './heading.module.css'; + +export type HeadingSize = 'sm' | 'md' | 'lg'; +export type HeadingComponent = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export interface HeadingProps { + as?: HeadingComponent; + size?: HeadingSize; + className?: string; + children?: ReactNode; +} + +export const Heading = ({ as = 'h2', size = 'md', className, children }: HeadingProps) => { + const H = as; + + return ( + + {children} + + ); +}; diff --git a/lib/components/Typography/Typography.tsx b/lib/components/Typography/Typography.tsx index 61b5fc4..036a820 100644 --- a/lib/components/Typography/Typography.tsx +++ b/lib/components/Typography/Typography.tsx @@ -1,21 +1,24 @@ import cx from 'classnames'; -import type { ReactNode } from 'react'; +import type { ElementType, ReactNode } from 'react'; import type { LayoutTheme } from '../Layout'; import styles from './typography.module.css'; -export type TypographySize = 'md' | 'lg' | 'xl'; +export type TypographySize = 'sm' | 'md' | 'lg'; export interface TypographyProps { + as?: ElementType; size?: TypographySize; theme?: LayoutTheme; className?: string; children?: ReactNode; } -export const Typography = ({ size = 'md', theme, className, children }: TypographyProps) => { +export const Typography = ({ as = 'div', size = 'md', theme, className, children }: TypographyProps) => { + const Component = as; + return ( -
    + {children} -
    + ); }; diff --git a/lib/components/Typography/heading.module.css b/lib/components/Typography/heading.module.css new file mode 100644 index 0000000..e1eaf73 --- /dev/null +++ b/lib/components/Typography/heading.module.css @@ -0,0 +1,21 @@ +.heading { + margin: 0; +} + +.heading[data-size="sm"] { + font-size: 1.125rem; + font-weight: 500; + line-height: 1.25; +} + +.heading[data-size="md"] { + font-size: 1.25rem; + font-weight: 500; + line-height: 1.5rem; +} + +.heading[data-size="lg"] { + font-size: 1.5rem; + font-weight: 500; + line-height: 1.5rem; +} diff --git a/lib/components/Typography/index.ts b/lib/components/Typography/index.ts index d64ebba..d0b1e6c 100644 --- a/lib/components/Typography/index.ts +++ b/lib/components/Typography/index.ts @@ -1 +1,2 @@ export * from './Typography'; +export * from './Heading'; diff --git a/lib/components/Typography/typography.module.css b/lib/components/Typography/typography.module.css index da03c48..955ee9d 100644 --- a/lib/components/Typography/typography.module.css +++ b/lib/components/Typography/typography.module.css @@ -4,6 +4,14 @@ line-height: 1.5; } +.item[data-size="sm"] { + font-size: 0.875rem; +} + +.item[data-size="md"] { + font-size: 1rem; +} + .typography[data-size="lg"] { font-size: 1.125rem; } diff --git a/lib/components/index.ts b/lib/components/index.ts index 82e78be..6acf3d7 100644 --- a/lib/components/index.ts +++ b/lib/components/index.ts @@ -15,6 +15,8 @@ export * from './Layout'; export * from './List'; export * from './Menu'; export * from './Meta'; +export * from './Searchbar'; export * from './Snackbar'; export * from './Toolbar'; +export * from './Page'; export * from './Typography'; diff --git a/lib/hooks/index.ts b/lib/hooks/index.ts new file mode 100644 index 0000000..71bb423 --- /dev/null +++ b/lib/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useClickOutside'; +export * from './useEscapeKey'; +export * from './useMenu'; diff --git a/lib/components/Menu/useClickOutside.ts b/lib/hooks/useClickOutside.ts similarity index 100% rename from lib/components/Menu/useClickOutside.ts rename to lib/hooks/useClickOutside.ts diff --git a/lib/components/Menu/useEscapeKey.ts b/lib/hooks/useEscapeKey.ts similarity index 79% rename from lib/components/Menu/useEscapeKey.ts rename to lib/hooks/useEscapeKey.ts index e6bf885..676fca2 100644 --- a/lib/components/Menu/useEscapeKey.ts +++ b/lib/hooks/useEscapeKey.ts @@ -1,11 +1,11 @@ 'use client'; import { useEffect } from 'react'; -export const useEscapeKey = (onEscape: () => void): void => { +export const useEscapeKey = (onEscape?: () => void): void => { useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onEscape(); + onEscape?.(); } }; document.addEventListener('keydown', handleEscape); diff --git a/lib/hooks/useMenu.tsx b/lib/hooks/useMenu.tsx new file mode 100644 index 0000000..a32ec68 --- /dev/null +++ b/lib/hooks/useMenu.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export interface UseMenuItemProps { + menuIndex: number; + active?: boolean; + props: T; +} + +export interface UseMenuGroup { + items: UseMenuItemProps[]; + props: Record; +} + +export interface UseMenuOutput { + menu: UseMenuGroup[]; + activeIndex: number; + setActiveIndex: (activeIndex: number) => void; +} + +export interface UseMenuInput { + items: T[]; + groups: Record; + groupByKey?: keyof T; + keyboardEvents?: boolean; +} + +export const useMenu = ({ + items, + groups, + groupByKey, + keyboardEvents = false, +}: UseMenuInput): UseMenuOutput => { + const [activeIndex, setActiveIndex] = useState(-1); + + const menu = useMemo(() => { + const flatItems: T[] = []; + const grouped = items.reduce( + (acc, item) => { + const key = groupByKey && item[groupByKey] ? (item[groupByKey] as string) : 'ungrouped'; + acc[key] = acc[key] || []; + acc[key].push(item); + flatItems.push(item); + return acc; + }, + {} as Record, + ); + + return Object.entries(grouped).map(([key, groupItems]) => ({ + items: groupItems.map((item) => ({ + menuIndex: flatItems.indexOf(item), + active: activeIndex === flatItems.indexOf(item), + props: item, + })), + props: groups[key] || {}, + })); + }, [items, groupByKey, activeIndex, groups]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + setActiveIndex((prevIndex) => (prevIndex + 1) % items.length); + } else if (event.key === 'ArrowUp') { + setActiveIndex((prevIndex) => (prevIndex - 1 + items.length) % items.length); + } + }, + [items.length], + ); + + useEffect(() => { + if (keyboardEvents) { + setActiveIndex(0); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + }, [handleKeyDown, keyboardEvents]); + + return { menu, activeIndex, setActiveIndex }; +}; diff --git a/lib/index.ts b/lib/index.ts index 07635cb..f76fd6f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1 +1,2 @@ export * from './components'; +export * from './hooks'; diff --git a/lib/stories/Color/MenuItem.stories.tsx b/lib/stories/Color/MenuItem.stories.tsx new file mode 100644 index 0000000..15e8e47 --- /dev/null +++ b/lib/stories/Color/MenuItem.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LayoutBase, type LayoutTheme, MenuBase, MenuItem, type MenuItemColor, MetaItem } from '../../components'; + +const meta = { + title: 'Demo/Color/MenuItem', + component: MenuItem, + tags: ['autodocs'], + parameters: {}, + args: { + id: 'inbox', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ThemesAndColors = () => { + const themes: LayoutTheme[] = ['global', 'neutral', 'company', 'person', 'global-dark']; + const colors: MenuItemColor[] = ['neutral', 'subtle', 'strong', 'company', 'person']; + + return ( +
    + {themes.map((theme) => { + return ( +
    + + + {colors.map((color) => { + return ( +
    + + {theme + '/' + color} +
    + ); + })} +
    +
    +
    + ); + })} +
    + ); +}; diff --git a/lib/stories/Color/Swatches.stories.tsx b/lib/stories/Color/Swatches.stories.tsx new file mode 100644 index 0000000..81f5413 --- /dev/null +++ b/lib/stories/Color/Swatches.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Swatches } from './Swatches'; + +const meta = { + title: 'Demo/Color/Swatches', + component: Swatches, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/stories/Color/Swatches.tsx b/lib/stories/Color/Swatches.tsx new file mode 100644 index 0000000..524903e --- /dev/null +++ b/lib/stories/Color/Swatches.tsx @@ -0,0 +1,42 @@ +import type { LayoutTheme } from '../../components'; +import colors from './colors.json'; +import styles from './swatches.module.css'; + +const themes = { + neutral: 'Neutral', + person: 'Person', + company: 'Company', +}; + +export interface SwatchesProps { + theme: LayoutTheme; +} + +export const SwatchesList = ({ theme }: SwatchesProps) => { + return ( +
    + {Object.keys(colors).map((key) => { + const style = { + backgroundColor: 'var(--theme-' + key + ')', + }; + + return ( +
    +
    + {key} +
    + ); + })} +
    + ); +}; + +export const Swatches = ({ theme }: SwatchesProps) => { + return ( +
    + {Object.keys(themes).map((key) => { + return ; + })} +
    + ); +}; diff --git a/lib/stories/Color/colors.json b/lib/stories/Color/colors.json new file mode 100644 index 0000000..18eab8e --- /dev/null +++ b/lib/stories/Color/colors.json @@ -0,0 +1,62 @@ +{ + "background-default": { + "$type": "color", + "$value": "{color..1}" + }, + "background-subtle": { + "$type": "color", + "$value": "{color..2}" + }, + "surface-default": { + "$type": "color", + "$value": "{color..3}" + }, + "surface-hover": { + "$type": "color", + "$value": "{color..4}" + }, + "surface-active": { + "$type": "color", + "$value": "{color..5}" + }, + "border-subtle": { + "$type": "color", + "$value": "{color..6}" + }, + "border-default": { + "$type": "color", + "$value": "{color..7}" + }, + "border-strong": { + "$type": "color", + "$value": "{color..8}" + }, + "base-default": { + "$type": "color", + "$value": "{color..9}" + }, + "base-hover": { + "$type": "color", + "$value": "{color..10}" + }, + "base-active": { + "$type": "color", + "$value": "{color..11}" + }, + "text-subtle": { + "$type": "color", + "$value": "{color..12}" + }, + "text-default": { + "$type": "color", + "$value": "{color..13}" + }, + "contrast-default": { + "$type": "color", + "$value": "{color..contrast-1}" + }, + "contrast-subtle": { + "$type": "color", + "$value": "{color..contrast-2}" + } +} diff --git a/lib/stories/Color/swatches.module.css b/lib/stories/Color/swatches.module.css new file mode 100644 index 0000000..42b1cf9 --- /dev/null +++ b/lib/stories/Color/swatches.module.css @@ -0,0 +1,14 @@ +.swatch { + width: 4rem; + height: 4rem; + border: 1px solid; +} + +.row { + display: flex; +} + +.col { + display: flex; + flex-direction: column; +} diff --git a/lib/stories/Inbox/BookmarksPage.tsx b/lib/stories/Inbox/BookmarksPage.tsx new file mode 100644 index 0000000..42930fc --- /dev/null +++ b/lib/stories/Inbox/BookmarksPage.tsx @@ -0,0 +1,52 @@ +'use client'; +import { + Heading, + ListBase, + ListItem, + MetaItem, + PageBase, + SectionBase, + SectionFooter, + SectionHeader, + Typography, +} from '../../components'; +import { InboxToolbar } from './InboxToolbar'; + +export function BookmarksPage() { + const bookmarks = [ + { + id: '1', + title: '123', + }, + { + id: '2', + title: '123', + }, + { + id: '3', + title: '123', + }, + ]; + + const count = bookmarks.length; + const title = (count > 1 && count + ' lagrede søk') || (count && '1 lagret søk') || 'Ingen lagrede søk'; + + return ( + + + + + {title} + + + {bookmarks.map((item) => ( + + ))} + + + Sist oppdatert: 10 minutter siden. + + + + ); +} diff --git a/lib/stories/Inbox/DialogPage.tsx b/lib/stories/Inbox/DialogPage.tsx new file mode 100644 index 0000000..8d83b40 --- /dev/null +++ b/lib/stories/Inbox/DialogPage.tsx @@ -0,0 +1,15 @@ +import { ActionMenu, Dialog, PageBase } from '../../components'; +import type { DialogProps } from '../../components'; + +interface DialogPageProps { + dialog: DialogProps; +} + +export function DialogPage({ dialog }: DialogPageProps) { + return ( + + + + + ); +} diff --git a/lib/stories/Inbox/Inbox.stories.tsx b/lib/stories/Inbox/Inbox.stories.tsx new file mode 100644 index 0000000..8e3150d --- /dev/null +++ b/lib/stories/Inbox/Inbox.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { accounts, dialogs } from './'; +import { Inbox } from './Inbox'; + +const meta = { + title: 'Demo/Inbox/Inbox', + component: Inbox, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + args: { + inboxId: 'inbox', + accounts, + accountId: 'a0', + dialogs, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const BulkMode: Story = { + args: { + selectedIds: ['d1'], + }, +}; + +export const DialogOpen: Story = { + args: { + dialogId: 'd1', + }, +}; + +export const SavedSearches: Story = { + args: { + inboxId: 'bookmarks', + }, +}; + +export const ProfilePage: Story = { + args: { + inboxId: 'profile', + }, +}; + +export const SettingsPage: Story = { + args: { + inboxId: 'settings', + }, +}; diff --git a/lib/stories/Inbox/Inbox.tsx b/lib/stories/Inbox/Inbox.tsx new file mode 100644 index 0000000..4d284c4 --- /dev/null +++ b/lib/stories/Inbox/Inbox.tsx @@ -0,0 +1,12 @@ +import { InboxLayout, InboxProvider, InboxSection } from './'; +import type { InboxDefaultValue } from './'; + +export const Inbox = ({ accounts, accountId, inboxId, dialogId, dialogs, selectedIds }: InboxDefaultValue) => { + return ( + + + + + + ); +}; diff --git a/lib/stories/Inbox/InboxLayout.tsx b/lib/stories/Inbox/InboxLayout.tsx new file mode 100644 index 0000000..dc657f3 --- /dev/null +++ b/lib/stories/Inbox/InboxLayout.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { ActionFooter, ActionHeader, ActionMenu, Layout } from '../../components'; +import { actionMenu, footer, header, menu } from './'; +import { useInbox } from './'; + +export interface InboxLayoutProps { + children: ReactNode; +} + +export const InboxLayout = ({ children }: InboxLayoutProps) => { + const { theme, itemsCount, selectedCount, inboxId, accounts, dialogId, onInboxId, onUnselectAll } = useInbox(); + + const selectedTitle = `${selectedCount} av ${itemsCount} valgt`; + const bulkMode = selectedCount > 0; + + return ( + { + return { + ...item, + selected: inboxId === item.id, + onClick: () => onInboxId(item.id), + }; + }), + }, + }} + > + + ); +}; diff --git a/lib/stories/Inbox/InboxPage.tsx b/lib/stories/Inbox/InboxPage.tsx new file mode 100644 index 0000000..c782b62 --- /dev/null +++ b/lib/stories/Inbox/InboxPage.tsx @@ -0,0 +1,50 @@ +'use client'; +import { DialogList, PageBase } from '../../components'; +import { useInbox } from './'; +import { InboxToolbar } from './InboxToolbar'; + +export function InboxPage() { + const { inboxId, onDialogId, items = [], onSelectId, selectedCount } = useInbox(); + const selected = items?.filter((item) => item.selected); + + const inboxItems = items + ?.filter((item) => { + const inboxStatus = item?.status.value; + + if (inboxId === 'drafts' && inboxStatus !== 'draft') { + return false; + } + if (inboxId === 'sent' && inboxStatus !== 'sent') { + return false; + } + if (inboxId === 'inbox' && (item.status.value === 'sent' || item.status.value === 'draft')) { + return false; + } + + return true; + }) + .map((item) => { + return { + ...item, + groupId: 'g1', + onClick: selectedCount > 0 ? () => onSelectId(item.id) : () => onDialogId(item.id), + select: { + checked: item?.selected, + onChange: () => onSelectId(item.id), + }, + }; + }); + + const inboxGroups = { + g1: { + title: 'Gruppe', + }, + }; + + return ( + + {!selected.length > 0 && } + + + ); +} diff --git a/lib/stories/Inbox/InboxProvider.tsx b/lib/stories/Inbox/InboxProvider.tsx new file mode 100644 index 0000000..6809218 --- /dev/null +++ b/lib/stories/Inbox/InboxProvider.tsx @@ -0,0 +1,136 @@ +import { type ReactNode, createContext, useContext, useState } from 'react'; +import type { DialogProps } from '../../components'; + +interface AccountProps { + id?: string; + type?: string; + name?: string; + selected?: boolean; +} + +export interface InboxDefaultValue { + accountId?: string | null; + accounts: AccountProps[]; + inboxId?: string | null; + dialogId?: string | null; + dialogs?: DialogProps[]; + selectedIds?: string[]; +} + +interface InboxProviderProps { + children: ReactNode; + defaultValue?: InboxDefaultValue; +} + +interface InboxContextProvider { + theme?: string; + account?: AccountProps; + accountId?: string | null; + accounts?: AccountProps[]; + inboxId?: string | null; + dialogId?: string | null; + onAccountId?: (id: string) => void; + onInboxId?: (id: string) => void; + onDialogId?: (id: string) => void; + items?: (DialogProps & { selected: boolean })[]; + itemsCount: number; + onSelectId?: (id: string) => void; + onUnselectAll?: () => void; + selectedCount: number; +} + +const InboxContext = createContext({ + theme: '', + account: {}, + accountId: null, + accounts: [], + inboxId: null, + dialogId: null, + items: [], + itemsCount: 0, + selectedCount: 0, +}); + +export const InboxProvider = ({ defaultValue, children }: InboxProviderProps) => { + const [accountId, setAccountId] = useState(defaultValue?.accountId ?? null); + const [accounts, setAccounts] = useState(defaultValue?.accounts ?? []); + const [inboxId, setInboxId] = useState(defaultValue?.inboxId ?? null); + const [dialogId, setDialogId] = useState(defaultValue?.dialogId ?? null); + const [selectedIds, setSelectedIds] = useState(defaultValue?.selectedIds || []); + + const onAccountId = (id: string) => { + setAccountId(id); + setAccounts((prevState) => { + return prevState.map((item) => { + return { + ...item, + selected: id === item.id, + }; + }); + }); + }; + + const account = accounts?.find((item) => item.id === accountId); + const theme = account?.type; + + const onInboxId = (id: string) => { + setInboxId(id); + setDialogId(null); + }; + + const onDialogId = (id: string) => { + setDialogId((prevState) => (prevState === id ? null : id)); + }; + + const onSelectId = (id: string) => { + setSelectedIds((prevState) => { + if (prevState.includes(id)) { + return prevState.filter((prevId) => prevId !== id); + } + return [...prevState, id]; + }); + }; + + const onUnselectAll = () => { + setSelectedIds([]); + }; + + const items = defaultValue?.dialogs?.map((item) => ({ + ...item, + selected: selectedIds.includes(item.id), + })); + + const itemsCount = items?.length || 0; + const selectedCount = selectedIds.length; + + return ( + + {children} + + ); +}; + +export const useInbox = () => { + return useContext(InboxContext); +}; + +export const useProfile = () => { + return useContext(InboxContext); +}; diff --git a/lib/stories/Inbox/InboxSection.tsx b/lib/stories/Inbox/InboxSection.tsx new file mode 100644 index 0000000..bd6d701 --- /dev/null +++ b/lib/stories/Inbox/InboxSection.tsx @@ -0,0 +1,39 @@ +'use client'; +import { actionMenu, useInbox } from './'; +import { BookmarksPage, DialogPage, InboxPage, ProfilePage, SettingsPage } from './'; + +export function InboxSection() { + const { inboxId, dialogId, onDialogId, items = [] } = useInbox(); + + if (inboxId === 'bookmarks') { + return ; + } + + if (inboxId === 'settings') { + return ; + } + + if (inboxId === 'profile') { + return ; + } + + if (dialogId) { + const dialog = items.find((item) => item.id === dialogId); + return ( + onDialogId(dialogId), + }, + }} + /> + ); + } + + return ; +} diff --git a/lib/stories/Inbox/InboxToolbar.tsx b/lib/stories/Inbox/InboxToolbar.tsx new file mode 100644 index 0000000..5944d39 --- /dev/null +++ b/lib/stories/Inbox/InboxToolbar.tsx @@ -0,0 +1,94 @@ +'use client'; +import { Toolbar } from '../../components'; +import { useInbox } from './'; + +export function InboxToolbar({ items }) { + const { accounts = [], accountId, onAccountId } = useInbox(); + + const senderOptions = {}; + + items?.map((item) => { + const { sender } = item; + + if (!senderOptions[sender?.name]) { + senderOptions[sender?.name] = { + label: sender.name, + value: sender.name, + count: 0, + }; + } + + senderOptions[sender?.name].count++; + }); + + const currentAccount = accounts?.find((item) => item.id === accountId); + + const accountMenu = { + label: currentAccount?.name, + value: currentAccount?.id, + items: accounts.map((item) => { + return { + group: item.type, + title: item?.name, + avatar: item, + selected: item.selected, + onClick: () => onAccountId(item.id), + }; + }), + }; + + const filters = + items && + [ + { + name: 'status', + label: 'Velg status', + optionType: 'checkbox', + options: [ + { + groupId: '1', + value: 'draft', + label: 'Utkast', + }, + { + groupId: '1', + value: 'sent', + label: 'Sendt', + }, + { + groupId: '2', + value: 'in-progress', + label: 'Under arbeid', + }, + { + groupId: '2', + value: 'requires-attention', + label: 'Krever handling', + }, + { + groupId: '2', + value: 'completed', + label: 'Avsluttet', + }, + ], + }, + { + name: 'sender', + label: 'Velg avsender', + optionType: 'checkbox', + options: Object.values(senderOptions)?.map((item) => { + return { + ...item, + badge: { label: item?.count.toString() || '2' }, + }; + }), + }, + ].map((item) => { + return { + ...item, + removable: true, + }; + }); + + return ; +} diff --git a/lib/stories/Inbox/ProfilePage.tsx b/lib/stories/Inbox/ProfilePage.tsx new file mode 100644 index 0000000..4783ed8 --- /dev/null +++ b/lib/stories/Inbox/ProfilePage.tsx @@ -0,0 +1,35 @@ +'use client'; +import { + Avatar, + Heading, + ListBase, + ListItem, + MetaItem, + PageBase, + PageHeader, + SectionBase, + SectionFooter, + SectionHeader, + Typography, +} from '../../components'; +import { useProfile } from './'; + +export function ProfilePage() { + const { account } = useProfile(); + + return ( + + + Lorem ipsum dolor sit amet + + + ); +} diff --git a/lib/stories/Inbox/SettingsPage.tsx b/lib/stories/Inbox/SettingsPage.tsx new file mode 100644 index 0000000..ea3f461 --- /dev/null +++ b/lib/stories/Inbox/SettingsPage.tsx @@ -0,0 +1,19 @@ +'use client'; +import { + Heading, + ListBase, + ListItem, + MetaItem, + PageBase, + SectionBase, + SectionFooter, + SectionHeader, + Typography, +} from '../../components'; +import { useProfile } from './'; + +export function SettingsPage() { + const { account } = useProfile; + + return {JSON.stringify(account)}; +} diff --git a/lib/stories/Inbox/accounts/accounts.ts b/lib/stories/Inbox/accounts/accounts.ts new file mode 100644 index 0000000..c4d621d --- /dev/null +++ b/lib/stories/Inbox/accounts/accounts.ts @@ -0,0 +1,24 @@ +export const accounts = [ + { + type: 'person', + name: 'Aurora Mikalsen', + selected: true, + }, + { + type: 'company', + name: 'Auroras blomster', + }, + { + type: 'company', + name: 'Aurora bier', + }, + { + type: 'company', + name: 'Auroras bier og blomster', + }, +].map((item, index) => { + return { + ...item, + id: 'a' + index, + }; +}); diff --git a/lib/stories/Inbox/accounts/index.ts b/lib/stories/Inbox/accounts/index.ts new file mode 100644 index 0000000..9a925f3 --- /dev/null +++ b/lib/stories/Inbox/accounts/index.ts @@ -0,0 +1 @@ +export * from './accounts'; diff --git a/lib/stories/Inbox/actionMenu.ts b/lib/stories/Inbox/actionMenu.ts new file mode 100644 index 0000000..a24d9f7 --- /dev/null +++ b/lib/stories/Inbox/actionMenu.ts @@ -0,0 +1,24 @@ +import type { MenuItemProps } from '../../components'; + +export const actionMenu: MenuItemProps[] = [ + { + id: '1', + icon: 'arrow-redo', + title: 'Del og gi tilgang', + }, + { + id: '2', + icon: 'eye', + title: 'Marker som lest', + }, + { + id: '3', + icon: 'archive', + title: 'Flytt til arkiv', + }, + { + id: '4', + icon: 'trash', + title: 'Flytt til papirkurv', + }, +]; diff --git a/lib/stories/Inbox/dialogs/brreg-completed.json b/lib/stories/Inbox/dialogs/brreg-completed.json new file mode 100644 index 0000000..4af80e8 --- /dev/null +++ b/lib/stories/Inbox/dialogs/brreg-completed.json @@ -0,0 +1,35 @@ +{ + "_createdAt": "2024-10-23T09:33:00Z", + "_id": "95f30463-b886-402d-b765-95d9eccf68c0", + "_rev": "n6M6ED8lI6VdyYqDQoyiME", + "_type": "dialog", + "_updatedAt": "2024-10-28T17:07:12Z", + "additionalInfo": "For at du skal kunne registrere enkeltpersonforetaket, er du nødt til å drive med næringsaktivitet. Dette er aktivitet som er egnet til å ha et visst omfang og varighet.", + "body": "## Nøkkelinformasjon\n- **Foretakets navn:** Olas Enkeltmannsforetak\n- **Organisasjonsnummer:** 999 999 999\n- **Dato registrert**: 14. mai 2024\n\n## Hva nå?\nTips og triks til deg som har startet enkeltpersonforetak:\n- Få en regnskapsfører.\n- Slik driver du selskapet ditt.\n- Bla bla bla.", + "history": [ + { + "_key": "334c343862da", + "_type": "historyItem", + "summary": "Skjema ble opprettet." + }, + { + "_key": "f5cc7d1053c9", + "_type": "historyItem", + "summary": "Bla bla bla." + } + ], + "recipient": { + "type": "person", + "name": "Rein Regnskapsfører", + "imageUrl": null + }, + "sender": { + "type": "company", + "name": "Brønnøysundregistrene", + "imageUrl": "https://cdn.sanity.io/images/z3it2oa7/production/3be5df0229c0b58c7ab59db2ade6208cc83954d2-300x300.png?w=96" + }, + "status": { "label": "Avsluttet", "value": "completed" }, + "summary": "Gratulerer! Ditt enkeltpersonforetak er godkjent og klart til bruk.", + "title": "Registrering av enkeltmannsforetak", + "updatedAt": "2024-10-28T17:07:12Z" +} diff --git a/lib/stories/Inbox/dialogs/brreg-draft.json b/lib/stories/Inbox/dialogs/brreg-draft.json new file mode 100644 index 0000000..82e10e5 --- /dev/null +++ b/lib/stories/Inbox/dialogs/brreg-draft.json @@ -0,0 +1,45 @@ +{ + "_createdAt": "2024-10-23T07:17:42Z", + "_id": "7cda3a28-2171-488c-8554-6b061b48e533", + "_rev": "n6M6ED8lI6VdyYqDQoylrM", + "_type": "dialog", + "_updatedAt": "2024-10-28T17:07:32Z", + "action": [ + { "priority": "primary", "label": "Til skjema" }, + { + "label": "Forkast utkast" + } + ], + "additionalInfo": "For at du skal kunne registrere enkeltpersonforetaket, er du nødt til å drive med næringsaktivitet. Dette er aktivitet som er egnet til å ha et visst omfang og varighet.", + "body": "## Oppsummering\n- **Foretakets navn:** Olas Enkeltmannsforetak\n- **Lorem ipsum:** Data", + "history": [ + { + "_key": "334c343862da", + "_type": "historyItem", + "summary": "Skjema ble opprettet." + }, + { + "_key": "f5cc7d1053c9", + "_type": "historyItem", + "summary": "Bla bla bla." + } + ], + "recipient": { "type": "person", "name": "Ola Nordmann", "imageUrl": null }, + "sender": { + "type": "company", + "name": "Brønnøysundregistrene", + "imageUrl": "https://cdn.sanity.io/images/z3it2oa7/production/3be5df0229c0b58c7ab59db2ade6208cc83954d2-300x300.png?w=96" + }, + "status": { "label": "Draft", "value": "draft" }, + "summary": "Et utkast til skjema er lagret.", + "title": "Registrering av enkeltmannsforetak", + "updatedAt": "2024-10-28T17:07:32Z", + "actions": [ + { "_key": "4af713286bf6", "_type": "dialogAction", "label": "Til skjema" }, + { + "_key": "1ef5bc424eee", + "_type": "dialogAction", + "label": "Forkast utkast" + } + ] +} diff --git a/lib/stories/Inbox/dialogs/index.ts b/lib/stories/Inbox/dialogs/index.ts new file mode 100644 index 0000000..ae25179 --- /dev/null +++ b/lib/stories/Inbox/dialogs/index.ts @@ -0,0 +1,10 @@ +import brregCompleted from './brreg-completed.json'; +import brregDraft from './brreg-draft.json'; +import skatt2023 from './skatt-2023.json'; + +export const dialogs = [brregDraft, brregCompleted, skatt2023].map((item, index) => { + return { + ...item, + id: 'd' + index, + }; +}); diff --git a/lib/stories/Inbox/dialogs/skatt-2023.json b/lib/stories/Inbox/dialogs/skatt-2023.json new file mode 100644 index 0000000..25c30b8 --- /dev/null +++ b/lib/stories/Inbox/dialogs/skatt-2023.json @@ -0,0 +1,33 @@ +{ + "_createdAt": "2024-10-23T21:31:44Z", + "_id": "83b80be7-9b3a-474e-94bc-d5d24710ec9c", + "_rev": "5k1KJXrtipgHMEvoTB1MJV", + "_type": "dialog", + "_updatedAt": "2024-10-28T17:07:25Z", + "action": [ + { + "_key": "2d7c53da7794", + "_type": "dialogAction", + "label": "Åpne skattemeldingen" + } + ], + "dueAt": "2024-05-31", + "recipient": { "type": "person", "name": "Ola Nordmann", "imageUrl": null }, + "sender": { + "type": "company", + "name": "Skatteetaten", + "imageUrl": "https://cdn.sanity.io/images/z3it2oa7/production/e16a4f384d1d5ed959e1d74f0626259711d67509-88x88.png?w=96" + }, + "status": { "label": "Krever handling", "value": "requires-attention" }, + "summary": "Skattemeldingen for 2023 er tilgjengelig. Du bør sjekke at opplysningene er riktige.", + "title": "Skatten din 2023", + "updatedAt": "2024-10-28T17:07:25Z", + "actions": [ + { + "_key": "2d7c53da7794", + "_type": "dialogAction", + "label": "Åpne skattemeldingen" + } + ], + "history": null +} diff --git a/lib/stories/Inbox/groupBy.ts b/lib/stories/Inbox/groupBy.ts new file mode 100644 index 0000000..287e7e9 --- /dev/null +++ b/lib/stories/Inbox/groupBy.ts @@ -0,0 +1,19 @@ +interface GroupableItem { + group: string; +} + +export const groupBy = (items: GroupableItem[]) => { + const groups: Record = items?.reduce( + (acc: Record, item) => { + const group = item?.group || ''; + if (!acc[group]) { + acc[group] = []; + } + acc[group].push(item); + return acc; + }, + {} as Record, + ); + + return groups; +}; diff --git a/lib/stories/Inbox/inboxSection.module.css b/lib/stories/Inbox/inboxSection.module.css new file mode 100644 index 0000000..a0d78bb --- /dev/null +++ b/lib/stories/Inbox/inboxSection.module.css @@ -0,0 +1,19 @@ +.section { + width: 100%; + display: flex; + flex-direction: column; + row-gap: 1rem; +} + +.header { + width: 100%; + display: flex; + flex-direction: column; +} + +.title { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.25; + margin: 6px 0; +} diff --git a/lib/stories/Inbox/index.ts b/lib/stories/Inbox/index.ts new file mode 100644 index 0000000..2049dd3 --- /dev/null +++ b/lib/stories/Inbox/index.ts @@ -0,0 +1,15 @@ +export * from './accounts/'; +export * from './dialogs/'; +export * from './layout/'; + +export * from './actionMenu'; + +export * from './InboxProvider'; +export * from './InboxLayout'; +export * from './InboxSection'; + +export * from './InboxPage'; +export * from './DialogPage'; +export * from './BookmarksPage'; +export * from './ProfilePage'; +export * from './SettingsPage'; diff --git a/lib/stories/Inbox/layout/footer.ts b/lib/stories/Inbox/layout/footer.ts new file mode 100644 index 0000000..9896a96 --- /dev/null +++ b/lib/stories/Inbox/layout/footer.ts @@ -0,0 +1,27 @@ +export const footer = { + address: 'Postboks 1382 Vika, 0114 Oslo.', + menu: { + items: [ + { + id: '1', + href: '#', + title: 'Om Altinn', + }, + { + id: '2', + href: '#', + title: 'Driftsmeldinger', + }, + { + id: '3', + href: '#', + title: 'Personvern', + }, + { + id: '4', + href: '#', + title: 'Tilgjengelighet', + }, + ], + }, +}; diff --git a/lib/stories/Inbox/layout/header.ts b/lib/stories/Inbox/layout/header.ts new file mode 100644 index 0000000..71546a5 --- /dev/null +++ b/lib/stories/Inbox/layout/header.ts @@ -0,0 +1,11 @@ +import { accounts } from '../accounts/'; + +export const header = { + search: { + name: 'search', + placeholder: 'Søk i innboksen', + }, + menu: { + accounts, + }, +}; diff --git a/lib/stories/Inbox/layout/index.ts b/lib/stories/Inbox/layout/index.ts new file mode 100644 index 0000000..9f0513f --- /dev/null +++ b/lib/stories/Inbox/layout/index.ts @@ -0,0 +1,3 @@ +export * from './menu'; +export * from './footer'; +export * from './header'; diff --git a/lib/stories/Inbox/layout/menu.ts b/lib/stories/Inbox/layout/menu.ts new file mode 100644 index 0000000..c647ec9 --- /dev/null +++ b/lib/stories/Inbox/layout/menu.ts @@ -0,0 +1,64 @@ +export const menu = { + groups: { + shortcuts: { + title: 'Snarveier', + defaultItemColor: 'neutral', + }, + }, + items: [ + { + id: 'inbox', + groupId: 1, + size: 'lg', + icon: 'inbox', + title: 'Innboks', + color: 'strong', + }, + { + id: 'drafts', + groupId: 2, + icon: 'doc-pencil', + title: 'Utkast', + }, + { + id: 'sent', + groupId: 2, + icon: 'file-checkmark', + selected: true, + title: 'Sendt', + }, + { + id: 'bookmarks', + groupId: 3, + icon: 'bookmark', + // disabled: true, + title: 'Lagrede søk', + }, + { + id: 'archived', + groupId: 4, + disabled: true, + icon: 'archive', + title: 'Arkivert', + }, + { + id: 'trash', + groupId: 4, + disabled: true, + icon: 'trash', + title: 'Papirkurv', + }, + { + id: 'profile', + groupId: 'shortcuts', + icon: 'person-circle', + title: 'Din profil', + }, + { + id: 'settings', + groupId: 'shortcuts', + icon: 'cog', + title: 'Innstillinger', + }, + ], +}; diff --git a/tsconfig.json b/tsconfig.json index 4d3981b..57babe3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,12 @@ "noFallthroughCasesInSwitch": true, "allowSyntheticDefaultImports": true }, - "include": ["./lib/**/*.ts", "./lib/**/*.tsx", "./**/*.d.ts"], - "exclude": ["./lib/**/*.stories.tsx", "./lib/**/*.stories.ts", "./lib/components/Icon/__AkselIcon.tsx"], + "include": ["./lib/**/*.ts", "./lib/hooks/*.tsx", "./**/*.d.ts"], + "exclude": [ + "./lib/stories/**/*", + "lib/components/**/*.stories.ts", + "lib/components/**/*.stories.tsx", + "./lib/components/Icon/__AkselIcon.tsx" + ], "references": [{ "path": "./tsconfig.node.json" }] }