- add shared button variants and size controls backed by theme-aware styles - move album import search controls onto shared button styling - keep the import route layout-specific CSS limited to positioning
293 lines
7.7 KiB
TypeScript
293 lines
7.7 KiB
TypeScript
import { Button as BaseButton } from '@base-ui/react/button';
|
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
|
import { Field } from '@base-ui/react/field';
|
|
import { Input as BaseInput } from '@base-ui/react/input';
|
|
import { Slider } from '@base-ui/react/slider';
|
|
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
|
import clsx from 'clsx';
|
|
import {
|
|
forwardRef,
|
|
type CSSProperties,
|
|
type ComponentPropsWithoutRef,
|
|
type ButtonHTMLAttributes,
|
|
type SelectHTMLAttributes,
|
|
type ReactNode,
|
|
type TextareaHTMLAttributes,
|
|
} from 'react';
|
|
|
|
import styles from './form.module.css';
|
|
|
|
export interface FormFieldProps {
|
|
children: ReactNode;
|
|
className?: string;
|
|
error?: ReactNode;
|
|
helperText?: ReactNode;
|
|
htmlFor?: string;
|
|
label: ReactNode;
|
|
}
|
|
|
|
export function FormField({
|
|
children,
|
|
className,
|
|
error,
|
|
helperText,
|
|
htmlFor,
|
|
label,
|
|
}: FormFieldProps) {
|
|
return (
|
|
<Field.Root className={clsx(styles.field, className)}>
|
|
<div className={styles.fieldHeader}>
|
|
{htmlFor ? (
|
|
<label className={styles.fieldLabel} htmlFor={htmlFor}>
|
|
{label}
|
|
</label>
|
|
) : (
|
|
<Field.Label className={styles.fieldLabel}>{label}</Field.Label>
|
|
)}
|
|
{helperText ? (
|
|
<Field.Description className={styles.fieldHelper}>{helperText}</Field.Description>
|
|
) : null}
|
|
</div>
|
|
<div className={styles.fieldControl}>{children}</div>
|
|
{error ? <FormError message={error} /> : null}
|
|
</Field.Root>
|
|
);
|
|
}
|
|
|
|
type BaseInputProps = ComponentPropsWithoutRef<typeof BaseInput>;
|
|
|
|
export type TextInputProps = Omit<BaseInputProps, 'className'> & {
|
|
className?: string;
|
|
};
|
|
|
|
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return <BaseInput ref={ref} className={clsx(styles.textInput, className)} {...props} />;
|
|
});
|
|
|
|
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
|
|
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
|
|
});
|
|
|
|
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
|
|
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
|
|
});
|
|
|
|
type BaseCheckboxProps = ComponentPropsWithoutRef<typeof BaseCheckbox.Root>;
|
|
|
|
export type CheckboxProps = Omit<BaseCheckboxProps, 'className' | 'children'> & {
|
|
className?: string;
|
|
};
|
|
|
|
export const Checkbox = forwardRef<HTMLElement, CheckboxProps>(function Checkbox(
|
|
{ className, ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<BaseCheckbox.Root ref={ref} className={clsx(styles.checkbox, className)} {...props}>
|
|
<BaseCheckbox.Indicator className={styles.checkboxIndicator}>
|
|
<span className={styles.checkboxIcon} aria-hidden="true">
|
|
✓
|
|
</span>
|
|
</BaseCheckbox.Indicator>
|
|
</BaseCheckbox.Root>
|
|
);
|
|
});
|
|
|
|
export interface RangeInputProps {
|
|
className?: string;
|
|
disabled?: boolean;
|
|
defaultValue?: number;
|
|
label?: ReactNode;
|
|
max?: number;
|
|
min?: number;
|
|
name?: string;
|
|
step?: number;
|
|
style?: CSSProperties;
|
|
value?: number;
|
|
onValueChange?: (value: number) => void;
|
|
}
|
|
|
|
export const RangeInput = forwardRef<HTMLDivElement, RangeInputProps>(function RangeInput(
|
|
{
|
|
className,
|
|
defaultValue,
|
|
disabled,
|
|
label,
|
|
max = 100,
|
|
min = 0,
|
|
name,
|
|
onValueChange,
|
|
step = 1,
|
|
style,
|
|
value,
|
|
},
|
|
ref,
|
|
) {
|
|
return (
|
|
<Slider.Root
|
|
ref={ref}
|
|
className={clsx(styles.rangeRoot, className)}
|
|
min={min}
|
|
max={max}
|
|
thumbAlignment="edge"
|
|
style={style}
|
|
disabled={disabled}
|
|
name={name}
|
|
step={step}
|
|
value={value}
|
|
defaultValue={defaultValue}
|
|
onValueChange={(nextValue) => {
|
|
onValueChange?.(Array.isArray(nextValue) ? nextValue[0] : nextValue);
|
|
}}
|
|
>
|
|
<Slider.Control className={styles.rangeControl}>
|
|
<Slider.Track className={styles.rangeTrack}>
|
|
<Slider.Indicator className={styles.rangeIndicator} />
|
|
<Slider.Thumb aria-label={typeof label === 'string' ? label : undefined} className={styles.rangeThumb} />
|
|
</Slider.Track>
|
|
</Slider.Control>
|
|
</Slider.Root>
|
|
);
|
|
});
|
|
|
|
export function OptionCardGroup({
|
|
className,
|
|
children,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return <div className={clsx(styles.optionCardGroup, className)}>{children}</div>;
|
|
}
|
|
|
|
export interface OptionCardProps extends Omit<
|
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
'title' | 'value'
|
|
> {
|
|
className?: string;
|
|
description?: ReactNode;
|
|
icon?: ReactNode;
|
|
selected?: boolean;
|
|
title?: ReactNode;
|
|
value?: string;
|
|
}
|
|
|
|
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
|
|
{ className, children, description, icon, selected = false, title, type = 'button', ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<BaseToggle
|
|
ref={ref}
|
|
pressed={selected}
|
|
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
|
|
type={type}
|
|
{...props}
|
|
>
|
|
{children ?? (
|
|
<>
|
|
{icon ? <div className={styles.optionCardIcon}>{icon}</div> : null}
|
|
<div className={styles.optionCardBody}>
|
|
{title ? <div className={styles.optionCardTitle}>{title}</div> : null}
|
|
{description ? <div className={styles.optionCardDescription}>{description}</div> : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</BaseToggle>
|
|
);
|
|
});
|
|
|
|
export function OptionButtonGroup({
|
|
className,
|
|
children,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
|
|
}
|
|
|
|
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
|
className?: string;
|
|
selected?: boolean;
|
|
value?: string;
|
|
}
|
|
|
|
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
|
{ className, children, selected = false, type = 'button', ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<BaseToggle
|
|
ref={ref}
|
|
pressed={selected}
|
|
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
|
|
type={type}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</BaseToggle>
|
|
);
|
|
});
|
|
|
|
type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
|
|
|
|
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
|
className?: string;
|
|
size?: 'sm' | 'md' | 'lg' | 'icon';
|
|
variant?: 'default' | 'primary' | 'ghost';
|
|
};
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
{ className, size = 'md', type = 'button', variant = 'default', ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<BaseButton
|
|
ref={ref}
|
|
className={clsx(
|
|
styles.button,
|
|
{
|
|
[styles.buttonSizeSm]: size === 'sm',
|
|
[styles.buttonSizeMd]: size === 'md',
|
|
[styles.buttonSizeLg]: size === 'lg',
|
|
[styles.buttonIcon]: size === 'icon',
|
|
[styles.buttonPrimary]: variant === 'primary',
|
|
[styles.buttonGhost]: variant === 'ghost',
|
|
},
|
|
className,
|
|
)}
|
|
data-variant={variant}
|
|
data-size={size}
|
|
type={type}
|
|
{...props}
|
|
/>
|
|
);
|
|
});
|
|
|
|
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
|
if (!message) return null;
|
|
|
|
return (
|
|
<div className={clsx(styles.formError, className)} role="alert">
|
|
{message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FormActions({ className, children }: { children: ReactNode; className?: string }) {
|
|
return <div className={clsx(styles.formActions, className)}>{children}</div>;
|
|
}
|