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

View File

@ -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,53 +203,86 @@ 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
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="isOpen5 = false"> <Button
Keep my account destructive
</Button> @click="isOpen4 = false"
<Button destructive @click="isOpen5 = false"> >
I understand. Delete my account now! Confirm Deletion
</Button> </Button>
</template> </template>
</Modal> </Modal>
@ -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>