Files
TREK/client/src/components/Collections/LabelManager.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

173 lines
6.7 KiB
TypeScript

import React, { useId, useState } from 'react'
import { Plus, Settings2, Trash2, Loader2 } from 'lucide-react'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { INPUT, PANEL } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import type { CollectionLabel, CollectionLabelUpdateRequest } from '@trek/shared'
import type { TranslationFn } from '../../types'
const SWATCHES = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#8b5cf6', '#64748b']
interface LabelManagerProps {
isOpen: boolean
labels: CollectionLabel[]
onCreate: (name: string, color?: string) => Promise<void> | void
onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void> | void
onDelete: (labelId: number) => Promise<void> | void
onClose: () => void
t: TranslationFn
}
/** Swatch row shared by the create form and each row's recolor control. */
function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }): React.ReactElement {
return (
<div className="flex flex-none flex-wrap items-center gap-1.5">
{SWATCHES.map(c => {
const on = value.toLowerCase() === c
return (
<button
key={c}
type="button"
onClick={() => onPick(c)}
aria-label={c}
aria-pressed={on}
className="h-5 w-5 rounded-full p-0 transition-transform"
style={{
background: c,
outline: on ? '2px solid var(--text-primary)' : '2px solid transparent',
outlineOffset: 1.5,
transform: on ? 'scale(1.08)' : 'scale(1)',
}}
/>
)
})}
</div>
)
}
/** One existing label: inline rename (save on blur/Enter), recolor, delete. */
function LabelRow({ label, onUpdate, onDelete, t }: {
label: CollectionLabel
onUpdate: LabelManagerProps['onUpdate']
onDelete: LabelManagerProps['onDelete']
t: TranslationFn
}): React.ReactElement {
const [name, setName] = useState(label.name)
const [color, setColor] = useState(label.color || '#6366f1')
const [busy, setBusy] = useState(false)
const commitName = async () => {
const trimmed = name.trim()
if (!trimmed || trimmed === label.name) { setName(label.name); return }
setBusy(true)
try { await onUpdate(label.id, { name: trimmed }) } finally { setBusy(false) }
}
const pickColor = async (c: string) => {
setColor(c)
setBusy(true)
try { await onUpdate(label.id, { color: c }) } finally { setBusy(false) }
}
return (
<div className="flex items-center gap-2.5 rounded-[12px] bg-surface-secondary py-1.5 ps-3 pe-1.5">
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: color }} />
{/* Transparent on the row until pointed at or typed in; the dark: variants
outrank the global dark rule that paints every input. */}
<input
value={name}
onChange={e => setName(e.target.value)}
onBlur={commitName}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
maxLength={60}
className="-ms-1.5 min-w-0 flex-1 rounded-[8px] border-0 bg-transparent px-1.5 py-1 font-semibold text-content outline-none hover:bg-surface-card focus:bg-surface-card focus:shadow-sm dark:bg-transparent dark:hover:bg-surface-card dark:focus:bg-surface-card"
style={fs(13, 'body')}
aria-label={t('collections.labels.name')}
/>
<Swatches value={color} onPick={pickColor} />
{busy && <Loader2 size={14} className="flex-none animate-spin text-content-faint" />}
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => onDelete(label.id)} aria-label={t('common.delete')}
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-danger">
<Trash2 size={13} />
</button>
</Tooltip>
</div>
)
}
/**
* Manage a list's custom labels — create, rename, recolor and delete. Available
* to any member who can edit the list; the labels are shared by the whole list.
*/
export default function LabelManager({ isOpen, labels, onCreate, onUpdate, onDelete, onClose, t }: LabelManagerProps): React.ReactElement {
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(SWATCHES[0])
const [adding, setAdding] = useState(false)
const labelId = useId()
const add = async () => {
const trimmed = newName.trim()
if (!trimmed || adding) return
setAdding(true)
try {
await onCreate(trimmed, newColor)
setNewName('')
setNewColor(SWATCHES[0])
} finally {
setAdding(false)
}
}
// Every change is saved as it is made, so there is nothing to confirm in a footer.
return (
<DialogShell
open={isOpen}
onClose={onClose}
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Settings2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('collections.labels.manage')}
/>
)}
>
<DialogSection label={t('collections.labels.title')}>
{labels.length === 0 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-4 py-5 text-center text-content-faint" style={fs(12.5, 'body')}>{t('collections.labels.empty')}</p>
) : (
<div className="flex flex-col gap-1.5">
{labels.map(l => <LabelRow key={l.id} label={l} onUpdate={onUpdate} onDelete={onDelete} t={t} />)}
</div>
)}
<div className={`${PANEL} mt-3`}>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: newColor }} />
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void add() }}
maxLength={60}
placeholder={t('collections.labels.namePlaceholder')}
className={`${INPUT} flex-1`}
/>
<DialogButton
variant="primary"
onClick={add}
disabled={!newName.trim() || adding}
icon={adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} strokeWidth={2.2} />}
>
{t('collections.labels.add')}
</DialogButton>
</div>
<Swatches value={newColor} onPick={setNewColor} />
</div>
</DialogSection>
</DialogShell>
)
}