feat: add new menu

This commit is contained in:
Aarnav Tale 2025-01-28 16:04:42 -05:00
parent 28e40eecbf
commit a19eb6bcda
No known key found for this signature in database
13 changed files with 428 additions and 399 deletions

View File

@ -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 (

View File

@ -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',

View File

@ -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' ? (

View File

@ -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
className={cn(
'bg-headplane-100 dark:bg-headplane-950', 'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200', 'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800', 'dark:border-b dark:border-headplane-800',
'shadow-inner', '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) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-headplane-50">
<p className="font-bold">{data.user.name}</p> <p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p> <p>{data.user.email}</p>
</div>
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item key="logout" textValue="Logout">
<Link <p className="text-red-500 dark:text-red-400">Logout</p>
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> </Menu.Section>
<Link </Menu.Panel>
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<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}

View File

@ -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 (
@ -27,8 +31,9 @@ export default function IconButton({ variant = 'light', ...props }: IconButtonPr
? [ ? [
'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', 'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30', 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]), ]),
@ -37,5 +42,5 @@ export default function IconButton({ variant = 'light', ...props }: IconButtonPr
> >
{props.children} {props.children}
</button> </button>
) );
} }

View File

@ -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 && (

View File

@ -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>
className={cn( {cloneElement(button, {
'mt-2 rounded-md', ...menuTriggerProps,
'bg-ui-50 dark:bg-ui-800', ref,
'overflow-hidden z-50', })}
'border border-ui-200 dark:border-ui-600', {state.isOpen && (
'entering:animate-in exiting:animate-out', <Popover state={state} triggerRef={ref} placement={placement}>
'entering:fade-in entering:zoom-in-95', {cloneElement(panel, {
'exiting:fade-out exiting:zoom-out-95', ...menuProps,
'fill-mode-forwards origin-left-right', autoFocus: state.focusStrategy ?? true,
)} onClose: () => state.close(),
> })}
<AriaMenu
{...props}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
props.className,
)}
>
{props.children}
</AriaMenu>
</Popover> </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" </div>
// 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]) { 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 ( return (
<MenuItem <ul
{...props} {...menuProps}
className={cn( ref={ref}
'px-4 py-2 w-full outline-none', className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
'hover:bg-ui-200 dark:hover:bg-ui-700', >
props.className, {[...state.collection].map((item) => (
)} <MenuSection key={item.key} section={item} state={state} />
/> ))}
</ul>
); );
} }
function Menu({ children }: { children: ReactNode }) { interface MenuSectionProps<T> {
return <MenuTrigger>{children}</MenuTrigger>; 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(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-none select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
)}
>
{item.rendered}
</li>
);
} }
export default Object.assign(Menu, { export default Object.assign(Menu, {
IconButton,
Button, Button,
IconButton,
Panel,
Section,
Item, Item,
ItemButton,
Items
}); });

View File

@ -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>
);
}

View File

@ -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}

View 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>
);
}

View File

@ -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( className={cn(
'block w-full rounded-lg my-1', 'text-xs font-medium px-3 mb-0.5',
'border border-ui-200 dark:border-ui-600', 'text-headplane-700 dark:text-headplane-100',
'bg-white dark:bg-ui-800 dark:text-ui-300',
'focus-within:outline-6',
props.className,
)} )}
> >
<Button {props.label}
</label>
<div
className={cn( className={cn(
'w-full flex items-center justify-between', 'flex rounded-xl focus:outline-none focus-within:ring',
'px-2.5 py-1.5 rounded-lg', 'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)} )}
> >
<SelectValue /> <input
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" /> {...inputProps}
</Button> 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 && (
<Popover <Popover
className={cn( popoverRef={popoverRef}
'mt-2 rounded-md w-[var(--trigger-width)]', triggerRef={inputRef}
'bg-ui-100 dark:bg-ui-800 shadow-sm', state={state}
'z-50 overflow-y-auto', isNonModal
'border border-ui-200 dark:border-ui-600', placement="bottom start"
'entering:animate-in exiting:animate-out', className="w-full max-w-xs"
'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> <ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover> </Popover>
</AriaSelect> )}
</div>
); );
} }
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>
); );
} }

View File

@ -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>
);
}

View File

@ -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>
);
}