Skip to content

Commit

Permalink
feat: add helper functions and docs (microsoft#29506)
Browse files Browse the repository at this point in the history
Adds helper functions to simplify working with `merge-styles`
shadow DOM APIs.

Adds documentation explaining basic usage of shadow DOM features.
  • Loading branch information
spmonahan committed Oct 12, 2023
1 parent fa83b7a commit 08dcdbe
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 35 deletions.
72 changes: 72 additions & 0 deletions packages/merge-styles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,3 +504,75 @@ window.FabricConfig = {
},
};
```
## Shadow DOM
`merge-styles` has experimental support for [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM). This feature is opt-in and incrementally adoptable. To enable the feature you need to include two [React Providers](https://react.dev/reference/react/createContext#provider):
1. `MergeStylesRootProvider`: acts as the "global" context for your application. You should have one of these per page.
2. `MergeStylesShadowRootProvider`: a context for each shadow root in your application. You should have one of these per shadow root.
`merge-styles` does not provide an option for creating shadow roots in React as how you get a shadow root doesn't matter, just that you have a reference to one. [`react-shadow`](https://www.npmjs.com/package/react-shadow) is one library that can create shadow roots in React and will be used in examples.
### Shadow DOM example
```tsx
import { PrimaryButton } from '@fluentui/react';
import { MergeStylesRootProvider, MergeStylesShadowRootProvider } from '@fluentui/utilities';
import root from 'react-shadow';

const ShadowRoot = ({ children }) => {
// This is a ref but we're using state to manage it so we can force
// a re-render.
const [shadowRootEl, setShadowRootEl] = React.useState<HTMLElement | null>(null);

return (
<MergeStylesRootProvider>
<root.div className="shadow-root" delegatesFocus ref={setShadowRootEl}>
<MergeStylesShadowRootProvider shadowRoot={shadowRootEl?.shadowRoot}>{children}</MergeStylesShadowRootProvider>
</root.div>
</MergeStylesRootProvider>
);
};

<ShadowRoot>
<PrimaryButton>I'm in the shadow DOM!</PrimaryButton>
</ShadowRoot>
<PrimaryButton>I'm in the light DOM!</PrimaryButton>
```
### Scoping styles for more efficient CSS
You do not _need_ to update your `merge-styles` styles to support shadow DOM but you can make styles more efficient with some updates.
Shadow DOM support is achieved in `merge-styles` by using [constructable stylesheets](https://web.dev/articles/constructable-stylesheets) and is scoped by "stylesheet keys". `merge-styles` creates one stylesheet per key and in Fluent this means each component has its own stylesheet. Each `MergeStylesShadowRootProvider` will only adopt styles for components it contains plus the global sheet (we cannot be certain whether we need this sheet or not so we always adopt it). This means a `MergeStylesShadowRootProvider` that contains a button will only adopt button styles (plus the global styles) but not checkbox styles, making styling within the shadow root more efficient.
If you use `customizable` or `styled` the existing "scope" value provided to these functions is used a unique key. If no key is provided `merge-styles` falls back to a "global" key. This global key is a catch-all and allows us to support code that was written before shadow DOM support was added or code that is called outside of React context.
All `@fluentui/react` styles are scoped to via `customizable` and `styled` (and some updates to specific component styles where needed). If your components use these functions and you set the "scope" property your components will automatically be scoped.
If you're using `mergeStyles()` (and other `merge-styles` APIs) directly, your styles will be placed in the global scope and still be available in shadow roots, just not as optimally as possible.
#### Style scoping example
```tsx
import { useWindow } from '@fluentui/react';
import { mergeStyles } from '@fluentui/merge-styles';
import { useShadowConfig, useAdoptedStylesheet } from '@fluentui/utilities';
import type { ShadowConfig } from '@fluentui/merge-styles';

// This must be globally unique for the application
const MY_COMPONENT_STYLESHEET_KEY: string = 'my-unique-key';

const MyComponent = props => {
// Make sure multi-window scenarios work (e.g., pop outs)
const win: Window = useWindow();
const shadowConfig: ShadowConfig = useShadowConfig(MY_COMPONENT_STYLESHEET_KEY, win);

const styles = React.useMemo(() => {
// shadowConfig must be the first parameter when it is used
return mergeStyles(shadowConfig, myStyles);
}, [shadowConfig, myStyles]);

useAdoptedStylesheet(MY_COMPONENT_STYLESHEET_KEY);
};
```
3 changes: 3 additions & 0 deletions packages/merge-styles/etc/merge-styles.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,9 @@ export interface IStyleSheetConfig {
// @public
export function keyframes(timeline: IKeyframes): string;

// @public (undocumented)
export const makeShadowConfig: (stylesheetKey: string, inShadow: boolean, window?: Window) => ShadowConfig;

// Warning: (ae-forgotten-export) The symbol "IStyleOptions" needs to be exported by the entry point index.d.ts
//
// @public
Expand Down
2 changes: 1 addition & 1 deletion packages/merge-styles/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export { setRTL } from './StyleOptionsState';

export type { ObjectOnly } from './ObjectOnly';

export { GLOBAL_STYLESHEET_KEY } from './shadowConfig';
export { GLOBAL_STYLESHEET_KEY, makeShadowConfig } from './shadowConfig';
export type { ShadowConfig } from './shadowConfig';

import './version';
9 changes: 9 additions & 0 deletions packages/merge-styles/src/shadowConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export const DEFAULT_SHADOW_CONFIG: ShadowConfig = {
__isShadowConfig__: true,
} as const;

export const makeShadowConfig = (stylesheetKey: string, inShadow: boolean, window?: Window): ShadowConfig => {
return {
stylesheetKey,
inShadow,
window,
__isShadowConfig__: true,
};
};

export const isShadowConfig = (obj: unknown): obj is ShadowConfig => {
if (!obj) {
return false;
Expand Down
10 changes: 2 additions & 8 deletions packages/utilities/src/customizations/customizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { Customizations } from './Customizations';
import { hoistStatics } from '../hoistStatics';
import { CustomizerContext } from './CustomizerContext';
import { concatStyleSets } from '@fluentui/merge-styles';
import { concatStyleSets, makeShadowConfig } from '@fluentui/merge-styles';
import type { ICustomizerContext } from './CustomizerContext';
import { MergeStylesShadowRootConsumer } from '../shadowDom/MergeStylesShadowRootContext';
import type { ShadowConfig } from '@fluentui/merge-styles';
Expand Down Expand Up @@ -56,14 +56,8 @@ export function customizable(
this._shadowConfig.stylesheetKey !== scope ||
this._shadowConfig.inShadow !== inShadow ||
this._shadowConfig.window !== win
// false
) {
this._shadowConfig = {
stylesheetKey: scope,
inShadow,
window: win,
__isShadowConfig__: true,
};
this._shadowConfig = makeShadowConfig(scope, inShadow, win);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
10 changes: 3 additions & 7 deletions packages/utilities/src/shadowDom/MergeStylesRootContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { GLOBAL_STYLESHEET_KEY, Stylesheet } from '@fluentui/merge-styles';
import { GLOBAL_STYLESHEET_KEY, Stylesheet, makeShadowConfig } from '@fluentui/merge-styles';
import { getWindow } from '../dom';

declare global {
Expand Down Expand Up @@ -82,12 +82,8 @@ export const MergeStylesRootProvider: React.FC<MergeStylesRootProviderProps> = (

let changed = false;
const next = new Map<string, CSSStyleSheet>(stylesheets);
const sheet = Stylesheet.getInstance({
window: win,
inShadow: false,
stylesheetKey: GLOBAL_STYLESHEET_KEY,
__isShadowConfig__: true,
});
const sheet = Stylesheet.getInstance(makeShadowConfig(GLOBAL_STYLESHEET_KEY, false, win));

sheet.forEachAdoptedStyleSheet((adoptedSheet, key) => {
next.set(key, adoptedSheet);
changed = true;
Expand Down
16 changes: 14 additions & 2 deletions packages/utilities/src/shadowDom/MergeStylesShadowRootContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { GLOBAL_STYLESHEET_KEY } from '@fluentui/merge-styles';
import { GLOBAL_STYLESHEET_KEY, makeShadowConfig } from '@fluentui/merge-styles';
import { FocusRectsProvider } from '../FocusRectsProvider';
import { useMergeStylesRootStylesheets } from './MergeStylesRootContext';

Expand Down Expand Up @@ -80,7 +80,6 @@ const GlobalStyles: React.FC = props => {
export const useAdoptedStylesheet = (stylesheetKey: string): boolean => {
const shadowCtx = useMergeStylesShadowRootContext();
const rootMergeStyles = useMergeStylesRootStylesheets();
// console.log('useAdoptedStylesheets', stylesheetKey);

if (!shadowCtx) {
return false;
Expand Down Expand Up @@ -112,3 +111,16 @@ export const useHasMergeStylesShadowRootContext = () => {
export const useMergeStylesShadowRootContext = () => {
return React.useContext(MergeStylesShadowRootContext);
};

/**
* Get a shadow config.
* @param stylesheetKey - Globally unique key
* @param win - Reference to the `window` global.
* @returns ShadowConfig
*/
export const useShadowConfig = (stylesheetKey: string, win?: Window) => {
const inShadow = useHasMergeStylesShadowRootContext();
return React.useMemo(() => {
return makeShadowConfig(stylesheetKey, inShadow, win);
}, [stylesheetKey, inShadow, win]);
};
20 changes: 3 additions & 17 deletions packages/utilities/src/styled.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { concatStyleSetsWithProps } from '@fluentui/merge-styles';
import { useAdoptedStylesheet, useHasMergeStylesShadowRootContext } from './shadowDom/MergeStylesShadowRootContext';
import { useAdoptedStylesheet, useShadowConfig } from './shadowDom/MergeStylesShadowRootContext';
import { useCustomizationSettings } from './customizations/useCustomizationSettings';
import type { IStyleSet, IStyleFunctionOrObject, ShadowConfig } from '@fluentui/merge-styles';
import { getWindow } from './dom/getWindow';
Expand Down Expand Up @@ -103,21 +103,7 @@ export function styled<
const additionalProps = getProps ? getProps(props) : undefined;

const win = useWindow() ?? getWindow();

const inShadow = useHasMergeStylesShadowRootContext();
const shadowConfig = React.useRef<ShadowConfig>({ stylesheetKey: scope, inShadow, __isShadowConfig__: true });
if (
shadowConfig.current.stylesheetKey !== scope ||
shadowConfig.current.inShadow !== inShadow ||
shadowConfig.current.window !== win
) {
shadowConfig.current = {
stylesheetKey: scope,
inShadow,
window: win,
__isShadowConfig__: true,
};
}
const shadowConfig = useShadowConfig(scope, win);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cache = (styles.current && (styles.current as any).__cachedInputs__) || [];
Expand All @@ -143,7 +129,7 @@ export function styled<
styles.current = concatenatedStyles as StyleFunction<TStyleProps, TStyleSet>;
}

styles.current.__shadowConfig__ = shadowConfig.current;
styles.current.__shadowConfig__ = shadowConfig;

useAdoptedStylesheet(scope);

Expand Down

0 comments on commit 08dcdbe

Please sign in to comment.