diff --git a/.storybook/preview.ts b/.storybook/preview.ts index b84c4fb9..f67b3c76 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -29,6 +29,6 @@ const preview: Preview = { }, }, }, -}; +} export default preview; diff --git a/__snapshots__/badge--button-example-chromium.png b/__snapshots__/badge--button-example-chromium.png index ad0537cf..4a82e690 100644 Binary files a/__snapshots__/badge--button-example-chromium.png and b/__snapshots__/badge--button-example-chromium.png differ diff --git a/__snapshots__/badge--button-example-firefox.png b/__snapshots__/badge--button-example-firefox.png index c1dd2765..ca8a1a87 100644 Binary files a/__snapshots__/badge--button-example-firefox.png and b/__snapshots__/badge--button-example-firefox.png differ diff --git a/__snapshots__/badge--button-example-webkit.png b/__snapshots__/badge--button-example-webkit.png index 177a95e4..ea3ca216 100644 Binary files a/__snapshots__/badge--button-example-webkit.png and b/__snapshots__/badge--button-example-webkit.png differ diff --git a/__snapshots__/badge--variants-chromium.png b/__snapshots__/badge--variants-chromium.png index d4b2130b..78fbdf14 100644 Binary files a/__snapshots__/badge--variants-chromium.png and b/__snapshots__/badge--variants-chromium.png differ diff --git a/__snapshots__/badge--variants-firefox.png b/__snapshots__/badge--variants-firefox.png index 00592fdb..30574390 100644 Binary files a/__snapshots__/badge--variants-firefox.png and b/__snapshots__/badge--variants-firefox.png differ diff --git a/__snapshots__/badge--variants-webkit.png b/__snapshots__/badge--variants-webkit.png index 2290efe5..15c29afb 100644 Binary files a/__snapshots__/badge--variants-webkit.png and b/__snapshots__/badge--variants-webkit.png differ diff --git a/__snapshots__/menu--menu-account-chromium.png b/__snapshots__/menu--menu-account-chromium.png new file mode 100644 index 00000000..46b83db8 Binary files /dev/null and b/__snapshots__/menu--menu-account-chromium.png differ diff --git a/__snapshots__/menu--menu-account-firefox.png b/__snapshots__/menu--menu-account-firefox.png new file mode 100644 index 00000000..ba3137d1 Binary files /dev/null and b/__snapshots__/menu--menu-account-firefox.png differ diff --git a/__snapshots__/menu--menu-account-list-chromium.png b/__snapshots__/menu--menu-account-list-chromium.png new file mode 100644 index 00000000..7b063994 Binary files /dev/null and b/__snapshots__/menu--menu-account-list-chromium.png differ diff --git a/__snapshots__/menu--menu-account-list-firefox.png b/__snapshots__/menu--menu-account-list-firefox.png new file mode 100644 index 00000000..4d9cc944 Binary files /dev/null and b/__snapshots__/menu--menu-account-list-firefox.png differ diff --git a/__snapshots__/menu--menu-account-list-webkit.png b/__snapshots__/menu--menu-account-list-webkit.png new file mode 100644 index 00000000..16492608 Binary files /dev/null and b/__snapshots__/menu--menu-account-list-webkit.png differ diff --git a/__snapshots__/menu--menu-account-webkit.png b/__snapshots__/menu--menu-account-webkit.png new file mode 100644 index 00000000..01e15241 Binary files /dev/null and b/__snapshots__/menu--menu-account-webkit.png differ diff --git a/src/components/badge/Badge.style.scss b/src/components/badge/Badge.style.scss index e6ef9916..fbb3b63e 100644 --- a/src/components/badge/Badge.style.scss +++ b/src/components/badge/Badge.style.scss @@ -14,6 +14,5 @@ .badge--#{$name} { @include opacityBox(false, $color); font-size: $tertiaryFontSize; - border: none; } } \ No newline at end of file diff --git a/src/components/menu/InternalMenu.tsx b/src/components/menu/InternalMenu.tsx new file mode 100644 index 00000000..da9138ae --- /dev/null +++ b/src/components/menu/InternalMenu.tsx @@ -0,0 +1,83 @@ +import {AriaMenuProps, useMenu, useMenuItem, useMenuSection} from "react-aria"; +import {Node, useTreeState} from "react-stately"; +import React from "react"; +import {TreeState} from "@react-stately/tree"; +import {IconCheck} from "@tabler/icons-react"; +import "./Menu.style.scss" +import {MenuItemType, MenuSectionType} from "./Menu"; + +export function InternalMenu(props: AriaMenuProps) { + + const dummyState = useTreeState(props); + const disabledKeys = [...dummyState.collection.getKeys()].map(key => dummyState.collection.getItem(key)).filter(item => { + return item?.props.disabled || item?.props.unselectable + }).map(item => item?.key ?? "") + + const state = useTreeState({ + ...props, + disabledKeys + }); + + // Get props for the menu element + const ref = React.useRef(null); + const {menuProps} = useMenu({ + ...props, + disabledKeys + }, state, ref); + + return ( + + ); +} + +function InternalMenuItem({item, state}: {item: Node, state: TreeState}) { + + const {variant = "secondary", disabled = false, unselectable = false} = item.props as MenuItemType + + // Get props for the menu item element + const ref = React.useRef(null); + const {menuItemProps, isSelected} = useMenuItem( + {key: item.key}, + state, + ref + ) + + return ( +
  • + +
    {item.rendered}
    + {isSelected && !unselectable ? : menuItemProps.role != "menuitem" ? : null} +
  • + ) +} + +function MenuSection({section, state}: {section: Node, state: TreeState}) { + + const {title} = section.props as MenuSectionType + // Get props for the menu item element + const ref = React.useRef(null); + const { itemProps, headingProps, groupProps } = useMenuSection({ + heading: section.rendered, + 'aria-label': section['aria-label'] + }); + + /**const children = [...state.collection.getKeys()].map((value) => { + return state.collection.getItem(value) + }).filter(item => item?.parentKey == section.key) as Node[]**/ + + return
      + {title && {title}} + {[...section.childNodes].map((node) => ( + + ))} +
    +} \ No newline at end of file diff --git a/src/components/menu/Menu.stories.tsx b/src/components/menu/Menu.stories.tsx new file mode 100644 index 00000000..f01a6f9a --- /dev/null +++ b/src/components/menu/Menu.stories.tsx @@ -0,0 +1,92 @@ +import {Meta, StoryObj} from "@storybook/react"; +import React from "react"; +import Button from "../button/Button"; +import {Placement} from "react-aria"; +import Menu from "./Menu"; +import {IconLogout, IconUserCancel, IconUserEdit} from "@tabler/icons-react"; +import Badge from "../badge/Badge"; + +const meta: Meta = { + title: "Menu", + component: Menu, + argTypes: { + placement: { + options: ['left start', 'left end', 'bottom start', 'bottom end', 'top start', 'top end', 'right start', 'right end'], + control: {type: 'radio'}, + } + } +} + +export default meta; + +type MenuStory = StoryObj<{ placement: Placement }> + +export const MenuAccount: MenuStory = { + render: (args) => { + + const {placement} = args + + return <> + + + + + + + + Storage almost full. You can
    + manage your storage in Settings → +
    +
    + + Update + Account + Delete + Account + + Logout ⌘Q +
    +
    + + + } +} + +export const MenuAccountList: MenuStory = { + render: (args) => { + + const {placement} = args + + return <> + + + + + + { + [{ + mail: "nsammito@dummy.de", + name: "Nico Sammito" + }, { + mail: "nvschrick@dummy.de", + name: "Niklas van Schrick" + }, { + mail: "rgoetz@dummy.de", + name: "Raphael Götz" + }, { + mail: "mstaedler@dummy.de", + name: "Maximillian Städler" + }].map(item => ( + {item.name} {item.mail} + )) + } + + + + + } +} \ No newline at end of file diff --git a/src/components/menu/Menu.style.scss b/src/components/menu/Menu.style.scss new file mode 100644 index 00000000..4a55ad59 --- /dev/null +++ b/src/components/menu/Menu.style.scss @@ -0,0 +1,89 @@ +@import "src/styles/helpers"; + +.menu { + + list-style: none; + margin: -.25rem 0; + padding: 0; + outline: none; + + > *:first-child.menu__section { + border-top: none; + margin-top: 0; + padding-top: 0; + } + + > *:last-child.menu__section { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + } + + &__item { + @include disabled(); + border: none !important; + margin: 0 -.25rem; + border-radius: .5rem; + padding: .5rem; + cursor: pointer; + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + + > div { + position: relative; + display: flex; + width: 100%; + align-items: center; + } + + } + + &__section { + border-top: 1px solid borderColor(); + border-bottom: 1px solid borderColor(); + list-style: none; + margin: .25rem -.5rem; + padding: .25rem .5rem; + outline: none; + + + .menu__section { + border-top: none; + margin-top: -.25rem; + } + } + + &__section-title { + font-size: $tertiaryFontSize; + color: rgba($white, .25); + display: block; + margin: .25rem 0 .25rem .25rem; + } + + &__icon { + margin-right: .5rem; + } + + &__shortcut { + margin-left: auto; + padding-left: .5rem; + } + +} + +@each $name, $color in $variants { + .menu__item--#{$name} { + @include hoverAndActiveContent { + background: rgba($color, .2); + } + + .menu__icon { + color: rgba($color, .5); + } + } + .menu__item--unselectable { + background: transparent !important; + pointer-events: none; + } +} \ No newline at end of file diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx new file mode 100644 index 00000000..f2f38baa --- /dev/null +++ b/src/components/menu/Menu.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import {getChild} from "../../utils/utils"; +import {PopoverProps} from "../popover/Popover"; +import {AriaMenuProps, Key, useButton, useMenuTrigger} from "react-aria"; +import {Item, Section, useMenuTriggerState} from "react-stately"; +import {AriaButtonOptions} from "@react-aria/button"; +import {InternalPopover} from "../popover/InternalPopover"; +import {InternalMenu} from "./InternalMenu"; +import Badge from "../badge/Badge"; +import "./Menu.style.scss" + +export interface MenuType extends AriaMenuProps, PopoverProps { + children: React.ReactElement[] +} + +export interface MenuTriggerType { + children: React.ReactElement +} + +export interface MenuContentType { + children: React.ReactNode +} + +export interface MenuItemType { + children: React.ReactNode + key: Key + variant?: "primary" | "secondary" | "info" | "success" | "warning" | "error" + disabled?: boolean + unselectable?: boolean + textValue?: string +} + +export interface MenuShortcutType { + children: string +} + +export interface MenuIconType { + children: React.ReactNode +} + +export interface MenuSectionType { + children: React.ReactElement | React.ReactElement[] + title?: string +} + +const Menu: React.FC> = (props) => { + + const {children, placement = "bottom start", ...args} = props + + const menuTrigger = getChild(children, MenuTrigger, true) + const menuContent = getChild(children, MenuContent, true) + + const state = useMenuTriggerState(props) + const triggerRef = React.useRef(null) + const {menuProps, menuTriggerProps} = useMenuTrigger({}, state, triggerRef) + const {buttonProps} = useButton(menuTriggerProps as AriaButtonOptions<'div'>, triggerRef); + + return ( + <> +
    + {menuTrigger ? React.cloneElement(menuTrigger.props.children, {...buttonProps, ...(!state.isOpen && {tabIndex: 1})}) : null} +
    + + {state.isOpen && ( + + {/** @ts-ignore **/} + {menuContent ? + {Array.of(menuContent.props.children).flat().map((child: React.ReactElement) => { + if (child.type == MenuSection) return
    { + Array.of(child.props.children).flat().map((item: React.ReactElement) => { + return {item.props.children} + }) + }
    + return {child.props.children} + })} +
    : null} +
    + )} + + ) +} + +const MenuTrigger: React.FC = (props) => { + + const {children, ...args} = props + + return
    + {children} +
    +} + +const MenuContent: React.FC = (props) => { + + const {children} = props + + return
    + {children} +
    +} + +const MenuItem: React.FC = (props) => { + + const {children} = props + + return <>{children} +} + +const MenuSection: React.FC = (props) => { + + const {children} = props + + return <>{children} +} + +const MenuShortcut: React.FC = (props) => { + + const {children} = props + + return {children} + +} + +const MenuIcon: React.FC = (props) => { + + const {children} = props + return {children} +} + +export default Object.assign(Menu, { + Trigger: MenuTrigger, + Content: MenuContent, + Item: MenuItem, + Shortcut: MenuShortcut, + Section: MenuSection, + Icon: MenuIcon +}) \ No newline at end of file diff --git a/src/components/popover/InternalPopover.tsx b/src/components/popover/InternalPopover.tsx new file mode 100644 index 00000000..9e784e6a --- /dev/null +++ b/src/components/popover/InternalPopover.tsx @@ -0,0 +1,32 @@ +import "./Popover.style.scss" +import React from "react"; +import {AriaPopoverProps, Overlay, usePopover} from "react-aria"; +import {OverlayTriggerState} from "react-stately"; + +interface InternalPopoverType extends Omit { + children: React.ReactNode; + state: OverlayTriggerState; +} + +export const InternalPopover: React.FC = (props) => { + + const {children, state, offset = 0, ...args} = props + const popoverRef = React.useRef(null); + const {popoverProps} = usePopover({ + ...args, + offset, + popoverRef + }, state); + + return ( + +
    + {children} +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/popover/Popover.style.scss b/src/components/popover/Popover.style.scss index 4e40e04b..6b339b73 100644 --- a/src/components/popover/Popover.style.scss +++ b/src/components/popover/Popover.style.scss @@ -5,5 +5,8 @@ &__content { @include box(false); padding: .5rem; + margin-bottom: .5rem; + margin-top: .5rem; + overflow-y: auto; } } \ No newline at end of file diff --git a/src/components/popover/Popover.tsx b/src/components/popover/Popover.tsx index 270abae0..cde8711f 100644 --- a/src/components/popover/Popover.tsx +++ b/src/components/popover/Popover.tsx @@ -1,10 +1,10 @@ import React from "react"; -import {Overlay, useButton, useOverlayTrigger, usePopover} from "react-aria"; +import {useButton, useOverlayTrigger} from "react-aria"; import {useOverlayTriggerState} from "react-stately"; import {getChild} from "../../utils/utils"; import {OverlayTriggerProps, PositionProps} from "@react-types/overlays"; -import "./Popover.style.scss" import {AriaButtonOptions} from "@react-aria/button"; +import {InternalPopover} from "./InternalPopover"; export interface PopoverProps extends PositionProps, OverlayTriggerProps { children: React.ReactElement[] @@ -20,7 +20,7 @@ export interface PopoverContentType { const Popover: React.FC = (props) => { - const {children, offset = 8, placement = "bottom start", ...args} = props + const {children, ...args} = props //get trigger and content from popover const popoverTrigger = getChild(children, PopoverTrigger, true) @@ -34,19 +34,12 @@ const Popover: React.FC = (props) => { }) const triggerRef = React.useRef(null) - const popoverRef = React.useRef(null) - const {triggerProps} = useOverlayTrigger( - {type: 'dialog'}, + const {triggerProps, overlayProps} = useOverlayTrigger( + {type: "dialog"}, state, triggerRef ) - const {popoverProps} = usePopover({ - placement, - offset, - triggerRef, - popoverRef - }, state); const {buttonProps} = useButton(triggerProps as AriaButtonOptions<'div'>, triggerRef); @@ -58,18 +51,11 @@ const Popover: React.FC = (props) => { {popoverTrigger?.props.children} - {state.isOpen && - ( - -
    - {popoverContent} -
    -
    - )} + {state.isOpen && ( + + {popoverContent ? React.cloneElement(popoverContent, overlayProps) : null} + + )} ); diff --git a/src/styles/_helpers.scss b/src/styles/_helpers.scss index 2f31b6af..c0c5d6a0 100644 --- a/src/styles/_helpers.scss +++ b/src/styles/_helpers.scss @@ -13,7 +13,7 @@ @if ($active == true) { @include hoverAndActiveContent { - background: mix($bodyBg, if($color == $primary, $white, $color), if($color == $primary, 95%, 85%)); + background: mix($bodyBg, if($color == $primary, $white, $color), if($color == $primary, 90%, 80%)); } } } @@ -35,8 +35,9 @@ } @mixin hoverAndActiveContent() { - &:hover, &:active, &--active { - @content + &:hover, &:active, &--active, &:focus, &:focus-visible { + @content; + outline: none; } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 42933557..961b1d02 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -12,7 +12,7 @@ export const getChild = (children: ReactNode | ReactNode[], child: React.FC } else if (React.Children.count(children) - 1 == index && !found && !childComponent && required) throw new Error(`${child.name} is required`) }) - }, []) + }, [children, child]) return childComponent }