@spectral_labs/ui 1.3.0 → 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.
Files changed (25) hide show
  1. package/dist/components/grid-list/__tests__/GridListTest.svelte +1 -1
  2. package/dist/components/grid-list/components/grid-list-root.svelte +2 -2
  3. package/dist/components/grid-list/components/grid-list-root.svelte.d.ts +23 -2
  4. package/dist/components/grid-list/components/grid-list-root.svelte.d.ts.map +1 -1
  5. package/dist/components/pin-input/__tests__/pin-input.test.svelte.js +46 -1
  6. package/dist/components/pin-input/pin-input.svelte +33 -26
  7. package/dist/components/pin-input/pin-input.svelte.d.ts.map +1 -1
  8. package/dist/components/pin-input/types.d.ts +10 -0
  9. package/dist/components/pin-input/types.d.ts.map +1 -1
  10. package/dist/components/sortable-list/__tests__/NestedSortableListTest.svelte +49 -0
  11. package/dist/components/sortable-list/__tests__/SortableListTest.svelte +1 -1
  12. package/dist/components/sortable-list/__tests__/sortable-list.nested.test.svelte.js +112 -0
  13. package/dist/components/sortable-list/components/sortable-list-root.svelte +2 -2
  14. package/dist/components/sortable-list/components/sortable-list-root.svelte.d.ts +23 -2
  15. package/dist/components/sortable-list/components/sortable-list-root.svelte.d.ts.map +1 -1
  16. package/dist/components/virtualizer/components/virtualizer-root.svelte +2 -2
  17. package/dist/components/virtualizer/components/virtualizer-root.svelte.d.ts +23 -2
  18. package/dist/components/virtualizer/components/virtualizer-root.svelte.d.ts.map +1 -1
  19. package/dist/internal/drag-drop/__tests__/drag-session.test.svelte.js +99 -13
  20. package/dist/internal/drag-drop/__tests__/drag-source.test.svelte.js +130 -0
  21. package/dist/internal/drag-drop/drag-session.svelte.d.ts.map +1 -1
  22. package/dist/internal/drag-drop/drag-session.svelte.js +14 -3
  23. package/dist/internal/drag-drop/drag-source.svelte.d.ts.map +1 -1
  24. package/dist/internal/drag-drop/drag-source.svelte.js +4 -0
  25. package/package.json +1 -1
@@ -38,7 +38,7 @@
38
38
  {onSelectionChange}
39
39
  aria-label="Test grid"
40
40
  >
41
- {#snippet item(itemData: TestItem)}
41
+ {#snippet item(itemData)}
42
42
  <Item id={itemData.id} textValue={itemData.label}>
43
43
  {itemData.label}
44
44
  </Item>
@@ -1,4 +1,4 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="T extends GridListItemData">
2
2
  import type { GridListItemData, GridListProps } from '../types.js'
3
3
  import { dragSession } from '../../../internal/drag-drop/index.js'
4
4
  import { isBrowser } from '../../../internal/helpers/index.js'
@@ -27,7 +27,7 @@
27
27
  onSelectionChange,
28
28
  item: itemSnippet,
29
29
  empty: emptySnippet,
30
- }: GridListProps<GridListItemData> = $props()
30
+ }: GridListProps<T> = $props()
31
31
 
32
32
  let containerEl: HTMLElement | undefined = $state()
33
33
  let indicatorPortalEl: HTMLDivElement | null = null
@@ -1,5 +1,26 @@
1
1
  import type { GridListItemData, GridListProps } from '../types.js';
2
- declare const GridListRoot: import("svelte").Component<GridListProps<GridListItemData>, {}, "selectedKeys">;
3
- type GridListRoot = ReturnType<typeof GridListRoot>;
2
+ declare function $$render<T extends GridListItemData>(): {
3
+ props: GridListProps<T>;
4
+ exports: {};
5
+ bindings: "selectedKeys";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<T extends GridListItemData> {
10
+ props(): ReturnType<typeof $$render<T>>['props'];
11
+ events(): ReturnType<typeof $$render<T>>['events'];
12
+ slots(): ReturnType<typeof $$render<T>>['slots'];
13
+ bindings(): "selectedKeys";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <T extends GridListItemData>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<T>['exports']>;
20
+ <T extends GridListItemData>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const GridListRoot: $$IsomorphicComponent;
24
+ type GridListRoot<T extends GridListItemData> = InstanceType<typeof GridListRoot<T>>;
4
25
  export default GridListRoot;
5
26
  //# sourceMappingURL=grid-list-root.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"grid-list-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/grid-list/components/grid-list-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AA2KlE,QAAA,MAAM,YAAY,iFAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"grid-list-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/grid-list/components/grid-list-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAIlE,iBAAS,QAAQ,CAAC,CAAC,SAAS,gBAAgB;WAsKf,aAAa,CAAC,CAAC,CAAC;;;;;EAAwF;AACrI,cAAM,iBAAiB,CAAC,CAAC,SAAS,gBAAgB;IAC9C,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAChD,MAAM,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IAClD,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAChD,QAAQ;IACR,OAAO;CACV;AAED,UAAU,qBAAqB;IAC3B,KAAK,CAAC,SAAS,gBAAgB,EAAE,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;KAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IACrZ,CAAC,CAAC,SAAS,gBAAgB,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IACpJ,YAAY,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;CACjE;AACD,QAAA,MAAM,YAAY,EAAE,qBAAmC,CAAC;AACtC,KAAK,YAAY,CAAC,CAAC,SAAS,gBAAgB,IAAI,YAAY,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;AACvF,eAAe,YAAY,CAAC"}
@@ -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"}
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import * as SortableList from '../index.js'
3
+
4
+ interface Block {
5
+ id: string
6
+ label: string
7
+ }
8
+
9
+ interface OuterBlock extends Block {
10
+ children?: Block[]
11
+ }
12
+
13
+ let {
14
+ items = $bindable<OuterBlock[]>([
15
+ { id: 'title', label: 'Title' },
16
+ {
17
+ id: 'container',
18
+ label: 'Container',
19
+ children: [
20
+ { id: 'inner-1', label: 'Inner 1' },
21
+ { id: 'inner-2', label: 'Inner 2' },
22
+ ],
23
+ },
24
+ { id: 'author', label: 'Author' },
25
+ { id: 'place', label: 'Place' },
26
+ ]),
27
+ }: {
28
+ items?: OuterBlock[]
29
+ } = $props()
30
+ </script>
31
+
32
+ <SortableList.Root bind:items group="nested-blocks" aria-label="Outer list">
33
+ {#snippet item(data: OuterBlock)}
34
+ <SortableList.Item id={data.id}>
35
+ {data.label}
36
+ {#if data.children}
37
+ <SortableList.Root
38
+ items={data.children}
39
+ group="nested-blocks"
40
+ aria-label="Inner list"
41
+ >
42
+ {#snippet item(child: Block)}
43
+ <SortableList.Item id={child.id}>{child.label}</SortableList.Item>
44
+ {/snippet}
45
+ </SortableList.Root>
46
+ {/if}
47
+ </SortableList.Item>
48
+ {/snippet}
49
+ </SortableList.Root>
@@ -33,7 +33,7 @@
33
33
  {onReorder}
34
34
  aria-label="Sortable test"
35
35
  >
36
- {#snippet item(data: Item)}
36
+ {#snippet item(data)}
37
37
  <SortableList.Item id={data.id}>
38
38
  <SortableList.Handle>&#x22EE;&#x22EE;</SortableList.Handle>
39
39
  {data.label}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * SortableList nested lists — regression lock for #190.
3
+ *
4
+ * A Root nested inside an Item (same group) used to freeze the ghost:
5
+ * the pointerdown bubbled from the inner item to the outer item (both drag
6
+ * sources), and the second beginPointerDrag clobbered the pending session
7
+ * before the movement threshold was met.
8
+ *
9
+ * These tests drive the pointer path far enough in jsdom to lock the claim
10
+ * semantics (innermost source wins) and the clean end of the session.
11
+ * Full spatial behavior (drop positions, indicators) stays in Playwright.
12
+ */
13
+ import { flushSync, mount, unmount } from 'svelte';
14
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
15
+ import { dragSession } from '../../../internal/drag-drop/index.js';
16
+ import NestedSortableListTest from './NestedSortableListTest.svelte';
17
+ // Polyfill browser APIs absent from jsdom
18
+ if (typeof PointerEvent === 'undefined') {
19
+ ;
20
+ globalThis.PointerEvent = class PointerEvent extends (MouseEvent) {
21
+ pointerId;
22
+ pointerType;
23
+ constructor(type, init = {}) {
24
+ super(type, init);
25
+ this.pointerId = init.pointerId ?? 0;
26
+ this.pointerType = init.pointerType ?? '';
27
+ }
28
+ };
29
+ }
30
+ function createMousePointerEvent(type, x, y) {
31
+ return new PointerEvent(type, {
32
+ clientX: x,
33
+ clientY: y,
34
+ pointerId: 1,
35
+ pointerType: 'mouse',
36
+ bubbles: true,
37
+ });
38
+ }
39
+ describe('sortableList — nested Root inside an Item (#190)', () => {
40
+ let target;
41
+ let instance;
42
+ beforeAll(() => {
43
+ if (!HTMLElement.prototype.setPointerCapture) {
44
+ HTMLElement.prototype.setPointerCapture = () => { };
45
+ HTMLElement.prototype.releasePointerCapture = () => { };
46
+ }
47
+ });
48
+ function setup() {
49
+ dragSession._resetForTests();
50
+ target = document.createElement('div');
51
+ document.body.appendChild(target);
52
+ instance = mount(NestedSortableListTest, { target, props: {} });
53
+ flushSync();
54
+ }
55
+ afterEach(() => {
56
+ if (instance) {
57
+ unmount(instance);
58
+ }
59
+ instance = undefined;
60
+ target?.remove();
61
+ dragSession._resetForTests();
62
+ });
63
+ function getItem(id) {
64
+ const el = target.querySelector(`[data-dnd-key="${id}"]`);
65
+ if (!el)
66
+ throw new Error(`item ${id} not found`);
67
+ return el;
68
+ }
69
+ it('renders both lists (outer 4 items, inner 2 items)', () => {
70
+ setup();
71
+ const lists = target.querySelectorAll('.sp-sortable-list');
72
+ expect(lists).toHaveLength(2);
73
+ expect(target.querySelectorAll('[data-dnd-key]')).toHaveLength(6);
74
+ });
75
+ it('pointerdown on an inner item claims the inner source, not the outer item', () => {
76
+ setup();
77
+ const inner = getItem('inner-1');
78
+ inner.dispatchEvent(createMousePointerEvent('pointerdown', 100, 100));
79
+ expect(dragSession.sourceId).not.toBeNull();
80
+ expect(dragSession.sourceId.endsWith('-item-inner-1')).toBe(true);
81
+ dragSession.cancel();
82
+ });
83
+ it('dragging an inner item past the threshold starts and ends a clean session', () => {
84
+ setup();
85
+ const inner = getItem('inner-1');
86
+ inner.dispatchEvent(createMousePointerEvent('pointerdown', 100, 100));
87
+ inner.dispatchEvent(createMousePointerEvent('pointermove', 112, 112));
88
+ expect(dragSession.isDragging).toBe(true);
89
+ expect(dragSession.sourceId.endsWith('-item-inner-1')).toBe(true);
90
+ expect(dragSession.draggingKeys.has('inner-1')).toBe(true);
91
+ expect(document.querySelector('.sp-drag-ghost')).not.toBeNull();
92
+ // Releasing the pointer must end the session — this is where the ghost
93
+ // used to stay frozen on screen before the fix.
94
+ inner.dispatchEvent(createMousePointerEvent('pointerup', 112, 112));
95
+ expect(dragSession.isDragging).toBe(false);
96
+ expect(dragSession.sourceId).toBeNull();
97
+ expect(document.querySelector('.sp-drag-ghost')).toBeNull();
98
+ });
99
+ it('dragging an outer item (the container itself) still works', () => {
100
+ setup();
101
+ const container = getItem('container');
102
+ // pointerdown directly on the container item, outside the inner list
103
+ container.dispatchEvent(createMousePointerEvent('pointerdown', 100, 100));
104
+ expect(dragSession.sourceId.endsWith('-item-container')).toBe(true);
105
+ container.dispatchEvent(createMousePointerEvent('pointermove', 112, 112));
106
+ expect(dragSession.isDragging).toBe(true);
107
+ expect(dragSession.draggingKeys.has('container')).toBe(true);
108
+ container.dispatchEvent(createMousePointerEvent('pointerup', 112, 112));
109
+ expect(dragSession.isDragging).toBe(false);
110
+ expect(document.querySelector('.sp-drag-ghost')).toBeNull();
111
+ });
112
+ });
@@ -1,4 +1,4 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="T extends SortableItem">
2
2
  import type { SortableItem, SortableListProps } from '../types.js'
3
3
  import { dragSession } from '../../../internal/drag-drop/index.js'
4
4
  import { createSortableList, setSortableListContext } from '../sortable-list.svelte.js'
@@ -20,7 +20,7 @@
20
20
  onRemove,
21
21
  item: itemSnippet,
22
22
  empty: emptySnippet,
23
- }: SortableListProps<SortableItem> = $props()
23
+ }: SortableListProps<T> = $props()
24
24
 
25
25
  let containerEl: HTMLElement | undefined = $state()
26
26
 
@@ -1,5 +1,26 @@
1
1
  import type { SortableItem, SortableListProps } from '../types.js';
2
- declare const SortableListRoot: import("svelte").Component<SortableListProps<SortableItem>, {}, "items">;
3
- type SortableListRoot = ReturnType<typeof SortableListRoot>;
2
+ declare function $$render<T extends SortableItem>(): {
3
+ props: SortableListProps<T>;
4
+ exports: {};
5
+ bindings: "items";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<T extends SortableItem> {
10
+ props(): ReturnType<typeof $$render<T>>['props'];
11
+ events(): ReturnType<typeof $$render<T>>['events'];
12
+ slots(): ReturnType<typeof $$render<T>>['slots'];
13
+ bindings(): "items";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <T extends SortableItem>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<T>['exports']>;
20
+ <T extends SortableItem>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const SortableListRoot: $$IsomorphicComponent;
24
+ type SortableListRoot<T extends SortableItem> = InstanceType<typeof SortableListRoot<T>>;
4
25
  export default SortableListRoot;
5
26
  //# sourceMappingURL=sortable-list-root.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sortable-list-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/sortable-list/components/sortable-list-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AA0ElE,QAAA,MAAM,gBAAgB,0EAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"sortable-list-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/sortable-list/components/sortable-list-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAGlE,iBAAS,QAAQ,CAAC,CAAC,SAAS,YAAY;WAsEX,iBAAiB,CAAC,CAAC,CAAC;;;;;EAAiF;AAClI,cAAM,iBAAiB,CAAC,CAAC,SAAS,YAAY;IAC1C,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAChD,MAAM,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IAClD,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAChD,QAAQ;IACR,OAAO;CACV;AAED,UAAU,qBAAqB;IAC3B,KAAK,CAAC,SAAS,YAAY,EAAE,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;KAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IACjZ,CAAC,CAAC,SAAS,YAAY,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IAChJ,YAAY,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;CACjE;AACD,QAAA,MAAM,gBAAgB,EAAE,qBAAmC,CAAC;AAC1C,KAAK,gBAAgB,CAAC,CAAC,SAAS,YAAY,IAAI,YAAY,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC;AAC3F,eAAe,gBAAgB,CAAC"}
@@ -1,4 +1,4 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="T extends SizedItem">
2
2
  import type { Rect, SizedItem, VirtualizerProps } from '../types.js'
3
3
  import { isBrowser } from '../../../internal/helpers/index.js'
4
4
 
@@ -8,7 +8,7 @@
8
8
  overscan = 300,
9
9
  class: className,
10
10
  item: itemSnippet,
11
- }: VirtualizerProps<SizedItem> = $props()
11
+ }: VirtualizerProps<T> = $props()
12
12
 
13
13
  let containerEl: HTMLElement | undefined = $state()
14
14
  let containerWidth = $state(0)
@@ -1,5 +1,26 @@
1
1
  import type { SizedItem, VirtualizerProps } from '../types.js';
2
- declare const VirtualizerRoot: import("svelte").Component<VirtualizerProps<SizedItem>, {}, "">;
3
- type VirtualizerRoot = ReturnType<typeof VirtualizerRoot>;
2
+ declare function $$render<T extends SizedItem>(): {
3
+ props: VirtualizerProps<T>;
4
+ exports: {};
5
+ bindings: "";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<T extends SizedItem> {
10
+ props(): ReturnType<typeof $$render<T>>['props'];
11
+ events(): ReturnType<typeof $$render<T>>['events'];
12
+ slots(): ReturnType<typeof $$render<T>>['slots'];
13
+ bindings(): "";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <T extends SizedItem>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<T>['exports']>;
20
+ <T extends SizedItem>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const VirtualizerRoot: $$IsomorphicComponent;
24
+ type VirtualizerRoot<T extends SizedItem> = InstanceType<typeof VirtualizerRoot<T>>;
4
25
  export default VirtualizerRoot;
5
26
  //# sourceMappingURL=virtualizer-root.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"virtualizer-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/virtualizer/components/virtualizer-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAQ,SAAS,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AA2FpE,QAAA,MAAM,eAAe,iEAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"virtualizer-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/virtualizer/components/virtualizer-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAQ,SAAS,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEpE,iBAAS,QAAQ,CAAC,CAAC,SAAS,SAAS;WAwFR,gBAAgB,CAAC,CAAC,CAAC;;;;;EAA4E;AAC5H,cAAM,iBAAiB,CAAC,CAAC,SAAS,SAAS;IACvC,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAChD,MAAM,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IAClD,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAChD,QAAQ;IACR,OAAO;CACV;AAED,UAAU,qBAAqB;IAC3B,KAAK,CAAC,SAAS,SAAS,EAAE,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;KAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IAC9Y,CAAC,CAAC,SAAS,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IAC7I,YAAY,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;CACjE;AACD,QAAA,MAAM,eAAe,EAAE,qBAAmC,CAAC;AACzC,KAAK,eAAe,CAAC,CAAC,SAAS,SAAS,IAAI,YAAY,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC;AACtF,eAAe,eAAe,CAAC"}
@@ -34,6 +34,21 @@ function createElement() {
34
34
  document.body.appendChild(el);
35
35
  return el;
36
36
  }
37
+ /** Polyfill PointerEvent for jsdom. */
38
+ function ensurePointerEventPolyfill() {
39
+ if (typeof globalThis.PointerEvent === 'undefined') {
40
+ ;
41
+ globalThis.PointerEvent = class PointerEvent extends MouseEvent {
42
+ pointerId;
43
+ pointerType;
44
+ constructor(type, init = {}) {
45
+ super(type, init);
46
+ this.pointerId = init.pointerId ?? 0;
47
+ this.pointerType = init.pointerType ?? '';
48
+ }
49
+ };
50
+ }
51
+ }
37
52
  describe('dragSession', () => {
38
53
  beforeEach(() => {
39
54
  dragSession._resetForTests();
@@ -265,19 +280,7 @@ describe('dragSession', () => {
265
280
  describe('touch long-press', () => {
266
281
  beforeEach(() => {
267
282
  vi.useFakeTimers();
268
- // Polyfill PointerEvent for jsdom
269
- if (typeof globalThis.PointerEvent === 'undefined') {
270
- ;
271
- globalThis.PointerEvent = class PointerEvent extends (MouseEvent) {
272
- pointerId;
273
- pointerType;
274
- constructor(type, init = {}) {
275
- super(type, init);
276
- this.pointerId = init.pointerId ?? 0;
277
- this.pointerType = init.pointerType ?? '';
278
- }
279
- };
280
- }
283
+ ensurePointerEventPolyfill();
281
284
  });
282
285
  afterEach(() => {
283
286
  vi.useRealTimers();
@@ -432,6 +435,89 @@ describe('dragSession', () => {
432
435
  expect(dragSession.isDragging).toBe(false);
433
436
  });
434
437
  });
438
+ describe('pointer drag re-entrance (#190)', () => {
439
+ beforeEach(() => {
440
+ ensurePointerEventPolyfill();
441
+ });
442
+ function createMousePointerEvent(type, x, y) {
443
+ return new PointerEvent(type, {
444
+ clientX: x,
445
+ clientY: y,
446
+ pointerId: 1,
447
+ pointerType: 'mouse',
448
+ bubbles: true,
449
+ });
450
+ }
451
+ function registerSource(id) {
452
+ const config = createSourceConfig(id);
453
+ const el = createElement();
454
+ el.setPointerCapture = vi.fn();
455
+ el.releasePointerCapture = vi.fn();
456
+ dragSession.registerSource(config, el);
457
+ return { config, el };
458
+ }
459
+ it('a second beginPointerDrag while one is pending does not clobber the first', () => {
460
+ const inner = registerSource('inner-src');
461
+ const outer = registerSource('outer-src');
462
+ // Same pointerdown delivered to both sources (bubbling in a nested DOM)
463
+ const down = createMousePointerEvent('pointerdown', 100, 100);
464
+ dragSession.beginPointerDrag('inner-src', down);
465
+ expect(dragSession.sourceId).toBe('inner-src');
466
+ dragSession.beginPointerDrag('outer-src', down);
467
+ expect(dragSession.sourceId).toBe('inner-src'); // first claim wins
468
+ // Crossing the threshold activates the drag for the FIRST source
469
+ inner.el.dispatchEvent(createMousePointerEvent('pointermove', 110, 110));
470
+ expect(dragSession.isDragging).toBe(true);
471
+ expect(dragSession.sourceId).toBe('inner-src');
472
+ expect(inner.config.onDragStart).toHaveBeenCalled();
473
+ expect(outer.config.onDragStart).not.toHaveBeenCalled();
474
+ // And the session ends cleanly — no stuck ghost
475
+ inner.el.dispatchEvent(createMousePointerEvent('pointerup', 110, 110));
476
+ expect(dragSession.isDragging).toBe(false);
477
+ expect(dragSession.sourceId).toBeNull();
478
+ expect(document.querySelector('.sp-drag-ghost')).toBeNull();
479
+ inner.el.remove();
480
+ outer.el.remove();
481
+ });
482
+ it('releasing before the threshold clears the pending claim for the next drag', () => {
483
+ const a = registerSource('pending-a');
484
+ const b = registerSource('pending-b');
485
+ dragSession.beginPointerDrag('pending-a', createMousePointerEvent('pointerdown', 100, 100));
486
+ a.el.dispatchEvent(createMousePointerEvent('pointerup', 100, 100));
487
+ expect(dragSession.sourceId).toBeNull();
488
+ dragSession.beginPointerDrag('pending-b', createMousePointerEvent('pointerdown', 100, 100));
489
+ expect(dragSession.sourceId).toBe('pending-b');
490
+ dragSession.cancel();
491
+ a.el.remove();
492
+ b.el.remove();
493
+ });
494
+ it('keyboard drag cannot start while a pointer drag is pending', () => {
495
+ const a = registerSource('kb-block-a');
496
+ const b = registerSource('kb-block-b');
497
+ dragSession.beginPointerDrag('kb-block-a', createMousePointerEvent('pointerdown', 100, 100));
498
+ dragSession.beginKeyboardDrag('kb-block-b');
499
+ expect(dragSession.isDragging).toBe(false);
500
+ expect(dragSession.sourceId).toBe('kb-block-a');
501
+ dragSession.cancel();
502
+ a.el.remove();
503
+ b.el.remove();
504
+ });
505
+ it('pagehide during a pending claim resets it so future drags are not blocked', () => {
506
+ const src = registerSource('ph-pending');
507
+ // Pointer down, threshold NOT crossed — claim is pending
508
+ dragSession.beginPointerDrag('ph-pending', createMousePointerEvent('pointerdown', 100, 100));
509
+ expect(dragSession.isDragging).toBe(false);
510
+ expect(dragSession.sourceId).toBe('ph-pending');
511
+ // Page navigates during the pending claim
512
+ window.dispatchEvent(new Event('pagehide'));
513
+ expect(dragSession.sourceId).toBeNull();
514
+ // The re-entrance guard must not block the next drag
515
+ dragSession.beginPointerDrag('ph-pending', createMousePointerEvent('pointerdown', 100, 100));
516
+ expect(dragSession.sourceId).toBe('ph-pending');
517
+ dragSession.cancel();
518
+ src.el.remove();
519
+ });
520
+ });
435
521
  describe('async onDrop (F-001)', () => {
436
522
  it('awaits async onDrop callback before cleanup', async () => {
437
523
  let dropResolved = false;
@@ -0,0 +1,130 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { dragSession } from '../drag-session.svelte.js';
3
+ import { DragSourceState } from '../drag-source.svelte.js';
4
+ function createSourceConfig(id, overrides = {}) {
5
+ return {
6
+ id,
7
+ getItems: () => [{ 'test-type': id, 'text/plain': `Item ${id}` }],
8
+ onDragStart: vi.fn(),
9
+ onDragEnd: vi.fn(),
10
+ ...overrides,
11
+ };
12
+ }
13
+ function createElement(parent = document.body) {
14
+ const el = document.createElement('div');
15
+ Object.defineProperty(el, 'getBoundingClientRect', {
16
+ value: () => ({
17
+ top: 0,
18
+ left: 0,
19
+ bottom: 100,
20
+ right: 200,
21
+ width: 200,
22
+ height: 100,
23
+ x: 0,
24
+ y: 0,
25
+ toJSON() { },
26
+ }),
27
+ });
28
+ el.setPointerCapture = vi.fn();
29
+ el.releasePointerCapture = vi.fn();
30
+ parent.appendChild(el);
31
+ return el;
32
+ }
33
+ /** Polyfill PointerEvent for jsdom. */
34
+ function ensurePointerEventPolyfill() {
35
+ if (typeof globalThis.PointerEvent === 'undefined') {
36
+ ;
37
+ globalThis.PointerEvent = class PointerEvent extends MouseEvent {
38
+ pointerId;
39
+ pointerType;
40
+ constructor(type, init = {}) {
41
+ super(type, init);
42
+ this.pointerId = init.pointerId ?? 0;
43
+ this.pointerType = init.pointerType ?? '';
44
+ }
45
+ };
46
+ }
47
+ }
48
+ function createMousePointerEvent(type, x, y) {
49
+ return new PointerEvent(type, {
50
+ clientX: x,
51
+ clientY: y,
52
+ pointerId: 1,
53
+ pointerType: 'mouse',
54
+ bubbles: true,
55
+ });
56
+ }
57
+ describe('dragSourceState', () => {
58
+ beforeEach(() => {
59
+ dragSession._resetForTests();
60
+ ensurePointerEventPolyfill();
61
+ });
62
+ describe('nested sources (#190)', () => {
63
+ it('the innermost source claims the pointerdown — the ancestor never sees it', () => {
64
+ const outerEl = createElement();
65
+ const innerEl = createElement(outerEl);
66
+ const outerConfig = createSourceConfig('outer');
67
+ const innerConfig = createSourceConfig('inner');
68
+ const outer = new DragSourceState(outerConfig);
69
+ const inner = new DragSourceState(innerConfig);
70
+ outer.attach(outerEl);
71
+ inner.attach(innerEl);
72
+ // pointerdown on the inner element bubbles through the outer element
73
+ innerEl.dispatchEvent(createMousePointerEvent('pointerdown', 100, 100));
74
+ expect(dragSession.sourceId).toBe('inner');
75
+ // Crossing the threshold starts the drag for the inner source only
76
+ innerEl.dispatchEvent(createMousePointerEvent('pointermove', 110, 110));
77
+ expect(dragSession.isDragging).toBe(true);
78
+ expect(dragSession.sourceId).toBe('inner');
79
+ expect(inner.isDragging).toBe(true);
80
+ expect(outer.isDragging).toBe(false);
81
+ expect(innerConfig.onDragStart).toHaveBeenCalled();
82
+ expect(outerConfig.onDragStart).not.toHaveBeenCalled();
83
+ // The session ends cleanly on pointerup — no stuck ghost
84
+ innerEl.dispatchEvent(createMousePointerEvent('pointerup', 110, 110));
85
+ expect(dragSession.isDragging).toBe(false);
86
+ expect(dragSession.sourceId).toBeNull();
87
+ expect(document.querySelector('.sp-drag-ghost')).toBeNull();
88
+ inner.detach();
89
+ outer.detach();
90
+ outerEl.remove();
91
+ });
92
+ it('dragging the outer source directly still works', () => {
93
+ const outerEl = createElement();
94
+ const innerEl = createElement(outerEl);
95
+ const outerConfig = createSourceConfig('outer-direct');
96
+ const innerConfig = createSourceConfig('inner-direct');
97
+ const outer = new DragSourceState(outerConfig);
98
+ const inner = new DragSourceState(innerConfig);
99
+ outer.attach(outerEl);
100
+ inner.attach(innerEl);
101
+ // pointerdown on the outer element itself (not inside the inner source)
102
+ outerEl.dispatchEvent(createMousePointerEvent('pointerdown', 100, 100));
103
+ expect(dragSession.sourceId).toBe('outer-direct');
104
+ outerEl.dispatchEvent(createMousePointerEvent('pointermove', 110, 110));
105
+ expect(dragSession.isDragging).toBe(true);
106
+ expect(outer.isDragging).toBe(true);
107
+ outerEl.dispatchEvent(createMousePointerEvent('pointerup', 110, 110));
108
+ expect(dragSession.isDragging).toBe(false);
109
+ inner.detach();
110
+ outer.detach();
111
+ outerEl.remove();
112
+ });
113
+ it('a disabled inner source lets the outer source claim the event', () => {
114
+ const outerEl = createElement();
115
+ const innerEl = createElement(outerEl);
116
+ const outerConfig = createSourceConfig('outer-en');
117
+ const innerConfig = createSourceConfig('inner-dis', { isDisabled: true });
118
+ const outer = new DragSourceState(outerConfig);
119
+ const inner = new DragSourceState(innerConfig);
120
+ outer.attach(outerEl);
121
+ inner.attach(innerEl);
122
+ innerEl.dispatchEvent(createMousePointerEvent('pointerdown', 100, 100));
123
+ expect(dragSession.sourceId).toBe('outer-en');
124
+ dragSession.cancel();
125
+ inner.detach();
126
+ outer.detach();
127
+ outerEl.remove();
128
+ });
129
+ });
130
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"drag-session.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/drag-session.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAGhB,UAAU,EACV,gBAAgB,EAEjB,MAAM,YAAY,CAAA;AAOnB,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;CACrB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;IACpB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACjD;AA4QD,eAAO,MAAM,WAAW;;;;;;;;;;;2BAmCC,gBAAgB,WAAW,WAAW,GAAG,MAAM,IAAI;2BAQhE,gBAAgB,WACf,WAAW,oBACF,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,GAC/C,MAAM,IAAI;kBASC,MAAM,IAAI,GAAG,MAAM,IAAI;+BASV,MAAM,KAAK,YAAY,GAAG,IAAI;gCAqG7B,MAAM,GAAG,IAAI;IAazC,gDAAgD;uBAC7B,IAAI;IAYvB,0DAA0D;uBACvC,IAAI;IAYvB,mFAAmF;0BAC7D,UAAU,GAAG,IAAI;YAQ/B,OAAO,CAAC,IAAI,CAAC;cAIX,IAAI;IAMd,uDAAuD;sCACrB,MAAM,QAAQ,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI;IAKlE,+FAA+F;iCAClE,MAAM,GAAG,OAAO;IAI7C,+FAA+F;6CACtD,MAAM,GAAG,OAAO;IAIzD,mCAAmC;kBACrB,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAInD,mCAAmC;kBACrB,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAInD,gBAAgB;sBACE,IAAI;CAMvB,CAAA"}
1
+ {"version":3,"file":"drag-session.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/drag-session.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAGhB,UAAU,EACV,gBAAgB,EAEjB,MAAM,YAAY,CAAA;AAOnB,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;CACrB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,gBAAgB,CAAA;IACxB,OAAO,EAAE,WAAW,CAAA;IACpB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CACjD;AA4QD,eAAO,MAAM,WAAW;;;;;;;;;;;2BAmCC,gBAAgB,WAAW,WAAW,GAAG,MAAM,IAAI;2BAQhE,gBAAgB,WACf,WAAW,oBACF,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,GAC/C,MAAM,IAAI;kBASC,MAAM,IAAI,GAAG,MAAM,IAAI;+BASV,MAAM,KAAK,YAAY,GAAG,IAAI;gCA4G7B,MAAM,GAAG,IAAI;IAezC,gDAAgD;uBAC7B,IAAI;IAYvB,0DAA0D;uBACvC,IAAI;IAYvB,mFAAmF;0BAC7D,UAAU,GAAG,IAAI;YAQ/B,OAAO,CAAC,IAAI,CAAC;cAIX,IAAI;IAMd,uDAAuD;sCACrB,MAAM,QAAQ,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI;IAKlE,+FAA+F;iCAClE,MAAM,GAAG,OAAO;IAI7C,+FAA+F;6CACtD,MAAM,GAAG,OAAO;IAIzD,mCAAmC;kBACrB,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAInD,mCAAmC;kBACrB,MAAM,GAAG,gBAAgB,GAAG,SAAS;IAInD,gBAAgB;sBACE,IAAI;CAMvB,CAAA"}
@@ -304,7 +304,10 @@ export const dragSession = {
304
304
  },
305
305
  // ── Start drag (pointer) ─────────────────────────────────────────────
306
306
  beginPointerDrag(sourceId, e) {
307
- if (_isDragging || !isBrowser)
307
+ // _sourceId is non-null while a pointer drag is still pending (before the
308
+ // movement threshold) — refuse re-entry so a second claim cannot clobber
309
+ // the pending session's source and leave a stuck ghost.
310
+ if (_isDragging || _sourceId !== null || !isBrowser)
308
311
  return;
309
312
  const source = registeredSources.get(sourceId);
310
313
  if (!source || source.config.isDisabled)
@@ -314,6 +317,8 @@ export const dragSession = {
314
317
  _startY = e.clientY;
315
318
  _dragThresholdMet = false;
316
319
  _sourceId = sourceId;
320
+ // Guard the pending claim against page navigation (see onPageHide)
321
+ startPageHideListener();
317
322
  const el = source.element;
318
323
  el.setPointerCapture(e.pointerId);
319
324
  function cleanup() {
@@ -354,6 +359,7 @@ export const dragSession = {
354
359
  }
355
360
  cleanup();
356
361
  _sourceId = null;
362
+ stopPageHideListener();
357
363
  return;
358
364
  }
359
365
  // Still waiting for long-press timer — don't do anything yet
@@ -383,6 +389,7 @@ export const dragSession = {
383
389
  }
384
390
  else {
385
391
  _sourceId = null;
392
+ stopPageHideListener();
386
393
  }
387
394
  };
388
395
  const onCancel = () => {
@@ -395,7 +402,9 @@ export const dragSession = {
395
402
  },
396
403
  // ── Start drag (keyboard) ───────────────────────────────────────────
397
404
  beginKeyboardDrag(sourceId) {
398
- if (_isDragging || !isBrowser)
405
+ // Same re-entrance rule as beginPointerDrag: a pending pointer drag
406
+ // (pointer down, threshold not yet met) blocks a keyboard start.
407
+ if (_isDragging || _sourceId !== null || !isBrowser)
399
408
  return;
400
409
  const source = registeredSources.get(sourceId);
401
410
  if (!source || source.config.isDisabled)
@@ -479,7 +488,9 @@ export const dragSession = {
479
488
  // If the page navigates during a drag (SvelteKit routing), the module-level
480
489
  // state would be left in a broken state. Listen for pagehide to force cleanup.
481
490
  function onPageHide() {
482
- if (_isDragging) {
491
+ // Also reset a PENDING claim (_sourceId set, threshold not met): leaving it
492
+ // behind would make the re-entrance guard block every future drag.
493
+ if (_isDragging || _sourceId !== null) {
483
494
  resetSession();
484
495
  }
485
496
  }
@@ -1 +1 @@
1
- {"version":3,"file":"drag-source.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/drag-source.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAIlD,qBAAa,eAAe;;gBAKd,MAAM,EAAE,gBAAgB;IAIpC,+DAA+D;IAC/D,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,4BAA4B;IAC5B,IAAI,EAAE,IAAI,MAAM,CAEf;IAED;;;OAGG;IACH,MAAM,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI;IAmBlC;;OAEG;IACH,MAAM,IAAI,IAAI;CA2Bf"}
1
+ {"version":3,"file":"drag-source.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/drag-drop/drag-source.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAIlD,qBAAa,eAAe;;gBAKd,MAAM,EAAE,gBAAgB;IAIpC,+DAA+D;IAC/D,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,4BAA4B;IAC5B,IAAI,EAAE,IAAI,MAAM,CAEf;IAED;;;OAGG;IACH,MAAM,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI;IAmBlC;;OAEG;IACH,MAAM,IAAI,IAAI;CA+Bf"}
@@ -61,6 +61,10 @@ export class DragSourceState {
61
61
  // Only primary button
62
62
  if (e.button !== 0)
63
63
  return;
64
+ // Claim the gesture: with nested sources (e.g. a sortable list inside
65
+ // another list's item) the same pointerdown bubbles to every ancestor
66
+ // source — the innermost one wins, ancestors must never see it.
67
+ e.stopPropagation();
64
68
  dragSession.beginPointerDrag(this.#config.id, e);
65
69
  };
66
70
  #onKeyDown = (e) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"