Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/eui/changelogs/upcoming/9797.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Updated `EuiIllustration` to render the color-mode-adaptive SVG when an asset provides one, setting `color-scheme` from the active theme so its colors resolve via CSS `light-dark()`. It falls back to the discrete `light`/`dark` markup otherwise.
165 changes: 164 additions & 1 deletion packages/eui/src/components/illustration/illustration.stories.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

We should disable controls for Adaptive story. We don't need them there and they don't work anyway.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking question:

The point of stories for the components is to showcase the component usage. But here we are showcasing @elastic/eui-illustration usage. So maybe we can rely on the documentation website entry instead?

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { hideAllStorybookControls } from '../../../.storybook/utils';
import { useEuiTheme } from '../../services';
import { EuiButton } from '../button';
import { EuiEmptyPrompt } from '../empty_prompt';
import { EuiFlexGroup, EuiFlexItem } from '../flex';
import { EuiPanel } from '../panel';
import { EuiSpacer } from '../spacer';
import { EuiText } from '../text';
import {
EuiIllustration,
EuiIllustrationProps,
Expand Down Expand Up @@ -59,7 +63,7 @@ export const Playground: Story = {
if (fullWidth) props.push('fullWidth');

return `import { ${type} } from '@elastic/eui-illustrations';

<EuiIllustration ${props.join(' ')} />`;
},
},
Expand Down Expand Up @@ -120,6 +124,52 @@ export const EmptyPrompt: Story = {
),
};

const ADAPTIVE_SNIPPET = `import { useEuiTheme } from '@elastic/eui';
import { shoppingCart } from '@elastic/eui-illustrations';

// One string. The ancestor \`color-scheme\` picks which \`light-dark()\` value
// applies. Pin it (\`light\`/\`dark\`), follow the OS \`prefers-color-scheme\`
// (\`light dark\`), or mirror the EUI theme (\`EuiProvider\`).
const PROVIDER_SCHEME = 'EuiProvider';
const SYSTEM_SCHEME = 'system';
const schemes = ['light', 'dark', PROVIDER_SCHEME, SYSTEM_SCHEME] as const;

const AdaptiveIllustrations = () => {
const { colorMode } = useEuiTheme();
const providerScheme = colorMode === 'DARK' ? 'dark' : 'light';

const resolveScheme = (scheme) => {
if (scheme === PROVIDER_SCHEME) return providerScheme;
if (scheme === SYSTEM_SCHEME) return 'light dark';
return scheme;
};

return schemes.map((scheme) => (
<div
key={scheme}
style={{ colorScheme: resolveScheme(scheme) }}
dangerouslySetInnerHTML={{ __html: shoppingCart.adaptive ?? shoppingCart.light }}
/>
));
};`;

/**
* Most assets ship a single \`adaptive\` SVG whose colors resolve via CSS
* \`light-dark()\`. \`EuiIllustration\` sets \`color-scheme\` from the EUI theme;
* this story sets it manually so the same string renders pinned \`light\`,
* pinned \`dark\`, following \`EuiProvider\`, and following the OS
* (\`light dark\`, via \`prefers-color-scheme\`) at once.
* \`aerospace\` has no \`adaptive\` variant and cannot adapt.
*/
Comment on lines +156 to +163

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Do we even need this comment? It's verbose, the escaping makes it hard to read and the story is self-explanatory?

export const Adaptive: Story = {
parameters: {
vrt: { skip: true },
codeSnippet: { snippet: ADAPTIVE_SNIPPET },
...hideAllStorybookControls,
},
render: () => <AdaptiveExample />,
};

/**
* VRT only
*/
Expand All @@ -146,6 +196,119 @@ export const SizingFullWidth: Story = {
* Helpers
*/

// Sentinels resolved to real CSS in `AdaptiveExample`: `EuiProvider` to the
// live theme color mode (a module-level const can't read `useEuiTheme()`), and
// `system` to `light dark` (the value that follows `prefers-color-scheme`).
Comment on lines +199 to +201

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

I think this comment is redundant. It's normal we cannot use hooks at a module level, we see PROVIDER_SCHEME set to EuiProvider and SYSTEM_SCHEME to system. If SYSTEM_SCHEME needs more explanation, we can add a JSDoc comment there that explains the value can be system | light | dark (prefers-color-scheme).

const PROVIDER_SCHEME = 'EuiProvider';
const SYSTEM_SCHEME = 'system';

const ADAPTIVE_COLOR_SCHEMES = [
{ scheme: 'light', label: 'color-scheme: light' },
{ scheme: 'dark', label: 'color-scheme: dark' },
{ scheme: PROVIDER_SCHEME, label: 'color-scheme: EuiProvider' },
{ scheme: SYSTEM_SCHEME, label: 'color-scheme: system' },
] as const;

const AdaptiveCard = ({
label,
illustration,
colorScheme,
}: {
label: string;
illustration: EuiIllustrationSource;
colorScheme: string;
}) => (
<EuiPanel
hasBorder
paddingSize="m"
css={css`
color-scheme: ${colorScheme};
`}
>
<EuiText size="xs" color="subdued">
<code>{label}</code>
</EuiText>
<EuiSpacer size="s" />
<div
css={css`
inline-size: 200px;
padding: 8px;
border-radius: 4px;
Comment on lines +235 to +236

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion:

Suggested change
padding: 8px;
border-radius: 4px;
padding: ${euiTheme.size.s};
border-radius: ${euiTheme.border.radius.medium};

/* Hardcoded so the surface follows color-scheme, not the EUI theme. */
background: light-dark(#ffffff, #0b1628);
`}
dangerouslySetInnerHTML={{
__html: illustration.adaptive ?? illustration.light,
}}
/>
</EuiPanel>
);

const AdaptiveExample = () => {
const { colorMode } = useEuiTheme();
const providerScheme = colorMode === 'DARK' ? 'dark' : 'light';

const resolveScheme = (scheme: string) => {
if (scheme === PROVIDER_SCHEME) return providerScheme;
if (scheme === SYSTEM_SCHEME) return 'light dark';
return scheme;
};
const resolveLabel = (scheme: string, label: string) => {
if (scheme === PROVIDER_SCHEME) return `${label} (${providerScheme})`;
if (scheme === SYSTEM_SCHEME) return `${label} (light dark)`;
return label;
};

return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem grow={false}>
<EuiText size="s">
<p>
One <code>shopping-cart.adaptive</code> string, rendered under
several <code>color-scheme</code> values. No theme change or
re-render — CSS <code>light-dark()</code> does the work. The{' '}
<code>EuiProvider</code> card mirrors what{' '}
<strong>EuiIllustration</strong> does: it follows the EUI color mode
(toggle the theme in the Storybook toolbar). The <code>system</code>{' '}
card resolves to <code>color-scheme: light dark</code>, following
the OS/browser <code>prefers-color-scheme</code> instead, regardless
of the EUI theme.
</p>
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="m">
{ADAPTIVE_COLOR_SCHEMES.map(({ scheme, label }) => (
<EuiFlexItem key={label} grow={false}>
<AdaptiveCard
label={resolveLabel(scheme, label)}
illustration={illustrations.shoppingCart}
colorScheme={resolveScheme(scheme)}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>

<EuiFlexItem grow={false}>
<EuiText size="s">
<p>
<code>aerospace</code> has no <code>adaptive</code> variant, so it
falls back to the discrete <code>light</code> markup and does not
respond to <code>color-scheme</code> (shown following{' '}
<code>EuiProvider</code>).
</p>
</EuiText>
<EuiSpacer size="s" />
<AdaptiveCard
label="aerospace.adaptive ?? light"
illustration={illustrations.aerospace}
colorScheme={providerScheme}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

/**
* Fixture SVG for VRT. Uses a fixed width smaller than the parent container
* so VRT snapshots can verify sizing without depending on `@elastic/eui-illustrations`.
Expand Down
45 changes: 45 additions & 0 deletions packages/eui/src/components/illustration/illustration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,51 @@ describe('EuiIllustration', () => {
});
});

describe('adaptive', () => {
const adaptiveIllustration: EuiIllustrationSource = {
...illustration,
adaptive: '<svg viewBox="0 0 10 10"><path data-mode="adaptive" /></svg>',
};

const originalCSS = global.CSS;
const stubCSS = (supported: boolean) => {
// jsdom's `CSS.supports` cannot evaluate `light-dark()`, so stub it.
global.CSS = { supports: () => supported } as unknown as typeof CSS;
};

beforeEach(() => stubCSS(true));
afterAll(() => {
global.CSS = originalCSS;
});

it('prefers the adaptive SVG when light-dark() is supported', () => {
const { container } = render(
<EuiIllustration type={adaptiveIllustration} />
);

expect(
container.querySelector('[data-mode="adaptive"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-mode="light"]')
).not.toBeInTheDocument();
});

it('falls back to the discrete variant when light-dark() is unsupported', () => {
stubCSS(false);
const { container } = render(
<EuiIllustration type={adaptiveIllustration} />
);

expect(
container.querySelector('[data-mode="light"]')
).toBeInTheDocument();
expect(
container.querySelector('[data-mode="adaptive"]')
).not.toBeInTheDocument();
});
});

describe('accessibility', () => {
it('defaults the accessible label to the illustration title', () => {
const { container } = render(<EuiIllustration type={illustration} />);
Expand Down
29 changes: 28 additions & 1 deletion packages/eui/src/components/illustration/illustration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export interface EuiIllustrationSource {
readonly light: string;
/** Trusted SVG markup for the dark color mode. Inlined verbatim — see the interface's security note. */
readonly dark: string;
/**
* Trusted single-SVG markup whose colors resolve via CSS `light-dark()`,
* driven by the `color-scheme` this component sets from the active
* `colorMode`. Preferred when present and supported; otherwise the component
* falls back to {@link light}/{@link dark}. Inlined verbatim — see the
* interface's security note.
*/
readonly adaptive?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Little too verbose for my taste as well:

Suggested change
readonly adaptive?: string;
/** Trusted SVG markup for both color modes via CSS `light-dark()`. Inlined verbatim — see the interface's security note. */
readonly adaptive?: string;

}

export type EuiIllustrationProps = Omit<
Expand All @@ -58,19 +66,37 @@ export type EuiIllustrationProps = Omit<
fullWidth?: boolean;
};

/**
* Whether the runtime can resolve CSS `light-dark()`. Defaults to `true` when
* `CSS` is unavailable (SSR) so the adaptive markup is chosen consistently on
* the server and on modern clients, avoiding a hydration mismatch.
*/
const supportsLightDark = () =>
typeof CSS === 'undefined' ||
CSS.supports?.('color', 'light-dark(#000, #fff)') === true;

export const EuiIllustration: FunctionComponent<EuiIllustrationProps> = ({
type,
alt,
className,
fullWidth = true,
style,
...rest
}) => {
const { colorMode } = useEuiTheme();
const styles = useEuiMemoizedStyles(euiIllustrationStyles);
const classes = classNames('euiIllustration', className);
const cssStyles = [styles.euiIllustration, fullWidth && styles.fullWidth];

const svg = colorMode === 'DARK' ? type.dark : type.light;
const isDark = colorMode === 'DARK';
const useAdaptive = type.adaptive != null && supportsLightDark();
const svg = useAdaptive ? type.adaptive! : isDark ? type.dark : type.light;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
const svg = useAdaptive ? type.adaptive! : isDark ? type.dark : type.light;
const svg = useAdaptive ? type.adaptive : isDark ? type.dark : type.light;


// Pins `color-scheme` so the adaptive SVG's `light-dark()` colors follow the
// EUI color mode rather than the OS preference.
const inlineStyle = useAdaptive
? { colorScheme: isDark ? 'dark' : 'light', ...style }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking suggestion:

Do we want to reverse to avoid consumers overriding it? Unlikely scenario.

Suggested change
? { colorScheme: isDark ? 'dark' : 'light', ...style }
? { ...style, colorScheme: isDark ? 'dark' : 'light' }

: style;

const isDecorative = alt === '';
const a11yProps = isDecorative
Expand All @@ -81,6 +107,7 @@ export const EuiIllustration: FunctionComponent<EuiIllustrationProps> = ({
<span
className={classes}
css={cssStyles}
style={inlineStyle}
{...a11yProps}
{...rest}
dangerouslySetInnerHTML={{ __html: svg }}
Expand Down
36 changes: 29 additions & 7 deletions packages/illustrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

Theme-adaptable SVG illustrations for Elastic UI.

This package is framework-agnostic and ships raw, designer-authored SVGs as typed modules. Assets are reusable by `@elastic/eui` (through `EuiIllustration`), Kibana, Cloud UI and any other consumer. Each illustration carries a `light` and a `dark` variant, and the consumer renders the one that matches the active
color mode.
This package is framework-agnostic and ships raw, designer-authored SVGs as typed modules. Assets are reusable by `@elastic/eui` (through `EuiIllustration`), Kibana, Cloud UI and any other consumer. Each illustration carries a `light` and a `dark` variant, plus a single `adaptive` variant whose colors resolve at runtime.

## How it works

Expand All @@ -17,10 +16,11 @@ Designers never touch TypeScript. They export an illustration from Figma for bot
```mermaid
flowchart LR
A["Designer exports from Figma<br/>(light + dark)"] --> B["src/svgs<br/>name.light.svg + name.dark.svg"]
B --> C["yarn generate<br/>(SVGO optimize + codegen)"]
C --> D["src/generated/*.ts<br/>{ id, title, light, dark }"]
D --> E["build → lib/(cjs|esm) + types"]
E --> F["EuiIllustration picks light/dark<br/>from useEuiTheme().colorMode"]
B --> C["yarn generate<br/>(SVGO optimize + merge)"]
C --> D["src/generated/*.ts<br/>{ id, title, light, dark, adaptive? }"]
C --> G["src/generated/svgs/name.adaptive.svg"]
D --> E["build → lib/(cjs|esm) + types + lib/svgs"]
E --> F["EuiIllustration inlines adaptive<br/>and sets color-scheme from colorMode"]
```

Each generated module satisfies:
Expand All @@ -31,9 +31,24 @@ type EuiIllustrationSource = {
title: string; // 'Dashboard'
light: string; // optimized SVG markup
dark: string; // optimized SVG markup
adaptive?: string; // single SVG, colors via CSS light-dark()
};
```

### Adaptive variant

`generate` merges each `light`/`dark` pair into one SVG: colors that differ between the two are rewritten to `light-dark(<light>, <dark>)` inline styles, shared colors are left as-is. The result adapts to the active [`color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) rather than requiring the consumer to swap files. `EuiIllustration` sets `color-scheme` from the EUI theme so the illustration follows the in-app toggle rather than the OS.

Merging relies on the two files being structurally identical (same elements, only colors differ). When they diverge — different element counts or tag order — `adaptive` is skipped for that illustration (it keeps `light`/`dark` only) and `generate` logs which ones. Re-export both variants from the same artwork to enable adaptive output.

The adaptive SVG is also written to `src/generated/svgs/<name>.adaptive.svg` (published to `lib/svgs`) for `<img src>` / CSS-background consumers; that file pins `color-scheme: light dark` on its root so it follows the OS preference across an `<img>` boundary:

```tsx
import dashboard from '@elastic/eui-illustrations/svgs/dashboard.adaptive.svg';

<img src={dashboard} alt="Dashboards" />;
```

## Consuming

### With `@elastic/eui`
Expand All @@ -49,11 +64,18 @@ import { dashboard } from '@elastic/eui-illustrations';

### Without `@elastic/eui`

The modules are plain data. Pick a mode and inline the markup yourself:
The modules are plain data. Prefer `adaptive` (when present) and let CSS pick the color mode via `color-scheme`, or pick a discrete mode yourself:

```tsx
import { dashboard } from '@elastic/eui-illustrations';

// Adaptive: one inlined SVG, colors follow the ancestor `color-scheme`.
<span
style={{ colorScheme: isDarkMode ? 'dark' : 'light' }}
dangerouslySetInnerHTML={{ __html: dashboard.adaptive ?? dashboard.light }}
/>;

// Or select a discrete variant.
const svg = isDarkMode ? dashboard.dark : dashboard.light;
<span dangerouslySetInnerHTML={{ __html: svg }} />;
```
Expand Down
1 change: 1 addition & 0 deletions packages/illustrations/changelogs/upcoming/9797.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added an `adaptive` variant to illustration assets: a single SVG whose colors resolve at runtime via CSS `light-dark()`, generated by merging the `light`/`dark` pair. It is also emitted as `@elastic/eui-illustrations/svgs/<name>.adaptive.svg` for `<img>`/CSS consumers. Illustrations whose `light`/`dark` files are not structurally identical keep `light`/`dark` only.
Loading