diff --git a/messages/en.json b/messages/en.json index d54c738..22990a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -192,5 +192,48 @@ "device_wtb_choose": "Choose your country", "device_wtb_selection": "Sites are chosen for independence and no paid ranking, and listed in alphabetical order. {#link}Missing a comparison site? Get in touch{/link}", "device_wtb_location": "Your country is preselected from your device's time zone, read only in your browser. Your choice is remembered on this device.", - "device_wtb_all_sites": "All comparison sites we link to, by country" + "device_wtb_all_sites": "All comparison sites we link to, by country", + "edit_field_about": "About {label}", + "edit_field_unset": "Not set", + "edit_field_option_devices": [ + { + "declarations": [ + "input count", + "local countPlural = count: plural", + "local countNumber = count: number" + ], + "selectors": ["countPlural"], + "match": { + "countPlural=one": "{countNumber} device", + "countPlural=other": "{countNumber} devices" + } + } + ], + "edit_field_combobox_loading": "Loading…", + "edit_field_combobox_error": "Couldn't load options, try typing again", + "edit_field_combobox_empty": "No matches", + "edit_field_combobox_count": "Matches: {count}", + "edit_field_combobox_more": "Showing {shown} of {total}. Search to narrow down.", + "edit_field_photo_slot": "Photo {number}", + "edit_field_photo_any": "Any category", + "edit_field_photo_category": "Category for photo {number}", + "edit_field_photo_choose_category": "Category", + "edit_field_photo_remove": "Remove photo {number}", + "edit_field_photo_add": "Choose or drop an image", + "edit_form_revert": "Revert", + "edit_form_revert_field": "Revert {label}", + "edit_form_submit": "Submit changes", + "edit_form_reset": "Revert all", + "edit_field_toggle_yes": "Yes", + "edit_field_toggle_no": "No", + "edit_field_issue_multiline": "Must be a single line", + "edit_field_issue_invalid_character": "{character} at position {position} is not allowed", + "edit_field_issue_too_short": "Must be at least {min} characters, currently {length}", + "edit_field_issue_too_long": "Must be at most {max} characters, currently {length}", + "edit_field_issue_ean_length": "Must be 8 or 13 digits, currently {length}", + "edit_field_issue_ean_checksum": "The last digit doesn't match the others, check for typos", + "edit_field_issue_unknown_option": "Choose one of the available options", + "edit_field_issue_unset": "A value that was set can't be removed", + "edit_field_issue_photo_type": "Photo {number} is not an image", + "edit_field_issue_photo_category": "Choose a category for photo {number}" } diff --git a/package.json b/package.json index 38106db..299337b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ ] }, "dependencies": { + "@lit-labs/ssr": "^4.1.0", "lucide": "^1.17.0", "undici": "^8.10.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1656f00..0563073 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@lit-labs/ssr': + specifier: ^4.1.0 + version: 4.1.0(@types/node@24.19.1) lucide: specifier: ^1.17.0 version: 1.53.0 @@ -1217,9 +1220,21 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lit-labs/ssr-client@1.1.8': + resolution: {integrity: sha512-PjGh81oKsoI64m3IDjTqqjhC7dr2uC/o0jrllUb5gRAyp/RlAHxapgJrjq9kWz97faCHLQ8jUlTi6tGm+8fgyA==} + '@lit-labs/ssr-dom-shim@1.6.0': resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==} + '@lit-labs/ssr@4.1.0': + resolution: {integrity: sha512-m0zymVVlHB1ddJQ1lastsV8ROW3whFOiHJhVPQWd04MnGTkTlUUVLQctux1QlyD9BtLXNN6iASxv388vhgKMFg==} + engines: {node: '>=13.9.0'} + peerDependencies: + '@types/node': '>=20.0.0 <25.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} @@ -1587,6 +1602,9 @@ packages: bundledDependencies: - napi-wasm + '@parse5/tools@0.3.0': + resolution: {integrity: sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==} + '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2765,10 +2783,18 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -4031,6 +4057,9 @@ packages: parse5-htmlparser2-tree-adapter@8.0.1: resolution: {integrity: sha512-aqkH+xQxX+QGZtP5GIMmqqp16Y0v9muEf2VVeypv35kFsD5bqP1UeBanSshdfjMY+RTh2vN4mmK6nEOVRMEvHg==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -4564,6 +4593,10 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@3.2.2: resolution: {integrity: sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==} @@ -6178,8 +6211,29 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@lit-labs/ssr-client@1.1.8': + dependencies: + '@lit/reactive-element': 2.1.2 + lit: 3.3.3 + lit-html: 3.3.3 + '@lit-labs/ssr-dom-shim@1.6.0': {} + '@lit-labs/ssr@4.1.0(@types/node@24.19.1)': + dependencies: + '@lit-labs/ssr-client': 1.1.8 + '@lit-labs/ssr-dom-shim': 1.6.0 + '@lit/reactive-element': 2.1.2 + '@parse5/tools': 0.3.0 + enhanced-resolve: 5.26.0 + lit: 3.3.3 + lit-element: 4.2.2 + lit-html: 3.3.3 + node-fetch: 3.3.2 + parse5: 7.3.0 + optionalDependencies: + '@types/node': 24.19.1 + '@lit/reactive-element@2.1.2': dependencies: '@lit-labs/ssr-dom-shim': 1.6.0 @@ -6708,6 +6762,10 @@ snapshots: is-glob: 4.0.3 picomatch: 4.0.7 + '@parse5/tools@0.3.0': + dependencies: + parse5: 7.3.0 + '@pkgr/core@0.3.6': {} '@rolldown/binding-android-arm-eabi@1.2.13': @@ -7959,8 +8017,15 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} entities@8.1.0: {} @@ -9357,6 +9422,10 @@ snapshots: domhandler: 6.0.1 parse5: 8.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.1.0 @@ -10046,6 +10115,8 @@ snapshots: tagged-tag@1.0.0: {} + tapable@2.3.3: {} + tar-stream@3.2.2: dependencies: b4a: 1.9.0 diff --git a/src/io/declaration.ts b/src/io/declaration.ts new file mode 100644 index 0000000..2161248 --- /dev/null +++ b/src/io/declaration.ts @@ -0,0 +1,222 @@ +import { z } from "astro/zod"; + +import { getDimensions } from "./dimension"; +import mock from "./mock/declarations.json"; +import type { IoDimensionCategory } from "./dimension"; + +/** translations keyed by locale, `en` is always present */ +export const IoDeclarationLocalized = z + .object({ en: z.string() }) + .catchall(z.string()); + +const IoDeclarationBase = { + id: z.string().min(1), + label: IoDeclarationLocalized, + hint: z.optional(IoDeclarationLocalized), + description: z.optional(IoDeclarationLocalized), +}; + +const IoDeclarationTextPatternBase = { + multiline: z.boolean(), + length: z.optional( + z + .object({ + min: z.optional(z.int().nonnegative()), + max: z.optional(z.int().positive()), + }) + .refine( + ({ min, max }) => + typeof min === "undefined" || + typeof max === "undefined" || + min <= max, + { message: "length.min exceeds length.max" }, + ), + ), +}; + +/** single character class, applied character by character */ +const IoDeclarationCharacterClass = z.string().refine( + (source) => { + try { + new RegExp(source, "u"); + return true; + } catch { + return false; + } + }, + { message: "invalid regex" }, +); + +export const IoDeclarationTextPattern = z.discriminatedUnion("kind", [ + z.object({ + ...IoDeclarationTextPatternBase, + kind: z.literal("letter"), + regex: IoDeclarationCharacterClass, + }), + z.object({ + ...IoDeclarationTextPatternBase, + kind: z.literal("ean"), + }), +]); + +export const IoDeclarationText = z.object({ + ...IoDeclarationBase, + type: z.literal("text"), + pattern: IoDeclarationTextPattern, +}); + +export const IoDeclarationToggle = z.object({ + ...IoDeclarationBase, + type: z.literal("toggle"), +}); + +export const IoDeclarationPickerOption = z.object({ + id: z.string().min(1), + title: IoDeclarationLocalized, + subtitle: z.optional(IoDeclarationLocalized), + /** name out of the icon set, unknown names fall back to a generic icon */ + icon: z.optional(z.string()), +}); + +export const IoDeclarationPickerResource = z.enum(["brand", "category"]); + +export const IoDeclarationPicker = z.discriminatedUnion("resource", [ + z.object({ + ...IoDeclarationBase, + type: z.literal("picker"), + resource: z.literal("none"), + options: z + .array(IoDeclarationPickerOption) + .min(1) + .refine( + (options) => + new Set(options.map(({ id }) => id)).size === options.length, + { message: "duplicate option id" }, + ), + }), + z.object({ + ...IoDeclarationBase, + type: z.literal("picker"), + resource: IoDeclarationPickerResource, + }), +]); + +export const IoDeclarationPhotosSlot = z.discriminatedUnion("constrained", [ + z.object({ + constrained: z.literal(true), + categories: z.array(z.string()).min(1), + }), + z.object({ constrained: z.literal(false) }), +]); + +export const IoDeclarationPhotos = z + .object({ + ...IoDeclarationBase, + type: z.literal("photos"), + // every photo gets a category, so there has to be one to pick + categories: z + .record( + z.string(), + z.object({ + label: IoDeclarationLocalized, + description: z.optional(IoDeclarationLocalized), + }), + ) + .refine((categories) => Object.keys(categories).length > 0, { + message: "no categories", + }), + pattern: z.object({ slots: z.array(IoDeclarationPhotosSlot).min(1) }), + }) + .superRefine(({ categories, pattern }, ctx) => { + for (const [index, slot] of pattern.slots.entries()) { + if (!slot.constrained) { + continue; + } + for (const category of slot.categories) { + if (!(category in categories)) { + ctx.addIssue({ + code: "custom", + path: ["pattern", "slots", index, "categories"], + message: `unknown category <${category}>`, + }); + } + } + } + }); + +export const IoDeclaration = z.discriminatedUnion("type", [ + IoDeclarationText, + IoDeclarationToggle, + IoDeclarationPicker, + IoDeclarationPhotos, +]); +export type IoDeclaration = z.infer; + +/** + * the declarations this client can render: unsupported or invalid ones + * (e.g. a newer `type`) and repeated ids are skipped with a warning, so the + * server can add declarations before the client supports them + */ +export const IoDeclarations = z.array(z.unknown()).transform((entries) => { + const ids = new Set(); + + return entries.flatMap((entry, index) => { + const result = IoDeclaration.safeParse(entry); + if (!result.success) { + console.warn( + `skipping declaration at ${index}\n${z.prettifyError(result.error)}`, + ); + return []; + } + if (ids.has(result.data.id)) { + console.warn(`skipping repeated declaration <${result.data.id}>`); + return []; + } + ids.add(result.data.id); + + return [result.data]; + }); +}); + +// mocked until the endpoint is available +export const getDeclarations = () => + Promise.resolve({ declarations: IoDeclarations.parse(mock) }); + +export type IoResourceOption = { id: string; name: string; count: number }; + +const flattenCategories = ( + categories: Record, +): IoResourceOption[] => + Object.entries(categories).flatMap(([id, { name, count, children }]) => [ + { id, name, count }, + ...flattenCategories(children), + ]); + +// brand and category come from the same response +let dimensionsOnce: ReturnType | undefined; +const sharedDimensions = () => { + dimensionsOnce ??= getDimensions().catch((error: unknown) => { + dimensionsOnce = undefined; + throw error; + }); + return dimensionsOnce; +}; + +// every option at once, served from the dimensions until dedicated endpoints +// exist (paginated like `/api/unstable/derived/devices`, filtered by `?term`) +export const getResourceOptions = async ( + resource: z.infer, +): Promise => { + const { dimensions } = await sharedDimensions(); + + switch (resource) { + case "brand": + return dimensions.manufacturers.map(({ name, count }) => ({ + id: name, + name, + count, + })); + case "category": + return flattenCategories(dimensions.categories); + } +}; diff --git a/src/io/mock/declarations.json b/src/io/mock/declarations.json new file mode 100644 index 0000000..56c4272 --- /dev/null +++ b/src/io/mock/declarations.json @@ -0,0 +1,132 @@ +[ + { + "id": "name", + "type": "text", + "label": { "en": "Name" }, + "hint": { "en": "Name of the device" }, + "description": { "en": "Minimally identifying name of a device." }, + "pattern": { + "multiline": false, + "length": { "min": 3, "max": 64 }, + "kind": "letter", + "regex": "[\\p{L}\\p{M}\\p{N}\\p{P}\\p{Zs}\\p{Sc}\\p{Sm}]" + } + }, + { + "id": "ean", + "type": "text", + "label": { "en": "EAN" }, + "hint": { "en": "A European barcode" }, + "description": { "en": "An EAN-13 or EAN-8 barcode." }, + "pattern": { + "multiline": false, + "kind": "ean" + } + }, + { + "id": "light.dimming", + "type": "toggle", + "label": { "en": "Dimmable" }, + "hint": { "en": "Capable of brightness adjustments" }, + "description": { + "en": "Indicates that the device has brightness adjustment capability." + } + }, + { + "id": "brand", + "type": "picker", + "label": { "en": "Brand" }, + "resource": "brand", + "hint": { "en": "Vendor that put their brand on the device" }, + "description": { + "en": "The vendor that put their brand on a device. Often the manufacturer of a device, but not necessarily." + } + }, + { + "id": "category", + "type": "picker", + "label": { "en": "Category" }, + "resource": "category", + "hint": { "en": "Category of the device" }, + "description": { "en": "Primary category of a device." } + }, + { + "id": "local-operation", + "type": "picker", + "label": { "en": "Local Operation" }, + "resource": "none", + "options": [ + { + "id": "local", + "title": { "en": "Fully Local" }, + "subtitle": { + "en": "Does not depend on cloud access for any functionality." + }, + "icon": "sun" + }, + { + "id": "cloud-ish", + "title": { "en": "Degraded without Cloud" }, + "subtitle": { "en": "Needs cloud access for full functionality." }, + "icon": "sun-behind-cloud" + }, + { + "id": "cloud", + "title": { "en": "Cloud Required" }, + "subtitle": { "en": "Can not be used without cloud access." }, + "icon": "cloud" + } + ], + "hint": { "en": "Local operation" }, + "description": { + "en": "Whether a device keeps working without cloud access." + } + }, + { + "id": "photos", + "type": "photos", + "label": { "en": "Photos" }, + "categories": { + "packshot": { + "label": { "en": "Packshot" }, + "description": { + "en": "Clean, isolated product on a plain background." + } + }, + "hero-shot": { + "label": { "en": "Hero shot" }, + "description": { + "en": "Visually striking composition showcasing the product as the main focus." + } + }, + "packaging": { + "label": { "en": "Packaging" }, + "description": { + "en": "The product’s packaging, including its design, labels, and materials." + } + }, + "unboxing": { + "label": { "en": "Unboxing" }, + "description": { + "en": "The product being removed from or revealed inside its packaging." + } + }, + "product-in-use": { + "label": { "en": "Product-in-use" }, + "description": { + "en": "The product being used in a real-life context." + } + } + }, + "pattern": { + "slots": [ + { "constrained": true, "categories": ["packshot", "hero-shot"] }, + { "constrained": true, "categories": ["product-in-use"] }, + { "constrained": true, "categories": ["unboxing"] }, + { "constrained": true, "categories": ["packaging"] }, + { "constrained": false }, + { "constrained": false } + ] + } + } +] diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts new file mode 100644 index 0000000..0c5349c --- /dev/null +++ b/src/islands/edit-field.ts @@ -0,0 +1,415 @@ +import { html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { PropertyValues } from "lit"; + +import { m } from "../paraglide/messages.js"; +import { defineElementOnce } from "../utilities/define-element.js"; +import { localized } from "../utilities/localized.js"; +import { icon } from "../utilities/presentation/icon.js"; +import { + validateOption, + validatePhotos, + validateText, + validateUnset, +} from "../validation"; +import { issueMessage } from "./edit-field/issue.js"; +import { + emptyPhotos, + photosFormData, + releasePhotos, + renderPhotos, +} from "./edit-field/photos.js"; +import { + pickerValues, + renderDynamicPicker, + renderStaticPicker, +} from "./edit-field/picker.js"; +import { renderText } from "./edit-field/text.js"; +import { renderToggle, TOGGLE_VALUES } from "./edit-field/toggle.js"; +import type { Declaration } from "../types/declaration"; +import type { ValidationIssue } from "../validation"; +import type { + EditFieldIds, + EditFieldRenderContext, +} from "./edit-field/context.js"; +import type { PhotoSlotValue } from "./edit-field/photos.js"; + +// light DOM, so ids have to be unique across the document +let instances = 0; +const editFieldIds = (): EditFieldIds => { + const prefix = `edit-field-${instances++}`; + return { + label: `${prefix}-label`, + control: `${prefix}-control`, + hint: `${prefix}-hint`, + description: `${prefix}-description`, + error: `${prefix}-error`, + }; +}; + +const validate = ( + declaration: Declaration, + value: string, + defaultValue: string, + photos: PhotoSlotValue[], +): ValidationIssue[] => { + switch (declaration.type) { + case "text": + return [ + ...validateUnset(value, defaultValue), + ...validateText(value, declaration.pattern), + ]; + case "toggle": + return [ + ...validateUnset(value, defaultValue), + ...validateOption(value, TOGGLE_VALUES), + ]; + case "picker": + return [ + ...validateUnset(value, defaultValue), + ...(declaration.resource === "none" + ? validateOption(value, pickerValues(declaration)) + : []), + ]; + case "photos": + return validatePhotos( + photos.map( + (photo) => + photo && { type: photo.file.type, category: photo.category }, + ), + declaration.pattern.slots, + ); + } +}; + +/** + * form-associated control rendered from a declaration + * + * mirrors native inputs: the `value` attribute is the initial value + * (`defaultValue`), the `value` property is the current one + */ +export class EditField extends LitElement { + static formAssociated = true; + + @property({ attribute: false }) declaration: Declaration | undefined; + @property({ attribute: "value" }) defaultValue = ""; + @property({ reflect: true }) name = ""; + + @state() private _current = ""; + /** photos are files, so they're kept apart from the string value */ + @state() private _photos: PhotoSlotValue[] = []; + @state() private _disabled = false; + /** like `:user-invalid`, issues show once the user committed an edit or submitted */ + @state() private _committed = false; + @state() private _descriptionOpen = false; + + private readonly _internals = this.attachInternals(); + private readonly _ids = editFieldIds(); + /** once edited, a new `defaultValue` no longer replaces the current value */ + private _dirty = false; + private _issues: ValidationIssue[] = []; + + constructor() { + super(); + this.addEventListener("invalid", () => { + this._committed = true; + }); + } + + get value(): string { + return this._current; + } + set value(value: string) { + this._dirty = true; + this._current = value; + } + + get photos(): readonly PhotoSlotValue[] { + return this._photos; + } + + // existing photos aren't loaded yet, so any added one is a change + get changed(): boolean { + return this.declaration?.type === "photos" + ? this._photos.some((photo) => photo !== null) + : this._current !== this.defaultValue; + } + + get form(): HTMLFormElement | null { + return this._internals.form; + } + get validity(): ValidityState { + return this._internals.validity; + } + get validationMessage(): string { + return this._internals.validationMessage; + } + get willValidate(): boolean { + return this._internals.willValidate; + } + checkValidity(): boolean { + return this._internals.checkValidity(); + } + reportValidity(): boolean { + return this._internals.reportValidity(); + } + + /** back to the value from the server, like a form reset of this field alone */ + revert(): void { + this._dirty = false; + this._committed = false; + this._current = this.defaultValue; + releasePhotos(this._photos); + this._photos = + this.declaration?.type === "photos" ? emptyPhotos(this.declaration) : []; + } + + formResetCallback(): void { + this.revert(); + } + + formStateRestoreCallback(state: unknown): void { + if (typeof state === "string") { + this.value = state; + } + } + + formDisabledCallback(disabled: boolean): void { + this._disabled = disabled; + } + + // a moved field reconnects, so previews are recreated rather than the photos dropped + override connectedCallback(): void { + super.connectedCallback(); + this._photos = this._photos.map( + (photo) => + photo && { ...photo, preview: URL.createObjectURL(photo.file) }, + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + releasePhotos(this._photos); + } + + protected override createRenderRoot(): HTMLElement { + return this; + } + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("defaultValue") && !this._dirty) { + this._current = this.defaultValue; + } + if (this.declaration?.type !== "photos" && this._photos.length > 0) { + releasePhotos(this._photos); + this._photos = []; + } + if (typeof this.declaration === "undefined") { + this._issues = []; + return; + } + // follows the declaration id, unless a name was set explicitly + const previous = changed.get("declaration"); + if (this.name === "" || this.name === previous?.id) { + this.name = this.declaration.id; + } + if ( + this.declaration.type === "photos" && + this._photos.length !== this.declaration.pattern.slots.length + ) { + const slots = emptyPhotos(this.declaration); + releasePhotos(this._photos.slice(slots.length)); + this._photos = slots.map((empty, index) => this._photos[index] ?? empty); + } + // unchanged fields aren't submitted, so a stored value never blocks a proposal + this._issues = this.changed + ? validate( + this.declaration, + this._current, + this.defaultValue, + this._photos, + ) + : []; + } + + protected override updated(): void { + // only changed fields are part of a proposal + const internals = this._internals; + if (!this.changed) { + internals.setFormValue(null); + } else if (this.declaration?.type === "photos") { + internals.setFormValue(photosFormData(this.name, this._photos)); + } else { + internals.setFormValue(this._current, this._current); + } + + const [issue] = this._issues; + if (typeof issue === "undefined") { + internals.setValidity({}); + } else { + internals.setValidity( + { customError: true }, + issueMessage(issue), + this._anchor, + ); + } + + // styling hooks, `edit-field:state(changed)` / `edit-field:state(user-invalid)` + this._toggleState("changed", this.changed); + this._toggleState("user-invalid", this._showIssue); + } + + /** grouped controls (e.g. radios) anchor on their first input */ + private get _anchor(): HTMLElement | undefined { + const control = this.querySelector(`#${this._ids.control}`); + return control?.matches("input, textarea, select") + ? control + : (control?.querySelector("input, textarea, select") ?? + undefined); + } + + /** the host isn't focusable, so focus goes to the control */ + override focus(options?: FocusOptions): void { + const anchor = this._anchor; + if (typeof anchor !== "undefined") { + anchor.focus(options); + } else { + super.focus(options); + } + } + + private get _showIssue(): boolean { + return this._committed && this._issues.length > 0; + } + + private _toggleState(name: string, on: boolean): void { + if (on) { + this._internals.states.add(name); + } else { + this._internals.states.delete(name); + } + } + + private _onInput = (value: string): void => { + this.value = value; + }; + + private _onPhotos = (photos: PhotoSlotValue[]): void => { + this._dirty = true; + this._photos = photos; + }; + + private _onCommit = (): void => { + this._committed = true; + }; + + private _toggleDescription = (): void => { + this._descriptionOpen = !this._descriptionOpen; + }; + + private _renderControl(ctx: EditFieldRenderContext) { + const { declaration } = ctx; + switch (declaration.type) { + case "text": + return renderText({ ...ctx, declaration }); + case "toggle": + return renderToggle({ ...ctx, declaration }); + case "picker": + return declaration.resource === "none" + ? renderStaticPicker({ ...ctx, declaration }) + : renderDynamicPicker({ ...ctx, declaration }); + case "photos": + return renderPhotos( + { ...ctx, declaration }, + this._photos, + this._onPhotos, + this._showIssue ? this._issues : [], + ); + } + } + + override render() { + const { declaration } = this; + if (typeof declaration === "undefined") { + return nothing; + } + + const ids = this._ids; + const label = localized(declaration.label); + const [issue] = this._issues; + const showIssue = this._showIssue; + const describedBy = + [ + typeof declaration.hint !== "undefined" ? ids.hint : undefined, + showIssue ? ids.error : undefined, + ] + .filter((id) => typeof id !== "undefined") + .join(" ") || undefined; + + return html` +
+ + ${ + typeof declaration.description !== "undefined" + ? html`` + : nothing + } +
+ ${ + typeof declaration.description !== "undefined" + ? html`

+ ${localized(declaration.description)} +

` + : nothing + } + ${ + typeof declaration.hint !== "undefined" + ? html`

+ ${localized(declaration.hint)} +

` + : nothing + } + ${this._renderControl({ + declaration, + value: this._current, + defaultValue: this.defaultValue, + ids, + describedBy, + invalid: showIssue, + disabled: this._disabled, + onInput: this._onInput, + onCommit: this._onCommit, + })} +

+ ${ + showIssue && typeof issue !== "undefined" + ? html`${icon("warning")} ${issueMessage(issue)}` + : nothing + } +

+ `; + } +} + +defineElementOnce("edit-field", EditField); + +declare global { + interface HTMLElementTagNameMap { + "edit-field": EditField; + } +} diff --git a/src/islands/edit-field/combobox.ts b/src/islands/edit-field/combobox.ts new file mode 100644 index 0000000..12e5327 --- /dev/null +++ b/src/islands/edit-field/combobox.ts @@ -0,0 +1,376 @@ +import { html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { live } from "lit/directives/live.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import type { PropertyValues } from "lit"; + +import { m } from "../../paraglide/messages.js"; +import { defineElementOnce } from "../../utilities/define-element.js"; +import { isAbortError } from "../../utilities/is-abort-error.js"; +import { icon } from "../../utilities/presentation/icon.js"; +import { highlight } from "../../utilities/search.js"; +import { notifyChange } from "./events"; + +export type ComboboxOption = { + value: string; + label: string; + detail?: string | undefined; +}; +export type ComboboxResult = { options: ComboboxOption[]; total: number }; +export type ComboboxSource = { + search: (term: string, signal: AbortSignal) => Promise; + /** label of a value that isn't among the loaded options, e.g. the initial one */ + label: (value: string) => Promise; +}; + +const FETCH_DELAY_MS = 200; + +/** + * editable combobox with a listbox popup, following the WAI-ARIA pattern + * https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ + * + * only picks existing options: typed text that wasn't picked is reverted + */ +export class EditFieldCombobox extends LitElement { + @property({ attribute: false }) source: ComboboxSource | undefined; + @property({ attribute: false }) value = ""; + @property({ attribute: false }) controlId = ""; + @property({ attribute: false }) labelId = ""; + @property({ attribute: false }) describedBy: string | undefined; + @property({ attribute: false }) invalid = false; + @property({ attribute: false }) disabled = false; + + @state() private _open = false; + @state() private _term = ""; + /** whether `_term` was typed, otherwise it shows the value's label */ + @state() private _typed = false; + @state() private _valueLabel = ""; + @state() private _result: ComboboxResult | undefined; + @state() private _status: "idle" | "loading" | "error" = "idle"; + @state() private _active = -1; + + private _abort: AbortController | undefined; + private _timer: ReturnType | undefined; + private readonly _anchor = createRef(); + private readonly _popover = createRef(); + private readonly _labels = new Map(); + + protected override createRenderRoot(): HTMLElement { + return this; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + clearTimeout(this._timer); + this._abort?.abort(); + } + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("value") || changed.has("source")) { + this._resolveLabel(); + } + } + + private _showLabel(label: string): void { + this._valueLabel = label; + if (!this._typed) { + this._term = label; + } + } + + private _resolveLabel(): void { + const { value, source } = this; + // the value itself until its label is known + this._showLabel(this._labels.get(value) ?? value); + if ( + value === "" || + this._labels.has(value) || + typeof source === "undefined" + ) { + return; + } + source.label(value).then( + (label) => { + this._labels.set(value, label); + if (this.value === value) { + this._showLabel(label); + } + }, + // keeps showing the value + () => undefined, + ); + } + + // untyped, the map also holds private state keys + protected override updated(changed: PropertyValues): void { + const popover = this._popover.value; + if (typeof popover !== "undefined" && changed.has("_open")) { + if (this._open && !popover.matches(":popover-open")) { + const source = this._anchor.value; + popover.showPopover( + typeof source !== "undefined" ? { source } : undefined, + ); + } else if (!this._open && popover.matches(":popover-open")) { + popover.hidePopover(); + } + } + if (changed.has("_active") && this._active !== -1) { + this.querySelector(`#${this._optionId(this._active)}`)?.scrollIntoView({ + block: "nearest", + }); + } + } + + private get _listboxId(): string { + return `${this.controlId}-listbox`; + } + + private _optionId(index: number): string { + return `${this.controlId}-option-${index}`; + } + + private get _options(): ComboboxOption[] { + return this._result?.options ?? []; + } + + private _openPopup = (): void => { + if (this._open) { + return; + } + this._open = true; + this._active = -1; + void this._fetch(); + }; + + // typing replaces the shown label instead of appending to it + private _onFocus = (e: FocusEvent): void => { + (e.currentTarget as HTMLInputElement).select(); + }; + + private _onClick = (e: MouseEvent): void => { + // the mouseup after focusing drops the selection, so select again + if (!this._open) { + (e.currentTarget as HTMLInputElement).select(); + } + this._openPopup(); + }; + + private _onInput = (e: Event): void => { + this._term = (e.currentTarget as HTMLInputElement).value; + this._typed = true; + // opens without fetching, the delayed fetch below covers it + this._open = true; + this._active = -1; + clearTimeout(this._timer); + this._timer = setTimeout(() => void this._fetch(), FETCH_DELAY_MS); + }; + + /** closes without picking, typed text goes back to the value */ + private _dismiss(): void { + clearTimeout(this._timer); + this._abort?.abort(); + this._open = false; + this._typed = false; + this._term = this._valueLabel; + } + + private _pick(option: ComboboxOption): void { + this._labels.set(option.value, option.label); + this._dismiss(); + this._showLabel(option.label); + this.dispatchEvent( + new CustomEvent("combobox-pick", { detail: option.value }), + ); + notifyChange(this); + } + + private async _fetch(): Promise { + if (typeof this.source === "undefined") { + return; + } + this._abort?.abort(); + const controller = new AbortController(); + this._abort = controller; + this._status = "loading"; + + try { + // without typing, show the most common options instead of the value alone + const result = await this.source.search( + this._typed ? this._term : "", + controller.signal, + ); + if (controller.signal.aborted) { + return; + } + for (const option of result.options) { + this._labels.set(option.value, option.label); + } + this._result = result; + this._status = "idle"; + this._active = -1; + } catch (error) { + if (!isAbortError(error)) { + this._status = "error"; + } + } + } + + private _onKeyDown = (e: KeyboardEvent): void => { + const count = this._options.length; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + if (!this._open) { + this._openPopup(); + } else if (count > 0) { + this._active = Math.min(this._active + 1, count - 1); + } + break; + case "ArrowUp": + e.preventDefault(); + if (this._open && count > 0) { + this._active = Math.max(this._active - 1, 0); + } + break; + case "Enter": { + if (!this._open) { + // lets the form submit + break; + } + e.preventDefault(); + const option = this._options[this._active]; + if (typeof option !== "undefined") { + this._pick(option); + } else { + this._dismiss(); + } + break; + } + case "Escape": + if (this._open) { + e.preventDefault(); + this._dismiss(); + } + break; + case "Tab": + // Tab moves through the options and only + // leaves the field when going past the last one + if (this._open && !e.shiftKey && this._active < count - 1) { + e.preventDefault(); + this._active += 1; + } else { + this._dismiss(); + } + break; + } + }; + + private _onFocusOut = (e: FocusEvent): void => { + if (!this.contains(e.relatedTarget as Node | null)) { + this._dismiss(); + } + }; + + private _statusText(): string { + if (this._status === "loading") { + return m.edit_field_combobox_loading(); + } + if (this._status === "error") { + return m.edit_field_combobox_error(); + } + const total = this._result?.total ?? 0; + const shown = this._options.length; + if (total === 0) { + return m.edit_field_combobox_empty(); + } + if (total > shown) { + return m.edit_field_combobox_more({ shown, total }); + } + + return m.edit_field_combobox_count({ count: total }); + } + + override render() { + const active = this._open && this._active !== -1; + + // the input belongs to the form, so a reset restores its `defaultValue` + // even when the picked value didn't change and nothing re-renders + return html` +
+ ${icon("search")} + +
+
e.preventDefault()} + > +
    + ${this._options.map( + (option, index) => html` +
  • this._pick(option)} + > + + ${this._typed ? highlight(option.label, this._term) : option.label} + + ${ + typeof option.detail !== "undefined" + ? html` + ${option.detail} + ` + : nothing + } +
  • + `, + )} +
+

+ ${this._open ? this._statusText() : nothing} +

+
+ `; + } +} + +defineElementOnce("edit-field-combobox", EditFieldCombobox); + +declare global { + interface HTMLElementTagNameMap { + "edit-field-combobox": EditFieldCombobox; + } +} diff --git a/src/islands/edit-field/context.ts b/src/islands/edit-field/context.ts new file mode 100644 index 0000000..24fc755 --- /dev/null +++ b/src/islands/edit-field/context.ts @@ -0,0 +1,24 @@ +import type { Declaration } from "../../types/declaration"; + +export type EditFieldIds = { + label: string; + control: string; + hint: string; + description: string; + error: string; +}; + +/** everything a type specific control needs, the host owns state and form integration */ +export type EditFieldRenderContext = { + declaration: D; + value: string; + /** value from the server, before any edit */ + defaultValue: string; + ids: EditFieldIds; + describedBy: string | undefined; + invalid: boolean; + disabled: boolean; + onInput: (value: string) => void; + /** user finished an edit, mirrors the native `change` event */ + onCommit: () => void; +}; diff --git a/src/islands/edit-field/events.ts b/src/islands/edit-field/events.ts new file mode 100644 index 0000000..ca232a2 --- /dev/null +++ b/src/islands/edit-field/events.ts @@ -0,0 +1,5 @@ +/** for edits that don't come from a native control, fire what one would */ +export const notifyChange = (target: EventTarget | null): void => { + target?.dispatchEvent(new Event("input", { bubbles: true })); + target?.dispatchEvent(new Event("change", { bubbles: true })); +}; diff --git a/src/islands/edit-field/issue.ts b/src/islands/edit-field/issue.ts new file mode 100644 index 0000000..78ede24 --- /dev/null +++ b/src/islands/edit-field/issue.ts @@ -0,0 +1,42 @@ +import { m } from "../../paraglide/messages.js"; +import type { ValidationIssue } from "../../validation"; + +const VISIBLE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}]$/u; + +// whitespace and control characters are invisible when quoted +const displayCharacter = (character: string): string => { + if (VISIBLE.test(character)) { + return `“${character}”`; + } + const codePoint = character.codePointAt(0) ?? 0; + + return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`; +}; + +export const issueMessage = (issue: ValidationIssue): string => { + switch (issue.code) { + case "multiline": + return m.edit_field_issue_multiline(); + case "invalid-character": + return m.edit_field_issue_invalid_character({ + character: displayCharacter(issue.character), + position: issue.index + 1, + }); + case "too-short": + return m.edit_field_issue_too_short(issue); + case "too-long": + return m.edit_field_issue_too_long(issue); + case "ean-length": + return m.edit_field_issue_ean_length(issue); + case "ean-checksum": + return m.edit_field_issue_ean_checksum(); + case "unknown-option": + return m.edit_field_issue_unknown_option(); + case "unset": + return m.edit_field_issue_unset(); + case "photo-type": + return m.edit_field_issue_photo_type({ number: issue.slot + 1 }); + case "photo-category": + return m.edit_field_issue_photo_category({ number: issue.slot + 1 }); + } +}; diff --git a/src/islands/edit-field/photos.ts b/src/islands/edit-field/photos.ts new file mode 100644 index 0000000..45ecfcd --- /dev/null +++ b/src/islands/edit-field/photos.ts @@ -0,0 +1,197 @@ +import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { m } from "../../paraglide/messages.js"; +import { getLocale } from "../../paraglide/runtime.js"; +import { localized } from "../../utilities/localized.js"; +import { icon } from "../../utilities/presentation/icon.js"; +import { notifyChange } from "./events"; +import type { DeclarationPhotos } from "../../types/declaration"; +import type { ValidationIssue } from "../../validation"; +import type { EditFieldRenderContext } from "./context"; + +/** `null` is an empty slot, `preview` is an object url */ +export type PhotoSlotValue = { + file: File; + category: string; + preview: string; +} | null; + +export const emptyPhotos = (declaration: DeclarationPhotos): PhotoSlotValue[] => + declaration.pattern.slots.map(() => null); + +export const releasePhotos = (photos: PhotoSlotValue[]): void => { + for (const photo of photos) { + if (photo !== null) { + URL.revokeObjectURL(photo.preview); + } + } +}; + +/** one entry per file and one for its category, e.g. `photos.0` and `photos.0.category` */ +export const photosFormData = ( + name: string, + photos: PhotoSlotValue[], +): FormData => { + const data = new FormData(); + for (const [index, photo] of photos.entries()) { + if (photo !== null) { + data.append(`${name}.${index}`, photo.file); + data.append(`${name}.${index}.category`, photo.category); + } + } + + return data; +}; + +export const renderPhotos = ( + ctx: EditFieldRenderContext, + photos: PhotoSlotValue[], + onPhotos: (photos: PhotoSlotValue[]) => void, + /** issues to show, those about a slot highlight it */ + issues: ValidationIssue[], +) => { + const { declaration, ids, describedBy, invalid, disabled } = ctx; + const invalidSlots = new Set( + issues.flatMap((issue) => ("slot" in issue ? [issue.slot] : [])), + ); + const or = new Intl.ListFormat(getLocale(), { type: "disjunction" }); + const categoryLabel = (id: string) => { + const category = declaration.categories[id]; + return typeof category !== "undefined" ? localized(category.label) : id; + }; + + /** `commit` shows issues, skipped while a category still has to be chosen */ + const set = (index: number, photo: PhotoSlotValue, commit = true) => { + const previous = photos[index] ?? null; + if (previous !== null && previous.preview !== photo?.preview) { + releasePhotos([previous]); + } + onPhotos(photos.map((current, at) => (at === index ? photo : current))); + if (commit) { + ctx.onCommit(); + } + }; + + return html` +
+ ${declaration.pattern.slots.map((slot, index) => { + const photo = photos[index] ?? null; + const categories = slot.constrained + ? slot.categories + : Object.keys(declaration.categories); + const number = index + 1; + const titleId = `${ids.control}-${index}-title`; + + const onFile = (e: Event) => { + const file = (e.currentTarget as HTMLInputElement).files?.[0]; + if (typeof file !== "undefined") { + // a single allowed category needs no choice + const category = + categories.length === 1 ? (categories[0] ?? "") : ""; + set( + index, + { file, category, preview: URL.createObjectURL(file) }, + category !== "", + ); + } + }; + + return html` +
+

+ ${m.edit_field_photo_slot({ number })} + + ${ + slot.constrained + ? or.format(categories.map(categoryLabel)) + : m.edit_field_photo_any() + } + +

+ ${ + photo === null + ? html`
+ + + +
` + : html` +
+ ${photo.file.name} + +
+ ${ + categories.length > 1 + ? html`
+ + +
` + : nothing + } + ` + } +
+ `; + })} +
+ `; +}; diff --git a/src/islands/edit-field/picker.ts b/src/islands/edit-field/picker.ts new file mode 100644 index 0000000..82b70b1 --- /dev/null +++ b/src/islands/edit-field/picker.ts @@ -0,0 +1,131 @@ +import { html, nothing } from "lit"; + +import { getResourceOptions } from "../../io/declaration"; +import { m } from "../../paraglide/messages.js"; +import { localized } from "../../utilities/localized.js"; +import * as presentation from "../../utilities/presentation"; +import { icon } from "../../utilities/presentation/icon.js"; +import { rank } from "../../utilities/search.js"; +import { renderRadioGroup } from "./radio-group"; +import type { IoResourceOption } from "../../io/declaration"; +import type { + DeclarationPickerDynamic, + DeclarationPickerResource, + DeclarationPickerStatic, +} from "../../types/declaration"; +import type { ComboboxSource } from "./combobox"; +import type { EditFieldRenderContext } from "./context"; +import type { RadioOption } from "./radio-group"; + +import "./combobox.js"; + +/** empty means not set, it's never submitted since it can't be picked */ +export const pickerValues = (declaration: DeclarationPickerStatic) => + new Set(["", ...declaration.options.map(({ id }) => id)]); + +// few static options read better all at once than behind a combobox +export const renderStaticPicker = ( + ctx: EditFieldRenderContext, +) => { + const { declaration, ids } = ctx; + + const options: RadioOption[] = declaration.options.map((option, index) => { + const titleId = `${ids.control}-${index}-title`; + const subtitleId = `${ids.control}-${index}-subtitle`; + + return { + value: option.id, + labelledBy: titleId, + describedBy: + typeof option.subtitle !== "undefined" ? subtitleId : undefined, + content: html` + ${ + typeof option.icon !== "undefined" + ? html` + ${icon(presentation.declarationIcon(option.icon), 20)} + ` + : nothing + } + + + ${localized(option.title)} + + ${ + typeof option.subtitle !== "undefined" + ? html` + ${localized(option.subtitle)} + ` + : nothing + } + + `, + }; + }); + + return renderRadioGroup(ctx, "edit-field-options", options); +}; + +const PAGE_SIZE = 20; + +// one source per resource, so re-renders don't hand the combobox a new object +const sources = new Map(); +const resourceSource = ( + resource: DeclarationPickerResource, +): ComboboxSource => { + let source = sources.get(resource); + if (typeof source !== "undefined") { + return source; + } + + // fetched once and shared, typing only filters + let all: Promise | undefined; + const options = () => { + all ??= getResourceOptions(resource).catch((error: unknown) => { + all = undefined; + throw error; + }); + return all; + }; + + source = { + search: async (term) => { + const ranked = rank(await options(), term, ({ name }) => name); + return { + options: ranked.slice(0, PAGE_SIZE).map(({ id, name, count }) => ({ + value: id, + label: name, + detail: m.edit_field_option_devices({ count }), + })), + total: ranked.length, + }; + }, + label: async (value) => + (await options()).find(({ id }) => id === value)?.name ?? value, + }; + sources.set(resource, source); + + return source; +}; + +// options live on the server, so they're searched instead of listed +export const renderDynamicPicker = ( + ctx: EditFieldRenderContext, +) => { + const { declaration, ids } = ctx; + + const onPick = (e: CustomEvent) => { + ctx.onInput(e.detail); + ctx.onCommit(); + }; + + return html``; +}; diff --git a/src/islands/edit-field/radio-group.ts b/src/islands/edit-field/radio-group.ts new file mode 100644 index 0000000..bbac98d --- /dev/null +++ b/src/islands/edit-field/radio-group.ts @@ -0,0 +1,60 @@ +import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { live } from "lit/directives/live.js"; +import type { TemplateResult } from "lit"; + +import type { Declaration } from "../../types/declaration"; +import type { EditFieldRenderContext } from "./context"; + +export type RadioOption = { + value: string; + content: TemplateResult | string; + /** names the radio when `content` holds more than its name */ + labelledBy?: string | undefined; + describedBy?: string | undefined; +}; + +export const renderRadioGroup = ( + ctx: EditFieldRenderContext, + className: string, + options: RadioOption[], +) => { + const { value, ids, describedBy, invalid, disabled } = ctx; + + const onChange = (e: Event) => { + ctx.onInput((e.currentTarget as HTMLInputElement).value); + ctx.onCommit(); + }; + + // radios need a shared `name` to group, pointing `form` at the control id + // leaves them without a form owner, so only the host gets submitted + return html` +
+ ${options.map( + (option) => html` + + `, + )} +
+ `; +}; diff --git a/src/islands/edit-field/text.ts b/src/islands/edit-field/text.ts new file mode 100644 index 0000000..ad0454a --- /dev/null +++ b/src/islands/edit-field/text.ts @@ -0,0 +1,81 @@ +import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { live } from "lit/directives/live.js"; + +import { icon } from "../../utilities/presentation/icon.js"; +import type { DeclarationText } from "../../types/declaration"; +import type { EditFieldRenderContext } from "./context"; + +// presentation is keyed by declaration id, it is not part of the declaration +const PREFIX_ICON: Record[0]> = { + ean: "barcode", +}; + +export const renderText = (ctx: EditFieldRenderContext) => { + const { + declaration, + value, + defaultValue, + ids, + describedBy, + invalid, + disabled, + } = ctx; + const { pattern } = declaration; + const max = pattern.length?.max; + const prefix = PREFIX_ICON[declaration.id]; + const numeric = pattern.kind === "ean"; + + const onInput = (e: Event) => { + ctx.onInput( + (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value, + ); + }; + + // no `name` and no constraint attributes: the host submits and validates, + // `defaultValue` is what a form reset restores + const control = pattern.multiline + ? html`` + : html``; + + return html` +
+ ${ + typeof prefix !== "undefined" + ? html`${icon(prefix)}` + : nothing + } + ${control} + ${ + typeof max !== "undefined" + ? html`` + : nothing + } +
+ `; +}; diff --git a/src/islands/edit-field/toggle.ts b/src/islands/edit-field/toggle.ts new file mode 100644 index 0000000..dbd7d11 --- /dev/null +++ b/src/islands/edit-field/toggle.ts @@ -0,0 +1,14 @@ +import { m } from "../../paraglide/messages.js"; +import { renderRadioGroup } from "./radio-group"; +import type { DeclarationToggle } from "../../types/declaration"; +import type { EditFieldRenderContext } from "./context"; + +/** empty means not set, it's never submitted since it can't be picked */ +export const TOGGLE_VALUES = new Set(["true", "false", ""]); + +// without a value neither is checked, like native radios +export const renderToggle = (ctx: EditFieldRenderContext) => + renderRadioGroup(ctx, "edit-field-toggle", [ + { value: "true", content: m.edit_field_toggle_yes() }, + { value: "false", content: m.edit_field_toggle_no() }, + ]); diff --git a/src/islands/edit-form.ts b/src/islands/edit-form.ts new file mode 100644 index 0000000..f6faf90 --- /dev/null +++ b/src/islands/edit-form.ts @@ -0,0 +1,160 @@ +import { html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { repeat } from "lit/directives/repeat.js"; +import type { PropertyValues } from "lit"; + +import { m } from "../paraglide/messages.js"; +import { defineElementOnce } from "../utilities/define-element.js"; +import { localized } from "../utilities/localized.js"; +import { icon } from "../utilities/presentation/icon.js"; +import { notifyChange } from "./edit-field/events"; +import type { Declaration } from "../types/declaration"; +import type { EditField } from "./edit-field.js"; + +import "./edit-field.js"; + +/** + * form of edit fields, submitting only the changed ones + * + * reverting lives here rather than in each field, so every field type + * offers it the same way regardless of its own design + */ +export class EditForm extends LitElement { + @property({ attribute: false }) declarations: Declaration[] = []; + /** values from the server by declaration id, missing or empty means not set */ + @property({ attribute: false }) values: Readonly> = {}; + + /** ids of the changed fields */ + @state() private _changed: ReadonlySet = new Set(); + + protected override createRenderRoot(): HTMLElement { + return this; + } + + protected override updated(changed: PropertyValues): void { + // fields pick up new values in their own update + if (changed.has("declarations") || changed.has("values")) { + void Promise.all(this._fields.map((field) => field.updateComplete)).then( + this._sync, + ); + } + } + + private get _fields(): EditField[] { + return [...this.querySelectorAll("edit-field")]; + } + + private _sync = (): void => { + this._changed = new Set( + this._fields.flatMap((field) => + field.changed && typeof field.declaration !== "undefined" + ? [field.declaration.id] + : [], + ), + ); + }; + + private async _revert(id: string): Promise { + const field = this._fields.find( + (candidate) => candidate.declaration?.id === id, + ); + if (typeof field === "undefined") { + return; + } + field.revert(); + // the revert button disappears, so focus moves to the field + await field.updateComplete; + field.focus(); + notifyChange(field); + } + + // `novalidate` skips the native bubbles, fields show their own errors + private _onSubmit = (e: SubmitEvent): void => { + e.preventDefault(); + const form = e.currentTarget as HTMLFormElement; + if (!form.checkValidity()) { + form.querySelector(":invalid")?.focus(); + return; + } + this.dispatchEvent( + new CustomEvent("edit-form-submit", { + detail: new FormData(form), + bubbles: true, + }), + ); + }; + + private _onReset = (): void => { + // after a real click a microtask still runs before the fields reset + setTimeout(this._sync); + }; + + override render() { + const nothingChanged = this._changed.size === 0; + + return html` +
+ ${repeat( + this.declarations, + (declaration) => declaration.id, + (declaration) => html` +
+ + ${ + this._changed.has(declaration.id) + ? html`` + : nothing + } +
+ `, + )} +
+ + +
+
+ `; + } +} + +defineElementOnce("edit-form", EditForm); + +declare global { + interface HTMLElementTagNameMap { + "edit-form": EditForm; + } + interface HTMLElementEventMap { + "edit-form-submit": CustomEvent; + } +} diff --git a/src/islands/edit-playground.ts b/src/islands/edit-playground.ts new file mode 100644 index 0000000..309ef7d --- /dev/null +++ b/src/islands/edit-playground.ts @@ -0,0 +1,448 @@ +import { z } from "astro/zod"; +import { html, LitElement, nothing } from "lit"; +import { state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; +import { live } from "lit/directives/live.js"; +import type { PropertyValues } from "lit"; + +import { getDeclarations, getResourceOptions } from "../io/declaration"; +import { Declaration } from "../types/declaration"; +import { defineElementOnce } from "../utilities/define-element.js"; +import { renderEditFieldView } from "../utilities/edit-field-view.js"; +import { + EXAMPLE_VALUES, + EXTRA_EXAMPLES, +} from "../utilities/edit-playground-examples.js"; +import { icon } from "../utilities/presentation/icon.js"; +import { notifyChange } from "./edit-field/events.js"; +import type { EditFieldViewPhoto } from "../utilities/edit-field-view.js"; +import type { EditField } from "./edit-field.js"; + +import "./edit-form.js"; + +// temporary page to debug one declaration at a time, intentionally english only + +type Editor = "text" | "toggle" | "options" | "resource" | "photos"; + +const EDITORS: Record = { + text: "Text", + toggle: "Toggle", + options: "Picker with fixed options", + resource: "Picker with server data", + photos: "Photos", +}; + +const editorOf = (declaration: Declaration): Editor => { + if (declaration.type !== "picker") { + return declaration.type; + } + + return declaration.resource === "none" ? "options" : "resource"; +}; + +type FieldSnapshot = { + value: string; + defaultValue: string; + changed: boolean; + valid: boolean; + validationMessage: string; + userInvalid: boolean; +}; + +const snapshot = (field: EditField): FieldSnapshot => ({ + value: field.value, + defaultValue: field.defaultValue, + changed: field.changed, + valid: field.validity.valid, + validationMessage: field.validationMessage, + userInvalid: field.matches(":state(user-invalid)"), +}); + +const parseDeclaration = ( + source: string, +): { declaration: Declaration } | { error: string } => { + let json: unknown; + try { + json = JSON.parse(source); + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) }; + } + const result = Declaration.safeParse(json); + + return result.success + ? { declaration: result.data } + : { error: z.prettifyError(result.error) }; +}; + +export class EditPlayground extends LitElement { + @state() private _examples: Declaration[] = []; + @state() private _editor: Editor = "text"; + @state() private _exampleId = ""; + @state() private _source = ""; + @state() private _declaration: Declaration | undefined; + @state() private _error: string | undefined; + @state() private _initialValue = ""; + @state() private _mode: "view" | "edit" = "edit"; + /** submitted photos, kept as if the device had them */ + @state() private _savedPhotos: EditFieldViewPhoto[] = []; + /** name of a brand or category value, for the view */ + @state() private _resourceLabel: string | undefined; + @state() private _snapshot: FieldSnapshot | undefined; + @state() private _submitted: [string, string][] | undefined; + + protected override createRenderRoot(): HTMLElement { + return this; + } + + override connectedCallback(): void { + super.connectedCallback(); + // `invalid` doesn't bubble, capture it to reflect submit attempts + this.addEventListener("invalid", this._refresh, true); + void getDeclarations().then(({ declarations }) => { + this._examples = [...declarations, ...EXTRA_EXAMPLES]; + this._loadEditor(this._editor); + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener("invalid", this._refresh, true); + } + + // untyped, the map holds private state keys + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("_declaration") || changed.has("_initialValue")) { + void this._resolveResourceLabel(); + } + } + + private async _resolveResourceLabel(): Promise { + const declaration = this._declaration; + const value = this._initialValue; + this._resourceLabel = undefined; + if ( + declaration?.type !== "picker" || + declaration.resource === "none" || + value === "" + ) { + return; + } + const options = await getResourceOptions(declaration.resource); + // skip if another declaration or value came in meanwhile + if (this._declaration === declaration && this._initialValue === value) { + this._resourceLabel = options.find(({ id }) => id === value)?.name; + } + } + + private _examplesOf(editor: Editor): Declaration[] { + return this._examples.filter( + (declaration) => editorOf(declaration) === editor, + ); + } + + private _loadEditor(editor: Editor): void { + this._editor = editor; + const [first] = this._examplesOf(editor); + if (typeof first !== "undefined") { + this._loadExample(first); + } + } + + private _loadExample(declaration: Declaration): void { + this._exampleId = declaration.id; + this._source = JSON.stringify(declaration, null, 2); + this._declaration = declaration; + this._error = undefined; + this._initialValue = EXAMPLE_VALUES[declaration.id] ?? ""; + this._submitted = undefined; + this._setSavedPhotos([]); + this._refresh(); + } + + private _setSavedPhotos(photos: EditFieldViewPhoto[]): void { + for (const photo of this._savedPhotos) { + if (!photos.includes(photo)) { + URL.revokeObjectURL(photo.src); + } + } + this._savedPhotos = photos; + } + + // reads the field once it rendered the latest change + private _refresh = (): void => { + void this._readField(); + }; + + private async _readField(): Promise { + await this.updateComplete; + const field = this.querySelector("edit-field"); + if (field === null) { + this._snapshot = undefined; + return; + } + await field.updateComplete; + this._snapshot = snapshot(field); + } + + private _onEditor = (e: Event): void => { + this._loadEditor((e.currentTarget as HTMLSelectElement).value as Editor); + }; + + private _onExample = (e: Event): void => { + const id = (e.currentTarget as HTMLSelectElement).value; + const declaration = this._examples.find((example) => example.id === id); + if (typeof declaration !== "undefined") { + this._loadExample(declaration); + } + }; + + private _onSource = (e: Event): void => { + this._source = (e.currentTarget as HTMLTextAreaElement).value; + const parsed = parseDeclaration(this._source); + if ("error" in parsed) { + // keep rendering the last valid declaration + this._error = parsed.error; + return; + } + this._error = undefined; + this._declaration = parsed.declaration; + this._refresh(); + }; + + private _onInitialValue = (e: Event): void => { + this._initialValue = (e.currentTarget as HTMLInputElement).value; + this._refresh(); + }; + + private _onMode = (e: Event): void => { + this._mode = (e.currentTarget as HTMLInputElement).value as "view" | "edit"; + this._refresh(); + }; + + // stands in for the server accepting the proposal: what was submitted + // becomes the device data, so the view shows it and edits start from it + private _onSubmit = (e: CustomEvent): void => { + const data = e.detail; + this._submitted = [...data.entries()].map(([key, value]) => [ + key, + typeof value === "string" ? value : value.name, + ]); + + const declaration = this._declaration; + if (declaration?.type === "photos") { + const added = [...data.entries()].flatMap(([key, file]) => { + if (!(file instanceof File)) { + return []; + } + const category = data.get(`${key}.category`); + return [ + { + src: URL.createObjectURL(file), + alt: file.name, + category: typeof category === "string" ? category : "", + }, + ]; + }); + this._setSavedPhotos([...this._savedPhotos, ...added]); + // saved, so they're no longer pending in the form + const field = this.querySelector("edit-field"); + if (field !== null) { + field.revert(); + notifyChange(field); + } + } else if (typeof declaration !== "undefined") { + const value = data.get(declaration.id); + if (typeof value === "string") { + this._initialValue = value; + } + } + this._refresh(); + }; + + private _onReset = (): void => { + this._submitted = undefined; + // after a real click a microtask still runs before the fields reset + setTimeout(this._refresh); + }; + + private _renderMode() { + // same look as the toggle field + return html` +
+ Mode +
+ ${(["view", "edit"] as const).map( + (mode) => html` + + `, + )} +
+
+ `; + } + + private _renderView(declaration: Declaration) { + return html`
+ ${renderEditFieldView({ + declaration, + value: this._initialValue, + resourceLabel: this._resourceLabel, + photos: this._savedPhotos, + })} +
`; + } + + // hidden instead of removed in view mode, so edits survive switching + private _renderEdit(declaration: Declaration) { + return html`
+ ${keyed( + this._exampleId, + html``, + )} + +
+

State

+
${JSON.stringify(this._snapshot ?? null, null, 2)}
+
+ + ${ + typeof this._submitted !== "undefined" + ? html`
+

Submitted form data

+
${JSON.stringify(this._submitted, null, 2)}
+
` + : nothing + } +
`; + } + + private _renderSelect( + label: string, + value: string, + options: { value: string; label: string }[], + onChange: (e: Event) => void, + ) { + return html` + + `; + } + + private _renderPickers() { + const editors = (Object.keys(EDITORS) as Editor[]).filter( + (editor) => this._examplesOf(editor).length > 0, + ); + + return html` +
+ ${this._renderSelect( + "Editor", + this._editor, + editors.map((editor) => ({ value: editor, label: EDITORS[editor] })), + this._onEditor, + )} + ${this._renderSelect( + "Example", + this._exampleId, + this._examplesOf(this._editor).map((example) => ({ + value: example.id, + label: example.label.en, + })), + this._onExample, + )} +
+ `; + } + + override render() { + return html` +
+ ${this._renderPickers()} + + ${ + typeof this._error !== "undefined" + ? html`
${this._error}
` + : nothing + } + +
+ +
+ ${this._renderMode()} + ${ + typeof this._declaration === "undefined" + ? nothing + : html` + ${this._mode === "view" ? this._renderView(this._declaration) : nothing} + ${this._renderEdit(this._declaration)} + ` + } +
+ `; + } +} + +defineElementOnce("edit-playground", EditPlayground); + +declare global { + interface HTMLElementTagNameMap { + "edit-playground": EditPlayground; + } +} diff --git a/src/islands/filter-sheet.ts b/src/islands/filter-sheet.ts index 43a9301..b6fd2c1 100644 --- a/src/islands/filter-sheet.ts +++ b/src/islands/filter-sheet.ts @@ -1,13 +1,11 @@ import { navigate } from "astro:transitions/client"; import { html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { m } from "../paraglide/messages.js"; import { getLocale } from "../paraglide/runtime.js"; import { defineElementOnce } from "../utilities/define-element.js"; -import * as presentation from "../utilities/presentation"; -import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import { icon } from "../utilities/presentation/icon.js"; import { highlight } from "../utilities/search.js"; import { groupOptions } from "./filter-modal.js"; import type { @@ -35,14 +33,6 @@ interface DimensionConfig { letterGroups: boolean; } -const icon = (name: Parameters[0], size: number) => - unsafeHTML( - presentation.render( - presentation.generic(name), - PresentationRenderPresetRoleIcon.withSize(size), - ), - ); - export class FilterSheet extends LitElement { @property({ type: Array }) categoryoptions: FilterModalOption[] = []; @property({ type: Array }) manufactureroptions: FilterModalOption[] = []; diff --git a/src/pages/edit-playground.astro b/src/pages/edit-playground.astro new file mode 100644 index 0000000..644d54a --- /dev/null +++ b/src/pages/edit-playground.astro @@ -0,0 +1,156 @@ +--- +import Layout from "../layouts/Layout.astro"; + +import { getDeclarations } from "../io/declaration"; +import { renderEditFieldView } from "../utilities/edit-field-view"; +import { + EXAMPLE_VALUES, + EXTRA_EXAMPLES, +} from "../utilities/edit-playground-examples"; +import { renderToHtml } from "../utilities/ssr"; + +// temporary page to try edit field declarations, not linked and not indexed + +// the same views the island renders, as the device page would on the server +const { declarations } = await getDeclarations(); +const views = [...declarations, ...EXTRA_EXAMPLES].map((declaration) => + renderToHtml( + renderEditFieldView({ + declaration, + value: EXAMPLE_VALUES[declaration.id] ?? "", + }), + ), +); +--- + + + +
+

Edit playground

+

+ Pick an editor and an example, or edit the declaration, to debug one field + at a time. View shows the initial value as the device page would, edit + submits only changed fields and shows the form data instead of sending it. +

+ +
+

Rendered on the server

+
+ {views.map((view) => )} +
+
+
+
+ + + + + diff --git a/src/styles/browse.css b/src/styles/browse.css index aea77b7..669f561 100644 --- a/src/styles/browse.css +++ b/src/styles/browse.css @@ -280,10 +280,6 @@ a.browse-pagination-page:hover { color: var(--fg-muted); font-variant-numeric: tabular-nums; } -.btn.is-disabled { - opacity: 0.4; - pointer-events: none; -} @media (max-width: 767px) { .browse { padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px)); diff --git a/src/styles/controls.css b/src/styles/controls.css index 4ed404f..e68980b 100644 --- a/src/styles/controls.css +++ b/src/styles/controls.css @@ -34,6 +34,12 @@ outline: 0; box-shadow: var(--ring-focus); } +/* `is-disabled` for links, which can't be disabled */ +.btn:disabled, +.btn.is-disabled { + opacity: 0.4; + pointer-events: none; +} input[type="text"], input[type="search"] { diff --git a/src/styles/edit-field-view.css b/src/styles/edit-field-view.css new file mode 100644 index 0000000..cc85f5a --- /dev/null +++ b/src/styles/edit-field-view.css @@ -0,0 +1,74 @@ +/* read only fields, rendered on the server, see utilities/edit-field-view.ts */ +.edit-field-views { + display: grid; + gap: var(--space-4); + margin: 0; +} +.edit-field-view { + display: grid; + gap: var(--space-1); +} +.edit-field-view-label { + font-size: var(--fs-13); + color: var(--fg-muted); +} +.edit-field-view-value { + margin: 0; + font-size: var(--fs-14); + color: var(--fg); + overflow-wrap: anywhere; +} +.edit-field-view-text { + white-space: pre-line; +} +.edit-field-view-text.is-code { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} +.edit-field-view-unset { + color: var(--fg-subtle); + font-style: italic; +} +.edit-field-view-option { + display: flex; + align-items: center; + gap: var(--space-2); +} +.edit-field-view-option-icon { + display: inline-flex; + color: var(--fg-muted); +} +.edit-field-view-option-text { + display: grid; + gap: 2px; +} +.edit-field-view-option-subtitle { + font-size: var(--fs-13); + line-height: var(--lh-snug); + color: var(--fg-muted); +} +.edit-field-view-photos { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} +.edit-field-view-photos figure { + display: grid; + gap: var(--space-1); + margin: 0; +} +.edit-field-view-photos img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: scale-down; + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--surface-sunken); +} +.edit-field-view-photos figcaption { + font-size: var(--fs-12); + color: var(--fg-muted); +} diff --git a/src/styles/edit-field.css b/src/styles/edit-field.css new file mode 100644 index 0000000..2475ea0 --- /dev/null +++ b/src/styles/edit-field.css @@ -0,0 +1,377 @@ +edit-field { + display: grid; + gap: var(--space-2); +} + +.edit-field-head { + display: flex; + align-items: center; + gap: var(--space-1); +} +.edit-field-label { + font-size: var(--fs-14); + font-weight: var(--fw-semibold); + color: var(--fg); +} +.edit-field-info { + display: inline-flex; + padding: 2px; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--fg-muted); + cursor: pointer; + transition: var(--transition-fast); +} +.edit-field-info:hover, +.edit-field-info[aria-expanded="true"] { + color: var(--fg); +} +.edit-field-info:focus-visible { + outline: 0; + box-shadow: var(--ring-focus); +} + +.edit-field-description, +.edit-field-hint, +.edit-field-error { + margin: 0; + font-size: var(--fs-13); + line-height: var(--lh-snug); +} +.edit-field-description { + padding: var(--space-2) var(--space-3); + border-left: 2px solid var(--border-strong); + background: var(--surface-sunken); + color: var(--fg-muted); +} +.edit-field-hint { + color: var(--fg-muted); +} +.edit-field-error { + display: flex; + align-items: center; + gap: var(--space-1); + min-height: calc(var(--fs-13) * var(--lh-snug)); + color: var(--error); +} + +/* text input, the combobox reuses it */ +.edit-field-control { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 36px; + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--surface); + transition: var(--transition-fast); +} +.edit-field-control:hover { + border-color: var(--border-strong); +} +.edit-field-control:focus-within { + box-shadow: var(--ring-focus); +} +.edit-field-control:has(textarea) { + align-items: flex-start; +} +.edit-field-control input[type="text"], +.edit-field-control textarea { + flex: 1; + min-width: 0; + height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--fg); + font: inherit; + font-size: var(--fs-14); + outline: 0; +} +.edit-field-control textarea { + padding: var(--space-2) 0; + resize: vertical; +} +.edit-field-control:has(:disabled) { + background: var(--surface-sunken); + color: var(--fg-disabled); +} +.edit-field-prefix { + display: inline-flex; + color: var(--fg-muted); +} +.edit-field-counter { + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; + color: var(--fg-subtle); +} +edit-field:state(user-invalid) .edit-field-control { + border-color: var(--error); +} + +/* toggle */ +.edit-field-toggle { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--surface); + overflow: hidden; +} +.edit-field-toggle label { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 36px; + padding: 0 var(--space-3); + font-size: var(--fs-14); + cursor: pointer; + transition: var(--transition-fast); +} +.edit-field-toggle label + label { + border-left: 1px solid var(--border); +} +.edit-field-toggle label:hover { + background: var(--surface-tint-soft); +} +.edit-field-toggle label:has(:checked) { + background: var(--surface-tint-mid); + font-weight: var(--fw-medium); +} +.edit-field-toggle label:has(:disabled) { + color: var(--fg-disabled); + cursor: default; +} +.edit-field-toggle input { + margin: 0; + accent-color: var(--primary); +} +.edit-field-toggle input:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} +edit-field:state(user-invalid) .edit-field-toggle { + border-color: var(--error); +} + +/* picker with static options */ +.edit-field-options { + display: grid; + gap: var(--space-2); +} +.edit-field-options label { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--surface); + cursor: pointer; + transition: var(--transition-fast); +} +.edit-field-options label:hover { + border-color: var(--border-strong); +} +.edit-field-options label:has(:checked) { + border-color: var(--fg); + background: var(--surface-tint-soft); +} +.edit-field-options label:has(:disabled) { + color: var(--fg-disabled); + cursor: default; +} +.edit-field-options input { + flex: none; + margin: 0; + accent-color: var(--primary); +} +.edit-field-options input:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} +.edit-field-option-icon { + display: inline-flex; + color: var(--fg-muted); +} +.edit-field-option-text { + display: grid; + gap: 2px; +} +.edit-field-option-title { + font-size: var(--fs-14); + font-weight: var(--fw-medium); +} +.edit-field-option-subtitle { + font-size: var(--fs-13); + line-height: var(--lh-snug); + color: var(--fg-muted); +} +edit-field:state(user-invalid) .edit-field-options label { + border-color: var(--error); +} + +/* picker with options from the server */ +.edit-field-popover { + margin-top: var(--space-1); + position-area: bottom; + width: anchor-size(width); + max-height: 320px; + overflow-y: auto; + padding: var(--space-1) 0 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius-4); + background: var(--surface); + box-shadow: var(--shadow-3); + color: var(--fg); +} +.edit-field-popover ul { + margin: 0; + padding: 0; + list-style: none; +} +.edit-field-popover [role="option"] { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + font-size: var(--fs-14); + cursor: pointer; +} +.edit-field-popover [role="option"]:hover { + background: var(--surface-tint-soft); +} +.edit-field-popover [role="option"][aria-selected="true"] { + background: var(--surface-tint-mid); +} +.edit-field-popover [role="option"].is-current { + font-weight: var(--fw-semibold); +} +.edit-field-option-detail { + flex: none; + font-size: var(--fs-12); + color: var(--fg-subtle); +} +.edit-field-popover-status { + position: sticky; + bottom: 0; + margin: var(--space-1) 0 0; + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-subtle); + background: var(--surface); + font-size: var(--fs-12); + color: var(--fg-muted); +} + +/* photos */ +.edit-field-photos { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-4); +} +.edit-field-photo { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: var(--space-2); +} +.edit-field-photo-title { + margin: 0; + font-size: var(--fs-13); + font-weight: var(--fw-medium); + line-height: var(--lh-snug); +} +.edit-field-photo-title span { + display: block; + font-weight: var(--fw-regular); + color: var(--fg-muted); +} +.edit-field-photo-frame { + position: relative; + display: grid; + aspect-ratio: 4 / 3; + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--surface-sunken); + overflow: hidden; + transition: var(--transition-fast); +} +.edit-field-photo-frame.is-empty { + place-items: center; + border-style: dashed; + border-color: var(--border-strong); + color: var(--fg-muted); +} +.edit-field-photo-frame.is-empty:hover { + border-color: var(--fg-muted); + background: var(--surface-tint-mid); + color: var(--fg); +} +.edit-field-photo-frame:has(input:focus-visible) { + box-shadow: var(--ring-focus); +} +.edit-field-photo-frame:has(input:disabled) { + background: var(--surface-sunken); + color: var(--fg-disabled); +} +.edit-field-photo-add { + display: grid; + justify-items: center; + gap: var(--space-1); + padding: var(--space-3); + font-size: var(--fs-12); + line-height: var(--lh-snug); + text-align: center; +} +.edit-field-photo-frame input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} +.edit-field-photo-frame input:disabled { + cursor: default; +} +/* out of the flow, so the image size never changes the frame, + large images shrink to fit and small ones keep their size */ +.edit-field-photo-frame img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: scale-down; +} +.edit-field-photo-remove { + position: absolute; + top: var(--space-2); + right: var(--space-2); + display: inline-flex; + padding: var(--space-1); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--surface); + color: var(--fg); + cursor: pointer; + transition: var(--transition-fast); +} +.edit-field-photo-remove:hover { + border-color: var(--border-strong); + background: var(--surface-tint-mid); +} +.edit-field-photo-remove:focus-visible { + outline: 0; + box-shadow: var(--ring-focus); +} +.edit-field-photo .select select { + width: 100%; +} +.edit-field-photo.is-invalid .edit-field-photo-frame, +.edit-field-photo.is-invalid select { + border-color: var(--error); +} diff --git a/src/styles/edit-form.css b/src/styles/edit-form.css new file mode 100644 index 0000000..d94c725 --- /dev/null +++ b/src/styles/edit-form.css @@ -0,0 +1,45 @@ +.edit-form { + display: grid; + gap: var(--space-5); +} + +/* the revert button sits next to the field label */ +.edit-form-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: var(--space-3); +} +.edit-form-row > edit-field { + grid-column: 1 / -1; + grid-row: 1; +} +.edit-form-revert { + grid-column: 2; + grid-row: 1; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-2); + border: 0; + border-radius: var(--radius-3); + background: transparent; + color: var(--fg-muted); + font: inherit; + font-size: var(--fs-13); + cursor: pointer; + transition: var(--transition-fast); +} +.edit-form-revert:hover { + background: var(--surface-tint-mid); + color: var(--fg); +} +.edit-form-revert:focus-visible { + outline: 0; + box-shadow: var(--ring-focus); +} + +.edit-form-actions { + display: flex; + gap: var(--space-2); +} diff --git a/src/styles/global.css b/src/styles/global.css index b70420c..c55a4ec 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -18,3 +18,6 @@ @import "./sheet.css"; @import "./loading.css"; @import "./redaction.css"; +@import "./edit-field.css"; +@import "./edit-field-view.css"; +@import "./edit-form.css"; diff --git a/src/types/declaration/index.ts b/src/types/declaration/index.ts new file mode 100644 index 0000000..685a1a5 --- /dev/null +++ b/src/types/declaration/index.ts @@ -0,0 +1,45 @@ +import type { z } from "astro/zod"; + +import { + IoDeclaration, + IoDeclarationPickerResource, +} from "../../io/declaration"; +import type { + IoDeclarationLocalized, + IoDeclarationPhotos, + IoDeclarationPhotosSlot, + IoDeclarationPicker, + IoDeclarationPickerOption, + IoDeclarationText, + IoDeclarationTextPattern, + IoDeclarationToggle, +} from "../../io/declaration"; + +export const Declaration = IoDeclaration; +export type Declaration = z.infer; +export type DeclarationType = Declaration["type"]; + +export type DeclarationLocalized = z.infer; + +export type DeclarationText = z.infer; +export type DeclarationTextPattern = z.infer; + +export type DeclarationToggle = z.infer; + +export type DeclarationPicker = z.infer; +export type DeclarationPickerStatic = Extract< + DeclarationPicker, + { resource: "none" } +>; +export type DeclarationPickerDynamic = Exclude< + DeclarationPicker, + { resource: "none" } +>; +export type DeclarationPickerOption = z.infer; +export const DeclarationPickerResource = IoDeclarationPickerResource; +export type DeclarationPickerResource = z.infer< + typeof DeclarationPickerResource +>; + +export type DeclarationPhotos = z.infer; +export type DeclarationPhotosSlot = z.infer; diff --git a/src/utilities/edit-field-view.ts b/src/utilities/edit-field-view.ts new file mode 100644 index 0000000..2e96b0d --- /dev/null +++ b/src/utilities/edit-field-view.ts @@ -0,0 +1,129 @@ +import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import type { TemplateResult } from "lit"; + +import { m } from "../paraglide/messages.js"; +import { localized } from "./localized"; +import * as presentation from "./presentation"; +import { iconSvg } from "./presentation/svg"; +import type { Declaration, DeclarationPhotos } from "../types/declaration"; + +export type EditFieldViewInput = { + declaration: Declaration; + /** value from the server, empty means not set */ + value: string; + /** display name of a resource value, e.g. a category name instead of its id */ + resourceLabel?: string | undefined; + /** photos of a photos field, they aren't part of the device data yet */ + photos?: readonly EditFieldViewPhoto[] | undefined; +}; + +export type EditFieldViewPhoto = { + src: string; + alt: string; + /** category id out of the declaration */ + category: string; +}; + +const unset = () => + html`${m.edit_field_unset()}`; + +const renderPhotos = ( + declaration: DeclarationPhotos, + photos: readonly EditFieldViewPhoto[], +): TemplateResult => { + if (photos.length === 0) { + return unset(); + } + + return html`
    + ${photos.map((photo) => { + const category = declaration.categories[photo.category]; + return html`
  • +
    + ${photo.alt} + ${ + typeof category !== "undefined" + ? html`
    ${localized(category.label)}
    ` + : nothing + } +
    +
  • `; + })} +
`; +}; + +const renderValue = ({ + declaration, + value, + resourceLabel, + photos = [], +}: EditFieldViewInput): TemplateResult => { + if (declaration.type === "photos") { + return renderPhotos(declaration, photos); + } + if (value === "") { + return unset(); + } + + switch (declaration.type) { + case "text": + return html`${value}`; + case "toggle": { + const labels: Record = { + true: m.edit_field_toggle_yes(), + false: m.edit_field_toggle_no(), + }; + return html`${labels[value] ?? value}`; + } + case "picker": { + if (declaration.resource !== "none") { + return html`${resourceLabel ?? value}`; + } + const option = declaration.options.find(({ id }) => id === value); + if (typeof option === "undefined") { + return html`${value}`; + } + + return html` + ${ + typeof option.icon !== "undefined" + ? html`${unsafeHTML( + iconSvg(presentation.declarationIcon(option.icon), 20), + )}` + : nothing + } + + ${localized(option.title)} + ${ + typeof option.subtitle !== "undefined" + ? html`${localized(option.subtitle)}` + : nothing + } + + `; + } + } +}; + +/** + * read only field as a `
` / `
` pair, meant for a `
` + * + * a lit template, islands render it as is, astro renders it to a string with + * `renderToHtml` from `utilities/ssr.ts`, so it has to stay serializable: + * no event listeners, property bindings or refs + */ +export const renderEditFieldView = ( + input: EditFieldViewInput, +): TemplateResult => + html`
+
${localized(input.declaration.label)}
+
${renderValue(input)}
+
`; diff --git a/src/utilities/edit-playground-examples.ts b/src/utilities/edit-playground-examples.ts new file mode 100644 index 0000000..1a63cf3 --- /dev/null +++ b/src/utilities/edit-playground-examples.ts @@ -0,0 +1,34 @@ +import type { Declaration } from "../types/declaration"; + +// shared by the playground island and its server rendered views + +// variants the mocked declarations don't cover +export const EXTRA_EXAMPLES: Declaration[] = [ + { + id: "notes", + type: "text", + label: { en: "Notes" }, + hint: { en: "Anything else worth knowing about the device" }, + pattern: { + multiline: true, + length: { max: 500 }, + kind: "letter", + regex: "[\\p{L}\\p{M}\\p{N}\\p{P}\\p{Zs}\\p{Sc}\\p{Sm}]", + }, + }, + { + id: "battery-powered", + type: "toggle", + label: { en: "Battery powered" }, + hint: { en: "Runs on batteries" }, + }, +]; + +export const EXAMPLE_VALUES: Record = { + name: "Hue White A19", + ean: "4006381333931", + "light.dimming": "true", + "local-operation": "cloud-ish", + brand: "Philips", + category: "lighting", +}; diff --git a/src/utilities/localized.ts b/src/utilities/localized.ts new file mode 100644 index 0000000..5688a83 --- /dev/null +++ b/src/utilities/localized.ts @@ -0,0 +1,4 @@ +import type { DeclarationLocalized } from "../types/declaration"; + +/** english only until it's decided how declaration translations tie into paraglide */ +export const localized = (value: DeclarationLocalized): string => value.en; diff --git a/src/utilities/presentation/icon.ts b/src/utilities/presentation/icon.ts new file mode 100644 index 0000000..6202a4c --- /dev/null +++ b/src/utilities/presentation/icon.ts @@ -0,0 +1,7 @@ +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { iconSvg } from "./svg"; + +/** icon as a lit template part, by generic name or renderable */ +export const icon = (...args: Parameters) => + unsafeHTML(iconSvg(...args)); diff --git a/src/utilities/presentation/index.ts b/src/utilities/presentation/index.ts index 985ff0d..c11392a 100644 --- a/src/utilities/presentation/index.ts +++ b/src/utilities/presentation/index.ts @@ -1,6 +1,7 @@ import { ArrowLeft, ArrowRight, + Barcode, Blinds, Bot, Car, @@ -9,11 +10,13 @@ import { ChevronLeft, ChevronRight, CircleQuestionMark, + Cloud, CloudSun, Droplet, Droplets, ExternalLink, Funnel, + ImagePlus, Languages, LayoutGrid, Lightbulb, @@ -29,9 +32,11 @@ import { Search, Speaker, Sprout, + Sun, Thermometer, ToggleRight, TriangleAlert, + Undo2, Users, Waves, X, @@ -166,6 +171,10 @@ const PRESENTATION_GENERIC = { languages: renderable(Languages), link: renderable(Link), warning: renderable(TriangleAlert), + help: renderable(CircleQuestionMark), + barcode: renderable(Barcode), + imagePlus: renderable(ImagePlus), + undo: renderable(Undo2), } as const; export const device = { @@ -214,6 +223,16 @@ export const quickFilter = (self: QuickFilterId) => { export const generic = (self: keyof typeof PRESENTATION_GENERIC) => PRESENTATION_GENERIC[self]; +// icon names declarations can refer to, e.g. on picker options +const PRESENTATION_DECLARATION_ICON = new Map([ + ["sun", renderable(Sun)], + ["sun-behind-cloud", renderable(CloudSun)], + ["cloud", renderable(Cloud)], +]); + +export const declarationIcon = (name: string): PresentationRenderable => + PRESENTATION_DECLARATION_ICON.get(name) ?? renderable(CircleQuestionMark); + export const render = ( renderable: PresentationRenderable, preset: PresentationRenderPreset, diff --git a/src/utilities/presentation/svg.ts b/src/utilities/presentation/svg.ts new file mode 100644 index 0000000..faa0682 --- /dev/null +++ b/src/utilities/presentation/svg.ts @@ -0,0 +1,13 @@ +import { generic, render } from "."; + +import { PresentationRenderPresetRoleIcon } from "./preset"; + +/** icon as an svg string by generic name or renderable, usable without lit */ +export const iconSvg = ( + self: Parameters[0] | Parameters[0], + size = 16, +): string => + render( + typeof self === "string" ? generic(self) : self, + PresentationRenderPresetRoleIcon.withSize(size), + ); diff --git a/src/utilities/ssr.ts b/src/utilities/ssr.ts new file mode 100644 index 0000000..cc4779d --- /dev/null +++ b/src/utilities/ssr.ts @@ -0,0 +1,16 @@ +import { render } from "@lit-labs/ssr"; +import { collectResultSync } from "@lit-labs/ssr/lib/render-result.js"; +import type { TemplateResult } from "lit"; + +// server only, in an island it would pull the whole renderer into the bundle +if (!import.meta.env.SSR) { + throw new Error("utilities/ssr is server only"); +} + +// comments lit emits for hydration, plus the `` it leaves after a trailing +// binding, nothing hydrates this output so they're only noise +const MARKERS = /|<\?>/g; + +/** lit template as plain html, for astro `set:html` */ +export const renderToHtml = (template: TemplateResult): string => + collectResultSync(render(template)).replaceAll(MARKERS, ""); diff --git a/src/validation/ean.ts b/src/validation/ean.ts new file mode 100644 index 0000000..7c50088 --- /dev/null +++ b/src/validation/ean.ts @@ -0,0 +1,36 @@ +import type { ValidationIssue } from "./types"; + +const EAN_LENGTHS = new Set([8, 13]); +const DIGIT = /^\d$/; + +/** GS1 mod 10, weights alternate 3, 1, … starting next to the check digit */ +const checkDigit = (payload: string[]): number => { + const sum = payload + .toReversed() + .reduce( + (acc, digit, index) => acc + Number(digit) * (index % 2 === 0 ? 3 : 1), + 0, + ); + + return (10 - (sum % 10)) % 10; +}; + +/** expects code points, see `validateText` */ +export const validateEan = (characters: string[]): ValidationIssue[] => { + for (const [index, character] of characters.entries()) { + if (!DIGIT.test(character)) { + return [{ code: "invalid-character", index, character }]; + } + } + + if (!EAN_LENGTHS.has(characters.length)) { + return [{ code: "ean-length", length: characters.length }]; + } + + const payload = characters.slice(0, -1); + if (checkDigit(payload) !== Number(characters.at(-1))) { + return [{ code: "ean-checksum" }]; + } + + return []; +}; diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000..a138c68 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,15 @@ +// returns structured issues instead of messages, consumers localize them + +export { validateEan } from "./ean"; +export { validateOption } from "./option"; +export { validatePhotos } from "./photos"; +export { validateText } from "./text"; +export { validateUnset } from "./unset"; +export type { + ValidationIssue, + ValidationIssueCode, + ValidationPhoto, + ValidationPhotoSlot, + ValidationTextLength, + ValidationTextPattern, +} from "./types"; diff --git a/src/validation/option.ts b/src/validation/option.ts new file mode 100644 index 0000000..cc55758 --- /dev/null +++ b/src/validation/option.ts @@ -0,0 +1,8 @@ +import type { ValidationIssue } from "./types"; + +/** empty values are valid, as fields are optional */ +export const validateOption = ( + value: string, + ids: ReadonlySet, +): ValidationIssue[] => + value === "" || ids.has(value) ? [] : [{ code: "unknown-option" }]; diff --git a/src/validation/photos.ts b/src/validation/photos.ts new file mode 100644 index 0000000..26f5e39 --- /dev/null +++ b/src/validation/photos.ts @@ -0,0 +1,25 @@ +import type { + ValidationIssue, + ValidationPhoto, + ValidationPhotoSlot, +} from "./types"; + +/** empty slots are valid, as fields are optional */ +export const validatePhotos = ( + photos: ValidationPhoto[], + slots: ValidationPhotoSlot[], +): ValidationIssue[] => + photos.flatMap((photo, slot): ValidationIssue[] => { + if (photo === null) { + return []; + } + if (!photo.type.startsWith("image/")) { + return [{ code: "photo-type", slot }]; + } + const rule = slots[slot]; + const allowed = + photo.category !== "" && + (rule?.constrained !== true || rule.categories.includes(photo.category)); + + return allowed ? [] : [{ code: "photo-category", slot }]; + }); diff --git a/src/validation/text.ts b/src/validation/text.ts new file mode 100644 index 0000000..48fd201 --- /dev/null +++ b/src/validation/text.ts @@ -0,0 +1,89 @@ +import { validateEan } from "./ean"; +import type { ValidationIssue, ValidationTextPattern } from "./types"; + +const LINE_BREAK = /[\n\r]/; + +type CharacterClass = { + /** cheap check whether any character is invalid, line breaks are left to `multiline` */ + whole: RegExp; + /** pinpoints the invalid character once `whole` fails */ + single: RegExp; +}; + +// validation runs on every keystroke, so compile each character class once +const compiled = new Map(); +const characterClass = (source: string): CharacterClass => { + let cached = compiled.get(source); + if (typeof cached === "undefined") { + cached = { + whole: new RegExp(`^(?:${source}|[\\n\\r])*$`, "u"), + single: new RegExp(`^(?:${source})$`, "u"), + }; + compiled.set(source, cached); + } + + return cached; +}; + +const invalidCharacter = ( + value: string, + characters: string[], + source: string, +): ValidationIssue | undefined => { + const { whole, single } = characterClass(source); + if (whole.test(value)) { + return undefined; + } + + for (const [index, character] of characters.entries()) { + if (!LINE_BREAK.test(character) && !single.test(character)) { + return { code: "invalid-character", index, character }; + } + } + + return undefined; +}; + +/** + * empty values are valid, as fields are optional + * issues are ordered by relevance, consumers usually show the first one + */ +export const validateText = ( + value: string, + pattern: ValidationTextPattern, +): ValidationIssue[] => { + if (value === "") { + return []; + } + + // code points, so that an emoji counts as one character + const characters = [...value]; + const issues: ValidationIssue[] = []; + + if (!pattern.multiline && LINE_BREAK.test(value)) { + issues.push({ code: "multiline" }); + } + + switch (pattern.kind) { + case "letter": { + const issue = invalidCharacter(value, characters, pattern.regex); + if (typeof issue !== "undefined") { + issues.push(issue); + } + break; + } + case "ean": + issues.push(...validateEan(characters)); + break; + } + + const { min, max } = pattern.length ?? {}; + if (typeof min !== "undefined" && characters.length < min) { + issues.push({ code: "too-short", min, length: characters.length }); + } + if (typeof max !== "undefined" && characters.length > max) { + issues.push({ code: "too-long", max, length: characters.length }); + } + + return issues; +}; diff --git a/src/validation/types.ts b/src/validation/types.ts new file mode 100644 index 0000000..8427cad --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +1,37 @@ +export type ValidationTextLength = { + min?: number | undefined; + max?: number | undefined; +}; + +export type ValidationTextPattern = { + multiline: boolean; + length?: ValidationTextLength | undefined; +} & ( + | { + /** checked character by character against `regex`, a single character class */ + kind: "letter"; + regex: string; + } + | { kind: "ean" } +); + +export type ValidationPhotoSlot = + { constrained: true; categories: string[] } | { constrained: false }; + +/** `type` is the file's media type, `null` is an empty slot */ +export type ValidationPhoto = { type: string; category: string } | null; + +/** lengths and indices count code points, not UTF-16 code units */ +export type ValidationIssue = + | { code: "multiline" } + | { code: "invalid-character"; index: number; character: string } + | { code: "too-short"; min: number; length: number } + | { code: "too-long"; max: number; length: number } + | { code: "ean-length"; length: number } + | { code: "ean-checksum" } + | { code: "unknown-option" } + | { code: "unset" } + | { code: "photo-type"; slot: number } + | { code: "photo-category"; slot: number }; + +export type ValidationIssueCode = ValidationIssue["code"]; diff --git a/src/validation/unset.ts b/src/validation/unset.ts new file mode 100644 index 0000000..9d1a663 --- /dev/null +++ b/src/validation/unset.ts @@ -0,0 +1,8 @@ +import type { ValidationIssue } from "./types"; + +/** a value can stay empty, but a value that was set can't be removed */ +export const validateUnset = ( + value: string, + defaultValue: string, +): ValidationIssue[] => + value === "" && defaultValue !== "" ? [{ code: "unset" }] : []; diff --git a/test/declaration.test.ts b/test/declaration.test.ts new file mode 100644 index 0000000..2d84c09 --- /dev/null +++ b/test/declaration.test.ts @@ -0,0 +1,176 @@ +import { describe, test, vi } from "vitest"; + +import { getDeclarations, IoDeclarations } from "../src/io/declaration"; +import { Declaration } from "../src/types/declaration"; +import { localized } from "../src/utilities/localized"; + +vi.mock("astro:env/server", () => ({ API_AUTHORITY: "https://example.com" })); + +const NAME = { + id: "name", + type: "text", + label: { en: "Name" }, + pattern: { + multiline: false, + length: { min: 3, max: 64 }, + kind: "letter", + regex: "[\\p{L}\\p{N}]", + }, +}; + +describe("getDeclarations", () => { + test("mock parses into every declaration type", async (t) => { + const { declarations } = await getDeclarations(); + t.expect(new Set(declarations.map(({ type }) => type))).toEqual( + new Set(["text", "toggle", "picker", "photos"]), + ); + }); +}); + +describe("Declaration", () => { + test("narrows text patterns by kind", (t) => { + const result = Declaration.parse(NAME); + t.expect(result.type === "text" && result.pattern.kind).toBe("letter"); + }); + + test("rejects unknown type", (t) => { + t.expect(Declaration.safeParse({ ...NAME, type: "slider" }).success).toBe( + false, + ); + }); + + test("requires english translations", (t) => { + t.expect( + Declaration.safeParse({ ...NAME, label: { de: "Name" } }).success, + ).toBe(false); + }); + + test("rejects letter pattern without regex", (t) => { + t.expect( + Declaration.safeParse({ + ...NAME, + pattern: { multiline: false, kind: "letter" }, + }).success, + ).toBe(false); + }); + + test("rejects regex that does not compile in unicode mode", (t) => { + t.expect( + Declaration.safeParse({ + ...NAME, + pattern: { ...NAME.pattern, regex: "[\\p{Nope}]" }, + }).success, + ).toBe(false); + }); + + test("rejects length with min above max", (t) => { + t.expect( + Declaration.safeParse({ + ...NAME, + pattern: { ...NAME.pattern, length: { min: 10, max: 3 } }, + }).success, + ).toBe(false); + }); + + test("static picker requires unique options", (t) => { + const picker = { + id: "local-operation", + type: "picker", + label: { en: "Local Operation" }, + resource: "none", + }; + const option = { id: "local", title: { en: "Fully Local" } }; + + t.expect(Declaration.safeParse(picker).success).toBe(false); + t.expect( + Declaration.safeParse({ ...picker, options: [option, option] }).success, + ).toBe(false); + t.expect( + Declaration.safeParse({ ...picker, options: [option] }).success, + ).toBe(true); + }); + + test("rejects unknown picker resource", (t) => { + t.expect( + Declaration.safeParse({ + id: "brand", + type: "picker", + label: { en: "Brand" }, + resource: "vendor", + }).success, + ).toBe(false); + }); + + test("rejects photos without categories", (t) => { + const result = Declaration.safeParse({ + id: "photos", + type: "photos", + label: { en: "Photos" }, + categories: {}, + pattern: { slots: [{ constrained: false }] }, + }); + t.expect(result.success).toBe(false); + t.expect(result.error?.issues[0]?.message).toBe("no categories"); + }); + + test("rejects photo slots referencing unknown categories", (t) => { + const result = Declaration.safeParse({ + id: "photos", + type: "photos", + label: { en: "Photos" }, + categories: { packshot: { label: { en: "Packshot" } } }, + pattern: { + slots: [ + { constrained: true, categories: ["packshot", "unboxing"] }, + { constrained: false }, + ], + }, + }); + t.expect(result.success).toBe(false); + t.expect(result.error?.issues[0]?.message).toBe( + "unknown category ", + ); + }); +}); + +describe("IoDeclarations", () => { + const quiet = () => vi.spyOn(console, "warn").mockImplementation(() => {}); + + test("skips unsupported declarations and keeps the rest", (t) => { + const warn = quiet(); + const declarations = IoDeclarations.parse([ + { ...NAME, id: "rating", type: "rating" }, + NAME, + { + id: "duplicate-of", + type: "picker", + label: { en: "Duplicate of" }, + resource: "device", + }, + ]); + t.expect(declarations.map(({ id }) => id)).toEqual(["name"]); + t.expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + test("keeps the first of repeated ids", (t) => { + const warn = quiet(); + const declarations = IoDeclarations.parse([ + NAME, + { ...NAME, label: { en: "Other" } }, + ]); + t.expect(declarations.map(({ label }) => label.en)).toEqual(["Name"]); + t.expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + test("still requires a list", (t) => { + t.expect(IoDeclarations.safeParse({}).success).toBe(false); + }); +}); + +describe("localized", () => { + test("picks english", (t) => { + t.expect(localized({ en: "Name", de: "Name (de)" })).toBe("Name"); + }); +}); diff --git a/test/edit-field-view.test.ts b/test/edit-field-view.test.ts new file mode 100644 index 0000000..c9d1c2f --- /dev/null +++ b/test/edit-field-view.test.ts @@ -0,0 +1,95 @@ +import { describe, test } from "vitest"; + +import { renderEditFieldView } from "../src/utilities/edit-field-view"; +import { renderToHtml } from "../src/utilities/ssr"; +import type { Declaration } from "../src/types/declaration"; + +const text: Declaration = { + id: "name", + type: "text", + label: { en: "Name" }, + pattern: { multiline: false, kind: "letter", regex: "\\p{L}" }, +}; + +const toggle: Declaration = { + id: "light.dimming", + type: "toggle", + label: { en: "Dimmable" }, +}; + +const picker: Declaration = { + id: "local-operation", + type: "picker", + label: { en: "Local Operation" }, + resource: "none", + options: [ + { id: "local", title: { en: "Fully Local" }, icon: "sun" }, + { id: "cloud", title: { en: "Cloud Required" } }, + ], +}; + +const view = ( + declaration: Declaration, + value: string, + resourceLabel?: string, +) => renderToHtml(renderEditFieldView({ declaration, value, resourceLabel })); + +describe("renderEditFieldView", () => { + test("pairs the label with the value", (t) => { + const html = view(text, "Hue "); + t.expect(html).toContain('
Name
'); + t.expect(html).toContain("Hue <White>"); + }); + + test("shows not set for empty values and photos", (t) => { + t.expect(view(text, "")).toContain("Not set"); + t.expect(view(toggle, "")).toContain("Not set"); + }); + + test("labels toggle values", (t) => { + t.expect(view(toggle, "true")).toContain(">Yes
"); + t.expect(view(toggle, "false")).toContain(">No"); + t.expect(view(toggle, "maybe")).toContain(">maybe"); + }); + + test("shows the picked option, or the raw value if it's unknown", (t) => { + const local = view(picker, "local"); + t.expect(local).toContain("Fully Local"); + t.expect(local).toContain("gone"); + }); + + test("lists photos with their category", (t) => { + const photos: Declaration = { + id: "photos", + type: "photos", + label: { en: "Photos" }, + categories: { packshot: { label: { en: "Packshot" } } }, + pattern: { slots: [{ constrained: false }] }, + }; + t.expect(view(photos, "")).toContain("Not set"); + const html = renderToHtml( + renderEditFieldView({ + declaration: photos, + value: "", + photos: [{ src: "a.png", alt: 'say "hi"', category: "packshot" }], + }), + ); + t.expect(html).toContain('say "hi"'); + t.expect(html).toContain("
Packshot
"); + }); + + test("prefers the resolved label of a resource value", (t) => { + const category: Declaration = { + id: "category", + type: "picker", + label: { en: "Category" }, + resource: "category", + }; + t.expect(view(category, "lighting", "Lighting")).toContain( + ">Lighting", + ); + t.expect(view(category, "lighting")).toContain(">lighting"); + }); +}); diff --git a/test/validation.test.ts b/test/validation.test.ts new file mode 100644 index 0000000..92fb383 --- /dev/null +++ b/test/validation.test.ts @@ -0,0 +1,177 @@ +import { describe, test } from "vitest"; + +import { + validateOption, + validatePhotos, + validateText, + validateUnset, +} from "../src/validation"; +import type { + ValidationPhotoSlot, + ValidationTextPattern, +} from "../src/validation"; + +// same patterns as the mock declarations +const NAME: ValidationTextPattern = { + multiline: false, + length: { min: 3, max: 64 }, + kind: "letter", + regex: "[\\p{L}\\p{M}\\p{N}\\p{P}\\p{Zs}\\p{Sc}\\p{Sm}]", +}; +const EAN: ValidationTextPattern = { multiline: false, kind: "ean" }; + +describe("validateText (name)", () => { + test.for([ + "Hue White A19", + "FRITZ!Box 6690 Cable", + "MacBook Air (M1, 2020)", + "PositionableRollerShutterWithLowSpeedManagement", + "Sonoff ZBMINI-L2", + "Türkontakt Café 3€", + ])("accepts %j", (value, t) => { + t.expect(validateText(value, NAME)).toEqual([]); + }); + + test("accepts empty, as fields are optional", (t) => { + t.expect(validateText("", NAME)).toEqual([]); + }); + + test("rejects too short", (t) => { + t.expect(validateText("99", NAME)).toEqual([ + { code: "too-short", min: 3, length: 2 }, + ]); + }); + + test("rejects too long", (t) => { + t.expect(validateText("a".repeat(65), NAME)).toEqual([ + { code: "too-long", max: 64, length: 65 }, + ]); + }); + + test("pinpoints emoji by code point", (t) => { + t.expect(validateText("Lamp 💡 Pro", NAME)).toEqual([ + { code: "invalid-character", index: 5, character: "💡" }, + ]); + }); + + test("counts emoji as one character", (t) => { + t.expect(validateText("💡💡", NAME)).toEqual([ + { code: "invalid-character", index: 0, character: "💡" }, + { code: "too-short", min: 3, length: 2 }, + ]); + }); + + test("rejects tab", (t) => { + t.expect(validateText("Hue\tGo", NAME)).toEqual([ + { code: "invalid-character", index: 3, character: "\t" }, + ]); + }); + + test("reports pasted line break as multiline only", (t) => { + t.expect(validateText("Hue Go\nPortable", NAME)).toEqual([ + { code: "multiline" }, + ]); + }); + + test("allows line breaks when multiline", (t) => { + t.expect( + validateText("first line\nsecond line", { + multiline: true, + kind: "letter", + regex: "[\\p{L}\\p{Zs}]", + }), + ).toEqual([]); + }); +}); + +describe("validateText (ean)", () => { + test.for(["4006381333931", "96385074"])("accepts %j", (value, t) => { + t.expect(validateText(value, EAN)).toEqual([]); + }); + + test("rejects wrong check digit", (t) => { + t.expect(validateText("4006381333932", EAN)).toEqual([ + { code: "ean-checksum" }, + ]); + }); + + test("rejects upc-a length", (t) => { + t.expect(validateText("036000291452", EAN)).toEqual([ + { code: "ean-length", length: 12 }, + ]); + }); + + test("pinpoints spaces as printed under barcodes", (t) => { + t.expect(validateText("4 006381 333931", EAN)).toEqual([ + { code: "invalid-character", index: 1, character: " " }, + ]); + }); +}); + +describe("validateOption", () => { + const ids = new Set(["local", "cloud-ish", "cloud"]); + + test("accepts known and empty", (t) => { + t.expect(validateOption("cloud", ids)).toEqual([]); + t.expect(validateOption("", ids)).toEqual([]); + }); + + test("rejects unknown", (t) => { + t.expect(validateOption("hybrid", ids)).toEqual([ + { code: "unknown-option" }, + ]); + }); +}); + +describe("validatePhotos", () => { + const SLOTS: ValidationPhotoSlot[] = [ + { constrained: true, categories: ["packshot", "hero-shot"] }, + { constrained: false }, + ]; + + test("accepts empty slots and allowed categories", (t) => { + t.expect(validatePhotos([null, null], SLOTS)).toEqual([]); + t.expect( + validatePhotos( + [ + { type: "image/jpeg", category: "hero-shot" }, + { type: "image/png", category: "unboxing" }, + ], + SLOTS, + ), + ).toEqual([]); + }); + + test("rejects files that aren't images", (t) => { + t.expect( + validatePhotos( + [{ type: "application/pdf", category: "packshot" }], + SLOTS, + ), + ).toEqual([{ code: "photo-type", slot: 0 }]); + }); + + test("rejects missing or disallowed categories", (t) => { + t.expect( + validatePhotos( + [ + { type: "image/jpeg", category: "unboxing" }, + { type: "image/jpeg", category: "" }, + ], + SLOTS, + ), + ).toEqual([ + { code: "photo-category", slot: 0 }, + { code: "photo-category", slot: 1 }, + ]); + }); +}); + +describe("validateUnset", () => { + test("keeps an empty value empty but never removes a set one", (t) => { + t.expect(validateUnset("", "")).toEqual([]); + t.expect(validateUnset("true", "")).toEqual([]); + t.expect(validateUnset("false", "true")).toEqual([]); + t.expect(validateUnset("", "true")).toEqual([{ code: "unset" }]); + }); +});