@motion-proto/live-tokens 0.80.0 → 0.82.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.
@@ -0,0 +1,243 @@
1
+ <script lang="ts">
2
+ import Button from '../system/components/Button.svelte';
3
+ import MenuSelect from '../system/components/MenuSelect.svelte';
4
+ import { portal } from '../system/internal/portal';
5
+
6
+ interface Props {
7
+ label: string;
8
+ items: { value: string; label: string }[];
9
+ value: string;
10
+ /** Trigger text when `value` matches no item. Defaults to the label. */
11
+ placeholder?: string;
12
+ /** Label beside the trigger instead of above it. */
13
+ inline?: boolean;
14
+ size?: 'default' | 'small';
15
+ disabled?: boolean;
16
+ error?: string;
17
+ onchange: (value: string) => void;
18
+ }
19
+
20
+ let {
21
+ label,
22
+ items,
23
+ value,
24
+ placeholder = label,
25
+ inline = false,
26
+ size = 'default',
27
+ disabled = false,
28
+ error = '',
29
+ onchange,
30
+ }: Props = $props();
31
+
32
+ let open = $state(false);
33
+ let wrapperEl: HTMLDivElement | undefined = $state();
34
+ let panelEl: HTMLDivElement | undefined = $state();
35
+ let buttonRef: HTMLButtonElement | undefined = $state();
36
+
37
+ const uid = $props.id();
38
+ const labelId = `${uid}-label`;
39
+ const buttonId = `${uid}-button`;
40
+
41
+ const selectedLabel = $derived(items.find((item) => item.value === value)?.label ?? placeholder);
42
+
43
+ /** Deduplicated because the ghosts are keyed by label, and two items may
44
+ legitimately carry the same one. */
45
+ const ghostLabels = $derived([...new Set([placeholder, ...items.map((item) => item.label)])]);
46
+
47
+ // Button.svelte has no aria-* passthrough, so the popover semantics are
48
+ // set imperatively on its underlying <button> via the bindable ref.
49
+ $effect(() => {
50
+ buttonRef?.setAttribute('aria-haspopup', 'listbox');
51
+ buttonRef?.setAttribute('aria-expanded', String(open));
52
+ buttonRef?.setAttribute('id', buttonId);
53
+ buttonRef?.setAttribute('aria-labelledby', `${labelId} ${buttonId}`);
54
+ });
55
+
56
+ /* Anchored below the trigger, left edges aligned; flips above when the space
57
+ below runs out, and clamps to the viewport on both axes. Placed rather than
58
+ laid out because the panel is portaled: it has no ancestor left to position
59
+ against. Visibility is inline rather than a state flag so the focus below
60
+ lands on an already-visible item (hidden elements refuse focus). */
61
+ function position(): void {
62
+ const btn = buttonRef;
63
+ const panel = panelEl;
64
+ if (!btn || !panel) return;
65
+ const margin = 8;
66
+ const br = btn.getBoundingClientRect();
67
+ const pr = panel.getBoundingClientRect();
68
+ const vw = window.innerWidth;
69
+ const vh = window.innerHeight;
70
+
71
+ let left = br.left;
72
+ if (left + pr.width > vw - margin) left = vw - margin - pr.width;
73
+ if (left < margin) left = margin;
74
+
75
+ let top = br.bottom + margin;
76
+ if (top + pr.height > vh - margin) {
77
+ top = br.top - margin - pr.height;
78
+ if (top < margin) top = margin;
79
+ }
80
+
81
+ panel.style.left = `${left}px`;
82
+ panel.style.top = `${top}px`;
83
+ panel.style.visibility = 'visible';
84
+ }
85
+
86
+ $effect(() => {
87
+ if (!open || !panelEl) return;
88
+ position();
89
+ const target =
90
+ panelEl.querySelector<HTMLButtonElement>('.menuselect-item.selected') ??
91
+ panelEl.querySelector<HTMLButtonElement>('.menuselect-item');
92
+ target?.focus();
93
+ // Fixed to the viewport, so the panel has to be re-placed against a trigger
94
+ // that scrolls. Capture, to catch scrolls in any pane between here and the
95
+ // window.
96
+ window.addEventListener('scroll', position, true);
97
+ window.addEventListener('resize', position);
98
+ return () => {
99
+ window.removeEventListener('scroll', position, true);
100
+ window.removeEventListener('resize', position);
101
+ };
102
+ });
103
+
104
+ $effect(() => {
105
+ if (!open) return;
106
+ const handlePointerDown = (event: PointerEvent) => {
107
+ // The panel is portaled out, so it is no longer inside the wrapper and
108
+ // has to be tested on its own — otherwise picking an item dismisses the
109
+ // list before the click reaches it.
110
+ const target = event.target;
111
+ if (!(target instanceof Node)) return;
112
+ if (wrapperEl?.contains(target) || panelEl?.contains(target)) return;
113
+ open = false;
114
+ };
115
+ const handleKeydown = (event: KeyboardEvent) => {
116
+ if (event.key === 'Escape') {
117
+ open = false;
118
+ buttonRef?.focus();
119
+ }
120
+ };
121
+ window.addEventListener('pointerdown', handlePointerDown);
122
+ window.addEventListener('keydown', handleKeydown);
123
+ return () => {
124
+ window.removeEventListener('pointerdown', handlePointerDown);
125
+ window.removeEventListener('keydown', handleKeydown);
126
+ };
127
+ });
128
+
129
+ function select(next: string): void {
130
+ open = false;
131
+ if (next === value) return;
132
+ onchange(next);
133
+ }
134
+ </script>
135
+
136
+ <div class="labeled-select" class:inline class:small={size === 'small'} bind:this={wrapperEl}>
137
+ <span class="labeled-select-label" id={labelId}>{label}</span>
138
+
139
+ <div class="labeled-select-trigger">
140
+ <Button
141
+ bind:buttonRef
142
+ class="labeled-select-button"
143
+ fullWidth
144
+ {size}
145
+ variant="secondary"
146
+ disabled={disabled || items.length === 0}
147
+ icon={open ? 'fas fa-chevron-up' : 'fas fa-chevron-down'}
148
+ iconPosition="right"
149
+ onclick={() => (open = !open)}
150
+ >
151
+ {selectedLabel}
152
+ </Button>
153
+
154
+ <!-- Ghost copies size the trigger to its widest label so the row never
155
+ reflows on selection. Real Buttons, so the metrics stay exact. -->
156
+ {#each ghostLabels as ghost (ghost)}
157
+ <div class="labeled-select-ghost" aria-hidden="true" inert>
158
+ <Button class="labeled-select-button" {size} variant="secondary" icon="fas fa-chevron-down" iconPosition="right">
159
+ {ghost}
160
+ </Button>
161
+ </div>
162
+ {/each}
163
+ </div>
164
+
165
+ {#if open}
166
+ <div class="labeled-select-panel" bind:this={panelEl} use:portal>
167
+ <MenuSelect {items} {value} minWidth="12rem" onchange={select} />
168
+ </div>
169
+ {/if}
170
+
171
+ {#if error}
172
+ <span class="error" aria-live="polite">{error}</span>
173
+ {/if}
174
+ </div>
175
+
176
+ <style>
177
+ .labeled-select {
178
+ position: relative;
179
+ display: flex;
180
+ flex-direction: column;
181
+ align-items: flex-start;
182
+ gap: var(--space-4);
183
+ }
184
+
185
+ .labeled-select.inline {
186
+ flex-direction: row;
187
+ flex-wrap: wrap;
188
+ align-items: center;
189
+ gap: var(--space-8);
190
+ }
191
+
192
+ .labeled-select-label {
193
+ font-family: var(--font-mono);
194
+ font-size: var(--font-size-md);
195
+ font-weight: var(--font-weight-semibold);
196
+ line-height: var(--line-height-normal);
197
+ color: var(--text-secondary);
198
+ }
199
+
200
+ .labeled-select.small .labeled-select-label {
201
+ font-size: var(--font-size-sm);
202
+ }
203
+
204
+ .labeled-select-trigger {
205
+ position: relative;
206
+ display: grid;
207
+ grid-template-areas: 'trigger';
208
+ }
209
+
210
+ .labeled-select-trigger > :global(*) {
211
+ grid-area: trigger;
212
+ }
213
+
214
+ .labeled-select-trigger :global(.labeled-select-button) {
215
+ justify-content: space-between;
216
+ }
217
+
218
+ .labeled-select-ghost {
219
+ height: 0;
220
+ visibility: hidden;
221
+ pointer-events: none;
222
+ }
223
+
224
+ /* Fixed and portaled to <body>: absolute positioning is clipped by any
225
+ ancestor overflow (the Home card) and trapped by any stacking context (the
226
+ demo hero), and the overflow it caused made that ancestor scrollable, so
227
+ moving focus into the list scrolled the page out from under it. */
228
+ .labeled-select-panel {
229
+ position: fixed;
230
+ top: 0;
231
+ left: 0;
232
+ z-index: 1000;
233
+ visibility: hidden;
234
+ }
235
+
236
+ .error {
237
+ max-width: 20rem;
238
+ color: var(--text-danger);
239
+ font-family: var(--font-sans);
240
+ font-size: var(--font-size-sm);
241
+ line-height: var(--line-height-normal);
242
+ }
243
+ </style>
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import SectionDivider from '../system/components/SectionDivider.svelte';
4
+
5
+ /** Section wrapper. `variant` picks a SectionDivider preset (lg/md/sm).
6
+ * Each preset is fully configured in the editor — typography, colors,
7
+ * background, geometry are all owned by the variant. */
8
+ interface Props {
9
+ title: string;
10
+ description?: string;
11
+ eyebrow?: string;
12
+ variant?: 'lg' | 'md' | 'sm';
13
+ gap?: string;
14
+ class?: string;
15
+ children: Snippet;
16
+ }
17
+
18
+ let {
19
+ title,
20
+ description,
21
+ eyebrow,
22
+ variant = 'md',
23
+ gap = 'var(--space-24)',
24
+ class: className,
25
+ children,
26
+ }: Props = $props();
27
+ </script>
28
+
29
+ <section class={['demo-section', className]} style:gap>
30
+ <SectionDivider {title} {description} {eyebrow} {variant} />
31
+ {@render children()}
32
+ </section>
33
+
34
+ <style>
35
+ .demo-section {
36
+ grid-column: 2 / span 10;
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ @media (max-width: 960px) {
42
+ .demo-section {
43
+ grid-column: 1 / -1;
44
+ }
45
+ }
46
+ </style>
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import LabeledSelect from './LabeledSelect.svelte';
3
+ import { setSketch, sketchPick, sketchStyles, unsavedSketchStyle } from '../editor/core/sketch';
4
+
5
+ let { inline = false, size = 'default', onpick }: { inline?: boolean; size?: 'default' | 'small'; onpick?: (id: string | null) => void } = $props();
6
+
7
+ // The empty string is the placeholder's value, so off needs a name of its own.
8
+ const OFF = 'none';
9
+
10
+ let busy = $state(false);
11
+ let error = $state('');
12
+
13
+ const items = $derived([
14
+ { value: OFF, label: 'None' },
15
+ /* The style the open theme carries, when nothing in the pool names it.
16
+ Without the row the select reads None over a drawn page, and a visitor
17
+ who picks something else cannot get back. */
18
+ ...($unsavedSketchStyle ? [{ value: $unsavedSketchStyle.id, label: $unsavedSketchStyle.label }] : []),
19
+ ...$sketchStyles.map((style) => ({ value: style.id, label: style.label })),
20
+ ]);
21
+
22
+ /* Off is its own choice here, so the picked sketchstyle only shows while the
23
+ effect is on. Turning it off in the Sketchstyle view leaves that
24
+ sketchstyle selected there; this reads as None until it is switched back on.
25
+ Dials moved off every named style land on the placeholder. */
26
+ const selected = $derived(
27
+ $sketchPick.state === 'off' ? OFF
28
+ : $sketchPick.state === 'style' ? $sketchPick.style.id
29
+ : '',
30
+ );
31
+
32
+ function changeSketch(next: string) {
33
+ if (busy) return;
34
+ busy = true;
35
+ error = '';
36
+ try {
37
+ const id = next === OFF ? null : next;
38
+ setSketch(id);
39
+ onpick?.(id);
40
+ } catch (reason) {
41
+ error = reason instanceof Error ? reason.message : 'Could not apply that sketchstyle';
42
+ } finally {
43
+ busy = false;
44
+ }
45
+ }
46
+ </script>
47
+
48
+ <LabeledSelect
49
+ {inline}
50
+ {size}
51
+ label="Sketch"
52
+ {items}
53
+ value={selected}
54
+ placeholder="Custom"
55
+ disabled={busy}
56
+ {error}
57
+ onchange={changeSketch}
58
+ />
@@ -0,0 +1,156 @@
1
+ <script lang="ts">
2
+ import npmLogoUrl from '../system/assets/npm-mark-white.svg';
3
+ import githubLogoUrl from '../system/assets/github-mark-white.svg';
4
+ import { contrastTokenForBackground } from '../system/backdrop';
5
+
6
+ let { compact = false }: { compact?: boolean } = $props();
7
+
8
+ let navEl: HTMLElement | undefined = $state();
9
+
10
+ // Same rule as the kite strings in FloatingTokenTags: the marks are painted
11
+ // through a mask in whichever invariant token out-contrasts the page
12
+ // background, so they survive a theme swap or a live token edit.
13
+ $effect(() => {
14
+ if (!navEl) return;
15
+ const el = navEl;
16
+ let frame = 0;
17
+ let lastBackground = '';
18
+ const sync = () => {
19
+ const background = getComputedStyle(el).getPropertyValue('--page-bg').trim();
20
+ if (background !== lastBackground) {
21
+ lastBackground = background;
22
+ el.style.setProperty('--brand-mark-color', `var(${contrastTokenForBackground(background)})`);
23
+ }
24
+ frame = requestAnimationFrame(sync);
25
+ };
26
+ sync();
27
+ return () => cancelAnimationFrame(frame);
28
+ });
29
+ </script>
30
+
31
+ <nav class="source-links" class:compact aria-label="Project links" bind:this={navEl}>
32
+ <a class="brand-link" href="https://www.npmjs.com/package/@motion-proto/live-tokens" target="_blank" rel="noopener">
33
+ <span class="brand-mark brand-mark--npm" style:--brand-mark-image={`url("${npmLogoUrl}")`}></span>
34
+ <span class="brand-text">
35
+ <span class="brand-caption">Published on</span>
36
+ <span class="brand-name">npm<span class="brand-arrow" aria-hidden="true">↗</span></span>
37
+ </span>
38
+ </a>
39
+ <a class="brand-link" href="https://github.com/motionproto/live-tokens" target="_blank" rel="noopener">
40
+ <span class="brand-mark brand-mark--github" style:--brand-mark-image={`url("${githubLogoUrl}")`}></span>
41
+ <span class="brand-text">
42
+ <span class="brand-caption">Source on</span>
43
+ <span class="brand-name">GitHub<span class="brand-arrow" aria-hidden="true">↗</span></span>
44
+ </span>
45
+ </a>
46
+ </nav>
47
+
48
+ <style>
49
+ .source-links {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: var(--space-48);
53
+ flex: none;
54
+ }
55
+
56
+ .brand-link {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: var(--space-16);
60
+ text-decoration: none;
61
+ }
62
+
63
+ .brand-mark {
64
+ /* Square chips, sized to the caption + name text stack beside them.
65
+ The mask URL must stay quoted: Vite inlines these SVGs as data URIs
66
+ holding single quotes, which an unquoted url() rejects — and a dropped
67
+ mask paints the whole chip as a solid block. */
68
+ height: calc(var(--font-size-md) * 1.1 + var(--font-size-2xl) * 1.1 + var(--space-4));
69
+ width: calc(var(--font-size-md) * 1.1 + var(--font-size-2xl) * 1.1 + var(--space-4));
70
+ display: block;
71
+ background-color: var(--brand-mark-color, var(--color-white));
72
+ -webkit-mask: var(--brand-mark-image) center / contain no-repeat;
73
+ mask: var(--brand-mark-image) center / contain no-repeat;
74
+ opacity: 0.8;
75
+ transition: opacity var(--duration-150, 150ms) ease;
76
+ }
77
+
78
+ /* Solid tile reads heavier than the octocat silhouette — soften and trim to match its optical weight. */
79
+ .brand-mark--npm {
80
+ border-radius: var(--radius-md);
81
+ transform: scale(0.86);
82
+ }
83
+
84
+ .brand-text {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: var(--space-2, 2px);
88
+ line-height: 1.05;
89
+ }
90
+
91
+ .brand-caption {
92
+ font-family: var(--font-mono);
93
+ font-size: var(--font-size-md);
94
+ letter-spacing: 0.01em;
95
+ color: var(--text-tertiary);
96
+ }
97
+
98
+ .brand-name {
99
+ display: inline-flex;
100
+ align-items: baseline;
101
+ gap: 0.25em;
102
+ font-family: var(--font-sans);
103
+ font-size: var(--font-size-2xl);
104
+ font-weight: var(--font-weight-medium);
105
+ color: var(--text-primary);
106
+ transition: color var(--duration-150, 150ms) ease;
107
+ }
108
+
109
+ .brand-arrow {
110
+ position: relative;
111
+ top: 0.12em;
112
+ font-size: 1em;
113
+ opacity: 1;
114
+ transition: transform var(--duration-150, 150ms) ease;
115
+ }
116
+
117
+ .brand-link:hover .brand-mark {
118
+ opacity: 1;
119
+ }
120
+
121
+ .brand-link:hover .brand-name {
122
+ color: var(--text-primary);
123
+ }
124
+
125
+ .brand-link:hover .brand-arrow {
126
+ transform: translate(0.15em, -0.15em);
127
+ }
128
+
129
+ /* Compact — the in-hero copy, scaled down a step on every axis. */
130
+ .source-links.compact {
131
+ gap: var(--space-32);
132
+ }
133
+
134
+ .compact .brand-link {
135
+ gap: var(--space-12);
136
+ }
137
+
138
+ .compact .brand-mark {
139
+ height: calc(var(--font-size-sm) * 1.1 + var(--font-size-xl) * 1.1 + var(--space-2));
140
+ width: calc(var(--font-size-sm) * 1.1 + var(--font-size-xl) * 1.1 + var(--space-2));
141
+ }
142
+
143
+ .compact .brand-caption {
144
+ font-size: var(--font-size-sm);
145
+ }
146
+
147
+ .compact .brand-name {
148
+ font-size: var(--font-size-xl);
149
+ }
150
+
151
+ @media (max-width: 960px) {
152
+ .source-links:not(.compact) {
153
+ gap: var(--space-24);
154
+ }
155
+ }
156
+ </style>