@humanspeak/svelte-json-view-lite 0.1.3 → 0.1.5
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/README.md +1 -0
- package/dist/DataRender.svelte +7 -14
- package/dist/ExpandableObject.svelte +53 -25
- package/dist/JsonView.svelte +4 -1
- package/dist/types.d.ts +56 -5
- package/dist/utils/expanderNavigation.d.ts +23 -0
- package/dist/utils/expanderNavigation.js +224 -0
- package/package.json +20 -20
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ runtime dependencies.
|
|
|
7
7
|
|
|
8
8
|
[](https://www.npmjs.com/package/@humanspeak/svelte-json-view-lite)
|
|
9
9
|
[](https://github.com/humanspeak/svelte-json-view-lite/actions/workflows/npm-publish.yml)
|
|
10
|
+
[](https://tokenmaxing.app/card/humanspeak/svelte-json-view-lite)
|
|
10
11
|
[](https://coveralls.io/github/humanspeak/svelte-json-view-lite?branch=main)
|
|
11
12
|
[](https://github.com/humanspeak/svelte-json-view-lite/blob/main/LICENSE)
|
|
12
13
|
[](https://www.npmjs.com/package/@humanspeak/svelte-json-view-lite)
|
package/dist/DataRender.svelte
CHANGED
|
@@ -16,25 +16,18 @@
|
|
|
16
16
|
const isArr = $derived(isArray(value))
|
|
17
17
|
const isObj = $derived(isObject(value) && !isDate(value) && !isFunction(value))
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// a
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
if (isArr) return (value as unknown[]).map((el) => [undefined, el])
|
|
25
|
-
if (isObj) {
|
|
26
|
-
const obj = value as Record<string, unknown>
|
|
27
|
-
return Object.keys(obj).map((k) => [k, obj[k]])
|
|
28
|
-
}
|
|
29
|
-
return []
|
|
30
|
-
})
|
|
19
|
+
// The children tuple array is NOT built here: a collapsed node only needs
|
|
20
|
+
// its child *count*, and materializing `Array<[key, value]>` for every
|
|
21
|
+
// node (a 100k-element array = 100k throwaway tuples at mount under
|
|
22
|
+
// collapseAllNested) is pure waste. ExpandableObject derives the tuples
|
|
23
|
+
// lazily, gated on its own `expanded` state. See issue #21.
|
|
31
24
|
</script>
|
|
32
25
|
|
|
33
26
|
{#if isArr}
|
|
34
27
|
<ExpandableObject
|
|
35
28
|
{...props}
|
|
36
29
|
value={value as unknown[]}
|
|
37
|
-
|
|
30
|
+
isArray={true}
|
|
38
31
|
openBracket="["
|
|
39
32
|
closeBracket="]"
|
|
40
33
|
/>
|
|
@@ -42,7 +35,7 @@
|
|
|
42
35
|
<ExpandableObject
|
|
43
36
|
{...props}
|
|
44
37
|
value={value as object}
|
|
45
|
-
|
|
38
|
+
isArray={false}
|
|
46
39
|
openBracket={OBJECT_OPEN}
|
|
47
40
|
closeBracket={OBJECT_CLOSE}
|
|
48
41
|
/>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte'
|
|
2
3
|
import DataRender from './DataRender.svelte'
|
|
3
4
|
import EmptyObject from './EmptyObject.svelte'
|
|
4
5
|
import type { AriaLabels, ExpandableRenderProps } from './types.js'
|
|
@@ -7,7 +8,7 @@
|
|
|
7
8
|
const {
|
|
8
9
|
field,
|
|
9
10
|
value,
|
|
10
|
-
|
|
11
|
+
isArray,
|
|
11
12
|
lastElement,
|
|
12
13
|
openBracket,
|
|
13
14
|
closeBracket,
|
|
@@ -26,15 +27,32 @@
|
|
|
26
27
|
// svelte-ignore state_referenced_locally
|
|
27
28
|
let expanded = $state(shouldExpandNode(level, value, field))
|
|
28
29
|
|
|
30
|
+
// Once a node has been opened we keep its children materialized, even after
|
|
31
|
+
// it collapses again: re-deriving the tuple array (and re-reading N child
|
|
32
|
+
// values) on every re-expand would be its own waste. Latches true, stays true.
|
|
33
|
+
// svelte-ignore state_referenced_locally
|
|
34
|
+
let hasMaterialized = $state(expanded)
|
|
35
|
+
|
|
29
36
|
let shouldExpandNodeCalled = false
|
|
30
37
|
|
|
38
|
+
// Single chokepoint for flipping expansion — every path (mount effect,
|
|
39
|
+
// click, keyboard) routes through here, so the materialization high-water
|
|
40
|
+
// mark can't be forgotten when a new expansion path is added.
|
|
41
|
+
function applyExpanded(next: boolean) {
|
|
42
|
+
expanded = next
|
|
43
|
+
if (next) hasMaterialized = true
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
$effect(() => {
|
|
32
47
|
const fn = shouldExpandNode
|
|
33
48
|
if (!shouldExpandNodeCalled) {
|
|
34
49
|
shouldExpandNodeCalled = true
|
|
35
50
|
return
|
|
36
51
|
}
|
|
37
|
-
|
|
52
|
+
// Track only the callback identity: untrack level/value/field so an
|
|
53
|
+
// ancestor re-render that mutates them can't rerun this effect and
|
|
54
|
+
// overwrite the user's expansion state.
|
|
55
|
+
applyExpanded(untrack(() => fn(level, value, field)))
|
|
38
56
|
})
|
|
39
57
|
|
|
40
58
|
// SSR-stable id for aria-controls linkage.
|
|
@@ -42,6 +60,13 @@
|
|
|
42
60
|
|
|
43
61
|
let expanderButton = $state<HTMLSpanElement | null>(null)
|
|
44
62
|
|
|
63
|
+
// Register from the node action, not a component effect: actions follow
|
|
64
|
+
// DOM creation order, so parent/top-level expanders append before children.
|
|
65
|
+
function registerExpander(node: HTMLSpanElement) {
|
|
66
|
+
const unregister = outerRef.navigation.register(node)
|
|
67
|
+
return { destroy: unregister }
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
const activeAriaLabels = $derived<AriaLabels>(
|
|
46
71
|
style.ariaLabels ??
|
|
47
72
|
style.ariaLables ?? {
|
|
@@ -54,16 +79,35 @@
|
|
|
54
79
|
expanded ? activeAriaLabels.collapseJson : activeAriaLabels.expandJson
|
|
55
80
|
)
|
|
56
81
|
const childLevel = $derived(level + 1)
|
|
57
|
-
const lastIndex = $derived(data.length - 1)
|
|
58
82
|
const hasField = $derived(field !== undefined)
|
|
59
83
|
const labelText = $derived(quoteString(field ?? '', style.quotesForFieldNames))
|
|
60
84
|
|
|
85
|
+
// Object keys resolved once and shared by the count and the tuple builder,
|
|
86
|
+
// so an expanded object node enumerates its keys a single time. Arrays skip
|
|
87
|
+
// this entirely — their length is free.
|
|
88
|
+
const objectKeys = $derived(isArray ? null : Object.keys(value as Record<string, unknown>))
|
|
89
|
+
|
|
90
|
+
// Child *count* is cheap — array length, or the key count for objects.
|
|
91
|
+
// Neither touches child *values*, so a collapsed node stays allocation-free.
|
|
92
|
+
const count = $derived(objectKeys ? objectKeys.length : (value as unknown[]).length)
|
|
93
|
+
const lastIndex = $derived(count - 1)
|
|
94
|
+
|
|
95
|
+
// The tuple array is materialized lazily on first open, then kept: a node
|
|
96
|
+
// that is never expanded never allocates its N tuples or reads its N child
|
|
97
|
+
// values (#21); one that has been opened doesn't re-read them on re-expand.
|
|
98
|
+
const entries = $derived.by<Array<[string | undefined, unknown]>>(() => {
|
|
99
|
+
if (!hasMaterialized) return []
|
|
100
|
+
if (isArray) return (value as unknown[]).map((el) => [undefined, el])
|
|
101
|
+
const obj = value as Record<string, unknown>
|
|
102
|
+
return (objectKeys as string[]).map((k) => [k, obj[k]])
|
|
103
|
+
})
|
|
104
|
+
|
|
61
105
|
function setExpandWithCallback(newExpandValue: boolean) {
|
|
62
106
|
if (expanded === newExpandValue) return
|
|
63
107
|
if (beforeExpandChange && !beforeExpandChange({ level, value, field, newExpandValue })) {
|
|
64
108
|
return
|
|
65
109
|
}
|
|
66
|
-
|
|
110
|
+
applyExpanded(newExpandValue)
|
|
67
111
|
}
|
|
68
112
|
|
|
69
113
|
function onKeyDown(e: KeyboardEvent) {
|
|
@@ -75,34 +119,18 @@
|
|
|
75
119
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
|
|
76
120
|
e.preventDefault()
|
|
77
121
|
const direction = e.key === 'ArrowUp' ? -1 : 1
|
|
78
|
-
|
|
79
|
-
if (!outer) return
|
|
80
|
-
const buttons = outer.querySelectorAll<HTMLElement>('[role=button]')
|
|
81
|
-
let currentIndex = -1
|
|
82
|
-
for (let i = 0; i < buttons.length; i++) {
|
|
83
|
-
if (buttons[i].tabIndex === 0) {
|
|
84
|
-
currentIndex = i
|
|
85
|
-
break
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (currentIndex < 0) return
|
|
89
|
-
const nextIndex = (currentIndex + direction + buttons.length) % buttons.length
|
|
90
|
-
buttons[currentIndex].tabIndex = -1
|
|
91
|
-
buttons[nextIndex].tabIndex = 0
|
|
92
|
-
buttons[nextIndex].focus()
|
|
122
|
+
if (expanderButton) outerRef.navigation.move(expanderButton, direction)
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
function onClick() {
|
|
96
126
|
setExpandWithCallback(!expanded)
|
|
97
127
|
if (!expanderButton) return
|
|
98
|
-
|
|
99
|
-
if (prev) prev.tabIndex = -1
|
|
100
|
-
expanderButton.tabIndex = 0
|
|
128
|
+
outerRef.navigation.activate(expanderButton)
|
|
101
129
|
expanderButton.focus()
|
|
102
130
|
}
|
|
103
131
|
</script>
|
|
104
132
|
|
|
105
|
-
{#if
|
|
133
|
+
{#if count === 0}
|
|
106
134
|
<EmptyObject {field} {openBracket} {closeBracket} {lastElement} {style} />
|
|
107
135
|
{:else}
|
|
108
136
|
<div
|
|
@@ -122,7 +150,7 @@
|
|
|
122
150
|
it tight anyway for a consistent rule.
|
|
123
151
|
-->
|
|
124
152
|
<!-- prettier-ignore -->
|
|
125
|
-
<span bind:this={expanderButton} class={expanderIconStyle} role="button" aria-label={ariaLabel} aria-expanded={expanded} aria-controls={expanded ? contentsId : undefined} tabindex={level === 0 ? 0 : -1} onclick={onClick} onkeydown={onKeyDown}></span>{#if hasField}{#if snippets.label}{@render snippets.label(
|
|
153
|
+
<span bind:this={expanderButton} use:registerExpander class={expanderIconStyle} role="button" aria-label={ariaLabel} aria-expanded={expanded} aria-controls={expanded ? contentsId : undefined} tabindex={level === 0 ? 0 : -1} onclick={onClick} onkeydown={onKeyDown}></span>{#if hasField}{#if snippets.label}{@render snippets.label(
|
|
126
154
|
{ field: field ?? '', level }
|
|
127
155
|
)}{:else if clickToExpandNode}<!-- svelte-ignore a11y_no_static_element_interactions --><span
|
|
128
156
|
class={style.clickableLabel}
|
|
@@ -131,7 +159,7 @@
|
|
|
131
159
|
>{:else}<span class={style.label}>{labelText}:</span>{/if}{/if}<span
|
|
132
160
|
class={style.punctuation}>{openBracket}</span
|
|
133
161
|
>{#if expanded}<ul id={contentsId} role="group" class={style.childFieldsContainer}>
|
|
134
|
-
{#each
|
|
162
|
+
{#each entries as [childField, childValue], index (childField ?? index)}<DataRender
|
|
135
163
|
field={childField}
|
|
136
164
|
value={childValue}
|
|
137
165
|
{style}
|
package/dist/JsonView.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { defaultStyles } from './index.js'
|
|
4
4
|
import type { OuterRef, Props, StyleProps } from './types.js'
|
|
5
5
|
import { isObject } from './utils/dataTypeDetection.js'
|
|
6
|
+
import { createExpanderNavigation } from './utils/expanderNavigation.js'
|
|
6
7
|
import { allExpanded } from './utils/expandStrategies.js'
|
|
7
8
|
|
|
8
9
|
const {
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
}: Props = $props()
|
|
27
28
|
|
|
28
29
|
let outerElement = $state<HTMLDivElement | null>(null)
|
|
30
|
+
const navigation = createExpanderNavigation()
|
|
29
31
|
|
|
30
32
|
// Merge user theme onto defaults. Also emit a deprecation warning when the
|
|
31
33
|
// legacy `ariaLables` key (typo in react-json-view-lite) is supplied
|
|
@@ -52,7 +54,8 @@
|
|
|
52
54
|
const outerRef: OuterRef = {
|
|
53
55
|
get current() {
|
|
54
56
|
return outerElement
|
|
55
|
-
}
|
|
57
|
+
},
|
|
58
|
+
navigation
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const snippets = $derived({
|
package/dist/types.d.ts
CHANGED
|
@@ -106,13 +106,62 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'data' | 'st
|
|
|
106
106
|
label?: Snippet<[LabelSnippetProps]>;
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
109
|
+
* Tree-local controller for roving tabindex across expandable nodes.
|
|
110
|
+
*
|
|
111
|
+
* The controller is intentionally passed through internal render props instead
|
|
112
|
+
* of discovered from the DOM on every keypress. Each expander registers its
|
|
113
|
+
* button while mounted, unregisters on `$effect` cleanup, and asks this helper
|
|
114
|
+
* to activate or move focus when the user clicks or presses ArrowUp/ArrowDown.
|
|
115
|
+
*/
|
|
116
|
+
export interface ExpanderNavigation {
|
|
117
|
+
/**
|
|
118
|
+
* Add a mounted expander button to the navigation order.
|
|
119
|
+
*
|
|
120
|
+
* @param _button - Expander button element rendered by an expandable node.
|
|
121
|
+
* @returns Cleanup callback that removes the button on component unmount.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```ts
|
|
125
|
+
* const cleanup = navigation.register(button)
|
|
126
|
+
* cleanup()
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
register(_button: HTMLElement): () => void;
|
|
130
|
+
/**
|
|
131
|
+
* Make a registered button the only expander with `tabIndex=0`.
|
|
132
|
+
*
|
|
133
|
+
* @param _button - Registered expander button to activate.
|
|
134
|
+
* @returns Nothing.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* navigation.activate(button)
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
activate(_button: HTMLElement): void;
|
|
142
|
+
/**
|
|
143
|
+
* Move focus to the next or previous registered expander.
|
|
144
|
+
*
|
|
145
|
+
* @param _button - Current registered expander button.
|
|
146
|
+
* @param _direction - `1` for ArrowDown, `-1` for ArrowUp.
|
|
147
|
+
* @returns Nothing.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* navigation.move(button, 1)
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
move(_button: HTMLElement, _direction: -1 | 1): void;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Reference wrapper passed from the root down to every expandable node. Using
|
|
158
|
+
* a getter ensures the child always reads the current `bind:this` target rather
|
|
159
|
+
* than a frozen snapshot; the navigation helper keeps roving tabindex state
|
|
160
|
+
* tree-local without doing live DOM sweeps on every keypress.
|
|
113
161
|
*/
|
|
114
162
|
export interface OuterRef {
|
|
115
163
|
readonly current: HTMLDivElement | null;
|
|
164
|
+
readonly navigation: ExpanderNavigation;
|
|
116
165
|
}
|
|
117
166
|
/** Internal shared props threaded through every renderer. Not exported. */
|
|
118
167
|
export interface CommonRenderProps {
|
|
@@ -132,7 +181,9 @@ export interface JsonRenderProps<T> extends CommonRenderProps {
|
|
|
132
181
|
export interface ExpandableRenderProps extends CommonRenderProps {
|
|
133
182
|
field?: string;
|
|
134
183
|
value: object | unknown[];
|
|
135
|
-
|
|
184
|
+
/** Whether `value` is an array. Resolved once by DataRender (the type
|
|
185
|
+
* dispatcher) so ExpandableObject never re-tests the value's shape. */
|
|
186
|
+
isArray: boolean;
|
|
136
187
|
openBracket: string;
|
|
137
188
|
closeBracket: string;
|
|
138
189
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExpanderNavigation } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Create the tree-local roving tabindex controller for expandable JSON nodes.
|
|
4
|
+
*
|
|
5
|
+
* Each expander registers its own button on mount and receives a cleanup
|
|
6
|
+
* callback for unmount. The controller stores buttons in document order as a
|
|
7
|
+
* linked list, so ArrowUp/ArrowDown can move from the current button to its
|
|
8
|
+
* neighbor without querying `[role=button]` or scanning `tabIndex` across the
|
|
9
|
+
* whole tree on every keypress. Normal document-order mounts append in O(1);
|
|
10
|
+
* the insertion scan is reserved for out-of-order registrations.
|
|
11
|
+
*
|
|
12
|
+
* @returns A navigation controller shared by every expandable node in one
|
|
13
|
+
* `JsonView` tree.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const navigation = createExpanderNavigation()
|
|
18
|
+
* const cleanup = navigation.register(button)
|
|
19
|
+
* navigation.move(button, 1)
|
|
20
|
+
* cleanup()
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare const createExpanderNavigation: () => ExpanderNavigation;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create the tree-local roving tabindex controller for expandable JSON nodes.
|
|
3
|
+
*
|
|
4
|
+
* Each expander registers its own button on mount and receives a cleanup
|
|
5
|
+
* callback for unmount. The controller stores buttons in document order as a
|
|
6
|
+
* linked list, so ArrowUp/ArrowDown can move from the current button to its
|
|
7
|
+
* neighbor without querying `[role=button]` or scanning `tabIndex` across the
|
|
8
|
+
* whole tree on every keypress. Normal document-order mounts append in O(1);
|
|
9
|
+
* the insertion scan is reserved for out-of-order registrations.
|
|
10
|
+
*
|
|
11
|
+
* @returns A navigation controller shared by every expandable node in one
|
|
12
|
+
* `JsonView` tree.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const navigation = createExpanderNavigation()
|
|
17
|
+
* const cleanup = navigation.register(button)
|
|
18
|
+
* navigation.move(button, 1)
|
|
19
|
+
* cleanup()
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export const createExpanderNavigation = () => {
|
|
23
|
+
const nodes = new WeakMap();
|
|
24
|
+
let first = null;
|
|
25
|
+
let last = null;
|
|
26
|
+
let active = null;
|
|
27
|
+
let activeIsSeed = false;
|
|
28
|
+
/**
|
|
29
|
+
* Detach a node from the linked list while preserving the surrounding
|
|
30
|
+
* neighbors. The caller owns deleting it from the lookup table.
|
|
31
|
+
*
|
|
32
|
+
* @param node - Registered expander node to remove from document order.
|
|
33
|
+
* @returns Nothing.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* unlink(node)
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
const unlink = (node) => {
|
|
41
|
+
if (node.previous)
|
|
42
|
+
node.previous.next = node.next;
|
|
43
|
+
else
|
|
44
|
+
first = node.next;
|
|
45
|
+
if (node.next)
|
|
46
|
+
node.next.previous = node.previous;
|
|
47
|
+
else
|
|
48
|
+
last = node.previous;
|
|
49
|
+
node.previous = null;
|
|
50
|
+
node.next = null;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Insert a registered node before another registered node in document
|
|
54
|
+
* order.
|
|
55
|
+
*
|
|
56
|
+
* @param node - Expander node being inserted.
|
|
57
|
+
* @param before - Existing expander node that currently follows `node`.
|
|
58
|
+
* @returns Nothing.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* insertBefore(node, before)
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
const insertBefore = (node, before) => {
|
|
66
|
+
node.previous = before.previous;
|
|
67
|
+
node.next = before;
|
|
68
|
+
if (before.previous)
|
|
69
|
+
before.previous.next = node;
|
|
70
|
+
else
|
|
71
|
+
first = node;
|
|
72
|
+
before.previous = node;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Append a registered node to the end of the document-order list.
|
|
76
|
+
*
|
|
77
|
+
* @param node - Expander node being appended.
|
|
78
|
+
* @returns Nothing.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* append(node)
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
const append = (node) => {
|
|
86
|
+
node.previous = last;
|
|
87
|
+
if (last)
|
|
88
|
+
last.next = node;
|
|
89
|
+
else
|
|
90
|
+
first = node;
|
|
91
|
+
last = node;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Make one registered button the active roving tabindex target.
|
|
95
|
+
*
|
|
96
|
+
* @param node - Expander node that should receive `tabIndex=0`.
|
|
97
|
+
* @param seed - Whether this is the implicit initial target, which may
|
|
98
|
+
* move to the document-order head if earlier nodes register later.
|
|
99
|
+
* @returns Nothing.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* setActive(node, false)
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
const setActive = (node, seed) => {
|
|
107
|
+
if (active && active !== node)
|
|
108
|
+
active.element.tabIndex = -1;
|
|
109
|
+
active = node;
|
|
110
|
+
activeIsSeed = seed;
|
|
111
|
+
node.element.tabIndex = 0;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Make one registered button the explicit active roving tabindex target.
|
|
115
|
+
*
|
|
116
|
+
* @param button - Expander button that should receive `tabIndex=0`.
|
|
117
|
+
* @returns Nothing.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* navigation.activate(button)
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
const activate = (button) => {
|
|
125
|
+
const node = nodes.get(button);
|
|
126
|
+
if (!node)
|
|
127
|
+
return;
|
|
128
|
+
setActive(node, false);
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Remove a button from the controller and keep a usable roving target when
|
|
132
|
+
* the active button unmounts.
|
|
133
|
+
*
|
|
134
|
+
* @param button - Expander button previously returned by `register`.
|
|
135
|
+
* @returns Nothing.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* unregister(button)
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
const unregister = (button) => {
|
|
143
|
+
const node = nodes.get(button);
|
|
144
|
+
if (!node)
|
|
145
|
+
return;
|
|
146
|
+
const fallback = node.next ?? node.previous;
|
|
147
|
+
const wasActive = active === node;
|
|
148
|
+
const wasSeed = activeIsSeed;
|
|
149
|
+
nodes.delete(button);
|
|
150
|
+
unlink(node);
|
|
151
|
+
if (wasActive) {
|
|
152
|
+
active = null;
|
|
153
|
+
activeIsSeed = false;
|
|
154
|
+
if (fallback)
|
|
155
|
+
setActive(fallback, wasSeed);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Register a mounted expander button in document order.
|
|
160
|
+
*
|
|
161
|
+
* @param button - Expander button rendered by an `ExpandableObject`.
|
|
162
|
+
* @returns Cleanup callback that unregisters `button` on component unmount.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* const cleanup = navigation.register(button)
|
|
167
|
+
* cleanup()
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
const register = (button) => {
|
|
171
|
+
const existing = nodes.get(button);
|
|
172
|
+
if (existing)
|
|
173
|
+
return () => unregister(button);
|
|
174
|
+
const node = { element: button, previous: null, next: null };
|
|
175
|
+
nodes.set(button, node);
|
|
176
|
+
if (!last ||
|
|
177
|
+
(last.element.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) {
|
|
178
|
+
append(node);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
let before = null;
|
|
182
|
+
for (let cursor = first; cursor; cursor = cursor.next) {
|
|
183
|
+
if ((button.compareDocumentPosition(cursor.element) &
|
|
184
|
+
Node.DOCUMENT_POSITION_FOLLOWING) !==
|
|
185
|
+
0) {
|
|
186
|
+
before = cursor;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (before)
|
|
191
|
+
insertBefore(node, before);
|
|
192
|
+
else
|
|
193
|
+
append(node);
|
|
194
|
+
}
|
|
195
|
+
if (button.tabIndex === 0)
|
|
196
|
+
setActive(node, false);
|
|
197
|
+
else if ((!active || activeIsSeed) && first)
|
|
198
|
+
setActive(first, true);
|
|
199
|
+
return () => unregister(button);
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Move focus to the adjacent registered expander, wrapping at list edges.
|
|
203
|
+
*
|
|
204
|
+
* @param button - Current expander button handling the keypress.
|
|
205
|
+
* @param direction - `1` for ArrowDown, `-1` for ArrowUp.
|
|
206
|
+
* @returns Nothing.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* navigation.move(button, 1)
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
const move = (button, direction) => {
|
|
214
|
+
const current = nodes.get(button);
|
|
215
|
+
if (!current)
|
|
216
|
+
return;
|
|
217
|
+
const next = direction === 1 ? (current.next ?? first) : (current.previous ?? last);
|
|
218
|
+
if (!next)
|
|
219
|
+
return;
|
|
220
|
+
setActive(next, false);
|
|
221
|
+
next.element.focus();
|
|
222
|
+
};
|
|
223
|
+
return { register, activate, move };
|
|
224
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-json-view-lite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Fast, tiny JSON tree viewer for Svelte 5 — port of react-json-view-lite with runes, SSR, snippet overrides, and zero runtime dependencies",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -57,37 +57,37 @@
|
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@eslint/compat": "^2.1.0",
|
|
59
59
|
"@eslint/js": "^10.0.1",
|
|
60
|
-
"@playwright/test": "^1.
|
|
60
|
+
"@playwright/test": "^1.61.1",
|
|
61
61
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
62
|
-
"@sveltejs/kit": "^2.
|
|
63
|
-
"@sveltejs/package": "^2.5.
|
|
62
|
+
"@sveltejs/kit": "^2.69.1",
|
|
63
|
+
"@sveltejs/package": "^2.5.8",
|
|
64
64
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
|
66
|
-
"@testing-library/svelte": "^5.
|
|
66
|
+
"@testing-library/svelte": "^5.4.2",
|
|
67
67
|
"@testing-library/user-event": "^14.6.1",
|
|
68
|
-
"@types/node": "^
|
|
69
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
70
|
-
"@typescript-eslint/parser": "^8.
|
|
71
|
-
"@vitest/coverage-v8": "^4.1.
|
|
72
|
-
"eslint": "^10.
|
|
68
|
+
"@types/node": "^26.1.0",
|
|
69
|
+
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
|
70
|
+
"@typescript-eslint/parser": "^8.62.1",
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
72
|
+
"eslint": "^10.6.0",
|
|
73
73
|
"eslint-config-prettier": "^10.1.8",
|
|
74
74
|
"eslint-plugin-import": "^2.32.0",
|
|
75
|
-
"eslint-plugin-svelte": "^3.
|
|
75
|
+
"eslint-plugin-svelte": "^3.20.0",
|
|
76
76
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
77
|
-
"globals": "^17.
|
|
77
|
+
"globals": "^17.7.0",
|
|
78
78
|
"husky": "^9.1.7",
|
|
79
79
|
"jsdom": "^29.1.1",
|
|
80
|
-
"mprocs": "^0.9.
|
|
81
|
-
"prettier": "^3.
|
|
80
|
+
"mprocs": "^0.9.6",
|
|
81
|
+
"prettier": "^3.9.4",
|
|
82
82
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
83
|
-
"prettier-plugin-svelte": "^4.
|
|
83
|
+
"prettier-plugin-svelte": "^4.1.1",
|
|
84
84
|
"publint": "^0.3.21",
|
|
85
|
-
"svelte": "^5.
|
|
86
|
-
"svelte-check": "^4.
|
|
85
|
+
"svelte": "^5.56.4",
|
|
86
|
+
"svelte-check": "^4.7.1",
|
|
87
87
|
"typescript": "^6.0.3",
|
|
88
|
-
"typescript-eslint": "^8.
|
|
89
|
-
"vite": "^8.
|
|
90
|
-
"vitest": "^4.1.
|
|
88
|
+
"typescript-eslint": "^8.62.1",
|
|
89
|
+
"vite": "^8.1.3",
|
|
90
|
+
"vitest": "^4.1.9"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
93
|
"svelte": "^5.0.0"
|