[Desktop, UI] Fix library dropdown overflow & navigation (#619)

* add radix dropdown menu

* [desktop] Improve Libraries Dropdown

* rename alignToParent prop and fix sizing

* Update pnpm-lock.yaml

* Revert "Update pnpm-lock.yaml"

This reverts commit 6113361c51.

* fix pnpm lock
This commit is contained in:
nikec 2023-03-21 06:01:48 +01:00 committed by GitHub
parent 0fd53d1287
commit 29f0dfb338
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 280 additions and 79 deletions

View file

@ -1,22 +1,19 @@
import clsx from 'clsx';
import { Gear, Lock, Plus } from 'phosphor-react';
import { useClientContext } from '@sd/client';
import { Dropdown, dialogManager } from '@sd/ui';
import { Dropdown, DropdownMenu, dialogManager } from '@sd/ui';
import CreateDialog from '../../settings/node/libraries/CreateDialog';
export default () => {
const { library, libraries, currentLibraryId } = useClientContext();
return (
<Dropdown.Root
// we override the sidebar dropdown item's hover styles
// because the dark style clashes with the sidebar
itemsClassName="dark:bg-sidebar-box dark:border-sidebar-line mt-1 dark:divide-menu-selected/30 shadow-none"
button={
<DropdownMenu.Root
trigger={
<Dropdown.Button
variant="gray"
className={clsx(
`text-ink w-full `,
`text-ink w-full`,
// these classname overrides are messy
// but they work
`!bg-sidebar-box !border-sidebar-line/50 active:!border-sidebar-line active:!bg-sidebar-button ui-open:!bg-sidebar-button ui-open:!border-sidebar-line ring-offset-sidebar`,
@ -28,32 +25,43 @@ export default () => {
</span>
</Dropdown.Button>
}
// we override the sidebar dropdown item's hover styles
// because the dark style clashes with the sidebar
className="dark:bg-sidebar-box dark:border-sidebar-line dark:divide-menu-selected/30 mt-1 shadow-none"
alignToTrigger
animate
>
<Dropdown.Section>
{libraries.data?.map((lib) => (
<Dropdown.Item
to={`/${lib.uuid}/overview`}
key={lib.uuid}
selected={lib.uuid === currentLibraryId}
>
{lib.config.name}
</Dropdown.Item>
))}
</Dropdown.Section>
<Dropdown.Section>
<Dropdown.Item
icon={Plus}
onClick={() => dialogManager.create((dp) => <CreateDialog {...dp} />)}
{libraries.data?.map((lib) => (
<DropdownMenu.Item
to={`/${lib.uuid}/overview`}
key={lib.uuid}
selected={lib.uuid === currentLibraryId}
>
New Library
</Dropdown.Item>
<Dropdown.Item icon={Gear} to="settings/library">
Manage Library
</Dropdown.Item>
<Dropdown.Item icon={Lock} onClick={() => alert('TODO: Not implemented yet!')}>
Lock
</Dropdown.Item>
</Dropdown.Section>
</Dropdown.Root>
{lib.config.name}
</DropdownMenu.Item>
))}
<DropdownMenu.Separator className="mx-0 my-0.5" />
<DropdownMenu.Item
label=" New Library"
icon={Plus}
iconProps={{ weight: 'bold', size: 16 }}
onClick={() => dialogManager.create((dp) => <CreateDialog {...dp} />)}
className="font-medium"
/>
<DropdownMenu.Item
label="Manage Library"
icon={Gear}
iconProps={{ weight: 'bold', size: 16 }}
to="settings/library/general"
className="font-medium"
/>
<DropdownMenu.Item
label="Lock"
icon={Lock}
iconProps={{ weight: 'bold', size: 16 }}
onClick={() => alert('TODO: Not implemented yet!')}
className="font-medium"
/>
</DropdownMenu.Root>
);
};

View file

@ -72,6 +72,7 @@
"storybook-tailwind-dark-mode": "^1.0.15",
"style-loader": "^3.3.1",
"tailwindcss": "^3.1.8",
"tailwindcss-animate": "^1.0.5",
"typescript": "^4.8.4"
}
}

View file

@ -1,28 +1,28 @@
import * as RadixCM from '@radix-ui/react-context-menu';
import { VariantProps, cva } from 'class-variance-authority';
import clsx from 'clsx';
import { CaretRight, Icon } from 'phosphor-react';
import { CaretRight, Icon, IconProps } from 'phosphor-react';
import { PropsWithChildren, Suspense } from 'react';
interface Props extends RadixCM.MenuContentProps {
interface ContextMenuProps extends RadixCM.MenuContentProps {
trigger: React.ReactNode;
}
const MENU_CLASSES = `
flex flex-col z-50
min-w-[8rem] px-1 py-0.5 my-2
text-left text-sm text-menu-ink
bg-menu cool-shadow
border border-menu-line
select-none cursor-default rounded-md
`;
export const contextMenuClasses = clsx(
'z-50 flex flex-col',
'my-2 min-w-[8rem] px-1 py-0.5',
'text-menu-ink text-left text-sm',
'bg-menu cool-shadow',
'border-menu-line border',
'cursor-default select-none rounded-md'
);
export const Root = ({ trigger, children, className, ...props }: PropsWithChildren<Props>) => {
const Root = ({ trigger, children, className, ...props }: PropsWithChildren<ContextMenuProps>) => {
return (
<RadixCM.Root>
<RadixCM.Trigger asChild>{trigger}</RadixCM.Trigger>
<RadixCM.Portal>
<RadixCM.Content {...props} className={clsx(MENU_CLASSES, className)}>
<RadixCM.Content {...props} className={clsx(contextMenuClasses, className)}>
{children}
</RadixCM.Content>
</RadixCM.Portal>
@ -30,33 +30,39 @@ export const Root = ({ trigger, children, className, ...props }: PropsWithChildr
);
};
export const Separator = () => (
<RadixCM.Separator className="border-b-menu-line pointer-events-none mx-2 border-0 border-b" />
export const contextMenuSeparatorClassNames =
'border-b-menu-line pointer-events-none mx-2 my-1 border-0 border-b';
const Separator = (props: { className?: string }) => (
<RadixCM.Separator className={clsx(contextMenuSeparatorClassNames, props.className)} />
);
export const SubMenu = ({
export const contextSubMenuTriggerClassNames =
"[&[data-state='open']_div]:bg-accent text-menu-ink py-[3px] focus:outline-none [&[data-state='open']_div]:text-white";
const SubMenu = ({
label,
icon,
className,
...props
}: RadixCM.MenuSubContentProps & ItemProps) => {
}: RadixCM.MenuSubContentProps & ContextMenuItemProps) => {
return (
<RadixCM.Sub>
<RadixCM.SubTrigger className="[&[data-state='open']_div]:bg-accent text-menu-ink py-[3px] focus:outline-none [&[data-state='open']_div]:text-white">
<RadixCM.SubTrigger className={contextSubMenuTriggerClassNames}>
<DivItem rightArrow {...{ label, icon }} />
</RadixCM.SubTrigger>
<RadixCM.Portal>
<Suspense fallback={null}>
<RadixCM.SubContent {...props} className={clsx(MENU_CLASSES, '-mt-2', className)} />
<RadixCM.SubContent {...props} className={clsx(contextMenuClasses, '-mt-2', className)} />
</Suspense>
</RadixCM.Portal>
</RadixCM.Sub>
);
};
const itemStyles = cva(
export const contextMenuItemStyles = cva(
[
'flex flex-1 flex-row items-center justify-start',
'flex flex-1 flex-row items-center justify-start overflow-hidden',
'space-x-2 px-2 py-[3px]',
'cursor-default rounded',
'focus:outline-none'
@ -78,46 +84,49 @@ const itemStyles = cva(
}
);
interface ItemProps extends VariantProps<typeof itemStyles> {
export interface ContextMenuItemProps extends VariantProps<typeof contextMenuItemStyles> {
icon?: Icon;
iconProps?: IconProps;
rightArrow?: boolean;
label?: string;
keybind?: string;
}
export const Item = ({
const Item = ({
icon,
label,
rightArrow,
children,
keybind,
variant,
...props
}: ItemProps & RadixCM.MenuItemProps) => {
}: ContextMenuItemProps & RadixCM.MenuItemProps) => {
return (
<RadixCM.Item
{...props}
className="text-menu-ink group !cursor-default select-none py-0.5 focus:outline-none active:opacity-80"
>
<div className={itemStyles({ variant })}>
<RadixCM.Item {...props} className="">
<div className={contextMenuItemStyles({ variant })}>
{children ? children : <ItemInternals {...{ icon, label, rightArrow, keybind }} />}
</div>
</RadixCM.Item>
);
};
const DivItem = ({ variant, ...props }: ItemProps) => (
<div className={itemStyles({ variant })}>
const DivItem = ({ variant, ...props }: ContextMenuItemProps) => (
<div className={contextMenuItemStyles({ variant })}>
<ItemInternals {...props} />
</div>
);
const ItemInternals = ({ icon, label, rightArrow, keybind }: ItemProps) => {
export const ItemInternals = ({
icon,
label,
rightArrow,
keybind,
iconProps
}: ContextMenuItemProps) => {
const ItemIcon = icon;
return (
<>
{ItemIcon && <ItemIcon size={18} />}
{ItemIcon && <ItemIcon size={18} {...iconProps} />}
{label && <p>{label}</p>}
{keybind && (
@ -134,3 +143,10 @@ const ItemInternals = ({ icon, label, rightArrow, keybind }: ItemProps) => {
</>
);
};
export const ContextMenu = {
Root,
Item,
Separator,
SubMenu
};

View file

@ -2,7 +2,7 @@ import { ReactComponent as CaretDown } from '@sd/assets/svgs/caret.svg';
import { Menu, Transition } from '@headlessui/react';
import { VariantProps, cva } from 'class-variance-authority';
import clsx from 'clsx';
import { Fragment, PropsWithChildren } from 'react';
import { Fragment, PropsWithChildren, forwardRef } from 'react';
import { Link } from 'react-router-dom';
import * as UI from '.';
import { tw } from './utils';
@ -60,18 +60,20 @@ export const Item = ({ to, className, icon: Icon, children, ...props }: Dropdown
);
};
export const Button = ({ children, className, ...props }: UI.ButtonProps) => {
return (
<UI.Button size="sm" {...props} className={clsx('flex text-left', className)}>
{children}
<span className="grow" />
<CaretDown
className="text-ink-dull ui-open:rotate-180 ui-open:translate-y-[-1px] w-[12px] translate-y-[1px] transition-transform"
aria-hidden="true"
/>
</UI.Button>
);
};
export const Button = forwardRef<HTMLButtonElement, UI.ButtonProps>(
({ children, className, ...props }, ref) => {
return (
<UI.Button size="sm" ref={ref} className={clsx('group flex text-left', className)} {...props}>
{children}
<span className="grow" />
<CaretDown
className="text-ink-dull group-radix-state-open:rotate-180 group-radix-state-open:translate-y-[-1px] ui-open:rotate-180 ui-open:translate-y-[-1px] ml-2 w-[12px] shrink-0 translate-y-[1px] transition-transform"
aria-hidden="true"
/>
</UI.Button>
);
}
);
export interface DropdownRootProps {
button: React.ReactNode;

View file

@ -0,0 +1,162 @@
import * as RadixDM from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import React, { PropsWithChildren, Suspense, useCallback, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
ContextMenuItemProps,
ItemInternals,
contextMenuClasses,
contextMenuItemStyles,
contextMenuSeparatorClassNames,
contextSubMenuTriggerClassNames
} from './ContextMenu';
interface DropdownMenuProps
extends RadixDM.MenuContentProps,
Pick<RadixDM.DropdownMenuProps, 'onOpenChange'> {
trigger: React.ReactNode;
triggerClassName?: string;
alignToTrigger?: boolean;
animate?: boolean;
}
const Root = ({
trigger,
children,
className,
asChild = true,
triggerClassName,
alignToTrigger,
onOpenChange,
animate,
...props
}: PropsWithChildren<DropdownMenuProps>) => {
const [width, setWidth] = useState<number>();
const measureRef = useCallback((ref: HTMLButtonElement | null) => {
alignToTrigger && ref && setWidth(ref.getBoundingClientRect().width);
}, []);
return (
<RadixDM.Root modal={false} onOpenChange={onOpenChange}>
<RadixDM.Trigger ref={measureRef} asChild={asChild} className={triggerClassName}>
{trigger}
</RadixDM.Trigger>
<RadixDM.Portal>
<div>
<div className="fixed inset-0"></div>
<RadixDM.Content
className={clsx(
contextMenuClasses,
animate && 'animate-in fade-in data-[side=bottom]:slide-in-from-top-2',
'w-44',
className
)}
align="start"
collisionPadding={5}
style={{ width }}
{...props}
>
{children}
</RadixDM.Content>
</div>
</RadixDM.Portal>
</RadixDM.Root>
);
};
const Separator = (props: { className?: string }) => (
<RadixDM.Separator className={clsx(contextMenuSeparatorClassNames, props.className)} />
);
const SubMenu = ({
label,
icon,
className,
...props
}: RadixDM.MenuSubContentProps & ContextMenuItemProps) => {
return (
<RadixDM.Sub>
<RadixDM.SubTrigger className={contextSubMenuTriggerClassNames}>
<div
className={contextMenuItemStyles({
class: 'group-radix-state-open:bg-trinary/50 group-radix-state-open:text-primary'
})}
>
<ItemInternals rightArrow {...{ label, icon }} />
</div>
</RadixDM.SubTrigger>
<RadixDM.Portal>
<Suspense fallback={null}>
<RadixDM.SubContent
className={clsx(contextMenuClasses, className)}
collisionPadding={5}
{...props}
/>
</Suspense>
</RadixDM.Portal>
</RadixDM.Sub>
);
};
interface DropdownItemProps extends ContextMenuItemProps, RadixDM.MenuItemProps {
to?: string;
selected?: boolean;
}
const Item = ({
icon,
iconProps,
label,
rightArrow,
children,
keybind,
variant,
className,
selected,
to,
...props
}: DropdownItemProps) => {
const ref = useRef<HTMLDivElement>(null);
return (
<RadixDM.Item
className={clsx(
'text-menu-ink group cursor-default select-none py-0.5 focus:outline-none active:opacity-80',
className
)}
ref={ref}
{...props}
>
{to ? (
<Link
to={to}
className={contextMenuItemStyles({
variant,
className: clsx(selected && 'bg-accent')
})}
onClick={() => ref.current?.click()}
>
{children ? (
<span className="truncate">{children}</span>
) : (
<ItemInternals {...{ icon, iconProps, label, rightArrow, keybind }} />
)}
</Link>
) : (
<div
className={contextMenuItemStyles({ variant, className: clsx(selected && 'bg-accent') })}
>
{children || <ItemInternals {...{ icon, iconProps, label, rightArrow, keybind }} />}
</div>
)}
</RadixDM.Item>
);
};
export const DropdownMenu = {
Root,
Item,
Separator,
SubMenu
};

View file

@ -1,7 +1,8 @@
export { cva, cx } from 'class-variance-authority';
export * from './Button';
export * from './CheckBox';
export * as ContextMenu from './ContextMenu';
export { ContextMenu } from './ContextMenu';
export { DropdownMenu } from './DropdownMenu';
export * from './Dialog';
export * as Dropdown from './Dropdown';
export * from './Input';

View file

@ -162,6 +162,7 @@ module.exports = function (app, options) {
// addVariant('open', '&[data-state="open"]');
// addVariant('closed', '&[data-state="closed"]');
// }),
require('tailwindcss-animate'),
require('@headlessui/tailwindcss'),
require('tailwindcss-radix')()
]

View file

@ -593,6 +593,7 @@ importers:
storybook-tailwind-dark-mode: ^1.0.15
style-loader: ^3.3.1
tailwindcss: ^3.1.8
tailwindcss-animate: ^1.0.5
tailwindcss-radix: ^2.6.0
typescript: ^4.8.4
dependencies:
@ -646,6 +647,7 @@ importers:
storybook-tailwind-dark-mode: 1.0.15_pmt6gdvpkbsejpsyufhkfrbkda
style-loader: 3.3.1_webpack@5.75.0
tailwindcss: 3.2.4_postcss@8.4.21
tailwindcss-animate: 1.0.5_tailwindcss@3.2.4
typescript: 4.9.4
packages:
@ -20327,6 +20329,14 @@ packages:
resolution: {integrity: sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==}
dev: true
/tailwindcss-animate/1.0.5_tailwindcss@3.2.4:
resolution: {integrity: sha512-UU3qrOJ4lFQABY+MVADmBm+0KW3xZyhMdRvejwtXqYOL7YjHYxmuREFAZdmVG5LPe5E9CAst846SLC4j5I3dcw==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
dependencies:
tailwindcss: 3.2.4_postcss@8.4.21
dev: true
/tailwindcss-radix/2.7.0:
resolution: {integrity: sha512-fIVkT5zQYdsjT9+/Mvp+DTlJDdTFpRDuyS5+PLuJDAIIVr9+rWYKhK6rsB9QtjwUwwb0YF+BkAJN6CjZivOfLA==}
dev: false