sv5ui 2.0.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 (57) hide show
  1. package/dist/Accordion/Accordion.svelte +11 -0
  2. package/dist/Badge/badge.types.d.ts +1 -1
  3. package/dist/Calendar/Calendar.svelte +14 -1
  4. package/dist/Collapsible/collapsible.types.d.ts +1 -1
  5. package/dist/Command/command.types.d.ts +4 -2
  6. package/dist/Command/index.d.ts +1 -1
  7. package/dist/ContextMenu/ContextMenu.svelte +1 -1
  8. package/dist/Drawer/Drawer.svelte +4 -2
  9. package/dist/Drawer/DrawerTriggerTestWrapper.svelte +10 -0
  10. package/dist/Drawer/DrawerTriggerTestWrapper.svelte.d.ts +18 -0
  11. package/dist/Drawer/drawer.types.d.ts +13 -2
  12. package/dist/Editor/Editor.svelte +85 -61
  13. package/dist/Editor/SlashPopup.svelte +8 -1
  14. package/dist/Editor/SlashPopup.svelte.d.ts +2 -0
  15. package/dist/Editor/editor.extensions.d.ts +1 -1
  16. package/dist/Editor/editor.extensions.js +25 -16
  17. package/dist/Editor/editor.schemas.d.ts +1 -0
  18. package/dist/Editor/editor.schemas.js +24 -0
  19. package/dist/Editor/editor.slash.svelte.d.ts +0 -9
  20. package/dist/Editor/editor.slash.svelte.js +33 -7
  21. package/dist/Editor/editor.suggestion.js +23 -0
  22. package/dist/Editor/editor.toolbar.js +0 -8
  23. package/dist/Editor/editor.types.d.ts +20 -0
  24. package/dist/Editor/editor.variants.d.ts +0 -5
  25. package/dist/Editor/editor.variants.js +0 -15
  26. package/dist/Editor/index.d.ts +6 -4
  27. package/dist/Editor/index.js +6 -4
  28. package/dist/FileUpload/FileUpload.svelte +7 -0
  29. package/dist/Icon/icon.types.d.ts +1 -1
  30. package/dist/Input/index.d.ts +1 -1
  31. package/dist/Modal/Modal.svelte +4 -2
  32. package/dist/Modal/ModalTriggerTestWrapper.svelte +10 -0
  33. package/dist/Modal/ModalTriggerTestWrapper.svelte.d.ts +18 -0
  34. package/dist/Modal/modal.types.d.ts +13 -3
  35. package/dist/Pagination/pagination.types.d.ts +1 -1
  36. package/dist/Popover/Popover.svelte +1 -1
  37. package/dist/Popover/popover.types.d.ts +2 -0
  38. package/dist/Progress/Progress.svelte +14 -6
  39. package/dist/RadioGroup/RadioGroup.svelte +3 -1
  40. package/dist/Select/select.types.d.ts +1 -1
  41. package/dist/SelectMenu/SelectMenu.svelte +21 -5
  42. package/dist/SelectMenu/select-menu.types.d.ts +1 -1
  43. package/dist/Separator/separator.types.d.ts +1 -1
  44. package/dist/Skeleton/Skeleton.svelte +3 -5
  45. package/dist/Slideover/Slideover.svelte +4 -2
  46. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte +10 -0
  47. package/dist/Slideover/SlideoverTriggerTestWrapper.svelte.d.ts +18 -0
  48. package/dist/Slideover/slideover.types.d.ts +13 -3
  49. package/dist/Stepper/Stepper.svelte +1 -3
  50. package/dist/Switch/Switch.svelte +12 -17
  51. package/dist/Table/table.utils.d.ts +7 -4
  52. package/dist/Table/table.utils.js +26 -25
  53. package/dist/Tabs/tabs.types.d.ts +1 -1
  54. package/dist/ThemeModeButton/ThemeModeButton.svelte +4 -3
  55. package/dist/Tooltip/Tooltip.svelte +1 -1
  56. package/dist/Tooltip/tooltip.types.d.ts +2 -0
  57. package/package.json +1 -1
@@ -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()
@@ -3,7 +3,7 @@ import type { SVGAttributes } from 'svelte/elements';
3
3
  import type { ClassNameValue } from 'tailwind-merge';
4
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
5
  /** Custom data attributes are forwarded to the rendered `<svg>`. */
6
- [key: `data-${string}`]: unknown;
6
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
7
7
  /**
8
8
  * Icon name in Iconify format: "collection:icon-name"
9
9
  * @example "lucide:home", "mdi:account", "heroicons:star"
@@ -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';
@@ -203,8 +203,10 @@
203
203
 
204
204
  <Dialog.Root bind:open onOpenChange={handleOpenChange} {onOpenChangeComplete}>
205
205
  {#if children}
206
- <Dialog.Trigger class={className as string}>
207
- {@render children()}
206
+ <Dialog.Trigger>
207
+ {#snippet child({ props })}
208
+ {@render children({ props })}
209
+ {/snippet}
208
210
  </Dialog.Trigger>
209
211
  {/if}
210
212
 
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import Modal from './Modal.svelte'
3
+ </script>
4
+
5
+ <Modal title="Trigger test" description="D">
6
+ {#snippet children({ props })}
7
+ <button data-testid="trigger" {...props}>Open</button>
8
+ {/snippet}
9
+ {#snippet body()}<p>Body</p>{/snippet}
10
+ </Modal>
@@ -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 ModalTriggerTestWrapper: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type ModalTriggerTestWrapper = InstanceType<typeof ModalTriggerTestWrapper>;
18
+ export default ModalTriggerTestWrapper;
@@ -101,10 +101,20 @@ export interface ModalProps extends RootProps, ContentProps {
101
101
  */
102
102
  class?: ClassNameValue;
103
103
  /**
104
- * Default slot content used as the trigger element.
105
- * When provided, clicking this element opens the modal.
104
+ * Trigger content. Spread the provided `props` onto your own focusable
105
+ * element (e.g. a `<Button>`) so the dialog's trigger ARIA and event
106
+ * handlers land on the real control instead of a nested wrapper button.
107
+ *
108
+ * @example
109
+ * ```svelte
110
+ * {#snippet children({ props })}
111
+ * <Button {...props}>Open</Button>
112
+ * {/snippet}
113
+ * ```
106
114
  */
107
- children?: Snippet;
115
+ children?: Snippet<[{
116
+ props: Record<string, unknown>;
117
+ }]>;
108
118
  /**
109
119
  * Custom content slot that replaces the entire default layout
110
120
  * (header, body, footer). Title and description are rendered
@@ -41,7 +41,7 @@ export interface PaginationItemSlotProps {
41
41
  */
42
42
  export interface PaginationProps extends Pick<PaginationRootProps, 'id' | 'style' | 'title' | 'role' | 'tabindex' | 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'onclick' | 'onkeydown' | 'onmouseenter' | 'onmouseleave' | 'onfocus' | 'onblur'> {
43
43
  /** Custom data attributes are forwarded to the root element. */
44
- [key: `data-${string}`]: unknown;
44
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
45
45
  /**
46
46
  * Bindable reference to the root DOM element.
47
47
  */
@@ -105,7 +105,7 @@
105
105
  {#if children}
106
106
  <Popover.Trigger>
107
107
  {#snippet child({ props })}
108
- <span {...props} class={className as string}>
108
+ <span {...props} class={[className]}>
109
109
  {@render children({ open })}
110
110
  </span>
111
111
  {/snippet}
@@ -5,6 +5,8 @@ import type { PopoverRootPropsWithoutHTML, PopoverContentPropsWithoutHTML, Popov
5
5
  type RootProps = Pick<PopoverRootPropsWithoutHTML, 'open' | 'onOpenChange' | 'onOpenChangeComplete'>;
6
6
  type ContentProps = Pick<PopoverContentPropsWithoutHTML, 'side' | 'sideOffset' | 'align' | 'alignOffset' | 'avoidCollisions' | 'collisionBoundary' | 'collisionPadding' | 'sticky' | 'hideWhenDetached' | 'trapFocus' | 'preventScroll' | 'onOpenAutoFocus' | 'onCloseAutoFocus' | 'onEscapeKeydown' | 'onInteractOutside' | 'forceMount'>;
7
7
  export interface PopoverProps extends RootProps, ContentProps {
8
+ /** Custom data attributes are forwarded to the content element. */
9
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
8
10
  /**
9
11
  * Bindable reference to the content DOM element.
10
12
  */
@@ -80,6 +80,19 @@
80
80
  }
81
81
  })
82
82
 
83
+ const stepClasses = $derived.by(() => {
84
+ const make = (step: 'active' | 'first' | 'last' | 'other') =>
85
+ progressVariants({ size, orientation, inverted, step }).step({
86
+ class: [config.slots.step, ui?.step]
87
+ })
88
+ return {
89
+ active: make('active'),
90
+ first: make('first'),
91
+ last: make('last'),
92
+ other: make('other')
93
+ }
94
+ })
95
+
83
96
  const state = $derived(isIndeterminate ? 'indeterminate' : 'determinate')
84
97
  </script>
85
98
 
@@ -108,12 +121,7 @@
108
121
  {#if hasSteps && Array.isArray(max)}
109
122
  <div class={classes.steps}>
110
123
  {#each max as step, index (index)}
111
- {@const stepClass = progressVariants({
112
- size,
113
- orientation,
114
- inverted,
115
- step: stepVariant(index)
116
- }).step({ class: [config.slots.step, ui?.step] })}
124
+ {@const stepClass = stepClasses[stepVariant(index)]}
117
125
  <div class={stepClass}>
118
126
  {#if stepSlot}
119
127
  {@render stepSlot({ step, index })}
@@ -54,6 +54,7 @@
54
54
  const resolvedId = $derived(id ?? formFieldContext?.ariaId ?? autoId)
55
55
  const resolvedName = $derived(name ?? formFieldContext?.name)
56
56
  const isDisabled = $derived(disabled || loading)
57
+ const legendId = $derived(`${resolvedId}-legend`)
57
58
 
58
59
  const ariaDescribedBy = $derived(
59
60
  !formFieldContext
@@ -186,13 +187,14 @@
186
187
  {orientation}
187
188
  aria-describedby={ariaDescribedBy}
188
189
  aria-invalid={hasError ? true : undefined}
190
+ aria-labelledby={legend && !legendSlot ? legendId : undefined}
189
191
  class={layoutClasses.fieldset}
190
192
  >
191
193
  {#if legend || legendSlot}
192
194
  {#if legendSlot}
193
195
  {@render legendSlot({ legend })}
194
196
  {:else}
195
- <span class={layoutClasses.legend}>{legend}</span>
197
+ <span id={legendId} class={layoutClasses.legend}>{legend}</span>
196
198
  {/if}
197
199
  {/if}
198
200
 
@@ -105,7 +105,7 @@ type ContentProps = Pick<SelectContentPropsWithoutHTML, 'side' | 'sideOffset' |
105
105
  type TriggerHTMLProps = Pick<SelectTriggerProps, 'style' | 'title' | 'role' | 'tabindex' | 'aria-label' | 'aria-labelledby' | 'onclick' | 'onkeydown' | 'onmouseenter' | 'onmouseleave' | 'onfocus' | 'onblur'>;
106
106
  export interface SelectProps extends RootProps, ContentProps, TriggerHTMLProps {
107
107
  /** Custom data attributes are forwarded to the trigger element. */
108
- [key: `data-${string}`]: unknown;
108
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
109
109
  /**
110
110
  * Bindable reference to the root DOM element.
111
111
  */
@@ -23,6 +23,7 @@
23
23
  import Avatar from '../Avatar/Avatar.svelte'
24
24
  import type { AvatarSize } from '../Avatar/avatar.types.js'
25
25
  import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
26
+ import { useDebounce } from '../hooks/useDebounce.svelte.js'
26
27
 
27
28
  const config = getComponentConfig('selectMenu', selectMenuDefaults)
28
29
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -181,13 +182,28 @@
181
182
 
182
183
  // ---- Search & filtering ----
183
184
  let searchTerm = $state('')
185
+ let debouncedSearch = $state('')
186
+ const searchDebounce = useDebounce({ delay: 200 })
187
+
188
+ function setSearch(term: string) {
189
+ searchTerm = term
190
+ searchDebounce.run(() => {
191
+ debouncedSearch = term
192
+ })
193
+ }
194
+
195
+ function resetSearch() {
196
+ searchDebounce.cancel()
197
+ searchTerm = ''
198
+ debouncedSearch = ''
199
+ }
184
200
 
185
201
  const filteredItems = $derived(
186
- ignoreFilter || !searchTerm.trim()
202
+ ignoreFilter || !debouncedSearch.trim()
187
203
  ? combinedItems
188
204
  : combinedItems.filter((item) => {
189
205
  if ('type' in item) return true
190
- const query = searchTerm.toLowerCase()
206
+ const query = debouncedSearch.toLowerCase()
191
207
  return filterFields.some((field) => {
192
208
  const val = (item as unknown as Record<string, unknown>)[field]
193
209
  return typeof val === 'string' && val.toLowerCase().includes(query)
@@ -256,7 +272,7 @@
256
272
  }
257
273
 
258
274
  emit.onChange()
259
- searchTerm = ''
275
+ resetSearch()
260
276
  }
261
277
 
262
278
  // ---- Leading / trailing ----
@@ -386,7 +402,7 @@
386
402
  // ---- Event handlers (Nuxt UI v4 pattern) ----
387
403
  function onUpdateOpen(val: boolean) {
388
404
  if (!val) {
389
- searchTerm = ''
405
+ resetSearch()
390
406
  emit.onBlur()
391
407
  } else {
392
408
  emit.onFocus()
@@ -450,7 +466,7 @@
450
466
  autofocus
451
467
  placeholder={searchPlaceholder}
452
468
  value={searchTerm}
453
- oninput={(e) => (searchTerm = (e.currentTarget as HTMLInputElement).value)}
469
+ oninput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
454
470
  onkeydown={(e: KeyboardEvent) => {
455
471
  if (e.key !== 'Enter') return
456
472
  if (!showCreateItem) return
@@ -90,7 +90,7 @@ type ContentProps = Pick<ComboboxContentPropsWithoutHTML, 'side' | 'sideOffset'
90
90
  type TriggerHTMLProps = Pick<ComboboxTriggerProps, 'style' | 'title' | 'role' | 'tabindex' | 'aria-label' | 'aria-labelledby' | 'onclick' | 'onkeydown' | 'onmouseenter' | 'onmouseleave' | 'onfocus' | 'onblur'>;
91
91
  export interface SelectMenuProps extends ContentProps, TriggerHTMLProps {
92
92
  /** Custom data attributes are forwarded to the trigger element. */
93
- [key: `data-${string}`]: unknown;
93
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
94
94
  /**
95
95
  * Bindable reference to the root DOM element.
96
96
  */
@@ -3,7 +3,7 @@ import type { ClassNameValue } from 'tailwind-merge';
3
3
  import type { Separator } from 'bits-ui';
4
4
  import type { SeparatorVariantProps, SeparatorSlots } from './separator.variants.js';
5
5
  import type { AvatarProps } from '../Avatar/avatar.types.js';
6
- export type SeparatorProps = Separator.RootProps & {
6
+ export type SeparatorProps = Omit<Separator.RootProps, 'class'> & {
7
7
  /**
8
8
  * Sets the color scheme applied to the separator.
9
9
  * @default 'surface'