sv5ui 1.8.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/Accordion/Accordion.svelte +11 -0
  2. package/dist/Alert/alert.types.d.ts +1 -1
  3. package/dist/AvatarGroup/AvatarGroup.svelte +5 -3
  4. package/dist/Badge/badge.types.d.ts +1 -1
  5. package/dist/Button/Button.svelte +7 -6
  6. package/dist/Button/button.types.d.ts +3 -3
  7. package/dist/Calendar/Calendar.svelte +14 -1
  8. package/dist/Collapsible/collapsible.types.d.ts +4 -2
  9. package/dist/Command/command.types.d.ts +4 -2
  10. package/dist/Command/index.d.ts +1 -1
  11. package/dist/ContextMenu/ContextMenu.svelte +1 -1
  12. package/dist/Drawer/Drawer.svelte +7 -3
  13. package/dist/Drawer/DrawerTriggerTestWrapper.svelte +10 -0
  14. package/dist/Drawer/DrawerTriggerTestWrapper.svelte.d.ts +18 -0
  15. package/dist/Drawer/drawer.types.d.ts +13 -2
  16. package/dist/DropdownMenu/DropdownMenu.svelte +1 -3
  17. package/dist/DropdownMenu/DropdownMenuTriggerTestWrapper.svelte +12 -0
  18. package/dist/DropdownMenu/DropdownMenuTriggerTestWrapper.svelte.d.ts +7 -0
  19. package/dist/DropdownMenu/dropdown-menu.types.d.ts +17 -9
  20. package/dist/Editor/Editor.svelte +85 -61
  21. package/dist/Editor/SlashPopup.svelte +8 -1
  22. package/dist/Editor/SlashPopup.svelte.d.ts +2 -0
  23. package/dist/Editor/editor.extensions.d.ts +1 -1
  24. package/dist/Editor/editor.extensions.js +25 -16
  25. package/dist/Editor/editor.schemas.d.ts +1 -0
  26. package/dist/Editor/editor.schemas.js +24 -0
  27. package/dist/Editor/editor.slash.svelte.d.ts +0 -9
  28. package/dist/Editor/editor.slash.svelte.js +33 -7
  29. package/dist/Editor/editor.suggestion.js +23 -0
  30. package/dist/Editor/editor.toolbar.js +0 -8
  31. package/dist/Editor/editor.types.d.ts +20 -0
  32. package/dist/Editor/editor.variants.d.ts +0 -5
  33. package/dist/Editor/editor.variants.js +0 -15
  34. package/dist/Editor/index.d.ts +6 -4
  35. package/dist/Editor/index.js +6 -4
  36. package/dist/FileUpload/FileUpload.svelte +7 -0
  37. package/dist/Icon/icon.types.d.ts +4 -1
  38. package/dist/Input/Input.svelte +22 -16
  39. package/dist/Input/index.d.ts +1 -1
  40. package/dist/Input/input.variants.d.ts +0 -15
  41. package/dist/Input/input.variants.js +1 -20
  42. package/dist/Link/Link.svelte +4 -3
  43. package/dist/Link/link.types.d.ts +2 -2
  44. package/dist/Modal/Modal.svelte +4 -2
  45. package/dist/Modal/ModalTriggerTestWrapper.svelte +10 -0
  46. package/dist/Modal/ModalTriggerTestWrapper.svelte.d.ts +18 -0
  47. package/dist/Modal/modal.types.d.ts +13 -3
  48. package/dist/Pagination/Pagination.svelte +7 -1
  49. package/dist/Pagination/pagination.types.d.ts +4 -1
  50. package/dist/Pagination/pagination.variants.d.ts +0 -72
  51. package/dist/Pagination/pagination.variants.js +6 -30
  52. package/dist/Popover/Popover.svelte +1 -1
  53. package/dist/Popover/popover.types.d.ts +2 -0
  54. package/dist/Progress/Progress.svelte +14 -6
  55. package/dist/RadioGroup/RadioGroup.svelte +3 -1
  56. package/dist/Select/Select.svelte +3 -1
  57. package/dist/Select/select.types.d.ts +5 -9
  58. package/dist/SelectMenu/SelectMenu.svelte +27 -10
  59. package/dist/SelectMenu/SelectMenuFormFieldTestWrapper.svelte +11 -0
  60. package/dist/SelectMenu/SelectMenuFormFieldTestWrapper.svelte.d.ts +7 -0
  61. package/dist/SelectMenu/select-menu.types.d.ts +5 -2
  62. package/dist/SelectMenu/select-menu.variants.d.ts +12 -2
  63. package/dist/SelectMenu/select-menu.variants.js +10 -1
  64. package/dist/Separator/Separator.svelte +9 -2
  65. package/dist/Separator/separator.types.d.ts +6 -1
  66. package/dist/Separator/separator.variants.d.ts +25 -0
  67. package/dist/Separator/separator.variants.js +7 -1
  68. package/dist/Skeleton/Skeleton.svelte +3 -5
  69. package/dist/Slideover/Slideover.svelte +4 -2
  70. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte +10 -0
  71. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte.d.ts +18 -0
  72. package/dist/Slideover/slideover.types.d.ts +13 -3
  73. package/dist/Stepper/Stepper.svelte +1 -3
  74. package/dist/Switch/Switch.svelte +12 -17
  75. package/dist/Table/table.utils.d.ts +7 -4
  76. package/dist/Table/table.utils.js +26 -25
  77. package/dist/Tabs/Tabs.svelte +4 -2
  78. package/dist/Tabs/tabs.types.d.ts +4 -6
  79. package/dist/ThemeModeButton/ThemeModeButton.svelte +4 -3
  80. package/dist/Tooltip/Tooltip.svelte +1 -1
  81. package/dist/Tooltip/tooltip.types.d.ts +2 -0
  82. package/dist/hooks/HookContextProbe.svelte +7 -0
  83. package/dist/hooks/HookContextProbe.svelte.d.ts +18 -0
  84. package/dist/hooks/HookContextProvider.svelte +9 -0
  85. package/dist/hooks/HookContextProvider.svelte.d.ts +18 -0
  86. package/dist/hooks/HookEmitProbe.svelte +14 -0
  87. package/dist/hooks/HookEmitProbe.svelte.d.ts +18 -0
  88. package/dist/hooks/index.d.ts +1 -1
  89. package/dist/hooks/index.js +1 -1
  90. package/dist/hooks/useFormField.svelte.d.ts +0 -31
  91. package/dist/hooks/useFormField.svelte.js +0 -21
  92. package/package.json +1 -1
@@ -53,7 +53,18 @@
53
53
 
54
54
  type SlotName = (typeof slotNames)[number]
55
55
 
56
+ const itemDefaults = $derived.by(() => {
57
+ const result = {} as Record<SlotName, string>
58
+ for (const slot of slotNames) {
59
+ result[slot] = variantSlots[slot]({ class: [config.slots[slot], ui?.[slot]] })
60
+ }
61
+ return result
62
+ })
63
+
56
64
  function getItemClasses(item: AccordionItem) {
65
+ if (!item.ui && item.class === undefined && item.disabled === undefined) {
66
+ return itemDefaults
67
+ }
57
68
  const result = {} as Record<SlotName, string>
58
69
  for (const slot of slotNames) {
59
70
  const baseClass = [
@@ -43,7 +43,7 @@ export type AlertProps = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'title'>
43
43
  color?: NonNullable<AlertVariantProps['color']>;
44
44
  /**
45
45
  * The visual style variant.
46
- * @default 'soft'
46
+ * @default 'solid'
47
47
  */
48
48
  variant?: NonNullable<AlertVariantProps['variant']>;
49
49
  /**
@@ -46,9 +46,11 @@
46
46
  }
47
47
  })
48
48
 
49
- const visibleAvatars = $derived(
50
- !avatars ? [] : max && max > 0 ? avatars.slice(0, max) : avatars
51
- )
49
+ const visibleAvatars = $derived.by(() => {
50
+ if (!avatars) return []
51
+ const shown = max && max > 0 ? avatars.slice(0, max) : avatars
52
+ return [...shown].reverse()
53
+ })
52
54
 
53
55
  const overflowCount = $derived(
54
56
  avatars && max && max > 0 ? Math.max(0, avatars.length - max) : 0
@@ -17,7 +17,7 @@ export type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, 'class'> & {
17
17
  * Override styles for specific badge slots.
18
18
  * Available slots: base, label, leadingIcon, trailingIcon, leadingAvatar.
19
19
  */
20
- ui?: Partial<Record<BadgeSlots, ClassNameValue>>;
20
+ ui?: Partial<Record<Exclude<BadgeSlots, 'leadingAvatarSize'>, ClassNameValue>>;
21
21
  /**
22
22
  * Sets the text content displayed inside the badge.
23
23
  */
@@ -76,13 +76,14 @@
76
76
  const isLeading = $derived((!!icon && !trailing) || (isLoading && !trailing) || !!leadingIcon)
77
77
  const isTrailing = $derived((!!icon && trailing) || (isLoading && trailing) || !!trailingIcon)
78
78
 
79
+ const spinLeading = $derived(isLoading && !trailing)
80
+ const spinTrailing = $derived(isLoading && trailing)
81
+
79
82
  const leadingIconName = $derived(
80
- isLoading && isLeading
81
- ? loadingIcon
82
- : leadingIcon || (isLeading && !trailing ? icon : undefined)
83
+ spinLeading ? loadingIcon : leadingIcon || (!trailing ? icon : undefined)
83
84
  )
84
85
  const trailingIconName = $derived(
85
- isLoading && isTrailing ? loadingIcon : trailingIcon || (trailing ? icon : undefined)
86
+ spinTrailing ? loadingIcon : trailingIcon || (trailing ? icon : undefined)
86
87
  )
87
88
 
88
89
  const resolvedColor = $derived(active && activeColor ? activeColor : color)
@@ -96,8 +97,8 @@
96
97
  block,
97
98
  square: isIconOnly,
98
99
  loading: isLoading,
99
- leading: isLeading,
100
- trailing: isTrailing
100
+ leading: spinLeading,
101
+ trailing: spinTrailing
101
102
  })
102
103
  return {
103
104
  base: slots.base({ class: [config.slots.base, fieldGroupClass, className, ui?.base] }),
@@ -1,9 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { HTMLAttributes, HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
3
3
  import type { ClassNameValue } from 'tailwind-merge';
4
4
  import type { ButtonVariantProps, ButtonSlots } from './button.variants.js';
5
5
  import type { AvatarProps } from '../Avatar/avatar.types.js';
6
- export type ButtonProps = Omit<HTMLAttributes<HTMLElement>, 'class' | 'color'> & {
6
+ export type ButtonProps = Omit<HTMLAttributes<HTMLElement>, 'class' | 'color'> & Pick<HTMLButtonAttributes, 'name' | 'value' | 'form' | 'formaction' | 'formenctype' | 'formmethod' | 'formnovalidate' | 'formtarget' | 'popovertarget' | 'popovertargetaction'> & Pick<HTMLAnchorAttributes, 'download' | 'hreflang' | 'ping' | 'media' | 'referrerpolicy'> & {
7
7
  /**
8
8
  * Bindable reference to the root DOM element.
9
9
  */
@@ -147,5 +147,5 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLElement>, 'class' | 'color'> &
147
147
  /**
148
148
  * Override styles for specific button slots.
149
149
  */
150
- ui?: Partial<Record<ButtonSlots, ClassNameValue>>;
150
+ ui?: Partial<Record<Exclude<ButtonSlots, 'leadingAvatarSize'>, ClassNameValue>>;
151
151
  };
@@ -14,7 +14,7 @@
14
14
  import { calendarVariants, calendarDefaults } from './calendar.variants.js'
15
15
  import { getComponentConfig } from '../config.js'
16
16
  import Icon from '../Icon/Icon.svelte'
17
- import type { DateValue } from '@internationalized/date'
17
+ import { type DateValue, today, getLocalTimeZone } from '@internationalized/date'
18
18
  import type { Month } from 'bits-ui'
19
19
  import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
20
20
 
@@ -63,6 +63,19 @@
63
63
  ...restProps
64
64
  }: Props = $props()
65
65
 
66
+ function firstDateOf(val: unknown): DateValue | undefined {
67
+ if (!val) return undefined
68
+ if (Array.isArray(val)) return val[0] as DateValue | undefined
69
+ if (typeof val === 'object' && 'start' in val) {
70
+ return (val as { start?: DateValue }).start
71
+ }
72
+ return val as DateValue
73
+ }
74
+
75
+ if (placeholder === undefined) {
76
+ placeholder = firstDateOf(value) ?? today(getLocalTimeZone())
77
+ }
78
+
66
79
  const formFieldContext = useFormField()
67
80
  const emit = useFormFieldEmit()
68
81
 
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ClassNameValue } from 'tailwind-merge';
3
- import type { CollapsibleRootPropsWithoutHTML } from 'bits-ui';
3
+ import type { CollapsibleRootProps, CollapsibleRootPropsWithoutHTML } from 'bits-ui';
4
4
  import type { CollapsibleSlots } from './collapsible.variants.js';
5
5
  /**
6
6
  * Props for the Collapsible component.
@@ -21,7 +21,9 @@ import type { CollapsibleSlots } from './collapsible.variants.js';
21
21
  *
22
22
  * @see https://bits-ui.com/docs/components/collapsible
23
23
  */
24
- export interface CollapsibleProps extends Pick<CollapsibleRootPropsWithoutHTML, 'open' | 'onOpenChange' | 'onOpenChangeComplete' | 'disabled'> {
24
+ export interface CollapsibleProps extends Pick<CollapsibleRootPropsWithoutHTML, 'open' | 'onOpenChange' | 'onOpenChangeComplete' | 'disabled'>, Pick<CollapsibleRootProps, 'id' | 'style' | 'title' | 'role' | 'tabindex' | 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'onclick' | 'onkeydown' | 'onmouseenter' | 'onmouseleave' | 'onfocus' | 'onblur'> {
25
+ /** Custom data attributes are forwarded to the root element. */
26
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
25
27
  /**
26
28
  * Bindable reference to the root DOM element.
27
29
  */
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ClassNameValue } from 'tailwind-merge';
3
- import type { CommandRootPropsWithoutHTML } from 'bits-ui';
3
+ import type { CommandRootProps, CommandRootPropsWithoutHTML } from 'bits-ui';
4
4
  import type { CommandSlots, CommandVariantProps } from './command.variants.js';
5
5
  /**
6
6
  * Configuration for an individual command item.
@@ -57,7 +57,9 @@ export interface CommandItemSlotProps {
57
57
  *
58
58
  * @see https://bits-ui.com/docs/components/command
59
59
  */
60
- export interface CommandProps extends Pick<CommandRootPropsWithoutHTML, 'value' | 'onValueChange' | 'filter' | 'shouldFilter' | 'loop' | 'vimBindings' | 'label'> {
60
+ export interface CommandProps extends Pick<CommandRootPropsWithoutHTML, 'value' | 'onValueChange' | 'filter' | 'shouldFilter' | 'loop' | 'vimBindings' | 'label'>, Pick<CommandRootProps, 'id'> {
61
+ /** Custom data attributes are forwarded to the root element. */
62
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
61
63
  /** Bindable reference to the root DOM element. */
62
64
  ref?: HTMLElement | null;
63
65
  /** Array of grouped command items. */
@@ -1,2 +1,2 @@
1
1
  export { default as Command } from './Command.svelte';
2
- export type { CommandProps } from './command.types.js';
2
+ export type { CommandProps, CommandGroup, CommandItem } from './command.types.js';
@@ -329,7 +329,7 @@
329
329
 
330
330
  <ContextMenu.Root bind:open {onOpenChange}>
331
331
  {#if children}
332
- <ContextMenu.Trigger class={className as string}>
332
+ <ContextMenu.Trigger class={[className]}>
333
333
  {@render children({ open })}
334
334
  </ContextMenu.Trigger>
335
335
  {/if}
@@ -44,7 +44,8 @@
44
44
  titleSlot,
45
45
  descriptionSlot,
46
46
  body: bodySlot,
47
- footer: footerSlot
47
+ footer: footerSlot,
48
+ ...rest
48
49
  }: Props = $props()
49
50
 
50
51
  const hasTitle = $derived(!!title || !!titleSlot)
@@ -81,6 +82,7 @@
81
82
 
82
83
  const rootProps = $derived.by(() => {
83
84
  const base = {
85
+ ...rest,
84
86
  open,
85
87
  onOpenChange: handleOpenChange,
86
88
  direction,
@@ -175,8 +177,10 @@
175
177
 
176
178
  {#snippet drawerBody()}
177
179
  {#if children}
178
- <Drawer.Trigger class={className as string}>
179
- {@render children()}
180
+ <Drawer.Trigger>
181
+ {#snippet child({ props })}
182
+ {@render children({ props })}
183
+ {/snippet}
180
184
  </Drawer.Trigger>
181
185
  {/if}
182
186
 
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import Drawer from './Drawer.svelte'
3
+ </script>
4
+
5
+ <Drawer>
6
+ {#snippet children({ props })}
7
+ <button data-testid="trigger" {...props}>Open</button>
8
+ {/snippet}
9
+ {#snippet content()}<p>Body</p>{/snippet}
10
+ </Drawer>
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const DrawerTriggerTestWrapper: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type DrawerTriggerTestWrapper = InstanceType<typeof DrawerTriggerTestWrapper>;
18
+ export default DrawerTriggerTestWrapper;
@@ -45,9 +45,20 @@ export type DrawerProps = VaulRootProps & {
45
45
  */
46
46
  class?: ClassNameValue;
47
47
  /**
48
- * Default slot renders the trigger element.
48
+ * Trigger content. Spread the provided `props` onto your own focusable
49
+ * element (e.g. a `<Button>`) so the drawer's trigger ARIA and event
50
+ * handlers land on the real control instead of a nested wrapper button.
51
+ *
52
+ * @example
53
+ * ```svelte
54
+ * {#snippet children({ props })}
55
+ * <Button {...props}>Open</Button>
56
+ * {/snippet}
57
+ * ```
49
58
  */
50
- children?: Snippet;
59
+ children?: Snippet<[{
60
+ props: Record<string, unknown>;
61
+ }]>;
51
62
  /**
52
63
  * Custom content slot (replaces default layout with header/body/footer).
53
64
  */
@@ -348,9 +348,7 @@
348
348
  {#if children}
349
349
  <DropdownMenu.Trigger>
350
350
  {#snippet child({ props })}
351
- <span {...props} class={className as string}>
352
- {@render children({ open })}
353
- </span>
351
+ {@render children({ open, props })}
354
352
  {/snippet}
355
353
  </DropdownMenu.Trigger>
356
354
  {/if}
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import DropdownMenu from './DropdownMenu.svelte'
3
+ import type { DropdownMenuItem } from './dropdown-menu.types.js'
4
+
5
+ let { items = [] }: { items?: DropdownMenuItem[] } = $props()
6
+ </script>
7
+
8
+ <DropdownMenu {items}>
9
+ {#snippet children({ open, props })}
10
+ <button data-testid="trigger" data-open={open} {...props}>Open</button>
11
+ {/snippet}
12
+ </DropdownMenu>
@@ -0,0 +1,7 @@
1
+ import type { DropdownMenuItem } from './dropdown-menu.types.js';
2
+ type $$ComponentProps = {
3
+ items?: DropdownMenuItem[];
4
+ };
5
+ declare const DropdownMenuTriggerTestWrapper: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type DropdownMenuTriggerTestWrapper = ReturnType<typeof DropdownMenuTriggerTestWrapper>;
7
+ export default DropdownMenuTriggerTestWrapper;
@@ -129,10 +129,6 @@ export type DropdownMenuItem = DropdownMenuItemAction | DropdownMenuItemCheckbox
129
129
  * Configuration for a radio group within the dropdown menu.
130
130
  */
131
131
  export interface DropdownMenuRadioGroup {
132
- /**
133
- * Unique identifier for the radio group.
134
- */
135
- name: string;
136
132
  /**
137
133
  * Currently selected value in the group.
138
134
  */
@@ -178,8 +174,9 @@ export interface DropdownMenuProps extends RootProps, ContentProps {
178
174
  */
179
175
  items?: DropdownMenuItem[];
180
176
  /**
181
- * Radio groups configuration for managing radio item selections.
182
- * @example [{ name: 'theme', value: 'dark', onValueChange: (v) => theme = v }]
177
+ * Radio group configuration for managing radio item selections.
178
+ * Only the first entry is used — all `type: 'radio'` items belong to it.
179
+ * @example [{ value: 'dark', onValueChange: (v) => theme = v }]
183
180
  */
184
181
  radioGroups?: DropdownMenuRadioGroup[];
185
182
  /**
@@ -214,7 +211,9 @@ export interface DropdownMenuProps extends RootProps, ContentProps {
214
211
  */
215
212
  size?: NonNullable<DropdownMenuVariantProps['size']>;
216
213
  /**
217
- * Additional CSS class for the trigger wrapper.
214
+ * Additional CSS class for the dropdown content.
215
+ * Applied only when no trigger `children` are provided (style your own
216
+ * trigger element directly otherwise).
218
217
  */
219
218
  class?: ClassNameValue;
220
219
  /**
@@ -222,11 +221,20 @@ export interface DropdownMenuProps extends RootProps, ContentProps {
222
221
  */
223
222
  ui?: Partial<Record<DropdownMenuSlots, ClassNameValue>>;
224
223
  /**
225
- * Default slot content used as the trigger element.
226
- * When provided, clicking this element opens the dropdown.
224
+ * Trigger content. Spread the provided `props` onto your own focusable
225
+ * element (e.g. a `<Button>`) so the trigger's ARIA and event handlers land
226
+ * on the real control.
227
+ *
228
+ * @example
229
+ * ```svelte
230
+ * {#snippet children({ open, props })}
231
+ * <Button {...props}>{open ? 'Close' : 'Open'}</Button>
232
+ * {/snippet}
233
+ * ```
227
234
  */
228
235
  children?: Snippet<[{
229
236
  open: boolean;
237
+ props: Record<string, unknown>;
230
238
  }]>;
231
239
  /**
232
240
  * Custom content to render at the top of the dropdown menu.
@@ -5,7 +5,7 @@
5
5
  </script>
6
6
 
7
7
  <script lang="ts">
8
- import { Editor } from '@tiptap/core'
8
+ import { Editor, type AnyExtension } from '@tiptap/core'
9
9
  import BubbleMenuExt from '@tiptap/extension-bubble-menu'
10
10
  import { untrack } from 'svelte'
11
11
  import { editorVariants, editorDefaults } from './editor.variants.js'
@@ -24,7 +24,12 @@
24
24
  import Icon from '../Icon/Icon.svelte'
25
25
  import Tooltip from '../Tooltip/Tooltip.svelte'
26
26
  import EditorUrlPrompt from './EditorUrlPrompt.svelte'
27
- import { httpUrlSchema, youtubeUrlSchema, type UrlSchema } from './editor.schemas.js'
27
+ import {
28
+ httpUrlSchema,
29
+ youtubeUrlSchema,
30
+ isSafeImageSrc,
31
+ type UrlSchema
32
+ } from './editor.schemas.js'
28
33
 
29
34
  const config = getComponentConfig('editor', editorDefaults)
30
35
 
@@ -53,6 +58,7 @@
53
58
  markdownAllowHtml = false,
54
59
  image = false,
55
60
  onImageUpload,
61
+ onImageUploadError,
56
62
  tables = false,
57
63
  onMention,
58
64
  mentionTrigger = '@',
@@ -77,6 +83,14 @@
77
83
  const formFieldContext = useFormField()
78
84
  const emit = useFormFieldEmit()
79
85
 
86
+ const resolvedOutput = untrack(() => output)
87
+
88
+ function getMarkdownStorage(ed: Editor): { getMarkdown?: () => string } | undefined {
89
+ return (ed.storage as unknown as Record<string, unknown>).markdown as
90
+ | { getMarkdown?: () => string }
91
+ | undefined
92
+ }
93
+
80
94
  const hasError = $derived(
81
95
  formFieldContext?.error !== undefined && formFieldContext?.error !== false
82
96
  )
@@ -143,11 +157,9 @@
143
157
  }
144
158
 
145
159
  function serialize(ed: Editor): string | EditorJSON {
146
- if (output === 'json') return ed.getJSON() as EditorJSON
147
- if (output === 'markdown') {
148
- const md = (ed.storage as unknown as Record<string, unknown>).markdown as
149
- | { getMarkdown?: () => string }
150
- | undefined
160
+ if (resolvedOutput === 'json') return ed.getJSON() as EditorJSON
161
+ if (resolvedOutput === 'markdown') {
162
+ const md = getMarkdownStorage(ed)
151
163
  if (md && typeof md.getMarkdown === 'function') {
152
164
  return md.getMarkdown()
153
165
  }
@@ -191,7 +203,7 @@
191
203
  })
192
204
  }
193
205
 
194
- function resolveExtensions() {
206
+ function resolveExtensions(): AnyExtension[] | Promise<AnyExtension[]> {
195
207
  if (extensionsOverride) return extensionsOverride
196
208
  return buildExtensions({
197
209
  headingLevels,
@@ -203,7 +215,7 @@
203
215
  tables,
204
216
  youtube,
205
217
  dragHandle,
206
- markdown: output === 'markdown',
218
+ markdown: resolvedOutput === 'markdown',
207
219
  markdownAllowHtml,
208
220
  mentionTrigger,
209
221
  mentionSuggestion: onMention
@@ -228,14 +240,11 @@
228
240
  }
229
241
 
230
242
  let suppressUpdate = false
243
+ let lastEmitted: string | EditorJSON | undefined
231
244
 
232
245
  $effect(() => {
233
246
  if (!contentElement) return
234
247
 
235
- // Untrack: these props would otherwise cause editor recreation on every
236
- // keystroke (value changes via onUpdate → effect re-runs → destroy + rebuild
237
- // → cursor lost → can only type 1 char). value sync is handled by a
238
- // dedicated effect below; disabled/readonly toggle via setEditable.
239
248
  const initialContent = untrack(() => value ?? '')
240
249
  const initialEditable = untrack(() => !disabled && !readonly)
241
250
  const initialAutofocus = untrack(() => autofocus)
@@ -245,43 +254,57 @@
245
254
  ...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}),
246
255
  ...(hasError ? { 'aria-invalid': 'true' } : {})
247
256
  }))
248
- const exts = untrack(() => resolveExtensions())
249
-
250
- const ed = new Editor({
251
- element: contentElement,
252
- extensions: exts,
253
- content: initialContent,
254
- editable: initialEditable,
255
- autofocus: initialAutofocus,
256
- editorProps: {
257
- attributes: initialAttrs as Record<string, string>
258
- },
259
- onCreate: ({ editor: e }) => syncState(e),
260
- onUpdate: ({ editor: e }) => {
261
- syncState(e)
262
- if (suppressUpdate) return
263
- const serialized = serialize(e)
264
- value = serialized
265
- emit.onInput()
266
- onValueChange?.(serialized)
267
- },
268
- onSelectionUpdate: ({ editor: e }) => syncState(e),
269
- onFocus: ({ editor: e }) => {
270
- syncState(e)
271
- emit.onFocus()
272
- onFocus?.()
273
- },
274
- onBlur: ({ editor: e }) => {
275
- syncState(e)
276
- emit.onBlur()
277
- onBlur?.()
278
- }
279
- })
257
+ const el = contentElement
258
+ const result = untrack(() => resolveExtensions())
259
+
260
+ let ed: Editor | null = null
261
+ let cancelled = false
262
+
263
+ const create = (exts: AnyExtension[]) => {
264
+ if (cancelled) return
265
+ ed = new Editor({
266
+ element: el,
267
+ extensions: exts,
268
+ content: initialContent,
269
+ editable: initialEditable,
270
+ autofocus: initialAutofocus,
271
+ editorProps: {
272
+ attributes: initialAttrs as Record<string, string>
273
+ },
274
+ onCreate: ({ editor: e }) => syncState(e),
275
+ onUpdate: ({ editor: e }) => {
276
+ syncState(e)
277
+ if (suppressUpdate) return
278
+ const serialized = serialize(e)
279
+ lastEmitted = serialized
280
+ value = serialized
281
+ emit.onInput()
282
+ onValueChange?.(serialized)
283
+ },
284
+ onSelectionUpdate: ({ editor: e }) => syncState(e),
285
+ onFocus: ({ editor: e }) => {
286
+ syncState(e)
287
+ emit.onFocus()
288
+ onFocus?.()
289
+ },
290
+ onBlur: ({ editor: e }) => {
291
+ syncState(e)
292
+ emit.onBlur()
293
+ onBlur?.()
294
+ }
295
+ })
296
+ editor = ed
297
+ }
280
298
 
281
- editor = ed
299
+ if (result instanceof Promise) {
300
+ result.then(create)
301
+ } else {
302
+ create(result)
303
+ }
282
304
 
283
305
  return () => {
284
- ed.destroy()
306
+ cancelled = true
307
+ ed?.destroy()
285
308
  editor = null
286
309
  }
287
310
  })
@@ -294,9 +317,6 @@
294
317
  }
295
318
  })
296
319
 
297
- // Sync aria attributes on the ProseMirror element when error/id state changes.
298
- // Tiptap's editorProps.attributes is read once at init, so toggling needs
299
- // direct DOM access. Run on every state change.
300
320
  $effect(() => {
301
321
  if (!contentElement) return
302
322
  const pm = contentElement.querySelector('.ProseMirror') as HTMLElement | null
@@ -311,6 +331,7 @@
311
331
  $effect(() => {
312
332
  if (!editor) return
313
333
  if (value === undefined) return
334
+ if (typeof value === 'string' && value === lastEmitted) return
314
335
  const current = serialize(editor)
315
336
  if (isContentEqual(current, value)) return
316
337
  suppressUpdate = true
@@ -334,13 +355,11 @@
334
355
  TOOLBAR_ACTIONS[action].run(editor)
335
356
  },
336
357
  getValue(format) {
337
- if (!editor) return output === 'json' ? ({} as EditorJSON) : ''
338
- const fmt = format ?? output
358
+ if (!editor) return resolvedOutput === 'json' ? ({} as EditorJSON) : ''
359
+ const fmt = format ?? resolvedOutput
339
360
  if (fmt === 'json') return editor.getJSON() as EditorJSON
340
361
  if (fmt === 'markdown') {
341
- const md = (editor.storage as unknown as Record<string, unknown>).markdown as
342
- | { getMarkdown?: () => string }
343
- | undefined
362
+ const md = getMarkdownStorage(editor)
344
363
  if (md && typeof md.getMarkdown === 'function') return md.getMarkdown()
345
364
  return editor.getHTML()
346
365
  }
@@ -384,7 +403,6 @@
384
403
  return {
385
404
  root: slots.root({ class: [c.root, className, u.root] }),
386
405
  toolbar: slots.toolbar({ class: [c.toolbar, u.toolbar] }),
387
- toolbarGroup: slots.toolbarGroup({ class: [c.toolbarGroup, u.toolbarGroup] }),
388
406
  toolbarButton: slots.toolbarButton({ class: [c.toolbarButton, u.toolbarButton] }),
389
407
  toolbarSeparator: slots.toolbarSeparator({
390
408
  class: [c.toolbarSeparator, u.toolbarSeparator]
@@ -396,7 +414,6 @@
396
414
  }
397
415
  })
398
416
 
399
- // ----- URL prompt modal (shared by YouTube/Image/Link toolbar + slash) -----
400
417
  interface UrlPromptState {
401
418
  open: boolean
402
419
  title: string
@@ -440,7 +457,6 @@
440
457
  }
441
458
  }
442
459
 
443
- // ----- Image upload via hidden file input -----
444
460
  let fileInput: HTMLInputElement | null = $state(null)
445
461
 
446
462
  async function handleFileSelected(event: Event): Promise<void> {
@@ -452,10 +468,19 @@
452
468
  if (!onImageUpload) return
453
469
  try {
454
470
  const url = await onImageUpload(file)
471
+ if (!isSafeImageSrc(url)) {
472
+ // eslint-disable-next-line no-console
473
+ console.warn('[Editor] blocked unsafe image src from onImageUpload:', url)
474
+ return
475
+ }
455
476
  editor.chain().focus().setImage({ src: url }).run()
456
477
  } catch (err) {
457
- // eslint-disable-next-line no-console
458
- console.error('[Editor] image upload failed', err)
478
+ if (onImageUploadError) {
479
+ onImageUploadError(err)
480
+ } else {
481
+ // eslint-disable-next-line no-console
482
+ console.error('[Editor] image upload failed', err)
483
+ }
459
484
  }
460
485
  }
461
486
 
@@ -503,7 +528,6 @@
503
528
  })
504
529
  }
505
530
 
506
- // ----- Table dimension picker -----
507
531
  let tableMenuOpen = $state(false)
508
532
  let tablePickerRows = $state(0)
509
533
  let tablePickerCols = $state(0)