62317f2ad7
Mise en place du Design System ManageMate Group v0.2 — refonte du
système de tokens (préfixe --mmg-color-*), 9 presets accent
user-themable validés WCAG AA, overlays Radix UI + Floating UI,
Storybook 8 + Vitest + axe-core en CI, doc Astro Starlight,
DESIGN.md (format google-labs-code) et exports tokens DTCG/CSS/
TS/Figma/Tailwind v3 et v4.
- 4 packages monorepo pnpm : @managemate/{tokens,css,react,icons}
- 62 composants React headless-first (Sheet, HoverCard, ContextMenu,
Slider, ToggleGroup, AvatarGroup, UserCard, ProfileHeader,
MetricCard, PricingCard, FeatureCard, Text/Display/Eyebrow/Lead…)
- Lint contraste WCAG : 37/37 paires AA, branché CI
- Toast pile Sonner-style avec ResizeObserver
- Theming user (9 presets) sans casser sémantique fixe
- Identité Synapse (rose #D12B6A) préservée
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
7.3 KiB
TypeScript
253 lines
7.3 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useId,
|
|
type InputHTMLAttributes,
|
|
type SelectHTMLAttributes,
|
|
type TextareaHTMLAttributes,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { cx } from "./utils";
|
|
import { Icon, type IconName } from "./Icon";
|
|
|
|
type FieldRenderArg = {
|
|
/** id du contrôle (à passer à <input>/<textarea>/<select>) */
|
|
id: string;
|
|
/** id du message à passer en aria-describedby (peut être undefined) */
|
|
describedBy: string | undefined;
|
|
/** L'état d'invalidité, à passer en aria-invalid */
|
|
invalid: boolean | undefined;
|
|
};
|
|
|
|
type FieldProps = {
|
|
label?: ReactNode;
|
|
hint?: ReactNode;
|
|
error?: ReactNode;
|
|
success?: ReactNode;
|
|
required?: boolean;
|
|
className?: string;
|
|
id?: string;
|
|
children: (arg: FieldRenderArg) => ReactNode;
|
|
};
|
|
|
|
export function Field({
|
|
label,
|
|
hint,
|
|
error,
|
|
success,
|
|
required,
|
|
className,
|
|
id: idProp,
|
|
children,
|
|
}: FieldProps) {
|
|
const auto = useId();
|
|
const id = idProp ?? auto;
|
|
const messageId = `${id}-message`;
|
|
const hasMessage = Boolean(error || success || hint);
|
|
const describedBy = hasMessage ? messageId : undefined;
|
|
const state = error ? "error" : success ? "success" : undefined;
|
|
return (
|
|
<div className={cx("mmg-field", state && `mmg-field--${state}`, className)}>
|
|
{label && (
|
|
<label
|
|
className={cx(
|
|
"mmg-field__label",
|
|
required && "mmg-field__label--required",
|
|
)}
|
|
htmlFor={id}
|
|
>
|
|
{label}
|
|
</label>
|
|
)}
|
|
{children({ id, describedBy, invalid: error ? true : undefined })}
|
|
{error ? (
|
|
<span id={messageId} className="mmg-field__error" role="alert">
|
|
{error}
|
|
</span>
|
|
) : success ? (
|
|
<span id={messageId} className="mmg-field__success">
|
|
{success}
|
|
</span>
|
|
) : hint ? (
|
|
<span id={messageId} className="mmg-field__hint">
|
|
{hint}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* — Input ————————————————————————————— */
|
|
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
|
|
label?: ReactNode;
|
|
hint?: ReactNode;
|
|
error?: ReactNode;
|
|
success?: ReactNode;
|
|
size?: "sm" | "md" | "lg";
|
|
prefixIcon?: IconName;
|
|
suffixIcon?: IconName;
|
|
};
|
|
|
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
{ label, hint, error, success, size = "md", prefixIcon, suffixIcon, required, className, ...rest },
|
|
ref,
|
|
) {
|
|
const cls = cx(
|
|
"mmg-input",
|
|
size !== "md" && `mmg-input--${size}`,
|
|
className,
|
|
);
|
|
return (
|
|
<Field label={label} hint={hint} error={error} success={success} required={required}>
|
|
{({ id, describedBy, invalid }) =>
|
|
prefixIcon || suffixIcon ? (
|
|
<span className={cx("mmg-input-wrap", suffixIcon && "mmg-input-wrap--with-suffix")}>
|
|
{prefixIcon && (
|
|
<span className="mmg-input-wrap__icon"><Icon name={prefixIcon} /></span>
|
|
)}
|
|
<input
|
|
ref={ref}
|
|
id={id}
|
|
className={cls}
|
|
required={required}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={invalid}
|
|
{...rest}
|
|
/>
|
|
{suffixIcon && (
|
|
<span className="mmg-input-wrap__icon mmg-input-wrap__icon--suffix">
|
|
<Icon name={suffixIcon} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<input
|
|
ref={ref}
|
|
id={id}
|
|
className={cls}
|
|
required={required}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={invalid}
|
|
{...rest}
|
|
/>
|
|
)
|
|
}
|
|
</Field>
|
|
);
|
|
});
|
|
|
|
/* — Textarea ————————————————————————————— */
|
|
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
label?: ReactNode;
|
|
hint?: ReactNode;
|
|
error?: ReactNode;
|
|
success?: ReactNode;
|
|
fieldSize?: "sm" | "md" | "lg";
|
|
};
|
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
function Textarea(
|
|
{ label, hint, error, success, fieldSize = "md", required, className, ...rest },
|
|
ref,
|
|
) {
|
|
const cls = cx(
|
|
"mmg-textarea",
|
|
fieldSize !== "md" && `mmg-textarea--${fieldSize}`,
|
|
className,
|
|
);
|
|
return (
|
|
<Field label={label} hint={hint} error={error} success={success} required={required}>
|
|
{({ id, describedBy, invalid }) => (
|
|
<textarea
|
|
ref={ref}
|
|
id={id}
|
|
className={cls}
|
|
required={required}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={invalid}
|
|
{...rest}
|
|
/>
|
|
)}
|
|
</Field>
|
|
);
|
|
},
|
|
);
|
|
|
|
/* — Select ————————————————————————————— */
|
|
export type SelectOption = { value: string; label: string; disabled?: boolean };
|
|
export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> & {
|
|
label?: ReactNode;
|
|
hint?: ReactNode;
|
|
error?: ReactNode;
|
|
success?: ReactNode;
|
|
size?: "sm" | "md" | "lg";
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
};
|
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
{ label, hint, error, success, size = "md", options, placeholder, required, className, ...rest },
|
|
ref,
|
|
) {
|
|
const cls = cx("mmg-select", size !== "md" && `mmg-select--${size}`, className);
|
|
return (
|
|
<Field label={label} hint={hint} error={error} success={success} required={required}>
|
|
{({ id, describedBy, invalid }) => (
|
|
<select
|
|
ref={ref}
|
|
id={id}
|
|
className={cls}
|
|
required={required}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={invalid}
|
|
{...rest}
|
|
>
|
|
{placeholder && <option value="">{placeholder}</option>}
|
|
{options.map((o) => (
|
|
<option key={o.value} value={o.value} disabled={o.disabled}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</Field>
|
|
);
|
|
});
|
|
|
|
/* — Checkbox / Radio ————————————————————————————— */
|
|
type CheckProps = InputHTMLAttributes<HTMLInputElement> & {
|
|
label: ReactNode;
|
|
};
|
|
export const Checkbox = forwardRef<HTMLInputElement, CheckProps>(function Checkbox(
|
|
{ label, className, ...rest },
|
|
ref,
|
|
) {
|
|
return (
|
|
<label className={cx("mmg-check", className)}>
|
|
<input ref={ref} type="checkbox" className="mmg-check__input" {...rest} />
|
|
<span className="mmg-check__label">{label}</span>
|
|
</label>
|
|
);
|
|
});
|
|
export const Radio = forwardRef<HTMLInputElement, CheckProps>(function Radio(
|
|
{ label, className, ...rest },
|
|
ref,
|
|
) {
|
|
return (
|
|
<label className={cx("mmg-check", className)}>
|
|
<input ref={ref} type="radio" className="mmg-check__input" {...rest} />
|
|
<span className="mmg-check__label">{label}</span>
|
|
</label>
|
|
);
|
|
});
|
|
|
|
/* — Switch ————————————————————————————— */
|
|
export const Switch = forwardRef<HTMLInputElement, CheckProps>(function Switch(
|
|
{ label, className, ...rest },
|
|
ref,
|
|
) {
|
|
return (
|
|
<label className={cx("mmg-switch", className)}>
|
|
<input ref={ref} type="checkbox" role="switch" className="mmg-switch__input" {...rest} />
|
|
<span className="mmg-check__label">{label}</span>
|
|
</label>
|
|
);
|
|
});
|