@joewinke/jatui 0.8.2 → 0.8.4

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/CLAUDE.md CHANGED
@@ -134,7 +134,7 @@ import { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from 'jatui
134
134
  |-----------|---------|-----------|
135
135
  | `ResizablePanel` | Resizable container | `minWidth`, `maxWidth`, `defaultWidth` |
136
136
  | `ResizableDivider` | Drag handle between panels | `onResize` |
137
- | `SectionNav` | Sticky in-page section index for long single-column pages — vertical rail beside the content on xl+, wrapping chip row above it below xl. Built-in IntersectionObserver scroll-spy (omit `activeId` and it just works; bind it to observe/control). Amber dot per `dirtyIds` marks sections holding unsaved edits. `go()` scrolls AND moves focus so keyboard/SR users land where the click did. j/k/Enter via `listNav`. Layout contract: parent is a `flex flex-wrap gap-8 items-start` row, content column `flex-1 min-w-0`; sections need `id` + `scroll-mt-*`. | `sections: {id,label}[]`, `activeId?` (bindable), `dirtyIds?: Set<string>`, `ariaLabel?`, `spy?` |
137
+ | `SectionNav` | Sticky in-page section index for long single-column pages — vertical rail beside the content on xl+, wrapping chip row above it below xl. Built-in scroll-spy (omit `activeId` and it just works; `bind:activeId` to observe/control — the spy writes back to it): on every observer callback + rAF-throttled scroll/resize it measures ALL sections and activates the last one whose top is at/above the activation line (`activationRatio`, default 0.3 of the scroller height); at the bottom of the page the last section wins. Clicking an item activates it immediately and holds the spy off until the smooth scroll lands (`scrollend`, or 800ms after the last scroll event); reduced-motion scrolls instantly. Pure decision fn exported as `computeActiveSection(rects, viewport, scroll, ratio?)`. Amber dot per `dirtyIds` marks sections holding unsaved edits. `go()` scrolls AND moves focus so keyboard/SR users land where the click did. j/k/Enter via `listNav`. Layout contract: parent is a `flex flex-wrap gap-8 items-start` row, content column `flex-1 min-w-0`; sections need `id` + `scroll-mt-*`. | `sections: {id,label}[]`, `activeId?` (bindable), `dirtyIds?: Set<string>`, `ariaLabel?`, `spy?`, `activationRatio?` (0.3) |
138
138
 
139
139
  ### Audio & Voice
140
140
 
@@ -32,6 +32,7 @@
32
32
  dropup = false,
33
33
  appendToBody = false,
34
34
  clearable = false,
35
+ clearLabel = 'Clear selection',
35
36
  triggerAttrs,
36
37
  embedded = false,
37
38
  onChange,
@@ -57,6 +58,8 @@
57
58
  appendToBody?: boolean;
58
59
  /** Show an × button on the trigger when a value is selected */
59
60
  clearable?: boolean;
61
+ /** Accessible label for the clear (×) button. */
62
+ clearLabel?: string;
60
63
  /** Extra attributes forwarded to the trigger button (e.g. data-rail-id) */
61
64
  triggerAttrs?: Record<string, string>;
62
65
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
@@ -349,6 +352,7 @@
349
352
  class:sd-disabled={disabled}
350
353
  class:sd-chip={variant === 'chip'}
351
354
  class:sd-sm={size === 'sm'}
355
+ class:sd-has-clear={clearable && value !== ''}
352
356
  style={activeColor
353
357
  ? variant === 'chip'
354
358
  ? `--sd-color: ${activeColor};`
@@ -371,20 +375,23 @@
371
375
  {/if}
372
376
  <span class="truncate">{triggerLabel}</span>
373
377
  </span>
374
- {#if clearable && value !== ''}
375
- <button
376
- type="button"
377
- class="sd-clear"
378
- aria-label="Clear filter"
379
- onclick={(e) => { e.stopPropagation(); onChange(''); }}
380
- >
381
- <svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
382
- </button>
383
- {/if}
384
378
  <svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
385
379
  <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
386
380
  </svg>
387
381
  </button>
382
+ <!-- The clear control is a SIBLING of the trigger (a <button> inside a
383
+ <button> is invalid HTML and unreachable by keyboard), pinned to the
384
+ trigger's right edge beside the chevron (mos-hhzd0). -->
385
+ {#if clearable && value !== ''}
386
+ <button
387
+ type="button"
388
+ class="sd-clear"
389
+ aria-label={clearLabel}
390
+ onclick={(e) => { e.stopPropagation(); onChange(''); }}
391
+ >
392
+ <svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
393
+ </button>
394
+ {/if}
388
395
 
389
396
  {#if open}
390
397
  <div
@@ -494,6 +501,7 @@
494
501
  }
495
502
 
496
503
  .sd-trigger-label {
504
+ flex: 1 1 auto;
497
505
  display: flex;
498
506
  align-items: center;
499
507
  gap: 0.375rem;
@@ -520,7 +528,16 @@
520
528
  font-size: 0.75rem;
521
529
  }
522
530
 
531
+ /* Room for the absolutely-positioned × between the label and the chevron
532
+ (the chevron stays at the trigger's right edge). */
533
+ .sd-has-clear .sd-trigger-label {
534
+ padding-right: 1.25rem;
535
+ }
523
536
  .sd-clear {
537
+ position: absolute;
538
+ top: 50%;
539
+ right: 1.5rem;
540
+ transform: translateY(-50%);
524
541
  display: flex;
525
542
  align-items: center;
526
543
  justify-content: center;
@@ -29,6 +29,8 @@ type $$ComponentProps = {
29
29
  appendToBody?: boolean;
30
30
  /** Show an × button on the trigger when a value is selected */
31
31
  clearable?: boolean;
32
+ /** Accessible label for the clear (×) button. */
33
+ clearLabel?: string;
32
34
  /** Extra attributes forwarded to the trigger button (e.g. data-rail-id) */
33
35
  triggerAttrs?: Record<string, string>;
34
36
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
@@ -19,14 +19,27 @@
19
19
  * sticky header never covers the heading it lands on.
20
20
  *
21
21
  * Scroll-spy: pass `activeId` (optionally bound) to control/observe the
22
- * active section yourself, or omit it entirely and the built-in
23
- * IntersectionObserver drives the highlight for you. `dirtyIds` marks
24
- * sections holding unsaved edits — any page long enough to need an index
25
- * is long enough to hide a dirty field; don't drop the dot.
22
+ * active section yourself, or omit it entirely and the built-in spy drives
23
+ * the highlight for you. The spy writes back to `activeId`, so
24
+ * `bind:activeId` consumers see every change. `dirtyIds` marks sections
25
+ * holding unsaved edits — any page long enough to need an index is long
26
+ * enough to hide a dirty field; don't drop the dot.
27
+ *
28
+ * How the spy decides (mos-fv9gj): on every IntersectionObserver callback,
29
+ * scroll (rAF-throttled, passive) and resize it measures ALL sections and
30
+ * picks the last one whose top is at or above the activation line
31
+ * (`activationRatio` of the scroll area's height, default 30%) — never just
32
+ * the observer batch, which misses sections a fast scroll skips over and
33
+ * leaves a stale highlight when a section leaves. At the bottom of the page
34
+ * the last section wins (it may never reach the line). Clicking an item
35
+ * activates it immediately and holds the spy off until the smooth scroll
36
+ * settles (`scrollend`, or 800ms after the last scroll event).
26
37
  *
27
38
  * Purely navigational — it owns no form state.
28
39
  */
40
+ import { untrack } from 'svelte';
29
41
  import { listNav } from '../../actions/listNav';
42
+ import { computeActiveSection, DEFAULT_ACTIVATION_RATIO, type SectionRect } from './sectionNavSpy';
30
43
 
31
44
  type Section = { id: string; label: string };
32
45
 
@@ -35,7 +48,8 @@
35
48
  activeId = $bindable(),
36
49
  dirtyIds = new Set<string>(),
37
50
  ariaLabel = 'Sections on this page',
38
- spy = true
51
+ spy = true,
52
+ activationRatio = DEFAULT_ACTIVATION_RATIO
39
53
  }: {
40
54
  sections: Section[];
41
55
  /** Bindable. Omit (or bind to observe) — the built-in scroll-spy keeps it current either way. */
@@ -43,46 +57,170 @@
43
57
  /** Sections holding unsaved edits, marked so a long page can't hide one. */
44
58
  dirtyIds?: Set<string>;
45
59
  ariaLabel?: string;
46
- /** Run the built-in IntersectionObserver scroll-spy. Default true. */
60
+ /** Run the built-in scroll-spy. Default true. */
47
61
  spy?: boolean;
62
+ /** Activation line as a fraction of the scroll area's height (0–1). Default 0.3. */
63
+ activationRatio?: number;
48
64
  } = $props();
49
65
 
50
- let active = $state(activeId ?? sections[0]?.id ?? '');
66
+ let active = $state(untrack(() => activeId ?? sections[0]?.id ?? ''));
51
67
 
52
- // External control wins whenever the consumer passes/updates the prop
53
- // (bound consumers get spy updates two-way, so this converges, not fights).
68
+ // External control wins whenever the consumer passes/updates the prop.
69
+ // Only `activeId` is tracked, so a spy update is never reverted by a
70
+ // stale, unbound prop value.
54
71
  $effect(() => {
55
- if (activeId !== undefined && activeId !== active) active = activeId;
72
+ const ext = activeId;
73
+ if (ext !== undefined && ext !== untrack(() => active)) active = ext;
56
74
  });
57
75
 
58
- // Built-in scroll-spy: topmost section whose band crosses the upper third
59
- // of the viewport becomes active. Re-observes when `sections` changes.
76
+ /** Single write path: local highlight + the bindable prop. */
77
+ function setActive(id: string) {
78
+ if (!id) return;
79
+ if (active !== id) active = id;
80
+ if (activeId !== id) activeId = id;
81
+ }
82
+
83
+ // Click-lock: while a nav-initiated scroll is in flight the spy is muted,
84
+ // otherwise the landing position can hand the highlight to a neighbour.
85
+ let clickLocked = false;
86
+ let lockTarget: HTMLElement | null = null;
87
+ let unlockTimer: ReturnType<typeof setTimeout> | undefined;
88
+ const CLICK_LOCK_FALLBACK_MS = 800;
89
+
90
+ function unlock() {
91
+ clickLocked = false;
92
+ lockTarget = null;
93
+ clearTimeout(unlockTimer);
94
+ unlockTimer = undefined;
95
+ }
96
+
97
+ function armUnlockFallback() {
98
+ clearTimeout(unlockTimer);
99
+ unlockTimer = setTimeout(unlock, CLICK_LOCK_FALLBACK_MS);
100
+ }
101
+
102
+ /** Nearest ancestor that actually scrolls, or null for the document. */
103
+ function scrollParent(el: HTMLElement | null): HTMLElement | null {
104
+ for (let p = el?.parentElement ?? null; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {
105
+ const oy = getComputedStyle(p).overflowY;
106
+ if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay') && p.scrollHeight > p.clientHeight) return p;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /** Visible area + scroll position of the section scroller (a container, or the window). */
112
+ function metrics(container: HTMLElement | null) {
113
+ if (container) {
114
+ const r = container.getBoundingClientRect();
115
+ return {
116
+ viewport: { top: r.top, height: container.clientHeight },
117
+ scroll: { scrollTop: container.scrollTop, clientHeight: container.clientHeight, scrollHeight: container.scrollHeight }
118
+ };
119
+ }
120
+ return {
121
+ viewport: { top: 0, height: window.innerHeight },
122
+ scroll: { scrollTop: window.scrollY, clientHeight: window.innerHeight, scrollHeight: document.documentElement.scrollHeight }
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Has the click-initiated scroll reached its destination? A `scrollend`
128
+ * can belong to a DIFFERENT scroll (one this click interrupted, or a
129
+ * scroll-into-view of the button itself), so it only releases the lock
130
+ * once the target sits at its landing spot — or the scroller is pinned
131
+ * against the edge it would need to pass. Otherwise the fallback timer
132
+ * (re-armed on every scroll event) releases it.
133
+ */
134
+ function landed(container: HTMLElement | null): boolean {
135
+ if (!lockTarget || !lockTarget.isConnected) return true;
136
+ const { viewport, scroll } = metrics(container);
137
+ const margin = parseFloat(getComputedStyle(lockTarget).scrollMarginTop) || 0;
138
+ const delta = lockTarget.getBoundingClientRect().top - (viewport.top + margin);
139
+ if (Math.abs(delta) <= 2) return true;
140
+ if (delta > 0 && scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 2) return true;
141
+ if (delta < 0 && scroll.scrollTop <= 0) return true;
142
+ return false;
143
+ }
144
+
60
145
  $effect(() => {
61
146
  if (!spy) return;
62
- const els = sections
63
- .map((s) => document.getElementById(s.id))
64
- .filter((el): el is HTMLElement => !!el);
147
+ const ids = sections.map((s) => s.id);
148
+ const ratio = activationRatio;
149
+ const els = ids.map((id) => document.getElementById(id)).filter((el): el is HTMLElement => !!el);
65
150
  if (els.length === 0) return;
66
- const io = new IntersectionObserver(
67
- (entries) => {
68
- const visible = entries
69
- .filter((e) => e.isIntersecting)
70
- .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
71
- if (visible[0]) active = visible[0].target.id;
72
- },
73
- { rootMargin: '-10% 0px -70% 0px', threshold: 0 }
74
- );
151
+
152
+ const container = scrollParent(els[0]);
153
+ const scrollTarget: HTMLElement | Window = container ?? window;
154
+ let raf = 0;
155
+
156
+ function recompute() {
157
+ raf = 0;
158
+ if (clickLocked) return;
159
+ const rects: SectionRect[] = [];
160
+ for (const id of ids) {
161
+ const el = document.getElementById(id);
162
+ if (el) rects.push({ id, top: el.getBoundingClientRect().top });
163
+ }
164
+ const { viewport, scroll } = metrics(container);
165
+ const id = computeActiveSection(rects, viewport, scroll, ratio);
166
+ if (id) setActive(id);
167
+ }
168
+
169
+ function schedule() {
170
+ if (clickLocked) {
171
+ // Programmatic scroll still moving — push the fallback unlock out.
172
+ armUnlockFallback();
173
+ return;
174
+ }
175
+ if (!raf) raf = requestAnimationFrame(recompute);
176
+ }
177
+
178
+ const scrollEnd = () => {
179
+ if (clickLocked && landed(container)) unlock();
180
+ };
181
+
182
+ // Any user-initiated scroll gesture ends the click-lock immediately.
183
+ const userScroll = () => {
184
+ if (clickLocked) unlock();
185
+ };
186
+
187
+ // The observer is only a trigger (layout shifts, lazy content); the
188
+ // decision always comes from measuring every section.
189
+ const io = new IntersectionObserver(schedule, { root: container, threshold: [0, 1] });
75
190
  for (const el of els) io.observe(el);
76
- return () => io.disconnect();
191
+
192
+ const passive = { passive: true } as const;
193
+ scrollTarget.addEventListener('scroll', schedule, passive);
194
+ scrollTarget.addEventListener('scrollend', scrollEnd, passive);
195
+ window.addEventListener('resize', schedule, passive);
196
+ window.addEventListener('wheel', userScroll, passive);
197
+ window.addEventListener('touchstart', userScroll, passive);
198
+
199
+ untrack(schedule);
200
+
201
+ return () => {
202
+ io.disconnect();
203
+ scrollTarget.removeEventListener('scroll', schedule);
204
+ scrollTarget.removeEventListener('scrollend', scrollEnd);
205
+ window.removeEventListener('resize', schedule);
206
+ window.removeEventListener('wheel', userScroll);
207
+ window.removeEventListener('touchstart', userScroll);
208
+ if (raf) cancelAnimationFrame(raf);
209
+ unlock();
210
+ };
77
211
  });
78
212
 
79
213
  function go(id: string) {
80
214
  const el = document.getElementById(id);
81
215
  if (!el) return;
82
- el.scrollIntoView({
83
- behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
84
- block: 'start'
85
- });
216
+ setActive(id);
217
+ if (spy) {
218
+ clickLocked = true;
219
+ lockTarget = el;
220
+ armUnlockFallback();
221
+ }
222
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
223
+ el.scrollIntoView({ behavior: reduce ? 'instant' : 'smooth', block: 'start' });
86
224
  // Move focus so keyboard and screen-reader users land where the click
87
225
  // did, not back at the top of the document.
88
226
  el.focus({ preventScroll: true });
@@ -9,8 +9,10 @@ type $$ComponentProps = {
9
9
  /** Sections holding unsaved edits, marked so a long page can't hide one. */
10
10
  dirtyIds?: Set<string>;
11
11
  ariaLabel?: string;
12
- /** Run the built-in IntersectionObserver scroll-spy. Default true. */
12
+ /** Run the built-in scroll-spy. Default true. */
13
13
  spy?: boolean;
14
+ /** Activation line as a fraction of the scroll area's height (0–1). Default 0.3. */
15
+ activationRatio?: number;
14
16
  };
15
17
  declare const SectionNav: import("svelte").Component<$$ComponentProps, {}, "activeId">;
16
18
  type SectionNav = ReturnType<typeof SectionNav>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure scroll-spy logic for SectionNav (mos-fv9gj).
3
+ *
4
+ * Kept DOM-free so it can be unit-tested with node:test (tests/unit/).
5
+ * The component measures every registered section on each observer callback
6
+ * and on every (rAF-throttled) scroll, then asks this function which one is
7
+ * active. It NEVER infers from an IntersectionObserver batch: a batch only
8
+ * holds the entries whose intersection changed, so a section that left the
9
+ * band — or that a fast scroll carried straight through it between two
10
+ * frames — is simply absent from it.
11
+ */
12
+ /** One registered section, measured in the same coordinate space as `viewport`. */
13
+ export type SectionRect = {
14
+ id: string;
15
+ top: number;
16
+ };
17
+ /** The visible scroll area: `window` (top 0, height innerHeight) or a scrolling container's rect. */
18
+ export type SpyViewport = {
19
+ top: number;
20
+ height: number;
21
+ };
22
+ /** Scroll position of the scroll area, used for the bottom-of-page rule. */
23
+ export type SpyScrollState = {
24
+ scrollTop: number;
25
+ clientHeight: number;
26
+ scrollHeight: number;
27
+ };
28
+ /** Default activation line: 30% of the way down the visible scroll area. */
29
+ export declare const DEFAULT_ACTIVATION_RATIO = 0.3;
30
+ /** Pixels of slack for "scrolled to the bottom" (sub-pixel rounding, zoom). */
31
+ export declare const BOTTOM_EPSILON = 2;
32
+ /**
33
+ * Which section is active?
34
+ *
35
+ * 1. At the bottom of a scrollable area, the LAST section — on a short page the
36
+ * last one or two sections can never climb to the activation line, because
37
+ * the page ends first.
38
+ * 2. Otherwise, the LAST section (in document order) whose top is at or above
39
+ * the activation line (`viewport.top + viewport.height * activationRatio`).
40
+ * 3. Before the first section has reached the line (page header still showing),
41
+ * the FIRST section.
42
+ *
43
+ * `sections` must be in document order. Returns null only when it is empty.
44
+ */
45
+ export declare function computeActiveSection(sections: SectionRect[], viewport: SpyViewport, scroll: SpyScrollState, activationRatio?: number): string | null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure scroll-spy logic for SectionNav (mos-fv9gj).
3
+ *
4
+ * Kept DOM-free so it can be unit-tested with node:test (tests/unit/).
5
+ * The component measures every registered section on each observer callback
6
+ * and on every (rAF-throttled) scroll, then asks this function which one is
7
+ * active. It NEVER infers from an IntersectionObserver batch: a batch only
8
+ * holds the entries whose intersection changed, so a section that left the
9
+ * band — or that a fast scroll carried straight through it between two
10
+ * frames — is simply absent from it.
11
+ */
12
+ /** Default activation line: 30% of the way down the visible scroll area. */
13
+ export const DEFAULT_ACTIVATION_RATIO = 0.3;
14
+ /** Pixels of slack for "scrolled to the bottom" (sub-pixel rounding, zoom). */
15
+ export const BOTTOM_EPSILON = 2;
16
+ /**
17
+ * Which section is active?
18
+ *
19
+ * 1. At the bottom of a scrollable area, the LAST section — on a short page the
20
+ * last one or two sections can never climb to the activation line, because
21
+ * the page ends first.
22
+ * 2. Otherwise, the LAST section (in document order) whose top is at or above
23
+ * the activation line (`viewport.top + viewport.height * activationRatio`).
24
+ * 3. Before the first section has reached the line (page header still showing),
25
+ * the FIRST section.
26
+ *
27
+ * `sections` must be in document order. Returns null only when it is empty.
28
+ */
29
+ export function computeActiveSection(sections, viewport, scroll, activationRatio = DEFAULT_ACTIVATION_RATIO) {
30
+ if (sections.length === 0)
31
+ return null;
32
+ const scrollable = scroll.scrollHeight > scroll.clientHeight + BOTTOM_EPSILON;
33
+ const atBottom = scrollable && scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - BOTTOM_EPSILON;
34
+ if (atBottom)
35
+ return sections[sections.length - 1].id;
36
+ const ratio = Number.isFinite(activationRatio) ? Math.min(1, Math.max(0, activationRatio)) : DEFAULT_ACTIVATION_RATIO;
37
+ const line = viewport.top + viewport.height * ratio;
38
+ let active = sections[0].id;
39
+ for (const s of sections) {
40
+ if (s.top <= line)
41
+ active = s.id;
42
+ }
43
+ return active;
44
+ }
package/dist/index.d.ts CHANGED
@@ -213,5 +213,7 @@ export { default as RadioGroup } from './components/basic/RadioGroup.svelte';
213
213
  export { default as Toggle } from './components/basic/Toggle.svelte';
214
214
  export { default as Separator } from './components/basic/Separator.svelte';
215
215
  export { default as SectionNav } from './components/basic/SectionNav.svelte';
216
+ export { computeActiveSection, DEFAULT_ACTIVATION_RATIO } from './components/basic/sectionNavSpy';
217
+ export type { SectionRect, SpyViewport, SpyScrollState } from './components/basic/sectionNavSpy';
216
218
  export { default as Slider } from './components/basic/Slider.svelte';
217
219
  export { default as Pagination } from './components/basic/Pagination.svelte';
package/dist/index.js CHANGED
@@ -233,5 +233,6 @@ export { default as RadioGroup } from './components/basic/RadioGroup.svelte';
233
233
  export { default as Toggle } from './components/basic/Toggle.svelte';
234
234
  export { default as Separator } from './components/basic/Separator.svelte';
235
235
  export { default as SectionNav } from './components/basic/SectionNav.svelte';
236
+ export { computeActiveSection, DEFAULT_ACTIVATION_RATIO } from './components/basic/sectionNavSpy';
236
237
  export { default as Slider } from './components/basic/Slider.svelte';
237
238
  export { default as Pagination } from './components/basic/Pagination.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -76,6 +76,7 @@
76
76
  "check:demos": "node scripts/check-demo-coverage.mjs",
77
77
  "guard": "node scripts/check-demo-coverage.mjs",
78
78
  "test:dist": "svelte-kit sync && vitest run --config vitest.dist.config.ts",
79
+ "test:unit": "node --test tests/unit/",
79
80
  "prepublishOnly": "node scripts/guard-ci-publish.mjs && npm run package"
80
81
  },
81
82
  "peerDependencies": {