@expansion-kit/svelte-engine
v0.1.0
Published
Reactive expansion state for Svelte 5 — typed Map<key, mode> registry (open/collapsed/closed) with optional injectable-storage persistence
Maintainers
Readme
@expansion-kit/core
Reactive expansion state for Svelte 5. A thin Map<key, mode> registry for
anything that opens, closes, or collapses — sidebar accordions, collapsible
cards, and drawers — with optional persistence to localStorage (or any
storage you inject).
- Three modes, not just a boolean —
'open' | 'collapsed' | 'closed', so a drawer can be a full panel, an icon-only rail, or fully hidden. - A simple boolean API on top —
isOpen/open/close/togglefor the common two-state case, so you don't have to think about modes at all if you don't need to. - SSR-safe — the class can be constructed before
windowexists; storage is only touched once you callinitialize(). - Zero required dependencies beyond Svelte — storage and encryption are both injectable interfaces, not hard dependencies.
Install
pnpm add @expansion-kit/coresvelte ^5.0.0 is a peer dependency.
Quick start (in-memory, no persistence)
import { ExpansionController } from '@expansion-kit/core';
const nav = new ExpansionController();
nav.isOpen('workspace'); // true (default fallback)
nav.close('workspace');
nav.isOpen('workspace'); // false
nav.toggle('workspace'); // → openUsed directly in a .svelte file, nav.state is a SvelteMap, so reads
of isOpen() / getMode() inside a template or $derived are reactive.
<script lang="ts">
import { ExpansionController } from '@expansion-kit/core';
const nav = new ExpansionController({ defaultKeys: ['workspace'] });
</script>
<button onclick={() => nav.toggle('workspace')}>
{nav.isOpen('workspace') ? 'Collapse' : 'Expand'}
</button>Persisting across reloads
Persistence only activates once you call initialize() — this is what
makes the controller safe to construct during SSR, before localStorage
exists. Call it once, client-side, e.g. in onMount:
import { onMount } from 'svelte';
import { ExpansionController } from '@expansion-kit/core';
const nav = new ExpansionController({
cacheKey: 'nav-state',
defaultKeys: ['workspace'], // open on a genuine first visit, before any cache exists
});
onMount(() => {
nav.initialize(); // attaches localStorage and hydrates any saved state
});Before initialize() runs, the controller works entirely in-memory:
defaultKeys are seeded synchronously in the constructor (safe during SSR),
and any mutations you make are simply not persisted yet. Once
initialize() runs, a valid saved cache fully replaces the
constructor's defaultKeys — the user's last-known layout wins.
Injecting a custom storage backend
initialize() accepts any object matching the tiny ExpansionStorage
interface (getItem/setItem/removeItem) — useful for sessionStorage,
a cookie-backed adapter, or an in-memory store in tests.
nav.initialize({ storage: sessionStorage });The three-state mode API (drawers, rails, etc.)
For anything beyond simple open/closed — e.g. a drawer that can also be an icon-only rail — use the mode API directly:
const drawer = new ExpansionController({ cacheKey: 'drawer' });
drawer.collapse('sidebar'); // icon-only rail
drawer.getMode('sidebar'); // 'collapsed'
drawer.isOpen('sidebar'); // false — only 'open' reads as true
drawer.setMode('sidebar', 'open'); // full panelisOpen() treats 'collapsed' the same as 'closed' — it's true only
for 'open'. If your UI needs to branch on all three states, read
getMode() instead of isOpen().
API reference
new ExpansionController<TKey>(config?)
| Option | Type | Default | Description |
| ------------- | ------------------ | ------- | ------------------------------------------------------------------- |
| cacheKey | string | — | Storage key. Omit to keep state in-memory only. |
| defaultKeys | TKey[] | — | Keys marked 'open' on first visit, before any valid cache exists. |
| encrypt | ExpansionEncrypt | — | Optional encode/decode hook applied to the persisted JSON string. |
.initialize(config?)
| Option | Type | Default |
| --------- | ------------------ | --------------------------------------- |
| storage | ExpansionStorage | localStorage if available, else no-op |
Attaches storage and hydrates any persisted state. Call once, client-side. Safe to call again if you need to swap storage backends at runtime.
Query
| Method | Returns |
| ----------------------------------- | --------------------------------------------------------------- |
| isOpen(key, fallback = true) | boolean — true only if the mode is 'open' |
| getMode(key, fallback = 'closed') | ExpansionMode — the explicit mode, or fallback if untracked |
| has(key) | boolean — whether key has any explicit mode |
| count | number — count of keys explicitly set to 'open' (reactive) |
Mutation
| Method | Effect |
| ------------------------------ | ------------------------------------------------------------- |
| open(key) | Set mode to 'open' |
| close(key) | Set mode to 'closed' |
| collapse(key) | Set mode to 'collapsed' |
| setMode(key, mode) | Set an explicit ExpansionMode |
| toggle(key, fallback = true) | Flip between 'open' and 'closed' (ignores 'collapsed') |
| openMany(keys) | Set multiple keys to 'open' in one persist call |
| closeAll() | Set every currently tracked key to 'closed' |
| reset(key) | Remove the explicit mode for one key — fallback governs again |
| clear() | Remove all explicit modes |
Cache format and migration
Persisted as a JSON array of [key, mode] tuples:
[
["workspace", "open"],
["sidebar", "collapsed"],
["growth", "closed"]
]If you're upgrading from an older boolean-based version of this package,
the legacy [key, boolean][] format is migrated transparently on read —
true becomes 'open', false becomes 'closed'. No manual cache
clearing is needed. A corrupted or unparseable cache entry is discarded
(and the stored key removed) instead of throwing.
Encryption
ExpansionEncrypt (encode/decode) is an optional pass-through hook
applied to the persisted JSON string before it's written to storage, and
after it's read back. This package ships no cryptography — it's a thin,
pluggable primitive, and encoding a client-side cache doesn't protect
against anything a user with access to their own browser can't already
see. It exists for cases like obscuring the raw JSON shape from casual
inspection, or reusing an encode/decode pair you already have.
If you need real encryption, plug in your own implementation (e.g. Web Crypto) or build a small adapter package on top of this one:
import { ExpansionController, type ExpansionEncrypt } from '@expansion-kit/core';
const encrypt: ExpansionEncrypt = {
encode: (json) => myEncode(json),
decode: (value) => myDecode(value),
};
const nav = new ExpansionController({ cacheKey: 'nav', encrypt });License
MIT
