@justeattakeaway/pie-modal 0.14.0 → 0.17.0

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/src/modal.scss CHANGED
@@ -1,4 +1,5 @@
1
1
  @use '@justeat/pie-design-tokens/dist/jet.scss' as dt;
2
+ @use 'dialog-polyfill/dist/dialog-polyfill.css';
2
3
 
3
4
  // TODO - add to CSS lib once created
4
5
  *,
@@ -33,6 +34,12 @@
33
34
  --modal-bg-color: var(--dt-color-container-default);
34
35
  --modal-elevation: var(--dt-elevation-04);
35
36
 
37
+ // TODO: This should be moved into global CSS typography setting
38
+ // This should be imported by consuming apps and set on the application body
39
+ text-rendering: optimizelegibility;
40
+ -webkit-font-smoothing: antialiased;
41
+ -moz-font-smoothing: antialiased;
42
+
36
43
  &:focus-visible {
37
44
  outline: none;
38
45
  }
@@ -44,6 +51,7 @@
44
51
  }
45
52
  }
46
53
 
54
+
47
55
  &[open] {
48
56
  // We only apply this when the modal is open,
49
57
  // otherwise it interferes with the native
@@ -148,6 +156,17 @@
148
156
  }
149
157
  }
150
158
 
159
+ // When hasStackedActions is set
160
+ // change the direction of the footer flex container so buttons are full width of modal
161
+ &[hasstackedactions] {
162
+ & .c-modal-footer {
163
+ // TODO: Move breakpoint sizes into shared CSS component utilities
164
+ @media (max-width: calc($breakpoint-wide - 1px)) {
165
+ flex-direction: column;
166
+ }
167
+ }
168
+ }
169
+
151
170
  & .c-modal-header {
152
171
  padding-inline: var(--dt-spacing-d);
153
172
  padding-block: 14px; // This is deliberately not a custom property
@@ -222,6 +241,32 @@
222
241
  grid-area: close;
223
242
  }
224
243
 
244
+ &[isfooterpinned] .c-modal-content,
245
+ & .c-modal-scrollContainer {
246
+ overflow-y: auto;
247
+ }
248
+
249
+ & .c-modal-scrollContainer {
250
+ // These are the shadows used to indicate scrolling above and below content
251
+ --bg-scroll-start: linear-gradient(var(--dt-color-container-default) 30%, rgba(255, 255, 255, 0));
252
+ --bg-scroll-end: linear-gradient(rgba(255, 255, 255, 0), var(--dt-color-container-default) 70%) 0 100%;
253
+ --bg-scroll-top: radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
254
+ --bg-scroll-bottom: radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)) 0 100%;
255
+
256
+ // Sizes of the scroll shadows
257
+ --bg-size-scroll-start: 100% 40px;
258
+ --bg-size-scroll-end: 100% 40px;
259
+ --bg-size-scroll-top: 100% 16px;
260
+ --bg-size-scroll-bottom: 100% 16px;
261
+
262
+ background: var(--bg-scroll-start), var(--bg-scroll-end), var(--bg-scroll-top), var(--bg-scroll-bottom);
263
+ background-repeat: no-repeat;
264
+ background-color: var(--dt-color-container-default);
265
+ background-size: var(--bg-size-scroll-start), var(--bg-size-scroll-end), var(--bg-size-scroll-top), var(--bg-size-scroll-bottom);
266
+
267
+ background-attachment: local, local, scroll, scroll;
268
+ }
269
+
225
270
  & .c-modal-content {
226
271
  // Modal content Custom Props
227
272
  --modal-content-font-size: calc(var(--dt-font-size-16) * 1px);
@@ -257,8 +302,6 @@
257
302
  padding-inline: var(--modal-content-padding);
258
303
  padding-block: var(--modal-content-padding-block);
259
304
 
260
- overflow-y: auto;
261
-
262
305
  &--scrollable {
263
306
  background:
264
307
  // Scroll shadow cover
@@ -612,6 +612,11 @@ test.describe('actions', () => {
612
612
  variant: 'primary',
613
613
  ariaLabel: 'Descriptive message',
614
614
  },
615
+ supportingAction: {
616
+ text: 'Cancel',
617
+ variant: 'ghost',
618
+ ariaLabel: 'Descriptive message',
619
+ },
615
620
  },
616
621
  });
617
622
 
@@ -635,6 +640,11 @@ test.describe('actions', () => {
635
640
  variant: 'primary',
636
641
  ariaLabel: 'Descriptive message',
637
642
  },
643
+ supportingAction: {
644
+ text: 'Cancel',
645
+ variant: 'ghost',
646
+ ariaLabel: 'Descriptive message',
647
+ },
638
648
  },
639
649
  });
640
650
 
@@ -651,3 +661,156 @@ test.describe('actions', () => {
651
661
  });
652
662
  });
653
663
  });
664
+
665
+ test.describe('Props: `aria`', () => {
666
+ test.describe('when aria exist', () => {
667
+ test('should render component elements with the correct aria-labels', async ({ mount }) => {
668
+ // Arrange
669
+ const component = await mount(PieModal, {
670
+ props: {
671
+ isOpen: true,
672
+ isDismissible: true,
673
+ isLoading: true,
674
+ hasBackButton: true,
675
+ aria: {
676
+ close: 'Close label info',
677
+ back: 'Back label info',
678
+ loading: 'Loading label info',
679
+ },
680
+ },
681
+ });
682
+
683
+ // Act
684
+ // Close button
685
+ const closeButton = await component.locator(closeButtonSelector);
686
+ const ariaCloseLabel = await closeButton.getAttribute('aria-label');
687
+
688
+ // Back button
689
+ const backButton = await component.locator(backButtonSelector);
690
+ const ariaBackLabel = await backButton.getAttribute('aria-label');
691
+
692
+ // Assert
693
+ await expect(ariaCloseLabel).toBe('Close label info');
694
+ await expect(ariaBackLabel).toBe('Back label info');
695
+ });
696
+
697
+ test.describe('when modal `isloading` is true', () => {
698
+ test('should render component with the correct aria values: `aria-label` & `aria-busy`', async ({ mount }) => {
699
+ // Arrange
700
+ const component = await mount(PieModal, {
701
+ props: {
702
+ isOpen: true,
703
+ isLoading: true,
704
+ aria: {
705
+ loading: 'Loading label info',
706
+ },
707
+ },
708
+ });
709
+
710
+ // Loading state
711
+ const pieModalComponent = await component.locator(componentSelector);
712
+ const ariaLoadingLabel = await pieModalComponent.getAttribute('aria-label');
713
+ const ariaLoadingBusy = await pieModalComponent.getAttribute('aria-busy');
714
+
715
+ // Assert
716
+ await expect(ariaLoadingLabel).toBe('Loading label info');
717
+ await expect(ariaLoadingBusy).toBe('true');
718
+ });
719
+ });
720
+
721
+ test.describe('when modal `isLoading` is dynamically changing from `isLoading: true` to `isLoading: false`', () => {
722
+ test('should dynamically add, remove, and update `arial-label` & `aria-busy` labels', async ({ mount }) => {
723
+ // Arrange
724
+ const component = await mount(PieModal, {
725
+ props: {
726
+ isOpen: true,
727
+ isLoading: true,
728
+ aria: {
729
+ loading: 'Loading label info',
730
+ },
731
+ },
732
+ });
733
+
734
+ const pieModalComponent = await component.locator(componentSelector);
735
+ let ariaLoadingLabel = await pieModalComponent.getAttribute('aria-label');
736
+ let ariaLoadingBusy = await pieModalComponent.getAttribute('aria-busy');
737
+
738
+ // Assert: When `isLoading: true`
739
+ await expect(ariaLoadingLabel).toBe('Loading label info');
740
+ await expect(ariaLoadingBusy).toBe('true');
741
+
742
+ await component.update({ props: { isLoading: false } });
743
+
744
+ ariaLoadingLabel = await pieModalComponent.getAttribute('aria-label');
745
+ ariaLoadingBusy = await pieModalComponent.getAttribute('aria-busy');
746
+
747
+ // Assert: When `isLoading: false`
748
+ await expect(ariaLoadingLabel).toBeNull();
749
+ await expect(ariaLoadingBusy).toBe('false');
750
+ });
751
+ });
752
+ });
753
+
754
+ test.describe('when aria does not exist', () => {
755
+ test('should not render the aria-labels', async ({ mount }) => {
756
+ // Arrange
757
+ const component = await mount(PieModal, {
758
+ props: {
759
+ isOpen: true,
760
+ isDismissible: true,
761
+ hasBackButton: true,
762
+ },
763
+ });
764
+
765
+ // Act
766
+ // Close button
767
+ const closeButton = await component.locator(closeButtonSelector);
768
+ const ariaCloseLabel = await closeButton.getAttribute('aria-label');
769
+
770
+ // Back button
771
+ const backButton = await component.locator(backButtonSelector);
772
+ const ariaBackLabel = await backButton.getAttribute('aria-label');
773
+
774
+ // Assert
775
+ await expect(ariaCloseLabel).toBe(null);
776
+ await expect(ariaBackLabel).toBe(null);
777
+ });
778
+ });
779
+
780
+ test.describe('when modal `isloading` is false', () => {
781
+ test('should not render aria-label', async ({ mount }) => {
782
+ // Arrange
783
+ const component = await mount(PieModal, {
784
+ props: {
785
+ isOpen: true,
786
+ isLoading: false,
787
+ },
788
+ });
789
+
790
+ // Loading state
791
+ const pieModalComponent = await component.locator(componentSelector);
792
+ const ariaLoadingLabel = await pieModalComponent.getAttribute('aria-label');
793
+
794
+ // Assert
795
+ await expect(ariaLoadingLabel).toBe(null);
796
+ });
797
+
798
+ test('should set `aria-busy` to `false`', async ({ mount }) => {
799
+ // Arrange
800
+ const component = await mount(PieModal, {
801
+ props: {
802
+ isOpen: true,
803
+ isLoading: false,
804
+ },
805
+ });
806
+
807
+ // Loading state
808
+ const pieModalComponent = await component.locator(componentSelector);
809
+ const ariaLoadingBusy = await pieModalComponent.getAttribute('aria-busy');
810
+
811
+ // Assert
812
+ await expect(ariaLoadingBusy).toBe('false');
813
+ });
814
+ });
815
+ });
816
+
@@ -75,25 +75,25 @@ test.describe('Prop: `isFullWidthBelowMid`', () => {
75
75
 
76
76
  test.describe('when false', () => {
77
77
  (['small', 'medium'] as Array<ModalProps['size']>)
78
- .forEach((size) => {
79
- test(`should not be full width for a modal with size = ${size}`, async ({ page, mount }) => {
80
- await mount(PieModal, {
81
- props: {
82
- heading: 'This is a modal heading',
83
- isFullWidthBelowMid: false,
84
- isOpen: true,
85
- size,
86
- leadingAction: {
87
- text: 'Confirm',
88
- variant: 'primary',
89
- ariaLabel: 'Confirmation text',
90
- },
91
- } as ModalProps,
78
+ .forEach((size) => {
79
+ test(`should not be full width for a modal with size = ${size}`, async ({ page, mount }) => {
80
+ await mount(PieModal, {
81
+ props: {
82
+ heading: 'This is a modal heading',
83
+ isFullWidthBelowMid: false,
84
+ isOpen: true,
85
+ size,
86
+ leadingAction: {
87
+ text: 'Confirm',
88
+ variant: 'primary',
89
+ ariaLabel: 'Confirmation text',
90
+ },
91
+ } as ModalProps,
92
+ });
93
+
94
+ await percySnapshot(page, `Modal - isFullWidthBelowMid = false, size = ${size}`);
92
95
  });
93
-
94
- await percySnapshot(page, `Modal - isFullWidthBelowMid = false, size = ${size}`);
95
96
  });
96
- });
97
97
  });
98
98
  });
99
99
 
@@ -231,8 +231,6 @@ test.describe('Prop: `leadingAction`', () => {
231
231
  await mount(PieModal, {
232
232
  props: {
233
233
  heading: 'This is a modal heading',
234
- hasBackButton: true,
235
- isDismissible: true,
236
234
  isOpen: true,
237
235
  leadingAction: {
238
236
  text: 'Confirm',
@@ -251,8 +249,6 @@ test.describe('Prop: `leadingAction`', () => {
251
249
  await mount(PieModal, {
252
250
  props: {
253
251
  heading: 'This is a modal heading',
254
- hasBackButton: true,
255
- isDismissible: true,
256
252
  isOpen: true,
257
253
  leadingAction: {
258
254
  text: 'Confirm',
@@ -269,8 +265,6 @@ test.describe('Prop: `leadingAction`', () => {
269
265
  await mount(PieModal, {
270
266
  props: {
271
267
  heading: 'This is a modal heading',
272
- hasBackButton: true,
273
- isDismissible: true,
274
268
  isOpen: true,
275
269
  leadingAction: {
276
270
  text: '',
@@ -287,8 +281,6 @@ test.describe('Prop: `leadingAction`', () => {
287
281
  await mount(PieModal, {
288
282
  props: {
289
283
  heading: 'This is a modal heading',
290
- hasBackButton: true,
291
- isDismissible: true,
292
284
  isOpen: true,
293
285
  } as ModalProps,
294
286
  });
@@ -298,7 +290,111 @@ test.describe('Prop: `leadingAction`', () => {
298
290
  });
299
291
  });
300
292
 
301
- test.describe('`position`', () => {
293
+ test.describe('Prop: `supportingAction`', () => {
294
+ test.describe('when `leadingAction` prop exists', () => {
295
+ test('should display `supportingAction` correctly', async ({ mount, page }) => {
296
+ await mount(PieModal, {
297
+ props: {
298
+ heading: 'This is a modal heading',
299
+ isOpen: true,
300
+ leadingAction: {
301
+ text: 'Confirm',
302
+ variant: 'primary',
303
+ ariaLabel: 'Confirmation text',
304
+ },
305
+ supportingAction: {
306
+ text: 'Cancel',
307
+ variant: 'ghost',
308
+ ariaLabel: 'Cancellation text',
309
+ },
310
+ } as ModalProps,
311
+ });
312
+
313
+ await percySnapshot(page, 'Modal displays supportingAction alongside leadingAction');
314
+ });
315
+
316
+ test.describe('when prop is provided but the optional child properties of `supportingAction` are not provided', () => {
317
+ test('should fall back to default property', async ({ mount, page }) => {
318
+ await mount(PieModal, {
319
+ props: {
320
+ heading: 'This is a modal heading',
321
+ isOpen: true,
322
+ leadingAction: {
323
+ text: 'Confirm',
324
+ variant: 'primary',
325
+ ariaLabel: 'Confirmation text',
326
+ },
327
+ supportingAction: {
328
+ text: 'Cancel',
329
+ ariaLabel: 'Confirmation text',
330
+ },
331
+ } as ModalProps,
332
+ });
333
+
334
+ await percySnapshot(page, 'Modal falls back to default variant property `ghost`');
335
+ });
336
+ });
337
+
338
+ test.describe('when `supportingAction` prop is provided but the `text` child property of `supportingAction` is empty', () => {
339
+ test('should not render supportingAction markup', async ({ mount, page }) => {
340
+ await mount(PieModal, {
341
+ props: {
342
+ heading: 'This is a modal heading',
343
+ isOpen: true,
344
+ leadingAction: {
345
+ text: 'Confirm',
346
+ variant: 'primary',
347
+ ariaLabel: 'Confirmation text',
348
+ },
349
+ supportingAction: {
350
+ text: '',
351
+ },
352
+ } as ModalProps,
353
+ });
354
+
355
+ await percySnapshot(page, 'Modal will not render `supportingAction` button when `text` is empty');
356
+ });
357
+ });
358
+
359
+ test.describe('when `supportingAction` is not supplied', () => {
360
+ test('should not render supportingAction markup', async ({ mount, page }) => {
361
+ await mount(PieModal, {
362
+ props: {
363
+ heading: 'This is a modal heading',
364
+ isOpen: true,
365
+ leadingAction: {
366
+ text: 'Confirm',
367
+ variant: 'primary',
368
+ ariaLabel: 'Confirmation text',
369
+ },
370
+ } as ModalProps,
371
+ });
372
+
373
+ await percySnapshot(page, 'Modal will not render `supportingAction` when it is not supplied');
374
+ });
375
+ });
376
+ });
377
+
378
+ test.describe('when `leadingAction` prop does not exist and `supportingAction` is supplied', () => {
379
+ test('should not render supportingAction markup', async ({ mount, page }) => {
380
+ await mount(PieModal, {
381
+ props: {
382
+ heading: 'This is a modal heading',
383
+ isOpen: true,
384
+ supportingAction: {
385
+ text: 'Cancel',
386
+ variant: 'ghost',
387
+ ariaLabel: 'Cancellation text',
388
+ },
389
+ } as ModalProps,
390
+ });
391
+
392
+ await percySnapshot(page, 'Modal will not render `supportingAction` when `leadingAction` is not supplied');
393
+ });
394
+ });
395
+ });
396
+
397
+ test.describe('Prop: `position`', () => {
302
398
  positions.forEach((position) => {
303
399
  test(`should be positioned in the correct part of the page when position is: ${position}`, async ({ mount, page }) => {
304
400
  await mount(PieModal, {
@@ -318,3 +414,81 @@ test.describe('`position`', () => {
318
414
  });
319
415
  });
320
416
  });
417
+
418
+ test.describe('Prop: `isFooterPinned`', () => {
419
+ [true, false].forEach((isFooterPinned) => {
420
+ test(`when isFooterPinned is: ${isFooterPinned}`, async ({ mount, page }) => {
421
+ await mount(PieModal, {
422
+ props: {
423
+ heading: 'This is a modal heading',
424
+ isOpen: true,
425
+ isFooterPinned,
426
+ leadingAction: {
427
+ text: 'Confirm',
428
+ variant: 'primary',
429
+ ariaLabel: 'Confirmation text',
430
+ },
431
+ } as ModalProps,
432
+ slots: {
433
+ default: `Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus in magni
434
+ quis obcaecati laboriosam est vero, perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor
435
+ sit amet consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus in magni quis obcaecati laboriosam est vero,
436
+ perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor sit amet consectetur adipisicing elit.
437
+
438
+ Deleniti fugit id exercitationem repellendus in magni quis obcaecati laboriosam est vero, perspiciatis ratione porro dolore
439
+ repudiandae ea numquam! Ipsa, fugiat aut. Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus
440
+ in magni quis obcaecati laboriosam est vero, perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor sit amet
441
+ consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus in magni quis obcaecati laboriosam est vero, perspiciatis ratione
442
+ porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti fugit id
443
+ exercitationem repellendus in magni quis obcaecati laboriosam est vero,
444
+ perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.
445
+
446
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus in magni
447
+ quis obcaecati laboriosam est vero, perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor
448
+ sit amet consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus in magni quis obcaecati laboriosam est vero,
449
+ perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor sit amet consectetur adipisicing elit.
450
+
451
+ Deleniti fugit id exercitationem repellendus in magni quis obcaecati laboriosam est vero, perspiciatis ratione porro dolore
452
+ repudiandae ea numquam! Ipsa, fugiat aut. Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus
453
+ in magni quis obcaecati laboriosam est vero, perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor sit amet
454
+ consectetur adipisicing elit. Deleniti fugit id exercitationem repellendus in magni quis obcaecati laboriosam est vero, perspiciatis ratione
455
+ porro dolore repudiandae ea numquam! Ipsa, fugiat aut.Lorem ipsum dolor sit amet consectetur adipisicing elit. Deleniti fugit id
456
+ exercitationem repellendus in magni quis obcaecati laboriosam est vero,
457
+ perspiciatis ratione porro dolore repudiandae ea numquam! Ipsa, fugiat aut.`,
458
+ },
459
+ });
460
+
461
+ await percySnapshot(page, `Modal isFooterPinned: ${isFooterPinned}`);
462
+ });
463
+ });
464
+ });
465
+
466
+ test.describe('Prop: `hasStackedActions`', () => {
467
+ test.describe('when true', () => {
468
+ (['small', 'medium', 'large'] as Array<ModalProps['size']>)
469
+ .forEach((size) => {
470
+ test(`should display actions full width (at narrow viewports – with leading action on top) for a modal with size = ${size}`, async ({ page, mount }) => {
471
+ await mount(PieModal, {
472
+ props: {
473
+ heading: 'This is a modal heading',
474
+ hasStackedActions: true,
475
+ isOpen: true,
476
+ size,
477
+ leadingAction: {
478
+ text: 'Confirm',
479
+ variant: 'primary',
480
+ ariaLabel: 'Confirmation text',
481
+ },
482
+ supportingAction: {
483
+ text: 'Cancel',
484
+ variant: 'ghost',
485
+ ariaLabel: 'Cancel and close modal',
486
+ },
487
+ } as ModalProps,
488
+ });
489
+
490
+ await percySnapshot(page, `Modal - hasStackedActions = true, size = ${size}`);
491
+ });
492
+ });
493
+ });
494
+ });
package/tsconfig.json CHANGED
@@ -4,5 +4,5 @@
4
4
  "baseUrl": ".",
5
5
  "rootDir": ".",
6
6
  },
7
- "include": ["src/**/*.ts","./declaration.d.ts", "test/**/*.ts"],
7
+ "include": ["src/**/*.ts","./declaration.d.ts", "test/**/*.ts", "playwright-lit-visual.config.ts", "playwright-lit.config.ts"],
8
8
  }