refactor(ui): unify low-level controls and expand design system
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
This commit is contained in:
@@ -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 && (
|
||||
|
||||
52
src/components/ui/Disclosure.tsx
Normal file
52
src/components/ui/Disclosure.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/DropdownMenu.tsx
Normal file
29
src/components/ui/DropdownMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/ui/FormField.tsx
Normal file
67
src/components/ui/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/ui/IconButton.tsx
Normal file
53
src/components/ui/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/ui/InlineAddItem.tsx
Normal file
59
src/components/ui/InlineAddItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/ui/InlineEditor.tsx
Normal file
68
src/components/ui/InlineEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
59
src/components/ui/SegmentedControl.tsx
Normal file
59
src/components/ui/SegmentedControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user