From 3a8fb8ad48ee5c1375f5f7e96ee6e28c26105dc0 Mon Sep 17 00:00:00 2001 From: Ana Belciug <88667174+anambl@users.noreply.github.com> Date: Thu, 26 Oct 2023 12:10:15 +0100 Subject: [PATCH] [ARGG-806] Add withScrimmedPortal component (#3041) * Add withScrimmedPortal component * update scrimmed portal * Add tests and fix types * add licenses * enable automatic API table for scrim utils * Update style to use spacing tokens --- examples/bpk-scrim-utils/examples.css | 18 ++ examples/bpk-scrim-utils/examples.scss | 45 ++++ examples/bpk-scrim-utils/examples.tsx | 74 ++++++ examples/bpk-scrim-utils/stories.tsx | 52 +++++ examples/bpk-scrim-utils/stories.utils.tsx | 32 +++ packages/bpk-scrim-utils/README.md | 29 +-- packages/bpk-scrim-utils/index.d.ts | 211 ++---------------- packages/bpk-scrim-utils/index.ts | 3 +- .../withScrimmedPortal-test.tsx.snap | 67 ++++++ .../src/accessibility-test.tsx | 15 ++ packages/bpk-scrim-utils/src/withScrim.d.ts | 2 +- packages/bpk-scrim-utils/src/withScrim.tsx | 14 +- .../src/withScrimmedPortal-test.tsx | 76 +++++++ .../src/withScrimmedPortal.d.ts | 25 +++ .../src/withScrimmedPortal.tsx | 73 ++++++ 15 files changed, 516 insertions(+), 220 deletions(-) create mode 100644 examples/bpk-scrim-utils/examples.css create mode 100644 examples/bpk-scrim-utils/examples.scss create mode 100644 examples/bpk-scrim-utils/examples.tsx create mode 100644 examples/bpk-scrim-utils/stories.tsx create mode 100644 examples/bpk-scrim-utils/stories.utils.tsx create mode 100644 packages/bpk-scrim-utils/src/__snapshots__/withScrimmedPortal-test.tsx.snap create mode 100644 packages/bpk-scrim-utils/src/withScrimmedPortal-test.tsx create mode 100644 packages/bpk-scrim-utils/src/withScrimmedPortal.d.ts create mode 100644 packages/bpk-scrim-utils/src/withScrimmedPortal.tsx diff --git a/examples/bpk-scrim-utils/examples.css b/examples/bpk-scrim-utils/examples.css new file mode 100644 index 0000000000..28fdcf1218 --- /dev/null +++ b/examples/bpk-scrim-utils/examples.css @@ -0,0 +1,18 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ +@keyframes bpk-keyframe-spin{100%{transform:rotate(1turn)}}.bpk-scrim-utils-example__dialog{z-index:1100;width:100%;max-width:32rem;margin:auto;outline:0;background-color:#fff;opacity:1;box-shadow:0px 12px 50px 0px rgba(37,32,31,0.25);border-radius:.5rem}.bpk-scrim-utils-example__dialog-content{padding:1rem;flex:1;overflow-y:auto}.bpk-scrim-utils-example__dialog-container{display:flex;padding:1.5rem} diff --git a/examples/bpk-scrim-utils/examples.scss b/examples/bpk-scrim-utils/examples.scss new file mode 100644 index 0000000000..7ee6ac19c7 --- /dev/null +++ b/examples/bpk-scrim-utils/examples.scss @@ -0,0 +1,45 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../packages/bpk-mixins/index.scss'; + +.bpk-scrim-utils-example { + &__dialog { + z-index: $bpk-zindex-modal; + width: 100%; + max-width: $bpk-modal-max-width; + margin: auto; + outline: 0; + background-color: $bpk-modal-background-color; + opacity: $bpk-modal-opacity; + + @include bpk-box-shadow-xl; + @include bpk-border-radius-sm; + + &-content { + padding: $bpk-modal-content-padding; + flex: 1; + overflow-y: auto; + } + + &-container { + display: flex; + padding: bpk-spacing-lg(); + } + } +} diff --git a/examples/bpk-scrim-utils/examples.tsx b/examples/bpk-scrim-utils/examples.tsx new file mode 100644 index 0000000000..54bf96f413 --- /dev/null +++ b/examples/bpk-scrim-utils/examples.tsx @@ -0,0 +1,74 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { withScrimmedPortal } from '../../packages/bpk-scrim-utils'; +import { BpkButtonV2 } from '../../packages/bpk-component-button'; +import { cssModules } from '../../packages/bpk-react-utils'; + +import STYLES from './examples.scss'; + +const getClassName = cssModules(STYLES); + +const DialogContent = () => ( +
+
+
Dialog content here.
+ Some button +
+
+) +const DialogContentWithScrim = withScrimmedPortal(DialogContent); + +const WithPortalScrimExample = () => ( +
+
+ This element should be hidden from AT by the scrim. + It should also not be possible to tab to it. +
+ document.getElementById('pagewrap')} + closeOnScrimClick={false} + containerClassName={getClassName('bpk-scrim-utils-example__dialog-container')} + /> +
+); + +const WithCustomElementAndPortalScrimExample = () => ( +
+
+ Dialog attached here. +
+
+
+ This element should be hidden from AT by the scrim. + It should also not be possible to tab to it. +
+ document.getElementById('pagewrap')} + closeOnScrimClick={false} + containerClassName={getClassName('bpk-scrim-utils-example__dialog-container')} + renderTarget={() => document.getElementById('portalElement')} + /> +
+
+) + +export { WithPortalScrimExample, WithCustomElementAndPortalScrimExample }; \ No newline at end of file diff --git a/examples/bpk-scrim-utils/stories.tsx b/examples/bpk-scrim-utils/stories.tsx new file mode 100644 index 0000000000..2c34fb5600 --- /dev/null +++ b/examples/bpk-scrim-utils/stories.tsx @@ -0,0 +1,52 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import { Title, Markdown, PRIMARY_STORY } from '@storybook/blocks'; +import { ArgsTable } from '@storybook/addon-docs'; + +import BpkScrim from '../../packages/bpk-scrim-utils/src/BpkScrim'; + +import { withScrimMock, withScrimmedPortalMock } from './stories.utils'; +import { + WithPortalScrimExample, + WithCustomElementAndPortalScrimExample + } from './examples'; + +export default { + title: 'bpk-scrim-utils', + component: BpkScrim, + subcomponents: { withScrimMock, withScrimmedPortalMock }, + parameters: { + docs: { + page: () => ( + <> + + <ArgsTable of={PRIMARY_STORY} /> + <Markdown> + {`\`withScrim\` sends all props it receives down to the component, except \`getApplicationElement\` and \`padded\`. It also adds some props that are used for a11y and closing the modal: + \`dialogRef\` should be set as the ref on the visible container on top of the scrim; it is used to set focus, \`onClose\` , \`isIphone\``} + </Markdown> + </> + ) + } + } +}; + +export const Example = WithPortalScrimExample; +export const ExampleWithCustomRenderTarget = WithCustomElementAndPortalScrimExample; diff --git a/examples/bpk-scrim-utils/stories.utils.tsx b/examples/bpk-scrim-utils/stories.utils.tsx new file mode 100644 index 0000000000..3699f55d49 --- /dev/null +++ b/examples/bpk-scrim-utils/stories.utils.tsx @@ -0,0 +1,32 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * This file is a workaround for Storybook not supporting HOCs API table generation in v7 by creating mock components that can be used to generate the API table + * They plan on adding support in v8 + * https://github.com/storybookjs/storybook/issues/12558#issuecomment-1288834879 + * @todo remove this file once we upgrade to Storybook v8 + */ + +import type { Props as withScrimProps } from '../../packages/bpk-scrim-utils/src/withScrim'; +import type { Props as withScrimmedPortalProps } from '../../packages/bpk-scrim-utils/src/withScrimmedPortal'; + +const withScrimMock = (props: withScrimProps) => <div />; +const withScrimmedPortalMock = (props: withScrimmedPortalProps) => <div />; + +export { withScrimMock, withScrimmedPortalMock }; \ No newline at end of file diff --git a/packages/bpk-scrim-utils/README.md b/packages/bpk-scrim-utils/README.md index 47ee2ee2ca..15bad0bef2 100644 --- a/packages/bpk-scrim-utils/README.md +++ b/packages/bpk-scrim-utils/README.md @@ -17,31 +17,4 @@ const Box = props => ( ); const BoxWithScrim = withScrim(Box); -``` - -`withScrim` sends all props it receives down to the component, except `getApplicationElement` and `padded`. It also adds some props that are used for a11y and closing the modal: - -* `dialogRef` should be set as the ref on the visible container on top of the scrim; it is used to set focus -* `onClose` should be set as the `onClick` action on a button or a link -* `isIphone` can be used to apply iPhone only styles or behaviour, as it has different scrolling behaviour - -`containerClassName` can be used to apply styles to the full-screen container into which the enriched component is inserted -(e.g. `display: flex` or `display: grid`) - -> **Note:** the `pagewrap` element id is a convention we use internally at Skyscanner. In most cases it should "just work". - -### Props - -| Property | PropType | Required | Default Value | -| --------------------- | -------- | -------- | -------------------------------------------------------------------------------- | -| onClose | func | true | See prop details | -| getApplicationElement | func | true | - | -| isIphone | bool | false | `/iPhone/i.test(typeof window !== 'undefined' ? window.navigator.platform : '')` | -| containerClassName | string | false | '' | -| closeOnScrimClick | bool | false | true | - -### Prop Details - -#### onClose - -This is required unless `closeOnScrimClick` is false. +``` \ No newline at end of file diff --git a/packages/bpk-scrim-utils/index.d.ts b/packages/bpk-scrim-utils/index.d.ts index d6dbfb0084..c8e2a9df70 100644 --- a/packages/bpk-scrim-utils/index.d.ts +++ b/packages/bpk-scrim-utils/index.d.ts @@ -18,230 +18,63 @@ /// <reference types="react" /> import withScrim from './src/withScrim'; -export { withScrim }; +import withScrimmedPortal from './src/withScrimmedPortal'; +export { withScrim, withScrimmedPortal }; declare const _default: { withScrim: <P extends object>(WrappedComponent: string | import("react").ComponentType<P>) => { - new (props: ({ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">) | Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>): { + new (props: (import("./src/withScrim").Props & Omit<P, "dialogRef">) | Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>): { dialogElement?: HTMLElement | null | undefined; componentDidMount(): void; componentWillUnmount(): void; dialogRef: (ref: HTMLElement | null | undefined) => void; render(): JSX.Element; context: any; - setState<K extends never>(state: {} | ((prevState: Readonly<{}>, props: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>) => {} | Pick<{}, K> | null) | Pick<{}, K> | null, callback?: (() => void) | undefined): void; + setState<K extends never>(state: {} | ((prevState: Readonly<{}>, props: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>) => {} | Pick<{}, K> | null) | Pick<{}, K> | null, callback?: (() => void) | undefined): void; forceUpdate(callback?: (() => void) | undefined): void; - readonly props: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">> & Readonly<{ + readonly props: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">> & Readonly<{ children?: import("react").ReactNode; }>; state: Readonly<{}>; refs: { [key: string]: import("react").ReactInstance; }; - shouldComponentUpdate?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): boolean; + shouldComponentUpdate?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): boolean; componentDidCatch?(error: Error, errorInfo: import("react").ErrorInfo): void; - getSnapshotBeforeUpdate?(prevProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, prevState: Readonly<{}>): any; - componentDidUpdate?(prevProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, prevState: Readonly<{}>, snapshot?: any): void; + getSnapshotBeforeUpdate?(prevProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, prevState: Readonly<{}>): any; + componentDidUpdate?(prevProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, prevState: Readonly<{}>, snapshot?: any): void; componentWillMount?(): void; UNSAFE_componentWillMount?(): void; - componentWillReceiveProps?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextContext: any): void; - UNSAFE_componentWillReceiveProps?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextContext: any): void; - componentWillUpdate?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; - UNSAFE_componentWillUpdate?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; + componentWillReceiveProps?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextContext: any): void; + UNSAFE_componentWillReceiveProps?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextContext: any): void; + componentWillUpdate?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; + UNSAFE_componentWillUpdate?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; }; - new (props: { - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">, context: any): { + new (props: import("./src/withScrim").Props & Omit<P, "dialogRef">, context: any): { dialogElement?: HTMLElement | null | undefined; componentDidMount(): void; componentWillUnmount(): void; dialogRef: (ref: HTMLElement | null | undefined) => void; render(): JSX.Element; context: any; - setState<K extends never>(state: {} | ((prevState: Readonly<{}>, props: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>) => {} | Pick<{}, K> | null) | Pick<{}, K> | null, callback?: (() => void) | undefined): void; + setState<K extends never>(state: {} | ((prevState: Readonly<{}>, props: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>) => {} | Pick<{}, K> | null) | Pick<{}, K> | null, callback?: (() => void) | undefined): void; forceUpdate(callback?: (() => void) | undefined): void; - readonly props: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">> & Readonly<{ + readonly props: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">> & Readonly<{ children?: import("react").ReactNode; }>; state: Readonly<{}>; refs: { [key: string]: import("react").ReactInstance; }; - shouldComponentUpdate?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): boolean; + shouldComponentUpdate?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): boolean; componentDidCatch?(error: Error, errorInfo: import("react").ErrorInfo): void; - getSnapshotBeforeUpdate?(prevProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, prevState: Readonly<{}>): any; - componentDidUpdate?(prevProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, prevState: Readonly<{}>, snapshot?: any): void; + getSnapshotBeforeUpdate?(prevProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, prevState: Readonly<{}>): any; + componentDidUpdate?(prevProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, prevState: Readonly<{}>, snapshot?: any): void; componentWillMount?(): void; UNSAFE_componentWillMount?(): void; - componentWillReceiveProps?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextContext: any): void; - UNSAFE_componentWillReceiveProps?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextContext: any): void; - componentWillUpdate?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; - UNSAFE_componentWillUpdate?(nextProps: Readonly<{ - [rest: string]: any; - getApplicationElement: () => HTMLElement | null; - onClose?: (() => void | null) | undefined; - isIphone?: boolean | undefined; - isIpad?: boolean | undefined; - containerClassName?: string | undefined; - closeOnScrimClick?: boolean | undefined; - } & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; + componentWillReceiveProps?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextContext: any): void; + UNSAFE_componentWillReceiveProps?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextContext: any): void; + componentWillUpdate?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; + UNSAFE_componentWillUpdate?(nextProps: Readonly<import("./src/withScrim").Props & Omit<P, "dialogRef">>, nextState: Readonly<{}>, nextContext: any): void; }; displayName: string; defaultProps: { diff --git a/packages/bpk-scrim-utils/index.ts b/packages/bpk-scrim-utils/index.ts index ce9a41a875..f44e45f9c0 100644 --- a/packages/bpk-scrim-utils/index.ts +++ b/packages/bpk-scrim-utils/index.ts @@ -17,8 +17,9 @@ */ import withScrim from './src/withScrim'; +import withScrimmedPortal from './src/withScrimmedPortal'; -export { withScrim }; +export { withScrim, withScrimmedPortal }; export default { withScrim, }; diff --git a/packages/bpk-scrim-utils/src/__snapshots__/withScrimmedPortal-test.tsx.snap b/packages/bpk-scrim-utils/src/__snapshots__/withScrimmedPortal-test.tsx.snap new file mode 100644 index 0000000000..b82071708d --- /dev/null +++ b/packages/bpk-scrim-utils/src/__snapshots__/withScrimmedPortal-test.tsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`withScrimmedPortal renders the wrapped component inside a portal correctly with fallback to document.body 1`] = ` +<body + style="overflow: hidden;" +> + <div> + <div + aria-hidden="true" + id="pagewrap" + > + <div> + Content hidden from AT + </div> + </div> + </div> + <div> + <div + class="bpk-scrim-content" + > + <div + class="bpk-scrim" + role="presentation" + /> + <div> + Dialog content + </div> + </div> + </div> +</body> +`; + +exports[`withScrimmedPortal renders the wrapped component inside a portal with renderTarget provided 1`] = ` +<body + style="overflow: hidden;" +> + <div> + <div> + <div + aria-hidden="true" + id="pagewrap" + > + <div> + Content hidden from AT + </div> + </div> + <div + id="modal-container" + > + <div> + <div + class="bpk-scrim-content" + > + <div + class="bpk-scrim" + role="presentation" + /> + <div> + Wrapped Component + </div> + </div> + </div> + </div> + </div> + </div> +</body> +`; diff --git a/packages/bpk-scrim-utils/src/accessibility-test.tsx b/packages/bpk-scrim-utils/src/accessibility-test.tsx index 08fe004da0..88f0ad7370 100644 --- a/packages/bpk-scrim-utils/src/accessibility-test.tsx +++ b/packages/bpk-scrim-utils/src/accessibility-test.tsx @@ -21,6 +21,7 @@ import { axe } from 'jest-axe'; import BpkScrim from './BpkScrim'; import withScrim from './withScrim'; +import withScrimmedPortal from './withScrimmedPortal'; describe('BpkScrim accessibility tests', () => { it('should not have programmatically-detectable accessibility issues', async () => { @@ -45,3 +46,17 @@ describe('withScrim accessibility tests', () => { expect(results).toHaveNoViolations(); }); }); + +describe('withScrimmedPortal accessibility tests', () => { + it('should not have programatically-detectable accessibility issues', async () => { + const WrappedComponent = (props: any) => <div {...props} />; + const Component = withScrimmedPortal(WrappedComponent); + const { container } = render( + <Component + getApplicationElement={jest.fn()} + />, + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }) +}); \ No newline at end of file diff --git a/packages/bpk-scrim-utils/src/withScrim.d.ts b/packages/bpk-scrim-utils/src/withScrim.d.ts index ac4f7d770a..3954b0076d 100644 --- a/packages/bpk-scrim-utils/src/withScrim.d.ts +++ b/packages/bpk-scrim-utils/src/withScrim.d.ts @@ -17,7 +17,7 @@ */ import type { ComponentType } from 'react'; -type Props = { +export type Props = { getApplicationElement: () => HTMLElement | null; onClose?: () => void | null; isIphone?: boolean; diff --git a/packages/bpk-scrim-utils/src/withScrim.tsx b/packages/bpk-scrim-utils/src/withScrim.tsx index 1d3f5622a8..b8665df3b1 100644 --- a/packages/bpk-scrim-utils/src/withScrim.tsx +++ b/packages/bpk-scrim-utils/src/withScrim.tsx @@ -43,11 +43,23 @@ import STYLES from './bpk-scrim-content.module.scss'; const getClassName = cssModules(STYLES); -type Props = { +export type Props = { + /** + * The `pagewrap` element id is a convention we use internally at Skyscanner. In most cases it should "just work". + */ getApplicationElement: () => HTMLElement | null; + /** + * This is required unless `closeOnScrimClick` is false. It should be set as the `onClick` action on a button or a link. + */ onClose?: () => void | null; + /** + * Can be used to apply iPhone only styles or behaviour, as it has different scrolling behaviour + */ isIphone?: boolean; isIpad?: boolean; + /** + * It can be used to apply styles to the full-screen container into which the enriched component is inserted (e.g. `display: flex` or `display: grid`) + */ containerClassName?: string; closeOnScrimClick?: boolean; [rest: string]: any; diff --git a/packages/bpk-scrim-utils/src/withScrimmedPortal-test.tsx b/packages/bpk-scrim-utils/src/withScrimmedPortal-test.tsx new file mode 100644 index 0000000000..8cb057010d --- /dev/null +++ b/packages/bpk-scrim-utils/src/withScrimmedPortal-test.tsx @@ -0,0 +1,76 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, within } from '@testing-library/react'; + +import withScrimmedPortal from './withScrimmedPortal'; + +describe('withScrimmedPortal', () => { + it('renders the wrapped component inside a portal correctly with fallback to document.body', () => { + const DialogContent = () => <div>Dialog content</div>; + const ScrimmedComponent = withScrimmedPortal(DialogContent); + + render( + <div id="pagewrap"> + <div> Content hidden from AT</div> + <ScrimmedComponent + getApplicationElement={() => document.getElementById('pagewrap')} + /> + </div> + ); + expect(document.body).toMatchSnapshot(); + }); + + it('renders the wrapped component inside a portal with renderTarget provided', () => { + const WrappedComponent = () => <div>Wrapped Component</div>; + const ScrimmedComponent = withScrimmedPortal(WrappedComponent); + render( + <div> + <div id="pagewrap"> + <div> Content hidden from AT</div> + <ScrimmedComponent + getApplicationElement={() => document.getElementById('pagewrap')} + renderTarget={() => document.getElementById('modal-container')} + /> + </div> + <div id="modal-container" /> + </div> + ); + expect(document.body).toMatchSnapshot(); + }); + + it('renders the wrapped component outside the applicationElement', () => { + const WrappedComponent = () => <div>Wrapped Component</div>; + const ScrimmedComponent = withScrimmedPortal(WrappedComponent); + render( + <div> + <div id="pagewrap"> + <div> Content hidden from AT</div> + <ScrimmedComponent + getApplicationElement={() => document.getElementById('pagewrap')} + renderTarget={() => document.getElementById('modal-container')} + /> + </div> + <div id="modal-container" /> + </div> + ); + + const hiddenElements = document.getElementById('pagewrap'); + expect(within(hiddenElements as HTMLElement).queryByText('Wrapped Component')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/packages/bpk-scrim-utils/src/withScrimmedPortal.d.ts b/packages/bpk-scrim-utils/src/withScrimmedPortal.d.ts new file mode 100644 index 0000000000..1cd0156027 --- /dev/null +++ b/packages/bpk-scrim-utils/src/withScrimmedPortal.d.ts @@ -0,0 +1,25 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ComponentType } from 'react'; +import type { Props as ScrimProps } from './withScrim'; +type Props = ScrimProps & { + renderTarget?: (() => HTMLElement | null) | null; +}; +declare const withScrimmedPortal: (WrappedComponent: ComponentType<ScrimProps>) => ({ renderTarget, ...rest }: Props) => import("react").ReactPortal; +export default withScrimmedPortal; diff --git a/packages/bpk-scrim-utils/src/withScrimmedPortal.tsx b/packages/bpk-scrim-utils/src/withScrimmedPortal.tsx new file mode 100644 index 0000000000..ef567917da --- /dev/null +++ b/packages/bpk-scrim-utils/src/withScrimmedPortal.tsx @@ -0,0 +1,73 @@ +/* + * Backpack - Skyscanner's Design System + * + * Copyright 2016 Skyscanner Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ComponentType} from 'react'; +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import withScrim from './withScrim'; +import type { Props as ScrimProps } from './withScrim'; + +export type Props = ScrimProps & { + renderTarget?: (() => HTMLElement | null) | null; +}; + +const getPortalElement = (target: (() => HTMLElement | null) | null | undefined) => { + const portalElement = target && typeof target === 'function' ? target() : null; + + if (portalElement) { + return portalElement; + } + + if (document.body) { + return document.body; + } + throw new Error('Render target and fallback unavailable'); +} + +const withScrimmedPortal = (WrappedComponent: ComponentType<ScrimProps>) => { + const Scrimmed = withScrim(WrappedComponent); + + const ScrimmedComponent = ({ renderTarget, ...rest}: Props) => { + const node = useRef<HTMLDivElement | null>(null); + + if (!node.current) { + node.current = document.createElement('div'); + } + + useEffect(() => { + const portalElement = getPortalElement(renderTarget); + + if (node.current) { + portalElement.appendChild(node.current); + } + + return () => { + if (node.current) { + portalElement.removeChild(node.current); + } + }; + }, []); + + return createPortal(<Scrimmed {...rest} />, node.current); + } + + return ScrimmedComponent; +} + +export default withScrimmedPortal;