Files
TREK/client/src/components/Settings/settingsKit.tsx
T
Maurice ba70ad28b8 fix(client): spacing and alignment follow the reading direction
Arabic runs the app right to left, but margins, paddings, insets, text
alignment, borders and corner radii were written for the left side, so
icons sat against the wrong edge of their labels, indents and badges
pointed the wrong way and amount columns aligned left. They now use the
logical forms (ms-, ps-, start-, text-start, marginInlineStart,
insetInlineEnd and their CSS equivalents), which are the very same sides
in a left to right language. Geometry stays physical on purpose: maps,
positions measured from the pointer or a bounding box, time axes,
sliders, transforms and print output.

Amounts drawn as several spans keep their order in a right to left row,
the time picker keeps hours before minutes, and two switches whose thumb
slid out of its track in Arabic now move the right way.

lint:rtl counts what is still physical per file against a baseline that
may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI
runs it, so new code reaches for the logical forms.
2026-10-07 22:33:58 +00:00

171 lines
8.3 KiB
TypeScript

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 px-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'}>
{/* The basis only in a row: in the stacked column it would read as a height. */}
<div className={stacked ? 'min-w-0' : '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'