Paramètres : un onglet par section, fenêtre dimensionnée sur l'écran

La boîte de dialogue empilait cinq sections dans 480 px de large et
défilait verticalement (et horizontalement à cause des boutons de la
poubelle). Elle passe à 760 × 480 px, bornée par la hauteur de la
fenêtre, avec les sections en onglets à gauche (Apparence, Excalidraw,
Poubelle, Liens de partage, API et MCP) ; boutons Fermer/Enregistrer
toujours visibles. Sur mobile, les onglets passent en ligne au-dessus.
L'onglet Excalidraw porte une pastille quand une version est à
installer, l'onglet Partage le nombre de liens actifs ; l'icône de mise
à jour ouvre directement l'onglet Excalidraw. Navigation au clavier par
les flèches. Les onglets masqués restent montés (jeton créé, titre en
cours de saisie conservés).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5Kam45ChmR5syg6igrpHZ
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-15 10:30:39 +02:00
1 parent d052ac383b
commit de81d99375
2 files changed
+181 -18

No files matched your search

+76 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ApiAccess } from "./ApiAccess"; import { ApiAccess } from "./ApiAccess";
import { api, type Channel, type Settings, type Share, type UpdateStatus } from "./api"; import { api, type Channel, type Settings, type Share, type UpdateStatus } from "./api";
import { CopyIcon, TrashIcon } from "./icons"; import { CopyIcon, TrashIcon } from "./icons";
@@ -11,7 +11,7 @@ const MAX_TITLE = 80; // voir storage.MaxTitleLen
interface Props { interface Props {
settings: Settings; settings: Settings;
update: UpdateStatus | null; update: UpdateStatus | null;
/** Ouvert depuis l'icône de mise à jour : affiche directement la section Excalidraw. */ /** Ouvert depuis l'icône de mise à jour : affiche directement l'onglet Excalidraw. */
focusExcalidraw?: boolean; focusExcalidraw?: boolean;
onChange: (s: Settings) => void; onChange: (s: Settings) => void;
onClose: () => void; onClose: () => void;
@@ -34,6 +34,16 @@ const TRASH_DAYS = [
{ days: 30, label: "30 jours" }, { days: 30, label: "30 jours" },
]; ];
/** Onglets du dialogue : une section par onglet, pour que tout tienne sans défilement. */
const TABS = [
{ id: "apparence", label: "Apparence" },
{ id: "excalidraw", label: "Excalidraw" },
{ id: "poubelle", label: "Poubelle" },
{ id: "partage", label: "Liens de partage" },
{ id: "api", label: "API et MCP" },
] as const;
type Tab = (typeof TABS)[number]["id"];
/** Paramètres partagés par tous les utilisateurs : titre, logo, version d'Excalidraw. */ /** Paramètres partagés par tous les utilisateurs : titre, logo, version d'Excalidraw. */
export function SettingsDialog({ settings, update, focusExcalidraw, onChange, onClose }: Props) { export function SettingsDialog({ settings, update, focusExcalidraw, onChange, onClose }: Props) {
const [title, setTitle] = useState(settings.title); const [title, setTitle] = useState(settings.title);
@@ -41,11 +51,12 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [shares, setShares] = useState<Share[] | null>(null); const [shares, setShares] = useState<Share[] | null>(null);
const [tab, setTab] = useState<Tab>(focusExcalidraw ? "excalidraw" : "apparence");
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const exSection = useRef<HTMLElement>(null); const tabList = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (focusExcalidraw) exSection.current?.scrollIntoView({ block: "start" }); if (focusExcalidraw) setTab("excalidraw");
}, [focusExcalidraw]); }, [focusExcalidraw]);
const loadShares = () => const loadShares = () =>
@@ -97,6 +108,16 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
run(() => api.uploadLogo(f)); run(() => api.uploadLogo(f));
}; };
// Flèches du clavier dans la liste d'onglets (verticale sur bureau, horizontale sur mobile).
const onTabKey = (e: ReactKeyboardEvent) => {
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
if (!step) return;
e.preventDefault();
const i = (TABS.findIndex((t) => t.id === tab) + step + TABS.length) % TABS.length;
setTab(TABS[i].id);
tabList.current?.querySelectorAll<HTMLButtonElement>("[role=tab]")[i]?.focus();
};
const dirty = title.trim() !== settings.title; const dirty = title.trim() !== settings.title;
const pending = pendingVersion(update, settings.channel); const pending = pendingVersion(update, settings.channel);
const cmd = pending ? rebuildCommand(pending) : null; const cmd = pending ? rebuildCommand(pending) : null;
@@ -107,6 +128,16 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
}); });
// Les onglets masqués restent montés : un jeton juste créé ou un titre en cours de saisie
// ne sont pas perdus en changeant d'onglet.
const pane = (id: Tab) => ({
id: `settings-${id}`,
role: "tabpanel",
"aria-labelledby": `settings-tab-${id}`,
className: "settings-pane",
hidden: tab !== id,
});
return ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose}>
<form <form
@@ -122,7 +153,29 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
> >
<h3>Paramètres</h3> <h3>Paramètres</h3>
<h4>Apparence</h4> <div className="settings-body">
<div className="settings-tabs" role="tablist" aria-label="Sections" ref={tabList} onKeyDown={onTabKey}>
{TABS.map((t) => (
<button
key={t.id}
id={`settings-tab-${t.id}`}
type="button"
role="tab"
aria-selected={tab === t.id}
aria-controls={`settings-${t.id}`}
tabIndex={tab === t.id ? 0 : -1}
className={tab === t.id ? "on" : ""}
onClick={() => setTab(t.id)}
>
{t.label}
{t.id === "excalidraw" && pending && <i className="dot" title={`À installer : ${pending}`} />}
{t.id === "partage" && !!shares?.length && <small>{shares.length}</small>}
</button>
))}
</div>
<div className="settings-panel">
<section {...pane("apparence")}>
<label className="field"> <label className="field">
<span>Titre affiché</span> <span>Titre affiché</span>
<input <input
@@ -130,7 +183,7 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
value={title} value={title}
maxLength={MAX_TITLE} maxLength={MAX_TITLE}
placeholder={DEFAULT_TITLE} placeholder={DEFAULT_TITLE}
autoFocus autoFocus={!focusExcalidraw}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</label> </label>
@@ -168,9 +221,10 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
/> />
</div> </div>
</div> </div>
</section>
<section className="field" ref={exSection}> <section {...pane("excalidraw")}>
<h4>Excalidraw</h4> <div className="field">
<span>Version à suivre</span> <span>Version à suivre</span>
<div className="radio-group" role="radiogroup" aria-label="Canal d'Excalidraw"> <div className="radio-group" role="radiogroup" aria-label="Canal d'Excalidraw">
{CHANNELS.map((c) => ( {CHANNELS.map((c) => (
@@ -220,10 +274,14 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
</small> </small>
</> </>
)} )}
<a className="link" href="https://github.com/excalidraw/excalidraw/releases" target="_blank" rel="noreferrer">
Notes de version
</a>
</div>
</section> </section>
<section className="field"> <section {...pane("poubelle")}>
<h4>Poubelle</h4> <div className="field">
<span>Suppression définitive automatique</span> <span>Suppression définitive automatique</span>
<div className="radio-group" role="radiogroup" aria-label="Purge de la poubelle"> <div className="radio-group" role="radiogroup" aria-label="Purge de la poubelle">
{TRASH_DAYS.map((d) => ( {TRASH_DAYS.map((d) => (
@@ -245,10 +303,11 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
? `Les éléments mis à la poubelle sont supprimés définitivement ${settings.trashDays} jour${settings.trashDays > 1 ? "s" : ""} après leur suppression.` ? `Les éléments mis à la poubelle sont supprimés définitivement ${settings.trashDays} jour${settings.trashDays > 1 ? "s" : ""} après leur suppression.`
: "La poubelle garde tout jusqu'à ce qu'on la vide ; son icône indique si elle contient des éléments."} : "La poubelle garde tout jusqu'à ce qu'on la vide ; son icône indique si elle contient des éléments."}
</small> </small>
</div>
</section> </section>
<section className="field"> <section {...pane("partage")}>
<h4>Liens de partage</h4> <div className="field">
{shares?.length ? ( {shares?.length ? (
<ul className="share-list all"> <ul className="share-list all">
{shares.map((s) => ( {shares.map((s) => (
@@ -292,16 +351,18 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
Tout révoquer Tout révoquer
</button> </button>
)} )}
</div>
</section> </section>
<div {...pane("api")}>
<ApiAccess onError={setError} /> <ApiAccess onError={setError} />
</div>
</div>
</div>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
<div className="modal-actions"> <div className="modal-actions">
<a className="link" href="https://github.com/excalidraw/excalidraw/releases" target="_blank" rel="noreferrer">
Notes de version
</a>
<span className="spacer" /> <span className="spacer" />
<button type="button" onClick={onClose}> <button type="button" onClick={onClose}>
Fermer Fermer
+105 -3
View File
@@ -1059,11 +1059,113 @@ li.active > .row {
font-size: 13px; font-size: 13px;
} }
/* Dialogue « Paramètres » */ /* Dialogue « Paramètres » : un onglet par section, une seule visible à la fois. Hauteur
fixe, bornée par la fenêtre, pour que la boîte ne change pas de taille d'un onglet à
l'autre ; seul le contenu de l'onglet défile en dernier recours, les boutons restent
visibles. Sur mobile, les onglets passent en ligne au-dessus du contenu et la boîte
prend la hauteur de l'onglet affiché. */
.modal.settings { .modal.settings {
width: min(480px, 100%); width: min(760px, 100%);
max-height: min(720px, 92dvh); height: min(480px, calc(100dvh - 32px));
max-height: none;
gap: 12px;
}
.settings-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 170px 1fr;
gap: 16px;
}
.settings-tabs {
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 12px;
border-right: 1px solid var(--border);
}
.settings-tabs button {
justify-content: flex-start;
width: 100%;
background: transparent;
box-shadow: none;
font-size: 13px;
letter-spacing: normal;
}
.settings-tabs button.on {
background: var(--ex-primary-container);
color: var(--ex-on-primary-container);
font-weight: 600;
}
.settings-tabs small {
margin-left: auto;
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--ex-surface-low);
font-size: 11px;
line-height: 18px;
text-align: center;
}
.settings-tabs .dot {
width: 7px;
height: 7px;
margin-left: auto;
border-radius: 50%;
background: var(--warn);
}
.settings-panel {
min-width: 0;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-right: 4px;
}
.settings-pane {
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-pane[hidden] {
display: none;
}
.settings-pane h4 {
display: none; /* redondant avec l'onglet */
}
.settings .radio-group {
flex-wrap: wrap;
max-width: 100%;
}
.settings .radio-group button {
min-width: 96px;
}
.settings .field .link {
align-self: flex-start;
}
.settings .modal-actions {
padding-top: 12px;
border-top: 1px solid var(--border);
}
@media (max-width: 640px) {
.modal.settings {
height: auto;
max-height: calc(100dvh - 32px);
}
.settings-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
gap: 10px;
}
.settings-tabs {
flex-direction: row;
overflow-x: auto;
padding: 0 0 8px;
border-right: none;
border-bottom: 1px solid var(--border);
}
.settings-tabs button {
width: auto;
flex: none;
}
} }
.modal h4 { .modal h4 {
margin: 6px 0 0; margin: 6px 0 0;