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
@@ -6,9 +6,11 @@
6
6
  items: SlashCommand[]
7
7
  selectedIndex: number
8
8
  onPick: (index: number) => void
9
+ listboxId: string
10
+ optionIdPrefix: string
9
11
  }
10
12
 
11
- let { items, selectedIndex, onPick }: Props = $props()
13
+ let { items, selectedIndex, onPick, listboxId, optionIdPrefix }: Props = $props()
12
14
 
13
15
  let listEl: HTMLDivElement | null = $state(null)
14
16
 
@@ -24,7 +26,9 @@
24
26
 
25
27
  <div
26
28
  bind:this={listEl}
29
+ id={listboxId}
27
30
  role="listbox"
31
+ aria-label="Slash commands"
28
32
  data-editor-slash-popup
29
33
  class="max-h-72 max-w-80 min-w-64 overflow-y-auto rounded-lg border border-outline-variant bg-surface py-1 shadow-lg"
30
34
  >
@@ -35,6 +39,9 @@
35
39
  {@const active = i === selectedIndex}
36
40
  <button
37
41
  type="button"
42
+ role="option"
43
+ id={`${optionIdPrefix}${i}`}
44
+ aria-selected={active}
38
45
  data-slash-item
39
46
  data-id={cmd.id}
40
47
  data-index={i}
@@ -3,6 +3,8 @@ interface Props {
3
3
  items: SlashCommand[];
4
4
  selectedIndex: number;
5
5
  onPick: (index: number) => void;
6
+ listboxId: string;
7
+ optionIdPrefix: string;
6
8
  }
7
9
  declare const SlashPopup: import("svelte").Component<Props, {}, "">;
8
10
  type SlashPopup = ReturnType<typeof SlashPopup>;
@@ -19,5 +19,5 @@ interface BuildExtensionsOptions {
19
19
  dragHandle?: boolean;
20
20
  extra?: AnyExtension[];
21
21
  }
22
- export declare function buildExtensions(options?: BuildExtensionsOptions): AnyExtension[];
22
+ export declare function buildExtensions(options?: BuildExtensionsOptions): AnyExtension[] | Promise<AnyExtension[]>;
23
23
  export {};
@@ -5,11 +5,6 @@ import Typography from '@tiptap/extension-typography';
5
5
  import CharacterCount from '@tiptap/extension-character-count';
6
6
  import Image from '@tiptap/extension-image';
7
7
  import Mention from '@tiptap/extension-mention';
8
- import { Table } from '@tiptap/extension-table';
9
- import { TableRow } from '@tiptap/extension-table-row';
10
- import { TableCell } from '@tiptap/extension-table-cell';
11
- import { TableHeader } from '@tiptap/extension-table-header';
12
- import { Markdown } from 'tiptap-markdown';
13
8
  import Youtube from '@tiptap/extension-youtube';
14
9
  import { DragHandle } from '@tiptap/extension-drag-handle';
15
10
  import { buildSlashExtension } from './editor.slash.svelte.js';
@@ -38,7 +33,13 @@ function buildImageExt() {
38
33
  HTMLAttributes: { class: 'sv5ui-editor-image' }
39
34
  });
40
35
  }
41
- function buildTableExts() {
36
+ async function buildTableExts() {
37
+ const [{ Table }, { TableRow }, { TableCell }, { TableHeader }] = await Promise.all([
38
+ import('@tiptap/extension-table'),
39
+ import('@tiptap/extension-table-row'),
40
+ import('@tiptap/extension-table-cell'),
41
+ import('@tiptap/extension-table-header')
42
+ ]);
42
43
  return [
43
44
  Table.configure({
44
45
  resizable: true,
@@ -49,7 +50,8 @@ function buildTableExts() {
49
50
  TableCell
50
51
  ];
51
52
  }
52
- function buildMarkdownExt(allowHtml) {
53
+ async function buildMarkdownExt(allowHtml) {
54
+ const { Markdown } = await import('tiptap-markdown');
53
55
  return Markdown.configure({
54
56
  html: allowHtml,
55
57
  tightLists: true,
@@ -73,8 +75,6 @@ function buildYoutubeExt() {
73
75
  HTMLAttributes: { class: 'sv5ui-editor-youtube' }
74
76
  });
75
77
  }
76
- // lucide:grip-vertical inline SVG. Used directly because @iconify/svelte
77
- // component would need to be mounted via Svelte; this is a vanilla DOM helper.
78
78
  const GRIP_VERTICAL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
79
79
  function buildDragHandleExt() {
80
80
  return DragHandle.configure({
@@ -106,18 +106,27 @@ const OPTIONAL_BUILDERS = [
106
106
  (o) => (o.dragHandle ? buildDragHandleExt() : null)
107
107
  ];
108
108
  function collectOptionalExts(options) {
109
- const acc = [];
109
+ const sync = [];
110
+ const lazy = [];
110
111
  for (const build of OPTIONAL_BUILDERS) {
111
112
  const result = build(options);
112
- if (result === null)
113
+ if (result === null || result === undefined)
113
114
  continue;
114
- if (Array.isArray(result))
115
- acc.push(...result);
115
+ if (result instanceof Promise)
116
+ lazy.push(result);
117
+ else if (Array.isArray(result))
118
+ sync.push(...result);
116
119
  else
117
- acc.push(result);
120
+ sync.push(result);
118
121
  }
119
- return acc;
122
+ if (lazy.length === 0)
123
+ return sync;
124
+ return Promise.all(lazy).then((resolved) => [...sync, ...resolved.flat()]);
120
125
  }
121
126
  export function buildExtensions(options = {}) {
122
- return [...buildCore(options), ...collectOptionalExts(options), ...(options.extra ?? [])];
127
+ const optional = collectOptionalExts(options);
128
+ if (optional instanceof Promise) {
129
+ return optional.then((opt) => [...buildCore(options), ...opt, ...(options.extra ?? [])]);
130
+ }
131
+ return [...buildCore(options), ...optional, ...(options.extra ?? [])];
123
132
  }
@@ -1,4 +1,5 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec';
2
2
  export type UrlSchema = StandardSchemaV1<string, string>;
3
3
  export declare const httpUrlSchema: UrlSchema;
4
+ export declare function isSafeImageSrc(src: string): boolean;
4
5
  export declare const youtubeUrlSchema: UrlSchema;
@@ -1,3 +1,27 @@
1
1
  import * as v from 'valibot';
2
2
  export const httpUrlSchema = v.pipe(v.string(), v.trim(), v.nonEmpty('URL is required'), v.url('Please enter a valid URL'), v.regex(/^https?:\/\//i, 'URL must start with http:// or https://'));
3
+ function normalizeUrl(src) {
4
+ const s = src.replace(/[\t\n\r]/g, '');
5
+ let start = 0;
6
+ let end = s.length;
7
+ while (start < end && s.charCodeAt(start) <= 0x20)
8
+ start++;
9
+ while (end > start && s.charCodeAt(end - 1) <= 0x20)
10
+ end--;
11
+ return s.slice(start, end);
12
+ }
13
+ export function isSafeImageSrc(src) {
14
+ const s = normalizeUrl(src);
15
+ if (!s)
16
+ return false;
17
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(s)?.[1]?.toLowerCase();
18
+ if (!scheme)
19
+ return true;
20
+ if (scheme === 'http' || scheme === 'https')
21
+ return true;
22
+ if (scheme === 'data') {
23
+ return /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)[;,]/i.test(s);
24
+ }
25
+ return false;
26
+ }
3
27
  export const youtubeUrlSchema = v.pipe(v.string(), v.trim(), v.nonEmpty('URL is required'), v.url('Please enter a valid URL'), v.regex(/^https?:\/\/(?:www\.|m\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)\//i, 'Must be a YouTube URL (youtube.com or youtu.be)'));
@@ -14,17 +14,8 @@ interface SlashCommandsContext {
14
14
  image?: boolean;
15
15
  tables?: boolean;
16
16
  youtube?: boolean;
17
- /**
18
- * Override for the URL prompt used by image/youtube commands. Pass an
19
- * async function that resolves to a URL string, or `null`/empty string
20
- * to cancel. When omitted, falls back to `window.prompt`.
21
- */
22
17
  promptUrl?: (opts: UrlPromptOptions) => Promise<string | null>;
23
18
  }
24
- /**
25
- * Returns the built-in slash command list, optionally including media
26
- * commands based on which features are enabled in the host Editor.
27
- */
28
19
  export declare function buildDefaultSlashCommands(ctx?: SlashCommandsContext): SlashCommand[];
29
20
  interface SlashExtensionOptions {
30
21
  suggestion: Partial<SuggestionOptions>;
@@ -9,10 +9,6 @@ function defaultPromptUrl(opts) {
9
9
  return Promise.resolve(null);
10
10
  return Promise.resolve(window.prompt(opts.title, opts.initialValue ?? opts.placeholder ?? ''));
11
11
  }
12
- /**
13
- * Returns the built-in slash command list, optionally including media
14
- * commands based on which features are enabled in the host Editor.
15
- */
16
12
  export function buildDefaultSlashCommands(ctx = {}) {
17
13
  const commands = [
18
14
  {
@@ -143,6 +139,7 @@ export function buildDefaultSlashCommands(ctx = {}) {
143
139
  }
144
140
  return commands;
145
141
  }
142
+ let slashSeq = 0;
146
143
  function substringFilter(commands, query) {
147
144
  const q = query.trim().toLowerCase();
148
145
  if (!q)
@@ -163,6 +160,10 @@ function buildSuggestionRender() {
163
160
  onStart: (props) => {
164
161
  if (typeof document === 'undefined')
165
162
  return;
163
+ const seq = ++slashSeq;
164
+ const listboxId = `sv5ui-slash-listbox-${seq}`;
165
+ const optionIdPrefix = `sv5ui-slash-${seq}-`;
166
+ const editorDom = props.editor.view.dom;
166
167
  const container = document.createElement('div');
167
168
  container.setAttribute('data-editor-slash-container', '');
168
169
  container.style.cssText = 'position:absolute;top:0;left:0;z-index:50;';
@@ -170,6 +171,8 @@ function buildSuggestionRender() {
170
171
  const state = $state({
171
172
  items: props.items,
172
173
  selectedIndex: 0,
174
+ listboxId,
175
+ optionIdPrefix,
173
176
  onPick: (i) => {
174
177
  const cmd = state.items[i];
175
178
  if (!cmd)
@@ -181,7 +184,26 @@ function buildSuggestionRender() {
181
184
  target: container,
182
185
  props: state
183
186
  });
184
- handle = { container, component, state, cleanup: null };
187
+ editorDom.setAttribute('aria-controls', listboxId);
188
+ editorDom.setAttribute('aria-expanded', 'true');
189
+ const stopActiveDescendant = $effect.root(() => {
190
+ $effect(() => {
191
+ if (state.items.length > 0) {
192
+ editorDom.setAttribute('aria-activedescendant', `${optionIdPrefix}${state.selectedIndex}`);
193
+ }
194
+ else {
195
+ editorDom.removeAttribute('aria-activedescendant');
196
+ }
197
+ });
198
+ });
199
+ handle = {
200
+ container,
201
+ component,
202
+ state,
203
+ cleanup: null,
204
+ editorDom,
205
+ stopActiveDescendant
206
+ };
185
207
  const rect = props.clientRect?.();
186
208
  if (rect) {
187
209
  const virtualEl = { getBoundingClientRect: () => rect };
@@ -224,6 +246,12 @@ function buildSuggestionRender() {
224
246
  if (!handle)
225
247
  return;
226
248
  handle.cleanup?.();
249
+ handle.stopActiveDescendant?.();
250
+ if (handle.editorDom) {
251
+ handle.editorDom.removeAttribute('aria-controls');
252
+ handle.editorDom.removeAttribute('aria-expanded');
253
+ handle.editorDom.removeAttribute('aria-activedescendant');
254
+ }
227
255
  unmount(handle.component);
228
256
  handle.container.remove();
229
257
  handle = null;
@@ -262,8 +290,6 @@ export function buildSlashExtension(commands, trigger = '/') {
262
290
  allowSpaces: false,
263
291
  items: ({ query }) => substringFilter(commands, query),
264
292
  render: buildSuggestionRender,
265
- // Tiptap merges `suggestion` shallowly when an extension is .configure()'d,
266
- // so the command default in addOptions gets overwritten. Include it here.
267
293
  command: ({ editor, range, props }) => {
268
294
  editor.chain().focus().deleteRange(range).run();
269
295
  props.run({ editor });
@@ -1,4 +1,5 @@
1
1
  import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
2
+ let mentionSeq = 0;
2
3
  function createPopup() {
3
4
  const popup = document.createElement('div');
4
5
  popup.setAttribute('data-editor-mention-popup', '');
@@ -25,6 +26,9 @@ function renderItems(state) {
25
26
  row.type = 'button';
26
27
  row.setAttribute('data-mention-item', '');
27
28
  row.setAttribute('data-index', String(i));
29
+ row.setAttribute('role', 'option');
30
+ row.id = `${state.optionIdPrefix}${i}`;
31
+ row.setAttribute('aria-selected', i === state.selectedIndex ? 'true' : 'false');
28
32
  row.className = [
29
33
  'flex w-full items-center gap-2 px-3 py-1.5 text-sm text-start',
30
34
  'hover:bg-surface-container-high',
@@ -49,6 +53,9 @@ function renderItems(state) {
49
53
  });
50
54
  state.listEl?.appendChild(row);
51
55
  });
56
+ if (state.editorDom && state.items.length > 0) {
57
+ state.editorDom.setAttribute('aria-activedescendant', `${state.optionIdPrefix}${state.selectedIndex}`);
58
+ }
52
59
  }
53
60
  export function buildMentionSuggestion(options) {
54
61
  return {
@@ -66,17 +73,28 @@ export function buildMentionSuggestion(options) {
66
73
  onStart: (props) => {
67
74
  if (typeof document === 'undefined')
68
75
  return;
76
+ const seq = ++mentionSeq;
77
+ const listboxId = `sv5ui-mention-listbox-${seq}`;
78
+ const optionIdPrefix = `sv5ui-mention-${seq}-`;
79
+ const editorDom = props.editor.view.dom;
69
80
  const popupEl = createPopup();
70
81
  const listEl = document.createElement('div');
71
82
  listEl.setAttribute('role', 'listbox');
83
+ listEl.id = listboxId;
84
+ listEl.setAttribute('aria-label', 'Mentions');
72
85
  popupEl.appendChild(listEl);
73
86
  document.body.appendChild(popupEl);
87
+ editorDom.setAttribute('aria-controls', listboxId);
88
+ editorDom.setAttribute('aria-expanded', 'true');
74
89
  state = {
75
90
  items: props.items,
76
91
  selectedIndex: 0,
77
92
  popupEl,
78
93
  listEl,
79
94
  cleanup: null,
95
+ editorDom,
96
+ listboxId,
97
+ optionIdPrefix,
80
98
  pickItem: (i) => {
81
99
  const it = state?.items[i];
82
100
  if (!it)
@@ -134,6 +152,11 @@ export function buildMentionSuggestion(options) {
134
152
  onExit: () => {
135
153
  state?.cleanup?.();
136
154
  state?.popupEl?.remove();
155
+ if (state?.editorDom) {
156
+ state.editorDom.removeAttribute('aria-controls');
157
+ state.editorDom.removeAttribute('aria-expanded');
158
+ state.editorDom.removeAttribute('aria-activedescendant');
159
+ }
137
160
  state = null;
138
161
  }
139
162
  };
@@ -1,11 +1,3 @@
1
- // ---------------------------------------------------------------------------
2
- // Fallback `run` handlers below (link/image/youtube) use window.prompt because
3
- // they need to gather a URL. Editor.svelte intercepts these actions and routes
4
- // them through the EditorUrlPrompt modal instead. The fallbacks only execute
5
- // when a consumer calls `api.run('link'|'image'|'youtube')` directly — at
6
- // which point we have no modal mount point, so window.prompt is the simplest
7
- // non-UI fallback.
8
- // ---------------------------------------------------------------------------
9
1
  function promptForLink(editor) {
10
2
  const previous = editor.getAttributes('link').href ?? 'https://';
11
3
  const url = typeof window === 'undefined' ? null : window.prompt('URL', previous);
@@ -195,10 +195,20 @@ export interface EditorProps extends Omit<HTMLAttributes<HTMLElement>, 'class' |
195
195
  * Bindable content. The runtime type depends on `output`:
196
196
  * - `output: 'html'` (default) → `string` of HTML
197
197
  * - `output: 'json'` → `EditorJSON` document
198
+ *
199
+ * Security: the serialized value is structurally validated by the editor
200
+ * schema (unknown tags, attributes, and event handlers are dropped), but it
201
+ * is NOT sanitizer-clean. It can still contain arbitrary image `src` values
202
+ * (including `data:` URIs from pasted content). Sanitize the HTML output
203
+ * (e.g. with DOMPurify) before rendering it as raw markup elsewhere.
198
204
  */
199
205
  value?: string | EditorJSON;
200
206
  /**
201
207
  * Serialization format used for `value` and `onValueChange`.
208
+ *
209
+ * Read once when the editor mounts (it also decides whether the Markdown
210
+ * extension is loaded). Changing it on an existing editor has no effect —
211
+ * re-key the component (e.g. `{#key output}`) to switch formats.
202
212
  * @default 'html'
203
213
  */
204
214
  output?: EditorOutput;
@@ -257,8 +267,18 @@ export interface EditorProps extends Omit<HTMLAttributes<HTMLElement>, 'class' |
257
267
  * must return the resolved URL to insert as `<img src=...>`. When
258
268
  * omitted and `image` is `true`, images can only be inserted via URL
259
269
  * prompt (toolbar).
270
+ *
271
+ * The returned URL is validated before insertion: relative URLs,
272
+ * `http(s)`, and raster `data:image/*` URIs are allowed; `javascript:`,
273
+ * `data:text/*`, and `data:image/svg+xml` are rejected (the image is not
274
+ * inserted and a warning is logged).
260
275
  */
261
276
  onImageUpload?: (file: File) => Promise<string>;
277
+ /**
278
+ * Called when `onImageUpload` rejects. When omitted, the error is logged to
279
+ * the console. Use this to surface upload failures to the user.
280
+ */
281
+ onImageUploadError?: (error: unknown) => void;
262
282
  /**
263
283
  * Enable tables. Adds the `table` toolbar action which opens a
264
284
  * dimension picker (rows × columns) and inserts a new table.
@@ -52,7 +52,6 @@ export declare const editorVariants: import("tailwind-variants").TVReturnType<{
52
52
  }, {
53
53
  root: string[];
54
54
  toolbar: string[];
55
- toolbarGroup: string;
56
55
  toolbarButton: string[];
57
56
  toolbarSeparator: string;
58
57
  content: string[];
@@ -112,7 +111,6 @@ export declare const editorVariants: import("tailwind-variants").TVReturnType<{
112
111
  }, {
113
112
  root: string[];
114
113
  toolbar: string[];
115
- toolbarGroup: string;
116
114
  toolbarButton: string[];
117
115
  toolbarSeparator: string;
118
116
  content: string[];
@@ -172,7 +170,6 @@ export declare const editorVariants: import("tailwind-variants").TVReturnType<{
172
170
  }, {
173
171
  root: string[];
174
172
  toolbar: string[];
175
- toolbarGroup: string;
176
173
  toolbarButton: string[];
177
174
  toolbarSeparator: string;
178
175
  content: string[];
@@ -236,7 +233,6 @@ export declare const editorDefaults: {
236
233
  }, {
237
234
  root: string[];
238
235
  toolbar: string[];
239
- toolbarGroup: string;
240
236
  toolbarButton: string[];
241
237
  toolbarSeparator: string;
242
238
  content: string[];
@@ -296,7 +292,6 @@ export declare const editorDefaults: {
296
292
  }, {
297
293
  root: string[];
298
294
  toolbar: string[];
299
- toolbarGroup: string;
300
295
  toolbarButton: string[];
301
296
  toolbarSeparator: string;
302
297
  content: string[];
@@ -13,7 +13,6 @@ export const editorVariants = tv({
13
13
  'bg-surface-container-low',
14
14
  'rounded-t-lg p-1.5'
15
15
  ],
16
- toolbarGroup: 'flex items-center gap-0.5',
17
16
  toolbarButton: [
18
17
  'inline-flex items-center justify-center rounded text-on-surface-variant',
19
18
  'hover:bg-surface-container-high hover:text-on-surface',
@@ -25,7 +24,6 @@ export const editorVariants = tv({
25
24
  toolbarSeparator: 'mx-1 h-5 w-px shrink-0 bg-outline-variant',
26
25
  content: [
27
26
  'text-on-surface',
28
- // ----- Inner ProseMirror element (the actual contenteditable) -----
29
27
  '[&_.ProseMirror]:outline-none',
30
28
  '[&_.ProseMirror]:focus:outline-none',
31
29
  '[&_.ProseMirror]:focus-visible:outline-none',
@@ -33,13 +31,11 @@ export const editorVariants = tv({
33
31
  '[&_.ProseMirror]:min-h-32',
34
32
  '[&_.ProseMirror]:whitespace-pre-wrap',
35
33
  '[&_.ProseMirror]:break-words',
36
- // ----- Placeholder (when editor is empty) -----
37
34
  '[&_.is-editor-empty]:before:content-[attr(data-placeholder)]',
38
35
  '[&_.is-editor-empty]:before:text-on-surface-variant/60',
39
36
  '[&_.is-editor-empty]:before:pointer-events-none',
40
37
  '[&_.is-editor-empty]:before:float-left',
41
38
  '[&_.is-editor-empty]:before:h-0',
42
- // ----- Content typography (lightweight in-house prose) -----
43
39
  '[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
44
40
  '[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:my-3',
45
41
  '[&_h2]:text-xl [&_h2]:font-bold [&_h2]:my-3',
@@ -56,17 +52,13 @@ export const editorVariants = tv({
56
52
  '[&_hr]:border-outline-variant [&_hr]:my-4',
57
53
  '[&_strong]:font-semibold',
58
54
  '[&_em]:italic',
59
- // ----- Image -----
60
55
  '[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_img]:my-2',
61
- // ----- YouTube embed (responsive 16:9) -----
62
56
  '[&_iframe[src*="youtube"]]:aspect-video [&_iframe[src*="youtube"]]:w-full [&_iframe[src*="youtube"]]:rounded-md [&_iframe[src*="youtube"]]:my-3 [&_iframe[src*="youtube"]]:border-0',
63
- // ----- Mention chip -----
64
57
  '[&_.sv5ui-editor-mention]:inline-flex [&_.sv5ui-editor-mention]:items-center',
65
58
  '[&_.sv5ui-editor-mention]:rounded [&_.sv5ui-editor-mention]:bg-primary-container/60',
66
59
  '[&_.sv5ui-editor-mention]:text-on-primary-container',
67
60
  '[&_.sv5ui-editor-mention]:px-1.5 [&_.sv5ui-editor-mention]:py-0.5',
68
61
  '[&_.sv5ui-editor-mention]:text-sm [&_.sv5ui-editor-mention]:font-medium',
69
- // ----- Table -----
70
62
  '[&_.tableWrapper]:my-3 [&_.tableWrapper]:overflow-x-auto',
71
63
  '[&_table]:w-full [&_table]:border-collapse [&_table]:table-fixed',
72
64
  '[&_table]:border [&_table]:border-outline-variant [&_table]:rounded-md',
@@ -77,12 +69,10 @@ export const editorVariants = tv({
77
69
  '[&_td]:border [&_td]:border-outline-variant',
78
70
  '[&_td]:px-2 [&_td]:py-1.5 [&_td]:align-top',
79
71
  '[&_td]:relative [&_td]:min-w-16',
80
- // Tiptap selectedCell highlight + column resize handle
81
72
  '[&_.selectedCell]:bg-primary/10',
82
73
  '[&_.column-resize-handle]:absolute [&_.column-resize-handle]:top-0 [&_.column-resize-handle]:bottom-[-2px]',
83
74
  '[&_.column-resize-handle]:-right-0.5 [&_.column-resize-handle]:w-1',
84
75
  '[&_.column-resize-handle]:bg-primary/40 [&_.column-resize-handle]:pointer-events-none',
85
- // ----- Selection styling -----
86
76
  '[&_::selection]:bg-primary/20'
87
77
  ],
88
78
  footer: [
@@ -94,11 +84,6 @@ export const editorVariants = tv({
94
84
  ],
95
85
  countLabel: 'tabular-nums',
96
86
  bubbleMenu: [
97
- // ⚠️ Tiptap's BubbleMenu only sets `visibility:hidden` initially,
98
- // leaving the element in normal flow (taking up space below the editor)
99
- // until the user first selects text. Setting `position:absolute` upfront
100
- // keeps it out of flow from mount — extension's Floating UI positioning
101
- // then overrides left/top inline on selection.
102
87
  'absolute top-0 left-0 z-50 invisible',
103
88
  'flex items-center gap-0.5 p-1',
104
89
  'rounded-lg border border-outline-variant bg-surface',
@@ -29,10 +29,12 @@
29
29
  *
30
30
  * (`npm add` / `yarn add` work equally well.)
31
31
  *
32
- * All 18 packages are required, even if you only enable a subset of
33
- * features. Editor imports every extension at module load so toggling
34
- * `image`, `tables`, `youtube`, etc. via props doesn't trigger a dynamic
35
- * import. Missing peers fail at runtime with module-resolution errors.
32
+ * All 18 packages must be installed even if you only enable a subset of
33
+ * features missing peers fail at runtime with module-resolution errors.
34
+ * Most extensions are imported eagerly, but the heaviest optional ones load
35
+ * on demand: `tiptap-markdown` (only with `output="markdown"`) and the table
36
+ * packages (only with `tables`) are pulled in via dynamic `import()`, so your
37
+ * bundler code-splits them out of the base editor chunk when unused.
36
38
  *
37
39
  * ## Usage
38
40
  *
@@ -29,10 +29,12 @@
29
29
  *
30
30
  * (`npm add` / `yarn add` work equally well.)
31
31
  *
32
- * All 18 packages are required, even if you only enable a subset of
33
- * features. Editor imports every extension at module load so toggling
34
- * `image`, `tables`, `youtube`, etc. via props doesn't trigger a dynamic
35
- * import. Missing peers fail at runtime with module-resolution errors.
32
+ * All 18 packages must be installed even if you only enable a subset of
33
+ * features missing peers fail at runtime with module-resolution errors.
34
+ * Most extensions are imported eagerly, but the heaviest optional ones load
35
+ * on demand: `tiptap-markdown` (only with `output="markdown"`) and the table
36
+ * packages (only with `tables`) are pulled in via dynamic `import()`, so your
37
+ * bundler code-splits them out of the base editor chunk when unused.
36
38
  *
37
39
  * ## Usage
38
40
  *
@@ -154,6 +154,13 @@
154
154
  }
155
155
  })
156
156
 
157
+ $effect(() => {
158
+ return () => {
159
+ for (const [, url] of urlCache) URL.revokeObjectURL(url)
160
+ urlCache.clear()
161
+ }
162
+ })
163
+
157
164
  export function open() {
158
165
  if (isDisabled) return
159
166
  inputRef?.click()
@@ -1,6 +1,9 @@
1
1
  import type { IconProps as IconifyProps } from '@iconify/svelte';
2
+ import type { SVGAttributes } from 'svelte/elements';
2
3
  import type { ClassNameValue } from 'tailwind-merge';
3
- export interface IconProps extends Omit<IconifyProps, 'icon' | 'width' | 'height' | 'rotate' | 'flip' | 'class'> {
4
+ export interface IconProps extends Omit<IconifyProps, 'icon' | 'width' | 'height' | 'rotate' | 'flip' | 'class'>, Pick<SVGAttributes<SVGSVGElement>, 'role' | 'tabindex' | 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'aria-hidden' | 'onclick' | 'onkeydown' | 'onmouseenter' | 'onmouseleave' | 'onfocus' | 'onblur'> {
5
+ /** Custom data attributes are forwarded to the rendered `<svg>`. */
6
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
4
7
  /**
5
8
  * Icon name in Iconify format: "collection:icon-name"
6
9
  * @example "lucide:home", "mdi:account", "heroicons:star"
@@ -94,19 +94,14 @@
94
94
  const resolvedId = $derived(id ?? formFieldContext?.ariaId)
95
95
  const resolvedName = $derived(name ?? formFieldContext?.name)
96
96
 
97
- const isLeading = $derived(
98
- (!!icon && !trailing) || (loading && !trailing) || !!leadingIcon || !!avatar
99
- )
100
- const isTrailing = $derived((!!icon && trailing) || (loading && trailing) || !!trailingIcon)
97
+ const loadingLeading = $derived(loading && !trailing)
98
+ const loadingTrailing = $derived(loading && trailing)
101
99
 
102
- const leadingIconName = $derived(
103
- loading && isLeading
104
- ? loadingIcon
105
- : leadingIcon || (isLeading && !trailing && !avatar ? icon : undefined)
106
- )
107
- const trailingIconName = $derived(
108
- loading && isTrailing ? loadingIcon : trailingIcon || (trailing ? icon : undefined)
109
- )
100
+ const isLeading = $derived((!!icon && !trailing) || !!leadingIcon || !!avatar || loadingLeading)
101
+ const isTrailing = $derived((!!icon && trailing) || !!trailingIcon || loadingTrailing)
102
+
103
+ const leadingIconName = $derived(leadingIcon || (!!icon && !trailing ? icon : undefined))
104
+ const trailingIconName = $derived(trailingIcon || (!!icon && trailing ? icon : undefined))
110
105
 
111
106
  const ariaDescribedBy = $derived(
112
107
  !formFieldContext
@@ -123,7 +118,6 @@
123
118
  size: resolvedSize,
124
119
  leading: isLeading,
125
120
  trailing: isTrailing,
126
- loading,
127
121
  highlight: resolvedHighlight
128
122
  })
129
123
  )
@@ -154,14 +148,20 @@
154
148
  <span class={classes.leading}>
155
149
  {@render leadingSlot()}
156
150
  </span>
157
- {:else if isLeading && leadingIconName}
151
+ {:else if loadingLeading}
158
152
  <span class={classes.leading}>
159
- <Icon name={leadingIconName} class={classes.leadingIcon} />
153
+ <span class="inline-flex animate-spin">
154
+ <Icon name={loadingIcon} class={classes.leadingIcon} />
155
+ </span>
160
156
  </span>
161
157
  {:else if avatar}
162
158
  <span class={classes.leading}>
163
159
  <Avatar {...avatar} size={classes.leadingAvatarSize} class={classes.leadingAvatar} />
164
160
  </span>
161
+ {:else if leadingIconName}
162
+ <span class={classes.leading}>
163
+ <Icon name={leadingIconName} class={classes.leadingIcon} />
164
+ </span>
165
165
  {/if}
166
166
 
167
167
  <input
@@ -185,7 +185,13 @@
185
185
  <span class={classes.trailing}>
186
186
  {@render trailingSlot()}
187
187
  </span>
188
- {:else if isTrailing && trailingIconName}
188
+ {:else if loadingTrailing}
189
+ <span class={classes.trailing}>
190
+ <span class="inline-flex animate-spin">
191
+ <Icon name={loadingIcon} class={classes.trailingIcon} />
192
+ </span>
193
+ </span>
194
+ {:else if trailingIconName}
189
195
  <span class={classes.trailing}>
190
196
  <Icon name={trailingIconName} class={classes.trailingIcon} />
191
197
  </span>
@@ -1,2 +1,2 @@
1
1
  export { default as Input } from './Input.svelte';
2
- export type { InputProps } from './input.types.js';
2
+ export type { InputProps, InputValue } from './input.types.js';