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 { 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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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' ? (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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">
|
||||
<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}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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