diff --git a/src/pseudo-selectors/aliases.ts b/src/pseudo-selectors/aliases.ts index eeaae33c..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. */ @@ -20,9 +25,10 @@ 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)${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 1feeb8ce..4fa794b1 100644 --- a/test/pseudo-classes.ts +++ b/test/pseudo-classes.ts @@ -213,6 +213,157 @@ 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( + '
', + ); + expect( + CSSselect.selectAll(":disabled", dom).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual([ + "fieldset", + "input", + "button", + "select", + "option", + "textarea", + ]); + expect( + CSSselect.selectAll(":enabled", dom).map( + (element) => element.attribs["id"], + ), + ).toStrictEqual([]); + }); + + 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(`