Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/skins/src/styles/layout/poster.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export default styles({
file: 'poster.css',
prefix: 'media-poster',
rules: {
// `<media-poster>` hosts a shadow root, and the skin's image is slotted through it.
root: {
shadowHost: true,
utilities: [
'pointer-events-none layer-media',
'transition-opacity duration-media-slower not-data-visible:opacity-0',
Expand All @@ -18,6 +20,7 @@ export default styles({
},
},
image: {
shadowHost: true,
utilities: ['layer-media object-media', '[&:not([src]):not([srcset])]:invisible'],
},
},
Expand Down
2 changes: 2 additions & 0 deletions packages/skins/src/styles/popups/popup.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default styles({
utilities: ['transition-media-popup data-ending-style:duration-media-instant'],
},
surface: {
// Also carried by `<media-slider-thumbnail>`, which hosts a shadow root.
shadowHost: true,
utilities: 'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset',
variants: {
minimal: 'after:hidden',
Expand Down
2 changes: 2 additions & 0 deletions packages/skins/src/styles/sliders/slider.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export default styles({
},
},
previewContent: {
// Also carried by `<media-slider-thumbnail>`, which hosts a shadow root.
shadowHost: true,
utilities: [
'absolute max-w-(--media-slider-preview-max-width) -translate-x-1/2 translate-y-media-hidden-preview-offset scale-media-hidden-preview opacity-0',
'origin-bottom blur-media-hidden',
Expand Down
4 changes: 4 additions & 0 deletions packages/skins/src/styles/sliders/thumbnail.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export default styles({
file: 'sliders.css',
prefix: 'media-slider-thumbnail',
rules: {
// `<media-slider-thumbnail>` hosts a shadow root, and the image and spinner are slotted through it.
root: {
shadowHost: true,
utilities: [
'group/thumbnail pointer-events-none overflow-hidden rounded-media-popup bg-media-backdrop/90',
'bottom-[calc(100%+var(--media-slider-preview-offset))]',
Expand All @@ -20,9 +22,11 @@ export default styles({
},
},
image: {
shadowHost: true,
utilities: ['block transition-opacity duration-media-base ease-out', 'group-data-loading/thumbnail:opacity-0'],
},
spinnerIcon: {
shadowHost: true,
utilities: [
'absolute top-1/2 left-1/2 z-10 size-media-icon -translate-x-1/2 -translate-y-1/2 opacity-0',
'transition-opacity duration-media-base ease-out',
Expand Down
5 changes: 3 additions & 2 deletions packages/vjsc/src/styles/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export interface StyleRule {
/** Also match this class when it is colocated on the configured CSS scope root. */
readonly scopeRoot?: boolean | undefined;
/**
* The styled element hosts a shadow root. WebKit does not match `@scope` rules whose subject hosts a shadow root, so
* these rules are emitted outside the scope block with the scope root as an ancestor instead.
* The styled element hosts a shadow root, or is a light-DOM child composed through one. WebKit does not match
* `@scope` rules whose subject hosts a shadow root or is slotted into one, so these rules are also emitted outside
* the scope block with the scope root as an ancestor.
*/
readonly shadowHost?: boolean | undefined;
/** Tailwind utilities shared by every configured variant. */
Expand Down
35 changes: 26 additions & 9 deletions packages/vjsc/src/styles/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,23 @@ function wrapFileCss(css: string, scope: string | undefined, file: StyleOutputFi
}

/**
* Keep the rules `@scope` cannot serve outside the scope block. Slotted nodes sit outside a shadow tree's CSS scope,
* and WebKit never matches a scoped rule whose subject hosts a shadow root; those rules take the scope root as an
* ancestor instead. Conditional at-rules retain their conditions when their matching rules move.
* Keep the rules `@scope` cannot serve outside the scope block. Slotted nodes sit outside a shadow tree's CSS scope, so
* their rules move out. WebKit never matches a scoped rule whose subject hosts a shadow root or is slotted into one, so
* a rule on a shadow host class is emitted twice: the scoped rule stays for engines that match it, and a copy with the
* scope root as a zero-specificity ancestor follows for WebKit. The copy never outranks the original, so the cascade
* elsewhere is unchanged. Conditional at-rules retain their conditions when their matching rules move or copy.
*/
function splitUnscopedRules(css: string, scope: string, shadowHostClasses: ReadonlySet<string>) {
let hasSlottedRules = false;
let hasShadowHostRules = false;
const isShadowHostRule = (rule: Rule) => isShadowHostStyleRule(rule, shadowHostClasses);
const isShadowHostRule = (rule: Rule) => !isSlottedStyleRule(rule) && isShadowHostStyleRule(rule, shadowHostClasses);
const scoped = filterCssRules(css, (rule) => {
const slotted = isSlottedStyleRule(rule);
const shadowHost = !slotted && isShadowHostRule(rule);

hasSlottedRules ||= slotted;
hasShadowHostRules ||= shadowHost;
hasShadowHostRules ||= isShadowHostRule(rule);

return !slotted && !shadowHost;
return !slotted;
});

const slotted = hasSlottedRules ? filterCssRules(css, isSlottedStyleRule) : '';
Expand Down Expand Up @@ -198,14 +199,30 @@ function filterNestedRules(rules: readonly Rule[], include: (rule: Rule) => bool
return filtered;
}

/** A rule whose selectors all start from a class of an element that hosts a shadow root. */
/**
* A rule with a selector whose subject carries a shadow host class. The subject is the last compound, so a relationship
* selector such as `:where(.owner)[data-x] .subject` counts by its `.subject`, and a pseudo-element on the subject is
* looked past.
*/
function isShadowHostStyleRule(rule: Rule, shadowHostClasses: ReadonlySet<string>): boolean {
return (
rule.type === 'style' &&
rule.value.selectors.every((selector) => selector[0]?.type === 'class' && shadowHostClasses.has(selector[0].name))
rule.value.selectors.some((selector) =>
subjectCompound(selector).some((component) => component.type === 'class' && shadowHostClasses.has(component.name))
)
);
}

function subjectCompound(selector: Selector): Selector {
let start = 0;

for (const [index, component] of selector.entries()) {
if (component.type === 'combinator') start = index + 1;
}

return selector.slice(start).filter((component) => component.type !== 'pseudo-element');
}

function isSlottedStyleRule(rule: Rule): boolean {
return (
rule.type === 'style' &&
Expand Down
25 changes: 17 additions & 8 deletions packages/vjsc/src/styles/tests/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,19 @@ describe('compileStyles', () => {
expect(css).toMatch(/}\s*\.media-poster > slot::slotted/);
});

it('emits shadow host rules outside the scope without changing specificity or conditions', async () => {
it('repeats shadow host rules outside the scope without changing specificity or conditions', async () => {
const thumbnail = {
...rule('image', 'media-thumbnail-image', ['block', 'data-loading:opacity-0', 'sm:flex']),
...rule('root', 'media-thumbnail', ['block', 'group/thumbnail', 'data-loading:opacity-0', 'sm:flex']),
shadowHost: true,
};
const image = {
...rule('image', 'media-thumbnail-image', ['group-data-loading/thumbnail:opacity-0']),
shadowHost: true,
};
const spinner = rule('spinner', 'media-thumbnail-spinner', ['absolute']);
const styles = await compileStyles({
design: await loadDesignSystem(designPath),
styles: resolvedStyles([thumbnail, spinner]),
styles: resolvedStyles([thumbnail, image, spinner]),
scope: '.media-skin-video',
variants: [],
});
Expand All @@ -197,12 +201,17 @@ describe('compileStyles', () => {

expect(scoped).toContain('@scope (.media-skin-video)');
expect(scoped).toContain('.media-thumbnail-spinner');
expect(scoped).not.toContain('.media-thumbnail-image');
expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail-image {');
expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail-image[data-loading] {');
expect(unscoped).toMatch(
/@media[^{}]+\{\s*:where\(\.media-skin-video\) \.media-thumbnail-image \{\s*display: flex;/
expect(scoped).toContain('.media-thumbnail {');
expect(scoped).toContain('.media-thumbnail[data-loading] {');
// The relationship keeps its nested scope inside the block, and reads as a plain descendant in the copy.
expect(scoped).toMatch(/@scope \(\.media-thumbnail\) \{\s*&\[data-loading\] \.media-thumbnail-image \{/);
expect(unscoped).not.toContain('.media-thumbnail-spinner');
expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail {');
expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail[data-loading] {');
expect(unscoped).toContain(
':where(.media-skin-video) :where(.media-thumbnail)[data-loading] .media-thumbnail-image {'
);
expect(unscoped).toMatch(/@media[^{}]+\{\s*:where\(\.media-skin-video\) \.media-thumbnail \{\s*display: flex;/);
});
});

Expand Down
Loading