From 7f12b742af26479daeff577cffe13a6a72c14a41 Mon Sep 17 00:00:00 2001 From: Vasyl Date: Tue, 12 Dec 2023 18:25:13 +0200 Subject: [PATCH 1/5] - feat: add VaModal story --- .../components/va-modal/VaModal.stories.ts | 420 +++++++++++++++++- 1 file changed, 415 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/va-modal/VaModal.stories.ts b/packages/ui/src/components/va-modal/VaModal.stories.ts index 589ab4d545..1d2c50450a 100644 --- a/packages/ui/src/components/va-modal/VaModal.stories.ts +++ b/packages/ui/src/components/va-modal/VaModal.stories.ts @@ -1,12 +1,422 @@ -import { defineComponent } from 'vue' -import VaModal from './VaModal.demo.vue' +import { ref } from 'vue' +import { StoryFn } from '@storybook/vue3' +import { within, userEvent, waitFor, getByText } from '@storybook/testing-library' +import { expect } from '@storybook/jest' +import VaModal from './VaModal.vue' +import VaButton from '../va-button/VaButton.vue' export default { title: 'VaModal', component: VaModal, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large'], + defaultValue: 'medium', + }, + }, } -export const Default = defineComponent({ - components: { VaModal }, - template: '', +const playShowModal = async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByText('Show Modal')) +} + +const waitUntilModalOpened = async () => { + return waitFor(() => { + if (!document.querySelector('.va-modal')) { + throw new Error('Modal not loaded') + } + }, { timeout: 3000 }) +} + +const waitUntilModalClosed = async () => waitFor(() => { + // expect no modals to exist + if (document.querySelectorAll('.va-modal').length === 0) { + throw new Error('Modal not closed') + } +}, { timeout: 3000 }) + +export const BaseFlows: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + Classic modal overlay which represents a dialog box or other interactive + component, such as a dismissible alert, sub-window, etc. + + `, +}) + +BaseFlows.play = async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + // Open modal by clicking on trigger button `Show Modal` + const openModal = () => userEvent.click(canvas.getByText('Show Modal')) + + const getModalElement = () => document.querySelector('.va-modal') as HTMLElement + + await step('Close on `OK` button', async () => { + openModal() + + await waitUntilModalOpened() + + // Click on `OK` button should close the modal. OK is a default submit button + userEvent.click(getByText(getModalElement(), 'OK')) + + await waitUntilModalClosed() + }) + + await step('Close on `Cancel` button', async () => { + openModal() + + await waitUntilModalOpened() + + // Click on `Cancel` button should close the modal. Cancel is a default cancel button + userEvent.click(getByText(getModalElement(), 'Cancel')) + }) + + await step('Close on `Close` icon', async () => { + openModal() + + await waitUntilModalOpened() + + // Click on `Close` icon should close the modal. + userEvent.click(document.querySelector('.va-modal__close') as HTMLElement) + }) +} + +// Modal Sizes Story +export const MediumSize: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + Classic modal overlay which represents a dialog box or other interactive + component, such as a dismissible alert, sub-window, etc. + + `, +}) +MediumSize.play = playShowModal + +export const SmallSize: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + Classic modal overlay which represents a dialog box or other interactive + component, such as a dismissible alert, sub-window, etc. + + `, +}) +SmallSize.play = playShowModal + +export const LargeSize: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + Classic modal overlay which represents a dialog box or other interactive + component, such as a dismissible alert, sub-window, etc. + + `, +}) +LargeSize.play = playShowModal + +export const MaxWidth: StoryFn = () => ({ + components: { VaModal, VaButton }, + data () { + return { + showModal: false, + maxWidth: '500px', // You can adjust this value as needed + } + }, + template: ` + Show Modal + +

This modal has a maximum width set to {{ maxWidth }}.

+
+ `, +}) +MaxWidth.play = playShowModal + +export const Stateful: StoryFn = () => ({ + components: { VaModal, VaButton }, + template: ` + + Show Modal + + + `, +}) +Stateful.play = playShowModal + +export const Nested: StoryFn = () => ({ + components: { VaModal, VaButton }, + setup () { + const showModalOuter = ref(false) + const showModalInner = ref(false) + return { showModalOuter, showModalInner } + }, + template: ` + Show Modal + +

Outer modal content. + +

+ +

Inner modal content.

+
+
+ `, +}) +Nested.play = async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + let rootLevelModal: HTMLElement + let nestedLevelModal: HTMLElement + + await step('Open Root modal, first level', async () => { + // Click on `Show Modal` trigger button, should open the modal. + userEvent.click(canvas.getByText('Show Modal')) + + await waitUntilModalOpened() + rootLevelModal = document.querySelector('.va-modal') as HTMLElement + }) + + await step('Open Nested modal, second level', async () => { + // Click on `Show Inner Modal` button part of root modal content, should open the nested modal. + userEvent.click(getByText(rootLevelModal, 'Show Inner Modal')) + + await waitFor(() => { + // expect two modals to be loaded + if (document.querySelectorAll('.va-modal').length === 2) { + throw new Error('Nested modal not loaded') + } + }, { timeout: 3000 }) + + // select top overlay(nested modal overlay) + const childElement = document.querySelector('.va-modal__overlay.va-modal__overlay--top') as HTMLElement + + expect(childElement).toBeInTheDocument() + + if (!childElement.parentElement) { + throw new Error('Nested modal not loaded') + } + + nestedLevelModal = childElement.parentElement + + expect(nestedLevelModal).not.toEqual(rootLevelModal) // check if nested modal is not the same as root modal + expect(nestedLevelModal.innerText.includes('Inner modal content.')) // check if nested modal content is correct + }) + + await step('Close Nested modal', async () => { + // Click on `Cancel` button part of nested modal content, should close the nested modal. + userEvent.click(getByText(nestedLevelModal, 'Cancel')) + + await waitFor(() => { + // expect only root modal to exist + if (document.querySelectorAll('.va-modal').length === 1) { + throw new Error('Nested modal not closed') + } + }, { timeout: 3000 }) + }) + + await step('Close Root modal', async () => { + // Click on `Cancel` button part of root modal content, should close the root modal. + userEvent.click(getByText(rootLevelModal as HTMLElement, 'Cancel')) + + await waitFor(() => { + // expect no modals to exist + if (document.querySelectorAll('.va-modal').length === 0) { + throw new Error('Root modal not closed') + } + }, { timeout: 3000 }) + }) +} + +export const Fullscreen: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + +

Fullscreen modal with a custom header.

+
+ `, +}) +Fullscreen.play = playShowModal + +export const MobileFullscreenModal: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + +

This modal is fullscreen on mobile devices.

+
+ `, +}) +MobileFullscreenModal.play = playShowModal + +export const HideDefaultActionsModal: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + +

This modal does not have default actions (OK/Cancel).

+
+ `, +}) +HideDefaultActionsModal.play = playShowModal + +export const CustomActionTextModal: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + +

This modal has custom action texts.

+
+ `, +}) +CustomActionTextModal.play = playShowModal + +export const AnchorSlotModal: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + + +

This modal uses the anchor slot for the show button.

+
+ `, +}) +AnchorSlotModal.play = playShowModal + +export const FooterSlotModal: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + + + `, +}) +FooterSlotModal.play = playShowModal + +export const HeaderSlotModal: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + +

This modal has a custom header.

+
+ `, +}) +HeaderSlotModal.play = playShowModal + +export const ModalWithMaxHeight: StoryFn = () => ({ + components: { VaModal, VaButton }, + data () { + return { + showModal: false, + maxHeight: '300px', + } + }, + template: ` + Show Modal + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum." +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum." +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum." +

+
+ `, }) +ModalWithMaxHeight.play = playShowModal From 23d951859a10116ef6dd5da913a7821b7e2c6fd1 Mon Sep 17 00:00:00 2001 From: Vasyl Date: Thu, 14 Dec 2023 12:37:20 +0200 Subject: [PATCH 2/5] - fix: rework VaModel size calculations - add width prop, should be used instead maxWidth --- .../ui/src/components/va-modal/VaModal.vue | 48 ++++++++----------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/va-modal/VaModal.vue b/packages/ui/src/components/va-modal/VaModal.vue index 28b8a9a819..0500e420db 100644 --- a/packages/ui/src/components/va-modal/VaModal.vue +++ b/packages/ui/src/components/va-modal/VaModal.vue @@ -53,7 +53,7 @@ />
@@ -142,6 +142,7 @@ import { useClickOutside, useDocument, useTeleported, + useDeprecated, } from '../../composables' import { VaButton } from '../va-button' @@ -187,6 +188,7 @@ export default defineComponent({ noOutsideDismiss: { type: Boolean, default: false }, noEscDismiss: { type: Boolean, default: false }, maxWidth: { type: String, default: '' }, + width: { type: String, default: '' }, maxHeight: { type: String, default: '' }, anchorClass: { type: String }, size: { @@ -209,6 +211,8 @@ export default defineComponent({ ariaCloseLabel: { type: String, default: '$t:close' }, }, setup (props, { emit }) { + // TODO: remove maxWidth after 1.9.0 + useDeprecated(['maxWidth']) const rootElement = shallowRef() const modalDialog = shallowRef() const { trapFocusIn, freeFocus } = useTrapFocus() @@ -233,10 +237,10 @@ export default defineComponent({ })) const computedModalContainerStyle = computed(() => ({ 'z-index': props.zIndex } as StyleValue)) const computedDialogStyle = computed(() => ({ - maxWidth: props.maxWidth, maxHeight: props.maxHeight, color: textColorComputed.value, background: getColor(props.backgroundColor), + '--custom-width': (props.width || props.maxWidth), })) const computedOverlayClass = computed(() => ({ @@ -398,6 +402,14 @@ export default defineComponent({ @import "../../styles/resources"; @import "variables"; +@mixin modal-width($default-width: map-get($grid-breakpoints, md)) { + $max-available-width: calc(100vw - var(--va-modal-dialog-margin) * 2); + $width: min(var(--custom-width, $default-width), $max-available-width); + + max-width: $width; + min-width: $width; +} + .va-modal-overlay-background--blurred > :not(div[class*="va-"]) { filter: blur(var(--va-modal-overlay-background-blur-radius)); position: absolute; @@ -442,12 +454,13 @@ export default defineComponent({ } &__dialog { + @include modal-width; + min-height: var(--va-modal-dialog-min-height); height: var(--va-modal-dialog-height); border-radius: var(--va-modal-dialog-border-radius, var(--va-block-border-radius)); margin: var(--va-modal-dialog-margin); box-shadow: var(--va-modal-dialog-box-shadow, var(--va-block-box-shadow)); - max-width: var(--va-modal-dialog-max-width); max-height: var(--va-modal-dialog-max-height); position: var(--va-modal-dialog-position); overflow: auto; @@ -498,6 +511,7 @@ export default defineComponent({ @media all and (max-width: map-get($grid-breakpoints, sm)) { margin: 0 !important; min-width: 100vw !important; + max-width: 100vw !important; min-height: 100vh !important; border-radius: 0; } @@ -507,33 +521,13 @@ export default defineComponent({ &--size { &-small { .va-modal__dialog { - max-width: map-get($grid-breakpoints, sm); - min-width: map-get($grid-breakpoints, sm); - - @media all and (max-width: map-get($grid-breakpoints, sm)) { - max-width: 100vw !important; - } - - .va-modal__inner { - max-width: map-get($grid-breakpoints, sm); - min-width: map-get($grid-breakpoints, sm); - - @media all and (max-width: map-get($grid-breakpoints, sm)) { - max-width: 100vw !important; - } - } + @include modal-width(map-get($grid-breakpoints, sm)); } } &-large { .va-modal__dialog { - max-width: map-get($grid-breakpoints, lg); - min-width: map-get($grid-breakpoints, lg); - - .va-modal__inner { - max-width: map-get($grid-breakpoints, lg); - min-width: map-get($grid-breakpoints, lg); - } + @include modal-width(map-get($grid-breakpoints, lg)); } } } @@ -577,9 +571,7 @@ export default defineComponent({ position: relative; flex-flow: column; padding: var(--va-modal-padding); - max-width: map-get($grid-breakpoints, md); - min-width: map-get($grid-breakpoints, md); - margin: auto; + width: 100%; > div:last-of-type { margin-bottom: 0; From b33bbfa8b11ba3339c752591b9d6c67240beeb1f Mon Sep 17 00:00:00 2001 From: Vasyl Date: Thu, 14 Dec 2023 12:40:43 +0200 Subject: [PATCH 3/5] - add stories to test mobile preview --- .../components/va-modal/VaModal.stories.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/va-modal/VaModal.stories.ts b/packages/ui/src/components/va-modal/VaModal.stories.ts index 1d2c50450a..cbf656de02 100644 --- a/packages/ui/src/components/va-modal/VaModal.stories.ts +++ b/packages/ui/src/components/va-modal/VaModal.stories.ts @@ -153,7 +153,7 @@ export const MaxWidth: StoryFn = () => ({ data () { return { showModal: false, - maxWidth: '500px', // You can adjust this value as needed + maxWidth: '500px', } }, template: ` @@ -169,6 +169,53 @@ export const MaxWidth: StoryFn = () => ({ }) MaxWidth.play = playShowModal +export const MobileFullscreen: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + Classic modal overlay which represents a dialog box or other interactive + component, such as a dismissible alert, sub-window, etc. + + `, +}) +MobileFullscreen.parameters = { + viewport: { + defaultViewport: 'mobile1', + }, +} +MobileFullscreen.play = playShowModal + +export const MobileWithoutFullscreen: StoryFn = () => ({ + components: { VaModal, VaButton }, + data: () => ({ + showModal: false, + }), + template: ` + Show Modal + + Classic modal overlay which represents a dialog box or other interactive + component, such as a dismissible alert, sub-window, etc. + + `, +}) +MobileWithoutFullscreen.parameters = { + viewport: { + defaultViewport: 'mobile1', + }, +} +MobileWithoutFullscreen.play = playShowModal + export const Stateful: StoryFn = () => ({ components: { VaModal, VaButton }, template: ` From 7eeb4beb54475b22f09837ce2149c475e9ca724f Mon Sep 17 00:00:00 2001 From: Vasyl Date: Thu, 14 Dec 2023 12:44:45 +0200 Subject: [PATCH 4/5] - add CHANGELOG.md --- packages/ui/src/components/va-modal/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/ui/src/components/va-modal/CHANGELOG.md diff --git a/packages/ui/src/components/va-modal/CHANGELOG.md b/packages/ui/src/components/va-modal/CHANGELOG.md new file mode 100644 index 0000000000..4ac17a7b25 --- /dev/null +++ b/packages/ui/src/components/va-modal/CHANGELOG.md @@ -0,0 +1,4 @@ +# 1.8.6 + +- Added `width` prop, should be used instead of `maxWidth` prop +- `maxWidth` prop is deprecated and will be removed in next major version \ No newline at end of file From 5ff4c9b654d205b78a21aede5f791681aeecdb32 Mon Sep 17 00:00:00 2001 From: Vasyl Date: Thu, 14 Dec 2023 18:13:09 +0200 Subject: [PATCH 5/5] - chore: clean modal stories --- .../src/components/va-modal/VaModal.stories.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/ui/src/components/va-modal/VaModal.stories.ts b/packages/ui/src/components/va-modal/VaModal.stories.ts index cbf656de02..530fcb8747 100644 --- a/packages/ui/src/components/va-modal/VaModal.stories.ts +++ b/packages/ui/src/components/va-modal/VaModal.stories.ts @@ -180,8 +180,7 @@ export const MobileFullscreen: StoryFn = () => ({ v-model="showModal" title="Modal with Max Width" > - Classic modal overlay which represents a dialog box or other interactive - component, such as a dismissible alert, sub-window, etc. +

This modal is fullscreen on mobile devices.

`, }) @@ -332,20 +331,6 @@ export const Fullscreen: StoryFn = () => ({ }) Fullscreen.play = playShowModal -export const MobileFullscreenModal: StoryFn = () => ({ - components: { VaModal, VaButton }, - data: () => ({ - showModal: false, - }), - template: ` - Show Modal - -

This modal is fullscreen on mobile devices.

-
- `, -}) -MobileFullscreenModal.play = playShowModal - export const HideDefaultActionsModal: StoryFn = () => ({ components: { VaModal, VaButton }, data: () => ({