@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.
Files changed (112) hide show
  1. package/README.md +9 -9
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/AccountMenu.svelte +2 -2
  4. package/dist/components/AccountMenu.svelte.d.ts +1 -1
  5. package/dist/components/Alert.svelte +2 -2
  6. package/dist/components/Alert.svelte.d.ts +1 -1
  7. package/dist/components/AppShell.svelte +7 -7
  8. package/dist/components/AppShell.svelte.d.ts +2 -2
  9. package/dist/components/AuthShell.svelte +1 -1
  10. package/dist/components/AuthShell.svelte.d.ts +1 -1
  11. package/dist/components/Autocomplete.svelte +1 -1
  12. package/dist/components/Badge.svelte +2 -2
  13. package/dist/components/Banner.svelte +1 -1
  14. package/dist/components/Banner.svelte.d.ts +1 -1
  15. package/dist/components/Button.svelte +5 -5
  16. package/dist/components/Button.svelte.d.ts +1 -1
  17. package/dist/components/Card.svelte +2 -2
  18. package/dist/components/Checkbox.svelte +3 -3
  19. package/dist/components/CheckoutSteps.svelte +54 -0
  20. package/dist/components/CheckoutSteps.svelte.d.ts +12 -0
  21. package/dist/components/Collapsible.svelte +1 -1
  22. package/dist/components/Collapsible.svelte.d.ts +1 -1
  23. package/dist/components/CopyField.svelte +2 -2
  24. package/dist/components/Field.svelte +1 -1
  25. package/dist/components/Field.svelte.d.ts +1 -1
  26. package/dist/components/FileInput.svelte +1 -1
  27. package/dist/components/FormMessage.svelte +4 -4
  28. package/dist/components/Logo.svelte +1 -1
  29. package/dist/components/Logo.svelte.d.ts +1 -1
  30. package/dist/components/MultiSelect.svelte +1 -1
  31. package/dist/components/PageHeader.svelte +2 -2
  32. package/dist/components/PageShell.svelte +4 -4
  33. package/dist/components/PageShell.svelte.d.ts +4 -4
  34. package/dist/components/Pagination.svelte +2 -2
  35. package/dist/components/Panel.svelte +1 -1
  36. package/dist/components/Panel.svelte.d.ts +1 -1
  37. package/dist/components/PasswordInput.svelte +5 -2
  38. package/dist/components/PluginCart.svelte +339 -0
  39. package/dist/components/PluginCart.svelte.d.ts +45 -0
  40. package/dist/components/Radio.svelte +2 -2
  41. package/dist/components/SearchInput.svelte +5 -4
  42. package/dist/components/SegmentedControl.svelte +4 -4
  43. package/dist/components/SegmentedControl.svelte.d.ts +2 -2
  44. package/dist/components/Select.svelte +5 -5
  45. package/dist/components/Select.svelte.d.ts +4 -4
  46. package/dist/components/SidebarNav.svelte +5 -5
  47. package/dist/components/Stat.svelte +1 -1
  48. package/dist/components/Stat.svelte.d.ts +1 -1
  49. package/dist/components/Table.svelte +4 -4
  50. package/dist/components/Tabs.svelte +2 -2
  51. package/dist/components/ThemeToggle.svelte +1 -1
  52. package/dist/components/TimePicker.svelte +2 -2
  53. package/dist/components/Toggle.svelte +3 -3
  54. package/dist/components/Toolbar.svelte +1 -1
  55. package/dist/components/Toolbar.svelte.d.ts +1 -1
  56. package/dist/components/TreeView.svelte +1 -1
  57. package/dist/components/dialog/Dialog.svelte +1 -1
  58. package/dist/components/dialog/DialogContainer.svelte +1 -1
  59. package/dist/components/dialog/dialog-manager.svelte.d.ts +3 -3
  60. package/dist/components/dialog/dialog-manager.svelte.js +7 -7
  61. package/dist/components/dialog/types.d.ts +3 -3
  62. package/dist/index.d.ts +7 -3
  63. package/dist/index.js +8 -5
  64. package/dist/internal/calendar.d.ts +1 -1
  65. package/dist/internal/calendar.js +2 -2
  66. package/dist/internal/choice.d.ts +6 -6
  67. package/dist/internal/choice.js +6 -6
  68. package/dist/internal/copy.svelte.d.ts +2 -2
  69. package/dist/internal/copy.svelte.js +2 -2
  70. package/dist/internal/field.d.ts +11 -11
  71. package/dist/internal/field.js +12 -12
  72. package/dist/internal/filter.d.ts +3 -3
  73. package/dist/internal/filter.js +3 -3
  74. package/dist/internal/href.d.ts +1 -1
  75. package/dist/internal/href.js +1 -1
  76. package/dist/internal/layout.d.ts +7 -7
  77. package/dist/internal/layout.js +7 -7
  78. package/dist/internal/listbox.svelte.d.ts +1 -1
  79. package/dist/internal/listbox.svelte.js +3 -3
  80. package/dist/internal/nav-expansion.svelte.d.ts +1 -1
  81. package/dist/internal/nav-expansion.svelte.js +2 -2
  82. package/dist/internal/nav-tree.d.ts +3 -3
  83. package/dist/internal/nav-tree.js +3 -3
  84. package/dist/internal/number.d.ts +1 -1
  85. package/dist/internal/number.js +1 -1
  86. package/dist/internal/overlay.d.ts +3 -3
  87. package/dist/internal/overlay.js +3 -3
  88. package/dist/internal/panel.d.ts +5 -5
  89. package/dist/internal/panel.js +5 -5
  90. package/dist/internal/pricing.d.ts +110 -0
  91. package/dist/internal/pricing.js +77 -0
  92. package/dist/internal/rollup.d.ts +3 -3
  93. package/dist/internal/rollup.js +3 -3
  94. package/dist/internal/scroll-edges.d.ts +4 -4
  95. package/dist/internal/scroll-edges.js +5 -5
  96. package/dist/internal/time.d.ts +3 -3
  97. package/dist/internal/time.js +3 -3
  98. package/dist/internal/tone.d.ts +3 -3
  99. package/dist/internal/tone.js +3 -3
  100. package/dist/internal/touch.d.ts +1 -1
  101. package/dist/internal/touch.js +1 -1
  102. package/dist/internal/tree.d.ts +2 -2
  103. package/dist/internal/tree.js +2 -2
  104. package/dist/motion.d.ts +3 -3
  105. package/dist/motion.js +4 -4
  106. package/dist/stores/toast.svelte.d.ts +1 -1
  107. package/dist/styles/theme.css +45 -45
  108. package/dist/utils/cn.d.ts +1 -1
  109. package/dist/utils/theme.d.ts +6 -6
  110. package/dist/utils/theme.js +10 -10
  111. package/package.json +1 -1
  112. 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 centring and the section
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`. Centring and `w-full` come with the gutter here, in PAGE_PAD, so
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; the title row keeps both.
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; it is `sr-only`, not absent. A page
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 centring and the section
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`. Centring and `w-full` come with the gutter here, in PAGE_PAD, so
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; the title row keeps both.
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; it is `sr-only`, not absent. A page
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; the hit box
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; the class is only the
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 labelled group inside a page, and reaching for 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 labelled group inside a page, and reaching for 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
- {value}
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 - a synthetic event, a script driving the
103
- // node - would still move the bound value with nothing on screen to say so.
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
- value = e.currentTarget.value;
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
- {value}
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 centres it and lift it 6px off the middle
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; the modes cannot be mixed in one
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; the page it opens does. */
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 colour alone, so a screen reader
243
- user was told nothing and a colour-blind user saw nothing. The
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; the modes cannot be mixed in one
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; the page it opens does. */
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 neighbouring rows that
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; one call site submits its form from the change event's
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 behaviour. The open state, the active
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; see SelectChangeEvent. */
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 unlabelled field announced its value and never the
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();