Skip to content

Commit cba5ea6

Browse files
rameerezclaude
andcommitted
0.1.4 review fixes: deliberate Enter, cached logo misses, honest edges
Four review findings against the unpublished 0.1.4, each with a test that fails on the previous code: - Enter commits only a deliberate choice: an arrowed-to row, the single remaining match, or text naming a make exactly. Focus opens the full list, and Enter there used to commit the first row alphabetically, silently replacing whatever the person had already picked. Text that matches the committed selection falls through to a plain form submit. - A failed logo fetch is a cached miss for the pageview, not a retry: rows re-request logos on every render, so a misbehaving logo endpoint meant up to fifty requests per keystroke, forever. - Blur restores the committed text synchronously; a submit button clicked in the same tick can no longer carry a value whose visible text disagrees with it. - A server-preselected value missing from the fetched list announces itself: onChange fires with the cleared state and the dropped value is named in a warning, instead of the form quietly submitting empty. Also: sideEffects back to true (the UMD global assignment IS the side effect; false let bundlers tree-shake bare imports into runtime errors), consumer onChange exceptions log whole via console.error instead of masquerading as API failures in onError, the required-field validity message is configurable (requiredMessage), the selected-make chip no longer flickers on each pick, the README stops claiming the widget calls /search, and CI runs once per change instead of twice per PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
1 parent fe3183c commit cba5ea6

5 files changed

Lines changed: 167 additions & 34 deletions

File tree

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ name: Test
22

33
on:
44
push:
5+
branches: [main]
6+
tags: ["v*"]
57
pull_request:
68

79
permissions:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The vehicle identity data is [CC-BY 4.0](https://vehiclesdb.com/attribution) —
8585

8686
## The API underneath
8787

88-
The widget speaks the documented Dropdown Data endpoints[`GET /api/v1/makes`](https://vehiclesdb.com/api), `GET /api/v1/makes/{slug}/models`, `GET /api/v1/search`, and make logos — which cost 0 credits and accept publishable keys, so the dropdown never spends your quota. Public keys are deliberately refused everywhere else. Use a private `vdb_sk_…` key from your backend for the rest of the [VehiclesDB API](https://vehiclesdb.com/api): resolving free-text vehicle strings, full records, imagery, and license-plate validation.
88+
The widget speaks the documented Dropdown Data endpoints: [`GET /api/v1/makes`](https://vehiclesdb.com/api), `GET /api/v1/makes/{slug}/models`, and make logos. (`GET /api/v1/search` is part of the same free surface if you build your own typeahead.) They cost 0 credits and accept publishable keys, so the dropdown never spends your quota. Public keys are deliberately refused everywhere else. Use a private `vdb_sk_…` key from your backend for the rest of the [VehiclesDB API](https://vehiclesdb.com/api): resolving free-text vehicle strings, full records, imagery, and license-plate validation.
8989

9090
## Development
9191

make-model-dropdown.js

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
attribution: true, // "Vehicle data by VehiclesDB" line (CC-BY credit)
3131
placeholderMake: "Make",
3232
placeholderModel: "Model",
33+
requiredMessage: "Please select a vehicle make.", // validity message on a required make field
3334
onChange: null, // ({ make, model }) => {}
3435
onError: null // (error) => {}
3536
}
@@ -94,9 +95,11 @@
9495
if (response.status === 404) return null
9596
if (!response.ok) throw new Error(`VehiclesDB logo API answered ${response.status}`)
9697
return response.blob()
97-
})
98+
// ANY failure is a cached miss for the rest of the pageview: the letter
99+
// chip renders instead. Every keystroke re-renders up to 50 rows, so a
100+
// misbehaving logo endpoint retried per render would be a request storm.
101+
}).catch(() => null)
98102
logoCache.set(cacheKey, promise)
99-
promise.catch(() => logoCache.delete(cacheKey))
100103
return promise
101104
}
102105

@@ -213,6 +216,7 @@
213216
const data = await fetchJSON(`${this.api}/makes${query(this.config)}`, this.config.key, "makes")
214217
if (this.destroyed) return
215218
this.makes = data.makes
219+
const preselected = this.makeSelect.value
216220
fillSelect(this.makeSelect, this.config.placeholderMake, data.makes, this.config)
217221
if (this.config.includeOther) appendOther(this.makeSelect)
218222
if (this.combobox) this.combobox.setItems(data.makes)
@@ -234,6 +238,14 @@
234238
this.combobox?.sync()
235239

236240
if (this.makeSelect.value) this.makeChanged()
241+
else if (preselected && preselected !== "other") {
242+
// A server-rendered selection that the fetched list does not carry
243+
// (filtered out by kind/country/topMakes, or simply unknown) has
244+
// just been dropped. Say so: the consumer's own state still holds
245+
// the old value, and a silent empty submit is the worst outcome.
246+
console.warn(`[VehiclesDropdown] preselected make ${JSON.stringify(preselected)} is not in the fetched list; selection cleared`)
247+
this.notify()
248+
}
237249
} catch (error) {
238250
if (!this.destroyed) this.fail(error)
239251
}
@@ -302,9 +314,6 @@
302314
const url = URL.createObjectURL(blob)
303315
this.objectUrls.add(url)
304316
return url
305-
}).catch(() => {
306-
this.logoUrls.delete(cacheKey)
307-
return null
308317
})
309318
this.logoUrls.set(cacheKey, promise)
310319
return promise
@@ -386,9 +395,15 @@
386395
wrap.append(adornment)
387396
}
388397

398+
let adornedFor = null
389399
function showSelected(item) {
390400
input.value = item.name
391401
if (!adornment) return
402+
// choose() lands here twice per pick (directly, then again via the
403+
// change event through sync()); repainting the chip while the cached
404+
// logo re-resolves flashes the letter fallback. Same item, no repaint.
405+
if (adornedFor === item.name && adornment.childElementCount) return
406+
adornedFor = item.name
392407
adornment.style.display = "inline-flex"
393408
input.style.paddingLeft = "2.75rem"
394409
adornment.replaceChildren(chip(item.name))
@@ -503,7 +518,7 @@
503518
function syncValidity() {
504519
if (!originalRequired) return
505520
const item = selectedItem()
506-
input.setCustomValidity(item && input.value === item.name ? "" : "Please select a vehicle make.")
521+
input.setCustomValidity(item && input.value === item.name ? "" : self.config.requiredMessage)
507522
}
508523

509524
input.addEventListener("input", () => { render(input.value); syncValidity() })
@@ -513,7 +528,12 @@
513528
render("")
514529
})
515530
input.addEventListener("blur", () => {
516-
blurTimer = setTimeout(() => { restoreSelected(); close() }, 100)
531+
// Synchronously: list rows preventDefault on mousedown so they never
532+
// blur the input, which means a blur is always a real departure — and
533+
// a submit button clicked right after must find the visible text and
534+
// the form value already agreeing.
535+
restoreSelected()
536+
blurTimer = setTimeout(close, 100)
517537
})
518538
input.addEventListener("keydown", (event) => {
519539
if (event.key === "Escape") {
@@ -532,9 +552,34 @@
532552
const next = active < 0 ? (delta > 0 ? 0 : shown.length - 1) : (active + delta + shown.length) % shown.length
533553
return setActive(next)
534554
}
535-
if (event.key === "Enter" && open && shown.length) {
536-
event.preventDefault()
537-
choose(shown[active >= 0 ? active : 0])
555+
if (event.key === "Enter") {
556+
// Commit only a deliberate choice: an arrowed-to row, the single
557+
// remaining match, or text that names one exactly. Focusing the
558+
// field opens the full list, and Enter there must never quietly
559+
// swap a committed selection for the first row alphabetically.
560+
if (open && shown.length && active >= 0) {
561+
event.preventDefault()
562+
return choose(shown[active])
563+
}
564+
// Text agreeing with the committed selection is a plain form
565+
// submit, not a re-pick (a re-pick would clear the chosen model).
566+
const committed = selectedItem()
567+
if (committed && input.value === committed.name) return close()
568+
if (open && shown.length === 1) {
569+
event.preventDefault()
570+
return choose(shown[0])
571+
}
572+
573+
const needle = input.value.trim().toLowerCase()
574+
const exact = needle && items.find((item) => item.name.toLowerCase() === needle)
575+
if (exact) {
576+
event.preventDefault()
577+
return choose(exact)
578+
}
579+
// No choice made: restore before the form submits, so the value it
580+
// carries is the one the field visibly shows.
581+
restoreSelected()
582+
close()
538583
}
539584
})
540585

@@ -560,7 +605,10 @@
560605
try {
561606
this.config.onChange(this.value)
562607
} catch (error) {
563-
this.fail(error)
608+
// A consumer bug is not an API failure: onError promises errors with
609+
// .status/.code, and handlers switching on those would misroute this.
610+
// Log it whole (stack included) without interrupting the widget.
611+
console.error("[VehiclesDropdown] onChange callback failed", error)
564612
}
565613
}
566614

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"README.md",
4343
"LICENSE"
4444
],
45-
"sideEffects": false,
45+
"sideEffects": true,
4646
"devDependencies": {
4747
"jsdom": "^26.1.0"
4848
}

test/make-model-dropdown.test.js

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,9 @@ test("attach validates its element and credential contract synchronously", () =>
141141
test("a throwing host callback cannot interrupt dependent model loading", async () => {
142142
const document = installDOM(`<select id="make"></select><select id="model"></select>`)
143143
const base = apiBase("callbacks")
144-
const warnings = []
145-
const originalWarn = console.warn
146-
console.warn = (...args) => warnings.push(args)
144+
const logged = []
145+
const originalError = console.error
146+
console.error = (...args) => logged.push(args)
147147
global.fetch = async (url) => {
148148
if (url.includes("/models")) return jsonResponse({ models: [{ name: "A3", slug: "a3" }] })
149149
return jsonResponse({ makes: [{ name: "Audi", slug: "audi" }] })
@@ -160,9 +160,11 @@ test("a throwing host callback cannot interrupt dependent model loading", async
160160
document.querySelector("#make").dispatchEvent(new Event("change", { bubbles: true }))
161161
await waitFor(() => document.querySelector("#model").options.length === 2, "models after callback failure")
162162
assert.equal(document.querySelector("#model").options[1].value, "a3")
163-
assert.equal(warnings.length, 1)
163+
// The consumer bug lands in console.error whole, and never in onError.
164+
assert.equal(logged.length, 1)
165+
assert.match(String(logged[0][1]), /consumer failed/)
164166
} finally {
165-
console.warn = originalWarn
167+
console.error = originalError
166168
}
167169
})
168170

@@ -393,8 +395,10 @@ test("combobox stays in sync, restores typed text, and restores labels on destro
393395

394396
input.value = "still not a make"
395397
input.dispatchEvent(new Event("blur"))
396-
await new Promise((resolve) => setTimeout(resolve, 120))
398+
// SYNCHRONOUSLY: a submit button clicked in the same tick as the blur must
399+
// already see the visible text agreeing with the form value.
397400
assert.equal(input.value, "Audi")
401+
assert.equal(select.value, "audi")
398402

399403
instance.destroy()
400404
instances = instances.filter((candidate) => candidate !== instance)
@@ -484,33 +488,112 @@ test("logo blobs are credential-scoped and object URLs are instance-owned", asyn
484488
}
485489
})
486490

487-
test("a transient logo failure can retry within the same instance", async () => {
491+
test("a failed logo fetch is a cached miss, not a retry storm", async () => {
488492
const document = installDOM(`<select id="make"></select>`)
489493
dom.window.HTMLElement.prototype.scrollIntoView = () => {}
490-
const base = apiBase("logo-retry")
494+
const base = apiBase("logo-miss")
491495
let logoCalls = 0
492496
global.fetch = async (url) => {
493497
if (new URL(url).pathname.endsWith("/logo")) {
494498
logoCalls += 1
495-
return logoCalls === 1
496-
? new Response("temporary", { status: 503 })
497-
: new Response(new Blob(["svg"]), { status: 200, headers: { "Content-Type": "image/svg+xml" } })
499+
return new Response("denied", { status: 403 })
498500
}
499501
return jsonResponse({ makes: [{ name: "Audi", slug: "audi" }] })
500502
}
501503

502-
const originalCreate = URL.createObjectURL
503-
URL.createObjectURL = () => "blob:retry"
504-
try {
505-
attach({ key: "logo-retry-key", apiBase: base, make: "#make", logos: true, attribution: false })
506-
await waitFor(() => document.querySelector("#make").options.length === 2)
507-
const input = document.querySelector("[role=combobox]")
504+
attach({ key: "logo-miss-key", apiBase: base, make: "#make", logos: true, attribution: false })
505+
await waitFor(() => document.querySelector("#make").options.length === 2)
506+
const input = document.querySelector("[role=combobox]")
507+
// Every focus re-renders the list; each row asks for its logo. A failing
508+
// logo endpoint must cost ONE request per make for the whole pageview,
509+
// never one per render.
510+
for (let i = 0; i < 4; i += 1) {
508511
input.dispatchEvent(new Event("focus"))
509-
await waitFor(() => logoCalls === 1)
510512
await new Promise((resolve) => setTimeout(resolve, 0))
511-
input.dispatchEvent(new Event("focus"))
512-
await waitFor(() => logoCalls === 2)
513+
input.dispatchEvent(new Event("blur"))
514+
await new Promise((resolve) => setTimeout(resolve, 0))
515+
}
516+
await new Promise((resolve) => setTimeout(resolve, 10))
517+
assert.equal(logoCalls, 1, `expected a single cached miss, saw ${logoCalls} requests`)
518+
})
519+
520+
test("focus plus Enter never overwrites a committed selection", async () => {
521+
const document = installDOM(`<select id="make"></select>`)
522+
dom.window.HTMLElement.prototype.scrollIntoView = () => {}
523+
const base = apiBase("enter-guard")
524+
global.fetch = async () => jsonResponse({ makes: [
525+
{ name: "Abarth", slug: "abarth" },
526+
{ name: "BMW", slug: "bmw" },
527+
{ name: "Toyota", slug: "toyota" }
528+
] })
529+
530+
attach({ key: "enter-key", apiBase: base, make: "#make", search: true, attribution: false })
531+
await waitFor(() => document.querySelector("#make").options.length === 4)
532+
const select = document.querySelector("#make")
533+
const input = document.querySelector("[role=combobox]")
534+
535+
select.value = "toyota"
536+
select.dispatchEvent(new Event("change", { bubbles: true }))
537+
assert.equal(input.value, "Toyota")
538+
539+
// The bug this guards: focus opens the full list, and Enter with nothing
540+
// active used to commit the first row alphabetically (Abarth).
541+
input.dispatchEvent(new Event("focus"))
542+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
543+
assert.equal(select.value, "toyota", "Enter with no active row must not pick row zero")
544+
assert.equal(input.value, "Toyota")
545+
546+
// Typing down to a single match plus Enter is still the productive path.
547+
input.value = "bm"
548+
input.dispatchEvent(new Event("input", { bubbles: true }))
549+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
550+
assert.equal(select.value, "bmw")
551+
assert.equal(input.value, "BMW")
552+
553+
// Garbage text plus Enter restores the committed pair before any submit.
554+
input.value = "zzzz"
555+
input.dispatchEvent(new Event("input", { bubbles: true }))
556+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
557+
assert.equal(select.value, "bmw")
558+
assert.equal(input.value, "BMW")
559+
})
560+
561+
test("a dropped server preselection announces itself instead of diverging silently", async () => {
562+
const document = installDOM(`
563+
<select id="make"><option value="lada" selected>Lada</option></select>
564+
`)
565+
const base = apiBase("preselect-drop")
566+
global.fetch = async () => jsonResponse({ makes: [{ name: "Audi", slug: "audi" }] })
567+
const changes = []
568+
const warnings = []
569+
const originalWarn = console.warn
570+
console.warn = (...parts) => warnings.push(parts.join(" "))
571+
try {
572+
attach({ key: "drop-key", apiBase: base, make: "#make", attribution: false,
573+
onChange(value) { changes.push(value) } })
574+
await waitFor(() => changes.length === 1)
513575
} finally {
514-
URL.createObjectURL = originalCreate
576+
console.warn = originalWarn
515577
}
578+
assert.equal(document.querySelector("#make").value, "")
579+
assert.equal(changes[0].make, null, "the consumer must hear that the selection is gone")
580+
assert.ok(warnings.some((line) => line.includes("lada")), "the dropped value should be named")
581+
})
582+
583+
test("a consumer onChange bug is not delivered to onError as an API failure", async () => {
584+
const document = installDOM(`<select id="make"></select><select id="model"></select>`)
585+
const base = apiBase("onchange-bug")
586+
global.fetch = async (url) => new URL(url).pathname.endsWith("/models")
587+
? jsonResponse({ models: [] })
588+
: jsonResponse({ makes: [{ name: "Audi", slug: "audi" }] })
589+
const apiErrors = []
590+
attach({ key: "bug-key", apiBase: base, make: "#make", model: "#model", attribution: false,
591+
onChange() { throw new Error("consumer bug") },
592+
onError(error) { apiErrors.push(error) } })
593+
await waitFor(() => document.querySelector("#make").options.length === 2)
594+
const select = document.querySelector("#make")
595+
select.value = "audi"
596+
select.dispatchEvent(new Event("change", { bubbles: true }))
597+
await new Promise((resolve) => setTimeout(resolve, 20))
598+
assert.equal(apiErrors.length, 0, "onError is for API failures, not consumer exceptions")
516599
})

0 commit comments

Comments
 (0)