From c3c7a7a4fe06a98b2e967eadbe6bd634f115b948 Mon Sep 17 00:00:00 2001 From: Moritz Vetter <16950410+Isokaeder@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:31:17 +0100 Subject: [PATCH 1/6] documentation(CodePreviewNew): backport CodePreview from 'vue3/upgrade' Co-Authored-By: Florian Wendelborn <1133858+FlorianWendelborn@users.noreply.github.com> --- .../components/CodePreviewNew.vue | 167 ++++++++++++++++++ packages/documentation/package.json | 1 + yarn.lock | 5 + 3 files changed, 173 insertions(+) create mode 100644 packages/documentation/components/CodePreviewNew.vue diff --git a/packages/documentation/components/CodePreviewNew.vue b/packages/documentation/components/CodePreviewNew.vue new file mode 100644 index 000000000..3a50aa6e1 --- /dev/null +++ b/packages/documentation/components/CodePreviewNew.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/packages/documentation/package.json b/packages/documentation/package.json index 546b8a9a8..2186cbea6 100644 --- a/packages/documentation/package.json +++ b/packages/documentation/package.json @@ -10,6 +10,7 @@ "lodash": "4.x", "marked": "^12.0.0", "natural-sort": "^1.0.0", + "ts-dedent": "^2.2.0", "vue-router": "3.x" }, "description": "Kotti-ui Doc Pages", diff --git a/yarn.lock b/yarn.lock index 77677aed5..3faefce67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From 3287548c076805866063447f0f5df31336496817 Mon Sep 17 00:00:00 2001 From: Florian Wendelborn <1133858+FlorianWendelborn@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:57:00 +0100 Subject: [PATCH 2/6] chore(vue3): Remove vue-project Co-Authored-By: Moritz Vetter <16950410+Isokaeder@users.noreply.github.com> --- packages/vue-project/src/App.vue | 218 ------------------------------- 1 file changed, 218 deletions(-) delete mode 100644 packages/vue-project/src/App.vue diff --git a/packages/vue-project/src/App.vue b/packages/vue-project/src/App.vue deleted file mode 100644 index f1656570e..000000000 --- a/packages/vue-project/src/App.vue +++ /dev/null @@ -1,218 +0,0 @@ - - - - - From 82589d2e2bf7c6c8938165665121660b61fa9e48 Mon Sep 17 00:00:00 2001 From: Florian Wendelborn <1133858+FlorianWendelborn@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:57:27 +0100 Subject: [PATCH 3/6] dx(editorconfig): Remove Max Line Length Was likely never actually used Co-Authored-By: Moritz Vetter <16950410+Isokaeder@users.noreply.github.com> --- .editorconfig | 1 - 1 file changed, 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 81274ea2f..8b09ff3c1 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] From be5db404aa6d6e35aef9c98ae654b54c94f2915a Mon Sep 17 00:00:00 2001 From: Florian Wendelborn <1133858+FlorianWendelborn@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:59:30 +0100 Subject: [PATCH 4/6] dx(eslint): Allow non-null-assertion, no-unnecessary-condition in Tests, Allow globalThis Co-Authored-By: Moritz Vetter <16950410+Isokaeder@users.noreply.github.com> --- packages/eslint-config/source/index.ts | 2 ++ packages/eslint-config/source/utils/no-restricted-globals.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/eslint-config/source/index.ts b/packages/eslint-config/source/index.ts index 356ec344d..ca8433269 100644 --- a/packages/eslint-config/source/index.ts +++ b/packages/eslint-config/source/index.ts @@ -482,6 +482,8 @@ export default { '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', diff --git a/packages/eslint-config/source/utils/no-restricted-globals.ts b/packages/eslint-config/source/utils/no-restricted-globals.ts index 97e54bb02..21fd96a30 100644 --- a/packages/eslint-config/source/utils/no-restricted-globals.ts +++ b/packages/eslint-config/source/utils/no-restricted-globals.ts @@ -27,6 +27,7 @@ const GLOBALS_WHITELIST = new Set([ 'FileReader', // complicated due to typescript types 'FormData', 'Function', + 'globalThis', 'Intl', 'JSON', 'Map', From e00b01a23495630237ef1861812125418598faa2 Mon Sep 17 00:00:00 2001 From: Florian Wendelborn <1133858+FlorianWendelborn@users.noreply.github.com> Date: Wed, 6 Nov 2024 22:11:28 +0100 Subject: [PATCH 5/6] =?UTF-8?q?dx(node):=20Force=20Version=20to=2020.x=20a?= =?UTF-8?q?s=20Eslint=20Doesn=E2=80=99t=20Work=20in=2022.x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Moritz Vetter <16950410+Isokaeder@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c455bed6..567a11c5d 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "typescript-eslint": "^7.8.0" }, "engines": { - "node": ">=20" + "node": "20.x" }, "husky": { "hooks": { From e9e7c2cc119e0d530a0ef2373eba191519eede02 Mon Sep 17 00:00:00 2001 From: Florian Wendelborn <1133858+FlorianWendelborn@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:10:56 +0100 Subject: [PATCH 6/6] feature(KtToaster): Re-implement KtToaster with Support for TypeScript, and A New Design, and API Co-Authored-By: Moritz Vetter <16950410+Isokaeder@users.noreply.github.com> --- eslint.config.mjs | 1 + internals/fake-root/package.json | 2 +- .../documentation/components/ColorPalette.vue | 23 +- .../documentation/components/MyToaster.vue | 37 ++ .../documentation/components/YocoPreview.vue | 25 +- packages/documentation/layouts/default.vue | 3 + packages/documentation/layouts/empty.vue | 6 + packages/documentation/layouts/fullpage.vue | 3 + .../documentation/pages/examples/layouts.vue | 4 +- .../pages/usage/components/button.vue | 66 ++- .../usage/components/field-inline-edit.vue | 4 +- .../pages/usage/components/form-fields.vue | 16 +- .../pages/usage/components/form.vue | 4 +- .../pages/usage/components/heading.vue | 4 +- .../pages/usage/components/line.vue | 10 +- .../pages/usage/components/popover.vue | 4 +- .../pages/usage/components/table-legacy.vue | 11 +- .../pages/usage/components/toaster.vue | 443 ++++++++++++++-- packages/documentation/styles/main.scss | 10 - packages/documentation/utilities/pages.ts | 8 +- packages/documentation/utilities/toaster.ts | 53 ++ packages/kotti-ui/package.json | 2 +- packages/kotti-ui/source/index.ts | 22 +- .../__snapshots__/ktAccordion.test.ts.snap | 4 +- .../kotti-accordion/ktAccordion.test.ts | 6 +- .../kotti-ui/source/kotti-style/tokens.css | 23 +- .../kotti-ui/source/kotti-toaster/KtToast.vue | 184 +++++++ .../source/kotti-toaster/KtToastProvider.vue | 26 + .../source/kotti-toaster/KtToaster.vue | 247 ++++----- .../kotti-ui/source/kotti-toaster/context.ts | 10 + .../source/kotti-toaster/create-deferred.ts | 30 ++ .../kotti-toaster/create-toaster.test.ts | 460 ++++++++++++++++ .../source/kotti-toaster/create-toaster.ts | 495 ++++++++++++++++++ .../kotti-ui/source/kotti-toaster/index.ts | 81 ++- .../kotti-ui/source/kotti-toaster/types.ts | 39 ++ .../source/kotti-toaster/utilities.js | 28 - packages/kotti-ui/source/types/kotti.ts | 3 +- packages/kotti-ui/source/utilities.ts | 4 - packages/kotti-ui/tokens/colors.js | 39 +- packages/kotti-ui/tokens/generate.js | 6 +- packages/kotti-ui/tokens/utilities.js | 2 +- yarn.lock | 10 +- 42 files changed, 2086 insertions(+), 372 deletions(-) create mode 100644 packages/documentation/components/MyToaster.vue create mode 100644 packages/documentation/utilities/toaster.ts create mode 100644 packages/kotti-ui/source/kotti-toaster/KtToast.vue create mode 100644 packages/kotti-ui/source/kotti-toaster/KtToastProvider.vue create mode 100644 packages/kotti-ui/source/kotti-toaster/context.ts create mode 100644 packages/kotti-ui/source/kotti-toaster/create-deferred.ts create mode 100644 packages/kotti-ui/source/kotti-toaster/create-toaster.test.ts create mode 100644 packages/kotti-ui/source/kotti-toaster/create-toaster.ts create mode 100644 packages/kotti-ui/source/kotti-toaster/types.ts delete mode 100644 packages/kotti-ui/source/kotti-toaster/utilities.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 3dc58c8f9..3361c50f1 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 995d6f93a..35539ab84 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/components/ColorPalette.vue b/packages/documentation/components/ColorPalette.vue index 1505f9faa..23a18d1d0 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 44211b3c3..1f5fa5995 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 2f2d83405..fe8352900 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-toaster/KtToastProvider.vue b/packages/kotti-ui/source/kotti-toaster/KtToastProvider.vue new file mode 100644 index 000000000..2c0f91de4 --- /dev/null +++ b/packages/kotti-ui/source/kotti-toaster/KtToastProvider.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/kotti-ui/source/kotti-toaster/KtToaster.vue b/packages/kotti-ui/source/kotti-toaster/KtToaster.vue index 8fb243dca..c1e186664 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 000000000..5d1ec3a27 --- /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 000000000..7a19b9216 --- /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 000000000..d93282a7e --- /dev/null +++ b/packages/kotti-ui/source/kotti-toaster/create-toaster.test.ts @@ -0,0 +1,460 @@ +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('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('.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 000000000..51697a90e --- /dev/null +++ b/packages/kotti-ui/source/kotti-toaster/create-toaster.ts @@ -0,0 +1,495 @@ +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 = () => { + // 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) +} + +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((x) => ({ + custom: x.message.custom, + duration: x.message.duration, + header: x.message.header, + metadata: x.message.metadata, + progress: x.progress, + text: x.message.text, + type: x.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', + ) + + subscriber = null + }, + }, + } +} diff --git a/packages/kotti-ui/source/kotti-toaster/index.ts b/packages/kotti-ui/source/kotti-toaster/index.ts index 55c8ddd7a..b068f8d35 100644 --- a/packages/kotti-ui/source/kotti-toaster/index.ts +++ b/packages/kotti-ui/source/kotti-toaster/index.ts @@ -1,15 +1,84 @@ 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', + designs: [ + { + title: 'Production', + type: MetaDesignType.FIGMA, + url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6671-10835', + }, + { + title: 'Draft', + type: MetaDesignType.FIGMA, + url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6861-3816', + }, + ], + 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: [ + { + title: 'Production', + type: MetaDesignType.FIGMA, + url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6671-10835', + }, + { + title: 'Draft', + type: MetaDesignType.FIGMA, + url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6861-3816', + }, + ], + 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 000000000..d56ed85e2 --- /dev/null +++ b/packages/kotti-ui/source/kotti-toaster/types.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +import { yocoIconSchema } from '@3yourmind/yoco' + +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 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({ + header: z.string().nullable().default(null), + progress: z.number().int().finite().positive().nullable().default(null), + text: z.string().nullable().default(null), + type: z + .union([styleSchema, z.enum(['error', 'info', 'success', 'warning'])]) + .default('info'), + }) +} 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 3dec4101e..000000000 --- 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/types/kotti.ts b/packages/kotti-ui/source/types/kotti.ts index 049f2af80..8cc7a0efc 100644 --- a/packages/kotti-ui/source/types/kotti.ts +++ b/packages/kotti-ui/source/types/kotti.ts @@ -67,6 +67,7 @@ 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 { 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 +104,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 c0a8185ca..1450ed8d5 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 3cbada57f..7998adbb2 100644 --- a/packages/kotti-ui/tokens/colors.js +++ b/packages/kotti-ui/tokens/colors.js @@ -6,6 +6,16 @@ export const baseColors = { white: '#FFF', black: '#000', + 'blue-10': '#EAF0FA', + 'blue-20': '#C1D7FB', + 'blue-30': '#AFC5E8', + 'blue-40': '#6795E0', + 'blue-50': '#3173DE', + 'blue-60': '#2C66C4', + 'blue-70': '#2659AB', + 'blue-80': '#1F55AD', + 'blue-90': '#153C7A', + 'blue-100': '#0D244A', 'gray-10': '#F8F8F8', 'gray-20': '#E0E0E0', 'gray-30': '#C6C6C6', @@ -26,10 +36,12 @@ export const baseColors = { 'primary-80': '#1F55AD', 'primary-90': '#153C7A', 'primary-100': '#0D244A', + 'green-10': '#E6F8D2', 'green-20': '#C4E0A5', 'green-50': '#71C716', 'green-60': '#64AD13', 'green-70': '#549410', + 'red-10': '#FBE1E1', 'red-20': '#F0A8A8', 'red-50': '#F21D1D', 'red-60': '#D91919', @@ -42,6 +54,7 @@ export const baseColors = { 'yellow-50': '#FFF490', 'yellow-60': '#FFE60D', 'yellow-70': '#DFC903', + 'orange-10': '#FDE2CB', 'orange-20': '#FAB980', 'orange-50': '#FF9333', 'orange-60': '#FF7800', @@ -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 0e6658ee5..c2c4151e4 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 53cf01ca6..0d6ce91fb 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/yarn.lock b/yarn.lock index 3faefce67..01aeab5f8 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"