diff --git a/packages/eui/changelogs/upcoming/9264.md b/packages/eui/changelogs/upcoming/9264.md new file mode 100644 index 00000000000..ab4ef7db260 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9264.md @@ -0,0 +1 @@ +- Added beta `euiContainer()`, `euiContainerCSS()`, and `euiContainerQuery()` Emotion utilities to help work with CSS Container Queries diff --git a/packages/eui/src/components/flyout/flyout.styles.ts b/packages/eui/src/components/flyout/flyout.styles.ts index b9aa7549247..00221d91587 100644 --- a/packages/eui/src/components/flyout/flyout.styles.ts +++ b/packages/eui/src/components/flyout/flyout.styles.ts @@ -25,6 +25,8 @@ import { import { UseEuiTheme } from '../../services'; import { euiFormMaxWidth } from '../form/form.styles'; +export const EUI_FLYOUT_CONTAINER_NAME = 'euiFlyout' as const; + export const FLYOUT_BREAKPOINT = 'm' as const; export const euiFlyoutSlideInRight = keyframes` diff --git a/packages/eui/src/global_styling/mixins/_container_query.test.tsx b/packages/eui/src/global_styling/mixins/_container_query.test.tsx new file mode 100644 index 00000000000..c36334a07b9 --- /dev/null +++ b/packages/eui/src/global_styling/mixins/_container_query.test.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '../../test/rtl'; +import { + euiContainer, + euiContainerCSS, + euiContainerQuery, +} from './_container_query'; + +describe('euiContainer', () => { + it('supports naming containers', () => { + expect(euiContainer('normal', 'my-unique-name')).toEqual( + 'container-name: my-unique-name' + ); + }); + + it('supports non-scroll state container types', () => { + expect(euiContainer('size', 'my-unique-name')).toEqual( + 'container-name: my-unique-name;container-type: size' + ); + expect(euiContainer('inline-size', 'my-unique-name')).toEqual( + 'container-name: my-unique-name;container-type: inline-size' + ); + }); + + it('supports scroll states via the scrollState argument', () => { + expect(euiContainer('size', 'my-unique-name', true)).toEqual( + 'container-name: my-unique-name;container-type: size scroll-state' + ); + + expect(euiContainer('size', 'my-unique-name', false)).toEqual( + 'container-name: my-unique-name;container-type: size' + ); + }); + + it('ignores the default "normal" container type', () => { + expect(euiContainer('normal', 'my-unique-name')).toEqual( + 'container-name: my-unique-name' + ); + + expect(euiContainer('normal', 'my-unique-name', true)).toEqual( + 'container-name: my-unique-name;container-type: scroll-state' + ); + }); +}); + +describe('euiContainerCSS', () => { + it('supports naming containers', () => { + const { container } = render( +
+ ); + + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + }); + + it('supports non-scroll state container types', () => { + const { container, rerender } = render( +
+ ); + + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + expect(container.firstChild).toHaveStyleRule('container-type', 'size'); + + rerender(
); + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + expect(container.firstChild).toHaveStyleRule( + 'container-type', + 'inline-size' + ); + }); + + it('supports scroll states via the scrollState argument', () => { + const { container, rerender } = render( +
+ ); + + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + expect(container.firstChild).toHaveStyleRule( + 'container-type', + 'size scroll-state' + ); + + rerender(
); + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + expect(container.firstChild).toHaveStyleRule('container-type', 'size'); + }); + + it('ignores the default "normal" container type', () => { + const { container, rerender } = render( +
+ ); + + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + expect(container.firstChild).not.toHaveStyleRule('container-type'); + + rerender(
); + expect(container.firstChild).toHaveStyleRule( + 'container-name', + 'my-unique-name' + ); + expect(container.firstChild).toHaveStyleRule( + 'container-type', + 'scroll-state' + ); + }); +}); + +describe('euiContainerQuery', () => { + it('supports any container conditions', () => { + expect(euiContainerQuery('(width > 150px)')).toEqual( + `@container (width > 150px)` + ); + + expect(euiContainerQuery('(width > 150px) and (width < 300px)')).toEqual( + `@container (width > 150px) and (width < 300px)` + ); + }); + + it('supports container names', () => { + expect(euiContainerQuery('(width > 150px)', 'my-container')).toEqual( + `@container my-container (width > 150px)` + ); + + expect( + euiContainerQuery('(width > 150px) and (width < 300px)', 'my-container') + ).toEqual(`@container my-container (width > 150px) and (width < 300px)`); + }); +}); diff --git a/packages/eui/src/global_styling/mixins/_container_query.ts b/packages/eui/src/global_styling/mixins/_container_query.ts new file mode 100644 index 00000000000..0d888890548 --- /dev/null +++ b/packages/eui/src/global_styling/mixins/_container_query.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; + +const CONTAINER_TYPES = ['normal', 'size', 'inline-size'] as const; + +/** + * Type of container context used in container queries. + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/container-type} + */ +export type EuiContainerType = (typeof CONTAINER_TYPES)[number]; + +/** + * Establish element as a query container. + * The scroll state is applied through the `scrollState` argument + * and not the `type` argument. + * + * @example + * // Export container name to use across the application + * export const PAGE_CONTENT_CONTAINER_NAME = 'my-app-page-content'; + * const pageContentStyles = css` + * ${euiContainer('inline-size', PAGE_CONTENT_CONTAINER_NAME)} + * margin: 0 auto; + * `; + * + * @returns A style string to be used inside Emotion's `css` template literal + * @beta + */ +export const euiContainer = ( + type: EuiContainerType, + name?: string, + scrollState?: boolean +): string => { + let finalType = ''; + if (type !== 'normal') { + finalType += type; + } + if (scrollState) { + if (finalType.length) { + finalType += ' '; + } + + finalType += 'scroll-state'; + } + + return [ + !!name && `container-name: ${name}`, + !!finalType && `container-type: ${finalType}`, + ] + .filter(Boolean) + .join(';'); +}; + +/** + * Establish element as a query container. + * The scroll state is applied through the `scrollState` argument + * and not the `type` argument. + * + * @example + * // Export container name to use across the application + * export const PAGE_CONTENT_CONTAINER_NAME = 'my-app-page-content'; + * const PageContent = ({ children }: PropsWithChildren) => ( + *
+ * {children} + *
+ * ); + * @returns Emotion's `SerializedStyles` object to be passed to the `css` prop + * of a React component. + * @beta + */ +export const euiContainerCSS = ( + type: EuiContainerType, + name?: string, + scrollState?: boolean +) => { + return css(euiContainer(type, name, scrollState)); +}; + +/** + * Get a @container rule for given conditions and an optional container name. + * + * Container queries can be used to apply conditional styles based on container + * size, its scroll state or even its styles. + * + * It's hugely useful to conditionally show or hide information based + * on the **container** dimensions instead of the **viewport** dimensions. + * + * When container name is provided, it will be used to target the containment + * context. When skipped, it will target the nearest ancestor with containment. + * + * @example + * const itemDetailsStyles = css` + * background: red; + * + * ${euiContainerQuery('(width > 250px)')} { + * background: blue; + * } + * `; + * + * @param conditions one or many conditions to query the container with. + * Similarly to media queries, you can use + * size queries (e.g., `(width > 300px)`), + * scroll state queries (e.g., `(scroll-state(scrollable: top))`), + * or even style queries. + * You can use the `and`, `or` and `not` logical keywords to define container + * conditions. Note that all conditions must be wrapped in parentheses. + * + * @param containerName When provided, it will be used to target + * the containment context and run queries against it. Otherwise, the nearest + * ancestor with containment will be queried instead. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container} + * @beta + */ +export const euiContainerQuery = ( + conditions: string, + containerName?: string +): string => { + return `@container ${containerName ?? ''}${ + containerName ? ' ' : '' + }${conditions}`; +}; diff --git a/packages/eui/src/global_styling/mixins/index.ts b/packages/eui/src/global_styling/mixins/index.ts index 480fafd39f8..49349989933 100644 --- a/packages/eui/src/global_styling/mixins/index.ts +++ b/packages/eui/src/global_styling/mixins/index.ts @@ -7,6 +7,7 @@ */ export * from './_color'; +export * from './_container_query'; export * from './_helpers'; export * from './_padding'; export * from './_states';