@juspay/svelte-ui-components 2.122.0 → 2.124.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.
@@ -22,8 +22,11 @@
22
22
  testId,
23
23
  ariaLabel,
24
24
  ariaExpanded,
25
+ ariaHaspopup,
25
26
  ariaSelected,
27
+ ariaBusy,
26
28
  role,
29
+ title,
27
30
  onclick,
28
31
  onkeydown = () => {},
29
32
  onkeyup = () => {},
@@ -88,8 +91,10 @@
88
91
  role={role ?? null}
89
92
  aria-label={ariaLabel ?? null}
90
93
  aria-expanded={ariaExpanded ?? null}
94
+ aria-haspopup={ariaHaspopup ?? null}
91
95
  aria-selected={ariaSelected ?? null}
92
- aria-busy={isBusy || null}
96
+ aria-busy={isBusy || ariaBusy || null}
97
+ title={title ?? null}
93
98
  type={href ? null : type}
94
99
  disabled={href ? null : isDisabled}
95
100
  href={href ? (isDisabled ? null : href) : null}
@@ -53,8 +53,26 @@ export type OptionalButtonProperties = {
53
53
  children?: Snippet;
54
54
  ariaLabel?: string;
55
55
  ariaExpanded?: boolean;
56
+ /**
57
+ * Native `aria-haspopup`. Needed when the button is the trigger for a menu, listbox or
58
+ * dialog — Menu hands exactly this to its `trigger` snippet.
59
+ */
60
+ ariaHaspopup?: 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | boolean;
56
61
  ariaSelected?: boolean;
62
+ /**
63
+ * Native `aria-busy`, for a control that stays usable while related data loads.
64
+ * Deliberately separate from `loading`, which also renders the spinner and
65
+ * disables the button — a trigger whose *contents* are still loading must stay
66
+ * clickable, so it cannot express that state through `loading`.
67
+ */
68
+ ariaBusy?: boolean;
57
69
  role?: string;
70
+ /**
71
+ * Native `title`, rendered as the browser's own hover tooltip. Distinct from
72
+ * `ariaLabel`, which names the control for assistive tech without any visible
73
+ * affordance; an icon-only button generally wants both.
74
+ */
75
+ title?: string;
58
76
  disabled?: boolean;
59
77
  classes?: string;
60
78
  /**
@@ -12,6 +12,8 @@
12
12
  infoMessage = '',
13
13
  validators = [],
14
14
  disable = false,
15
+ readonly = false,
16
+ spellcheck = null,
15
17
  validationPattern = null,
16
18
  inProgressPattern = null,
17
19
  addFocusColor = false,
@@ -113,6 +115,9 @@
113
115
  const hasRightIcon = $derived(typeof rightIcon === 'function');
114
116
 
115
117
  const charCount = $derived(value?.length ?? 0);
118
+ // Every numeric use below is either tel-only normalisation or the character
119
+ // counter; `null` means "no attribute", not "no ceiling on those paths".
120
+ const effectiveMaxLength = $derived(maxLength ?? 1000);
116
121
  const effectiveResize = $derived(autoResize ? 'none' : resize);
117
122
 
118
123
  // Grow the textarea to fit its content between minRows and maxRows.
@@ -128,8 +133,16 @@
128
133
  const border = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
129
134
  const lower = minRows ?? rows ?? 2;
130
135
  const minHeight = lower * lineHeight + verticalPadding + border;
131
- const maxHeight =
136
+ const rowsCeiling =
132
137
  maxRows != null ? maxRows * lineHeight + verticalPadding + border : Number.POSITIVE_INFINITY;
138
+ // --input-max-height is a ceiling too. Reading only maxRows left it at Infinity, so the
139
+ // inline height grew past the CSS clamp and overflowY was set to `hidden` — the box
140
+ // stopped at the right size but its overflow became unreachable instead of scrollable.
141
+ const styleCeiling = parseFloat(styles.maxHeight);
142
+ const maxHeight = Math.min(
143
+ rowsCeiling,
144
+ Number.isFinite(styleCeiling) ? styleCeiling : Number.POSITIVE_INFINITY
145
+ );
133
146
  const nextHeight = Math.min(Math.max(el.scrollHeight, minHeight), maxHeight);
134
147
  el.style.height = `${nextHeight}px`;
135
148
  el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
@@ -161,16 +174,16 @@
161
174
  inputElement.value = value;
162
175
  return;
163
176
  }
164
- if (numberLength > maxLength) {
177
+ if (numberLength > effectiveMaxLength) {
165
178
  const existingInput = value;
166
- if (existingInput.length === maxLength) {
179
+ if (existingInput.length === effectiveMaxLength) {
167
180
  inputElement.value = applyTextPresentation(value);
168
181
  return;
169
182
  }
170
183
  /**
171
184
  * choose last max length number of digits if length is bigger than max length passed in props
172
185
  */
173
- currentValue = currentValue.substring(numberLength - maxLength);
186
+ currentValue = currentValue.substring(numberLength - effectiveMaxLength);
174
187
  }
175
188
  currentValue = applyTextPresentation(currentValue);
176
189
  inputElement.value = currentValue;
@@ -189,6 +202,15 @@
189
202
  return;
190
203
  }
191
204
 
205
+ // Everything below the tel branch is tel-specific digit normalisation, and
206
+ // onPaste was only ever invoked from inside it — so a non-tel field (notably
207
+ // useTextArea) had no way to observe a paste at all. Hand the event over
208
+ // before that branch and return, leaving tel's behaviour byte-identical.
209
+ if (dataType !== 'tel') {
210
+ onPaste(event);
211
+ return;
212
+ }
213
+
192
214
  if (event.clipboardData) {
193
215
  if (dataType === 'tel') {
194
216
  let unfilteredNumber = event.clipboardData.getData('text');
@@ -210,12 +232,12 @@
210
232
  /**
211
233
  * user pasted 10+ digit number , overrides all cases
212
234
  */
213
- if (filteredNumber.length > maxLength) {
235
+ if (filteredNumber.length > effectiveMaxLength) {
214
236
  /**
215
237
  * choose last max length number of digits if length is bigger than max length passed in props
216
238
  */
217
239
  const finalValue = applyTextPresentation(
218
- filteredNumber.substring(filteredNumberLength - maxLength)
240
+ filteredNumber.substring(filteredNumberLength - effectiveMaxLength)
219
241
  );
220
242
  // Adding reactivity
221
243
  value = finalValue;
@@ -281,6 +303,8 @@
281
303
  style:resize={effectiveResize}
282
304
  rows={rows ?? null}
283
305
  disabled={disable}
306
+ readonly={readonly || null}
307
+ {spellcheck}
284
308
  bind:this={inputElement}
285
309
  maxlength={dataType === 'tel' ? null : maxLength}
286
310
  minlength={minLength}
@@ -311,6 +335,8 @@
311
335
  testID={testId}
312
336
  class:action-input={actionInput}
313
337
  disabled={disable}
338
+ readonly={readonly || null}
339
+ {spellcheck}
314
340
  bind:this={inputElement}
315
341
  maxlength={dataType === 'tel' ? null : maxLength}
316
342
  minlength={minLength}
@@ -375,8 +401,8 @@
375
401
  </div>
376
402
  {/if}
377
403
  {#if useTextArea && showCount && !actionInput}
378
- <div class="input-char-count" class:at-limit={charCount >= maxLength}>
379
- {charCount}/{maxLength}
404
+ <div class="input-char-count" class:at-limit={charCount >= effectiveMaxLength}>
405
+ {charCount}/{effectiveMaxLength}
380
406
  </div>
381
407
  {/if}
382
408
  </div>
@@ -386,6 +412,13 @@
386
412
  input {
387
413
  box-sizing: var(--input-box-sizing, border-box);
388
414
  height: var(--input-height, fit-content);
415
+
416
+ /* Both default to the CSS initial value, so a consumer that sets neither is
417
+ byte-identical to before. A textarea that grows with its content needs a
418
+ ceiling before it can scroll, and one used as a paste target needs a floor;
419
+ neither was reachable through the --input-* surface. */
420
+ min-height: var(--input-min-height, auto);
421
+ max-height: var(--input-max-height, none);
389
422
  background-color: var(--input-background, white);
390
423
  font-size: var(--input-font-size, 16px) !important;
391
424
  font-family: var(--input-font-family, inherit);
@@ -393,6 +426,12 @@
393
426
  outline: none;
394
427
  padding: var(--input-padding, 16px);
395
428
  font-weight: var(--input-font-weight, 500);
429
+
430
+ /* `normal` is what a textarea/input computes today regardless of any inherited
431
+ value — the UA sheet sets it, and inheritance loses to a UA declaration on the
432
+ element itself. So the default here is byte-identical for existing consumers,
433
+ and this is the only way a consumer can set it at all. */
434
+ line-height: var(--input-line-height, normal);
396
435
  width: var(--input-width, fit-content);
397
436
  margin: var(--input-margin, 0);
398
437
  appearance: none !important;
@@ -13,10 +13,28 @@ export type OptionalInputProperties = {
13
13
  infoMessage?: string | null;
14
14
  validators?: CustomValidator[];
15
15
  disable?: boolean;
16
+ /**
17
+ * Renders the field read-only: the value can be focused, selected and copied but not
18
+ * edited. Deliberately distinct from `disable`, which also removes the element from
19
+ * the focus order and so cannot serve a select-all-to-copy affordance.
20
+ */
21
+ readonly?: boolean;
22
+ /**
23
+ * Native `spellcheck`. Defaults to `null`, which Svelte renders as "attribute
24
+ * absent", so the browser default is unchanged for every existing consumer.
25
+ * Pass `false` for fields holding code, JSON or identifiers, where red
26
+ * squiggles are noise.
27
+ */
28
+ spellcheck?: boolean | null;
16
29
  validationPattern?: RegExp | null;
17
30
  inProgressPattern?: RegExp | null;
18
31
  addFocusColor?: boolean;
19
- maxLength?: number;
32
+ /**
33
+ * Native `maxlength`. Defaults to 1000. Pass `null` for no limit — a composer or
34
+ * paste target that silently truncates long input is worse than an unbounded one,
35
+ * and the attribute is rendered unconditionally otherwise.
36
+ */
37
+ maxLength?: number | null;
20
38
  minLength?: number;
21
39
  min?: number;
22
40
  max?: number;
@@ -10,6 +10,7 @@
10
10
  open = $bindable(false),
11
11
  testId,
12
12
  trigger,
13
+ interactiveTrigger = false,
13
14
  onselect,
14
15
  onopen,
15
16
  onclose,
@@ -199,9 +200,28 @@
199
200
  focusedIndex = -1;
200
201
  typeaheadQuery = '';
201
202
  onclose?.();
202
- if (triggerEl !== null) {
203
- triggerEl.focus({ preventScroll: true });
203
+ focusTrigger();
204
+ }
205
+
206
+ /**
207
+ * Returns focus to whatever is actually focusable for this trigger. Under
208
+ * `interactiveTrigger` the wrapper carries no tabindex, so focusing it is a no-op and
209
+ * focus falls to <body> — the control the snippet rendered is the real target.
210
+ */
211
+ function focusTrigger() {
212
+ if (triggerEl === null) {
213
+ return;
214
+ }
215
+ if (interactiveTrigger) {
216
+ const control = triggerEl.querySelector(
217
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
218
+ );
219
+ if (control instanceof HTMLElement) {
220
+ control.focus({ preventScroll: true });
221
+ return;
222
+ }
204
223
  }
224
+ triggerEl.focus({ preventScroll: true });
205
225
  }
206
226
 
207
227
  function selectItem(item: MenuItem) {
@@ -239,6 +259,23 @@
239
259
  }
240
260
  }
241
261
 
262
+ /**
263
+ * Keydown wiring handed to an `interactiveTrigger` snippet. Enter and Space are
264
+ * deliberately NOT handled here: the snippet owns a real `<button>`, which already
265
+ * synthesises a click from both, and that click is already wired to `toggle`. Handling
266
+ * them again would open a menu the click then immediately closes. Only the arrow keys —
267
+ * which no native button implements — are added.
268
+ */
269
+ function handleInteractiveTriggerKeydown(event: KeyboardEvent) {
270
+ if (event.key === 'ArrowDown') {
271
+ event.preventDefault();
272
+ openMenu();
273
+ } else if (event.key === 'ArrowUp') {
274
+ event.preventDefault();
275
+ openMenu(selectableItems.length - 1);
276
+ }
277
+ }
278
+
242
279
  function handleMenuKeydown(event: KeyboardEvent) {
243
280
  switch (event.key) {
244
281
  case 'ArrowDown': {
@@ -345,20 +382,45 @@
345
382
  data-pw={typeof testId === 'string' ? testId : null}
346
383
  testID={typeof testId === 'string' ? testId : null}
347
384
  >
348
- <div
349
- class="menu-trigger"
350
- bind:this={triggerEl}
351
- onclick={toggle}
352
- onkeydown={handleTriggerKeydown}
353
- role="button"
354
- tabindex="0"
355
- aria-haspopup="menu"
356
- aria-expanded={open}
357
- >
358
- {#if typeof trigger === 'function'}
359
- {@render trigger()}
360
- {/if}
361
- </div>
385
+ <!-- Two shapes on purpose. When the snippet renders its own interactive element this
386
+ wrapper must NOT be a second one: two focusable nodes for one conceptual trigger
387
+ means two Tab stops, both announcing as a button, and interactive content nested
388
+ inside interactive content. The wiring is handed to the snippet instead, for it to
389
+ spread onto the one real control. Written as two branches rather than conditional
390
+ attributes so role/tabindex stay statically paired — Svelte's a11y check reads them
391
+ together, and a dynamic pair trips a11y_no_noninteractive_tabindex. -->
392
+ {#if interactiveTrigger}
393
+ <div class="menu-trigger" bind:this={triggerEl}>
394
+ {#if typeof trigger === 'function'}
395
+ {@render trigger({
396
+ onclick: toggle,
397
+ onkeydown: handleInteractiveTriggerKeydown,
398
+ ariaHaspopup: 'menu',
399
+ ariaExpanded: open
400
+ })}
401
+ {/if}
402
+ </div>
403
+ {:else}
404
+ <div
405
+ class="menu-trigger"
406
+ bind:this={triggerEl}
407
+ onclick={toggle}
408
+ onkeydown={handleTriggerKeydown}
409
+ role="button"
410
+ tabindex="0"
411
+ aria-haspopup="menu"
412
+ aria-expanded={open}
413
+ >
414
+ {#if typeof trigger === 'function'}
415
+ {@render trigger({
416
+ onclick: toggle,
417
+ onkeydown: handleTriggerKeydown,
418
+ ariaHaspopup: 'menu',
419
+ ariaExpanded: open
420
+ })}
421
+ {/if}
422
+ </div>
423
+ {/if}
362
424
 
363
425
  {#if open}
364
426
  <div
@@ -1,4 +1,12 @@
1
1
  import type { Snippet } from 'svelte';
2
+ /** The interaction wiring Menu hands to its `trigger` snippet. */
3
+ export type MenuTriggerProps = {
4
+ onclick: (event: MouseEvent) => void;
5
+ onkeydown: (event: KeyboardEvent) => void;
6
+ /** camelCase to match the library's own prop convention, so it spreads onto Button. */
7
+ ariaHaspopup: 'menu';
8
+ ariaExpanded: boolean;
9
+ };
2
10
  export type MenuItem = {
3
11
  label: string;
4
12
  value: string;
@@ -23,7 +31,26 @@ export type MandatoryMenuProperties = {
23
31
  export type OptionalMenuProperties = {
24
32
  open?: boolean;
25
33
  testId?: string;
26
- trigger?: Snippet;
34
+ /**
35
+ * Renders the control that opens the menu. Receives Menu's interaction wiring, so a
36
+ * consumer whose trigger is ITSELF an interactive element (a Button, say) can spread
37
+ * it onto that element and set `interactiveTrigger` — see below. A snippet that
38
+ * declares no parameters simply ignores what it is handed, so existing triggers are
39
+ * unaffected.
40
+ */
41
+ trigger?: Snippet<[MenuTriggerProps]>;
42
+ /**
43
+ * Set when the trigger snippet renders its own interactive element. Menu then stops
44
+ * making its wrapper a second one.
45
+ *
46
+ * By default Menu wraps the trigger in a `role="button" tabindex="0"` div carrying
47
+ * the click/keydown handlers. That is correct for inert trigger content, but if the
48
+ * snippet renders a real control the result is two focusable elements for one
49
+ * conceptual trigger — two Tab stops, both announcing as a button, and interactive
50
+ * content nested inside interactive content. Defaults to `false`, preserving the
51
+ * existing behaviour for every current consumer.
52
+ */
53
+ interactiveTrigger?: boolean;
27
54
  classes?: string;
28
55
  /** Value of the currently selected item. When set, opening the menu focuses the
29
56
  * selected option instead of the first item, the matching item gets the
package/dist/types.d.ts CHANGED
@@ -1,9 +1,20 @@
1
1
  import type { FlyParams } from 'svelte/transition';
2
2
  /**
3
3
  * @name InputDataType
4
- * @description Different types of input data which can be passed to the Input Component
4
+ * @description Different types of input data which can be passed to the Input Component.
5
+ *
6
+ * Input renders `<input type={dataType}>`, so this union is the only thing deciding
7
+ * which native types a consumer may ask for. The four added beyond the original
8
+ * text/tel/password/email/number all keep `value` a plain string with no parallel
9
+ * `checked`/`files` model, so Input's existing value plumbing covers them unchanged.
10
+ * `validateInput()` switches on dataType with no default branch, and `number` has
11
+ * always fallen through it unvalidated — these fall through identically.
12
+ *
13
+ * Deliberately NOT included: checkbox and radio (driven by `checked`, which Input has
14
+ * no prop for), and file (rejects scripted `value` writes outright). Those need new
15
+ * props, not a wider union.
5
16
  */
6
- export type InputDataType = 'text' | 'tel' | 'password' | 'email' | 'number';
17
+ export type InputDataType = 'text' | 'tel' | 'password' | 'email' | 'number' | 'time' | 'date' | 'search' | 'url';
7
18
  export type ModalTransition = 'IN' | 'ALL';
8
19
  /**
9
20
  * @name CustomValidator
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.122.0",
3
+ "version": "2.124.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",