diff --git a/.editorconfig b/.editorconfig
index 81274ea2f6..8b09ff3c1f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,7 +5,6 @@ charset = utf-8
end_of_line = lf
indent_style = tab
insert_final_newline = true
-max_line_length = 80
trim_trailing_whitespace = true
[*.md]
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3dc58c8f99..3361c50f18 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -15,6 +15,7 @@ const trustedDependencies = new Set([
'@metatypes/typography',
'@metatypes/units',
'filesize',
+ 'nanoid',
'zod',
])
diff --git a/internals/fake-root/package.json b/internals/fake-root/package.json
index 995d6f93a5..35539ab84b 100644
--- a/internals/fake-root/package.json
+++ b/internals/fake-root/package.json
@@ -3,7 +3,7 @@
"private": true,
"scripts": {
"check:eslint": "yarn --cwd ../.. run eslint --max-warnings=0 --ignore-pattern=internals --ignore-pattern=packages .",
- "check:knip": "yarn --cwd ../.. run knip",
+ "check:knip": "yarn --cwd ../.. run knip --tags=-knipignore",
"check:prettier": "yarn --cwd ../.. run prettier --check --ignore-path=.fake-prettierignore --ignore-path=.prettierignore .",
"fix:eslint": "yarn run check:eslint --fix",
"fix:prettier": "yarn run check:prettier --write"
diff --git a/packages/documentation/assets/img/banner_alert.png b/packages/documentation/assets/img/banner_alert.png
deleted file mode 100644
index c0318daad6..0000000000
Binary files a/packages/documentation/assets/img/banner_alert.png and /dev/null differ
diff --git a/packages/documentation/assets/img/banner_expand.png b/packages/documentation/assets/img/banner_expand.png
deleted file mode 100644
index 8b30ff8ffd..0000000000
Binary files a/packages/documentation/assets/img/banner_expand.png and /dev/null differ
diff --git a/packages/documentation/components/CodePreviewNew.vue b/packages/documentation/components/CodePreviewNew.vue
new file mode 100644
index 0000000000..3a50aa6e19
--- /dev/null
+++ b/packages/documentation/components/CodePreviewNew.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
diff --git a/packages/documentation/components/ColorPalette.vue b/packages/documentation/components/ColorPalette.vue
index 1505f9faad..23a18d1d08 100644
--- a/packages/documentation/components/ColorPalette.vue
+++ b/packages/documentation/components/ColorPalette.vue
@@ -11,17 +11,15 @@
-
- Copy successful
-
diff --git a/packages/documentation/components/YocoPreview.vue b/packages/documentation/components/YocoPreview.vue
index 44211b3c38..1f5fa59951 100644
--- a/packages/documentation/components/YocoPreview.vue
+++ b/packages/documentation/components/YocoPreview.vue
@@ -18,22 +18,18 @@
-
diff --git a/packages/documentation/layouts/fullpage.vue b/packages/documentation/layouts/fullpage.vue
index 2f2d83405a..fe83529008 100644
--- a/packages/documentation/layouts/fullpage.vue
+++ b/packages/documentation/layouts/fullpage.vue
@@ -5,6 +5,7 @@
+
@@ -12,6 +13,7 @@
+
+
+
+
+
+
+
+
+
+ {{ text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
diff --git a/packages/kotti-ui/source/kotti-banner/index.ts b/packages/kotti-ui/source/kotti-banner/index.ts
index 4c7118cbc8..3d87ec5335 100755
--- a/packages/kotti-ui/source/kotti-banner/index.ts
+++ b/packages/kotti-ui/source/kotti-banner/index.ts
@@ -9,11 +9,25 @@ export const KtBanner = attachMeta(makeInstallable(KtBannerVue), {
deprecated: null,
designs: {
type: MetaDesignType.FIGMA,
- url: 'https://www.figma.com/file/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=1439%3A5',
+ url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=7096-5539',
},
slots: {
- default: {
- description: 'Content when Banner is expanded',
+ action: {
+ description:
+ 'Use this only if you need to implement a custom action button',
+ scope: null,
+ },
+ footer: {
+ description:
+ 'Used to put e.g. buttons or other interactive elements at the bottom of the banner',
+ scope: null,
+ },
+ header: {
+ description: 'Used to replace the optional header text',
+ scope: null,
+ },
+ text: {
+ description: 'Used to replace the main text',
scope: null,
},
},
diff --git a/packages/kotti-ui/source/kotti-banner/types.ts b/packages/kotti-ui/source/kotti-banner/types.ts
index 9ac31877d9..38c62a2b48 100644
--- a/packages/kotti-ui/source/kotti-banner/types.ts
+++ b/packages/kotti-ui/source/kotti-banner/types.ts
@@ -1,22 +1,29 @@
import { z } from 'zod'
-import { Yoco, yocoIconSchema } from '@3yourmind/yoco'
+import { yocoIconSchema } from '@3yourmind/yoco'
export module KottiBanner {
+ export const styleSchema = z
+ .object({
+ backgroundColor: z.string(),
+ darkColor: z.string(),
+ icon: yocoIconSchema.nullable(),
+ lightColor: z.string(),
+ })
+ .strict()
+
+ export type Style = z.infer
+
export const propsSchema = z.object({
- actionText: z.string().nullable().default(null),
- expandCloseLabel: z.string().nullable().default(null),
- expandLabel: z.string().nullable().default(null),
- icon: yocoIconSchema.default(Yoco.Icon.ANNOUNCE),
- isGray: z.boolean().default(false),
- message: z.string(),
+ action: z.string().nullable().default(null),
+ header: z.string().nullable().default(null),
+ isCloseable: z.boolean().default(false),
+ text: z.string().nullable().default(null),
+ type: z
+ .union([styleSchema, z.enum(['error', 'info', 'success', 'warning'])])
+ .default('info'),
})
export type Props = z.input
export type PropsInternal = z.output
-
- export type Translations = {
- expandCloseLabel: string
- expandLabel: string
- }
}
diff --git a/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts b/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts
index b81b6c7767..f80ebb5d57 100644
--- a/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts
+++ b/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts
@@ -18,10 +18,6 @@ module Common {
}
export const deDE: KottiI18n.Messages = {
- KtBanner: {
- expandCloseLabel: 'Schließen',
- expandLabel: 'Öffnen',
- },
KtComment: {
cancelMessage: 'Drücken Sie die Esc-Taste oder',
clickToCancelLabel: 'klicken Sie zum Abbrechen',
diff --git a/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts b/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts
index ff4a75aece..49f3d388ec 100644
--- a/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts
+++ b/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts
@@ -18,10 +18,6 @@ module Common {
}
export const enUS: KottiI18n.Messages = {
- KtBanner: {
- expandCloseLabel: 'Close',
- expandLabel: 'View',
- },
KtComment: {
cancelMessage: 'Press Esc key or',
clickToCancelLabel: 'click to cancel',
diff --git a/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts b/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts
index 753b3e78d7..f6d19381f6 100644
--- a/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts
+++ b/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts
@@ -18,10 +18,6 @@ module Common {
}
export const esES: KottiI18n.Messages = {
- KtBanner: {
- expandCloseLabel: 'Cerrar',
- expandLabel: 'Ver',
- },
KtComment: {
cancelMessage: 'Pulse la tecla Esc o',
clickToCancelLabel: 'haga clic para cancelar',
diff --git a/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts b/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts
index 63ce56d7d6..603d6d627c 100644
--- a/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts
+++ b/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts
@@ -18,10 +18,6 @@ module Common {
}
export const frFR: KottiI18n.Messages = {
- KtBanner: {
- expandCloseLabel: 'Fermer',
- expandLabel: 'Voir',
- },
KtComment: {
cancelMessage: 'Appuyez sur la touche Esc ou',
clickToCancelLabel: 'cliquez pour annuler',
diff --git a/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts b/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts
index 8233d708a9..9f2d47486e 100644
--- a/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts
+++ b/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts
@@ -18,10 +18,6 @@ module Common {
}
export const jaJP: KottiI18n.Messages = {
- KtBanner: {
- expandCloseLabel: '閉じる',
- expandLabel: '表示',
- },
KtComment: {
cancelMessage: 'Escキーを押すか',
clickToCancelLabel: 'クリックしてキャンセルします',
diff --git a/packages/kotti-ui/source/kotti-i18n/types.ts b/packages/kotti-ui/source/kotti-i18n/types.ts
index 41a9dfea79..3668c3d9e4 100644
--- a/packages/kotti-ui/source/kotti-i18n/types.ts
+++ b/packages/kotti-ui/source/kotti-i18n/types.ts
@@ -1,6 +1,5 @@
import type { Ref } from 'vue'
-import type { KottiBanner } from '../kotti-banner/types'
import type { KottiComment } from '../kotti-comment/types'
import type { KottiField } from '../kotti-field/types'
import type { Shared as KottiFieldFileUploadShared } from '../kotti-field-file-upload/types'
@@ -33,7 +32,6 @@ export module KottiI18n {
}
export type Messages = {
- KtBanner: KottiBanner.Translations
KtComment: KottiComment.Translations
KtFieldFileUpload: KottiFieldFileUploadShared.Translations
KtFieldInlineEdit: KottiFieldInlineEdit.Translations
diff --git a/packages/kotti-ui/source/kotti-style/tokens.css b/packages/kotti-ui/source/kotti-style/tokens.css
index 2263c27883..10d90363eb 100644
--- a/packages/kotti-ui/source/kotti-style/tokens.css
+++ b/packages/kotti-ui/source/kotti-style/tokens.css
@@ -5,9 +5,19 @@
*/
:root {
- --white: #fff;
--black: #000;
+ --blue-10: #eaf0fa;
+ --blue-100: #0d244a;
+ --blue-20: #c1d7fb;
+ --blue-30: #afc5e8;
+ --blue-40: #6795e0;
+ --blue-50: #3173de;
+ --blue-60: #2c66c4;
+ --blue-70: #2659ab;
+ --blue-80: #1f55ad;
+ --blue-90: #153c7a;
--gray-10: #f8f8f8;
+ --gray-100: #141414;
--gray-20: #e0e0e0;
--gray-30: #c6c6c6;
--gray-40: #a8a8a8;
@@ -16,8 +26,18 @@
--gray-70: #525252;
--gray-80: #393939;
--gray-90: #262626;
- --gray-100: #141414;
+ --green-10: #e6f8d2;
+ --green-20: #c4e0a5;
+ --green-50: #71c716;
+ --green-60: #64ad13;
+ --green-70: #549410;
+ --orange-10: #fde2cb;
+ --orange-20: #fab980;
+ --orange-50: #ff9333;
+ --orange-60: #ff7800;
+ --orange-70: #ba6820;
--primary-10: #eaf0fa;
+ --primary-100: #0d244a;
--primary-20: #c1d7fb;
--primary-30: #afc5e8;
--primary-40: #6795e0;
@@ -26,27 +46,20 @@
--primary-70: #2659ab;
--primary-80: #1f55ad;
--primary-90: #153c7a;
- --primary-100: #0d244a;
- --green-20: #c4e0a5;
- --green-50: #71c716;
- --green-60: #64ad13;
- --green-70: #549410;
- --red-20: #f0a8a8;
- --red-50: #f21d1d;
- --red-60: #d91919;
- --red-70: #bf1717;
--purple-20: #b995ca;
--purple-50: #932dc2;
--purple-60: #6c218f;
--purple-70: #591b75;
+ --red-10: #fbe1e1;
+ --red-20: #f0a8a8;
+ --red-50: #f21d1d;
+ --red-60: #d91919;
+ --red-70: #bf1717;
+ --white: #fff;
--yellow-20: #fff9c0;
--yellow-50: #fff490;
--yellow-60: #ffe60d;
--yellow-70: #dfc903;
- --orange-20: #fab980;
- --orange-50: #ff9333;
- --orange-60: #ff7800;
- --orange-70: #ba6820;
--ui-background: var(--white);
--ui-01: var(--gray-10);
--ui-02: var(--gray-20);
@@ -76,15 +89,19 @@
--icon-02: var(--gray-50);
--icon-03: var(--white);
--support-error: var(--red-50);
+ --support-error-bg: var(--red-10);
--support-error-dark: var(--red-70);
--support-error-light: var(--red-20);
--support-warning: var(--orange-50);
+ --support-warning-bg: var(--orange-10);
--support-warning-dark: var(--orange-70);
--support-warning-light: var(--orange-20);
--support-success: var(--green-50);
+ --support-success-bg: var(--green-10);
--support-success-dark: var(--green-70);
--support-success-light: var(--green-20);
- --support-info: var(--primary-50);
- --support-info-dark: var(--primary-70);
- --support-info-light: var(--primary-20);
+ --support-info: var(--blue-50);
+ --support-info-bg: var(--blue-10);
+ --support-info-dark: var(--blue-70);
+ --support-info-light: var(--blue-20);
}
diff --git a/packages/kotti-ui/source/kotti-toaster/KtToast.vue b/packages/kotti-ui/source/kotti-toaster/KtToast.vue
new file mode 100644
index 0000000000..6227b902d0
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/KtToast.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ headerWithFallback }}
+
+
+
+
+ {{ textWithFallback }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/kotti-ui/source/kotti-toaster/KtToastProvider.vue b/packages/kotti-ui/source/kotti-toaster/KtToastProvider.vue
new file mode 100644
index 0000000000..48c02edb45
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/KtToastProvider.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/packages/kotti-ui/source/kotti-toaster/KtToaster.vue b/packages/kotti-ui/source/kotti-toaster/KtToaster.vue
index 8fb243dcaf..9ade381efa 100644
--- a/packages/kotti-ui/source/kotti-toaster/KtToaster.vue
+++ b/packages/kotti-ui/source/kotti-toaster/KtToaster.vue
@@ -1,167 +1,132 @@
-
-
-
+
+
+
+
+
diff --git a/packages/kotti-ui/source/kotti-toaster/context.ts b/packages/kotti-ui/source/kotti-toaster/context.ts
new file mode 100644
index 0000000000..5d1ec3a273
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/context.ts
@@ -0,0 +1,10 @@
+import type { ComputedRef } from 'vue'
+
+export type ToastContext = ComputedRef<{
+ delete: () => void
+ header: string | null
+ progress: number | null
+ text: string
+}>
+
+export const TOAST_CONTEXT = Symbol('TOAST_CONTEXT')
diff --git a/packages/kotti-ui/source/kotti-toaster/create-deferred.ts b/packages/kotti-ui/source/kotti-toaster/create-deferred.ts
new file mode 100644
index 0000000000..7a19b92169
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/create-deferred.ts
@@ -0,0 +1,30 @@
+/**
+ * Creates a deferred promise, useful in scenarios where a promise needs to be created and
+ * resolved or rejected from an external context. This exposes `resolve` and `reject` functions,
+ * allowing external control over the promise's resolution state.
+ *
+ * @throws {Error} Throws an error if the promise's `resolve` or `reject` functions could not be initialized (which shouldn't occur under typical JavaScript execution).
+ */
+export const createDeferred = (): {
+ promise: Promise
+ reject: (arg: unknown) => void
+ resolve: (res: PROMISE_RESOLUTION_TYPE) => void
+} => {
+ let resolve: ((res: PROMISE_RESOLUTION_TYPE) => void) | null = null
+ let reject: ((arg: unknown) => void) | null = null
+
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (resolve === null || reject === null)
+ throw new Error('could not create deferred promise')
+
+ return {
+ promise,
+ reject,
+ resolve,
+ }
+}
diff --git a/packages/kotti-ui/source/kotti-toaster/create-toaster.test.ts b/packages/kotti-ui/source/kotti-toaster/create-toaster.test.ts
new file mode 100644
index 0000000000..f46e707872
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/create-toaster.test.ts
@@ -0,0 +1,509 @@
+import { describe, expect, it, vitest } from 'vitest'
+
+import { createToaster } from './create-toaster'
+
+const DEBUG: boolean = false
+
+const createAnimationFrameMock = () => {
+ let interval: Timer | null = null
+ return {
+ getIsRunning: () => interval !== null,
+ start: (update: () => void) => {
+ if (interval)
+ throw new Error(
+ 'Could not start animation frame, already running. This is likely a bug.',
+ )
+
+ if (DEBUG) console.log('animation-frame-mock: start')
+ interval = globalThis.setInterval(() => {
+ if (DEBUG) console.log('animation-frame-mock: update')
+ update()
+ }, 5)
+ },
+ stop: () => {
+ if (DEBUG) console.log('animation-frame-mock: stop')
+ if (interval) {
+ globalThis.clearInterval(interval)
+ interval = null
+ }
+ },
+ }
+}
+
+describe('createToaster', () => {
+ it('returns things', () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+
+ expect(toaster).toEqual({
+ _internal_pls_dont_touch: expect.anything(),
+ abort: expect.anything(),
+ show: expect.anything(),
+ withOptions: expect.anything(),
+ })
+ })
+
+ describe('.abort()', () => {
+ it('can abort a toast', async () => {
+ const toaster = createToaster()
+
+ const toast = toaster.show({ duration: 1, text: 'test' })
+ expect(() => {
+ toaster.abort(toast.metadata.id)
+ }).not.toThrow()
+ expect(toast.metadata.abortController.signal.aborted).toBe(true)
+ await expect(toast.done).rejects.toThrow('INTERNAL_ABORT')
+ })
+
+ it('cannot abort an aborted toast', async () => {
+ const toaster = createToaster()
+
+ const toast = toaster.show({ duration: 1, text: 'test' })
+ expect(() => {
+ toaster.abort(toast.metadata.id)
+ }).not.toThrow()
+ expect(toast.metadata.abortController.signal.aborted).toBe(true)
+ await expect(toast.done).rejects.toThrow('INTERNAL_ABORT')
+ expect(() => {
+ toaster.abort(toast.metadata.id)
+ }).toThrow()
+ })
+
+ it('cannot abort an already toasted toast', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+
+ const toast = toaster.show({ duration: 1, text: 'test' })
+ await expect(toast.done).resolves.toBe('deleted')
+ expect(() => {
+ toaster.abort(toast.metadata.id)
+ }).toThrow()
+ })
+
+ it('throws for unknown toasts', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+ const toast = toaster.show({ duration: 1, text: 'test' })
+ expect(() => {
+ toaster.abort('not-a-real-toast')
+ }).toThrow(
+ 'could not find toast in fifoToasterQueue with id “not-a-real-toast”',
+ )
+ expect(toast.metadata.abortController.signal.aborted).toBe(false)
+ await expect(toast.done).resolves.toBe('deleted')
+ })
+ })
+
+ describe('.withOptions()', () => {
+ it('can create a customized show function', () => {
+ const toaster = createToaster()
+ const show = toaster.withOptions({
+ duration: 3000,
+ })
+ expect(show).toBeInstanceOf(Function)
+ })
+
+ it('can call customized show function', () => {
+ const toaster = createToaster()
+ const show = toaster.withOptions({
+ duration: 3000,
+ })
+ expect(() => show({ duration: 1, text: 'some text' })).not.toThrowError()
+ })
+ })
+
+ describe('.show()', () => {
+ it('can push', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+ const toast = toaster.show({ duration: 1, text: 'example toast' })
+
+ expect(toast.text).toBe('example toast')
+ await expect(toast.done).resolves.toBe('deleted')
+ })
+
+ it('correctly handles durations', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+
+ const wait = (ms: number) =>
+ new Promise((_resolve, reject) => {
+ globalThis.setTimeout(() => {
+ reject(new Error(`timeout after ${ms}ms`))
+ }, ms)
+ })
+
+ const fastToast = toaster.show({ duration: 1, text: 'fast toast' })
+ await expect(Promise.race([fastToast.done, wait(10)])).resolves.toBe(
+ 'deleted',
+ )
+
+ const slowToast = toaster.show({ duration: 100, text: 'slow toast' })
+ await expect(Promise.race([slowToast.done, wait(200)])).resolves.toBe(
+ 'deleted',
+ )
+
+ const superSlowToast = toaster.show({
+ duration: 500,
+ text: 'super slow toast',
+ })
+ await expect(
+ Promise.race([superSlowToast.done, wait(200)]),
+ ).rejects.toThrow('timeout')
+ })
+
+ it('can push custom data', async () => {
+ const custom = { testing: true }
+
+ const toaster = createToaster<{
+ default: Record
+ test: { testing: boolean }
+ }>({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+ const toast = toaster.show({
+ custom,
+ duration: 1,
+ text: 'example toast',
+ type: 'test',
+ })
+
+ expect(toast.custom).toEqual(custom)
+ await expect(toast.done).resolves.toBe('deleted')
+ })
+
+ it('can push with metadata.id', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+ const toast = toaster.show({
+ duration: 1,
+ metadata: { id: 'foo' },
+ text: 'example toast',
+ })
+
+ expect(toast.metadata.id).toEqual('foo')
+ await expect(toast.done).resolves.toBe('deleted')
+ })
+
+ it('can push with metadata.abortController', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ const abortController = new globalThis.AbortController()
+ const toast = toaster.show({
+ duration: 1,
+ metadata: { abortController },
+ text: 'example toast',
+ })
+
+ expect(toast.metadata.abortController).toBe(abortController)
+ expect(() => {
+ toast.abort()
+ }).not.toThrow()
+ expect(abortController.signal.aborted).toBe(true)
+ await expect(toast.done).rejects.toThrow('INTERNAL_ABORT')
+ })
+
+ describe('returns', () => {
+ it('.abort()', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+ const toast = toaster.show({ duration: 1, text: '42' })
+
+ expect(toast.metadata.abortController.signal.aborted).toBe(false)
+ toast.abort()
+ expect(toast.metadata.abortController.signal.aborted).toBe(true)
+ await expect(toast.done).rejects.toThrow('INTERNAL_ABORT')
+ })
+
+ it('supports await toast.done', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+ const toast = toaster.show({ duration: 10, text: '42' })
+
+ await expect(toast.done).resolves.toBe('deleted')
+ })
+ })
+ })
+
+ describe('._internal_pls_dont_touch', () => {
+ describe('.requestDelete()', () => {
+ it('deletes a toast', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ toaster._internal_pls_dont_touch.subscribe(() => {})
+
+ let testPromiseIsDone = false
+
+ const toast = toaster.show({ text: 'test' })
+
+ void toast.done.then(() => {
+ testPromiseIsDone = true
+ })
+
+ expect(testPromiseIsDone).toBe(false)
+ toaster._internal_pls_dont_touch.requestDelete(toast.metadata.id)
+ await new Promise((res) => globalThis.setTimeout(res, 1))
+ expect(testPromiseIsDone).toBe(true)
+ })
+ })
+
+ describe('.subscribe()', () => {
+ it('can subscribe', () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ const handler = vitest.fn()
+
+ toaster._internal_pls_dont_touch.subscribe(handler)
+
+ expect(handler.mock.calls).toEqual([[[]]])
+ toaster.show({ text: 'test' })
+ expect(handler.mock.calls).toEqual([[[]], [[expect.anything()]]])
+ })
+
+ it('correctly handles old toasts upon subscribing', () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ const handler = vitest.fn()
+
+ toaster.show({ text: 'test' })
+
+ expect(handler.mock.calls).toEqual([])
+ toaster._internal_pls_dont_touch.subscribe(handler)
+ expect(handler.mock.calls).toEqual([
+ [
+ [
+ {
+ custom: {},
+ duration: null,
+ header: null,
+ metadata: expect.anything(),
+ progress: null,
+ text: 'test',
+ type: 'default',
+ },
+ ],
+ ],
+ ])
+ })
+
+ it('prevents multiple simultaneous subscriptions', () => {
+ const toaster = createToaster()
+ const handler = () => {}
+
+ expect(() => {
+ toaster._internal_pls_dont_touch.subscribe(handler)
+ }).not.toThrow()
+
+ expect(() => {
+ toaster._internal_pls_dont_touch.subscribe(handler)
+ }).toThrow('toaster already has a subscriber')
+ })
+
+ it('receives messages in FIFO order', async () => {
+ const toaster = createToaster({
+ animationFrame: createAnimationFrameMock(),
+ })
+ const handler = vitest.fn()
+
+ const toast1 = toaster.show({ duration: 50, text: 'test' })
+ const toast2 = toaster.show({ duration: 50, text: 'test 2' })
+
+ expect(handler.mock.calls).toEqual([])
+ toaster._internal_pls_dont_touch.subscribe(handler)
+ expect(handler.mock.lastCall![0][0]).toMatchObject({ text: 'test' })
+ expect(handler.mock.lastCall![0][1]).toMatchObject({ text: 'test 2' })
+
+ await Promise.all([toast1.done, toast2.done])
+
+ expect(handler.mock.lastCall).toEqual([[]])
+
+ const toast3 = toaster.show({ duration: 50, text: 'test 3' })
+ expect(handler.mock.lastCall![0][0]).toMatchObject({ text: 'test 3' })
+
+ await toast3.done
+ expect(handler.mock.lastCall).toEqual([[]])
+ })
+ })
+
+ describe('.unsubscribe()', () => {
+ it('can unsubscribe', () => {
+ const toaster = createToaster()
+ const handler = vitest.fn()
+
+ toaster._internal_pls_dont_touch.subscribe(handler)
+ toaster._internal_pls_dont_touch.unsubscribe()
+
+ expect(handler.mock.calls).toEqual([[[]]])
+ toaster.show({ text: 'test' })
+ expect(handler.mock.calls).toEqual([[[]]])
+ })
+
+ it('throws when unsubscribe without a subscription', () => {
+ const toaster = createToaster()
+
+ expect(() => {
+ toaster._internal_pls_dont_touch.unsubscribe()
+ }).toThrow('toaster currently has no subscriber')
+ })
+ })
+ })
+})
+
+// type-level tests
+// HACK: These tests don’t actually need to be run, they work by letting tsc report any type errors
+const doNotRun = () => {
+ const toaster = createToaster<{
+ default: Record
+ error: { error: 'error' }
+ }>()
+
+ // @ts-expect-error wrong, can not pass arbitrary arguments
+ toaster.withOptions({
+ any: 'thing',
+ type: 'error',
+ })({
+ custom: { error: 'error' },
+ text: 'wow',
+ })
+
+ // @ts-expect-error wrong, text is not allowed
+ toaster.withOptions({ text: 'something' })
+
+ // @ts-expect-error wrong, custom is not supported in withOptions
+ toaster.withOptions({ custom: {}, type: 'default' })({
+ // @ts-expect-error wrong, overriding types is not supported in withOptions
+ custom: { error: 'error' },
+ text: 'error',
+ // @ts-expect-error wrong, overriding types is not supported in withOptions
+ type: 'error',
+ })
+
+ // @ts-expect-error wrong, custom should be empty
+ toaster.withOptions({ custom: {}, type: 'default' })({ text: 'wow' })
+
+ // @ts-expect-error wrong, custom should not be empty
+ toaster.withOptions({
+ custom: {},
+ type: 'error',
+ })
+
+ toaster.withOptions({ type: 'default' })({ text: 'wow' })
+ toaster.withOptions({ type: 'default' })({ custom: {}, text: 'wow' })
+
+ // @ts-expect-error wrong, can not override type
+ toaster.withOptions({ type: 'error' })({
+ custom: {
+ error: 'error',
+ },
+ text: 'wow',
+ type: 'default',
+ })
+
+ toaster.withOptions({ type: 'error' })({
+ // @ts-expect-error wrong, custom should not be empty
+ custom: {},
+ text: 'wow',
+ })
+
+ toaster.withOptions({ type: 'error' })({
+ custom: { error: 'error' },
+ text: 'wow',
+ })
+ toaster.withOptions({
+ duration: 5000,
+ type: 'error',
+ })({
+ custom: { error: 'error' },
+ duration: 6000,
+ text: 'wow',
+ })
+
+ // @ts-expect-error wrong, text was not provided
+ toaster.withOptions({ type: 'default' })({
+ duration: 4000,
+ })
+
+ // @ts-expect-error wrong, custom was not provided
+ toaster.withOptions({ type: 'error' })({
+ text: 'wow',
+ })
+
+ // @ts-expect-error wrong, text was not provided
+ toaster.withOptions({ type: 'error' })({
+ custom: { error: 'error' },
+ })
+
+ toaster.withOptions({ type: 'default' })({
+ // @ts-expect-error wrong, can not override type
+ custom: { error: 'error' },
+ text: 'wow',
+ // @ts-expect-error wrong, can not override type
+ type: 'error',
+ })
+
+ // @ts-expect-error wrong, custom should not be empty
+ toaster.withOptions({ duration: 5000 })({
+ custom: {}, // wrong
+ text: 'wow',
+ type: 'error',
+ })
+
+ toaster.withOptions({ duration: 5000 })({
+ // @ts-expect-error wrong, custom should be empty
+ custom: { error: 'error' },
+ text: 'wow',
+ type: 'default',
+ })
+
+ toaster.withOptions({ duration: 5000 })({
+ custom: { error: 'error' },
+ text: 'wow',
+ type: 'error',
+ })
+
+ // return types
+
+ const myToaster = createToaster<{
+ default: Record
+ error: { error: 'error' }
+ success: { success: 'success' }
+ }>()
+
+ const res1 = myToaster.show({
+ custom: {
+ error: 'error',
+ key: true, // error
+ },
+ text: 'wow',
+ type: 'error',
+ })
+
+ // @ts-expect-error expected type test failurue, should only allow toast1.custom.success
+ res1.custom.success
+ res1.custom.error
+
+ res1.metadata
+}
+
+// make linters happy
+if (Math.random() > 2) doNotRun()
diff --git a/packages/kotti-ui/source/kotti-toaster/create-toaster.ts b/packages/kotti-ui/source/kotti-toaster/create-toaster.ts
new file mode 100644
index 0000000000..ca09711842
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/create-toaster.ts
@@ -0,0 +1,504 @@
+import { nanoid } from 'nanoid'
+import { z } from 'zod'
+
+import { createDeferred } from './create-deferred'
+
+const customSchema = z.record(z.unknown())
+
+const durationSchema = z.number().int().finite().positive().nullable()
+
+const metadataSchema = z
+ .object({
+ abortController: z.instanceof(globalThis.AbortController),
+ id: z.string(),
+ })
+ .strict()
+
+const queuedToastSchema = z
+ .object({
+ custom: customSchema,
+ deferred: z
+ .object({
+ promise: z.promise(z.literal('deleted')),
+ reject: z.function().args(z.unknown()).returns(z.void()),
+ resolve: z.function().args(z.literal('deleted')).returns(z.void()),
+ })
+ .strict(),
+ duration: durationSchema,
+ header: z.string().nullable(),
+ metadata: metadataSchema,
+ text: z.string(),
+ type: z.string(),
+ })
+ .strict()
+
+export type QueuedToast = z.output
+
+const renderedMessageSchema = z
+ .object({
+ custom: customSchema,
+ duration: z.number().positive().finite().nullable(),
+ header: z.string().nullable(),
+ metadata: metadataSchema,
+ progress: z.number().min(0).max(1).nullable(),
+ text: z.string(),
+ type: z.string(),
+ })
+ .strict()
+
+export type RenderedMessage = z.output
+
+const messageSchema = z
+ .object({
+ custom: customSchema.default(() => ({})),
+ duration: durationSchema.default(null),
+ header: z.string().nullable().default(null),
+ metadata: z
+ .object({
+ abortController: z
+ .instanceof(globalThis.AbortController)
+ .default(() => new globalThis.AbortController()),
+ id: z.string().default(nanoid),
+ })
+ .strict()
+ .default(() => ({})),
+ text: z.string(),
+ type: z.string().default('default'),
+ })
+ .strict()
+
+const subscribeHandlerSchema = z
+ .function()
+ .args(z.array(renderedMessageSchema))
+ .returns(z.union([z.promise(z.void()), z.void()]))
+
+type SubscribeHandler = z.output
+
+// utilties
+
+type IsEmptyObject = T extends Record ? true : false
+
+// messages
+
+type MessageTypes = {
+ [key: string]: Record
+ default: Record
+}
+
+type Messages = {
+ [TYPE in keyof MESSAGE_TYPES]: Omit<
+ z.input,
+ 'custom' | 'type'
+ > &
+ (IsEmptyObject extends true
+ ? { custom?: MESSAGE_TYPES[TYPE] }
+ : { custom: MESSAGE_TYPES[TYPE] }) &
+ (TYPE extends 'default' ? { type?: 'default' } : { type: TYPE })
+}
+
+type MessagesNoDefault = {
+ [KEY in keyof MESSAGE_TYPES]: {
+ duration?: number | null
+ header?: string | null
+ text: string
+ type: KEY
+ } & (IsEmptyObject extends true
+ ? { custom?: MESSAGE_TYPES[KEY] }
+ : { custom: MESSAGE_TYPES[KEY] })
+}
+
+// show etc.
+
+type ShowResult<
+ MESSAGE_TYPES extends MessageTypes,
+ TYPE extends keyof MESSAGE_TYPES,
+> = {
+ abort: () => void
+ custom: MESSAGE_TYPES[TYPE]
+ done: Promise<'deleted'>
+ header: string | null
+ metadata: z.output
+ text: string
+}
+
+type Show = <
+ MESSAGE extends Messages[keyof MESSAGE_TYPES & string],
+>(
+ message: Exclude<
+ keyof MESSAGE,
+ keyof Messages[keyof MESSAGE_TYPES & string]
+ > extends never
+ ? MESSAGE
+ : never,
+) => ShowResult<
+ MESSAGE_TYPES,
+ MESSAGE extends { type: infer TYPE } ? TYPE : 'default'
+>
+
+type WithInferredOptions<
+ MESSAGE_TYPES extends MessageTypes,
+ OPTIONS extends {
+ duration?: number | null
+ type?: keyof MESSAGE_TYPES
+ },
+> = OPTIONS extends {
+ duration?: number | null
+ type: infer TYPE extends keyof MESSAGE_TYPES
+}
+ ?
+ | MessagesNoDefault[TYPE]
+ | Omit[TYPE], 'type'>
+ : Messages[keyof MESSAGE_TYPES]
+
+type WithOptions = <
+ BASE_OPTIONS extends {
+ duration?: number | null
+ type?: keyof MESSAGE_TYPES
+ },
+>(
+ baseOptions: Exclude extends never
+ ? BASE_OPTIONS
+ : `Argument "${Exclude}" is not supported`,
+) => >(
+ options: Exclude<
+ keyof OPTIONS,
+ keyof WithInferredOptions
+ > extends never
+ ? OPTIONS
+ : `Argument "${Exclude}" is not supported`,
+) => ShowResult<
+ MESSAGE_TYPES,
+ BASE_OPTIONS & OPTIONS extends {
+ type: infer TYPE extends keyof MESSAGE_TYPES
+ }
+ ? TYPE
+ : 'default'
+>
+
+export type ToasterReturn = {
+ abort: (toastId: string) => void
+ show: Show
+ withOptions: WithOptions
+
+ // internal
+ _internal_pls_dont_touch: {
+ requestDelete: (toastId: string) => void
+ subscribe: (handler: z.output) => void
+ unsubscribe: () => void
+ }
+}
+
+const createToasterOptions = z
+ .object({
+ animationFrame: z
+ .object({
+ getIsRunning: z.function().args().returns(z.boolean()),
+ start: z
+ .function()
+ .args(z.function().args().returns(z.void()))
+ .returns(z.void()),
+ stop: z.function().args().returns(z.void()),
+ })
+ .strict()
+ .default(() => {
+ let animationFrameId: number | null = null
+ return {
+ getIsRunning: () => animationFrameId !== null,
+ start: (update) => {
+ const animate = () => {
+ // Leave console log for now, so issues with the toaster have a chance to be detected
+ // eslint-disable-next-line no-console
+ console.log('create-toaster: update')
+ animationFrameId = globalThis.requestAnimationFrame(animate)
+ update()
+ }
+ animationFrameId = globalThis.requestAnimationFrame(animate)
+ },
+ stop: () => {
+ if (animationFrameId) {
+ globalThis.cancelAnimationFrame(animationFrameId)
+ animationFrameId = null
+ }
+ },
+ }
+ }),
+ // eslint-disable-next-line no-magic-numbers
+ numberOfToasts: z.number().int().positive().finite().default(3),
+ })
+ .strict()
+ .default(() => ({}))
+
+type CreateToasterOptions = z.input
+
+type ActiveToast = {
+ beginTime: number
+ endTime: number | null
+ message: QueuedToast
+ progress: number | null
+}
+
+const INTERNAL_ABORT = 'INTERNAL_ABORT'
+
+const calculateProgress = (start: number, now: number, end: number): number => {
+ const unclamped = (now - start) / (end - start)
+ return Math.max(Math.min(unclamped, 1), 0)
+}
+
+// --------------------------------------------------------------- //
+// --------------------------------------------------------------- //
+// -- Begin of implementation ------------------------------------ //
+// --------------------------------------------------------------- //
+// --------------------------------------------------------------- //
+
+export const createToaster = <
+ MESSAGE_TYPES extends MessageTypes = { default: Record },
+>(
+ _options: CreateToasterOptions = {},
+): ToasterReturn => {
+ const options = createToasterOptions.parse(_options)
+
+ const fifoToasterQueue: Array = []
+ const activeToasts: Array = []
+
+ let subscriber: SubscribeHandler | null = null
+ const notifySubscriber = () => {
+ if (subscriber === null) return
+
+ void subscriber(
+ activeToasts.map((toast) => ({
+ custom: toast.message.custom,
+ duration: toast.message.duration,
+ header: toast.message.header,
+ metadata: toast.message.metadata,
+ progress: toast.progress,
+ text: toast.message.text,
+ type: toast.message.type,
+ })),
+ )
+ }
+
+ const deleteToastFromActiveToasts = (toastId: string) => {
+ const index = activeToasts.findIndex(
+ (toast) => toast.message.metadata.id === toastId,
+ )
+ if (index === -1)
+ throw new Error(
+ `could not find toast in activeToasts with id “${toastId}”`,
+ )
+
+ const removedToast = activeToasts.splice(index, 1)[0]
+
+ if (!removedToast)
+ throw new Error(
+ `could not find toast in activeToasts with id “${toastId}”`,
+ )
+
+ notifySubscriber()
+ return removedToast.message
+ }
+
+ const deleteToastFromFifoQueue = (toastId: string) => {
+ const index = fifoToasterQueue.findIndex(
+ (toast) => toast.metadata.id === toastId,
+ )
+ if (index === -1)
+ throw new Error(
+ `could not find toast in fifoToasterQueue with id “${toastId}”`,
+ )
+
+ const removedToast = fifoToasterQueue.splice(index, 1)[0]
+
+ if (!removedToast)
+ throw new Error(
+ `could not find toast in fifoToasterQueue with id “${toastId}”`,
+ )
+
+ return removedToast
+ }
+
+ const deleteAndAbortToast = (mode: 'abort' | 'delete', toastId: string) => {
+ const removedToast = activeToasts.some(
+ (toast) => toast.message.metadata.id === toastId,
+ )
+ ? deleteToastFromActiveToasts(toastId)
+ : deleteToastFromFifoQueue(toastId)
+
+ switch (mode) {
+ case 'abort': {
+ const { abortController } = removedToast.metadata
+
+ if (!abortController.signal.aborted) {
+ abortController.abort(INTERNAL_ABORT)
+ }
+
+ removedToast.deferred.reject(
+ abortController.signal.aborted
+ ? abortController.signal.reason
+ : 'aborted',
+ )
+ break
+ }
+ case 'delete': {
+ removedToast.deferred.resolve('deleted')
+ break
+ }
+ }
+ }
+
+ /**
+ * Updates the list of active toasts, managing their display duration and progress.
+ *
+ * - If a toast has an `endTime`, it calculates its progress based on the current time.
+ * - If a toast's progress reaches 100%, it is deleted and resolved.
+ * - If there is room for more toasts, it moves items from the `fifoToasterQueue` to `activeToasts`.
+ * - Manages the animation frame, starting or stopping it based on whether there are active toasts.
+ * - Notifies the subscriber if the state of `activeToasts` changes.
+ */
+ const updateActiveToasts = (_dirty = false) => {
+ if (subscriber === null) return
+ let dirty = _dirty
+
+ let index = 0
+ while (index < activeToasts.length) {
+ const toast = activeToasts[index] as ActiveToast
+
+ if (toast.endTime === null) {
+ index++
+ continue
+ }
+
+ toast.progress = calculateProgress(
+ toast.beginTime,
+ Date.now(),
+ toast.endTime,
+ )
+ dirty = true
+
+ if (toast.progress >= 1) {
+ deleteAndAbortToast('delete', toast.message.metadata.id)
+ continue
+ }
+ index++
+ }
+
+ while (activeToasts.length < options.numberOfToasts) {
+ const message = fifoToasterQueue.shift() ?? null
+ if (message === null) break
+
+ activeToasts.push({
+ beginTime: Date.now(),
+ endTime: message.duration ? Date.now() + message.duration : null,
+ message,
+ progress: message.duration ? 0 : null,
+ })
+ dirty = true
+ }
+
+ const isRunning = options.animationFrame.getIsRunning()
+ const isNowEmpty = activeToasts.length === 0
+
+ if (!isRunning && !isNowEmpty) {
+ options.animationFrame.start(() => {
+ updateActiveToasts()
+ })
+ } else if (isRunning && isNowEmpty) {
+ options.animationFrame.stop()
+ }
+
+ if (dirty) notifySubscriber()
+ }
+
+ const show: Show = <
+ MESSAGE,
+ TYPE extends keyof MESSAGE_TYPES = MESSAGE extends {
+ type: infer TYPE extends keyof MESSAGE_TYPES
+ }
+ ? TYPE
+ : 'default',
+ >(
+ message: MESSAGE,
+ ) => {
+ const options = messageSchema.parse(message)
+
+ const doneDeferred = createDeferred<'deleted'>()
+ const { signal } = options.metadata.abortController
+
+ signal.addEventListener('abort', () => {
+ if (signal.reason !== INTERNAL_ABORT)
+ deleteAndAbortToast('abort', options.metadata.id)
+ })
+
+ fifoToasterQueue.push({
+ custom: options.custom,
+ deferred: doneDeferred,
+ duration: options.duration,
+ header: options.header,
+ metadata: options.metadata,
+ text: options.text,
+ type: options.type,
+ })
+
+ updateActiveToasts()
+
+ return {
+ abort: () => {
+ deleteAndAbortToast('abort', options.metadata.id)
+ },
+ custom: options.custom as MESSAGE_TYPES[TYPE],
+ done: doneDeferred.promise,
+ header: options.header,
+ metadata: options.metadata,
+ text: options.text,
+ type: options.type as TYPE,
+ }
+ }
+
+ return {
+ abort: (toastId: string) => {
+ deleteAndAbortToast('abort', toastId)
+ },
+ show,
+ withOptions: (baseOptions) => (options) =>
+ show({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...(baseOptions as any),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...(options as any),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any,
+ /**
+ * The methods in here expose the toasts from `activeToasts` to a single subscriber.
+ * Toasts from `fifoToasterQueue` are kept private.
+ *
+ * The subscriber:
+ * - gets updated whenever `activeToasts` gets mutated
+ * - can delete a specific toast by id
+ */
+ _internal_pls_dont_touch: {
+ requestDelete: (deleteId) => {
+ deleteAndAbortToast('delete', deleteId)
+ updateActiveToasts(true)
+ },
+ subscribe: (handler) => {
+ if (subscriber)
+ throw new Error(
+ 'create-toaster: toaster already has a subscriber, aborting',
+ )
+
+ subscriber = handler
+
+ updateActiveToasts(true)
+ },
+ unsubscribe: () => {
+ if (!subscriber)
+ throw new Error(
+ 'create-toaster: toaster currently has no subscriber, aborting',
+ )
+
+ options.animationFrame.stop()
+
+ subscriber = null
+ },
+ },
+ }
+}
diff --git a/packages/kotti-ui/source/kotti-toaster/index.ts b/packages/kotti-ui/source/kotti-toaster/index.ts
index 55c8ddd7ab..f5c91bf0ad 100644
--- a/packages/kotti-ui/source/kotti-toaster/index.ts
+++ b/packages/kotti-ui/source/kotti-toaster/index.ts
@@ -1,15 +1,68 @@
import { MetaDesignType } from '../types/kotti'
import { attachMeta, makeInstallable } from '../utilities'
+import KtToastVue from './KtToast.vue'
import KtToasterVue from './KtToaster.vue'
+import { KottiToast, KottiToaster } from './types'
+
+export { createToaster } from './create-toaster'
export const KtToaster = attachMeta(makeInstallable(KtToasterVue), {
- addedVersion: '1.0.0',
+ addedVersion: '8.0.0',
deprecated: null,
designs: {
type: MetaDesignType.FIGMA,
- url: 'https://www.figma.com/file/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=128%3A2082',
+ url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6671-10835',
+ },
+ slots: {
+ default: {
+ description:
+ 'Slots for all message types exist, with default being the fallback',
+ scope: {
+ custom: { description: 'Custom data object', type: 'object' },
+ delete: { description: 'Deletes the toast', type: 'function' },
+ duration: {
+ description:
+ 'Total toasting duration in ms, null for persistent toasts',
+ type: 'integer',
+ },
+ header: { description: 'Optional header text', type: 'string' },
+ progress: { description: 'Lifecycle progress (0–1)', type: 'float' },
+ text: { description: 'Main text content', type: 'string' },
+ type: { description: 'Toast type', type: 'string' },
+ },
+ },
+ },
+ typeScript: {
+ namespace: 'KottiToaster',
+ schema: KottiToaster.propsSchema,
+ },
+})
+
+export const KtToast = attachMeta(makeInstallable(KtToastVue), {
+ addedVersion: '8.0.0',
+ deprecated: null,
+ designs: {
+ type: MetaDesignType.FIGMA,
+ url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6671-10835',
+ },
+ slots: {
+ actions: {
+ description:
+ 'Used to put e.g. buttons or other interactive elements at the bottom of the toast',
+ scope: null,
+ },
+ header: {
+ description: 'Used to replace the optional header text',
+ scope: null,
+ },
+ text: {
+ description: 'Used to replace the main text',
+ scope: null,
+ },
+ },
+ typeScript: {
+ namespace: 'KottiToast',
+ schema: KottiToast.propsSchema,
},
- slots: {},
- typeScript: null,
})
diff --git a/packages/kotti-ui/source/kotti-toaster/types.ts b/packages/kotti-ui/source/kotti-toaster/types.ts
new file mode 100644
index 0000000000..abd5578139
--- /dev/null
+++ b/packages/kotti-ui/source/kotti-toaster/types.ts
@@ -0,0 +1,34 @@
+import { z } from 'zod'
+
+import { KottiBanner } from '../kotti-banner/types'
+
+export module KottiToaster {
+ export const propsSchema = z.object({
+ toaster: z.object({
+ _internal_pls_dont_touch: z.object({}).passthrough(),
+ abort: z.function(),
+ show: z.function(),
+ withOptions: z.function(),
+ }),
+ })
+
+ export type Props = z.input
+ export type PropsInternal = z.output
+}
+
+export module KottiToast {
+ export const propsSchema = z.object({
+ header: z.string().nullable().default(null),
+ progress: z.number().int().finite().positive().nullable().default(null),
+ text: z.string().nullable().default(null),
+ type: z
+ .union([
+ KottiBanner.styleSchema,
+ z.enum(['error', 'info', 'success', 'warning']),
+ ])
+ .default('info'),
+ })
+
+ export type Props = z.input
+ export type PropsInternal = z.output
+}
diff --git a/packages/kotti-ui/source/kotti-toaster/utilities.js b/packages/kotti-ui/source/kotti-toaster/utilities.js
deleted file mode 100644
index 3dec4101e5..0000000000
--- a/packages/kotti-ui/source/kotti-toaster/utilities.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/* eslint-disable no-magic-numbers */
-/**
- * @description generates a random id
- * @param {Number} ID_BITS id entropy in bits, defaults to 64 (4 words)
- * @returns {String} random id
- */
-export const generateId = (ID_BITS = 64) => {
- const randomWord = () =>
- Math.floor((1 + Math.random()) * 0x10000)
- .toString(16)
- .substring(1)
-
- const result = []
-
- for (let i = 0; i < ID_BITS; i += 16) result.push(randomWord())
-
- return result.join('')
-}
-
-/**
- * @description curried function that filters all ids that don't match a given id
- * @param {String} $0.id first id
- * @returns {Function} compares id1 to passed id
- */
-export const notId =
- ({ id: id1 }) =>
- ({ id: id2 }) =>
- id1 !== id2
diff --git a/packages/kotti-ui/source/locales/input.json b/packages/kotti-ui/source/locales/input.json
index 07acefe9b4..b4b77870d3 100644
--- a/packages/kotti-ui/source/locales/input.json
+++ b/packages/kotti-ui/source/locales/input.json
@@ -9,10 +9,6 @@
"lessThanOrEqual": "is less than or equal to",
"restrictedAccess": "Restricted access"
},
- "ktBanner": {
- "expandCloseLabel": "Close",
- "expandLabel": "View"
- },
"ktComment": {
"cancelMessage": "Press Esc key or",
"clickToCancelLabel": "click to cancel",
diff --git a/packages/kotti-ui/source/types/kotti.ts b/packages/kotti-ui/source/types/kotti.ts
index 4c5a3d3f68..e4b6734984 100644
--- a/packages/kotti-ui/source/types/kotti.ts
+++ b/packages/kotti-ui/source/types/kotti.ts
@@ -67,6 +67,10 @@ export { KottiPopover as Popover } from '../kotti-popover/types'
export { KottiRow as Row } from '../kotti-row/types'
export { KottiTableLegacy as TableLegacy } from '../kotti-table-legacy/types'
export { KottiTag as Tag } from '../kotti-tag/types'
+export {
+ KottiToast as Toast,
+ KottiToaster as Toaster,
+} from '../kotti-toaster/types'
export { KottiUserMenu as UserMenu } from '../kotti-user-menu/types'
export { KottiValueLabel as ValueLabel } from '../kotti-value-label/types'
export * from './decimal-separator'
@@ -103,7 +107,7 @@ export type Meta = {
string,
{
description: string | null
- type: 'function' | 'integer' | 'object'
+ type: 'float' | 'function' | 'integer' | 'object' | 'string'
}
> | null
}
diff --git a/packages/kotti-ui/source/utilities.ts b/packages/kotti-ui/source/utilities.ts
index c0a8185ca8..1450ed8d59 100644
--- a/packages/kotti-ui/source/utilities.ts
+++ b/packages/kotti-ui/source/utilities.ts
@@ -15,10 +15,6 @@ export const attachMeta = (
): C & { meta: Kotti.Meta & T } =>
Object.assign(component, { meta: Object.assign({}, meta, other) })
-export const isBrowser = Boolean(
- typeof window !== 'undefined' && window.document,
-)
-
/**
* Checks if the given HTML element, or any of its children, is in focus
* @param element The HTML element
diff --git a/packages/kotti-ui/tokens/colors.js b/packages/kotti-ui/tokens/colors.js
index 3cbada57fc..182805f58e 100644
--- a/packages/kotti-ui/tokens/colors.js
+++ b/packages/kotti-ui/tokens/colors.js
@@ -4,9 +4,19 @@
// The tokens are defined with these base colors
export const baseColors = {
- white: '#FFF',
black: '#000',
+ 'blue-10': '#EAF0FA',
+ 'blue-100': '#0D244A',
+ 'blue-20': '#C1D7FB',
+ 'blue-30': '#AFC5E8',
+ 'blue-40': '#6795E0',
+ 'blue-50': '#3173DE',
+ 'blue-60': '#2C66C4',
+ 'blue-70': '#2659AB',
+ 'blue-80': '#1F55AD',
+ 'blue-90': '#153C7A',
'gray-10': '#F8F8F8',
+ 'gray-100': '#141414',
'gray-20': '#E0E0E0',
'gray-30': '#C6C6C6',
'gray-40': '#A8A8A8',
@@ -15,8 +25,18 @@ export const baseColors = {
'gray-70': '#525252',
'gray-80': '#393939',
'gray-90': '#262626',
- 'gray-100': '#141414',
+ 'green-10': '#E6F8D2',
+ 'green-20': '#C4E0A5',
+ 'green-50': '#71C716',
+ 'green-60': '#64AD13',
+ 'green-70': '#549410',
+ 'orange-10': '#FDE2CB',
+ 'orange-20': '#FAB980',
+ 'orange-50': '#FF9333',
+ 'orange-60': '#FF7800',
+ 'orange-70': '#BA6820',
'primary-10': '#EAF0FA',
+ 'primary-100': '#0D244A',
'primary-20': '#C1D7FB',
'primary-30': '#AFC5E8',
'primary-40': '#6795E0',
@@ -25,27 +45,20 @@ export const baseColors = {
'primary-70': '#2659AB',
'primary-80': '#1F55AD',
'primary-90': '#153C7A',
- 'primary-100': '#0D244A',
- 'green-20': '#C4E0A5',
- 'green-50': '#71C716',
- 'green-60': '#64AD13',
- 'green-70': '#549410',
- 'red-20': '#F0A8A8',
- 'red-50': '#F21D1D',
- 'red-60': '#D91919',
- 'red-70': '#BF1717',
'purple-20': '#B995CA',
'purple-50': '#932DC2',
'purple-60': '#6C218F',
'purple-70': '#591B75',
+ 'red-10': '#FBE1E1',
+ 'red-20': '#F0A8A8',
+ 'red-50': '#F21D1D',
+ 'red-60': '#D91919',
+ 'red-70': '#BF1717',
+ white: '#FFF',
'yellow-20': '#FFF9C0',
'yellow-50': '#FFF490',
'yellow-60': '#FFE60D',
'yellow-70': '#DFC903',
- 'orange-20': '#FAB980',
- 'orange-50': '#FF9333',
- 'orange-60': '#FF7800',
- 'orange-70': '#BA6820',
}
// Tokens are calling base colors
@@ -196,6 +209,11 @@ export const tokens = [
description: 'Error',
reference: 'red-50',
},
+ {
+ name: 'support-error-bg',
+ description: 'Error Background',
+ reference: 'red-10',
+ },
{
name: 'support-error-dark',
description: 'Error dark',
@@ -211,6 +229,11 @@ export const tokens = [
description: 'Warning',
reference: 'orange-50',
},
+ {
+ name: 'support-warning-bg',
+ description: 'Warning Background',
+ reference: 'orange-10',
+ },
{
name: 'support-warning-dark',
description: 'Warning dark',
@@ -226,6 +249,11 @@ export const tokens = [
description: 'Success',
reference: 'green-50',
},
+ {
+ name: 'support-success-bg',
+ description: 'Success Background',
+ reference: 'green-10',
+ },
{
name: 'support-success-dark',
description: 'Success dark',
@@ -239,16 +267,21 @@ export const tokens = [
{
name: 'support-info',
description: 'Information',
- reference: 'primary-50',
+ reference: 'blue-50',
+ },
+ {
+ name: 'support-info-bg',
+ description: 'Information Background',
+ reference: 'blue-10',
},
{
name: 'support-info-dark',
description: 'Information dark',
- reference: 'primary-70',
+ reference: 'blue-70',
},
{
name: 'support-info-light',
description: 'Information light',
- reference: 'primary-20',
+ reference: 'blue-20',
},
]
diff --git a/packages/kotti-ui/tokens/generate.js b/packages/kotti-ui/tokens/generate.js
index 0e6658ee55..c2c4151e43 100644
--- a/packages/kotti-ui/tokens/generate.js
+++ b/packages/kotti-ui/tokens/generate.js
@@ -11,9 +11,9 @@ const output = `
Run \`yarn workspace @3yourmind/kotti-ui run build:tokens\` to regenerate it
*/
-:root{
- ${arrayToCustomProperties(objectToArray(baseColors), 'color')}
- ${arrayToCustomProperties(tokens)}
+:root {
+${arrayToCustomProperties(objectToArray(baseColors), 'color')}
+${arrayToCustomProperties(tokens)}
}`
// Write it
diff --git a/packages/kotti-ui/tokens/utilities.js b/packages/kotti-ui/tokens/utilities.js
index 53cf01ca63..0d6ce91fb4 100644
--- a/packages/kotti-ui/tokens/utilities.js
+++ b/packages/kotti-ui/tokens/utilities.js
@@ -2,7 +2,7 @@ export const arrayToCustomProperties = (colors, type = 'reference') =>
colors
.map(
(color) =>
- `--${color.name}: ${
+ `\t--${color.name}: ${
type === 'reference' ? `var(--${color.reference})` : color.value
};`,
)
diff --git a/packages/vue-project/src/App.vue b/packages/vue-project/src/App.vue
deleted file mode 100644
index f1656570e9..0000000000
--- a/packages/vue-project/src/App.vue
+++ /dev/null
@@ -1,218 +0,0 @@
-
-
-
-
-
- (isNarrow = val)"
- >
-
-
-
-
-
-
- alerty('hello')" />
-
-
-
- Let me help you
-
-
-
-
- Let me help you
-
-
-
-
- Let me help you
-
-
-
-
-
locale
-
-
-
diff --git a/yarn.lock b/yarn.lock
index 77677aed5b..01aeab5f80 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10237,16 +10237,16 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554"
integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==
+nanoid@3.x, nanoid@^3.1.23, nanoid@^3.3.7:
+ version "3.3.7"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
+ integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
+
nanoid@^2.1.0:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
-nanoid@^3.1.23, nanoid@^3.3.7:
- version "3.3.7"
- resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
- integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
-
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -14654,6 +14654,11 @@ ts-api-utils@^1.3.0:
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
+ts-dedent@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
+ integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
+
ts-loader@8.x, ts-loader@^8.0.17:
version "8.4.0"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-8.4.0.tgz#e845ea0f38d140bdc3d7d60293ca18d12ff2720f"