@justeattakeaway/pie-modal 0.0.0-snapshot-release-20231123112917

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ <p align="center">
2
+ <img align="center" src="../../../readme_image.png" height="200" alt="">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/@justeattakeaway/pie-modal">
7
+ <img alt="GitHub Workflow Status" src="https://img.shields.io/npm/v/@justeattakeaway/pie-modal.svg">
8
+ </a>
9
+ </p>
10
+
11
+ # Table of Contents
12
+
13
+ 1. [Introduction](#pie-modal)
14
+ 2. [Installation](#installation)
15
+ 3. [Importing the component](#importing-the-component)
16
+ 4. [Peer Dependencies](#peer-dependencies)
17
+ 5. [Props](#props)
18
+ 6. [Legacy browser support](#legacy-browser-support)
19
+ 7. [Contributing](#contributing)
20
+
21
+
22
+ ## pie-modal
23
+
24
+ `pie-modal` is a Web Component built using the Lit library. It offers a simple and accessible modal component for web applications, which uses the native HTML `dialog` element under the hood.
25
+
26
+ This component can be easily integrated into various frontend frameworks and customized through a set of properties.
27
+
28
+
29
+ ## Installation
30
+
31
+ To install `pie-modal` in your application, run the following on your command line:
32
+
33
+ ```bash
34
+ # npm
35
+ $ npm i @justeattakeaway/pie-modal
36
+
37
+ # yarn
38
+ $ yarn add @justeattakeaway/pie-modal
39
+ ```
40
+
41
+ For full information on using PIE components as part of an application, check out the [Getting Started Guide](https://github.com/justeattakeaway/pie/wiki/Getting-started-with-PIE-Web-Components).
42
+
43
+
44
+ ### Importing the component
45
+
46
+ #### JavaScript
47
+ ```js
48
+ // Default – for Native JS Applications, Vue, Angular, Svelte, etc.
49
+ import { PieModal } from '@justeattakeaway/pie-modal';
50
+
51
+ // If you don't need to reference the imported object, you can simply
52
+ // import the module which registers the component as a custom element.
53
+ import '@justeattakeaway/pie-modal';
54
+ ```
55
+
56
+ #### React
57
+ ```js
58
+ // React
59
+ // For React, you will need to import our React-specific component build
60
+ // which wraps the web component using ​@lit/react
61
+ import { PieModal } from '@justeattakeaway/pie-modal/dist/react';
62
+ ```
63
+
64
+ > [!NOTE]
65
+ > When using the React version of the component, please make sure to also
66
+ > include React as a [peer dependency](#peer-dependencies) in your project.
67
+
68
+
69
+ ## Peer Dependencies
70
+
71
+ > [!IMPORTANT]
72
+ > When using `pie-modal`, you will also need to include a couple of dependencies to ensure the component renders as expected. See [the PIE Wiki](https://github.com/justeattakeaway/pie/wiki/Getting-started-with-PIE-Web-Components#expected-dependencies) for more information and how to include these in your application.
73
+
74
+
75
+ ## Props
76
+
77
+ | Property | Type | Default | Description |
78
+ |-------------------------------|-----------|----------------|-------------|
79
+ | aria | `Object` | `undefined` | An object representing the aria labels for the `close` & `back` buttons within the modal as well as the `isLoading` state labels (`aria-label` & `aria-busy`). |
80
+ | hasBackButton | `Boolean` | `false` | If true, the modal includes a back button which closes the modal when clicked. |
81
+ | hasStackedActions | `Boolean` | `false` | If true, the action buttons will be stacked (full width) at narrow breakpoints. |
82
+ | heading | `String` | n/a (Required) | The heading text of the modal. |
83
+ | headingLevel | `String` | `h2` | The HTML tag to use for the modal's heading (can be `h1`-`h6`). |
84
+ | isDismissible | `Boolean` | `false` | If true, the modal includes a close button and can be dismissed by clicking on the backdrop or pressing the `Esc` key. |
85
+ | isFooterPinned | `Boolean` | `true` | When false, the modal footer will scroll with the content inside the modal body. |
86
+ | isFullWidthBelowMid | `Boolean` | `false` | If true and the page is narrower than the mid breakpoint, a **medium-sized** modal will take up the full width of the screen. |
87
+ | isLoading | `Boolean` | `false` | When true, displays a loading spinner in the modal |
88
+ | isOpen | `Boolean` | `false` | Controls if the modal element is open or closed |
89
+ | leadingAction | `Object` | `undefined` | An object representing the leading action of the modal. It has the following properties:<br>- text (required): The text to display on the leading action button.<br>- variant: The variant of the leading action button. Please see [pie-button](https://github.com/justeattakeaway/pie/blob/main/packages/components/pie-button/README.md#props) for options. Defaults to primary if not provided.<br>- ariaLabel: The ARIA label for the leading action button. Example: `leadingAction = { text: "Example Text", variant: "secondary", ariaLabel: "Example Aria Label" }` |
90
+ | position | `String` | `center` | The position of the modal; this controls where it will appear on the page. Can be `top` or `center`. |
91
+ | returnFocusAfterCloseSelector | `String` | `undefined` | If provided, focus will be sent to the first element that matches this selector when the modal is closed. If not provided, the `dialog` element will return focus to the element that opened the modal. |
92
+ | size | `String` | `medium` | Determines the maximum width of the modal. Large modals will expand to fill the entire page width at narrow viewports. Can be `small`, `medium` or `large`. |
93
+ | supportingAction | `Object` | `undefined` | An object representing the supporting action of the modal. It has the following properties:<br>- text (required): The text to display on the supporting action button.<br>- variant: The variant of the supporting action button. Please see [pie-button](https://github.com/justeattakeaway/pie/blob/main/packages/components/pie-button/README.md#props) for options. Defaults to ghost if not provided.<br>- ariaLabel: The ARIA label for the supporting action button. Example: `supportingAction = { text: "Example Text", variant: "ghost", ariaLabel: "Example Aria Label" }` |
94
+
95
+ In your markup or JSX, you can then use these to set the properties for the `pie-modal` component:
96
+
97
+ ```html
98
+ <!-- Native HTML -->
99
+ <pie-modal heading='My Awesome Heading' headingLevel='h3'>Click me!</pie-modal>
100
+
101
+ <!-- JSX -->
102
+ <PieModal heading='My Awesome Heading' headingLevel='h3'>Click me!</PieModal>
103
+ ```
104
+
105
+ ## Legacy browser support
106
+
107
+ `pie-modal` uses the Dialog element which might not be supported by legacy browsers.
108
+
109
+ To support them, `pie-modal` uses the [dialog-polyfill](https://github.com/GoogleChrome/dialog-polyfill) package. It works automatically and doesn't need any setup.
110
+
111
+ The polyfill comes with a few limitations, as noted on its [documentation page](https://github.com/GoogleChrome/dialog-polyfill#limitations):
112
+ - Dialogs should not be contained by parents that create a stacking context
113
+ - The browser's chrome may not always be accessible via the tab key
114
+ - Changes to the CSS top/bottom values while open aren't retained
115
+
116
+ For more details, check the package documentation mentioned above.
117
+
118
+
119
+ ## Contributing
120
+
121
+ Check out our [contributing guide](https://github.com/justeattakeaway/pie/wiki/Contributing-Guide) for more information on [local development](https://github.com/justeattakeaway/pie/wiki/Contributing-Guide#local-development) and how to run specific [component tests](https://github.com/justeattakeaway/pie/wiki/Contributing-Guide#testing).
@@ -0,0 +1,9 @@
1
+ declare module '*.scss' {
2
+ const content: Record<string, string>;
3
+ export default content;
4
+ }
5
+
6
+ declare module '*.scss?inline' {
7
+ const content: Record<string, string>;
8
+ export default content;
9
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@justeattakeaway/pie-modal",
3
+ "version": "0.0.0-snapshot-release-20231123112917",
4
+ "description": "PIE design system modal built using web components",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "src",
11
+ "dist",
12
+ "**/*.d.ts"
13
+ ],
14
+ "scripts": {
15
+ "build": "yarn build:wrapper pie-modal && run -T vite build",
16
+ "lint:scripts": "run -T eslint .",
17
+ "lint:scripts:fix": "run -T eslint . --fix",
18
+ "lint:style": "run -T stylelint ./src/**/*.{css,scss}",
19
+ "lint:style:fix": "yarn lint:style --fix",
20
+ "watch": "run -T vite build --watch",
21
+ "test": "echo \"Error: no test specified\" && exit 0",
22
+ "test:ci": "yarn test",
23
+ "test:browsers": "npx playwright test -c ./playwright-lit.config.ts",
24
+ "test:browsers:ci": "yarn test:browsers",
25
+ "test:visual": "run -T cross-env-shell PERCY_TOKEN=${PERCY_TOKEN_PIE_MODAL} percy exec --allowed-hostname cloudfront.net -- npx playwright test -c ./playwright-lit-visual.config.ts",
26
+ "test:visual:ci": "yarn test:visual"
27
+ },
28
+ "author": "Just Eat Takeaway.com - Design System Team",
29
+ "license": "Apache-2.0",
30
+ "devDependencies": {
31
+ "@justeat/pie-design-tokens": "5.9.0",
32
+ "@justeattakeaway/pie-components-config": "0.6.0",
33
+ "@types/body-scroll-lock": "3.1.2"
34
+ },
35
+ "volta": {
36
+ "extends": "../../../package.json"
37
+ },
38
+ "dependencies": {
39
+ "@justeattakeaway/pie-button": "0.40.0",
40
+ "@justeattakeaway/pie-icon-button": "0.22.0",
41
+ "@justeattakeaway/pie-icons-webc": "0.11.1",
42
+ "@justeattakeaway/pie-spinner": "0.3.0",
43
+ "@justeattakeaway/pie-webc-core": "0.12.0",
44
+ "body-scroll-lock": "3.1.5",
45
+ "dialog-polyfill": "0.5.6"
46
+ },
47
+ "sideEffects": [
48
+ "dist/*.js"
49
+ ]
50
+ }
package/src/defs.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { Variant } from '@justeattakeaway/pie-button/src/defs.ts';
2
+
3
+ export const headingLevels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
4
+ export const sizes = ['small', 'medium', 'large'] as const;
5
+ export const positions = ['top', 'center'] as const;
6
+
7
+ export type AriaProps = {
8
+ close?: string;
9
+ back?: string;
10
+ loading?: string;
11
+ };
12
+
13
+ export type ActionProps = {
14
+ /**
15
+ * The text to display inside the button.
16
+ */
17
+ text: string;
18
+
19
+ /**
20
+ * The button variant.
21
+ */
22
+ variant?: Variant;
23
+
24
+ /**
25
+ * The ARIA label for the button.
26
+ */
27
+ ariaLabel?: string;
28
+ };
29
+
30
+ export type ModalProps = {
31
+ /**
32
+ * The ARIA labels used for the modal close and back buttons, as well as loading state.
33
+ */
34
+ aria?: AriaProps;
35
+
36
+ /**
37
+ * When true, the modal will have a back button. This currently behaves the same as the close button.
38
+ */
39
+ hasBackButton: boolean;
40
+
41
+ /**
42
+ * When true, the modal will have a back button. This currently behaves the same as the close button.
43
+ */
44
+ hasStackedActions: boolean;
45
+
46
+ /**
47
+ * The text to display in the modal's heading.
48
+ */
49
+ heading: string;
50
+
51
+ /**
52
+ * The HTML heading tag to use for the modal's heading. Can be h1-h6.
53
+ */
54
+ headingLevel: typeof headingLevels[number];
55
+
56
+ /**
57
+ * When true, the modal will be open.
58
+ */
59
+ isOpen: boolean;
60
+
61
+ /**
62
+ * When set to `true`:
63
+ * 1. The close button within the modal will be visible.
64
+ * 2. The user can dismiss the modal via the ESCAPE key, clicking the backdrop
65
+ * or via a close button.
66
+ *
67
+ * When set to `false`:
68
+ * 1. The close button within the modal will be hidden.
69
+ * 2. The user can NOT dismiss the modal via the ESCAPE key or clicking the backdrop.
70
+ *
71
+ */
72
+ isDismissible: boolean;
73
+
74
+ /**
75
+ * When false, the modal footer will scroll with the content inside the modal body.
76
+ */
77
+ isFooterPinned: boolean;
78
+
79
+ /**
80
+ * This controls whether a *medium-sized* modal will cover the full width of the page when below the mid breakpoint.
81
+ */
82
+ isFullWidthBelowMid: boolean;
83
+
84
+ /**
85
+ * When true, displays a loading spinner in the modal.
86
+ */
87
+ isLoading: boolean;
88
+
89
+ /**
90
+ * The leading action configuration for the modal.
91
+ */
92
+ leadingAction: ActionProps;
93
+
94
+ /*
95
+ * The position of the modal; this controls where it will appear on the page.
96
+ */
97
+ position: typeof positions[number];
98
+
99
+ /**
100
+ * The selector for the element that you would like focus to be returned to when the modal is closed, e.g., #skipToMain
101
+ */
102
+ returnFocusAfterCloseSelector?: string;
103
+
104
+ /**
105
+ * The size of the modal; this controls how wide it will appear on the page.
106
+ */
107
+ size: typeof sizes[number];
108
+
109
+ /**
110
+ * The supporting action configuration for the modal.
111
+ */
112
+ supportingAction: ActionProps;
113
+ };
114
+
115
+ /**
116
+ * Event name for when the modal is closed.
117
+ *
118
+ * @constant
119
+ */
120
+ export const ON_MODAL_CLOSE_EVENT = 'pie-modal-close';
121
+
122
+ /**
123
+ * Event name for when the modal is opened.
124
+ *
125
+ * @constant
126
+ */
127
+ export const ON_MODAL_OPEN_EVENT = 'pie-modal-open';
128
+
129
+ /**
130
+ * Event name for when the modal back button is clicked.
131
+ *
132
+ * @constant
133
+ */
134
+ export const ON_MODAL_BACK_EVENT = 'pie-modal-back';
135
+
136
+ /**
137
+ * Event name for when the modal leading action is clicked.
138
+ *
139
+ * @constant
140
+ */
141
+ export const ON_MODAL_LEADING_ACTION_CLICK = 'pie-modal-leading-action-click';
142
+
143
+ /**
144
+ * Event name for when the modal supporting action is clicked.
145
+ *
146
+ * @constant
147
+ */
148
+ export const ON_MODAL_SUPPORTING_ACTION_CLICK = 'pie-modal-supporting-action-click';
149
+
150
+ export type ModalActionType = 'leading' | 'supporting';
package/src/index.ts ADDED
@@ -0,0 +1,481 @@
1
+ import {
2
+ LitElement, nothing, TemplateResult, unsafeCSS, PropertyValues,
3
+ } from 'lit';
4
+ import { html, unsafeStatic } from 'lit/static-html.js';
5
+ import { property, query } from 'lit/decorators.js';
6
+ import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
7
+
8
+ import '@justeattakeaway/pie-button';
9
+ import '@justeattakeaway/pie-icon-button';
10
+ import {
11
+ requiredProperty, RtlMixin, validPropertyValues, defineCustomElement,
12
+ } from '@justeattakeaway/pie-webc-core';
13
+ import '@justeattakeaway/pie-icons-webc/IconClose';
14
+ import '@justeattakeaway/pie-icons-webc/IconChevronLeft';
15
+ import '@justeattakeaway/pie-icons-webc/IconChevronRight';
16
+ import '@justeattakeaway/pie-spinner';
17
+
18
+ import styles from './modal.scss?inline';
19
+ import {
20
+ type AriaProps,
21
+ type ActionProps,
22
+ type ModalProps,
23
+ type ModalActionType,
24
+ headingLevels,
25
+ positions,
26
+ sizes,
27
+ ON_MODAL_BACK_EVENT,
28
+ ON_MODAL_CLOSE_EVENT,
29
+ ON_MODAL_OPEN_EVENT,
30
+ ON_MODAL_LEADING_ACTION_CLICK,
31
+ ON_MODAL_SUPPORTING_ACTION_CLICK,
32
+ } from './defs';
33
+
34
+ // Valid values available to consumers
35
+ export * from './defs';
36
+
37
+ const componentSelector = 'pie-modal';
38
+
39
+ export interface ModalEventDetail {
40
+ targetModal: PieModal;
41
+ }
42
+
43
+ /**
44
+ * @tagname pie-modal
45
+ * @event {CustomEvent} pie-modal-open - when the modal is opened.
46
+ * @event {CustomEvent} pie-modal-close - when the modal is closed.
47
+ * @event {CustomEvent} pie-modal-back - when the modal back button is clicked.
48
+ * @event {CustomEvent} pie-modal-leading-action-click - when the modal leading action is clicked.
49
+ * @event {CustomEvent} pie-modal-supporting-action-click - when the modal supporting action is clicked.
50
+ */
51
+ export class PieModal extends RtlMixin(LitElement) implements ModalProps {
52
+ @property({ type: Object })
53
+ public aria!: AriaProps;
54
+
55
+ @property({ type: String })
56
+ @requiredProperty(componentSelector)
57
+ public heading!: string;
58
+
59
+ @property()
60
+ @validPropertyValues(componentSelector, headingLevels, 'h2')
61
+ public headingLevel: ModalProps['headingLevel'] = 'h2';
62
+
63
+ @property({ type: Boolean })
64
+ public hasBackButton = false;
65
+
66
+ @property({ type: Boolean })
67
+ public hasStackedActions = false;
68
+
69
+ @property({ type: Boolean, reflect: true })
70
+ public isDismissible = false;
71
+
72
+ @property({ type: Boolean })
73
+ public isFooterPinned = true;
74
+
75
+ @property({ type: Boolean })
76
+ public isFullWidthBelowMid = false;
77
+
78
+ @property({ type: Boolean, reflect: true })
79
+ public isLoading = false;
80
+
81
+ @property({ type: Boolean })
82
+ public isOpen = false;
83
+
84
+ @property({ type: Object })
85
+ public leadingAction!: ActionProps;
86
+
87
+ @property()
88
+ @validPropertyValues(componentSelector, positions, 'center')
89
+ public position: ModalProps['position'] = 'center';
90
+
91
+ @property()
92
+ public returnFocusAfterCloseSelector?: string;
93
+
94
+ @property()
95
+ @validPropertyValues(componentSelector, sizes, 'medium')
96
+ public size: ModalProps['size'] = 'medium';
97
+
98
+ @property({ type: Object })
99
+ public supportingAction!: ActionProps;
100
+
101
+ @query('dialog')
102
+ private _dialog?: HTMLDialogElement;
103
+
104
+ private _backButtonClicked = false;
105
+
106
+ // Renders a `CSSResult` generated from SCSS by Vite
107
+ static styles = unsafeCSS(styles);
108
+
109
+ connectedCallback () : void {
110
+ super.connectedCallback();
111
+ this.addEventListener('click', (event) => this._handleDialogLightDismiss(event));
112
+ document.addEventListener(ON_MODAL_OPEN_EVENT, (event) => this._handleModalOpened(<CustomEvent>event));
113
+ document.addEventListener(ON_MODAL_CLOSE_EVENT, (event) => this._handleModalClosed(<CustomEvent>event));
114
+ document.addEventListener(ON_MODAL_BACK_EVENT, (event) => this._handleModalClosed(<CustomEvent>event));
115
+ }
116
+
117
+ disconnectedCallback () : void {
118
+ document.removeEventListener(ON_MODAL_OPEN_EVENT, (event) => this._handleModalOpened(<CustomEvent>event));
119
+ document.removeEventListener(ON_MODAL_CLOSE_EVENT, (event) => this._handleModalClosed(<CustomEvent>event));
120
+ document.removeEventListener(ON_MODAL_BACK_EVENT, (event) => this._handleModalClosed(<CustomEvent>event));
121
+ super.disconnectedCallback();
122
+ }
123
+
124
+ async firstUpdated (changedProperties: PropertyValues<this>) : Promise<void> {
125
+ super.firstUpdated(changedProperties);
126
+
127
+ if (this._dialog) {
128
+ const dialogPolyfill = await import('dialog-polyfill').then((module) => module.default);
129
+ dialogPolyfill.registerDialog(this._dialog);
130
+ this._dialog.addEventListener('cancel', (event) => this._handleDialogCancelEvent(event));
131
+ this._dialog.addEventListener('close', () => {
132
+ this.isOpen = false;
133
+ });
134
+ }
135
+
136
+ this._handleModalOpenStateOnFirstRender(changedProperties);
137
+ }
138
+
139
+ updated (changedProperties: PropertyValues<this>) : void {
140
+ super.updated(changedProperties);
141
+ this._handleModalOpenStateChanged(changedProperties);
142
+ }
143
+
144
+ /**
145
+ * Opens the dialog element and disables page scrolling
146
+ */
147
+ private _handleModalOpened (event: CustomEvent): void {
148
+ const { targetModal } = event.detail;
149
+
150
+ if (targetModal === this) {
151
+ const modalScrollContainer = this._dialog?.querySelector('.c-modal-scrollContainer');
152
+
153
+ if (modalScrollContainer) {
154
+ disableBodyScroll(modalScrollContainer);
155
+ }
156
+
157
+ if (this._dialog?.hasAttribute('open') || !this._dialog?.isConnected) {
158
+ return;
159
+ }
160
+
161
+ // The ::backdrop pseudoelement is only shown if the modal is opened via JS
162
+ this._dialog?.showModal();
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Closes the dialog element and re-enables page scrolling
168
+ */
169
+ private _handleModalClosed (event: CustomEvent): void {
170
+ const { targetModal } = event.detail;
171
+
172
+ if (targetModal === this) {
173
+ const modalScrollContainer = this._dialog?.querySelector('.c-modal-scrollContainer');
174
+
175
+ if (modalScrollContainer) {
176
+ enableBodyScroll(modalScrollContainer);
177
+ }
178
+
179
+ this._dialog?.close();
180
+ this._returnFocus();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Prevents the user from dismissing the dialog via the `cancel`
186
+ * event (ESC key) when `isDismissible` is set to false.
187
+ *
188
+ * @param {Event} event - The event object.
189
+ */
190
+ private _handleDialogCancelEvent = (event: Event) : void => {
191
+ if (!this.isDismissible) {
192
+ event.preventDefault();
193
+ }
194
+ };
195
+
196
+ // Handles the value of the isOpen property on first render of the component
197
+ private _handleModalOpenStateOnFirstRender (changedProperties: PropertyValues<this>) : void {
198
+ // This ensures if the modal is open on first render, the scroll lock and backdrop are applied
199
+ const previousValue = changedProperties.get('isOpen');
200
+
201
+ if (previousValue === undefined && this.isOpen) {
202
+ this._dispatchModalCustomEvent(ON_MODAL_OPEN_EVENT);
203
+ }
204
+ }
205
+
206
+ // Handles changes to the modal isOpen property by dispatching any appropriate events
207
+ private _handleModalOpenStateChanged (changedProperties: PropertyValues<this>) : void {
208
+ const wasPreviouslyOpen = changedProperties.get('isOpen');
209
+
210
+ if (wasPreviouslyOpen !== undefined) {
211
+ if (wasPreviouslyOpen) {
212
+ if (this._backButtonClicked) {
213
+ // Reset the flag
214
+ this._backButtonClicked = false;
215
+ this._dispatchModalCustomEvent(ON_MODAL_BACK_EVENT);
216
+ } else {
217
+ this._dispatchModalCustomEvent(ON_MODAL_CLOSE_EVENT);
218
+ }
219
+ } else {
220
+ this._dispatchModalCustomEvent(ON_MODAL_OPEN_EVENT);
221
+ }
222
+ }
223
+ }
224
+
225
+ private _handleActionClick (actionType: ModalActionType) : void {
226
+ if (actionType === 'leading') {
227
+ this._dialog?.close('leading');
228
+ this._dispatchModalCustomEvent(ON_MODAL_LEADING_ACTION_CLICK);
229
+ } else if (actionType === 'supporting') {
230
+ this._dialog?.close('supporting');
231
+ this._dispatchModalCustomEvent(ON_MODAL_SUPPORTING_ACTION_CLICK);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Return focus to the specified element, providing the selector is valid
237
+ * and the chosen element can be found.
238
+ */
239
+ private _returnFocus () : void {
240
+ const selector = this.returnFocusAfterCloseSelector?.trim();
241
+
242
+ if (selector) {
243
+ (document.querySelector(selector) as HTMLElement)?.focus();
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Template for the close button element. Called within the
249
+ * main render function.
250
+ *
251
+ * @private
252
+ */
253
+ private renderCloseButton (): TemplateResult {
254
+ return html`
255
+ <pie-icon-button
256
+ @click="${() => { this.isOpen = false; }}"
257
+ variant="ghost-secondary"
258
+ class="c-modal-closeBtn"
259
+ aria-label="${this.aria?.close || nothing}"
260
+ data-test-id="modal-close-button">
261
+ <icon-close></icon-close>
262
+ </pie-icon-button>`;
263
+ }
264
+
265
+ /**
266
+ * Template for the back button element. Called within the
267
+ * main render function.
268
+ *
269
+ * @private
270
+ */
271
+ private renderBackButton () : TemplateResult {
272
+ return html`
273
+ <pie-icon-button
274
+ @click="${() => { this._backButtonClicked = true; this.isOpen = false; }}"
275
+ variant="ghost-secondary"
276
+ class="c-modal-backBtn"
277
+ aria-label="${this.aria?.back || nothing}"
278
+ data-test-id="modal-back-button">
279
+ ${this.isRTL ? html`<icon-chevron-right></icon-chevron-right>` : html`<icon-chevron-left></icon-chevron-left>`}
280
+ </pie-icon-button>
281
+ `;
282
+ }
283
+
284
+ /**
285
+ * Render leadingAction button depending on prop availability.
286
+ *
287
+ * 1. If the prop `leadingAction` is not provided, the button is not rendered.
288
+ * 2. If the prop `leadingAction` is provided but any of the optional properties
289
+ * are not provided, they fall back to their default values.
290
+ *
291
+ * @private
292
+ */
293
+ private renderLeadingAction () : TemplateResult | typeof nothing {
294
+ const { text, variant = 'primary', ariaLabel } = this.leadingAction;
295
+
296
+ if (!text) {
297
+ return nothing;
298
+ }
299
+
300
+ return html`
301
+ <pie-button
302
+ variant="${variant}"
303
+ aria-label="${ariaLabel || nothing}"
304
+ type="submit"
305
+ ?isFullWidth="${this.hasStackedActions}"
306
+ @click="${() => this._handleActionClick('leading')}"
307
+ data-test-id="modal-leading-action">
308
+ ${text}
309
+ </pie-button>
310
+ `;
311
+ }
312
+
313
+ /**
314
+ * Render supportingAction button depending on prop availability.
315
+ *
316
+ * 1. If the prop `supportingAction` is not provided, the button is not rendered.
317
+ * 2. If the prop `supportingAction` is provided but any of the optional properties
318
+ * are not provided, they fall back to their default values.
319
+ * 3. If `supportingAction` is provided but not `leadingAction`, log a warning and do
320
+ * not render `supportingAction`.
321
+ *
322
+ * @private
323
+ */
324
+ private renderSupportingAction (): TemplateResult | typeof nothing {
325
+ const { text, variant = 'ghost', ariaLabel } = this.supportingAction;
326
+
327
+ if (!text) {
328
+ return nothing;
329
+ }
330
+
331
+ if (!this.leadingAction) {
332
+ console.warn('Use `leadingAction` instead of `supportingAction`. `supportingAction` is being ignored.');
333
+ return nothing;
334
+ }
335
+
336
+ return html`
337
+ <pie-button
338
+ variant="${variant}"
339
+ aria-label="${ariaLabel || nothing}"
340
+ type="reset"
341
+ ?isFullWidth="${this.hasStackedActions}"
342
+ @click="${() => this._handleActionClick('supporting')}"
343
+ data-test-id="modal-supporting-action">
344
+ ${text}
345
+ </pie-button>
346
+ `;
347
+ }
348
+
349
+ /**
350
+ * Renders the modal inner content and footer of the modal.
351
+ * @private
352
+ */
353
+ private renderModalContentAndFooter (): TemplateResult {
354
+ return html`
355
+ <article class="c-modal-scrollContainer c-modal-content c-modal-content--scrollable">
356
+ <div class="c-modal-contentInner">
357
+ <slot></slot>
358
+ </div>
359
+ ${this.isLoading ? html`<pie-spinner size="xlarge" variant="secondary"></pie-spinner>` : nothing}
360
+ </article>
361
+ <footer class="c-modal-footer">
362
+ ${this.leadingAction ? this.renderLeadingAction() : nothing}
363
+ ${this.supportingAction ? this.renderSupportingAction() : nothing}
364
+ </footer>`;
365
+ }
366
+
367
+ public render () {
368
+ const {
369
+ aria,
370
+ hasBackButton,
371
+ hasStackedActions,
372
+ heading,
373
+ headingLevel = 'h2',
374
+ isDismissible,
375
+ isFooterPinned,
376
+ isFullWidthBelowMid,
377
+ isLoading,
378
+ leadingAction,
379
+ position,
380
+ size,
381
+ supportingAction,
382
+ } = this;
383
+
384
+ const headingTag = unsafeStatic(headingLevel);
385
+
386
+ return html`
387
+ <dialog
388
+ id="dialog"
389
+ class="c-modal"
390
+ size="${size}"
391
+ position="${position}"
392
+ ?hasActions=${leadingAction || supportingAction}
393
+ ?hasBackButton=${hasBackButton}
394
+ ?hasStackedActions=${hasStackedActions}
395
+ ?isDismissible=${isDismissible}
396
+ ?isFooterPinned=${isFooterPinned}
397
+ ?isFullWidthBelowMid=${isFullWidthBelowMid}
398
+ ?isLoading=${isLoading}
399
+ aria-busy="${isLoading ? 'true' : 'false'}"
400
+ aria-label="${(isLoading && aria?.loading) || nothing}"
401
+ data-test-id="pie-modal">
402
+ <header class="c-modal-header">
403
+ ${hasBackButton ? this.renderBackButton() : nothing}
404
+ <${headingTag} class="c-modal-heading">
405
+ ${heading}
406
+ </${headingTag}>
407
+ ${isDismissible ? this.renderCloseButton() : nothing}
408
+ </header>
409
+ ${
410
+ // We need to wrap the remaining content in a shared scrollable container if the footer is not pinned
411
+ isFooterPinned
412
+ ? this.renderModalContentAndFooter()
413
+ : html`
414
+ <div class="c-modal-scrollContainer">
415
+ ${this.renderModalContentAndFooter()}
416
+ </div>
417
+ `
418
+ }
419
+ </dialog>`;
420
+ }
421
+
422
+ /**
423
+ * Dismisses the modal on backdrop click if `isDismissible` is `true`.
424
+ * @param {MouseEvent} event - the click event targetting the modal/backdrop
425
+ */
426
+ private _handleDialogLightDismiss = (event: MouseEvent) : void => {
427
+ if (!this.isDismissible) {
428
+ return;
429
+ }
430
+
431
+ const rect = this._dialog?.getBoundingClientRect();
432
+
433
+ const {
434
+ top = 0, bottom = 0, left = 0, right = 0,
435
+ } = rect || {};
436
+
437
+ // This means the dialog is not open due to clicking the close button so
438
+ // we want to escape early
439
+ if (top === 0 && bottom === 0 && left === 0 && right === 0) {
440
+ return;
441
+ }
442
+
443
+ const isClickOutsideDialog = event.clientY < top ||
444
+ event.clientY > bottom ||
445
+ event.clientX < left ||
446
+ event.clientX > right;
447
+
448
+ if (isClickOutsideDialog) {
449
+ this.isOpen = false;
450
+ }
451
+ };
452
+
453
+ /**
454
+ * Note: We should aim to have a shareable event helper system to allow
455
+ * us to share this across components in-future.
456
+ *
457
+ * Dispatch a custom event.
458
+ *
459
+ * To be used whenever we have behavioural events we want to
460
+ * bubble up through the modal.
461
+ *
462
+ * @param {string} eventType
463
+ */
464
+ private _dispatchModalCustomEvent (eventType: string): void {
465
+ const event = new CustomEvent<ModalEventDetail>(eventType, {
466
+ bubbles: true,
467
+ composed: true,
468
+ detail: { targetModal: this },
469
+ });
470
+
471
+ this.dispatchEvent(event);
472
+ }
473
+ }
474
+
475
+ defineCustomElement(componentSelector, PieModal);
476
+
477
+ declare global {
478
+ interface HTMLElementTagNameMap {
479
+ [componentSelector]: PieModal;
480
+ }
481
+ }
package/src/modal.scss ADDED
@@ -0,0 +1,321 @@
1
+ @use '@justeattakeaway/pie-css/scss' as p;
2
+ @use '@justeat/pie-design-tokens/dist/jet.scss' as dt;
3
+ @use 'dialog-polyfill/dist/dialog-polyfill.css';
4
+
5
+ *,
6
+ *:before,
7
+ *:after {
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ .c-modal {
12
+ // Custom Property Declarations
13
+ // These are defined here instead of :host to encapsulate them inside Shadow DOM
14
+ $breakpoint-wide: 768px;
15
+
16
+ --modal-size-s: 450px;
17
+ --modal-size-m: 600px;
18
+ --modal-size-l: 1080px;
19
+
20
+ // The base modal defaults
21
+ --modal-border-radius: var(--dt-radius-rounded-d);
22
+ --modal-font: var(--dt-font-interactive-m-family);
23
+ --modal-bg-color: var(--dt-color-container-default);
24
+ --modal-elevation: var(--dt-elevation-04);
25
+
26
+ &:focus-visible {
27
+ outline: none;
28
+ }
29
+
30
+ // We need to override the icon sizes at different screen sizes regardless of size prop passed in
31
+ pie-icon-button {
32
+ @media (max-width: calc($breakpoint-wide - 1px)) {
33
+ --btn-dimension: 40px;
34
+ }
35
+ }
36
+
37
+
38
+ &[open] {
39
+ // We only apply this when the modal is open,
40
+ // otherwise it interferes with the native
41
+ // `display: none;` on the dialog element.
42
+ display: flex;
43
+ flex-direction: column;
44
+ }
45
+
46
+ border-radius: var(--modal-border-radius);
47
+ border: none;
48
+ box-shadow: var(--modal-elevation);
49
+ font-family: var(--modal-font);
50
+ background-color: var(--modal-bg-color);
51
+
52
+ padding: 0;
53
+
54
+ --modal-margin-none: var(--dt-spacing-none);
55
+ --modal-margin-small: var(--dt-spacing-g);
56
+ --modal-margin-large: var(--dt-spacing-j);
57
+ --modal-margin-block: var(--modal-margin-small);
58
+
59
+ @media (min-width: $breakpoint-wide) {
60
+ --modal-margin-block: var(--modal-margin-large);
61
+ }
62
+
63
+ // The initial values for these variables apply to the medium-sized modal
64
+ // Other sizes will update the variables as needed
65
+ --modal-block-size: fit-content;
66
+ --modal-inline-size: 75%;
67
+ --modal-max-block-size: calc(100vh - calc(var(--modal-margin-block) * 2));
68
+ --modal-max-inline-size: var(--modal-size-m);
69
+
70
+ block-size: var(--modal-block-size);
71
+ inline-size: var(--modal-inline-size);
72
+ max-block-size: var(--modal-max-block-size);
73
+ max-inline-size: var(--modal-max-inline-size);
74
+
75
+ &[size='small'] {
76
+ --modal-max-inline-size: var(--modal-size-s);
77
+
78
+ @media (min-width: $breakpoint-wide) {
79
+ --modal-margin-block: var(--modal-margin-large);
80
+ }
81
+ }
82
+
83
+ &[size='medium'] {
84
+ /* Same as default styles */
85
+ &[isfullwidthbelowmid] {
86
+ @media (max-width: calc($breakpoint-wide - 1px)) {
87
+ --modal-margin-block: var(--modal-margin-none);
88
+ --modal-border-radius: var(--dt-radius-rounded-none);
89
+ --modal-block-size: 100%;
90
+ --modal-inline-size: 100%;
91
+
92
+ // In this case, the modal must exceed the previous maximum width
93
+ --modal-max-inline-size: 100%;
94
+
95
+ // this ensures the content container spans the full height
96
+ > .c-modal-scrollContainer {
97
+ block-size: 100%;
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ &[size='large'] {
104
+ --modal-inline-size: 75%;
105
+ --modal-max-inline-size: var(--modal-size-l);
106
+ --modal-margin-block: var(--modal-margin-large);
107
+
108
+ @media (max-width: calc($breakpoint-wide - 1px)) {
109
+ --modal-margin-block: var(--modal-margin-none);
110
+ --modal-border-radius: var(--dt-radius-rounded-none);
111
+ --modal-block-size: 100%;
112
+ --modal-inline-size: 100%;
113
+ }
114
+ }
115
+
116
+ &[position='top'] {
117
+ margin-block-start: var(--dt-spacing-j);
118
+ max-block-size: calc(100% - var(--dt-spacing-j) * 2);
119
+
120
+ &[size='large'],
121
+ &[isfullwidthbelowmid][size='medium'] {
122
+ @media (max-width: calc($breakpoint-wide - 1px)) {
123
+ margin-block-start: var(--dt-spacing-none);
124
+ max-block-size: 100%;
125
+ }
126
+ }
127
+ }
128
+
129
+ // We need to pull in the token directly here because the
130
+ // pseudo element `::backdrop` doesn't seem to pick up custom css properties.
131
+ &::backdrop {
132
+ background: #{dt.$color-overlay};
133
+ }
134
+
135
+ & .c-modal-footer {
136
+ --modal-button-spacing: var(--dt-spacing-d);
137
+ --modal-footer-padding: var(--dt-spacing-d);
138
+
139
+ display: flex;
140
+ flex-flow: row-reverse;
141
+ flex-wrap: wrap;
142
+ gap: var(--modal-button-spacing);
143
+ padding: var(--modal-footer-padding);
144
+
145
+ @media (min-width: $breakpoint-wide) {
146
+ --modal-footer-padding: var(--dt-spacing-e);
147
+ }
148
+ }
149
+
150
+ // When hasStackedActions is set
151
+ // change the direction of the footer flex container so buttons are full width of modal
152
+ &[hasstackedactions] {
153
+ & .c-modal-footer {
154
+ // TODO: Move breakpoint sizes into shared CSS component utilities
155
+ @media (max-width: calc($breakpoint-wide - 1px)) {
156
+ flex-direction: column;
157
+ }
158
+ }
159
+ }
160
+
161
+ & .c-modal-header {
162
+ display: grid;
163
+ grid-template-areas: 'back heading close';
164
+ grid-template-columns: minmax(0, max-content) minmax(0, 1fr) minmax(0, max-content);
165
+ align-items: start;
166
+ }
167
+
168
+ & .c-modal-heading {
169
+ --modal-header-font-size: calc(var(--dt-font-heading-m-size--narrow) * 1px);
170
+ --modal-header-font-line-height: calc(var(--dt-font-heading-m-line-height--narrow) * 1px);
171
+ --modal-header-font-weight: var(--dt-font-heading-m-weight);
172
+
173
+ font-size: var(--modal-header-font-size);
174
+ line-height: var(--modal-header-font-line-height);
175
+ font-weight: var(--modal-header-font-weight);
176
+ margin: 0;
177
+ grid-area: heading;
178
+
179
+ margin-inline: var(--dt-spacing-d);
180
+ margin-block: 14px; // This is deliberately not a custom property
181
+
182
+ @media (min-width: $breakpoint-wide) {
183
+ --modal-header-font-size: calc(var(--dt-font-heading-m-size--wide) * 1px);
184
+ --modal-header-font-line-height: calc(var(--dt-font-heading-m-line-height--wide) * 1px);
185
+ margin-inline: var(--dt-spacing-e);
186
+ margin-block: 20px; // This is deliberately not a custom property
187
+ }
188
+ }
189
+
190
+ // Ensure correct padding when there is a back button in front of the heading
191
+ &[hasbackbutton] .c-modal-heading {
192
+ margin-inline-start: var(--dt-spacing-b);
193
+
194
+ @media (min-width: $breakpoint-wide) {
195
+ margin-inline-start: var(--dt-spacing-c);
196
+ }
197
+ }
198
+
199
+ // Ensure correct padding when there is a close button behind the heading
200
+ &[isdismissible] .c-modal-heading {
201
+ margin-inline-end: var(--dt-spacing-d);
202
+
203
+ @media (min-width: $breakpoint-wide) {
204
+ margin-inline-end: var(--dt-spacing-e);
205
+ }
206
+ }
207
+
208
+ & .c-modal-backBtn {
209
+ grid-area: back;
210
+ margin-block: var(--dt-spacing-b);
211
+ margin-inline: var(--dt-spacing-b) var(--dt-spacing-none);
212
+
213
+ @media (min-width: $breakpoint-wide) {
214
+ margin-block: var(--dt-spacing-c);
215
+ margin-inline: var(--dt-spacing-c) var(--dt-spacing-none);
216
+ }
217
+ }
218
+
219
+ & .c-modal-closeBtn {
220
+ grid-area: close;
221
+ margin-block: var(--dt-spacing-b);
222
+ margin-inline: var(--dt-spacing-none) var(--dt-spacing-b);
223
+
224
+ @media (min-width: $breakpoint-wide) {
225
+ margin-block: var(--dt-spacing-c);
226
+ margin-inline: var(--dt-spacing-none) var(--dt-spacing-c);
227
+ }
228
+ }
229
+
230
+ & .c-modal-content {
231
+ // Modal content Custom Props
232
+ --modal-content-font-size: calc(var(--dt-font-size-16) * 1px);
233
+ --modal-content-font-weight: var(--dt-font-weight-regular);
234
+ --modal-content-line-height: calc(var(--dt-font-size-16-line-height) * 1px);
235
+ --modal-content-padding-block: var(--dt-spacing-a);
236
+ --modal-content-padding-inline: var(--dt-spacing-d);
237
+
238
+ @media (min-width: $breakpoint-wide) {
239
+ --modal-content-padding-inline: var(--dt-spacing-e);
240
+ }
241
+
242
+ position: relative;
243
+ min-block-size: var(--dt-spacing-j);
244
+
245
+ font-size: var(--modal-content-font-size);
246
+ line-height: var(--modal-content-line-height);
247
+ font-weight: var(--modal-content-font-weight);
248
+
249
+ padding-inline: var(--modal-content-padding-inline);
250
+ padding-block: var(--modal-content-padding-block);
251
+ flex-grow: 1;
252
+
253
+ &--scrollable {
254
+ background:
255
+ // Scroll shadow cover
256
+ // A top-to-bottom opacity gradient from transparent to the component background colour
257
+ linear-gradient(to bottom, transparent, var(--dt-color-container-default) 75%) center bottom,
258
+ // Scroll shadow
259
+ linear-gradient(transparent, var(--dt-color-border-strong)) center bottom;
260
+ background-repeat: no-repeat;
261
+ background-size: 100% 48px, 100% 12px;
262
+
263
+ // The shadow cover is an opacity gradient which is attached to the bottom of the scrollable element
264
+ // and scrolls with it, so as you reach the bottom of the content the more opaque portion covers
265
+ // (and therefore hides) the shadow. This gives the effect of the shadow fading away.
266
+ // The shadow itself does not move as you scroll.
267
+ background-attachment: local, scroll;
268
+ }
269
+ }
270
+
271
+ & > .c-modal-scrollContainer {
272
+ display: flex;
273
+ flex-direction: column;
274
+ block-size: auto;
275
+ overflow-y: auto;
276
+
277
+ // These are the shadows used to indicate scrolling above and below content
278
+ --bg-scroll-end: linear-gradient(rgba(255, 255, 255, 0), var(--dt-color-container-default) 70%) 0 100%;
279
+ --bg-scroll-bottom: radial-gradient(farthest-corner at 50% 100%, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)) 0 100%;
280
+
281
+ // Sizes of the scroll shadows
282
+ --bg-size-scroll-end: 100% 40px;
283
+ --bg-size-scroll-bottom: 100% 8px;
284
+
285
+ background: var(--bg-scroll-end), var(--bg-scroll-bottom);
286
+ background-repeat: no-repeat;
287
+ background-size: var(--bg-size-scroll-end), var(--bg-size-scroll-bottom);
288
+
289
+ background-attachment: local, scroll;
290
+
291
+ .c-modal-content {
292
+ flex-shrink: 0;
293
+ }
294
+ }
295
+
296
+ &[isfooterpinned] .c-modal-content {
297
+ overflow-y: auto;
298
+ }
299
+
300
+ &[isLoading] .c-modal-content {
301
+ pie-spinner {
302
+ position: absolute;
303
+ left: 50%;
304
+ top: 50%;
305
+ transform: translate(-50%, -50%);
306
+ }
307
+
308
+ & .c-modal-contentInner {
309
+ display: none;
310
+ }
311
+ }
312
+
313
+ // removes the scroll shadow effect if "background-attachment" isn't supported.
314
+ // @supports (background-attachment: local) isn't used as Safari 14 recognizes the property but it has no effect.
315
+ // thus, "aspect-ratio" is used instead as it's only supported in Safari>=15
316
+ @supports not (aspect-ratio: 1 / 1) {
317
+ .c-modal-scrollContainer {
318
+ background: none;
319
+ }
320
+ }
321
+ }