mirror of
https://github.com/tale/headplane.git
synced 2026-08-17 16:47:51 +00:00
feat: swap button and decompose dialog components
This commit is contained in:
@@ -14,19 +14,13 @@ import {
|
||||
useOverlayTriggerState,
|
||||
} from "react-stately";
|
||||
|
||||
import Button, { ButtonProps } from "~/components/Button";
|
||||
import IconButton, { IconButtonProps } from "~/components/IconButton";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Button, { ButtonProps } from "~/components/button";
|
||||
import cn from "~/utils/cn";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
export interface DialogProps extends OverlayTriggerProps {
|
||||
children:
|
||||
| [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<DialogPanelProps>,
|
||||
]
|
||||
| [React.ReactElement<ButtonProps>, React.ReactElement<DialogPanelProps>]
|
||||
| React.ReactElement<DialogPanelProps>;
|
||||
}
|
||||
|
||||
@@ -179,10 +173,5 @@ function DModal(props: DModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, {
|
||||
Button,
|
||||
IconButton,
|
||||
Panel,
|
||||
Title,
|
||||
Text,
|
||||
});
|
||||
export { Panel as DialogPanel };
|
||||
export default Dialog;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { useRef } from "react";
|
||||
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;
|
||||
ref?: React.RefObject<HTMLButtonElement | 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 (
|
||||
<button
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
aria-label={props.label}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full p-1",
|
||||
"transition-colors duration-100",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
props.isDisabled && "pointer-events-none opacity-50",
|
||||
...(variant === "heavy"
|
||||
? [
|
||||
"bg-indigo-500 font-semibold text-white",
|
||||
"hover:bg-indigo-500/90",
|
||||
"dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80",
|
||||
]
|
||||
: ["bg-mist-100 dark:bg-mist-700/30", "hover:bg-mist-200/90 dark:hover:bg-mist-800/30"]),
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useRef } from "react";
|
||||
import { type AriaNumberFieldProps, useId, useLocale, useNumberField } from "react-aria";
|
||||
import { useNumberFieldState } from "react-stately";
|
||||
|
||||
import IconButton from "~/components/IconButton";
|
||||
import Button from "~/components/button";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface InputProps extends AriaNumberFieldProps {
|
||||
@@ -57,12 +57,20 @@ export default function NumberInput(props: InputProps) {
|
||||
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
|
||||
/>
|
||||
<input type="hidden" name={name} value={state.numberValue} />
|
||||
<IconButton {...decrementButtonProps} label="Decrement" className="h-7.5 w-7.5 rounded-lg">
|
||||
<Minus className="p-1" />
|
||||
</IconButton>
|
||||
<IconButton {...incrementButtonProps} label="Increment" className="h-7.5 w-7.5 rounded-lg">
|
||||
<Plus className="p-1" />
|
||||
</IconButton>
|
||||
<Button
|
||||
{...decrementButtonProps}
|
||||
aria-label="Decrement"
|
||||
className="h-7.5 w-7.5 rounded-lg p-1"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
{...incrementButtonProps}
|
||||
aria-label="Increment"
|
||||
className="h-7.5 w-7.5 rounded-lg p-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{props.description && (
|
||||
<div
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
|
||||
import { Item, Node, TabListState, useTabListState } from "react-stately";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface OptionsProps extends AriaTabListProps<object> {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Options({ label, className, ...props }: OptionsProps) {
|
||||
const state = useTabListState(props);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabListProps } = useTabList(props, state, ref);
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div {...tabListProps} ref={ref} className="flex items-center gap-2 overflow-x-scroll">
|
||||
{[...state.collection].map((item) => (
|
||||
<Option key={item.key} item={item} state={state} />
|
||||
))}
|
||||
</div>
|
||||
<OptionsPanel key={state.selectedItem?.key} state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsOptionProps {
|
||||
item: Node<object>;
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function Option({ item, state }: OptionsOptionProps) {
|
||||
const { key, rendered } = item;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabProps } = useTab({ key }, state, ref);
|
||||
return (
|
||||
<div
|
||||
{...tabProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"pl-0.5 pr-2 py-0.5 rounded-md cursor-pointer",
|
||||
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"border border-mist-100 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps extends AriaTabPanelProps {
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||
return (
|
||||
<div {...tabPanelProps} ref={ref} className="mt-2 w-full">
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Options, { Item });
|
||||
@@ -1,21 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Spinner({ className }: Props) {
|
||||
return (
|
||||
<div className={clsx('inline-block align-middle mb-0.5', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full w-full h-full',
|
||||
'border-2 border-current border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ToastQueue, ToastState, useToastQueue } from "@react-stately/toast";
|
||||
import { X } from "lucide-react";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
import IconButton from "~/components/IconButton";
|
||||
import Button from "~/components/button";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface ToastProps extends AriaToastProps<React.ReactNode> {
|
||||
@@ -27,16 +27,17 @@ function Toast({ state, ...props }: ToastProps) {
|
||||
<div {...contentProps} className="flex flex-col gap-2">
|
||||
<div {...titleProps}>{props.toast.content}</div>
|
||||
</div>
|
||||
<IconButton
|
||||
<Button
|
||||
{...closeButtonProps}
|
||||
label="Close"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"rounded-full p-1",
|
||||
"bg-transparent hover:bg-mist-700",
|
||||
"dark:bg-transparent dark:hover:bg-mist-800",
|
||||
)}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</IconButton>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ButtonProps extends AriaButtonOptions<"button"> {
|
||||
variant?: "heavy" | "light" | "danger" | "ghost";
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
"aria-label"?: string;
|
||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
@@ -19,8 +20,9 @@ export default function Button({ variant = "light", ...props }: ButtonProps) {
|
||||
<button
|
||||
ref={ref}
|
||||
{...buttonProps}
|
||||
aria-label={props["aria-label"]}
|
||||
className={cn(
|
||||
"w-fit rounded-md px-3.5 py-2 text-sm leading-tight",
|
||||
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
|
||||
"transition-colors duration-100",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
@@ -1,164 +0,0 @@
|
||||
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 {
|
||||
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";
|
||||
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: Key[];
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: onAction is called twice for some reason?
|
||||
// TODO: isDisabled per-prop
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = "bottom", isDisabled, disabledKeys = [] } = 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 (
|
||||
<div>
|
||||
{cloneElement(button, {
|
||||
...menuTriggerProps,
|
||||
isDisabled: isDisabled,
|
||||
ref,
|
||||
})}
|
||||
{state.isOpen && (
|
||||
<Popover state={state} triggerRef={ref} placement={placement}>
|
||||
{cloneElement(panel, {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
disabledKeys,
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
const state = useTreeState(props);
|
||||
const ref = useRef(null);
|
||||
|
||||
const { menuProps } = useMenu(props, state, ref);
|
||||
return (
|
||||
<ul
|
||||
{...menuProps}
|
||||
ref={ref}
|
||||
className="min-w-[200px] rounded-md pt-1 pb-1 shadow-2xs focus:outline-hidden"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection
|
||||
key={item.key}
|
||||
section={item}
|
||||
state={state}
|
||||
disabledKeys={props.disabledKeys}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuSectionProps<T> {
|
||||
section: Node<T>;
|
||||
state: TreeState<T>;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function MenuSection<T>({ section, state, disabledKeys }: 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={cn("mx-2 mt-1 mb-1 border-t", "border-mist-200 dark:border-mist-800")}
|
||||
/>
|
||||
) : undefined}
|
||||
<li {...itemProps}>
|
||||
<ul {...groupProps}>
|
||||
{[...section.childNodes].map((item) => (
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
state={state}
|
||||
isDisabled={disabledKeys?.includes(item.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||
|
||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||
|
||||
return (
|
||||
<li
|
||||
{...menuItemProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"py-2 px-3 mx-1 rounded-lg",
|
||||
"focus:outline-hidden select-none",
|
||||
isFocused && "bg-mist-100/50 dark:bg-mist-800",
|
||||
isDisabled
|
||||
? "text-mist-400 dark:text-mist-600"
|
||||
: "hover:bg-mist-100/50 dark:hover:bg-mist-800 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, {
|
||||
Button,
|
||||
IconButton,
|
||||
Panel,
|
||||
Section,
|
||||
Item,
|
||||
});
|
||||
Reference in New Issue
Block a user