diff --git a/packages/admin-ui/.storybook/overrides.css b/packages/admin-ui/.storybook/overrides.css new file mode 100644 index 00000000000..110c99cbee8 --- /dev/null +++ b/packages/admin-ui/.storybook/overrides.css @@ -0,0 +1,5 @@ +/* storybook-overrides.css */ +.sb-show-main.sb-main-centered #storybook-root { + margin: 0; + padding: 0; +} \ No newline at end of file diff --git a/packages/admin-ui/.storybook/preview.ts b/packages/admin-ui/.storybook/preview.ts index 0dd3314486b..a09204ebc04 100644 --- a/packages/admin-ui/.storybook/preview.ts +++ b/packages/admin-ui/.storybook/preview.ts @@ -1,6 +1,7 @@ import type { Preview } from "@storybook/react"; import "../src/theme.scss"; +import "./overrides.css"; const preview: Preview = { parameters: { diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 46e029c2146..1827b73e3d1 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.0", diff --git a/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx b/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx new file mode 100644 index 00000000000..3f7ce2bb3b0 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Sidebar } from "./Sidebar"; +import React from "react"; +import { ReactComponent as CreditCard } from "@material-design-icons/svg/outlined/credit_score.svg"; +import { ReactComponent as Settings } from "@material-design-icons/svg/outlined/settings.svg"; +import { ReactComponent as AuditLogsIcon } from "@material-design-icons/svg/outlined/assignment.svg"; +import { ReactComponent as FormBuilderIcon } from "@material-design-icons/svg/outlined/check_box.svg"; +import { ReactComponent as CmsIcon } from "@material-design-icons/svg/outlined/web.svg"; +import { ReactComponent as PageBuilderIcon } from "@material-design-icons/svg/outlined/table_chart.svg"; +import { ReactComponent as ApwIcon } from "@material-design-icons/svg/outlined/account_tree.svg"; +import { ReactComponent as TenantManagerIcon } from "@material-design-icons/svg/outlined/domain.svg"; +import { ReactComponent as SettingsIcon } from "@material-design-icons/svg/outlined/settings.svg"; +import { ReactComponent as ArticleIcon } from "@material-design-icons/svg/outlined/article.svg"; + +import wbyLogo from "./stories/wby-logo.png"; +import { SidebarProvider } from "~/Sidebar/components/SidebarProvider"; + +const meta: Meta = { + title: "Components/Sidebar", + component: Sidebar, + tags: ["autodocs"], + argTypes: {}, + render: args => ( + <> + + +
Main content goes here.
+
+ + ) +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Webiny", + icon: } label={"Webiny"} />, + children: ( + <> + } />} + content={"Audit Logs"} + /> + } />} + content={"Form Builder"} + /> + } />} + content={"Headless CMS"} + > + + + + + + } />} + content={"Page Builder"} + > + } />} + content={"Blocks"} + > + + + + } />} + content={"Pages"} + > + + + + + + + } />} + content={"Publishing Workflows"} + > + } + label={"Content Reviews"} + /> + } + /> + } label={"Workflows"} />} + /> + + } /> + } + content={"Tenant manager"} + /> + } />} + content={"Settings (active)"} + active={true} + /> + + ) + } +}; diff --git a/packages/admin-ui/src/Sidebar/Sidebar.tsx b/packages/admin-ui/src/Sidebar/Sidebar.tsx new file mode 100644 index 00000000000..4586afbce0d --- /dev/null +++ b/packages/admin-ui/src/Sidebar/Sidebar.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import { makeDecoratable, withStaticProps } from "~/utils"; +import { SidebarRoot } from "./components/SidebarRoot"; +import { SidebarContent } from "./components/SidebarContent"; +import { SidebarSeparator } from "./components/SidebarSeparator"; +import { SidebarMenuItem } from "./components/SidebarMenuItem"; +import { SidebarGroup } from "./components/SidebarGroup"; +import { SidebarMenu } from "./components/SidebarMenu"; +import { SidebarHeader } from "./components/SidebarHeader"; +import { SidebarIcon } from "./components/SidebarIcon"; + +interface SidebarProps + extends Omit, "title">, + Omit, "title"> { + title?: React.ReactNode; + icon?: React.ReactNode; + children: React.ReactNode; +} + +const SidebarBase = React.forwardRef, SidebarProps>( + (props, ref) => { + const { headerProps, rootProps, contentProps } = React.useMemo(() => { + const { + // Header props. + title, + icon, + + // Root props. + side, + collapsible, + + // Content props. + ...rest + } = props; + + return { + headerProps: { + title, + icon + }, + rootProps: { + side, + collapsible + }, + contentProps: rest + }; + }, [props]); + + return ( + + + + {props.children} + + + ); + } +); + +SidebarBase.displayName = "Sidebar"; + +const DecoratableSidebar = makeDecoratable("Sidebar", SidebarBase); + +const Sidebar = withStaticProps(DecoratableSidebar, { + Separator: SidebarSeparator, + Group: SidebarGroup, + Item: SidebarMenuItem, + Icon: SidebarIcon +}); + +export { Sidebar }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx b/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx new file mode 100644 index 00000000000..a3c0fdd989a --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarContent = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +SidebarContent.displayName = "SidebarContent"; + +export { SidebarContent }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarFooter.tsx b/packages/admin-ui/src/Sidebar/components/SidebarFooter.tsx new file mode 100644 index 00000000000..e3de443b9a9 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarFooter.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarFooter = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +SidebarFooter.displayName = "SidebarFooter"; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarGroup.tsx b/packages/admin-ui/src/Sidebar/components/SidebarGroup.tsx new file mode 100644 index 00000000000..692fa962a3f --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarGroup.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarGroup = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +SidebarGroup.displayName = "SidebarGroup"; + +export { SidebarGroup }; \ No newline at end of file diff --git a/packages/admin-ui/src/Sidebar/components/SidebarGroupAction.tsx b/packages/admin-ui/src/Sidebar/components/SidebarGroupAction.tsx new file mode 100644 index 00000000000..1f30bbd97e8 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarGroupAction.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "~/utils"; + +const SidebarGroupAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); +}); +SidebarGroupAction.displayName = "SidebarGroupAction"; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarGroupContent.tsx b/packages/admin-ui/src/Sidebar/components/SidebarGroupContent.tsx new file mode 100644 index 00000000000..5b4690a6fe0 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarGroupContent.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarGroupContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +SidebarGroupContent.displayName = "SidebarGroupContent"; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarGroupLabel.tsx b/packages/admin-ui/src/Sidebar/components/SidebarGroupLabel.tsx new file mode 100644 index 00000000000..9c0cf742e29 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarGroupLabel.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "~/utils"; + +const SidebarGroupLabel = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div"; + + return ( + svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className + )} + {...props} + /> + ); +}); +SidebarGroupLabel.displayName = "SidebarGroupLabel"; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarHeader.tsx b/packages/admin-ui/src/Sidebar/components/SidebarHeader.tsx new file mode 100644 index 00000000000..3fdd4eeb439 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarHeader.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Separator } from "~/Separator"; +import { IconButton } from "~/Button"; +import { useSidebar } from "./SidebarProvider"; +import { ReactComponent as ToggleSidebarIcon } from "@material-design-icons/svg/outlined/chrome_reader_mode.svg"; + +interface SidebarHeaderProps extends Omit, "title"> { + icon?: React.ReactNode; + title?: React.ReactNode; +} + +const SidebarHeader = ({ title, icon }: SidebarHeaderProps) => { + const { toggleSidebar } = useSidebar(); + + return ( +
+
+
+
+ {icon} + + {title} + +
+ + } + data-sidebar="trigger" + size="xs" + variant={"ghost"} + onClick={toggleSidebar} + /> +
+
+
+ +
+
+ ); +}; + +export { SidebarHeader, type SidebarHeaderProps }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarIcon.tsx b/packages/admin-ui/src/Sidebar/components/SidebarIcon.tsx new file mode 100644 index 00000000000..0f74f99fa00 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarIcon.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Icon, type IconProps } from "~/Icon"; +import { useSidebar } from "./SidebarProvider"; + +interface SidebarIconProps extends Omit { + element?: React.ReactNode; +} + +const SidebarIcon = ({ element, ...props }: SidebarIconProps) => { + const { toggleSidebar } = useSidebar(); + return ( + + ); +}; + +export { SidebarIcon, type SidebarIconProps }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarInput.tsx b/packages/admin-ui/src/Sidebar/components/SidebarInput.tsx new file mode 100644 index 00000000000..9865201b408 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarInput.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Input } from "~/Input"; +import { cn } from "~/utils"; + +const SidebarInput = ({ className, ...props }: React.ComponentProps) => { + return ( + + ); +}; + +export { SidebarInput }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenu.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenu.tsx new file mode 100644 index 00000000000..4bcea116a5a --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenu.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarMenu = React.forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +); +SidebarMenu.displayName = "SidebarMenu"; + +export { SidebarMenu }; \ No newline at end of file diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuAction.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuAction.tsx new file mode 100644 index 00000000000..1a0035fa54a --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuAction.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "~/utils"; + +const SidebarMenuAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; + } +>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className + )} + {...props} + /> + ); +}); +SidebarMenuAction.displayName = "SidebarMenuAction"; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuBadge.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuBadge.tsx new file mode 100644 index 00000000000..9b50212073a --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuBadge.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarMenuBadge = React.forwardRef>( + ({ className, ...props }, ref) => ( +
    + ) +); +SidebarMenuBadge.displayName = "SidebarMenuBadge"; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuButton.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuButton.tsx new file mode 100644 index 00000000000..5f23de8a5e3 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuButton.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "~/utils"; + +interface SidebarMenuButtonProps extends React.ComponentProps<"button"> { + asChild?: boolean; + active?: boolean; + icon?: React.ReactNode; +} + +const SidebarMenuButton = ({ + asChild = false, + icon, + active, + className, + children, + ...props +}: SidebarMenuButtonProps) => { + const Comp = asChild ? Slot : "button"; + + return ( + svg]:wby-fill-neutral-xstrong", + "[&>span:last-child]:wby-truncate [&>svg]:wby-shrink-0", + className + )} + {...props} + > + {icon} {children} + + ); +}; + +export { SidebarMenuButton }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuItem.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuItem.tsx new file mode 100644 index 00000000000..6d8a5bc3f7b --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuItem.tsx @@ -0,0 +1,83 @@ +import React, { useMemo } from "react"; +import { cn, withStaticProps } from "~/utils"; +import { SidebarMenuButton } from "./SidebarMenuButton"; +import { SidebarMenuItemIcon } from "./SidebarMenuItemIcon"; +import { SidebarMenuSub } from "./SidebarMenuSub"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; +import { SidebarMenuSubItem } from "./SidebarMenuSubItem"; +import { Icon } from "~/Icon"; +import { ReactComponent as KeyboardArrowRightIcon } from "@material-design-icons/svg/outlined/keyboard_arrow_down.svg"; + +interface SidebarMenuItemProps extends Omit, "content"> { + content: React.ReactNode; + icon?: React.ReactNode; + active?: boolean; + disabled?: boolean; +} + +const SidebarMenuItemBase = ({ + content, + icon, + active, + disabled, + className, + children, + ...props +}: SidebarMenuItemProps) => { + const sidebarMenuButton = useMemo(() => { + if (!children) { + return ( + + {content} + + ); + } + + return ( + + + + {content} + } + /> + + + + + {React.Children.map(children, child => { + if (React.isValidElement(child)) { + return ; + } + return child; + })} + + + + ); + }, [children, icon, content]); + + return ( +
  • + {sidebarMenuButton} +
  • + ); +}; + +const SidebarMenuItem = withStaticProps(SidebarMenuItemBase, { + Icon: SidebarMenuItemIcon +}); + +export { SidebarMenuItem, type SidebarMenuItemProps }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuItemIcon.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuItemIcon.tsx new file mode 100644 index 00000000000..21512a1e21f --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuItemIcon.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Icon, type IconProps } from "~/Icon"; + +interface SidebarMenuItemIconProps extends Omit { + element?: React.ReactNode; +} + +const SidebarMenuItemIcon = ({ element, ...props }: SidebarMenuItemIconProps) => { + return ; +}; + +export { SidebarMenuItemIcon, type SidebarMenuItemIconProps }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuSkeleton.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuSkeleton.tsx new file mode 100644 index 00000000000..ae4fe72ab36 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuSkeleton.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { cn } from "~/utils"; +import { Skeleton } from "~/Skeleton"; + +const SidebarMenuSkeleton = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + showIcon?: boolean; + } +>(({ className, showIcon = false, ...props }, ref) => { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); + + return ( +
    + {showIcon && ( + + )} + +
    + ); +}); +SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"; + +export { SidebarMenuSkeleton }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuSub.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuSub.tsx new file mode 100644 index 00000000000..77e65b416b9 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuSub.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { cn } from "~/utils"; + +const SidebarMenuSub = ({ className, ...props }: React.ComponentProps<"ul">) => ( +
      +); + +export { SidebarMenuSub }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuSubButton.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuSubButton.tsx new file mode 100644 index 00000000000..cb657d67b57 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuSubButton.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "~/utils"; + +interface SidebarMenuSubButtonProps extends React.ComponentProps<"button"> { + asChild?: boolean; + active?: boolean; + icon?: React.ReactNode; +} + +const SidebarMenuSubButton = ({ + asChild = false, + icon, + active, + className, + children, + ...props +}: SidebarMenuSubButtonProps) => { + const Comp = asChild ? Slot : "button"; + + return ( + span:last-child]:wby-truncate [&>svg]:wby-shrink-0", + "group-data-[collapsible=icon]:wby-hidden", + className + )} + {...props} + > + {icon} {children} + + ); +}; + +export { SidebarMenuSubButton }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuSubItem.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuSubItem.tsx new file mode 100644 index 00000000000..673ea5791c6 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuSubItem.tsx @@ -0,0 +1,85 @@ +import React, { useMemo } from "react"; +import { cn } from "~/utils"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; +import { SidebarMenuSubButton } from "./SidebarMenuSubButton"; +import { SidebarMenuSubItemIndentation } from "./SidebarMenuSubItemIndentation"; +import { SidebarMenuSub } from "./SidebarMenuSub"; +import { Icon } from "~/Icon"; +import { ReactComponent as KeyboardArrowRightIcon } from "@material-design-icons/svg/outlined/keyboard_arrow_down.svg"; + +interface SidebarMenuSubItemProps extends Omit, "content"> { + content: React.ReactNode; + icon?: React.ReactNode; + active?: boolean; + disabled?: boolean; + lvl?: number; +} + +const SidebarMenuSubItem = ({ + content, + icon, + active, + disabled, + children, + className, + lvl = 1, + ...props +}: SidebarMenuSubItemProps) => { + const sidebarMenuSubButton = useMemo(() => { + if (!children) { + return ( + <> + + + {content} + + + ); + } + + return ( + +
      + + + + {content} + } + /> + + +
      + + + {React.Children.map(children, child => { + if (React.isValidElement(child)) { + return ; + } + return child; + })} + + +
      + ); + }, [children, icon, content, lvl]); + + return ( +
    • + {sidebarMenuSubButton} +
    • + ); +}; +export { SidebarMenuSubItem }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarMenuSubItemIndentation.tsx b/packages/admin-ui/src/Sidebar/components/SidebarMenuSubItemIndentation.tsx new file mode 100644 index 00000000000..1153e9409ed --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarMenuSubItemIndentation.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Separator } from "~/Separator"; + +export interface SidebarMenuSubItemIndentationProps extends Omit, "content"> { + lvl: number; +} + +const SidebarMenuSubItemIndentation = ({ lvl }: SidebarMenuSubItemIndentationProps) => { + return ( +
      + {Array.from({ length: lvl }, (_, index) => ( +
      + +
      + ))} +
      + ); +}; + +export { SidebarMenuSubItemIndentation }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx b/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx new file mode 100644 index 00000000000..df61c2318f3 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { useIsMobile } from "~/hooks/useIsMobile"; +import { cn } from "~/utils"; + +import { + SIDEBAR_COOKIE_NAME, + SIDEBAR_COOKIE_MAX_AGE, + SIDEBAR_WIDTH, + SIDEBAR_WIDTH_ICON, + SIDEBAR_KEYBOARD_SHORTCUT +} from "./constants"; + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open] + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ); + + return ( + + {/**/} +
      + {children} +
      + {/*
      */} +
      + ); + } +); +SidebarProvider.displayName = "SidebarProvider"; + +export { SidebarProvider, useSidebar }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx b/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx new file mode 100644 index 00000000000..2cc24539b5e --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { cn } from "~/utils"; +import { useSidebar } from "./SidebarProvider"; + +const SidebarRoot = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>(({ side = "left", collapsible = "offcanvas", className, children, ...props }, ref) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
      + {children} +
      + ); + } + + return ( +
      +
      +
      +
      + {children} +
      +
      +
      + ); +}); + +export { SidebarRoot }; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarSeparator.tsx b/packages/admin-ui/src/Sidebar/components/SidebarSeparator.tsx new file mode 100644 index 00000000000..d164a2637fb --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/SidebarSeparator.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Separator } from "~/Separator"; +import { cn } from "~/utils"; + +const SidebarSeparator = ({ className, ...props }: React.ComponentProps) => { + return ( + + ); +}; + +export { SidebarSeparator }; diff --git a/packages/admin-ui/src/Sidebar/components/constants.ts b/packages/admin-ui/src/Sidebar/components/constants.ts new file mode 100644 index 00000000000..ba771400186 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "44px"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/packages/admin-ui/src/Sidebar/index.ts b/packages/admin-ui/src/Sidebar/index.ts new file mode 100644 index 00000000000..d6789989723 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/index.ts @@ -0,0 +1 @@ +export * from "./Sidebar"; diff --git a/packages/admin-ui/src/Sidebar/stories/wby-logo.png b/packages/admin-ui/src/Sidebar/stories/wby-logo.png new file mode 100644 index 00000000000..ee207a1d70d Binary files /dev/null and b/packages/admin-ui/src/Sidebar/stories/wby-logo.png differ diff --git a/packages/admin-ui/src/hooks/useIsMobile.ts b/packages/admin-ui/src/hooks/useIsMobile.ts new file mode 100644 index 00000000000..08c7d9efc71 --- /dev/null +++ b/packages/admin-ui/src/hooks/useIsMobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} \ No newline at end of file diff --git a/packages/admin-ui/src/index.ts b/packages/admin-ui/src/index.ts index a5b703021db..94f3ef67a12 100644 --- a/packages/admin-ui/src/index.ts +++ b/packages/admin-ui/src/index.ts @@ -24,6 +24,7 @@ export * from "./Popover"; export * from "./Providers"; export * from "./RadioGroup"; export * from "./RangeSlider"; +export * from "./Sidebar"; export * from "./Select"; export * from "./Separator"; export * from "./Skeleton"; diff --git a/packages/app-admin-rmwc/src/modules/Navigation/index.tsx b/packages/app-admin-rmwc/src/modules/Navigation/index.tsx index 7e14c9b653a..29d16f6e4f2 100644 --- a/packages/app-admin-rmwc/src/modules/Navigation/index.tsx +++ b/packages/app-admin-rmwc/src/modules/Navigation/index.tsx @@ -19,8 +19,21 @@ import { MenuSectionItemRenderer } from "./renderers/MenuSectionItemRenderer"; import { MenuSectionRenderer } from "./renderers/MenuSectionRenderer"; import { MenuLinkRenderer } from "./renderers/MenuLinkRenderer"; import { MenuElementRenderer } from "./renderers/MenuElementRenderer"; + +import { ReactComponent as CreditCard } from "@material-design-icons/svg/outlined/credit_score.svg"; +import { ReactComponent as Settings } from "@material-design-icons/svg/outlined/settings.svg"; +import { ReactComponent as AuditLogsIcon } from "@material-design-icons/svg/outlined/assignment.svg"; +import { ReactComponent as FormBuilderIcon } from "@material-design-icons/svg/outlined/check_box.svg"; +import { ReactComponent as CmsIcon } from "@material-design-icons/svg/outlined/web.svg"; +import { ReactComponent as PageBuilderIcon } from "@material-design-icons/svg/outlined/table_chart.svg"; +import { ReactComponent as ApwIcon } from "@material-design-icons/svg/outlined/account_tree.svg"; +import { ReactComponent as TenantManagerIcon } from "@material-design-icons/svg/outlined/domain.svg"; +import { ReactComponent as SettingsIcon } from "@material-design-icons/svg/outlined/settings.svg"; +import wbyLogo from "./stories/wby-logo.png"; + import { List } from "@webiny/ui/List"; import { MenuFooter, MenuHeader, navContent, navHeader } from "./Styled"; +import { Sidebar } from "@webiny/admin-ui"; const AutoWidthDrawer = styled(Drawer)` width: auto; @@ -28,6 +41,7 @@ const AutoWidthDrawer = styled(Drawer)` interface NavigationContext { visible: boolean; + setVisible(visible: boolean): void; } @@ -135,21 +149,64 @@ const SortedMenuItems: HigherOrderComponent = MenuItems => { export const Navigation = () => { return ( - - - - - - - +
      + } label={"Webiny"} /> + } + > + } />} + content={"Audit Logs"} + /> + } />} + content={"Form Builder"} + /> + } />} + content={"Headless CMS"} + /> + } />} + content={"Page Builder"} + > + } />} + content={"Blocks"} + > + + + + } />} + content={"Pages"} + > + + + + + + + } />} + content={"Publishing Workflows"} + > + + + + } /> + } + content={"Tenant manager"} + /> + } />} + content={"Settings"} + /> + +
      ); }; diff --git a/packages/app-admin-rmwc/src/modules/Navigation/stories/wby-logo.png b/packages/app-admin-rmwc/src/modules/Navigation/stories/wby-logo.png new file mode 100644 index 00000000000..ee207a1d70d Binary files /dev/null and b/packages/app-admin-rmwc/src/modules/Navigation/stories/wby-logo.png differ diff --git a/todo.txt b/todo.txt new file mode 100644 index 00000000000..ceb9f86ef1b --- /dev/null +++ b/todo.txt @@ -0,0 +1,13 @@ +- ref / makeDecoratable +- storybook fixed-related rendering issues 🤨 +- config api +- both avatar and icon for main icon +- expanding / collapsing sidebar - can we do something with text breaking into multiple + lines and making the animation weird? Right now we have "wby-h-xl wby-whitespace-nowrap" in place. +- remove mobile-related styles +- auto opened items / cookies? +- kreso questions +- action +- footer +- submenu item not in line with the above one +- active item - what's up with hover? \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 90848bb1c2c..228e22c5378 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6634,6 +6634,20 @@ __metadata: languageName: node linkType: hard +"@material-symbols/svg-500@npm:^0.28.2": + version: 0.28.2 + resolution: "@material-symbols/svg-500@npm:0.28.2" + checksum: 10/63952304189bfbc93e00ac8e47dfb39001ee4bf4b2dcebd0ca1bd4edd6789b9579301f2e405e3fc9e7dbe1744a52954d24b41a8352e4c3c0f4158a30143c0322 + languageName: node + linkType: hard + +"@material-symbols/svg-700@npm:^0.28.2": + version: 0.28.2 + resolution: "@material-symbols/svg-700@npm:0.28.2" + checksum: 10/8ea5a13da89ee1fa4ff20da1b15c5eda4693cb4e4aaf1349ac31c6471093cc112dd85bbac39ff2e89c758fd6bac5853cbf2c268c33de663bb67f729d1fe790eb + languageName: node + linkType: hard + "@material/animation@npm:^14.0.0": version: 14.0.0 resolution: "@material/animation@npm:14.0.0" @@ -9096,6 +9110,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-collapsible@npm:^1.1.3": + version: 1.1.3 + resolution: "@radix-ui/react-collapsible@npm:1.1.3" + dependencies: + "@radix-ui/primitive": "npm:1.1.1" + "@radix-ui/react-compose-refs": "npm:1.1.1" + "@radix-ui/react-context": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.0" + "@radix-ui/react-presence": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.0.2" + "@radix-ui/react-use-controllable-state": "npm:1.1.0" + "@radix-ui/react-use-layout-effect": "npm:1.1.0" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/4d8fcd68e3b3b9ad87728d0acc6b9afb61d6eb742187536a1eb335fe54855e0039632d5c36e86e61359b45ad1f5768688216c7732cd27add5b87c054e60ae9e6 + languageName: node + linkType: hard + "@radix-ui/react-collection@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-collection@npm:1.0.3" @@ -9675,6 +9715,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:2.0.2": + version: 2.0.2 + resolution: "@radix-ui/react-primitive@npm:2.0.2" + dependencies: + "@radix-ui/react-slot": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/877b20d63487d0dec3f152f59a11d5826507d0839603b48b6aaa3f3eededea11f040ed36d6b868c02a7364570e5fbbbdb102c624fd930d4ed308b36c57154638 + languageName: node + linkType: hard + "@radix-ui/react-radio-group@npm:^1.2.1": version: 1.2.2 resolution: "@radix-ui/react-radio-group@npm:1.2.2" @@ -9888,6 +9947,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slot@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-slot@npm:1.1.2" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/6d5e1fac17b3eb79019a697581133dff23a3f6406f8ecfca476ab4a6a73baa53d66a7c9caeeebcc677363aa3b4132aa9d2168641ba9642658a2e4a297c05e4d3 + languageName: node + linkType: hard + "@radix-ui/react-switch@npm:^1.1.0": version: 1.1.2 resolution: "@radix-ui/react-switch@npm:1.1.2" @@ -14976,10 +15050,13 @@ __metadata: "@fortawesome/free-solid-svg-icons": "npm:^6.0.0" "@fortawesome/react-fontawesome": "npm:^0.1.17" "@material-design-icons/svg": "npm:^0.14.13" + "@material-symbols/svg-500": "npm:^0.28.2" + "@material-symbols/svg-700": "npm:^0.28.2" "@radix-ui/react-accessible-icon": "npm:^1.1.0" "@radix-ui/react-accordion": "npm:^1.2.2" "@radix-ui/react-avatar": "npm:^1.1.0" "@radix-ui/react-checkbox": "npm:^1.1.2" + "@radix-ui/react-collapsible": "npm:^1.1.3" "@radix-ui/react-dialog": "npm:^1.1.4" "@radix-ui/react-dropdown-menu": "npm:^2.1.4" "@radix-ui/react-label": "npm:^2.1.0"