Skip to content
Merged
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/9251.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added `useEuiContainerQuery` hook to observe container query changes in JavaScript
Original file line number Diff line number Diff line change
@@ -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.
*/

/// <reference types="cypress" />
/// <reference types="cypress-real-events" />
/// <reference types="../../../cypress/support" />

import React from 'react';

import { useEuiContainerQuery } from './container_query_hook';

const TestComponent = ({
condition,
name,
}: {
condition: string;
name?: string;
}) => {
const { ref, matches } = useEuiContainerQuery<HTMLDivElement>(
condition,
name
);

return (
<div
data-test-subj="container"
style={
{
container: `${name ?? 'none'} / inline-size`,
width: '400px',
} as React.CSSProperties
}
>
<div
ref={ref}
data-matches={matches}
data-test-subj="child"
style={{ outline: '1px solid lime' }}
>
Matches: {matches ? 'yes' : 'no'}
</div>
</div>
);
};

describe('useEuiContainerQuery', () => {
it('matches according to container query', () => {
cy.realMount(<TestComponent condition="(width > 100px)" />);

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(<TestComponent condition="(width > 250px)" />);

// 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(
<div
style={
{
containerType: 'inline-size',
width: '200px',
outline: '1px solid cyan',
} as React.CSSProperties
}
>
<TestComponent condition="(width > 300px)" name="my-container" />
</div>
);

// 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'
);
});
});
Original file line number Diff line number Diff line change
@@ -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 `<container-condition>` 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 <div ref={ref}>{matches ? 'Wide' : 'Narrow'}</div>;
* ```
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container | MDN: @container}
* @beta
*/
export function useEuiContainerQuery<T extends HTMLElement = HTMLElement>(
containerCondition: string,
name?: string
) {
const containerQueryString = name
? `${name} ${containerCondition}`
: containerCondition;
const ref = useRef<T | null>(null);
const [matches, setMatches] = useState<boolean>(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 };
}
9 changes: 9 additions & 0 deletions packages/eui/src/services/container_query/index.ts
Original file line number Diff line number Diff line change
@@ -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';
159 changes: 159 additions & 0 deletions packages/eui/src/services/container_query/match_container.spec.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/

/// <reference types="cypress" />
/// <reference types="cypress-real-events" />
/// <reference types="../../../cypress/support" />

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(
''
);
});
});
});
});
Loading