commit 98b486020c1d538e194dccade88916a85e56f201
parent d1c13e06bdda52657db296d75ef9230fc8ec22be
Author: Nicolas Chevobbe <nchevobbe@mozilla.com>
Date: Wed, 10 Dec 2025 16:17:04 +0000
Bug 1895176 - [devtools] Show @position-try rules. r=devtools-reviewers,ochameau.
In a similar fashion to what we do for `@keyframes` rules, we collect the different
`@position-try` rules from the stylesheets of the page, and only return them
when they're actually being used in a `position-try-fallback` declaration.
For now, we show them as read-only, as updating the declarations of `CSSPositionTryRule`
is still brittle (see Bug 2004046).
A test is added to cover this.
Differential Revision: https://phabricator.services.mozilla.com/D275075
Diffstat:
10 files changed, 330 insertions(+), 19 deletions(-)
diff --git a/devtools/client/fronts/style-rule.js b/devtools/client/fronts/style-rule.js
@@ -69,6 +69,9 @@ class StyleRuleFront extends FrontClassWithSpec(styleRuleSpec) {
get type() {
return this._form.type;
}
+ get className() {
+ return this._form.className;
+ }
get line() {
return this._form.line || -1;
}
diff --git a/devtools/client/inspector/rules/models/rule.js b/devtools/client/inspector/rules/models/rule.js
@@ -904,7 +904,12 @@ class Rule {
* @returns {boolean} Whether or not the rule can be edited
*/
isEditable() {
- return !this.isSystem && this.domRule.type !== PRES_HINTS;
+ return (
+ !this.isSystem &&
+ this.domRule.type !== PRES_HINTS &&
+ // FIXME: Should be removed as part of Bug 2004046
+ this.domRule.className !== "CSSPositionTryRule"
+ );
}
/**
diff --git a/devtools/client/inspector/rules/rules.js b/devtools/client/inspector/rules/rules.js
@@ -94,6 +94,7 @@ const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/;
const RULE_VIEW_HEADER_CLASSNAME = "ruleview-header";
const PSEUDO_ELEMENTS_CONTAINER_ID = "pseudo-elements-container";
const REGISTERED_PROPERTIES_CONTAINER_ID = "registered-properties-container";
+const POSITION_TRY_CONTAINER_ID = "position-try-container";
/**
* Our model looks like this:
@@ -1606,6 +1607,17 @@ CssRuleView.prototype = {
)
);
}
+ } else if (rule.domRule.className === "CSSPositionTryRule") {
+ containerKey = POSITION_TRY_CONTAINER_ID;
+ if (!containers.has(containerKey)) {
+ containers.set(
+ containerKey,
+ this.createExpandableContainer(
+ `@position-try`,
+ `position-try-container`
+ )
+ );
+ }
}
rule.editor.element.setAttribute("role", "article");
@@ -1696,7 +1708,10 @@ CssRuleView.prototype = {
let isSelectorHighlighted = false;
let selectorNodes = [...rule.editor.selectorText.childNodes];
- if (rule.domRule.type === CSSRule.KEYFRAME_RULE) {
+ if (
+ rule.domRule.type === CSSRule.KEYFRAME_RULE ||
+ rule.domRule.className === "CSSPositionTryRule"
+ ) {
selectorNodes = [rule.editor.selectorText];
} else if (rule.domRule.type === ELEMENT_STYLE) {
selectorNodes = [];
diff --git a/devtools/client/inspector/rules/test/browser_part2.toml b/devtools/client/inspector/rules/test/browser_part2.toml
@@ -246,6 +246,8 @@ fail-if = [
"a11y_checks", # Bug 1849028 clicked element may not be focusable and/or labeled
]
+["browser_rules_position-try.js"]
+
["browser_rules_preview-tooltips-sizes.js"]
["browser_rules_print_media_simulation.js"]
diff --git a/devtools/client/inspector/rules/test/browser_rules_position-try.js b/devtools/client/inspector/rules/test/browser_rules_position-try.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that @position-try rules are displayed in a dedicated section.
+
+const TEST_URI = `https://example.org/document-builder.sjs?html=${encodeURIComponent(`
+ <style>
+ .anchor {
+ anchor-name: --test-anchor;
+ }
+
+ .anchored {
+ position: fixed;
+ position-anchor: --test-anchor;
+ color: peachpuff;
+ }
+
+ .no-at-position-try {
+ position-try: top;
+ }
+
+ .unknown-at-position-try {
+ position-try: top, --unknown;
+ }
+
+ .single-at-position-try {
+ position-try: bottom, --custom-bottom;
+ }
+
+ .multiple-at-position-try {
+ position-try: left, --custom-right,--custom-bottom;
+ }
+
+ @position-try --custom-bottom {
+ top: anchor(bottom);
+ color: gold;
+ }
+
+ @position-try --custom-right {
+ top: anchor(bottom);
+ left: anchor(right);
+ color: tomato;
+ }
+ </style>
+ <main>
+ <div class=anchor>⚓️</div>
+ <span class="anchored no-at-position-try"></span>
+ <span class="anchored unknown-at-position-try"></span>
+ <span class="anchored single-at-position-try"></span>
+ <span class="anchored multiple-at-position-try"></span>
+ </main>
+`)}`;
+
+add_task(async function () {
+ await pushPref("layout.css.anchor-positioning.enabled", true);
+ await addTab(TEST_URI);
+ const { inspector, view } = await openRuleView();
+
+ info(
+ "Check that the @property-try section isn't displayed if the at-rules are not used in the position-try declaration"
+ );
+ await selectNode(".anchored.no-at-position-try", inspector);
+
+ const anchoredClassRuleItem = {
+ selector: ".anchored",
+ declarations: [
+ { name: "position", value: "fixed" },
+ { name: "position-anchor", value: "--test-anchor" },
+ { name: "color", value: "peachpuff" },
+ ],
+ };
+
+ await checkRuleViewContent(view, [
+ {
+ selector: "element",
+ selectorEditable: false,
+ declarations: [],
+ },
+ {
+ selector: ".no-at-position-try",
+ declarations: [{ name: "position-try", value: "top" }],
+ },
+ anchoredClassRuleItem,
+ ]);
+
+ info(
+ "Check that the @property-try section isn't displayed if the the position-try value " +
+ "refers to an unknown dashed ident"
+ );
+ await selectNode(".anchored.unknown-at-position-try", inspector);
+ await checkRuleViewContent(view, [
+ {
+ selector: "element",
+ selectorEditable: false,
+ declarations: [],
+ },
+ {
+ selector: ".unknown-at-position-try",
+ declarations: [{ name: "position-try", value: "top, --unknown" }],
+ },
+ anchoredClassRuleItem,
+ ]);
+
+ info(
+ "Check that the @property-try section is displayed and has expected content if a" +
+ "dashed ident is used in the position-try declaration"
+ );
+ await selectNode(".anchored.single-at-position-try", inspector);
+ await checkRuleViewContent(view, [
+ {
+ selector: "element",
+ selectorEditable: false,
+ declarations: [],
+ },
+ {
+ selector: ".single-at-position-try",
+ declarations: [
+ { name: "position-try", value: "bottom, --custom-bottom" },
+ ],
+ },
+ anchoredClassRuleItem,
+ {
+ header: `@position-try`,
+ },
+ {
+ selector: "--custom-bottom",
+ selectorEditable: false,
+ hasSelectorHighlighterButton: false,
+ declarations: [
+ { name: "top", value: "anchor(bottom)" },
+ // we have this here to make sure it's not marked as overridden / does not override
+ // color declaration for regular rules.
+ // Ultimately this should be marked as inactive (see Bug 1895178)
+ { name: "color", value: "gold" },
+ ],
+ },
+ ]);
+
+ info(
+ "Check that the @property-try section is displayed and has expected content if multiple " +
+ "dashed-ident are used in the position-try declaration"
+ );
+ await selectNode(".anchored.multiple-at-position-try", inspector);
+ await checkRuleViewContent(view, [
+ {
+ selector: "element",
+ selectorEditable: false,
+ declarations: [],
+ },
+ {
+ selector: ".multiple-at-position-try",
+ declarations: [
+ { name: "position-try", value: "left, --custom-right,--custom-bottom" },
+ ],
+ },
+ anchoredClassRuleItem,
+ {
+ header: `@position-try`,
+ },
+ {
+ selector: "--custom-bottom",
+ selectorEditable: false,
+ hasSelectorHighlighterButton: false,
+ declarations: [
+ { name: "top", value: "anchor(bottom)" },
+ // we have this here to make sure it's not marked as overridden / does not override
+ // color declaration for regular rules.
+ // Ultimately this should be marked as inactive (see Bug 1895178)
+ { name: "color", value: "gold" },
+ ],
+ },
+ {
+ selector: "--custom-right",
+ selectorEditable: false,
+ hasSelectorHighlighterButton: false,
+ declarations: [
+ { name: "top", value: "anchor(bottom)" },
+ { name: "left", value: "anchor(right)" },
+ // we have this here to make sure it's not marked as overridden / does not override
+ // color declaration for regular rules.
+ // Ultimately this should be marked as inactive (see Bug 1895178)
+ { name: "color", value: "tomato" },
+ ],
+ },
+ ]);
+
+ info("Check that we can filter on the @position-try name");
+ await setSearchFilter(view, "--custom-r");
+
+ await checkRuleViewContent(view, [
+ {
+ selector: "element",
+ selectorEditable: false,
+ declarations: [],
+ },
+ {
+ selector: ".multiple-at-position-try",
+ declarations: [
+ {
+ name: "position-try",
+ value: "left, --custom-right,--custom-bottom",
+ highlighted: true,
+ },
+ ],
+ },
+ {
+ header: `@position-try`,
+ },
+ {
+ selector: "--custom-right",
+ selectorEditable: false,
+ hasSelectorHighlighterButton: false,
+ declarations: [
+ { name: "top", value: "anchor(bottom)" },
+ { name: "left", value: "anchor(right)" },
+ { name: "color", value: "tomato" },
+ ],
+ },
+ ]);
+
+ // TODO: At the moment we display @position-try rules as read-only, but as part of
+ // Bug 2004046, we should assert that adding modifying/adding declaration propagates the change
+ // stylesheet as expected, and that the declarations of the rules are properly updated.
+});
diff --git a/devtools/client/inspector/rules/test/head.js b/devtools/client/inspector/rules/test/head.js
@@ -1373,6 +1373,8 @@ function getSmallIncrementKey() {
* unmatched selector with `~~` characters (e.g. "div, ~~unmatched~~")
* @param {boolean} expectedElements[].selectorEditable - Whether or not the selector can
* be edited. Defaults to true.
+ * @param {boolean} expectedElements[].hasSelectorHighlighterButton - Whether or not a
+ * selector highlighter button is visible. Defaults to true.
* @param {string[]|null} expectedElements[].ancestorRulesData - An array of the parent
* selectors of the rule, with their indentations and the opening brace.
* e.g. for the following rule `html { body { span {} } }`, for the `span` rule,
@@ -1458,6 +1460,11 @@ function checkRuleViewContent(view, expectedElements) {
expectedElement.selectorEditable ?? true,
`Selector for element #${i} (${selector}) ${(expectedElement.selectorEditable ?? true) ? "is" : "isn't"} editable`
);
+ is(
+ elementInView.querySelector(`.ruleview-selectorhighlighter`) !== null,
+ expectedElement.hasSelectorHighlighterButton ?? true,
+ `Element #${i} (${selector}) ${(expectedElement.hasSelectorHighlighterButton ?? true) ? "has" : "does not have"} a selector highlighter button`
+ );
const ancestorData = elementInView.querySelector(
`.ruleview-rule-ancestor-data`
diff --git a/devtools/client/inspector/rules/views/rule-editor.js b/devtools/client/inspector/rules/views/rule-editor.js
@@ -144,7 +144,15 @@ RuleEditor.prototype = {
return (
this.isEditable &&
this.rule.domRule.type !== ELEMENT_STYLE &&
- this.rule.domRule.type !== CSSRule.KEYFRAME_RULE
+ this.rule.domRule.type !== CSSRule.KEYFRAME_RULE &&
+ this.rule.domRule.className !== "CSSPositionTryRule"
+ );
+ },
+
+ get showSelectorHighlighterButton() {
+ return (
+ this.rule.domRule.type !== CSSRule.KEYFRAME_RULE &&
+ this.rule.domRule.className !== "CSSPositionTryRule"
);
},
@@ -394,18 +402,20 @@ RuleEditor.prototype = {
// be computed on demand when the highlighter is requested.
}
- const isHighlighted =
- this.ruleView.isSelectorHighlighted(computedSelector);
- // Handling of click events is delegated to CssRuleView.handleEvent()
- createChild(header, "button", {
- class:
- "ruleview-selectorhighlighter js-toggle-selector-highlighter" +
- (isHighlighted ? " highlighted" : ""),
- "aria-pressed": isHighlighted,
- // This is used in rules.js for the selector highlighter
- "data-computed-selector": computedSelector,
- title: l10n("rule.selectorHighlighter.tooltip"),
- });
+ if (this.showSelectorHighlighterButton) {
+ const isHighlighted =
+ this.ruleView.isSelectorHighlighted(computedSelector);
+ // Handling of click events is delegated to CssRuleView.handleEvent()
+ createChild(header, "button", {
+ class:
+ "ruleview-selectorhighlighter js-toggle-selector-highlighter" +
+ (isHighlighted ? " highlighted" : ""),
+ "aria-pressed": isHighlighted,
+ // This is used in rules.js for the selector highlighter
+ "data-computed-selector": computedSelector,
+ title: l10n("rule.selectorHighlighter.tooltip"),
+ });
+ }
}
this.openBrace = createChild(header, "span", {
@@ -662,6 +672,8 @@ RuleEditor.prototype = {
this.selectorText.textContent = this.rule.selectorText;
} else if (this.rule.domRule.type === CSSRule.KEYFRAME_RULE) {
this.selectorText.textContent = this.rule.domRule.keyText;
+ } else if (this.rule.domRule.className === "CSSPositionTryRule") {
+ this.selectorText.textContent = this.rule.domRule.name;
} else {
this.rule.domRule.selectors.forEach((selector, i) => {
this._populateSelector(selector, i);
diff --git a/devtools/server/actors/inspector/css-logic.js b/devtools/server/actors/inspector/css-logic.js
@@ -195,6 +195,9 @@ class CssLogic {
// Cached keyframes rules in all stylesheets
#keyframesRules = null;
+ // Cached @position-try rules in all stylesheets
+ #positionTryRules = null;
+
/**
* Reset various properties
*/
@@ -207,6 +210,7 @@ class CssLogic {
this.#matchedRules = null;
this.#matchedSelectors = null;
this.#keyframesRules = [];
+ this.#positionTryRules = [];
}
/**
@@ -350,7 +354,7 @@ class CssLogic {
* Cache a stylesheet if it falls within the requirements: if it's enabled,
* and if the @media is allowed. This method also walks through the stylesheet
* cssRules to find @imported rules, to cache the stylesheets of those rules
- * as well. In addition, the @keyframes rules in the stylesheet are cached.
+ * as well. In addition, @keyframes and @position-try rules in the stylesheet are cached.
*
* @private
* @param {CSSStyleSheet} domSheet the CSSStyleSheet object to cache.
@@ -370,8 +374,8 @@ class CssLogic {
if (cssSheet.passId != this.passId) {
cssSheet.passId = this.passId;
- // Find import and keyframes rules. We loop through all the stylesheet recursively,
- // so we can go through nested rules.
+ // Find import, keyframes and position-try rules. We loop through all the stylesheet
+ // recursively, so we can go through nested rules.
const traverseRules = ruleList => {
for (const aDomRule of ruleList) {
const ruleClassName = ChromeUtils.getClassName(aDomRule);
@@ -383,6 +387,8 @@ class CssLogic {
this.#cacheSheet(aDomRule.styleSheet);
} else if (ruleClassName === "CSSKeyframesRule") {
this.#keyframesRules.push(aDomRule);
+ } else if (ruleClassName === "CSSPositionTryRule") {
+ this.#positionTryRules.push(aDomRule);
}
if (aDomRule.cssRules) {
@@ -428,6 +434,18 @@ class CssLogic {
}
/**
+ * Retrieve the list of @position-try rules in the document.
+ *
+ * @returns {CSSPositionTryRule[]} the list of @position-try rules in the document.
+ */
+ get positionTryRules() {
+ if (!this.#sheetsCached) {
+ this.#cacheSheets();
+ }
+ return this.#positionTryRules;
+ }
+
+ /**
* Retrieve a CssSheet object for a given a CSSStyleSheet object. If the
* stylesheet is already cached, you get the existing CssSheet object,
* otherwise the new CSSStyleSheet object is cached.
diff --git a/devtools/server/actors/page-style.js b/devtools/server/actors/page-style.js
@@ -1129,9 +1129,9 @@ class PageStyleActor extends Actor {
}
}
- // Add all the keyframes rule associated with the element
const computedStyle = this.cssLogic.computedStyle;
if (computedStyle) {
+ // Add all the keyframes rule associated with the element
let animationNames = computedStyle.animationName.split(",");
animationNames = animationNames.map(name => name.trim());
@@ -1151,6 +1151,25 @@ class PageStyleActor extends Actor {
}
}
}
+
+ // Add all the @position-try associated with the element
+ const positionTryIdents = new Set();
+ for (const part of computedStyle.positionTryFallbacks.split(",")) {
+ const name = part.trim();
+ if (name.startsWith("--")) {
+ positionTryIdents.add(name);
+ }
+ }
+
+ for (const positionTryRule of this.cssLogic.positionTryRules) {
+ if (!positionTryIdents.has(positionTryRule.name)) {
+ continue;
+ }
+
+ entries.push({
+ rule: this.styleRef(positionTryRule),
+ });
+ }
}
return entries;
diff --git a/devtools/server/actors/style-rule.js b/devtools/server/actors/style-rule.js
@@ -406,6 +406,7 @@ class StyleRuleActor extends Actor {
const form = {
actor: this.actorID,
type: this.type,
+ className: this.ruleClassName,
line: this.line || undefined,
column: this.column,
traits: {
@@ -483,6 +484,7 @@ class StyleRuleActor extends Actor {
form.href = this.rawRule.href;
break;
case "CSSKeyframesRule":
+ case "CSSPositionTryRule":
form.name = this.rawRule.name;
break;
case "CSSKeyframeRule":
@@ -641,6 +643,7 @@ class StyleRuleActor extends Actor {
_getCssText() {
switch (this.ruleClassName) {
case "CSSNestedDeclarations":
+ case "CSSPositionTryRule":
case "CSSStyleRule":
case ELEMENT_STYLE:
case PRES_HINTS:
@@ -870,6 +873,7 @@ class StyleRuleActor extends Actor {
"CSSLayerBlockRule",
"CSSMediaRule",
"CSSNestedDeclarations",
+ "CSSPositionTryRule",
"CSSStyleRule",
"CSSSupportsRule",
]);