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 { type AriaButtonOptions, useButton } from 'react-aria';
import { cn } from '~/utils/cn';
@ -7,10 +6,12 @@ export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
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);
return (

View File

@ -1,7 +1,7 @@
import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { CheckIcon, CopyIcon } from '@primer/octicons-react';
import { HTMLProps, useState } from 'react';
import { cn } from '~/utils/cn';
import { toast } from '~/components/Toaster';
import toast from '~/utils/toast';
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
@ -22,6 +22,7 @@ export default function Code(props: Props) {
</code>
{props.isCopyable ? (
<button
type="button"
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',

View File

@ -111,7 +111,7 @@ function Panel(props: DialogPanelProps) {
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg">
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (

View File

@ -1,21 +1,19 @@
import {
GearIcon,
GlobeIcon,
LockIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { CircleUser, PlaneTakeoff } from 'lucide-react';
import { Form, NavLink } from 'react-router';
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import cn from '~/utils/cn';
import type { HeadplaneContext } from '~/utils/config/headplane';
import type { SessionData } from '~/utils/sessions.server';
import Menu from './Menu';
interface Props {
config: HeadplaneContext['config'];
user?: SessionData['user'];
@ -40,7 +38,7 @@ function TabLink({ name, to, icon }: TabLinkProps) {
prefetch="intent"
className={({ isActive }) =>
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:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
@ -68,13 +66,17 @@ function Link({ href, text }: LinkProps) {
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800',
'shadow-inner',
)}>
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800',
'shadow-inner',
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PlaneTakeoff />
@ -86,43 +88,35 @@ export default function Header(data: Props) {
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton className="p-0">
<Menu.IconButton label="User">
<CircleUser />
</Menu.IconButton>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
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.Panel
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>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
@ -131,29 +125,21 @@ export default function Header(data: Props) {
<TabLink
to="/machines"
name="Machines"
icon={<ServerIcon className="w-4 h-4" />}
/>
<TabLink
to="/users"
name="Users"
icon={<PeopleIcon className="w-4 h-4" />}
icon={<Server className="w-5" />}
/>
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
<TabLink
to="/acls"
name="Access Control"
icon={<LockIcon className="w-4 h-4" />}
icon={<Lock className="w-5" />}
/>
{data.config.read ? (
<>
<TabLink
to="/dns"
name="DNS"
icon={<GlobeIcon className="w-4 h-4" />}
/>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
<TabLink
to="/settings"
name="Settings"
icon={<GearIcon className="w-4 h-4" />}
icon={<Settings className="w-5" />}
/>
</>
) : undefined}

View File

@ -1,17 +1,21 @@
import type { Dispatch, SetStateAction } 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';
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light'
className?: string
children: React.ReactNode
label: string
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({ variant = 'light', ...props }: IconButtonProps) {
const ref = useRef<HTMLButtonElement | null>(null);
export default function IconButton({
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);
return (
@ -25,17 +29,18 @@ export default function IconButton({ variant = 'light', ...props }: IconButtonPr
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'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-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'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',
]),
props.className,
)}
>
{props.children}
</button>
)
);
}

View File

@ -1,14 +1,17 @@
import { useRef } from 'react';
import { type AriaTextFieldProps, useTextField } from 'react-aria';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
isRequired?: boolean;
className?: string;
}
export default function Input(props: InputProps) {
const { label } = props;
const { label, className } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
@ -22,7 +25,7 @@ export default function Input(props: InputProps) {
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={props.name}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
@ -34,11 +37,13 @@ export default function Input(props: InputProps) {
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
/>
{props.description && (

View File

@ -1,89 +1,150 @@
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import Button from '~/components/Button';
import IconButton from '~/components/IconButton';
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
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 (
<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(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'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',
'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',
)}
>
<AriaMenu
{...props}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
props.className,
)}
>
{props.children}
</AriaMenu>
</Popover>
{item.rendered}
</li>
);
}
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, {
IconButton,
Button,
IconButton,
Panel,
Section,
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">
<label
{...labelProps}
// TODO: This is WRONG use useId
htmlFor={name}
className={cn(
'text-xs font-medium px-3 mb-0.5',
@ -58,7 +59,7 @@ export default function NumberInput(props: InputProps) {
required={props.isRequired}
name={name}
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
{...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 type { Dispatch, ReactNode, SetStateAction } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
Button,
ListBox,
ListBoxItem,
Popover,
Select as AriaSelect,
SelectValue,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useListBox,
useOption,
} 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] & {
readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode;
};
export interface SelectProps extends AriaComboBoxProps<object> {}
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 (
<AriaSelect
{...props}
aria-label={props.label}
selectedKey={props.state?.[0]}
onSelectionChange={(key) => {
props.state?.[1](key.toString());
}}
className={cn(
'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',
'focus-within:outline-6',
props.className,
<div className="flex flex-col">
<label
{...labelProps}
// TODO: THIS IS WRONG, use useId
htmlFor={props['aria-labelledby']}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{props.label}
</label>
<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>
)}
>
<Button
className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
>
<SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
</Button>
<Popover
className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]',
'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>
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</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 (
<ListBoxItem
{...props}
<ul
{...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(
'px-4 py-2 w-full outline-none w-full',
'hover:bg-ui-200 dark:hover:bg-ui-700',
props.className,
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'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}
</ListBoxItem>
{item.rendered}
{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>
);
}