feat: add new menu
This commit is contained in:
parent
28e40eecbf
commit
a19eb6bcda
@ -1,4 +1,3 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { type AriaButtonOptions, useButton } from 'react-aria';
|
import { type AriaButtonOptions, useButton } from 'react-aria';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
@ -7,10 +6,12 @@ export interface ButtonProps extends AriaButtonOptions<'button'> {
|
|||||||
variant?: 'heavy' | 'light' | 'danger';
|
variant?: 'heavy' | 'light' | 'danger';
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({ variant = 'light', ...props }: ButtonProps) {
|
export default function Button({ variant = 'light', ...props }: ButtonProps) {
|
||||||
const ref = useRef<HTMLButtonElement | null>(null);
|
// In case the button is used as a trigger ref
|
||||||
|
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
||||||
const { buttonProps } = useButton(props, ref);
|
const { buttonProps } = useButton(props, ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, HTMLProps } from 'react';
|
import { CheckIcon, CopyIcon } from '@primer/octicons-react';
|
||||||
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
|
import { HTMLProps, useState } from 'react';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
import { toast } from '~/components/Toaster';
|
import toast from '~/utils/toast';
|
||||||
|
|
||||||
interface Props extends HTMLProps<HTMLSpanElement> {
|
interface Props extends HTMLProps<HTMLSpanElement> {
|
||||||
isCopyable?: boolean;
|
isCopyable?: boolean;
|
||||||
@ -22,6 +22,7 @@ export default function Code(props: Props) {
|
|||||||
</code>
|
</code>
|
||||||
{props.isCopyable ? (
|
{props.isCopyable ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 p-1 rounded-md',
|
'ml-1 p-1 rounded-md',
|
||||||
'bg-ui-100 dark:bg-ui-800',
|
'bg-ui-100 dark:bg-ui-800',
|
||||||
|
|||||||
@ -111,7 +111,7 @@ function Panel(props: DialogPanelProps) {
|
|||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card className="w-full max-w-lg">
|
<Card className="w-full max-w-lg" variant="flat">
|
||||||
{children}
|
{children}
|
||||||
<div className="mt-6 flex justify-end gap-4">
|
<div className="mt-6 flex justify-end gap-4">
|
||||||
{variant === 'unactionable' ? (
|
{variant === 'unactionable' ? (
|
||||||
|
|||||||
@ -1,21 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
GearIcon,
|
CircleUser,
|
||||||
GlobeIcon,
|
Globe2,
|
||||||
LockIcon,
|
Lock,
|
||||||
PeopleIcon,
|
PlaneTakeoff,
|
||||||
PersonIcon,
|
Server,
|
||||||
ServerIcon,
|
Settings,
|
||||||
} from '@primer/octicons-react';
|
Users,
|
||||||
import { CircleUser, PlaneTakeoff } from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Form, NavLink } from 'react-router';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { NavLink, useSubmit } from 'react-router';
|
||||||
import { cn } from '~/utils/cn';
|
import Menu from '~/components/Menu';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
import type { HeadplaneContext } from '~/utils/config/headplane';
|
import type { HeadplaneContext } from '~/utils/config/headplane';
|
||||||
import type { SessionData } from '~/utils/sessions.server';
|
import type { SessionData } from '~/utils/sessions.server';
|
||||||
|
|
||||||
import Menu from './Menu';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: HeadplaneContext['config'];
|
config: HeadplaneContext['config'];
|
||||||
user?: SessionData['user'];
|
user?: SessionData['user'];
|
||||||
@ -40,7 +38,7 @@ function TabLink({ name, to, icon }: TabLinkProps) {
|
|||||||
prefetch="intent"
|
prefetch="intent"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2',
|
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
||||||
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
||||||
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
||||||
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
||||||
@ -68,13 +66,17 @@ function Link({ href, text }: LinkProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Header(data: Props) {
|
export default function Header(data: Props) {
|
||||||
|
const submit = useSubmit();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn(
|
<header
|
||||||
'bg-headplane-100 dark:bg-headplane-950',
|
className={cn(
|
||||||
'text-headplane-800 dark:text-headplane-200',
|
'bg-headplane-100 dark:bg-headplane-950',
|
||||||
'dark:border-b dark:border-headplane-800',
|
'text-headplane-800 dark:text-headplane-200',
|
||||||
'shadow-inner',
|
'dark:border-b dark:border-headplane-800',
|
||||||
)}>
|
'shadow-inner',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="container flex items-center justify-between py-4">
|
<div className="container flex items-center justify-between py-4">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<PlaneTakeoff />
|
<PlaneTakeoff />
|
||||||
@ -86,43 +88,35 @@ export default function Header(data: Props) {
|
|||||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||||
{data.user ? (
|
{data.user ? (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.IconButton className="p-0">
|
<Menu.IconButton label="User">
|
||||||
<CircleUser />
|
<CircleUser />
|
||||||
</Menu.IconButton>
|
</Menu.IconButton>
|
||||||
<Menu.Items>
|
<Menu.Panel
|
||||||
<Menu.Item className="text-right">
|
onAction={(key) => {
|
||||||
<p className="font-bold">{data.user.name}</p>
|
if (key === 'logout') {
|
||||||
<p>{data.user.email}</p>
|
submit(
|
||||||
</Menu.Item>
|
{},
|
||||||
<Menu.Item className="text-right sm:hidden">
|
{
|
||||||
<Link
|
method: 'POST',
|
||||||
isMenu
|
action: '/logout',
|
||||||
href="https://tailscale.com/download"
|
},
|
||||||
text="Download"
|
);
|
||||||
/>
|
}
|
||||||
</Menu.Item>
|
}}
|
||||||
<Menu.Item className="text-right sm:hidden">
|
disabledKeys={['profile']}
|
||||||
<Link
|
>
|
||||||
isMenu
|
<Menu.Section>
|
||||||
href="https://github.com/tale/headplane"
|
<Menu.Item key="profile" textValue="Profile">
|
||||||
text="GitHub"
|
<div className="text-black dark:text-headplane-50">
|
||||||
/>
|
<p className="font-bold">{data.user.name}</p>
|
||||||
</Menu.Item>
|
<p>{data.user.email}</p>
|
||||||
<Menu.Item className="text-right sm:hidden">
|
</div>
|
||||||
<Link
|
</Menu.Item>
|
||||||
isMenu
|
<Menu.Item key="logout" textValue="Logout">
|
||||||
href="https://github.com/juanfont/headscale"
|
<p className="text-red-500 dark:text-red-400">Logout</p>
|
||||||
text="Headscale"
|
</Menu.Item>
|
||||||
/>
|
</Menu.Section>
|
||||||
</Menu.Item>
|
</Menu.Panel>
|
||||||
<Menu.Item className="text-red-500 dark:text-red-400">
|
|
||||||
<Form method="POST" action="/logout">
|
|
||||||
<button type="submit" className="w-full text-right">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</Form>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
@ -131,29 +125,21 @@ export default function Header(data: Props) {
|
|||||||
<TabLink
|
<TabLink
|
||||||
to="/machines"
|
to="/machines"
|
||||||
name="Machines"
|
name="Machines"
|
||||||
icon={<ServerIcon className="w-4 h-4" />}
|
icon={<Server className="w-5" />}
|
||||||
/>
|
|
||||||
<TabLink
|
|
||||||
to="/users"
|
|
||||||
name="Users"
|
|
||||||
icon={<PeopleIcon className="w-4 h-4" />}
|
|
||||||
/>
|
/>
|
||||||
|
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/acls"
|
to="/acls"
|
||||||
name="Access Control"
|
name="Access Control"
|
||||||
icon={<LockIcon className="w-4 h-4" />}
|
icon={<Lock className="w-5" />}
|
||||||
/>
|
/>
|
||||||
{data.config.read ? (
|
{data.config.read ? (
|
||||||
<>
|
<>
|
||||||
<TabLink
|
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
|
||||||
to="/dns"
|
|
||||||
name="DNS"
|
|
||||||
icon={<GlobeIcon className="w-4 h-4" />}
|
|
||||||
/>
|
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
name="Settings"
|
name="Settings"
|
||||||
icon={<GearIcon className="w-4 h-4" />}
|
icon={<Settings className="w-5" />}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useButton, type AriaButtonOptions } from 'react-aria';
|
import { type AriaButtonOptions, useButton } from 'react-aria';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
export interface IconButtonProps extends AriaButtonOptions<'button'> {
|
export interface IconButtonProps extends AriaButtonOptions<'button'> {
|
||||||
variant?: 'heavy' | 'light'
|
variant?: 'heavy' | 'light';
|
||||||
className?: string
|
className?: string;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
label: string
|
label: string;
|
||||||
|
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconButton({ variant = 'light', ...props }: IconButtonProps) {
|
export default function IconButton({
|
||||||
const ref = useRef<HTMLButtonElement | null>(null);
|
variant = 'light',
|
||||||
|
...props
|
||||||
|
}: IconButtonProps) {
|
||||||
|
// In case the button is used as a trigger ref
|
||||||
|
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
|
||||||
const { buttonProps } = useButton(props, ref);
|
const { buttonProps } = useButton(props, ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -25,17 +29,18 @@ export default function IconButton({ variant = 'light', ...props }: IconButtonPr
|
|||||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||||
...(variant === 'heavy'
|
...(variant === 'heavy'
|
||||||
? [
|
? [
|
||||||
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||||
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||||
'text-headplane-200 dark:text-headplane-800'
|
'text-headplane-200 dark:text-headplane-800',
|
||||||
] : [
|
]
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
: [
|
||||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||||
]),
|
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||||
|
]),
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { type AriaTextFieldProps, useTextField } from 'react-aria';
|
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
const { label } = props;
|
const { label, className } = props;
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const id = useId(props.id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
labelProps,
|
labelProps,
|
||||||
inputProps,
|
inputProps,
|
||||||
@ -22,7 +25,7 @@ export default function Input(props: InputProps) {
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
htmlFor={props.name}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
'text-xs font-medium px-3 mb-0.5',
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
'text-headplane-700 dark:text-headplane-100',
|
||||||
@ -34,11 +37,13 @@ export default function Input(props: InputProps) {
|
|||||||
{...inputProps}
|
{...inputProps}
|
||||||
required={props.isRequired}
|
required={props.isRequired}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl px-3 py-2',
|
'rounded-xl px-3 py-2',
|
||||||
'focus:outline-none focus:ring',
|
'focus:outline-none focus:ring',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
|
|||||||
@ -1,89 +1,150 @@
|
|||||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
import React, { useRef, cloneElement } from 'react';
|
||||||
import Button from '~/components/Button';
|
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
|
||||||
import IconButton from '~/components/IconButton';
|
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
|
||||||
|
import { Item, Section } from 'react-stately';
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
type MenuTriggerProps,
|
||||||
Menu as AriaMenu,
|
Node,
|
||||||
MenuItem,
|
TreeState,
|
||||||
MenuTrigger,
|
useMenuTriggerState,
|
||||||
Popover,
|
useTreeState,
|
||||||
} from 'react-aria-components';
|
} from 'react-stately';
|
||||||
import { cn } from '~/utils/cn';
|
import Button, { ButtonProps } from '~/components/Button';
|
||||||
|
import IconButton, { IconButtonProps } from '~/components/IconButton';
|
||||||
|
import Popover from '~/components/Popover';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
function Items(props: Parameters<typeof AriaMenu>[0]) {
|
interface MenuProps extends MenuTriggerProps {
|
||||||
|
placement?: Placement;
|
||||||
|
children: [
|
||||||
|
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||||
|
React.ReactElement<MenuPanelProps>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: onAction is called twice for some reason?
|
||||||
|
function Menu(props: MenuProps) {
|
||||||
|
const { placement = 'bottom' } = props;
|
||||||
|
const state = useMenuTriggerState(props);
|
||||||
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||||
|
{},
|
||||||
|
state,
|
||||||
|
ref,
|
||||||
|
);
|
||||||
|
|
||||||
|
// cloneElement is necessary because the button is a union type
|
||||||
|
// of multiple things and we need to join props from our hooks
|
||||||
|
const [button, panel] = props.children;
|
||||||
return (
|
return (
|
||||||
<Popover
|
<div>
|
||||||
|
{cloneElement(button, {
|
||||||
|
...menuTriggerProps,
|
||||||
|
ref,
|
||||||
|
})}
|
||||||
|
{state.isOpen && (
|
||||||
|
<Popover state={state} triggerRef={ref} placement={placement}>
|
||||||
|
{cloneElement(panel, {
|
||||||
|
...menuProps,
|
||||||
|
autoFocus: state.focusStrategy ?? true,
|
||||||
|
onClose: () => state.close(),
|
||||||
|
})}
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel(props: MenuPanelProps) {
|
||||||
|
const state = useTreeState(props);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const { menuProps } = useMenu(props, state, ref);
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
{...menuProps}
|
||||||
|
ref={ref}
|
||||||
|
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
||||||
|
>
|
||||||
|
{[...state.collection].map((item) => (
|
||||||
|
<MenuSection key={item.key} section={item} state={state} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuSectionProps<T> {
|
||||||
|
section: Node<T>;
|
||||||
|
state: TreeState<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||||
|
const { itemProps, groupProps } = useMenuSection({
|
||||||
|
heading: section.rendered,
|
||||||
|
'aria-label': section['aria-label'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { separatorProps } = useSeparator({
|
||||||
|
elementType: 'li',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{section.key !== state.collection.getFirstKey() ? (
|
||||||
|
<li
|
||||||
|
{...separatorProps}
|
||||||
|
className="border-t border-gray-300 mx-2 mt-1 mb-1"
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
<li {...itemProps}>
|
||||||
|
<ul {...groupProps}>
|
||||||
|
{[...section.childNodes].map((item) => (
|
||||||
|
<MenuItem key={item.key} item={item} state={state} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItemProps<T> {
|
||||||
|
item: Node<T>;
|
||||||
|
state: TreeState<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
|
||||||
|
const ref = useRef<HTMLLIElement | null>(null);
|
||||||
|
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||||
|
|
||||||
|
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||||
|
const isDisabled = state.selectionManager.isDisabled(item.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
{...menuItemProps}
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 rounded-md',
|
'py-2 px-3 mx-1 rounded-lg',
|
||||||
'bg-ui-50 dark:bg-ui-800',
|
'focus:outline-none select-none',
|
||||||
'overflow-hidden z-50',
|
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
||||||
'border border-ui-200 dark:border-ui-600',
|
isDisabled
|
||||||
'entering:animate-in exiting:animate-out',
|
? 'text-headplane-400 dark:text-headplane-600'
|
||||||
'entering:fade-in entering:zoom-in-95',
|
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
|
||||||
'exiting:fade-out exiting:zoom-out-95',
|
|
||||||
'fill-mode-forwards origin-left-right',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AriaMenu
|
{item.rendered}
|
||||||
{...props}
|
</li>
|
||||||
className={cn(
|
|
||||||
'outline-none',
|
|
||||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</AriaMenu>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ButtonProps = Parameters<typeof AriaButton>[0] & {
|
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
|
||||||
};
|
|
||||||
|
|
||||||
function ItemButton(props: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<MenuItem className="outline-none">
|
|
||||||
<AriaButton
|
|
||||||
{...props}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 w-full outline-none text-left',
|
|
||||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
aria-label="Menu Dialog"
|
|
||||||
// If control is passed, set the state value
|
|
||||||
onPress={(event) => {
|
|
||||||
props.onPress?.(event);
|
|
||||||
props.control?.[1](true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Item(props: Parameters<typeof MenuItem>[0]) {
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
{...props}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 w-full outline-none',
|
|
||||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Menu({ children }: { children: ReactNode }) {
|
|
||||||
return <MenuTrigger>{children}</MenuTrigger>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Object.assign(Menu, {
|
export default Object.assign(Menu, {
|
||||||
IconButton,
|
|
||||||
Button,
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Panel,
|
||||||
|
Section,
|
||||||
Item,
|
Item,
|
||||||
ItemButton,
|
|
||||||
Items
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
import { PlusIcon, DashIcon } from '@primer/octicons-react';
|
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Input,
|
|
||||||
NumberField as AriaNumberField,
|
|
||||||
} from 'react-aria-components';
|
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
|
||||||
label: string;
|
|
||||||
state?: [number, Dispatch<SetStateAction<number>>];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NumberField(props: NumberFieldProps) {
|
|
||||||
return (
|
|
||||||
<AriaNumberField
|
|
||||||
{...props}
|
|
||||||
aria-label={props.label}
|
|
||||||
className="w-full"
|
|
||||||
value={props.state?.[0]}
|
|
||||||
onChange={(value) => {
|
|
||||||
props.state?.[1](value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group
|
|
||||||
className={cn(
|
|
||||||
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
|
||||||
'border border-ui-200 dark:border-ui-600',
|
|
||||||
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
|
||||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Input className="w-full bg-transparent focus:outline-none" />
|
|
||||||
<Button slot="decrement">
|
|
||||||
<DashIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button slot="increment">
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</AriaNumberField>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -36,6 +36,7 @@ export default function NumberInput(props: InputProps) {
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
|
// TODO: This is WRONG use useId
|
||||||
htmlFor={name}
|
htmlFor={name}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
'text-xs font-medium px-3 mb-0.5',
|
||||||
@ -58,7 +59,7 @@ export default function NumberInput(props: InputProps) {
|
|||||||
required={props.isRequired}
|
required={props.isRequired}
|
||||||
name={name}
|
name={name}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="w-full pl-3 py-2 rounded-l-xl focus:outline-none"
|
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
{...decrementButtonProps}
|
{...decrementButtonProps}
|
||||||
|
|||||||
49
app/components/Popover.tsx
Normal file
49
app/components/Popover.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import {
|
||||||
|
type AriaPopoverProps,
|
||||||
|
DismissButton,
|
||||||
|
Overlay,
|
||||||
|
usePopover,
|
||||||
|
} from 'react-aria';
|
||||||
|
import type { OverlayTriggerState } from 'react-stately';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
|
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
state: OverlayTriggerState;
|
||||||
|
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Popover(props: PopoverProps) {
|
||||||
|
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
|
||||||
|
const { state, children, className } = props;
|
||||||
|
const { popoverProps, underlayProps } = usePopover(
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
popoverRef: ref,
|
||||||
|
offset: 8,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay>
|
||||||
|
<div {...underlayProps} className="fixed inset-0" />
|
||||||
|
<div
|
||||||
|
{...popoverProps}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-10 shadow-sm rounded-xl overflow-hidden',
|
||||||
|
'bg-white dark:bg-headplane-900',
|
||||||
|
'border border-headplane-200 dark:border-headplane-800',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DismissButton onDismiss={state.close} />
|
||||||
|
{children}
|
||||||
|
<DismissButton onDismiss={state.close} />
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,79 +1,166 @@
|
|||||||
import { ChevronDownIcon } from '@primer/octicons-react';
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
import { useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
AriaComboBoxProps,
|
||||||
ListBox,
|
AriaListBoxOptions,
|
||||||
ListBoxItem,
|
useButton,
|
||||||
Popover,
|
useComboBox,
|
||||||
Select as AriaSelect,
|
useFilter,
|
||||||
SelectValue,
|
useListBox,
|
||||||
} from 'react-aria-components';
|
useOption,
|
||||||
import { cn } from '~/utils/cn';
|
} from 'react-aria';
|
||||||
|
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
|
||||||
|
import Popover from '~/components/Popover';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
export interface SelectProps extends AriaComboBoxProps<object> {}
|
||||||
readonly label: string;
|
|
||||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
|
||||||
readonly children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Select(props: SelectProps) {
|
function Select(props: SelectProps) {
|
||||||
|
const { contains } = useFilter({ sensitivity: 'base' });
|
||||||
|
const state = useComboBoxState({ ...props, defaultFilter: contains });
|
||||||
|
|
||||||
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const listBoxRef = useRef<HTMLUListElement | null>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
buttonProps: triggerProps,
|
||||||
|
inputProps,
|
||||||
|
listBoxProps,
|
||||||
|
labelProps,
|
||||||
|
descriptionProps,
|
||||||
|
} = useComboBox(
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
inputRef,
|
||||||
|
buttonRef,
|
||||||
|
listBoxRef,
|
||||||
|
popoverRef,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { buttonProps } = useButton(triggerProps, buttonRef);
|
||||||
return (
|
return (
|
||||||
<AriaSelect
|
<div className="flex flex-col">
|
||||||
{...props}
|
<label
|
||||||
aria-label={props.label}
|
{...labelProps}
|
||||||
selectedKey={props.state?.[0]}
|
// TODO: THIS IS WRONG, use useId
|
||||||
onSelectionChange={(key) => {
|
htmlFor={props['aria-labelledby']}
|
||||||
props.state?.[1](key.toString());
|
className={cn(
|
||||||
}}
|
'text-xs font-medium px-3 mb-0.5',
|
||||||
className={cn(
|
'text-headplane-700 dark:text-headplane-100',
|
||||||
'block w-full rounded-lg my-1',
|
)}
|
||||||
'border border-ui-200 dark:border-ui-600',
|
>
|
||||||
'bg-white dark:bg-ui-800 dark:text-ui-300',
|
{props.label}
|
||||||
'focus-within:outline-6',
|
</label>
|
||||||
props.className,
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex rounded-xl focus:outline-none focus-within:ring',
|
||||||
|
'bg-white dark:bg-headplane-900',
|
||||||
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
ref={inputRef}
|
||||||
|
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
{...buttonProps}
|
||||||
|
ref={buttonRef}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||||
|
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||||
|
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="p-0.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{props.description && (
|
||||||
|
<div
|
||||||
|
{...descriptionProps}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-3 mt-1',
|
||||||
|
'text-headplane-500 dark:text-headplane-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.description}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
{state.isOpen && (
|
||||||
<Button
|
<Popover
|
||||||
className={cn(
|
popoverRef={popoverRef}
|
||||||
'w-full flex items-center justify-between',
|
triggerRef={inputRef}
|
||||||
'px-2.5 py-1.5 rounded-lg',
|
state={state}
|
||||||
)}
|
isNonModal
|
||||||
>
|
placement="bottom start"
|
||||||
<SelectValue />
|
className="w-full max-w-xs"
|
||||||
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
>
|
||||||
</Button>
|
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||||
<Popover
|
</Popover>
|
||||||
className={cn(
|
)}
|
||||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
</div>
|
||||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
|
||||||
'z-50 overflow-y-auto',
|
|
||||||
'border border-ui-200 dark:border-ui-600',
|
|
||||||
'entering:animate-in exiting:animate-out',
|
|
||||||
'entering:fade-in entering:zoom-in-95',
|
|
||||||
'exiting:fade-out exiting:zoom-out-95',
|
|
||||||
'fill-mode-forwards origin-left-right',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ListBox orientation="vertical">{props.children}</ListBox>
|
|
||||||
</Popover>
|
|
||||||
</AriaSelect>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemProps = Parameters<typeof ListBoxItem>[0];
|
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||||
|
listBoxRef?: React.RefObject<HTMLUListElement | null>;
|
||||||
|
state: ListState<object>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListBox(props: ListBoxProps) {
|
||||||
|
const { listBoxRef, state } = props;
|
||||||
|
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
|
||||||
|
const { listBoxProps } = useListBox(props, state, ref);
|
||||||
|
|
||||||
function Item(props: ItemProps) {
|
|
||||||
return (
|
return (
|
||||||
<ListBoxItem
|
<ul
|
||||||
{...props}
|
{...listBoxProps}
|
||||||
|
ref={listBoxRef}
|
||||||
|
className="w-full max-h-72 overflow-auto outline-none pt-1"
|
||||||
|
>
|
||||||
|
{[...state.collection].map((item) => (
|
||||||
|
<Option key={item.key} item={item} state={state} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionProps {
|
||||||
|
item: Node<unknown>;
|
||||||
|
state: ListState<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Option({ item, state }: OptionProps) {
|
||||||
|
const ref = useRef<HTMLLIElement | null>(null);
|
||||||
|
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
|
||||||
|
{
|
||||||
|
key: item.key,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
ref,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
{...optionProps}
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-4 py-2 w-full outline-none w-full',
|
'flex items-center justify-between',
|
||||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||||
props.className,
|
'focus:outline-none select-none',
|
||||||
|
isFocused || isSelected
|
||||||
|
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
||||||
|
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||||
|
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{item.rendered}
|
||||||
</ListBoxItem>
|
{isSelected && <Check className="p-0.5" />}
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
import { Input, TextField as AriaTextField } from 'react-aria-components';
|
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
|
|
||||||
readonly label: string;
|
|
||||||
readonly placeholder: string;
|
|
||||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TextField(props: TextFieldProps) {
|
|
||||||
return (
|
|
||||||
<AriaTextField {...props} aria-label={props.label} className="w-full">
|
|
||||||
<Input
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
value={props.state?.[0]}
|
|
||||||
name={props.name}
|
|
||||||
className={cn(
|
|
||||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
|
||||||
'border border-ui-200 dark:border-ui-600',
|
|
||||||
'dark:bg-ui-800 dark:text-ui-300',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
onChange={(event) => {
|
|
||||||
props.state?.[1](event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AriaTextField>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { XIcon } from '@primer/octicons-react';
|
|
||||||
import {
|
|
||||||
AriaToastProps,
|
|
||||||
useToast,
|
|
||||||
useToastRegion,
|
|
||||||
} from '@react-aria/toast';
|
|
||||||
import {
|
|
||||||
ToastQueue,
|
|
||||||
ToastState,
|
|
||||||
useToastQueue,
|
|
||||||
} from '@react-stately/toast';
|
|
||||||
import { ReactNode, useRef } from 'react';
|
|
||||||
import { Button } from 'react-aria-components';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { ClientOnly } from 'remix-utils/client-only';
|
|
||||||
import { cn } from '~/utils/cn';
|
|
||||||
|
|
||||||
type ToastProps = AriaToastProps<ReactNode> & {
|
|
||||||
readonly state: ToastState<ReactNode>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Toast({ state, ...properties }: ToastProps) {
|
|
||||||
const reference = useRef(null);
|
|
||||||
|
|
||||||
const { toastProps, titleProps, closeButtonProps } = useToast(
|
|
||||||
properties,
|
|
||||||
state,
|
|
||||||
reference,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...toastProps}
|
|
||||||
ref={reference}
|
|
||||||
className={cn(
|
|
||||||
'bg-main-700 dark:bg-main-800 rounded-lg',
|
|
||||||
'text-main-100 dark:text-main-200 z-50',
|
|
||||||
'border border-main-600 dark:border-main-700',
|
|
||||||
'flex items-center justify-between p-3 pl-4 w-80',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div {...titleProps}>{properties.toast.content}</div>
|
|
||||||
<Button
|
|
||||||
{...closeButtonProps}
|
|
||||||
className={cn(
|
|
||||||
'outline-none rounded-full p-1',
|
|
||||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<XIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toasts = new ToastQueue<ReactNode>({
|
|
||||||
maxVisibleToasts: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function toast(text: string) {
|
|
||||||
return toasts.add(text, { timeout: 5000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const reference = useRef(null);
|
|
||||||
const state = useToastQueue(toasts);
|
|
||||||
|
|
||||||
const { regionProps } = useToastRegion({}, state, reference);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClientOnly>
|
|
||||||
{
|
|
||||||
() =>
|
|
||||||
createPortal(
|
|
||||||
state.visibleToasts.length >= 0 ? (
|
|
||||||
<div
|
|
||||||
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
|
|
||||||
{...regionProps}
|
|
||||||
ref={reference}
|
|
||||||
>
|
|
||||||
{state.visibleToasts.map((toast) => (
|
|
||||||
<Toast key={toast.key} toast={toast} state={state} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : undefined,
|
|
||||||
document.body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</ClientOnly>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user