@lyeve-labs/ui-kit 0.39.1 → 0.41.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,428 @@
1
+ <script lang="ts">
2
+ import {
3
+ Lock,
4
+ LockOpen,
5
+ PanelLeftClose,
6
+ PanelLeftOpen,
7
+ PanelRightClose,
8
+ PanelRightOpen,
9
+ } from '@lucide/svelte';
10
+ import { tick, untrack, type Snippet } from 'svelte';
11
+ import Button from './Button.svelte';
12
+ import SectionHeading from './SectionHeading.svelte';
13
+ import { clampWidth, readAside, viewerStorage, writeAside } from '../internal/aside.js';
14
+
15
+ /**
16
+ * A column docked to one side of a page, with one width discipline for every
17
+ * page that has one. Above the breakpoint its inner edge drags, a lock fixes
18
+ * the width, the keyboard moves the edge, and the width and the lock are
19
+ * remembered per viewer. A collapsible one folds to a strip carrying its
20
+ * name. Below the breakpoint none of that applies: it stacks in the page's
21
+ * own flow, hides, or becomes the overlay a toolbar opens, as the page
22
+ * decides. The drag overlays the width while the pointer moves and writes
23
+ * once on release.
24
+ */
25
+ type Breakpoint = 'md' | 'lg' | 'xl';
26
+
27
+ interface Props {
28
+ /** The localStorage key the width and lock are kept under. */
29
+ storageKey: string;
30
+ /** The word on the strip and the header. */
31
+ label: string;
32
+ /** Which edge of the page it docks to. The handle sits on the other edge. */
33
+ side: 'start' | 'end';
34
+ /** The width it opens at until somebody drags it. */
35
+ width?: number;
36
+ min?: number;
37
+ max?: number;
38
+ /** Where the desktop controls start. */
39
+ breakpoint?: Breakpoint;
40
+ /**
41
+ * What it does under the breakpoint: stays in the page's flow across the
42
+ * full width, disappears, or floats over the page while `overlay` is set.
43
+ */
44
+ below?: 'stack' | 'overlay' | 'hidden';
45
+ /** Shown as an overlay below the breakpoint. Only read with `below="overlay"`. */
46
+ overlay?: boolean;
47
+ /** Offers a collapse to a strip. */
48
+ collapsible?: boolean;
49
+ /** Expanded above the breakpoint. Bindable so a page can open it from an offer. */
50
+ open?: boolean;
51
+ /** Keeps the header's count or status beside the label. */
52
+ meta?: Snippet;
53
+ /** Classes for the aside element itself, for the stacked layout's share of the height. */
54
+ class?: string;
55
+ testId?: string;
56
+ children: Snippet;
57
+ }
58
+
59
+ let {
60
+ storageKey,
61
+ label,
62
+ side,
63
+ width: defaultWidth = 280,
64
+ min = 200,
65
+ max = 560,
66
+ breakpoint = 'xl',
67
+ below = 'stack',
68
+ overlay = false,
69
+ collapsible = false,
70
+ open = $bindable(true),
71
+ meta,
72
+ class: klass = '',
73
+ testId,
74
+ children,
75
+ }: Props = $props();
76
+
77
+ // Tailwind reads class names from source, so each breakpoint spells its
78
+ // variants out rather than building them from the prop. The dock entry hands
79
+ // an overlay back to the page at the breakpoint, and nothing moves or
80
+ // transitions there. It resets the offset with translate-none rather than a
81
+ // zero offset, because any translate makes the aside the containing block
82
+ // for a fixed popover inside it. The overlay's layer applies only under the
83
+ // breakpoint: a docked pane is a flex item, and a stacking layer on it would
84
+ // lift it, and every popover inside it, over the main column's popovers. The
85
+ // room entry keeps the main column at least the pane's minimum wide, so a
86
+ // width stored on a wider screen never pushes it off this one.
87
+ type Variant =
88
+ | 'dock'
89
+ | 'w8'
90
+ | 'flex'
91
+ | 'block'
92
+ | 'hidden'
93
+ | 'width'
94
+ | 'room'
95
+ | 'noBorderB'
96
+ | 'borderE'
97
+ | 'borderS'
98
+ | 'flexNone'
99
+ | 'maxHNone';
100
+ const AT: Record<Breakpoint, Record<Variant, string>> = {
101
+ md: {
102
+ dock: 'max-md:z-dropdown md:static md:visible md:translate-none md:opacity-100 md:transition-none',
103
+ w8: 'md:w-8',
104
+ flex: 'md:flex',
105
+ block: 'md:block',
106
+ hidden: 'md:hidden',
107
+ width: 'md:w-(--panel-w)',
108
+ room: 'md:max-w-[calc(100%_-_var(--panel-min))]',
109
+ noBorderB: 'md:border-b-0',
110
+ borderE: 'md:border-e',
111
+ borderS: 'md:border-s',
112
+ flexNone: 'md:flex-none',
113
+ maxHNone: 'md:max-h-none',
114
+ },
115
+ lg: {
116
+ dock: 'max-lg:z-dropdown lg:static lg:visible lg:translate-none lg:opacity-100 lg:transition-none',
117
+ w8: 'lg:w-8',
118
+ flex: 'lg:flex',
119
+ block: 'lg:block',
120
+ hidden: 'lg:hidden',
121
+ width: 'lg:w-(--panel-w)',
122
+ room: 'lg:max-w-[calc(100%_-_var(--panel-min))]',
123
+ noBorderB: 'lg:border-b-0',
124
+ borderE: 'lg:border-e',
125
+ borderS: 'lg:border-s',
126
+ flexNone: 'lg:flex-none',
127
+ maxHNone: 'lg:max-h-none',
128
+ },
129
+ xl: {
130
+ dock: 'max-xl:z-dropdown xl:static xl:visible xl:translate-none xl:opacity-100 xl:transition-none',
131
+ w8: 'xl:w-8',
132
+ flex: 'xl:flex',
133
+ block: 'xl:block',
134
+ hidden: 'xl:hidden',
135
+ width: 'xl:w-(--panel-w)',
136
+ room: 'xl:max-w-[calc(100%_-_var(--panel-min))]',
137
+ noBorderB: 'xl:border-b-0',
138
+ borderE: 'xl:border-e',
139
+ borderS: 'xl:border-s',
140
+ flexNone: 'xl:flex-none',
141
+ maxHNone: 'xl:max-h-none',
142
+ },
143
+ };
144
+ const at = $derived(AT[breakpoint]);
145
+ const id = $derived(testId ?? `aside-${storageKey}`);
146
+
147
+ // Each breakpoint as the stylesheet compiles it, in rem, so the script and
148
+ // the variants above change over at the same width.
149
+ const WIDE: Record<Breakpoint, string> = {
150
+ md: '(min-width: 48rem)',
151
+ lg: '(min-width: 64rem)',
152
+ xl: '(min-width: 80rem)',
153
+ };
154
+
155
+ /**
156
+ * Whether the page is under the breakpoint, once the browser has said. Only
157
+ * an overlay asks, and only to set inert. Every other part of the overlay is
158
+ * a class, and its breakpoint variant hands the docked column back. Inert is
159
+ * an attribute, which no variant can scope, and the docked column has to take
160
+ * focus and clicks whatever the overlay flag says. Until the browser answers,
161
+ * invisible keeps a closed overlay out of focus and out of reach.
162
+ */
163
+ let narrow = $state(false);
164
+
165
+ $effect(() => {
166
+ if (below !== 'overlay' || typeof window.matchMedia !== 'function') return;
167
+ const wide = window.matchMedia(WIDE[breakpoint]);
168
+ const sync = () => {
169
+ narrow = !wide.matches;
170
+ };
171
+ sync();
172
+ wide.addEventListener('change', sync);
173
+ return () => wide.removeEventListener('change', sync);
174
+ });
175
+
176
+ const bounds = $derived({ width: defaultWidth, min, max });
177
+
178
+ // The key never changes for the life of the pane, so the read happens once,
179
+ // at mount. The server has no storage, so it renders the default width.
180
+ const initial = readAside(
181
+ viewerStorage(),
182
+ untrack(() => storageKey),
183
+ untrack(() => bounds),
184
+ );
185
+ let width = $state(initial.width);
186
+ let locked = $state(initial.locked);
187
+
188
+ let aside: HTMLElement | undefined = $state();
189
+ let drag = $state<{ startX: number; startWidth: number; grows: number } | null>(null);
190
+
191
+ /**
192
+ * Which way a pointer or an arrow moving right changes the width. The inner
193
+ * edge is on the right of a start pane and on the left of an end pane, and a
194
+ * right-to-left page swaps both, so the edge always follows the key the way
195
+ * it looks.
196
+ */
197
+ function growsRight(): number {
198
+ const rtl = aside ? getComputedStyle(aside).direction === 'rtl' : false;
199
+ return (side === 'start' ? 1 : -1) * (rtl ? -1 : 1);
200
+ }
201
+
202
+ /**
203
+ * The bounds as this screen allows them. The upper bound also leaves the
204
+ * main column the pane's minimum, the same cap the stylesheet draws. A
205
+ * parent with no measured width (before layout, or in a test) leaves the
206
+ * bounds as given.
207
+ */
208
+ function room() {
209
+ const total = aside?.parentElement?.clientWidth ?? 0;
210
+ const upper = total > 0 ? Math.max(min, Math.min(max, total - min)) : max;
211
+ return { min, max: upper };
212
+ }
213
+
214
+ /**
215
+ * The upper bound the room allows, kept current as the page resizes, so the
216
+ * handle announces the width drawn and a maximum the keys can reach. The
217
+ * server has no layout and renders the bounds as given.
218
+ */
219
+ let roomMax = $state(untrack(() => max));
220
+
221
+ $effect(() => {
222
+ const parent = aside?.parentElement;
223
+ const sync = () => {
224
+ roomMax = room().max;
225
+ };
226
+ sync();
227
+ if (!parent || typeof ResizeObserver !== 'function') return;
228
+ const watch = new ResizeObserver(sync);
229
+ watch.observe(parent);
230
+ return () => watch.disconnect();
231
+ });
232
+
233
+ /** The width on screen, which the room cap can hold below the stored one. */
234
+ function shown(): number {
235
+ const drawn = aside?.getBoundingClientRect().width ?? 0;
236
+ return drawn > 0 ? Math.min(width, Math.round(drawn)) : width;
237
+ }
238
+
239
+ function remember() {
240
+ writeAside(viewerStorage(), storageKey, bounds, { width, locked });
241
+ }
242
+
243
+ function startResize(e: PointerEvent) {
244
+ if (e.button !== 0) return;
245
+ e.preventDefault();
246
+ drag = { startX: e.clientX, startWidth: shown(), grows: growsRight() };
247
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
248
+ }
249
+
250
+ function resizeMove(e: PointerEvent) {
251
+ if (!drag) return;
252
+ width = clampWidth(room(), drag.startWidth + (e.clientX - drag.startX) * drag.grows);
253
+ }
254
+
255
+ function endResize(e: PointerEvent) {
256
+ if (!drag) return;
257
+ drag = null;
258
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
259
+ remember();
260
+ }
261
+
262
+ function resizeKey(e: KeyboardEvent) {
263
+ const step = e.shiftKey ? 80 : 24;
264
+ const limits = room();
265
+ let next: number | null = null;
266
+ if (e.key === 'ArrowRight') next = shown() + step * growsRight();
267
+ else if (e.key === 'ArrowLeft') next = shown() - step * growsRight();
268
+ else if (e.key === 'Home') next = limits.min;
269
+ else if (e.key === 'End') next = limits.max;
270
+ if (next === null) return;
271
+ e.preventDefault();
272
+ width = clampWidth(limits, next);
273
+ remember();
274
+ }
275
+
276
+ function toggleLock() {
277
+ locked = !locked;
278
+ remember();
279
+ }
280
+
281
+ let strip: HTMLElement | undefined = $state();
282
+ let controls: HTMLElement | undefined = $state();
283
+
284
+ /**
285
+ * Folds or opens the pane from its own control. The fold hides the control
286
+ * that was pressed, so once the markup carries the new state focus moves to
287
+ * the control that undoes it. Left where it was, a keyboard reader lands on
288
+ * the body or on a button nobody can see.
289
+ */
290
+ async function fold(next: boolean) {
291
+ open = next;
292
+ await tick();
293
+ (next ? controls : strip)?.querySelector<HTMLElement>('[aria-expanded]')?.focus();
294
+ }
295
+
296
+ // The panel glyphs are drawn for a left-to-right page, so a right-to-left
297
+ // one mirrors them with the rest of the layout.
298
+ const Collapse = $derived(side === 'start' ? PanelLeftClose : PanelRightClose);
299
+ const Expand = $derived(side === 'start' ? PanelLeftOpen : PanelRightOpen);
300
+
301
+ /** The strip is the only thing a collapsed pane shows, and only above the breakpoint. */
302
+ const folded = $derived(collapsible && !open);
303
+
304
+ const edge = $derived(side === 'start' ? at.borderE : at.borderS);
305
+
306
+ /**
307
+ * The overlay's motion under the breakpoint. It is CSS because the overlay
308
+ * stays mounted at every width, so what the reader typed in it survives a
309
+ * close and a resize. Closed, it sits off its own edge, transparent and
310
+ * invisible. It slides and fades in on the slow rung and out one rung
311
+ * faster. Visibility is in the exit's list only. Leaving, the panel turns
312
+ * invisible once the slide has finished. Arriving, it is visible at once, so
313
+ * a field inside can take focus on the first frame.
314
+ */
315
+ const slide = $derived(
316
+ overlay
317
+ ? 'transition-[translate,opacity] duration-slow ease-enter'
318
+ : `invisible opacity-0 ${side === 'start' ? '-translate-x-full rtl:translate-x-full' : 'translate-x-full rtl:-translate-x-full'} transition-[translate,opacity,visibility] duration-base ease-exit`,
319
+ );
320
+
321
+ const frame = $derived.by(() => {
322
+ if (below === 'overlay') {
323
+ // Floating under the breakpoint at the panel's own width. Only the
324
+ // docked variant folds to the strip.
325
+ return `absolute inset-y-0 w-(--panel-w) max-w-full shrink-0 border-line bg-surface ${slide} ${at.dock} ${folded ? at.w8 : at.room} ${side === 'start' ? 'start-0 border-e' : 'end-0 border-s'}`;
326
+ }
327
+ const shownBelow =
328
+ below === 'hidden'
329
+ ? `hidden ${at.flex}`
330
+ : `flex border-b ${at.noBorderB} ${at.flexNone} ${at.maxHNone}`;
331
+ return `${shownBelow} min-h-0 flex-col border-line bg-surface ${folded ? at.w8 : `${at.width} ${at.room}`} ${edge} ${klass}`;
332
+ });
333
+ </script>
334
+
335
+ <aside
336
+ bind:this={aside}
337
+ class={frame}
338
+ style="--panel-w:{width}px;--panel-min:{min}px"
339
+ aria-label={label}
340
+ data-testid={id}
341
+ data-open={open}
342
+ data-locked={locked}
343
+ inert={below === 'overlay' && narrow && !overlay}
344
+ >
345
+ {#if collapsible}
346
+ <!-- The strip: the name read down the edge and the one control that brings the pane back. -->
347
+ <div
348
+ bind:this={strip}
349
+ class="h-full flex-col items-center gap-2 py-1 {folded ? `hidden ${at.flex}` : 'hidden'}"
350
+ data-testid="{id}-strip"
351
+ >
352
+ <Button
353
+ variant="ghost"
354
+ size="sm"
355
+ aria-label="Expand {label}"
356
+ aria-expanded={false}
357
+ hint={false}
358
+ onclick={() => fold(true)}
359
+ >
360
+ <Expand size={14} class="rtl:-scale-x-100" />
361
+ </Button>
362
+ <span
363
+ class="text-xs font-medium tracking-wide text-faint uppercase [writing-mode:vertical-rl]"
364
+ >{label}</span
365
+ >
366
+ </div>
367
+ {/if}
368
+
369
+ <div class="relative flex h-full min-h-0 flex-1 flex-col {folded ? at.hidden : ''}">
370
+ <div
371
+ class="flex h-control shrink-0 items-center justify-between border-b border-line ps-3 pe-1"
372
+ >
373
+ <SectionHeading level={2} variant="eyebrow" actions={meta}>{label}</SectionHeading>
374
+ <div bind:this={controls} class="hidden items-center {at.flex}">
375
+ <Button
376
+ variant="ghost"
377
+ size="sm"
378
+ aria-label={locked ? `Unlock ${label} width` : `Lock ${label} width`}
379
+ aria-pressed={locked}
380
+ hint={false}
381
+ onclick={toggleLock}
382
+ >
383
+ {#if locked}<Lock size={14} />{:else}<LockOpen size={14} />{/if}
384
+ </Button>
385
+ {#if collapsible}
386
+ <Button
387
+ variant="ghost"
388
+ size="sm"
389
+ aria-label="Collapse {label}"
390
+ aria-expanded={true}
391
+ hint={false}
392
+ onclick={() => fold(false)}
393
+ >
394
+ <Collapse size={14} class="rtl:-scale-x-100" />
395
+ </Button>
396
+ {/if}
397
+ </div>
398
+ </div>
399
+ <div class="min-h-0 flex-1 overflow-y-auto">
400
+ {@render children()}
401
+ </div>
402
+ {#if !locked}
403
+ <!-- A separator that takes focus is the pattern for a splitter, and the
404
+ a11y rules read every separator as static. -->
405
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
406
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
407
+ <div
408
+ class="absolute inset-y-0 hidden w-2 cursor-col-resize hover:bg-brand/40 focus-visible:bg-brand/40 {at.block} {side ===
409
+ 'start'
410
+ ? '-end-1'
411
+ : '-start-1'} {drag ? 'bg-brand/40' : ''}"
412
+ role="separator"
413
+ aria-orientation="vertical"
414
+ aria-label="Resize {label}"
415
+ aria-valuenow={clampWidth({ min, max: roomMax }, width)}
416
+ aria-valuemin={min}
417
+ aria-valuemax={roomMax}
418
+ tabindex="0"
419
+ data-testid="{id}-handle"
420
+ onpointerdown={startResize}
421
+ onpointermove={resizeMove}
422
+ onpointerup={endResize}
423
+ onpointercancel={endResize}
424
+ onkeydown={resizeKey}
425
+ ></div>
426
+ {/if}
427
+ </div>
428
+ </aside>
@@ -0,0 +1,46 @@
1
+ import { type Snippet } from 'svelte';
2
+ /**
3
+ * A column docked to one side of a page, with one width discipline for every
4
+ * page that has one. Above the breakpoint its inner edge drags, a lock fixes
5
+ * the width, the keyboard moves the edge, and the width and the lock are
6
+ * remembered per viewer. A collapsible one folds to a strip carrying its
7
+ * name. Below the breakpoint none of that applies: it stacks in the page's
8
+ * own flow, hides, or becomes the overlay a toolbar opens, as the page
9
+ * decides. The drag overlays the width while the pointer moves and writes
10
+ * once on release.
11
+ */
12
+ type Breakpoint = 'md' | 'lg' | 'xl';
13
+ interface Props {
14
+ /** The localStorage key the width and lock are kept under. */
15
+ storageKey: string;
16
+ /** The word on the strip and the header. */
17
+ label: string;
18
+ /** Which edge of the page it docks to. The handle sits on the other edge. */
19
+ side: 'start' | 'end';
20
+ /** The width it opens at until somebody drags it. */
21
+ width?: number;
22
+ min?: number;
23
+ max?: number;
24
+ /** Where the desktop controls start. */
25
+ breakpoint?: Breakpoint;
26
+ /**
27
+ * What it does under the breakpoint: stays in the page's flow across the
28
+ * full width, disappears, or floats over the page while `overlay` is set.
29
+ */
30
+ below?: 'stack' | 'overlay' | 'hidden';
31
+ /** Shown as an overlay below the breakpoint. Only read with `below="overlay"`. */
32
+ overlay?: boolean;
33
+ /** Offers a collapse to a strip. */
34
+ collapsible?: boolean;
35
+ /** Expanded above the breakpoint. Bindable so a page can open it from an offer. */
36
+ open?: boolean;
37
+ /** Keeps the header's count or status beside the label. */
38
+ meta?: Snippet;
39
+ /** Classes for the aside element itself, for the stacked layout's share of the height. */
40
+ class?: string;
41
+ testId?: string;
42
+ children: Snippet;
43
+ }
44
+ declare const SplitPane: import("svelte").Component<Props, {}, "open">;
45
+ type SplitPane = ReturnType<typeof SplitPane>;
46
+ export default SplitPane;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { default as Table } from './components/Table.svelte';
21
21
  export { default as DescriptionList } from './components/DescriptionList.svelte';
22
22
  export { default as Toolbar } from './components/Toolbar.svelte';
23
23
  export { default as TreeView } from './components/TreeView.svelte';
24
+ export { default as SplitPane } from './components/SplitPane.svelte';
24
25
  export type { CellFit } from './components/Table.svelte';
25
26
  export type { SidebarState } from './components/AppShell.svelte';
26
27
  export type { PageWidth, SectionVariant } from './internal/layout.js';
@@ -77,6 +78,9 @@ export { default as Spinner } from './components/Spinner.svelte';
77
78
  export { default as Skeleton } from './components/Skeleton.svelte';
78
79
  export { default as EmptyState } from './components/EmptyState.svelte';
79
80
  export { default as CheckoutSteps } from './components/CheckoutSteps.svelte';
81
+ export { default as RefusalNotice } from './components/RefusalNotice.svelte';
82
+ export { default as GateNotice } from './components/GateNotice.svelte';
83
+ export type { Gate, GateText, GateWords } from './internal/gate.js';
80
84
  export { default as PluginCart } from './components/PluginCart.svelte';
81
85
  export type { QuickPick } from './components/PluginCart.svelte';
82
86
  export { billedCount, formatCents, ladderRows, ladderState, type BillingPeriod, type CartCatalog, type CartPlugin, type CartQuote, type CartQuoteItem, type DiscountBracket, type LadderState, } from './internal/pricing.js';
@@ -103,4 +107,4 @@ export { cn, type ClassValue } from './utils/cn.js';
103
107
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
104
108
  export * as motion from './motion.js';
105
109
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
106
- export declare const VERSION = "0.39.1";
110
+ export declare const VERSION = "0.41.0";
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ export { default as Table } from './components/Table.svelte';
22
22
  export { default as DescriptionList } from './components/DescriptionList.svelte';
23
23
  export { default as Toolbar } from './components/Toolbar.svelte';
24
24
  export { default as TreeView } from './components/TreeView.svelte';
25
+ export { default as SplitPane } from './components/SplitPane.svelte';
25
26
  // Stated once in an app's root layout, the format turns every PageShell's
26
27
  // title into the browser tab's. An app that never states one gets no title
27
28
  // element from the kit.
@@ -77,6 +78,8 @@ export { default as Spinner } from './components/Spinner.svelte';
77
78
  export { default as Skeleton } from './components/Skeleton.svelte';
78
79
  export { default as EmptyState } from './components/EmptyState.svelte';
79
80
  export { default as CheckoutSteps } from './components/CheckoutSteps.svelte';
81
+ export { default as RefusalNotice } from './components/RefusalNotice.svelte';
82
+ export { default as GateNotice } from './components/GateNotice.svelte';
80
83
  export { default as PluginCart } from './components/PluginCart.svelte';
81
84
  export { billedCount, formatCents, ladderRows, ladderState, } from './internal/pricing.js';
82
85
  export { default as Stat } from './components/Stat.svelte';
@@ -116,4 +119,4 @@ export * as motion from './motion.js';
116
119
  // ── Version ────────────────────────────────────────────────────────────────
117
120
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
118
121
  // this line. The build and the test suite fail when the two disagree.
119
- export const VERSION = '0.39.1';
122
+ export const VERSION = '0.41.0';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A file input's `accept` list, read out as the names a person scans for.
3
+ *
4
+ * The attribute is written for the browser: extensions and MIME types side by
5
+ * side, often naming one format twice (`.json,application/json`) and sometimes
6
+ * as a vendor type longer than the drop zone is wide. Printed as it stands it
7
+ * runs as one unbroken line. Each entry folds to the short name a reader knows
8
+ * the format by, duplicates collapse, and the list joins as an English "or".
9
+ *
10
+ * Not exported from the package entry point: this is an implementation detail.
11
+ */
12
+ /** The `accept` list as a short line, or an empty string when it names nothing. */
13
+ export declare function describeAccept(accept: string): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A file input's `accept` list, read out as the names a person scans for.
3
+ *
4
+ * The attribute is written for the browser: extensions and MIME types side by
5
+ * side, often naming one format twice (`.json,application/json`) and sometimes
6
+ * as a vendor type longer than the drop zone is wide. Printed as it stands it
7
+ * runs as one unbroken line. Each entry folds to the short name a reader knows
8
+ * the format by, duplicates collapse, and the list joins as an English "or".
9
+ *
10
+ * Not exported from the package entry point: this is an implementation detail.
11
+ */
12
+ /**
13
+ * Names a reader knows a format by, keyed by an extension or a MIME subtype.
14
+ * Both spellings go through one table, so `.yml` and `application/yaml` fold
15
+ * into one entry.
16
+ */
17
+ const ALIASES = {
18
+ plain: 'txt',
19
+ jpeg: 'jpg',
20
+ yml: 'yaml',
21
+ htm: 'html',
22
+ tif: 'tiff',
23
+ 'ld+json': 'JSON-LD',
24
+ msword: 'DOC',
25
+ 'vnd.ms-excel': 'XLS',
26
+ 'vnd.ms-powerpoint': 'PPT',
27
+ 'vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',
28
+ 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',
29
+ 'vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',
30
+ 'vnd.oasis.opendocument.text': 'ODT',
31
+ 'vnd.oasis.opendocument.spreadsheet': 'ODS',
32
+ };
33
+ /** Whole types whose subtype alone names the wrong format. */
34
+ const TYPES = {
35
+ 'audio/mpeg': 'MP3',
36
+ };
37
+ /** What `type/*` accepts, said as a kind of file rather than a format. */
38
+ const WILDCARDS = {
39
+ image: 'images',
40
+ video: 'videos',
41
+ audio: 'audio',
42
+ text: 'text files',
43
+ };
44
+ /** Fixed to en-US for the reason `number.ts` gives: server and browser must agree. */
45
+ const OR_LIST = new Intl.ListFormat('en-US', { type: 'disjunction' });
46
+ function alias(format) {
47
+ return (ALIASES[format] ?? format).toUpperCase();
48
+ }
49
+ function name(entry) {
50
+ const token = entry.toLowerCase();
51
+ if (token.startsWith('.'))
52
+ return alias(token.slice(1));
53
+ const [type, sub] = token.split('/');
54
+ if (!sub)
55
+ return entry;
56
+ if (sub === '*')
57
+ return WILDCARDS[type] ?? entry;
58
+ if (TYPES[token])
59
+ return TYPES[token];
60
+ if (ALIASES[sub])
61
+ return alias(sub);
62
+ // A vendor type has no short form to derive, so an unknown one stays as
63
+ // written rather than turning into a guess.
64
+ if (sub.startsWith('vnd.'))
65
+ return entry;
66
+ return alias(sub.replace(/^x-/, '').split('+')[0]);
67
+ }
68
+ /** The `accept` list as a short line, or an empty string when it names nothing. */
69
+ export function describeAccept(accept) {
70
+ const names = accept
71
+ .split(',')
72
+ .map((entry) => entry.trim())
73
+ .filter(Boolean)
74
+ .map(name);
75
+ return OR_LIST.format([...new Set(names)]);
76
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What a split pane remembers per viewer: its width and whether the width is
3
+ * locked. Browser storage can be absent or throw (a private window, cleared or
4
+ * blocked site data, a thumbnail capture), so every read and write is guarded
5
+ * and the pane falls back to its default. A convenience, not state a server
6
+ * holds.
7
+ *
8
+ * The stored value is `{"width": <px>, "locked": <bool>}` under the key the
9
+ * consumer names, so a reader keeps the width an earlier copy of this pane
10
+ * wrote under the same key.
11
+ *
12
+ * Not exported from the package entry point: this is an implementation detail.
13
+ */
14
+ export interface AsideBounds {
15
+ /** The width the pane opens at until somebody drags it. */
16
+ width: number;
17
+ min: number;
18
+ max: number;
19
+ }
20
+ export interface AsidePrefs {
21
+ width: number;
22
+ locked: boolean;
23
+ }
24
+ type Reader = Pick<Storage, 'getItem'> | null | undefined;
25
+ type Writer = Pick<Storage, 'setItem'> | null | undefined;
26
+ /**
27
+ * The page's local storage, or nothing. Reading the global can itself throw
28
+ * when site data is blocked. Outside a browser it is never read: a server
29
+ * runtime may define a localStorage of its own, warning on first access or
30
+ * holding one store that every request would share.
31
+ */
32
+ export declare function viewerStorage(): Storage | null;
33
+ export declare function clampWidth(bounds: Pick<AsideBounds, 'min' | 'max'>, width: number): number;
34
+ export declare function readAside(storage: Reader, key: string, bounds: AsideBounds): AsidePrefs;
35
+ export declare function writeAside(storage: Writer, key: string, bounds: AsideBounds, prefs: AsidePrefs): void;
36
+ export {};