@spectral_labs/ui 1.3.1 → 1.5.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.
- package/dist/components/combobox/components/combobox-item.svelte +1 -1
- package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +25 -0
- package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +67 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte +37 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.d.ts.map +1 -1
- package/dist/components/menubar/__tests__/MenubarTest.svelte +17 -1
- package/dist/components/menubar/__tests__/menubar.test.svelte.js +35 -0
- package/dist/components/pin-input/__tests__/pin-input.test.svelte.js +46 -1
- package/dist/components/pin-input/pin-input.svelte +33 -26
- package/dist/components/pin-input/pin-input.svelte.d.ts.map +1 -1
- package/dist/components/pin-input/types.d.ts +10 -0
- package/dist/components/pin-input/types.d.ts.map +1 -1
- package/dist/components/select/components/select-item.svelte +1 -1
- package/package.json +1 -1
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
align-items: center;
|
|
56
56
|
gap: 0.5rem;
|
|
57
57
|
padding: 0.5rem 0.75rem;
|
|
58
|
-
border-radius: var(--sp-sys-shape-corner-
|
|
58
|
+
border-radius: var(--sp-sys-shape-corner-md, 12px);
|
|
59
59
|
font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
|
|
60
60
|
color: var(--sp-sys-color-on-surface);
|
|
61
61
|
cursor: pointer;
|
|
@@ -2,24 +2,49 @@
|
|
|
2
2
|
import DropdownMenuContent from '../components/dropdown-menu-content.svelte'
|
|
3
3
|
import DropdownMenuItem from '../components/dropdown-menu-item.svelte'
|
|
4
4
|
import DropdownMenuRoot from '../components/dropdown-menu-root.svelte'
|
|
5
|
+
import DropdownMenuSubContent from '../components/dropdown-menu-sub-content.svelte'
|
|
6
|
+
import DropdownMenuSubTrigger from '../components/dropdown-menu-sub-trigger.svelte'
|
|
7
|
+
import DropdownMenuSub from '../components/dropdown-menu-sub.svelte'
|
|
5
8
|
import DropdownMenuTrigger from '../components/dropdown-menu-trigger.svelte'
|
|
6
9
|
|
|
7
10
|
let {
|
|
8
11
|
open = $bindable(false),
|
|
9
12
|
portalTo,
|
|
10
13
|
onOpenChange,
|
|
14
|
+
withSub = false,
|
|
15
|
+
subFirst = false,
|
|
16
|
+
subDisabled = false,
|
|
11
17
|
}: {
|
|
12
18
|
open?: boolean
|
|
13
19
|
portalTo?: HTMLElement | string
|
|
14
20
|
onOpenChange?: (open: boolean) => void
|
|
21
|
+
withSub?: boolean
|
|
22
|
+
subFirst?: boolean
|
|
23
|
+
subDisabled?: boolean
|
|
15
24
|
} = $props()
|
|
16
25
|
</script>
|
|
17
26
|
|
|
27
|
+
{#snippet subBlock()}
|
|
28
|
+
<DropdownMenuSub>
|
|
29
|
+
<DropdownMenuSubTrigger disabled={subDisabled}>More options</DropdownMenuSubTrigger>
|
|
30
|
+
<DropdownMenuSubContent>
|
|
31
|
+
<DropdownMenuItem>Sub Item 1</DropdownMenuItem>
|
|
32
|
+
<DropdownMenuItem>Sub Item 2</DropdownMenuItem>
|
|
33
|
+
</DropdownMenuSubContent>
|
|
34
|
+
</DropdownMenuSub>
|
|
35
|
+
{/snippet}
|
|
36
|
+
|
|
18
37
|
<DropdownMenuRoot bind:open {onOpenChange}>
|
|
19
38
|
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
|
20
39
|
<DropdownMenuContent {portalTo}>
|
|
40
|
+
{#if withSub && subFirst}
|
|
41
|
+
{@render subBlock()}
|
|
42
|
+
{/if}
|
|
21
43
|
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
|
22
44
|
<DropdownMenuItem>Item 2</DropdownMenuItem>
|
|
23
45
|
<DropdownMenuItem>Item 3</DropdownMenuItem>
|
|
46
|
+
{#if withSub && !subFirst}
|
|
47
|
+
{@render subBlock()}
|
|
48
|
+
{/if}
|
|
24
49
|
</DropdownMenuContent>
|
|
25
50
|
</DropdownMenuRoot>
|
|
@@ -60,4 +60,71 @@ describe('dropdown-menu', () => {
|
|
|
60
60
|
flushSync();
|
|
61
61
|
expect(calls).toEqual([true, false]);
|
|
62
62
|
});
|
|
63
|
+
describe('subTrigger', () => {
|
|
64
|
+
function pressKey(key) {
|
|
65
|
+
const el = document.activeElement;
|
|
66
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
67
|
+
flushSync();
|
|
68
|
+
}
|
|
69
|
+
it('participates in roving focus (data-roving-focus-item + tabindex -1)', () => {
|
|
70
|
+
setup({ open: true, withSub: true });
|
|
71
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
72
|
+
expect(subTrigger).toBeTruthy();
|
|
73
|
+
expect(subTrigger.hasAttribute('data-roving-focus-item')).toBe(true);
|
|
74
|
+
expect(subTrigger.getAttribute('tabindex')).toBe('-1');
|
|
75
|
+
});
|
|
76
|
+
it('carries both item and sub-trigger classes (visual parity hook)', () => {
|
|
77
|
+
setup({ open: true, withSub: true });
|
|
78
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
79
|
+
expect(subTrigger.classList.contains('sp-dropdown-menu-item')).toBe(true);
|
|
80
|
+
expect(subTrigger.classList.contains('sp-dropdown-sub-trigger')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
it('is autofocused on open when it is the first row', () => {
|
|
83
|
+
setup({ open: true, withSub: true, subFirst: true });
|
|
84
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
85
|
+
expect(document.activeElement).toBe(subTrigger);
|
|
86
|
+
});
|
|
87
|
+
it('is reached by ArrowDown/ArrowUp roving among plain items', () => {
|
|
88
|
+
setup({ open: true, withSub: true });
|
|
89
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
90
|
+
// Autofocus lands on Item 1; sub-trigger is the 4th row.
|
|
91
|
+
expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
|
|
92
|
+
pressKey('ArrowDown');
|
|
93
|
+
pressKey('ArrowDown');
|
|
94
|
+
pressKey('ArrowDown');
|
|
95
|
+
expect(document.activeElement).toBe(subTrigger);
|
|
96
|
+
pressKey('ArrowUp');
|
|
97
|
+
expect(document.activeElement?.textContent?.trim()).toBe('Item 3');
|
|
98
|
+
});
|
|
99
|
+
it('is skipped by autofocus and roving when disabled', () => {
|
|
100
|
+
setup({ open: true, withSub: true, subFirst: true, subDisabled: true });
|
|
101
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
102
|
+
expect(subTrigger.hasAttribute('data-disabled')).toBe(true);
|
|
103
|
+
// Autofocus skips the disabled sub-trigger even though it is first.
|
|
104
|
+
expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
|
|
105
|
+
// Roving loops over the three plain items without ever landing on it.
|
|
106
|
+
pressKey('ArrowDown');
|
|
107
|
+
pressKey('ArrowDown');
|
|
108
|
+
pressKey('ArrowDown');
|
|
109
|
+
expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
|
|
110
|
+
pressKey('ArrowUp');
|
|
111
|
+
expect(document.activeElement?.textContent?.trim()).toBe('Item 3');
|
|
112
|
+
});
|
|
113
|
+
it('opens the sub content on ArrowRight and reflects aria-expanded', () => {
|
|
114
|
+
setup({ open: true, withSub: true, subFirst: true });
|
|
115
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
116
|
+
expect(document.activeElement).toBe(subTrigger);
|
|
117
|
+
expect(subTrigger.getAttribute('aria-expanded')).toBe('false');
|
|
118
|
+
expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
|
|
119
|
+
pressKey('ArrowRight');
|
|
120
|
+
expect(subTrigger.getAttribute('aria-expanded')).toBe('true');
|
|
121
|
+
expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
it('opens the sub content on Enter', () => {
|
|
124
|
+
setup({ open: true, withSub: true, subFirst: true });
|
|
125
|
+
expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
|
|
126
|
+
pressKey('Enter');
|
|
127
|
+
expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
63
130
|
});
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
align-items: center;
|
|
58
58
|
gap: 0.5rem;
|
|
59
59
|
padding: 0.5rem 0.75rem;
|
|
60
|
-
border-radius: var(--sp-sys-shape-corner-
|
|
60
|
+
border-radius: var(--sp-sys-shape-corner-md, 12px);
|
|
61
61
|
font-family: var(--sp-sys-typescale-body-medium-font, inherit);
|
|
62
62
|
font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
|
|
63
63
|
color: var(--sp-sys-color-on-surface);
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
align-items: center;
|
|
46
46
|
gap: 0.5rem;
|
|
47
47
|
padding: 0.5rem 0.75rem;
|
|
48
|
-
border-radius: var(--sp-sys-shape-corner-
|
|
48
|
+
border-radius: var(--sp-sys-shape-corner-md, 12px);
|
|
49
49
|
font-family: var(--sp-sys-typescale-body-medium-font, inherit);
|
|
50
50
|
font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
|
|
51
51
|
color: var(--sp-sys-color-on-surface);
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
if (disabled) return
|
|
30
30
|
if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
|
|
31
31
|
e.preventDefault()
|
|
32
|
+
// The key is fully handled here: stop it from bubbling to ancestor menu
|
|
33
|
+
// keydown handlers (menubar-content treats ArrowRight as "close menu +
|
|
34
|
+
// open the next menubar menu", which would immediately undo openSub;
|
|
35
|
+
// Space would also leak into content-level typeahead). Same pattern as
|
|
36
|
+
// dropdown-menu-sub-content.svelte for ArrowLeft/Escape.
|
|
37
|
+
e.stopPropagation()
|
|
32
38
|
sub.openSub()
|
|
33
39
|
}
|
|
34
40
|
}
|
|
@@ -47,7 +53,8 @@
|
|
|
47
53
|
aria-expanded={sub.open}
|
|
48
54
|
aria-disabled={disabled || undefined}
|
|
49
55
|
data-disabled={disabled || undefined}
|
|
50
|
-
|
|
56
|
+
data-roving-focus-item
|
|
57
|
+
tabindex={-1}
|
|
51
58
|
onpointerenter={onPointerEnter}
|
|
52
59
|
onpointerleave={onPointerLeave}
|
|
53
60
|
onkeydown={onKeyDown}
|
|
@@ -71,11 +78,40 @@
|
|
|
71
78
|
</div>
|
|
72
79
|
|
|
73
80
|
<style>
|
|
81
|
+
/* Svelte scoped styles don't cross components: sharing the `sp-dropdown-menu-item`
|
|
82
|
+
class name inherits nothing from dropdown-menu-item.svelte. The item base styles
|
|
83
|
+
are duplicated here (same pattern as dropdown-menu-checkbox-item.svelte) and must
|
|
84
|
+
stay in sync with dropdown-menu-item.svelte. */
|
|
74
85
|
.sp-dropdown-sub-trigger {
|
|
75
86
|
display: flex;
|
|
76
87
|
align-items: center;
|
|
77
88
|
justify-content: space-between;
|
|
78
89
|
gap: 0.5rem;
|
|
90
|
+
padding: 0.5rem 0.75rem;
|
|
91
|
+
border-radius: var(--sp-sys-shape-corner-md, 12px);
|
|
92
|
+
font-family: var(--sp-sys-typescale-body-medium-font, inherit);
|
|
93
|
+
font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
|
|
94
|
+
color: var(--sp-sys-color-on-surface);
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
user-select: none;
|
|
97
|
+
outline: none;
|
|
98
|
+
transition: background-color var(--sp-sys-motion-duration-short2, 100ms)
|
|
99
|
+
var(--sp-sys-motion-easing-effects, ease);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sp-dropdown-sub-trigger:focus,
|
|
103
|
+
.sp-dropdown-sub-trigger:hover:not([data-disabled]),
|
|
104
|
+
.sp-dropdown-sub-trigger[aria-expanded='true'] {
|
|
105
|
+
background-color: color-mix(
|
|
106
|
+
in oklch,
|
|
107
|
+
var(--sp-sys-color-on-surface) calc(var(--sp-sys-state-hover-opacity, 0.08) * 100%),
|
|
108
|
+
transparent
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.sp-dropdown-sub-trigger[data-disabled] {
|
|
113
|
+
opacity: 0.38;
|
|
114
|
+
cursor: not-allowed;
|
|
79
115
|
}
|
|
80
116
|
|
|
81
117
|
.sp-dropdown-sub-trigger-label {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dropdown-menu-sub-trigger.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"dropdown-menu-sub-trigger.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AA+D9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import DropdownMenuItem from '../../dropdown-menu/components/dropdown-menu-item.svelte'
|
|
3
3
|
import DropdownMenuSeparator from '../../dropdown-menu/components/dropdown-menu-separator.svelte'
|
|
4
|
+
import DropdownMenuSubContent from '../../dropdown-menu/components/dropdown-menu-sub-content.svelte'
|
|
5
|
+
import DropdownMenuSubTrigger from '../../dropdown-menu/components/dropdown-menu-sub-trigger.svelte'
|
|
6
|
+
import DropdownMenuSub from '../../dropdown-menu/components/dropdown-menu-sub.svelte'
|
|
4
7
|
import MenubarContent from '../components/menubar-content.svelte'
|
|
5
8
|
import MenubarMenu from '../components/menubar-menu.svelte'
|
|
6
9
|
import MenubarRoot from '../components/menubar-root.svelte'
|
|
7
10
|
import MenubarTrigger from '../components/menubar-trigger.svelte'
|
|
8
11
|
|
|
9
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
loop = true,
|
|
14
|
+
portalTo,
|
|
15
|
+
withSub = false,
|
|
16
|
+
}: { loop?: boolean; portalTo?: HTMLElement | string; withSub?: boolean } = $props()
|
|
10
17
|
</script>
|
|
11
18
|
|
|
12
19
|
<MenubarRoot {loop}>
|
|
@@ -17,6 +24,15 @@
|
|
|
17
24
|
<DropdownMenuItem>Open</DropdownMenuItem>
|
|
18
25
|
<DropdownMenuSeparator />
|
|
19
26
|
<DropdownMenuItem>Save</DropdownMenuItem>
|
|
27
|
+
{#if withSub}
|
|
28
|
+
<DropdownMenuSub>
|
|
29
|
+
<DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
|
|
30
|
+
<DropdownMenuSubContent>
|
|
31
|
+
<DropdownMenuItem>Email</DropdownMenuItem>
|
|
32
|
+
<DropdownMenuItem>Copy Link</DropdownMenuItem>
|
|
33
|
+
</DropdownMenuSubContent>
|
|
34
|
+
</DropdownMenuSub>
|
|
35
|
+
{/if}
|
|
20
36
|
</MenubarContent>
|
|
21
37
|
</MenubarMenu>
|
|
22
38
|
<MenubarMenu value="edit">
|
|
@@ -195,4 +195,39 @@ describe('menubar', () => {
|
|
|
195
195
|
// No menu should open from hover alone
|
|
196
196
|
expect(target.querySelector('[role="menu"]')).toBeNull();
|
|
197
197
|
});
|
|
198
|
+
// --- SubTrigger inside a menubar menu ---
|
|
199
|
+
describe('subTrigger', () => {
|
|
200
|
+
function pressKey(key) {
|
|
201
|
+
const el = document.activeElement;
|
|
202
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
|
203
|
+
flushSync();
|
|
204
|
+
}
|
|
205
|
+
it('arrowRight on a plain item moves to the next menubar menu', () => {
|
|
206
|
+
setup({ withSub: true });
|
|
207
|
+
const triggers = target.querySelectorAll('[data-menubar-trigger]');
|
|
208
|
+
triggers[0].click();
|
|
209
|
+
flushSync();
|
|
210
|
+
// Autofocus lands on the first item of the opened menu
|
|
211
|
+
expect(document.activeElement?.textContent?.trim()).toBe('New');
|
|
212
|
+
pressKey('ArrowRight');
|
|
213
|
+
expect(triggers[0].getAttribute('data-state')).toBe('closed');
|
|
214
|
+
expect(triggers[1].getAttribute('data-state')).toBe('open');
|
|
215
|
+
});
|
|
216
|
+
it('arrowRight on the sub-trigger opens the submenu without switching menubar menus', () => {
|
|
217
|
+
setup({ withSub: true });
|
|
218
|
+
const triggers = target.querySelectorAll('[data-menubar-trigger]');
|
|
219
|
+
triggers[0].click();
|
|
220
|
+
flushSync();
|
|
221
|
+
const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
|
|
222
|
+
expect(subTrigger).toBeTruthy();
|
|
223
|
+
subTrigger.focus();
|
|
224
|
+
pressKey('ArrowRight');
|
|
225
|
+
// Submenu opened…
|
|
226
|
+
expect(subTrigger.getAttribute('aria-expanded')).toBe('true');
|
|
227
|
+
expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
|
|
228
|
+
// …and the menubar neither closed the current menu nor advanced to the next one
|
|
229
|
+
expect(triggers[0].getAttribute('data-state')).toBe('open');
|
|
230
|
+
expect(triggers[1].getAttribute('data-state')).toBe('closed');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
198
233
|
});
|
|
@@ -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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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 (
|
|
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=
|
|
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={
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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;
|
|
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;
|
|
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"}
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
align-items: center;
|
|
66
66
|
gap: 0.5rem;
|
|
67
67
|
padding: 0.5rem 0.75rem;
|
|
68
|
-
border-radius: var(--sp-sys-shape-corner-
|
|
68
|
+
border-radius: var(--sp-sys-shape-corner-md, 12px);
|
|
69
69
|
font-family: var(--sp-sys-typescale-body-medium-font, inherit);
|
|
70
70
|
font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
|
|
71
71
|
color: var(--sp-sys-color-on-surface);
|