@juspay/svelte-ui-components 4.33.2 → 4.34.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 (50) hide show
  1. package/dist/Accordion/Accordion.svelte +26 -2
  2. package/dist/Accordion/properties.d.ts +7 -0
  3. package/dist/ArmedButton/ArmedButton.svelte +147 -0
  4. package/dist/ArmedButton/ArmedButton.svelte.d.ts +4 -0
  5. package/dist/ArmedButton/properties.d.ts +24 -0
  6. package/dist/ArmedButton/properties.js +1 -0
  7. package/dist/Banner/Banner.svelte +13 -8
  8. package/dist/Banner/properties.d.ts +8 -1
  9. package/dist/Button/Button.svelte +26 -0
  10. package/dist/Button/properties.d.ts +17 -0
  11. package/dist/ChatComposer/ChatComposer.svelte +314 -1
  12. package/dist/ChatComposer/properties.d.ts +42 -0
  13. package/dist/ChatMessageList/ChatMessageList.svelte +22 -2
  14. package/dist/ChatMessageList/ChatMessageList.svelte.d.ts +1 -1
  15. package/dist/CommandMenu/CommandMenu.svelte +53 -20
  16. package/dist/CommandMenu/properties.d.ts +24 -0
  17. package/dist/DiffViewer/DiffViewer.svelte +210 -0
  18. package/dist/DiffViewer/DiffViewer.svelte.d.ts +4 -0
  19. package/dist/DiffViewer/properties.d.ts +25 -0
  20. package/dist/DiffViewer/properties.js +1 -0
  21. package/dist/EmptyState/EmptyState.svelte +22 -3
  22. package/dist/EmptyState/properties.d.ts +9 -0
  23. package/dist/HITL/HITL.svelte +318 -49
  24. package/dist/HITL/properties.d.ts +113 -11
  25. package/dist/Input/Input.svelte +45 -1
  26. package/dist/Input/properties.d.ts +15 -0
  27. package/dist/KeyValue/KeyValue.svelte +16 -3
  28. package/dist/KeyValue/properties.d.ts +2 -0
  29. package/dist/Progress/Progress.svelte +96 -41
  30. package/dist/Progress/properties.d.ts +19 -0
  31. package/dist/Snippet/Snippet.svelte +45 -1
  32. package/dist/Snippet/properties.d.ts +8 -0
  33. package/dist/Tabs/Tabs.svelte +75 -16
  34. package/dist/Tabs/properties.d.ts +21 -0
  35. package/dist/ThinkingIndicator/ThinkingIndicator.svelte.d.ts +1 -1
  36. package/dist/ThinkingOrb/orb/modes/form.js +30 -3
  37. package/dist/ThinkingOrb/orb/modes/network.d.ts +4 -0
  38. package/dist/ThinkingOrb/orb/modes/network.js +33 -1
  39. package/dist/ThinkingOrb/orb/modes/sphere.js +69 -73
  40. package/dist/Toolbar/Toolbar.svelte +81 -3
  41. package/dist/Toolbar/properties.d.ts +37 -0
  42. package/dist/WindowedList/WindowedList.svelte +129 -0
  43. package/dist/WindowedList/WindowedList.svelte.d.ts +25 -0
  44. package/dist/WindowedList/properties.d.ts +38 -0
  45. package/dist/WindowedList/properties.js +1 -0
  46. package/dist/index.d.ts +6 -0
  47. package/dist/index.js +3 -0
  48. package/dist/styles/theme-dark.css +42 -0
  49. package/dist-wc/index.js +2714 -1708
  50. package/package.json +2 -1
@@ -11,6 +11,8 @@
11
11
  placeholder = '',
12
12
  dataType = 'text',
13
13
  label = '',
14
+ labelSuffix,
15
+ autofocus = false,
14
16
  onErrorMessage = '',
15
17
  infoMessage = '',
16
18
  validators = [],
@@ -110,6 +112,19 @@
110
112
 
111
113
  let inputElement: HTMLInputElement | HTMLTextAreaElement | null = $state(null);
112
114
 
115
+ // Guarded to once per mount: without hasAutoFocused, a later element swap
116
+ // (useTextArea toggling, or a left/right-icon layout change moving
117
+ // fieldElement's render site) replaces inputElement and this effect would
118
+ // refocus the field again, long after the user may have moved on.
119
+ let hasAutoFocused = false;
120
+ // eslint-disable-next-line no-restricted-syntax
121
+ $effect(() => {
122
+ if (autofocus && inputElement && !hasAutoFocused) {
123
+ inputElement.focus();
124
+ hasAutoFocused = true;
125
+ }
126
+ });
127
+
113
128
  let validationState = $derived.by(() => {
114
129
  const valueValidation: ValidationState = validateInput(
115
130
  value,
@@ -313,7 +328,16 @@
313
328
  </script>
314
329
 
315
330
  <div class="input-container {classes ?? ''}" class:input-error={showError && !actionInput}>
316
- {#if hasVisibleLabel}
331
+ <!-- One chain, so the label-only default is the same single block it always was. -->
332
+ {#if hasVisibleLabel && typeof labelSuffix === 'function'}
333
+ <div class="label-row">
334
+ <label class="label" for={effectiveId}>
335
+ {label}{#if isRequired}<span class="input-mandatory-asterisk" aria-hidden="true">*</span
336
+ >{/if}
337
+ </label>
338
+ <span>{@render labelSuffix()}</span>
339
+ </div>
340
+ {:else if hasVisibleLabel}
317
341
  <label class="label" for={effectiveId}>
318
342
  {label}{#if isRequired}<span class="input-mandatory-asterisk" aria-hidden="true">*</span>{/if}
319
343
  </label>
@@ -542,6 +566,26 @@
542
566
  width: var(--input-container-width, fit-content);
543
567
  }
544
568
 
569
+ /* Only present when labelSuffix is supplied (and written as a direct child of the
570
+ container, so a `label-row` class a consumer passes through `classes`, which lands
571
+ on the container itself, cannot match): a flex item's own margin is
572
+ space around just that item, not something later content in the column
573
+ can rely on to open up room below the whole row, so the row itself
574
+ carries the margin a lone .label used to carry on its own below. */
575
+ .input-container > .label-row {
576
+ display: flex;
577
+ align-items: baseline;
578
+ justify-content: space-between;
579
+ gap: var(--input-label-row-gap, 8px);
580
+ margin: var(--input-label-msg-margin, 0px 0px 6px 0px);
581
+ }
582
+
583
+ /* A row-wrapped label hands its margin to the row above, so it does not
584
+ take it twice. Only exists in the labelSuffix branch. */
585
+ .input-container > .label-row > .label {
586
+ margin: 0;
587
+ }
588
+
545
589
  .label {
546
590
  font-weight: var(--input-label-msg-text-weight, 400);
547
591
  font-size: var(--input-label-msg-text-size, 12px);
@@ -32,6 +32,21 @@ export type OptionalInputProperties = {
32
32
  */
33
33
  dataType?: InputDataType;
34
34
  label?: string | null;
35
+ /**
36
+ * Rendered beside the label, right-aligned (a status Pill, a character
37
+ * count, a short hint). It sits outside the `<label>`, so it never joins the
38
+ * field's accessible name, but it stays readable to assistive tech. Mark a
39
+ * purely decorative suffix `aria-hidden` yourself. Ignored without a
40
+ * visible `label` (there is no label row for it to sit in).
41
+ */
42
+ labelSuffix?: Snippet;
43
+ /**
44
+ * Focuses the field once it exists, via a plain native `.focus()` —
45
+ * deliberately not the exported `focus()` method, which also
46
+ * `scrollIntoView`s smoothly; a field that already exists to be typed
47
+ * into (a single-field dialog, say) does not need to scroll to itself.
48
+ */
49
+ autofocus?: boolean;
35
50
  onErrorMessage?: string | null;
36
51
  infoMessage?: string | null;
37
52
  validators?: CustomValidator[];
@@ -40,7 +40,7 @@
40
40
  {item.label}
41
41
  {/if}
42
42
  </dt>
43
- <dd class="key-value-value">
43
+ <dd class="key-value-value" title={item.title}>
44
44
  {#if valueSnippet}
45
45
  {@render valueSnippet(item, index)}
46
46
  {:else}
@@ -104,8 +104,12 @@
104
104
  line-height: var(--keyvalue-label-line-height, 1.4);
105
105
  }
106
106
 
107
+ /* The two cells' flex as variables, not just the label's basis: a label
108
+ that grows and truncates beside a content-sized value (a long name with a
109
+ short figure on the right) needs grow/shrink too, and a consumer's own
110
+ .key-value-label rule could only lose to this scoped one. */
107
111
  .key-value--horizontal .key-value-label {
108
- flex: 0 0 var(--keyvalue-label-width, 140px);
112
+ flex: var(--keyvalue-label-flex, 0 0 var(--keyvalue-label-width, 140px));
109
113
  }
110
114
 
111
115
  .key-value-value {
@@ -117,8 +121,17 @@
117
121
  overflow-wrap: anywhere;
118
122
  }
119
123
 
124
+ /* Unset, each inherits -- exactly what the cell did before these hooks. Zero specificity,
125
+ so a consumer's own rule on the cell (`dd { text-align: right }`) keeps winning, as it
126
+ did before the hooks existed. */
127
+ :where(.key-value-value) {
128
+ font-family: var(--keyvalue-value-font-family, inherit);
129
+ text-align: var(--keyvalue-value-text-align, inherit);
130
+ font-variant-ligatures: var(--keyvalue-value-font-variant-ligatures, inherit);
131
+ }
132
+
120
133
  .key-value--horizontal .key-value-value {
121
- flex: 1 1 auto;
134
+ flex: var(--keyvalue-value-flex, 1 1 auto);
122
135
  min-width: 0;
123
136
  }
124
137
 
@@ -8,6 +8,8 @@ export type KeyValueItem = {
8
8
  * `hideEmpty` is `true` (the default), or rendered as `emptyText` otherwise.
9
9
  */
10
10
  value?: string | number | null;
11
+ /** Tooltip for the value cell, as its native `title` (e.g. the full path behind a shortened one). */
12
+ title?: string;
11
13
  /** Optional per-item test id, emitted as `data-pw` on the item wrapper. */
12
14
  testId?: string;
13
15
  };
@@ -5,9 +5,13 @@
5
5
  let {
6
6
  value,
7
7
  max = 100,
8
+ leadingLabel,
8
9
  showLabel = false,
9
10
  animateValue = false,
10
11
  ariaLabel,
12
+ headerStart,
13
+ headerEnd,
14
+ note,
11
15
  testId,
12
16
  classes
13
17
  }: ProgressProperties = $props();
@@ -40,49 +44,89 @@
40
44
  let labelText = $derived(isIndeterminate ? 'Loading' : `${Math.round(percentage)}%`);
41
45
  </script>
42
46
 
43
- <div
44
- class="container {classes ?? ''}"
45
- data-pw={typeof testId === 'string' ? testId : null}
46
- testID={typeof testId === 'string' ? testId : null}
47
- role="progressbar"
48
- aria-valuenow={isIndeterminate ? null : preciseValueNow}
49
- aria-valuemin={0}
50
- aria-valuemax={100}
51
- aria-valuetext={isIndeterminate ? 'indeterminate' : null}
52
- aria-busy={isIndeterminate ? true : null}
53
- aria-label={ariaLabel ?? labelText}
54
- >
55
- <div class="track">
56
- <div
57
- class="bar"
58
- class:indeterminate={isIndeterminate}
59
- style:width={isIndeterminate ? null : `${percentage}%`}
60
- ></div>
61
- </div>
62
- {#if showLabel && !isIndeterminate}
63
- <div class="label">
64
- {#if animateValue}
65
- <!-- The rounded percentage is already the display value labelText
66
- computes, so it is passed as a plain number rather than through
67
- `format`: `AnimatedNumber`'s Intl percent style expects a 0-1
68
- fraction and would re-derive a different number from this
69
- already-0-100 one. The % is a literal beside it instead, exactly
70
- mirroring what labelText already spells out below. -->
71
- <!-- The unit goes INSIDE AnimatedNumber, not beside it. The odometer
72
- carries role="img" with its own aria-label, so a unit left outside is
73
- invisible to anyone navigating by graphic -- the rotor reads "75" with
74
- no sign of what it measures. Passing the formatted string keeps the
75
- unit in the accessible name; only the digits move, since the string
76
- path rolls the characters that changed and leaves the rest. -->
77
- <AnimatedNumber value={`${Math.round(percentage)}%`} />
78
- {:else}
79
- {labelText}
80
- {/if}
47
+ <!-- The bar itself. Rendered bare, exactly as it always was, unless a header
48
+ or note is passed: only then does a `.progress-root` wrap it. -->
49
+ {#snippet trackRow()}
50
+ <div
51
+ class="container {classes ?? ''}"
52
+ data-pw={typeof testId === 'string' ? testId : null}
53
+ testID={typeof testId === 'string' ? testId : null}
54
+ role="progressbar"
55
+ aria-valuenow={isIndeterminate ? null : preciseValueNow}
56
+ aria-valuemin={0}
57
+ aria-valuemax={100}
58
+ aria-valuetext={isIndeterminate ? 'indeterminate' : null}
59
+ aria-busy={isIndeterminate ? true : null}
60
+ aria-label={ariaLabel ?? labelText}
61
+ >
62
+ {#if typeof leadingLabel === 'function'}
63
+ {@render leadingLabel()}
64
+ {/if}
65
+ <div class="track">
66
+ <div
67
+ class="bar"
68
+ class:indeterminate={isIndeterminate}
69
+ style:width={isIndeterminate ? null : `${percentage}%`}
70
+ ></div>
81
71
  </div>
82
- {/if}
83
- </div>
72
+ {#if showLabel && !isIndeterminate}
73
+ <div class="label">
74
+ {#if animateValue}
75
+ <!-- The rounded percentage is already the display value labelText
76
+ computes, so it is passed as a plain number rather than through
77
+ `format`: `AnimatedNumber`'s Intl percent style expects a 0-1
78
+ fraction and would re-derive a different number from this
79
+ already-0-100 one. The % is a literal beside it instead, exactly
80
+ mirroring what labelText already spells out below. -->
81
+ <!-- The unit goes INSIDE AnimatedNumber, not beside it. The odometer
82
+ carries role="img" with its own aria-label, so a unit left outside is
83
+ invisible to anyone navigating by graphic -- the rotor reads "75" with
84
+ no sign of what it measures. Passing the formatted string keeps the
85
+ unit in the accessible name; only the digits move, since the string
86
+ path rolls the characters that changed and leaves the rest. -->
87
+ <AnimatedNumber value={`${Math.round(percentage)}%`} />
88
+ {:else}
89
+ {labelText}
90
+ {/if}
91
+ </div>
92
+ {/if}
93
+ </div>
94
+ {/snippet}
95
+
96
+ {#if typeof headerStart === 'function' || typeof headerEnd === 'function' || typeof note === 'function'}
97
+ <div class="progress-root">
98
+ {#if typeof headerStart === 'function' || typeof headerEnd === 'function'}
99
+ <div class="progress-header">
100
+ {#if typeof headerStart === 'function'}{@render headerStart()}{/if}
101
+ {#if typeof headerEnd === 'function'}{@render headerEnd()}{/if}
102
+ </div>
103
+ {/if}
104
+ {@render trackRow()}
105
+ {#if typeof note === 'function'}
106
+ <div class="progress-note">{@render note()}</div>
107
+ {/if}
108
+ </div>
109
+ {:else}
110
+ {@render trackRow()}
111
+ {/if}
84
112
 
85
113
  <style>
114
+ /* Only present when a header or note is passed; layout only, no type or
115
+ colour -- the snippets' content is the caller's to style. The names are prefixed so that a
116
+ class like `header` or `root`, which a consumer passes through `classes` to the
117
+ progressbar itself, cannot match them. */
118
+ .progress-root {
119
+ display: grid;
120
+ gap: var(--progress-root-gap, 5px);
121
+ }
122
+
123
+ .progress-header {
124
+ display: flex;
125
+ align-items: baseline;
126
+ justify-content: space-between;
127
+ gap: var(--progress-header-gap, 8px);
128
+ }
129
+
86
130
  .container {
87
131
  display: flex;
88
132
  align-items: center;
@@ -92,13 +136,24 @@
92
136
  }
93
137
 
94
138
  .track {
95
- flex: 1;
139
+ flex: var(--progress-track-flex, 1);
96
140
  height: var(--progress-track-height, 8px);
97
141
  background: var(--progress-track-background, #e0e0e0);
98
142
  border-radius: var(--progress-track-border-radius, var(--radius, 4px));
99
143
  overflow: hidden;
100
144
  }
101
145
 
146
+ /* A track-scoped width is separate from --progress-container-width: the container also
147
+ holds leadingLabel/showLabel and their gaps, so sizing IT would size the whole row, not
148
+ the bar. Grow/shrink stay in the flex shorthand alongside the basis specifically so an
149
+ explicit width isn't silently stretched or shrunk away from by a consumer who set only
150
+ --progress-track-width and left --progress-track-flex at its default. Zero specificity,
151
+ so a consumer's own width rule for the track keeps winning, as it did before this hook
152
+ existed. */
153
+ :where(.track) {
154
+ width: var(--progress-track-width, auto);
155
+ }
156
+
102
157
  .bar {
103
158
  height: 100%;
104
159
  background: var(--progress-bar-background, #2196f3);
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  export type ProgressProperties = MandatoryProgressProperties & OptionalProgressProperties;
2
3
  export type MandatoryProgressProperties = {
3
4
  value: number;
@@ -11,6 +12,12 @@ export type OptionalProgressProperties = {
11
12
  * `aria-valuenow` and the percentage fallback text below.
12
13
  */
13
14
  max?: number;
15
+ /**
16
+ * Rendered in `.container` before the track, e.g. a fixed caption a
17
+ * percentage label shouldn't replace. Independent of `showLabel`, which
18
+ * renders after the track instead.
19
+ */
20
+ leadingLabel?: Snippet;
14
21
  showLabel?: boolean;
15
22
  /**
16
23
  * Routes the completion percentage through `AnimatedNumber` instead of
@@ -32,6 +39,18 @@ export type OptionalProgressProperties = {
32
39
  * fallback reads `"0%"`.
33
40
  */
34
41
  ariaLabel?: string;
42
+ /**
43
+ * Left end of a header row above the track, e.g. the bar's name. With
44
+ * `headerEnd` it makes the label/value line a metered bar usually carries
45
+ * ("Context 42%"). Layout only: the content's type and colour are the
46
+ * caller's. When none of `headerStart`, `headerEnd` and `note` is passed,
47
+ * the component renders exactly as before, with no extra wrapper.
48
+ */
49
+ headerStart?: Snippet;
50
+ /** Right end of the header row (see `headerStart`), e.g. the value text. */
51
+ headerEnd?: Snippet;
52
+ /** A line under the track, e.g. "resets in 2h" (see `headerStart`). */
53
+ note?: Snippet;
35
54
  testId?: string;
36
55
  classes?: string;
37
56
  };
@@ -13,6 +13,7 @@
13
13
  copyIcon,
14
14
  copiedLabel = 'Copied!',
15
15
  copyResetMs = 2000,
16
+ multiline = false,
16
17
  oncopy,
17
18
  onerror,
18
19
  classes
@@ -44,11 +45,15 @@
44
45
 
45
46
  <div
46
47
  class="snippet {classes ?? ''}"
48
+ data-multiline={multiline ? '' : null}
49
+ data-has-copy={multiline && showCopyButton ? '' : null}
47
50
  data-pw={typeof testId === 'string' ? testId : null}
48
51
  testID={typeof testId === 'string' ? testId : null}
49
52
  >
50
53
  <code class="snippet-code">
51
- <span class="snippet-prompt">{prompt}</span>
54
+ {#if !multiline || prompt !== ''}
55
+ <span class="snippet-prompt">{prompt}</span>
56
+ {/if}
52
57
  <span class="snippet-text">{text}</span>
53
58
  </code>
54
59
  {#if showCopyButton}
@@ -90,6 +95,10 @@
90
95
  gap: var(--snippet-gap, 8px);
91
96
  background: var(--snippet-background, #1e1e1e);
92
97
  border: var(--snippet-border, 1px solid #333);
98
+ /* One side on its own, for a block that sits flush under a header and wants
99
+ only a separator above it. Defaults to the whole border, so unset it is
100
+ the shorthand above. */
101
+ border-top: var(--snippet-border-top, var(--snippet-border, 1px solid #333));
93
102
  border-radius: var(--snippet-border-radius, var(--radius, 4px));
94
103
  padding: var(--snippet-padding, 12px 16px);
95
104
  font-family: var(--snippet-font-family, monospace);
@@ -106,6 +115,41 @@
106
115
  min-width: 0;
107
116
  }
108
117
 
118
+ /* multiline: every line of long output, not one ellipsised line. Declared
119
+ here rather than left to a consumer's own selectors, which can only TIE
120
+ these scoped rules on specificity -- and then stylesheet order, which
121
+ differs between dev and a production build, decides the layout. Marked by
122
+ data attributes, not classes: a class directive would strip a `multiline`
123
+ class a consumer already passes through `classes`, and these rules would
124
+ then style every snippet that carries one. */
125
+ .snippet[data-multiline] {
126
+ display: block;
127
+ position: relative;
128
+ }
129
+
130
+ .snippet[data-multiline] .snippet-code {
131
+ display: block;
132
+ overflow: auto;
133
+ max-height: var(--snippet-code-max-height, none);
134
+ }
135
+
136
+ .snippet[data-multiline][data-has-copy] .snippet-code {
137
+ padding-right: var(--snippet-code-padding-right, 32px);
138
+ }
139
+
140
+ .snippet[data-multiline] .snippet-text {
141
+ display: inline;
142
+ white-space: pre;
143
+ overflow: visible;
144
+ text-overflow: clip;
145
+ }
146
+
147
+ .snippet[data-multiline] .snippet-copy {
148
+ position: absolute;
149
+ top: var(--snippet-copy-inset, 8px);
150
+ right: var(--snippet-copy-inset, 8px);
151
+ }
152
+
109
153
  .snippet-prompt {
110
154
  color: var(--snippet-prompt-color, #888);
111
155
  margin-right: var(--snippet-prompt-margin-right, 8px);
@@ -12,6 +12,14 @@ export type OptionalSnippetProperties = {
12
12
  copiedLabel?: string;
13
13
  /** Milliseconds before the copied feedback reverts to the copy icon. Defaults to `2000`. */
14
14
  copyResetMs?: number;
15
+ /**
16
+ * Lays the snippet out as a pre-formatted block for long output (a command's
17
+ * stdout, a JSON dump) instead of the default one-line, ellipsised chip:
18
+ * every line shows, the code area scrolls (capped by
19
+ * `--snippet-code-max-height`), the copy button sits in the top-right corner,
20
+ * and an empty `prompt` renders nothing rather than an indent.
21
+ */
22
+ multiline?: boolean;
15
23
  classes?: string;
16
24
  };
17
25
  export type SnippetEventProperties = {
@@ -17,16 +17,30 @@
17
17
  activationMode = 'automatic',
18
18
  loop = true,
19
19
  dir,
20
+ navigation = false,
20
21
  testId,
21
22
  scrollLeftIcon,
22
23
  scrollRightIcon,
23
24
  tab,
24
25
  classes,
26
+ ariaLabel,
25
27
  onchange,
26
28
  onkeychange
27
29
  }: TabsProperties = $props();
28
30
 
29
31
  const isVertical = $derived(orientation === 'vertical');
32
+ // `navigation` is passed AND every item has an href: the bar is site navigation, not a
33
+ // tab set. An href alone changes nothing, so items that already carry one as routing data
34
+ // keep rendering and behaving as the tab set they always did.
35
+ const isLinkMode = $derived(
36
+ navigation &&
37
+ Array.isArray(items) &&
38
+ items.length > 0 &&
39
+ items.every(
40
+ (item: string | TabItem) =>
41
+ typeof item === 'object' && typeof item.href === 'string' && item.href.length > 0
42
+ )
43
+ );
30
44
 
31
45
  const isObjectMode = $derived(items.length > 0 && typeof items.at(0) === 'object');
32
46
 
@@ -158,7 +172,8 @@
158
172
  }
159
173
 
160
174
  function updateIndicator(): void {
161
- if (scrollContainer === null) {
175
+ if (scrollContainer === null || isLinkMode) {
176
+ indicatorReady = false;
162
177
  return;
163
178
  }
164
179
  const activeEl = scrollContainer.querySelector<HTMLElement>('.tabs-item.active');
@@ -230,6 +245,22 @@
230
245
  activate(index);
231
246
  }
232
247
 
248
+ // Link mode has no activate() of its own to consult -- the browser navigates on click, which
249
+ // is the point of a real <a href>. A disabled item's `aria-disabled` is cosmetic to a pointer
250
+ // or native Enter/Space activation, so this is the only place that actually honours
251
+ // TabItem.disabled's documented "clicks skip past it" for a link-mode item.
252
+ function blockIfDisabled(index: number, event: Event): void {
253
+ if (isItemDisabled(index)) {
254
+ event.preventDefault();
255
+ }
256
+ }
257
+
258
+ function handleLinkKeydown(index: number, event: KeyboardEvent): void {
259
+ if (event.key === 'Enter' || event.key === ' ') {
260
+ blockIfDisabled(index, event);
261
+ }
262
+ }
263
+
233
264
  function focusTabAt(index: number): void {
234
265
  const item = items.at(index);
235
266
  if (typeof item !== 'string' && typeof item !== 'object') {
@@ -433,19 +464,21 @@
433
464
  {/if}
434
465
  </button>
435
466
  {/if}
436
- <div
467
+ <svelte:element
468
+ this={isLinkMode ? 'nav' : 'div'}
437
469
  class="tabs-bar"
438
470
  class:fade-left={canScrollLeft}
439
471
  class:fade-right={canScrollRight}
440
472
  class:fade-top={canScrollUp}
441
473
  class:fade-bottom={canScrollDown}
442
- role="tablist"
443
- aria-orientation={isVertical ? 'vertical' : null}
474
+ role={isLinkMode ? null : 'tablist'}
475
+ aria-orientation={isLinkMode || !isVertical ? null : 'vertical'}
476
+ aria-label={typeof ariaLabel === 'string' ? ariaLabel : null}
444
477
  data-orientation={orientation}
445
478
  {@attach initOverflow}
446
479
  onscroll={updateOverflow}
447
- onfocusin={handleFocusIn}
448
- onfocusout={handleFocusOut}
480
+ onfocusin={isLinkMode ? null : handleFocusIn}
481
+ onfocusout={isLinkMode ? null : handleFocusOut}
449
482
  >
450
483
  {#each items as item, index (isObjectMode ? (toTabItem(item)?.key ?? index) : index)}
451
484
  {@const tabItem = toTabItem(item)}
@@ -453,20 +486,27 @@
453
486
  {#if typeof tabItem?.sectionLabel === 'string' && tabItem.sectionLabel.length > 0}
454
487
  <div class="tabs-section-label" aria-hidden="true">{tabItem.sectionLabel}</div>
455
488
  {/if}
456
- <div
489
+ <svelte:element
490
+ this={isLinkMode ? 'a' : 'div'}
491
+ href={isLinkMode ? tabItem?.href : null}
457
492
  class="tabs-item"
458
493
  class:active={isActiveItem(index)}
459
- role="tab"
460
- aria-selected={isActiveItem(index)}
494
+ role={isLinkMode ? null : 'tab'}
495
+ aria-selected={isLinkMode ? null : isActiveItem(index)}
496
+ aria-current={isLinkMode && isActiveItem(index) ? 'page' : null}
461
497
  aria-disabled={isItemDisabled(index) ? 'true' : null}
462
- tabindex={index === tabStopIndex ? 0 : -1}
498
+ tabindex={isLinkMode ? null : index === tabStopIndex ? 0 : -1}
463
499
  data-state={isActiveItem(index) ? 'active' : 'inactive'}
464
500
  data-disabled={isItemDisabled(index) ? '' : null}
465
501
  data-orientation={orientation}
466
502
  data-pw={tabItem?.testId}
467
503
  testID={tabItem?.testId}
468
- onclick={() => handleTabClick(index)}
469
- onkeydown={(event) => handleKeydown(event, index)}
504
+ onclick={isLinkMode
505
+ ? (event: MouseEvent) => blockIfDisabled(index, event)
506
+ : () => handleTabClick(index)}
507
+ onkeydown={isLinkMode
508
+ ? (event: KeyboardEvent) => handleLinkKeydown(index, event)
509
+ : (event: KeyboardEvent) => handleKeydown(event, index)}
470
510
  >
471
511
  {#if typeof tab === 'function'}
472
512
  {@render tab({
@@ -486,9 +526,9 @@
486
526
  <span class="tabs-item-status status-{tabItem.status}" aria-hidden="true"></span>
487
527
  {/if}
488
528
  {/if}
489
- </div>
529
+ </svelte:element>
490
530
  {/each}
491
- {#if indicatorReady}
531
+ {#if indicatorReady && !isLinkMode}
492
532
  <span
493
533
  class="tabs-indicator"
494
534
  aria-hidden="true"
@@ -497,7 +537,7 @@
497
537
  : `left: ${indicatorLeft}px; width: ${indicatorWidth}px;`}
498
538
  ></span>
499
539
  {/if}
500
- </div>
540
+ </svelte:element>
501
541
  {#if showEndArrow}
502
542
  <button
503
543
  class="tabs-arrow tabs-arrow-end"
@@ -638,7 +678,7 @@
638
678
  border-radius: var(--tabs-item-border-radius, 0);
639
679
  outline: none;
640
680
  white-space: nowrap;
641
- flex-shrink: 0;
681
+ flex-shrink: var(--tabs-item-flex-shrink, 0);
642
682
  user-select: none;
643
683
  transition: var(
644
684
  --tabs-transition,
@@ -649,6 +689,19 @@
649
689
  );
650
690
  }
651
691
 
692
+ /* The grow and basis hooks live in a zero-specificity rule, so a consumer's own rule for
693
+ either (`.tabs-item { flex: 1 }` to stretch the items) wins exactly as it did before the
694
+ hooks existed. The shrink hook above replaces the literal that was already there. */
695
+ :where(.tabs-item) {
696
+ flex-grow: var(--tabs-item-flex-grow, 0);
697
+ flex-basis: var(--tabs-item-flex-basis, auto);
698
+ }
699
+
700
+ /* A link-mode item is an <a>: no underline unless asked for. Only an anchor matches. */
701
+ a.tabs-item {
702
+ text-decoration: var(--tabs-item-text-decoration, none);
703
+ }
704
+
652
705
  /* Roving focus really does move (see focusedKey/focusTabAt above) -- this
653
706
  just replaces the INDICATOR that outline:none above strips, which is what
654
707
  was actually missing (WCAG 2.4.7). :focus-visible only, so a mouse click
@@ -850,6 +903,12 @@
850
903
  user-select: none;
851
904
  }
852
905
 
906
+ /* Zero specificity, so a consumer's own font rule for the label still wins. */
907
+ :where(.tabs-section-label) {
908
+ font-family: var(--tabs-section-label-font-family, inherit);
909
+ font-variant-ligatures: var(--tabs-section-label-font-variant-ligatures, inherit);
910
+ }
911
+
853
912
  @media (prefers-reduced-motion: reduce) {
854
913
  .tabs-indicator,
855
914
  .tabs-wrapper.vertical .tabs-indicator {
@@ -23,6 +23,12 @@ export type TabItem = {
23
23
  * `tab` snippet, since it sits outside the item row.
24
24
  */
25
25
  sectionLabel?: string;
26
+ /**
27
+ * The link target in navigation mode (see `navigation` on the bar). Ignored
28
+ * unless `navigation` is set, so an item that already carries an `href` as its
29
+ * own routing data renders and behaves exactly as before.
30
+ */
31
+ href?: string;
26
32
  /**
27
33
  * Blocks selection of this item. It keeps its place and its `aria-disabled`
28
34
  * state, but arrow keys, `Home`/`End` and clicks skip past it, and it never
@@ -36,6 +42,21 @@ export type MandatoryTabsProperties = {
36
42
  items: string[] | TabItem[];
37
43
  };
38
44
  export type OptionalTabsProperties = {
45
+ /**
46
+ * Turns the bar into site navigation instead of a tab set, when EVERY item also
47
+ * has an `href`: a `<nav>` of `<a href>` items, the active one marked
48
+ * `aria-current="page"`, reached with Tab like any links -- no tablist/tab
49
+ * roles, no roving tabindex, no arrow keys, no sliding indicator. Navigation is
50
+ * the browser's; `onchange` does not fire. Defaults to false, which keeps the
51
+ * tab set whatever the items contain.
52
+ */
53
+ navigation?: boolean;
54
+ /**
55
+ * Accessible name for the bar -- the tablist, or the `<nav>` in link mode
56
+ * (e.g. "Manual controls"; a `sectionLabel` is aria-hidden, so it does not
57
+ * name the bar).
58
+ */
59
+ ariaLabel?: string;
39
60
  activeIndex?: number;
40
61
  activeKey?: string;
41
62
  disabled?: boolean;
@@ -1,4 +1,4 @@
1
1
  import type { ThinkingIndicatorProperties } from './properties';
2
- declare const ThinkingIndicator: import("svelte").Component<ThinkingIndicatorProperties, {}, "selected" | "expanded">;
2
+ declare const ThinkingIndicator: import("svelte").Component<ThinkingIndicatorProperties, {}, "expanded" | "selected">;
3
3
  type ThinkingIndicator = ReturnType<typeof ThinkingIndicator>;
4
4
  export default ThinkingIndicator;