@joewinke/jatui 0.7.0 → 0.8.0

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,6 +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
138
 
138
139
  ### Audio & Voice
139
140
 
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SectionNav — in-page section index for long single-column pages.
4
+ *
5
+ * The page it serves is a long stack of cards; without an index the only
6
+ * way to reach "Business hours" is to scroll past six unrelated cards.
7
+ * Rendered as a sticky vertical rail beside the content column on xl+,
8
+ * and as a full-width, horizontally scrollable chip row above the content
9
+ * on smaller viewports.
10
+ *
11
+ * LAYOUT CONTRACT — the component renders TWO sibling <nav> elements (one
12
+ * per breakpoint), so its parent decides how they flow. The pattern that
13
+ * works: make the parent a `flex flex-wrap gap-8 items-start` row with the
14
+ * content column as the other flex child (`flex-1 min-w-0`). Below xl the
15
+ * chip nav's own `w-full` wraps it onto its own line above the content;
16
+ * on xl+ only the `w-52` rail renders and sits beside the content.
17
+ *
18
+ * Section elements need `id` (the jump target) and `scroll-mt-*` so the
19
+ * sticky header never covers the heading it lands on.
20
+ *
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.
26
+ *
27
+ * Purely navigational — it owns no form state.
28
+ */
29
+ import { listNav } from '../actions/listNav';
30
+
31
+ type Section = { id: string; label: string };
32
+
33
+ let {
34
+ sections,
35
+ activeId = $bindable(),
36
+ dirtyIds = new Set<string>(),
37
+ ariaLabel = 'Sections on this page',
38
+ spy = true
39
+ }: {
40
+ sections: Section[];
41
+ /** Bindable. Omit (or bind to observe) — the built-in scroll-spy keeps it current either way. */
42
+ activeId?: string;
43
+ /** Sections holding unsaved edits, marked so a long page can't hide one. */
44
+ dirtyIds?: Set<string>;
45
+ ariaLabel?: string;
46
+ /** Run the built-in IntersectionObserver scroll-spy. Default true. */
47
+ spy?: boolean;
48
+ } = $props();
49
+
50
+ let active = $state(activeId ?? sections[0]?.id ?? '');
51
+
52
+ // External control wins whenever the consumer passes/updates the prop
53
+ // (bound consumers get spy updates two-way, so this converges, not fights).
54
+ $effect(() => {
55
+ if (activeId !== undefined && activeId !== active) active = activeId;
56
+ });
57
+
58
+ // Built-in scroll-spy: topmost section whose band crosses the upper third
59
+ // of the viewport becomes active. Re-observes when `sections` changes.
60
+ $effect(() => {
61
+ if (!spy) return;
62
+ const els = sections
63
+ .map((s) => document.getElementById(s.id))
64
+ .filter((el): el is HTMLElement => !!el);
65
+ 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
+ );
75
+ for (const el of els) io.observe(el);
76
+ return () => io.disconnect();
77
+ });
78
+
79
+ function go(id: string) {
80
+ const el = document.getElementById(id);
81
+ if (!el) return;
82
+ el.scrollIntoView({
83
+ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
84
+ block: 'start'
85
+ });
86
+ // Move focus so keyboard and screen-reader users land where the click
87
+ // did, not back at the top of the document.
88
+ el.focus({ preventScroll: true });
89
+ }
90
+
91
+ const select = (el: HTMLElement) => {
92
+ if (el.dataset.navId) go(el.dataset.navId);
93
+ };
94
+ </script>
95
+
96
+ <!-- Wide: sticky vertical index beside the column -->
97
+ <nav class="sticky top-6 hidden w-52 shrink-0 self-start xl:block" aria-label={ariaLabel}>
98
+ <p class="text-base-content/45 px-3 mb-1.5 text-[0.8125rem] font-medium tracking-[0.005em]">
99
+ On this page
100
+ </p>
101
+ <ul class="space-y-0.5" use:listNav={{ onSelect: select }}>
102
+ {#each sections as s (s.id)}
103
+ <li>
104
+ <button
105
+ type="button"
106
+ data-nav-id={s.id}
107
+ onclick={() => go(s.id)}
108
+ aria-current={active === s.id ? 'true' : undefined}
109
+ class="w-full rounded-field px-3 py-1.5 text-left text-[0.9375rem] transition-colors flex items-center gap-2 outline-none
110
+ {active === s.id
111
+ ? 'bg-primary/10 text-base-content font-semibold'
112
+ : 'text-base-content/70 hover:text-base-content hover:bg-primary/5 [&.jk-focused]:bg-primary/5'}"
113
+ >
114
+ <span class="min-w-0 flex-1 truncate">{s.label}</span>
115
+ {#if dirtyIds.has(s.id)}
116
+ <span class="bg-warning h-1.5 w-1.5 shrink-0 rounded-full" aria-hidden="true"></span>
117
+ <span class="sr-only">(unsaved changes)</span>
118
+ {/if}
119
+ </button>
120
+ </li>
121
+ {/each}
122
+ </ul>
123
+ </nav>
124
+
125
+ <!-- Narrow: full-width chip row that WRAPS above the content column.
126
+ This nav is a flex sibling of the content div inside the page's
127
+ `flex flex-wrap gap-8` row, so below xl it MUST claim a whole line
128
+ (w-full under flex-wrap) — as plain content-width it sits beside the
129
+ content, hogging the left two-thirds of the page and squeezing every
130
+ card into a narrow overflowing right rail. -->
131
+ <nav class="xl:hidden sticky top-0 z-20 -mx-2 mb-4 w-full border-b border-base-content/10 bg-base-100 px-2 py-2" aria-label={ariaLabel}>
132
+ <ul class="flex gap-1.5 overflow-x-auto pb-0.5" use:listNav={{ onSelect: select }}>
133
+ {#each sections as s (s.id)}
134
+ <li class="shrink-0">
135
+ <button
136
+ type="button"
137
+ data-nav-id={s.id}
138
+ onclick={() => go(s.id)}
139
+ aria-current={active === s.id ? 'true' : undefined}
140
+ class="rounded-field inline-flex px-3 py-1.5 text-[0.8125rem] whitespace-nowrap transition-colors items-center gap-1.5 outline-none
141
+ {active === s.id
142
+ ? 'bg-primary/10 text-base-content font-semibold'
143
+ : 'text-base-content/70 hover:bg-primary/5 [&.jk-focused]:bg-primary/5'}"
144
+ >
145
+ {s.label}
146
+ {#if dirtyIds.has(s.id)}
147
+ <span class="bg-warning h-1.5 w-1.5 shrink-0 rounded-full" aria-hidden="true"></span>
148
+ <span class="sr-only">(unsaved changes)</span>
149
+ {/if}
150
+ </button>
151
+ </li>
152
+ {/each}
153
+ </ul>
154
+ </nav>
@@ -0,0 +1,17 @@
1
+ type Section = {
2
+ id: string;
3
+ label: string;
4
+ };
5
+ type $$ComponentProps = {
6
+ sections: Section[];
7
+ /** Bindable. Omit (or bind to observe) — the built-in scroll-spy keeps it current either way. */
8
+ activeId?: string;
9
+ /** Sections holding unsaved edits, marked so a long page can't hide one. */
10
+ dirtyIds?: Set<string>;
11
+ ariaLabel?: string;
12
+ /** Run the built-in IntersectionObserver scroll-spy. Default true. */
13
+ spy?: boolean;
14
+ };
15
+ declare const SectionNav: import("svelte").Component<$$ComponentProps, {}, "activeId">;
16
+ type SectionNav = ReturnType<typeof SectionNav>;
17
+ export default SectionNav;
package/dist/index.d.ts CHANGED
@@ -212,5 +212,6 @@ export { default as Checkbox } from './components/basic/Checkbox.svelte';
212
212
  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
+ export { default as SectionNav } from './components/basic/SectionNav.svelte';
215
216
  export { default as Slider } from './components/basic/Slider.svelte';
216
217
  export { default as Pagination } from './components/basic/Pagination.svelte';
package/dist/index.js CHANGED
@@ -232,5 +232,6 @@ export { default as Checkbox } from './components/basic/Checkbox.svelte';
232
232
  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
+ export { default as SectionNav } from './components/basic/SectionNav.svelte';
235
236
  export { default as Slider } from './components/basic/Slider.svelte';
236
237
  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.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",