@juspay/svelte-ui-components 2.99.0 → 2.100.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.
@@ -3,12 +3,15 @@
3
3
  import Img from '../Img/Img.svelte';
4
4
  import chevronLeftSvg from '../assets/chevron-left.svg?raw';
5
5
  import chevronRightSvg from '../assets/chevron-right.svg?raw';
6
+ import chevronUpSvg from '../assets/chevron-up.svg?raw';
7
+ import chevronDownSvg from '../assets/chevron-down.svg?raw';
6
8
 
7
9
  let {
8
10
  items,
9
11
  activeIndex = $bindable(0),
10
12
  activeKey,
11
13
  disabled = false,
14
+ orientation = 'horizontal',
12
15
  testId,
13
16
  scrollLeftIcon,
14
17
  scrollRightIcon,
@@ -18,6 +21,8 @@
18
21
  onkeychange
19
22
  }: TabsProperties = $props();
20
23
 
24
+ const isVertical = $derived(orientation === 'vertical');
25
+
21
26
  const isObjectMode = $derived(items.length > 0 && typeof items.at(0) === 'object');
22
27
 
23
28
  function toTabItem(item: string | TabItem): TabItem | null {
@@ -44,16 +49,29 @@
44
49
  let scrollContainer: HTMLDivElement | null = null;
45
50
  let canScrollLeft = $state(false);
46
51
  let canScrollRight = $state(false);
52
+ let canScrollUp = $state(false);
53
+ let canScrollDown = $state(false);
47
54
 
48
- // Sliding indicator state
55
+ // Sliding indicator state — horizontal tracks left/width, vertical tracks top/height.
49
56
  let indicatorLeft = $state(0);
50
57
  let indicatorWidth = $state(0);
58
+ let indicatorTop = $state(0);
59
+ let indicatorHeight = $state(0);
51
60
  let indicatorReady = $state(false);
52
61
 
62
+ const showStartArrow = $derived(isVertical ? canScrollUp : canScrollLeft);
63
+ const showEndArrow = $derived(isVertical ? canScrollDown : canScrollRight);
64
+
53
65
  function updateOverflow(): void {
54
66
  if (scrollContainer === null) {
55
67
  return;
56
68
  }
69
+ if (isVertical) {
70
+ const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
71
+ canScrollUp = scrollTop > 1;
72
+ canScrollDown = scrollTop + clientHeight < scrollHeight - 1;
73
+ return;
74
+ }
57
75
  const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
58
76
  canScrollLeft = scrollLeft > 1;
59
77
  canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
@@ -68,20 +86,35 @@
68
86
  indicatorReady = false;
69
87
  indicatorLeft = 0;
70
88
  indicatorWidth = 0;
89
+ indicatorTop = 0;
90
+ indicatorHeight = 0;
71
91
  return;
72
92
  }
73
- indicatorLeft = activeEl.offsetLeft;
74
- indicatorWidth = activeEl.offsetWidth;
93
+ if (isVertical) {
94
+ indicatorTop = activeEl.offsetTop;
95
+ indicatorHeight = activeEl.offsetHeight;
96
+ } else {
97
+ indicatorLeft = activeEl.offsetLeft;
98
+ indicatorWidth = activeEl.offsetWidth;
99
+ }
75
100
  indicatorReady = true;
76
101
  }
77
102
 
78
- function scroll(direction: 'left' | 'right'): void {
103
+ function scroll(edge: 'start' | 'end'): void {
79
104
  if (scrollContainer === null) {
80
105
  return;
81
106
  }
107
+ if (isVertical) {
108
+ const amount = scrollContainer.clientHeight * 0.6;
109
+ scrollContainer.scrollBy({
110
+ top: edge === 'start' ? -amount : amount,
111
+ behavior: 'smooth'
112
+ });
113
+ return;
114
+ }
82
115
  const amount = scrollContainer.clientWidth * 0.6;
83
116
  scrollContainer.scrollBy({
84
- left: direction === 'left' ? -amount : amount,
117
+ left: edge === 'start' ? -amount : amount,
85
118
  behavior: 'smooth'
86
119
  });
87
120
  }
@@ -154,18 +187,18 @@
154
187
  );
155
188
  </script>
156
189
 
157
- <div class={rootClass} class:disabled data-pw={testId}>
158
- {#if canScrollLeft}
190
+ <div class={rootClass} class:disabled class:vertical={isVertical} data-pw={testId}>
191
+ {#if showStartArrow}
159
192
  <button
160
- class="tabs-arrow tabs-arrow-left"
161
- aria-label="Scroll tabs left"
162
- onclick={() => scroll('left')}
193
+ class="tabs-arrow tabs-arrow-start"
194
+ aria-label={isVertical ? 'Scroll tabs up' : 'Scroll tabs left'}
195
+ onclick={() => scroll('start')}
163
196
  >
164
197
  {#if typeof scrollLeftIcon === 'function'}
165
198
  {@render scrollLeftIcon()}
166
199
  {:else}
167
200
  <!-- eslint-disable svelte/no-at-html-tags -->
168
- {@html chevronLeftSvg}
201
+ {@html isVertical ? chevronUpSvg : chevronLeftSvg}
169
202
  {/if}
170
203
  </button>
171
204
  {/if}
@@ -173,6 +206,8 @@
173
206
  class="tabs-bar"
174
207
  class:fade-left={canScrollLeft}
175
208
  class:fade-right={canScrollRight}
209
+ class:fade-top={canScrollUp}
210
+ class:fade-bottom={canScrollDown}
176
211
  role="tablist"
177
212
  {@attach initOverflow}
178
213
  onscroll={updateOverflow}
@@ -180,6 +215,9 @@
180
215
  {#each items as item, index (isObjectMode ? (toTabItem(item)?.key ?? index) : index)}
181
216
  {@const tabItem = toTabItem(item)}
182
217
  {@const label = toStringLabel(item)}
218
+ {#if typeof tabItem?.sectionLabel === 'string' && tabItem.sectionLabel.length > 0}
219
+ <div class="tabs-section-label" aria-hidden="true">{tabItem.sectionLabel}</div>
220
+ {/if}
183
221
  <div
184
222
  class="tabs-item"
185
223
  class:active={isActiveItem(index)}
@@ -215,21 +253,23 @@
215
253
  <span
216
254
  class="tabs-indicator"
217
255
  aria-hidden="true"
218
- style="left: {indicatorLeft}px; width: {indicatorWidth}px;"
256
+ style={isVertical
257
+ ? `top: ${indicatorTop}px; height: ${indicatorHeight}px;`
258
+ : `left: ${indicatorLeft}px; width: ${indicatorWidth}px;`}
219
259
  ></span>
220
260
  {/if}
221
261
  </div>
222
- {#if canScrollRight}
262
+ {#if showEndArrow}
223
263
  <button
224
- class="tabs-arrow tabs-arrow-right"
225
- aria-label="Scroll tabs right"
226
- onclick={() => scroll('right')}
264
+ class="tabs-arrow tabs-arrow-end"
265
+ aria-label={isVertical ? 'Scroll tabs down' : 'Scroll tabs right'}
266
+ onclick={() => scroll('end')}
227
267
  >
228
268
  {#if typeof scrollRightIcon === 'function'}
229
269
  {@render scrollRightIcon()}
230
270
  {:else}
231
271
  <!-- eslint-disable svelte/no-at-html-tags -->
232
- {@html chevronRightSvg}
272
+ {@html isVertical ? chevronDownSvg : chevronRightSvg}
233
273
  {/if}
234
274
  </button>
235
275
  {/if}
@@ -376,7 +416,13 @@
376
416
  margin-left: auto;
377
417
  border-radius: 50%;
378
418
  flex-shrink: 0;
379
- background: var(--tabs-item-status-default-color, transparent);
419
+ background: transparent;
420
+ }
421
+
422
+ /* Neutral "has activity / configured" dot — blue by default, matching the app's
423
+ --text-color-focus. Maps from a settings menu's Default circle type. */
424
+ .tabs-item-status.status-default {
425
+ background: var(--tabs-item-status-default-color, #1a73e8);
380
426
  }
381
427
 
382
428
  .tabs-item-status.status-pending {
@@ -416,8 +462,96 @@
416
462
  pointer-events: none;
417
463
  }
418
464
 
465
+ /* ---------- Vertical orientation (nav / menu rail) ---------- */
466
+ .tabs-wrapper.vertical {
467
+ flex-direction: column;
468
+ align-items: stretch;
469
+ max-width: none;
470
+ border-bottom: none;
471
+ }
472
+
473
+ .tabs-wrapper.vertical .tabs-bar {
474
+ flex-direction: column;
475
+ overflow-x: hidden;
476
+ overflow-y: auto;
477
+ }
478
+
479
+ .tabs-wrapper.vertical .tabs-item {
480
+ /* Full-width rows: label sits at the leading edge, the status dot's margin-left:auto
481
+ pushes it to the trailing edge (the settings-menu look). */
482
+ width: 100%;
483
+ box-sizing: border-box;
484
+ justify-content: flex-start;
485
+ }
486
+
487
+ .tabs-bar.fade-top {
488
+ mask-image: linear-gradient(
489
+ to bottom,
490
+ transparent var(--tabs-fade-solid, 8px),
491
+ black var(--tabs-fade-size, 32px)
492
+ );
493
+ -webkit-mask-image: linear-gradient(
494
+ to bottom,
495
+ transparent var(--tabs-fade-solid, 8px),
496
+ black var(--tabs-fade-size, 32px)
497
+ );
498
+ }
499
+
500
+ .tabs-bar.fade-bottom {
501
+ mask-image: linear-gradient(
502
+ to top,
503
+ transparent var(--tabs-fade-solid, 8px),
504
+ black var(--tabs-fade-size, 32px)
505
+ );
506
+ -webkit-mask-image: linear-gradient(
507
+ to top,
508
+ transparent var(--tabs-fade-solid, 8px),
509
+ black var(--tabs-fade-size, 32px)
510
+ );
511
+ }
512
+
513
+ .tabs-bar.fade-top.fade-bottom {
514
+ mask-image: linear-gradient(
515
+ to bottom,
516
+ transparent var(--tabs-fade-solid, 8px),
517
+ black var(--tabs-fade-size, 32px),
518
+ black calc(100% - var(--tabs-fade-size, 32px)),
519
+ transparent calc(100% - var(--tabs-fade-solid, 8px))
520
+ );
521
+ -webkit-mask-image: linear-gradient(
522
+ to bottom,
523
+ transparent var(--tabs-fade-solid, 8px),
524
+ black var(--tabs-fade-size, 32px),
525
+ black calc(100% - var(--tabs-fade-size, 32px)),
526
+ transparent calc(100% - var(--tabs-fade-solid, 8px))
527
+ );
528
+ }
529
+
530
+ .tabs-wrapper.vertical .tabs-indicator {
531
+ top: 0;
532
+ bottom: auto;
533
+ left: 0;
534
+ width: var(--tabs-indicator-height, 2px);
535
+ height: var(--tabs-indicator-height, 2px);
536
+ border-radius: var(--tabs-indicator-border-radius-vertical, 0 2px 2px 0);
537
+ transition: var(--tabs-indicator-transition-vertical, top 0.3s ease, height 0.3s ease);
538
+ }
539
+
540
+ /* Section header rendered above a group of vertical nav items. */
541
+ .tabs-section-label {
542
+ padding: var(--tabs-section-label-padding, 12px 16px 4px);
543
+ font-size: var(--tabs-section-label-font-size, 11px);
544
+ font-weight: var(--tabs-section-label-font-weight, 700);
545
+ letter-spacing: var(--tabs-section-label-letter-spacing, 0.04em);
546
+ text-transform: var(--tabs-section-label-text-transform, uppercase);
547
+ color: var(--tabs-section-label-color, var(--tabs-item-color, #999999));
548
+ white-space: nowrap;
549
+ user-select: none;
550
+ }
551
+
419
552
  @media (prefers-reduced-motion: reduce) {
420
- .tabs-indicator {
553
+ .tabs-indicator,
554
+ .tabs-wrapper.vertical .tabs-indicator {
421
555
  transition: none;
422
556
  }
423
557
  }
@@ -13,9 +13,16 @@ export type TabItem = {
13
13
  /**
14
14
  * Optional status dot rendered after the label — for nav/menu tabs that flag
15
15
  * per-item state. `'none'` (default) renders nothing. Colours are themeable via
16
- * `--tabs-item-status-{pending,error,success}-color`.
16
+ * `--tabs-item-status-{default,pending,error,success}-color`. `'default'` is a
17
+ * neutral highlight dot (blue by default) for "has activity / configured" state.
17
18
  */
18
- status?: 'none' | 'pending' | 'error' | 'success';
19
+ status?: 'none' | 'default' | 'pending' | 'error' | 'success';
20
+ /**
21
+ * Optional section header rendered ABOVE this item — for grouped vertical nav
22
+ * menus (e.g. a "SETTINGS" / "BODY" divider label). Renders regardless of the
23
+ * `tab` snippet, since it sits outside the item row.
24
+ */
25
+ sectionLabel?: string;
19
26
  };
20
27
  export type TabsProperties = MandatoryTabsProperties & OptionalTabsProperties & TabsEventProperties;
21
28
  export type MandatoryTabsProperties = {
@@ -25,6 +32,12 @@ export type OptionalTabsProperties = {
25
32
  activeIndex?: number;
26
33
  activeKey?: string;
27
34
  disabled?: boolean;
35
+ /**
36
+ * Layout axis. `'horizontal'` (default) is the classic tab bar — items in a row,
37
+ * indicator on the bottom edge. `'vertical'` stacks items in a column (a nav/menu
38
+ * rail) — indicator on the leading edge, scroll arrows point up/down.
39
+ */
40
+ orientation?: 'horizontal' | 'vertical';
28
41
  testId?: string;
29
42
  scrollLeftIcon?: Snippet;
30
43
  scrollRightIcon?: Snippet;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.99.0",
3
+ "version": "2.100.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",