From f8e728e785902181525678cd61bc0465a2a8fa37 Mon Sep 17 00:00:00 2001 From: Ferit Date: Tue, 13 Oct 2026 10:49:44 +0300 Subject: [PATCH 1/2] fix(pseudos): inherit disabled fieldsets outside the first legend --- src/pseudo-selectors/aliases.ts | 7 ++-- test/pseudo-classes.ts | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/pseudo-selectors/aliases.ts b/src/pseudo-selectors/aliases.ts index eeaae33c..2d6b8055 100644 --- a/src/pseudo-selectors/aliases.ts +++ b/src/pseudo-selectors/aliases.ts @@ -20,9 +20,12 @@ export const aliases: Record = { // https://html.spec.whatwg.org/multipage/scripting.html#disabled-elements disabled: `:is( - :is(button, input, select, textarea, optgroup, option)[disabled], + :is(button, input, select, textarea, optgroup, option, fieldset)[disabled], optgroup[disabled] > option, - fieldset[disabled]:not(fieldset[disabled] legend:first-of-type *) + :is(button, input, select, textarea, fieldset):is( + fieldset[disabled] > :not(legend:first-of-type), + fieldset[disabled] > :not(legend:first-of-type) * + ) )`, enabled: ":is(button, input, select, textarea, optgroup, option, fieldset):not(:disabled)", diff --git a/test/pseudo-classes.ts b/test/pseudo-classes.ts index 1feeb8ce..9a838d31 100644 --- a/test/pseudo-classes.ts +++ b/test/pseudo-classes.ts @@ -213,6 +213,64 @@ describe(":has", () => { }); }); +describe(":disabled fieldsets", () => { + it("should disable direct and nested controls, but not arbitrary descendants", () => { + const dom = parseDocument( + '
', + ); + expect( + CSSselect.selectAll(":disabled", dom).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["fieldset", "input", "button", "select", "textarea"]); + expect( + CSSselect.selectAll(":enabled", dom).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["option"]); + }); + + it("should exempt only the first direct legend", () => { + const dom = parseDocument( + '
', + ); + expect( + CSSselect.selectAll("input:disabled", dom).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["second"]); + expect( + CSSselect.selectAll("input:enabled", dom).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["first"]); + }); + + it("should not exempt a nested fieldset legend from an outer disabled fieldset", () => { + const dom = parseDocument( + '
', + ); + expect( + CSSselect.selectAll( + "#inner:disabled, input:disabled", + dom, + ).map((element) => element.attribs["id"]), + ).toStrictEqual(["inner", "nested"]); + }); + + it("should still match an explicitly disabled fieldset inside an exempt legend", () => { + const dom = parseDocument( + '
', + ); + expect( + CSSselect.selectAll( + "#inner:disabled, input:disabled", + dom, + ).map((element) => element.attribs["id"]), + ).toStrictEqual(["inner", "nested"]); + }); +}); + describe(":enabled", () => { it("should match form-associated elements", () => { const dom = parseDocument(` From 95cc9e3b17eaa1562869030628ba5f4a41f1bdfc Mon Sep 17 00:00:00 2001 From: Ferit Date: Tue, 13 Oct 2026 18:17:58 +0300 Subject: [PATCH 2/2] fix(pseudos): preserve disabled ancestors outside query context --- src/pseudo-selectors/aliases.ts | 11 ++-- src/pseudo-selectors/index.ts | 10 +++- test/pseudo-classes.ts | 97 ++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/pseudo-selectors/aliases.ts b/src/pseudo-selectors/aliases.ts index 2d6b8055..8ad6c128 100644 --- a/src/pseudo-selectors/aliases.ts +++ b/src/pseudo-selectors/aliases.ts @@ -7,6 +7,11 @@ const textControl = "input:is([type=text i],[type=search i],[type=url i],[type=tel i],[type=email i],[type=password i],[type=date i],[type=month i],[type=week i],[type=time i],[type=datetime-local i],[type=number i])"; +const disabledByFieldset = `:is( + fieldset[disabled] > :not(legend:first-of-type), + fieldset[disabled] > :not(legend:first-of-type) * +)`; + /** * Aliases are pseudos that are expressed as selectors. */ @@ -22,10 +27,8 @@ export const aliases: Record = { disabled: `:is( :is(button, input, select, textarea, optgroup, option, fieldset)[disabled], optgroup[disabled] > option, - :is(button, input, select, textarea, fieldset):is( - fieldset[disabled] > :not(legend:first-of-type), - fieldset[disabled] > :not(legend:first-of-type) * - ) + :is(button, input, select, textarea, fieldset)${disabledByFieldset}, + :is(optgroup, option):is(select[disabled] *, select${disabledByFieldset} *) )`, enabled: ":is(button, input, select, textarea, optgroup, option, fieldset):not(:disabled)", diff --git a/src/pseudo-selectors/index.ts b/src/pseudo-selectors/index.ts index b4cf903d..5eebe045 100644 --- a/src/pseudo-selectors/index.ts +++ b/src/pseudo-selectors/index.ts @@ -60,7 +60,15 @@ export function compilePseudoSelector( // The alias has to be parsed here, to make sure options are respected. const alias = parse(stringPseudo); - return subselects["is"](next, alias, options, context, compileToken); + const isDisabledAlias = name === "disabled" && userPseudo === undefined; + // Disabled state can depend on ancestors outside the search context. + return subselects["is"]( + next, + alias, + isDisabledAlias ? { ...options, relativeSelector: false } : options, + isDisabledAlias ? undefined : context, + compileToken, + ); } if (typeof userPseudo === "function") { diff --git a/test/pseudo-classes.ts b/test/pseudo-classes.ts index 9a838d31..4fa794b1 100644 --- a/test/pseudo-classes.ts +++ b/test/pseudo-classes.ts @@ -214,6 +214,92 @@ describe(":has", () => { }); describe(":disabled fieldsets", () => { + it("should inherit a disabled select's state for options and optgroups", () => { + const dom = parseDocument( + '', + ); + expect( + CSSselect.selectAll( + "optgroup:disabled, option:disabled", + dom, + ).map((element) => element.attribs["id"]), + ).toStrictEqual(["group", "option"]); + expect( + CSSselect.selectAll("optgroup:enabled, option:enabled", dom), + ).toHaveLength(0); + }); + + it("should exempt a select and its options inside the first legend", () => { + const dom = parseDocument( + '
', + ); + expect( + CSSselect.selectAll("optgroup:disabled, option:disabled", dom), + ).toHaveLength(0); + expect( + CSSselect.selectAll( + "optgroup:enabled, option:enabled", + dom, + ).map((element) => element.attribs["id"]), + ).toStrictEqual(["group", "option"]); + }); + + it("should not inherit fieldset disabled state without an ancestor select", () => { + const dom = parseDocument( + '
', + ); + expect( + CSSselect.selectAll("optgroup:disabled, option:disabled", dom), + ).toHaveLength(0); + }); + + it("should find disabled ancestors above a scoped search root", () => { + const dom = parseDocument( + '
', + ); + const root = CSSselect.selectAll("#root", dom)[0]; + expect( + CSSselect.selectAll(":disabled", root).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["input"]); + expect(CSSselect.selectAll(":enabled", root)).toHaveLength(0); + expect( + CSSselect.selectAll(":disabled", [root], { cacheResults: false }), + ).toHaveLength(1); + expect( + CSSselect.selectAll(":disabled", root, { context: root }), + ).toHaveLength(1); + const input = CSSselect.selectAll("input", root)[0]; + expect(CSSselect.is(input, ":disabled", { context: root })).toBe(true); + expect(CSSselect.is(input, ":enabled", { context: root })).toBe(false); + }); + + it("should preserve the query context for a custom disabled alias", () => { + const dom = parseDocument( + '
', + ); + const root = CSSselect.selectAll("#root", dom)[0]; + expect( + CSSselect.selectAll(":disabled", root, { + pseudos: { disabled: "fieldset input" }, + }), + ).toHaveLength(0); + }); + + it("should keep the legend exemption when it is above the search root", () => { + const dom = parseDocument( + '
', + ); + const root = CSSselect.selectAll("#root", dom)[0]; + expect(CSSselect.selectAll(":disabled", root)).toHaveLength(0); + expect( + CSSselect.selectAll(":enabled", root).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual(["input"]); + }); + it("should disable direct and nested controls, but not arbitrary descendants", () => { const dom = parseDocument( '
', @@ -222,12 +308,19 @@ describe(":disabled fieldsets", () => { CSSselect.selectAll(":disabled", dom).map( (element) => element.attribs["id"], ), - ).toStrictEqual(["fieldset", "input", "button", "select", "textarea"]); + ).toStrictEqual([ + "fieldset", + "input", + "button", + "select", + "option", + "textarea", + ]); expect( CSSselect.selectAll(":enabled", dom).map( (element) => element.attribs["id"], ), - ).toStrictEqual(["option"]); + ).toStrictEqual([]); }); it("should exempt only the first direct legend", () => {