@spectral_labs/ui 1.3.1 → 1.4.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,4 +1,4 @@
1
- import { mount, unmount } from 'svelte';
1
+ import { flushSync, mount, unmount } from 'svelte';
2
2
  import { afterEach, describe, expect, it } from 'vitest';
3
3
  import PinInput from '../pin-input.svelte';
4
4
  describe('pin-input', () => {
@@ -23,4 +23,49 @@ describe('pin-input', () => {
23
23
  setup({ length: 6 });
24
24
  expect(target.querySelectorAll('input').length).toBe(6);
25
25
  });
26
+ it('uses the inputmode prop on slots', () => {
27
+ setup({ length: 4, inputmode: 'text' });
28
+ const input = target.querySelector('input');
29
+ expect(input.getAttribute('inputmode')).toBe('text');
30
+ });
31
+ it('defaults inputmode to numeric (backward compat)', () => {
32
+ setup({ length: 4 });
33
+ const input = target.querySelector('input');
34
+ expect(input.getAttribute('inputmode')).toBe('numeric');
35
+ });
36
+ it('uses ariaLabelPrefix in slot aria-labels', () => {
37
+ setup({ length: 4, ariaLabelPrefix: 'Character' });
38
+ const input = target.querySelector('input');
39
+ expect(input.getAttribute('aria-label')).toBe('Character 1 of 4');
40
+ });
41
+ it('pasteTransformer strips separators before distributing', () => {
42
+ setup({ length: 6, pasteTransformer: (raw) => raw.replace(/[^0-9A-Z]/gi, '').toUpperCase() });
43
+ const input = target.querySelector('input');
44
+ const ev = new Event('paste', { bubbles: true });
45
+ Object.defineProperty(ev, 'clipboardData', { value: { getData: () => 'ab-cd-ef' } });
46
+ input.dispatchEvent(ev);
47
+ flushSync();
48
+ const joined = Array.from(target.querySelectorAll('input')).map((i) => i.value).join('');
49
+ expect(joined).toBe('ABCDEF');
50
+ });
51
+ it('charTransformer uppercases typed input', () => {
52
+ setup({ length: 4, charTransformer: (c) => c.toUpperCase() });
53
+ const input = target.querySelector('input');
54
+ input.value = 'a';
55
+ input.dispatchEvent(new Event('input', { bubbles: true }));
56
+ flushSync();
57
+ expect(target.querySelectorAll('input')[0].value).toBe('A');
58
+ });
59
+ it('allowedPattern rejects disallowed typed characters', () => {
60
+ setup({ length: 4, allowedPattern: /[2-9A-HJKMNP-Z]/i });
61
+ const input = target.querySelector('input');
62
+ input.value = '0';
63
+ input.dispatchEvent(new Event('input', { bubbles: true }));
64
+ flushSync();
65
+ expect(target.querySelectorAll('input')[0].value).toBe('');
66
+ });
67
+ it('renders group separators when groupSize is set', () => {
68
+ setup({ length: 8, groupSize: 4 });
69
+ expect(target.querySelectorAll('.sp-pin-input-separator').length).toBe(1);
70
+ });
26
71
  });
@@ -12,19 +12,27 @@
12
12
  name,
13
13
  required = false,
14
14
  class: className,
15
+ inputmode = 'numeric',
16
+ ariaLabelPrefix = 'Digit',
17
+ pasteTransformer,
18
+ charTransformer,
19
+ allowedPattern,
15
20
  }: PinInputProps = $props()
16
21
 
17
22
  const inputs: HTMLInputElement[] = $state.raw([])
18
23
 
19
24
  function handleInput(index: number, event: Event) {
20
25
  const target = event.target as HTMLInputElement
21
- const char = target.value.slice(-1)
26
+ let char = target.value.slice(-1)
27
+ if (char && allowedPattern && !allowedPattern.test(char)) {
28
+ target.value = value[index] ?? '' // reject: value state unchanged ⇒ Svelte won't re-render this slot
29
+ return
30
+ }
31
+ if (char && charTransformer) char = charTransformer(char)
22
32
  const chars = value.split('')
23
33
  chars[index] = char
24
34
  value = chars.join('').slice(0, length)
25
-
26
35
  if (char && index < length - 1) inputs[index + 1]?.focus()
27
-
28
36
  if (value.length === length) onComplete?.(value)
29
37
  }
30
38
 
@@ -45,38 +53,31 @@
45
53
 
46
54
  function handlePaste(event: ClipboardEvent) {
47
55
  event.preventDefault()
48
- const pasted = event.clipboardData?.getData('text')?.slice(0, length) ?? ''
49
- value = pasted
50
- const nextIndex = Math.min(pasted.length, length - 1)
56
+ const raw = event.clipboardData?.getData('text') ?? ''
57
+ const cleaned = pasteTransformer ? pasteTransformer(raw) : raw
58
+ value = cleaned.slice(0, length)
59
+ const nextIndex = Math.min(value.length, length - 1)
51
60
  inputs[nextIndex]?.focus()
52
- if (pasted.length === length) onComplete?.(pasted)
61
+ if (value.length === length) onComplete?.(value)
53
62
  }
54
63
  </script>
55
64
 
56
65
  <div class="sp-pin-input {className ?? ''}" role="group">
57
66
  {#each { length } as _, i (i)}
58
67
  {#if groupSize > 0 && i > 0 && i % groupSize === 0}
59
- <span class="sp-pin-input-separator" aria-hidden="true">
60
- <svg
61
- viewBox="0 0 24 24"
62
- fill="none"
63
- stroke="currentColor"
64
- stroke-width="2"
65
- stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12" /></svg
66
- >
67
- </span>
68
+ <span class="sp-pin-input-separator" aria-hidden="true"></span>
68
69
  {/if}
69
70
  <input
70
71
  bind:this={inputs[i]}
71
72
  class="sp-pin-input-slot"
72
73
  type={mask ? 'password' : 'text'}
73
- inputmode="numeric"
74
+ inputmode={inputmode}
74
75
  maxlength={1}
75
76
  autocomplete="off"
76
77
  {placeholder}
77
78
  {disabled}
78
79
  value={value[i] ?? ''}
79
- aria-label={`Digit ${i + 1} of ${length}`}
80
+ aria-label={`${ariaLabelPrefix} ${i + 1} of ${length}`}
80
81
  oninput={(e) => handleInput(i, e)}
81
82
  onkeydown={(e) => handleKeydown(i, e)}
82
83
  onpaste={handlePaste}
@@ -96,14 +97,14 @@
96
97
  }
97
98
 
98
99
  .sp-pin-input-separator {
99
- display: flex;
100
- align-items: center;
101
- color: var(--sp-sys-color-outline);
102
- }
103
-
104
- .sp-pin-input-separator svg {
105
- width: 16px;
106
- height: 16px;
100
+ /* A reliable CSS dash between groups. The previous inline <svg viewBox> rendered at 0 width
101
+ (no intrinsic size), so groupSize separators were invisible. */
102
+ flex: 0 0 auto;
103
+ align-self: center;
104
+ width: 0.75rem;
105
+ height: 2px;
106
+ border-radius: 999px;
107
+ background-color: var(--sp-sys-color-outline);
107
108
  }
108
109
 
109
110
  .sp-pin-input-slot {
@@ -132,6 +133,12 @@
132
133
  opacity: 0.5;
133
134
  }
134
135
 
136
+ /* Empty focused slot: hide the placeholder so the caret never overlaps the ○.
137
+ Stable class hook (.sp-pin-input-slot), no generated s-... classes. */
138
+ .sp-pin-input-slot:focus::placeholder {
139
+ color: transparent;
140
+ }
141
+
135
142
  .sp-pin-input-slot:disabled {
136
143
  opacity: 0.38;
137
144
  cursor: not-allowed;
@@ -1 +1 @@
1
- {"version":3,"file":"pin-input.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/components/pin-input/pin-input.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA4EhD,QAAA,MAAM,QAAQ,wDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"pin-input.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/components/pin-input/pin-input.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAmFhD,QAAA,MAAM,QAAQ,wDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -19,5 +19,15 @@ export interface PinInputProps {
19
19
  required?: boolean;
20
20
  /** Additional CSS class. */
21
21
  class?: string;
22
+ /** Virtual-keyboard hint applied to every slot. Default 'numeric' (PIN). Use 'text' for alphanumeric codes. */
23
+ inputmode?: 'numeric' | 'text' | 'tel' | 'email' | 'url' | 'decimal' | 'search' | 'none';
24
+ /** Noun used in each slot's aria-label: `${ariaLabelPrefix} N of M`. Default 'Digit'. */
25
+ ariaLabelPrefix?: string;
26
+ /** Transform pasted clipboard text BEFORE distributing into slots (e.g. strip separators, uppercase). */
27
+ pasteTransformer?: (raw: string) => string;
28
+ /** Transform each typed character before storing (e.g. uppercase). Applied only to characters accepted by `allowedPattern`. */
29
+ charTransformer?: (char: string) => string;
30
+ /** When set, a typed character is accepted only if it matches this pattern (paste is filtered by pasteTransformer instead). */
31
+ allowedPattern?: RegExp;
22
32
  }
23
33
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/pin-input/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,gGAAgG;IAChG,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,sCAAsC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/pin-input/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,gGAAgG;IAChG,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,sCAAsC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,+GAA+G;IAC/G,SAAS,CAAC,EAAE,SAAS,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;IACxF,yFAAyF;IACzF,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,yGAAyG;IACzG,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IAC1C,+HAA+H;IAC/H,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IAC1C,+HAA+H;IAC/H,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.3.1",
4
+ "version": "1.4.0",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"