Skip to content
Open
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
10 changes: 8 additions & 2 deletions src/pseudo-selectors/aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -20,9 +25,10 @@ export const aliases: Record<string, string> = {

// 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} *)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Options get the wrong state

For <select disabled><option>X</option></select>, this branch makes the option match :disabled instead of :enabled. Options do not inherit a select's disabled state. An option is disabled by its own attribute or a disabled parent optgroup; an optgroup needs its own attribute.

The same mistake occurs when a fieldset disables the select, so callers get the wrong query results in both cases. Remove this branch and update the tests that expect inherited state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop treating disabled select descendants as disabled

For <select disabled>...</select>, HTML only makes the select itself disabled: an optgroup needs its own disabled attribute, while an option needs its own attribute or a disabled parent optgroup. This branch newly makes both descendants match :disabled (and disappear from :enabled) for every disabled select, including selects disabled by a fieldset, reversing the prior and browser behavior; retain fieldset inheritance on the select without propagating it to these descendants.

Useful? React with 👍 / 👎.

)`,
enabled:
":is(button, input, select, textarea, optgroup, option, fieldset):not(:disabled)",
Expand Down
10 changes: 9 additions & 1 deletion src/pseudo-selectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ export function compilePseudoSelector<Node, ElementNode extends Node>(

// 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") {
Expand Down
151 changes: 151 additions & 0 deletions test/pseudo-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,157 @@ describe(":has", () => {
});
});

describe(":disabled fieldsets", () => {
it("should inherit a disabled select's state for options and optgroups", () => {
const dom = parseDocument(
'<select disabled><optgroup id="group"><option id="option">X</option></optgroup></select>',
);
expect(
CSSselect.selectAll<AnyNode, Element>(
"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(
'<fieldset disabled><legend><select><optgroup id="group"><option id="option">X</option></optgroup></select></legend></fieldset>',
);
expect(
CSSselect.selectAll("optgroup:disabled, option:disabled", dom),
).toHaveLength(0);
expect(
CSSselect.selectAll<AnyNode, Element>(
"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(
'<fieldset disabled><optgroup id="group"><option id="option">X</option></optgroup></fieldset>',
);
expect(
CSSselect.selectAll("optgroup:disabled, option:disabled", dom),
).toHaveLength(0);
});

it("should find disabled ancestors above a scoped search root", () => {
const dom = parseDocument(
'<fieldset disabled><div id="root"><input id="input"></div></fieldset>',
);
const root = CSSselect.selectAll<AnyNode, Element>("#root", dom)[0];
expect(
CSSselect.selectAll<AnyNode, Element>(":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<AnyNode, Element>("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(
'<fieldset disabled><div id="root"><input></div></fieldset>',
);
const root = CSSselect.selectAll<AnyNode, Element>("#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(
'<fieldset disabled><legend><div id="root"><input id="input"></div></legend></fieldset>',
);
const root = CSSselect.selectAll<AnyNode, Element>("#root", dom)[0];
expect(CSSselect.selectAll(":disabled", root)).toHaveLength(0);
expect(
CSSselect.selectAll<AnyNode, Element>(":enabled", root).map(
(element) => element.attribs["id"],
),
).toStrictEqual(["input"]);
});

it("should disable direct and nested controls, but not arbitrary descendants", () => {
const dom = parseDocument(
'<fieldset disabled id="fieldset"><input id="input"><div id="div"><button id="button">X</button><select id="select"><option id="option">X</option></select><textarea id="textarea"></textarea></div></fieldset>',
);
expect(
CSSselect.selectAll<AnyNode, Element>(":disabled", dom).map(
(element) => element.attribs["id"],
),
).toStrictEqual([
"fieldset",
"input",
"button",
"select",
"option",
"textarea",
]);
expect(
CSSselect.selectAll<AnyNode, Element>(":enabled", dom).map(
(element) => element.attribs["id"],
),
).toStrictEqual([]);
});

it("should exempt only the first direct legend", () => {
const dom = parseDocument(
'<fieldset disabled><div></div><legend><input id="first"></legend><legend><input id="second"></legend></fieldset>',
);
expect(
CSSselect.selectAll<AnyNode, Element>("input:disabled", dom).map(
(element) => element.attribs["id"],
),
).toStrictEqual(["second"]);
expect(
CSSselect.selectAll<AnyNode, Element>("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(
'<fieldset disabled><fieldset id="inner"><legend><input id="nested"></legend></fieldset></fieldset>',
);
expect(
CSSselect.selectAll<AnyNode, Element>(
"#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(
'<fieldset disabled><legend><fieldset disabled id="inner"><input id="nested"></fieldset></legend></fieldset>',
);
expect(
CSSselect.selectAll<AnyNode, Element>(
"#inner:disabled, input:disabled",
dom,
).map((element) => element.attribs["id"]),
).toStrictEqual(["inner", "nested"]);
});
});

describe(":enabled", () => {
it("should match form-associated elements", () => {
const dom = parseDocument(`
Expand Down