Supprimer l'ascenseur des onglets, et rendre les réglages à un rail
Les onglets de section défilaient horizontalement dès que la liste dépassait la largeur. Une barre de défilement cache la moitié des entrées derrière un geste que rien n'annonce : on ne sait pas ce qu'on ne voit pas. Ils passent à la ligne — deux rangées coûtent seize pixels et montrent tout. Les réglages sortent des onglets pour un rail vertical, et eux seuls. Ce n'est pas un retour de la barre latérale : partout ailleurs la section tient en quelques entrées et le contenu garde toute la largeur. Les réglages en comptent une vingtaine ; en onglets, ils occuperaient trois rangées au-dessus de chaque écran et changeraient de forme d'une page à l'autre selon la longueur des libellés. Le rail les groupe dans l'ordre d'un paramétrage — société, organisation, travail, paie, accès, conformité — et non par ordre alphabétique : quelqu'un qui installe l'instance descend la liste. Une entrée ajoutée sans groupe reste visible en fin de rail plutôt que de disparaître. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9356c6e3f4
commit
dd01fb58f4
3 files changed
+138
-5
No files matched your search
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { SectionTabs } from '@/components/shell/SectionTabs';
|
import { SectionTabs } from '@/components/shell/SectionTabs';
|
||||||
|
import { SettingsRail } from '@/components/shell/SettingsRail';
|
||||||
import { ThemeToggle } from '@/components/shell/ThemeToggle';
|
import { ThemeToggle } from '@/components/shell/ThemeToggle';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
@@ -32,6 +33,7 @@ export function AppShell({
|
|||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const section = sectionForPath(pathname);
|
const section = sectionForPath(pathname);
|
||||||
|
const isSettings = section.id === 'reglages';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
||||||
@@ -127,11 +129,32 @@ export function AppShell({
|
|||||||
*/}
|
*/}
|
||||||
<main className="min-h-0 flex-1 overflow-auto">
|
<main className="min-h-0 flex-1 overflow-auto">
|
||||||
<div className="mx-auto flex min-h-full w-[1280px] flex-col">
|
<div className="mx-auto flex min-h-full w-[1280px] flex-col">
|
||||||
<div className="px-6 pt-5">
|
{/*
|
||||||
<SectionTabs section={section} pathname={pathname} />
|
Deux navigations de section, pour deux usages.
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
Partout, des onglets : la section tient en quelques entrées et le
|
||||||
|
contenu garde toute la largeur. Aux réglages, un rail vertical : une
|
||||||
|
vingtaine d'entrées en onglets prendraient trois rangées au-dessus de
|
||||||
|
chaque écran, et changeraient de forme d'une page à l'autre selon la
|
||||||
|
longueur des libellés. On traverse les réglages, on ne fait pas
|
||||||
|
d'aller-retour entre eux pendant qu'on travaille.
|
||||||
|
*/}
|
||||||
|
{isSettings ? (
|
||||||
|
// Le rail porte la marge gauche ; le contenu garde la sienne, celle
|
||||||
|
// de `PageBody`. Les additionner ferait dériver la colonne des
|
||||||
|
// réglages par rapport à toutes les autres pages.
|
||||||
|
<div className="flex min-h-full flex-1 gap-2 pl-6">
|
||||||
|
<SettingsRail section={section} pathname={pathname} />
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
<SectionTabs section={section} pathname={pathname} />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<p
|
<p
|
||||||
data-print="hide"
|
data-print="hide"
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ export function SectionTabs({
|
|||||||
if (section.items.length === 0) return null;
|
if (section.items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// Les onglets **passent à la ligne**, ils ne défilent pas. Une barre de
|
||||||
|
// défilement horizontale cache la moitié des entrées derrière un geste que
|
||||||
|
// rien n'annonce : on ne sait pas ce qu'on ne voit pas. Deux rangées
|
||||||
|
// prennent seize pixels de plus et montrent tout.
|
||||||
<nav
|
<nav
|
||||||
aria-label={`Section ${section.label}`}
|
aria-label={`Section ${section.label}`}
|
||||||
data-print="hide"
|
data-print="hide"
|
||||||
className="flex gap-1 overflow-x-auto rounded-3 border border-line-1 bg-surface p-1.5"
|
className="flex flex-wrap gap-1 rounded-3 border border-line-1 bg-surface p-1.5"
|
||||||
>
|
>
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const current = item.href ? isActive(item.href, pathname) : false;
|
const current = item.href ? isActive(item.href, pathname) : false;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isActive,
|
||||||
|
placeholderHref,
|
||||||
|
type NavSection,
|
||||||
|
} from '@/components/shell/navigation';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rail latéral des réglages — et **seulement** des réglages.
|
||||||
|
*
|
||||||
|
* Partout ailleurs, la section tient en une poignée d'entrées et se présente en
|
||||||
|
* onglets, qui laissent toute la largeur au contenu. Les réglages en comptent
|
||||||
|
* une vingtaine : en onglets, ils occuperaient trois rangées au-dessus de
|
||||||
|
* chaque écran, et la liste changerait de forme d'une page à l'autre selon la
|
||||||
|
* longueur des libellés.
|
||||||
|
*
|
||||||
|
* Une liste verticale les tient dans un ordre stable, groupés par domaine. Ce
|
||||||
|
* n'est pas la même navigation qu'ailleurs parce que ce n'est pas le même
|
||||||
|
* usage : on traverse les réglages, on ne fait pas d'aller-retour entre eux
|
||||||
|
* pendant qu'on travaille.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regroupement des entrées, dans l'ordre d'un paramétrage.
|
||||||
|
*
|
||||||
|
* L'identité de l'entreprise, puis ce qui organise le travail, puis ce qui le
|
||||||
|
* paie, puis ce qui l'encadre. Un client qui installe l'instance descend la
|
||||||
|
* liste ; c'est cet ordre-là, pas l'alphabet.
|
||||||
|
*/
|
||||||
|
const GROUPS: ReadonlyArray<{ label: string; ids: readonly string[] }> = [
|
||||||
|
{ label: 'Société', ids: ['compte', 'sites', 'convention'] },
|
||||||
|
{ label: 'Organisation', ids: ['emplois', 'etiquettes', 'types-absence'] },
|
||||||
|
{ label: 'Travail', ids: ['preferences', 'impression', 'productivite'] },
|
||||||
|
{ label: 'Paie', ids: ['paie-reglages'] },
|
||||||
|
{ label: 'Accès', ids: ['roles', 'securite', 'email'] },
|
||||||
|
{
|
||||||
|
label: 'Conformité',
|
||||||
|
ids: ['registre', 'conservation', 'rgpd', 'modeles'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsRail({
|
||||||
|
section,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
section: NavSection;
|
||||||
|
pathname: string;
|
||||||
|
}) {
|
||||||
|
const byId = new Map(section.items.map((item) => [item.id, item]));
|
||||||
|
|
||||||
|
// Une entrée ajoutée à la navigation sans être rangée dans un groupe reste
|
||||||
|
// visible, en fin de liste : la perdre serait pire que de mal la classer.
|
||||||
|
const grouped = new Set(GROUPS.flatMap((group) => group.ids));
|
||||||
|
const rest = section.items.filter((item) => !grouped.has(item.id));
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
...GROUPS.map((group) => ({
|
||||||
|
label: group.label,
|
||||||
|
items: group.ids
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter((item) => item !== undefined),
|
||||||
|
})),
|
||||||
|
...(rest.length > 0 ? [{ label: 'Autres', items: rest }] : []),
|
||||||
|
].filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Réglages"
|
||||||
|
data-print="hide"
|
||||||
|
className="flex w-56 flex-none flex-col gap-5 pt-6 pb-4"
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label} className="flex flex-col gap-0.5">
|
||||||
|
<p className="eyebrow px-2.5 pb-1.5">{group.label}</p>
|
||||||
|
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const current = item.href ? isActive(item.href, pathname) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={item.href ?? placeholderHref(item.label)}
|
||||||
|
aria-current={current ? 'page' : undefined}
|
||||||
|
className={cx(
|
||||||
|
'rounded-2 border-l-2 px-2.5 py-1.5 text-sm',
|
||||||
|
'transition-colors duration-[var(--d-2)] ease-organic',
|
||||||
|
current
|
||||||
|
? 'border-accent bg-accent-soft font-semibold text-accent-soft-ink'
|
||||||
|
: item.href
|
||||||
|
? 'border-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1'
|
||||||
|
: 'border-transparent text-ink-3 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user