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 diff --git a/packages/ui/src/components/va-modal/VaModal.stories.ts b/packages/ui/src/components/va-modal/VaModal.stories.ts index 589ab4d545..df0397f92c 100644 --- a/packages/ui/src/components/va-modal/VaModal.stories.ts +++ b/packages/ui/src/components/va-modal/VaModal.stories.ts @@ -1,12 +1,470 @@ -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', + } + }, + template: ` + Show Modal + +

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

+
+ `, +}) +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: ` + + 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 diff --git a/packages/ui/src/components/va-modal/VaModal.vue b/packages/ui/src/components/va-modal/VaModal.vue index 28b8a9a819..a4a6e62eb8 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' @@ -182,11 +183,11 @@ export default defineComponent({ hideDefaultActions: { type: Boolean, default: false }, fullscreen: { type: Boolean, default: false }, closeButton: { type: Boolean, default: false }, - mobileFullscreen: { type: Boolean, default: true }, noDismiss: { type: Boolean, default: false }, 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 +210,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() @@ -226,17 +229,16 @@ export default defineComponent({ const computedClass = computed(() => ({ 'va-modal--fullscreen': props.fullscreen, - 'va-modal--mobile-fullscreen': props.mobileFullscreen, 'va-modal--fixed-layout': props.fixedLayout, 'va-modal--no-padding': props.noPadding, [`va-modal--size-${props.size}`]: props.size !== 'medium', })) 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 +400,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 +452,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; @@ -493,47 +504,16 @@ export default defineComponent({ } } - &--mobile-fullscreen { - .va-modal__dialog { - @media all and (max-width: map-get($grid-breakpoints, sm)) { - margin: 0 !important; - min-width: 100vw !important; - min-height: 100vh !important; - border-radius: 0; - } - } - } - &--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 +557,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;