diff --git a/packages/eui/changelogs/upcoming/9251.md b/packages/eui/changelogs/upcoming/9251.md new file mode 100644 index 00000000000..8ac89d57fbb --- /dev/null +++ b/packages/eui/changelogs/upcoming/9251.md @@ -0,0 +1 @@ +- Added `useEuiContainerQuery` hook to observe container query changes in JavaScript diff --git a/packages/eui/src/services/container_query/container_query_hook.spec.tsx b/packages/eui/src/services/container_query/container_query_hook.spec.tsx new file mode 100644 index 00000000000..d8039b93c82 --- /dev/null +++ b/packages/eui/src/services/container_query/container_query_hook.spec.tsx @@ -0,0 +1,129 @@ +/* + * 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 { useEuiContainerQuery } from './container_query_hook'; + +const TestComponent = ({ + condition, + name, +}: { + condition: string; + name?: string; +}) => { + const { ref, matches } = useEuiContainerQuery( + condition, + name + ); + + return ( +
+
+ Matches: {matches ? 'yes' : 'no'} +
+
+ ); +}; + +describe('useEuiContainerQuery', () => { + it('matches according to container query', () => { + cy.realMount(); + + cy.get('[data-test-subj="child"]') + .should('exist') + .invoke('attr', 'data-matches') + .should('eq', 'true'); + + cy.get('[data-test-subj="container"]').should( + 'have.css', + 'container', + 'none / inline-size' + ); + }); + + it('matches updates when container resizes', () => { + cy.mount(); + + // initial state + cy.get('[data-test-subj="child"]') + .invoke('attr', 'data-matches') + .should('eq', 'true'); + + // resize to smaller width + cy.get('[data-test-subj="container"]') + .invoke('css', 'width', '200px') + .wait(100); + + cy.get('[data-test-subj="child"]') + .invoke('attr', 'data-matches') + .should('eq', 'false'); + + // resize back to larger width + cy.get('[data-test-subj="container"]') + .invoke('css', 'width', '300px') + .wait(100); + + cy.get('[data-test-subj="child"]') + .invoke('attr', 'data-matches') + .should('eq', 'true'); + }); + + it('supports name in the container query', () => { + cy.mount( +
+ +
+ ); + + // targets the container named + cy.get('[data-test-subj="child"]') + .invoke('attr', 'data-matches') + .should('eq', 'true'); + + // resize back to larger width + cy.get('[data-test-subj="container"]') + .invoke('css', 'width', '250px') + .wait(100); + + cy.get('[data-test-subj="child"]') + .invoke('attr', 'data-matches') + .should('eq', 'false'); + + cy.get('[data-test-subj="container"]').should( + 'have.css', + 'container', + 'my-container / inline-size' + ); + }); +}); diff --git a/packages/eui/src/services/container_query/container_query_hook.ts b/packages/eui/src/services/container_query/container_query_hook.ts new file mode 100644 index 00000000000..b74a37ca8e5 --- /dev/null +++ b/packages/eui/src/services/container_query/container_query_hook.ts @@ -0,0 +1,62 @@ +/* + * 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 { useEffect, useState, useRef } from 'react'; +import { matchContainer } from './match_container'; + +/** + * React hook that subscribes to CSS container query changes. + * + * For this to work: + * - a proper container (an element with e.g. `container-type: inline-size`) is needed, and + * - the container MUST to be a parent of the element being observed + * + * @param containerCondition - A CSS `` string, e.g. `(width > 400px)` or `(min-width: 600px)` + * @param name - Optional container name, e.g. `sidebar` + * @returns An object containing: + * - `ref`: A ref to attach to the container element + * - `matches`: `true` if the container matches the condition, `false` otherwise + * + * @example + * ```tsx + * const { ref, matches } = useEuiContainerQuery('(width > 400px)'); + * return
{matches ? 'Wide' : 'Narrow'}
; + * ``` + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container | MDN: @container} + * @beta + */ +export function useEuiContainerQuery( + containerCondition: string, + name?: string +) { + const containerQueryString = name + ? `${name} ${containerCondition}` + : containerCondition; + const ref = useRef(null); + const [matches, setMatches] = useState(false); + + useEffect(() => { + if (!ref.current) return; + + const queryList = matchContainer(ref.current, containerQueryString); + const handleChange = () => { + setMatches(queryList.matches); + }; + + setMatches(queryList.matches); + queryList.addEventListener('change', handleChange); + + return () => { + queryList.removeEventListener('change', handleChange); + queryList.dispose(); + }; + }, [ref, containerQueryString]); + + return { ref, matches }; +} diff --git a/packages/eui/src/services/container_query/index.ts b/packages/eui/src/services/container_query/index.ts new file mode 100644 index 00000000000..26bba8a4c4c --- /dev/null +++ b/packages/eui/src/services/container_query/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './container_query_hook'; diff --git a/packages/eui/src/services/container_query/match_container.spec.tsx b/packages/eui/src/services/container_query/match_container.spec.tsx new file mode 100644 index 00000000000..7d3b988d2da --- /dev/null +++ b/packages/eui/src/services/container_query/match_container.spec.tsx @@ -0,0 +1,159 @@ +/* + * 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 { + matchContainer, + ContainerQueryList, + ContainerQueryListChangeEvent, +} from './match_container'; + +describe('matchContainer', () => { + before(() => { + cy.document().then((doc) => { + // create container element + const parent = doc.createElement('div'); + parent.setAttribute('data-test-subj', 'container'); + parent.style.containerType = 'inline-size'; + + // create child + const child = doc.createElement('div'); + child.setAttribute('data-test-subj', 'child'); + child.textContent = 'Child element text'; + child.style.outline = '1px solid cyan'; + parent.appendChild(child); + + doc.body.appendChild(parent); + }); + }); + + beforeEach(() => { + cy.get('[data-test-subj="container"]') + .should('exist') + .as('container') + .invoke('css', 'width', '250px'); + cy.get('[data-test-subj="child"]').should('exist').as('child'); + }); + + it('returns a ContainerQueryList instance', () => { + const condition = '(width > 100px)'; + + cy.get('@child').then(($child) => { + const cql = matchContainer($child[0], condition); + expect(cql).to.be.instanceOf(ContainerQueryList); + expect(cql).to.have.property('matches'); + expect(cql).to.have.property('container'); + expect(cql.container).to.equal(condition); + cql.dispose(); + }); + }); + + it('matches is true when container query condition is met', () => { + cy.get('@child').then(($child) => { + const cql = matchContainer($child[0], '(width > 100px)'); + expect(cql.matches).to.equal(true); + cql.dispose(); + }); + }); + + it('matches is false when container query condition is not met', () => { + cy.get('@child').then(($child) => { + const cql = matchContainer($child[0], '(width > 1000px)'); + expect(cql.matches).to.equal(false); + cql.dispose(); + }); + }); + + it('matches is updated when DOM changes', () => { + cy.get('@child').then(($child) => { + const cql = matchContainer($child[0], '(width > 200px)'); + expect(cql.matches).to.equal(true); + + cy.get('@container') + .invoke('css', 'width', '100px') + .wait(100) + .then(() => { + expect(cql.matches).to.equal(false); + cql.dispose(); + }); + }); + }); + + it('fires change event when matches changes', () => { + const changeSpy = cy.spy().as('changeSpy'); + cy.get('@child').then(($child) => { + const cql = matchContainer($child[0], '(width <= 250px)'); + cql.addEventListener('change', changeSpy); + expect(cql.matches).to.equal(true); + + cy.get('[data-test-subj="container"]') + .invoke('css', 'width', '300px') + .wait(100) + .then(() => { + expect(changeSpy).to.have.been.calledOnce; + expect(changeSpy.firstCall.args[0]).to.be.instanceOf( + ContainerQueryListChangeEvent + ); + expect(cql.matches).to.equal(false); + }) + .invoke('css', 'width', '100px') + .wait(100) + .then(() => { + expect(changeSpy).to.have.been.calledTwice; + expect(cql.matches).to.equal(true); + cql.dispose(); + }) + .invoke('css', 'width', '400px') + .wait(100) + .then(() => { + // no more after cql.dispose() above + expect(changeSpy).to.have.been.calledTwice; + }); + }); + }); + + it('cleans up properly when calling dispose', () => { + cy.get('@child').then(($child) => { + const child = $child[0]; + const cql = matchContainer(child, '(width > 100px)'); + expect(cql.matches).to.equal(true); + + // verify setup happened + const hasMarkerBefore = Array.from(child.attributes).some((attr) => + attr.name.startsWith('data-container-query-observer-') + ); + expect(hasMarkerBefore).to.equal(true); + expect(child.style.getPropertyValue('transition')).to.not.equal(''); + expect(child.style.getPropertyValue('transition-behavior')).to.equal( + 'allow-discrete' + ); + + cy.document().then((doc) => { + expect(doc.adoptedStyleSheets.length).to.be.greaterThan(0); + + // cleanup happens here + cql.dispose(); + + expect(doc.adoptedStyleSheets.length).to.equal(0); + + const hasMarkerAfter = Array.from(child.attributes).some((attr) => + attr.name.startsWith('data-container-query-observer-') + ); + expect(hasMarkerAfter).to.equal(false); + + expect(child.style.getPropertyValue('transition')).to.equal(''); + expect(child.style.getPropertyValue('transition-behavior')).to.equal( + '' + ); + }); + }); + }); +}); diff --git a/packages/eui/src/services/container_query/match_container.ts b/packages/eui/src/services/container_query/match_container.ts new file mode 100644 index 00000000000..42348540b6a --- /dev/null +++ b/packages/eui/src/services/container_query/match_container.ts @@ -0,0 +1,234 @@ +/* + * 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. + */ + +/** + * @license MIT License + * + * Copyright (c) 2025 Martin Winkler + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * @see {@link https://github.com/teetotum/match-container/blob/main/matchContainer.js} + */ + +import { v1 as uuid } from 'uuid'; + +/** + * Listen for changes on a container query. + * Just like `window.matchMedia`. + * + * @example + * ```js + * const cql = matchContainer(element, '(width > 42rem)'); + * cql.addEventListener('change', ({ matches }) { + * // .. + * }) + * ``` + * + * @param element + * @param containerQueryString e.g. (width > 42rem) + * @returns ContainerQueryList + */ +export function matchContainer( + element: HTMLElement, + containerQueryString: string +) { + return new ContainerQueryList(element, containerQueryString); +} + +/** + * `change` event dispatched by instances of {@link ContainerQueryList} + * whenever the value of `matches` changes + */ +export class ContainerQueryListChangeEvent extends Event { + /** Whether the container query matches */ + readonly matches: boolean; + /** A string representation of the container query list e.g. "(width > 1000px)" */ + readonly container: string; + + constructor(matches: boolean, container: string) { + super('change'); + this.matches = matches; + this.container = container; + } +} + +/** + * A hacky implementation of a possible native `ContainerQueryList` + * based on the teetotum/match-container polyfill: + * - based on a API proposal in W3C CSS WG {@link https://github.com/w3c/csswg-drafts/issues/6205}) + * - mimicking MediaQueryList {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList} + * + * Not meant to be used directly, but rather call `matchContainer`. + * + * It works by listening on a `transitionrun` event on the element, + * that gets triggered by a container query changing a custom property. + * Setting `transition-behavior: allow-discrete` is what makes it possible + * to have a CSS `transition` for a custom property. + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-behavior} + */ +export class ContainerQueryList extends EventTarget { + private element: HTMLElement | null = null; + private styleSheet: CSSStyleSheet | null = null; + private markerAttributeName: string = ''; + private sentinelPropertyName: string = ''; + private computedStyle: CSSStyleDeclaration | null = null; + private transitionRunListener: ((event: TransitionEvent) => void) | null = + null; + + #matches: boolean = false; + + /** Whether the container query matches */ + get matches() { + return this.#matches; + } + + /** + * A string representation of the container query list e.g. "(width > 1000px)" + * (the name is weird but it is so for consistency with mediaQueryList.media) + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/media} + * */ + readonly container: string; + + constructor(element: HTMLElement, containerQueryString: string) { + super(); + + this.container = containerQueryString; + this.element = element; + // we call this only once to try to avoid any impact on performance + this.computedStyle = getComputedStyle(this.element); + + const uniqueName = `container-query-observer-${uuid()}`; + this.markerAttributeName = `data-${uniqueName}`; + this.sentinelPropertyName = `--${uniqueName}`; + + // order is important (as in life) + this.applyMarkerAttribute(); + this.createStyleSheet(); + this.#matches = + this.computedStyle.getPropertyValue(this.sentinelPropertyName) === + '--true'; + this.setupTransitionListener(); + } + + /** + * The marker attribute is `data-container-query-observer-{UUID}`, + * it will be used as a selector in the container query, + * in the global CSS that's being added below. + */ + private applyMarkerAttribute() { + this.element!.setAttribute(this.markerAttributeName, ''); + } + + /** + * Create a CSS custom property with values either `--true` or `--false`, + * and add container query targetting the element. + * Whenever the container query matches, the custom property will be `--true`. + * This styles are added globaly via `document.adoptedStyleSheets`. + */ + private createStyleSheet() { + const css = ` + @property ${this.sentinelPropertyName} { + syntax: ''; + inherits: false; + initial-value: --false; + } + @container ${this.container} { + [${this.markerAttributeName}] { + ${this.sentinelPropertyName}: --true; + } + } + `; + const styleSheet = new CSSStyleSheet(); + styleSheet.replaceSync(css); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet]; + this.styleSheet = styleSheet; + } + + /** + * This is the key to the hack: + * - a `transition` style is added for the custom property + * - the `transitionrun` event will fire whenever the custom property value changes + * because of the container query + * - we get the value from computed styles + * - the `matches` value is updated and + * - a ContainerQueryListChangeEvent event is dispatched + */ + private setupTransitionListener() { + const { element, computedStyle, sentinelPropertyName } = this; + + if (!element) return; + + let currentValue = computedStyle!.getPropertyValue(sentinelPropertyName); + + element.style.setProperty( + 'transition', + `${sentinelPropertyName} 0.001ms step-start` + ); + element.style.setProperty('transition-behavior', 'allow-discrete'); + + this.transitionRunListener = (event: TransitionEvent) => { + if (event.target !== element) return; + + const nextValue = computedStyle!.getPropertyValue(sentinelPropertyName); + if (nextValue === currentValue) return; + + currentValue = nextValue; + this.#matches = nextValue === '--true'; + + this.dispatchEvent( + new ContainerQueryListChangeEvent(this.#matches, this.container) + ); + }; + + element.addEventListener('transitionrun', this.transitionRunListener); + } + + dispose() { + // remove the stylesheet from adoptedStyleSheets + if (this.styleSheet) { + document.adoptedStyleSheets = document.adoptedStyleSheets.filter( + (sheet) => sheet !== this.styleSheet + ); + } + + if (!this.element) return; + + if (this.transitionRunListener) { + this.element.removeEventListener( + 'transitionrun', + this.transitionRunListener + ); + } + + this.element.removeAttribute(this.markerAttributeName); + this.element.style.removeProperty('transition'); + this.element.style.removeProperty('transition-behavior'); + + this.element = null; + this.styleSheet = null; + this.computedStyle = null; + this.transitionRunListener = null; + } +}