Add tree state functionality
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
A dependency-free, instance-based checkbox tree for browser ES modules. It
|
||||
supports cascading selection, indeterminate parent states, expandable branches,
|
||||
multiple independent instances, and optional local-storage persistence.
|
||||
multiple independent instances, optional local-storage persistence, and compact
|
||||
shareable URL state backed by an append-only node manifest.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -65,6 +66,9 @@ flags.
|
||||
- `setSelectedIds(ids, { notify })` replaces the selection.
|
||||
- `restoreState()` restores selection and expansion state.
|
||||
- `destroy()` removes listeners, markup, and the root CSS class.
|
||||
- `serializeStateToFragment(manifest)` returns `#v=1&c=...&x=...` state.
|
||||
- `restoreStateFromLocation(manifest, location)` safely applies URL state.
|
||||
- `validateManifest(manifest)` reports unregistered and missing paths.
|
||||
|
||||
Options:
|
||||
|
||||
@@ -72,6 +76,32 @@ Options:
|
||||
- `initiallySelected` defaults to `false`.
|
||||
- `storageKey` defaults to `null`, which disables persistence.
|
||||
- `onSelectionChange` receives `{ selectedIds, selectedNodes }`.
|
||||
- `manifest` optionally supplies a parsed manifest and automatically restores
|
||||
state from `window.location.hash` after `setData()`.
|
||||
|
||||
## Shareable URL state
|
||||
|
||||
Create a JSON manifest whose array positions are permanent slots. Canonical node
|
||||
paths join ancestor IDs with `>`:
|
||||
|
||||
```json
|
||||
{ "schema": 1, "nodes": ["fruit", "fruit>apple", "fruit>pear", null] }
|
||||
```
|
||||
|
||||
Import `loadManifest`, pass its result as the `manifest` option, and call
|
||||
`tree.serializeStateToFragment()` when creating a share link. Existing entries
|
||||
must never be reordered. Append new paths, replace removed paths with `null`, and
|
||||
edit a path in place for a logical rename or move. Node IDs therefore cannot
|
||||
contain `>`.
|
||||
|
||||
The fragment contains independently trimmed checked (`c`) and expanded (`x`)
|
||||
bitsets. Bits are least-significant-bit first within each byte. Missing bits
|
||||
default to unchecked and collapsed; malformed or unsupported state is ignored.
|
||||
The helpers `encodeTreeState`, `decodeTreeState`, `applyTreeState`, and
|
||||
`restoreStateFromLocation` are also exported for custom integrations.
|
||||
Use `validateManifestEvolution(previous, next)` in a build check to reject
|
||||
removed slots and tombstone reuse; path changes are reported for deliberate
|
||||
rename/move review.
|
||||
|
||||
Styling is namespaced under `.checkbox-tree`. Override the custom properties
|
||||
`--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and
|
||||
|
||||
+35
-1
@@ -8,20 +8,25 @@
|
||||
<style>
|
||||
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
|
||||
#selection { padding: 0.75rem; background: #f3f3f3; }
|
||||
#share-status { min-height: 1.5em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Checkbox Tree</h1>
|
||||
<p>A representative sample of countries, U.S. states, and North Dakota counties.</p>
|
||||
<div id="tree"></div>
|
||||
<p><button id="share" type="button">Copy shareable URL</button></p>
|
||||
<p id="share-status" role="status"></p>
|
||||
<h2>Selected places</h2>
|
||||
<pre id="selection">[]</pre>
|
||||
<script type="module">
|
||||
import { CheckboxTree } from "../src/checkbox-tree.js";
|
||||
import { CheckboxTree, loadManifest } from "../src/checkbox-tree.js";
|
||||
|
||||
const output = document.querySelector("#selection");
|
||||
const manifest = loadManifest(await fetch("./manifest.json").then(response => response.json()));
|
||||
const tree = new CheckboxTree(document.querySelector("#tree"), {
|
||||
initiallySelected: true,
|
||||
manifest,
|
||||
onSelectionChange({ selectedIds }) {
|
||||
output.textContent = JSON.stringify(selectedIds, null, 2);
|
||||
},
|
||||
@@ -72,6 +77,35 @@
|
||||
{ id: "country:south-africa", label: "South Africa" },
|
||||
]);
|
||||
output.textContent = JSON.stringify(tree.getSelectedIds(), null, 2);
|
||||
|
||||
function updateUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = tree.serializeStateToFragment();
|
||||
if (url.href !== window.location.href) history.pushState(null, "", url);
|
||||
}
|
||||
|
||||
document.querySelector("#tree").addEventListener("change", updateUrl);
|
||||
document.querySelector("#tree").addEventListener("click", event => {
|
||||
if (event.target.closest('.checkbox-tree__toggle')) updateUrl();
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
tree.restoreStateFromLocation();
|
||||
output.textContent = JSON.stringify(tree.getSelectedIds(), null, 2);
|
||||
document.querySelector("#share-status").textContent = "";
|
||||
});
|
||||
|
||||
document.querySelector("#share").addEventListener("click", async () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = tree.serializeStateToFragment();
|
||||
const status = document.querySelector("#share-status");
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.href);
|
||||
status.textContent = "Shareable URL copied.";
|
||||
} catch {
|
||||
status.textContent = `Share this URL: ${url.href}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"nodes": [
|
||||
"country:argentina",
|
||||
"country:australia",
|
||||
"country:brazil",
|
||||
"country:canada",
|
||||
"country:france",
|
||||
"country:germany",
|
||||
"country:india",
|
||||
"country:japan",
|
||||
"country:mexico",
|
||||
"country:united-kingdom",
|
||||
"country:usa",
|
||||
"country:usa>state:california",
|
||||
"country:usa>state:florida",
|
||||
"country:usa>state:illinois",
|
||||
"country:usa>state:minnesota",
|
||||
"country:usa>state:montana",
|
||||
"country:usa>state:north-dakota",
|
||||
"country:usa>state:north-dakota>county:adams-nd",
|
||||
"country:usa>state:north-dakota>county:barnes-nd",
|
||||
"country:usa>state:north-dakota>county:burleigh-nd",
|
||||
"country:usa>state:north-dakota>county:cass-nd",
|
||||
"country:usa>state:north-dakota>county:grand-forks-nd",
|
||||
"country:usa>state:north-dakota>county:mckenzie-nd",
|
||||
"country:usa>state:north-dakota>county:morton-nd",
|
||||
"country:usa>state:north-dakota>county:ramsey-nd",
|
||||
"country:usa>state:north-dakota>county:stark-nd",
|
||||
"country:usa>state:north-dakota>county:ward-nd",
|
||||
"country:usa>state:new-york",
|
||||
"country:usa>state:south-dakota",
|
||||
"country:usa>state:texas",
|
||||
"country:usa>state:washington",
|
||||
"country:south-africa"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+222
-3
@@ -2,9 +2,200 @@ const DEFAULT_OPTIONS = {
|
||||
initiallyCollapsed: true,
|
||||
initiallySelected: false,
|
||||
storageKey: null,
|
||||
onSelectionChange: null
|
||||
onSelectionChange: null,
|
||||
manifest: null
|
||||
};
|
||||
|
||||
const SERIALIZATION_VERSION = 1;
|
||||
const PATH_DELIMITER = '>';
|
||||
|
||||
export function loadManifest(source) {
|
||||
let records;
|
||||
let schema = 1;
|
||||
|
||||
if (typeof source === 'string') {
|
||||
records = source.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
.map(line => line === '!deleted' ? null : line);
|
||||
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
|
||||
schema = source.schema;
|
||||
records = source.nodes;
|
||||
} else if (source && Array.isArray(source.slots)) {
|
||||
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
|
||||
} else {
|
||||
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
|
||||
}
|
||||
|
||||
return normalizeManifest(schema, records);
|
||||
}
|
||||
|
||||
function normalizeManifest(schema, records) {
|
||||
if (schema !== 1) {
|
||||
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
|
||||
}
|
||||
|
||||
const pathToSlot = new Map();
|
||||
const slots = records.map((record, index) => {
|
||||
if (record === null || record === '!deleted') {
|
||||
return { path: null, deleted: true };
|
||||
}
|
||||
if (typeof record !== 'string' || !record || record.trim() !== record) {
|
||||
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
|
||||
}
|
||||
if (pathToSlot.has(record)) {
|
||||
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
|
||||
}
|
||||
pathToSlot.set(record, index);
|
||||
return { path: record, deleted: false };
|
||||
});
|
||||
|
||||
return { schema: 1, slots, pathToSlot };
|
||||
}
|
||||
|
||||
export function setBit(bytes, bitIndex, value) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
const mask = 1 << (bitIndex % 8);
|
||||
if (value) bytes[byteIndex] |= mask;
|
||||
else bytes[byteIndex] &= ~mask;
|
||||
}
|
||||
|
||||
export function getBit(bytes, bitIndex) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(value, maximumLength = Infinity) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new Error('Invalid Base64URL value.');
|
||||
}
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
|
||||
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function trimTrailingZeroBytes(bytes) {
|
||||
let end = bytes.length;
|
||||
while (end && bytes[end - 1] === 0) end--;
|
||||
return bytes.slice(0, end);
|
||||
}
|
||||
|
||||
function asManifest(manifest) {
|
||||
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
|
||||
}
|
||||
|
||||
export function validateManifest(manifest, tree) {
|
||||
const normalized = asManifest(manifest);
|
||||
const treePaths = new Set(tree.nodesByPath.keys());
|
||||
return {
|
||||
valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)),
|
||||
unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)),
|
||||
missingPaths: normalized.slots
|
||||
.filter(slot => !slot.deleted && !treePaths.has(slot.path))
|
||||
.map(slot => slot.path)
|
||||
};
|
||||
}
|
||||
|
||||
export function validateManifestEvolution(previous, next) {
|
||||
const oldManifest = asManifest(previous);
|
||||
const newManifest = asManifest(next);
|
||||
const errors = [];
|
||||
|
||||
if (newManifest.slots.length < oldManifest.slots.length) {
|
||||
errors.push('Manifest slots cannot be removed; use tombstones.');
|
||||
}
|
||||
oldManifest.slots.forEach((oldSlot, index) => {
|
||||
const newSlot = newManifest.slots[index];
|
||||
if (!newSlot) return;
|
||||
if (oldSlot.deleted && !newSlot.deleted) {
|
||||
errors.push(`Tombstoned slot ${index} cannot be reused.`);
|
||||
}
|
||||
if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) {
|
||||
errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function encodeTreeState(tree, manifest) {
|
||||
const normalized = asManifest(manifest);
|
||||
const byteLength = Math.ceil(normalized.slots.length / 8);
|
||||
const checked = new Uint8Array(byteLength);
|
||||
const expanded = new Uint8Array(byteLength);
|
||||
|
||||
for (const [path, node] of tree.nodesByPath) {
|
||||
const slot = normalized.pathToSlot.get(path);
|
||||
if (slot === undefined) continue;
|
||||
const item = tree.itemForNode(node.id);
|
||||
const checkbox = item && tree.directCheckbox(item);
|
||||
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
||||
setBit(checked, slot, Boolean(checkbox?.checked));
|
||||
setBit(expanded, slot, toggle?.getAttribute('aria-expanded') === 'true');
|
||||
}
|
||||
|
||||
const checkedValue = bytesToBase64Url(trimTrailingZeroBytes(checked));
|
||||
const expandedValue = bytesToBase64Url(trimTrailingZeroBytes(expanded));
|
||||
return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null };
|
||||
}
|
||||
|
||||
export function decodeTreeState(fragment, manifest) {
|
||||
const normalized = asManifest(manifest);
|
||||
const value = fragment.startsWith('#') ? fragment.slice(1) : fragment;
|
||||
const params = new URLSearchParams(value);
|
||||
if (params.get('v') !== String(SERIALIZATION_VERSION)) {
|
||||
return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] };
|
||||
}
|
||||
const maximumLength = Math.ceil(normalized.slots.length / 8);
|
||||
try {
|
||||
return {
|
||||
applied: true,
|
||||
version: SERIALIZATION_VERSION,
|
||||
checked: base64UrlToBytes(params.get('c') ?? '', maximumLength),
|
||||
expanded: base64UrlToBytes(params.get('x') ?? '', maximumLength),
|
||||
warnings: []
|
||||
};
|
||||
} catch (error) {
|
||||
return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTreeState(tree, manifest, state) {
|
||||
if (!state?.applied) return false;
|
||||
const normalized = asManifest(manifest);
|
||||
for (const [path, node] of tree.nodesByPath) {
|
||||
const slot = normalized.pathToSlot.get(path);
|
||||
if (slot === undefined) continue;
|
||||
const item = tree.itemForNode(node.id);
|
||||
const checkbox = item && tree.directCheckbox(item);
|
||||
if (checkbox && !checkbox.disabled) checkbox.checked = getBit(state.checked, slot);
|
||||
tree.setExpanded(item, getBit(state.expanded, slot));
|
||||
}
|
||||
tree.updateAllParentCheckboxes();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function serializeStateToFragment(tree, manifest) {
|
||||
const state = encodeTreeState(tree, manifest);
|
||||
const params = new URLSearchParams({ v: String(state.version) });
|
||||
if (state.checked) params.set('c', state.checked);
|
||||
if (state.expanded) params.set('x', state.expanded);
|
||||
return `#${params}`;
|
||||
}
|
||||
|
||||
export function restoreStateFromLocation(tree, manifest, location = window.location) {
|
||||
const state = decodeTreeState(location.hash ?? '', manifest);
|
||||
if (state.applied) applyTreeState(tree, manifest, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export class CheckboxTree {
|
||||
constructor(container, options = {}) {
|
||||
if (!(container instanceof Element)) {
|
||||
@@ -14,6 +205,7 @@ export class CheckboxTree {
|
||||
this.container = container;
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
this.nodesById = new Map();
|
||||
this.nodesByPath = new Map();
|
||||
this.rootNodes = [];
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
@@ -29,11 +221,13 @@ export class CheckboxTree {
|
||||
}
|
||||
|
||||
this.nodesById.clear();
|
||||
this.nodesByPath.clear();
|
||||
this.rootNodes = nodes;
|
||||
this.indexNodes(nodes);
|
||||
this.render();
|
||||
this.updateAllParentCheckboxes();
|
||||
this.restoreState();
|
||||
if (this.options.manifest) this.restoreStateFromLocation();
|
||||
}
|
||||
|
||||
getSelectedIds() {
|
||||
@@ -66,9 +260,10 @@ export class CheckboxTree {
|
||||
this.container.classList.remove('checkbox-tree');
|
||||
this.container.replaceChildren();
|
||||
this.nodesById.clear();
|
||||
this.nodesByPath.clear();
|
||||
}
|
||||
|
||||
indexNodes(nodes) {
|
||||
indexNodes(nodes, parentPath = '') {
|
||||
nodes.forEach(node => {
|
||||
if (!node || typeof node.id !== 'string' || !node.id || typeof node.label !== 'string') {
|
||||
throw new TypeError('Each CheckboxTree node requires non-empty string id and label properties.');
|
||||
@@ -77,12 +272,18 @@ export class CheckboxTree {
|
||||
throw new Error(`Duplicate CheckboxTree node id: ${node.id}`);
|
||||
}
|
||||
|
||||
if (node.id.includes(PATH_DELIMITER)) {
|
||||
throw new Error(`CheckboxTree node id cannot contain "${PATH_DELIMITER}": ${node.id}`);
|
||||
}
|
||||
|
||||
this.nodesById.set(node.id, node);
|
||||
const path = parentPath ? `${parentPath}${PATH_DELIMITER}${node.id}` : node.id;
|
||||
this.nodesByPath.set(path, node);
|
||||
if (node.children !== undefined) {
|
||||
if (!Array.isArray(node.children)) {
|
||||
throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`);
|
||||
}
|
||||
this.indexNodes(node.children);
|
||||
this.indexNodes(node.children, path);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -191,6 +392,7 @@ export class CheckboxTree {
|
||||
}
|
||||
|
||||
setExpanded(item, expanded) {
|
||||
if (!item) return;
|
||||
const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
||||
const children = this.directChildrenContainer(item);
|
||||
if (!toggle || !children) {
|
||||
@@ -211,6 +413,11 @@ export class CheckboxTree {
|
||||
return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox');
|
||||
}
|
||||
|
||||
itemForNode(id) {
|
||||
return Array.from(this.container.querySelectorAll('.checkbox-tree__item'))
|
||||
.find(item => item.dataset.nodeId === id) ?? null;
|
||||
}
|
||||
|
||||
updateAncestorCheckboxes(item) {
|
||||
let parentItem = item.parentElement?.closest('.checkbox-tree__item');
|
||||
while (parentItem && this.container.contains(parentItem)) {
|
||||
@@ -286,4 +493,16 @@ export class CheckboxTree {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateManifest(manifest = this.options.manifest) {
|
||||
return validateManifest(manifest, this);
|
||||
}
|
||||
|
||||
serializeStateToFragment(manifest = this.options.manifest) {
|
||||
return serializeStateToFragment(this, manifest);
|
||||
}
|
||||
|
||||
restoreStateFromLocation(manifest = this.options.manifest, location = window.location) {
|
||||
return restoreStateFromLocation(this, manifest, location);
|
||||
}
|
||||
}
|
||||
|
||||
+106
-1
@@ -8,7 +8,16 @@ globalThis.document = dom.window.document;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
|
||||
const { CheckboxTree } = await import('../src/checkbox-tree.js');
|
||||
const {
|
||||
CheckboxTree,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
decodeTreeState,
|
||||
getBit,
|
||||
loadManifest,
|
||||
setBit,
|
||||
validateManifestEvolution
|
||||
} = await import('../src/checkbox-tree.js');
|
||||
|
||||
function createTree(options = {}) {
|
||||
const container = document.createElement('div');
|
||||
@@ -103,3 +112,99 @@ test('rejects duplicate node ids', () => {
|
||||
/Duplicate CheckboxTree node id/
|
||||
);
|
||||
});
|
||||
|
||||
test('bit operations use least-significant-bit-first byte ordering', () => {
|
||||
const bytes = new Uint8Array(2);
|
||||
setBit(bytes, 0, true);
|
||||
setBit(bytes, 7, true);
|
||||
setBit(bytes, 8, true);
|
||||
assert.deepEqual(Array.from(bytes), [129, 1]);
|
||||
assert.equal(getBit(bytes, 0), true);
|
||||
assert.equal(getBit(bytes, 7), true);
|
||||
assert.equal(getBit(bytes, 8), true);
|
||||
assert.equal(getBit(bytes, 16), false);
|
||||
setBit(bytes, 7, false);
|
||||
assert.deepEqual(Array.from(bytes), [1, 1]);
|
||||
});
|
||||
|
||||
test('Base64URL encoding is unpadded, URL-safe, and round trips', () => {
|
||||
const bytes = Uint8Array.from([251, 255, 0]);
|
||||
const encoded = bytesToBase64Url(bytes);
|
||||
assert.equal(encoded, '-_8A');
|
||||
assert.deepEqual(Array.from(base64UrlToBytes(encoded)), Array.from(bytes));
|
||||
assert.equal(bytesToBase64Url(new Uint8Array()), '');
|
||||
assert.throws(() => base64UrlToBytes('not valid!'), /Invalid Base64URL/);
|
||||
});
|
||||
|
||||
test('loads text and JSON manifests while retaining tombstone slots', () => {
|
||||
const text = loadManifest('# tree-state-manifest\nroot\n!deleted\nroot>child\n');
|
||||
assert.equal(text.slots.length, 3);
|
||||
assert.equal(text.slots[1].deleted, true);
|
||||
assert.equal(text.pathToSlot.get('root>child'), 2);
|
||||
|
||||
const json = loadManifest({ schema: 1, nodes: ['renamed', null, 'renamed>child', 'new'] });
|
||||
assert.equal(json.pathToSlot.get('renamed>child'), 2);
|
||||
assert.equal(json.pathToSlot.get('new'), 3);
|
||||
assert.throws(() => loadManifest({ schema: 1, nodes: ['same', 'same'] }), /Duplicate/);
|
||||
});
|
||||
|
||||
test('manifest evolution rejects slot removal and tombstone reuse', () => {
|
||||
const original = { schema: 1, nodes: ['root', null, 'root>child'] };
|
||||
assert.equal(validateManifestEvolution(original, {
|
||||
schema: 1,
|
||||
nodes: ['root', null, 'root>child', 'new']
|
||||
}).valid, true);
|
||||
assert.match(validateManifestEvolution(original, {
|
||||
schema: 1,
|
||||
nodes: ['root', 'reused', 'root>child']
|
||||
}).errors[0], /cannot be reused/);
|
||||
assert.match(validateManifestEvolution(original, {
|
||||
schema: 1,
|
||||
nodes: ['root']
|
||||
}).errors[0], /cannot be removed/);
|
||||
});
|
||||
|
||||
test('serializes and restores checked and expanded state through a fragment', () => {
|
||||
const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', null, 'parent>second'] });
|
||||
const first = createTree();
|
||||
checkbox(first.container, 'first').click();
|
||||
first.container.querySelector('.checkbox-tree__toggle').click();
|
||||
const fragment = first.tree.serializeStateToFragment(manifest);
|
||||
assert.equal(fragment, '#v=1&c=Ag&x=AQ');
|
||||
|
||||
const second = createTree({ manifest });
|
||||
const result = second.tree.restoreStateFromLocation(manifest, { hash: fragment });
|
||||
assert.equal(result.applied, true);
|
||||
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
|
||||
assert.equal(second.container.querySelector('.checkbox-tree__toggle').getAttribute('aria-expanded'), 'true');
|
||||
});
|
||||
|
||||
test('compatible manifest changes preserve slots and default appended nodes', () => {
|
||||
const oldManifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
|
||||
const first = createTree();
|
||||
checkbox(first.container, 'first').click();
|
||||
const fragment = first.tree.serializeStateToFragment(oldManifest);
|
||||
|
||||
const evolved = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', null, 'parent>second', 'future'] });
|
||||
const second = createTree();
|
||||
const result = second.tree.restoreStateFromLocation(evolved, { hash: fragment });
|
||||
assert.equal(result.applied, true);
|
||||
assert.deepEqual(second.tree.getSelectedIds(), ['first']);
|
||||
assert.deepEqual(second.tree.validateManifest(evolved), {
|
||||
valid: true,
|
||||
unregisteredPaths: [],
|
||||
missingPaths: ['future']
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed, oversized, and unsupported URL state fails safely', () => {
|
||||
const manifest = loadManifest({ schema: 1, nodes: ['parent', 'parent>first', 'parent>second'] });
|
||||
assert.equal(decodeTreeState('#v=2&c=AQ', manifest).applied, false);
|
||||
assert.equal(decodeTreeState('#v=1&c=!', manifest).applied, false);
|
||||
assert.equal(decodeTreeState('#v=1&c=AQA', manifest).applied, false);
|
||||
|
||||
const { tree } = createTree({ initiallySelected: true });
|
||||
const result = tree.restoreStateFromLocation(manifest, { hash: '#v=1&c=!' });
|
||||
assert.equal(result.applied, false);
|
||||
assert.deepEqual(tree.getSelectedIds(), ['parent', 'first', 'second']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user