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 @@
Click to Copy
-
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 @@ + + + + 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 @@ + + + + + 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 @@ - - - - - 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"