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">
|
||||
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 Button from '~/components/ui/Button.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Heading from '~/components/ui/Heading.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
overPopover?: true,
|
||||
destructive?: true
|
||||
destructive?: true,
|
||||
cancel?: string
|
||||
} & (ColorProps | DefaultProps)>()
|
||||
|
||||
const isOpen = defineModel<boolean>({ default:false })
|
||||
|
||||
const previouslyFocusedElement = ref();
|
||||
|
||||
// Handle focus and inertness of the elements behind the modal
|
||||
watchEffect(() =>
|
||||
isOpen.value
|
||||
? document.querySelector('#app')?.setAttribute('inert', 'true')
|
||||
: document.querySelector('#app')?.removeAttribute('inert')
|
||||
? ( previouslyFocusedElement.value = document.activeElement,
|
||||
document.querySelector('#app')?.setAttribute('inert', 'true'))
|
||||
: ( nextTick(()=> previouslyFocusedElement.value?.focus()),
|
||||
document.querySelector('#app')?.removeAttribute('inert'))
|
||||
)
|
||||
|
||||
onKeyboardShortcut('escape', () => isOpen.value = false)
|
||||
|
||||
// TODO:
|
||||
// When overflowing content: Add inset shadow to indicate scrollability
|
||||
// - [ ] Add aria role to inform screenreaders
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -43,16 +49,22 @@ onKeyboardShortcut('escape', () => isOpen.value = false)
|
|||
'over-popover': overPopover,
|
||||
}
|
||||
]"
|
||||
v-bind="color(props)"
|
||||
v-bind="{...$attrs, ...color(props)}"
|
||||
>
|
||||
<h2
|
||||
:class="{
|
||||
'destructive-header': destructive
|
||||
}"
|
||||
>
|
||||
{{ title }}
|
||||
<Button icon="bi-x-lg" ghost @click="isOpen = false" />
|
||||
</h2>
|
||||
<Layout flex gap-12 style="padding: 12px;">
|
||||
<div style="width: 48px;">
|
||||
<slot name="topleft" />
|
||||
</div>
|
||||
<Spacer h grow />
|
||||
<Heading :h2="title"
|
||||
section-heading
|
||||
: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">
|
||||
<Transition>
|
||||
|
@ -65,10 +77,16 @@ onKeyboardShortcut('escape', () => isOpen.value = false)
|
|||
|
||||
<slot />
|
||||
</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" />
|
||||
<Button v-if="cancel" autofocus :onClick="()=>{ isOpen = false }">
|
||||
{{ cancel }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Spacer :size="64" v-else />
|
||||
<Spacer size-48 v-else />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
|
@ -22,6 +22,8 @@ const isOpen5 = ref(false)
|
|||
const isOpen6 = ref(false)
|
||||
const isOpen7 = ref(false)
|
||||
const isOpen8 = ref(false)
|
||||
const isOpen9 = ref(false)
|
||||
const isOpen10 = ref(false)
|
||||
|
||||
</script>
|
||||
|
||||
|
@ -59,7 +61,7 @@ import Modal from "~/components/ui/Modal.vue";
|
|||
|
||||
</Layout>
|
||||
|
||||
## Modal actions
|
||||
## Add actions
|
||||
|
||||
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
|
||||
|
||||
<template #actions>
|
||||
<Button @click="isOpen = false">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
|
||||
<Button autofocus @click="isOpen = false">
|
||||
Ok
|
||||
</Button>
|
||||
|
@ -94,10 +93,8 @@ Make sure to add `autofocus` to the preferred button.
|
|||
<Modal v-model="isOpen2" title="My modal">
|
||||
Modal content
|
||||
<template #actions>
|
||||
<Button @click="isOpen2 = false">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button autofocus @click="isOpen2 = false">
|
||||
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
|
||||
<Button primary autofocus @click="isOpen2 = false">
|
||||
Ok
|
||||
</Button>
|
||||
</template>
|
||||
|
@ -106,6 +103,34 @@ Make sure to add `autofocus` to the preferred button.
|
|||
|
||||
</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
|
||||
|
||||
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
|
||||
</Button>
|
||||
|
||||
<Modal v-model="isOpen" title="My modal">
|
||||
<Modal v-model="isOpen8" title="My modal" cancel="Cancel">
|
||||
Modal content
|
||||
|
||||
<template #alert v-if="alertOpen">
|
||||
|
@ -139,7 +164,7 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
|||
>
|
||||
Open modal
|
||||
</Button>
|
||||
<Modal v-model="isOpen8" title="My modal">
|
||||
<Modal v-model="isOpen8" title="My modal" cancel="Cancel">
|
||||
Modal content
|
||||
<template #alert v-if="alertOpen">
|
||||
<Alert blue>
|
||||
|
@ -150,9 +175,6 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
|||
</Alert>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button @click="isOpen3 = false" secondary>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button primary @click="isOpen8 = false">
|
||||
Ok
|
||||
</Button>
|
||||
|
@ -162,123 +184,6 @@ You can nest [Funkwhale alerts](./alert) to visually highlight content within th
|
|||
|
||||
</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
|
||||
|
||||
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>
|
||||
|
||||
```vue-html
|
||||
<Button @click="isOpen = true" destructive>
|
||||
Delete my account ...
|
||||
<Button
|
||||
@click="isOpen4 = true"
|
||||
destructive
|
||||
>
|
||||
Delete my account
|
||||
</Button>
|
||||
|
||||
<Modal v-model="isOpen" title="Delete account?">
|
||||
<Modal
|
||||
v-model="isOpen4"
|
||||
title="Delete Account?"
|
||||
destructive
|
||||
cancel="Cancel"
|
||||
>
|
||||
<template #alert>
|
||||
<Alert red>
|
||||
1 082 music files that you uploaded will be deleted.<br />
|
||||
7 879 items in your collections will be unlinked.
|
||||
Detailed consequences of the action
|
||||
</Alert>
|
||||
</template>
|
||||
Do you want to delete your account forever?
|
||||
|
||||
You will not be able to restore your account.
|
||||
<template #actions>
|
||||
<Button autofocus @click="isOpen = false" >
|
||||
Keep my account
|
||||
</Button>
|
||||
<Button destructive @click="isOpen = false">
|
||||
I understand. Delete my account now!
|
||||
<Button
|
||||
destructive
|
||||
@click="isOpen4 = false"
|
||||
>
|
||||
Confirm Deletion
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
<Button @click="isOpen5 = true" destructive>
|
||||
Delete my account ...
|
||||
<Button
|
||||
@click="isOpen4 = true"
|
||||
destructive
|
||||
>
|
||||
Delete my account
|
||||
</Button>
|
||||
<Modal v-model="isOpen5" title="Delete account?">
|
||||
<template #alert>
|
||||
<Alert red>
|
||||
1 082 music files that you uploaded will be deleted.<br />
|
||||
7 879 items in your collections will be unlinked.
|
||||
</Alert>
|
||||
</template>
|
||||
Do you want to delete your account forever?
|
||||
|
||||
You will not be able to restore your account.
|
||||
<template #actions>
|
||||
<Button autofocus @click="isOpen5 = false">
|
||||
Keep my account
|
||||
</Button>
|
||||
<Button destructive @click="isOpen5 = false">
|
||||
I understand. Delete my account now!
|
||||
</Button>
|
||||
</template>
|
||||
<Modal
|
||||
v-model="isOpen4"
|
||||
title="Delete Account?"
|
||||
destructive
|
||||
cancel="Cancel"
|
||||
>
|
||||
<template #alert>
|
||||
<Alert red>
|
||||
Detailed consequences of the action
|
||||
</Alert>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
destructive
|
||||
@click="isOpen4 = false"
|
||||
>
|
||||
Confirm Deletion
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
</Layout>
|
||||
|
@ -391,3 +329,47 @@ primary
|
|||
</div>
|
||||
|
||||
</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