Modal ​

Modals are not placed in templates. They are opened from code, rendered by the single WModal container mounted at the app root (see Getting started), and stacked when several are open. The same container renders the menus of WButtonMore, so a confirm can take a menu's place.

Confirm ​

Modal.addConfirm opens a ready-made dialog with a title, description and up to three actions. onAccept may return a promise — the accept button shows a spinner until it resolves, then the modal closes. If it rejects, the modal stays open.

Nothing happened yet.

vue
<template>
  <WButton
    :semantic-type="SemanticType.NEGATIVE"
    @click="confirmDelete"
  >
    Delete bed
  </WButton>

  <p class="mt-2 text-sm text-description">
    {{ status }}
  </p>
</template>

<script lang="ts" setup>
import {ref} from 'vue'

import {Modal} from 'eco-vue-js/dist/utils/Modal'
import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'

import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'

const status = ref('Nothing happened yet.')

const confirmDelete = () => {
  Modal.addConfirm({
    title: 'Delete bed?',
    description: 'The bed and its planting history are removed for everyone.',
    acceptText: 'Delete',
    acceptSemanticType: SemanticType.NEGATIVE,
    onAccept: () => {
      status.value = 'Deleted.'
    },
    onCancel: () => {
      status.value = 'Cancelled.'
    },
  })
}
</script>

Anchored confirm ​

A modal covers the page, so the user loses sight of the row or selection they are confirming for. Pass anchor and the same confirm opens as a dropdown under that element instead — a bottom sheet without the backdrop on phones. Use it for short yes-or-no questions; keep the modal for long descriptions and forms.

Open it with useOverlay(), called in setup, whose add, addAutoclosable and addConfirm work like Modal for the overlay the component is in:

  • Opened from a menu — WButtonMore, or the More menu of a list's selection bar — the confirm takes the menu's place at the menu's anchor, without anchor: the ⋯ button, or the point where a row was right-clicked. The row stays highlighted until the confirm closes.

  • Opened from a modal, it closes together with the modal, and a click beside the modal closes the confirm first.

  • A click outside, Escape or a swipe down closes it and calls onCancel. Opening it again from the same anchor closes it too.

  • While the onAccept promise is pending, clicks on the page are blocked, so the selection or filters it was confirmed for cannot change under it.

  • When the anchor is no longer on the page, addConfirm opens the modal instead.

Nothing happened yet.

vue
<template>
  <div class="flex flex-wrap items-center gap-2">
    <WButton
      :semantic-type="SemanticType.NEGATIVE"
      @click="confirmClear"
    >
      Clear bed
    </WButton>

    <WButtonMore>
      <WMenuClearBed @status="status = $event" />
    </WButtonMore>
  </div>

  <p class="mt-2 text-sm text-description">
    {{ status }}
  </p>
</template>

<script lang="ts" setup>
import {ref} from 'vue'

import {useOverlay} from 'eco-vue-js/dist/utils/Overlay'
import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'

import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'
import WButtonMore from 'eco-vue-js/dist/components/Button/WButtonMore.vue'

import WMenuClearBed from './parts/WMenuClearBed.vue'

const status = ref('Nothing happened yet.')

const overlay = useOverlay()

const confirmClear = (event: Event) => {
  overlay.addConfirm({
    title: 'Clear the bed?',
    description: 'Every plant in it is moved to the compost.',
    acceptText: 'Clear',
    acceptSemanticType: SemanticType.NEGATIVE,
    // The clicked button.
    anchor: event.currentTarget as Element,
    // The page is held still while the promise is pending.
    onAccept: () => new Promise<void>(resolve => setTimeout(resolve, 1000)).then(() => {
      status.value = 'Cleared.'
    }),
    onCancel: () => {
      status.value = 'Cancelled.'
    },
  })
}
</script>
vue
<template>
  <WButtonMoreItem
    text="Clear bed"
    :icon="markRaw(IconTrash)"
    :semantic-type="SemanticType.NEGATIVE"
    @click="confirmClear"
  />
</template>

<script lang="ts" setup>
import {markRaw} from 'vue'

import {useOverlay} from 'eco-vue-js/dist/utils/Overlay'
import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'

import WButtonMoreItem from 'eco-vue-js/dist/components/Button/WButtonMoreItem.vue'

import IconTrash from 'eco-vue-js/dist/assets/icons/IconTrash'

const emit = defineEmits<{
  (e: 'status', value: string): void
}>()

// Opened from a menu, the confirm takes its place at the menu's anchor — the `⋯` button, or the point a row was right-clicked.
const overlay = useOverlay()

const confirmClear = () => {
  overlay.addConfirm({
    title: 'Clear the bed?',
    description: 'Every plant in it is moved to the compost.',
    acceptText: 'Clear',
    acceptSemanticType: SemanticType.NEGATIVE,
    onAccept: () => emit('status', 'Cleared from the menu.'),
    onCancel: () => emit('status', 'Cancelled.'),
  })
}
</script>

useOverlay(), called in setup, opens any component and lets WModal frame it. Say how with present:

  • modal — centered over the backdrop, stacked over other modals. The component brings its own frame, such as WModalWrapper.
  • dropdown — at anchor, one at a time, and a bottom sheet on phones. It is centered on the anchor with a tip pointing at it, and near the edge of the screen the box shifts aside while the tip stays. dropdown options open it at a point instead (cornered) or aligned to the anchor without the tip (align, such as HorizontalAlign.FILL for a field's menu), set its box (frameClass), and whether a click inside closes it (closeOnClick). A layer opened from a dropdown with closeOnClick takes its place, as from a menu. Opened from any other dropdown, it stays over it — like a select's menu inside a filter, which closes first on Escape.

open returns a function that closes the layer, or null if nothing opened. The component closes the layer by emitting close:modal. Inside it:

  • It sees the opener's injections, and its own useOverlay() opens what belongs to the same layer.
  • useOverlayFrame() tells whether it is shown in a modal, a dropdown or a sheet, to adjust its layout.
  • useOverlayClose() closes the layer it is in, as emitting close:modal does — for content deeper than the root, such as a step's own Cancel button.
  • useLayerBusy(() => loading.value) keeps the layer open on Escape, outside clicks, swipes and a removed anchor while something runs.

A dropdown closes when the opener unmounts, unless it took a menu's place; a modal stays.

The frame owns the layout around the content. A component built on WModalWrapper opens as a modal, a dropdown or a bottom sheet unchanged: the frame takes its title, subtitle and actions and places them — a sticky header and footer in a modal, a compact heading and pinned buttons in a dropdown, a centered title and stacked buttons in a sheet — and pads the body by --w-frame-padding. The content brings no padding of its own; what reaches the edges, such as a list, takes w-frame-bleed. Outside an overlay, WModalWrapper lays the same parts out on the page. Content without it, such as a menu, is shown edge to edge; useOverlayFrameOptions(() => ({padded: true})) asks for the padding.

A WUniform with api-method keeps its layer open while it saves, and in a modal asks before the close button discards unsaved changes. A dropdown is dismissed without asking, as a menu is — keep forms in it small enough to fill again. useLayerBusy and useLayerChanges do the same for other content.

ts
import {useOverlay} from 'eco-vue-js/dist/utils/Overlay'

const overlay = useOverlay()

const openNotes = (event: MouseEvent) => {
  overlay.open({
    present: 'dropdown',
    anchor: event.currentTarget as Element,
    content: markRaw(WPlantNotes),
    props: {plantId: 42},
  })
}

Custom modal ​

Modal.add(component, props) opens any component. Wrap its content in WModalWrapper, which provides the title, a scrolling body and a sticky footer with the actions. Its padding comes from --w-modal-wrapper-padding, set once for the app (w-modal-wrapper-p---inner-margin on body), and the frame pads the title, the body and the actions with it — the content brings no padding of its own. Content that reaches the edges, such as a list, takes w-frame-bleed; WTabs and WInfoCard do it on their own. Give the action buttons w-full to share the footer width. The modal closes when it emits close:modal, when the backdrop's close button is clicked, or when the function returned by Modal.add is called.

Pass callbacks as props to get results back. Load the modal with defineAsyncComponent, so its code is fetched on first open, and wrap it in markRaw, as for every component passed as a prop.

vue
<template>
  <WButton @click="rename">
    Rename “{{ name }}”
  </WButton>
</template>

<script lang="ts" setup>
import {defineAsyncComponent, markRaw, ref} from 'vue'

import {Modal} from 'eco-vue-js/dist/utils/Modal'

import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'

// Loaded on first open, so the modal's code stays out of the page bundle.
const RenameModal = defineAsyncComponent(() => import('./parts/RenameModal.vue'))

const name = ref('Herb bed')

const rename = () => {
  Modal.add(markRaw(RenameModal), {
    name: name.value,
    onSave: (value: string) => name.value = value,
  })
}
</script>
vue
<template>
  <WModalWrapper>
    <template #title>
      Rename
    </template>

    <WInput
      v-model="value"
      title="Name"
      autofocus
    />

    <template #actions>
      <WButton
        outline
        class="w-full"
        @click="$emit('close:modal')"
      >
        Cancel
      </WButton>

      <WButton
        :disabled="!value"
        class="w-full"
        @click="save"
      >
        Save
      </WButton>
    </template>
  </WModalWrapper>
</template>

<script lang="ts" setup>
import {ref} from 'vue'

import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'
import WInput from 'eco-vue-js/dist/components/Input/WInput.vue'
import WModalWrapper from 'eco-vue-js/dist/components/Modal/WModalWrapper.vue'

const props = defineProps<{
  name: string
  onSave: (name: string) => void
}>()

const emit = defineEmits<{
  (e: 'close:modal'): void
}>()

const value = ref<string | undefined>(props.name)

const save = () => {
  if (!value.value) return

  props.onSave(value.value)
  emit('close:modal')
}
</script>

If the modal body contains a form with unsaved changes, exposing it as formRef with a hasChanges flag makes the close button ask before discarding them.

Steps ​

A stepper WTabs with stepper-controls inside a WModalWrapper is a wizard: one WTabsItem per step. It hands the modal the current step's title, a progress line under it, and the buttons — Close, or Cancel once something is changed, or Back, and Next or the submit. validate on an item runs before Next leaves it, and so do the Uniform fields inside the step — an error message is shown and the step stays open — and requireSave submits the form inside first. The last step's button submits the form with api-method around the stepper, or emits submit; submitText names it. submitting shows its spinner while a submit on submit runs, and disabledNext disables it, e.g. until something is picked. A template ref gives next() and previous(), to move on after a pick. The button texts come from setTexts, so an app translates them once.

WModalStepper is deprecated: it is the same wizard with its own buttons, without Cancel or the form's submit. Move to a WModalWrapper around WTabs stepper no-header stepper-controls:

WModalStepperInstead
loadingsubmitting on WTabs, or nothing inside a form with api-method
disabledNext, submitText, disableMinHeightthe same props on WTabs
disabledsubmitting, or disabled on the items
@submit@submit on WTabs, or the form's api-method
@close:modal on the first stepnot needed: Close closes the modal it is in
#title#title on WModalWrapper, which stays over the step's title
next(), previous()the same methods of WTabs
vue
<template>
  <div class="flex flex-wrap items-center gap-4">
    <WButton @click="invite">
      Invite a member
    </WButton>

    <span
      v-if="invited"
      class="text-description"
    >
      Invited {{ invited }}
    </span>
  </div>
</template>

<script lang="ts" setup>
import {defineAsyncComponent, markRaw, ref} from 'vue'

import {Modal} from 'eco-vue-js/dist/utils/Modal'

import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'

const InviteModal = defineAsyncComponent(() => import('./parts/InviteModal.vue'))

const invited = ref<string>()

const invite = () => {
  Modal.add(markRaw(InviteModal), {
    onInvite: (email: string, role: string) => invited.value = `${ email } as ${ role }`,
  })
}
</script>
vue
<template>
  <!-- The stepper brings the title, the progress and the buttons; the wrapper hands them to the modal. -->
  <WModalWrapper
    maximized
    class="w-modal-wrapper-w-160"
  >
    <WTabs
      :submitting="sending"
      submit-text="Send invite"
      stepper
      no-header
      stepper-controls
      @submit="send"
    >
      <WTabsItem
        title="Who to invite"
        name="email"
        :validate="() => email ? undefined : 'Enter an email to invite'"
      >
        <WInput
          v-model="email"
          title="Email"
          type="email"
          autofocus
          class="pt-4"
        />
      </WTabsItem>

      <WTabsItem
        title="Role"
        name="role"
      >
        <WButtonGroup
          v-model="role"
          :list="ROLES"
          title="Role"
          class="pt-4"
        >
          <template #option="{option}">
            {{ option }}
          </template>
        </WButtonGroup>
      </WTabsItem>

      <WTabsItem
        title="Check and send"
        name="summary"
      >
        <p class="pt-4">
          {{ email }} will join as {{ role }}.
        </p>
      </WTabsItem>
    </WTabs>
  </WModalWrapper>
</template>

<script lang="ts" setup>
import {ref} from 'vue'

import WButtonGroup from 'eco-vue-js/dist/components/Button/WButtonGroup.vue'
import WInput from 'eco-vue-js/dist/components/Input/WInput.vue'
import WModalWrapper from 'eco-vue-js/dist/components/Modal/WModalWrapper.vue'
import WTabs from 'eco-vue-js/dist/components/Tabs/WTabs.vue'
import WTabsItem from 'eco-vue-js/dist/components/Tabs/WTabsItem.vue'

const ROLES = ['Viewer', 'Editor', 'Admin']

const props = defineProps<{
  onInvite: (email: string, role: string) => void
}>()

const emit = defineEmits<{
  (e: 'close:modal'): void
}>()

const email = ref<string>()
const role = ref('Viewer')
const sending = ref(false)

const send = () => {
  sending.value = true

  setTimeout(() => {
    props.onInvite(email.value!, role.value)
    emit('close:modal')
  }, 800)
}
</script>

Export and import ​

WModalExport and WModalImport are the modals behind the list's export and import. WModalExport loads every item of a query page by page — or with apiMethod in one request — with a progress bar, builds a JSON, CSV or Markdown file and offers it for download. WModalImport creates a list of items with createMethod, all at once, with a progress bar and a button that aborts the requests still running. Open them with Modal.add like any modal.

API ​

WModalWrapper ​

ts
import WModalWrapper from 'eco-vue-js/dist/components/Modal/WModalWrapper.vue'

Props ​

PropTypeDefaultDescription
maximizedboolean—Fills the whole screen on small screens instead of floating with a margin.
actionsColboolean—Stacks the actions buttons vertically on every screen size, not only on small ones.

Slots ​

SlotPropsDescription
title—Heading of the dialog, also used as its accessible name. Stays pinned at the top while the content scrolls.
subtitle—Content under the heading, pinned with it.
default—Body of the modal.
actions—Buttons pinned at the bottom.

WModal ​

ts
import WModal from 'eco-vue-js/dist/components/Modal/WModal.vue'

Props ​

No props.

WModalStepper ​

ts
import WModalStepper from 'eco-vue-js/dist/components/Modal/WModalStepper.vue'

Props ​

PropTypeDefaultDescription
loadingboolean—Shows a spinner in the Next or Submit button and disables Back and Close, e.g. while the form submits.
disabledboolean—Disables all the buttons.
disabledNextboolean—Disables the Next or Submit button, e.g. until something is picked on the step.
submitTextstring—Text of the submit button on the last step. Defaults to "Submit".
disableMinHeightboolean—Lets the modal shrink to the current step's height. By default it keeps the height of the tallest step shown so far.

Events ​

EventPayloadDescription
close:modal—Close was clicked on the first step.
submit—Submit was clicked on the last step.
update:has-changes(value: boolean)Whether a form inside has unsaved changes, for the modal to ask before closing.

Slots ​

SlotPropsDescription
default—The steps, as WTabsItem items. Their validate, hasValue and requireSave work as in a stepper WTabs.
title—Replaces the title, which is the current step's title by default.

WModalExport ​

ts
import WModalExport from 'eco-vue-js/dist/components/Modal/WModalExport.vue'

Props ​

PropTypeDefaultDescription
format"json" | "csv" | "md"requiredFormat of the file: the items as JSON, rows of CSV, or Markdown sections.
fileNamestring—Start of the file name, before the date.
titlestring | ((count: number) => string)—Title of the modal, or a function of the number of items. Defaults to one that names the format.
cancelTextstring—Text of the close button. Defaults to "Close".
downloadTextstring—Text of the download button. Defaults to "Download".
useQueryFnUseQueryDefault<PaginatedResponse<Model>, QueryParams> | UseQueryDefault<Model[], QueryParams>—Query the items are loaded with, page by page when it is paginated.
initQueryParamsQueryParamsrequiredParams of the query or apiMethod, such as the list's filters.
apiMethod((queryParams: QueryParams) => Promise<Model[]>)—Loads all the items in one request, instead of useQueryFn.
headerstring[]—Header row of the CSV.
prepare((item: Model, index: number) => string[][] | Promise<string[][]>)—Rows of the CSV for an item. Needed for csv.
toMarkdown((item: Model, index: number) => string)—Markdown of an item. Needed for md.
resolve(() => void)—Called once the file is downloaded.

Events ​

EventPayloadDescription
close:modal—The file was downloaded, or the modal was closed.

WModalImport ​

ts
import WModalImport from 'eco-vue-js/dist/components/Modal/WModalImport.vue'

Props ​

PropTypeDefaultDescription
createMethod(item: Item, config: RequestConfig<RequestData>) => Promise<RequestResponse<unknown, RequestData>>requiredCreates one item. All items are sent at once; config.signal aborts a request.
itemsItem[]requiredItems to create.
titlestring—Title of the modal. Defaults to "Importing N items".
successTextstring—Text of the button once every item is sent. Defaults to "Done".
abortTextstring—Text of the button while items are being sent. Defaults to "Abort upload".
resolve(() => void)—Called when every item is sent, or when the modal is closed after that.
reject((length: number) => void)—Called with the number of aborted requests when the upload is aborted.

Events ​

EventPayloadDescription
close:modal—Every item was sent, or the button was clicked.