@lyeve-labs/ui-kit 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/AccountMenu.svelte +2 -2
- package/dist/components/AccountMenu.svelte.d.ts +1 -1
- package/dist/components/Alert.svelte +2 -2
- package/dist/components/Alert.svelte.d.ts +1 -1
- package/dist/components/AppShell.svelte +7 -7
- package/dist/components/AppShell.svelte.d.ts +2 -2
- package/dist/components/AuthShell.svelte +1 -1
- package/dist/components/AuthShell.svelte.d.ts +1 -1
- package/dist/components/Autocomplete.svelte +1 -1
- package/dist/components/Badge.svelte +2 -2
- package/dist/components/Banner.svelte +1 -1
- package/dist/components/Banner.svelte.d.ts +1 -1
- package/dist/components/Button.svelte +5 -5
- package/dist/components/Button.svelte.d.ts +1 -1
- package/dist/components/Card.svelte +2 -2
- package/dist/components/Checkbox.svelte +3 -3
- package/dist/components/CheckoutSteps.svelte +54 -0
- package/dist/components/CheckoutSteps.svelte.d.ts +12 -0
- package/dist/components/Collapsible.svelte +1 -1
- package/dist/components/Collapsible.svelte.d.ts +1 -1
- package/dist/components/CopyField.svelte +2 -2
- package/dist/components/Field.svelte +1 -1
- package/dist/components/Field.svelte.d.ts +1 -1
- package/dist/components/FileInput.svelte +1 -1
- package/dist/components/FormMessage.svelte +4 -4
- package/dist/components/Logo.svelte +1 -1
- package/dist/components/Logo.svelte.d.ts +1 -1
- package/dist/components/MultiSelect.svelte +1 -1
- package/dist/components/PageHeader.svelte +2 -2
- package/dist/components/PageShell.svelte +4 -4
- package/dist/components/PageShell.svelte.d.ts +4 -4
- package/dist/components/Pagination.svelte +2 -2
- package/dist/components/Panel.svelte +1 -1
- package/dist/components/Panel.svelte.d.ts +1 -1
- package/dist/components/PasswordInput.svelte +5 -2
- package/dist/components/PluginCart.svelte +339 -0
- package/dist/components/PluginCart.svelte.d.ts +45 -0
- package/dist/components/Radio.svelte +2 -2
- package/dist/components/SearchInput.svelte +5 -4
- package/dist/components/SegmentedControl.svelte +4 -4
- package/dist/components/SegmentedControl.svelte.d.ts +2 -2
- package/dist/components/Select.svelte +5 -5
- package/dist/components/Select.svelte.d.ts +4 -4
- package/dist/components/SidebarNav.svelte +5 -5
- package/dist/components/Stat.svelte +1 -1
- package/dist/components/Stat.svelte.d.ts +1 -1
- package/dist/components/Table.svelte +4 -4
- package/dist/components/Tabs.svelte +2 -2
- package/dist/components/ThemeToggle.svelte +1 -1
- package/dist/components/TimePicker.svelte +2 -2
- package/dist/components/Toggle.svelte +3 -3
- package/dist/components/Toolbar.svelte +1 -1
- package/dist/components/Toolbar.svelte.d.ts +1 -1
- package/dist/components/TreeView.svelte +1 -1
- package/dist/components/dialog/Dialog.svelte +1 -1
- package/dist/components/dialog/DialogContainer.svelte +1 -1
- package/dist/components/dialog/dialog-manager.svelte.d.ts +3 -3
- package/dist/components/dialog/dialog-manager.svelte.js +7 -7
- package/dist/components/dialog/types.d.ts +3 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +8 -5
- package/dist/internal/calendar.d.ts +1 -1
- package/dist/internal/calendar.js +2 -2
- package/dist/internal/choice.d.ts +6 -6
- package/dist/internal/choice.js +6 -6
- package/dist/internal/copy.svelte.d.ts +2 -2
- package/dist/internal/copy.svelte.js +2 -2
- package/dist/internal/field.d.ts +11 -11
- package/dist/internal/field.js +12 -12
- package/dist/internal/filter.d.ts +3 -3
- package/dist/internal/filter.js +3 -3
- package/dist/internal/href.d.ts +1 -1
- package/dist/internal/href.js +1 -1
- package/dist/internal/layout.d.ts +7 -7
- package/dist/internal/layout.js +7 -7
- package/dist/internal/listbox.svelte.d.ts +1 -1
- package/dist/internal/listbox.svelte.js +3 -3
- package/dist/internal/nav-expansion.svelte.d.ts +1 -1
- package/dist/internal/nav-expansion.svelte.js +2 -2
- package/dist/internal/nav-tree.d.ts +3 -3
- package/dist/internal/nav-tree.js +3 -3
- package/dist/internal/number.d.ts +1 -1
- package/dist/internal/number.js +1 -1
- package/dist/internal/overlay.d.ts +3 -3
- package/dist/internal/overlay.js +3 -3
- package/dist/internal/panel.d.ts +5 -5
- package/dist/internal/panel.js +5 -5
- package/dist/internal/pricing.d.ts +110 -0
- package/dist/internal/pricing.js +77 -0
- package/dist/internal/rollup.d.ts +3 -3
- package/dist/internal/rollup.js +3 -3
- package/dist/internal/scroll-edges.d.ts +4 -4
- package/dist/internal/scroll-edges.js +5 -5
- package/dist/internal/time.d.ts +3 -3
- package/dist/internal/time.js +3 -3
- package/dist/internal/tone.d.ts +3 -3
- package/dist/internal/tone.js +3 -3
- package/dist/internal/touch.d.ts +1 -1
- package/dist/internal/touch.js +1 -1
- package/dist/internal/tree.d.ts +2 -2
- package/dist/internal/tree.js +2 -2
- package/dist/motion.d.ts +3 -3
- package/dist/motion.js +4 -4
- package/dist/stores/toast.svelte.d.ts +1 -1
- package/dist/styles/theme.css +45 -45
- package/dist/utils/cn.d.ts +1 -1
- package/dist/utils/theme.d.ts +6 -6
- package/dist/utils/theme.js +10 -10
- package/package.json +1 -1
- package/src/lib/styles/theme.css +45 -45
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* The page frame: the gutter, the content cap, the
|
|
3
|
+
* The page frame: the gutter, the content cap, the centering and the section
|
|
4
4
|
* rhythm, all owned by the shell so a page cannot pick its own.
|
|
5
5
|
*
|
|
6
6
|
* Nothing in the library owned the frame, so every page built one. Across one
|
|
7
7
|
* app 31 of 35 pages restate the gutter in four spellings, five content caps
|
|
8
8
|
* are in use with no rule for choosing between them, and one page renders
|
|
9
9
|
* against the left edge of the window because it set a cap and forgot
|
|
10
|
-
* `mx-auto`.
|
|
10
|
+
* `mx-auto`. Centering and `w-full` come with the gutter here, in PAGE_PAD, so
|
|
11
11
|
* there is no order of props that leaves them out.
|
|
12
12
|
*
|
|
13
13
|
* The shell renders no landmark of its own. An app shell already owns `main`,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
/**
|
|
31
31
|
* A full-height page that manages its own scrolling, for instance a split
|
|
32
32
|
* pane or a canvas. The content loses the gutter and the cap so the pane
|
|
33
|
-
* can reach the edges
|
|
33
|
+
* can reach the edges. The title row keeps both.
|
|
34
34
|
*/
|
|
35
35
|
fill?: boolean;
|
|
36
36
|
/**
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* spent two rows before its canvas, the title row and then its own toolbar
|
|
64
64
|
* carrying the same name, with the shell's gap between them. The page
|
|
65
65
|
* still has exactly one h1, so a heading query and a screen reader see
|
|
66
|
-
* what every other page gives them
|
|
66
|
+
* what every other page gives them. It is `sr-only`, not absent. A page
|
|
67
67
|
* that hides the row takes on the way back: `back` renders nothing here.
|
|
68
68
|
*/
|
|
69
69
|
titleHidden?: boolean;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The page frame: the gutter, the content cap, the
|
|
2
|
+
* The page frame: the gutter, the content cap, the centering and the section
|
|
3
3
|
* rhythm, all owned by the shell so a page cannot pick its own.
|
|
4
4
|
*
|
|
5
5
|
* Nothing in the library owned the frame, so every page built one. Across one
|
|
6
6
|
* app 31 of 35 pages restate the gutter in four spellings, five content caps
|
|
7
7
|
* are in use with no rule for choosing between them, and one page renders
|
|
8
8
|
* against the left edge of the window because it set a cap and forgot
|
|
9
|
-
* `mx-auto`.
|
|
9
|
+
* `mx-auto`. Centering and `w-full` come with the gutter here, in PAGE_PAD, so
|
|
10
10
|
* there is no order of props that leaves them out.
|
|
11
11
|
*
|
|
12
12
|
* The shell renders no landmark of its own. An app shell already owns `main`,
|
|
@@ -24,7 +24,7 @@ interface Props {
|
|
|
24
24
|
/**
|
|
25
25
|
* A full-height page that manages its own scrolling, for instance a split
|
|
26
26
|
* pane or a canvas. The content loses the gutter and the cap so the pane
|
|
27
|
-
* can reach the edges
|
|
27
|
+
* can reach the edges. The title row keeps both.
|
|
28
28
|
*/
|
|
29
29
|
fill?: boolean;
|
|
30
30
|
/**
|
|
@@ -60,7 +60,7 @@ interface Props {
|
|
|
60
60
|
* spent two rows before its canvas, the title row and then its own toolbar
|
|
61
61
|
* carrying the same name, with the shell's gap between them. The page
|
|
62
62
|
* still has exactly one h1, so a heading query and a screen reader see
|
|
63
|
-
* what every other page gives them
|
|
63
|
+
* what every other page gives them. It is `sr-only`, not absent. A page
|
|
64
64
|
* that hides the row takes on the way back: `back` renders nothing here.
|
|
65
65
|
*/
|
|
66
66
|
titleHidden?: boolean;
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
184
|
* 28px drawn, 44px under a finger. The steps sit 2px apart, so growing the
|
|
185
|
-
* visual would put a seven-page pager past a phone's width
|
|
185
|
+
* visual would put a seven-page pager past a phone's width. The hit box
|
|
186
186
|
* grows instead and the pager keeps its shape.
|
|
187
187
|
*/
|
|
188
188
|
const btnBase = `${HIT_AREA} inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors`;
|
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
*
|
|
209
209
|
* An anchor has no `disabled`, and `disabled:` never matches one, so the two
|
|
210
210
|
* states have to be painted rather than declared. Dropping the href is what
|
|
211
|
-
* takes it out of the tab order and stops activation
|
|
211
|
+
* takes it out of the tab order and stops activation. The class is only the
|
|
212
212
|
* part a reader can see, at the same 30% the disabled buttons use.
|
|
213
213
|
*/
|
|
214
214
|
const linkInert = 'pointer-events-none opacity-30';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* An audit counted 63 hand-rolled card surfaces across 31 files in nine
|
|
7
7
|
* paddings while Card itself was used zero times. Many of them did not want
|
|
8
|
-
* a Card. They wanted a
|
|
8
|
+
* a Card. They wanted a labeled group inside a page, and reaching for a
|
|
9
9
|
* card meant a box on a box, so each one was written out by hand and each
|
|
10
10
|
* one picked its own padding. Panel is that group: it takes Card's surface
|
|
11
11
|
* and Card's padding scale from the shared layout contract, so a panel and a
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* An audit counted 63 hand-rolled card surfaces across 31 files in nine
|
|
6
6
|
* paddings while Card itself was used zero times. Many of them did not want
|
|
7
|
-
* a Card. They wanted a
|
|
7
|
+
* a Card. They wanted a labeled group inside a page, and reaching for a
|
|
8
8
|
* card meant a box on a box, so each one was written out by hand and each
|
|
9
9
|
* one picked its own padding. Panel is that group: it takes Card's surface
|
|
10
10
|
* and Card's padding scale from the shared layout contract, so a panel and a
|
|
@@ -77,8 +77,11 @@
|
|
|
77
77
|
// A disabled field hands over nothing, including a look at what it holds.
|
|
78
78
|
const showToggle = $derived(revealable && !disabled);
|
|
79
79
|
|
|
80
|
+
// bind:value rather than a one-way value: on hydration it adopts what the
|
|
81
|
+
// field already holds, so a password typed or autofilled before the page
|
|
82
|
+
// took over is kept. A one-way value set the field back to empty, and the
|
|
83
|
+
// form then refused to submit with the password still shown as required.
|
|
80
84
|
function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
|
|
81
|
-
value = e.currentTarget.value;
|
|
82
85
|
oninput?.(e);
|
|
83
86
|
}
|
|
84
87
|
</script>
|
|
@@ -103,7 +106,7 @@
|
|
|
103
106
|
{disabled}
|
|
104
107
|
{readonly}
|
|
105
108
|
{autocomplete}
|
|
106
|
-
|
|
109
|
+
bind:value
|
|
107
110
|
oninput={handleInput}
|
|
108
111
|
{onchange}
|
|
109
112
|
aria-invalid={error ? 'true' : undefined}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The capability picker and its running summary, for every surface that sells.
|
|
3
|
+
|
|
4
|
+
It renders, it never prices. The total, the discount and the annual figure
|
|
5
|
+
all come in as a quote the server made, and `quoted` below refuses to show
|
|
6
|
+
one that no longer describes what is selected. A host that wants a fresh
|
|
7
|
+
price listens to `onchange` and fetches one: a form action where there is a
|
|
8
|
+
server beside the page, the public quote endpoint where there is not.
|
|
9
|
+
|
|
10
|
+
The checkboxes carry a name and a value, so a host that wraps this in a form
|
|
11
|
+
still posts the selection with no JavaScript running at all.
|
|
12
|
+
-->
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import type { Snippet } from 'svelte';
|
|
15
|
+
import { X } from '@lucide/svelte';
|
|
16
|
+
import Badge from './Badge.svelte';
|
|
17
|
+
import Button from './Button.svelte';
|
|
18
|
+
import Checkbox from './Checkbox.svelte';
|
|
19
|
+
import SearchInput from './SearchInput.svelte';
|
|
20
|
+
import SegmentedControl from './SegmentedControl.svelte';
|
|
21
|
+
import Spinner from './Spinner.svelte';
|
|
22
|
+
import { cn } from '../utils/cn.js';
|
|
23
|
+
import {
|
|
24
|
+
billedCount,
|
|
25
|
+
formatCents,
|
|
26
|
+
ladderState,
|
|
27
|
+
type BillingPeriod,
|
|
28
|
+
type CartPlugin,
|
|
29
|
+
type CartQuote,
|
|
30
|
+
type DiscountBracket,
|
|
31
|
+
} from '../internal/pricing.js';
|
|
32
|
+
|
|
33
|
+
/** One button that replaces the whole selection. */
|
|
34
|
+
export interface QuickPick {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
skus: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
plugins: CartPlugin[];
|
|
42
|
+
brackets?: DiscountBracket[];
|
|
43
|
+
/** What the reader has picked. Bindable, so the host can price it. */
|
|
44
|
+
selected?: Set<string>;
|
|
45
|
+
/** Picked before arriving, from another surface's cart. Seeds `selected`. */
|
|
46
|
+
initial?: string[];
|
|
47
|
+
/** Already paid for. Shown, never added twice. */
|
|
48
|
+
held?: string[];
|
|
49
|
+
/** The server's price for this selection, or null before one exists. */
|
|
50
|
+
quote?: CartQuote | null;
|
|
51
|
+
/** The period that quote was priced for. A monthly quote says nothing about a year. */
|
|
52
|
+
quotePeriod?: BillingPeriod | null;
|
|
53
|
+
period?: BillingPeriod;
|
|
54
|
+
/** True while a fresh price is in flight. */
|
|
55
|
+
quoting?: boolean;
|
|
56
|
+
/** The count the bill will carry, when the host knows better than the cart. */
|
|
57
|
+
billedSkus?: string[] | null;
|
|
58
|
+
currency?: string;
|
|
59
|
+
/** The form field each checkbox posts under, for the path with no JavaScript. */
|
|
60
|
+
fieldName?: string;
|
|
61
|
+
error?: string | null;
|
|
62
|
+
quickPicks?: QuickPick[];
|
|
63
|
+
/** Called with the new selection whenever it changes. */
|
|
64
|
+
onchange?: (skus: string[]) => void;
|
|
65
|
+
/** The host's call to action, under the total. */
|
|
66
|
+
actions?: Snippet<[{ skus: string[]; total: number | null }]>;
|
|
67
|
+
/** Anything the host wants between the total and the actions. */
|
|
68
|
+
notice?: Snippet;
|
|
69
|
+
class?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a reader brought from another surface, minus anything the catalog no
|
|
74
|
+
* longer sells or they already pay for.
|
|
75
|
+
*
|
|
76
|
+
* Called in the destructuring default rather than an effect, because an
|
|
77
|
+
* effect runs only in the browser. The server render is the whole page for a
|
|
78
|
+
* reader with no JavaScript, and seeding there showed an empty cart to
|
|
79
|
+
* somebody who had just filled one.
|
|
80
|
+
*/
|
|
81
|
+
function seed(all: CartPlugin[], owned: string[], brought: string[]): Set<string> {
|
|
82
|
+
const sells = new Set(all.map((p) => p.sku));
|
|
83
|
+
const has = new Set(owned);
|
|
84
|
+
return new Set(brought.filter((s) => sells.has(s) && !has.has(s)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let {
|
|
88
|
+
plugins,
|
|
89
|
+
brackets = [],
|
|
90
|
+
initial = [],
|
|
91
|
+
held = [],
|
|
92
|
+
selected = $bindable(seed(plugins, held, initial)),
|
|
93
|
+
quote = null,
|
|
94
|
+
quotePeriod = null,
|
|
95
|
+
period = 'monthly',
|
|
96
|
+
quoting = false,
|
|
97
|
+
billedSkus = null,
|
|
98
|
+
currency = 'USD',
|
|
99
|
+
fieldName = 'sku',
|
|
100
|
+
error = null,
|
|
101
|
+
quickPicks = [],
|
|
102
|
+
onchange,
|
|
103
|
+
actions,
|
|
104
|
+
notice,
|
|
105
|
+
class: klass = '',
|
|
106
|
+
}: Props = $props();
|
|
107
|
+
|
|
108
|
+
const heldSet = $derived(new Set(held));
|
|
109
|
+
const known = $derived(new Set(plugins.map((p) => p.sku)));
|
|
110
|
+
|
|
111
|
+
let query = $state('');
|
|
112
|
+
let category = $state('all');
|
|
113
|
+
|
|
114
|
+
const categories = $derived([
|
|
115
|
+
{ value: 'all', label: 'All' },
|
|
116
|
+
...[...new Set(plugins.map((p) => p.category).filter(Boolean) as string[])]
|
|
117
|
+
.sort()
|
|
118
|
+
.map((c) => ({ value: c, label: c })),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const sellable = $derived(plugins.filter((p) => p.status !== 'soon'));
|
|
122
|
+
|
|
123
|
+
const shown = $derived(
|
|
124
|
+
sellable.filter((p) => {
|
|
125
|
+
if (category !== 'all' && p.category !== category) return false;
|
|
126
|
+
if (!query) return true;
|
|
127
|
+
const q = query.toLowerCase();
|
|
128
|
+
return (
|
|
129
|
+
p.name.toLowerCase().includes(q) ||
|
|
130
|
+
(p.blurb ?? '').toLowerCase().includes(q) ||
|
|
131
|
+
p.sku.includes(q)
|
|
132
|
+
);
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const bySku = $derived(new Map(plugins.map((p) => [p.sku, p])));
|
|
137
|
+
const count = $derived(selected.size);
|
|
138
|
+
|
|
139
|
+
// A price counts only for the exact selection and period it was made for.
|
|
140
|
+
// Anything else is a number that used to be true, which is worse on a page
|
|
141
|
+
// about to charge someone than no number at all.
|
|
142
|
+
const quoted = $derived(
|
|
143
|
+
quote &&
|
|
144
|
+
(quotePeriod ?? 'monthly') === period &&
|
|
145
|
+
quote.count === count &&
|
|
146
|
+
quote.items.every((i) => selected.has(i.sku))
|
|
147
|
+
? quote
|
|
148
|
+
: null,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const total = $derived(
|
|
152
|
+
quoted ? (period === 'annual' ? (quoted.annual_total_cents ?? null) : quoted.total_cents) : null,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const ladder = $derived(
|
|
156
|
+
ladderState(
|
|
157
|
+
brackets,
|
|
158
|
+
billedCount({ selected, held, change: held.length > 0, pricedSkus: billedSkus }),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const perLabel = $derived(period === 'annual' ? '/yr' : '/mo');
|
|
163
|
+
|
|
164
|
+
function commit(next: Set<string>) {
|
|
165
|
+
selected = next;
|
|
166
|
+
onchange?.([...next]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toggle(sku: string) {
|
|
170
|
+
if (heldSet.has(sku)) return;
|
|
171
|
+
const next = new Set(selected);
|
|
172
|
+
if (next.has(sku)) next.delete(sku);
|
|
173
|
+
else next.add(sku);
|
|
174
|
+
commit(next);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function pick(skus: string[]) {
|
|
178
|
+
commit(new Set(skus.filter((s) => known.has(s) && !heldSet.has(s))));
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<div class={cn('grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]', klass)}>
|
|
183
|
+
<div class="min-w-0 space-y-4">
|
|
184
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
185
|
+
<div class="min-w-48 flex-1">
|
|
186
|
+
<SearchInput
|
|
187
|
+
label="Search capabilities"
|
|
188
|
+
placeholder="Search by name or what it does"
|
|
189
|
+
bind:value={query}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
{#if quickPicks.length > 0}
|
|
193
|
+
<div class="flex flex-wrap gap-1.5" role="group" aria-label="Quick picks">
|
|
194
|
+
{#each quickPicks as qp (qp.id)}
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
size="sm"
|
|
198
|
+
variant="ghost"
|
|
199
|
+
onclick={() => pick(qp.skus)}
|
|
200
|
+
disabled={qp.skus.length === 0}
|
|
201
|
+
>
|
|
202
|
+
{qp.label}
|
|
203
|
+
</Button>
|
|
204
|
+
{/each}
|
|
205
|
+
</div>
|
|
206
|
+
{/if}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{#if categories.length > 2}
|
|
210
|
+
<div class="-mx-1 overflow-x-auto px-1">
|
|
211
|
+
<SegmentedControl
|
|
212
|
+
options={categories}
|
|
213
|
+
bind:value={category}
|
|
214
|
+
label="Category"
|
|
215
|
+
labelHidden
|
|
216
|
+
size="sm"
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
{/if}
|
|
220
|
+
|
|
221
|
+
{#if shown.length === 0}
|
|
222
|
+
<p class="rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted">
|
|
223
|
+
Nothing matches that search.
|
|
224
|
+
</p>
|
|
225
|
+
{:else}
|
|
226
|
+
<div class="grid gap-3 sm:grid-cols-2 2xl:grid-cols-3">
|
|
227
|
+
{#each shown as plugin (plugin.sku)}
|
|
228
|
+
{@const owned = heldSet.has(plugin.sku)}
|
|
229
|
+
<Checkbox
|
|
230
|
+
variant="card"
|
|
231
|
+
name={fieldName}
|
|
232
|
+
value={plugin.sku}
|
|
233
|
+
label={plugin.name}
|
|
234
|
+
description={owned
|
|
235
|
+
? 'Already on your subscription'
|
|
236
|
+
: `${formatCents(plugin.price_cents, currency)}/mo${plugin.category ? `, ${plugin.category}` : ''}. ${plugin.blurb ?? ''}`}
|
|
237
|
+
checked={selected.has(plugin.sku) || owned}
|
|
238
|
+
disabled={owned}
|
|
239
|
+
onchange={() => toggle(plugin.sku)}
|
|
240
|
+
/>
|
|
241
|
+
{/each}
|
|
242
|
+
</div>
|
|
243
|
+
{/if}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<aside
|
|
247
|
+
class="self-start rounded-xl border border-line bg-surface p-5 lg:sticky lg:top-4"
|
|
248
|
+
aria-label="Your selection"
|
|
249
|
+
>
|
|
250
|
+
<div class="mb-4 flex items-center justify-between gap-2">
|
|
251
|
+
<p class="text-xs font-semibold uppercase tracking-widest text-faint">Your selection</p>
|
|
252
|
+
{#if count > 0}
|
|
253
|
+
<Badge tone="brand" size="sm">
|
|
254
|
+
{count}
|
|
255
|
+
{count === 1 ? 'capability' : 'capabilities'}
|
|
256
|
+
</Badge>
|
|
257
|
+
{/if}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{#if error}
|
|
261
|
+
<p class="mb-3 rounded-lg border border-danger/30 bg-danger/10 p-3 text-xs text-danger">
|
|
262
|
+
{error}
|
|
263
|
+
</p>
|
|
264
|
+
{/if}
|
|
265
|
+
|
|
266
|
+
{#if count === 0}
|
|
267
|
+
<p class="py-6 text-center text-sm text-muted">
|
|
268
|
+
Nothing picked yet. With none of these you are on the free tier, which needs no checkout.
|
|
269
|
+
</p>
|
|
270
|
+
{:else}
|
|
271
|
+
<ul class="mb-4 max-h-56 space-y-1 overflow-y-auto pe-1">
|
|
272
|
+
{#each [...selected] as sku (sku)}
|
|
273
|
+
{@const p = bySku.get(sku)}
|
|
274
|
+
<li class="flex items-center justify-between gap-2 text-xs">
|
|
275
|
+
<span class="truncate text-fg">{p?.name ?? sku}</span>
|
|
276
|
+
<span class="flex items-center gap-1">
|
|
277
|
+
<span class="font-mono text-muted">{formatCents(p?.price_cents ?? 0, currency)}</span>
|
|
278
|
+
<Button
|
|
279
|
+
type="button"
|
|
280
|
+
variant="ghost"
|
|
281
|
+
size="sm"
|
|
282
|
+
onclick={() => toggle(sku)}
|
|
283
|
+
aria-label={`Take ${p?.name ?? sku} out of your selection`}
|
|
284
|
+
>
|
|
285
|
+
<X size={12} />
|
|
286
|
+
</Button>
|
|
287
|
+
</span>
|
|
288
|
+
</li>
|
|
289
|
+
{/each}
|
|
290
|
+
</ul>
|
|
291
|
+
|
|
292
|
+
{#if ladder.reachedPct > 0}
|
|
293
|
+
<p class="mb-2 text-xs text-success">
|
|
294
|
+
Volume discount {ladder.reachedPct}% applied.
|
|
295
|
+
</p>
|
|
296
|
+
{/if}
|
|
297
|
+
{#if ladder.next}
|
|
298
|
+
<p class="mb-3 text-xs text-muted">
|
|
299
|
+
{ladder.next.more} more and the discount is {ladder.next.discount_pct}%.
|
|
300
|
+
</p>
|
|
301
|
+
{/if}
|
|
302
|
+
|
|
303
|
+
<div class="mb-4 border-t border-line pt-3">
|
|
304
|
+
{#if total === null}
|
|
305
|
+
<p class="flex items-center gap-2 text-sm text-muted">
|
|
306
|
+
{#if quoting}
|
|
307
|
+
<Spinner size={14} />
|
|
308
|
+
Pricing your selection
|
|
309
|
+
{:else}
|
|
310
|
+
Waiting for a price.
|
|
311
|
+
{/if}
|
|
312
|
+
</p>
|
|
313
|
+
{:else}
|
|
314
|
+
<p class="flex items-baseline justify-between gap-2">
|
|
315
|
+
<span class="text-sm text-muted">Total</span>
|
|
316
|
+
<span class="font-mono text-xl font-semibold text-fg">
|
|
317
|
+
{formatCents(total, currency)}<span class="text-sm font-normal text-muted"
|
|
318
|
+
>{perLabel}</span
|
|
319
|
+
>
|
|
320
|
+
</span>
|
|
321
|
+
</p>
|
|
322
|
+
{#if quoted && quoted.volume_discount_cents > 0}
|
|
323
|
+
<p class="mt-1 text-end text-xs text-muted">
|
|
324
|
+
{formatCents(quoted.volume_discount_cents, currency)} off the list price.
|
|
325
|
+
</p>
|
|
326
|
+
{/if}
|
|
327
|
+
{#if quoted && quoted.community_discount_cents > 0}
|
|
328
|
+
<p class="mt-1 text-end text-xs text-success">
|
|
329
|
+
Community discount {quoted.community_discount_pct}% applied.
|
|
330
|
+
</p>
|
|
331
|
+
{/if}
|
|
332
|
+
{/if}
|
|
333
|
+
</div>
|
|
334
|
+
{/if}
|
|
335
|
+
|
|
336
|
+
{#if notice}{@render notice()}{/if}
|
|
337
|
+
{#if actions}{@render actions({ skus: [...selected], total })}{/if}
|
|
338
|
+
</aside>
|
|
339
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type BillingPeriod, type CartPlugin, type CartQuote, type DiscountBracket } from '../internal/pricing.js';
|
|
3
|
+
/** One button that replaces the whole selection. */
|
|
4
|
+
export interface QuickPick {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
skus: string[];
|
|
8
|
+
}
|
|
9
|
+
interface Props {
|
|
10
|
+
plugins: CartPlugin[];
|
|
11
|
+
brackets?: DiscountBracket[];
|
|
12
|
+
/** What the reader has picked. Bindable, so the host can price it. */
|
|
13
|
+
selected?: Set<string>;
|
|
14
|
+
/** Picked before arriving, from another surface's cart. Seeds `selected`. */
|
|
15
|
+
initial?: string[];
|
|
16
|
+
/** Already paid for. Shown, never added twice. */
|
|
17
|
+
held?: string[];
|
|
18
|
+
/** The server's price for this selection, or null before one exists. */
|
|
19
|
+
quote?: CartQuote | null;
|
|
20
|
+
/** The period that quote was priced for. A monthly quote says nothing about a year. */
|
|
21
|
+
quotePeriod?: BillingPeriod | null;
|
|
22
|
+
period?: BillingPeriod;
|
|
23
|
+
/** True while a fresh price is in flight. */
|
|
24
|
+
quoting?: boolean;
|
|
25
|
+
/** The count the bill will carry, when the host knows better than the cart. */
|
|
26
|
+
billedSkus?: string[] | null;
|
|
27
|
+
currency?: string;
|
|
28
|
+
/** The form field each checkbox posts under, for the path with no JavaScript. */
|
|
29
|
+
fieldName?: string;
|
|
30
|
+
error?: string | null;
|
|
31
|
+
quickPicks?: QuickPick[];
|
|
32
|
+
/** Called with the new selection whenever it changes. */
|
|
33
|
+
onchange?: (skus: string[]) => void;
|
|
34
|
+
/** The host's call to action, under the total. */
|
|
35
|
+
actions?: Snippet<[{
|
|
36
|
+
skus: string[];
|
|
37
|
+
total: number | null;
|
|
38
|
+
}]>;
|
|
39
|
+
/** Anything the host wants between the total and the actions. */
|
|
40
|
+
notice?: Snippet;
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
declare const PluginCart: import("svelte").Component<Props, {}, "selected">;
|
|
44
|
+
type PluginCart = ReturnType<typeof PluginCart>;
|
|
45
|
+
export default PluginCart;
|
|
@@ -99,8 +99,8 @@
|
|
|
99
99
|
function handleChange() {
|
|
100
100
|
// Disabled is enforced here as well as on the input. The native attribute
|
|
101
101
|
// is what stops a real click, and it is the only thing that does, so a
|
|
102
|
-
// change arriving any other way
|
|
103
|
-
// node
|
|
102
|
+
// change arriving any other way (a synthetic event, a script driving the
|
|
103
|
+
// node) would still move the bound value with nothing on screen to say so.
|
|
104
104
|
if (disabled) return;
|
|
105
105
|
group = value;
|
|
106
106
|
onchange?.(value);
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
|
|
27
27
|
const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
|
|
28
28
|
|
|
29
|
+
// bind:value keeps what was typed before hydration. A one-way value
|
|
30
|
+
// cleared it. The binding has updated value by the time this runs.
|
|
29
31
|
function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
|
|
30
|
-
|
|
31
|
-
oninput?.(value);
|
|
32
|
+
oninput?.(e.currentTarget.value);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function clear() {
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
{name}
|
|
65
66
|
{placeholder}
|
|
66
67
|
{disabled}
|
|
67
|
-
|
|
68
|
+
bind:value
|
|
68
69
|
oninput={handleInput}
|
|
69
70
|
class="{CONTROL_BASE} {controlBorder(false)} ps-9
|
|
70
71
|
[&::-webkit-search-cancel-button]:appearance-none
|
|
@@ -78,7 +79,7 @@
|
|
|
78
79
|
sits inside the field. The padding is taken back off the horizontal axis
|
|
79
80
|
only: the box is positioned from its right edge, so a negative right
|
|
80
81
|
margin holds the glyph exactly where it was, while a negative top margin
|
|
81
|
-
would fight the translate that
|
|
82
|
+
would fight the translate that centers it and lift it 6px off the middle
|
|
82
83
|
of the field.
|
|
83
84
|
-->
|
|
84
85
|
<button
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
icon?: Component<{ size?: number; class?: string }>;
|
|
12
12
|
/**
|
|
13
13
|
* Renders the segment as a link to this URL instead of a radio. Every
|
|
14
|
-
* option carries one or none does
|
|
14
|
+
* option carries one or none does. The modes cannot be mixed in one
|
|
15
15
|
* control. The chosen segment is still the one whose value matches
|
|
16
16
|
* `value`, and it carries aria-current="page", so a page can keep its
|
|
17
17
|
* choice in the URL and the control works before any script has loaded.
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
size?: Size;
|
|
44
44
|
disabled?: boolean;
|
|
45
45
|
class?: string;
|
|
46
|
-
/** Value mode only. A link segment reports nothing
|
|
46
|
+
/** Value mode only. A link segment reports nothing. The page it opens does. */
|
|
47
47
|
onchange?: (value: T) => void;
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -239,8 +239,8 @@
|
|
|
239
239
|
<!--
|
|
240
240
|
Each segment is a radio, and aria-checked is what a screen reader
|
|
241
241
|
reads. The theme picker this replaces was a row of plain buttons whose
|
|
242
|
-
choice was carried by a background
|
|
243
|
-
user was told nothing and a
|
|
242
|
+
choice was carried by a background color alone, so a screen reader
|
|
243
|
+
user was told nothing and a color-blind user saw nothing. The
|
|
244
244
|
selected segment also sits at a heavier weight, so the state survives
|
|
245
245
|
a palette a reader cannot separate.
|
|
246
246
|
-->
|
|
@@ -12,7 +12,7 @@ export interface SegmentOption<T extends string = string> {
|
|
|
12
12
|
}>;
|
|
13
13
|
/**
|
|
14
14
|
* Renders the segment as a link to this URL instead of a radio. Every
|
|
15
|
-
* option carries one or none does
|
|
15
|
+
* option carries one or none does. The modes cannot be mixed in one
|
|
16
16
|
* control. The chosen segment is still the one whose value matches
|
|
17
17
|
* `value`, and it carries aria-current="page", so a page can keep its
|
|
18
18
|
* choice in the URL and the control works before any script has loaded.
|
|
@@ -37,7 +37,7 @@ declare function $$render<T extends string>(): {
|
|
|
37
37
|
size?: "sm" | "md";
|
|
38
38
|
disabled?: boolean;
|
|
39
39
|
class?: string;
|
|
40
|
-
/** Value mode only. A link segment reports nothing
|
|
40
|
+
/** Value mode only. A link segment reports nothing. The page it opens does. */
|
|
41
41
|
onchange?: (value: T) => void;
|
|
42
42
|
};
|
|
43
43
|
exports: {};
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
keywords?: string[];
|
|
37
37
|
/**
|
|
38
|
-
* Groups rows under a heading. A group is a run of
|
|
38
|
+
* Groups rows under a heading. A group is a run of neighboring rows that
|
|
39
39
|
* name it, in both modes, because optgroup nests and cannot describe an
|
|
40
40
|
* interleaved list either. A caller who interleaves two groups gets the
|
|
41
41
|
* heading twice, which is what the array says.
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
*
|
|
53
53
|
* The native element is the default and stays the default. Most call sites
|
|
54
54
|
* sit inside a form and pass `name`, and a custom listbox is a button, which
|
|
55
|
-
* serializes nothing
|
|
55
|
+
* serializes nothing. One call site submits its form from the change event's
|
|
56
56
|
* `currentTarget.form`, which only a form-associated element carries. So the
|
|
57
57
|
* mode is never inferred, not from `options` and not from `searchable`: a
|
|
58
58
|
* page inside a form opts in to the listbox deliberately or keeps a real
|
|
59
59
|
* select.
|
|
60
60
|
*
|
|
61
|
-
* Listbox mode owns nothing of its own
|
|
61
|
+
* Listbox mode owns nothing of its own behavior. The open state, the active
|
|
62
62
|
* row, the keyboard model and the dismissal come from internal/listbox, the
|
|
63
63
|
* matching from internal/filter and every class in the panel from
|
|
64
64
|
* internal/panel, so this control cannot drift away from the other lists in
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
disabled?: boolean;
|
|
98
98
|
error?: string;
|
|
99
99
|
class?: string;
|
|
100
|
-
/** Native mode only. Its signature is frozen
|
|
100
|
+
/** Native mode only. Its signature is frozen. See SelectChangeEvent. */
|
|
101
101
|
onchange?: (e: SelectChangeEvent) => void;
|
|
102
102
|
/** Option elements, written by hand. Native mode only. */
|
|
103
103
|
children?: Snippet;
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
* The label-derived id stays for a control that has a label, because call
|
|
153
153
|
* sites point their own markup at it. The fallback used to be undefined,
|
|
154
154
|
* which left an error message with no id and the control with no
|
|
155
|
-
* aria-describedby, so an
|
|
155
|
+
* aria-describedby, so an unlabeled field announced its value and never the
|
|
156
156
|
* reason it was rejected.
|
|
157
157
|
*/
|
|
158
158
|
const uid = $props.id();
|