mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 04:12:25 +00:00
Merge pull request #526 from PR0M3TH3AN/codex/add-dynamic-styles-and-refactor-utilities
Add dynamic design-system stylesheet helper
This commit is contained in:
8 files changed
+507
-36
No files matched your search
@@ -121,6 +121,18 @@
|
||||
@apply flex items-center justify-start py-md;
|
||||
}
|
||||
|
||||
.ds-metric-probe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout size style;
|
||||
inline-size: 0;
|
||||
block-size: var(--ds-metric-probe-length, 0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ds-header__logo {
|
||||
@apply h-24 w-auto max-w-none;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,26 @@ The new design system ships behind the `FEATURE_DESIGN_SYSTEM` runtime flag defi
|
||||
- **Runtime toggle:** Set `window.__BITVID_RUNTIME_FLAGS__.FEATURE_DESIGN_SYSTEM = false` (or call `setFeatureDesignSystemEnabled(false)`) to temporarily fall back to the legacy primitives. The entrypoint automatically updates every `[data-ds]` container to `data-ds="new"` and notifies controllers.
|
||||
- **DOM contract:** Templates and partials must include `data-ds` on their root elements. Controllers should rely on the `designSystem` context (see `js/designSystem.js`) before attaching classes or behaviors that only exist in the new system.
|
||||
|
||||
## Dynamic runtime styling
|
||||
|
||||
Inline styles are blocked in production builds, so runtime components that need bespoke coordinates or measurements must route their overrides through `js/designSystem/dynamicStyles.js`.
|
||||
|
||||
- `registerScope(id, selectors)` creates (or reuses) a constructable stylesheet when supported, allocates a unique identifier, and injects empty rules scoped to `[data-ds-style-id="<id>"]`. Callers attach the attribute to any element that should inherit those rules.
|
||||
- `setVariables(id, tokens)` updates CSS custom properties on the registered scope. Pass CSS variable names (`"--floating-fallback-top"`) and string or number values—`setVariables` handles serialization and removes entries when they resolve to empty strings.
|
||||
- `releaseScope(id)` removes the generated rules and frees the identifier. Always invoke it from component teardown paths to avoid leaking rules between renders.
|
||||
|
||||
The floating panel utility and design-system metrics both lean on this helper:
|
||||
|
||||
- `createFloatingPanelStyles` assigns each popover a unique scope and publishes fallback coordinates through CSS variables instead of mutating `.style`. Positioning logic reads and writes the same API, and teardown releases the scope before removing dataset attributes.
|
||||
- `readDesignTokenAsPixels` measures non-`px`/`rem` tokens with an offscreen probe that ships in core CSS (`.ds-metric-probe`). The probe is registered through the helper so dynamic lengths are fed into `--ds-metric-probe-length` without touching inline styles.
|
||||
|
||||
When introducing a new runtime feature that requires dynamic styling:
|
||||
|
||||
1. Register a dedicated scope during setup and attach the returned attribute to the relevant DOM node.
|
||||
2. Update custom properties with `setVariables` instead of touching `.style` or appending `<style>` tags.
|
||||
3. Clean up by calling `releaseScope` when the component unmounts.
|
||||
4. Prefer reusable primitives (like `.ds-metric-probe`) for offscreen measurement or layout shims so multiple components can share the same CSS.
|
||||
|
||||
## Theming
|
||||
|
||||
### `[data-theme]` contract
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
const DATA_ATTRIBUTE = "data-ds-style-id";
|
||||
|
||||
const DEFAULT_SELECTOR = ":scope";
|
||||
|
||||
const SCOPE_REGISTRY = new Map();
|
||||
const DOCUMENT_MANAGERS = new WeakMap();
|
||||
|
||||
function sanitizeScopeId(baseId) {
|
||||
if (typeof baseId !== "string") {
|
||||
return "ds-scope";
|
||||
}
|
||||
const trimmed = baseId.trim();
|
||||
if (!trimmed) {
|
||||
return "ds-scope";
|
||||
}
|
||||
const normalized = trimmed.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||
return normalized || "ds-scope";
|
||||
}
|
||||
|
||||
function resolveDocument(documentRef) {
|
||||
if (documentRef && typeof documentRef === "object" && documentRef.nodeType === 9) {
|
||||
return documentRef;
|
||||
}
|
||||
if (typeof globalThis.document === "object" && globalThis.document?.nodeType === 9) {
|
||||
return globalThis.document;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsConstructableStylesheets(doc) {
|
||||
return (
|
||||
!!doc &&
|
||||
Array.isArray(doc.adoptedStyleSheets) &&
|
||||
typeof doc.adoptedStyleSheets.splice === "function" &&
|
||||
typeof globalThis.CSSStyleSheet === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureManager(doc) {
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
let manager = DOCUMENT_MANAGERS.get(doc);
|
||||
if (manager) {
|
||||
return manager;
|
||||
}
|
||||
|
||||
let sheet = null;
|
||||
let styleElement = null;
|
||||
const constructable = supportsConstructableStylesheets(doc);
|
||||
|
||||
if (constructable) {
|
||||
sheet = new globalThis.CSSStyleSheet();
|
||||
try {
|
||||
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
||||
} catch (error) {
|
||||
// Fall back to a <style> element if adoption fails at runtime.
|
||||
sheet = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sheet) {
|
||||
styleElement = doc.createElement("style");
|
||||
styleElement.setAttribute("data-ds-dynamic", "true");
|
||||
doc.head?.appendChild(styleElement);
|
||||
sheet = styleElement.sheet || null;
|
||||
}
|
||||
|
||||
manager = {
|
||||
document: doc,
|
||||
sheet,
|
||||
styleElement,
|
||||
counter: 0,
|
||||
scopes: new Map(),
|
||||
};
|
||||
DOCUMENT_MANAGERS.set(doc, manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
function generateScopeId(baseId, manager) {
|
||||
const prefix = sanitizeScopeId(baseId);
|
||||
if (!SCOPE_REGISTRY.has(prefix) && !manager.scopes.has(prefix)) {
|
||||
return prefix;
|
||||
}
|
||||
let suffix = 1;
|
||||
let candidate = `${prefix}-${suffix}`;
|
||||
while (SCOPE_REGISTRY.has(candidate) || manager.scopes.has(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = `${prefix}-${suffix}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function buildSelector(scopeId, selector) {
|
||||
const token = `[${DATA_ATTRIBUTE}="${scopeId}"]`;
|
||||
if (typeof selector !== "string" || !selector.trim()) {
|
||||
return token;
|
||||
}
|
||||
if (selector.includes("&")) {
|
||||
return selector.replace(/&/g, token);
|
||||
}
|
||||
if (selector.includes(":scope")) {
|
||||
return selector.replace(/:scope/g, token);
|
||||
}
|
||||
const trimmed = selector.trim();
|
||||
if (/^[>+~]/.test(trimmed)) {
|
||||
return `${token}${trimmed}`;
|
||||
}
|
||||
return `${token} ${trimmed}`;
|
||||
}
|
||||
|
||||
function insertEmptyRule(sheet, selectorText) {
|
||||
if (!sheet || typeof sheet.insertRule !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const index = sheet.insertRule(`${selectorText} {}`, sheet.cssRules.length);
|
||||
return sheet.cssRules[index] || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findRuleIndex(sheet, rule) {
|
||||
if (!sheet || !rule) {
|
||||
return -1;
|
||||
}
|
||||
const { cssRules } = sheet;
|
||||
for (let index = 0; index < cssRules.length; index += 1) {
|
||||
if (cssRules[index] === rule) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeSelectors(selectors) {
|
||||
if (!Array.isArray(selectors)) {
|
||||
return [DEFAULT_SELECTOR];
|
||||
}
|
||||
const filtered = selectors
|
||||
.map((selector) => (typeof selector === "string" ? selector.trim() : ""))
|
||||
.filter((selector) => selector !== "");
|
||||
if (filtered.length === 0) {
|
||||
return [DEFAULT_SELECTOR];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function registerScope(baseId, selectors = [DEFAULT_SELECTOR], options = {}) {
|
||||
const doc = resolveDocument(options.documentRef);
|
||||
const manager = ensureManager(doc);
|
||||
if (!manager || !manager.sheet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scopeId = generateScopeId(baseId, manager);
|
||||
const normalizedSelectors = normalizeSelectors(selectors);
|
||||
const records = [];
|
||||
|
||||
for (const selector of normalizedSelectors) {
|
||||
const selectorText = buildSelector(scopeId, selector);
|
||||
const rule = insertEmptyRule(manager.sheet, selectorText);
|
||||
if (rule) {
|
||||
records.push({ selector, rule });
|
||||
}
|
||||
}
|
||||
|
||||
const scopeRecord = {
|
||||
id: scopeId,
|
||||
manager,
|
||||
rules: records,
|
||||
};
|
||||
|
||||
manager.scopes.set(scopeId, scopeRecord);
|
||||
SCOPE_REGISTRY.set(scopeId, scopeRecord);
|
||||
|
||||
return scopeId;
|
||||
}
|
||||
|
||||
function normalizeVariableValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return `${value}`;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function setVariables(scopeId, variables = {}, { selector = DEFAULT_SELECTOR } = {}) {
|
||||
if (!scopeId || typeof variables !== "object" || variables === null) {
|
||||
return false;
|
||||
}
|
||||
const scope = SCOPE_REGISTRY.get(scopeId);
|
||||
if (!scope || !scope.manager?.sheet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetRecord = scope.rules.find((record) => record.selector === selector) ||
|
||||
scope.rules.find((record) => record.selector === DEFAULT_SELECTOR);
|
||||
|
||||
if (!targetRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = targetRecord.rule;
|
||||
const ruleStyle = rule ? rule["style"] : null;
|
||||
if (!ruleStyle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let didUpdate = false;
|
||||
for (const [name, rawValue] of Object.entries(variables)) {
|
||||
if (typeof name !== "string" || !name.trim().startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
const value = normalizeVariableValue(rawValue);
|
||||
if (value) {
|
||||
ruleStyle.setProperty(name.trim(), value);
|
||||
} else {
|
||||
ruleStyle.removeProperty(name.trim());
|
||||
}
|
||||
didUpdate = true;
|
||||
}
|
||||
|
||||
return didUpdate;
|
||||
}
|
||||
|
||||
export function releaseScope(scopeId) {
|
||||
if (!scopeId) {
|
||||
return false;
|
||||
}
|
||||
const scope = SCOPE_REGISTRY.get(scopeId);
|
||||
if (!scope || !scope.manager?.sheet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { sheet } = scope.manager;
|
||||
let removed = false;
|
||||
|
||||
for (const record of scope.rules) {
|
||||
const rule = record.rule;
|
||||
if (!rule) {
|
||||
continue;
|
||||
}
|
||||
const index = findRuleIndex(sheet, rule);
|
||||
if (index >= 0) {
|
||||
try {
|
||||
sheet.deleteRule(index);
|
||||
removed = true;
|
||||
} catch (error) {
|
||||
// Ignore deletion failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.manager.scopes.delete(scopeId);
|
||||
SCOPE_REGISTRY.delete(scopeId);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function getScopeAttributeName() {
|
||||
return DATA_ATTRIBUTE;
|
||||
}
|
||||
|
||||
export default {
|
||||
registerScope,
|
||||
setVariables,
|
||||
releaseScope,
|
||||
getScopeAttributeName,
|
||||
};
|
||||
+61
-22
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
registerScope,
|
||||
setVariables,
|
||||
getScopeAttributeName,
|
||||
} from "./dynamicStyles.js";
|
||||
|
||||
const TOKEN_FALLBACKS = {
|
||||
"--grid-card-min-width": "20rem",
|
||||
"--popover-backdrop-blur": "12px",
|
||||
@@ -7,8 +13,13 @@ const TOKEN_FALLBACKS = {
|
||||
"--popup-padding-block": "0.75rem",
|
||||
};
|
||||
|
||||
const PROBE_SCOPE_BASE = "metric-probe";
|
||||
const PROBE_LENGTH_VAR = "--ds-metric-probe-length";
|
||||
|
||||
const PROBE_CACHE = new WeakMap();
|
||||
|
||||
function resolveDocument(documentRef) {
|
||||
if (documentRef && typeof documentRef === "object") {
|
||||
if (documentRef && typeof documentRef === "object" && documentRef.nodeType === 9) {
|
||||
return documentRef;
|
||||
}
|
||||
return typeof globalThis.document === "object" ? globalThis.document : null;
|
||||
@@ -60,10 +71,10 @@ export function readDesignToken(tokenName, { documentRef } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (doc?.documentElement?.style) {
|
||||
const inlineValue = normalizeTokenValue(
|
||||
doc.documentElement.style.getPropertyValue(token),
|
||||
);
|
||||
const root = doc?.documentElement || null;
|
||||
const inlineStyles = root ? root["style"] : null;
|
||||
if (inlineStyles) {
|
||||
const inlineValue = normalizeTokenValue(inlineStyles.getPropertyValue(token));
|
||||
if (inlineValue) {
|
||||
return inlineValue;
|
||||
}
|
||||
@@ -77,6 +88,47 @@ function parsePixelValue(value) {
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
function ensureProbe(documentRef) {
|
||||
const doc = resolveDocument(documentRef);
|
||||
if (!doc?.body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let record = PROBE_CACHE.get(doc);
|
||||
if (record?.element?.isConnected) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const element = doc.createElement("div");
|
||||
element.className = "ds-metric-probe";
|
||||
|
||||
const scopeId = registerScope(PROBE_SCOPE_BASE, [":scope"], { documentRef: doc });
|
||||
if (scopeId) {
|
||||
element.setAttribute(getScopeAttributeName(), scopeId);
|
||||
setVariables(scopeId, { [PROBE_LENGTH_VAR]: "0" });
|
||||
}
|
||||
|
||||
doc.body.appendChild(element);
|
||||
record = { element, scopeId };
|
||||
PROBE_CACHE.set(doc, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function measureWithProbe(value, documentRef) {
|
||||
const record = ensureProbe(documentRef);
|
||||
if (!record?.element) {
|
||||
return 0;
|
||||
}
|
||||
if (record.scopeId) {
|
||||
setVariables(record.scopeId, { [PROBE_LENGTH_VAR]: value });
|
||||
}
|
||||
const measured = record.element.offsetHeight;
|
||||
if (record.scopeId) {
|
||||
setVariables(record.scopeId, { [PROBE_LENGTH_VAR]: "0" });
|
||||
}
|
||||
return Number.isFinite(measured) ? measured : 0;
|
||||
}
|
||||
|
||||
function convertLengthToPixels(value, documentRef) {
|
||||
const raw = normalizeTokenValue(value);
|
||||
if (!raw) {
|
||||
@@ -107,23 +159,9 @@ function convertLengthToPixels(value, documentRef) {
|
||||
return rem * base;
|
||||
}
|
||||
|
||||
const doc = resolveDocument(documentRef);
|
||||
if (doc?.createElement && doc?.body) {
|
||||
try {
|
||||
const probe = doc.createElement("div");
|
||||
probe.style.position = "absolute";
|
||||
probe.style.visibility = "hidden";
|
||||
probe.style.width = "0";
|
||||
probe.style.height = raw;
|
||||
doc.body.appendChild(probe);
|
||||
const measured = probe.offsetHeight;
|
||||
doc.body.removeChild(probe);
|
||||
if (Number.isFinite(measured)) {
|
||||
return measured;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore measurement errors and fall through to numeric parsing.
|
||||
}
|
||||
const measured = measureWithProbe(raw, documentRef);
|
||||
if (measured > 0) {
|
||||
return measured;
|
||||
}
|
||||
|
||||
return parsePixelValue(raw);
|
||||
@@ -206,3 +244,4 @@ export const designMetrics = {
|
||||
};
|
||||
|
||||
export default designMetrics;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
registerScope,
|
||||
setVariables,
|
||||
releaseScope,
|
||||
getScopeAttributeName,
|
||||
} from "../../designSystem/dynamicStyles.js";
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
placement: "bottom",
|
||||
alignment: "start",
|
||||
@@ -10,6 +17,8 @@ const DEFAULT_STATE = {
|
||||
|
||||
const FALLBACK_TOP_VAR = "--floating-fallback-top";
|
||||
const FALLBACK_LEFT_VAR = "--floating-fallback-left";
|
||||
const SCOPE_BASE_ID = "floating-panel";
|
||||
const SCOPE_SELECTORS = [":scope"];
|
||||
|
||||
function toCssPixel(value) {
|
||||
if (Number.isFinite(value)) {
|
||||
@@ -36,18 +45,36 @@ function ensureDatasetValue(element, key, value) {
|
||||
element.dataset[key] = value;
|
||||
}
|
||||
|
||||
function applyFallbackVariables(scopeId, variables) {
|
||||
if (!scopeId) {
|
||||
return;
|
||||
}
|
||||
setVariables(scopeId, variables);
|
||||
}
|
||||
|
||||
export function createFloatingPanelStyles(panel) {
|
||||
const safePanel = panel || null;
|
||||
const state = { ...DEFAULT_STATE };
|
||||
|
||||
const documentRef = safePanel?.ownerDocument || null;
|
||||
const scopeId = safePanel
|
||||
? registerScope(SCOPE_BASE_ID, SCOPE_SELECTORS, { documentRef })
|
||||
: null;
|
||||
|
||||
if (safePanel) {
|
||||
safePanel.dataset.floatingPanel = "true";
|
||||
safePanel.style.setProperty(FALLBACK_TOP_VAR, "auto");
|
||||
safePanel.style.setProperty(FALLBACK_LEFT_VAR, "auto");
|
||||
if (scopeId) {
|
||||
safePanel.setAttribute(getScopeAttributeName(), scopeId);
|
||||
applyFallbackVariables(scopeId, {
|
||||
[FALLBACK_TOP_VAR]: "auto",
|
||||
[FALLBACK_LEFT_VAR]: "auto",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
element: safePanel,
|
||||
scopeId,
|
||||
setPlacement(value) {
|
||||
state.placement = typeof value === "string" && value ? value : DEFAULT_STATE.placement;
|
||||
ensureDatasetValue(safePanel, "floatingPlacement", state.placement);
|
||||
@@ -91,9 +118,11 @@ export function createFloatingPanelStyles(panel) {
|
||||
setFallbackPosition({ top, left }) {
|
||||
state.fallbackTop = Number.isFinite(top) ? Number(top) : null;
|
||||
state.fallbackLeft = Number.isFinite(left) ? Number(left) : null;
|
||||
if (safePanel) {
|
||||
safePanel.style.setProperty(FALLBACK_TOP_VAR, toCssPixel(state.fallbackTop));
|
||||
safePanel.style.setProperty(FALLBACK_LEFT_VAR, toCssPixel(state.fallbackLeft));
|
||||
if (scopeId) {
|
||||
applyFallbackVariables(scopeId, {
|
||||
[FALLBACK_TOP_VAR]: toCssPixel(state.fallbackTop),
|
||||
[FALLBACK_LEFT_VAR]: toCssPixel(state.fallbackLeft),
|
||||
});
|
||||
}
|
||||
return { ...api.getFallbackPosition() };
|
||||
},
|
||||
@@ -104,14 +133,17 @@ export function createFloatingPanelStyles(panel) {
|
||||
};
|
||||
},
|
||||
teardown() {
|
||||
if (scopeId) {
|
||||
applyFallbackVariables(scopeId, {
|
||||
[FALLBACK_TOP_VAR]: "auto",
|
||||
[FALLBACK_LEFT_VAR]: "auto",
|
||||
});
|
||||
releaseScope(scopeId);
|
||||
}
|
||||
if (!safePanel) {
|
||||
return;
|
||||
}
|
||||
safePanel.style.removeProperty(FALLBACK_TOP_VAR);
|
||||
safePanel.style.removeProperty(FALLBACK_LEFT_VAR);
|
||||
safePanel.style.removeProperty("right");
|
||||
safePanel.style.removeProperty("bottom");
|
||||
safePanel.style.removeProperty("position");
|
||||
safePanel.removeAttribute(getScopeAttributeName());
|
||||
delete safePanel.dataset.floatingPanel;
|
||||
delete safePanel.dataset.floatingPlacement;
|
||||
delete safePanel.dataset.floatingAlignment;
|
||||
|
||||
@@ -39,10 +39,7 @@ const IGNORED_FILES = new Set([
|
||||
"sw.min.js",
|
||||
]);
|
||||
|
||||
const VIOLATION_ALLOWLIST = new Map([
|
||||
["js/ui/utils/floatingPanelStyles.js", new Set(["Direct .style usage"])],
|
||||
["js/designSystem/metrics.js", new Set(["Direct .style usage"])],
|
||||
]);
|
||||
const VIOLATION_ALLOWLIST = new Map();
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".astro",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
readDesignTokenAsPixels,
|
||||
} from "../js/designSystem/metrics.js";
|
||||
|
||||
function getScopeRule(document, scopeId) {
|
||||
const styleEl = document.querySelector('style[data-ds-dynamic="true"]');
|
||||
const rules = [];
|
||||
if (styleEl?.sheet?.cssRules) {
|
||||
rules.push(...Array.from(styleEl.sheet.cssRules));
|
||||
}
|
||||
const adoptedSheets = document.adoptedStyleSheets;
|
||||
if (Array.isArray(adoptedSheets)) {
|
||||
for (const sheet of adoptedSheets) {
|
||||
if (sheet?.cssRules) {
|
||||
rules.push(...Array.from(sheet.cssRules));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules.find((cssRule) => cssRule.selectorText === `[data-ds-style-id="${scopeId}"]`) || null;
|
||||
}
|
||||
|
||||
test("metric probe falls back to numeric parsing when measurement fails", () => {
|
||||
const dom = new JSDOM(`<!doctype html><html><head></head><body></body></html>`, {
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const { document } = dom.window;
|
||||
|
||||
Object.defineProperty(dom.window.HTMLDivElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return Number.parseFloat(this.dataset.mockHeight || "0");
|
||||
},
|
||||
});
|
||||
|
||||
const rootStyle = document.documentElement["style"];
|
||||
rootStyle?.setProperty("--probe-test", "12q");
|
||||
const fallback = readDesignTokenAsPixels("--probe-test", { documentRef: document });
|
||||
assert.equal(fallback, 12);
|
||||
|
||||
const probe = document.querySelector(".ds-metric-probe");
|
||||
assert.ok(probe, "probe element should be created");
|
||||
const scopeId = probe.getAttribute("data-ds-style-id");
|
||||
assert.ok(scopeId, "probe should be registered with a dynamic scope");
|
||||
probe.dataset.mockHeight = "48";
|
||||
|
||||
const initialRule = getScopeRule(document, scopeId);
|
||||
assert.ok(initialRule, "metric probe should have a dynamic rule");
|
||||
|
||||
rootStyle?.setProperty("--probe-test", "3vh");
|
||||
const measured = readDesignTokenAsPixels("--probe-test", { documentRef: document });
|
||||
assert.equal(measured, 48);
|
||||
|
||||
const rule = getScopeRule(document, scopeId);
|
||||
assert.ok(rule, "metric probe rule should persist");
|
||||
const ruleStyle = rule ? rule["style"] : null;
|
||||
assert.ok(ruleStyle, "probe rule should expose a CSSStyleDeclaration");
|
||||
assert.equal(ruleStyle.getPropertyValue("--ds-metric-probe-length"), "0");
|
||||
});
|
||||
@@ -4,6 +4,23 @@ import { JSDOM } from "jsdom";
|
||||
import positionFloatingPanel from "../js/ui/utils/positionFloatingPanel.js";
|
||||
import { createFloatingPanelStyles } from "../js/ui/utils/floatingPanelStyles.js";
|
||||
|
||||
function getScopeRule(document, scopeId) {
|
||||
const styleEl = document.querySelector('style[data-ds-dynamic="true"]');
|
||||
const rules = [];
|
||||
if (styleEl?.sheet?.cssRules) {
|
||||
rules.push(...Array.from(styleEl.sheet.cssRules));
|
||||
}
|
||||
const adoptedSheets = document.adoptedStyleSheets;
|
||||
if (Array.isArray(adoptedSheets)) {
|
||||
for (const sheet of adoptedSheets) {
|
||||
if (sheet?.cssRules) {
|
||||
rules.push(...Array.from(sheet.cssRules));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules.find((cssRule) => cssRule.selectorText === `[data-ds-style-id="${scopeId}"]`) || null;
|
||||
}
|
||||
|
||||
test("flips to top when bottom placement collides with viewport", () => {
|
||||
const dom = new JSDOM(
|
||||
`
|
||||
@@ -61,10 +78,20 @@ test("flips to top when bottom placement collides with viewport", () => {
|
||||
assert.equal(styles.getStrategy(), "fixed");
|
||||
assert.deepEqual(styles.getFallbackPosition(), { top: 132, left: 100 });
|
||||
assert.equal(panel.dataset.floatingMode, "fallback");
|
||||
const scopeId = panel.getAttribute("data-ds-style-id");
|
||||
assert.ok(scopeId, "panel should receive a dynamic style scope id");
|
||||
const rule = getScopeRule(document, scopeId);
|
||||
assert.ok(rule, "scope rule should exist for the floating panel");
|
||||
const ruleStyleTop = rule ? rule["style"] : null;
|
||||
assert.ok(ruleStyleTop, "floating panel rule should expose a CSSStyleDeclaration");
|
||||
assert.equal(ruleStyleTop.getPropertyValue("--floating-fallback-top"), "132px");
|
||||
assert.equal(ruleStyleTop.getPropertyValue("--floating-fallback-left"), "100px");
|
||||
|
||||
positioner.destroy();
|
||||
assert.equal(panel.dataset.floatingPanel, undefined);
|
||||
assert.equal(trigger.dataset.floatingAnchor, undefined);
|
||||
const ruleAfterDestroy = getScopeRule(document, scopeId);
|
||||
assert.equal(ruleAfterDestroy, null);
|
||||
});
|
||||
|
||||
test("respects RTL alignment and clamps within the viewport", () => {
|
||||
@@ -194,10 +221,17 @@ test("updates when scroll containers move the trigger", async () => {
|
||||
|
||||
assert.deepEqual(styles.getFallbackPosition(), { top: 152, left: 40 });
|
||||
assert.ok(scrollHandler, "scroll listener should be registered");
|
||||
const scopeId = panel.getAttribute("data-ds-style-id");
|
||||
const rule = getScopeRule(document, scopeId);
|
||||
assert.ok(rule, "dynamic rule should be present for scroll updates");
|
||||
const fallbackRuleStyle = rule ? rule["style"] : null;
|
||||
assert.ok(fallbackRuleStyle, "dynamic rule should be present for scroll updates");
|
||||
assert.equal(fallbackRuleStyle.getPropertyValue("--floating-fallback-top"), "152px");
|
||||
|
||||
anchorTop = 60;
|
||||
scrollHandler?.({ type: "scroll" });
|
||||
assert.deepEqual(styles.getFallbackPosition(), { top: 112, left: 40 });
|
||||
assert.equal(fallbackRuleStyle.getPropertyValue("--floating-fallback-top"), "112px");
|
||||
|
||||
positioner.destroy();
|
||||
window.addEventListener = originalAddEventListener;
|
||||
|
||||
Reference in new issue
Block a user