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.
+
+ Show Inner Modal
+
+
+
+ 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
+
+
+ Custom Header
+
+ 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: `
+
+
+ Show Modal
+
+ 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
+
+
+
+
Custom footer content.
+
+
+
+ `,
+})
+FooterSlotModal.play = playShowModal
+
+export const HeaderSlotModal: StoryFn = () => ({
+ components: { VaModal, VaButton },
+ data: () => ({
+ showModal: false,
+ }),
+ template: `
+ Show Modal
+
+
+
+
Custom Header
+
+
+ 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;