Les explications sur comment utiliser un écran (grille des besoins, vue
bénévoles, artistes, import, disponibilités, créneaux types, seuils,
notification par email…) étaient affichées en permanence dans la page,
prenant de la place pour un contenu qu'on ne lit qu'une fois. Elles passent
dans une bulle affichée au survol (ou au focus clavier/tactile) d'un petit
repère « ? » posé à côté du titre ou du champ concerné.
- `HelpTip` (frontend/src/components/ui.tsx) : nouveau composant partagé,
cohérent avec le design (bordures franches, pas d'arrondi) ; `side`
('top'/'bottom') pour éviter que la bulle ne sorte du cadre visible selon
que le repère est en haut ou en bas de son conteneur.
- Appliqué à GrillePage, VolunteerViewPage, ConfigArtists, ConfigVolunteers
(import, ajout manuel, disponibilités), ConfigGrid, ConfigRules, ConfigMail.
- Aucun texte déplacé n'a changé : mêmes clés i18n, seul l'endroit où elles
s'affichent change.
Laissé en l'état (pas des indications d'usage mais des messages d'état) :
bannières identifiants de démo, liens de connexion en dev, états vides, et
la description des rôles (contenu propre des cartes, pas un mode d'emploi).
Vérifié : typecheck, lint, build, smoke (7 routes) OK.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
251 lines
6.6 KiB
TypeScript
251 lines
6.6 KiB
TypeScript
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||
|
||
import { cx } from '@/lib/cx';
|
||
|
||
// Primitives visuelles du design « Bénévoles Festival » : bordures franches,
|
||
// ombres portées dures, libellés capitales. Aucun texte en dur ici — les
|
||
// libellés sont passés par les écrans (via i18n).
|
||
|
||
export function Panel({
|
||
children,
|
||
className,
|
||
shadow = 'md',
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
shadow?: 'none' | 'md' | 'lg';
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cx(
|
||
'border-[3px] border-ink bg-paper',
|
||
shadow === 'md' && 'shadow-brutal-md',
|
||
shadow === 'lg' && 'shadow-brutal-lg',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PanelHeader({
|
||
children,
|
||
hatched = false,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
hatched?: boolean;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cx(
|
||
'px-3 py-2 font-display text-[12px] font-black uppercase tracking-wide text-cream',
|
||
hatched ? 'bg-hatch-ink text-yellow' : 'bg-ink',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type BtnVariant = 'solid' | 'ghost' | 'danger' | 'dark';
|
||
|
||
export function Btn({
|
||
variant = 'solid',
|
||
className,
|
||
type = 'button',
|
||
...props
|
||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: BtnVariant }) {
|
||
return (
|
||
<button
|
||
type={type}
|
||
className={cx(
|
||
'inline-flex cursor-pointer items-center justify-center gap-2 border-2 border-ink px-3 py-2',
|
||
'font-display text-[11px] font-extrabold uppercase tracking-wide transition-colors',
|
||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||
variant === 'solid' && 'bg-yellow text-ink shadow-brutal-sm hover:bg-green hover:text-white',
|
||
variant === 'ghost' && 'bg-cream text-ink hover:bg-yellow',
|
||
variant === 'danger' && 'bg-cream text-ink hover:bg-orange',
|
||
variant === 'dark' && 'bg-ink text-cream hover:bg-yellow hover:text-ink',
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function Chip({
|
||
active,
|
||
children,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
children: ReactNode;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={cx(
|
||
'cursor-pointer border-2 border-ink px-[9px] py-[6px] font-display text-[10.5px] tracking-wide transition-colors',
|
||
active
|
||
? 'bg-ink font-extrabold text-cream'
|
||
: 'bg-cream font-semibold text-ink hover:bg-yellow',
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
type BadgeTone = 'success' | 'info' | 'warn' | 'danger' | 'neutral';
|
||
|
||
export function Badge({ tone, children }: { tone: BadgeTone; children: ReactNode }) {
|
||
return (
|
||
<span
|
||
className={cx(
|
||
'inline-flex items-center border-2 px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide',
|
||
tone === 'success' && 'border-ink bg-green text-white',
|
||
tone === 'info' && 'border-ink bg-blue text-ink',
|
||
tone === 'warn' && 'border-ink bg-yellow-soft text-ink',
|
||
tone === 'danger' && 'border-ink bg-orange text-ink',
|
||
tone === 'neutral' && 'border-ink bg-cream text-ink',
|
||
)}
|
||
>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function Stepper({
|
||
value,
|
||
onDecrement,
|
||
onIncrement,
|
||
suffix,
|
||
}: {
|
||
value: ReactNode;
|
||
onDecrement: () => void;
|
||
onIncrement: () => void;
|
||
suffix?: string;
|
||
}) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={onDecrement}
|
||
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold hover:bg-orange"
|
||
>
|
||
–
|
||
</button>
|
||
<span className="min-w-[46px] text-center font-mono text-[16px] font-medium">
|
||
{value}
|
||
{suffix ? ` ${suffix}` : null}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={onIncrement}
|
||
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold hover:bg-green hover:text-white"
|
||
>
|
||
+
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function StatCard({
|
||
value,
|
||
label,
|
||
tone = 'paper',
|
||
}: {
|
||
value: ReactNode;
|
||
label: string;
|
||
tone?: 'paper' | 'yellow' | 'green';
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cx(
|
||
'border-[3px] border-ink px-3 py-[11px] shadow-brutal-lg',
|
||
tone === 'paper' && 'bg-paper',
|
||
tone === 'yellow' && 'bg-yellow',
|
||
tone === 'green' && 'bg-green text-white',
|
||
)}
|
||
>
|
||
<div className="font-mono text-[34px] font-black leading-none">{value}</div>
|
||
<div className="mt-1 font-display text-[9.5px] font-extrabold uppercase leading-tight tracking-wide">
|
||
{label}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Petit repère « ? » : au survol (ou au focus clavier/tactile), affiche
|
||
* `text` dans une bulle. Sert à sortir les indications d'usage du flux de la
|
||
* page plutôt que de les y afficher en permanence.
|
||
*/
|
||
export function HelpTip({
|
||
text,
|
||
side = 'top',
|
||
className,
|
||
}: {
|
||
text: string;
|
||
side?: 'top' | 'bottom';
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<span className={cx('group relative inline-flex align-middle', className)}>
|
||
<button
|
||
type="button"
|
||
className="flex h-[15px] w-[15px] flex-none cursor-help items-center justify-center border-[1.5px] border-ink bg-cream font-mono text-[9px] font-bold leading-none text-ink hover:bg-yellow focus-visible:bg-yellow focus-visible:outline-none"
|
||
aria-label={text}
|
||
>
|
||
?
|
||
</button>
|
||
<span
|
||
className={cx(
|
||
'pointer-events-none absolute left-1/2 z-20 w-max max-w-[260px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100 group-focus-within:opacity-100',
|
||
side === 'top' ? 'bottom-full mb-[6px]' : 'top-full mt-[6px]',
|
||
)}
|
||
>
|
||
{text}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function FieldLabel({ children }: { children: ReactNode }) {
|
||
return (
|
||
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function TextInput({
|
||
value,
|
||
onChange,
|
||
mono = false,
|
||
className,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
mono?: boolean;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className={cx(
|
||
'w-full border-2 border-ink bg-cream p-[9px] text-[13px]',
|
||
mono ? 'font-mono' : 'font-display font-bold',
|
||
className,
|
||
)}
|
||
/>
|
||
);
|
||
}
|