Skip to content

Commit

Permalink
Merge pull request #6035 from nextcloud-libraries/backport/6005/next
Browse files Browse the repository at this point in the history
[next] feat(NcDialogButton): Allow to return `false` from callback to keep dialog open
  • Loading branch information
susnux authored Jan 20, 2025
2 parents 285780d + 28860a1 commit 6beb1a0
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 85 deletions.
4 changes: 4 additions & 0 deletions l10n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ msgstr ""
msgid "Load more \"{options}\""
msgstr ""

#. TRANSLATORS: The button is in a loading state
msgid "Loading …"
msgstr ""

#. TRANSLATORS: A color name for RGB(45, 115, 190)
msgid "Mariner"
msgstr ""
Expand Down
93 changes: 86 additions & 7 deletions src/components/NcDialog/NcDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ Note that this is not possible if the dialog contains a navigation!
</div>
</template>
<script>
import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
import IconCheck from '@mdi/svg/svg/check.svg?raw'

export default {
Expand All @@ -128,6 +127,82 @@ export default {
}
</script>
```

### Loading buttons
Sometimes a dialog ends with a request and this request might fail due to server-side-validation.
In this case it is often desired to keep the dialog open, this can be done by returning `false` from the button callback,
to not block this callback should return a `Promise<false>`.

It is also possible to get the result of the callback from the dialog, as the result is passed as the payload of the `closing` event.

While the promise is awaited the button will have a loading state,
this means, as long as no custom `icon`-slot is used, a loading icon will be shown.
Please note that the **button will not be disabled or accessibility reasons**,
because disabled elements cannot be focused and so the loading state could not be communicated e.g. via screen readers.

```vue
<template>
<div>
<NcButton @click="openDialog">Show dialog</NcButton>
<NcDialog :buttons="buttons"
name="Create user"
:message="message"
:open.sync="showDialog"
@closing="response = $event"
@update:open="clickClosesDialog = false" />
<div style="margin-top: 8px;">Dialog response: {{ response }}</div>
</div>
</template>
<script>
export default {
data() {
return {
showDialog: false,
clickClosesDialog: false,
response: 'none',
}
},

methods: {
async callback() {
// wait 3 seconds
await new Promise((resolve) => window.setTimeout(resolve, 3000))
this.clickClosesDialog = !this.clickClosesDialog
// Do not close the dialog on first and then every second button click
if (this.clickClosesDialog) {
// return false means the dialog stays open
return false
}
return '✅'
},

openDialog() {
this.response = 'none'
this.showDialog = true
},
},

computed: {
buttons() {
return [
{
label: 'Create user',
type: 'primary',
callback: this.callback,
}
]
},
message() {
if (this.clickClosesDialog) {
return 'Next button click will work and close the dialog.'
} else {
return 'Clicking the button will load but not close the dialog.'
}
},
},
}
</script>
```
</docs>

<template>
Expand All @@ -137,7 +212,7 @@ export default {
:enable-swipe="false"
v-bind="modalProps"
@close="handleClosed"
@update:show="handleClosing">
@update:show="handleClosing()">
<!-- The dialog name / header -->
<h2 :id="navigationId" class="dialog__name" v-text="name" />
<component :is="dialogTagName"
Expand Down Expand Up @@ -453,25 +528,29 @@ export default defineComponent({
// Because NcModal does not emit `close` when show prop is changed
/**
* Handle clicking a dialog button -> should close
* @param {MouseEvent} event The click event
* @param {unknown} result Result of the callback function
*/
const handleButtonClose = () => {
const handleButtonClose = (event, result) => {
// Skip close if invalid dialog
if (dialogTagName.value === 'form' && !dialogElement.value.reportValidity()) {
return
}
handleClosing()
handleClosing(result)
window.setTimeout(() => handleClosed(), 300)
}

/**
* Handle closing the dialog, optional out transition did not run yet
* @param {unknown} result the result of the callback
*/
const handleClosing = () => {
const handleClosing = (result) => {
showModal.value = false
/**
* Emitted when the dialog is closing, so the out transition did not finish yet
* Emitted when the dialog is closing, so the out transition did not finish yet.
* @param result The result of the button callback (`undefined` if closing because of clicking the 'close'-button)
*/
emit('closing')
emit('closing', result)
}

/**
Expand Down
169 changes: 91 additions & 78 deletions src/components/NcDialogButton/NcDialogButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,103 +17,116 @@ Dialog button component used by NcDialog in the actions slot to display the butt
<template #icon>
<!-- @slot Allow to set a custom icon for the button -->
<slot name="icon">
<NcIconSvgWrapper v-if="icon !== undefined" :svg="icon" />
<!-- The loading state is an information that must be accessible -->
<NcLoadingIcon v-if="isLoading" :name="t('Loading …') /* TRANSLATORS: The button is in a loading state*/" />
<NcIconSvgWrapper v-else-if="icon !== undefined" :svg="icon" />
</slot>
</template>
</NcButton>
</template>

<script lang="ts">
import { defineComponent, type PropType } from 'vue'
<script setup lang="ts">
import type { PropType } from 'vue'
import { ref } from 'vue'

import NcButton, { ButtonNativeType, ButtonType } from '../NcButton/index'
import NcIconSvgWrapper from '../NcIconSvgWrapper/index.js'
import NcLoadingIcon from '../NcLoadingIcon/index.js'
import { t } from '../../l10n.js'

export default defineComponent({
name: 'NcDialogButton',
const props = defineProps({
/**
* The function that will be called when the button is pressed.
* If the function returns `false` the click is ignored and the dialog will not be closed.
* @type {() => unknown|false|Promise<unknown|false>}
*/
callback: {
type: Function,
required: false,
default: () => {},
},

components: {
NcButton,
NcIconSvgWrapper,
/**
* The label of the button
*/
label: {
type: String,
required: true,
},

props: {
/**
* The function that will be called when the button is pressed
* @type {() => void}
*/
callback: {
type: Function,
required: false,
default: () => {},
},
/**
* Optional inline SVG icon for the button
*/
icon: {
type: String,
required: false,
default: undefined,
},

/**
* The label of the button
*/
label: {
type: String,
required: true,
/**
* The button type, see NcButton
* @type {'primary'|'secondary'|'error'|'warning'|'success'}
*/
type: {
type: String as PropType<ButtonType>,
default: ButtonType.Secondary,
required: false,
validator(value: string) {
return typeof value === 'string'
&& Object.values(ButtonType).includes(value as ButtonType)
},
},

/**
* Optional inline SVG icon for the button
*/
icon: {
type: String,
required: false,
default: undefined,
/**
* The native type of the button, see `NcButton`
* @type {'button'|'submit'|'reset'}
*/
nativeType: {
type: String as PropType<ButtonNativeType>,
required: false,
default: 'button',
validator(value) {
return typeof value === 'string'
&& Object.values(ButtonNativeType).includes(value as ButtonNativeType)
},
},

/**
* The button type, see NcButton
* @type {'primary'|'secondary'|'error'|'warning'|'success'}
*/
type: {
type: String as PropType<ButtonType>,
default: ButtonType.Secondary,
required: false,
validator(value: string) {
return typeof value === 'string'
&& Object.values(ButtonType).includes(value as ButtonType)
},
},
/**
* If the button should be shown as disabled
*/
disabled: {
type: Boolean,
default: false,
},
})

/**
* The native type of the button, see `NcButton`
* @type {'button'|'submit'|'reset'}
*/
nativeType: {
type: String as PropType<ButtonNativeType>,
required: false,
default: 'button',
validator(value) {
return typeof value === 'string'
&& Object.values(ButtonNativeType).includes(value as ButtonNativeType)
},
},
const emit = defineEmits<{
(name: 'click', event: MouseEvent, payload: unknown): void
}>()

/**
* If the button should be shown as disabled
*/
disabled: {
type: Boolean,
default: false,
},
},
const isLoading = ref(false)

emits: ['click'],
/**
* Handle clicking the button
* @param {MouseEvent} e The click event
*/
const handleClick = async (e) => {
// Do not re-emit while loading
if (isLoading.value) {
return
}

setup(props, { emit }) {
/**
* Handle clicking the button
* @param {MouseEvent} e The click event
*/
const handleClick = (e) => {
props.callback?.()
emit('click', e)
isLoading.value = true
try {
const result = await props.callback?.()
if (result !== false) {
/**
* The click event (`MouseEvent`) and the value returned by the callback
*/
emit('click', e, result)
}

return { handleClick }
},
})
} finally {
isLoading.value = false
}
}
</script>
3 changes: 3 additions & 0 deletions styleguide.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ module.exports = async () => {
},

enhancePreviewApp: path.resolve(__dirname, 'styleguide/preview.js'),
compilerConfig: {
transforms: { asyncAwait: false },
},

sections: [
{
Expand Down

0 comments on commit 6beb1a0

Please sign in to comment.