Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 0 additions & 73 deletions LICENSES/Apache-2.0.txt

This file was deleted.

6 changes: 0 additions & 6 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,3 @@ path = ".env.development"
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "CC0-1.0"

[[annotations]]
path = ["styles/close-dark.svg", "styles/close.svg", "styles/loader.svg"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2018-2024 Google LLC"
SPDX-License-Identifier = "Apache-2.0"
15 changes: 15 additions & 0 deletions l10n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ msgid_plural "Choose %n files"
msgstr[0] ""
msgstr[1] ""

msgid "Close"
msgstr ""

msgid "Confirm"
msgstr ""

Expand Down Expand Up @@ -77,6 +80,9 @@ msgstr ""
msgid "Enter your name"
msgstr ""

msgid "Error: {message}"
msgstr ""

msgid "Existing version"
msgstr ""

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

msgid "Info: {message}"
msgstr ""

msgid "Invalid folder name."
msgstr ""

Expand Down Expand Up @@ -199,12 +208,18 @@ msgstr ""
msgid "Submit name"
msgstr ""

msgid "Success: {message}"
msgstr ""

msgid "Undo"
msgstr ""

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

msgid "Warning: {message}"
msgstr ""

msgid "When an incoming folder is selected, any conflicting files within it will also be overwritten."
msgstr ""

Expand Down
231 changes: 231 additions & 0 deletions lib/components/ToastNotification.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="nc-toast"
:class="[typeClass, { 'nc-toast--clickable': onClick }]"
:role="role"
@click="onClick?.()">
<!-- Loading: text + spinner pushed to the right -->
<template v-if="isLoading">
<span class="nc-toast__message">{{ message }}</span>
<span class="nc-toast__loader" aria-hidden="true">
<NcLoadingIcon :size="20" />
</span>
</template>

<!-- Undo: text + undo button -->
<template v-else-if="isUndo">
<span class="nc-toast__message">{{ message }}</span>
<NcButton
class="nc-toast__undo-button"
variant="tertiary"
@click.stop="handleUndoClick">
{{ t('Undo') }}
</NcButton>
</template>

<!-- Default: plain string, HTML string, or arbitrary DOM Node -->
<template v-else>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="isHTML && isStringMessage" class="nc-toast__message" v-html="message" />
<span v-else-if="isStringMessage" class="nc-toast__message">{{ message }}</span>
<!-- Node content is appended in onMounted -->
<span v-else ref="nodeRef" class="nc-toast__message" />
</template>

<!-- Close button -->
<NcButton
v-if="close"
class="nc-toast__close"
variant="tertiary"
:aria-label="t('Close')"
@click.stop="dismiss">
<template #icon>
<NcIconSvgWrapper :path="mdiClose" :size="20" />
</template>
</NcButton>
</div>
</template>

<script setup lang="ts">
import { mdiClose } from '@mdi/js'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { ToastType } from '../toast.ts'
import { t } from '../utils/l10n.js'

const props = withDefaults(defineProps<{
/** Text or DOM node to display as the toast body */
message: string | Node
/** Allow raw HTML in the message (only when message is a string) */
isHTML?: boolean
/** Visual type variant (controls left-border colour and element icons) */
type?: ToastType
/** Auto-dismiss delay in ms; -1 means permanent */
timeout: number
/** Show the close button */
close?: boolean
/** ARIA role derived from the aria-live level */
role: 'alert' | 'status'
/** Optional click handler for the whole toast */
onClick?: () => void
/** Undo callback, only used when type is UNDO */
onUndo?: (event: MouseEvent) => void
}>(), {
isHTML: false,
type: undefined,
close: true,

Check warning on line 82 in lib/components/ToastNotification.vue

View workflow job for this annotation

GitHub Actions / eslint

Boolean prop should only be defaulted to false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if possible fix this to default to false. E.g. rename to noClose.

You also do not need to specify defaults of false (see isHTML) because for boolean props false is automatically the default value (just like native HTML attributes).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saw that eslint warning indeed. Sure thing!!

onClick: undefined,
onUndo: undefined,
})

const emit = defineEmits<{
/** Emitted when the toast should be removed (close btn, timer, or undo) */
dismiss: []
}>()

/** Mount target for arbitrary DOM Node content */
const nodeRef = ref<HTMLElement | null>(null)
let _timer: ReturnType<typeof setTimeout> | null = null

// Derived state
const typeClass = computed(() => props.type ? `nc-toast--${props.type.replace(/^toast-/, '')}` : null)
const isStringMessage = computed(() => typeof props.message === 'string')
const isLoading = computed(() => props.type === ToastType.LOADING)
const isUndo = computed(() => props.type === ToastType.UNDO)

/** Remove the toast (clears the auto-dismiss timer and emits dismiss). */
function dismiss(): void {
if (_timer !== null) {
clearTimeout(_timer)
_timer = null
}
emit('dismiss')
}

/**
* Handle click on the undo button: stop propagation,
* call the undo callback, and dismiss the toast.
*
* @param event The click event from the undo button
*/
function handleUndoClick(event: MouseEvent): void {
// Prevent the click from bubbling up to the toast's onClick handler
event.stopPropagation()
props.onUndo?.(event)
dismiss()
}

onMounted(() => {
// Attach arbitrary DOM Node content into the message slot
if (props.message instanceof Node && nodeRef.value) {
nodeRef.value.appendChild(props.message)
}
// Start the auto-dismiss countdown
if (props.timeout > 0) {
_timer = setTimeout(dismiss, props.timeout)
}
})

onUnmounted(() => {
if (_timer !== null) {
clearTimeout(_timer)
_timer = null
}
})

defineExpose({ hide: dismiss })
</script>

<style lang="scss">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking (sorry did not see it in the first place):

Why is this not scoped? This sounds dangerous once we have different library versions (different apps) on the same page.
Even small changes can cause conflicts in styles.

Best to always use <style module> for new code as this is the only way to properly 2-way scope styles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because some attributes are also used outside this vue component. Like the notification container :)
Have a better idea for this ?
I haven't thought of it much 😊

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also import the classes in the toast.ts file and use it there.
But then we need to support multiple toast containers (if there are different versions of the library are used - same version still only yields one containers).

Like:

import { toastContainer } from './toasts.module.css'

// ...

_container.className = toastContainer

$spacing: 12px;

.nc-toast-container {
position: fixed;
top: calc(var(--header-height) + 10px);
right: 10px;
z-index: 10100;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
// Individual toasts manage their own pointer-events
pointer-events: none;
}

@keyframes nc-toast-in {
from {
opacity: 0;
transform: translateY(-6px);
}

to {
opacity: 1;
transform: translateY(0);
}
}

.nc-toast {
min-width: 200px;
background-color: var(--color-main-background);
color: var(--color-main-text);
box-shadow: 0 0 6px 0 var(--color-box-shadow);
padding: 0 $spacing;
border-radius: var(--border-radius);
display: flex;
align-items: center;
min-height: var(--clickable-area-large);
pointer-events: auto;
animation: nc-toast-in var(--animation-slow) ease-out;

// Modifiers

&--clickable {
cursor: pointer;
}

&--error {
border-left: 3px solid var(--color-element-error, var(--color-error));
}

&--info {
border-left: 3px solid var(--color-element-info, var(--color-primary));
}

&--warning {
border-left: 3px solid var(--color-element-warning, var(--color-warning));
}

&--success {
border-left: 3px solid var(--color-element-success, var(--color-success));
}

&--undo {
border-left: 3px solid var(--color-element-success, var(--color-success));
}

&--loading {
border-left: 3px solid var(--color-element-info, var(--color-primary));
}

// Elements
&__message {
flex: 1;
padding: $spacing 0;
}

&__loader,
&__close,
&__undo-button {
display: flex;
align-items: center;
margin-left: $spacing;
flex-shrink: 0;
}
}
</style>
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
} from './toast.js'

export type {
ToastHandle,
ToastOptions,
} from './toast.js'

Expand Down
Loading
Loading