Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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 packages/commands/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1687,7 +1687,7 @@ namespace Private {
function validateSelector(
options: CommandRegistry.IKeyBindingOptions
): string {
if (options.selector.indexOf(',') !== -1) {
if (Selector.hasTopLevelComma(options.selector)) {
throw new Error(`Selector cannot contain commas: ${options.selector}`);
}
if (!Selector.isValid(options.selector)) {
Expand Down
19 changes: 19 additions & 0 deletions packages/domutils/src/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ export namespace Selector {
export function matches(element: Element, selector: string): boolean {
return Private.protoMatchFunc.call(element, selector);
}

/**
* Remove :is() and :where() functional pseudo-classes so that
* commas inside them do not count as top-level selector separators.
*/
function stripIsWhere(selector: string): string {
return selector.replace(
/:(is|where)\(([^()]|\([^()]*\))*\)/g,
''
);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

stripIsWhere() uses a regex that only handles limited parenthesis nesting. Valid selectors can nest functional pseudos (e.g. :is(:not(:is(.b,.c)))), and in those cases the regex won’t strip the outer :is(...), causing commas inside nested selectors to be treated as “top-level” and rejected. Consider replacing the regex with a small scanner that tracks parentheses depth and whether the current depth is inside an :is( / :where( function.

Suggested change
return selector.replace(
/:(is|where)\(([^()]|\([^()]*\))*\)/g,
''
);
let result = '';
const len = selector.length;
let i = 0;
while (i < len) {
const ch = selector[i];
if (
ch === ':' &&
(selector.startsWith('is(', i + 1) ||
selector.startsWith('where(', i + 1))
) {
// Determine which function name we matched.
const isIs = selector.startsWith('is(', i + 1);
const nameLen = isIs ? 2 : 5; // 'is' or 'where'
const openParenIndex = i + 1 + nameLen;
// Sanity check: ensure there is an opening parenthesis.
if (openParenIndex >= len || selector[openParenIndex] !== '(') {
// Fallback: treat ':' as a normal character.
result += ch;
i++;
continue;
}
// Skip over the entire :is(...) or :where(...) construct,
// correctly handling nested parentheses.
let depth = 1;
let j = openParenIndex + 1;
while (j < len && depth > 0) {
const cj = selector[j];
if (cj === '(') {
depth++;
} else if (cj === ')') {
depth--;
}
j++;
}
// Set i to the position after the closing parenthesis (or end of string).
i = j;
continue;
}
// Normal character, keep it.
result += ch;
i++;
}
return result;

Copilot uses AI. Check for mistakes.
}

/**
* Returns true if the selector contains a top-level comma.
*/
export function hasTopLevelComma(selector: string): boolean {
return stripIsWhere(selector).indexOf(',') !== -1;
}
Comment on lines +97 to +102
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

hasTopLevelComma() now allows selectors like .a:is(.b,.c), but Selector.calculateSpecificity()/Private.calculateSingle() still split the selector at the first comma (selector.split(',', 1)[0]). That will cut commas inside :is()/:where() and produce incorrect specificity values, which affects ordering logic in context menus and keybinding resolution. Please update specificity parsing to ignore commas inside :is()/:where() (or implement their Selectors Level 4 specificity rules) so the new allowed selectors don’t break precedence decisions.

Copilot uses AI. Check for mistakes.

}

/**
Expand Down
18 changes: 18 additions & 0 deletions packages/domutils/tests/src/selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,22 @@ describe('@lumino/domutils', () => {
});
});
});

describe('hasTopLevelComma()', () => {
it('allows commas inside :is()', () => {
expect(Selector.hasTopLevelComma('.a:is(.b,.c)')).to.equal(false);
});

it('allows commas inside :where()', () => {
expect(Selector.hasTopLevelComma('.a:where(.b,.c)')).to.equal(false);
});

it('rejects top-level commas', () => {
expect(Selector.hasTopLevelComma('.a, .b')).to.equal(true);
});

it('rejects mixed selectors', () => {
expect(Selector.hasTopLevelComma('.a:is(.b,.c), .d')).to.equal(true);
});
});
});
2 changes: 1 addition & 1 deletion packages/widgets/src/contextmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ namespace Private {
* invalid or contains commas.
*/
function validateSelector(selector: string): string {
if (selector.indexOf(',') !== -1) {
if (Selector.hasTopLevelComma(selector)) {
throw new Error(`Selector cannot contain commas: ${selector}`);
}
if (!Selector.isValid(selector)) {
Expand Down
Loading