Skip to content

Commit e245c3e

Browse files
committed
feat: accessible toast and drop toastify
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent fbacc41 commit e245c3e

File tree

12 files changed

+729
-319
lines changed

12 files changed

+729
-319
lines changed

LICENSES/Apache-2.0.txt

Lines changed: 0 additions & 73 deletions
This file was deleted.

REUSE.toml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,3 @@ path = ".env.development"
2222
precedence = "aggregate"
2323
SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors"
2424
SPDX-License-Identifier = "CC0-1.0"
25-
26-
[[annotations]]
27-
path = ["styles/close-dark.svg", "styles/close.svg", "styles/loader.svg"]
28-
precedence = "aggregate"
29-
SPDX-FileCopyrightText = "2018-2024 Google LLC"
30-
SPDX-License-Identifier = "Apache-2.0"

l10n/messages.pot

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ msgid_plural "Choose %n files"
4747
msgstr[0] ""
4848
msgstr[1] ""
4949

50+
msgid "Close"
51+
msgstr ""
52+
5053
msgid "Confirm"
5154
msgstr ""
5255

@@ -77,6 +80,9 @@ msgstr ""
7780
msgid "Enter your name"
7881
msgstr ""
7982

83+
msgid "Error: {message}"
84+
msgstr ""
85+
8086
msgid "Existing version"
8187
msgstr ""
8288

@@ -107,6 +113,9 @@ msgstr ""
107113
msgid "If you select both versions, the incoming file will have a number added to its name."
108114
msgstr ""
109115

116+
msgid "Info: {message}"
117+
msgstr ""
118+
110119
msgid "Invalid folder name."
111120
msgstr ""
112121

@@ -199,12 +208,18 @@ msgstr ""
199208
msgid "Submit name"
200209
msgstr ""
201210

211+
msgid "Success: {message}"
212+
msgstr ""
213+
202214
msgid "Undo"
203215
msgstr ""
204216

205217
msgid "Upload some content or sync with your devices!"
206218
msgstr ""
207219

220+
msgid "Warning: {message}"
221+
msgstr ""
222+
208223
msgid "When an incoming folder is selected, any conflicting files within it will also be overwritten."
209224
msgstr ""
210225

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<div
7+
class="nc-toast"
8+
:class="[typeClass, { 'nc-toast--clickable': onClick }]"
9+
:role="role"
10+
@click="onClick?.()">
11+
<!-- Loading: text + spinner pushed to the right -->
12+
<template v-if="isLoading">
13+
<span class="nc-toast__message">{{ message }}</span>
14+
<span class="nc-toast__loader" aria-hidden="true">
15+
<NcLoadingIcon :size="20" />
16+
</span>
17+
</template>
18+
19+
<!-- Undo: text + undo button -->
20+
<template v-else-if="isUndo">
21+
<span class="nc-toast__message">{{ message }}</span>
22+
<NcButton
23+
class="nc-toast__undo-button"
24+
variant="tertiary"
25+
@click.stop="handleUndoClick">
26+
{{ t('Undo') }}
27+
</NcButton>
28+
</template>
29+
30+
<!-- Default: plain string, HTML string, or arbitrary DOM Node -->
31+
<template v-else>
32+
<!-- eslint-disable-next-line vue/no-v-html -->
33+
<span v-if="isHTML && isStringMessage" class="nc-toast__message" v-html="message" />
34+
<span v-else-if="isStringMessage" class="nc-toast__message">{{ message }}</span>
35+
<!-- Node content is appended in onMounted -->
36+
<span v-else ref="nodeRef" class="nc-toast__message" />
37+
</template>
38+
39+
<!-- Close button -->
40+
<NcButton
41+
v-if="close"
42+
class="nc-toast__close"
43+
variant="tertiary"
44+
:aria-label="t('Close')"
45+
@click.stop="dismiss">
46+
<template #icon>
47+
<NcIconSvgWrapper :path="mdiClose" :size="20" />
48+
</template>
49+
</NcButton>
50+
</div>
51+
</template>
52+
53+
<script setup lang="ts">
54+
import { mdiClose } from '@mdi/js'
55+
import { computed, onMounted, onUnmounted, ref } from 'vue'
56+
import NcButton from '@nextcloud/vue/components/NcButton'
57+
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
58+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
59+
import { ToastType } from '../toast.ts'
60+
import { t } from '../utils/l10n.js'
61+
62+
const props = withDefaults(defineProps<{
63+
/** Text or DOM node to display as the toast body */
64+
message: string | Node
65+
/** Allow raw HTML in the message (only when message is a string) */
66+
isHTML?: boolean
67+
/** Visual type variant (controls left-border colour and element icons) */
68+
type?: ToastType
69+
/** Auto-dismiss delay in ms; -1 means permanent */
70+
timeout: number
71+
/** Show the close button */
72+
close?: boolean
73+
/** ARIA role derived from the aria-live level */
74+
role: 'alert' | 'status'
75+
/** Optional click handler for the whole toast */
76+
onClick?: () => void
77+
/** Undo callback, only used when type is UNDO */
78+
onUndo?: (event: MouseEvent) => void
79+
}>(), {
80+
isHTML: false,
81+
type: undefined,
82+
close: true,
83+
onClick: undefined,
84+
onUndo: undefined,
85+
})
86+
87+
const emit = defineEmits<{
88+
/** Emitted when the toast should be removed (close btn, timer, or undo) */
89+
dismiss: []
90+
}>()
91+
92+
/** Mount target for arbitrary DOM Node content */
93+
const nodeRef = ref<HTMLElement | null>(null)
94+
let _timer: ReturnType<typeof setTimeout> | null = null
95+
96+
// Derived state
97+
const typeClass = computed(() => props.type ? `nc-toast--${props.type.replace(/^toast-/, '')}` : null)
98+
const isStringMessage = computed(() => typeof props.message === 'string')
99+
const isLoading = computed(() => props.type === ToastType.LOADING)
100+
const isUndo = computed(() => props.type === ToastType.UNDO)
101+
102+
/** Remove the toast (clears the auto-dismiss timer and emits dismiss). */
103+
function dismiss(): void {
104+
if (_timer !== null) {
105+
clearTimeout(_timer)
106+
_timer = null
107+
}
108+
emit('dismiss')
109+
}
110+
111+
/**
112+
* Handle click on the undo button: stop propagation,
113+
* call the undo callback, and dismiss the toast.
114+
*
115+
* @param event The click event from the undo button
116+
*/
117+
function handleUndoClick(event: MouseEvent): void {
118+
// Prevent the click from bubbling up to the toast's onClick handler
119+
event.stopPropagation()
120+
props.onUndo?.(event)
121+
dismiss()
122+
}
123+
124+
onMounted(() => {
125+
// Attach arbitrary DOM Node content into the message slot
126+
if (props.message instanceof Node && nodeRef.value) {
127+
nodeRef.value.appendChild(props.message)
128+
}
129+
// Start the auto-dismiss countdown
130+
if (props.timeout > 0) {
131+
_timer = setTimeout(dismiss, props.timeout)
132+
}
133+
})
134+
135+
onUnmounted(() => {
136+
if (_timer !== null) {
137+
clearTimeout(_timer)
138+
_timer = null
139+
}
140+
})
141+
142+
defineExpose({ hide: dismiss })
143+
</script>
144+
145+
<style lang="scss">
146+
$spacing: 12px;
147+
148+
.nc-toast-container {
149+
position: fixed;
150+
top: calc(var(--header-height) + 10px);
151+
right: 10px;
152+
z-index: 10100;
153+
display: flex;
154+
flex-direction: column;
155+
gap: 8px;
156+
align-items: flex-end;
157+
// Individual toasts manage their own pointer-events
158+
pointer-events: none;
159+
}
160+
161+
@keyframes nc-toast-in {
162+
from {
163+
opacity: 0;
164+
transform: translateY(-6px);
165+
}
166+
167+
to {
168+
opacity: 1;
169+
transform: translateY(0);
170+
}
171+
}
172+
173+
.nc-toast {
174+
min-width: 200px;
175+
background-color: var(--color-main-background);
176+
color: var(--color-main-text);
177+
box-shadow: 0 0 6px 0 var(--color-box-shadow);
178+
padding: 0 $spacing;
179+
border-radius: var(--border-radius);
180+
display: flex;
181+
align-items: center;
182+
min-height: var(--clickable-area-large);
183+
pointer-events: auto;
184+
animation: nc-toast-in var(--animation-slow) ease-out;
185+
186+
// Modifiers
187+
188+
&--clickable {
189+
cursor: pointer;
190+
}
191+
192+
&--error {
193+
border-left: 3px solid var(--color-element-error, var(--color-error));
194+
}
195+
196+
&--info {
197+
border-left: 3px solid var(--color-element-info, var(--color-primary));
198+
}
199+
200+
&--warning {
201+
border-left: 3px solid var(--color-element-warning, var(--color-warning));
202+
}
203+
204+
&--success {
205+
border-left: 3px solid var(--color-element-success, var(--color-success));
206+
}
207+
208+
&--undo {
209+
border-left: 3px solid var(--color-element-success, var(--color-success));
210+
}
211+
212+
&--loading {
213+
border-left: 3px solid var(--color-element-info, var(--color-primary));
214+
}
215+
216+
// Elements
217+
&__message {
218+
flex: 1;
219+
padding: $spacing 0;
220+
}
221+
222+
&__loader,
223+
&__close,
224+
&__undo-button {
225+
display: flex;
226+
align-items: center;
227+
margin-left: $spacing;
228+
flex-shrink: 0;
229+
}
230+
}
231+
</style>

lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export {
4343
} from './toast.js'
4444

4545
export type {
46+
ToastHandle,
4647
ToastOptions,
4748
} from './toast.js'
4849

0 commit comments

Comments
 (0)