feat(ui): add `cancel` prop to Modal; auto-focus previously focused element on close; add topleft slot

This commit is contained in:
upsiflu 2025-02-06 15:35:15 +01:00
parent e16d0a6130
commit 5f4bc5f175
2 changed files with 181 additions and 181 deletions

View File

@ -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>

View File

@ -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>