@juspay/svelte-ui-components 4.22.0 → 4.24.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;
@@ -302,9 +302,69 @@ function instanceFor(options) {
302
302
  * Markdown → HTML with the sanitizing pipeline above. Pure string transform —
303
303
  * no DOM involved — so it renders identically on server and client.
304
304
  */
305
+ /**
306
+ * Matches a body that is ENTIRELY one fence labelled as markdown.
307
+ *
308
+ * Anchored at both ends on purpose. A peer's live implementation used an
309
+ * unanchored, global regex, so a message that *explained* markdown by quoting a
310
+ * fenced sample had the sample unwrapped and rendered as live markup. Anchoring
311
+ * means a fence with anything before or after it is left alone.
312
+ *
313
+ * The trailing whitespace is `\s*`, not `[ \t]*\s*` -- `[ \t]` is already a
314
+ * subset of `\s`, so pairing them let the engine try every split between the
315
+ * two quantifiers over the same characters. On uncontrolled input (this runs
316
+ * on chat/model text) that is a polynomial-time blow-up: a closing delimiter
317
+ * followed by many tabs and then one stray character made a single `.exec`
318
+ * call take seconds instead of a fraction of a millisecond.
319
+ *
320
+ * Groups: 1 = the opening run, 2 = the info string, 3 = the body, 4 = the closing run.
321
+ */
322
+ /*
323
+ * Hoisted rather than built per call. Both carry only `m` -- no `g` or `y` --
324
+ * so they hold no `lastIndex` between calls and are safe to share.
325
+ */
326
+ const INNER_BACKTICK_FENCE = /^[ \t]*`{3,}/m;
327
+ const INNER_TILDE_FENCE = /^[ \t]*~{3,}/m;
328
+ const WHOLE_BODY_MARKDOWN_FENCE = /^\s*(`{3,}|~{3,})[ \t]*(markdown|md)[ \t]*\r?\n([\s\S]*?)\r?\n?(`{3,}|~{3,})\s*$/i;
329
+ /**
330
+ * Removes a fence that wraps the entire body and is labelled `markdown` or
331
+ * `md`, which is how models overwhelmingly emit a markdown answer. Returns the
332
+ * source untouched in every other case.
333
+ *
334
+ * An unlabelled fence is deliberately not unwrapped: it may be code the author
335
+ * meant to show, and there is nothing in the source to tell the two apart.
336
+ */
337
+ export function unwrapMarkdownFence(source) {
338
+ const match = WHOLE_BODY_MARKDOWN_FENCE.exec(source);
339
+ if (match === null) {
340
+ return source;
341
+ }
342
+ const opening = match[1];
343
+ const body = match[3];
344
+ const closing = match[4];
345
+ // CommonMark lets a closing fence be LONGER than its opening, so the closing
346
+ // run is its own group and is checked here rather than by a `\1`
347
+ // backreference. The backreference did not refuse a longer close -- it
348
+ // matched the last three ticks and swept the extra one into the body, so
349
+ // `# Heading` came back as "# Heading\n`". Content corruption, not a refusal.
350
+ // Same character, and at least as long, is the actual rule.
351
+ if (closing[0] !== opening[0] || closing.length < opening.length) {
352
+ return source;
353
+ }
354
+ // A body still containing a fence run means the outer delimiters may not be a
355
+ // wrapper at all -- the regex is lazy to the LAST delimiter, so this shape is
356
+ // ambiguous. Refusing keeps a message quoting fenced code from being
357
+ // reinterpreted as markup, which is the failure this feature exists to avoid.
358
+ const innerFence = opening[0] === '~' ? INNER_TILDE_FENCE : INNER_BACKTICK_FENCE;
359
+ if (innerFence.test(body)) {
360
+ return source;
361
+ }
362
+ return body;
363
+ }
305
364
  export function renderMarkdown(markdown, options = {}) {
365
+ const source = options.unwrapFence === true ? unwrapMarkdownFence(markdown) : markdown;
306
366
  const instance = instanceFor(options);
307
- const output = options.inline === true ? instance.parseInline(markdown) : instance.parse(markdown);
367
+ const output = options.inline === true ? instance.parseInline(source) : instance.parse(source);
308
368
  if (typeof output !== 'string') {
309
369
  return '';
310
370
  }
@@ -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`.
@@ -115,6 +130,8 @@ export type RenderMarkdownOptions = {
115
130
  tableLabel?: string;
116
131
  /** Class name for the scroll region wrapping each table. See `MarkdownTextProperties.tableWrapperClass`. */
117
132
  tableWrapperClass?: string;
133
+ /** Unwrap a whole-body ```markdown fence. See `MarkdownTextProperties.unwrapFence`. */
134
+ unwrapFence?: boolean;
118
135
  /** Narrow the protocol allow-list. See `MarkdownSanitizeOptions`. */
119
136
  sanitize?: MarkdownSanitizeOptions;
120
137
  };
@@ -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;
@@ -1188,11 +1188,18 @@
1188
1188
  }
1189
1189
 
1190
1190
  /* ── Container ──────────────────────────────────────────────────────────── */
1191
+ /* `hidden` is the default because it is what clips the table's corners to
1192
+ this element's border-radius; without it the first and last cells square
1193
+ off over the rounded border. A consumer who needs the container itself to
1194
+ scroll sideways sets this to `auto` and accepts that trade. Note the
1195
+ scrollable variant below sets `overflow-y`, and per the CSS overflow spec
1196
+ a `visible` on one axis computes to `auto` when the other is not visible,
1197
+ so `visible` here does not survive on a scrollable table. */
1191
1198
  .table-container {
1192
1199
  border: var(--table-border, 1px solid #e5e7eb);
1193
1200
  border-radius: var(--table-border-radius, var(--radius, 4px));
1194
1201
  width: var(--table-container-width, 100%);
1195
- overflow: hidden;
1202
+ overflow: var(--table-container-overflow, hidden);
1196
1203
  }
1197
1204
 
1198
1205
  .scrollable-table {
@@ -10,7 +10,9 @@
10
10
  id,
11
11
  ariaLabel,
12
12
  ariaLabelledby,
13
- onclick
13
+ onclick,
14
+ name,
15
+ value
14
16
  }: ToggleProperties = $props();
15
17
 
16
18
  // Stable across hydration so the hidden input stays named and text remains clickable.
@@ -40,6 +42,8 @@
40
42
  type="checkbox"
41
43
  {checked}
42
44
  {disabled}
45
+ name={name ?? null}
46
+ value={value ?? null}
43
47
  aria-label={ariaLabel?.trim() || null}
44
48
  aria-labelledby={ariaLabelledby?.trim() || null}
45
49
  onclick={handleCheckboxClick}
@@ -112,7 +116,7 @@
112
116
  }
113
117
 
114
118
  .input-checkbox:focus + .slider {
115
- box-shadow: 0 0 1px #171717;
119
+ box-shadow: var(--toggle-focus-ring, 0 0 0 3px rgba(33, 150, 243, 0.3));
116
120
  }
117
121
 
118
122
  .input-checkbox:checked + .slider:before {
@@ -19,6 +19,15 @@ export type OptionalToggleProperties = {
19
19
  * `text` or `inputAriaLabel` for labels outside the shadow root.
20
20
  */
21
21
  ariaLabelledby?: string;
22
+ /** Native `name` on the underlying `<input type="checkbox">`, so the switch
23
+ * participates in a surrounding `<form>`'s submission. Omitted by default:
24
+ * the input stays nameless and outside `FormData`, exactly as before this
25
+ * prop existed. */
26
+ name?: string;
27
+ /** Native `value` submitted when on. Defaults to `"on"` — the native
28
+ * checkbox default — when omitted. An off toggle never appears in
29
+ * `FormData`, matching native behaviour. */
30
+ value?: string;
22
31
  };
23
32
  export type ToggleEventProperties = {
24
33
  onclick?: (checked: boolean) => void;