From 029e2db1c072ff97d52a1bd9f67dd705a2db0a75 Mon Sep 17 00:00:00 2001 From: Istvan Hevele Date: Mon, 17 Aug 2026 07:41:40 -0400 Subject: [PATCH 1/4] feat(ECX-579): add Drawer component Adds a persistent, non-modal drawer primitive that pushes adjacent layout over instead of overlaying it, for the ECX-579 larger-PLP-image A/B test's collapsible filter panel. --- src/components/Drawer/Drawer.scss | 19 +++++++++++++++++ src/components/Drawer/Drawer.stories.tsx | 26 ++++++++++++++++++++++++ src/components/Drawer/Drawer.test.tsx | 21 +++++++++++++++++++ src/components/Drawer/Drawer.tsx | 13 ++++++++++++ src/components/Drawer/index.ts | 1 + src/components/index.ts | 1 + 6 files changed, 81 insertions(+) create mode 100644 src/components/Drawer/Drawer.scss create mode 100644 src/components/Drawer/Drawer.stories.tsx create mode 100644 src/components/Drawer/Drawer.test.tsx create mode 100644 src/components/Drawer/Drawer.tsx create mode 100644 src/components/Drawer/index.ts diff --git a/src/components/Drawer/Drawer.scss b/src/components/Drawer/Drawer.scss new file mode 100644 index 000000000..5733c1f94 --- /dev/null +++ b/src/components/Drawer/Drawer.scss @@ -0,0 +1,19 @@ +@use '~@moda/om'; + +.Drawer { + --drawer-width: 15rem; + + flex: 0 0 auto; + width: 0; + overflow: hidden; + transition: width 250ms ease; + + &--open { + width: var(--drawer-width); + } + + &__content { + width: var(--drawer-width); + height: 100%; + } +} diff --git a/src/components/Drawer/Drawer.stories.tsx b/src/components/Drawer/Drawer.stories.tsx new file mode 100644 index 000000000..615150464 --- /dev/null +++ b/src/components/Drawer/Drawer.stories.tsx @@ -0,0 +1,26 @@ +import React, { useState } from 'react'; +import { Button } from '../Button'; +import { Text } from '../Text'; +import { Drawer } from './Drawer'; + +export default { title: 'Components/Drawer' }; + +export const Default = () => { + const [open, setOpen] = useState(false); + + return ( +
+ + Drawer content + + +
+ + + The drawer pushes this content over when open, rather than overlaying it — no backdrop, no + focus trap. + +
+
+ ); +}; diff --git a/src/components/Drawer/Drawer.test.tsx b/src/components/Drawer/Drawer.test.tsx new file mode 100644 index 000000000..7d4e7b29c --- /dev/null +++ b/src/components/Drawer/Drawer.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { Drawer } from './Drawer'; + +describe('Drawer', () => { + it('renders its children', () => { + render(Hello); + expect(screen.getByText('Hello')).toBeVisible(); + }); + + it('toggles the open class based on the open prop', () => { + const { rerender } = render(Hello); + // eslint-disable-next-line testing-library/no-node-access + expect(screen.getByText('Hello').parentNode).not.toHaveClass('Drawer--open'); + + rerender(Hello); + // eslint-disable-next-line testing-library/no-node-access + expect(screen.getByText('Hello').parentNode).toHaveClass('Drawer--open'); + }); +}); diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx new file mode 100644 index 000000000..5e78a336a --- /dev/null +++ b/src/components/Drawer/Drawer.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import classNames from 'classnames'; +import './Drawer.scss'; + +export type DrawerProps = React.HTMLAttributes & { + open: boolean; +}; + +export const Drawer: React.FC = ({ className, open, children, ...rest }) => ( +
+
{children}
+
+); diff --git a/src/components/Drawer/index.ts b/src/components/Drawer/index.ts new file mode 100644 index 000000000..0529d6460 --- /dev/null +++ b/src/components/Drawer/index.ts @@ -0,0 +1 @@ +export * from './Drawer'; diff --git a/src/components/index.ts b/src/components/index.ts index f6a71b2a7..09027052f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -13,6 +13,7 @@ export * from './CreditCardNumberInput'; export * from './DefinitionList'; export * from './Dialog'; export * from './Divider'; +export * from './Drawer'; export * from './Expandable'; export * from './Field'; export * from './Label'; From c640a507f1f93bcd3631f83201c9dbd7c30ee326 Mon Sep 17 00:00:00 2001 From: Istvan Hevele Date: Mon, 17 Aug 2026 07:41:49 -0400 Subject: [PATCH 2/4] feat(ECX-579): add wide size variant to Constrain Adds an optional size prop ('medium' | 'wide', default 'medium') so consumers can opt into a wider, uncapped layout width without changing the default behavior for existing usages. Needed for ECX-579's larger PLP product images. --- src/components/Constrain/Constrain.scss | 4 ++++ src/components/Constrain/Constrain.tsx | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/Constrain/Constrain.scss b/src/components/Constrain/Constrain.scss index 5d05cdef5..ba32c844b 100644 --- a/src/components/Constrain/Constrain.scss +++ b/src/components/Constrain/Constrain.scss @@ -6,6 +6,10 @@ margin: 0 auto; max-width: map.get(om.$breakpoints, xl); + &--wide { + width: 95%; + } + @include om.breakpoint(md, $prop: max-width) { width: 100%; padding: om.spacing(0, 6); diff --git a/src/components/Constrain/Constrain.tsx b/src/components/Constrain/Constrain.tsx index 6dde15b67..9192dda65 100644 --- a/src/components/Constrain/Constrain.tsx +++ b/src/components/Constrain/Constrain.tsx @@ -2,10 +2,20 @@ import React from 'react'; import classNames from 'classnames'; import './Constrain.scss'; -export type ConstrainProps = React.HTMLAttributes; +export type ConstrainSize = 'medium' | 'wide'; -export const Constrain: React.FC = ({ className, children, ...rest }) => ( -
+export type ConstrainProps = React.HTMLAttributes & { size?: ConstrainSize }; + +export const Constrain: React.FC = ({ + className, + children, + size = 'medium', + ...rest +}) => ( +
{children}
); From 925b1a54bbeda57303ef4c7e8ba0afbe64666619 Mon Sep 17 00:00:00 2001 From: Istvan Hevele Date: Mon, 17 Aug 2026 07:42:00 -0400 Subject: [PATCH 3/4] fix(storybook): replace unmaintained ignore-not-found-export-webpack-plugin ignore-not-found-export-webpack-plugin deep-imports webpack/lib/ModuleDependencyWarning, which moved under lib/errors/ in newer webpack 5 releases, breaking `npm run storybook` entirely. Replaces it with a small local plugin that achieves the same warning filtering via warning.constructor.name, with no dependency on webpack's internal file layout. --- .storybook/ignoreNotFoundExportWarningsPlugin.js | 16 ++++++++++++++++ .storybook/main.js | 4 ++-- package-lock.json | 14 -------------- package.json | 1 - 4 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 .storybook/ignoreNotFoundExportWarningsPlugin.js diff --git a/.storybook/ignoreNotFoundExportWarningsPlugin.js b/.storybook/ignoreNotFoundExportWarningsPlugin.js new file mode 100644 index 000000000..722123694 --- /dev/null +++ b/.storybook/ignoreNotFoundExportWarningsPlugin.js @@ -0,0 +1,16 @@ +// Replaces the unmaintained `ignore-not-found-export-webpack-plugin`, whose only functionality +// is filtering out "export was not found" warnings — its `require('webpack/lib/ModuleDependencyWarning')` +// deep-import breaks on newer webpack 5 versions, where that module moved under `lib/errors/`. +// Matching on `constructor.name` avoids importing the class at all. +const EXPORT_NOT_FOUND = /export.*was not found in/; + +module.exports = class IgnoreNotFoundExportWarningsPlugin { + apply(compiler) { + compiler.hooks.done.tap('IgnoreNotFoundExportWarningsPlugin', stats => { + stats.compilation.warnings = stats.compilation.warnings.filter( + warning => + !(warning.constructor.name === 'ModuleDependencyWarning' && EXPORT_NOT_FOUND.test(warning.message)) + ); + }); + } +}; diff --git a/.storybook/main.js b/.storybook/main.js index 1f3b4427a..50b14faa9 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,5 +1,5 @@ const path = require('path'); -const IgnoreNotFoundExportPlugin = require('ignore-not-found-export-webpack-plugin'); +const IgnoreNotFoundExportWarningsPlugin = require('./ignoreNotFoundExportWarningsPlugin'); module.exports = { stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], @@ -28,7 +28,7 @@ module.exports = { } ] }, - plugins: [...config.plugins, new IgnoreNotFoundExportPlugin()] + plugins: [...config.plugins, new IgnoreNotFoundExportWarningsPlugin()] }), framework: { name: '@storybook/react-webpack5', diff --git a/package-lock.json b/package-lock.json index 2e694a189..2a488fa10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,6 @@ "globals": "^17.0.0", "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", - "ignore-not-found-export-webpack-plugin": "^1.0.2", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "jest-matchmedia-mock": "^1.1.0", @@ -17265,19 +17264,6 @@ "node": ">= 4" } }, - "node_modules/ignore-not-found-export-webpack-plugin": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ignore-not-found-export-webpack-plugin/-/ignore-not-found-export-webpack-plugin-1.0.2.tgz", - "integrity": "sha512-CeMqul+L7fEEc59NpQhzr5sh/LRjbMW4cYmMUJWdCm3dYyWF8Big6qea0YSBHQvajKfrnKTcARmwzd9rTp+50w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "webpack": ">=4.0.0" - } - }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", diff --git a/package.json b/package.json index 4e77e2e62..0fabf81e4 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,6 @@ "globals": "^17.0.0", "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", - "ignore-not-found-export-webpack-plugin": "^1.0.2", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "jest-matchmedia-mock": "^1.1.0", From 89aa809b43345e72421f481545c64b984457d5f7 Mon Sep 17 00:00:00 2001 From: Istvan Hevele Date: Mon, 17 Aug 2026 07:51:39 -0400 Subject: [PATCH 4/4] fix(ECX-579): address Drawer review feedback - Hide the drawer's contents from focus and assistive tech while closed (visibility: hidden, delayed on close so the width transition still animates visibly, immediate on open). - Assert on a stable data-testid instead of DOM traversal in the test. --- src/components/Drawer/Drawer.scss | 5 ++++- src/components/Drawer/Drawer.test.tsx | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/Drawer/Drawer.scss b/src/components/Drawer/Drawer.scss index 5733c1f94..52cd2c4c6 100644 --- a/src/components/Drawer/Drawer.scss +++ b/src/components/Drawer/Drawer.scss @@ -6,10 +6,13 @@ flex: 0 0 auto; width: 0; overflow: hidden; - transition: width 250ms ease; + visibility: hidden; + transition: width 250ms ease, visibility 0s 250ms; &--open { width: var(--drawer-width); + visibility: visible; + transition: width 250ms ease, visibility 0s; } &__content { diff --git a/src/components/Drawer/Drawer.test.tsx b/src/components/Drawer/Drawer.test.tsx index 7d4e7b29c..49e8a7d7f 100644 --- a/src/components/Drawer/Drawer.test.tsx +++ b/src/components/Drawer/Drawer.test.tsx @@ -10,12 +10,18 @@ describe('Drawer', () => { }); it('toggles the open class based on the open prop', () => { - const { rerender } = render(Hello); - // eslint-disable-next-line testing-library/no-node-access - expect(screen.getByText('Hello').parentNode).not.toHaveClass('Drawer--open'); + const { rerender } = render( + + Hello + + ); + expect(screen.getByTestId('drawer')).not.toHaveClass('Drawer--open'); - rerender(Hello); - // eslint-disable-next-line testing-library/no-node-access - expect(screen.getByText('Hello').parentNode).toHaveClass('Drawer--open'); + rerender( + + Hello + + ); + expect(screen.getByTestId('drawer')).toHaveClass('Drawer--open'); }); });