@lyeve-labs/ui-kit 0.40.0 → 0.41.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.
- package/README.md +9 -6
- package/dist/components/FileInput.svelte +3 -1
- package/dist/components/GateNotice.svelte +68 -0
- package/dist/components/GateNotice.svelte.d.ts +20 -0
- package/dist/components/RefusalNotice.svelte +61 -0
- package/dist/components/RefusalNotice.svelte.d.ts +29 -0
- package/dist/components/SplitPane.svelte +428 -0
- package/dist/components/SplitPane.svelte.d.ts +46 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/internal/accept.d.ts +13 -0
- package/dist/internal/accept.js +76 -0
- package/dist/internal/aside.d.ts +36 -0
- package/dist/internal/aside.js +61 -0
- package/dist/internal/gate.d.ts +71 -0
- package/dist/internal/gate.js +1 -0
- package/dist/internal/href.d.ts +8 -0
- package/dist/internal/href.js +18 -0
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
|
|
|
28
28
|
|
|
29
29
|
## What's in the box
|
|
30
30
|
|
|
31
|
-
- **
|
|
31
|
+
- **75 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
|
|
32
32
|
- **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
|
|
33
33
|
- **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token. The rest is just Tailwind.
|
|
34
34
|
- **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
|
|
@@ -37,10 +37,10 @@ No config file, no theme provider, no setup ceremony.
|
|
|
37
37
|
## Component list
|
|
38
38
|
|
|
39
39
|
<details>
|
|
40
|
-
<summary>
|
|
40
|
+
<summary>75 components, organized by purpose</summary>
|
|
41
41
|
|
|
42
42
|
**Layout and structure**
|
|
43
|
-
Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
|
|
43
|
+
Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView, SplitPane
|
|
44
44
|
|
|
45
45
|
**Forms and inputs**
|
|
46
46
|
Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
|
|
@@ -55,7 +55,7 @@ Modal, Drawer, Tooltip
|
|
|
55
55
|
Dialog, DialogContainer, ConfirmDialog (plus the `openDialog` and `confirm` services)
|
|
56
56
|
|
|
57
57
|
**Feedback and status**
|
|
58
|
-
Alert, Banner, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton
|
|
58
|
+
Alert, Banner, RefusalNotice, GateNotice, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton
|
|
59
59
|
|
|
60
60
|
**Media**
|
|
61
61
|
Avatar, AvatarGroup, Logo
|
|
@@ -70,7 +70,7 @@ ThemeToggle, Toaster (plus the `toast` service)
|
|
|
70
70
|
- **Svelte 5.0** or newer
|
|
71
71
|
- **Tailwind CSS v4** with the `@tailwindcss/vite` plugin
|
|
72
72
|
- **`@lucide/svelte`** 0.511 or newer, below 2.0
|
|
73
|
-
- **Node
|
|
73
|
+
- **Node 24** or newer
|
|
74
74
|
|
|
75
75
|
## Install
|
|
76
76
|
|
|
@@ -385,6 +385,9 @@ const JOB_TONE: Record<JobState, StatusTone> = {
|
|
|
385
385
|
|
|
386
386
|
## Local development
|
|
387
387
|
|
|
388
|
+
The kit is developed on Node 26 and installs with pnpm only. Applications that
|
|
389
|
+
consume it need Node 24 or newer and may use any package manager.
|
|
390
|
+
|
|
388
391
|
```bash
|
|
389
392
|
pnpm install # install dependencies
|
|
390
393
|
pnpm test # run unit tests
|
|
@@ -400,7 +403,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
|
|
|
400
403
|
```
|
|
401
404
|
src/
|
|
402
405
|
└── lib/ # → published as @lyeve-labs/ui-kit
|
|
403
|
-
├── components/ #
|
|
406
|
+
├── components/ # 75 .svelte files
|
|
404
407
|
├── stores/ # toast.svelte.ts
|
|
405
408
|
├── styles/ # theme.css (the one stylesheet)
|
|
406
409
|
├── utils/ # cn.ts, theme.ts
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
FIELD_WRAP,
|
|
7
7
|
describedBy,
|
|
8
8
|
} from '../internal/field.js';
|
|
9
|
+
import { describeAccept } from '../internal/accept.js';
|
|
9
10
|
interface Props {
|
|
10
11
|
id?: string;
|
|
11
12
|
name?: string;
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
}: Props = $props();
|
|
34
35
|
|
|
35
36
|
let dragOver = $state(false);
|
|
37
|
+
const types = $derived(describeAccept(accept ?? ''));
|
|
36
38
|
|
|
37
39
|
function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
|
|
38
40
|
onchange?.(e.currentTarget.files);
|
|
@@ -93,7 +95,7 @@
|
|
|
93
95
|
<div class="text-sm">
|
|
94
96
|
<span class="{error ? 'text-danger' : 'text-brand'} font-medium">Click to upload</span>
|
|
95
97
|
<span class="text-muted"> or drag & drop</span>
|
|
96
|
-
{#if
|
|
98
|
+
{#if types}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{types}</p>{/if}
|
|
97
99
|
</div>
|
|
98
100
|
<input
|
|
99
101
|
type="file"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { RefreshCw } from '@lucide/svelte';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import Alert from './Alert.svelte';
|
|
5
|
+
import Button from './Button.svelte';
|
|
6
|
+
import type { Gate, GateWords } from '../internal/gate.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a page shows in place of what a refused or failed read would have
|
|
10
|
+
* filled, the same way on every page. The page passes the gate it mapped
|
|
11
|
+
* from the server's answer and every sentence in its own words. Only the
|
|
12
|
+
* states that waiting can change offer to try again.
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
gate: Gate;
|
|
16
|
+
words: GateWords;
|
|
17
|
+
/** Drawn for a feature that is not enabled, given the link forward the gate carried. */
|
|
18
|
+
locked: Snippet<[forwardHref: string]>;
|
|
19
|
+
/** The address that asks for this page again, usually its own path and query. */
|
|
20
|
+
retryHref: string;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let { gate, words, locked, retryHref, class: klass = '' }: Props = $props();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
{#snippet retry()}
|
|
28
|
+
<div class="mt-3">
|
|
29
|
+
<!-- A plain link works before the page hydrates. After it hydrates, a
|
|
30
|
+
SvelteKit router keeps the data it holds for an address that did not
|
|
31
|
+
change, so the link asks for a fresh document instead. Other hosts
|
|
32
|
+
ignore the attribute. -->
|
|
33
|
+
<Button variant="secondary" size="sm" href={retryHref} data-sveltekit-reload>
|
|
34
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
35
|
+
{words.retry}
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
{/snippet}
|
|
39
|
+
|
|
40
|
+
{#if gate.state !== 'ok'}
|
|
41
|
+
<div class={klass} data-testid="gate-notice" data-state={gate.state}>
|
|
42
|
+
{#if gate.state === 'absent'}
|
|
43
|
+
<Alert tone="brand">{words.absent}</Alert>
|
|
44
|
+
{:else if gate.state === 'locked'}
|
|
45
|
+
{@render locked(gate.forwardHref)}
|
|
46
|
+
{:else if gate.state === 'full'}
|
|
47
|
+
<Alert tone="warn" title={words.full.title}>{words.full.body}</Alert>
|
|
48
|
+
{:else if gate.state === 'forbidden'}
|
|
49
|
+
<!-- Trying again cannot change a role, so this one offers nothing to press. -->
|
|
50
|
+
<Alert tone="warn">{words.forbidden}</Alert>
|
|
51
|
+
{:else if gate.state === 'busy'}
|
|
52
|
+
<Alert tone="warn" title={words.busy.title}>
|
|
53
|
+
<p>{words.busy.body}</p>
|
|
54
|
+
{@render retry()}
|
|
55
|
+
</Alert>
|
|
56
|
+
{:else if gate.state === 'unavailable'}
|
|
57
|
+
<Alert tone="warn" title={words.unavailable.title}>
|
|
58
|
+
<p>{words.unavailable.body}</p>
|
|
59
|
+
{@render retry()}
|
|
60
|
+
</Alert>
|
|
61
|
+
{:else if gate.state === 'error'}
|
|
62
|
+
<Alert tone="danger">
|
|
63
|
+
<p>{gate.message}</p>
|
|
64
|
+
{@render retry()}
|
|
65
|
+
</Alert>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Gate, GateWords } from '../internal/gate.js';
|
|
3
|
+
/**
|
|
4
|
+
* What a page shows in place of what a refused or failed read would have
|
|
5
|
+
* filled, the same way on every page. The page passes the gate it mapped
|
|
6
|
+
* from the server's answer and every sentence in its own words. Only the
|
|
7
|
+
* states that waiting can change offer to try again.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
gate: Gate;
|
|
11
|
+
words: GateWords;
|
|
12
|
+
/** Drawn for a feature that is not enabled, given the link forward the gate carried. */
|
|
13
|
+
locked: Snippet<[forwardHref: string]>;
|
|
14
|
+
/** The address that asks for this page again, usually its own path and query. */
|
|
15
|
+
retryHref: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
declare const GateNotice: import("svelte").Component<Props, {}, "">;
|
|
19
|
+
type GateNotice = ReturnType<typeof GateNotice>;
|
|
20
|
+
export default GateNotice;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Alert from './Alert.svelte';
|
|
3
|
+
import Button from './Button.svelte';
|
|
4
|
+
import { isExternalHref } from '../internal/href.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A write the server refused, said the same way on every page: a title, the
|
|
8
|
+
* sentence that says why, the way forward when there is one, and a link that
|
|
9
|
+
* is always offered, such as the page where the setting is read. The page
|
|
10
|
+
* passes every word, so a lifted limit never reads as a stale one here.
|
|
11
|
+
*/
|
|
12
|
+
interface NoticeLink {
|
|
13
|
+
href: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
title: string;
|
|
19
|
+
body: string;
|
|
20
|
+
/** Where the reader gets past the refusal, when the refusal names a place. */
|
|
21
|
+
forward?: NoticeLink | null;
|
|
22
|
+
/** The link offered on every refusal. */
|
|
23
|
+
link: NoticeLink;
|
|
24
|
+
/**
|
|
25
|
+
* True where the way forward is the one action on its surface. A surface
|
|
26
|
+
* holds one primary, and the notice usually sits in a drawer or a page that
|
|
27
|
+
* has its own Create or Save, so by default its links stay below that: the
|
|
28
|
+
* way forward secondary and the other link ghost.
|
|
29
|
+
*/
|
|
30
|
+
lead?: boolean;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let { title, body, forward = null, link, lead = false, class: klass = '' }: Props = $props();
|
|
35
|
+
|
|
36
|
+
// A link to another origin opens in a tab of its own, so the page it opens
|
|
37
|
+
// cannot reach back into this one through window.opener.
|
|
38
|
+
const away = $derived(forward !== null && isExternalHref(forward.href));
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<div class={klass} data-testid="refusal-notice">
|
|
42
|
+
<Alert tone="warn" {title}>
|
|
43
|
+
<p>{body}</p>
|
|
44
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
45
|
+
{#if forward}
|
|
46
|
+
<Button
|
|
47
|
+
variant={lead ? 'primary' : 'secondary'}
|
|
48
|
+
size="sm"
|
|
49
|
+
href={forward.href}
|
|
50
|
+
target={away ? '_blank' : undefined}
|
|
51
|
+
rel={away ? 'noopener noreferrer' : undefined}
|
|
52
|
+
>
|
|
53
|
+
{forward.label}
|
|
54
|
+
</Button>
|
|
55
|
+
{/if}
|
|
56
|
+
<Button variant={forward && !lead ? 'ghost' : 'secondary'} size="sm" href={link.href}>
|
|
57
|
+
{link.label}
|
|
58
|
+
</Button>
|
|
59
|
+
</div>
|
|
60
|
+
</Alert>
|
|
61
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A write the server refused, said the same way on every page: a title, the
|
|
3
|
+
* sentence that says why, the way forward when there is one, and a link that
|
|
4
|
+
* is always offered, such as the page where the setting is read. The page
|
|
5
|
+
* passes every word, so a lifted limit never reads as a stale one here.
|
|
6
|
+
*/
|
|
7
|
+
interface NoticeLink {
|
|
8
|
+
href: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
interface Props {
|
|
12
|
+
title: string;
|
|
13
|
+
body: string;
|
|
14
|
+
/** Where the reader gets past the refusal, when the refusal names a place. */
|
|
15
|
+
forward?: NoticeLink | null;
|
|
16
|
+
/** The link offered on every refusal. */
|
|
17
|
+
link: NoticeLink;
|
|
18
|
+
/**
|
|
19
|
+
* True where the way forward is the one action on its surface. A surface
|
|
20
|
+
* holds one primary, and the notice usually sits in a drawer or a page that
|
|
21
|
+
* has its own Create or Save, so by default its links stay below that: the
|
|
22
|
+
* way forward secondary and the other link ghost.
|
|
23
|
+
*/
|
|
24
|
+
lead?: boolean;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const RefusalNotice: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type RefusalNotice = ReturnType<typeof RefusalNotice>;
|
|
29
|
+
export default RefusalNotice;
|
|
@@ -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.
|
|
110
|
+
export declare const VERSION = "0.41.1";
|
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.
|
|
122
|
+
export const VERSION = '0.41.1';
|
|
@@ -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 {};
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* The page's local storage, or nothing. Reading the global can itself throw
|
|
16
|
+
* when site data is blocked. Outside a browser it is never read: a server
|
|
17
|
+
* runtime may define a localStorage of its own, warning on first access or
|
|
18
|
+
* holding one store that every request would share.
|
|
19
|
+
*/
|
|
20
|
+
export function viewerStorage() {
|
|
21
|
+
if (typeof window === 'undefined')
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function clampWidth(bounds, width) {
|
|
31
|
+
return Math.round(Math.min(bounds.max, Math.max(bounds.min, width)));
|
|
32
|
+
}
|
|
33
|
+
export function readAside(storage, key, bounds) {
|
|
34
|
+
const fallback = { width: bounds.width, locked: false };
|
|
35
|
+
try {
|
|
36
|
+
const raw = storage?.getItem(key);
|
|
37
|
+
if (!raw)
|
|
38
|
+
return fallback;
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
41
|
+
return fallback;
|
|
42
|
+
const { width, locked } = parsed;
|
|
43
|
+
return {
|
|
44
|
+
width: typeof width === 'number' && Number.isFinite(width)
|
|
45
|
+
? clampWidth(bounds, width)
|
|
46
|
+
: fallback.width,
|
|
47
|
+
locked: locked === true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function writeAside(storage, key, bounds, prefs) {
|
|
55
|
+
try {
|
|
56
|
+
storage?.setItem(key, JSON.stringify({ width: clampWidth(bounds, prefs.width), locked: prefs.locked }));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Storage refused: the width lasts the page and nothing else.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a read a page needed did not fill it, as the page learned it from the
|
|
3
|
+
* server's answer. A page never decides how a feature is enabled, which role a
|
|
4
|
+
* route needs or whether the database is up. It maps the answer to one of
|
|
5
|
+
* these states and hands it to GateNotice with its own words.
|
|
6
|
+
*/
|
|
7
|
+
export type Gate = {
|
|
8
|
+
state: 'ok';
|
|
9
|
+
}
|
|
10
|
+
/** The route is not there. */
|
|
11
|
+
| {
|
|
12
|
+
state: 'absent';
|
|
13
|
+
}
|
|
14
|
+
/** Not enabled here. The link forward is where it is turned on, when the server names one. */
|
|
15
|
+
| {
|
|
16
|
+
state: 'locked';
|
|
17
|
+
forwardHref: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* At a capacity ceiling: as many are in use as the server allows, so it
|
|
21
|
+
* refuses one more. The notice draws the page's words for it and offers no
|
|
22
|
+
* link, since only the page knows a way past the ceiling. The numbers are
|
|
23
|
+
* there for the page to put into those words, and either is null when the
|
|
24
|
+
* server did not send it.
|
|
25
|
+
*/
|
|
26
|
+
| {
|
|
27
|
+
state: 'full';
|
|
28
|
+
limit: number | null;
|
|
29
|
+
current: number | null;
|
|
30
|
+
}
|
|
31
|
+
/** The reader's role may not read it, which no retry changes. */
|
|
32
|
+
| {
|
|
33
|
+
state: 'forbidden';
|
|
34
|
+
}
|
|
35
|
+
/** Refused for the request rate. The same read succeeds once the window passes. */
|
|
36
|
+
| {
|
|
37
|
+
state: 'busy';
|
|
38
|
+
}
|
|
39
|
+
/** The server or its database did not answer. The same read may answer in a moment. */
|
|
40
|
+
| {
|
|
41
|
+
state: 'unavailable';
|
|
42
|
+
}
|
|
43
|
+
/** The read failed. The message says so in words that never read as an empty result. */
|
|
44
|
+
| {
|
|
45
|
+
state: 'error';
|
|
46
|
+
message: string;
|
|
47
|
+
};
|
|
48
|
+
/** A heading and the sentence under it. */
|
|
49
|
+
export interface GateText {
|
|
50
|
+
title: string;
|
|
51
|
+
body: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every sentence a GateNotice can draw, in the page's own words. The kit holds
|
|
55
|
+
* none of them, so each console says a refusal its own way and in its own
|
|
56
|
+
* language.
|
|
57
|
+
*/
|
|
58
|
+
export interface GateWords {
|
|
59
|
+
/** What a missing route means for this page. */
|
|
60
|
+
absent: string;
|
|
61
|
+
/** A capacity ceiling, with the numbers the gate carried already in the body. */
|
|
62
|
+
full: GateText;
|
|
63
|
+
/** A role that may not read it, and who to ask. */
|
|
64
|
+
forbidden: string;
|
|
65
|
+
/** A refusal for the request rate. */
|
|
66
|
+
busy: GateText;
|
|
67
|
+
/** A server or database that did not answer. */
|
|
68
|
+
unavailable: GateText;
|
|
69
|
+
/** The label of the link that asks for the page again. */
|
|
70
|
+
retry: string;
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/internal/href.d.ts
CHANGED
|
@@ -11,3 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/** The href as given, or nothing when its scheme is one no component will emit. */
|
|
13
13
|
export declare function safeHref(href: string | undefined | null): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Whether an href leaves the page's own origin: an absolute URL with a scheme
|
|
16
|
+
* and a host, or a protocol-relative one, whose origin is not the page's. A
|
|
17
|
+
* link that leaves opens in a tab of its own, so the page it opens cannot
|
|
18
|
+
* reach back through window.opener. A server render has no location to
|
|
19
|
+
* compare with, so there every absolute URL counts as leaving.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isExternalHref(href: string): boolean;
|
package/dist/internal/href.js
CHANGED
|
@@ -25,3 +25,21 @@ export function safeHref(href) {
|
|
|
25
25
|
return undefined;
|
|
26
26
|
return DENIED.test(href.replace(IGNORED, '')) ? undefined : href;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether an href leaves the page's own origin: an absolute URL with a scheme
|
|
30
|
+
* and a host, or a protocol-relative one, whose origin is not the page's. A
|
|
31
|
+
* link that leaves opens in a tab of its own, so the page it opens cannot
|
|
32
|
+
* reach back through window.opener. A server render has no location to
|
|
33
|
+
* compare with, so there every absolute URL counts as leaving.
|
|
34
|
+
*/
|
|
35
|
+
export function isExternalHref(href) {
|
|
36
|
+
const absolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(href) || href.startsWith('//');
|
|
37
|
+
if (!absolute || typeof location === 'undefined')
|
|
38
|
+
return absolute;
|
|
39
|
+
try {
|
|
40
|
+
return new URL(href, location.href).origin !== location.origin;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyeve-labs/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "LyEve Labs <hello@lyeve.com>",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"lyeve"
|
|
23
23
|
],
|
|
24
24
|
"type": "module",
|
|
25
|
-
"sideEffects":
|
|
25
|
+
"sideEffects": [
|
|
26
|
+
"**/*.css"
|
|
27
|
+
],
|
|
26
28
|
"engines": {
|
|
27
29
|
"node": ">=24"
|
|
28
30
|
},
|
|
@@ -52,20 +54,23 @@
|
|
|
52
54
|
"access": "public"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
57
|
+
"@axe-core/playwright": "^4.10.1",
|
|
55
58
|
"@lucide/svelte": "^0.511.0",
|
|
59
|
+
"@playwright/experimental-ct-svelte": "1.58.2",
|
|
56
60
|
"@sveltejs/package": "^2.5.7",
|
|
57
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
61
|
+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
58
62
|
"@testing-library/svelte": "^5.3.1",
|
|
59
|
-
"@vitest/coverage-v8": "^
|
|
63
|
+
"@vitest/coverage-v8": "^4.1.12",
|
|
60
64
|
"jsdom": "^25.0.1",
|
|
61
65
|
"prettier": "^3.8.3",
|
|
62
66
|
"prettier-plugin-svelte": "^4.0.1",
|
|
63
67
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
64
68
|
"publint": "^0.2.12",
|
|
65
|
-
"svelte": "^5.
|
|
69
|
+
"svelte": "^5.57.2",
|
|
66
70
|
"svelte-check": "^4.4.8",
|
|
67
71
|
"typescript": "^5.9.3",
|
|
68
|
-
"
|
|
72
|
+
"vite": "^6.4.5",
|
|
73
|
+
"vitest": "^4.1.12"
|
|
69
74
|
},
|
|
70
75
|
"scripts": {
|
|
71
76
|
"build": "pnpm version:check && svelte-package && publint && node scripts/check-dist.mjs",
|