Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
- Older API responses without `years_on_record`, and spans outside 1886 to next year, are handled the same as `null`.
- A host-supplied year outside the model's span is kept (inserted in order and selected, with one `console.warn`), never dropped.
- The widget disables the year select only for `yearFallback: "none"` with nothing to choose (with `includeOther`, Other stays choosable), and only ever undoes its own disabling.
- New option `otherLabel` (default `"Other / not listed"`): the text of the `includeOther` row on make, model and year, for pages in other languages. The option value stays `"other"`.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ VehiclesDropdown.attach({
yearFallback: "range", // a model with no register span: "range" (default) or "none"
values: "slug", // option values: "slug" (default) or "name"
includeOther: true, // append "Other / not listed" — pickers should never dead-end
otherLabel: "Other / not listed", // its text, to translate; the value is always "other"

search: true, // searchable combobox instead of a plain select
logos: true, // make logos in the list (search mode; free key feature)
Expand Down
1 change: 1 addition & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ <h1>VehiclesDropdown demo</h1>
search: true,
logos: true,
includeOther: true,
otherLabel: "Other / not listed",
onChange: (value) => {
document.getElementById("state").textContent = JSON.stringify(value, null, 2)
},
Expand Down
21 changes: 12 additions & 9 deletions make-model-dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
yearFallback: "range", // a model with no register span: "range" | "none"
values: "slug", // what option values hold: "slug" | "name"
includeOther: false, // append an "Other / not listed" escape hatch
otherLabel: "Other / not listed", // its text; the option value stays "other"
logos: false, // render make logos (combobox mode only)
search: false, // searchable combobox instead of a plain <select>
attribution: true, // "Vehicle data by VehiclesDB" line (CC-BY credit)
Expand Down Expand Up @@ -141,8 +142,10 @@
}
}

function appendOther(select) {
select.add(new Option("Other / not listed", "other"))
// The escape hatch's TEXT is the host's to translate; its VALUE is the
// API contract ("other" in every payload, whatever the page's language).
function appendOther(select, config) {
select.add(new Option(config.otherLabel, "other"))
}

// A model's register span, or null. `years_on_record` reached the API
Expand Down Expand Up @@ -271,7 +274,7 @@
this.makes = data.makes
const preselected = this.makeSelect.value
fillSelect(this.makeSelect, this.config.placeholderMake, data.makes, this.config)
if (this.config.includeOther) appendOther(this.makeSelect)
if (this.config.includeOther) appendOther(this.makeSelect, this.config)
if (this.combobox) this.combobox.setItems(data.makes)

// The preselected make (slug or name, forgiving): set the form value
Expand Down Expand Up @@ -321,7 +324,7 @@

const makeValue = this.makeSelect.value
if (!makeValue || makeValue === "other") {
if (makeValue === "other" && this.config.includeOther) appendOther(this.modelSelect)
if (makeValue === "other" && this.config.includeOther) appendOther(this.modelSelect, this.config)
return
}

Expand All @@ -342,7 +345,7 @@
// so it can only ever describe the models actually on screen.
this.models = data.models
fillSelect(this.modelSelect, this.config.placeholderModel, data.models, this.config, "")
if (this.config.includeOther) appendOther(this.modelSelect)
if (this.config.includeOther) appendOther(this.modelSelect, this.config)

// The preselected model, applied exactly once — after that the person
// is driving, and a make change must reset the model like always.
Expand Down Expand Up @@ -402,7 +405,7 @@
: none ? []
: yearItems(current - FALLBACK_YEARS, current)
fillSelect(select, this.config.placeholderYear, items, this.config, "")
if (this.config.includeOther) appendOther(select)
if (this.config.includeOther) appendOther(select, this.config)

// The preselected year, applied exactly once and only to its intended
// make and model — after that the person is driving.
Expand All @@ -429,7 +432,7 @@
keepYear(wanted, modelValue) {
const select = this.yearSelect
if (!Array.from(select.options).some((option) => option.value && option.value === wanted)) {
if (wanted === "other") appendOther(select)
if (wanted === "other") appendOther(select, this.config)
else if (/^\d+$/.test(wanted)) {
const before = Array.from(select.options).find((option) =>
option.value === "other" || (option.value && Number(option.value) < Number(wanted)))
Expand Down Expand Up @@ -644,7 +647,7 @@
shown = matches.slice(0, 50)
// The escape hatch rides along unfiltered: a picker should never
// dead-end, least of all for the person whose make is not listed.
if (self.config.includeOther) shown = shown.concat({ name: "Other / not listed", value: "other" })
if (self.config.includeOther) shown = shown.concat({ name: self.config.otherLabel, value: "other" })
active = -1
input.removeAttribute("aria-activedescendant")
list.replaceChildren(...shown.map(row))
Expand All @@ -653,7 +656,7 @@

function selectedItem() {
if (select.value === "other" && self.config.includeOther) {
return { name: "Other / not listed", value: "other" }
return { name: self.config.otherLabel, value: "other" }
}
return items.find((item) => optionValue(self.config, item) === select.value)
}
Expand Down
63 changes: 63 additions & 0 deletions test/make-model-dropdown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,69 @@ test("Other / not listed reaches the year step and never strands it", async () =
assert.deepEqual(changes.at(-1), { make: "other", model: null, year: null })
})

test("otherLabel translates the escape hatch on every select; its value stays \"other\"", async () => {
const document = installDOM(`
<select id="make"></select><select id="model"></select><select id="year"></select>
<select id="make-es"></select><select id="model-es"></select><select id="year-es"></select>
`)
const base = apiBase("other-label")
const changes = []
global.fetch = async (url) => new URL(url).pathname.endsWith("/models")
? jsonResponse({ models: [{ name: "A3", slug: "a3", years_on_record: { first: 2020, last: 2021 } }] })
: jsonResponse({ makes: [{ name: "Audi", slug: "audi" }] })
const lastText = (select) => select.options[select.options.length - 1].textContent

attach({
key: "other-label-key", apiBase: base, make: "#make", model: "#model", year: "#year",
includeOther: true, attribution: false
})
attach({
key: "other-label-key", apiBase: base, make: "#make-es", model: "#model-es", year: "#year-es",
includeOther: true, otherLabel: "Otra / no aparece", attribution: false,
onChange(value) { changes.push(value) }
})
await waitFor(() => document.querySelector("#make").options.length === 3 &&
document.querySelector("#make-es").options.length === 3)

for (const [suffix, label] of [["", "Other / not listed"], ["-es", "Otra / no aparece"]]) {
const make = document.querySelector(`#make${suffix}`)
const model = document.querySelector(`#model${suffix}`)
const year = document.querySelector(`#year${suffix}`)
pick(make, "audi")
await waitFor(() => model.options.length === 3)
pick(model, "a3")
assert.deepEqual(optionValues(year), ["", "2021", "2020", "other"])
for (const select of [make, model, year]) assert.equal(lastText(select), label)

// The value is the API contract, whatever the page's language.
pick(make, "other")
assert.deepEqual(optionValues(model), ["", "other"])
assert.equal(lastText(model), label)
}
assert.deepEqual(changes.at(-1), { make: "other", model: null, year: null })
})

test("otherLabel names the Other row in the searchable combobox too", async () => {
const document = installDOM(`<select id="make"></select>`)
dom.window.HTMLElement.prototype.scrollIntoView = () => {}
const base = apiBase("other-label-combobox")
global.fetch = async () => jsonResponse({ makes: [{ name: "Audi", slug: "audi" }] })

attach({
key: "other-label-combobox-key", apiBase: base, make: "#make", search: true,
includeOther: true, otherLabel: "Inna / brak na liście", attribution: false
})
await waitFor(() => document.querySelector("#make").options.length === 3)
const input = document.querySelector("[role=combobox]")
input.dispatchEvent(new Event("focus"))
const rows = Array.from(document.querySelectorAll("[role=option]"))
assert.equal(rows.at(-1).textContent, "Inna / brak na liście")
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }))
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
assert.equal(document.querySelector("#make").value, "other")
assert.equal(input.value, "Inna / brak na liście")
})

test("a widget without a year select keeps the exact {make, model} contract", async () => {
const document = installDOM(`<select id="make"></select><select id="model"></select><select id="year"></select>`)
const base = apiBase("year-absent")
Expand Down
Loading