From 7c3307ab3c56c50d36bd6ba6e5e32d66360212a4 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:00:39 +0200 Subject: [PATCH 01/39] Add edit field declaration schema with mock data --- src/io/declaration.ts | 158 +++++++++++++++++++++++++++++++++ src/io/mock/declarations.json | 132 +++++++++++++++++++++++++++ src/types/declaration/index.ts | 37 ++++++++ src/utilities/localized.ts | 4 + test/declaration.test.ts | 134 ++++++++++++++++++++++++++++ 5 files changed, 465 insertions(+) create mode 100644 src/io/declaration.ts create mode 100644 src/io/mock/declarations.json create mode 100644 src/types/declaration/index.ts create mode 100644 src/utilities/localized.ts create mode 100644 test/declaration.test.ts diff --git a/src/io/declaration.ts b/src/io/declaration.ts new file mode 100644 index 0000000..f97659b --- /dev/null +++ b/src/io/declaration.ts @@ -0,0 +1,158 @@ +import { z } from "astro/zod"; + +import mock from "./mock/declarations.json"; + +/** 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"), + categories: z.record( + z.string(), + z.object({ + label: IoDeclarationLocalized, + description: z.optional(IoDeclarationLocalized), + }), + ), + 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; + +export const IoDeclarations = z + .array(IoDeclaration) + .refine( + (declarations) => + new Set(declarations.map(({ id }) => id)).size === declarations.length, + { message: "duplicate declaration id" }, + ); + +// mocked until the endpoint is available +export const getDeclarations = () => + Promise.resolve({ declarations: IoDeclarations.parse(mock) }); 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/types/declaration/index.ts b/src/types/declaration/index.ts new file mode 100644 index 0000000..42160ac --- /dev/null +++ b/src/types/declaration/index.ts @@ -0,0 +1,37 @@ +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 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/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/test/declaration.test.ts b/test/declaration.test.ts new file mode 100644 index 0000000..58fccad --- /dev/null +++ b/test/declaration.test.ts @@ -0,0 +1,134 @@ +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 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", () => { + test("rejects duplicate ids", (t) => { + t.expect(IoDeclarations.safeParse([NAME, NAME]).success).toBe(false); + }); +}); + +describe("localized", () => { + test("picks english", (t) => { + t.expect(localized({ en: "Name", de: "Name (de)" })).toBe("Name"); + }); +}); From a44c0df88352b9203d38ecb0f6a20e8c20dec697 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:34:56 +0200 Subject: [PATCH 02/39] Add edit field validation helpers --- src/validation/ean.ts | 36 ++++++++++++ src/validation/index.ts | 11 ++++ src/validation/option.ts | 8 +++ src/validation/text.ts | 89 ++++++++++++++++++++++++++++++ src/validation/types.ts | 28 ++++++++++ test/validation.test.ts | 116 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 288 insertions(+) create mode 100644 src/validation/ean.ts create mode 100644 src/validation/index.ts create mode 100644 src/validation/option.ts create mode 100644 src/validation/text.ts create mode 100644 src/validation/types.ts create mode 100644 test/validation.test.ts 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..c6a8623 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,11 @@ +// returns structured issues instead of messages, consumers localize them + +export { validateEan } from "./ean"; +export { validateOption } from "./option"; +export { validateText } from "./text"; +export type { + ValidationIssue, + ValidationIssueCode, + 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/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..3910bf9 --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +1,28 @@ +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" } +); + +/** 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" }; + +export type ValidationIssueCode = ValidationIssue["code"]; diff --git a/test/validation.test.ts b/test/validation.test.ts new file mode 100644 index 0000000..05e35c2 --- /dev/null +++ b/test/validation.test.ts @@ -0,0 +1,116 @@ +import { describe, test } from "vitest"; + +import { validateOption, validateText } from "../src/validation"; +import type { 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" }, + ]); + }); +}); From 0946eb5d390d35f162e3f9dc20f143cdd70af0ac Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:19:49 +0200 Subject: [PATCH 03/39] Add generic edit field element --- messages/en.json | 11 +- src/islands/edit-field.ts | 287 ++++++++++++++++++++++++++++ src/islands/edit-field/context.ts | 21 ++ src/islands/edit-field/issue.ts | 36 ++++ src/styles/edit-field.css | 64 +++++++ src/styles/global.css | 1 + src/utilities/presentation/index.ts | 1 + 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/islands/edit-field.ts create mode 100644 src/islands/edit-field/context.ts create mode 100644 src/islands/edit-field/issue.ts create mode 100644 src/styles/edit-field.css diff --git a/messages/en.json b/messages/en.json index d54c738..5e0919f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -192,5 +192,14 @@ "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_unsupported": "Editing {type} fields is not supported yet", + "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" } diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts new file mode 100644 index 0000000..9f0d153 --- /dev/null +++ b/src/islands/edit-field.ts @@ -0,0 +1,287 @@ +import { html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.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 * as presentation from "../utilities/presentation"; +import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import { validateText } from "../validation"; +import { issueMessage } from "./edit-field/issue.js"; +import type { Declaration } from "../types/declaration"; +import type { ValidationIssue } from "../validation"; +import type { + EditFieldIds, + EditFieldRenderContext, +} from "./edit-field/context.js"; + +// light DOM, so ids have to be unique across the document +let instances = 0; +const editFieldIds = (): EditFieldIds => { + const prefix = `edit-field-${instances++}`; + return { + control: `${prefix}-control`, + hint: `${prefix}-hint`, + description: `${prefix}-description`, + error: `${prefix}-error`, + }; +}; + +const validate = ( + declaration: Declaration, + value: string, +): ValidationIssue[] => { + switch (declaration.type) { + case "text": + return validateText(value, declaration.pattern); + case "toggle": + case "picker": + case "photos": + return []; + } +}; + +const icon = (name: Parameters[0]) => + unsafeHTML( + presentation.render( + presentation.generic(name), + PresentationRenderPresetRoleIcon.withSize(16), + ), + ); + +/** + * 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 = ""; + @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 changed(): boolean { + return 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(); + } + + formResetCallback(): void { + this._dirty = false; + this._committed = false; + this._current = this.defaultValue; + } + + formStateRestoreCallback(state: unknown): void { + if (typeof state === "string") { + this.value = state; + } + } + + formDisabledCallback(disabled: boolean): void { + this._disabled = disabled; + } + + protected override createRenderRoot(): HTMLElement { + return this; + } + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("defaultValue") && !this._dirty) { + this._current = this.defaultValue; + } + if (typeof this.declaration === "undefined") { + this._issues = []; + return; + } + if (this.name === "") { + this.name = this.declaration.id; + } + this._issues = validate(this.declaration, this._current); + } + + protected override updated(): void { + const internals = this._internals; + internals.setFormValue(this._current, this._current); + + const [issue] = this._issues; + if (typeof issue === "undefined") { + internals.setValidity({}); + } else { + const anchor = + this.querySelector(`#${this._ids.control}`) ?? undefined; + internals.setValidity({ customError: true }, issueMessage(issue), anchor); + } + + // styling hooks, `edit-field:state(changed)` / `edit-field:state(user-invalid)` + this._toggleState("changed", this.changed); + this._toggleState("user-invalid", this._showIssue); + } + + 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 _onCommit = (): void => { + this._committed = true; + }; + + private _toggleDescription = (): void => { + this._descriptionOpen = !this._descriptionOpen; + }; + + private _renderControl(ctx: EditFieldRenderContext) { + const { declaration } = ctx; + switch (declaration.type) { + case "text": + case "toggle": + case "picker": + case "photos": + return html`

+ ${m.edit_field_unsupported({ type: declaration.type })} +

`; + } + } + + 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, + 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/context.ts b/src/islands/edit-field/context.ts new file mode 100644 index 0000000..e0f172a --- /dev/null +++ b/src/islands/edit-field/context.ts @@ -0,0 +1,21 @@ +import type { Declaration } from "../../types/declaration"; + +export type EditFieldIds = { + 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; + 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/issue.ts b/src/islands/edit-field/issue.ts new file mode 100644 index 0000000..b402ea9 --- /dev/null +++ b/src/islands/edit-field/issue.ts @@ -0,0 +1,36 @@ +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(); + } +}; diff --git a/src/styles/edit-field.css b/src/styles/edit-field.css new file mode 100644 index 0000000..1724af9 --- /dev/null +++ b/src/styles/edit-field.css @@ -0,0 +1,64 @@ +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, +.edit-field-unsupported { + 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); + color: var(--error); +} + +.edit-field-unsupported { + padding: var(--space-2) var(--space-3); + border: 1px dashed var(--border-strong); + border-radius: var(--radius-3); + color: var(--fg-subtle); +} diff --git a/src/styles/global.css b/src/styles/global.css index b70420c..f9dbdbc 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -18,3 +18,4 @@ @import "./sheet.css"; @import "./loading.css"; @import "./redaction.css"; +@import "./edit-field.css"; diff --git a/src/utilities/presentation/index.ts b/src/utilities/presentation/index.ts index 985ff0d..1614355 100644 --- a/src/utilities/presentation/index.ts +++ b/src/utilities/presentation/index.ts @@ -166,6 +166,7 @@ const PRESENTATION_GENERIC = { languages: renderable(Languages), link: renderable(Link), warning: renderable(TriangleAlert), + help: renderable(CircleQuestionMark), } as const; export const device = { From 446850954d0f79327073bd3ae757a36c0afc2528 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:19:57 +0200 Subject: [PATCH 04/39] Add text type to edit field --- src/islands/edit-field.ts | 2 + src/islands/edit-field/text.ts | 81 +++++++++++++++++++++++++++++ src/styles/edit-field.css | 57 ++++++++++++++++++++ src/utilities/presentation/index.ts | 2 + 4 files changed, 142 insertions(+) create mode 100644 src/islands/edit-field/text.ts diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index 9f0d153..34bd7eb 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -10,6 +10,7 @@ import * as presentation from "../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; import { validateText } from "../validation"; import { issueMessage } from "./edit-field/issue.js"; +import { renderText } from "./edit-field/text.js"; import type { Declaration } from "../types/declaration"; import type { ValidationIssue } from "../validation"; import type { @@ -194,6 +195,7 @@ export class EditField extends LitElement { const { declaration } = ctx; switch (declaration.type) { case "text": + return renderText({ ...ctx, declaration }); case "toggle": case "picker": case "photos": diff --git a/src/islands/edit-field/text.ts b/src/islands/edit-field/text.ts new file mode 100644 index 0000000..586d3a7 --- /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 { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import * as presentation from "../../utilities/presentation"; +import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset.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: Partial< + Record[0]> +> = { + ean: "barcode", +}; + +export const renderText = (ctx: EditFieldRenderContext) => { + const { declaration, value, ids, describedBy, invalid, disabled } = ctx; + const { pattern } = declaration; + const max = pattern.length?.max; + const icon = 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 + const control = pattern.multiline + ? html`` + : html``; + + return html` +
+ ${ + typeof icon !== "undefined" + ? html` + ${unsafeHTML( + presentation.render( + presentation.generic(icon), + PresentationRenderPresetRoleIcon.withSize(16), + ), + )} + ` + : nothing + } + ${control} + ${ + typeof max !== "undefined" + ? html`` + : nothing + } +
+ `; +}; diff --git a/src/styles/edit-field.css b/src/styles/edit-field.css index 1724af9..25cdbd8 100644 --- a/src/styles/edit-field.css +++ b/src/styles/edit-field.css @@ -62,3 +62,60 @@ edit-field { border-radius: var(--radius-3); color: var(--fg-subtle); } + +/* text */ +.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); +} diff --git a/src/utilities/presentation/index.ts b/src/utilities/presentation/index.ts index 1614355..c3fc248 100644 --- a/src/utilities/presentation/index.ts +++ b/src/utilities/presentation/index.ts @@ -1,6 +1,7 @@ import { ArrowLeft, ArrowRight, + Barcode, Blinds, Bot, Car, @@ -167,6 +168,7 @@ const PRESENTATION_GENERIC = { link: renderable(Link), warning: renderable(TriangleAlert), help: renderable(CircleQuestionMark), + barcode: renderable(Barcode), } as const; export const device = { From 4698b314a4eb8ffce40f5c9014e6d2d7ddd64164 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:20:04 +0200 Subject: [PATCH 05/39] Add edit field playground --- src/islands/edit-playground.ts | 262 ++++++++++++++++++++++++++++++++ src/pages/edit-playground.astro | 114 ++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 src/islands/edit-playground.ts create mode 100644 src/pages/edit-playground.astro diff --git a/src/islands/edit-playground.ts b/src/islands/edit-playground.ts new file mode 100644 index 0000000..d89bee9 --- /dev/null +++ b/src/islands/edit-playground.ts @@ -0,0 +1,262 @@ +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 { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { getDeclarations } from "../io/declaration"; +import { Declaration } from "../types/declaration"; +import { defineElementOnce } from "../utilities/define-element.js"; +import * as presentation from "../utilities/presentation"; +import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import type { EditField } from "./edit-field.js"; + +import "./edit-field.js"; + +// temporary page to debug one declaration at a time, intentionally english only + +const PRESET_VALUES: Record = { + name: "Hue White A19", + ean: "4006381333931", +}; + +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 _presets: Declaration[] = []; + @state() private _presetId = ""; + @state() private _source = ""; + @state() private _declaration: Declaration | undefined; + @state() private _error: string | undefined; + @state() private _initialValue = ""; + @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._presets = declarations; + const [first] = declarations; + if (typeof first !== "undefined") { + this._loadPreset(first); + } + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener("invalid", this._refresh, true); + } + + private _loadPreset(declaration: Declaration): void { + this._presetId = declaration.id; + this._source = JSON.stringify(declaration, null, 2); + this._declaration = declaration; + this._error = undefined; + this._initialValue = PRESET_VALUES[declaration.id] ?? ""; + this._submitted = undefined; + this._refresh(); + } + + // 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 _onPreset = (e: Event): void => { + const id = (e.currentTarget as HTMLSelectElement).value; + const declaration = this._presets.find((preset) => preset.id === id); + if (typeof declaration !== "undefined") { + this._loadPreset(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 _onSubmit = (e: SubmitEvent): void => { + e.preventDefault(); + this._submitted = [ + ...new FormData(e.currentTarget as HTMLFormElement).entries(), + ].map(([key, value]) => [ + key, + typeof value === "string" ? value : value.name, + ]); + this._refresh(); + }; + + private _onReset = (): void => { + this._submitted = undefined; + // `reset` fires before the controls reset + void Promise.resolve().then(this._refresh); + }; + + private _renderPresets() { + return html` + + `; + } + + override render() { + return html` +
+ ${this._renderPresets()} + + ${ + typeof this._error !== "undefined" + ? html`
${this._error}
` + : nothing + } + +
+ +
+
+ ${ + typeof this._declaration !== "undefined" + ? keyed( + this._presetId, + html``, + ) + : nothing + } +
+ + +
+
+ +
+

State

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

Submitted form data

+
${JSON.stringify(this._submitted, null, 2)}
+
` + : nothing + } +
+ `; + } +} + +defineElementOnce("edit-playground", EditPlayground); + +declare global { + interface HTMLElementTagNameMap { + "edit-playground": EditPlayground; + } +} diff --git a/src/pages/edit-playground.astro b/src/pages/edit-playground.astro new file mode 100644 index 0000000..fa15ec3 --- /dev/null +++ b/src/pages/edit-playground.astro @@ -0,0 +1,114 @@ +--- +import Layout from "../layouts/Layout.astro"; + +// temporary page to try edit field declarations, not linked and not indexed +--- + + + +
+

Edit playground

+

+ Pick a preset or edit the declaration to debug one field at a time. + Submitting shows the form data instead of sending it. +

+ +
+
+ + + + + From a68d5a2fd034d9dded4b6e4b15f10d719c06a434 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:25:25 +0200 Subject: [PATCH 06/39] Add toggle type to edit field --- messages/en.json | 3 ++ src/io/declaration.ts | 2 + src/io/mock/declarations.json | 1 + src/islands/edit-field.ts | 19 ++++++-- src/islands/edit-field/context.ts | 3 ++ src/islands/edit-field/toggle.ts | 72 +++++++++++++++++++++++++++++++ src/islands/edit-playground.ts | 1 + src/styles/edit-field.css | 46 ++++++++++++++++++++ test/declaration.test.ts | 16 +++++++ 9 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/islands/edit-field/toggle.ts diff --git a/messages/en.json b/messages/en.json index 5e0919f..56d9bc9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -195,6 +195,9 @@ "device_wtb_all_sites": "All comparison sites we link to, by country", "edit_field_about": "About {label}", "edit_field_unsupported": "Editing {type} fields is not supported yet", + "edit_field_toggle_yes": "Yes", + "edit_field_toggle_no": "No", + "edit_field_toggle_unset": "Not set", "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}", diff --git a/src/io/declaration.ts b/src/io/declaration.ts index f97659b..d79e3a7 100644 --- a/src/io/declaration.ts +++ b/src/io/declaration.ts @@ -66,6 +66,8 @@ export const IoDeclarationText = z.object({ export const IoDeclarationToggle = z.object({ ...IoDeclarationBase, type: z.literal("toggle"), + /** whether a set value can be changed back to not set, defaults to true */ + allowUnset: z.optional(z.boolean()), }); export const IoDeclarationPickerOption = z.object({ diff --git a/src/io/mock/declarations.json b/src/io/mock/declarations.json index 56c4272..859d8f3 100644 --- a/src/io/mock/declarations.json +++ b/src/io/mock/declarations.json @@ -26,6 +26,7 @@ { "id": "light.dimming", "type": "toggle", + "allowUnset": false, "label": { "en": "Dimmable" }, "hint": { "en": "Capable of brightness adjustments" }, "description": { diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index 34bd7eb..6386031 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -8,9 +8,10 @@ import { defineElementOnce } from "../utilities/define-element.js"; import { localized } from "../utilities/localized.js"; import * as presentation from "../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; -import { validateText } from "../validation"; +import { validateOption, validateText } from "../validation"; import { issueMessage } from "./edit-field/issue.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 { @@ -23,6 +24,7 @@ let instances = 0; const editFieldIds = (): EditFieldIds => { const prefix = `edit-field-${instances++}`; return { + label: `${prefix}-label`, control: `${prefix}-control`, hint: `${prefix}-hint`, description: `${prefix}-description`, @@ -38,6 +40,7 @@ const validate = ( case "text": return validateText(value, declaration.pattern); case "toggle": + return validateOption(value, TOGGLE_VALUES); case "picker": case "photos": return []; @@ -157,8 +160,12 @@ export class EditField extends LitElement { if (typeof issue === "undefined") { internals.setValidity({}); } else { - const anchor = - this.querySelector(`#${this._ids.control}`) ?? undefined; + // grouped controls (e.g. radios) anchor on their first input + const control = this.querySelector(`#${this._ids.control}`); + const anchor = control?.matches("input, textarea, select") + ? control + : (control?.querySelector("input, textarea, select") ?? + undefined); internals.setValidity({ customError: true }, issueMessage(issue), anchor); } @@ -197,6 +204,7 @@ export class EditField extends LitElement { case "text": return renderText({ ...ctx, declaration }); case "toggle": + return renderToggle({ ...ctx, declaration }); case "picker": case "photos": return html`

@@ -225,7 +233,9 @@ export class EditField extends LitElement { return html`

- + ${ typeof declaration.description !== "undefined" ? html`` + : nothing + } +
+
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/picker.ts b/src/islands/edit-field/picker.ts index 19de932..f981cc7 100644 --- a/src/islands/edit-field/picker.ts +++ b/src/islands/edit-field/picker.ts @@ -1,15 +1,26 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { getResourceOptions } from "../../io/declaration"; import { m } from "../../paraglide/messages.js"; +import { getLocale } from "../../paraglide/runtime.js"; import { localized } from "../../utilities/localized.js"; import * as presentation from "../../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset.js"; +import { rank } from "../../utilities/search.js"; import { renderRadioGroup, unsetAllowed } from "./radio-group"; -import type { DeclarationPickerStatic } from "../../types/declaration"; +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"; + /** submitted values, empty means not set */ export const pickerValues = (declaration: DeclarationPickerStatic) => new Set(["", ...declaration.options.map(({ id }) => id)]); @@ -69,3 +80,75 @@ export const renderStaticPicker = ( 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: count.toLocaleString(getLocale()), + }), + })), + 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-playground.ts b/src/islands/edit-playground.ts index 8538577..a902418 100644 --- a/src/islands/edit-playground.ts +++ b/src/islands/edit-playground.ts @@ -20,6 +20,8 @@ const PRESET_VALUES: Record = { ean: "4006381333931", "light.dimming": "true", "local-operation": "cloud-ish", + brand: "Philips", + category: "lighting", }; type FieldSnapshot = { diff --git a/src/styles/edit-field.css b/src/styles/edit-field.css index 32b7f04..0a3fb65 100644 --- a/src/styles/edit-field.css +++ b/src/styles/edit-field.css @@ -63,7 +63,7 @@ edit-field { color: var(--fg-subtle); } -/* text */ +/* text input, the combobox reuses it */ .edit-field-control { display: flex; align-items: center; @@ -222,3 +222,72 @@ edit-field:state(user-invalid) .edit-field-toggle { edit-field:state(user-invalid) .edit-field-options label { border-color: var(--error); } + +/* picker with options from the server */ +.edit-field-clear { + display: inline-flex; + padding: 2px; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--fg-muted); + cursor: pointer; +} +.edit-field-clear:hover { + color: var(--fg); +} +.edit-field-clear:focus-visible { + outline: 0; + box-shadow: var(--ring-focus); +} +.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); +} diff --git a/src/types/declaration/index.ts b/src/types/declaration/index.ts index f0e4260..685a1a5 100644 --- a/src/types/declaration/index.ts +++ b/src/types/declaration/index.ts @@ -31,6 +31,10 @@ 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< From 613131071154265d23c38c1990e5d454501de2fa Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:38:24 +0200 Subject: [PATCH 09/39] Add photos type to edit field --- messages/en.json | 11 +- src/islands/edit-field.ts | 64 +++++++++--- src/islands/edit-field/issue.ts | 4 + src/islands/edit-field/photos.ts | 167 +++++++++++++++++++++++++++++++ src/styles/edit-field.css | 50 +++++++-- src/validation/index.ts | 3 + src/validation/photos.ts | 25 +++++ src/validation/types.ts | 10 +- test/validation.test.ts | 55 +++++++++- 9 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 src/islands/edit-field/photos.ts create mode 100644 src/validation/photos.ts diff --git a/messages/en.json b/messages/en.json index c1c9f0c..4637542 100644 --- a/messages/en.json +++ b/messages/en.json @@ -194,7 +194,6 @@ "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", "edit_field_about": "About {label}", - "edit_field_unsupported": "Editing {type} fields is not supported yet", "edit_field_unset": "Not set", "edit_field_clear": "Clear {label}", "edit_field_option_devices": "{count} devices", @@ -203,6 +202,12 @@ "edit_field_combobox_empty": "No matches", "edit_field_combobox_count": "{count} matches", "edit_field_combobox_more": "Showing {shown} of {total}, type 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": "Choose a category", + "edit_field_photo_remove": "Remove photo {number}", + "edit_field_photo_remove_short": "Remove", "edit_field_toggle_yes": "Yes", "edit_field_toggle_no": "No", "edit_field_issue_multiline": "Must be a single line", @@ -211,5 +216,7 @@ "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_unknown_option": "Choose one of the available options", + "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/src/islands/edit-field.ts b/src/islands/edit-field.ts index 822ddfd..ac3f7ad 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -8,8 +8,14 @@ import { defineElementOnce } from "../utilities/define-element.js"; import { localized } from "../utilities/localized.js"; import * as presentation from "../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; -import { validateOption, validateText } from "../validation"; +import { validateOption, validatePhotos, validateText } from "../validation"; import { issueMessage } from "./edit-field/issue.js"; +import { + emptyPhotos, + photosFormData, + releasePhotos, + renderPhotos, +} from "./edit-field/photos.js"; import { pickerValues, renderDynamicPicker, @@ -23,6 +29,7 @@ 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; @@ -40,6 +47,7 @@ const editFieldIds = (): EditFieldIds => { const validate = ( declaration: Declaration, value: string, + photos: PhotoSlotValue[], ): ValidationIssue[] => { switch (declaration.type) { case "text": @@ -51,7 +59,13 @@ const validate = ( ? validateOption(value, pickerValues(declaration)) : []; case "photos": - return []; + return validatePhotos( + photos.map( + (photo) => + photo && { type: photo.file.type, category: photo.category }, + ), + declaration.pattern.slots, + ); } }; @@ -77,6 +91,8 @@ export class EditField extends LitElement { @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; @@ -103,8 +119,15 @@ export class EditField extends LitElement { 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._current !== this.defaultValue; + return this.declaration?.type === "photos" + ? this._photos.some((photo) => photo !== null) + : this._current !== this.defaultValue; } get form(): HTMLFormElement | null { @@ -130,6 +153,9 @@ export class EditField extends LitElement { this._dirty = false; this._committed = false; this._current = this.defaultValue; + releasePhotos(this._photos); + this._photos = + this.declaration?.type === "photos" ? emptyPhotos(this.declaration) : []; } formStateRestoreCallback(state: unknown): void { @@ -157,12 +183,23 @@ export class EditField extends LitElement { if (this.name === "") { this.name = this.declaration.id; } - this._issues = validate(this.declaration, this._current); + if ( + this.declaration.type === "photos" && + this._photos.length !== this.declaration.pattern.slots.length + ) { + const slots = emptyPhotos(this.declaration); + this._photos = slots.map((empty, index) => this._photos[index] ?? empty); + } + this._issues = validate(this.declaration, this._current, this._photos); } protected override updated(): void { const internals = this._internals; - internals.setFormValue(this._current, this._current); + 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") { @@ -198,6 +235,11 @@ export class EditField extends LitElement { this.value = value; }; + private _onPhotos = (photos: PhotoSlotValue[]): void => { + this._dirty = true; + this._photos = photos; + }; + private _onCommit = (): void => { this._committed = true; }; @@ -206,12 +248,6 @@ export class EditField extends LitElement { this._descriptionOpen = !this._descriptionOpen; }; - private _renderUnsupported(type: Declaration["type"]) { - return html`

- ${m.edit_field_unsupported({ type })} -

`; - } - private _renderControl(ctx: EditFieldRenderContext) { const { declaration } = ctx; switch (declaration.type) { @@ -224,7 +260,11 @@ export class EditField extends LitElement { ? renderStaticPicker({ ...ctx, declaration }) : renderDynamicPicker({ ...ctx, declaration }); case "photos": - return this._renderUnsupported(declaration.type); + return renderPhotos( + { ...ctx, declaration }, + this._photos, + this._onPhotos, + ); } } diff --git a/src/islands/edit-field/issue.ts b/src/islands/edit-field/issue.ts index b402ea9..ec5edee 100644 --- a/src/islands/edit-field/issue.ts +++ b/src/islands/edit-field/issue.ts @@ -32,5 +32,9 @@ export const issueMessage = (issue: ValidationIssue): string => { return m.edit_field_issue_ean_checksum(); case "unknown-option": return m.edit_field_issue_unknown_option(); + 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..1ff6b83 --- /dev/null +++ b/src/islands/edit-field/photos.ts @@ -0,0 +1,167 @@ +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 type { DeclarationPhotos } from "../../types/declaration"; +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, +) => { + const { declaration, ids, describedBy, invalid, disabled } = ctx; + 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; + }; + + const set = (index: number, photo: PhotoSlotValue) => { + releasePhotos([photos[index] ?? null]); + onPhotos(photos.map((current, at) => (at === index ? photo : current))); + 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") { + set(index, { + file, + // a single allowed category needs no choice + category: categories.length === 1 ? (categories[0] ?? "") : "", + preview: URL.createObjectURL(file), + }); + } + }; + + 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/styles/edit-field.css b/src/styles/edit-field.css index 0a3fb65..89dd8f6 100644 --- a/src/styles/edit-field.css +++ b/src/styles/edit-field.css @@ -34,8 +34,7 @@ edit-field { .edit-field-description, .edit-field-hint, -.edit-field-error, -.edit-field-unsupported { +.edit-field-error { margin: 0; font-size: var(--fs-13); line-height: var(--lh-snug); @@ -56,13 +55,6 @@ edit-field { color: var(--error); } -.edit-field-unsupported { - padding: var(--space-2) var(--space-3); - border: 1px dashed var(--border-strong); - border-radius: var(--radius-3); - color: var(--fg-subtle); -} - /* text input, the combobox reuses it */ .edit-field-control { display: flex; @@ -291,3 +283,43 @@ edit-field:state(user-invalid) .edit-field-options label { font-size: var(--fs-12); color: var(--fg-muted); } + +/* photos */ +.edit-field-photos { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-4); +} +.edit-field-photo { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: start; + justify-items: start; + gap: var(--space-2); + font-size: var(--fs-13); +} +.edit-field-photo-title { + margin: 0; + font-weight: var(--fw-medium); +} +.edit-field-photo-title span { + display: block; + font-weight: var(--fw-regular); + color: var(--fg-muted); +} +.edit-field-photo input, +.edit-field-photo select { + max-width: 100%; + font: inherit; +} +.edit-field-photo img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--surface-sunken); +} +edit-field:state(user-invalid) .edit-field-photo img { + border-color: var(--error); +} diff --git a/src/validation/index.ts b/src/validation/index.ts index c6a8623..19a2747 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -2,10 +2,13 @@ export { validateEan } from "./ean"; export { validateOption } from "./option"; +export { validatePhotos } from "./photos"; export { validateText } from "./text"; export type { ValidationIssue, ValidationIssueCode, + ValidationPhoto, + ValidationPhotoSlot, ValidationTextLength, ValidationTextPattern, } from "./types"; 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/types.ts b/src/validation/types.ts index 3910bf9..a394ac0 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -15,6 +15,12 @@ export type ValidationTextPattern = { | { 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" } @@ -23,6 +29,8 @@ export type ValidationIssue = | { code: "too-long"; max: number; length: number } | { code: "ean-length"; length: number } | { code: "ean-checksum" } - | { code: "unknown-option" }; + | { code: "unknown-option" } + | { code: "photo-type"; slot: number } + | { code: "photo-category"; slot: number }; export type ValidationIssueCode = ValidationIssue["code"]; diff --git a/test/validation.test.ts b/test/validation.test.ts index 05e35c2..c6b1803 100644 --- a/test/validation.test.ts +++ b/test/validation.test.ts @@ -1,7 +1,14 @@ import { describe, test } from "vitest"; -import { validateOption, validateText } from "../src/validation"; -import type { ValidationTextPattern } from "../src/validation"; +import { + validateOption, + validatePhotos, + validateText, +} from "../src/validation"; +import type { + ValidationPhotoSlot, + ValidationTextPattern, +} from "../src/validation"; // same patterns as the mock declarations const NAME: ValidationTextPattern = { @@ -114,3 +121,47 @@ describe("validateOption", () => { ]); }); }); + +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 }, + ]); + }); +}); From 1cda61611872297d6496ce33f2a3cac5575badd9 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:19:45 +0200 Subject: [PATCH 10/39] Keep photo preview on category change --- src/islands/edit-field/photos.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/islands/edit-field/photos.ts b/src/islands/edit-field/photos.ts index 1ff6b83..73c95c0 100644 --- a/src/islands/edit-field/photos.ts +++ b/src/islands/edit-field/photos.ts @@ -54,7 +54,10 @@ export const renderPhotos = ( }; const set = (index: number, photo: PhotoSlotValue) => { - releasePhotos([photos[index] ?? null]); + const previous = photos[index] ?? null; + if (previous !== null && previous.preview !== photo?.preview) { + releasePhotos([previous]); + } onPhotos(photos.map((current, at) => (at === index ? photo : current))); ctx.onCommit(); }; From c8895df50001beaf9b38d723662ed1c82338d475 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:19:50 +0200 Subject: [PATCH 11/39] Search once on first combobox keystroke --- src/islands/edit-field/combobox.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/islands/edit-field/combobox.ts b/src/islands/edit-field/combobox.ts index 48d70c5..1a595dc 100644 --- a/src/islands/edit-field/combobox.ts +++ b/src/islands/edit-field/combobox.ts @@ -155,6 +155,16 @@ export class EditFieldCombobox extends LitElement { void this._fetch(); }; + 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); @@ -209,14 +219,6 @@ export class EditFieldCombobox extends LitElement { } } - private _onInput = (e: Event): void => { - this._term = (e.currentTarget as HTMLInputElement).value; - this._typed = true; - this._openPopup(); - clearTimeout(this._timer); - this._timer = setTimeout(() => void this._fetch(), FETCH_DELAY_MS); - }; - private _onKeyDown = (e: KeyboardEvent): void => { const count = this._options.length; From a0e743be375e144136b0a1201e262abe532e9823 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:19:55 +0200 Subject: [PATCH 12/39] Wait for photo category before showing errors --- src/islands/edit-field/photos.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/islands/edit-field/photos.ts b/src/islands/edit-field/photos.ts index 73c95c0..d2379cb 100644 --- a/src/islands/edit-field/photos.ts +++ b/src/islands/edit-field/photos.ts @@ -53,13 +53,16 @@ export const renderPhotos = ( return typeof category !== "undefined" ? localized(category.label) : id; }; - const set = (index: number, photo: PhotoSlotValue) => { + /** `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))); - ctx.onCommit(); + if (commit) { + ctx.onCommit(); + } }; return html` @@ -82,12 +85,14 @@ export const renderPhotos = ( const onFile = (e: Event) => { const file = (e.currentTarget as HTMLInputElement).files?.[0]; if (typeof file !== "undefined") { - set(index, { - file, - // a single allowed category needs no choice - category: categories.length === 1 ? (categories[0] ?? "") : "", - preview: URL.createObjectURL(file), - }); + // a single allowed category needs no choice + const category = + categories.length === 1 ? (categories[0] ?? "") : ""; + set( + index, + { file, category, preview: URL.createObjectURL(file) }, + category !== "", + ); } }; From 7c785a2df1387eb4f3ce990bd7ca37b042aa80ca Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:09 +0200 Subject: [PATCH 13/39] Enforce allowUnset in edit field validation --- messages/en.json | 1 + src/islands/edit-field.ts | 36 ++++++++++++++++++++++----- src/islands/edit-field/issue.ts | 2 ++ src/islands/edit-field/picker.ts | 3 ++- src/islands/edit-field/radio-group.ts | 6 ----- src/islands/edit-field/toggle.ts | 3 ++- src/validation/index.ts | 2 +- src/validation/option.ts | 11 ++++++++ src/validation/types.ts | 1 + test/validation.test.ts | 13 ++++++++++ 10 files changed, 63 insertions(+), 15 deletions(-) diff --git a/messages/en.json b/messages/en.json index 4637542..4bc7ab2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -217,6 +217,7 @@ "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": "This value can't be cleared", "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/src/islands/edit-field.ts b/src/islands/edit-field.ts index ac3f7ad..bbc661a 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -8,7 +8,13 @@ import { defineElementOnce } from "../utilities/define-element.js"; import { localized } from "../utilities/localized.js"; import * as presentation from "../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; -import { validateOption, validatePhotos, validateText } from "../validation"; +import { + unsetAllowed, + validateOption, + validatePhotos, + validateText, + validateUnset, +} from "../validation"; import { issueMessage } from "./edit-field/issue.js"; import { emptyPhotos, @@ -47,17 +53,30 @@ const editFieldIds = (): EditFieldIds => { const validate = ( declaration: Declaration, value: string, + defaultValue: string, photos: PhotoSlotValue[], ): ValidationIssue[] => { switch (declaration.type) { case "text": return validateText(value, declaration.pattern); case "toggle": - return validateOption(value, TOGGLE_VALUES); + return [ + ...validateUnset( + value, + unsetAllowed(declaration.allowUnset, defaultValue), + ), + ...validateOption(value, TOGGLE_VALUES), + ]; case "picker": - return declaration.resource === "none" - ? validateOption(value, pickerValues(declaration)) - : []; + return [ + ...validateUnset( + value, + unsetAllowed(declaration.allowUnset, defaultValue), + ), + ...(declaration.resource === "none" + ? validateOption(value, pickerValues(declaration)) + : []), + ]; case "photos": return validatePhotos( photos.map( @@ -190,7 +209,12 @@ export class EditField extends LitElement { const slots = emptyPhotos(this.declaration); this._photos = slots.map((empty, index) => this._photos[index] ?? empty); } - this._issues = validate(this.declaration, this._current, this._photos); + this._issues = validate( + this.declaration, + this._current, + this.defaultValue, + this._photos, + ); } protected override updated(): void { diff --git a/src/islands/edit-field/issue.ts b/src/islands/edit-field/issue.ts index ec5edee..78ede24 100644 --- a/src/islands/edit-field/issue.ts +++ b/src/islands/edit-field/issue.ts @@ -32,6 +32,8 @@ export const issueMessage = (issue: ValidationIssue): string => { 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": diff --git a/src/islands/edit-field/picker.ts b/src/islands/edit-field/picker.ts index f981cc7..e1cff8f 100644 --- a/src/islands/edit-field/picker.ts +++ b/src/islands/edit-field/picker.ts @@ -8,7 +8,8 @@ import { localized } from "../../utilities/localized.js"; import * as presentation from "../../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset.js"; import { rank } from "../../utilities/search.js"; -import { renderRadioGroup, unsetAllowed } from "./radio-group"; +import { unsetAllowed } from "../../validation"; +import { renderRadioGroup } from "./radio-group"; import type { IoResourceOption } from "../../io/declaration"; import type { DeclarationPickerDynamic, diff --git a/src/islands/edit-field/radio-group.ts b/src/islands/edit-field/radio-group.ts index 86ba82a..bbac98d 100644 --- a/src/islands/edit-field/radio-group.ts +++ b/src/islands/edit-field/radio-group.ts @@ -14,12 +14,6 @@ export type RadioOption = { describedBy?: string | undefined; }; -/** without `allowUnset`, "not set" only shows when the device has no value yet */ -export const unsetAllowed = ( - allowUnset: boolean | undefined, - defaultValue: string, -): boolean => allowUnset !== false || defaultValue === ""; - export const renderRadioGroup = ( ctx: EditFieldRenderContext, className: string, diff --git a/src/islands/edit-field/toggle.ts b/src/islands/edit-field/toggle.ts index a5466ab..395d86c 100644 --- a/src/islands/edit-field/toggle.ts +++ b/src/islands/edit-field/toggle.ts @@ -1,5 +1,6 @@ import { m } from "../../paraglide/messages.js"; -import { renderRadioGroup, unsetAllowed } from "./radio-group"; +import { unsetAllowed } from "../../validation"; +import { renderRadioGroup } from "./radio-group"; import type { DeclarationToggle } from "../../types/declaration"; import type { EditFieldRenderContext } from "./context"; diff --git a/src/validation/index.ts b/src/validation/index.ts index 19a2747..6912698 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,7 +1,7 @@ // returns structured issues instead of messages, consumers localize them export { validateEan } from "./ean"; -export { validateOption } from "./option"; +export { unsetAllowed, validateOption, validateUnset } from "./option"; export { validatePhotos } from "./photos"; export { validateText } from "./text"; export type { diff --git a/src/validation/option.ts b/src/validation/option.ts index cc55758..23022ff 100644 --- a/src/validation/option.ts +++ b/src/validation/option.ts @@ -6,3 +6,14 @@ export const validateOption = ( ids: ReadonlySet, ): ValidationIssue[] => value === "" || ids.has(value) ? [] : [{ code: "unknown-option" }]; + +/** without `allowUnset`, a value can only stay empty when it was empty before */ +export const unsetAllowed = ( + allowUnset: boolean | undefined, + defaultValue: string, +): boolean => allowUnset !== false || defaultValue === ""; + +export const validateUnset = ( + value: string, + allowed: boolean, +): ValidationIssue[] => (value === "" && !allowed ? [{ code: "unset" }] : []); diff --git a/src/validation/types.ts b/src/validation/types.ts index a394ac0..8427cad 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -30,6 +30,7 @@ export type ValidationIssue = | { code: "ean-length"; length: number } | { code: "ean-checksum" } | { code: "unknown-option" } + | { code: "unset" } | { code: "photo-type"; slot: number } | { code: "photo-category"; slot: number }; diff --git a/test/validation.test.ts b/test/validation.test.ts index c6b1803..26b6c3e 100644 --- a/test/validation.test.ts +++ b/test/validation.test.ts @@ -1,9 +1,11 @@ import { describe, test } from "vitest"; import { + unsetAllowed, validateOption, validatePhotos, validateText, + validateUnset, } from "../src/validation"; import type { ValidationPhotoSlot, @@ -165,3 +167,14 @@ describe("validatePhotos", () => { ]); }); }); + +describe("validateUnset", () => { + test("allows clearing unless the declaration forbids it", (t) => { + t.expect(validateUnset("", unsetAllowed(undefined, "true"))).toEqual([]); + t.expect(validateUnset("", unsetAllowed(false, ""))).toEqual([]); + t.expect(validateUnset("", unsetAllowed(false, "true"))).toEqual([ + { code: "unset" }, + ]); + t.expect(validateUnset("false", unsetAllowed(false, "true"))).toEqual([]); + }); +}); From 77a70b9fed247877b580ef75ae5b725d63f0f579 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:13 +0200 Subject: [PATCH 14/39] Release photos dropped from a declaration --- src/islands/edit-field.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index bbc661a..ce1e608 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -195,6 +195,10 @@ export class EditField extends LitElement { 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; @@ -207,6 +211,7 @@ export class EditField extends LitElement { 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); } this._issues = validate( From aaf0835e8e7f99060fab3ce9f5d82e1c3e589748 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:17 +0200 Subject: [PATCH 15/39] Load edit field styles only where used --- src/islands/edit-field.ts | 2 ++ src/styles/global.css | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index ce1e608..2322d6f 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -3,6 +3,8 @@ import { property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { PropertyValues } from "lit"; +import "../styles/edit-field.css"; + import { m } from "../paraglide/messages.js"; import { defineElementOnce } from "../utilities/define-element.js"; import { localized } from "../utilities/localized.js"; diff --git a/src/styles/global.css b/src/styles/global.css index f9dbdbc..b70420c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -18,4 +18,3 @@ @import "./sheet.css"; @import "./loading.css"; @import "./redaction.css"; -@import "./edit-field.css"; From 7b0c94afe4a015b754ebfc8b8e64504a82c9d1a5 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:22 +0200 Subject: [PATCH 16/39] Share icon helper across islands --- src/islands/edit-field.ts | 12 +----------- src/islands/edit-field/combobox.ts | 12 +----------- src/islands/edit-field/picker.ts | 10 ++-------- src/islands/edit-field/text.ts | 21 +++++---------------- src/islands/edit-playground.ts | 11 ++--------- src/islands/filter-sheet.ts | 12 +----------- src/utilities/presentation/icon.ts | 17 +++++++++++++++++ 7 files changed, 29 insertions(+), 66 deletions(-) create mode 100644 src/utilities/presentation/icon.ts diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index 2322d6f..0aa98ff 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -1,6 +1,5 @@ import { html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { PropertyValues } from "lit"; import "../styles/edit-field.css"; @@ -8,8 +7,7 @@ import "../styles/edit-field.css"; import { m } from "../paraglide/messages.js"; import { defineElementOnce } from "../utilities/define-element.js"; import { localized } from "../utilities/localized.js"; -import * as presentation from "../utilities/presentation"; -import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import { icon } from "../utilities/presentation/icon.js"; import { unsetAllowed, validateOption, @@ -90,14 +88,6 @@ const validate = ( } }; -const icon = (name: Parameters[0]) => - unsafeHTML( - presentation.render( - presentation.generic(name), - PresentationRenderPresetRoleIcon.withSize(16), - ), - ); - /** * form-associated control rendered from a declaration * diff --git a/src/islands/edit-field/combobox.ts b/src/islands/edit-field/combobox.ts index 1a595dc..5c99576 100644 --- a/src/islands/edit-field/combobox.ts +++ b/src/islands/edit-field/combobox.ts @@ -3,14 +3,12 @@ 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 { unsafeHTML } from "lit/directives/unsafe-html.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 * 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"; export type ComboboxOption = { @@ -27,14 +25,6 @@ export type ComboboxSource = { const FETCH_DELAY_MS = 200; -const icon = (name: Parameters[0]) => - unsafeHTML( - presentation.render( - presentation.generic(name), - PresentationRenderPresetRoleIcon.withSize(16), - ), - ); - /** * editable combobox with a listbox popup, following the WAI-ARIA pattern * https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ diff --git a/src/islands/edit-field/picker.ts b/src/islands/edit-field/picker.ts index e1cff8f..b579896 100644 --- a/src/islands/edit-field/picker.ts +++ b/src/islands/edit-field/picker.ts @@ -1,12 +1,11 @@ import { html, nothing } from "lit"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { getResourceOptions } from "../../io/declaration"; import { m } from "../../paraglide/messages.js"; import { getLocale } from "../../paraglide/runtime.js"; import { localized } from "../../utilities/localized.js"; import * as presentation from "../../utilities/presentation"; -import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset.js"; +import { icon } from "../../utilities/presentation/icon.js"; import { rank } from "../../utilities/search.js"; import { unsetAllowed } from "../../validation"; import { renderRadioGroup } from "./radio-group"; @@ -45,12 +44,7 @@ export const renderStaticPicker = ( ${ typeof option.icon !== "undefined" ? html` - ${unsafeHTML( - presentation.render( - presentation.declarationIcon(option.icon), - PresentationRenderPresetRoleIcon.withSize(20), - ), - )} + ${icon(presentation.declarationIcon(option.icon), 20)} ` : nothing } diff --git a/src/islands/edit-field/text.ts b/src/islands/edit-field/text.ts index 586d3a7..ae7d1f8 100644 --- a/src/islands/edit-field/text.ts +++ b/src/islands/edit-field/text.ts @@ -1,17 +1,13 @@ import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { live } from "lit/directives/live.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import * as presentation from "../../utilities/presentation"; -import { PresentationRenderPresetRoleIcon } from "../../utilities/presentation/preset.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: Partial< - Record[0]> -> = { +const PREFIX_ICON: Partial[0]>> = { ean: "barcode", }; @@ -19,7 +15,7 @@ export const renderText = (ctx: EditFieldRenderContext) => { const { declaration, value, ids, describedBy, invalid, disabled } = ctx; const { pattern } = declaration; const max = pattern.length?.max; - const icon = PREFIX_ICON[declaration.id]; + const prefix = PREFIX_ICON[declaration.id]; const numeric = pattern.kind === "ean"; const onInput = (e: Event) => { @@ -57,15 +53,8 @@ export const renderText = (ctx: EditFieldRenderContext) => { return html`
${ - typeof icon !== "undefined" - ? html` - ${unsafeHTML( - presentation.render( - presentation.generic(icon), - PresentationRenderPresetRoleIcon.withSize(16), - ), - )} - ` + typeof prefix !== "undefined" + ? html`${icon(prefix)}` : nothing } ${control} diff --git a/src/islands/edit-playground.ts b/src/islands/edit-playground.ts index a902418..95d619a 100644 --- a/src/islands/edit-playground.ts +++ b/src/islands/edit-playground.ts @@ -2,13 +2,11 @@ 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 { unsafeHTML } from "lit/directives/unsafe-html.js"; import { getDeclarations } from "../io/declaration"; import { Declaration } from "../types/declaration"; 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 type { EditField } from "./edit-field.js"; import "./edit-field.js"; @@ -173,12 +171,7 @@ export class EditPlayground extends LitElement { )}
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/utilities/presentation/icon.ts b/src/utilities/presentation/icon.ts new file mode 100644 index 0000000..ed9fc40 --- /dev/null +++ b/src/utilities/presentation/icon.ts @@ -0,0 +1,17 @@ +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { generic, render } from "."; + +import { PresentationRenderPresetRoleIcon } from "./preset"; + +/** icon as a lit template part, by generic name or renderable */ +export const icon = ( + self: Parameters[0] | Parameters[0], + size = 16, +) => + unsafeHTML( + render( + typeof self === "string" ? generic(self) : self, + PresentationRenderPresetRoleIcon.withSize(size), + ), + ); From 8d20283c5bad7a0be98d6264735ed14ad6c12817 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:26 +0200 Subject: [PATCH 17/39] Fix combobox match count wording --- messages/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index 4bc7ab2..6813603 100644 --- a/messages/en.json +++ b/messages/en.json @@ -200,7 +200,7 @@ "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": "{count} matches", + "edit_field_combobox_count": "Matches: {count}", "edit_field_combobox_more": "Showing {shown} of {total}, type to narrow down", "edit_field_photo_slot": "Photo {number}", "edit_field_photo_any": "Any category", From 33eb32de60eeffefb1c946d5dc77e0a81d3dbb76 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:29 +0200 Subject: [PATCH 18/39] Fire input event on photo removal --- src/islands/edit-field/combobox.ts | 5 ++--- src/islands/edit-field/events.ts | 5 +++++ src/islands/edit-field/photos.ts | 6 ++---- 3 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/islands/edit-field/events.ts diff --git a/src/islands/edit-field/combobox.ts b/src/islands/edit-field/combobox.ts index 5c99576..c71fada 100644 --- a/src/islands/edit-field/combobox.ts +++ b/src/islands/edit-field/combobox.ts @@ -10,6 +10,7 @@ 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; @@ -173,9 +174,7 @@ export class EditFieldCombobox extends LitElement { this.dispatchEvent( new CustomEvent("combobox-pick", { detail: value }), ); - // a pick doesn't come from typing, so fire what a native control would - this.dispatchEvent(new Event("input", { bubbles: true })); - this.dispatchEvent(new Event("change", { bubbles: true })); + notifyChange(this); } private async _fetch(): Promise { 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/photos.ts b/src/islands/edit-field/photos.ts index d2379cb..7f0eade 100644 --- a/src/islands/edit-field/photos.ts +++ b/src/islands/edit-field/photos.ts @@ -4,6 +4,7 @@ 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 { notifyChange } from "./events"; import type { DeclarationPhotos } from "../../types/declaration"; import type { EditFieldRenderContext } from "./context"; @@ -157,10 +158,7 @@ export const renderPhotos = ( ?disabled=${disabled} @click=${(e: Event) => { set(index, null); - // not a native edit, so fire what a native control would - e.currentTarget?.dispatchEvent( - new Event("change", { bubbles: true }), - ); + notifyChange(e.currentTarget); }} > ${m.edit_field_photo_remove_short()} From c345bfded44ece7a68ad389b110edb8ad9e27592 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:20:33 +0200 Subject: [PATCH 19/39] Follow declaration id for edit field name --- src/islands/edit-field.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index 0aa98ff..629f90f 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -195,7 +195,9 @@ export class EditField extends LitElement { this._issues = []; return; } - if (this.name === "") { + // 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 ( From 445e2a0b588f13646c7160b5be7276264e97c13a Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:38:29 +0200 Subject: [PATCH 20/39] Show inline errors only on submit --- src/islands/edit-field.ts | 31 ++++++++++++++++++++++++------- src/islands/edit-playground.ts | 13 ++++++++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/islands/edit-field.ts b/src/islands/edit-field.ts index 629f90f..096aabe 100644 --- a/src/islands/edit-field.ts +++ b/src/islands/edit-field.ts @@ -228,13 +228,11 @@ export class EditField extends LitElement { if (typeof issue === "undefined") { internals.setValidity({}); } else { - // grouped controls (e.g. radios) anchor on their first input - const control = this.querySelector(`#${this._ids.control}`); - const anchor = control?.matches("input, textarea, select") - ? control - : (control?.querySelector("input, textarea, select") ?? - undefined); - internals.setValidity({ customError: true }, issueMessage(issue), anchor); + internals.setValidity( + { customError: true }, + issueMessage(issue), + this._anchor, + ); } // styling hooks, `edit-field:state(changed)` / `edit-field:state(user-invalid)` @@ -242,6 +240,25 @@ export class EditField extends LitElement { 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; } diff --git a/src/islands/edit-playground.ts b/src/islands/edit-playground.ts index 95d619a..969ec4f 100644 --- a/src/islands/edit-playground.ts +++ b/src/islands/edit-playground.ts @@ -140,11 +140,17 @@ export class EditPlayground extends LitElement { this._refresh(); }; + // `novalidate` skips the native bubbles, fields show their own errors private _onSubmit = (e: SubmitEvent): void => { e.preventDefault(); - this._submitted = [ - ...new FormData(e.currentTarget as HTMLFormElement).entries(), - ].map(([key, value]) => [ + const form = e.currentTarget as HTMLFormElement; + if (!form.checkValidity()) { + form.querySelector(":invalid")?.focus(); + this._submitted = undefined; + this._refresh(); + return; + } + this._submitted = [...new FormData(form).entries()].map(([key, value]) => [ key, typeof value === "string" ? value : value.name, ]); @@ -210,6 +216,7 @@ export class EditPlayground extends LitElement {
Date: Mon, 19 Oct 2026 12:54:38 +0200 Subject: [PATCH 21/39] Pick playground examples by editor --- src/islands/edit-playground.ts | 138 ++++++++++++++++++++++++++------ src/pages/edit-playground.astro | 14 +++- 2 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/islands/edit-playground.ts b/src/islands/edit-playground.ts index 969ec4f..1f6b8a4 100644 --- a/src/islands/edit-playground.ts +++ b/src/islands/edit-playground.ts @@ -2,6 +2,7 @@ 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 { getDeclarations } from "../io/declaration"; import { Declaration } from "../types/declaration"; @@ -13,7 +14,47 @@ import "./edit-field.js"; // temporary page to debug one declaration at a time, intentionally english only -const PRESET_VALUES: Record = { +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"; +}; + +// variants the mocked declarations don't cover +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" }, + }, +]; + +const EXAMPLE_VALUES: Record = { name: "Hue White A19", ean: "4006381333931", "light.dimming": "true", @@ -57,8 +98,9 @@ const parseDeclaration = ( }; export class EditPlayground extends LitElement { - @state() private _presets: Declaration[] = []; - @state() private _presetId = ""; + @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; @@ -75,11 +117,8 @@ export class EditPlayground extends LitElement { // `invalid` doesn't bubble, capture it to reflect submit attempts this.addEventListener("invalid", this._refresh, true); void getDeclarations().then(({ declarations }) => { - this._presets = declarations; - const [first] = declarations; - if (typeof first !== "undefined") { - this._loadPreset(first); - } + this._examples = [...declarations, ...EXTRA_EXAMPLES]; + this._loadEditor(this._editor); }); } @@ -88,12 +127,26 @@ export class EditPlayground extends LitElement { this.removeEventListener("invalid", this._refresh, true); } - private _loadPreset(declaration: Declaration): void { - this._presetId = declaration.id; + 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 = PRESET_VALUES[declaration.id] ?? ""; + this._initialValue = EXAMPLE_VALUES[declaration.id] ?? ""; this._submitted = undefined; this._refresh(); } @@ -114,11 +167,15 @@ export class EditPlayground extends LitElement { this._snapshot = snapshot(field); } - private _onPreset = (e: Event): void => { + 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._presets.find((preset) => preset.id === id); + const declaration = this._examples.find((example) => example.id === id); if (typeof declaration !== "undefined") { - this._loadPreset(declaration); + this._loadExample(declaration); } }; @@ -163,16 +220,25 @@ export class EditPlayground extends LitElement { void Promise.resolve().then(this._refresh); }; - private _renderPresets() { + private _renderSelect( + label: string, + value: string, + options: { value: string; label: string }[], + onChange: (e: Event) => void, + ) { return html`