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
25 changes: 15 additions & 10 deletions src/plugins/bound/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,24 +170,29 @@ function plugin({ directive, entangle, evaluateLater, mapAttributes, mutateDom,
break;

case "SELECT":
// WORKAROUND:
// For the "select" element, there might be a situation
// where options are generated dynamically using the "x-for" directive,
// and in this case, attempting to set the "value" property
// will have no effect since there are no options yet.
// Therefore, we use a small trick to set the value a bit later
// when the "x-for" directive has finished its work.
// Alpine initializes the options after this directive runs:
// "x-for" renders them and ":value" binds their values.
// The value read from the element before that would be wrong,
// so the binding starts a bit later.
const timer_id = setTimeout(() => {
const apply = () => apply_select_values(el, as_array(get_value() ?? []));

// if the value of the bound property is "null" or "undefined",
// we initialize it with the value from the element.
is_nullish(get_value()) && update_variable();

effect(() => apply_select_values(el, as_array(get_value() ?? [])));
effect(apply);
cleanup(listen(el, "change", () => set_value(collect_selected_values(el))));

// the options and their values can change later, e.g. when
// the "x-for" items do, so the selection is applied again
const observer = new MutationObserver(apply);
observer.observe(el, { childList: true, subtree: true, characterData: true, attributeFilter: ["value"] });
cleanup(() => observer.disconnect());
});

// the element can be destroyed before the timer fires,
// and the effect and listener created after that would never be released.
// the element can be destroyed before the timer fires, and the effect,
// listener and observer created after that would never be released.
cleanup(() => clearTimeout(timer_id));

processed = true;
Expand Down
66 changes: 66 additions & 0 deletions tests/playwright/x-bound.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,72 @@ test.describe("x-bound: select", () => {
await expect(page.locator("span")).toHaveText("1");
});

test("select (options rendered later)", async ({ page }) => {
await set_html(page, `
<div x-data="{ value: '2', options: [] }">
<select &value>
<template x-for="v in options" :key="v">
<option :value="v" x-text="v"></option>
</template>
</select>
</div>`);

const root = page.locator("div");

await set_data(root, { options: ["1", "2", "3"] });
await expect(page.locator("select")).toHaveValue("2");

// Without its option the select shows the first one,
// while the property keeps its value until the option is back
await set_data(root, { options: ["1", "3"] });
await expect(page.locator("select")).toHaveValue("1");

await set_data(root, { options: ["1", "2", "3"] });
await expect(page.locator("select")).toHaveValue("2");
expect(await root.evaluate(el => Alpine.$data(el).value)).toBe("2");
});

test("select (option values change)", async ({ page }) => {
await set_html(page, `
<div x-data="{ value: '2', options: [{ id: '1' }, { id: '2' }] }">
<select &value>
<template x-for="option in options">
<option :value="option.id" x-text="option.id"></option>
</template>
</select>
</div>`);

await expect(page.locator("select")).toHaveValue("2");

// The selected option gets the value "1", and the first option
// gets the value "2", which becomes selected instead
await page.locator("div").evaluate(async el => {
const { options } = Alpine.$data(el);
[options[0].id, options[1].id] = ["2", "1"];
await Alpine.nextTick();
});

await expect(page.locator("select")).toHaveValue("2");
});

test("select multiple (options rendered later)", async ({ page }) => {
await set_html(page, `
<div x-data="{ value: ['2', '3'], options: ['1', '2'] }">
<select &value multiple>
<optgroup label="Options">
<template x-for="v in options" :key="v">
<option :value="v" x-text="v"></option>
</template>
</optgroup>
</select>
</div>`);

await expect(page.locator("select")).toHaveValues(["2"]);

await set_data(page.locator("div"), { options: ["1", "2", "3"] });
await expect(page.locator("select")).toHaveValues(["2", "3"]);
});

test("initialize from element when property is null", async ({ page }) => {
await set_html(page, `
<div x-data="{ value: null }">
Expand Down
Loading