@juspay/svelte-ui-components 4.23.0 → 4.25.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.
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
+ import { flushSync } from 'svelte';
2
3
  import type { CheckboxProperties } from './properties';
3
4
  import checkmarkSvg from '../assets/checkmark.svg?raw';
4
5
  import minusSvg from '../assets/minus.svg?raw';
5
6
 
6
7
  let {
7
- text,
8
+ text = '',
8
9
  checked = $bindable(false),
9
10
  disabled = false,
10
11
  indeterminate = $bindable(false),
@@ -16,9 +17,13 @@
16
17
  ariaControls,
17
18
  ariaLabel,
18
19
  controlled = false,
19
- attributes
20
+ attributes,
21
+ name,
22
+ value
20
23
  }: CheckboxProperties = $props();
21
24
 
25
+ let nativeInputEl: HTMLInputElement | null = $state(null);
26
+
22
27
  // Visible text wins over `ariaLabel`, which is what `properties.ts` has always
23
28
  // promised and what WCAG 2.5.3 requires: a control showing "Accept terms" must
24
29
  // carry that text in its accessible name, so letting an explicit label replace
@@ -44,15 +49,45 @@
44
49
  // for. Nothing is flipped locally, so a parent that declines the change
45
50
  // (a controlled table selection that rejects a row) keeps the box, the
46
51
  // native input and aria-checked all agreeing with the value it passed.
52
+ // No `input`/`change` dispatch here either: the input's checked state
53
+ // genuinely has not changed, so firing one would tell listeners (a
54
+ // parent `<form>`, a consumer's own `onchange`) about a flip that did
55
+ // not happen.
47
56
  if (controlled) {
48
57
  onclick?.(!checked);
49
58
  return;
50
59
  }
51
- checked = !checked;
52
- if (indeterminate) {
53
- indeterminate = false;
54
- }
60
+ // `flushSync` (rather than `await tick()`) applies the reactive
61
+ // `input.checked` write before the dispatch below runs, without handing
62
+ // control back to the event loop first. A keyboard repeat or a rapid
63
+ // double click stays a single synchronous call stack from gesture to
64
+ // dispatch: nothing can interleave a second `handleClick` in the middle
65
+ // (the race an `await` gap would allow), a screen reader sees the
66
+ // `input`/`change` pair land in the same tick as the key press it
67
+ // expects them tied to, and there is no unmount gap for `nativeInputEl`
68
+ // to go stale in.
69
+ flushSync(() => {
70
+ checked = !checked;
71
+ if (indeterminate) {
72
+ indeterminate = false;
73
+ }
74
+ });
55
75
  onclick?.(checked);
76
+ // The box (role="checkbox") is the only element the user can actually
77
+ // reach — the native input is aria-hidden, tabindex="-1" and
78
+ // pointer-events: none, purely a form-submission mirror. Because it
79
+ // never receives a real user click, the browser never fires a native
80
+ // `change` on it either, so a parent `<form>` listener (or a consumer's
81
+ // own `input.addEventListener('change', ...)`) never learns the value
82
+ // moved. `input`/`change` are dispatched manually to restore that signal.
83
+ // `composed: true` matters as much as `bubbles: true` here: native `input`/
84
+ // `change` are composed, and this component ships inside a shadow root
85
+ // (Checkbox.wc.svelte's `shadow: 'open'`) for every <sui-checkbox> consumer.
86
+ // `bubbles` alone stops at the shadow boundary; without `composed` the event
87
+ // never reaches a light-DOM `<form>`, silently defeating the whole point of
88
+ // wiring `name`/`value` onto this input for every web-component consumer.
89
+ nativeInputEl?.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
90
+ nativeInputEl?.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
56
91
  }
57
92
 
58
93
  function handleKeyDown(e: KeyboardEvent): void {
@@ -84,9 +119,12 @@
84
119
  class="native-checkbox"
85
120
  {checked}
86
121
  {disabled}
122
+ name={name ?? null}
123
+ value={value ?? null}
87
124
  tabindex={-1}
88
125
  aria-hidden="true"
89
126
  onclick={(e: MouseEvent) => e.stopPropagation()}
127
+ bind:this={nativeInputEl}
90
128
  data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
91
129
  testID={typeof testId === 'string' ? `${testId}-native-input` : null}
92
130
  />
@@ -1,9 +1,15 @@
1
1
  import type { Snippet } from 'svelte';
2
- export type CheckboxProperties = MandatoryCheckboxProperties & OptionalCheckboxProperties & CheckboxEventProperties;
2
+ export type CheckboxProperties = OptionalCheckboxProperties & CheckboxEventProperties;
3
3
  export type MandatoryCheckboxProperties = {
4
- text: string;
4
+ text?: string;
5
5
  };
6
6
  export type OptionalCheckboxProperties = {
7
+ /** Visible label. Optional, defaulting to `''` (unlabelled) -- matching Toggle's
8
+ * own `text` contract (Toggle/properties.ts) -- so a form-only checkbox
9
+ * (`<Checkbox name="agree" />`) does not need an empty string just to compile.
10
+ * A checkbox with no visible text needs `ariaLabel` for an accessible name;
11
+ * see `ariaLabel` below. */
12
+ text?: string;
7
13
  checked?: boolean;
8
14
  disabled?: boolean;
9
15
  indeterminate?: boolean;
@@ -27,6 +33,16 @@ export type OptionalCheckboxProperties = {
27
33
  * `aria-controls` to point at, or a consumer's own test attribute. Spread
28
34
  * last, so a value here wins over the component's own `data-pw`. */
29
35
  attributes?: Record<string, string>;
36
+ /** Native `name` on the underlying `<input type="checkbox">`, so the control
37
+ * participates in a surrounding `<form>`'s submission. Omitted by default:
38
+ * the input stays nameless and outside `FormData`, exactly as before this
39
+ * prop existed. */
40
+ name?: string;
41
+ /** Native `value` submitted when checked. Defaults to `"on"` — the native
42
+ * checkbox default — when omitted, so setting `name` alone is enough to
43
+ * form-associate a plain "checked or not" checkbox. An unchecked checkbox
44
+ * never appears in `FormData`, matching native behaviour. */
45
+ value?: string;
30
46
  };
31
47
  export type CheckboxEventProperties = {
32
48
  onclick?: (checked: boolean) => void;
@@ -9,11 +9,12 @@
9
9
  classes,
10
10
  tableLabel,
11
11
  tableWrapperClass,
12
- sanitize
12
+ sanitize,
13
+ unwrapFence = false
13
14
  }: MarkdownTextProperties = $props();
14
15
 
15
16
  let html = $derived(
16
- renderMarkdown(markdown, { breaks, tableLabel, tableWrapperClass, sanitize })
17
+ renderMarkdown(markdown, { breaks, tableLabel, tableWrapperClass, sanitize, unwrapFence })
17
18
  );
18
19
  </script>
19
20
 
@@ -1,6 +1,11 @@
1
1
  import type { RenderMarkdownOptions } from './properties';
2
2
  /**
3
- * Markdown → HTML with the sanitizing pipeline above. Pure string transform —
4
- * no DOM involved — so it renders identically on server and client.
3
+ * Removes a fence that wraps the entire body and is labelled `markdown` or
4
+ * `md`, which is how models overwhelmingly emit a markdown answer. Returns the
5
+ * source untouched in every other case.
6
+ *
7
+ * An unlabelled fence is deliberately not unwrapped: it may be code the author
8
+ * meant to show, and there is nothing in the source to tell the two apart.
5
9
  */
10
+ export declare function unwrapMarkdownFence(source: string): string;
6
11
  export declare function renderMarkdown(markdown: string, options?: RenderMarkdownOptions): string;
@@ -115,9 +115,19 @@ function tagAllowed(allowed, tag) {
115
115
  * consumer can hand this anything at runtime, so anything other than the one
116
116
  * recognised opt-in degrades to today's escaping rather than throwing — an
117
117
  * unrecognised value must never be the reason content silently disappears.
118
+ *
119
+ * `'sanitize'` without a sanitizer resolves to `'escape'` for the same reason:
120
+ * the unsafe path stays unreachable by omission rather than by discipline.
118
121
  */
119
- function resolveRawHtmlMode(rawHtml) {
120
- return rawHtml === 'strip' ? 'strip' : 'escape';
122
+ function resolveRawHtmlStrategy(sanitize) {
123
+ const rawHtml = sanitize?.rawHtml;
124
+ if (rawHtml === 'strip') {
125
+ return 'strip';
126
+ }
127
+ if (rawHtml === 'sanitize' && typeof sanitize?.htmlSanitizer === 'function') {
128
+ return 'passthrough';
129
+ }
130
+ return 'escape';
121
131
  }
122
132
  // An inert, server-compatible HTML parser handles quoted delimiters, entities
123
133
  // and raw-text elements. Escaping its text nodes prevents decoded entities from
@@ -139,7 +149,23 @@ function rawHtmlText(node) {
139
149
  function createSanitizingRenderer(protocols, allowedTags, disableTaskLists, rawHtml) {
140
150
  return {
141
151
  html(token) {
142
- return rawHtml === 'strip' ? rawHtmlText(parseFragment(token.text)) : escapeHtml(token.text);
152
+ if (rawHtml === 'strip') {
153
+ return rawHtmlText(parseFragment(token.text));
154
+ }
155
+ /*
156
+ * Emitted verbatim, and sanitized later in one pass over the whole
157
+ * document. Sanitizing here instead looks safer and is not: marked splits
158
+ * a block-level container into separate opening and closing tokens with
159
+ * the markdown between them as its own block, so a sanitizer -- which
160
+ * parses its input and serialises the tree back -- sees `<div class="x">`
161
+ * alone, balances it into `<div class="x"></div>`, and drops the lone
162
+ * `</div>`. The content the author wrapped then renders OUTSIDE its
163
+ * container. See markdown.test.ts, 'one pass over the assembled output'.
164
+ */
165
+ if (rawHtml === 'passthrough') {
166
+ return token.text;
167
+ }
168
+ return escapeHtml(token.text);
143
169
  },
144
170
  link(token) {
145
171
  if (hasSafeProtocol(token.href, protocols.links) && tagAllowed(allowedTags, 'a')) {
@@ -273,12 +299,19 @@ function wrapTables(html, label, wrapperClass) {
273
299
  : `<div class="${classAttr}" tabindex="0">`;
274
300
  return html.replace(TABLE_OPEN, `${open}<table>`).replace(TABLE_CLOSE, '</table></div>');
275
301
  }
276
- function instanceFor(options) {
302
+ function instanceFor(options, rawHtml) {
277
303
  const breaks = options.breaks === true;
278
304
  const protocols = resolveProtocolSets(options.sanitize);
279
305
  const allowedTags = resolveTagAllowList(options.sanitize?.allowedTags);
280
306
  const disableTaskLists = options.sanitize?.disableTaskLists === true;
281
- const rawHtml = resolveRawHtmlMode(options.sanitize?.rawHtml);
307
+ /*
308
+ * The strategy is what the renderer closes over, and it is a string, so the
309
+ * cache keeps working even though a caller now supplies a function: two
310
+ * consumers passing different sanitizers share one instance because that
311
+ * instance no longer knows about either of them. Keying on function identity
312
+ * would instead grow the map without bound, since the idiomatic call site
313
+ * passes a fresh inline arrow on every render.
314
+ */
282
315
  const key = [
283
316
  breaks ? 'breaks' : 'default',
284
317
  [...protocols.links].sort().join(','),
@@ -298,18 +331,118 @@ function instanceFor(options) {
298
331
  }
299
332
  return instance;
300
333
  }
334
+ /** marked's parse can be configured to return a promise; this pipeline never is. */
335
+ function parseWith(markdown, options, rawHtml) {
336
+ const instance = instanceFor(options, rawHtml);
337
+ const output = options.inline === true ? instance.parseInline(markdown) : instance.parse(markdown);
338
+ return typeof output === 'string' ? output : null;
339
+ }
340
+ /**
341
+ * A consumer's sanitizer is arbitrary code. `null` here means it did not return
342
+ * usable markup, and the caller falls back to the escaping path that was always
343
+ * there -- never to the unsanitized assembly.
344
+ */
345
+ function runSanitizer(html, htmlSanitizer) {
346
+ try {
347
+ const sanitized = htmlSanitizer(html);
348
+ return typeof sanitized === 'string' ? sanitized : null;
349
+ }
350
+ catch {
351
+ return null;
352
+ }
353
+ }
301
354
  /**
302
355
  * Markdown → HTML with the sanitizing pipeline above. Pure string transform —
303
356
  * no DOM involved — so it renders identically on server and client.
304
357
  */
358
+ /**
359
+ * Matches a body that is ENTIRELY one fence labelled as markdown.
360
+ *
361
+ * Anchored at both ends on purpose. A peer's live implementation used an
362
+ * unanchored, global regex, so a message that *explained* markdown by quoting a
363
+ * fenced sample had the sample unwrapped and rendered as live markup. Anchoring
364
+ * means a fence with anything before or after it is left alone.
365
+ *
366
+ * The trailing whitespace is `\s*`, not `[ \t]*\s*` -- `[ \t]` is already a
367
+ * subset of `\s`, so pairing them let the engine try every split between the
368
+ * two quantifiers over the same characters. On uncontrolled input (this runs
369
+ * on chat/model text) that is a polynomial-time blow-up: a closing delimiter
370
+ * followed by many tabs and then one stray character made a single `.exec`
371
+ * call take seconds instead of a fraction of a millisecond.
372
+ *
373
+ * Groups: 1 = the opening run, 2 = the info string, 3 = the body, 4 = the closing run.
374
+ */
375
+ /*
376
+ * Hoisted rather than built per call. Both carry only `m` -- no `g` or `y` --
377
+ * so they hold no `lastIndex` between calls and are safe to share.
378
+ */
379
+ const INNER_BACKTICK_FENCE = /^[ \t]*`{3,}/m;
380
+ const INNER_TILDE_FENCE = /^[ \t]*~{3,}/m;
381
+ const WHOLE_BODY_MARKDOWN_FENCE = /^\s*(`{3,}|~{3,})[ \t]*(markdown|md)[ \t]*\r?\n([\s\S]*?)\r?\n?(`{3,}|~{3,})\s*$/i;
382
+ /**
383
+ * Removes a fence that wraps the entire body and is labelled `markdown` or
384
+ * `md`, which is how models overwhelmingly emit a markdown answer. Returns the
385
+ * source untouched in every other case.
386
+ *
387
+ * An unlabelled fence is deliberately not unwrapped: it may be code the author
388
+ * meant to show, and there is nothing in the source to tell the two apart.
389
+ */
390
+ export function unwrapMarkdownFence(source) {
391
+ const match = WHOLE_BODY_MARKDOWN_FENCE.exec(source);
392
+ if (match === null) {
393
+ return source;
394
+ }
395
+ const opening = match[1];
396
+ const body = match[3];
397
+ const closing = match[4];
398
+ // CommonMark lets a closing fence be LONGER than its opening, so the closing
399
+ // run is its own group and is checked here rather than by a `\1`
400
+ // backreference. The backreference did not refuse a longer close -- it
401
+ // matched the last three ticks and swept the extra one into the body, so
402
+ // `# Heading` came back as "# Heading\n`". Content corruption, not a refusal.
403
+ // Same character, and at least as long, is the actual rule.
404
+ if (closing[0] !== opening[0] || closing.length < opening.length) {
405
+ return source;
406
+ }
407
+ // A body still containing a fence run means the outer delimiters may not be a
408
+ // wrapper at all -- the regex is lazy to the LAST delimiter, so this shape is
409
+ // ambiguous. Refusing keeps a message quoting fenced code from being
410
+ // reinterpreted as markup, which is the failure this feature exists to avoid.
411
+ const innerFence = opening[0] === '~' ? INNER_TILDE_FENCE : INNER_BACKTICK_FENCE;
412
+ if (innerFence.test(body)) {
413
+ return source;
414
+ }
415
+ return body;
416
+ }
305
417
  export function renderMarkdown(markdown, options = {}) {
306
- const instance = instanceFor(options);
307
- const output = options.inline === true ? instance.parseInline(markdown) : instance.parse(markdown);
308
- if (typeof output !== 'string') {
418
+ /* Unwrapping happens before parsing, so the sanitizer sees the document the
419
+ author meant rather than a fence around it. */
420
+ const source = options.unwrapFence === true ? unwrapMarkdownFence(markdown) : markdown;
421
+ const rawHtml = resolveRawHtmlStrategy(options.sanitize);
422
+ const parsed = parseWith(source, options, rawHtml);
423
+ if (parsed === null) {
309
424
  return '';
310
425
  }
311
- /* Inline parsing produces no block elements, so there is no table to wrap. */
426
+ /*
427
+ * One pass, over the assembled document, because that is the only string in
428
+ * which a raw-HTML container and the markdown it wraps are both present and
429
+ * balanced. The cost is the contract: the sanitizer also sees markdown-
430
+ * generated HTML, so a configuration restrictive enough to strip `<p>` or
431
+ * `<code>` will strip them here too. Documented on `htmlSanitizer`.
432
+ */
433
+ let output = parsed;
434
+ if (rawHtml === 'passthrough') {
435
+ const htmlSanitizer = options.sanitize?.htmlSanitizer;
436
+ const sanitized = typeof htmlSanitizer === 'function' ? runSanitizer(parsed, htmlSanitizer) : null;
437
+ output = sanitized ?? parseWith(source, options, 'escape') ?? '';
438
+ }
439
+ /*
440
+ * After sanitization, so the library's own link hardening cannot be stripped
441
+ * by the caller's configuration -- and so it reaches links that arrived as
442
+ * raw HTML rather than markdown syntax.
443
+ */
312
444
  const linked = annotateExternalLinks(output);
445
+ /* Inline parsing produces no block elements, so there is no table to wrap. */
313
446
  return options.inline === true
314
447
  ? linked
315
448
  : wrapTables(linked, options.tableLabel, options.tableWrapperClass);
@@ -29,6 +29,21 @@ export type OptionalMarkdownTextProperties = {
29
29
  * whitespace-only string, keeps only `markdown-table-wrapper`.
30
30
  */
31
31
  tableWrapperClass?: string;
32
+ /**
33
+ * Remove a fence that wraps the ENTIRE value and is labelled `markdown` or
34
+ * `md`, then render what was inside it as markdown.
35
+ *
36
+ * Models very often return a whole answer wrapped that way, which renders as
37
+ * a code block showing source rather than as a document. Off by default,
38
+ * because unwrapping is not always right: a message quoting a fenced sample
39
+ * would be reinterpreted as live markup, which is exactly what an
40
+ * unanchored implementation elsewhere did.
41
+ *
42
+ * Only the whole-body case is unwrapped. A fence with anything before or
43
+ * after it, an unlabelled fence, a fence labelled as another language, and a
44
+ * body that still contains a fence delimiter are all left alone.
45
+ */
46
+ unwrapFence?: boolean;
32
47
  /**
33
48
  * Narrow which URL protocols survive on rendered links/images. See
34
49
  * `MarkdownSanitizeOptions`.
@@ -94,13 +109,57 @@ export type MarkdownSanitizeOptions = {
94
109
  * keep their existing rendering. Neither mode inserts source HTML into the DOM.
95
110
  */
96
111
  rawHtml?: MarkdownRawHtmlMode;
112
+ /**
113
+ * Sanitizer consulted only while `rawHtml` is `'sanitize'`. It is called ONCE
114
+ * per render, with the whole assembled document, and what it returns is what
115
+ * gets rendered.
116
+ *
117
+ * Note what that means: it sees markdown-generated HTML too, not only the raw
118
+ * HTML the author wrote. A configuration restrictive enough to drop `<p>`,
119
+ * `<code>` or `<table>` will drop them here as well, so allow the tags this
120
+ * component emits. It is not called per raw-HTML token, because marked splits
121
+ * a block-level container into separate opening and closing tokens -- and a
122
+ * sanitizer handed `<div class="x">` on its own balances it to
123
+ * `<div class="x"></div>`, which puts the content the author wrapped outside
124
+ * its container.
125
+ *
126
+ * This library deliberately does not implement HTML sanitization and does not
127
+ * depend on a sanitizer. Escaping everything is a provably safe default, and a
128
+ * sanitizer written here could only subtract from it: mutation XSS, namespace
129
+ * confusion and parse-then-reparse divergence are a specialist problem, and a
130
+ * home-grown one inside a package this many projects install would be the most
131
+ * dangerous file in it. Injection keeps that code in your dependency tree,
132
+ * where it can be audited and updated on its own schedule.
133
+ *
134
+ * It also keeps the contract honest: this signature says the returned string
135
+ * is YOUR responsibility. Pass DOMPurify's `sanitize`, or anything you trust.
136
+ * Note that the library's URL protocol allow-list guards markdown-syntax links
137
+ * and images only: a `javascript:` href written as raw HTML is emitted exactly
138
+ * as your sanitizer returns it, because re-checking that would mean parsing
139
+ * and rewriting the output -- the sanitization this option exists to delegate.
140
+ *
141
+ * If it throws or returns a non-string, the document is re-rendered with
142
+ * `'escape'` instead; the unsanitized assembly is never emitted.
143
+ *
144
+ * It must work on the server as well as in the browser. `MarkdownText`
145
+ * renders this through `{@html}` from a `$derived`, so a sanitizer that only
146
+ * works client-side (DOMPurify without a DOM, say) throws during SSR, falls
147
+ * back to escaped output there, and succeeds on hydration -- the two renders
148
+ * then disagree. Give it a server implementation, or leave `rawHtml` alone on
149
+ * server-rendered pages.
150
+ */
151
+ htmlSanitizer?: (html: string) => string;
97
152
  };
98
153
  /**
99
154
  * `'escape'` renders raw HTML as visible escaped text (the default, and what
100
- * the component has always done); `'strip'` removes it. See
155
+ * the component has always done); `'strip'` removes it; `'sanitize'` keeps raw
156
+ * HTML in place and hands the assembled document to
157
+ * `MarkdownSanitizeOptions.htmlSanitizer`, rendering what comes back. With no
158
+ * sanitizer supplied, `'sanitize'` behaves exactly like `'escape'` -- the unsafe
159
+ * path is unreachable by omission rather than by discipline. See
101
160
  * `MarkdownSanitizeOptions.rawHtml`.
102
161
  */
103
- export type MarkdownRawHtmlMode = 'escape' | 'strip';
162
+ export type MarkdownRawHtmlMode = 'escape' | 'strip' | 'sanitize';
104
163
  export type RenderMarkdownOptions = {
105
164
  /** Render single newlines as `<br>` (GFM "breaks" mode). */
106
165
  breaks?: boolean;
@@ -115,6 +174,8 @@ export type RenderMarkdownOptions = {
115
174
  tableLabel?: string;
116
175
  /** Class name for the scroll region wrapping each table. See `MarkdownTextProperties.tableWrapperClass`. */
117
176
  tableWrapperClass?: string;
177
+ /** Unwrap a whole-body ```markdown fence. See `MarkdownTextProperties.unwrapFence`. */
178
+ unwrapFence?: boolean;
118
179
  /** Narrow the protocol allow-list. See `MarkdownSanitizeOptions`. */
119
180
  sanitize?: MarkdownSanitizeOptions;
120
181
  };
@@ -36,7 +36,8 @@
36
36
  hierarchy = 'default',
37
37
  leftIcon,
38
38
  leftIconTestId,
39
- usePortal = false
39
+ usePortal = false,
40
+ name
40
41
  }: SelectProperties = $props();
41
42
 
42
43
  function normalizeItems(source: SelectItem[] | string[]): SelectItem[] {
@@ -518,6 +519,16 @@
518
519
  bind:this={containerEl}
519
520
  {...typeof testId === 'string' ? { 'data-pw': testId, testID: testId } : {}}
520
521
  >
522
+ {#if typeof name === 'string' && name.length > 0}
523
+ <!-- No native <select> exists to carry `name`/`value` -- this is a custom
524
+ combobox/listbox. One hidden input per selected id mirrors a native
525
+ `<select multiple>`, which likewise submits one FormData entry per
526
+ selected <option> under the same name; `disabled` matches native
527
+ behaviour by excluding all of them from the submission. -->
528
+ {#each value as id (id)}
529
+ <input type="hidden" {name} value={id} {disabled} />
530
+ {/each}
531
+ {/if}
521
532
  <div class="select-control" class:has-clear={showClearButton}>
522
533
  <div
523
534
  class="select-trigger"
@@ -134,6 +134,17 @@ export type OptionalSelectProperties = {
134
134
  * `--select-dropdown-z-index` if it must sit above an even higher overlay.
135
135
  */
136
136
  usePortal?: boolean;
137
+ /**
138
+ * Native `name` for form submission. Select has no underlying `<select>` —
139
+ * it is a custom combobox/listbox — so setting this renders one
140
+ * `<input type="hidden">` per entry in `value`, each carrying the
141
+ * selected item's `id` as its value. Omitted by default: no hidden inputs
142
+ * are rendered and nothing changes for an existing consumer. Mirrors a
143
+ * native `<select multiple>`, which likewise submits one entry per
144
+ * selected `<option>` under the same name; an empty `value` array
145
+ * submits nothing, matching a native select with no selection.
146
+ */
147
+ name?: string;
137
148
  };
138
149
  export type SelectEventProperties = {
139
150
  onchange?: (value: string[]) => void;
@@ -0,0 +1,89 @@
1
+ import type { SpeechSynthesisEngineLike, SpeechSynthesisOptions, SpeechUtteranceOptions } from './types';
2
+ export declare const DEFAULT_ERROR_TIMEOUT_MS = 4000;
3
+ /**
4
+ * Clamps a caller-supplied numeric option into `[min, max]`, falling back to `fallback` for
5
+ * a non-finite value (`NaN`, `+/-Infinity`) rather than clamping garbage into range silently.
6
+ * Exported (and kept free of the class) so range enforcement is unit-testable without a
7
+ * browser or an engine.
8
+ */
9
+ export declare function clampSpeechNumber(value: number, range: {
10
+ readonly min: number;
11
+ readonly max: number;
12
+ readonly fallback: number;
13
+ }): number;
14
+ /** Floors a caller-supplied toast delay at zero, falling back to the default for non-finite input. */
15
+ export declare function clampErrorTimeoutMs(value: number): number;
16
+ /**
17
+ * Wraps the real `speechSynthesis` global so the controller only ever talks to
18
+ * `SpeechSynthesisEngineLike` -- never a live `SpeechSynthesisUtterance` (which extends
19
+ * EventTarget). `speak()` builds and wires a real utterance internally per call; nothing
20
+ * outside this function ever sees it.
21
+ *
22
+ * `window.speechSynthesis` is a single page-wide singleton, so voice-change notification is
23
+ * wired with `addEventListener('voiceschanged', ...)` rather than the single-slot
24
+ * `.onvoiceschanged =` property -- the latter would let a second controller's registration
25
+ * silently overwrite a first controller's, since both would be assigning the same DOM
26
+ * property on the same object. `addEventListener` registration is additive: each call to
27
+ * this function adds its own listener, and the engine's own `onvoiceschanged` setter -- the
28
+ * injected-engine contract stays a plain property, unrelated to how this wrapper talks to
29
+ * the real DOM object underneath -- removes exactly that listener when the controller tears
30
+ * down (`set onvoiceschanged(null)`), never another instance's.
31
+ */
32
+ export declare function createBrowserEngine(synth: SpeechSynthesis): SpeechSynthesisEngineLike;
33
+ /**
34
+ * Headless text-to-speech over the browser's `speechSynthesis` API. The symmetric
35
+ * counterpart to `SpeechToTextController`: support detection, the voiceschanged dance for
36
+ * voice enumeration, toggle-to-stop orchestration (calling `speak()` while already speaking
37
+ * stops instead of starting a new utterance), and a self-hiding error toast. Rendering
38
+ * stays entirely with the host.
39
+ */
40
+ export declare class SpeechSynthesisController {
41
+ /** True between the confirmed start and end of an utterance. */
42
+ speaking: boolean;
43
+ /** Often empty until the platform's voiceschanged fires -- see `initialize()`. */
44
+ voices: readonly SpeechSynthesisVoice[];
45
+ errorMessage: string;
46
+ errorVisible: boolean;
47
+ supported: boolean;
48
+ private readonly options;
49
+ private readonly messages;
50
+ private readonly errorMessages;
51
+ private engine;
52
+ private everInitialized;
53
+ private internalSpeaking;
54
+ private errorTimer;
55
+ private speechGeneration;
56
+ constructor(options?: SpeechSynthesisOptions);
57
+ /**
58
+ * Detects the API and starts tracking voices. Safe to call again; `speak` calls it
59
+ * lazily if the host never did. Re-initializing onto a *different* engine (a
60
+ * `getSynthesisEngine` swap, not merely calling this twice against the same one) tears the
61
+ * previous engine down first -- otherwise it would keep speaking with its `onvoiceschanged`
62
+ * handler still live, and a later `stop()`/`destroy()` would only ever reach the new engine.
63
+ */
64
+ initialize(): boolean;
65
+ /**
66
+ * Speaks `text`. Calling this while already speaking stops instead of starting a new
67
+ * utterance -- the same toggle-to-stop behavior as a mic button's start/stop click.
68
+ */
69
+ speak(text: string, options?: SpeechUtteranceOptions): void;
70
+ stop(): void;
71
+ /** Clears the toast timer and tears the engine connection down. */
72
+ destroy(): void;
73
+ /** Stops a superseded engine and detaches its listener; invalidates any utterance still in flight on it. */
74
+ private teardownEngine;
75
+ private refreshVoices;
76
+ /**
77
+ * Runs voice selection (the caller-supplied `selectVoice` hook, or the built-in default)
78
+ * guarded the same way `getSynthesisEngine` is guarded in `initialize()`: a throwing hook
79
+ * must not propagate out of `speak()` and strand the caller's utterance, so it is reported
80
+ * via `onDiagnostic` and selection falls back to the built-in default instead.
81
+ */
82
+ private resolveVoice;
83
+ private handleStart;
84
+ private handleEnd;
85
+ private handleUtteranceError;
86
+ private setSpeaking;
87
+ private showError;
88
+ private diagnose;
89
+ }