@heyramzi/ui 1.53.1 → 1.54.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/COMPONENTS.md +6 -0
- package/package.json +2 -2
- package/src/agents/agent-avatar.svelte +3 -2
- package/src/agents/helpers.ts +9 -0
- package/src/components/data-table/sortable-head.svelte +1 -1
- package/src/components/docs/doc-page.svelte +161 -0
- package/src/components/docs/index.ts +12 -0
- package/src/components/docs/on-this-page.svelte +217 -0
- package/src/components/docs/scroll-spy.svelte.ts +97 -0
- package/src/components/field-row/field-row.svelte +57 -0
- package/src/components/field-row/index.ts +1 -0
- package/src/components/list-page/toolbar-search.svelte +1 -1
- package/src/components/list-page/toolbar-trigger.ts +1 -1
- package/src/components/picker/trigger.ts +2 -2
- package/src/components/section-card/index.ts +1 -0
- package/src/components/section-card/section-card.svelte +43 -0
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.svelte +54 -0
- package/src/components/section-header/section-header.test.ts +25 -0
- package/src/components/segmented/collapse.test.ts +10 -0
- package/src/components/segmented/collapse.ts +5 -0
- package/src/components/segmented/segmented-filter.svelte +5 -4
- package/src/components/segmented/segmented-track.svelte +20 -0
- package/src/components/segmented/segmented.test.ts +1 -1
- package/src/components/segmented/segmented.ts +2 -2
- package/src/components/table-view/index.ts +3 -0
- package/src/components/table-view/table-view.svelte +103 -0
- package/src/components/table-view/table-view.test.ts +53 -0
- package/src/components/table-view/types.ts +14 -0
- package/src/components/theme/index.ts +17 -0
- package/src/components/theme/theme-provider.svelte +47 -0
- package/src/components/theme/theme-provider.test.ts +61 -0
- package/src/effects/spotlight/Spotlight.svelte +0 -1
- package/src/lib/tone.test.ts +10 -0
- package/src/lib/tone.ts +1 -1
- package/src/shadcn/badge/badge.svelte +1 -1
- package/src/shadcn/button/button.svelte +10 -1
- package/src/shadcn/button/button.test.ts +19 -0
- package/src/shadcn/checkbox/checkbox.svelte +1 -1
- package/src/shadcn/context-menu/context-menu-separator.svelte +4 -1
- package/src/shadcn/dropdown-menu/dropdown-menu-separator.svelte +4 -1
- package/src/shadcn/input/input.svelte +2 -2
- package/src/shadcn/progress/progress.svelte +8 -2
- package/src/shadcn/progress/tone.test.ts +13 -0
- package/src/shadcn/progress/tone.ts +15 -0
- package/src/shadcn/sidebar/sidebar-group-label.svelte +1 -1
- package/src/shadcn/table/table-cell.svelte +21 -1
- package/src/shadcn/table/table-cell.test.ts +16 -0
- package/src/tokens/base.css +183 -0
- package/src/tokens/craft.css +179 -0
- package/src/tokens/index.css +19 -0
- package/src/tokens/palette.css +410 -0
- package/src/tokens/primitives.css +223 -0
- package/scripts/relative-imports.mjs +0 -39
- package/src/tokens.css +0 -993
package/COMPONENTS.md
CHANGED
|
@@ -71,7 +71,13 @@ Generic pieces built on the primitives, imported from `@heyramzi/ui/components/<
|
|
|
71
71
|
- `mark` — Mark, a logo or photo glyph with an initial fallback
|
|
72
72
|
- `open-in` — OpenIn, a link out to a tool that takes the tool's name, label and mark as a prop
|
|
73
73
|
- `picker` — Picker, MarkPicker, pickerTriggerClass, a command palette at popover scale
|
|
74
|
+
- `docs` — DocPage, OnThisPage, ScrollSpy: one page anatomy for documentation, an icon, title and summary header, a reading column and a contents rail that becomes a bar on a narrow pane
|
|
75
|
+
- `section-card` — SectionCard, a card with a fixed-height header, an h3 title, optional actions and a line under it
|
|
76
|
+
- `section-header` — SectionHeader, a section label with a count chip, a line of description and actions
|
|
77
|
+
- `field-row` — FieldRow, a labelled control with its action button at the control's height
|
|
78
|
+
- `table-view` — TableView, a read-only table from columns and rows: numeric columns, footer row, custom cell
|
|
74
79
|
- `segmented` — AnimatedTabs (pill or underline), SegmentedFilter, SegmentedNav, SectionRuns, one bordered track with a sliding pill, a badge note and an overflow menu
|
|
80
|
+
- `theme` — ThemeProvider, mode, userPrefersMode, setMode, toggleMode, resetMode: the one light and dark system (mode-watcher, optional peer). Mount `<ThemeProvider initial onChange themeColors modeStorageKey />` once; flip with `toggleMode()`, read `mode.current`. Pair with `theme-toggle`'s `revealTheme(event, toggleMode)` for the wipe
|
|
75
81
|
- `star-rating-input` — StarRatingInput
|
|
76
82
|
- `svg` — Svg, an SVG file imported with `?raw`
|
|
77
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.54.0",
|
|
4
4
|
"description": "Svelte 5 UI primitives: effects, animations, and shared component CSS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"types": "./src/index.ts",
|
|
29
29
|
"default": "./src/index.ts"
|
|
30
30
|
},
|
|
31
|
-
"./tokens.css": "./src/tokens.css",
|
|
31
|
+
"./tokens.css": "./src/tokens/index.css",
|
|
32
32
|
"./lib/*.js": {
|
|
33
33
|
"svelte": "./src/lib/*.ts",
|
|
34
34
|
"types": "./src/lib/*.ts",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { AgentAvatarProps } from './types.js';
|
|
9
9
|
import { botAvatarPresets, stateLabels } from './vendor/presets.js';
|
|
10
10
|
import { SHAPE_PATHS, SHAPE_PARTS } from './vendor/shapes.js';
|
|
11
|
-
import {
|
|
11
|
+
import { richer, shade } from './vendor/color.js';
|
|
12
12
|
import { Sim, restPose } from './vendor/engine.js';
|
|
13
13
|
import { draw, LIGHT_DEFAULTS, OVERSCAN, RISE, type DrawConfig } from './vendor/draw.js';
|
|
14
14
|
import { bakesPending, hurry, onLanded, takeMissed, warmPlastic, type FurStyle } from './vendor/plastic.js';
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
clampOpt,
|
|
28
28
|
deviceDpr,
|
|
29
29
|
hashSeed,
|
|
30
|
+
inkFor,
|
|
30
31
|
lightsFor,
|
|
31
32
|
pointerStrength,
|
|
32
33
|
q,
|
|
@@ -138,7 +139,7 @@
|
|
|
138
139
|
vivid,
|
|
139
140
|
),
|
|
140
141
|
);
|
|
141
|
-
const inkColor = $derived(ink ??
|
|
142
|
+
const inkColor = $derived(ink ?? inkFor(body));
|
|
142
143
|
const seedValue = $derived(Math.min(1, Math.max(0, seed ?? hashSeed(uid))));
|
|
143
144
|
const stateKey = $derived((botState in stateLabels ? botState : 'default') as BotAvatarState);
|
|
144
145
|
const frozen = $derived(paused || !(speed > 0));
|
package/src/agents/helpers.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/* Pure helpers for the avatar: numbers, theme, lights, idle scheduling. */
|
|
2
|
+
import { DARK_INK, LIGHT_INK, luminance } from './vendor/color.js';
|
|
2
3
|
import { stockLights } from './vendor/plastic.js';
|
|
3
4
|
|
|
5
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
6
|
+
|
|
7
|
+
/** Off-black or off-white eyes, whichever reads stronger on the body: the vendor's fixed cutoff left 2:1 eyes on mid blues. */
|
|
8
|
+
export function inkFor(body: string): string {
|
|
9
|
+
const l = luminance(body);
|
|
10
|
+
return contrast(l, luminance(DARK_INK)) >= contrast(l, luminance(LIGHT_INK)) ? DARK_INK : LIGHT_INK;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
export const clamp = (v: number, lo: number, hi: number) =>
|
|
5
14
|
Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 1));
|
|
6
15
|
export const q = (v: number, lo: number, hi: number) => Math.round(clamp(v, lo, hi) * 20) / 20;
|
|
@@ -50,7 +50,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|
|
50
50
|
{:else if direction === "desc"}
|
|
51
51
|
<CaretDown class="size-3.5 text-foreground" weight="bold" />
|
|
52
52
|
{:else}
|
|
53
|
-
<CaretUpDown class="size-3.5 text-muted-foreground/
|
|
53
|
+
<CaretUpDown class="size-3.5 text-muted-foreground/75" />
|
|
54
54
|
{/if}
|
|
55
55
|
</span>
|
|
56
56
|
</th>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import type { Component, Snippet } from "svelte";
|
|
6
|
+
import OnThisPage from "./on-this-page.svelte";
|
|
7
|
+
import {
|
|
8
|
+
ScrollSpy,
|
|
9
|
+
type DocHeading,
|
|
10
|
+
type LocateHeading,
|
|
11
|
+
} from "./scroll-spy.svelte.js";
|
|
12
|
+
|
|
13
|
+
// =========================
|
|
14
|
+
// TYPES
|
|
15
|
+
// =========================
|
|
16
|
+
interface Props {
|
|
17
|
+
title: string;
|
|
18
|
+
/** One line under the title. Optional: a page with nothing to add leaves it out. */
|
|
19
|
+
summary?: string;
|
|
20
|
+
icon?: Component<{ class?: string; weight?: "bold" }>;
|
|
21
|
+
/** Where the page lives, as a reader would say it: "Marketing / Reports". */
|
|
22
|
+
section?: string;
|
|
23
|
+
/** Already worded and localised: "Updated 30 Jul 2026". */
|
|
24
|
+
updated?: string;
|
|
25
|
+
/** Room at the right of the title for a page's own controls. */
|
|
26
|
+
actions?: Snippet;
|
|
27
|
+
/** The document's h2s, which the contents rail walks. Under two, there's no rail. */
|
|
28
|
+
headings?: DocHeading[];
|
|
29
|
+
/** The rail's name: "On this page". */
|
|
30
|
+
contentsLabel: string;
|
|
31
|
+
/** Supplied by a document that lives in a frame; the default reads the DOM by id. */
|
|
32
|
+
locate?: LocateHeading;
|
|
33
|
+
/** Changes when the page is swapped without navigation, so the pane returns to the top. */
|
|
34
|
+
resetKey?: string;
|
|
35
|
+
/** The reading column's width. `wide` is for a document that brings tables and boards. */
|
|
36
|
+
measure?: "prose" | "wide";
|
|
37
|
+
/** The scroll box, for a document that has to place things inside it. */
|
|
38
|
+
scroller?: HTMLElement | null;
|
|
39
|
+
children: Snippet;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// =========================
|
|
43
|
+
// PROPS
|
|
44
|
+
// =========================
|
|
45
|
+
let {
|
|
46
|
+
title,
|
|
47
|
+
summary,
|
|
48
|
+
icon: Icon,
|
|
49
|
+
section,
|
|
50
|
+
updated,
|
|
51
|
+
actions,
|
|
52
|
+
headings = [],
|
|
53
|
+
contentsLabel,
|
|
54
|
+
locate,
|
|
55
|
+
resetKey,
|
|
56
|
+
measure = "prose",
|
|
57
|
+
scroller = $bindable(null),
|
|
58
|
+
children,
|
|
59
|
+
}: Props = $props();
|
|
60
|
+
|
|
61
|
+
// =========================
|
|
62
|
+
// STATE
|
|
63
|
+
// =========================
|
|
64
|
+
const spy = new ScrollSpy();
|
|
65
|
+
|
|
66
|
+
// DERIVED
|
|
67
|
+
let hasRail = $derived(headings.length > 1);
|
|
68
|
+
let meta = $derived([section, updated].filter(Boolean) as string[]);
|
|
69
|
+
|
|
70
|
+
// =========================
|
|
71
|
+
// EFFECTS
|
|
72
|
+
// =========================
|
|
73
|
+
$effect(() => spy.attach(scroller, headings, locate));
|
|
74
|
+
|
|
75
|
+
$effect(() => {
|
|
76
|
+
void resetKey;
|
|
77
|
+
scroller?.scrollTo({ top: 0 });
|
|
78
|
+
});
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<!-- =========================== -->
|
|
82
|
+
<!-- MARKUP -->
|
|
83
|
+
<!-- =========================== -->
|
|
84
|
+
<!-- The anatomy, once: header, then the reading column with its contents rail.
|
|
85
|
+
A surface supplies content and never lays this out, so two pages can't
|
|
86
|
+
arrive with different layouts. The pane is its own container, since the
|
|
87
|
+
room left after the map is what decides the rail, not the window. -->
|
|
88
|
+
<div
|
|
89
|
+
bind:this={scroller}
|
|
90
|
+
class="doc-page @container/page thin-scrollbar relative min-h-0 flex-1 overflow-y-auto"
|
|
91
|
+
>
|
|
92
|
+
{#if hasRail}
|
|
93
|
+
<!-- Below the rail's width the contents sit on a strip that stays put. -->
|
|
94
|
+
<div class="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur @3xl/page:hidden">
|
|
95
|
+
<OnThisPage
|
|
96
|
+
variant="bar"
|
|
97
|
+
items={headings}
|
|
98
|
+
active={spy.active}
|
|
99
|
+
label={contentsLabel}
|
|
100
|
+
onselect={(id) => spy.jump(id)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
<div
|
|
106
|
+
class="mx-auto flex w-full gap-10 px-5 @2xl/page:px-8 {measure === 'wide'
|
|
107
|
+
? 'max-w-[84rem]'
|
|
108
|
+
: 'max-w-[68rem]'}"
|
|
109
|
+
>
|
|
110
|
+
<article class="min-w-0 flex-1 pb-20 pt-8 @2xl/page:pt-10">
|
|
111
|
+
<header class="mb-8 border-b border-border pb-6">
|
|
112
|
+
<div class="flex items-start gap-4">
|
|
113
|
+
{#if Icon}
|
|
114
|
+
<span
|
|
115
|
+
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-brand-indigo dark:bg-indigo-800/30 dark:text-brand-indigo-light"
|
|
116
|
+
>
|
|
117
|
+
<Icon class="size-5" weight="bold" />
|
|
118
|
+
</span>
|
|
119
|
+
{/if}
|
|
120
|
+
<div class="min-w-0 flex-1">
|
|
121
|
+
<h1
|
|
122
|
+
class="font-heading text-2xl font-semibold leading-tight tracking-tight text-balance text-foreground @2xl/page:text-[1.75rem]"
|
|
123
|
+
>
|
|
124
|
+
{title}
|
|
125
|
+
</h1>
|
|
126
|
+
{#if summary}
|
|
127
|
+
<p class="mt-1.5 max-w-[60ch] text-[15px] leading-relaxed text-muted-foreground text-pretty">
|
|
128
|
+
{summary}
|
|
129
|
+
</p>
|
|
130
|
+
{/if}
|
|
131
|
+
{#if meta.length > 0}
|
|
132
|
+
<p class="mt-3 flex flex-wrap items-center gap-x-2 text-xs tabular-nums text-muted-foreground">
|
|
133
|
+
{#each meta as item, i (item)}
|
|
134
|
+
{#if i > 0}<span aria-hidden="true" class="size-0.5 rounded-full bg-current opacity-50"></span>{/if}
|
|
135
|
+
<span>{item}</span>
|
|
136
|
+
{/each}
|
|
137
|
+
</p>
|
|
138
|
+
{/if}
|
|
139
|
+
</div>
|
|
140
|
+
{#if actions}
|
|
141
|
+
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
142
|
+
{/if}
|
|
143
|
+
</div>
|
|
144
|
+
</header>
|
|
145
|
+
|
|
146
|
+
{@render children()}
|
|
147
|
+
</article>
|
|
148
|
+
|
|
149
|
+
{#if hasRail}
|
|
150
|
+
<!-- `self-start`, since a sticky child only travels inside its own box. -->
|
|
151
|
+
<aside class="hidden w-48 shrink-0 self-start pt-10 @3xl/page:sticky @3xl/page:top-0 @3xl/page:block">
|
|
152
|
+
<OnThisPage
|
|
153
|
+
items={headings}
|
|
154
|
+
active={spy.active}
|
|
155
|
+
label={contentsLabel}
|
|
156
|
+
onselect={(id) => spy.jump(id)}
|
|
157
|
+
/>
|
|
158
|
+
</aside>
|
|
159
|
+
{/if}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documentation anatomy: one page component and the contents rail inside it.
|
|
3
|
+
* A surface supplies a title and a body, and never lays a document out itself.
|
|
4
|
+
*/
|
|
5
|
+
export { default as DocPage } from "./doc-page.svelte";
|
|
6
|
+
export { default as OnThisPage } from "./on-this-page.svelte";
|
|
7
|
+
export {
|
|
8
|
+
ScrollSpy,
|
|
9
|
+
READ_LINE,
|
|
10
|
+
type DocHeading,
|
|
11
|
+
type LocateHeading,
|
|
12
|
+
} from "./scroll-spy.svelte.js";
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { tick } from "svelte";
|
|
6
|
+
import { CaretDown, List } from "phosphor-svelte";
|
|
7
|
+
import type { DocHeading } from "./scroll-spy.svelte.js";
|
|
8
|
+
|
|
9
|
+
// =========================
|
|
10
|
+
// TYPES
|
|
11
|
+
// =========================
|
|
12
|
+
interface Props {
|
|
13
|
+
items: DocHeading[];
|
|
14
|
+
/** The id the reader is in. */
|
|
15
|
+
active: string;
|
|
16
|
+
/** What the rail is called: "On this page". Passed in so the library holds no copy. */
|
|
17
|
+
label: string;
|
|
18
|
+
onselect: (id: string) => void;
|
|
19
|
+
/**
|
|
20
|
+
* `rail` is the column beside the document. `bar` is the strip that takes
|
|
21
|
+
* its place on a narrow pane: it names where you are and opens the list.
|
|
22
|
+
*/
|
|
23
|
+
variant?: "rail" | "bar";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// =========================
|
|
27
|
+
// PROPS
|
|
28
|
+
// =========================
|
|
29
|
+
let { items, active, label, onselect, variant = "rail" }: Props = $props();
|
|
30
|
+
|
|
31
|
+
// =========================
|
|
32
|
+
// STATE
|
|
33
|
+
// =========================
|
|
34
|
+
let list = $state<HTMLElement | null>(null);
|
|
35
|
+
let open = $state(false);
|
|
36
|
+
let marker = $state({ y: 0, h: 0, ready: false });
|
|
37
|
+
// Plain, not state: the effect below writes `marker`, so it can't also read it.
|
|
38
|
+
let placed = false;
|
|
39
|
+
let scrollBox = $state<HTMLElement | null>(null);
|
|
40
|
+
let overflowing = $state(false);
|
|
41
|
+
|
|
42
|
+
// =========================
|
|
43
|
+
// DERIVED
|
|
44
|
+
// =========================
|
|
45
|
+
let activeIndex = $derived(Math.max(0, items.findIndex((item) => item.id === active)));
|
|
46
|
+
let activeLabel = $derived(items.find((item) => item.id === active)?.label ?? items[0]?.label ?? "");
|
|
47
|
+
// How far through the document: the bar's hairline, and the number a reader
|
|
48
|
+
// reads at a glance.
|
|
49
|
+
let progress = $derived(items.length > 1 ? activeIndex / (items.length - 1) : 0);
|
|
50
|
+
|
|
51
|
+
// =========================
|
|
52
|
+
// EFFECTS
|
|
53
|
+
// =========================
|
|
54
|
+
// One marker that slides to the active row, rather than a border that blinks
|
|
55
|
+
// from one row to the next.
|
|
56
|
+
$effect(() => {
|
|
57
|
+
void active;
|
|
58
|
+
void items;
|
|
59
|
+
if (variant !== "rail" || !list) return;
|
|
60
|
+
overflowing = !!scrollBox && scrollBox.scrollHeight > scrollBox.clientHeight + 1;
|
|
61
|
+
const row = list.querySelector<HTMLElement>("[data-current='true']");
|
|
62
|
+
if (!row) return;
|
|
63
|
+
const y = row.offsetTop;
|
|
64
|
+
const h = row.offsetHeight;
|
|
65
|
+
// First placement lands with no slide, or it would glide in from the top.
|
|
66
|
+
const wasPlaced = placed;
|
|
67
|
+
placed = true;
|
|
68
|
+
marker = { y, h, ready: wasPlaced };
|
|
69
|
+
if (!wasPlaced) return;
|
|
70
|
+
// A long list scrolls inside the rail, so keep the current row in view.
|
|
71
|
+
const box = list.parentElement;
|
|
72
|
+
if (box && (y < box.scrollTop || y + h > box.scrollTop + box.clientHeight)) {
|
|
73
|
+
box.scrollTo({ top: y - box.clientHeight / 2, behavior: "auto" });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function pick(id: string) {
|
|
78
|
+
open = false;
|
|
79
|
+
onselect(id);
|
|
80
|
+
void tick();
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<svelte:window
|
|
85
|
+
onkeydown={(event) => {
|
|
86
|
+
if (event.key === "Escape") open = false;
|
|
87
|
+
}}
|
|
88
|
+
onpointerdown={(event) => {
|
|
89
|
+
if (!open) return;
|
|
90
|
+
if (!(event.target as HTMLElement).closest("[data-otp-bar]")) open = false;
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
|
|
94
|
+
<!-- =========================== -->
|
|
95
|
+
<!-- MARKUP -->
|
|
96
|
+
<!-- =========================== -->
|
|
97
|
+
{#if variant === "rail"}
|
|
98
|
+
<nav aria-label={label} class="flex max-h-[calc(100svh-8rem)] flex-col gap-3">
|
|
99
|
+
<!-- Set like the map's own headings on the left, so the two rails read as one. -->
|
|
100
|
+
<p class="font-heading text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
101
|
+
{label}
|
|
102
|
+
</p>
|
|
103
|
+
<div
|
|
104
|
+
bind:this={scrollBox}
|
|
105
|
+
class="thin-scrollbar -mr-2 min-h-0 overflow-y-auto pr-2"
|
|
106
|
+
class:otp-fade={overflowing}
|
|
107
|
+
>
|
|
108
|
+
<div bind:this={list} class="relative border-l border-border">
|
|
109
|
+
<!-- The marker sits on the track's own line, so the active row reads as
|
|
110
|
+
the part of the line you're standing on. -->
|
|
111
|
+
<span
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
class="otp-marker absolute -left-px top-0 w-0.5 rounded-full bg-brand-indigo dark:bg-brand-indigo-light"
|
|
114
|
+
class:is-ready={marker.ready}
|
|
115
|
+
style="transform: translateY({marker.y}px); height: {marker.h}px;"
|
|
116
|
+
></span>
|
|
117
|
+
<ul class="flex flex-col">
|
|
118
|
+
{#each items as item (item.id)}
|
|
119
|
+
{@const current = item.id === active}
|
|
120
|
+
<li>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
data-current={current}
|
|
124
|
+
aria-current={current ? "location" : undefined}
|
|
125
|
+
onclick={() => pick(item.id)}
|
|
126
|
+
class="otp-row block w-full py-1.5 pl-3 pr-1 text-left text-[13px] leading-snug transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] any-pointer-coarse:py-2.5 {current
|
|
127
|
+
? 'font-medium text-foreground'
|
|
128
|
+
: 'text-muted-foreground hover:text-foreground'}"
|
|
129
|
+
>
|
|
130
|
+
{item.label}
|
|
131
|
+
</button>
|
|
132
|
+
</li>
|
|
133
|
+
{/each}
|
|
134
|
+
</ul>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</nav>
|
|
138
|
+
{:else}
|
|
139
|
+
<div data-otp-bar class="relative">
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
aria-expanded={open}
|
|
143
|
+
aria-label={label}
|
|
144
|
+
onclick={() => (open = !open)}
|
|
145
|
+
class="flex h-11 w-full items-center gap-2.5 px-4 text-left transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] hover:bg-muted/50"
|
|
146
|
+
>
|
|
147
|
+
<List size={16} class="shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
148
|
+
<span class="shrink-0 text-xs font-medium text-muted-foreground">{label}</span>
|
|
149
|
+
<span class="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{activeLabel}</span>
|
|
150
|
+
<CaretDown
|
|
151
|
+
size={14}
|
|
152
|
+
class="shrink-0 text-muted-foreground transition-transform duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] {open
|
|
153
|
+
? 'rotate-180'
|
|
154
|
+
: ''}"
|
|
155
|
+
/>
|
|
156
|
+
</button>
|
|
157
|
+
<!-- How far through the reader is. The only motion here, and it is the state. -->
|
|
158
|
+
<span aria-hidden="true" class="absolute inset-x-0 bottom-0 h-px bg-border">
|
|
159
|
+
<span
|
|
160
|
+
class="otp-progress block h-full origin-left bg-brand-indigo dark:bg-brand-indigo-light"
|
|
161
|
+
style="transform: scaleX({progress});"
|
|
162
|
+
></span>
|
|
163
|
+
</span>
|
|
164
|
+
|
|
165
|
+
{#if open}
|
|
166
|
+
<nav
|
|
167
|
+
aria-label={label}
|
|
168
|
+
class="thin-scrollbar absolute inset-x-2 top-full z-20 mt-1 max-h-[60svh] overflow-y-auto rounded-xl border border-border bg-popover p-1.5 shadow-lg"
|
|
169
|
+
>
|
|
170
|
+
<ul class="flex flex-col">
|
|
171
|
+
{#each items as item (item.id)}
|
|
172
|
+
{@const current = item.id === active}
|
|
173
|
+
<li>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
aria-current={current ? "location" : undefined}
|
|
177
|
+
onclick={() => pick(item.id)}
|
|
178
|
+
class="block w-full rounded-lg px-3 py-2.5 text-left text-sm leading-snug transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] {current
|
|
179
|
+
? 'bg-muted font-medium text-foreground'
|
|
180
|
+
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}"
|
|
181
|
+
>
|
|
182
|
+
{item.label}
|
|
183
|
+
</button>
|
|
184
|
+
</li>
|
|
185
|
+
{/each}
|
|
186
|
+
</ul>
|
|
187
|
+
</nav>
|
|
188
|
+
{/if}
|
|
189
|
+
</div>
|
|
190
|
+
{/if}
|
|
191
|
+
|
|
192
|
+
<!-- =========================== -->
|
|
193
|
+
<!-- STYLE -->
|
|
194
|
+
<!-- =========================== -->
|
|
195
|
+
<style>
|
|
196
|
+
.otp-marker.is-ready {
|
|
197
|
+
transition:
|
|
198
|
+
transform var(--duration-medium) var(--ease-smooth-out),
|
|
199
|
+
height var(--duration-medium) var(--ease-smooth-out);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.otp-progress {
|
|
203
|
+
transition: transform var(--duration-medium) var(--ease-smooth-out);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* A long list fades into its edges instead of ending on a hard cut. */
|
|
207
|
+
.otp-fade {
|
|
208
|
+
mask-image: linear-gradient(to bottom, transparent, #000 0.5rem, #000 calc(100% - 1.5rem), transparent);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@media (prefers-reduced-motion: reduce) {
|
|
212
|
+
.otp-marker.is-ready,
|
|
213
|
+
.otp-progress {
|
|
214
|
+
transition: none;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
</style>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which heading the reader is in, for a box that scrolls. One spy feeds the
|
|
3
|
+
* side rail and the compact bar, so the two can never disagree.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DocHeading = { id: string; label: string };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where a heading sits inside the scroller, in scroller content coordinates.
|
|
10
|
+
* The default reads the live DOM by id. A framed document supplies its own,
|
|
11
|
+
* since its headings live in another document.
|
|
12
|
+
*/
|
|
13
|
+
export type LocateHeading = (id: string) => number | null;
|
|
14
|
+
|
|
15
|
+
/** How far below the scroller's top edge a heading counts as "reached" and lands. */
|
|
16
|
+
export const READ_LINE = 80;
|
|
17
|
+
|
|
18
|
+
export class ScrollSpy {
|
|
19
|
+
active = $state("");
|
|
20
|
+
|
|
21
|
+
#scroller: HTMLElement | null = null;
|
|
22
|
+
#items: DocHeading[] = [];
|
|
23
|
+
#locate: LocateHeading | undefined;
|
|
24
|
+
#frame = 0;
|
|
25
|
+
|
|
26
|
+
/** Point the spy at a scroller and a list. Returns the cleanup. */
|
|
27
|
+
attach(scroller: HTMLElement | null, items: DocHeading[], locate?: LocateHeading) {
|
|
28
|
+
this.#scroller = scroller;
|
|
29
|
+
this.#items = items;
|
|
30
|
+
this.#locate = locate;
|
|
31
|
+
if (!scroller || items.length === 0) {
|
|
32
|
+
this.active = "";
|
|
33
|
+
return () => {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const schedule = () => {
|
|
37
|
+
this.#frame ||= requestAnimationFrame(() => {
|
|
38
|
+
this.#frame = 0;
|
|
39
|
+
this.update();
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
scroller.addEventListener("scroll", schedule, { passive: true });
|
|
43
|
+
const observer = new ResizeObserver(schedule);
|
|
44
|
+
observer.observe(scroller);
|
|
45
|
+
// A document that grows after load (a diagram, a chart) moves every
|
|
46
|
+
// heading below it, so watch the content too.
|
|
47
|
+
for (const child of scroller.children) observer.observe(child);
|
|
48
|
+
this.update();
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
scroller.removeEventListener("scroll", schedule);
|
|
52
|
+
observer.disconnect();
|
|
53
|
+
if (this.#frame) cancelAnimationFrame(this.#frame);
|
|
54
|
+
this.#frame = 0;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
topOf(id: string): number | null {
|
|
59
|
+
const scroller = this.#scroller;
|
|
60
|
+
if (!scroller) return null;
|
|
61
|
+
if (this.#locate) return this.#locate(id);
|
|
62
|
+
const el = scroller.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
|
|
63
|
+
if (!el) return null;
|
|
64
|
+
return (
|
|
65
|
+
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
update() {
|
|
70
|
+
const scroller = this.#scroller;
|
|
71
|
+
if (!scroller || this.#items.length === 0) return;
|
|
72
|
+
// At the very bottom the last heading wins, or a short final section could
|
|
73
|
+
// never be the current one.
|
|
74
|
+
const atEnd = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
|
|
75
|
+
if (atEnd && scroller.scrollTop > 0) {
|
|
76
|
+
this.active = this.#items[this.#items.length - 1].id;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const line = scroller.scrollTop + READ_LINE + 1;
|
|
80
|
+
let current = this.#items[0].id;
|
|
81
|
+
for (const item of this.#items) {
|
|
82
|
+
const top = this.topOf(item.id);
|
|
83
|
+
if (top !== null && top <= line) current = item.id;
|
|
84
|
+
}
|
|
85
|
+
this.active = current;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Scroll so the heading lands on the read line, instant under reduced motion. */
|
|
89
|
+
jump(id: string) {
|
|
90
|
+
const scroller = this.#scroller;
|
|
91
|
+
const top = this.topOf(id);
|
|
92
|
+
if (!scroller || top === null) return;
|
|
93
|
+
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
94
|
+
this.active = id;
|
|
95
|
+
scroller.scrollTo({ top: Math.max(0, top - READ_LINE), behavior: reduced ? "auto" : "smooth" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import { cn } from "@heyramzi/utils";
|
|
7
|
+
import * as Field from "../../shadcn/field/index.js";
|
|
8
|
+
|
|
9
|
+
// =========================
|
|
10
|
+
// TYPES
|
|
11
|
+
// =========================
|
|
12
|
+
interface Props {
|
|
13
|
+
label: string;
|
|
14
|
+
/** Id of the control, so the label focuses it. */
|
|
15
|
+
for: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
/** The control: an Input, a Select trigger. It takes the width the action leaves. */
|
|
20
|
+
children: Snippet;
|
|
21
|
+
/** The button that acts on the control. Same height as the control, bottom of the row. */
|
|
22
|
+
action?: Snippet;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// =========================
|
|
26
|
+
// PROPS
|
|
27
|
+
// =========================
|
|
28
|
+
let { label, for: htmlFor, description, error, class: className, children, action }: Props = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<!-- =========================== -->
|
|
32
|
+
<!-- MARKUP -->
|
|
33
|
+
<!-- WHY a measure of its own: a settings card fills the page, but a text field
|
|
34
|
+
stretched over 1200px is hard to read and to aim at. 36rem, left aligned. -->
|
|
35
|
+
<!-- =========================== -->
|
|
36
|
+
<Field.Simple
|
|
37
|
+
{label}
|
|
38
|
+
{description}
|
|
39
|
+
{error}
|
|
40
|
+
for={htmlFor}
|
|
41
|
+
class={cn("max-w-xl", className)}
|
|
42
|
+
>
|
|
43
|
+
<div
|
|
44
|
+
class="flex items-start gap-2"
|
|
45
|
+
data-slot="field-row"
|
|
46
|
+
>
|
|
47
|
+
<div class="min-w-0 flex-1">{@render children()}</div>
|
|
48
|
+
{#if action}
|
|
49
|
+
<!-- A disabled action reads as a muted button, not a half-faded one. -->
|
|
50
|
+
<div
|
|
51
|
+
class="shrink-0 [&_button:disabled]:bg-muted [&_button:disabled]:text-muted-foreground [&_button:disabled]:opacity-100 [&_button:disabled]:shadow-none"
|
|
52
|
+
>
|
|
53
|
+
{@render action()}
|
|
54
|
+
</div>
|
|
55
|
+
{/if}
|
|
56
|
+
</div>
|
|
57
|
+
</Field.Simple>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as FieldRow } from "./field-row.svelte";
|
|
@@ -16,7 +16,7 @@ export function toolbarTriggerClass({
|
|
|
16
16
|
}: ToolbarTriggerOpts = {}): string {
|
|
17
17
|
return cn(
|
|
18
18
|
"inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-border font-medium transition-colors active:translate-y-px",
|
|
19
|
-
"h-7 px-2.5 text-xs any-pointer-coarse:h-
|
|
19
|
+
"h-7 px-2.5 text-xs any-pointer-coarse:h-11 any-pointer-coarse:px-3",
|
|
20
20
|
active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground",
|
|
21
21
|
className,
|
|
22
22
|
);
|
|
@@ -25,8 +25,8 @@ export const pickerTriggerClass = tv({
|
|
|
25
25
|
// Heights match `segmentedTrackClass`, so a strip and a picker line up.
|
|
26
26
|
// Padding is 6px leading, 10px trailing: an even 8px shunted icon rows right.
|
|
27
27
|
size: {
|
|
28
|
-
sm: "h-6 ps-1.5 pe-2 any-pointer-coarse:h-
|
|
29
|
-
default: "h-7 ps-1.5 pe-2.5 any-pointer-coarse:h-
|
|
28
|
+
sm: "h-6 ps-1.5 pe-2 any-pointer-coarse:h-11 any-pointer-coarse:pe-2.5",
|
|
29
|
+
default: "h-7 ps-1.5 pe-2.5 any-pointer-coarse:h-11",
|
|
30
30
|
},
|
|
31
31
|
/**
|
|
32
32
|
* Fill the row. Off by default: Linear's property rows sit at natural width
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as SectionCard } from "./section-card.svelte";
|