Retirer la barre latérale au profit d'onglets de section

Deux défauts la condamnaient. Elle rognait la largeur utile au moment précis où
la grille de planning en manque le plus. Et elle donnait à la navigation deux
grammaires : des onglets sur la fiche salarié, une liste verticale partout
ailleurs. Une grammaire se retient, deux se cherchent.

Les entrées de la section courante s'affichent donc en onglets, dans la même
forme que la fiche salarié. Une entrée sans écran construit reste visible et
mène à l'écran d'attente : la masquer ferait croire le produit plus étroit que
ce qu'il vise, la laisser inerte ferait croire la navigation cassée.

La colonne centrale passe en largeur fixe. Une largeur qui suit la fenêtre fait
bouger les colonnes du planning d'un poste à l'autre : la même semaine ne se lit
pas au même endroit sur deux écrans, et l'œil doit se réorienter à chaque
ouverture. Une largeur arrêtée rend la page reconnaissable ; sur une fenêtre
étroite, elle défile horizontalement plutôt que de comprimer la grille.

À l'impression, cette largeur n'a plus de sens et déborderait de la feuille :
elle y est relâchée, comme les autres largeurs minimales de la grille.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 13:31:23 +02:00
1 parent 8033553222
commit 0a916ff73e
6 files changed
+107 -64

No files matched your search

+7
View File
@@ -411,6 +411,13 @@ body {
min-width: 0 !important;
}
/* La colonne centrale est de largeur fixe à l'écran — c'est ce qui rend la
grille reconnaissable d'un poste à l'autre. Sur une feuille, cette largeur
n'a plus de sens et déborderait de la page. */
.w-\[1280px\] {
width: auto !important;
}
[data-print='hide'],
nav,
aside {
+25 -61
View File
@@ -4,18 +4,15 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
import { SectionTabs } from '@/components/shell/SectionTabs';
import { ThemeToggle } from '@/components/shell/ThemeToggle';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cx } from '@/lib/cx';
import { signOutAction } from '@/server/auth/actions';
import {
isActive,
NAVIGATION,
placeholderHref,
sectionForPath,
sectionHref,
type NavItem,
} from '@/components/shell/navigation';
export interface AppShellProps {
@@ -91,64 +88,31 @@ export function AppShell({
</form>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-57 flex-none flex-col gap-0.5 overflow-auto border-r border-line-1 bg-surface p-3 pt-4">
<p className="px-2.5 pb-2.5 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
{section.label}
</p>
{section.items.map((item) => (
<SidebarLink key={item.id} item={item} pathname={pathname} />
))}
<span className="flex-1" />
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
Instance auto-hébergée
<br />
{accountName}
</p>
</aside>
{/*
Colonne centrale de **largeur fixe**, pas fluide.
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
</div>
Une largeur qui suit la fenêtre fait bouger les colonnes du planning
d'un poste à l'autre : la même semaine ne se lit pas au même endroit sur
deux écrans, et l'œil doit se réorienter à chaque ouverture. Une largeur
arrêtée rend la page reconnaissable, et laisse la fenêtre défiler
horizontalement plutôt que de comprimer la grille.
*/}
<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">
<SectionTabs section={section} pathname={pathname} />
</div>
{children}
<p
data-print="hide"
className="mt-auto border-t border-line-1 px-6 py-4 text-micro leading-relaxed text-ink-3"
>
Instance auto-hébergée · {accountName}
</p>
</div>
</main>
</div>
);
}
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const current = item.href ? isActive(item.href, pathname) : false;
const className = cx(
'flex w-full items-center justify-between gap-2 rounded-2 px-2.5 py-1.5 text-left text-sm',
'transition-colors duration-[var(--d-1)] ease-organic',
current
? 'bg-accent-soft font-semibold text-accent-soft-ink'
: 'text-ink-2 hover:bg-surface-2',
);
const content = (
<>
<span className="truncate">{item.label}</span>
{item.badge ? (
<Badge subtle tone={current ? 'neutral' : 'neutral'}>
{item.badge}
</Badge>
) : null}
</>
);
if (!item.href) {
return (
<Link href={placeholderHref(item.label)} className={cx(className, 'text-ink-3')}>
{content}
</Link>
);
}
return (
<Link
href={item.href}
aria-current={current ? 'page' : undefined}
className={className}
>
{content}
</Link>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import Link from 'next/link';
import { Badge } from '@/components/ui/Badge';
import {
isActive,
placeholderHref,
type NavSection,
} from '@/components/shell/navigation';
import { cx } from '@/lib/cx';
/**
* Entrées de la section courante, en onglets.
*
* Elles vivaient dans une barre latérale. Deux raisons de l'avoir retirée :
* elle rognait la largeur utile au moment précis où la grille de planning en
* manque, et elle donnait à la navigation deux grammaires — des onglets sur la
* fiche salarié, une liste verticale ailleurs. Une seule grammaire se retient ;
* deux se cherchent.
*
* Une entrée sans `href` désigne un écran du périmètre qui n'est pas encore
* construit. Elle reste affichée et mène à un écran d'attente qui le dit : la
* masquer ferait croire le produit plus étroit qu'il n'est visé, et la laisser
* inerte ferait croire la navigation cassée.
*/
export function SectionTabs({
section,
pathname,
}: {
section: NavSection;
pathname: string;
}) {
if (section.items.length === 0) return null;
return (
<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"
>
{section.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(
'flex shrink-0 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
'transition-colors duration-[var(--d-1)] ease-organic',
current
? 'bg-surface-3 text-ink-1'
: item.href
? 'text-ink-2 hover:bg-surface-2 hover:text-ink-1'
: 'text-ink-3 hover:bg-surface-2',
)}
>
{item.label}
{item.badge ? (
<Badge subtle tone="neutral">
{item.badge}
</Badge>
) : null}
</Link>
);
})}
</nav>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ test('chaque tuile mène à ses lignes sources', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// Les tuiles sont des liens, pas des chiffres décoratifs. On vise celle du
// contenu, pas l'entrée homonyme de la barre latérale.
// contenu, pas l'onglet de section homonyme.
const tile = page
.locator('main')
.getByRole('link', { name: /Profils incomplets/ })
+2 -1
View File
@@ -51,7 +51,8 @@ test('le thème bascule et survit à un rechargement', async ({ page }) => {
test('une entrée de navigation non construite mène à un écran explicite', async ({
page,
}) => {
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
// Les onglets n'affichent que les entrées de la section courante : on
// l'ouvre d'abord.
await page.goto('/absences/calendrier');
// « Politiques de congés » fait partie du périmètre visé mais n'est pas
// encore construit : l'entrée reste, et mène à un écran qui le dit.
+1 -1
View File
@@ -23,7 +23,7 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({
}) => {
await signIn(page, 'manager.nantes@example.test');
// La barre latérale ne propose pas la section, mais c'est un confort :
// Les onglets ne proposent pas la section, mais c'est un confort :
// le contrôle qui compte est celui du serveur, testé en accédant à l'URL.
await page.goto('/reglages/etablissements');