@humanspeak/svelte-json-view-lite 0.1.4 → 0.2.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/README.md CHANGED
@@ -259,6 +259,7 @@ pnpm build # vite build + svelte-package + publint
259
259
 
260
260
  Part of the [Humanspeak](https://humanspeak.com) family of runes-native Svelte 5 packages:
261
261
 
262
+ <!-- prettier-ignore-start -->
262
263
  | Package | Description |
263
264
  | --- | --- |
264
265
  | [@humanspeak/svelte-markdown](https://markdown.svelte.page) | Runtime markdown renderer for Svelte |
@@ -271,6 +272,7 @@ Part of the [Humanspeak](https://humanspeak.com) family of runes-native Svelte 5
271
272
  | [@humanspeak/memory-cache](https://memory.svelte.page) | In-memory cache for TypeScript |
272
273
  | **[@humanspeak/svelte-json-view-lite](https://jsonview.svelte.page)** — _this package_ | JSON tree viewer for Svelte 5 |
273
274
  | [@humanspeak/svelte-scoped-props](https://scoped.svelte.page) | Scoped class props for Svelte |
275
+ <!-- prettier-ignore-end -->
274
276
 
275
277
  ## License
276
278
 
@@ -15,7 +15,9 @@
15
15
  live in a single punctuation span so the `.punctuation-base + .punctuation-base`
16
16
  adjacent-sibling margin rule isn't needed here.
17
17
  -->
18
- <div class={style.basicChildStyle} role="treeitem" aria-selected={false}>
18
+ <!-- Upstream parity: unselected treeitems omit aria-selected entirely. -->
19
+ <!-- svelte-ignore a11y_role_has_required_aria_props -->
20
+ <div class={style.basicChildStyle} role="treeitem">
19
21
  <!-- prettier-ignore -->
20
22
  {#if hasField}<span class={style.label}>{labelText}:</span>{/if}<span class={style.punctuation}
21
23
  >{openBracket}{closeBracket}{lastElement ? '' : ','}</span
@@ -60,6 +60,13 @@
60
60
 
61
61
  let expanderButton = $state<HTMLSpanElement | null>(null)
62
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
+
63
70
  const activeAriaLabels = $derived<AriaLabels>(
64
71
  style.ariaLabels ??
65
72
  style.ariaLables ?? {
@@ -112,29 +119,13 @@
112
119
  if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
113
120
  e.preventDefault()
114
121
  const direction = e.key === 'ArrowUp' ? -1 : 1
115
- const outer = outerRef.current
116
- if (!outer) return
117
- const buttons = outer.querySelectorAll<HTMLElement>('[role=button]')
118
- let currentIndex = -1
119
- for (let i = 0; i < buttons.length; i++) {
120
- if (buttons[i].tabIndex === 0) {
121
- currentIndex = i
122
- break
123
- }
124
- }
125
- if (currentIndex < 0) return
126
- const nextIndex = (currentIndex + direction + buttons.length) % buttons.length
127
- buttons[currentIndex].tabIndex = -1
128
- buttons[nextIndex].tabIndex = 0
129
- buttons[nextIndex].focus()
122
+ if (expanderButton) outerRef.navigation.move(expanderButton, direction)
130
123
  }
131
124
 
132
125
  function onClick() {
133
126
  setExpandWithCallback(!expanded)
134
127
  if (!expanderButton) return
135
- const prev = outerRef.current?.querySelector<HTMLElement>('[role=button][tabindex="0"]')
136
- if (prev) prev.tabIndex = -1
137
- expanderButton.tabIndex = 0
128
+ outerRef.navigation.activate(expanderButton)
138
129
  expanderButton.focus()
139
130
  }
140
131
  </script>
@@ -142,12 +133,9 @@
142
133
  {#if count === 0}
143
134
  <EmptyObject {field} {openBracket} {closeBracket} {lastElement} {style} />
144
135
  {:else}
145
- <div
146
- class={style.basicChildStyle}
147
- role="treeitem"
148
- aria-expanded={expanded}
149
- aria-selected={false}
150
- >
136
+ <!-- Upstream parity: unselected treeitems omit aria-selected entirely. -->
137
+ <!-- svelte-ignore a11y_role_has_required_aria_props -->
138
+ <div class={style.basicChildStyle} role="treeitem" aria-expanded={expanded}>
151
139
  <!--
152
140
  The entire inline sequence inside a row lives on a single
153
141
  prettier-ignored line because Svelte preserves template whitespace
@@ -159,7 +147,7 @@
159
147
  it tight anyway for a consistent rule.
160
148
  -->
161
149
  <!-- prettier-ignore -->
162
- <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(
150
+ <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(
163
151
  { field: field ?? '', level }
164
152
  )}{:else if clickToExpandNode}<!-- svelte-ignore a11y_no_static_element_interactions --><span
165
153
  class={style.clickableLabel}
@@ -112,7 +112,9 @@
112
112
  whitespace natively; we hand-collapse it. Per-type snippet branches key
113
113
  off `kind` rather than re-running predicates.
114
114
  -->
115
- <div class={style.basicChildStyle} role="treeitem" aria-selected={false}>
115
+ <!-- Upstream parity: unselected treeitems omit aria-selected entirely. -->
116
+ <!-- svelte-ignore a11y_role_has_required_aria_props -->
117
+ <div class={style.basicChildStyle} role="treeitem">
116
118
  <!-- prettier-ignore -->
117
119
  {#if hasField}{#if snippets.label}{@render snippets.label({ field: field ?? '', level })}{:else}<span class={style.label}>{labelText}:</span>{/if}{/if}{#if activeSnippet && kind === 'null'}{@render snippets.null?.(
118
120
  { value: null, field, level }
@@ -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
- * Reference wrapper passed from the root down to every expandable node so
110
- * that cross-sibling keyboard navigation can query `[role=button]` elements
111
- * scoped to the tree. Using a getter ensures the child always reads the
112
- * current `bind:this` target rather than a frozen snapshot.
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 {
@@ -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.4",
3
+ "version": "0.2.0",
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",