refactor(ui): unify low-level controls and expand design system
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s

This commit is contained in:
2026-03-03 15:50:15 +01:00
parent 9a43980412
commit db7a0cef96
47 changed files with 1404 additions and 711 deletions

View File

@@ -1,6 +1,6 @@
import { forwardRef, ButtonHTMLAttributes } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'brand';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -15,6 +15,7 @@ const variantStyles: Record<ButtonVariant, string> = {
outline: 'bg-transparent text-foreground hover:bg-card-hover border-border',
ghost: 'bg-transparent text-foreground hover:bg-card-hover border-transparent',
destructive: 'bg-destructive text-white hover:bg-destructive/90 border-transparent',
brand: 'bg-[var(--purple)] text-white hover:opacity-90 border-transparent',
};
const sizeStyles: Record<ButtonSize, string> = {
@@ -23,6 +24,28 @@ const sizeStyles: Record<ButtonSize, string> = {
lg: 'h-12 px-6 text-base gap-2',
};
interface GetButtonClassNameOptions {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
}
export function getButtonClassName({
variant = 'primary',
size = 'md',
className = '',
}: GetButtonClassNameOptions = {}) {
return `
inline-flex items-center justify-center rounded-lg border font-medium
transition-colors focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = '', variant = 'primary', size = 'md', loading, disabled, children, ...props },
@@ -32,15 +55,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<button
ref={ref}
disabled={disabled || loading}
className={`
inline-flex items-center justify-center rounded-lg border font-medium
transition-colors focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
className={getButtonClassName({ variant, size, className })}
{...props}
>
{loading && (

View File

@@ -0,0 +1,52 @@
'use client';
import { ReactNode, useId, useState } from 'react';
interface DisclosureProps {
title: ReactNode;
subtitle?: ReactNode;
defaultOpen?: boolean;
className?: string;
children: ReactNode;
}
export function Disclosure({
title,
subtitle,
defaultOpen = false,
className = '',
children,
}: DisclosureProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className={`rounded-lg border border-border bg-card-hover ${className}`}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
aria-controls={contentId}
className="flex w-full items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">{title}</div>
{subtitle && <div className="text-xs text-muted">{subtitle}</div>}
</div>
<svg
className={`h-4 w-4 shrink-0 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div id={contentId} className="border-t border-border px-4 py-3">
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { ReactNode, useRef, useState } from 'react';
import { useClickOutside } from '@/hooks/useClickOutside';
interface DropdownMenuProps {
trigger: (options: { open: boolean; toggle: () => void }) => ReactNode;
children: (options: { close: () => void }) => ReactNode;
panelClassName?: string;
className?: string;
}
export function DropdownMenu({
trigger,
children,
panelClassName = '',
className = '',
}: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false), open);
return (
<div ref={ref} className={`relative ${className}`}>
{trigger({ open, toggle: () => setOpen((prev) => !prev) })}
{open && <div className={panelClassName}>{children({ close: () => setOpen(false) })}</div>}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { ChangeEventHandler, ReactNode } from 'react';
import { Input } from './Input';
interface FormFieldProps {
id?: string;
label?: string;
error?: string;
hint?: string;
className?: string;
children: ReactNode;
}
export function FormField({ id, label, error, hint, className = '', children }: FormFieldProps) {
return (
<div className={className || 'w-full'}>
{label && (
<label htmlFor={id} className="mb-2 block text-sm font-medium text-foreground">
{label}
</label>
)}
{children}
{hint && !error && <p className="mt-1 text-xs text-muted">{hint}</p>}
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
</div>
);
}
type BaseInputProps = {
id?: string;
name?: string;
value?: string | number;
defaultValue?: string | number;
onChange?: ChangeEventHandler<HTMLInputElement>;
required?: boolean;
disabled?: boolean;
min?: string | number;
max?: string | number;
step?: string | number;
className?: string;
};
interface DateInputProps extends BaseInputProps {
label?: string;
error?: string;
}
export function DateInput({ label, error, className = '', ...props }: DateInputProps) {
return (
<FormField id={props.id} label={label} error={error}>
<Input type="date" className={className} {...props} />
</FormField>
);
}
interface NumberInputProps extends BaseInputProps {
label?: string;
error?: string;
hint?: string;
}
export function NumberInput({ label, error, hint, className = '', ...props }: NumberInputProps) {
return (
<FormField id={props.id} label={label} error={error} hint={hint}>
<Input type="number" className={className} {...props} />
</FormField>
);
}

View File

@@ -0,0 +1,53 @@
import { ButtonHTMLAttributes, ReactNode } from 'react';
type IconButtonVariant = 'default' | 'primary' | 'destructive' | 'ghost';
type IconButtonSize = 'xs' | 'sm' | 'md';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
}
const variantStyles: Record<IconButtonVariant, string> = {
default: 'text-muted hover:bg-card-hover hover:text-foreground border-transparent',
primary: 'text-muted hover:bg-primary/10 hover:text-primary border-transparent',
destructive:
'text-muted hover:bg-destructive/10 hover:text-destructive border-transparent',
ghost: 'text-muted hover:text-foreground border-transparent',
};
const sizeStyles: Record<IconButtonSize, string> = {
xs: 'h-6 w-6',
sm: 'h-7 w-7',
md: 'h-8 w-8',
};
export function IconButton({
icon,
label,
variant = 'default',
size = 'sm',
className = '',
type = 'button',
...props
}: IconButtonProps) {
return (
<button
type={type}
aria-label={label}
title={label}
className={`
inline-flex items-center justify-center rounded-md border
transition-colors disabled:pointer-events-none disabled:opacity-50
${sizeStyles[size]}
${variantStyles[variant]}
${className}
`}
{...props}
>
{icon}
</button>
);
}

View File

@@ -0,0 +1,59 @@
import { ReactNode } from 'react';
import { InlineFormActions } from './InlineFormActions';
interface InlineAddItemProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
isPending: boolean;
placeholder?: string;
rows?: number;
extra?: ReactNode;
submitColorClass?: string;
className?: string;
}
export function InlineAddItem({
value,
onChange,
onSubmit,
onCancel,
isPending,
placeholder,
rows = 2,
extra,
submitColorClass,
className = '',
}: InlineAddItemProps) {
return (
<div className={`rounded-lg border border-border bg-card p-2 shadow-sm ${className}`}>
<textarea
autoFocus
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmit();
} else if (e.key === 'Escape') {
onCancel();
}
}}
placeholder={placeholder}
className="w-full resize-none rounded border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-0"
rows={rows}
disabled={isPending}
/>
{extra}
<InlineFormActions
onCancel={onCancel}
onSubmit={onSubmit}
isPending={isPending}
disabled={!value.trim()}
submitColorClass={submitColorClass}
className="mt-1"
/>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { ReactNode } from 'react';
interface InlineEditorProps {
value: string;
onChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
isPending: boolean;
placeholder?: string;
rows?: number;
submitLabel?: string;
footer?: ReactNode;
}
export function InlineEditor({
value,
onChange,
onSave,
onCancel,
isPending,
placeholder,
rows = 2,
submitLabel = 'Enregistrer',
footer,
}: InlineEditorProps) {
return (
<div className="space-y-2">
<textarea
autoFocus
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
onSave();
} else if (e.key === 'Escape') {
onCancel();
}
}}
className="w-full resize-none rounded border-0 bg-transparent p-0 text-sm text-foreground focus:outline-none focus:ring-0"
rows={rows}
disabled={isPending}
placeholder={placeholder}
/>
{footer}
{!footer && (
<div className="flex justify-end gap-2">
<button
type="button"
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
onClick={onCancel}
disabled={isPending}
>
Annuler
</button>
<button
type="button"
className="rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
onClick={onSave}
disabled={isPending || !value.trim()}
>
{submitLabel}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { Button } from './Button';
interface InlineFormActionsProps {
onCancel: () => void;
onSubmit: () => void;
@@ -19,21 +21,27 @@ export function InlineFormActions({
}: InlineFormActionsProps) {
return (
<div className={`flex justify-end gap-1 ${className}`}>
<button
<Button
type="button"
size="sm"
variant="ghost"
onClick={onCancel}
className="rounded px-2 py-1 text-xs text-muted hover:bg-card-hover"
className="h-7 px-2 text-xs text-muted"
disabled={isPending}
>
Annuler
</button>
<button
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onSubmit}
disabled={isPending || disabled}
className={`rounded px-2 py-1 text-xs font-medium disabled:opacity-50 ${submitColorClass}`}
className={`h-7 px-2 text-xs ${submitColorClass}`}
>
{isPending ? '...' : submitLabel}
</button>
</Button>
</div>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import { ReactNode } from 'react';
export interface SegmentedOption<T extends string> {
value: T;
label: ReactNode;
disabled?: boolean;
}
interface SegmentedControlProps<T extends string> {
value: T;
options: SegmentedOption<T>[];
onChange: (value: T) => void;
size?: 'sm' | 'md';
fullWidth?: boolean;
className?: string;
}
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
} as const;
export function SegmentedControl<T extends string>({
value,
options,
onChange,
size = 'md',
fullWidth = false,
className = '',
}: SegmentedControlProps<T>) {
return (
<div
className={`inline-flex items-center gap-1 rounded-lg border border-border bg-card p-1 ${className}`}
>
{options.map((option) => (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={() => onChange(option.value)}
className={`
rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50
${sizeStyles[size]}
${fullWidth ? 'min-w-0 flex-1' : ''}
${
value === option.value
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
}
`}
>
{option.label}
</button>
))}
</div>
);
}

View File

@@ -1,8 +1,10 @@
export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Button } from './Button';
export { Button, getButtonClassName } from './Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { CollaboratorDisplay } from './CollaboratorDisplay';
export { Disclosure } from './Disclosure';
export { DropdownMenu } from './DropdownMenu';
export { EditableTitle } from './EditableTitle';
export {
EditableSessionTitle,
@@ -13,6 +15,9 @@ export {
EditableGifMoodTitle,
} from './EditableTitles';
export { IconEdit, IconTrash, IconDuplicate, IconPlus, IconCheck, IconClose } from './Icons';
export { IconButton } from './IconButton';
export { InlineAddItem } from './InlineAddItem';
export { InlineEditor } from './InlineEditor';
export { InlineFormActions } from './InlineFormActions';
export { PageHeader } from './PageHeader';
export { SessionPageHeader } from './SessionPageHeader';
@@ -22,6 +27,8 @@ export { Modal, ModalFooter } from './Modal';
export { RocketIcon } from './RocketIcon';
export { Select } from './Select';
export type { SelectOption } from './Select';
export { SegmentedControl } from './SegmentedControl';
export { Textarea } from './Textarea';
export { ToggleGroup } from './ToggleGroup';
export type { ToggleOption } from './ToggleGroup';
export { FormField, DateInput, NumberInput } from './FormField';