import { css, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { EN_FIELD_LABELS } from '../generated/field-labels-en.js'; import { apiLang, readsEnglish } from '../i18n/lang.js'; import { RoxyLocalizedElement } from '../i18n/localized-element.js'; import { signGlyph } from '../tokens/index.js'; import type { SearchCitiesResponse } from '../types/index.js'; import { baseStyles } from '../utils/base-styles.js'; import { customElement } from '../utils/define.js'; import { chevron, disclosureStyles } from '../utils/disclosure.js'; import { type ApiIssue, type ApiRoute, canFetch, } from '../utils/fetch-controller.js'; import { buildFormModel, deriveGroupLabel, deriveSubmitLabel, type FieldDef, type FieldOption, type FormGroup, type FormModel, isIndex, isZodiacEnum, type OpenApiDoc, type OperationSchema, optionKind, parseRepeatGroup, type RepeatDef, readsNow, repeatFields, repeatGroup, sliceBase, sliceFileName, translatedFieldLabel, withRenderIntent, } from '../utils/field-schema.js'; import { viewerTimeZone } from '../utils/format.js'; import { dispatchKeyRefusal, keyRefusal } from '../utils/key-guard.js'; import { displayField, displayOption } from '../utils/localized.js'; import { OptionPicker, pickerStyles, readOptions, renderPicker, } from '../utils/option-picker.js'; import { ROXY_UI_VERSION } from '../version.js'; /** Production spec, fetched when no slice is available and no explicit `spec-url` is set. */ const PROD_SPEC_URL = 'https://roxyapi.com/api/v2/openapi.json'; /** The digested per-operation schema slices, read beside the script that loaded this module, so a slice always matches the bundle it ships with. */ const SLICE_BASE = sliceBase( ROXY_UI_VERSION, (globalThis.document?.currentScript as HTMLScriptElement | null)?.src || undefined, ); const specCache = new Map>(); /** * A spec fetch that came back with a non-2xx status, carrying the STATUS rather than a sentence. * * @remarks * The visible wording is composed by the element, in the element's language, and never here: {@link loadSpec} shares one cached promise across every form on the page, so a message built at throw time would freeze the first mounter's language onto every later one. Same split as everywhere else in this library, the machine value travels and the display copy is made where the reader is. */ class SpecHttpError extends Error { constructor(readonly status: number) { super(`HTTP ${status}`); } } /** The endpoint is absent from the document that was loaded. Carries the two parts the message names, for the reason {@link SpecHttpError} gives. */ class SpecMissingOperationError extends Error { constructor( readonly method: string, readonly path: string, ) { super(`Endpoint ${method} ${path} not found in OpenAPI spec`); } } /** * Drop every cached spec promise. * @remarks * Test-only, and deliberately NOT re-exported from the package entry. The cache is module state shared by every test file in a single `bun test` process, and it only self-evicts on rejection, so one file that resolves a spec leaves a warm entry that silently satisfies a later file assertion about fetching. That made slice-fallback coverage pass or fail purely on file order. * @internal */ export function resetSpecCache(): void { specCache.clear(); } async function loadSpec(url: string): Promise { let pending = specCache.get(url); if (!pending) { pending = fetch(url) .then(async (res) => { if (!res.ok) throw new SpecHttpError(res.status); return (await res.json()) as OpenApiDoc; }) .catch((err) => { // Evict the rejected promise BEFORE rethrowing so subsequent // callers (the user clicking Retry, a remount) hit the network // again instead of replaying the cached failure forever. specCache.delete(url); throw err; }); specCache.set(url, pending); } return pending; } /** A fresh seed for a hidden `seed` field, so each submit is a new draw unless the caller pinned one. */ function randomSeed(): string { return ( globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) ); } /** True when the spec describes a place field as where someone was born, as opposed to where an observer or a reader stands. */ const isBirthPlace = (f?: FieldDef): boolean => /\bbirth/i.test(f?.description ?? ''); /** True when a grouped field lives inside its group on the wire (`person1.date`), false when the group is only how the form shows it and the key is a flat property (`birthLatitude`). */ const isNested = (f: FieldDef): boolean => f.key === `${f.group}.${f.name}`; /** The city `` reports on `roxy-location-select`. */ type PickedCity = SearchCitiesResponse['cities'][number]; /** Parse an array-field text value into a real array: JSON when it parses to one, else comma-separated tokens. */ function parseArrayValue(raw: string): unknown { const trimmed = raw.trim(); if (!trimmed) return []; try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) return parsed; } catch { // Not JSON: fall through to comma splitting. } return trimmed .split(',') .map((s) => s.trim()) .filter(Boolean); } /** * Schema-driven form. Pass `endpoint` (e.g. "vedic-astrology/birth-chart"). The form digests the operation's request schema into a spec-derived input for each parameter (a zodiac tile grid, a date or time input, a city search, a toggle) with progressive disclosure, and emits a `roxy-submit` CustomEvent carrying the validated payload. The caller decides what to do (call the SDK, render a chart, navigate). * * @remarks * Schema resolution order: an explicit `spec-url` fetches that full spec and digests it (unchanged, the demo path); otherwise the form tries a small version-pinned per-operation slice from the CDN, and on any miss falls back to fetching the production spec. Each input kind is chosen purely from the parameter shape (see {@link ../utils/field-schema.ts}), so a new endpoint gets a working, on-brand form with no per-endpoint code. * * The visitor-facing `lang` parameter is never rendered: a site owner sets the element `lang` attribute, and the form routes it to the query string on submit. Optional parameters collapse under one Advanced disclosure behind the required ones; when nothing is required, every group and every undefaulted parameter renders in the open and only the defaulted ones collapse, so a request the schema can only describe as one of two optional shapes still shows its inputs. A form whose only required field is an enum submits on selection. * * **Two languages are in play here and they are different answers.** {@link RoxyLocalizedElement.effectiveLang} is the DISPLAY tag, region included, and it is what every `t()` call and the city search read. {@link RoxyEndpointForm.requestLang} is the WIRE value, region stripped and unsupported languages omitted, and it is what reaches `?lang=`. Swapping them is silent in both directions: the request one demotes every regional visitor, and the display one is a 400. * * **Field labels and option text ARE translated now, and they arrive over the network rather than from a catalogue.** They are keyed by the WIRE NAME the spec uses, not by the English text, because that English text is itself computed here by `humanize()`: there is no constant for a source-keyed catalogue to key on, which is why the chrome catalogue could never reach them. {@link RoxyEndpointForm.fieldText} and {@link RoxyEndpointForm.optionText} read a map baked into the per-language payload at build time and published through the same registry the chrome strings use. It rides the `locales/{lang}.js` download a translated page already makes, so it costs no extra request, needs no network at render time, and cannot flash English before correcting itself. * * **`humanize()` remains, as the fallback, and that is what makes this safe.** The payload is cosmetic and never awaited: a form draws immediately, an offline page or a blocked request renders exactly what it rendered before labels existed, and a language with no translation for a given key falls back to English server-side. There is no state in which a missing label breaks a form. Operation SUMMARIES and field DESCRIPTIONS are deliberately NOT in that map: a description is developer-facing reference prose, most of it collapsed behind a disclosure, and the widget already states the same contract language-neutrally through its input type and bounds. GROUP names stay catalogued rather than fetched, because the spec has nine of them: see {@link RoxyEndpointForm.groupName}. */ @customElement('roxy-endpoint-form') export class RoxyEndpointForm extends RoxyLocalizedElement { static styles = [ baseStyles, disclosureStyles, pickerStyles, css` form { display: grid; /* Never an implicit auto column: it floors at min-content, and a number * input's intrinsic width is wider than a phone card, so two fields * would sit side by side past the edge instead of stacking. */ grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-md, 1rem); background: var(--roxy-surface, #fff); color: var(--roxy-fg, #0a0a0a); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-lg, 1.5rem); box-shadow: var(--roxy-shadow-sm); } .title { margin: 0; font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); } .fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); align-items: start; gap: var(--roxy-space-md, 1rem); } .fields:empty { display: none; } .person-group { background: var(--roxy-surface, #fff); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-md, 1rem); margin: 0; display: grid; gap: var(--roxy-space-md, 1rem); min-inline-size: 0; } .person-group legend { padding: 0 var(--roxy-space-xs, 0.25rem); font-weight: var(--roxy-weight-bold, 600); } .field { display: flex; flex-direction: column; gap: var(--roxy-space-xs, 0.25rem); min-width: 0; } /* A grid item floors at its content's intrinsic width unless told otherwise, and * a native number or date input is wider than half a phone card. */ .fields > * { min-width: 0; } .field > input, .field > select { min-width: 0; } .tiles-field { grid-column: 1 / -1; } label, .label { font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-secondary, #475569); } .req { color: var(--_danger-fg); margin-left: 4px; } input, select { width: 100%; box-sizing: border-box; padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); font-size: var(--roxy-text-base, 1rem); font-family: inherit; color: var(--roxy-fg, #0a0a0a); background: var(--roxy-bg, #fff); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); } input:focus, select:focus { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; border-color: var(--roxy-accent-ink, #b45309); } .help { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); } .field-error { color: var(--_danger-fg); font-size: var(--roxy-text-xs, 0.75rem); } input[aria-invalid='true'], select[aria-invalid='true'] { border-color: var(--roxy-danger, #dc2626); } /* A field another choice rules out reads like a ruled-out tile. */ input:disabled, select:disabled { cursor: default; color: var(--roxy-muted, #71717a); border-style: dashed; } /* Long descriptions collapse: the summary shows the first line, the body the rest. */ .help-details > summary { display: flex; align-items: center; gap: 4px; cursor: pointer; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); } .help-details .help-lead { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .help-details .help-full { display: block; margin-top: var(--roxy-space-xs, 0.25rem); } .location-block { display: grid; gap: var(--roxy-space-xs, 0.25rem); grid-column: 1 / -1; } .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); gap: var(--roxy-space-sm, 0.5rem); } .tile { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; min-height: 44px; padding: var(--roxy-space-sm, 0.5rem); background: var(--roxy-bg, #fff); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); color: var(--roxy-fg, #0a0a0a); font-family: inherit; font-size: var(--roxy-text-sm, 0.875rem); cursor: pointer; transition: border-color var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, ease), background-color var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, ease); } .tile:hover:not(:disabled) { border-color: var(--roxy-accent, #f59e0b); } .tile:disabled { cursor: default; color: var(--roxy-muted, #71717a); border-style: dashed; } .tiles.narrow { grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr)); gap: var(--roxy-space-xs, 0.25rem); } .tile[aria-checked='true'] { border-color: var(--roxy-accent, #f59e0b); background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 12%, transparent); color: var(--roxy-accent-ink, #b45309); font-weight: var(--roxy-weight-bold, 600); } .tile:focus-visible { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; } .tile-glyph { font-size: 1.35em; line-height: 1; } .toggle-row { display: flex; align-items: center; gap: var(--roxy-space-sm, 0.5rem); } .toggle { position: relative; flex-shrink: 0; width: 44px; height: 26px; padding: 0; border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-full, 9999px); background: var(--roxy-border, #e4e4e7); cursor: pointer; transition: background-color var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, ease); } .toggle[aria-checked='true'] { background: var(--roxy-accent, #f59e0b); border-color: var(--roxy-accent, #f59e0b); } .toggle .knob { position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: var(--roxy-bg, #fff); transition: transform var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, ease); } .toggle[aria-checked='true'] .knob { transform: translateX(18px); } .toggle:focus-visible { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; } .toggle-label { cursor: pointer; } @media (prefers-reduced-motion: reduce) { .tile, .toggle, .toggle .knob { transition: none; } } .advanced { border-top: 1px solid var(--roxy-border, #e4e4e7); padding-top: var(--roxy-space-md, 1rem); } .advanced > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-fg, #0a0a0a); } .advanced[open] > summary { margin-bottom: var(--roxy-space-md, 1rem); } .repeat-controls { display: flex; gap: var(--roxy-space-sm, 0.5rem); } .repeat-btn { background: transparent; color: var(--roxy-accent-ink, #b45309); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-xs, 0.25rem) var(--roxy-space-md, 1rem); font: inherit; font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); cursor: pointer; } .repeat-btn:hover { border-color: var(--roxy-accent-ink, #b45309); } .advanced .fields, .advanced .person-group { margin-top: var(--roxy-space-md, 1rem); } .validation-error { display: grid; gap: var(--roxy-space-xs, 0.25rem); padding: var(--roxy-space-md, 1rem); background: color-mix(in srgb, var(--roxy-danger, #dc2626) 8%, transparent); border: 1px solid var(--roxy-danger, #dc2626); border-radius: var(--roxy-radius-md, 8px); color: var(--_danger-fg); font-size: var(--roxy-text-sm, 0.875rem); } button.submit { justify-self: start; min-height: 44px; background: var(--roxy-accent-ink, #b45309); color: var(--roxy-bg, #fff); border: 0; border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-lg, 1.5rem); font-size: var(--roxy-text-base, 1rem); font-weight: var(--roxy-weight-bold, 600); cursor: pointer; transition: transform var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1)); } button.submit:hover { transform: scale(1.02); } button.submit:focus-visible { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { button.submit { transition: none; } } .spec-error { display: grid; gap: var(--roxy-space-md, 1rem); justify-items: start; background: var(--roxy-surface, #fff); border: 1px solid var(--roxy-danger, #dc2626); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-lg, 1.5rem); color: var(--_danger-fg); font-size: var(--roxy-text-sm, 0.875rem); } `, ]; @property({ type: String, attribute: 'data-endpoint' }) endpoint = 'vedic-astrology/birth-chart'; @property({ type: String }) method: 'GET' | 'POST' = 'POST'; /** Explicit OpenAPI spec URL. When set, the form fetches and digests that full spec (the demo path). When empty, it resolves a version-pinned slice first, then the production spec. */ @property({ type: String, attribute: 'spec-url' }) specUrl = ''; /** Override the submit-button label. Empty derives an outcome-first label from the endpoint. */ @property({ type: String, attribute: 'submit-label' }) submitLabel = ''; /** Browser-safe publishable key, forwarded to the slotted city search so the natal or synastry form can geocode; a key the guard refuses replaces the inputs with the reason. */ @property({ type: String, attribute: 'publishable-key' }) publishableKey?: string; /** Where the slotted city search sends its GET, absolute or relative to the page. Unset, the search goes through the proxy route a component hands this form, else to the public location endpoint. */ @property({ type: String, attribute: 'location-url' }) locationUrl?: string; /** Prefill values, keyed by field name (nested per group). Used by `remember` mode and to restore the previous submission. JS property only. */ @property({ attribute: false }) initialValues?: Record; /** * The fields the API rejected on the last submit, by wire path, as the host's fetch reported them. JS property only. * * @remarks * The wire path is the same identity a field is keyed by (`year`, `person1.date`, `members.0.date`, `birthLatitude`), so each issue prints under the input it names and the input is marked invalid; an issue on a whole group (`members.0`) prints on that card, and one the form cannot place prints in the summary alone. Editing a field clears its issue, so a corrected value is not still marked wrong. */ @property({ attribute: false }) serverIssues: ApiIssue[] | null = null; /** Where the choices for a field are read through, handed in by a component in self-fetch mode so a proxied page reads them through its own route; a placed form reads them with its own publishable key. JS property only. */ @property({ attribute: false }) apiRoute?: ApiRoute; /** Submit once on load when the request needs nothing from the visitor, set by a component in self-fetch mode so a list or today's reading opens on its result. JS property only. */ @property({ attribute: false }) autoload = false; /** Ask a hidden `limit` for the largest page the API serves rather than its default, set by a list component that pages its own rows. JS property only. */ @property({ attribute: false }) fullPages = false; /** The choices read for each optional field the API lists values for, or the read in flight; absent when they could not be read, and the field keeps its plain input. */ @state() private choices: Record = {}; /** One search-as-you-type picker per required free-string identifier, keyed by field. */ private pickers = new Map(); /** Issues the visitor has since edited away, by wire path. */ @state() private clearedIssues = new Set(); @state() private fields: FieldDef[] = []; private groups: FormGroup[] = []; private modelOpensOnLoad = false; /** The array-of-object request properties, each a set of cards the visitor can grow up to `max` and shrink back to `min`. */ @state() private repeats: RepeatDef[] = []; @state() private formTitle = ''; @state() private hasLang = false; @state() private values: Record = {}; @state() private loaded = false; @state() private specError: string | null = null; @state() private validationErrors: string[] = []; connectedCallback(): void { super.connectedCallback(); void this.loadSchema(); } private async loadSchema() { this.specError = null; try { this.applyModel(await this.resolveModel()); } catch (err) { // The banner reads in the page language; the event keeps the canonical // English, because a listener is code and a developer console is not a // localized surface. this.specError = this.specErrorMessage(err); this.loaded = true; this.dispatchEvent( new CustomEvent('roxy-spec-error', { detail: { url: this.specUrl || PROD_SPEC_URL, message: err instanceof Error ? err.message : String(err), }, bubbles: true, composed: true, }), ); } } /** The failure sentence a visitor reads. Composed here rather than at the throw, so the shared spec-cache promise cannot freeze one form's language onto another. A message from anywhere else (a browser network error) is a wire fact and passes through untranslated. */ private specErrorMessage(err: unknown): string { if (err instanceof SpecHttpError) return this.t('HTTP error {{status}}', { status: err.status }); if (err instanceof SpecMissingOperationError) return this.t('Endpoint {{method}} {{path}} not found in OpenAPI spec', { method: err.method, path: err.path, }); return err instanceof Error ? err.message : String(err); } /** Resolve the form model: explicit spec-url wins; else a version-pinned slice; else the production spec. */ private async resolveModel(): Promise { if (this.specUrl) return this.modelFromSpec(this.specUrl); const slice = await this.tryLoadSlice(); return slice ?? this.modelFromSpec(PROD_SPEC_URL); } /** Fetch and digest one operation from a full OpenAPI document. */ private async modelFromSpec(url: string): Promise { const spec = await loadSpec(url); const path = `/${this.endpoint.replace(/^\//, '')}`; const op = spec.paths?.[path]?.[this.method.toLowerCase()] as | OperationSchema | undefined; if (!op) throw new SpecMissingOperationError(this.method, path); return buildFormModel( op, spec.components?.schemas ?? {}, this.endpoint, this.method, ); } /** Try the small precomputed slice; return null on any miss so the caller falls back to the full spec. */ private async tryLoadSlice(): Promise { try { const res = await fetch( `${SLICE_BASE}/${sliceFileName(this.method, this.endpoint)}`, ); if (!res.ok) return null; return (await res.json()) as FormModel; } catch { return null; } } private applyModel(model: FormModel) { // Older slices omit render intent; normalize through the same pure model builder. if (!model.groups || model.opensOnLoad === undefined) model = withRenderIntent(model, this.method, this.endpoint); this.fields = model.fields; this.groups = model.groups ?? []; this.modelOpensOnLoad = model.opensOnLoad ?? false; this.repeats = model.repeats ?? []; this.formTitle = model.title; this.hasLang = model.hasLang; // Pre-fill: schema defaults first, then any remembered/initial values (only // for fields this endpoint has) so a stale stored payload from another // endpoint cannot inject unknown keys. Remembered values are nested per // group, so read them by group. const init: Record = {}; for (const f of model.fields) { // A viewer-zone field stays empty so the submit can tell the API default from a choice. const seed = f.viewerZone ? undefined : this.fullPages && f.name === 'limit' && this.roleOf(f) === 'hidden-default' ? (f.max ?? f.default) : f.default; if (seed !== undefined) init[f.key] = seed; const remembered = f.group && isNested(f) ? ( this.initialValues?.[f.group] as | Record | undefined )?.[f.name] : this.initialValues?.[f.key]; if (remembered !== undefined) init[f.key] = remembered; } this.values = this.withoutDisabled(init); this.loaded = true; this.loadChoices(); if (this.autoload && this.opensOnLoad && canFetch(this.route)) void this.updateComplete.then(() => this.submit()); } /** The route the choices are read through. */ private get route(): ApiRoute { return this.apiRoute ?? { publishableKey: this.publishableKey }; } /** Read the choices of every sourced field when this form can reach the API: a picker for a required identifier, a list read once for an optional filter. */ private loadChoices() { for (const old of this.pickers.values()) this.removeController(old); this.pickers.clear(); if (!canFetch(this.route)) return; const context = () => ({ route: this.route, lang: this.requestLang() }); for (const f of this.fields) { if (!f.source) continue; if (f.required) { const picker = new OptionPicker(this, f.source, f.name, context, (o) => this.pickChoice(f, o.value), ); picker.query = String(this.values[f.key] ?? ''); this.pickers.set(f.key, picker); continue; } this.choices = { ...this.choices, [f.key]: 'loading' }; readOptions(this.route, f.source, f.name, this.requestLang()).then( (options) => { this.choices = { ...this.choices, [f.key]: options }; }, () => { const { [f.key]: _, ...rest } = this.choices; this.choices = rest; }, ); } } private retryLoadSchema = () => { this.loaded = false; this.specError = null; void this.loadSchema(); }; private setValue(name: string, value: unknown) { this.setValues({ [name]: value }); } /** Writes several values at once and clears the issue on each one the API named. */ private setValues(patch: Record) { this.values = this.withoutDisabled({ ...this.values, ...patch }); const edited = Object.keys(patch).filter((k) => this.serverIssues?.some((i) => i.path === k), ); if (edited.length) this.clearedIssues = new Set([...this.clearedIssues, ...edited]); } /** True while another field holds the value this field waits on, so it takes no input and sends nothing. */ private isDisabled(f: FieldDef, values = this.values): boolean { const when = f.disabledWhen; return !!when && values[when.field] === when.value; } /** The values with every disabled field dropped, so a choice made before it was disabled is never sent. */ private withoutDisabled(values: Record) { for (const f of this.fields) if (this.isDisabled(f, values)) values[f.key] = undefined; return values; } /** The live issues: what the API reported, less what has been edited since. A new report resets the edits. */ protected willUpdate(changed: Map): void { if (changed.has('serverIssues')) this.clearedIssues = new Set(); const refusal = changed.has('publishableKey') ? keyRefusal(this.publishableKey) : undefined; if (refusal) dispatchKeyRefusal(this, refusal, { warn: true }); } private liveIssues(): ApiIssue[] { return (this.serverIssues ?? []).filter( (i) => !this.clearedIssues.has(i.path), ); } /** The issue on exactly this field, if the API named it. */ private issueFor(f: FieldDef): ApiIssue | undefined { return this.liveIssues().find((i) => i.path === f.key); } /** The issues on the coordinates and timezone a group's city box writes, which have no input of their own to sit under. */ private cityIssues(group?: string): ApiIssue[] { return this.liveIssues().filter((i) => { const f = this.fields.find((x) => x.key === i.path); return ( !!f && this.roleOf(f) === 'location' && this.cityGroup(f) === group ); }); } /** The issues on a whole group card (`members.0`), as opposed to one of its fields. */ private issuesForGroup(group: string): ApiIssue[] { return this.liveIssues().filter((i) => i.path === group); } /** * A field named the way its input is labelled on the form, for a message that sends the visitor back to it. * * @remarks * The group half translates and the field half cannot, which is not a half-measure: this names a block the visitor has to go back to, so the word in the message has to be the word printed on the fieldset legend. A message saying `Person 1` over a legend reading `Persona 1` points at nothing. */ private fieldLabel(f: FieldDef): string { if (this.roleOf(f) === 'location' || f.city) return this.locationLabel(this.cityGroup(f)); return f.group ? `${this.groupName(f.group)} ${this.fieldText(f)}` : this.fieldText(f); } /** The issues the API reported on a whole card or a city box, printed under it. */ private issueLines(issues: ApiIssue[]) { return issues.map( (i) => html`${i.message}`, ); } /** The inline message under an input, and the attributes that tie it to the input for assistive tech. */ private fieldIssue(f: FieldDef) { const issue = this.issueFor(f); return issue ? html`${issue.message}` : nothing; } /** * The summary of what the API rejected: one line per issue, the field named by * its label on the form rather than by its wire path, so the reader is sent to * the input they can see. The message is the API's own and prints as sent. */ private renderIssueSummary() { const issues = this.liveIssues(); if (issues.length === 0) return nothing; const labelFor = (path: string): string => { const field = this.fields.find((f) => f.key === path); if (field) return this.fieldLabel(field); const group = this.groupKeys().find((g) => g === path); if (group) return this.groupName(group); return this.fieldText({ name: path.split('.')[0] ?? path }); }; return html``; } /** The field's render role: a hidden autogenerated seed, a hidden defaulted limit/offset, a suppressed location coordinate, or a normally rendered input. */ private roleOf( f: FieldDef, ): 'seed' | 'hidden-default' | 'location' | 'normal' { return f.role ?? 'normal'; } /** True when the model assigns the field its own input. */ private isRendered(f: FieldDef): boolean { return this.roleOf(f) === 'normal'; } /** Groups in the model's render order, including the flat top level. */ private groupKeys(): (string | undefined)[] { return this.groups.map((g) => g.key); } /** The city box assigned to this group by the model. */ private groupHasLocation(group?: string): boolean { return !!this.groups.find((g) => g.key === group)?.location; } /** The group whose city box fills this field, including a claimed flat timezone. */ private cityGroup(f: FieldDef): string | undefined { return ( this.groups.find((g) => g.location?.fields.includes(f.key))?.key ?? f.group ); } /** The field whose spec description says what place a group's city box asks for: its latitude, or a timezone standing alone. */ private placeField(group?: string): FieldDef | undefined { const inGroup = this.fields.filter((f) => f.group === group); return ( inGroup.find((f) => f.name === 'latitude') ?? inGroup.find((f) => f.name === 'timezone') ); } /** Whether the model marks this city box required. */ private locationRequired(group?: string): boolean { return ( this.groups.find((g) => g.key === group)?.location?.required ?? false ); } /** * The single visible required choice, when the whole form reduces to exactly one: no location block, no named group, one required rendered enum or picker. This is what turns the horoscope form into a tap-to-load sign grid and the dream card into type, pick, read; returns null otherwise. */ private get soleChoice(): FieldDef | null { if (this.groupKeys().some((g) => this.groupHasLocation(g))) return null; if (this.groupKeys().some((g) => g !== undefined)) return null; const req = this.fields.filter((f) => f.required && this.isRendered(f)); const only = req[0]; if (req.length !== 1 || !only) return null; return only.kind === 'tiles' || only.kind === 'select' || this.pickers.has(only.key) ? only : null; } /** True when this form opens on its result, so it stays above the result as a filter bar. */ private get opensOnLoad(): boolean { return this.modelOpensOnLoad; } /** True for an optional filter on a form that opens on load: a listed choice that submits when picked and clears the other filters. */ private isFilter(f: FieldDef): boolean { return this.opensOnLoad && !f.required && !!(f.enum || f.source); } /** True when every input on the form sends it by itself (a sole choice, or a filter bar with nothing typed), so it draws no submit button. */ private get selfSubmits(): boolean { if (this.soleChoice) return true; if (!this.opensOnLoad) return false; const shown = this.fields.filter((f) => this.isRendered(f)); return shown.length > 0 && shown.every((f) => this.isFilter(f)); } /** Tiles or a select for a choice field: by the shape of its choices, except that a filter of words is a select, so a list that opens on load keeps its filters to one compact row and only an index (letters, short codes) stays a tile bar. */ private choiceKind(f: FieldDef): 'tiles' | 'select' { const values = this.choicesOf(f).map((o) => o.value); if (this.isFilter(f) && !isIndex(values)) return 'select'; // A list the API served is classed here; an enum keeps the kind its schema gave it. return this.choices[f.key] ? optionKind(values) : (f.kind as 'tiles' | 'select'); } /** The choices a field offers: its enum with the published option text, or the list the API served for it. */ private choicesOf(f: FieldDef): FieldOption[] { const listed = this.choices[f.key]; if (Array.isArray(listed)) return listed; return (f.enum ?? []).map((value) => ({ value, label: this.optionText(f.name, value), })); } /** The submit-button label. A caller-supplied one is theirs and is printed verbatim; the derived verb is ours, so it goes through the catalogue. {@link deriveSubmitLabel} returns the canonical English verb, which IS the catalogue key; an empty moment with a clock reads as now. */ private effectiveSubmitLabel(): string { if (this.submitLabel) return this.submitLabel; return readsNow(this.fields, this.values) ? this.t('Show right now') : this.t(deriveSubmitLabel(this.endpoint, this.fields)); } /** * The value this form may put on `?lang=`, or undefined to leave the request on the API default. * * @remarks * NOT the display language, and the name says so: `effectiveLang()` on the base class is the full display tag every `t()` reads. `?lang=es-AR` is a 400 and `?lang=es` is a 200, and the language resolves from `` rather than only from an attribute a developer typed, so the unfiltered tag reaching the query string would break every regional and every untranslated locale. {@link apiLang} does both narrowings; this method exists so the submit path reads as one call. */ private requestLang(): string | undefined { return apiLang(this); } /** * The words this form writes over a request field, and the options under it. * * @remarks * `humanize()` is the FALLBACK, not the default, and it stays because it has to: the labels * arrive over the network and a form must draw before they land, on an offline page, and when * the request fails. So the worst case is exactly what this form rendered before labels * existed, and the best case is a translated one, with no state in between where it breaks. * * Keyed by WIRE NAME rather than by the English text, because the English text is itself * computed here by `humanize()`; there is no constant to key on. Same reason the label map is * a separate key space from the chrome catalogue. * * English ships no payload, so the API English label is compiled in and comes before `humanize()`. * A contextual model label (a picker noun, a transit date) reads its translation through `translatedFieldLabel`. */ private fieldText( f: Pick & Partial>, ): string { const lang = this.requestLang(); return f.label ? (translatedFieldLabel(lang, f) ?? f.label) : displayField(lang, f.name, EN_FIELD_LABELS[f.name]); } /** The option text under one field, falling back the same way. */ private optionText(field: string, value: string): string { return displayOption(this.requestLang(), field, value); } /** * Location-select handler bound to a group: fills that group's coordinate keys plus whichever * timezone field it owns. * * Keys are LOOKED UP from the model rather than built as `group.name`. That assumption held only * while every group came from object nesting; a prefixed group stores under the original wire * name (`birthLatitude`), so constructing `birth.latitude` would write a key the request builder * never reads and the coordinates would silently stay empty. */ private onLocationFor(group?: string) { return (city: PickedCity) => { const keys = this.groups.find((g) => g.key === group)?.location?.fields ?? []; const patch: Record = {}; for (const key of keys) { const name = this.fields.find((f) => f.key === key)?.name; if (name) patch[key] = city[name as keyof PickedCity]; } this.setValues(patch); }; } /** Records a chosen option, and submits at once when it is the only input the form has or a filter; picking the chosen filter again clears it. */ private chooseOption(f: FieldDef, value: string) { if (this.isFilter(f)) { this.setFilter(f, this.values[f.key] === value ? '' : value); this.submit(); return; } this.pickChoice(f, value); } /** Records a value, then sends a form that draws no submit button once its one choice is made. */ private pickChoice(f: FieldDef, value: unknown) { this.setValue(f.key, value); this.resend(); } /** A form that reduces to one choice sends itself on that pick and again on every later change to another input, carrying the choice it holds. */ private resend() { const sole = this.soleChoice; if (sole && this.values[sole.key]) this.submit(); } /** Sets one filter and clears the others and the text search, so a list is narrowed one way at a time. */ private setFilter(f: FieldDef, value: unknown) { const patch: Record = { [f.key]: value }; for (const other of this.fields) if ( other !== f && (this.isFilter(other) || (this.opensOnLoad && other.kind === 'text' && this.isRendered(other))) ) patch[other.key] = undefined; this.setValues(patch); } /** Roving-tabindex arrow-key navigation for a tile radiogroup, modeled on the shared tablist pattern (selection follows focus). */ private onTilesKeyDown(f: FieldDef) { return (e: KeyboardEvent) => { const opts = this.choicesOf(f) .filter((o) => !o.disabled) .map((o) => o.value); if (opts.length === 0) return; const cur = opts.indexOf(this.values[f.key] as string); let next: number; if (e.key === 'Home') next = 0; else if (e.key === 'End') next = opts.length - 1; else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = cur === -1 ? 0 : (cur + 1) % opts.length; else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = cur === -1 ? opts.length - 1 : (cur - 1 + opts.length) % opts.length; else return; e.preventDefault(); const value = opts[next]; if (value === undefined) return; this.chooseOption(f, value); const root = e.currentTarget as HTMLElement; const tile = this.choicesOf(f).findIndex((o) => o.value === value); requestAnimationFrame(() => root.querySelector(`[data-tile='${tile}']`)?.focus(), ); }; } private onSubmit = (e: Event) => { e.preventDefault(); this.submit(); }; /** Validate, then emit `roxy-submit` with the reconstructed payload, the spec query keys, and whether the form stays above its result. */ private submit() { const missing = this.collectMissing(); if (missing.keys.length > 0) { this.validationErrors = missing.labels; this.dispatchEvent( new CustomEvent('roxy-validation-error', { detail: { missing: missing.keys }, bubbles: true, composed: true, }), ); return; } this.validationErrors = []; this.clearedIssues = new Set((this.serverIssues ?? []).map((i) => i.path)); const out: Record = {}; for (const f of this.fields) { let v = this.values[f.key]; // A hidden seed autogenerates per submit unless a value was remembered. if (f.name === 'seed' && (v === undefined || v === '')) v = randomSeed(); if (f.viewerZone && (v === undefined || v === '')) v = viewerTimeZone() ?? f.default; if (v === undefined || v === '') continue; if (f.kind === 'array' && typeof v === 'string') v = parseArrayValue(v); const repeat = f.group ? parseRepeatGroup(f.group) : null; if (repeat) { // A repeating group serialises as one object per record, in record order. const list = (out[repeat.key] as Record[] | undefined) ?? []; const g = list[repeat.index] ?? {}; g[f.name] = v; list[repeat.index] = g; out[repeat.key] = list; } else if (f.group && isNested(f)) { const g = (out[f.group] as Record | undefined) ?? {}; g[f.name] = v; out[f.group] = g; } else { // The key is the wire identity: a prefixed coordinate (`birthLatitude`) is // grouped for the form and flat on the wire. out[f.key] = v; } } // The spec knows which parameters belong in the query string; the listener // does not. Report them (including a site-owner lang) so a POST with a query // parameter reaches the API correctly instead of being dropped in the body. const queryKeys = this.fields.filter((f) => f.inQuery).map((f) => f.name); const lang = this.requestLang(); if (this.hasLang && lang) { out.lang = lang; queryKeys.push('lang'); } this.dispatchEvent( new CustomEvent('roxy-submit', { detail: { endpoint: this.endpoint, values: out, queryKeys, sticky: this.soleChoice != null || this.opensOnLoad, }, bubbles: true, composed: true, }), ); } /** Required fields left empty, with the location trio collapsed to one entry per group. Seed and hidden defaults never block. */ private collectMissing(): { keys: string[]; labels: string[] } { const keys: string[] = []; const labels: string[] = []; const locGroups = new Set(); for (const f of this.fields) { if (!f.required) continue; const isLocation = this.roleOf(f) === 'location'; if (!this.isRendered(f) && !isLocation) continue; const v = this.values[f.key]; if (v !== undefined && v !== '') continue; keys.push(f.key); if (isLocation) { locGroups.add(this.cityGroup(f)); continue; } labels.push(this.fieldLabel(f)); } for (const g of locGroups) { labels.push(this.locationLabel(g)); } return { keys, labels }; } /** True on a page that reads in English, the one language the operation summary and the field descriptions are written in. */ private get readsEnglish(): boolean { return readsEnglish(this.effectiveLang()); } /** The operation summary as the form heading, on an English page only: it is API reference copy, so another language draws no heading rather than an English one. */ private renderTitle() { return this.readsEnglish ? html`

${this.formTitle}

` : nothing; } private reqMark(f: FieldDef) { return f.required ? html`` : nothing; } /** * A description under a field: rendered inline when short, collapsed behind a disclosure showing its first line when long. * * @remarks * Both shapes carry `part="hint"` on their outermost element, so one `::part(hint)` rule reaches every field description on the form. The words are the API reference text for the request field, which is the right register on a developer page and the wrong one on a page written for a client, and hiding them leaves the label, the input and its validation untouched. They are English reference copy, so a page in another language draws none. */ private description(f: FieldDef) { if (!f.description || !this.readsEnglish) return nothing; if (f.description.length <= 120) { return html`${f.description}`; } const lead = f.description.split('. ')[0] ?? f.description; return html`
${lead}${chevron()} ${f.description}
`; } private renderTiles(f: FieldDef) { const labelId = `roxy-form-${f.key}-label`; const zodiac = !!f.enum && isZodiacEnum(f.enum); const opts = this.choicesOf(f); const narrow = isIndex(opts.map((o) => o.value)); const cur = opts.findIndex((o) => o.value === this.values[f.key]); const active = cur === -1 ? opts.findIndex((o) => !o.disabled) : cur; return html`
${this.fieldText(f)}${this.reqMark(f)}
${opts.map((opt, i) => { const selected = this.values[f.key] === opt.value; const glyph = zodiac ? signGlyph(opt.value) : undefined; return html``; })}
${this.description(f)} ${this.fieldIssue(f)}
`; } private renderSelect(f: FieldDef) { const id = `roxy-form-${f.key}`; return html`
${this.description(f)} ${this.fieldIssue(f)}
`; } /** A required identifier picked from its collection as the visitor types; the sole input on its form is named by the form title wherever one is drawn. */ private renderPickerField(f: FieldDef, picker: OptionPicker) { const id = `roxy-form-${f.key}`; const titled = this.soleChoice?.key === f.key && this.readsEnglish; const issue = this.issueFor(f); return html`
${titled ? nothing : html``} ${renderPicker( picker, id, { placeholder: this.t('Type to search'), empty: this.t('{{count}} matches', { count: 0 }), loading: this.t('Loading'), }, { labelledby: titled ? 'roxy-form-title' : undefined, describedby: issue ? `${id}-error` : undefined, invalid: !!issue, }, )} ${this.fieldIssue(f)}
`; } private renderToggle(f: FieldDef) { const id = `roxy-form-${f.key}`; const checked = this.values[f.key] === true; return html`
${this.description(f)} ${this.fieldIssue(f)}
`; } private renderInput(f: FieldDef) { const id = `roxy-form-${f.key}`; const type = f.kind === 'datetime' ? 'datetime-local' : f.kind === 'number' ? 'number' : f.kind; // A spec example in words is English reference copy, so only a numeric one shows on another page. const example = f.example != null ? String(f.example) : ''; const placeholder = f.kind === 'array' ? this.t('Comma separated') : this.readsEnglish || !/\p{L}/u.test(example) ? example : ''; return html`
{ const v = this.coerce(f.kind, (e.target as HTMLInputElement).value); if (this.opensOnLoad && f.kind === 'text') this.setFilter(f, v); else this.setValue(f.key, v); }} @change=${() => this.resend()} /> ${this.description(f)} ${this.fieldIssue(f)}
`; } private renderField(f: FieldDef) { // Only a timezone no coordinate pair claims reaches here; a claimed one is the location block's. if (f.city) return this.renderTimezoneCity(f); const picker = this.pickers.get(f.key); if (picker) return this.renderPickerField(f, picker); const listed = this.choices[f.key]; if (listed === 'loading') return html`
`; if (listed || f.kind === 'tiles' || f.kind === 'select') return this.choiceKind(f) === 'tiles' ? this.renderTiles(f) : this.renderSelect(f); switch (f.kind) { case 'toggle': return this.renderToggle(f); default: return this.renderInput(f); } } /** The shared group legend, using this form's chrome and field-label catalogues. */ private groupName(group: string): string { return deriveGroupLabel( group, (source, vars) => this.t(source, vars), (name) => this.fieldText({ name }), ); } /** How many records a repeating property currently shows. */ private repeatCount(key: string): number { return new Set( this.fields .map((f) => f.group && parseRepeatGroup(f.group)) .filter((r) => r && r.key === key) .map((r) => (r as { index: number }).index), ).size; } /** Appends a record with the same fields as the first, pre-filled with the schema defaults the first record was. */ private addRecord(key: string) { const index = this.repeatCount(key); const added = repeatFields(this.fields, key, index); const template = this.groups.find((g) => g.key === repeatGroup(key, 0)); if (template) { const rekey = (k: string) => k.replace(`${repeatGroup(key, 0)}.`, `${repeatGroup(key, index)}.`); this.groups = [ ...this.groups, { ...template, key: repeatGroup(key, index), label: deriveGroupLabel(repeatGroup(key, index)), ...(template.location && { location: { ...template.location, fields: template.location.fields.map(rekey), }, }), }, ]; } const next = { ...this.values }; for (const f of added) if (f.default !== undefined) next[f.key] = f.default; this.values = next; this.fields = [...this.fields, ...added]; } /** Drops the last record and every value it held, so a removed card cannot ride along in the body. */ private removeRecord(key: string) { const group = repeatGroup(key, this.repeatCount(key) - 1); this.fields = this.fields.filter((f) => f.group !== group); this.groups = this.groups.filter((g) => g.key !== group); const next = { ...this.values }; for (const k of Object.keys(next)) if (k.startsWith(`${group}.`)) delete next[k]; this.values = next; } /** The add and remove controls under the records of one repeating property, each only where the spec leaves room for it. */ private repeatControls(repeat: RepeatDef) { const count = this.repeatCount(repeat.key); if (repeat.max <= repeat.min) return nothing; return html`
${ count < repeat.max ? html`` : nothing } ${ count > repeat.min ? html`` : nothing }
`; } /** * The name of one location block, and of the same block in a validation message, which is why it is a method rather than two literals. * * The placeholder carries the group rather than the form concatenating a name in front of a translated noun, so a translator owns the word order: `Person 1 location` is English syntax, and Turkish suffixes the head noun, Hindi takes a genitive, Russian a parenthetical and German a colon. The four shapes are deliberately different and normalizing them breaks three languages. */ private locationLabel(group?: string): string { if (group) return this.t('{{group}} location', { group: this.groupName(group) }); return isBirthPlace(this.placeField(group)) ? this.t('Birth location') : this.t('Location'); } /** One city search standing in for a group's raw coordinates. */ private locationBlock(group?: string) { return html`
${this.citySearch(group, this.onLocationFor(group))} ${this.issueLines(this.cityIssues(group))}
`; } /** A `timezone` no coordinate pair claims, asked for as a city and labelled as the place: the IANA zone it writes resolves to the offset in force on the requested date. */ private renderTimezoneCity(f: FieldDef) { return html`
${this.citySearch(f.group, (city) => this.pickChoice(f, city.timezone))} ${this.fieldIssue(f)}
`; } /** The one city search every location input draws, its placeholder named for the place; `lang`, `endpoint` and the proxy route are forwarded because a shadow root hides the host page from it. */ private citySearch( group: string | undefined, onPick: (city: PickedCity) => void, ) { const placeholder = group ? this.t('{{group}} city', { group: this.groupName(group) }) : isBirthPlace(this.placeField(group)) ? this.t('City of birth') : undefined; return html`) => onPick(e.detail)} > ${this.t('Type a city, then pick it from the list.')}`; } private groupCard(group: string) { const fields = this.fields.filter( (f) => f.group === group && this.isRendered(f), ); return html`
${this.groupName(group)} ${this.groupHasLocation(group) ? this.locationBlock(group) : nothing}
${fields.map((f) => this.renderField(f))}
${this.issueLines(this.issuesForGroup(group))}
`; } render() { if (!this.loaded) { return html`
`; } if (this.specError) { return html``; } // A refused key can never send, so the reason stands in for inputs nobody could submit. const refusal = keyRefusal(this.publishableKey); if (refusal) { return html`
${this.renderTitle()}
`; } const flat = this.fields.filter((f) => !f.group && this.isRendered(f)); const named = this.groupKeys().filter((g): g is string => g !== undefined); const openFields = flat.filter((f) => !f.fold); const tuckedFields = flat.filter((f) => f.fold); const openGroups = named.filter( (key) => !this.groups.find((g) => g.key === key)?.fold, ); const tuckedGroups = named.filter( (key) => this.groups.find((g) => g.key === key)?.fold, ); const hasAdvanced = tuckedFields.length > 0 || tuckedGroups.length > 0; return html`
${this.renderTitle()} ${ this.validationErrors.length > 0 ? html`` : nothing } ${this.renderIssueSummary()}
${openFields.map((f) => this.renderField(f))}
${this.groupHasLocation(undefined) ? this.locationBlock(undefined) : nothing} ${openGroups.map((g) => this.groupCard(g))} ${this.repeats.map((r) => this.repeatControls(r))} ${ hasAdvanced ? html`
[...tuckedFields.map((f) => f.key), ...tuckedGroups].some( (k) => i.path === k || i.path.startsWith(`${k}.`), ), )} > ${this.t('Advanced')}${chevron()}
${tuckedFields.map((f) => this.renderField(f))}
${tuckedGroups.map((g) => this.groupCard(g))}
` : nothing } ${ this.selfSubmits ? nothing : html`` }
`; } private coerce(kind: string, v: string): unknown { if (v === '') return undefined; if (kind === 'number') { const n = Number(v); return Number.isFinite(n) ? n : undefined; } return v; } } declare global { interface HTMLElementTagNameMap { 'roxy-endpoint-form': RoxyEndpointForm; } }