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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ When `preserveChanges` is false, `#resetFormProperties` runs after the attribute

Moves complete out of document order, and a radio that moves can join another radio group, so resetting radios one at a time can leave a group showing the wrong radio. Adding `checked` to a radio unchecks the rest of its group too, even radios later in the document, and a node list morph adds its first node after the others. So when a reset changes a radio's `.checked`, the target checks a visited radio, a radio moves, or a node list morph adds radios, its group is synced when the morph settles: each radio's `.checked` is set from its `checked` attribute in document order, so the last one the markup checks wins, as when parsing. Radios outside the morph are left alone, and so is a whole group with a radio in a vetoed subtree or with a vetoed `checked` update, since checking one radio unchecks the rest. Nothing is synced under `preserveChanges`.

A move can also change radio groups outside the morph: a checked radio that joins a group unchecks the rest of it, and moving a form briefly takes the radios outside it that use its `form` attribute out of its group. Adding or removing a form with an id, or changing a form's id or a radio's `form` attribute, changes which form such radios belong to too (and changing a checked radio's name, or turning a checked input into a radio, is handled the same way), and Firefox and Safari briefly put a radio that changes form in the group of radios without a form, where a checked one unchecks a radio it never joins. So these changes first uncheck the checked radios outside the changing node whose `form` attribute names a form id it holds (or the radio itself, or the checked radios inside a moving element that change form), and check them again straight after, in both modes, except that without `preserveChanges` the morph's own radios (or, inside a `morphlex-clobber` element, that element's radios) wait until it settles, so a radio the markup unchecks never unchecks the rest of its new group. A radio that's checked again straight after and still follows its `checked` attribute is unchecked by removing the attribute and checked by adding it back, because setting `.checked` would stop it following the markup. A radio outside the morph that joins a group with a checked radio inside the morph leaves that one checked, as when the page is parsed with the markup's radio later. When the morph settles, the radios that such a recheck unchecked (or that a checked radio inside the morph took the group from, including one the morph checks by adding its `checked` attribute or resetting `.checked`, and a new radio with a `form` attribute, which is inserted unchecked and checked straight after) are checked again if the radio that unchecked them has left their group (removed, or moved to another form or name) or is no longer checked, unless another radio in their group is checked or the markup decides their own `checked` (it removed the attribute, or the default reset visited them), so removing a form before a radio that names it, or changing a form's id before the radio's `form` attribute, doesn't lose a radio it only passed by. A live target also unchecks the radios where it is (in its own document or shadow root, and inside it) that name its forms, before anything takes it or its forms out of where they are, and checks them again straight after it's inserted. Without `preserveChanges` it also unchecks the checked radios inside it whose form changes, and checks them again when the morph settles if the markup or a veto keeps them checked.
A move can also change radio groups outside the morph: a checked radio that joins a group unchecks the rest of it, and moving a form briefly takes the radios outside it that use its `form` attribute out of its group. Adding or removing a form with an id, or changing a form's id or a radio's `form` attribute, changes which form such radios belong to too (and changing a checked radio's name, or turning a checked input into a radio, is handled the same way), and Firefox and Safari briefly put a radio that changes form in the group of radios without a form, where a checked one unchecks a radio it never joins. Chromium and Firefox also briefly reset the form of a radio with a `form` attribute while a form around it moves or is removed, so a checked one can uncheck a radio in another group for that moment. So these changes first uncheck the checked radios outside the changing node whose `form` attribute names a form id it holds (or the radio itself, or the checked radios inside a moving element that change form, or the checked radios with a `form` attribute inside a moving or removed node that holds a form, which are checked again straight after in both modes), and check them again straight after, in both modes, except that without `preserveChanges` the morph's own radios (or, inside a `morphlex-clobber` element, that element's radios) wait until it settles, so a radio the markup unchecks never unchecks the rest of its new group. A radio the morph checks again when it settles joins its group's radio sync, so a later radio the markup checks there still wins. A radio that's checked again straight after and still follows its `checked` attribute is unchecked by removing the attribute and checked by adding it back, because setting `.checked` would stop it following the markup. A radio outside the morph that joins a group with a checked radio inside the morph leaves that one checked, as when the page is parsed with the markup's radio later. When the morph settles, the radios that such a recheck unchecked (or that a checked radio inside the morph took the group from, including one the morph checks by adding its `checked` attribute or resetting `.checked`, and a new radio with a `form` attribute, which is inserted unchecked and checked straight after) are checked again if the radio that unchecked them has left their group (removed, or moved to another form or name) or is no longer checked, unless another radio in their group is checked or the markup decides their own `checked` (it removed the attribute, or the default reset visited them), so removing a form before a radio that names it, or changing a form's id before the radio's `form` attribute, doesn't lose a radio it only passed by. A live target also unchecks the radios where it is (in its own document or shadow root, and inside it) that name its forms, before anything takes it or its forms out of where they are, and checks them again straight after it's inserted. Without `preserveChanges` it also unchecks the checked radios inside it whose form changes, and checks them again when the morph settles if the markup or a veto keeps them checked.

### `open` on `details` and `dialog`

Expand Down
33 changes: 28 additions & 5 deletions src/morphlex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,8 @@ class Morph {
// Checking it would uncheck the rest of its new group, where a checked radio that's vetoed stays checked.
if (radioGroupOf(radio, groups).some((member) => member.checked && this.#isVetoed(member))) continue
radio.checked = true
// The new group can have a later radio the markup checks, which wins as when parsing.
;(this.#radiosToSync ??= new Set()).add(radio)
}
}

Expand Down Expand Up @@ -2306,10 +2308,12 @@ class Morph {

if (!shouldNotMove[matchInd]) {
const outsideRadios = this.#uncheckRadiosNamingFormsIn(match, getRootNode(match))
const insideRadios = this.#uncheckRadiosInFormsIn(match)
const focus = this.#watchFocus(match, parent)
moveBefore(parent, match, insertionPoint)
if (focus) this.#restoreFocus(focus)
this.#checkRadios(outsideRadios)
this.#checkRadios(insideRadios, true)
Comment thread
joeldrapper marked this conversation as resolved.
}
// Read this before the morph, which can replace the match. A match that moved itself
// elsewhere when it reconnected leaves the insertion point where it was.
Expand Down Expand Up @@ -2559,6 +2563,15 @@ class Morph {
return ids && this.#uncheckRadiosNaming(ids, root, inside ? null : node)
}

// Chromium and Firefox briefly reset the form of a radio with a `form` attribute while a form around it
// moves or leaves, and a checked one then unchecks the radio in the group it joins for that moment. So
// when a node holding a form moves or leaves, these radios go unchecked, and are checked again straight
// after, back in the group they were in.
#uncheckRadiosInFormsIn(node: Node): Array<HTMLInputElement> | null {
if (!isElement(node) || (!isFormElement(node) && getElementsByTagName(node, "form").length === 0)) return null
return this.#uncheckRadiosWithForm(node)
Comment thread
joeldrapper marked this conversation as resolved.
}

// A new radio with a `form` attribute is checked in the group it joins, but can leave that group when the
// morph adds or changes its form later. So it's inserted unchecked and checked again straight after, which
// notes the radios it unchecks, to give them their check back if it leaves.
Expand All @@ -2568,7 +2581,7 @@ class Morph {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]!
if (isCheckedRadio(input) && input.hasAttribute("form")) {
this.#uncheckRadio(input)
this.#uncheckRadio(input, true)
;(unchecked ??= []).push(input)
}
}
Expand Down Expand Up @@ -2612,8 +2625,8 @@ class Morph {

// Setting `.checked` stops a radio from following its `checked` attribute. So a radio that's checked
// again straight after, and still follows the attribute, is unchecked by removing the attribute.
#uncheckRadio(radio: HTMLInputElement): void {
const value = this.#defersRadio(radio) ? null : radio.getAttribute("checked")
#uncheckRadio(radio: HTMLInputElement, immediate = false): void {
const value = !immediate && this.#defersRadio(radio) ? null : radio.getAttribute("checked")
if (value !== null) {
radio.removeAttribute("checked")
if (!radio.checked) {
Expand Down Expand Up @@ -2710,8 +2723,10 @@ class Morph {

#removeChild(node: ChildNode): void {
const radios = this.#uncheckRadiosNamingFormsIn(node, getRootNode(node))
const insideRadios = this.#uncheckRadiosInFormsIn(node)
remove(node)
this.#checkRadios(radios)
this.#checkRadios(insideRadios, true)
}

// Check each radio the markup checks, in document order, so the last one wins as when parsing.
Expand Down Expand Up @@ -2924,14 +2939,20 @@ class Morph {
// A live target takes its forms away from the radios where it is, in its own document or shadow root
// and inside it, including forms that live elements claim out of it next. Those inside it are
// checked again straight away, since they're the target's own state, not markup the morph resets.
const sourceRadios = isConnected(node) ? this.#uncheckRadiosNamingFormsIn(node, getRootNode(node), true) : null
const live = isConnected(node)
const sourceRadios = live ? this.#uncheckRadiosNamingFormsIn(node, getRootNode(node), true) : null
// A live target can hold the focused element, which its claimed descendants take out of it next.
const focus = this.#watchFocus(node, parent)
clearImplicitSelection(node, parent)
this.#placeMovableDescendants(node, parent)
if (this.#targetOpensDetails && isElement(node)) this.#noteAddedDetails(node)
const radios = this.#uncheckRadiosNamingFormsIn(node, getRootNode(parent))
const addedRadios = this.#targetChecksInputs && isElement(node) ? this.#uncheckRadiosWithForm(node) : null
const addedRadios =
this.#targetChecksInputs && isElement(node)
? this.#uncheckRadiosWithForm(node)
: live
? this.#uncheckRadiosInFormsIn(node)
: null
moveInto(parent, node, insertionPoint)
if (focus) this.#restoreFocus(focus)
this.#checkRadios(radios)
Expand Down Expand Up @@ -2980,11 +3001,13 @@ class Morph {
if (!inCycle && this.#liveElementsById.get(idOf(target)) === live && !contains(live, parent)) {
this.#liveElementsById.delete(idOf(target))
const radios = this.#uncheckRadiosForMove(live, parent)
const insideRadios = this.#uncheckRadiosInFormsIn(live)
const focus = this.#watchFocus(live, parent)
moveInto(parent, live, placeholder)
if (focus) this.#restoreFocus(focus)
remove(placeholder)
this.#checkRadios(radios)
this.#checkRadios(insideRadios, true)
if (!this.#preserveChanges) this.#noteRadioGroups(live)
this.#morphOneToOne(live, target)
} else {
Expand Down
130 changes: 130 additions & 0 deletions test/new/moved-form-radios.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { expect, onTestFinished, test } from "vitest"
import { morph, morphInner } from "../../src/morphlex"

// Chromium and Firefox briefly reset the form of a radio with a `form` attribute while an ancestor of it moves.
// A checked one then joins another group for that moment and unchecks the radio there.

function mount(html: string): HTMLElement {
const host = document.createElement("section")
host.innerHTML = html
document.body.append(host)
onTestFinished(() => host.remove())
return host
}

function checked(host: Element, selector: string): boolean {
return host.querySelector<HTMLInputElement>(selector)!.checked
}

test("moving a form holding a checked radio of another form keeps the user's pick in the moved form", () => {
const host = mount(
`<form id="f1"><input type="radio" name="b" value="1" id="c0"><input type="radio" name="b" value="2" checked form="f2"></form><form id="f2"></form><p></p>`,
)
host.querySelector<HTMLInputElement>("#c0")!.checked = true

morph(
host,
`<section><form id="f2"></form><p></p><form id="f1"><input type="radio" name="b" value="1" id="c0"><input type="radio" name="b" value="2" checked form="f2"></form></section>`,
{ preserveChanges: true },
)

expect(checked(host, "#c0")).toBe(true)
expect(checked(host, "input[value='2']")).toBe(true)
})

test("moving a form holding a checked radio of a missing form keeps the user's pick in the moved form", () => {
const form = `<form id="f1"><input type="radio" class="x" value="1" name="a"><input id="c0" value="3" checked name="a" type="radio" form="missing"></form>`
const host = mount(`<select id="c1" name="u"><option>3</option></select>${form}`)
host.querySelector<HTMLInputElement>("input.x")!.checked = true

morph(host, `<section>${form}<select id="c1" name="u"><option>3</option></select></section>`, { preserveChanges: true })

expect(checked(host, "input.x")).toBe(true)
expect(checked(host, "#c0")).toBe(true)
})

test("moving a form holding a radio the user checked that names the form keeps a formless radio checked", () => {
const html = `<input type="radio" name="a" value="3" checked id="c0"><p></p>`
const form = `<form id="f1"><input type="radio" name="a" value="1" form="f1" id="c2"></form>`

for (const preserveChanges of [false, true]) {
const host = mount(`${html}${form}`)
host.querySelector<HTMLInputElement>("#c2")!.checked = true

morph(host, `<section>${form}${html}</section>`, { preserveChanges })

expect(checked(host, "#c0")).toBe(true)
expect(checked(host, "#c2")).toBe(preserveChanges)
}
})

test("moving a form holding a checked radio that names the form keeps an untouched formless radio checked", () => {
const form = `<form id="f1"><input form="f1" name="a" value="3" type="radio" checked></form>`
const radio = `<input type="radio" checked name="a" value="1">`
const host = mount(`<input name="u" type="email">${form}${radio}`)
host.querySelector<HTMLInputElement>("input[type=email]")!.value = "typed"

morph(host, `<section>${form}<input name="u" type="email">${radio}</section>`, { preserveChanges: true })

expect(checked(host, "input[value='1']")).toBe(true)
expect(checked(host, "input[value='3']")).toBe(true)
})

test("moving a form holding a radio the user checked that names the form keeps a radio of a missing form checked", () => {
const radio = `<input id="c0" type="radio" name="a" form="missing" checked>`
const form = `<form id="f1"><input type="radio" name="a" value="1" form="f1" id="c2"></form>`
const host = mount(`${radio}<p></p>${form}`)
host.querySelector<HTMLInputElement>("#c2")!.checked = true

morph(host, `<section>${form}${radio}<p></p></section>`)

expect(checked(host, "#c0")).toBe(true)
expect(checked(host, "#c2")).toBe(false)
})

test("removing a form holding a radio the user checked that names the form keeps a formless radio checked", () => {
const html = `<input type="radio" name="b" checked id="c1">`
const host = mount(`${html}<form id="f1"><input type="radio" name="b" form="f1" id="r6"></form>`)
host.querySelector<HTMLInputElement>("#r6")!.checked = true

morph(host, `<section>${html}</section>`)

expect(checked(host, "#c1")).toBe(true)
})

test("moving a form holding a radio the user checked that names a missing form keeps the form's radio checked", () => {
const form = `<form><input id="c2" name="a" type="radio" checked value="2"><input id="c4" name="a" type="radio" form="missing" value="3"></form>`
const checkbox = `<input id="c3" type="checkbox" checked>`
const host = mount(`${checkbox}${form}`)
host.querySelector<HTMLInputElement>("#c4")!.checked = true

morph(host, `<section>${form}${checkbox}</section>`)

expect(checked(host, "#c2")).toBe(true)
expect(checked(host, "#c4")).toBe(false)
})

test("an untouched radio with a form attribute in a moved form still follows its checked attribute", () => {
const form = `<form id="f1"><input type="radio" name="a" form="f1" checked id="r"></form>`
const host = mount(`<p></p><p></p>${form}`)

morph(host, `<section>${form}<p></p><p></p></section>`)
host.querySelector("#r")!.removeAttribute("checked")

expect(checked(host, "#r")).toBe(false)
})

test("inserting a live form holding radios the user checked keeps them checked when one names a missing form", () => {
for (const preserveChanges of [false, true]) {
const host = mount(
`<div id="root"></div><div id="source"><form><input id="c2" name="a" type="radio"><input id="c4" name="a" type="radio" form="missing"></form></div>`,
)
host.querySelector<HTMLInputElement>("#c2")!.checked = true
host.querySelector<HTMLInputElement>("#c4")!.checked = true

morphInner(host.querySelector("#root")!, host.querySelector("#source")!, { preserveChanges })

expect(checked(host, "#c2")).toBe(true)
expect(checked(host, "#c4")).toBe(true)
}
})
21 changes: 21 additions & 0 deletions test/new/renamed-form-radios.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, onTestFinished, test } from "vitest"
import { morph } from "../../src/morphlex"

// A checked radio that a form id change moves into another group joins it as when parsing, so the last
// radio the markup checks there stays checked.

test("a user-checked radio moved into another group by a form id change leaves that group's later radio checked", () => {
const host = document.createElement("section")
host.innerHTML = `<form id="f1"><input checked name="a" form="f2" type="radio" value="3" id="c1"></form><input value="3" checked id="c3" type="radio" name="a"><form id="f2"></form>`
document.body.append(host)
onTestFinished(() => host.remove())
host.querySelector<HTMLInputElement>("#c1")!.checked = true

morph(
host,
`<section><form id="f1"><input form="f2" id="c1" name="a" value="3" type="radio" checked></form><input name="a" value="3" checked id="c3" type="radio"><form id="f9"></form></section>`,
)

expect(host.querySelector<HTMLInputElement>("#c1")!.checked).toBe(false)
expect(host.querySelector<HTMLInputElement>("#c3")!.checked).toBe(true)
})
Loading