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: () => (
+ <>
+
+
+
+ {`\`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\``}
+
+ >
+ )
+ }
+ }
+};
+
+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) => ;
+const withScrimmedPortalMock = (props: withScrimmedPortalProps) => ;
+
+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 @@
///
import withScrim from './src/withScrim';
-export { withScrim };
+import withScrimmedPortal from './src/withScrimmedPortal';
+export { withScrim, withScrimmedPortal };
declare const _default: {
withScrim: (WrappedComponent: string | import("react").ComponentType
) => {
- 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
) | Readonly<{
- [rest: string]: any;
- getApplicationElement: () => HTMLElement | null;
- onClose?: (() => void | null) | undefined;
- isIphone?: boolean | undefined;
- isIpad?: boolean | undefined;
- containerClassName?: string | undefined;
- closeOnScrimClick?: boolean | undefined;
- } & Omit
>): {
+ new (props: (import("./src/withScrim").Props & Omit
) | Readonly>): {
dialogElement?: HTMLElement | null | undefined;
componentDidMount(): void;
componentWillUnmount(): void;
dialogRef: (ref: HTMLElement | null | undefined) => void;
render(): JSX.Element;
context: any;
- setState(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>) => {} | Pick<{}, K> | null) | Pick<{}, K> | null, callback?: (() => void) | undefined): void;
+ setState(state: {} | ((prevState: Readonly<{}>, props: Readonly>) => {} | 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> & Readonly<{
+ readonly props: Readonly> & 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>, nextState: Readonly<{}>, nextContext: any): boolean;
+ shouldComponentUpdate?(nextProps: Readonly>, 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>, 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
>, prevState: Readonly<{}>, snapshot?: any): void;
+ getSnapshotBeforeUpdate?(prevProps: Readonly>, prevState: Readonly<{}>): any;
+ componentDidUpdate?(prevProps: Readonly>, 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>, 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
>, 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
>, 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
>, nextState: Readonly<{}>, nextContext: any): void;
+ componentWillReceiveProps?(nextProps: Readonly>, nextContext: any): void;
+ UNSAFE_componentWillReceiveProps?(nextProps: Readonly>, nextContext: any): void;
+ componentWillUpdate?(nextProps: Readonly>, nextState: Readonly<{}>, nextContext: any): void;
+ UNSAFE_componentWillUpdate?(nextProps: Readonly>, 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, context: any): {
+ new (props: import("./src/withScrim").Props & Omit
, context: any): {
dialogElement?: HTMLElement | null | undefined;
componentDidMount(): void;
componentWillUnmount(): void;
dialogRef: (ref: HTMLElement | null | undefined) => void;
render(): JSX.Element;
context: any;
- setState(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>) => {} | Pick<{}, K> | null) | Pick<{}, K> | null, callback?: (() => void) | undefined): void;
+ setState(state: {} | ((prevState: Readonly<{}>, props: Readonly>) => {} | 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> & Readonly<{
+ readonly props: Readonly> & 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>, nextState: Readonly<{}>, nextContext: any): boolean;
+ shouldComponentUpdate?(nextProps: Readonly>, 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>, 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
>, prevState: Readonly<{}>, snapshot?: any): void;
+ getSnapshotBeforeUpdate?(prevProps: Readonly>, prevState: Readonly<{}>): any;
+ componentDidUpdate?(prevProps: Readonly>, 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>, 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
>, 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
>, 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
>, nextState: Readonly<{}>, nextContext: any): void;
+ componentWillReceiveProps?(nextProps: Readonly>, nextContext: any): void;
+ UNSAFE_componentWillReceiveProps?(nextProps: Readonly>, nextContext: any): void;
+ componentWillUpdate?(nextProps: Readonly>, nextState: Readonly<{}>, nextContext: any): void;
+ UNSAFE_componentWillUpdate?(nextProps: Readonly>, 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`] = `
+
+
+
+
+ Content hidden from AT
+
+
+
+
+
+`;
+
+exports[`withScrimmedPortal renders the wrapped component inside a portal with renderTarget provided 1`] = `
+
+
+
+
+
+ Content hidden from AT
+
+
+
+
+
+
+
+ Wrapped Component
+
+
+
+
+
+
+
+`;
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) => ;
+ const Component = withScrimmedPortal(WrappedComponent);
+ const { container } = render(
+ ,
+ );
+ 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 = () => Dialog content
;
+ const ScrimmedComponent = withScrimmedPortal(DialogContent);
+
+ render(
+
+
Content hidden from AT
+
document.getElementById('pagewrap')}
+ />
+
+ );
+ expect(document.body).toMatchSnapshot();
+ });
+
+ it('renders the wrapped component inside a portal with renderTarget provided', () => {
+ const WrappedComponent = () => Wrapped Component
;
+ const ScrimmedComponent = withScrimmedPortal(WrappedComponent);
+ render(
+
+
+
Content hidden from AT
+
document.getElementById('pagewrap')}
+ renderTarget={() => document.getElementById('modal-container')}
+ />
+
+
+
+ );
+ expect(document.body).toMatchSnapshot();
+ });
+
+ it('renders the wrapped component outside the applicationElement', () => {
+ const WrappedComponent = () => Wrapped Component
;
+ const ScrimmedComponent = withScrimmedPortal(WrappedComponent);
+ render(
+
+
+
Content hidden from AT
+
document.getElementById('pagewrap')}
+ renderTarget={() => document.getElementById('modal-container')}
+ />
+
+
+
+ );
+
+ 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) => ({ 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) => {
+ const Scrimmed = withScrim(WrappedComponent);
+
+ const ScrimmedComponent = ({ renderTarget, ...rest}: Props) => {
+ const node = useRef(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(, node.current);
+ }
+
+ return ScrimmedComponent;
+}
+
+export default withScrimmedPortal;