feat: switch form fields to base-ui

This commit is contained in:
Aarnav Tale
2026-03-17 15:19:22 -04:00
parent 941971b8c7
commit 7403ebea06
75 changed files with 842 additions and 1086 deletions
-84
View File
@@ -1,84 +0,0 @@
import { Asterisk } from "lucide-react";
import { useRef } from "react";
import { type AriaTextFieldProps, useId, useTextField } from "react-aria";
import cn from "~/utils/cn";
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
}
export default function Input(props: InputProps) {
const { label, labelHidden, className, isInvalid: customIsInvalid, errorMessage } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
"aria-label": label,
},
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex w-full flex-col">
<label
{...labelProps}
className={cn(
"text-xs font-medium px-3 mb-0.5",
"text-mist-700 dark:text-mist-100",
labelHidden && "sr-only",
)}
htmlFor={id}
>
{label}
{props.isRequired && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
</label>
<input
{...inputProps}
className={cn(
"rounded-md px-3 py-2",
"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",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{isInvalid ? (
<div
{...errorMessageProps}
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
>
{errorMessage ?? validationErrors.join(" ")}
</div>
) : null}
</div>
);
}
-45
View File
@@ -1,45 +0,0 @@
import {
CircleAlert,
CircleSlash2,
LucideProps,
TriangleAlert,
} from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: 'default' | 'error' | 'warning';
icon?: React.ReactElement<LucideProps>;
}
export default function Notice({
children,
title,
variant,
icon,
}: NoticeProps) {
return (
<Card variant="flat" className="max-w-2xl my-6">
<div className="flex items-center justify-between">
{title ? (
<Card.Title className="text-xl mb-0">{title}</Card.Title>
) : undefined}
{!variant && icon ? icon : iconForVariant(variant)}
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
switch (variant) {
case 'error':
return <TriangleAlert className="text-red-500" />;
case 'warning':
return <CircleAlert className="text-yellow-500" />;
default:
return <CircleSlash2 />;
}
}
-99
View File
@@ -1,99 +0,0 @@
import { Minus, Plus } from "lucide-react";
import { useRef } from "react";
import { type AriaNumberFieldProps, useButton, useId, useLocale, useNumberField } from "react-aria";
import { useNumberFieldState } from "react-stately";
import cn from "~/utils/cn";
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
const decrRef = useRef<HTMLButtonElement | null>(null);
const incrRef = useRef<HTMLButtonElement | null>(null);
const { buttonProps: decrProps } = useButton(decrementButtonProps, decrRef);
const { buttonProps: incrProps } = useButton(incrementButtonProps, incrRef);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
>
{label}
</label>
<div
{...groupProps}
className={cn(
"flex items-center gap-1 rounded-md pr-1",
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<button
{...decrProps}
ref={decrRef}
aria-label="Decrement"
className="h-7.5 w-7.5 rounded-lg p-1"
>
<Minus className="h-4 w-4" />
</button>
<button
{...incrProps}
ref={incrRef}
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
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
>
{validationErrors.join(" ")}
</div>
)}
</div>
);
}
-45
View File
@@ -1,45 +0,0 @@
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-overlay rounded-lg",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
-78
View File
@@ -1,78 +0,0 @@
import React, { createContext, useContext, useRef } from "react";
import { AriaRadioGroupProps, AriaRadioProps, VisuallyHidden, useFocusRing } from "react-aria";
import { useRadio, useRadioGroup } from "react-aria";
import { RadioGroupState } from "react-stately";
import { useRadioGroupState } from "react-stately";
import cn from "~/utils/cn";
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
"aria-label": label,
},
state,
);
return (
<div {...radioGroupProps} className={cn("flex flex-col gap-2", className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
"aria-label": label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
"w-5 h-5 aspect-square rounded-full p-1 border-2",
"border border-mist-600 dark:border-mist-300",
isFocusVisible
? "ring-2 ring-indigo-500/40 ring-offset-1 dark:ring-indigo-400/40 dark:ring-offset-mist-900"
: "",
isDisabled ? "opacity-50 cursor-not-allowed" : "",
isSelected ? "border-[6px] border-mist-900 dark:border-mist-100" : "",
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });
-170
View File
@@ -1,170 +0,0 @@
import { Check, ChevronDown } from "lucide-react";
import { useRef } from "react";
import {
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from "react-aria";
import { Item, ListState, Node, useComboBoxState } from "react-stately";
import Popover from "~/components/Popover";
import cn from "~/utils/cn";
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: "base" });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
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 (
<div className={cn("flex flex-col", props.className)}>
<label
{...labelProps}
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
"flex rounded-md focus:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
props.isInvalid && "ring-red-400",
)}
>
<input
{...inputProps}
className="w-full rounded-l-md bg-transparent px-3 py-2 outline-hidden"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
className={cn(
"flex items-center justify-center p-1 rounded-lg m-1",
"bg-mist-100 dark:bg-mist-700/30 font-medium",
props.isDisabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
className="max-h-72 w-full overflow-auto pt-1 outline-hidden"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} 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}
className={cn(
"flex items-center justify-between",
"py-2 px-3 mx-1 rounded-lg mb-1",
"focus:outline-hidden select-none",
isFocused || isSelected
? "bg-mist-100/50 dark:bg-mist-800"
: "hover:bg-mist-100/50 dark:hover:bg-mist-800",
isDisabled && "text-mist-300 dark:text-mist-600",
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });
-56
View File
@@ -1,56 +0,0 @@
import { useRef } from "react";
import { AriaSwitchProps, VisuallyHidden, useFocusRing, useSwitch } from "react-aria";
import { useToggleState } from "react-stately";
import cn from "~/utils/cn";
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
switchClassName?: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
"aria-label": props.label,
},
state,
ref,
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input {...inputProps} {...focusProps} aria-label={props.label} ref={ref} />
</VisuallyHidden>
<div
aria-hidden
className={cn(
"flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full",
"bg-mist-300 dark:bg-mist-700",
"border border-transparent dark:border-mist-800",
state.isSelected && "bg-mist-900 dark:bg-mist-950",
isFocusVisible &&
"ring-2 ring-indigo-500/40 ring-offset-1 dark:ring-indigo-400/40 dark:ring-offset-mist-900",
props.isDisabled && "opacity-50",
props.className,
)}
>
<span
className={cn(
"h-[18px] w-[18px] transform rounded-full",
"bg-white transition duration-50 ease-in-out",
"translate-x-0 group-selected:translate-x-full",
state.isSelected && "translate-x-full",
props.switchClassName,
)}
/>
</div>
</label>
);
}
-11
View File
@@ -1,11 +0,0 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TextProps {
children: React.ReactNode;
className?: string;
}
export default function Text({ children, className }: TextProps) {
return <p className={cn('text-md my-0', className)}>{children}</p>;
}
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TitleProps {
children: React.ReactNode;
className?: string;
}
export default function Title({ children, className }: TitleProps) {
return (
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
);
}
@@ -3,7 +3,7 @@ import { Check, Copy, Info } from "lucide-react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import Tooltip from "./Tooltip";
import Tooltip from "./tooltip";
export interface AttributeProps {
name: string;
@@ -1,7 +1,7 @@
import React from "react";
import Text from "~/components/Text";
import Title from "~/components/Title";
import Text from "~/components/text";
import Title from "~/components/title";
import cn from "~/utils/cn";
interface Props extends React.HTMLProps<HTMLDivElement> {
+2 -2
View File
@@ -4,8 +4,8 @@ import { isRouteErrorResponse } from "react-router";
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
import Card from "./Card";
import Code from "./Code";
import Card from "./card";
import Code from "./code";
import Link from "./link";
export function getErrorMessage(error: Error | unknown): {
+72
View File
@@ -0,0 +1,72 @@
import { Field } from "@base-ui/react/field";
import { Input as BaseInput } from "@base-ui/react/input";
import { Asterisk } from "lucide-react";
import type { ComponentProps } from "react";
import cn from "~/utils/cn";
export interface InputProps extends Omit<ComponentProps<typeof BaseInput>, "onChange"> {
label: string;
labelHidden?: boolean;
required?: boolean;
className?: string;
invalid?: boolean;
errorMessage?: string;
description?: string;
onChange?: (value: string) => void;
}
export default function Input(props: InputProps) {
const {
label,
labelHidden,
className,
invalid,
errorMessage,
description,
required,
onChange,
...rest
} = props;
return (
<Field.Root className={cn("flex w-full flex-col gap-1", className)} invalid={invalid}>
<Field.Label
className={cn(
"text-sm font-medium",
"text-mist-700 dark:text-mist-200",
labelHidden && "sr-only",
)}
>
{label}
{required && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
</Field.Label>
<BaseInput
{...rest}
required={required}
className={cn(
"rounded-md px-3 py-2 text-sm",
"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",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
)}
onChange={
onChange
? (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
: undefined
}
/>
{description && (
<Field.Description className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>
{description}
</Field.Description>
)}
{invalid && errorMessage ? (
<Field.Error className={cn("text-xs", "text-red-500 dark:text-red-400")}>
{errorMessage}
</Field.Error>
) : null}
</Field.Root>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { CircleAlert, CircleSlash2, LucideProps, TriangleAlert } from "lucide-react";
import React from "react";
import Card from "~/components/card";
export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: "default" | "error" | "warning";
icon?: React.ReactElement<LucideProps>;
}
export default function Notice({ children, title, variant, icon }: NoticeProps) {
return (
<Card variant="flat" className="my-6 max-w-2xl">
<div className="flex items-center justify-between">
{title ? <Card.Title className="mb-0 text-xl">{title}</Card.Title> : undefined}
{!variant && icon ? icon : iconForVariant(variant)}
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
function iconForVariant(variant?: "default" | "error" | "warning") {
switch (variant) {
case "error":
return <TriangleAlert className="text-red-500" />;
case "warning":
return <CircleAlert className="text-yellow-500" />;
default:
return <CircleSlash2 />;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NumberField } from "@base-ui/react/number-field";
import { Minus, Plus } from "lucide-react";
import cn from "~/utils/cn";
export interface NumberInputProps {
label?: string;
name?: string;
description?: string;
required?: boolean;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
defaultValue?: number;
value?: number;
onValueChange?: (value: number | null) => void;
}
export default function NumberInput(props: NumberInputProps) {
const { label, name, description } = props;
return (
<NumberField.Root
className="flex flex-col gap-1"
defaultValue={props.defaultValue}
value={props.value}
onValueChange={props.onValueChange}
min={props.min}
max={props.max}
step={props.step}
disabled={props.disabled}
required={props.required}
>
{label && (
<NumberField.ScrubArea>
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
<NumberField.ScrubAreaCursor />
{label}
</label>
</NumberField.ScrubArea>
)}
<NumberField.Group
className={cn(
"flex items-center gap-1 rounded-md pr-1",
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
)}
>
<NumberField.Input
name={name}
className="w-full rounded-l-md bg-transparent py-2 pl-3 text-sm focus:outline-hidden"
/>
<NumberField.Decrement aria-label="Decrement" className="h-7.5 w-7.5 rounded-lg p-1">
<Minus className="h-4 w-4" />
</NumberField.Decrement>
<NumberField.Increment aria-label="Increment" className="h-7.5 w-7.5 rounded-lg p-1">
<Plus className="h-4 w-4" />
</NumberField.Increment>
</NumberField.Group>
{description && (
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
)}
</NumberField.Root>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Radio } from "@base-ui/react/radio";
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import type React from "react";
import cn from "~/utils/cn";
interface RadioGroupProps {
children: React.ReactNode;
label: string;
className?: string;
name?: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string, eventDetails: BaseRadioGroup.ChangeEventDetails) => void;
}
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
return (
<BaseRadioGroup
{...props}
aria-label={label}
className={cn("flex flex-col gap-2.5", className)}
>
{children}
</BaseRadioGroup>
);
}
interface RadioItemProps {
value: string;
label: string;
className?: string;
disabled?: boolean;
children?: React.ReactNode;
}
function RadioItem({ children, label, className, value, disabled }: RadioItemProps) {
return (
<label className="flex items-center gap-2.5 text-sm">
<Radio.Root
value={value}
disabled={disabled}
aria-label={label}
className={cn(
"w-5 h-5 aspect-square rounded-full border-2",
"border-mist-400 dark:border-mist-500",
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
"data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
"data-[checked]:border-[6px] data-[checked]:border-mist-900 dark:data-[checked]:border-mist-100",
className,
)}
>
<Radio.Indicator />
</Radio.Root>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio: RadioItem });
+138
View File
@@ -0,0 +1,138 @@
import { Combobox } from "@base-ui/react/combobox";
import { Check, ChevronDown } from "lucide-react";
import cn from "~/utils/cn";
export interface SelectItem {
value: string;
label: string;
}
export interface SelectProps {
items: SelectItem[];
label?: string;
"aria-label"?: string;
name?: string;
className?: string;
placeholder?: string;
description?: string;
required?: boolean;
disabled?: boolean;
invalid?: boolean;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
}
export default function Select({
items,
label,
className,
placeholder,
description,
required,
disabled,
invalid,
value,
defaultValue,
onValueChange,
name,
...props
}: SelectProps) {
const selectedItem =
value !== undefined ? (items.find((i) => i.value === value) ?? null) : undefined;
const defaultSelectedItem =
defaultValue !== undefined ? (items.find((i) => i.value === defaultValue) ?? null) : undefined;
return (
<div className={cn("flex flex-col gap-1", className)}>
{label && (
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
{label}
</label>
)}
<Combobox.Root
items={items}
value={selectedItem}
defaultValue={defaultSelectedItem}
onValueChange={(item) => onValueChange?.(item?.value ?? null)}
disabled={disabled}
name={name}
aria-label={props["aria-label"]}
>
<div
className={cn(
"relative rounded-md",
"focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
invalid && "ring-red-400",
)}
>
<Combobox.Input
placeholder={placeholder}
required={required}
className="w-full rounded-md bg-transparent px-3 py-2 pr-9 text-sm outline-hidden"
data-1p-ignore
/>
<Combobox.Trigger
className={cn(
"absolute inset-y-0 right-0 flex items-center pr-3",
"text-mist-400 dark:text-mist-500",
disabled
? "opacity-50 cursor-not-allowed"
: "hover:text-mist-600 dark:hover:text-mist-300",
)}
>
<ChevronDown className="h-4 w-4" />
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Positioner
className="z-50"
sideOffset={8}
style={{ width: "var(--anchor-width)" }}
>
<Combobox.Popup
className={cn(
"max-h-72 w-full overflow-auto rounded-lg p-1",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
"shadow-overlay",
)}
>
<Combobox.Empty className="px-3 py-2 text-sm text-mist-500 empty:hidden">
No results found.
</Combobox.Empty>
<Combobox.List>
{(item: SelectItem) => (
<Combobox.Item
key={item.value}
value={item}
className={cn(
"flex items-center justify-between text-sm",
"py-1.5 px-2.5 rounded-md",
"outline-hidden select-none cursor-default",
"data-[highlighted]:bg-mist-100 dark:data-[highlighted]:bg-mist-800",
"data-[selected]:font-medium",
"data-[disabled]:text-mist-300 dark:data-[disabled]:text-mist-600",
)}
>
{item.label}
<Combobox.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</Combobox.ItemIndicator>
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
{description && (
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Switch as BaseSwitch } from "@base-ui/react/switch";
import cn from "~/utils/cn";
export interface SwitchProps {
label: string;
className?: string;
switchClassName?: string;
name?: string;
disabled?: boolean;
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export default function Switch(props: SwitchProps) {
return (
<BaseSwitch.Root
aria-label={props.label}
checked={props.checked}
className={cn(
"flex h-[22px] w-[38px] p-[3px] shrink-0 rounded-full",
"bg-mist-300 dark:bg-mist-700",
"border border-transparent dark:border-mist-800",
"data-[checked]:bg-mist-900 dark:data-[checked]:bg-mist-950",
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
props.disabled && "opacity-50",
props.className,
)}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
name={props.name}
onCheckedChange={props.onCheckedChange}
>
<BaseSwitch.Thumb
className={cn(
"h-[14px] w-[14px] transform rounded-full",
"bg-white transition duration-50 ease-in-out",
"translate-x-0 data-[checked]:translate-x-full",
props.switchClassName,
)}
/>
</BaseSwitch.Root>
);
}
+26 -26
View File
@@ -1,32 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import { Info } from "lucide-react";
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export interface ExitNodeTagProps {
isEnabled?: boolean;
isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this
from the "Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this from the "Edit route
settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import Chip from "../Chip";
import Tooltip from "../Tooltip";
import Chip from "../chip";
import Tooltip from "../tooltip";
export interface ExpiryTagProps {
variant: "expired" | "no-expiry";
+16 -17
View File
@@ -1,20 +1,19 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn(
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
)}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide
host information in the web UI.
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide host information in
the web UI.
</Tooltip.Body>
</Tooltip>
);
}
+26 -26
View File
@@ -1,32 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import { Info } from "lucide-react";
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export interface SubnetTagProps {
isEnabled?: boolean;
isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the
"Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip>
<Chip
text="Subnets"
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the "Edit route
settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+16 -18
View File
@@ -1,21 +1,19 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import cn from "~/utils/cn";
import Chip from "../chip";
import Tooltip from "../tooltip";
export function TailscaleSSHTag() {
return (
<Tooltip>
<Chip
text="Tailscale SSH"
className={cn(
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
)}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate
SSH credentials using your Tailscale account and via the Headplane web
UI.
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip>
<Chip
text="Tailscale SSH"
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials
using your Tailscale account and via the Headplane web UI.
</Tooltip.Body>
</Tooltip>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import cn from "~/utils/cn";
export interface TextProps {
children: React.ReactNode;
className?: string;
}
export default function Text({ children, className }: TextProps) {
return <p className={cn("text-md my-0", className)}>{children}</p>;
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import cn from "~/utils/cn";
export interface TitleProps {
children: React.ReactNode;
className?: string;
}
export default function Title({ children, className }: TitleProps) {
return <h3 className={cn("text-2xl font-bold mb-2", className)}>{children}</h3>;
}