Skip to content
Closed
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
6 changes: 6 additions & 0 deletions packages/apollo-wind/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
// Utilities
// -----------------------------------------------------------------------------
export { cn } from './lib/utils';
export { registerCssPropertyRules } from './lib/register-shadow-dom-properties';
export {
injectTailwindIntoShadowRoot,
hasApolloWindCss,
TAILWIND_INJECT_ATTR,
} from './lib/shadow-dom-css';

// -----------------------------------------------------------------------------
// Layout Components
Expand Down
6 changes: 6 additions & 0 deletions packages/apollo-wind/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export { cn, get, deepEqual } from './utils';
export { registerCssPropertyRules } from './register-shadow-dom-properties';
export {
injectTailwindIntoShadowRoot,
hasApolloWindCss,
TAILWIND_INJECT_ATTR,
} from './shadow-dom-css';
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { registerCssPropertyRules } from './register-shadow-dom-properties';

const SELECTOR = 'style[data-tw-property-rules]';

const SAMPLE_CSS = `
@layer base { .border { border-style: var(--tw-border-style); border-width: 1px; } }
@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}
@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}
.bg-popover { background-color: var(--popover); }
`;

beforeEach(() => {
document.querySelectorAll(SELECTOR).forEach((el) => el.remove());
});

afterEach(() => {
document.querySelectorAll(SELECTOR).forEach((el) => el.remove());
});

describe('registerCssPropertyRules', () => {
it('extracts @property rules from CSS and injects them into document.head', () => {
registerCssPropertyRules(SAMPLE_CSS);

const style = document.querySelector(SELECTOR);
expect(style).not.toBeNull();

const text = style?.textContent ?? '';
expect(text).toContain('@property --tw-border-style');
expect(text).toContain('@property --tw-shadow');
expect(text).toContain('@property --tw-translate-x');
});

it('does not include non-@property rules', () => {
registerCssPropertyRules(SAMPLE_CSS);

const text = document.querySelector(SELECTOR)?.textContent ?? '';
expect(text).not.toContain('.border');
expect(text).not.toContain('.bg-popover');
expect(text).not.toContain('@layer');
});

it('injects only one style element when called multiple times', () => {
registerCssPropertyRules(SAMPLE_CSS);
registerCssPropertyRules(SAMPLE_CSS);
registerCssPropertyRules(SAMPLE_CSS);

const elements = document.querySelectorAll(SELECTOR);
expect(elements).toHaveLength(1);
});

it('does nothing when CSS has no @property rules', () => {
registerCssPropertyRules('.flex { display: flex; }');

expect(document.querySelector(SELECTOR)).toBeNull();
});
});
50 changes: 50 additions & 0 deletions packages/apollo-wind/src/lib/register-shadow-dom-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Register Tailwind v4 `@property` CSS declarations at the document level.
*
* Tailwind v4 emits `@property` at-rules (e.g. `@property --tw-border-style`)
* that define initial values for intermediate custom properties used by
* composable utilities (border, shadow, ring, transform). Browsers register
* `@property` rules globally — but silently **ignore** them when they appear
* inside a shadow root's `<style>` element.
*
* This means any Tailwind utility that chains through `--tw-*` variables
* breaks inside shadow DOM: `border`, `shadow-*`, `ring-*`, `translate-*`,
* etc. produce no visible effect because the intermediate properties are
* unregistered and fall back to the CSS "guaranteed-invalid" value.
*
* This helper extracts `@property` declarations from a CSS string and injects
* them into `document.head` so they register at the global scope where shadow
* DOM can see them.
*
* @see https://github.com/tailwindlabs/tailwindcss/discussions/16772
*
* @example
* ```ts
* import { registerCssPropertyRules } from '@uipath/apollo-wind';
* import css from '@uipath/apollo-react/canvas/styles/tailwind.canvas.css?raw';
*
* // Call once at app startup — idempotent, safe to call multiple times.
* registerCssPropertyRules(css);
* ```
*/

const PROPERTY_RE = /@property\s+--[\w-]+\s*\{[^}]*\}/g;
const MARKER = 'data-tw-property-rules';

export function registerCssPropertyRules(css: string): void {
if (typeof document === 'undefined') return;
if (document.querySelector(`style[${MARKER}]`)) return;

const rules: string[] = [];
let match: RegExpExecArray | null;
PROPERTY_RE.lastIndex = 0;
while ((match = PROPERTY_RE.exec(css)) !== null) {
rules.push(match[0]);
}
if (rules.length === 0) return;

const style = document.createElement('style');
style.setAttribute(MARKER, '');
style.textContent = rules.join('\n');
document.head.appendChild(style);
}
147 changes: 147 additions & 0 deletions packages/apollo-wind/src/lib/shadow-dom-css.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
hasApolloWindCss,
injectTailwindIntoShadowRoot,
TAILWIND_INJECT_ATTR,
} from './shadow-dom-css';

const SELECTOR = `style[${TAILWIND_INJECT_ATTR}]`;
const PROPERTY_SELECTOR = 'style[data-tw-property-rules]';

const SAMPLE_CSS = `
@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}
.flex { display: flex; }
.grid { display: grid; }
`;

function createShadowHost(): { host: HTMLElement; root: ShadowRoot } {
const host = document.createElement('div');
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'open' });
return { host, root };
}

beforeEach(() => {
document.querySelectorAll(SELECTOR).forEach((el) => el.remove());
document.querySelectorAll(PROPERTY_SELECTOR).forEach((el) => el.remove());
});

afterEach(() => {
document.querySelectorAll(SELECTOR).forEach((el) => el.remove());
document.querySelectorAll(PROPERTY_SELECTOR).forEach((el) => el.remove());
document
.querySelectorAll('div')
.forEach((el) => el.parentNode?.removeChild(el));
});

describe('injectTailwindIntoShadowRoot', () => {
it('creates a style element in the shadow root', () => {
const { host, root } = createShadowHost();

const style = injectTailwindIntoShadowRoot(root, SAMPLE_CSS);

expect(style).not.toBeNull();
expect(style?.parentNode).toBe(root);
expect(style?.textContent).toBe(SAMPLE_CSS);
expect(style?.getAttribute(TAILWIND_INJECT_ATTR)).toBe('');

host.remove();
});

it('prepends the style element (before existing content)', () => {
const { host, root } = createShadowHost();
const existing = document.createElement('div');
root.appendChild(existing);

injectTailwindIntoShadowRoot(root, SAMPLE_CSS);

expect(root.firstChild).toBeInstanceOf(HTMLStyleElement);

host.remove();
});

it('registers @property rules at the document level', () => {
const { host, root } = createShadowHost();

injectTailwindIntoShadowRoot(root, SAMPLE_CSS);

const propertyStyle = document.querySelector(PROPERTY_SELECTOR);
expect(propertyStyle).not.toBeNull();
expect(propertyStyle?.textContent).toContain('@property --tw-border-style');

host.remove();
});

it('returns null and skips injection when already present', () => {
const { host, root } = createShadowHost();

const first = injectTailwindIntoShadowRoot(root, SAMPLE_CSS);
const second = injectTailwindIntoShadowRoot(root, SAMPLE_CSS);

expect(first).not.toBeNull();
expect(second).toBeNull();
expect(root.querySelectorAll(SELECTOR)).toHaveLength(1);

host.remove();
});

it('injects into separate shadow roots independently', () => {
const host1 = document.createElement('div');
const host2 = document.createElement('div');
document.body.appendChild(host1);
document.body.appendChild(host2);
const root1 = host1.attachShadow({ mode: 'open' });
const root2 = host2.attachShadow({ mode: 'open' });

const style1 = injectTailwindIntoShadowRoot(root1, SAMPLE_CSS);
const style2 = injectTailwindIntoShadowRoot(root2, SAMPLE_CSS);

expect(style1).not.toBeNull();
expect(style2).not.toBeNull();

host1.remove();
host2.remove();
});
});

describe('hasApolloWindCss', () => {
it('returns true when data-tailwind-inject tag exists in document', () => {
const style = document.createElement('style');
style.setAttribute(TAILWIND_INJECT_ATTR, '');
document.head.appendChild(style);

expect(hasApolloWindCss(document)).toBe(true);

style.remove();
});

it('returns true when data-tailwind-inject tag exists in shadow root', () => {
const { host, root } = createShadowHost();

injectTailwindIntoShadowRoot(root, SAMPLE_CSS);

expect(hasApolloWindCss(root)).toBe(true);

host.remove();
});

it('returns false when no styles are present', () => {
// jsdom does not apply CSS rulesets, so computed-style probes fail —
// correct fail-closed behavior (inject redundantly rather than skip).
expect(hasApolloWindCss(document)).toBe(false);
});

it('returns false for an empty shadow root', () => {
const { host, root } = createShadowHost();

expect(hasApolloWindCss(root)).toBe(false);

host.remove();
});
});

describe('TAILWIND_INJECT_ATTR', () => {
it('equals data-tailwind-inject', () => {
expect(TAILWIND_INJECT_ATTR).toBe('data-tailwind-inject');
});
});
81 changes: 81 additions & 0 deletions packages/apollo-wind/src/lib/shadow-dom-css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { registerCssPropertyRules } from './register-shadow-dom-properties';

export const TAILWIND_INJECT_ATTR = 'data-tailwind-inject';

const PROBES: ReadonlyArray<{
className: string;
property: string;
expected: string;
}> = [
{ className: 'grid', property: 'display', expected: 'grid' },
{ className: 'flex', property: 'display', expected: 'flex' },
{ className: 'hidden', property: 'display', expected: 'none' },
{ className: 'relative', property: 'position', expected: 'relative' },
];

/**
* Returns true if apollo-wind Tailwind CSS rules are in scope inside the given
* root (so injection would be redundant). Two checks:
*
* 1. Fast path: look for a `<style data-tailwind-inject>` tag already present.
* 2. Computed-style probe: apply several known apollo-wind utility classes to a
* temp div and require ALL of them to produce their expected computed value.
* Consensus prevents a stray single-class definition from false-positiving
* into skipping a needed injection.
*
* NOTE: we deliberately do NOT probe a custom property (e.g. `--surface`).
* Custom properties inherit across the shadow boundary, so a host that ships
* apollo-wind globally in the light DOM would false-positive — the bridge
* variables reach the shadow root via inheritance even though the utility
* *rulesets* (which do NOT cross the boundary) are absent.
*/
export function hasApolloWindCss(root: Document | ShadowRoot): boolean {
if (root.querySelector(`style[${TAILWIND_INJECT_ATTR}]`)) {
return true;
}

const probeParent = root instanceof Document ? root.body : root;
const probe = document.createElement('div');
probeParent.appendChild(probe);

try {
const computed = getComputedStyle(probe);
return PROBES.every((p) => {
probe.className = p.className;
return computed.getPropertyValue(p.property) === p.expected;
});
} finally {
probe.remove();
}
}

/**
* Inject an apollo-wind Tailwind stylesheet into a shadow root and register
* `@property` rules at the document level. No-ops if a `data-tailwind-inject`
* style tag is already present in the root.
*
* Returns the created `<style>` element, or `null` if injection was skipped.
*
* @example
* ```ts
* import { injectTailwindIntoShadowRoot } from '@uipath/apollo-wind';
* import css from '@uipath/apollo-react/canvas/styles/tailwind.canvas.css?raw';
*
* // In a web component's connectedCallback:
* const shadow = this.attachShadow({ mode: 'open' });
* injectTailwindIntoShadowRoot(shadow, css);
* ```
*/
export function injectTailwindIntoShadowRoot(
root: ShadowRoot,
css: string,
): HTMLStyleElement | null {
if (root.querySelector(`style[${TAILWIND_INJECT_ATTR}]`)) return null;

const style = document.createElement('style');
style.setAttribute(TAILWIND_INJECT_ATTR, '');
style.textContent = css;
root.prepend(style);
registerCssPropertyRules(css);
return style;
}
Loading