diff --git a/admin/src/app/icons.ts b/admin/src/app/icons.ts index b1d8255..ee884e5 100644 --- a/admin/src/app/icons.ts +++ b/admin/src/app/icons.ts @@ -102,10 +102,16 @@ export const winterIcons: Readonly> = { /** Neutral icon for names neither lucide map knows. */ export const fallbackIcon: Component = Square +/** An own entry of a map: names come from the server, so inherited object + * members such as "constructor" or "toString" never count as icons. */ +function own(map: Readonly>, key: string): T | undefined { + return Object.hasOwn(map, key) ? map[key] : undefined +} + export function iconFor(name: string | null | undefined): Component { if (!name) { return fallbackIcon } const key = name.trim() - return icons[key] ?? icons[winterIcons[key] ?? ''] ?? fallbackIcon + return own(icons, key) ?? own(icons, own(winterIcons, key) ?? '') ?? fallbackIcon } diff --git a/admin/src/components/form/control.ts b/admin/src/components/form/control.ts new file mode 100644 index 0000000..77ad8fe --- /dev/null +++ b/admin/src/components/form/control.ts @@ -0,0 +1,69 @@ +// Shared props and helpers of the field controls (D-05). Kept apart from the +// registry, which imports every control: a control importing the registry +// would close an import cycle. +import type { ControllerParams, FormField, RelationOption } from '../../api/types' + +/** Props every field control receives from FieldRenderer. */ +export interface FieldControlProps { + field: FormField + modelValue: unknown + /** id of the focusable control; the label points at it. */ + controlId: string + invalid?: boolean + /** Space-separated ids of the comment and error lines. */ + describedBy?: string + /** Display labels of the field's current relation values (meta.labels). */ + labels?: RelationOption[] + /** Controller whose option endpoints serve this form; null for settings. */ + source?: ControllerParams | null + /** Id of the record being edited; null on create and on settings pages. */ + recordId?: number | null +} + +// Winter `attributes:` are HTML attributes on the control. Only these are +// passed through; anything else (event handlers included) is dropped. +const allowedAttributes = new Set([ + 'readonly', + 'placeholder', + 'maxlength', + 'minlength', + 'min', + 'max', + 'step', + 'autocomplete', + 'spellcheck', + 'pattern', +]) + +export function controlAttributes(field: FormField): Record { + const out: Record = {} + for (const [name, value] of Object.entries(field.attributes ?? {})) { + const key = name.toLowerCase() + if (!allowedAttributes.has(key) || value === null || value === false) { + continue + } + out[key] = value + } + return out +} + +/** Shared control styling: 44px, radius 10, danger border when invalid. */ +export function controlClass(invalid: boolean | undefined): string { + return [ + 'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out', + invalid ? 'border-danger' : 'border-border-strong', + ].join(' ') +} + +/** Truthiness of a stored toggle value (true, 1, "1", "true"). */ +export function toggleOn(value: unknown): boolean { + if (typeof value === 'string') { + return value !== '' && value !== '0' && value.toLowerCase() !== 'false' + } + return Boolean(value) +} + +/** A toggle's next value in the stored value's own type (1/0 or boolean). */ +export function toggleValue(current: unknown, on: boolean): boolean | number { + return typeof current === 'number' ? (on ? 1 : 0) : on +} diff --git a/admin/src/components/form/fields/CheckboxField.vue b/admin/src/components/form/fields/CheckboxField.vue index d8f3223..5d6ab16 100644 --- a/admin/src/components/form/fields/CheckboxField.vue +++ b/admin/src/components/form/fields/CheckboxField.vue @@ -1,6 +1,6 @@ +