diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index cdc120f..bf1e420 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
-import { RocketIcon } from '@/components/ui';
+import { Button, Input, RocketIcon } from '@/components/ui';
export default function LoginPage() {
const router = useRouter();
@@ -62,42 +62,32 @@ export default function LoginPage() {
)}
-
-
-
-
-
Pas encore de compte ?{' '}
diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx
index 0173b78..77320eb 100644
--- a/src/app/(auth)/register/page.tsx
+++ b/src/app/(auth)/register/page.tsx
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
-import { RocketIcon } from '@/components/ui';
+import { Button, Input, RocketIcon } from '@/components/ui';
export default function RegisterPage() {
const router = useRouter();
@@ -91,74 +91,55 @@ export default function RegisterPage() {
)}
-
-
-
-
-
-
-
-
-
+
{loading ? 'Création...' : 'Créer mon compte'}
-
+
Déjà un compte ?{' '}
diff --git a/src/app/design-system/page.tsx b/src/app/design-system/page.tsx
new file mode 100644
index 0000000..54a0950
--- /dev/null
+++ b/src/app/design-system/page.tsx
@@ -0,0 +1,525 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ Avatar,
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ CollaboratorDisplay,
+ DateInput,
+ Disclosure,
+ DropdownMenu,
+ EditableGifMoodTitle,
+ EditableMotivatorTitle,
+ EditableSessionTitle,
+ EditableTitle,
+ EditableWeatherTitle,
+ EditableWeeklyCheckInTitle,
+ EditableYearReviewTitle,
+ InlineFormActions,
+ Input,
+ IconCheck,
+ IconClose,
+ IconDuplicate,
+ IconEdit,
+ IconButton,
+ IconPlus,
+ IconTrash,
+ Modal,
+ ModalFooter,
+ PageHeader,
+ ParticipantInput,
+ RocketIcon,
+ Select,
+ SegmentedControl,
+ SessionPageHeader,
+ Textarea,
+ ToggleGroup,
+ FormField,
+ NumberInput,
+} from '@/components/ui';
+
+const BUTTON_VARIANTS = [
+ 'primary',
+ 'secondary',
+ 'outline',
+ 'ghost',
+ 'destructive',
+ 'brand',
+] as const;
+const BUTTON_SIZES = ['sm', 'md', 'lg'] as const;
+
+const BADGE_VARIANTS = [
+ 'default',
+ 'primary',
+ 'strength',
+ 'weakness',
+ 'opportunity',
+ 'threat',
+ 'success',
+ 'warning',
+ 'destructive',
+ 'accent',
+] as const;
+
+const SELECT_OPTIONS = [
+ { value: 'editor', label: 'Editeur' },
+ { value: 'viewer', label: 'Lecteur' },
+ { value: 'admin', label: 'Admin' },
+];
+
+const SECTION_LINKS = [
+ { id: 'buttons', label: 'Buttons' },
+ { id: 'badges', label: 'Badges' },
+ { id: 'icon-button', label: 'IconButton' },
+ { id: 'form-inputs', label: 'Form Inputs' },
+ { id: 'select-toggle', label: 'Select & Toggle' },
+ { id: 'form-field', label: 'FormField / Date / Number' },
+ { id: 'cards', label: 'Cards' },
+ { id: 'avatars', label: 'Avatar & Collaborators' },
+ { id: 'disclosure-dropdown', label: 'Disclosure & Dropdown' },
+ { id: 'menu', label: 'Menu' },
+ { id: 'editable-titles', label: 'Editable Titles' },
+ { id: 'session-header', label: 'Session Header' },
+ { id: 'participant-input', label: 'ParticipantInput' },
+ { id: 'icons', label: 'Icons' },
+ { id: 'modal', label: 'Modal' },
+] as const;
+
+export default function DesignSystemPage() {
+ const [modalOpen, setModalOpen] = useState(false);
+ const [toggleValue, setToggleValue] = useState<'cards' | 'table' | 'list'>('cards');
+ const [selectMd, setSelectMd] = useState('editor');
+ const [selectSm, setSelectSm] = useState('viewer');
+ const [selectXs, setSelectXs] = useState('admin');
+ const [selectLg, setSelectLg] = useState('editor');
+ const [menuCount, setMenuCount] = useState(0);
+
+ return (
+
+
+ Action principale
+
+ }
+ />
+
+
+
+
+
+
+ Buttons
+
+ {BUTTON_SIZES.map((size) => (
+
+ {size}
+ {BUTTON_VARIANTS.map((variant) => (
+
+ {variant}
+
+ ))}
+
+ ))}
+
+ Chargement
+ Desactive
+
+
+
+
+
+ Badges
+
+ {BADGE_VARIANTS.map((variant) => (
+
+ {variant}
+
+ ))}
+
+
+
+
+ IconButton
+
+ } label="Edit" />
+ } label="Duplicate" variant="primary" />
+ } label="Delete" variant="destructive" />
+
+
+
+
+ Form Inputs
+
+
+
+
+
+
+
+
+
+ Select & ToggleGroup
+
+
+
+
+
+
Toggle group
+
+
Valeur active: {toggleValue}
+
Segmented control
+
+
+
+
+
+
+ FormField / Date / Number
+
+
+
+
+
+
+
+
+
+
+ Cards & Header blocks
+
+
+
+ Card title
+ Description secondaire
+
+
+ Contenu principal de la card.
+
+
+
+ Annuler
+
+ Valider
+
+
+
+
+ Inline actions
+
+ {}}
+ onSubmit={() => {}}
+ isPending={false}
+ submitLabel="Ajouter"
+ />
+
+
+
+
+
+ Avatar & Collaborators
+
+
+
+
+ Disclosure & Dropdown
+
+
+ Contenu du panneau.
+
+
(
+
+ Menu demo {open ? '▲' : '▼'}
+
+ )}
+ >
+ {({ close }) => (
+ {
+ setMenuCount((prev) => prev + 1);
+ close();
+ }}
+ >
+ Incrementer ({menuCount})
+
+ )}
+
+
+
+
+
+
+
+ Editable Titles
+
+
+
EditableTitle (base)
+
({ success: true })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ParticipantInput
+
+
+
+
+ Icons
+
+
+
+ Edit
+
+
+
+ Trash
+
+
+
+ Duplicate
+
+
+
+ Plus
+
+
+
+ Check
+
+
+
+ Close
+
+
+
+ Rocket
+
+
+
+
+
+ Modal
+ setModalOpen(true)}>Ouvrir la popup
+
+
+
+
+ setModalOpen(false)} title="Exemple de popup" size="md">
+
+ Ceci est un exemple de modal avec ses actions standardisees.
+
+
+ setModalOpen(false)}>
+ Annuler
+
+ setModalOpen(false)}>Confirmer
+
+
+
+ );
+}
diff --git a/src/app/gif-mood/new/page.tsx b/src/app/gif-mood/new/page.tsx
index c5a4926..daf2a77 100644
--- a/src/app/gif-mood/new/page.tsx
+++ b/src/app/gif-mood/new/page.tsx
@@ -9,6 +9,7 @@ import {
CardDescription,
CardContent,
Button,
+ DateInput,
Input,
} from '@/components/ui';
import { createGifMoodSession } from '@/actions/gif-mood';
@@ -78,20 +79,14 @@ export default function NewGifMoodPage() {
required
/>
-
-
- setSelectedDate(e.target.value)}
- required
- className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
- />
-
+ setSelectedDate(e.target.value)}
+ required
+ />
Comment ça marche ?
diff --git a/src/app/objectives/page.tsx b/src/app/objectives/page.tsx
index f317dbd..c740495 100644
--- a/src/app/objectives/page.tsx
+++ b/src/app/objectives/page.tsx
@@ -2,7 +2,7 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs';
-import { Card, PageHeader } from '@/components/ui';
+import { Card, PageHeader, getButtonClassName } from '@/components/ui';
import { ObjectivesList } from '@/components/okrs/ObjectivesList';
import { comparePeriods } from '@/lib/okr-utils';
@@ -46,10 +46,11 @@ export default async function ObjectivesPage() {
Vous n'avez pas encore d'OKR défini. Contactez un administrateur d'équipe
pour en créer.
-
-
- Voir mes équipes
-
+
+ Voir mes équipes
) : (
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 4371799..6a764e4 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,4 +1,5 @@
import Link from 'next/link';
+import { getButtonClassName } from '@/components/ui';
import { WORKSHOPS, getSessionsTabUrl } from '@/lib/workshops';
export default function Home() {
@@ -888,14 +889,16 @@ function WorkshopCard({
Démarrer
Mes sessions
diff --git a/src/app/profile/PasswordForm.tsx b/src/app/profile/PasswordForm.tsx
index 1f2fa43..fc1ef00 100644
--- a/src/app/profile/PasswordForm.tsx
+++ b/src/app/profile/PasswordForm.tsx
@@ -39,29 +39,20 @@ export function PasswordForm() {
return (
{isAdmin && (
-
}
+ label="Supprimer l'OKR"
+ variant="destructive"
+ size="xs"
onClick={handleDelete}
- className="h-5 w-5 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
+ className="flex-shrink-0"
style={{
color: 'var(--destructive)',
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
}}
disabled={isPending}
- title="Supprimer l'OKR"
- >
-
-
+ />
)}
{isAdmin && (
- }
+ label="Supprimer l'OKR"
+ variant="destructive"
+ size="sm"
onClick={handleDelete}
- className="h-6 w-6 p-0 flex items-center justify-center rounded hover:bg-destructive/10 transition-colors flex-shrink-0"
+ className="flex-shrink-0"
style={{
color: 'var(--destructive)',
border: '1px solid color-mix(in srgb, var(--destructive) 40%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--destructive) 5%, transparent)',
}}
disabled={isPending}
- title="Supprimer l'OKR"
- >
-
-
+ />
)}
{submitting
? initialData?.teamMemberId
diff --git a/src/components/swot/ActionPanel.tsx b/src/components/swot/ActionPanel.tsx
index 0fc6759..2a16cd2 100644
--- a/src/components/swot/ActionPanel.tsx
+++ b/src/components/swot/ActionPanel.tsx
@@ -2,7 +2,18 @@
import { useState, useTransition } from 'react';
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
-import { Button, Badge, Modal, ModalFooter, Input, Textarea, Select } from '@/components/ui';
+import {
+ Button,
+ Badge,
+ Modal,
+ ModalFooter,
+ Input,
+ Textarea,
+ Select,
+ IconButton,
+ IconEdit,
+ IconTrash,
+} from '@/components/ui';
import { createAction, updateAction, deleteAction } from '@/actions/swot';
type ActionWithLinks = Action & {
@@ -202,44 +213,19 @@ export function ActionPanel({
{action.title}
-
}
+ label="Modifier"
onClick={() => openEditModal(action)}
- className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
- aria-label="Modifier"
- >
-
-
-
+
}
+ label="Supprimer"
+ variant="destructive"
onClick={() => handleDelete(action.id)}
- className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
- aria-label="Supprimer"
- >
-
-
+ className="opacity-0 transition-opacity group-hover:opacity-100"
+ />
@@ -377,35 +363,37 @@ export function ActionPanel({
{priorityLabels.map((label, index) => (
- setPriority(index)}
className={`
- flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors
+ flex-1
${
priority === index
? index === 2
? 'border-destructive bg-destructive/10 text-destructive'
: index === 1
? 'border-warning bg-warning/10 text-warning'
- : 'border-primary bg-primary/10 text-primary'
+ : 'border-primary bg-primary/10 text-primary'
: 'border-border bg-card text-muted hover:bg-card-hover'
}
`}
>
{label}
-
+
))}
-
+
Annuler
-
+
{editingAction ? 'Enregistrer' : "Créer l'action"}
diff --git a/src/components/swot/SwotBoard.tsx b/src/components/swot/SwotBoard.tsx
index 32c7f00..bdc174f 100644
--- a/src/components/swot/SwotBoard.tsx
+++ b/src/components/swot/SwotBoard.tsx
@@ -6,6 +6,7 @@ import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client'
import { SwotQuadrant } from './SwotQuadrant';
import { SwotCard } from './SwotCard';
import { ActionPanel } from './ActionPanel';
+import { Button } from '@/components/ui';
import { moveSwotItem } from '@/actions/swot';
type ActionWithLinks = Action & {
@@ -94,12 +95,9 @@ export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
-
+
Annuler
-
+
)}
diff --git a/src/components/swot/SwotCard.tsx b/src/components/swot/SwotCard.tsx
index b2309d7..2be78c1 100644
--- a/src/components/swot/SwotCard.tsx
+++ b/src/components/swot/SwotCard.tsx
@@ -3,7 +3,7 @@
import { forwardRef, memo, useState, useTransition } from 'react';
import type { SwotItem, SwotCategory } from '@prisma/client';
import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
-import { IconEdit, IconTrash, IconDuplicate, IconCheck } from '@/components/ui';
+import { IconEdit, IconTrash, IconDuplicate, IconCheck, IconButton } from '@/components/ui';
interface SwotCardProps {
item: SwotItem;
@@ -114,30 +114,32 @@ export const SwotCard = memo(
{/* Actions (visible on hover) */}
{!linkMode && (
- }
+ label="Modifier"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
- className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
- aria-label="Modifier"
- >
-
-
- { e.stopPropagation(); handleDuplicate(); }}
- className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
- aria-label="Dupliquer"
- >
-
-
- { e.stopPropagation(); handleDelete(); }}
- className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
- aria-label="Supprimer"
- >
-
-
+ />
+ }
+ label="Dupliquer"
+ variant="primary"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDuplicate();
+ }}
+ />
+ }
+ label="Supprimer"
+ variant="destructive"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDelete();
+ }}
+ />
)}
diff --git a/src/components/swot/SwotQuadrant.tsx b/src/components/swot/SwotQuadrant.tsx
index d657d98..fcf02ce 100644
--- a/src/components/swot/SwotQuadrant.tsx
+++ b/src/components/swot/SwotQuadrant.tsx
@@ -4,7 +4,7 @@ import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { SwotCategory } from '@prisma/client';
import { createSwotItem } from '@/actions/swot';
import { QuadrantHelpPanel } from './QuadrantHelp';
-import { IconPlus, InlineFormActions } from '@/components/ui';
+import { IconPlus, InlineAddItem } from '@/components/ui';
interface SwotQuadrantProps {
category: SwotCategory;
@@ -66,16 +66,6 @@ export const SwotQuadrant = forwardRef(
});
}
- function handleKeyDown(e: React.KeyboardEvent) {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleAdd();
- } else if (e.key === 'Escape') {
- setIsAdding(false);
- setNewContent('');
- }
- }
-
return (
(
{/* Add Form */}
{isAdding && (
-
-
+
{
+ setIsAdding(false);
+ setNewContent('');
+ }}
+ isPending={isPending}
+ placeholder="Décrivez cet élément..."
+ submitColorClass={`${styles.text} hover:bg-white/50`}
+ />
)}
diff --git a/src/components/teams/AddMemberModal.tsx b/src/components/teams/AddMemberModal.tsx
index 4b1c2ae..199701d 100644
--- a/src/components/teams/AddMemberModal.tsx
+++ b/src/components/teams/AddMemberModal.tsx
@@ -153,7 +153,7 @@ export function AddMemberModal({
{loading ? 'Ajout...' : 'Ajouter'}
diff --git a/src/components/teams/DeleteTeamButton.tsx b/src/components/teams/DeleteTeamButton.tsx
index 4db070c..05d791f 100644
--- a/src/components/teams/DeleteTeamButton.tsx
+++ b/src/components/teams/DeleteTeamButton.tsx
@@ -42,7 +42,11 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
setShowModal(true)}
variant="outline"
- className="text-destructive border-destructive hover:bg-destructive/10"
+ size="sm"
+ style={{
+ color: 'var(--destructive)',
+ borderColor: 'var(--destructive)',
+ }}
>
Supprimer l'équipe
@@ -63,7 +67,7 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
supprimés.
- setShowModal(false)} disabled={isPending}>
+ setShowModal(false)} disabled={isPending}>
Annuler
diff --git a/src/components/teams/MembersList.tsx b/src/components/teams/MembersList.tsx
index 6c8bd7e..0b78f47 100644
--- a/src/components/teams/MembersList.tsx
+++ b/src/components/teams/MembersList.tsx
@@ -75,10 +75,7 @@ export function MembersList({ members, teamId, isAdmin, onMemberUpdate }: Member
Membres ({members.length})
{isAdmin && (
-
setAddMemberOpen(true)}
- className="bg-[var(--purple)] text-white hover:opacity-90 border-transparent"
- >
+ setAddMemberOpen(true)} variant="brand" size="sm">
Ajouter un membre
)}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 07bcc63..d25bd23 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -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 {
@@ -15,6 +15,7 @@ const variantStyles: Record = {
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 = {
@@ -23,6 +24,28 @@ const sizeStyles: Record = {
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(
(
{ className = '', variant = 'primary', size = 'md', loading, disabled, children, ...props },
@@ -32,15 +55,7 @@ export const Button = forwardRef(
{loading && (
diff --git a/src/components/ui/Disclosure.tsx b/src/components/ui/Disclosure.tsx
new file mode 100644
index 0000000..f43a965
--- /dev/null
+++ b/src/components/ui/Disclosure.tsx
@@ -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 (
+
+
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"
+ >
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+
+ {isOpen && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/DropdownMenu.tsx b/src/components/ui/DropdownMenu.tsx
new file mode 100644
index 0000000..916c882
--- /dev/null
+++ b/src/components/ui/DropdownMenu.tsx
@@ -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(null);
+ useClickOutside(ref, () => setOpen(false), open);
+
+ return (
+
+ {trigger({ open, toggle: () => setOpen((prev) => !prev) })}
+ {open &&
{children({ close: () => setOpen(false) })}
}
+
+ );
+}
diff --git a/src/components/ui/FormField.tsx b/src/components/ui/FormField.tsx
new file mode 100644
index 0000000..664c57d
--- /dev/null
+++ b/src/components/ui/FormField.tsx
@@ -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 (
+
+ {label && (
+
+ )}
+ {children}
+ {hint && !error &&
{hint}
}
+ {error &&
{error}
}
+
+ );
+}
+
+type BaseInputProps = {
+ id?: string;
+ name?: string;
+ value?: string | number;
+ defaultValue?: string | number;
+ onChange?: ChangeEventHandler;
+ 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 (
+
+
+
+ );
+}
+
+interface NumberInputProps extends BaseInputProps {
+ label?: string;
+ error?: string;
+ hint?: string;
+}
+
+export function NumberInput({ label, error, hint, className = '', ...props }: NumberInputProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx
new file mode 100644
index 0000000..3f08ee7
--- /dev/null
+++ b/src/components/ui/IconButton.tsx
@@ -0,0 +1,53 @@
+import { ButtonHTMLAttributes, ReactNode } from 'react';
+
+type IconButtonVariant = 'default' | 'primary' | 'destructive' | 'ghost';
+type IconButtonSize = 'xs' | 'sm' | 'md';
+
+interface IconButtonProps extends ButtonHTMLAttributes {
+ icon: ReactNode;
+ label: string;
+ variant?: IconButtonVariant;
+ size?: IconButtonSize;
+}
+
+const variantStyles: Record = {
+ 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 = {
+ 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 (
+
+ {icon}
+
+ );
+}
diff --git a/src/components/ui/InlineAddItem.tsx b/src/components/ui/InlineAddItem.tsx
new file mode 100644
index 0000000..e97991f
--- /dev/null
+++ b/src/components/ui/InlineAddItem.tsx
@@ -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 (
+
+
+ );
+}
diff --git a/src/components/ui/InlineEditor.tsx b/src/components/ui/InlineEditor.tsx
new file mode 100644
index 0000000..1202e80
--- /dev/null
+++ b/src/components/ui/InlineEditor.tsx
@@ -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 (
+
+
+ );
+}
diff --git a/src/components/ui/InlineFormActions.tsx b/src/components/ui/InlineFormActions.tsx
index 099f2f1..499b0e5 100644
--- a/src/components/ui/InlineFormActions.tsx
+++ b/src/components/ui/InlineFormActions.tsx
@@ -1,3 +1,5 @@
+import { Button } from './Button';
+
interface InlineFormActionsProps {
onCancel: () => void;
onSubmit: () => void;
@@ -19,21 +21,27 @@ export function InlineFormActions({
}: InlineFormActionsProps) {
return (
-
Annuler
-
-
+ 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}
-
+
);
}
diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx
new file mode 100644
index 0000000..fe812f2
--- /dev/null
+++ b/src/components/ui/SegmentedControl.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { ReactNode } from 'react';
+
+export interface SegmentedOption {
+ value: T;
+ label: ReactNode;
+ disabled?: boolean;
+}
+
+interface SegmentedControlProps {
+ value: T;
+ options: SegmentedOption[];
+ 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({
+ value,
+ options,
+ onChange,
+ size = 'md',
+ fullWidth = false,
+ className = '',
+}: SegmentedControlProps) {
+ return (
+
+ {options.map((option) => (
+ 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}
+
+ ))}
+
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 3f7e877..ea7f6b3 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -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';
diff --git a/src/components/weather/WeatherInfoPanel.tsx b/src/components/weather/WeatherInfoPanel.tsx
index dc8efb3..91874a4 100644
--- a/src/components/weather/WeatherInfoPanel.tsx
+++ b/src/components/weather/WeatherInfoPanel.tsx
@@ -1,58 +1,36 @@
'use client';
-import { useState } from 'react';
+import { Disclosure } from '@/components/ui';
export function WeatherInfoPanel() {
- const [isOpen, setIsOpen] = useState(false);
-
return (
-
-
setIsOpen(!isOpen)}
- className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
- >
-
- Les 4 axes de la météo personnelle
-
-
-
- {isOpen && (
-
-
-
-
☀️ Performance
-
- Votre performance personnelle et l'atteinte de vos objectifs
-
-
-
-
😊 Moral
-
- Votre moral actuel et votre ressenti
-
-
-
-
🌊 Flux
-
- Votre flux de travail personnel et les blocages éventuels
-
-
-
-
💎 Création de valeur
-
- Votre création de valeur et votre apport
-
-
-
+
+
+
+
☀️ Performance
+
+ Votre performance personnelle et l'atteinte de vos objectifs
+
- )}
-
+
+
😊 Moral
+
+ Votre moral actuel et votre ressenti
+
+
+
+
🌊 Flux
+
+ Votre flux de travail personnel et les blocages éventuels
+
+
+
+
💎 Création de valeur
+
+ Votre création de valeur et votre apport
+
+
+
+
);
}
diff --git a/src/components/weather/WeatherTrendChart.tsx b/src/components/weather/WeatherTrendChart.tsx
index 9f6b296..9cde6b4 100644
--- a/src/components/weather/WeatherTrendChart.tsx
+++ b/src/components/weather/WeatherTrendChart.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
+import { Disclosure } from '@/components/ui';
import type { WeatherHistoryPoint } from '@/services/weather';
interface WeatherTrendChartProps {
@@ -63,7 +64,6 @@ function buildPath(
const Y_TICKS = [1, 5, 10, 14, 19];
export function WeatherTrendChart({ data, currentSessionId }: WeatherTrendChartProps) {
- const [isOpen, setIsOpen] = useState(false);
const [hoveredIdx, setHoveredIdx] = useState
(null);
if (data.length < 2) return null;
@@ -71,27 +71,16 @@ export function WeatherTrendChart({ data, currentSessionId }: WeatherTrendChartP
const hoveredPt = hoveredIdx !== null ? data[hoveredIdx] : null;
return (
-
-
setIsOpen(!isOpen)}
- className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
- >
-
+
Évolution dans le temps
{data.length} sessions
-
-
-
-
- {isOpen && (
-
+ >
+ }
+ >
+
{/* Legend */}
{INDICATORS.map((ind) => (
@@ -300,8 +289,7 @@ export function WeatherTrendChart({ data, currentSessionId }: WeatherTrendChartP
Score 1 = ☀️ (meilleur) · Score 19 = dégradé
-
- )}
-
+
+
);
}
diff --git a/src/components/weekly-checkin/WeeklyCheckInCard.tsx b/src/components/weekly-checkin/WeeklyCheckInCard.tsx
index bc2a5c8..ff4703f 100644
--- a/src/components/weekly-checkin/WeeklyCheckInCard.tsx
+++ b/src/components/weekly-checkin/WeeklyCheckInCard.tsx
@@ -4,7 +4,7 @@ import { forwardRef, memo, useState, useTransition } from 'react';
import type { WeeklyCheckInItem } from '@prisma/client';
import { updateWeeklyCheckInItem, deleteWeeklyCheckInItem } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
-import { IconEdit, IconTrash, InlineFormActions } from '@/components/ui';
+import { IconEdit, IconTrash, IconButton, InlineFormActions } from '@/components/ui';
import { Select } from '@/components/ui/Select';
interface WeeklyCheckInCardProps {
@@ -144,23 +144,23 @@ export const WeeklyCheckInCard = memo(
{/* Actions (visible on hover) */}
- }
+ label="Modifier"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
- className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
- aria-label="Modifier"
- >
-
-
- { e.stopPropagation(); handleDelete(); }}
- className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
- aria-label="Supprimer"
- >
-
-
+ />
+ }
+ label="Supprimer"
+ variant="destructive"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDelete();
+ }}
+ />
>
)}
diff --git a/src/components/weekly-checkin/WeeklyCheckInSection.tsx b/src/components/weekly-checkin/WeeklyCheckInSection.tsx
index 87c2cde..ea1d31d 100644
--- a/src/components/weekly-checkin/WeeklyCheckInSection.tsx
+++ b/src/components/weekly-checkin/WeeklyCheckInSection.tsx
@@ -4,7 +4,7 @@ import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { WeeklyCheckInCategory } from '@prisma/client';
import { createWeeklyCheckInItem } from '@/actions/weekly-checkin';
import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types';
-import { IconPlus, InlineFormActions } from '@/components/ui';
+import { IconPlus, InlineAddItem } from '@/components/ui';
import { Select } from '@/components/ui/Select';
interface WeeklyCheckInSectionProps {
@@ -44,17 +44,6 @@ export const WeeklyCheckInSection = forwardRef
{
- // Don't close if focus moves to another element in this container
- const currentTarget = e.currentTarget;
- const relatedTarget = e.relatedTarget as Node | null;
- if (relatedTarget && currentTarget.contains(relatedTarget)) {
- return;
- }
- // Only add on blur if content is not empty
- if (newContent.trim()) {
- handleAdd();
- } else {
- setIsAdding(false);
- setNewContent('');
- setNewEmotion('NONE');
- }
+ {
+ setIsAdding(false);
+ setNewContent('');
+ setNewEmotion('NONE');
}}
- >
-
diff --git a/src/components/year-review/YearReviewCard.tsx b/src/components/year-review/YearReviewCard.tsx
index 063bef9..79943d2 100644
--- a/src/components/year-review/YearReviewCard.tsx
+++ b/src/components/year-review/YearReviewCard.tsx
@@ -4,7 +4,7 @@ import { forwardRef, memo, useState, useTransition } from 'react';
import type { YearReviewItem } from '@prisma/client';
import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
-import { IconEdit, IconTrash } from '@/components/ui';
+import { IconEdit, IconTrash, IconButton } from '@/components/ui';
interface YearReviewCardProps {
item: YearReviewItem;
@@ -88,23 +88,23 @@ export const YearReviewCard = memo(
{/* Actions (visible on hover) */}
- }
+ label="Modifier"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
- className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
- aria-label="Modifier"
- >
-
-
- { e.stopPropagation(); handleDelete(); }}
- className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
- aria-label="Supprimer"
- >
-
-
+ />
+ }
+ label="Supprimer"
+ variant="destructive"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDelete();
+ }}
+ />
>
)}
diff --git a/src/components/year-review/YearReviewSection.tsx b/src/components/year-review/YearReviewSection.tsx
index ec80146..989d9be 100644
--- a/src/components/year-review/YearReviewSection.tsx
+++ b/src/components/year-review/YearReviewSection.tsx
@@ -4,7 +4,7 @@ import { forwardRef, useState, useTransition, useRef, ReactNode } from 'react';
import type { YearReviewCategory } from '@prisma/client';
import { createYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
-import { IconPlus, InlineFormActions } from '@/components/ui';
+import { IconPlus, InlineAddItem } from '@/components/ui';
interface YearReviewSectionProps {
category: YearReviewCategory;
@@ -40,16 +40,6 @@ export const YearReviewSection = forwardRef
-
+ {
+ setIsAdding(false);
+ setNewContent('');
+ }}
+ isPending={isPending}
+ placeholder={`Décrivez ${config.title.toLowerCase()}...`}
+ />
)}