feat(ui): add `cancel` prop to Modal; auto-focus previously focused element on close; add topleft slot
This commit is contained in:
parent
e16d0a6130
commit
5f4bc5f175
|
@ -1,31 +1,37 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type ColorProps, type DefaultProps, color } from '~/composables/color';
|
import { type ColorProps, type DefaultProps, color } from '~/composables/color';
|
||||||
import { watchEffect } from 'vue';
|
import { watchEffect, ref, nextTick } from 'vue';
|
||||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
|
import onKeyboardShortcut from '~/composables/onKeyboardShortcut';
|
||||||
|
|
||||||
import Button from '~/components/ui/Button.vue'
|
import Button from '~/components/ui/Button.vue'
|
||||||
import Spacer from '~/components/ui/Spacer.vue'
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
import Layout from '~/components/ui/Layout.vue'
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Heading from '~/components/ui/Heading.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string,
|
title: string,
|
||||||
overPopover?: true,
|
overPopover?: true,
|
||||||
destructive?: true
|
destructive?: true,
|
||||||
|
cancel?: string
|
||||||
} & (ColorProps | DefaultProps)>()
|
} & (ColorProps | DefaultProps)>()
|
||||||
|
|
||||||
const isOpen = defineModel<boolean>({ default:false })
|
const isOpen = defineModel<boolean>({ default:false })
|
||||||
|
|
||||||
|
const previouslyFocusedElement = ref();
|
||||||
|
|
||||||
|
// Handle focus and inertness of the elements behind the modal
|
||||||
watchEffect(() =>
|
watchEffect(() =>
|
||||||
isOpen.value
|
isOpen.value
|
||||||
? document.querySelector('#app')?.setAttribute('inert', 'true')
|
? ( previouslyFocusedElement.value = document.activeElement,
|
||||||
: document.querySelector('#app')?.removeAttribute('inert')
|
document.querySelector('#app')?.setAttribute('inert', 'true'))
|
||||||
|
: ( nextTick(()=> previouslyFocusedElement.value?.focus()),
|
||||||
|
document.querySelector('#app')?.removeAttribute('inert'))
|
||||||
)
|
)
|
||||||
|
|
||||||
onKeyboardShortcut('escape', () => isOpen.value = false)
|
onKeyboardShortcut('escape', () => isOpen.value = false)
|
||||||
|
|
||||||
// TODO:
|
// TODO:
|
||||||
// When overflowing content: Add inset shadow to indicate scrollability
|
// When overflowing content: Add inset shadow to indicate scrollability
|
||||||
// - [ ] Add aria role to inform screenreaders
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -43,16 +49,22 @@ onKeyboardShortcut('escape', () => isOpen.value = false)
|
||||||
'over-popover': overPopover,
|
'over-popover': overPopover,
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
v-bind="color(props)"
|
v-bind="{...$attrs, ...color(props)}"
|
||||||
>
|
>
|
||||||
<h2
|
<Layout flex gap-12 style="padding: 12px;">
|
||||||
:class="{
|
<div style="width: 48px;">
|
||||||
'destructive-header': destructive
|
<slot name="topleft" />
|
||||||
}"
|
</div>
|
||||||
>
|
<Spacer h grow />
|
||||||
{{ title }}
|
<Heading :h2="title"
|
||||||
<Button icon="bi-x-lg" ghost @click="isOpen = false" />
|
section-heading
|
||||||
</h2>
|
:class="{'destructive-header': destructive}"
|
||||||
|
/>
|
||||||
|
<Spacer h grow />
|
||||||
|
<Button icon="bi-x-lg" ghost @click="isOpen = false" align-self="baseline" />
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<Transition>
|
<Transition>
|
||||||
|
@ -65,10 +77,16 @@ onKeyboardShortcut('escape', () => isOpen.value = false)
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<Layout flex gap-12 style="flex-wrap: wrap;" v-if="$slots.actions" class="modal-actions">
|
|
||||||
|
<!-- Actions slot -->
|
||||||
|
|
||||||
|
<Layout flex gap-12 style="flex-wrap: wrap;" v-if="$slots.actions || cancel" class="modal-actions">
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
|
<Button v-if="cancel" autofocus :onClick="()=>{ isOpen = false }">
|
||||||
|
{{ cancel }}
|
||||||
|
</Button>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Spacer :size="64" v-else />
|
<Spacer size-48 v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
|
@ -22,6 +22,8 @@ const isOpen5 = ref(false)
|
||||||
const isOpen6 = ref(false)
|
const isOpen6 = ref(false)
|
||||||
const isOpen7 = ref(false)
|
const isOpen7 = ref(false)
|
||||||
const isOpen8 = ref(false)
|
const isOpen8 = ref(false)
|
||||||
|
const isOpen9 = ref(false)
|
||||||
|
const isOpen10 = ref(false)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -59,7 +61,7 @@ import Modal from "~/components/ui/Modal.vue";
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
## Modal actions
|
## Add actions
|
||||||
|
|
||||||
Use the `#actions` slot to add actions to a modal. Actions typically take the form of [buttons](./button).
|
Use the `#actions` slot to add actions to a modal. Actions typically take the form of [buttons](./button).
|
||||||
|
|
||||||
|
@ -76,10 +78,7 @@ Make sure to add `autofocus` to the preferred button.
|
||||||
Modal content
|
Modal content
|
||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button @click="isOpen = false">
|
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button autofocus @click="isOpen = false">
|
<Button autofocus @click="isOpen = false">
|
||||||
Ok
|
Ok
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -94,10 +93,8 @@ Make sure to add `autofocus` to the preferred button.
|
||||||
<Modal v-model="isOpen2" title="My modal">
|
<Modal v-model="isOpen2" title="My modal">
|
||||||
Modal content
|
Modal content
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button @click="isOpen2 = false">
|
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
|
||||||
Cancel
|
<Button primary autofocus @click="isOpen2 = false">
|
||||||
</Button>
|
|
||||||
<Button autofocus @click="isOpen2 = false">
|
|
||||||
Ok
|
Ok
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
@ -106,6 +103,34 @@ Make sure to add `autofocus` to the preferred button.
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
### Add a cancel button
|
||||||
|
|
||||||
|
All modals can be closed so it makes sense to add a `cancel` button. Note that `Modal` also implements the `ESC` shortcut by default.
|
||||||
|
|
||||||
|
The `cancel` prop accepts a string and will add a cancel button.
|
||||||
|
|
||||||
|
Note that the Cancel button has `autofocus`. If you want another button to auto-focus, implement the Cancel button manually inside the `#action` slot.
|
||||||
|
|
||||||
|
## Add elements to the top left corner
|
||||||
|
|
||||||
|
Use this slot for indicators such as the user's photo.
|
||||||
|
|
||||||
|
<Button primary @click="isOpen10 = true">
|
||||||
|
Open modal
|
||||||
|
</Button>
|
||||||
|
<Modal v-model="isOpen10" title="My modal">
|
||||||
|
Modal content
|
||||||
|
<template #topleft>
|
||||||
|
<Button disabled icon="bi-star" align-self="baseline" />
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<Spacer grow />
|
||||||
|
<Button autofocus @click="isOpen10 = false">
|
||||||
|
Ok
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
## Alert inside modal
|
## Alert inside modal
|
||||||
|
|
||||||
You can nest [Funkwhale alerts](./alert) to visually highlight content within the modal.
|
You can nest [Funkwhale alerts](./alert) to visually highlight content within the modal.
|
||||||
|
@ -117,7 +142,7 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
||||||
Open modal
|
Open modal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Modal v-model="isOpen" title="My modal">
|
<Modal v-model="isOpen8" title="My modal" cancel="Cancel">
|
||||||
Modal content
|
Modal content
|
||||||
|
|
||||||
<template #alert v-if="alertOpen">
|
<template #alert v-if="alertOpen">
|
||||||
|
@ -139,7 +164,7 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
||||||
>
|
>
|
||||||
Open modal
|
Open modal
|
||||||
</Button>
|
</Button>
|
||||||
<Modal v-model="isOpen8" title="My modal">
|
<Modal v-model="isOpen8" title="My modal" cancel="Cancel">
|
||||||
Modal content
|
Modal content
|
||||||
<template #alert v-if="alertOpen">
|
<template #alert v-if="alertOpen">
|
||||||
<Alert blue>
|
<Alert blue>
|
||||||
|
@ -150,9 +175,6 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
||||||
</Alert>
|
</Alert>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button @click="isOpen3 = false" secondary>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button primary @click="isOpen8 = false">
|
<Button primary @click="isOpen8 = false">
|
||||||
Ok
|
Ok
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -162,123 +184,6 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
# Destructive Modal
|
|
||||||
|
|
||||||
The `destructive` prop is used to visually indicate a potentially dangerous or irreversible action. When set to `true`, the modal will have a distinctive red styling to draw the user's attention and highlight the critical nature of the action.
|
|
||||||
|
|
||||||
| Prop | Data type | Required? | Default | Description |
|
|
||||||
| ------------- | --------- | --------- | ------- | ---------------------------------------------------- |
|
|
||||||
| `destructive` | `true` | No | `false` | Applies a red styling to emphasize dangerous actions |
|
|
||||||
|
|
||||||
<Layout flex>
|
|
||||||
|
|
||||||
```vue-html
|
|
||||||
<Modal
|
|
||||||
v-model="isOpen"
|
|
||||||
title="Delete Account"
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
Dangerous action consequences
|
|
||||||
</Modal>
|
|
||||||
```
|
|
||||||
|
|
||||||
<div class="preview">
|
|
||||||
<Button
|
|
||||||
destructive
|
|
||||||
@click="isOpen3 = true"
|
|
||||||
>
|
|
||||||
Delete Account
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
v-model="isOpen3"
|
|
||||||
title="Delete Account"
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
Dangerous action consequences
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</Layout>
|
|
||||||
|
|
||||||
### Styling Effects
|
|
||||||
|
|
||||||
When the `destructive` prop is set to `true`, the modal will:
|
|
||||||
|
|
||||||
- Add a red border
|
|
||||||
- Style the title in red
|
|
||||||
|
|
||||||
To visually distinguish the modal from standard modals
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
- Use the `destructive` prop sparingly
|
|
||||||
- Clearly explain the consequences of the action unsing `<Alert red>`
|
|
||||||
- Provide a clear way to cancel the action
|
|
||||||
- Use descriptive action button labels
|
|
||||||
|
|
||||||
The example in the "Confirm a dangerous action" section demonstrates the recommended usage of the destructive modal.
|
|
||||||
|
|
||||||
<Layout flex>
|
|
||||||
|
|
||||||
```vue-html
|
|
||||||
<Modal
|
|
||||||
v-model="isOpen"
|
|
||||||
title="Delete Account?"
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
<template #alert>
|
|
||||||
<Alert red>
|
|
||||||
Detailed consequences of the action
|
|
||||||
</Alert>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<Button @click="isOpen = false">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
destructive
|
|
||||||
@click="deleteAccount()"
|
|
||||||
>
|
|
||||||
Confirm Deletion
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
```
|
|
||||||
|
|
||||||
<div class="preview">
|
|
||||||
<Button
|
|
||||||
@click="isOpen4 = true"
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
Delete my account
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
v-model="isOpen4"
|
|
||||||
title="Delete Account?"
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
<template #alert>
|
|
||||||
<Alert red>
|
|
||||||
Detailed consequences of the action
|
|
||||||
</Alert>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<Button @click="isOpen4 = false">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
destructive
|
|
||||||
@click="isOpen4 = false"
|
|
||||||
>
|
|
||||||
Confirm Deletion
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</Layout>
|
|
||||||
|
|
||||||
## Confirm a dangerous action
|
## Confirm a dangerous action
|
||||||
|
|
||||||
Note that confirmation dialogs interrupt the user's workflow. Consider adding a recovery functionality such as "undo" instead.
|
Note that confirmation dialogs interrupt the user's workflow. Consider adding a recovery functionality such as "undo" instead.
|
||||||
|
@ -298,55 +203,88 @@ Note that confirmation dialogs interrupt the user's workflow. Consider adding a
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### Destructive Modal
|
||||||
|
|
||||||
|
The `destructive` prop is used to visually indicate a potentially dangerous or irreversible action. When set to `true`, the modal will have a distinctive red styling to draw the user's attention and highlight the critical nature of the action.
|
||||||
|
|
||||||
|
| Prop | Data type | Required? | Default | Description |
|
||||||
|
| ------------- | --------- | --------- | ------- | ---------------------------------------------------- |
|
||||||
|
| `destructive` | `true` | No | `false` | Applies a red styling to emphasize dangerous actions |
|
||||||
|
|
||||||
|
### Styling Effects
|
||||||
|
|
||||||
|
When the `destructive` prop is set to `true`, the modal will:
|
||||||
|
|
||||||
|
- Add a red border
|
||||||
|
- Style the title in red
|
||||||
|
|
||||||
|
To visually distinguish the modal from standard modals
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- Use the `destructive` prop sparingly
|
||||||
|
- Clearly explain the consequences of the action unsing `<Alert red>`
|
||||||
|
- Provide a clear way to cancel the action. If you use the `cancel` prop, the Cancel button will have autofocus, preventing accidental confirmation.
|
||||||
|
- Use descriptive action button labels
|
||||||
|
|
||||||
|
The example in the "Confirm a dangerous action" section demonstrates the recommended usage of the destructive modal.
|
||||||
|
|
||||||
<Layout flex>
|
<Layout flex>
|
||||||
|
|
||||||
```vue-html
|
```vue-html
|
||||||
<Button @click="isOpen = true" destructive>
|
<Button
|
||||||
Delete my account ...
|
@click="isOpen4 = true"
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
Delete my account
|
||||||
</Button>
|
</Button>
|
||||||
|
<Modal
|
||||||
<Modal v-model="isOpen" title="Delete account?">
|
v-model="isOpen4"
|
||||||
|
title="Delete Account?"
|
||||||
|
destructive
|
||||||
|
cancel="Cancel"
|
||||||
|
>
|
||||||
<template #alert>
|
<template #alert>
|
||||||
<Alert red>
|
<Alert red>
|
||||||
1 082 music files that you uploaded will be deleted.<br />
|
Detailed consequences of the action
|
||||||
7 879 items in your collections will be unlinked.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</template>
|
</template>
|
||||||
Do you want to delete your account forever?
|
|
||||||
|
|
||||||
You will not be able to restore your account.
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button autofocus @click="isOpen = false" >
|
<Button
|
||||||
Keep my account
|
destructive
|
||||||
</Button>
|
@click="isOpen4 = false"
|
||||||
<Button destructive @click="isOpen = false">
|
>
|
||||||
I understand. Delete my account now!
|
Confirm Deletion
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
```
|
```
|
||||||
|
|
||||||
<Button @click="isOpen5 = true" destructive>
|
<Button
|
||||||
Delete my account ...
|
@click="isOpen4 = true"
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
Delete my account
|
||||||
</Button>
|
</Button>
|
||||||
<Modal v-model="isOpen5" title="Delete account?">
|
<Modal
|
||||||
<template #alert>
|
v-model="isOpen4"
|
||||||
<Alert red>
|
title="Delete Account?"
|
||||||
1 082 music files that you uploaded will be deleted.<br />
|
destructive
|
||||||
7 879 items in your collections will be unlinked.
|
cancel="Cancel"
|
||||||
</Alert>
|
>
|
||||||
</template>
|
<template #alert>
|
||||||
Do you want to delete your account forever?
|
<Alert red>
|
||||||
|
Detailed consequences of the action
|
||||||
You will not be able to restore your account.
|
</Alert>
|
||||||
<template #actions>
|
</template>
|
||||||
<Button autofocus @click="isOpen5 = false">
|
<template #actions>
|
||||||
Keep my account
|
<Button
|
||||||
</Button>
|
destructive
|
||||||
<Button destructive @click="isOpen5 = false">
|
@click="isOpen4 = false"
|
||||||
I understand. Delete my account now!
|
>
|
||||||
</Button>
|
Confirm Deletion
|
||||||
</template>
|
</Button>
|
||||||
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -391,3 +329,47 @@ primary
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
## Responsivity
|
||||||
|
|
||||||
|
### Designing for small screens
|
||||||
|
|
||||||
|
On slim phones, the content may only be 260px wide (Galaxy S5). Make sure the content wraps accordingly.
|
||||||
|
|
||||||
|
### Long, scrolling content
|
||||||
|
|
||||||
|
Consider using an indicator such as a line or a shadow to convey that there is content below the fold.
|
||||||
|
|
||||||
|
```vue-html
|
||||||
|
<Button primary @click="isOpen9 = true">
|
||||||
|
Open modal
|
||||||
|
</Button>
|
||||||
|
<Modal v-model="isOpen9" title="Long content">
|
||||||
|
Nested modal content
|
||||||
|
<section v-for="index in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]">
|
||||||
|
<h2>{{ index }}</h2>
|
||||||
|
<p>
|
||||||
|
<span v-for="_ in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]">Lorem ipsum dolor sit amet.</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<template #actions>
|
||||||
|
<div style="background: currentcolor; opacity: .2; width: 100%; height: 1px;" > </div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Button primary @click="isOpen9 = true">
|
||||||
|
Open modal
|
||||||
|
</Button>
|
||||||
|
<Modal v-model="isOpen9" title="Long content">
|
||||||
|
Nested modal content
|
||||||
|
<section v-for="index in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]">
|
||||||
|
<h2>{{ index }}</h2>
|
||||||
|
<p>
|
||||||
|
<span v-for="_ in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]">Lorem ipsum dolor sit amet.</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<template #actions>
|
||||||
|
<div style="background: currentcolor; opacity: .2; width: 100%; height: 1px;" > </div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
Loading…
Reference in New Issue