From 79db50dcd4fd11afc83bee9b45885cc9e67dfc6f Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Sat, 23 May 2026 19:08:38 +0200 Subject: [PATCH 1/4] feat(ui): make dropdown positioning responsive --- .../src/DropDown/getCoords.js | 68 +++++++++++ .../src/DropDown/useDropDownCoords.js | 110 ++++++++++++++++++ packages/decap-cms-ui-default/src/Dropdown.js | 18 ++- 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 packages/decap-cms-ui-default/src/DropDown/getCoords.js create mode 100644 packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js diff --git a/packages/decap-cms-ui-default/src/DropDown/getCoords.js b/packages/decap-cms-ui-default/src/DropDown/getCoords.js new file mode 100644 index 000000000000..b635f4629684 --- /dev/null +++ b/packages/decap-cms-ui-default/src/DropDown/getCoords.js @@ -0,0 +1,68 @@ +/** @typedef {'left' | 'right' | 'center' | undefined} Position */ + +/** + * Calculate relative co-ordinates for a dropdown to position it below a reference element. + * @param {{ reference: DOMRect; target: DOMRect; viewport: DOMRect; dropdownPosition: Position }} options + */ +export function getCoords({ reference, target, dropdownPosition, viewport }) { + let { x, y } = computeCoordsFromPlacement({ reference, target, dropdownPosition }); + ({ x, y } = constrain({ x, y, viewport, target })); + return relativize({ x, y, reference }); +} + +/** + * @param {{ reference: DOMRect; target: DOMRect; dropdownPosition: Position }} options + * @returns {{ x: number; y: number }} co-ordinates + */ +function computeCoordsFromPlacement({ reference, target, dropdownPosition }) { + const commonAlign = reference.width / 2 - target.width / 2; + + const coords = { + x: reference.x + commonAlign, + y: reference.y + reference.height, + }; + + switch (dropdownPosition) { + case 'left': + coords.x -= commonAlign; + break; + case 'right': + coords.x += commonAlign; + break; + default: + } + + return coords; +} + +/** + * Constrain co-ordinates within the viewport. + * @param {{ x: number; y: number, viewport: DOMRect, target: DOMRect }} options + */ +function constrain({ x, y, viewport, target }) { + const overflow = { + left: x, + right: x + target.width - viewport.width, + }; + + x = clamp(x - overflow.left, x, x - overflow.right); + + return { x, y }; +} + +/** + * @param {number} min + * @param {number} value + * @param {number} max + */ +function clamp(min, value, max) { + return Math.min(Math.max(min, value), max); +} + +/** + * Convert absolute viewport co-ordinates into element-relative co-ordinates. + * @param {{ x: number; y: number; reference: DOMRect }} options + */ +function relativize({ x, y, reference }) { + return { x: x - reference.x, y: y - reference.y }; +} diff --git a/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js b/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js new file mode 100644 index 000000000000..be0675398459 --- /dev/null +++ b/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js @@ -0,0 +1,110 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { getCoords } from './getCoords'; + +/** + * @type {{ callbacks: Map; observer?: ResizeObserver; viewport?: DOMRect; raf?: number }} + */ +const viewportState = { callbacks: new Map() }; + +/** @type {ResizeObserverCallback} */ +function onResize([entry]) { + viewportState.viewport = entry.contentRect; + if (viewportState.raf) cancelAnimationFrame(viewportState.raf); + viewportState.raf = requestAnimationFrame(() => { + viewportState.callbacks.forEach(cb => cb(viewportState.viewport)); + }); +} + +/** + * Initializes a `ResizeObserver` to track viewport changes and notify subscribers. + * We cache this single observer to avoid the overhead of creating a new one for every dropdown. + */ +function initObserver() { + if (!viewportState.observer) { + viewportState.observer = new ResizeObserver(onResize); + viewportState.observer.observe(document.documentElement); + } +} + +/** + * Registers a callback to be called with the viewport rect on changes. + * @param {(viewport: DOMRect | undefined) => void} callback + * @returns {() => void} An unsubscribe function to stop listening for viewport changes. + */ +function subscribeToViewportRect(callback) { + initObserver(); + callback(viewportState.viewport); + viewportState.callbacks.set(callback, callback); + + // Unsubscribe function that cleans up the callback and also the observer if no-one is listening anymore. + return () => { + viewportState.callbacks.delete(callback); + if (viewportState.callbacks.size === 0 && viewportState.observer) { + viewportState.observer.disconnect(); + delete viewportState.observer; + } + }; +} + +/** React hook providing the DOMRect of the viewport. */ +function useViewportRect() { + const [viewport, setViewport] = useState(/** @type {DOMRect | undefined} */ (undefined)); + useEffect(() => subscribeToViewportRect(setViewport), []); + return viewport; +} + +/** + * Get co-ordinates for a dropdown based on its source element and the viewport. + * @param {{ dropdownPosition?: import('./getCoords').Position; open?: boolean }} options + * + * @example + * const [open, setOpen] = useState(false); + * const { refs, coords } = useDropDownCoords({ dropdownPosition: 'right', open }); + * + * return ( + *
+ * // Pass the source ref to the button to attach to. + * + *
    + * ... + *
+ *
+ * ); + */ +export function useDropDownCoords({ dropdownPosition = 'left', open } = {}) { + const [x, setX] = useState(0); + const [y, setY] = useState(0); + const viewport = useViewportRect(); + const source = useRef(/** @type {HTMLElement | null} */ (null)); + const dropdown = useRef(/** @type {HTMLElement | null} */ (null)); + + useLayoutEffect(() => { + if (!open || !viewport || !source.current || !dropdown.current) { + return; + } + + const { x, y } = getCoords({ + reference: source.current.getBoundingClientRect(), + target: dropdown.current.getBoundingClientRect(), + viewport, + dropdownPosition, + }); + + setX(x); + setY(y); + }, [dropdownPosition, source.current, dropdown.current, viewport, open]); + + return { refs: { source, dropdown }, coords: { x, y } }; +} diff --git a/packages/decap-cms-ui-default/src/Dropdown.js b/packages/decap-cms-ui-default/src/Dropdown.js index c64f4aad4d55..fc82ee463d59 100644 --- a/packages/decap-cms-ui-default/src/Dropdown.js +++ b/packages/decap-cms-ui-default/src/Dropdown.js @@ -4,6 +4,7 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { Wrapper, Button as DropdownButton, Menu, MenuItem } from 'react-aria-menubutton'; +import { useDropDownCoords } from './DropDown/useDropDownCoords'; import { colors, buttons, components, zIndex } from './styles'; import Icon from './Icon'; @@ -11,6 +12,7 @@ const StyledWrapper = styled(Wrapper)` position: relative; font-size: 14px; user-select: none; + touch-action: manipulation; `; const StyledDropdownButton = styled(DropdownButton)` @@ -20,6 +22,7 @@ const StyledDropdownButton = styled(DropdownButton)` padding-left: 20px; padding-right: 40px; position: relative; + white-space: nowrap; &:after { ${components.caretDown}; @@ -44,8 +47,7 @@ const DropdownList = styled.ul` ${props => css` width: ${props.width}; top: ${props.top}; - left: ${props.position === 'left' ? 0 : 'auto'}; - right: ${props.position === 'right' ? 0 : 'auto'}; + left: ${props.left}; `}; `; @@ -91,15 +93,23 @@ function Dropdown({ className, children, }) { + const [open, setOpen] = React.useState(false); + const { coords, refs } = useDropDownCoords({ dropdownPosition, open }); return ( handler()} + onMenuToggle={({ isOpen }) => setOpen(isOpen)} className={className} > - {renderButton()} +
{renderButton()}
- + {children} From 9ba1e435ad9c9f847d4d9c5503b58ac3e76c5317 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Sat, 23 May 2026 19:30:17 +0200 Subject: [PATCH 2/4] fix: handle `ResizeObserver` being undefined in test environment --- packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js b/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js index be0675398459..6e40aea2134b 100644 --- a/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js +++ b/packages/decap-cms-ui-default/src/DropDown/useDropDownCoords.js @@ -21,7 +21,7 @@ function onResize([entry]) { * We cache this single observer to avoid the overhead of creating a new one for every dropdown. */ function initObserver() { - if (!viewportState.observer) { + if (!viewportState.observer && typeof ResizeObserver !== 'undefined') { viewportState.observer = new ResizeObserver(onResize); viewportState.observer.observe(document.documentElement); } From 056464e8b6f14cb88f7584abb4328cb6d103d0fc Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Sat, 23 May 2026 19:30:47 +0200 Subject: [PATCH 3/4] test: update snapshots --- .../__snapshots__/EditorToolbar.spec.js.snap | 176 +++++++++++------- 1 file changed, 104 insertions(+), 72 deletions(-) diff --git a/packages/decap-cms-core/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap b/packages/decap-cms-core/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap index 38f1aa359057..1c5888c4c395 100644 --- a/packages/decap-cms-core/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap +++ b/packages/decap-cms-core/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap @@ -106,6 +106,7 @@ exports[`EditorToolbar should render normal save button 1`] = ` -moz-user-select: none; -ms-user-select: none; user-select: none; + touch-action: manipulation; margin: 0 10px; } @@ -127,6 +128,7 @@ exports[`EditorToolbar should render normal save button 1`] = ` padding-left: 20px; padding-right: 40px; position: relative; + white-space: nowrap; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -245,15 +247,17 @@ exports[`EditorToolbar should render normal save button 1`] = `
- +
+ +
@@ -383,6 +387,7 @@ exports[`EditorToolbar should render normal save button 2`] = ` -moz-user-select: none; -ms-user-select: none; user-select: none; + touch-action: manipulation; margin: 0 10px; } @@ -404,6 +409,7 @@ exports[`EditorToolbar should render normal save button 2`] = ` padding-left: 20px; padding-right: 40px; position: relative; + white-space: nowrap; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -522,15 +528,17 @@ exports[`EditorToolbar should render normal save button 2`] = `
- +
+ +
@@ -927,6 +935,7 @@ exports[`EditorToolbar should render with status=draft,useOpenAuthoring=false 1` -moz-user-select: none; -ms-user-select: none; user-select: none; + touch-action: manipulation; margin: 0 10px; } @@ -948,6 +957,7 @@ exports[`EditorToolbar should render with status=draft,useOpenAuthoring=false 1` padding-left: 20px; padding-right: 40px; position: relative; + white-space: nowrap; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -1072,15 +1082,17 @@ exports[`EditorToolbar should render with status=draft,useOpenAuthoring=false 1`
- +
+ +
- +
+ +