feat(settings): a settings kit in the planner's language; cards, navigation and header

This commit is contained in:
Maurice
2026-10-01 15:27:43 +02:00
parent d7bf7f2f16
commit 4bc7c5cf68
4 changed files with 200 additions and 51 deletions
+18 -30
View File
@@ -49,15 +49,12 @@ export default function PageSidebar({
}, [mobileOpen])
return (
<div
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative bg-surface-card border border-edge"
style={{
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
}}
>
// The planner's layout (#2541): a navigation card on the left, the panel's
// own cards on the page beside it, no frame around the two.
<div className="relative flex flex-col items-start gap-5 lg:flex-row">
{/* Mobile top bar with hamburger */}
<div
className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-edge"
className="lg:hidden flex w-full items-center justify-between rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2"
>
<button type="button"
onClick={() => setMobileOpen(true)}
@@ -74,11 +71,8 @@ export default function PageSidebar({
{/* Desktop sidebar (always visible on lg) */}
<aside
className="hidden lg:flex flex-col shrink-0 relative bg-surface-secondary border-r border-edge"
style={{
width: 260,
padding: '24px 14px',
}}
className="hidden lg:flex sticky flex-col shrink-0 self-start rounded-[18px] border border-edge-faint bg-surface-secondary p-2.5"
style={{ width: 248, top: 'calc(var(--nav-h, 56px) + 16px)', maxHeight: 'calc(100vh - var(--nav-h, 56px) - 32px)', overflowY: 'auto' }}
>
<SidebarInner
sidebarLabel={sidebarLabel}
@@ -134,7 +128,7 @@ export default function PageSidebar({
)}
{/* Panel */}
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
<div className="w-full min-w-0 flex-1">
{children}
</div>
</div>
@@ -157,13 +151,11 @@ function SidebarInner({
return (
<>
{sidebarLabel && (
<div
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3 text-content-muted"
>
<div className="mb-1.5 mt-1 px-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))' }}>
{sidebarLabel}
</div>
)}
<nav className="flex flex-col gap-1 flex-1">
<nav className="flex flex-col gap-0.5 flex-1">
{(() => {
let lastGroup: string | undefined
return tabs.map((tab) => {
@@ -174,24 +166,19 @@ function SidebarInner({
return (
<React.Fragment key={tab.id}>
{showHeader && (
<div className="text-[10px] font-bold tracking-widest uppercase text-content-faint px-3 mt-3 mb-0.5 first:mt-0">
<div className="mt-3 mb-1 px-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={{ fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))' }}>
{tab.group}
</div>
)}
<button type="button"
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`}
style={{
background: active ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent'
}}
aria-current={active ? 'page' : undefined}
className={`flex items-center gap-2.5 rounded-[11px] px-2 py-1.5 text-left transition-colors ${active ? 'bg-surface-card font-semibold text-content shadow-sm ring-1 ring-edge-faint' : 'font-medium text-content-secondary hover:bg-surface-hover hover:text-content'}`}
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<Icon size={16} className="shrink-0" />
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-[8px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}>
<Icon size={14} strokeWidth={2} />
</span>
<span className="truncate">{tab.label}</span>
</button>
</React.Fragment>
@@ -201,7 +188,8 @@ function SidebarInner({
</nav>
{footer && (
<div
className="mt-4 pt-3 px-3 text-[10px] tracking-wide text-content-faint border-t border-edge"
className="mt-3 border-t border-edge-faint px-2.5 pt-2.5 font-geist tabular-nums text-content-faint"
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}
>
{footer}
</div>
+10 -11
View File
@@ -1,5 +1,6 @@
import React from 'react'
import type { LucideIcon } from 'lucide-react'
import { SettingsCard } from './settingsKit'
interface SectionProps {
title: string
@@ -9,20 +10,18 @@ interface SectionProps {
*/
icon: LucideIcon | React.ComponentType<{ className?: string }>
badge?: React.ReactNode
/** One line under the title in the head band. */
hint?: React.ReactNode
/** A control on the right of the head band. */
action?: React.ReactNode
children: React.ReactNode
}
export default function Section({ title, icon: Icon, badge, children }: SectionProps): React.ReactElement {
/** A settings group: the planner's card with a head band (see settingsKit). */
export default function Section({ title, icon, badge, hint, action, children }: SectionProps): React.ReactElement {
return (
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge" style={{ marginBottom: 24 }}>
<div className="px-6 py-4 border-b flex items-center gap-2 border-edge-secondary">
<Icon className="w-5 h-5 text-content-secondary" />
<h2 className="font-semibold text-content">{title}</h2>
{badge}
</div>
<div className="p-6 space-y-4">
{children}
</div>
</div>
<SettingsCard icon={icon} title={title} badge={badge} hint={hint} action={action}>
{children}
</SettingsCard>
)
}
@@ -0,0 +1,169 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
/*
* The settings and admin pages in the planner's language (#2541): a tinted bar
* on top, cards with a head band, white rows inside them, eyebrow labels over
* fields. One kit for both pages, so a toggle, a hint or a danger zone reads the
* same wherever it sits. Fields themselves come from shared/dialogParts (INPUT,
* EditorField, Segmented) and shared/CustomSelect, like in the editors.
*/
type IconLike = LucideIcon | React.ComponentType<{ className?: string; size?: number; strokeWidth?: number }>
/** The page's own bar: icon tile, title and subtitle on the left, actions on the right. */
export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
icon: IconLike
title: ReactNode
subtitle?: ReactNode
actions?: ReactNode
}) {
return (
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-[18px] bg-surface-tertiary py-3 pl-3 pr-3">
<span className="grid h-10 w-10 flex-none place-items-center rounded-[12px] bg-surface-card text-content-secondary shadow-sm">
<Icon size={18} strokeWidth={1.9} />
</span>
<div className="min-w-0 flex-1">
<h1 className="m-0 text-subtitle font-semibold tracking-[-0.01em] text-content">{title}</h1>
{subtitle && <p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
)
}
/**
* A group of settings: a card with a head band (icon tile, title, an optional
* badge and an action on the right) and its body below. `tone` tints the band:
* neutral for ordinary groups, danger for the destructive ones.
*/
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName }: {
icon?: IconLike
title: ReactNode
/** One line under the title in the band. */
hint?: ReactNode
badge?: ReactNode
action?: ReactNode
tone?: 'neutral' | 'danger'
children?: ReactNode
id?: string
/** Replaces the body's padded column. */
bodyClassName?: string
}) {
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
return (
<section id={id} className="mb-5 overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary">
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: tint }}>
{Icon && (
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
<Icon size={15} strokeWidth={2} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="m-0 truncate font-bold text-content" style={fs(14, 'body')}>{title}</h2>
{badge}
</div>
{hint && <p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>{hint}</p>}
</div>
{action && <div className="flex flex-none items-center gap-1.5">{action}</div>}
</div>
{children != null && <div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>{children}</div>}
</section>
)
}
/** A white box of rows separated by hairlines, the way the bag sidebar lists its bags. */
export function SettingRows({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card ${className}`}>
{children}
</div>
)
}
/**
* One setting as a row: what it is (and a hint) on the left, the control on the
* right. `stacked` puts the control under the text for wide controls (a select,
* a segmented row) on narrow windows and always for a `full` control.
*/
export function SettingRow({ label, hint, control, children, stacked = false, dimmed = false, htmlFor }: {
label: ReactNode
hint?: ReactNode
/** The control on the right. */
control?: ReactNode
/** Extra content under the row (an expanded editor, a list). */
children?: ReactNode
stacked?: boolean
/** While a save is on its way. */
dimmed?: boolean
htmlFor?: string
}) {
return (
<div className={`px-3.5 py-3 ${dimmed ? 'opacity-60' : ''}`}>
<div className={stacked ? 'flex flex-col gap-2.5' : 'flex flex-wrap items-center gap-x-4 gap-y-2'}>
<div className="min-w-0 flex-1 basis-56">
<label htmlFor={htmlFor} className="block font-medium text-content" style={fs(13, 'body')}>{label}</label>
{hint && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
</div>
{control && <div className={stacked ? 'min-w-0' : 'flex flex-none items-center gap-2'}>{control}</div>}
</div>
{children && <div className="mt-3">{children}</div>}
</div>
)
}
/** A quiet line of explanation inside a card body. */
export function SettingsHint({ children, className = '' }: { children: ReactNode; className?: string }) {
return <p className={`m-0 leading-normal text-content-faint ${className}`} style={fs(11.5)}>{children}</p>
}
/** A row of pills to pick one (or several) from, for choices too many for a segmented track. */
export function ChoiceChips<T extends string>({ value, options, onChange, label, multi }: {
value: T | T[]
options: readonly { value: T; label: ReactNode; title?: string }[]
onChange: (value: T) => void
label: string
multi?: boolean
}) {
const isOn = (v: T) => (Array.isArray(value) ? value.includes(v) : value === v)
return (
<div role="group" aria-label={label} className="flex flex-wrap gap-1.5">
{options.map(o => {
const on = isOn(o.value)
return (
<button key={o.value} type="button" onClick={() => onChange(o.value)} aria-pressed={on} title={o.title}
{...(multi ? { role: 'checkbox', 'aria-checked': on } : {})}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-[5px] font-medium transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card text-content shadow-sm' : 'border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}>
{o.label}
</button>
)
})}
</div>
)
}
/** A small status pill for a card's band or a row: neutral, success, warning or danger. */
export function StatusPill({ tone = 'neutral', children, icon }: { tone?: 'neutral' | 'success' | 'warning' | 'danger' | 'accent'; children: ReactNode; icon?: ReactNode }) {
const look = {
neutral: 'border border-edge-faint bg-surface-card text-content-secondary',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
accent: 'bg-accent text-accent-text',
}[tone]
return (
<span className={`inline-flex flex-none items-center gap-1 whitespace-nowrap rounded-full px-2 py-[2px] font-geist font-semibold tabular-nums ${look}`} style={fs(11)}>
{icon}{children}
</span>
)
}
/** The buttons of a settings card, in the dialogs' shapes. */
export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50'
/** A round icon action in a band or a row, named by its tooltip. */
export const SETTINGS_ICON_BUTTON = 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint hover:text-content disabled:cursor-default disabled:opacity-50'
+3 -10
View File
@@ -3,6 +3,7 @@ import { Settings, SlidersHorizontal, Paintbrush, Map, Bell, Plug, CloudOff, Use
import { useTranslation } from '../i18n'
import PageShell from '../components/Layout/PageShell'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import { SettingsHeader } from '../components/Settings/settingsKit'
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
import AppearanceSettingsTab from '../components/Settings/AppearanceSettingsTab'
import MapSettingsTab from '../components/Settings/MapSettingsTab'
@@ -55,18 +56,10 @@ function SettingsPageDesktop(): React.ReactElement {
return (
<PageShell background="var(--bg-secondary)">
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="w-full px-4 sm:px-6 lg:px-8 py-6">
{/* Header */}
<HelpAnchor id={helpId} />
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-surface-tertiary">
<Settings className="w-5 h-5 text-content-secondary" />
</div>
<div>
<h1 className="text-2xl font-bold text-content">{t('settings.title')}</h1>
<p className="text-sm text-content-muted">{t('settings.subtitle')}</p>
</div>
</div>
<SettingsHeader icon={Settings} title={t('settings.title')} subtitle={t('settings.subtitle')} />
{/* Sidebar layout */}
<PageSidebar