@joewinke/jatui 0.8.2 → 0.8.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/CLAUDE.md +1 -1
- package/dist/components/basic/SectionNav.svelte +167 -29
- package/dist/components/basic/SectionNav.svelte.d.ts +3 -1
- package/dist/components/basic/sectionNavSpy.d.ts +45 -0
- package/dist/components/basic/sectionNavSpy.js +44 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +2 -1
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
|
|
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
|
|
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
72
|
+
const ext = activeId;
|
|
73
|
+
if (ext !== undefined && ext !== untrack(() => active)) active = ext;
|
|
56
74
|
});
|
|
57
75
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.8.3",
|
|
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": {
|