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
+136
-3
No files matched your search
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SectionTabs } from '@/components/shell/SectionTabs';
|
||||
import { SettingsRail } from '@/components/shell/SettingsRail';
|
||||
import { ThemeToggle } from '@/components/shell/ThemeToggle';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cx } from '@/lib/cx';
|
||||
@@ -32,6 +33,7 @@ export function AppShell({
|
||||
}: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const section = sectionForPath(pathname);
|
||||
const isSettings = section.id === 'reglages';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="mx-auto flex min-h-full w-[1280px] flex-col">
|
||||
<div className="px-6 pt-5">
|
||||
{/*
|
||||
Deux navigations de section, pour deux usages.
|
||||
|
||||
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
|
||||
data-print="hide"
|
||||
|
||||
@@ -34,10 +34,14 @@ export function SectionTabs({
|
||||
if (section.items.length === 0) return null;
|
||||
|
||||
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
|
||||
aria-label={`Section ${section.label}`}
|
||||
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) => {
|
||||
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