|
36 | 36 | import { AnimatePresence, MotionButton, MotionSpan } from '@humanspeak/svelte-motion' |
37 | 37 | import CheckIcon from '@lucide/svelte/icons/check' |
38 | 38 | import CopyIcon from '@lucide/svelte/icons/copy' |
39 | | - import { onDestroy } from 'svelte' |
| 39 | + import { onDestroy, onMount } from 'svelte' |
40 | 40 |
|
41 | | - interface CodeSample { |
42 | | - /** Stable identifier — surfaces as the small-caps tag on the left of |
43 | | - * the header strip. */ |
44 | | - id: string |
45 | | - /** Display title rendered as the header's `<h3>`. */ |
46 | | - label: string |
| 41 | + interface CodeSamplePayload { |
47 | 42 | /** The raw code text. Used for the copy button and as the plain-text |
48 | 43 | * fallback when `html` is not provided. */ |
49 | 44 | code: string |
|
56 | 51 | html?: { light: string; dark: string } |
57 | 52 | } |
58 | 53 |
|
| 54 | + interface EagerCodeSample extends CodeSamplePayload { |
| 55 | + /** Stable identifier — surfaces as the small-caps tag on the left of |
| 56 | + * the header strip. */ |
| 57 | + id: string |
| 58 | + /** Display title rendered as the header's `<h3>`. */ |
| 59 | + label: string |
| 60 | + } |
| 61 | +
|
| 62 | + interface LazyCodeSample { |
| 63 | + /** Stable identifier — surfaces as the small-caps tag on the left of |
| 64 | + * the header strip. */ |
| 65 | + id: string |
| 66 | + /** Display title rendered as the header's `<h3>`. */ |
| 67 | + label: string |
| 68 | + /** Load the raw/highlighted code on demand. Supports both a plain |
| 69 | + * payload and an ESM module default export so consumers can pass |
| 70 | + * `() => import('virtual:docs-kit/demo/...')` directly. */ |
| 71 | + load: () => Promise<CodeSamplePayload | { default: CodeSamplePayload }> |
| 72 | + /** Override component-level lazy preloading for this sample. */ |
| 73 | + preload?: 'idle' | false |
| 74 | + } |
| 75 | +
|
| 76 | + type CodeSample = EagerCodeSample | LazyCodeSample |
| 77 | + type IdleRuntime = typeof globalThis & { |
| 78 | + requestIdleCallback?: (callback: () => void) => number |
| 79 | + cancelIdleCallback?: (id: number) => void |
| 80 | + } |
| 81 | +
|
| 82 | + function isLazySample(sample: CodeSample): sample is LazyCodeSample { |
| 83 | + return 'load' in sample |
| 84 | + } |
| 85 | +
|
59 | 86 | interface Props { |
60 | 87 | samples: CodeSample[] |
61 | 88 | /** Columns at desktop width. Default `auto`, which uses the number of |
62 | 89 | * samples up to 3 (so 1 sample = 1 col, 4 samples = still 3 cols |
63 | 90 | * wrapping). Pass an integer to force a specific column count. */ |
64 | 91 | columns?: number | 'auto' |
| 92 | + /** Lazy sample preload strategy. `idle` fetches after hydration so |
| 93 | + * code drawers are warm without blocking the initial page. Eager |
| 94 | + * samples are unaffected. */ |
| 95 | + preload?: 'idle' | false |
65 | 96 | } |
66 | 97 |
|
67 | | - const { samples, columns = 'auto' }: Props = $props() |
| 98 | + const { samples, columns = 'auto', preload = 'idle' }: Props = $props() |
68 | 99 |
|
69 | 100 | const colCount = $derived( |
70 | 101 | columns === 'auto' ? Math.min(samples.length, 3) : Math.max(1, columns) |
|
75 | 106 | // show "copied" independently if the user clicks through them quickly. |
76 | 107 | let copiedId = $state<string | null>(null) |
77 | 108 | let copyResetTimer: ReturnType<typeof setTimeout> | null = null |
| 109 | + let loadedById = $state<Record<string, CodeSamplePayload>>({}) |
| 110 | + let loadingById = $state<Record<string, boolean>>({}) |
| 111 | + let errorById = $state<Record<string, boolean>>({}) |
| 112 | + const pendingById = new Map<string, Promise<CodeSamplePayload | null>>() |
| 113 | + const cancelPreloads: Array<() => void> = [] |
78 | 114 |
|
79 | 115 | const showCopyFeedback = (sampleId: string) => { |
80 | 116 | copiedId = sampleId |
|
85 | 121 | }, 1600) |
86 | 122 | } |
87 | 123 |
|
| 124 | + const getPayload = (sample: CodeSample): CodeSamplePayload | null => { |
| 125 | + if (!isLazySample(sample)) return sample |
| 126 | + return loadedById[sample.id] ?? null |
| 127 | + } |
| 128 | +
|
| 129 | + const unwrapPayload = ( |
| 130 | + payload: CodeSamplePayload | { default: CodeSamplePayload } |
| 131 | + ): CodeSamplePayload => ('default' in payload ? payload.default : payload) |
| 132 | +
|
| 133 | + const loadSample = async (sample: CodeSample): Promise<CodeSamplePayload | null> => { |
| 134 | + if (!isLazySample(sample)) return sample |
| 135 | +
|
| 136 | + const cached = loadedById[sample.id] |
| 137 | + if (cached) return cached |
| 138 | +
|
| 139 | + const pending = pendingById.get(sample.id) |
| 140 | + if (pending) return pending |
| 141 | +
|
| 142 | + loadingById = { ...loadingById, [sample.id]: true } |
| 143 | + errorById = { ...errorById, [sample.id]: false } |
| 144 | +
|
| 145 | + const next = sample |
| 146 | + .load() |
| 147 | + .then((payload) => { |
| 148 | + const loaded = unwrapPayload(payload) |
| 149 | + loadedById = { ...loadedById, [sample.id]: loaded } |
| 150 | + return loaded |
| 151 | + }) |
| 152 | + .catch(() => { |
| 153 | + errorById = { ...errorById, [sample.id]: true } |
| 154 | + return null |
| 155 | + }) |
| 156 | + .finally(() => { |
| 157 | + loadingById = { ...loadingById, [sample.id]: false } |
| 158 | + pendingById.delete(sample.id) |
| 159 | + }) |
| 160 | +
|
| 161 | + pendingById.set(sample.id, next) |
| 162 | + return next |
| 163 | + } |
| 164 | +
|
| 165 | + const scheduleIdlePreload = (sample: LazyCodeSample) => { |
| 166 | + const runtime = globalThis as IdleRuntime |
| 167 | +
|
| 168 | + const run = () => { |
| 169 | + void loadSample(sample) |
| 170 | + } |
| 171 | +
|
| 172 | + if (runtime.requestIdleCallback && runtime.cancelIdleCallback) { |
| 173 | + const id = runtime.requestIdleCallback(run) |
| 174 | + cancelPreloads.push(() => runtime.cancelIdleCallback?.(id)) |
| 175 | + return |
| 176 | + } |
| 177 | +
|
| 178 | + const id = globalThis.setTimeout(run, 250) |
| 179 | + cancelPreloads.push(() => globalThis.clearTimeout(id)) |
| 180 | + } |
| 181 | +
|
88 | 182 | const copy = async (sample: CodeSample) => { |
| 183 | + const payload = await loadSample(sample) |
| 184 | + if (!payload) return |
| 185 | +
|
89 | 186 | showCopyFeedback(sample.id) |
90 | 187 | if (typeof navigator === 'undefined' || !navigator.clipboard) return |
91 | 188 | try { |
92 | | - await navigator.clipboard.writeText(sample.code) |
| 189 | + await navigator.clipboard.writeText(payload.code) |
93 | 190 | } catch { |
94 | 191 | /* clipboard blocked — fail quiet, the user can select + copy */ |
95 | 192 | } |
96 | 193 | } |
97 | 194 |
|
| 195 | + onMount(() => { |
| 196 | + for (const sample of samples) { |
| 197 | + if (!isLazySample(sample)) continue |
| 198 | + const samplePreload = sample.preload ?? preload |
| 199 | + if (samplePreload === 'idle') scheduleIdlePreload(sample) |
| 200 | + } |
| 201 | + }) |
| 202 | +
|
98 | 203 | onDestroy(() => { |
99 | 204 | if (copyResetTimer) clearTimeout(copyResetTimer) |
| 205 | + for (const cancel of cancelPreloads) cancel() |
100 | 206 | }) |
101 | 207 |
|
102 | 208 | const copyPress = { scale: 0.96 } |
|
106 | 212 |
|
107 | 213 | <div class="dk-coderef" style="--dk-coderef-cols: {colCount}"> |
108 | 214 | {#each samples as sample (sample.id)} |
| 215 | + {@const payload = getPayload(sample)} |
| 216 | + {@const isLoading = Boolean(loadingById[sample.id])} |
| 217 | + {@const hasError = Boolean(errorById[sample.id])} |
109 | 218 | <article class="dk-coderef-cell"> |
110 | 219 | <header class="dk-coderef-head"> |
111 | 220 | <div class="dk-coderef-meta"> |
|
119 | 228 | onclick={() => copy(sample)} |
120 | 229 | whileTap={copyPress} |
121 | 230 | whileHover={copyHover} |
| 231 | + disabled={isLoading} |
| 232 | + aria-busy={isLoading} |
122 | 233 | > |
123 | 234 | <AnimatePresence initial={false}> |
124 | 235 | {#if copiedId === sample.id} |
|
133 | 244 | <CheckIcon size={11} /> |
134 | 245 | <span>copied</span> |
135 | 246 | </MotionSpan> |
| 247 | + {:else if isLoading} |
| 248 | + <MotionSpan |
| 249 | + key="loading" |
| 250 | + class="dk-coderef-copy-state loading-state" |
| 251 | + initial={{ opacity: 1, y: 0 }} |
| 252 | + animate={{ opacity: 1, y: 0 }} |
| 253 | + exit={{ opacity: 0, y: 0 }} |
| 254 | + transition={copyStateTransition} |
| 255 | + > |
| 256 | + <span>loading</span> |
| 257 | + </MotionSpan> |
136 | 258 | {:else} |
137 | 259 | <MotionSpan |
138 | 260 | key="copy" |
|
150 | 272 | </MotionButton> |
151 | 273 | </header> |
152 | 274 | <div class="dk-coderef-code"> |
153 | | - {#if sample.html} |
| 275 | + {#if payload?.html} |
154 | 276 | <!-- eslint-disable-next-line svelte/no-at-html-tags -- shiki-rendered HTML supplied by the consumer; treat as trusted input --> |
155 | | - <div class="shiki-light">{@html sample.html.light}</div> |
| 277 | + <div class="shiki-light">{@html payload.html.light}</div> |
156 | 278 | <!-- eslint-disable-next-line svelte/no-at-html-tags -- shiki-rendered HTML supplied by the consumer; treat as trusted input --> |
157 | | - <div class="shiki-dark">{@html sample.html.dark}</div> |
| 279 | + <div class="shiki-dark">{@html payload.html.dark}</div> |
| 280 | + {:else if payload} |
| 281 | + <pre><code>{payload.code}</code></pre> |
| 282 | + {:else if hasError} |
| 283 | + <div class="dk-coderef-placeholder"> |
| 284 | + <strong>failed to load code</strong> |
| 285 | + <span>try reopening the panel</span> |
| 286 | + </div> |
158 | 287 | {:else} |
159 | | - <pre><code>{sample.code}</code></pre> |
| 288 | + <div class="dk-coderef-placeholder"> |
| 289 | + <strong>loading code</strong> |
| 290 | + <span>fetching the highlighted snippet</span> |
| 291 | + </div> |
160 | 292 | {/if} |
161 | 293 | </div> |
162 | 294 | </article> |
|
251 | 383 | color-mix(in srgb, var(--brut-accent) 10%, transparent) |
252 | 384 | ); |
253 | 385 | } |
| 386 | + .dk-coderef :global(.dk-coderef-copy:disabled) { |
| 387 | + cursor: progress; |
| 388 | + opacity: 0.72; |
| 389 | + } |
254 | 390 | .dk-coderef :global(.dk-coderef-copy-state) { |
255 | 391 | display: inline-flex; |
256 | 392 | align-items: center; |
|
267 | 403 | .dk-coderef :global(.dk-coderef-copy-state.copied-state) { |
268 | 404 | color: var(--brut-accent); |
269 | 405 | } |
| 406 | + .dk-coderef :global(.dk-coderef-copy-state.loading-state) { |
| 407 | + color: var(--brut-ink-3); |
| 408 | + } |
270 | 409 |
|
271 | 410 | /* ── Code area — fills the cell so the scrollbar sits flush ───── */ |
272 | 411 | .dk-coderef-code { |
|
309 | 448 | font-family: inherit; |
310 | 449 | background: transparent; |
311 | 450 | } |
| 451 | + .dk-coderef-placeholder { |
| 452 | + display: grid; |
| 453 | + min-height: 180px; |
| 454 | + place-content: center; |
| 455 | + gap: 6px; |
| 456 | + padding: 28px 18px; |
| 457 | + background: var(--brut-bg); |
| 458 | + color: var(--brut-ink-3); |
| 459 | + text-align: center; |
| 460 | + } |
| 461 | + .dk-coderef-placeholder strong { |
| 462 | + color: var(--brut-ink); |
| 463 | + font-size: 12px; |
| 464 | + text-transform: uppercase; |
| 465 | + } |
| 466 | + .dk-coderef-placeholder span { |
| 467 | + font-size: 11px; |
| 468 | + } |
312 | 469 |
|
313 | 470 | /* Light/dark switching for shiki-rendered samples. Prose.css scopes |
314 | 471 | its toggle to `.prose`, so we re-state it here on the grid root to |
|
0 commit comments