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/package.json b/package.json
index 4c455bed6b..567a11c5df 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,7 @@
"typescript-eslint": "^7.8.0"
},
"engines": {
- "node": ">=20"
+ "node": "20.x"
},
"husky": {
"hooks": {
diff --git a/packages/documentation/components/CodePreviewNew.vue b/packages/documentation/components/CodePreviewNew.vue
new file mode 100644
index 0000000000..3a50aa6e19
--- /dev/null
+++ b/packages/documentation/components/CodePreviewNew.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
diff --git a/packages/documentation/components/ColorPalette.vue b/packages/documentation/components/ColorPalette.vue
index 1505f9faad..23a18d1d08 100644
--- a/packages/documentation/components/ColorPalette.vue
+++ b/packages/documentation/components/ColorPalette.vue
@@ -11,17 +11,15 @@
-
- Copy successful
-
diff --git a/packages/documentation/components/YocoPreview.vue b/packages/documentation/components/YocoPreview.vue
index 44211b3c38..1f5fa59951 100644
--- a/packages/documentation/components/YocoPreview.vue
+++ b/packages/documentation/components/YocoPreview.vue
@@ -18,22 +18,18 @@
-
diff --git a/packages/documentation/layouts/fullpage.vue b/packages/documentation/layouts/fullpage.vue
index 2f2d83405a..fe83529008 100644
--- a/packages/documentation/layouts/fullpage.vue
+++ b/packages/documentation/layouts/fullpage.vue
@@ -5,6 +5,7 @@
+
@@ -12,6 +13,7 @@
+
+
+
+
+
+
+
+
+
+
+ {{ textWithFallback }}
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+
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..2c0f91de4b
--- /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 8fb243dcaf..c1e1866643 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..d93282a7ee
--- /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 0000000000..51697a90ee
--- /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 55c8ddd7ab..b068f8d354 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 0000000000..d56ed85e22
--- /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 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/types/kotti.ts b/packages/kotti-ui/source/types/kotti.ts
index 049f2af80a..8cc7a0efc7 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 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..7998adbb2d 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 0e6658ee55..c2c4151e43 100644
--- a/packages/kotti-ui/tokens/generate.js
+++ b/packages/kotti-ui/tokens/generate.js
@@ -11,9 +11,9 @@ const output = `
Run \`yarn workspace @3yourmind/kotti-ui run build:tokens\` to regenerate it
*/
-:root{
- ${arrayToCustomProperties(objectToArray(baseColors), 'color')}
- ${arrayToCustomProperties(tokens)}
+:root {
+${arrayToCustomProperties(objectToArray(baseColors), 'color')}
+${arrayToCustomProperties(tokens)}
}`
// Write it
diff --git a/packages/kotti-ui/tokens/utilities.js b/packages/kotti-ui/tokens/utilities.js
index 53cf01ca63..0d6ce91fb4 100644
--- a/packages/kotti-ui/tokens/utilities.js
+++ b/packages/kotti-ui/tokens/utilities.js
@@ -2,7 +2,7 @@ export const arrayToCustomProperties = (colors, type = 'reference') =>
colors
.map(
(color) =>
- `--${color.name}: ${
+ `\t--${color.name}: ${
type === 'reference' ? `var(--${color.reference})` : color.value
};`,
)
diff --git a/packages/vue-project/src/App.vue b/packages/vue-project/src/App.vue
deleted file mode 100644
index f1656570e9..0000000000
--- a/packages/vue-project/src/App.vue
+++ /dev/null
@@ -1,218 +0,0 @@
-
-
-
-
-
- (isNarrow = val)"
- >
-
-
-
-
-
-
- alerty('hello')" />
-
-
-
- Let me help you
-
-
-
-
- Let me help you
-
-
-
-
- Let me help you
-
-
-
-
-
locale
-
-
-
diff --git a/yarn.lock b/yarn.lock
index 77677aed5b..01aeab5f80 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10237,16 +10237,16 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554"
integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==
+nanoid@3.x, nanoid@^3.1.23, nanoid@^3.3.7:
+ version "3.3.7"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
+ integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
+
nanoid@^2.1.0:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
-nanoid@^3.1.23, nanoid@^3.3.7:
- version "3.3.7"
- resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
- integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
-
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -14654,6 +14654,11 @@ ts-api-utils@^1.3.0:
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
+ts-dedent@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
+ integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
+
ts-loader@8.x, ts-loader@^8.0.17:
version "8.4.0"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-8.4.0.tgz#e845ea0f38d140bdc3d7d60293ca18d12ff2720f"