@heyramzi/ui 3.6.0 → 3.7.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.
Files changed (31) hide show
  1. package/dist/canvas/pen/node-name.svelte.js +29 -6
  2. package/package.json +13 -1
  3. package/scripts/lib/svelte-modules.d.mts +4 -0
  4. package/scripts/lib/svelte-modules.mjs +38 -0
  5. package/scripts/svelte-modules.mjs +14 -0
  6. package/src/ai/chat-window/reveal.svelte.js +106 -0
  7. package/src/ai/confirmation/confirmation-context.svelte.js +12 -0
  8. package/src/ai/reasoning/reasoning-context.svelte.js +12 -0
  9. package/src/canvas/editor/dispatch.svelte.js +160 -0
  10. package/src/canvas/lod/lod-mode.svelte.js +24 -0
  11. package/src/canvas/lod/render-all.svelte.js +42 -0
  12. package/src/canvas/pen/node-name.svelte.js +48 -0
  13. package/src/canvas/pen/node-name.svelte.ts +30 -6
  14. package/src/components/copy/copied.svelte.js +34 -0
  15. package/src/components/docs/scroll-spy.svelte.js +76 -0
  16. package/src/components/open-in/open-in.svelte +14 -15
  17. package/src/components/open-in/open-in.svelte.d.ts +2 -2
  18. package/src/consumer-prebundle.test.ts +126 -0
  19. package/src/effects/animated-beam/path-calculator.svelte.js +54 -0
  20. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +39 -15
  21. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte.d.ts +2 -1
  22. package/src/onboarding/onboarding.svelte.js +19 -0
  23. package/src/shadcn/hooks/is-mobile.svelte.js +7 -0
  24. package/src/shadcn/hooks/use-highlighted-code.svelte.js +65 -0
  25. package/src/shadcn/sidebar/context.svelte.js +63 -0
  26. package/src/shadcn/sidebar/sidebar-provider.svelte +9 -2
  27. package/src/shadcn/sidebar/sidebar-provider.svelte.d.ts +5 -0
  28. package/src/slides/deck-controller.svelte.js +254 -0
  29. package/src/work/kanban-board-state.svelte.js +205 -0
  30. package/src/work/kanban-rail-state.svelte.js +85 -0
  31. package/src/work/use-kanban-dnd.svelte.js +129 -0
@@ -0,0 +1,76 @@
1
+ /** How far below the scroller's top edge a heading counts as "reached" and lands. */
2
+ export const READ_LINE = 80;
3
+ export class ScrollSpy {
4
+ active = $state("");
5
+ #scroller = null;
6
+ #items = [];
7
+ #locate;
8
+ #frame = 0;
9
+ /** Point the spy at a scroller and a list. Returns the cleanup. */
10
+ attach(scroller, items, locate) {
11
+ this.#scroller = scroller;
12
+ this.#items = items;
13
+ this.#locate = locate;
14
+ if (!scroller || items.length === 0) {
15
+ this.active = "";
16
+ return () => {};
17
+ }
18
+ const schedule = () => {
19
+ this.#frame ||= requestAnimationFrame(() => {
20
+ this.#frame = 0;
21
+ this.update();
22
+ });
23
+ };
24
+ scroller.addEventListener("scroll", schedule, { passive: true });
25
+ const observer = new ResizeObserver(schedule);
26
+ observer.observe(scroller);
27
+ // A document that grows after load (a diagram, a chart) moves every
28
+ // heading below it, so watch the content too.
29
+ for (const child of scroller.children) observer.observe(child);
30
+ this.update();
31
+ return () => {
32
+ scroller.removeEventListener("scroll", schedule);
33
+ observer.disconnect();
34
+ if (this.#frame) cancelAnimationFrame(this.#frame);
35
+ this.#frame = 0;
36
+ };
37
+ }
38
+ topOf(id) {
39
+ const scroller = this.#scroller;
40
+ if (!scroller) return null;
41
+ if (this.#locate) return this.#locate(id);
42
+ const el = scroller.querySelector(`#${CSS.escape(id)}`);
43
+ if (!el) return null;
44
+ return el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
45
+ }
46
+ update() {
47
+ const scroller = this.#scroller;
48
+ if (!scroller || this.#items.length === 0) return;
49
+ // At the very bottom the last heading wins, or a short final section could
50
+ // never be the current one.
51
+ const atEnd = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
52
+ if (atEnd && scroller.scrollTop > 0) {
53
+ this.active = this.#items[this.#items.length - 1].id;
54
+ return;
55
+ }
56
+ const line = scroller.scrollTop + READ_LINE + 1;
57
+ let current = this.#items[0].id;
58
+ for (const item of this.#items) {
59
+ const top = this.topOf(item.id);
60
+ if (top !== null && top <= line) current = item.id;
61
+ }
62
+ this.active = current;
63
+ }
64
+ /** Scroll so the heading lands on the read line, instant under reduced motion. */
65
+ jump(id) {
66
+ const scroller = this.#scroller;
67
+ const top = this.topOf(id);
68
+ if (!scroller || top === null) return;
69
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
70
+ this.active = id;
71
+ scroller.scrollTo({
72
+ top: Math.max(0, top - READ_LINE),
73
+ behavior: reduced ? "auto" : "smooth"
74
+ });
75
+ }
76
+ }
@@ -14,8 +14,8 @@
14
14
  }
15
15
 
16
16
  /** `chip` is the bordered inline tag, `button` sits in a header beside other
17
- * buttons, `icon` is the bare mark for a dense row. */
18
- export type OpenInVariant = "chip" | "button" | "icon";
17
+ * buttons. A bare-mark form was cut: at 20px it failed WCAG 2.5.8. */
18
+ export type OpenInVariant = "chip" | "button";
19
19
  export type OpenInSize = "sm" | "default";
20
20
  </script>
21
21
 
@@ -86,22 +86,21 @@
86
86
  class={cn(
87
87
  variant === "button"
88
88
  ? buttonVariants({ variant: "outline", size: size === "sm" ? "sm" : "default" })
89
- : variant === "icon"
90
- ? "inline-flex shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
91
- : cn(
92
- "inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
93
- size === "sm" ? "px-1.5 py-0.5 text-2xs" : "px-2 py-1 text-xs",
94
- ),
95
- variant === "icon" && (size === "sm" ? "size-5" : "size-6"),
89
+ : cn(
90
+ "inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
91
+ // WHY the button heights: the sm chip was 19px, under the 24px target
92
+ // floor. Under a thumb it grows to 40 and 44, and the sm hit area to 44.
93
+ size === "sm"
94
+ ? "relative h-7 px-2 text-2xs after:absolute after:top-1/2 after:left-1/2 after:h-[max(100%,1.5rem)] after:w-[max(100%,1.5rem)] after:-translate-x-1/2 after:-translate-y-1/2 any-pointer-coarse:h-10 any-pointer-coarse:after:h-[max(100%,2.75rem)] any-pointer-coarse:after:w-[max(100%,2.75rem)]"
95
+ : "h-8 px-2.5 text-xs any-pointer-coarse:h-11 any-pointer-coarse:px-3",
96
+ ),
96
97
  className,
97
98
  )}
98
99
  >
99
100
  <Icon aria-hidden="true" size={iconSize} />
100
- {#if variant !== "icon"}
101
- {text}
102
- <!-- The one thing the mark can't say: this leaves the app. It stays muted
103
- so the eye lands on the tool name first. -->
104
- <ArrowSquareOut aria-hidden="true" size={iconSize} class="text-muted-foreground" />
105
- {/if}
101
+ {text}
102
+ <!-- The one thing the mark can't say: this leaves the app. It stays muted
103
+ so the eye lands on the tool name first. -->
104
+ <ArrowSquareOut aria-hidden="true" size={iconSize} class="text-muted-foreground" />
106
105
  </a>
107
106
  </div>
@@ -15,8 +15,8 @@ export interface OpenInTool {
15
15
  }>;
16
16
  }
17
17
  /** `chip` is the bordered inline tag, `button` sits in a header beside other
18
- * buttons, `icon` is the bare mark for a dense row. */
19
- export type OpenInVariant = "chip" | "button" | "icon";
18
+ * buttons. A bare-mark form was cut: at 20px it failed WCAG 2.5.8. */
19
+ export type OpenInVariant = "chip" | "button";
20
20
  export type OpenInSize = "sm" | "default";
21
21
  interface Props {
22
22
  /** The tool to link to: its name, accessible sentence and mark. */
@@ -0,0 +1,126 @@
1
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ rmSync,
11
+ symlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { createRequire } from "node:module";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join, relative } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import { afterAll, describe, expect, it } from "vite-plus/test";
19
+ import { writeSvelteModules } from "../scripts/lib/svelte-modules.mjs";
20
+
21
+ // A consumer's dev server prebundles this package, types still in each `.svelte.ts`. 3.6.0's
22
+ // reveal.svelte.ts killed Locameublé's dev server at startup on `import type`.
23
+
24
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
26
+ scripts: Record<string, string>;
27
+ exports: Record<string, string | string[] | Record<string, string | string[]>>;
28
+ };
29
+
30
+ /** The Vite core a consumer gets beside vite-plugin-svelte; this package only depends on vite-plus. */
31
+ type Vite = {
32
+ resolveConfig(config: object, command: "serve", mode: string): Promise<unknown>;
33
+ optimizeDeps(
34
+ config: unknown,
35
+ force: boolean,
36
+ ): Promise<{ optimized: Record<string, { file: string }> }>;
37
+ };
38
+ const plugin = createRequire(import.meta.url).resolve("@sveltejs/vite-plugin-svelte");
39
+ const vite: Vite = await import(pathToFileURL(createRequire(plugin).resolve("vite")).href);
40
+
41
+ function walk(dir: string, out: string[] = []): string[] {
42
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
43
+ const p = join(dir, e.name);
44
+ if (e.isDirectory()) walk(p, out);
45
+ else out.push(p);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** The tarball's shape: `files` as prepack leaves them, under a consumer's node_modules. */
51
+ async function consumer(): Promise<{ dir: string; ui: string }> {
52
+ const dir = mkdtempSync(join(tmpdir(), "ui-consumer-"));
53
+ const nm = join(dir, "node_modules");
54
+ const ui = join(nm, "@heyramzi/ui");
55
+ mkdirSync(ui, { recursive: true });
56
+ cpSync(join(root, "src"), join(ui, "src"), { recursive: true });
57
+ cpSync(join(root, "package.json"), join(ui, "package.json"));
58
+ await writeSvelteModules(join(ui, "src"));
59
+ for (const name of readdirSync(join(root, "node_modules"))) {
60
+ const from = join(root, "node_modules", name);
61
+ const scoped = name.startsWith("@") ? readdirSync(from).map((s) => join(name, s)) : [name];
62
+ if (name.startsWith("@")) mkdirSync(join(nm, name));
63
+ for (const dep of scoped)
64
+ symlinkSync(realpathSync(join(root, "node_modules", dep)), join(nm, dep));
65
+ }
66
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "consumer", type: "module" }));
67
+ return { dir, ui };
68
+ }
69
+
70
+ /** Every subpath a Svelte app can import, wildcards expanded against the tarball's files. */
71
+ function browserEntries(ui: string): string[] {
72
+ const out = new Set<string>();
73
+ for (const [key, value] of Object.entries(pkg.exports)) {
74
+ if (typeof value !== "object" || Array.isArray(value)) continue;
75
+ const target = value.svelte;
76
+ if (typeof target !== "string") continue;
77
+ if (!key.includes("*")) {
78
+ out.add(`@heyramzi/ui${key.slice(1)}`);
79
+ continue;
80
+ }
81
+ const [head, tail] = target.split("*");
82
+ for (const file of walk(join(ui, head))) {
83
+ const rel = relative(join(ui, head), file);
84
+ if (!rel.endsWith(tail) || /\.(test|spec)\./.test(rel)) continue;
85
+ out.add(`@heyramzi/ui${key.slice(1).replace("*", rel.slice(0, -tail.length))}`);
86
+ }
87
+ }
88
+ return [...out].toSorted();
89
+ }
90
+
91
+ const made: string[] = [];
92
+ afterAll(() => {
93
+ for (const dir of made) rmSync(dir, { recursive: true, force: true });
94
+ });
95
+
96
+ describe("a consumer's dev server", () => {
97
+ it("writes the type-free modules on every pack and removes them after", () => {
98
+ expect(pkg.scripts.prepack).toContain("node scripts/svelte-modules.mjs");
99
+ expect(pkg.scripts.postpack).toContain("node scripts/svelte-modules.mjs --clean");
100
+ });
101
+
102
+ it("prebundles every Svelte subpath, chat windows included", async () => {
103
+ const { dir, ui } = await consumer();
104
+ made.push(dir);
105
+ const include = browserEntries(ui);
106
+ expect(include).toContain("@heyramzi/ui/ai/chat-window");
107
+ expect(include).toContain("@heyramzi/ui/shadcn/hooks/use-highlighted-code.svelte");
108
+ expect(include).toContain("@heyramzi/ui/canvas/editor/dispatch.svelte.js");
109
+
110
+ const config = await vite.resolveConfig(
111
+ {
112
+ root: dir,
113
+ configFile: false,
114
+ logLevel: "error",
115
+ plugins: [svelte({ configFile: false })],
116
+ optimizeDeps: { include, noDiscovery: true },
117
+ },
118
+ "serve",
119
+ "development",
120
+ );
121
+ const { optimized } = await vite.optimizeDeps(config, true);
122
+ const chat = optimized["@heyramzi/ui/ai/chat-window"];
123
+ expect(chat && existsSync(chat.file)).toBe(true);
124
+ expect(readFileSync(chat.file, "utf8")).toContain("REVEAL_BEATS");
125
+ }, 120_000);
126
+ });
@@ -0,0 +1,54 @@
1
+ function anchorX(rect, containerLeft, anchor, offset) {
2
+ const left = rect.left - containerLeft;
3
+ if (anchor === "left") return left + offset;
4
+ if (anchor === "right") return left + rect.width + offset;
5
+ return left + rect.width / 2 + offset;
6
+ }
7
+ export function usePathCalculator() {
8
+ let pathD = $state("");
9
+ let svgDimensions = $state({
10
+ width: 0,
11
+ height: 0
12
+ });
13
+ function calculatePath(containerRef, fromRef, toRef, curvature, startXOffset, startYOffset, endXOffset, endYOffset, fromAnchor = "center", toAnchor = "center") {
14
+ if (!containerRef || !fromRef || !toRef) return;
15
+ const containerRect = containerRef.getBoundingClientRect();
16
+ const rectA = fromRef.getBoundingClientRect();
17
+ const rectB = toRef.getBoundingClientRect();
18
+ // A scaled ancestor shrinks every rect, but the SVG draws in the container's own pixels.
19
+ // Divide the scale out, or the beams stop short of their nodes.
20
+ const scaleX = containerRef.offsetWidth ? containerRect.width / containerRef.offsetWidth : 1;
21
+ const scaleY = containerRef.offsetHeight ? containerRect.height / containerRef.offsetHeight : 1;
22
+ const local = (rect) => new DOMRect((rect.left - containerRect.left) / scaleX, (rect.top - containerRect.top) / scaleY, rect.width / scaleX, rect.height / scaleY);
23
+ const a = local(rectA);
24
+ const b = local(rectB);
25
+ svgDimensions = {
26
+ width: containerRect.width / scaleX,
27
+ height: containerRect.height / scaleY
28
+ };
29
+ const startX = anchorX(a, 0, fromAnchor, startXOffset);
30
+ const startY = a.top + a.height / 2 + startYOffset;
31
+ const endX = anchorX(b, 0, toAnchor, endXOffset);
32
+ const endY = b.top + b.height / 2 + endYOffset;
33
+ // A cubic with horizontal handles, not a quadratic through a raised midpoint.
34
+ // The quadratic put its single control point at `midY` minus `curvature`, which for a fan into one node lands past the far end:
35
+ // the top beam dove below the hub and crossed the middle beam before arriving, and three beams bundled into a lens for the last stretch.
36
+ // Handles along the axis of travel make each beam leave and arrive perpendicular to its anchor, so a fan converges at the anchor and nowhere else.
37
+ // `curvature` still bows the middle, and zero still draws a straight line between two anchors at the same height.
38
+ const span = Math.abs(endX - startX);
39
+ const handle = Math.max(span * .5, 24);
40
+ const direction = endX >= startX ? 1 : -1;
41
+ const c1x = startX + handle * direction;
42
+ const c2x = endX - handle * direction;
43
+ pathD = `M ${startX},${startY} C ${c1x},${startY - curvature} ${c2x},${endY - curvature} ${endX},${endY}`;
44
+ }
45
+ return {
46
+ get pathD() {
47
+ return pathD;
48
+ },
49
+ get svgDimensions() {
50
+ return svgDimensions;
51
+ },
52
+ calculatePath
53
+ };
54
+ }
@@ -12,7 +12,8 @@
12
12
  /** `submit` posts the form the button sits in. */
13
13
  type?: "button" | "submit";
14
14
  disabled?: boolean;
15
- size?: "default" | "md" | "sm";
15
+ /** `sm` is 40px for a condensed bar; under a thumb both are 44px. */
16
+ size?: "default" | "sm";
16
17
  tone?: "ink" | "coral";
17
18
  beam?: boolean;
18
19
  dot?: boolean;
@@ -43,7 +44,6 @@
43
44
  let classes = $derived(
44
45
  [
45
46
  "cta-hover",
46
- size === "md" && "cta-hover--md",
47
47
  size === "sm" && "cta-hover--sm",
48
48
  tone === "coral" && "cta-hover--coral",
49
49
  !beam && "cta-hover--no-beam",
@@ -168,28 +168,24 @@
168
168
  pointer-events: none;
169
169
  }
170
170
 
171
- /* 40px, between 44px and 32px, for a CTA in a condensed bar. 32px is too
172
- small to read as the main action, so only the label shrinks, to 13px. */
173
- .cta-hover--md {
171
+ /* 40px for a CTA in a condensed bar. The old 32px was too small to read as
172
+ the main action, so only the label shrinks, to 13px. */
173
+ .cta-hover--sm {
174
174
  height: 40px;
175
175
  padding: 0 24px;
176
176
  font-size: 13px;
177
177
  }
178
178
 
179
- .cta-hover--md .cta-hover__dot {
179
+ .cta-hover--sm .cta-hover__dot {
180
180
  width: 7px;
181
181
  height: 7px;
182
182
  }
183
183
 
184
- .cta-hover--sm {
185
- height: 32px;
186
- padding: 0 20px;
187
- font-size: 13px;
188
- }
189
-
190
- .cta-hover--sm .cta-hover__dot {
191
- width: 6px;
192
- height: 6px;
184
+ /* Under a thumb both sizes clear the 44px touch target, as Button does. */
185
+ @media (any-pointer: coarse) {
186
+ .cta-hover--sm {
187
+ height: 44px;
188
+ }
193
189
  }
194
190
 
195
191
  .cta-hover--no-beam::before {
@@ -344,11 +340,39 @@
344
340
  }
345
341
  }
346
342
 
343
+ /* The beam stops, and hover swaps the fill and label in place: no dot
344
+ growing out, no label sliding across. */
347
345
  @media (prefers-reduced-motion: reduce) {
348
346
  .cta-hover::before {
349
347
  animation: none;
350
348
  opacity: 0;
351
349
  }
350
+
351
+ .cta-hover,
352
+ .cta-hover__resting,
353
+ .cta-hover__dot,
354
+ .cta-hover__label,
355
+ .cta-hover__reveal,
356
+ :global(.cta-hover__arrow--inline) {
357
+ transition-property: opacity, background-color, color;
358
+ }
359
+ }
360
+
361
+ @media (prefers-reduced-motion: reduce) and (hover: hover) and (pointer: fine) {
362
+ .cta-hover:hover .cta-hover__dot,
363
+ .cta-hover:not(.cta-hover--no-dot):hover .cta-hover__resting .cta-hover__label,
364
+ .cta-hover:not(.cta-hover--no-dot):hover .cta-hover__reveal,
365
+ .cta-hover--no-dot:hover :global(.cta-hover__arrow--inline) {
366
+ transform: none;
367
+ }
368
+
369
+ .cta-hover:not(.cta-hover--no-dot):hover::after {
370
+ background: var(--color-signal-600);
371
+ }
372
+
373
+ .cta-hover--coral:not(.cta-hover--no-dot):hover::after {
374
+ background: var(--color-ink);
375
+ }
352
376
  }
353
377
  }
354
378
 
@@ -3,7 +3,8 @@ interface Props {
3
3
  /** `submit` posts the form the button sits in. */
4
4
  type?: "button" | "submit";
5
5
  disabled?: boolean;
6
- size?: "default" | "md" | "sm";
6
+ /** `sm` is 40px for a condensed bar; under a thumb both are 44px. */
7
+ size?: "default" | "sm";
7
8
  tone?: "ink" | "coral";
8
9
  beam?: boolean;
9
10
  dot?: boolean;
@@ -0,0 +1,19 @@
1
+ class Onboarding {
2
+ /** True when the current page has a tour, so a menu item can hide itself. */
3
+ hasTour = $state(false);
4
+ /** True while a tour is on screen. */
5
+ running = $state(false);
6
+ #replay = null;
7
+ /** The provider registers itself here. Returns the undo for cleanup. */
8
+ bind(replay) {
9
+ this.#replay = replay;
10
+ return () => {
11
+ if (this.#replay === replay) this.#replay = null;
12
+ };
13
+ }
14
+ /** Restarts this page's tour, or the one named. Writes no seen-state. False when nothing started. */
15
+ replay(tourId) {
16
+ return this.#replay ? this.#replay(tourId) : Promise.resolve(false);
17
+ }
18
+ }
19
+ export const onboarding = new Onboarding();
@@ -0,0 +1,7 @@
1
+ import { MediaQuery } from "svelte/reactivity";
2
+ const DEFAULT_MOBILE_BREAKPOINT = 768;
3
+ export class IsMobile extends MediaQuery {
4
+ constructor(breakpoint = DEFAULT_MOBILE_BREAKPOINT) {
5
+ super(`max-width: ${breakpoint - 1}px`);
6
+ }
7
+ }
@@ -0,0 +1,65 @@
1
+ import { loadHighlighter } from "./highlighter.js";
2
+ /**
3
+ * Highlights `code`. Each `current` entry is a plain string until the highlighter loads, then
4
+ * tokens, so pass it to `CodeBlock.Line`'s `code` prop either way.
5
+ */
6
+ export function useHighlightedCode(value, language) {
7
+ let loaded = $state.raw(null);
8
+ $effect(() => {
9
+ const lang = language();
10
+ let cancelled = false;
11
+ loadHighlighter(lang).then((next) => {
12
+ if (!cancelled) {
13
+ loaded = {
14
+ language: lang,
15
+ highlighter: next
16
+ };
17
+ }
18
+ }).catch(() => {});
19
+ return () => {
20
+ cancelled = true;
21
+ };
22
+ });
23
+ // The complete lines, highlighted. While the code streams, only the new
24
+ // lines are tokenized, starting from the grammar state of the last one.
25
+ let done = {
26
+ highlighter: null,
27
+ text: "",
28
+ lines: []
29
+ };
30
+ const lines = $derived.by(() => {
31
+ const raw = value();
32
+ // A trailing newline would render as an extra empty line.
33
+ const code = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
34
+ const lang = language();
35
+ const highlighter = loaded?.language === lang ? loaded.highlighter : null;
36
+ if (!highlighter) {
37
+ return code.split("\n");
38
+ }
39
+ let next = done;
40
+ if (next.highlighter !== highlighter || !code.startsWith(next.text)) {
41
+ next = {
42
+ highlighter,
43
+ text: "",
44
+ lines: []
45
+ };
46
+ }
47
+ const end = code.lastIndexOf("\n") + 1;
48
+ if (end > next.text.length) {
49
+ const result = highlighter.highlight(code.slice(next.text.length, end - 1), next.state);
50
+ next = {
51
+ highlighter,
52
+ text: code.slice(0, end),
53
+ lines: [...next.lines, ...result.lines],
54
+ state: result.state
55
+ };
56
+ }
57
+ done = next;
58
+ // The last line is still being written, so it is tokenized every time.
59
+ const tail = code.slice(end);
60
+ return [...next.lines, highlighter.highlight(tail, next.state).lines[0]];
61
+ });
62
+ return { get current() {
63
+ return lines;
64
+ } };
65
+ }
@@ -0,0 +1,63 @@
1
+ import { IsMobile } from "../hooks/is-mobile.svelte.js";
2
+ import { getContext, setContext } from "svelte";
3
+ import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
4
+ // Cmd+B in a field means bold, so the shortcut leaves typing alone.
5
+ function isEditable(target) {
6
+ if (!(target instanceof HTMLElement)) return false;
7
+ return target.closest("input, textarea, [contenteditable]:not([contenteditable=\"false\"])") !== null;
8
+ }
9
+ class SidebarState {
10
+ props;
11
+ open = $derived.by(() => this.props.open());
12
+ openMobile = $state(false);
13
+ setOpen;
14
+ #isMobile;
15
+ state = $derived.by(() => this.open ? "expanded" : "collapsed");
16
+ id;
17
+ constructor(props) {
18
+ this.setOpen = props.setOpen;
19
+ this.id = props.id;
20
+ this.#isMobile = new IsMobile();
21
+ this.props = props;
22
+ }
23
+ // Convenience getter for checking if the sidebar is mobile
24
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
25
+ get isMobile() {
26
+ return this.#isMobile.current;
27
+ }
28
+ /** Whether the sidebar a trigger controls is showing: the sheet on a phone, the panel otherwise. */
29
+ get expanded() {
30
+ return this.#isMobile.current ? this.openMobile : this.open;
31
+ }
32
+ // Event handler to apply to the `<svelte:window>`
33
+ handleShortcutKeydown = (e) => {
34
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey) && !isEditable(e.target)) {
35
+ e.preventDefault();
36
+ this.toggle();
37
+ }
38
+ };
39
+ setOpenMobile = (value) => {
40
+ this.openMobile = value;
41
+ };
42
+ toggle = () => {
43
+ return this.#isMobile.current ? this.openMobile = !this.openMobile : this.setOpen(!this.open);
44
+ };
45
+ }
46
+ const SYMBOL_KEY = "scn-sidebar";
47
+ /**
48
+ * Instantiates a new `SidebarState` instance and sets it in the context.
49
+ *
50
+ * @param props The constructor props for the `SidebarState` class.
51
+ * @returns The `SidebarState` instance.
52
+ */
53
+ export function setSidebar(props) {
54
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
55
+ }
56
+ /**
57
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
58
+ * so you cannot destructure it.
59
+ * @returns The `SidebarState` instance.
60
+ */
61
+ export function useSidebar() {
62
+ return getContext(Symbol.for(SYMBOL_KEY));
63
+ }
@@ -14,6 +14,7 @@
14
14
  ref = $bindable(null),
15
15
  open = $bindable(true),
16
16
  onOpenChange = () => {},
17
+ persist = true,
17
18
  class: className,
18
19
  style,
19
20
  children,
@@ -21,6 +22,11 @@
21
22
  }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
22
23
  open?: boolean;
23
24
  onOpenChange?: (open: boolean) => void;
25
+ /**
26
+ * False for a sidebar that isn't the app's shell, such as a docs demo: it then
27
+ * writes no state cookie and takes no Cmd+B, so it can't fold the real one.
28
+ */
29
+ persist?: boolean;
24
30
  } = $props();
25
31
 
26
32
  let openState = $state(open);
@@ -40,12 +46,13 @@
40
46
  onOpenChange(value);
41
47
 
42
48
  // This sets the cookie to keep the sidebar state.
43
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
49
+ if (persist)
50
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
44
51
  },
45
52
  });
46
53
  </script>
47
54
 
48
- <svelte:window onkeydown={sidebar.handleShortcutKeydown} />
55
+ <svelte:window onkeydown={persist ? sidebar.handleShortcutKeydown : undefined} />
49
56
 
50
57
  <Tooltip.Provider delayDuration={0}>
51
58
  <!-- An h-svh frame that never scrolls: the app puts its page in a
@@ -3,6 +3,11 @@ import type { HTMLAttributes } from "svelte/elements";
3
3
  type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
4
4
  open?: boolean;
5
5
  onOpenChange?: (open: boolean) => void;
6
+ /**
7
+ * False for a sidebar that isn't the app's shell, such as a docs demo: it then
8
+ * writes no state cookie and takes no Cmd+B, so it can't fold the real one.
9
+ */
10
+ persist?: boolean;
6
11
  };
7
12
  declare const $$Component: import("svelte").Component<$$ComponentProps, {}, "ref" | "open">;
8
13
  type $$Component = ReturnType<typeof $$Component>;