@humanspeak/svelte-json-view-lite 0.2.1 → 0.2.3

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
@@ -266,12 +266,12 @@ Part of the [Humanspeak](https://humanspeak.com) family of runes-native Svelte 5
266
266
  | [@humanspeak/svelte-virtual-list](https://virtuallist.svelte.page) | Virtual scrolling for Svelte |
267
267
  | [@humanspeak/svelte-motion](https://motion.svelte.page) | Framer Motion for Svelte 5 |
268
268
  | [@humanspeak/svelte-headless-table](https://table.svelte.page) | Headless data tables for Svelte |
269
- | [@humanspeak/svelte-diff-match-patch](https://diff.svelte.page) | Diff comparison for Svelte |
270
- | [@humanspeak/svelte-purify](https://purify.svelte.page) | HTML sanitisation for Svelte |
269
+ | [@humanspeak/svelte-diff](https://diff.svelte.page) | Diff comparison for Svelte |
271
270
  | [@humanspeak/svelte-virtual-chat](https://virtualchat.svelte.page) | Virtual chat viewport for Svelte 5 |
271
+ | [@humanspeak/svelte-purify](https://purify.svelte.page) | HTML sanitisation for Svelte |
272
272
  | [@humanspeak/memory-cache](https://memory.svelte.page) | In-memory cache for TypeScript |
273
- | **[@humanspeak/svelte-json-view-lite](https://jsonview.svelte.page)** — _this package_ | JSON tree viewer for Svelte 5 |
274
273
  | [@humanspeak/svelte-scoped-props](https://scoped.svelte.page) | Scoped class props for Svelte |
274
+ | **[@humanspeak/svelte-json-view-lite](https://jsonview.svelte.page)** — _this package_ | JSON tree viewer for Svelte 5 |
275
275
  <!-- prettier-ignore-end -->
276
276
 
277
277
  ## License
@@ -13,8 +13,13 @@
13
13
  const value = $derived(props.value)
14
14
 
15
15
  // Array check first — arrays are also objects, so order matters.
16
- const isArr = $derived(isArray(value))
17
- const isObj = $derived(isObject(value) && !isDate(value) && !isFunction(value))
16
+ const kind = $derived(
17
+ isArray(value)
18
+ ? 'array'
19
+ : isObject(value) && !isDate(value) && !isFunction(value)
20
+ ? 'object'
21
+ : 'primitive'
22
+ )
18
23
 
19
24
  // The children tuple array is NOT built here: a collapsed node only needs
20
25
  // its child *count*, and materializing `Array<[key, value]>` for every
@@ -23,7 +28,7 @@
23
28
  // lazily, gated on its own `expanded` state. See issue #21.
24
29
  </script>
25
30
 
26
- {#if isArr}
31
+ {#if kind === 'array'}
27
32
  <ExpandableObject
28
33
  {...props}
29
34
  value={value as unknown[]}
@@ -31,7 +36,7 @@
31
36
  openBracket="["
32
37
  closeBracket="]"
33
38
  />
34
- {:else if isObj}
39
+ {:else if kind === 'object'}
35
40
  <ExpandableObject
36
41
  {...props}
37
42
  value={value as object}
@@ -1,8 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte'
3
3
  import DataRender from './DataRender.svelte'
4
- import EmptyObject from './EmptyObject.svelte'
5
- import type { AriaLabels, ExpandableRenderProps } from './types.js'
4
+ import type { AriaLabels, ExpandableRenderProps, ExpansionStrategy } from './types.js'
6
5
  import { quoteString } from './utils/quoteString.js'
7
6
 
8
7
  const {
@@ -21,9 +20,8 @@
21
20
  snippets
22
21
  }: ExpandableRenderProps = $props()
23
22
 
24
- // Initial expand state captured once on mount; the effect below
25
- // re-runs only when the callback identity changes so level/value/
26
- // field mutations from ancestor re-renders don't collapse the node.
23
+ // Capture initial expansion once; only a new strategy or a user toggle
24
+ // changes it. Data/theme updates preserve the user's manual override.
27
25
  // svelte-ignore state_referenced_locally
28
26
  let expanded = $state(shouldExpandNode(level, value, field))
29
27
 
@@ -33,38 +31,39 @@
33
31
  // svelte-ignore state_referenced_locally
34
32
  let hasMaterialized = $state(expanded)
35
33
 
36
- let shouldExpandNodeCalled = false
34
+ let appliedStrategy = untrack(() => shouldExpandNode)
37
35
 
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.
36
+ // Preserve the child cache through both strategy changes and user toggles.
41
37
  function applyExpanded(next: boolean) {
42
38
  expanded = next
43
39
  if (next) hasMaterialized = true
44
40
  }
45
41
 
46
- $effect(() => {
47
- const fn = shouldExpandNode
48
- if (!shouldExpandNodeCalled) {
49
- shouldExpandNodeCalled = true
50
- return
51
- }
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)))
56
- })
42
+ const updateExpansion = (fn: ExpansionStrategy) => {
43
+ if (appliedStrategy === fn) return
44
+ appliedStrategy = fn
45
+ applyExpanded(fn(level, value, field))
46
+ }
57
47
 
58
48
  // SSR-stable id for aria-controls linkage.
59
49
  const contentsId = $props.id()
60
50
 
61
51
  let expanderButton = $state<HTMLSpanElement | null>(null)
62
52
 
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 }
53
+ // The container survives empty/nonempty transitions, so it keeps receiving
54
+ // strategy updates even when there is no expander button. Its action owns
55
+ // both cleanup paths; no node subscribes to shouldExpandNode reactively.
56
+ function registerContainer(_node: HTMLDivElement) {
57
+ outerRef.expansionListeners.add(updateExpansion)
58
+ $effect(() => {
59
+ const button = expanderButton
60
+ if (button) return untrack(() => outerRef.navigation.register(button))
61
+ })
62
+ return {
63
+ destroy() {
64
+ outerRef.expansionListeners.delete(updateExpansion)
65
+ }
66
+ }
68
67
  }
69
68
 
70
69
  const activeAriaLabels = $derived<AriaLabels>(
@@ -127,12 +126,19 @@
127
126
  }
128
127
  </script>
129
128
 
130
- {#if count === 0}
131
- <EmptyObject {field} {openBracket} {closeBracket} {lastElement} {style} />
132
- {:else}
133
- <!-- Upstream parity: unselected treeitems omit aria-selected entirely. -->
134
- <!-- svelte-ignore a11y_role_has_required_aria_props -->
135
- <div class={style.basicChildStyle} role="treeitem" aria-expanded={expanded}>
129
+ <!-- svelte-ignore a11y_role_has_required_aria_props -->
130
+ <div
131
+ class={style.basicChildStyle}
132
+ role="treeitem"
133
+ aria-expanded={count === 0 ? undefined : expanded}
134
+ use:registerContainer
135
+ >
136
+ {#if count === 0}
137
+ <!-- prettier-ignore -->
138
+ {#if field !== undefined}<span class={style.label}>{labelText}:</span>{/if}<span
139
+ class={style.punctuation}>{openBracket}{closeBracket}{lastElement ? '' : ','}</span
140
+ >
141
+ {:else}
136
142
  <!--
137
143
  The entire inline sequence inside a row lives on a single
138
144
  prettier-ignored line because Svelte preserves template whitespace
@@ -144,7 +150,7 @@
144
150
  it tight anyway for a consistent rule.
145
151
  -->
146
152
  <!-- prettier-ignore -->
147
- <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 field !== undefined}{#if snippets.label}{@render snippets.label(
153
+ <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 field !== undefined}{#if snippets.label}{@render snippets.label(
148
154
  { field: field ?? '', level }
149
155
  )}{:else if clickToExpandNode}<!-- svelte-ignore a11y_no_static_element_interactions --><span
150
156
  class={style.clickableLabel}
@@ -172,5 +178,5 @@
172
178
  ></span>{/if}<span class={style.punctuation}
173
179
  >{closeBracket}{lastElement ? '' : ','}</span
174
180
  >
175
- </div>
176
- {/if}
181
+ {/if}
182
+ </div>
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte'
2
3
  import DataRender from './DataRender.svelte'
3
4
  import { defaultStyles } from './index.js'
4
- import type { OuterRef, Props, StyleProps } from './types.js'
5
+ import type { ExpansionListener, OuterRef, Props, StyleProps } from './types.js'
5
6
  import { isObject } from './utils/dataTypeDetection.js'
6
7
  import { createExpanderNavigation } from './utils/expanderNavigation.js'
7
8
  import { allExpanded } from './utils/expandStrategies.js'
@@ -26,6 +27,19 @@
26
27
  ...rest
27
28
  }: Props = $props()
28
29
 
30
+ // One strategy subscription per tree. Untracked dispatch lets each node read
31
+ // its current props without turning data/theme changes into expansion resets.
32
+ const expansionListeners = new Set<ExpansionListener>()
33
+ let previousStrategy = untrack(() => shouldExpandNode)
34
+ $effect(() => {
35
+ const fn = shouldExpandNode
36
+ if (previousStrategy === fn) return
37
+ previousStrategy = fn
38
+ untrack(() => {
39
+ for (const update of expansionListeners) update(fn)
40
+ })
41
+ })
42
+
29
43
  let outerElement = $state<HTMLDivElement | null>(null)
30
44
  const navigation = createExpanderNavigation()
31
45
 
@@ -55,7 +69,8 @@
55
69
  get current() {
56
70
  return outerElement
57
71
  },
58
- navigation
72
+ navigation,
73
+ expansionListeners
59
74
  }
60
75
 
61
76
  const snippets = $derived({
package/dist/types.d.ts CHANGED
@@ -105,6 +105,9 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'data' | 'st
105
105
  function?: Snippet<[FunctionSnippetProps]>;
106
106
  label?: Snippet<[LabelSnippetProps]>;
107
107
  }
108
+ /** Internal expansion callbacks shared by one viewer's controller. */
109
+ export type ExpansionStrategy = NonNullable<Props['shouldExpandNode']>;
110
+ export type ExpansionListener = (_strategy: ExpansionStrategy) => void;
108
111
  /**
109
112
  * Tree-local controller for roving tabindex across expandable nodes.
110
113
  *
@@ -162,6 +165,7 @@ export interface ExpanderNavigation {
162
165
  export interface OuterRef {
163
166
  readonly current: HTMLDivElement | null;
164
167
  readonly navigation: ExpanderNavigation;
168
+ readonly expansionListeners: Set<ExpansionListener>;
165
169
  }
166
170
  /** Internal shared props threaded through every renderer. Not exported. */
167
171
  export interface CommonRenderProps {
@@ -187,10 +191,3 @@ export interface ExpandableRenderProps extends CommonRenderProps {
187
191
  openBracket: string;
188
192
  closeBracket: string;
189
193
  }
190
- export interface EmptyRenderProps {
191
- field?: string;
192
- openBracket: string;
193
- closeBracket: string;
194
- lastElement: boolean;
195
- style: StyleProps;
196
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-json-view-lite",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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.61.1",
60
+ "@playwright/test": "^1.62.1",
61
61
  "@sveltejs/adapter-auto": "^7.0.1",
62
- "@sveltejs/kit": "^2.69.1",
62
+ "@sveltejs/kit": "^2.70.3",
63
63
  "@sveltejs/package": "^2.5.8",
64
- "@sveltejs/vite-plugin-svelte": "^7.1.2",
65
- "@testing-library/jest-dom": "^6.9.1",
64
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
65
+ "@testing-library/jest-dom": "^7.0.1",
66
66
  "@testing-library/svelte": "^5.4.2",
67
- "@testing-library/user-event": "^14.6.1",
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",
67
+ "@testing-library/user-event": "^14.6.5",
68
+ "@types/node": "^26.2.0",
69
+ "@typescript-eslint/eslint-plugin": "^8.67.0",
70
+ "@typescript-eslint/parser": "^8.67.0",
71
+ "@vitest/coverage-v8": "^4.1.11",
72
+ "eslint": "^10.8.1",
73
73
  "eslint-config-prettier": "^10.1.8",
74
74
  "eslint-plugin-import": "^2.32.0",
75
- "eslint-plugin-svelte": "^3.20.0",
75
+ "eslint-plugin-svelte": "^3.23.0",
76
76
  "eslint-plugin-unused-imports": "^4.4.1",
77
- "globals": "^17.7.0",
77
+ "globals": "^17.11.0",
78
78
  "husky": "^9.1.7",
79
- "jsdom": "^29.1.1",
79
+ "jsdom": "^30.0.1",
80
80
  "mprocs": "^0.9.6",
81
- "prettier": "^3.9.4",
81
+ "prettier": "^3.9.6",
82
82
  "prettier-plugin-organize-imports": "^4.3.0",
83
83
  "prettier-plugin-svelte": "^4.1.1",
84
- "publint": "^0.3.21",
85
- "svelte": "^5.56.4",
86
- "svelte-check": "^4.7.1",
84
+ "publint": "^0.3.24",
85
+ "svelte": "^5.56.10",
86
+ "svelte-check": "^4.7.6",
87
87
  "typescript": "^6.0.3",
88
- "typescript-eslint": "^8.62.1",
89
- "vite": "^8.1.3",
90
- "vitest": "^4.1.9"
88
+ "typescript-eslint": "^8.67.0",
89
+ "vite": "^8.2.2",
90
+ "vitest": "^4.1.11"
91
91
  },
92
92
  "peerDependencies": {
93
93
  "svelte": "^5.0.0"
@@ -1,25 +0,0 @@
1
- <script lang="ts">
2
- import type { EmptyRenderProps } from './types.js'
3
- import { quoteString } from './utils/quoteString.js'
4
-
5
- const { field, openBracket, closeBracket, lastElement, style }: EmptyRenderProps = $props()
6
- const hasField = $derived(field !== undefined)
7
- const labelText = $derived(quoteString(field ?? '', style.quotesForFieldNames))
8
- </script>
9
-
10
- <!--
11
- Label + brackets collapsed onto one prettier-ignored line because Svelte
12
- preserves template whitespace between adjacent elements as visible spaces
13
- under the container's `white-space: pre-wrap`. React/JSX strips that
14
- whitespace natively; we hand-collapse it. Brackets and trailing comma also
15
- live in a single punctuation span so the `.punctuation-base + .punctuation-base`
16
- adjacent-sibling margin rule isn't needed here.
17
- -->
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">
21
- <!-- prettier-ignore -->
22
- {#if hasField}<span class={style.label}>{labelText}:</span>{/if}<span class={style.punctuation}
23
- >{openBracket}{closeBracket}{lastElement ? '' : ','}</span
24
- >
25
- </div>
@@ -1,4 +0,0 @@
1
- import type { EmptyRenderProps } from './types.js';
2
- declare const EmptyObject: import("svelte").Component<EmptyRenderProps, {}, "">;
3
- type EmptyObject = ReturnType<typeof EmptyObject>;
4
- export default EmptyObject;