@joewinke/jatui 0.7.0 → 0.8.1
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';
|