mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
bdf5767a5f
The rail lists its sections in the order a book is made: Pages, Content, Layouts, Elements and Travel, then Theme and Layers set apart below a rule. Stickers are a tab of Elements beside the icons instead of a rail section of their own, and Elements has six tabs (text, shapes, lines, frames with the grids, icons, stickers). The quotation mark moved to the text styles, the outline rectangle to the basic shapes and the plain rule to the lines. The rail and every tab row are tab lists the arrow keys walk through, and a long name wraps instead of being cut. The icon and sticker searches say in the field how many match, which the sticker library's own head used to. Every panel on both sides opens on the same head, and every labelled control uses one row that moves the control under its label when the two do not fit, so no label sits in a fixed column any more. Number fields, chips, sliders, tab labels, theme names and travel cards wrap the same way, a country beside a stop wraps under itself, and an upload cell holds its words to two lines with the whole label in its tooltip. The heads, shelves and groups are real headings, so a screen reader can walk the panels by them. The inspector shows every kind in one order: what the element is and says, its position and size, its look, its effects, then Arrange. Align and the stacking buttons are one Arrange group with a button that opens the page in Layers; its rows sit under their labels, each in two sets that move to a second line whole. Rarely used groups start folded and open on an element that already uses them (a turned element, a map held at a zoom, a summary set in another face), and every group mounts afresh for the next selection. A sticker's duplicate angle and opacity are gone in favour of the shared position group, which now has the turn buttons for every kind. With several elements selected it keeps their angle, opacity and turn, and the mirror when all of them are stickers, and leaves the frame's numbers to one element. A photo's fill page and fill spread sit under its crop. With nothing selected the inspector shows the page background, the book's theme with the way to the Theme panel, and the page numbers. Seventeen translation keys nothing has used since before the rebuild (the old inspector title, the empty spread note, the summary and country list names, the roads buttons and others) are gone from every language. The broad key template the old panels used had counted them as reached; the format names, the empty photo grid messages and the map source hints are now named where the key check can read them. Also fixed on the way: the travel stops list shared its class with the gradient stops and was laid out as sixteen pixel handles; the alignment buttons were named in raw English; the map's print resolution note joined its two halves with a dash.
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import { useId, useState, type ReactNode } from 'react'
|
|
import { Check, ChevronDown } from 'lucide-react'
|
|
import ToggleSwitch from '../Settings/ToggleSwitch'
|
|
|
|
/**
|
|
* The inspector's vocabulary.
|
|
*
|
|
* ── Why this file exists ─────────────────────────────────────────────────
|
|
*
|
|
* The panel had grown four ways of saying "this one is on" — a filled pill, a
|
|
* ring, a tinted border, ink on a tint — and used the *same* pill for a choice
|
|
* between four things and for a switch that turns one thing off. A map showed
|
|
* seventy controls in ten sections that were all open at once, all labelled in
|
|
* the same small capitals, so nothing on the panel looked more important than
|
|
* anything else. None of that was a decision; it was what happens when a panel
|
|
* grows one control at a time.
|
|
*
|
|
* So there are now exactly three ways to answer a question here, and each looks
|
|
* like what it is:
|
|
*
|
|
* **Choose one** is a row of chips, and the chosen one is filled.
|
|
* **Choose several** is a row of chips that carry a tick when they are on,
|
|
* because a set with three of seven picked has to be readable at a glance.
|
|
* **On or off** is the app's switch, on its own line beside the words —
|
|
* the one control in TREK that already means exactly that.
|
|
*
|
|
* A section can be folded away, which is the other half of the answer: a
|
|
* property panel is a reference, and a reference nobody can collapse is a wall.
|
|
*/
|
|
|
|
/**
|
|
* One group of properties.
|
|
*
|
|
* `defaultOpen={false}` is for the groups you set once and rarely return to:
|
|
* the frame's numbers, the effects, the advanced settings of a kind. The
|
|
* inspector opens such a group anyway when the element already uses it, so a
|
|
* photo with a duotone shows its duotone and a plain one keeps it out of the
|
|
* way (inspector/inspectorModel.ts says which groups are in use). The state
|
|
* is per mounted section rather than remembered: the inspector mounts its
|
|
* groups afresh for every selection, and coming back to an element and finding
|
|
* its panel folded up differently from last time is worse than opening one
|
|
* group again.
|
|
*/
|
|
export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
|
label: string
|
|
children: ReactNode
|
|
defaultOpen?: boolean
|
|
/** A line under the label, for a group whose name cannot carry the whole idea. */
|
|
hint?: string
|
|
/**
|
|
* A figure beside the label, for a group worth reading while it is folded.
|
|
* The stops list is the case: how many of them count is the thing you came
|
|
* to check, and opening a section to read one number is a section that
|
|
* cannot usefully be closed.
|
|
*/
|
|
badge?: string
|
|
}) {
|
|
const [open, setOpen] = useState(defaultOpen)
|
|
const body = useId()
|
|
return (
|
|
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
|
<h3 className="st-section-title">
|
|
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}
|
|
aria-controls={open ? body : undefined}>
|
|
<span className="st-section-label">{label}</span>
|
|
{badge && <span className="st-section-badge">{badge}</span>}
|
|
<ChevronDown size={13} className="st-section-caret" aria-hidden />
|
|
</button>
|
|
</h3>
|
|
{open && (
|
|
<div className="st-section-body" id={body}>
|
|
{hint && <p className="st-hint st-section-hint">{hint}</p>}
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A labelled line: the label first, the control after it.
|
|
*
|
|
* The two share the line while they fit and the control moves under the label
|
|
* when they do not, so a long label ("Πεζά και κεφαλαία", "Átlátszatlanság")
|
|
* pushes its control down a line instead of being cut or squeezing the control
|
|
* to nothing. There is no column of a fixed width for the label to fit into;
|
|
* short labels sit at a common minimum, so the controls of a group still start
|
|
* at one edge. `wide` puts the control under the label always, for a control
|
|
* that needs the whole width, like a row of swatches.
|
|
*/
|
|
export function Line({ label, children, wide = false }: {
|
|
label: string
|
|
children: ReactNode
|
|
/** Put the control under the label rather than beside it, when it needs the width. */
|
|
wide?: boolean
|
|
}) {
|
|
return (
|
|
<div className={`st-line ${wide ? 'is-wide' : ''}`}>
|
|
<span className="st-line-label">{label}</span>
|
|
<div className="st-line-body">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Choose one of a few. The chosen one is filled in. */
|
|
export function Choice<T extends string | number>({ value, options, onPick, ariaLabel }: {
|
|
value: T
|
|
options: { value: T; label: ReactNode; title?: string; disabled?: boolean }[]
|
|
onPick: (v: T) => void
|
|
ariaLabel?: string
|
|
}) {
|
|
return (
|
|
<div className="st-row" role="radiogroup" aria-label={ariaLabel}>
|
|
{options.map(o => (
|
|
<button type="button"
|
|
key={String(o.value)}
|
|
role="radio"
|
|
aria-checked={o.value === value}
|
|
className={`st-chip ${o.value === value ? 'is-on' : ''}`}
|
|
disabled={o.disabled}
|
|
title={o.title}
|
|
onClick={() => onPick(o.value)}
|
|
>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Choose several. A tick marks what is in the set.
|
|
*
|
|
* Not switches: seven metrics as seven switched lines is a column of switches
|
|
* taller than the element it configures, and what matters about a set is which
|
|
* of it is in — a question a row answers and a list does not.
|
|
*/
|
|
export function Picks<T extends string>({ value, options, onToggle }: {
|
|
value: readonly T[]
|
|
options: { value: T; label: ReactNode; disabled?: boolean }[]
|
|
onToggle: (v: T) => void
|
|
}) {
|
|
return (
|
|
<div className="st-row">
|
|
{options.map(o => {
|
|
const on = value.includes(o.value)
|
|
return (
|
|
<button type="button"
|
|
key={o.value}
|
|
role="checkbox"
|
|
aria-checked={on}
|
|
className={`st-chip is-pick ${on ? 'is-on' : ''}`}
|
|
disabled={o.disabled}
|
|
onClick={() => onToggle(o.value)}
|
|
>
|
|
{on && <Check size={12} strokeWidth={3} />}
|
|
{o.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** On or off, said the way the rest of TREK says it. */
|
|
export function Switch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
|
|
return (
|
|
<div className="st-switch">
|
|
<span className="st-switch-label">{label}</span>
|
|
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A number, with its unit.
|
|
*
|
|
* Held as text while it is being typed and only read back on blur or Enter,
|
|
* which fixes three things at once: "0." no longer snaps to "0" the moment the
|
|
* point is typed, an emptied field no longer collapses the element to zero,
|
|
* and a value is one undo step rather than one per keystroke.
|
|
*/
|
|
export function NumField({
|
|
label, value, onChange, min, max, step = 0.5, unit,
|
|
}: {
|
|
label: string
|
|
value: number
|
|
onChange: (v: number) => void
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
unit?: string
|
|
}) {
|
|
const shown = String(Math.round(value * 100) / 100)
|
|
const [draft, setDraft] = useState<string | null>(null)
|
|
|
|
const commit = (raw: string) => {
|
|
setDraft(null)
|
|
const v = Number(raw)
|
|
if (raw.trim() === '' || !Number.isFinite(v)) return
|
|
const clamped = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v))
|
|
if (clamped !== value) onChange(clamped)
|
|
}
|
|
|
|
return (
|
|
<label className="st-num">
|
|
<span className="st-num-label">{label}</span>
|
|
<span className="st-num-box">
|
|
<input
|
|
className="st-input"
|
|
type="number"
|
|
value={draft ?? shown}
|
|
step={step}
|
|
min={min}
|
|
max={max}
|
|
onChange={e => setDraft(e.target.value)}
|
|
onBlur={e => commit(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
|
|
if (e.key === 'Escape') setDraft(null)
|
|
}}
|
|
/>
|
|
{unit && <span className="st-num-unit">{unit}</span>}
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|