@marianmeres/stuic 3.8.1 → 3.9.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/AGENTS.md +13 -0
- package/dist/components/Cart/Cart.svelte +513 -0
- package/dist/components/Cart/Cart.svelte.d.ts +97 -0
- package/dist/components/Cart/README.md +185 -0
- package/dist/components/Cart/index.css +409 -0
- package/dist/components/Cart/index.d.ts +1 -0
- package/dist/components/Cart/index.js +1 -0
- package/dist/components/DataTable/DataTable.svelte +5 -1
- package/dist/components/DataTable/index.css +2 -1
- package/dist/components/DropdownMenu/DropdownMenu.svelte +9 -3
- package/dist/components/ModalDialog/ModalDialog.svelte +9 -2
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +3 -3
package/AGENTS.md
CHANGED
|
@@ -94,3 +94,16 @@ src/lib/
|
|
|
94
94
|
| `src/lib/themes/css/stone.css` | Default theme (generated) |
|
|
95
95
|
| `src/lib/components/Button/` | Reference component |
|
|
96
96
|
| `scripts/generate-theme.ts` | CLI: `pnpm run build:theme:all` |
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Svelte MCP Server
|
|
101
|
+
|
|
102
|
+
You have access to the Svelte MCP server providing comprehensive Svelte 5 documentation.
|
|
103
|
+
This is a **component library**, not a SvelteKit application — skip SvelteKit-specific guidance (routing, load functions, hooks, adapters, etc.).
|
|
104
|
+
|
|
105
|
+
### Available Tools
|
|
106
|
+
1. **list-sections**: Call FIRST to discover available documentation sections.
|
|
107
|
+
2. **get-documentation**: Fetch full docs for specific sections (runes, lifecycle, snippets, etc.).
|
|
108
|
+
3. **svelte-autofixer**: Validate Svelte code for correctness — always run before finalizing component changes.
|
|
109
|
+
4. **playground-link**: Generate a Svelte Playground link — only after user confirms they want one.
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import type { TranslateFn } from "../../types.js";
|
|
5
|
+
import { isPlainObject } from "../../utils/is-plain-object.js";
|
|
6
|
+
import { replaceMap } from "../../utils/replace-map.js";
|
|
7
|
+
|
|
8
|
+
// i18n ready
|
|
9
|
+
function t_default(
|
|
10
|
+
k: string,
|
|
11
|
+
values: false | null | undefined | Record<string, string | number> = null,
|
|
12
|
+
fallback: string | boolean = "",
|
|
13
|
+
_i18nSpanWrap: boolean = true
|
|
14
|
+
) {
|
|
15
|
+
const m: Record<string, string> = {
|
|
16
|
+
empty_cart: "Your cart is empty",
|
|
17
|
+
unit_price_each: "{price} each",
|
|
18
|
+
quantity_label: "Qty: {quantity}",
|
|
19
|
+
remove_item: "Remove",
|
|
20
|
+
total_label: "Total",
|
|
21
|
+
item_count_1: "1 item",
|
|
22
|
+
item_count_n: "{count} items",
|
|
23
|
+
decrease_quantity: "Decrease quantity",
|
|
24
|
+
increase_quantity: "Increase quantity",
|
|
25
|
+
};
|
|
26
|
+
let out = m[k] ?? fallback ?? k;
|
|
27
|
+
return isPlainObject(values)
|
|
28
|
+
? replaceMap(out, values as any, {
|
|
29
|
+
preSearchKeyTransform: (k) => `{${k}}`,
|
|
30
|
+
})
|
|
31
|
+
: out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A single item in the cart */
|
|
35
|
+
export interface CartComponentItem {
|
|
36
|
+
/** Unique item identifier */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Product name (displayed, used as link text) */
|
|
39
|
+
name: string;
|
|
40
|
+
/** Link to product page */
|
|
41
|
+
href?: string;
|
|
42
|
+
/** Short description (shown below name if provided) */
|
|
43
|
+
description?: string;
|
|
44
|
+
/** Image URL for product thumbnail */
|
|
45
|
+
thumbnailSrc?: string;
|
|
46
|
+
/** Image alt text (defaults to name) */
|
|
47
|
+
thumbnailAlt?: string;
|
|
48
|
+
/** Price per unit (cents integer recommended) */
|
|
49
|
+
unitPrice: number;
|
|
50
|
+
/** Current quantity */
|
|
51
|
+
quantity: number;
|
|
52
|
+
/** Pre-computed total for this line */
|
|
53
|
+
lineTotal: number;
|
|
54
|
+
/** Unit label: "pcs", "kg", etc. */
|
|
55
|
+
unit?: string;
|
|
56
|
+
/** Increment/decrement step (default 1) */
|
|
57
|
+
quantityStep?: number;
|
|
58
|
+
/** Floor for decrement (default 0) */
|
|
59
|
+
minQuantity?: number;
|
|
60
|
+
/** Ceiling for increment */
|
|
61
|
+
maxQuantity?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Layout variant */
|
|
65
|
+
export type CartVariant = "default" | "compact";
|
|
66
|
+
|
|
67
|
+
export interface Props
|
|
68
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
69
|
+
/** Cart items to display */
|
|
70
|
+
items: CartComponentItem[];
|
|
71
|
+
|
|
72
|
+
/** Layout variant. "compact" = smaller thumbnails, tighter spacing, scrollable, implicitly readonly */
|
|
73
|
+
variant?: CartVariant;
|
|
74
|
+
|
|
75
|
+
/** Format a numeric price for display. Default: (v) => (v / 100).toFixed(2) */
|
|
76
|
+
formatPrice?: (value: number) => string;
|
|
77
|
+
|
|
78
|
+
/** Called when quantity changes (not called in readonly/compact mode) */
|
|
79
|
+
onQuantityChange?: (id: string, newQuantity: number) => void;
|
|
80
|
+
/** Called when remove is clicked (not called in readonly/compact mode) */
|
|
81
|
+
onRemove?: (id: string) => void;
|
|
82
|
+
|
|
83
|
+
/** Hide all thumbnails */
|
|
84
|
+
noThumbnails?: boolean;
|
|
85
|
+
/** Hide all interactive controls — used for checkout summary */
|
|
86
|
+
readonly?: boolean;
|
|
87
|
+
/** Show loading skeleton instead of content */
|
|
88
|
+
loading?: boolean;
|
|
89
|
+
/** Set of item IDs currently being updated (shown with reduced opacity) */
|
|
90
|
+
updatingItems?: Set<string>;
|
|
91
|
+
|
|
92
|
+
/** Override thumbnail rendering */
|
|
93
|
+
thumbnail?: Snippet<[{ item: CartComponentItem }]>;
|
|
94
|
+
/** Override entire item row rendering */
|
|
95
|
+
itemRow?: Snippet<
|
|
96
|
+
[{
|
|
97
|
+
item: CartComponentItem;
|
|
98
|
+
isUpdating: boolean;
|
|
99
|
+
readonly: boolean;
|
|
100
|
+
formatPrice: (v: number) => string;
|
|
101
|
+
}]
|
|
102
|
+
>;
|
|
103
|
+
/** Override/extend summary section */
|
|
104
|
+
summary?: Snippet<
|
|
105
|
+
[{
|
|
106
|
+
items: CartComponentItem[];
|
|
107
|
+
total: number;
|
|
108
|
+
itemCount: number;
|
|
109
|
+
formatPrice: (v: number) => string;
|
|
110
|
+
}]
|
|
111
|
+
>;
|
|
112
|
+
/** Custom empty state */
|
|
113
|
+
empty?: Snippet;
|
|
114
|
+
/** Content after the summary (e.g., CTA buttons) */
|
|
115
|
+
footer?: Snippet<
|
|
116
|
+
[{
|
|
117
|
+
items: CartComponentItem[];
|
|
118
|
+
total: number;
|
|
119
|
+
itemCount: number;
|
|
120
|
+
}]
|
|
121
|
+
>;
|
|
122
|
+
|
|
123
|
+
/** Optional translate function */
|
|
124
|
+
t?: TranslateFn;
|
|
125
|
+
|
|
126
|
+
/** Skip all default styling */
|
|
127
|
+
unstyled?: boolean;
|
|
128
|
+
/** Additional CSS classes for the root container */
|
|
129
|
+
class?: string;
|
|
130
|
+
/** Bindable element reference */
|
|
131
|
+
el?: HTMLDivElement;
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<script lang="ts">
|
|
136
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
137
|
+
import { Breakpoint } from "../../utils/breakpoint.svelte.js";
|
|
138
|
+
import Skeleton from "../Skeleton/Skeleton.svelte";
|
|
139
|
+
|
|
140
|
+
let {
|
|
141
|
+
items,
|
|
142
|
+
variant = "default",
|
|
143
|
+
formatPrice = (v: number) => (v / 100).toFixed(2),
|
|
144
|
+
onQuantityChange,
|
|
145
|
+
onRemove,
|
|
146
|
+
noThumbnails = false,
|
|
147
|
+
readonly: readonlyProp = false,
|
|
148
|
+
loading = false,
|
|
149
|
+
updatingItems = new Set<string>(),
|
|
150
|
+
thumbnail,
|
|
151
|
+
itemRow,
|
|
152
|
+
summary,
|
|
153
|
+
empty,
|
|
154
|
+
footer,
|
|
155
|
+
t = t_default,
|
|
156
|
+
unstyled = false,
|
|
157
|
+
class: classProp,
|
|
158
|
+
el = $bindable(),
|
|
159
|
+
...rest
|
|
160
|
+
}: Props = $props();
|
|
161
|
+
|
|
162
|
+
// --- Responsive ---
|
|
163
|
+
const bp = Breakpoint.instance;
|
|
164
|
+
let isDesktop = $derived(bp.md);
|
|
165
|
+
let isCompact = $derived(variant === "compact");
|
|
166
|
+
let isReadonly = $derived(readonlyProp || isCompact);
|
|
167
|
+
|
|
168
|
+
// --- Derived ---
|
|
169
|
+
let total = $derived(items.reduce((sum, i) => sum + i.lineTotal, 0));
|
|
170
|
+
let itemCount = $derived(items.reduce((sum, i) => sum + i.quantity, 0));
|
|
171
|
+
|
|
172
|
+
// --- Inline editing ---
|
|
173
|
+
let editingItemId = $state<string | null>(null);
|
|
174
|
+
|
|
175
|
+
function handleQuantityInputCommit(
|
|
176
|
+
itemId: string,
|
|
177
|
+
currentQty: number,
|
|
178
|
+
inputValue: string
|
|
179
|
+
) {
|
|
180
|
+
editingItemId = null;
|
|
181
|
+
const newQty = parseInt(inputValue, 10);
|
|
182
|
+
if (!isNaN(newQty) && newQty !== currentQty && newQty >= 0) {
|
|
183
|
+
onQuantityChange?.(itemId, newQty);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function decrementQuantity(item: CartComponentItem) {
|
|
188
|
+
const step = item.quantityStep ?? 1;
|
|
189
|
+
const min = item.minQuantity ?? 0;
|
|
190
|
+
const newQty = Math.max(min, item.quantity - step);
|
|
191
|
+
if (newQty !== item.quantity) {
|
|
192
|
+
onQuantityChange?.(item.id, newQty);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function incrementQuantity(item: CartComponentItem) {
|
|
197
|
+
const step = item.quantityStep ?? 1;
|
|
198
|
+
const newQty = item.quantity + step;
|
|
199
|
+
if (item.maxQuantity != null && newQty > item.maxQuantity) return;
|
|
200
|
+
onQuantityChange?.(item.id, newQty);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- Auto-focus action (avoids a11y autofocus warning) ---
|
|
204
|
+
function autoFocusAndSelect(node: HTMLInputElement) {
|
|
205
|
+
node.focus();
|
|
206
|
+
node.select();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- CSS ---
|
|
210
|
+
let rootClass = $derived(
|
|
211
|
+
unstyled ? classProp : twMerge("stuic-cart", classProp)
|
|
212
|
+
);
|
|
213
|
+
</script>
|
|
214
|
+
|
|
215
|
+
<!-- Root container -->
|
|
216
|
+
<div
|
|
217
|
+
bind:this={el}
|
|
218
|
+
class={rootClass}
|
|
219
|
+
data-variant={!unstyled ? variant : undefined}
|
|
220
|
+
data-loading={!unstyled && loading ? "" : undefined}
|
|
221
|
+
data-readonly={!unstyled && isReadonly ? "" : undefined}
|
|
222
|
+
{...rest}
|
|
223
|
+
>
|
|
224
|
+
{#if loading}
|
|
225
|
+
<!-- Loading skeleton -->
|
|
226
|
+
<div class={!unstyled ? "stuic-cart-skeleton" : undefined}>
|
|
227
|
+
{#each [1, 2, 3] as _, i (i)}
|
|
228
|
+
<div class={!unstyled ? "stuic-cart-skeleton-item" : undefined}>
|
|
229
|
+
<Skeleton
|
|
230
|
+
variant="rectangle"
|
|
231
|
+
width={isCompact ? "3rem" : "4rem"}
|
|
232
|
+
height={isCompact ? "3rem" : "4rem"}
|
|
233
|
+
rounded="0.375rem"
|
|
234
|
+
/>
|
|
235
|
+
<div class={!unstyled ? "stuic-cart-skeleton-content" : undefined}>
|
|
236
|
+
<Skeleton height="1.25rem" width="60%" />
|
|
237
|
+
<Skeleton height="0.875rem" width="30%" />
|
|
238
|
+
{#if !isReadonly}
|
|
239
|
+
<Skeleton height="2rem" width="8rem" class="mt-2" />
|
|
240
|
+
{/if}
|
|
241
|
+
</div>
|
|
242
|
+
<Skeleton height="1.5rem" width="4rem" />
|
|
243
|
+
</div>
|
|
244
|
+
{/each}
|
|
245
|
+
</div>
|
|
246
|
+
{:else if items.length === 0}
|
|
247
|
+
<!-- Empty state -->
|
|
248
|
+
<div class={!unstyled ? "stuic-cart-empty" : undefined}>
|
|
249
|
+
{#if empty}
|
|
250
|
+
{@render empty()}
|
|
251
|
+
{:else}
|
|
252
|
+
<p>{t("empty_cart")}</p>
|
|
253
|
+
{/if}
|
|
254
|
+
</div>
|
|
255
|
+
{:else}
|
|
256
|
+
<!-- Item list -->
|
|
257
|
+
<div
|
|
258
|
+
class={!unstyled ? "stuic-cart-items" : undefined}
|
|
259
|
+
data-variant={!unstyled ? variant : undefined}
|
|
260
|
+
>
|
|
261
|
+
{#each items as item (item.id)}
|
|
262
|
+
{@const isUpdating = updatingItems.has(item.id)}
|
|
263
|
+
{#if itemRow}
|
|
264
|
+
{@render itemRow({
|
|
265
|
+
item,
|
|
266
|
+
isUpdating,
|
|
267
|
+
readonly: isReadonly,
|
|
268
|
+
formatPrice,
|
|
269
|
+
})}
|
|
270
|
+
{:else}
|
|
271
|
+
<div
|
|
272
|
+
class={!unstyled ? "stuic-cart-item" : undefined}
|
|
273
|
+
data-variant={!unstyled ? variant : undefined}
|
|
274
|
+
data-updating={!unstyled && isUpdating ? "" : undefined}
|
|
275
|
+
>
|
|
276
|
+
<!-- Thumbnail -->
|
|
277
|
+
{#if !noThumbnails}
|
|
278
|
+
<div class={!unstyled ? "stuic-cart-item-thumbnail" : undefined}
|
|
279
|
+
data-variant={!unstyled ? variant : undefined}
|
|
280
|
+
>
|
|
281
|
+
{#if thumbnail}
|
|
282
|
+
{@render thumbnail({ item })}
|
|
283
|
+
{:else if item.thumbnailSrc}
|
|
284
|
+
{#if item.href}
|
|
285
|
+
<a href={item.href}>
|
|
286
|
+
<img
|
|
287
|
+
src={item.thumbnailSrc}
|
|
288
|
+
alt={item.thumbnailAlt ?? item.name}
|
|
289
|
+
class={!unstyled ? "stuic-cart-item-image" : undefined}
|
|
290
|
+
/>
|
|
291
|
+
</a>
|
|
292
|
+
{:else}
|
|
293
|
+
<img
|
|
294
|
+
src={item.thumbnailSrc}
|
|
295
|
+
alt={item.thumbnailAlt ?? item.name}
|
|
296
|
+
class={!unstyled ? "stuic-cart-item-image" : undefined}
|
|
297
|
+
/>
|
|
298
|
+
{/if}
|
|
299
|
+
{:else}
|
|
300
|
+
<div
|
|
301
|
+
class={!unstyled ? "stuic-cart-item-placeholder" : undefined}
|
|
302
|
+
></div>
|
|
303
|
+
{/if}
|
|
304
|
+
</div>
|
|
305
|
+
{/if}
|
|
306
|
+
|
|
307
|
+
<!-- Info section -->
|
|
308
|
+
<div class={!unstyled ? "stuic-cart-item-info" : undefined}>
|
|
309
|
+
{#if item.href}
|
|
310
|
+
<a
|
|
311
|
+
href={item.href}
|
|
312
|
+
class={!unstyled
|
|
313
|
+
? "stuic-cart-item-name"
|
|
314
|
+
: undefined}
|
|
315
|
+
>
|
|
316
|
+
{item.name}
|
|
317
|
+
</a>
|
|
318
|
+
{:else}
|
|
319
|
+
<span
|
|
320
|
+
class={!unstyled
|
|
321
|
+
? "stuic-cart-item-name"
|
|
322
|
+
: undefined}
|
|
323
|
+
>
|
|
324
|
+
{item.name}
|
|
325
|
+
</span>
|
|
326
|
+
{/if}
|
|
327
|
+
|
|
328
|
+
{#if item.description && !isCompact}
|
|
329
|
+
<div
|
|
330
|
+
class={!unstyled
|
|
331
|
+
? "stuic-cart-item-description"
|
|
332
|
+
: undefined}
|
|
333
|
+
>
|
|
334
|
+
{item.description}
|
|
335
|
+
</div>
|
|
336
|
+
{/if}
|
|
337
|
+
|
|
338
|
+
<div
|
|
339
|
+
class={!unstyled
|
|
340
|
+
? "stuic-cart-item-unit-price"
|
|
341
|
+
: undefined}
|
|
342
|
+
>
|
|
343
|
+
{t("unit_price_each", {
|
|
344
|
+
price: formatPrice(item.unitPrice),
|
|
345
|
+
})}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{#if isReadonly}
|
|
349
|
+
<!-- Readonly quantity display -->
|
|
350
|
+
<div
|
|
351
|
+
class={!unstyled
|
|
352
|
+
? "stuic-cart-item-quantity-readonly"
|
|
353
|
+
: undefined}
|
|
354
|
+
>
|
|
355
|
+
{t("quantity_label", {
|
|
356
|
+
quantity: item.quantity,
|
|
357
|
+
})}{#if item.unit} {item.unit}{/if}
|
|
358
|
+
</div>
|
|
359
|
+
{:else}
|
|
360
|
+
<!-- Interactive quantity controls -->
|
|
361
|
+
<div
|
|
362
|
+
class={!unstyled
|
|
363
|
+
? "stuic-cart-item-controls"
|
|
364
|
+
: undefined}
|
|
365
|
+
>
|
|
366
|
+
<div
|
|
367
|
+
class={!unstyled
|
|
368
|
+
? "stuic-cart-quantity"
|
|
369
|
+
: undefined}
|
|
370
|
+
>
|
|
371
|
+
<button
|
|
372
|
+
type="button"
|
|
373
|
+
class={!unstyled
|
|
374
|
+
? "stuic-cart-quantity-button"
|
|
375
|
+
: undefined}
|
|
376
|
+
disabled={isUpdating ||
|
|
377
|
+
item.quantity <=
|
|
378
|
+
(item.minQuantity ?? 0)}
|
|
379
|
+
onclick={() =>
|
|
380
|
+
decrementQuantity(item)}
|
|
381
|
+
aria-label={t(
|
|
382
|
+
"decrease_quantity"
|
|
383
|
+
)}
|
|
384
|
+
>
|
|
385
|
+
−
|
|
386
|
+
</button>
|
|
387
|
+
{#if editingItemId === item.id}
|
|
388
|
+
<input
|
|
389
|
+
type="number"
|
|
390
|
+
min={item.minQuantity ?? 0}
|
|
391
|
+
max={item.maxQuantity}
|
|
392
|
+
step={item.quantityStep ?? 1}
|
|
393
|
+
class={!unstyled
|
|
394
|
+
? "stuic-cart-quantity-input"
|
|
395
|
+
: undefined}
|
|
396
|
+
value={item.quantity}
|
|
397
|
+
onblur={(e) =>
|
|
398
|
+
handleQuantityInputCommit(
|
|
399
|
+
item.id,
|
|
400
|
+
item.quantity,
|
|
401
|
+
e.currentTarget.value
|
|
402
|
+
)}
|
|
403
|
+
onkeydown={(e) => {
|
|
404
|
+
if (e.key === "Enter") {
|
|
405
|
+
handleQuantityInputCommit(
|
|
406
|
+
item.id,
|
|
407
|
+
item.quantity,
|
|
408
|
+
e.currentTarget.value
|
|
409
|
+
);
|
|
410
|
+
} else if (
|
|
411
|
+
e.key === "Escape"
|
|
412
|
+
) {
|
|
413
|
+
editingItemId = null;
|
|
414
|
+
}
|
|
415
|
+
}}
|
|
416
|
+
use:autoFocusAndSelect
|
|
417
|
+
/>
|
|
418
|
+
{:else}
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
class={!unstyled
|
|
422
|
+
? "stuic-cart-quantity-value"
|
|
423
|
+
: undefined}
|
|
424
|
+
onclick={() =>
|
|
425
|
+
(editingItemId = item.id)}
|
|
426
|
+
disabled={isUpdating}
|
|
427
|
+
>
|
|
428
|
+
{item.quantity}
|
|
429
|
+
</button>
|
|
430
|
+
{/if}
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
class={!unstyled
|
|
434
|
+
? "stuic-cart-quantity-button"
|
|
435
|
+
: undefined}
|
|
436
|
+
disabled={isUpdating ||
|
|
437
|
+
(item.maxQuantity != null &&
|
|
438
|
+
item.quantity >=
|
|
439
|
+
item.maxQuantity)}
|
|
440
|
+
onclick={() =>
|
|
441
|
+
incrementQuantity(item)}
|
|
442
|
+
aria-label={t(
|
|
443
|
+
"increase_quantity"
|
|
444
|
+
)}
|
|
445
|
+
>
|
|
446
|
+
+
|
|
447
|
+
</button>
|
|
448
|
+
</div>
|
|
449
|
+
{#if item.unit}
|
|
450
|
+
<span
|
|
451
|
+
class={!unstyled
|
|
452
|
+
? "stuic-cart-item-unit"
|
|
453
|
+
: undefined}
|
|
454
|
+
>
|
|
455
|
+
{item.unit}
|
|
456
|
+
</span>
|
|
457
|
+
{/if}
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
class={!unstyled
|
|
461
|
+
? "stuic-cart-remove"
|
|
462
|
+
: undefined}
|
|
463
|
+
disabled={isUpdating}
|
|
464
|
+
onclick={() => onRemove?.(item.id)}
|
|
465
|
+
>
|
|
466
|
+
{t("remove_item")}
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
469
|
+
{/if}
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<!-- Line total -->
|
|
473
|
+
<div
|
|
474
|
+
class={!unstyled
|
|
475
|
+
? "stuic-cart-item-total"
|
|
476
|
+
: undefined}
|
|
477
|
+
>
|
|
478
|
+
{formatPrice(item.lineTotal)}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
{/if}
|
|
482
|
+
{/each}
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
<!-- Summary -->
|
|
486
|
+
{#if summary}
|
|
487
|
+
{@render summary({ items, total, itemCount, formatPrice })}
|
|
488
|
+
{:else}
|
|
489
|
+
<div class={!unstyled ? "stuic-cart-summary" : undefined}
|
|
490
|
+
data-variant={!unstyled ? variant : undefined}
|
|
491
|
+
>
|
|
492
|
+
<span class={!unstyled ? "stuic-cart-summary-label" : undefined}>
|
|
493
|
+
{t("total_label")}
|
|
494
|
+
({itemCount === 1
|
|
495
|
+
? t("item_count_1")
|
|
496
|
+
: t("item_count_n", { count: itemCount })})
|
|
497
|
+
</span>
|
|
498
|
+
<span class={!unstyled ? "stuic-cart-summary-total" : undefined}
|
|
499
|
+
data-variant={!unstyled ? variant : undefined}
|
|
500
|
+
>
|
|
501
|
+
{formatPrice(total)}
|
|
502
|
+
</span>
|
|
503
|
+
</div>
|
|
504
|
+
{/if}
|
|
505
|
+
|
|
506
|
+
<!-- Footer -->
|
|
507
|
+
{#if footer}
|
|
508
|
+
<div class={!unstyled ? "stuic-cart-footer" : undefined}>
|
|
509
|
+
{@render footer({ items, total, itemCount })}
|
|
510
|
+
</div>
|
|
511
|
+
{/if}
|
|
512
|
+
{/if}
|
|
513
|
+
</div>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import type { TranslateFn } from "../../types.js";
|
|
4
|
+
/** A single item in the cart */
|
|
5
|
+
export interface CartComponentItem {
|
|
6
|
+
/** Unique item identifier */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Product name (displayed, used as link text) */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Link to product page */
|
|
11
|
+
href?: string;
|
|
12
|
+
/** Short description (shown below name if provided) */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** Image URL for product thumbnail */
|
|
15
|
+
thumbnailSrc?: string;
|
|
16
|
+
/** Image alt text (defaults to name) */
|
|
17
|
+
thumbnailAlt?: string;
|
|
18
|
+
/** Price per unit (cents integer recommended) */
|
|
19
|
+
unitPrice: number;
|
|
20
|
+
/** Current quantity */
|
|
21
|
+
quantity: number;
|
|
22
|
+
/** Pre-computed total for this line */
|
|
23
|
+
lineTotal: number;
|
|
24
|
+
/** Unit label: "pcs", "kg", etc. */
|
|
25
|
+
unit?: string;
|
|
26
|
+
/** Increment/decrement step (default 1) */
|
|
27
|
+
quantityStep?: number;
|
|
28
|
+
/** Floor for decrement (default 0) */
|
|
29
|
+
minQuantity?: number;
|
|
30
|
+
/** Ceiling for increment */
|
|
31
|
+
maxQuantity?: number;
|
|
32
|
+
}
|
|
33
|
+
/** Layout variant */
|
|
34
|
+
export type CartVariant = "default" | "compact";
|
|
35
|
+
export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
36
|
+
/** Cart items to display */
|
|
37
|
+
items: CartComponentItem[];
|
|
38
|
+
/** Layout variant. "compact" = smaller thumbnails, tighter spacing, scrollable, implicitly readonly */
|
|
39
|
+
variant?: CartVariant;
|
|
40
|
+
/** Format a numeric price for display. Default: (v) => (v / 100).toFixed(2) */
|
|
41
|
+
formatPrice?: (value: number) => string;
|
|
42
|
+
/** Called when quantity changes (not called in readonly/compact mode) */
|
|
43
|
+
onQuantityChange?: (id: string, newQuantity: number) => void;
|
|
44
|
+
/** Called when remove is clicked (not called in readonly/compact mode) */
|
|
45
|
+
onRemove?: (id: string) => void;
|
|
46
|
+
/** Hide all thumbnails */
|
|
47
|
+
noThumbnails?: boolean;
|
|
48
|
+
/** Hide all interactive controls — used for checkout summary */
|
|
49
|
+
readonly?: boolean;
|
|
50
|
+
/** Show loading skeleton instead of content */
|
|
51
|
+
loading?: boolean;
|
|
52
|
+
/** Set of item IDs currently being updated (shown with reduced opacity) */
|
|
53
|
+
updatingItems?: Set<string>;
|
|
54
|
+
/** Override thumbnail rendering */
|
|
55
|
+
thumbnail?: Snippet<[{
|
|
56
|
+
item: CartComponentItem;
|
|
57
|
+
}]>;
|
|
58
|
+
/** Override entire item row rendering */
|
|
59
|
+
itemRow?: Snippet<[
|
|
60
|
+
{
|
|
61
|
+
item: CartComponentItem;
|
|
62
|
+
isUpdating: boolean;
|
|
63
|
+
readonly: boolean;
|
|
64
|
+
formatPrice: (v: number) => string;
|
|
65
|
+
}
|
|
66
|
+
]>;
|
|
67
|
+
/** Override/extend summary section */
|
|
68
|
+
summary?: Snippet<[
|
|
69
|
+
{
|
|
70
|
+
items: CartComponentItem[];
|
|
71
|
+
total: number;
|
|
72
|
+
itemCount: number;
|
|
73
|
+
formatPrice: (v: number) => string;
|
|
74
|
+
}
|
|
75
|
+
]>;
|
|
76
|
+
/** Custom empty state */
|
|
77
|
+
empty?: Snippet;
|
|
78
|
+
/** Content after the summary (e.g., CTA buttons) */
|
|
79
|
+
footer?: Snippet<[
|
|
80
|
+
{
|
|
81
|
+
items: CartComponentItem[];
|
|
82
|
+
total: number;
|
|
83
|
+
itemCount: number;
|
|
84
|
+
}
|
|
85
|
+
]>;
|
|
86
|
+
/** Optional translate function */
|
|
87
|
+
t?: TranslateFn;
|
|
88
|
+
/** Skip all default styling */
|
|
89
|
+
unstyled?: boolean;
|
|
90
|
+
/** Additional CSS classes for the root container */
|
|
91
|
+
class?: string;
|
|
92
|
+
/** Bindable element reference */
|
|
93
|
+
el?: HTMLDivElement;
|
|
94
|
+
}
|
|
95
|
+
declare const Cart: import("svelte").Component<Props, {}, "el">;
|
|
96
|
+
type Cart = ReturnType<typeof Cart>;
|
|
97
|
+
export default Cart;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Cart
|
|
2
|
+
|
|
3
|
+
A reusable shopping cart component for displaying cart items with quantity controls,
|
|
4
|
+
pricing, and a summary section. Supports interactive (full cart) and readonly (checkout
|
|
5
|
+
summary) modes, plus a compact variant suitable for popover previews.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```svelte
|
|
10
|
+
<script>
|
|
11
|
+
import { Cart } from "@marianmeres/stuic";
|
|
12
|
+
|
|
13
|
+
let items = [
|
|
14
|
+
{
|
|
15
|
+
id: "prod-1",
|
|
16
|
+
name: "Widget Pro",
|
|
17
|
+
href: "/products/widget-pro",
|
|
18
|
+
thumbnailSrc: "/images/widget.jpg",
|
|
19
|
+
unitPrice: 1999, // cents
|
|
20
|
+
quantity: 2,
|
|
21
|
+
lineTotal: 3998,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "prod-2",
|
|
25
|
+
name: "Gadget Lite",
|
|
26
|
+
unitPrice: 499,
|
|
27
|
+
quantity: 1,
|
|
28
|
+
lineTotal: 499,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function handleQuantityChange(id, newQuantity) {
|
|
33
|
+
// sync with server, then update items
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleRemove(id) {
|
|
37
|
+
// remove from server, then update items
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<Cart
|
|
42
|
+
{items}
|
|
43
|
+
formatPrice={(v) => `$${(v / 100).toFixed(2)}`}
|
|
44
|
+
onQuantityChange={handleQuantityChange}
|
|
45
|
+
onRemove={handleRemove}
|
|
46
|
+
/>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Readonly Mode (checkout summary)
|
|
50
|
+
|
|
51
|
+
```svelte
|
|
52
|
+
<Cart {items} readonly formatPrice={(v) => `$${(v / 100).toFixed(2)}`} />
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Compact Variant (for popovers)
|
|
56
|
+
|
|
57
|
+
```svelte
|
|
58
|
+
<Cart
|
|
59
|
+
{items}
|
|
60
|
+
variant="compact"
|
|
61
|
+
formatPrice={(v) => `$${(v / 100).toFixed(2)}`}
|
|
62
|
+
>
|
|
63
|
+
{#snippet footer({ total, itemCount })}
|
|
64
|
+
<div class="flex gap-2">
|
|
65
|
+
<a href="/cart">View Cart</a>
|
|
66
|
+
<a href="/checkout">Checkout</a>
|
|
67
|
+
</div>
|
|
68
|
+
{/snippet}
|
|
69
|
+
</Cart>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Integration with @marianmeres/ecsuite
|
|
73
|
+
|
|
74
|
+
```svelte
|
|
75
|
+
<script>
|
|
76
|
+
import { Cart } from "@marianmeres/stuic";
|
|
77
|
+
import type { EnrichedCartItem } from "@marianmeres/ecsuite";
|
|
78
|
+
import type { CartComponentItem } from "@marianmeres/stuic";
|
|
79
|
+
|
|
80
|
+
// Map from ecsuite's EnrichedCartItem to Cart's CartComponentItem
|
|
81
|
+
function toCartItems(enriched: EnrichedCartItem[]): CartComponentItem[] {
|
|
82
|
+
return enriched.map((ei) => ({
|
|
83
|
+
id: ei.product_id,
|
|
84
|
+
name: ei.product?.name ?? "Unknown Product",
|
|
85
|
+
href: ei.product?.slug ? `/products/${ei.product.slug}` : undefined,
|
|
86
|
+
description: ei.product?.short_description,
|
|
87
|
+
thumbnailSrc: getProductImage(ei.product), // your image helper
|
|
88
|
+
unitPrice: ei.product?.price ?? 0,
|
|
89
|
+
quantity: ei.quantity,
|
|
90
|
+
lineTotal: ei.lineTotal,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Use with ecsuite's CartManager
|
|
95
|
+
async function handleQuantityChange(id: string, qty: number) {
|
|
96
|
+
await suite.cart.updateItemQuantity(id, qty);
|
|
97
|
+
items = toCartItems(await suite.cart.getEnrichedItems(suite.product));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function handleRemove(id: string) {
|
|
101
|
+
await suite.cart.removeItem(id);
|
|
102
|
+
items = toCartItems(await suite.cart.getEnrichedItems(suite.product));
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Props
|
|
108
|
+
|
|
109
|
+
| Prop | Type | Default | Description |
|
|
110
|
+
|------|------|---------|-------------|
|
|
111
|
+
| `items` | `CartComponentItem[]` | required | Cart items to display |
|
|
112
|
+
| `variant` | `"default" \| "compact"` | `"default"` | Layout variant. Compact is smaller, scrollable, implicitly readonly |
|
|
113
|
+
| `formatPrice` | `(value: number) => string` | `(v) => (v / 100).toFixed(2)` | Format numeric price for display |
|
|
114
|
+
| `onQuantityChange` | `(id: string, qty: number) => void` | — | Called when quantity changes |
|
|
115
|
+
| `onRemove` | `(id: string) => void` | — | Called when remove is clicked |
|
|
116
|
+
| `readonly` | `boolean` | `false` | Hide interactive controls |
|
|
117
|
+
| `loading` | `boolean` | `false` | Show loading skeleton |
|
|
118
|
+
| `updatingItems` | `Set<string>` | `new Set()` | Item IDs currently being updated |
|
|
119
|
+
| `t` | `TranslateFn` | built-in | Translation function |
|
|
120
|
+
| `unstyled` | `boolean` | `false` | Skip all default styling |
|
|
121
|
+
| `class` | `string` | — | Additional CSS classes |
|
|
122
|
+
| `el` | `HTMLDivElement` | — | Bindable element reference |
|
|
123
|
+
|
|
124
|
+
### CartComponentItem
|
|
125
|
+
|
|
126
|
+
| Field | Type | Required | Description |
|
|
127
|
+
|-------|------|----------|-------------|
|
|
128
|
+
| `id` | `string` | yes | Unique identifier |
|
|
129
|
+
| `name` | `string` | yes | Product name |
|
|
130
|
+
| `href` | `string` | — | Link to product page |
|
|
131
|
+
| `description` | `string` | — | Short description |
|
|
132
|
+
| `thumbnailSrc` | `string` | — | Image URL |
|
|
133
|
+
| `thumbnailAlt` | `string` | — | Image alt (defaults to name) |
|
|
134
|
+
| `unitPrice` | `number` | yes | Price per unit (cents) |
|
|
135
|
+
| `quantity` | `number` | yes | Current quantity |
|
|
136
|
+
| `lineTotal` | `number` | yes | Pre-computed line total |
|
|
137
|
+
| `unit` | `string` | — | Unit label ("pcs", "kg") |
|
|
138
|
+
| `quantityStep` | `number` | — | +/- step (default 1) |
|
|
139
|
+
| `minQuantity` | `number` | — | Min quantity (default 0) |
|
|
140
|
+
| `maxQuantity` | `number` | — | Max quantity |
|
|
141
|
+
|
|
142
|
+
### Snippets
|
|
143
|
+
|
|
144
|
+
| Snippet | Params | Description |
|
|
145
|
+
|---------|--------|-------------|
|
|
146
|
+
| `thumbnail` | `{ item }` | Override thumbnail rendering |
|
|
147
|
+
| `itemRow` | `{ item, isUpdating, readonly, formatPrice }` | Override entire item row |
|
|
148
|
+
| `summary` | `{ items, total, itemCount, formatPrice }` | Override summary section |
|
|
149
|
+
| `empty` | — | Custom empty state |
|
|
150
|
+
| `footer` | `{ items, total, itemCount }` | Content after summary (CTAs) |
|
|
151
|
+
|
|
152
|
+
## CSS Variables
|
|
153
|
+
|
|
154
|
+
| Variable | Default | Description |
|
|
155
|
+
|----------|---------|-------------|
|
|
156
|
+
| `--stuic-cart-gap` | `1rem` | Gap between items |
|
|
157
|
+
| `--stuic-cart-item-padding` | `1rem` | Item card padding |
|
|
158
|
+
| `--stuic-cart-item-radius` | `var(--radius-lg)` | Item border radius |
|
|
159
|
+
| `--stuic-cart-item-border-color` | `var(--stuic-color-border)` | Item border color |
|
|
160
|
+
| `--stuic-cart-item-bg` | `var(--stuic-color-background)` | Item background |
|
|
161
|
+
| `--stuic-cart-thumbnail-size` | `4rem` | Thumbnail dimensions |
|
|
162
|
+
| `--stuic-cart-thumbnail-size-sm` | `3rem` | Compact thumbnail dimensions |
|
|
163
|
+
| `--stuic-cart-thumbnail-radius` | `var(--radius-md)` | Thumbnail border radius |
|
|
164
|
+
| `--stuic-cart-thumbnail-bg` | `var(--stuic-color-muted)` | Thumbnail placeholder bg |
|
|
165
|
+
| `--stuic-cart-quantity-border-color` | `var(--stuic-color-border)` | Quantity control border |
|
|
166
|
+
| `--stuic-cart-quantity-button-size` | `2rem` | Quantity button dimensions |
|
|
167
|
+
| `--stuic-cart-remove-color` | `var(--stuic-color-destructive)` | Remove button color |
|
|
168
|
+
| `--stuic-cart-summary-border-color` | `var(--stuic-color-border)` | Summary separator |
|
|
169
|
+
| `--stuic-cart-compact-max-height` | `12rem` | Compact variant scroll height |
|
|
170
|
+
| `--stuic-cart-compact-item-padding` | `0.5rem` | Compact item padding |
|
|
171
|
+
| `--stuic-cart-transition` | `150ms` | Transition duration |
|
|
172
|
+
|
|
173
|
+
## Translation Keys
|
|
174
|
+
|
|
175
|
+
| Key | Default | Description |
|
|
176
|
+
|-----|---------|-------------|
|
|
177
|
+
| `empty_cart` | "Your cart is empty" | Empty state text |
|
|
178
|
+
| `unit_price_each` | "{price} each" | Unit price label |
|
|
179
|
+
| `quantity_label` | "Qty: {quantity}" | Readonly quantity display |
|
|
180
|
+
| `remove_item` | "Remove" | Remove button text |
|
|
181
|
+
| `total_label` | "Total" | Summary label |
|
|
182
|
+
| `item_count_1` | "1 item" | Singular item count |
|
|
183
|
+
| `item_count_n` | "{count} items" | Plural item count |
|
|
184
|
+
| `decrease_quantity` | "Decrease quantity" | Aria label for − button |
|
|
185
|
+
| `increase_quantity` | "Increase quantity" | Aria label for + button |
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
CART COMPONENT TOKENS
|
|
3
|
+
Override globally: :root { --stuic-cart-item-padding: 0.5rem; }
|
|
4
|
+
Override locally: <Cart style="--stuic-cart-item-padding: 0.5rem;">
|
|
5
|
+
============================================================================ */
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
/* Layout */
|
|
9
|
+
--stuic-cart-gap: 1rem;
|
|
10
|
+
--stuic-cart-transition: 150ms;
|
|
11
|
+
|
|
12
|
+
/* Item card */
|
|
13
|
+
--stuic-cart-item-padding: 1rem;
|
|
14
|
+
--stuic-cart-item-radius: var(--radius-lg);
|
|
15
|
+
--stuic-cart-item-border-color: var(--stuic-color-border);
|
|
16
|
+
--stuic-cart-item-bg: var(--stuic-color-background);
|
|
17
|
+
|
|
18
|
+
/* Thumbnail */
|
|
19
|
+
--stuic-cart-thumbnail-size: 4rem;
|
|
20
|
+
--stuic-cart-thumbnail-size-sm: 3rem;
|
|
21
|
+
--stuic-cart-thumbnail-radius: var(--radius-md);
|
|
22
|
+
--stuic-cart-thumbnail-bg: var(--stuic-color-muted);
|
|
23
|
+
|
|
24
|
+
/* Quantity controls */
|
|
25
|
+
--stuic-cart-quantity-border-color: var(--stuic-color-border);
|
|
26
|
+
--stuic-cart-quantity-button-size: 2rem;
|
|
27
|
+
--stuic-cart-quantity-button-bg-hover: var(--stuic-color-muted);
|
|
28
|
+
--stuic-cart-quantity-input-bg: var(--stuic-color-input);
|
|
29
|
+
|
|
30
|
+
/* Remove button */
|
|
31
|
+
--stuic-cart-remove-color: var(--stuic-color-destructive);
|
|
32
|
+
--stuic-cart-remove-color-hover: var(--stuic-color-destructive-hover);
|
|
33
|
+
|
|
34
|
+
/* Summary */
|
|
35
|
+
--stuic-cart-summary-border-color: var(--stuic-color-border);
|
|
36
|
+
|
|
37
|
+
/* Compact variant */
|
|
38
|
+
--stuic-cart-compact-max-height: 12rem;
|
|
39
|
+
--stuic-cart-compact-item-padding: 0.5rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@layer components {
|
|
43
|
+
/* ============================================================================
|
|
44
|
+
BASE STYLES
|
|
45
|
+
============================================================================ */
|
|
46
|
+
|
|
47
|
+
.stuic-cart {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ============================================================================
|
|
53
|
+
SKELETON (loading state)
|
|
54
|
+
============================================================================ */
|
|
55
|
+
|
|
56
|
+
.stuic-cart-skeleton {
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
gap: var(--stuic-cart-gap);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.stuic-cart-skeleton-item {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: flex-start;
|
|
65
|
+
gap: 1rem;
|
|
66
|
+
padding: var(--stuic-cart-item-padding);
|
|
67
|
+
border: 1px solid var(--stuic-cart-item-border-color);
|
|
68
|
+
border-radius: var(--stuic-cart-item-radius);
|
|
69
|
+
background: var(--stuic-cart-item-bg);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.stuic-cart-skeleton-content {
|
|
73
|
+
flex: 1;
|
|
74
|
+
min-width: 0;
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
gap: 0.5rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ============================================================================
|
|
81
|
+
EMPTY STATE
|
|
82
|
+
============================================================================ */
|
|
83
|
+
|
|
84
|
+
.stuic-cart-empty {
|
|
85
|
+
text-align: center;
|
|
86
|
+
padding: 3rem 1rem;
|
|
87
|
+
opacity: 0.7;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ============================================================================
|
|
91
|
+
ITEMS LIST
|
|
92
|
+
============================================================================ */
|
|
93
|
+
|
|
94
|
+
.stuic-cart-items {
|
|
95
|
+
display: flex;
|
|
96
|
+
flex-direction: column;
|
|
97
|
+
gap: var(--stuic-cart-gap);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.stuic-cart-items[data-variant="compact"] {
|
|
101
|
+
max-height: var(--stuic-cart-compact-max-height);
|
|
102
|
+
overflow-y: auto;
|
|
103
|
+
gap: 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ============================================================================
|
|
107
|
+
ITEM CARD
|
|
108
|
+
============================================================================ */
|
|
109
|
+
|
|
110
|
+
.stuic-cart-item {
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: flex-start;
|
|
113
|
+
gap: 1rem;
|
|
114
|
+
padding: var(--stuic-cart-item-padding);
|
|
115
|
+
border: 1px solid var(--stuic-cart-item-border-color);
|
|
116
|
+
border-radius: var(--stuic-cart-item-radius);
|
|
117
|
+
background: var(--stuic-cart-item-bg);
|
|
118
|
+
transition: opacity var(--stuic-cart-transition);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.stuic-cart-item[data-variant="compact"] {
|
|
122
|
+
padding: var(--stuic-cart-compact-item-padding);
|
|
123
|
+
border-radius: 0;
|
|
124
|
+
border-left: 0;
|
|
125
|
+
border-right: 0;
|
|
126
|
+
border-bottom: 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.stuic-cart-item[data-variant="compact"]:first-child {
|
|
130
|
+
border-top: 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.stuic-cart-item[data-updating] {
|
|
134
|
+
opacity: 0.5;
|
|
135
|
+
pointer-events: none;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ============================================================================
|
|
139
|
+
THUMBNAIL
|
|
140
|
+
============================================================================ */
|
|
141
|
+
|
|
142
|
+
.stuic-cart-item-thumbnail {
|
|
143
|
+
flex-shrink: 0;
|
|
144
|
+
width: var(--stuic-cart-thumbnail-size);
|
|
145
|
+
height: var(--stuic-cart-thumbnail-size);
|
|
146
|
+
border-radius: var(--stuic-cart-thumbnail-radius);
|
|
147
|
+
background: var(--stuic-cart-thumbnail-bg);
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
justify-content: center;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.stuic-cart-item-thumbnail[data-variant="compact"] {
|
|
155
|
+
width: var(--stuic-cart-thumbnail-size-sm);
|
|
156
|
+
height: var(--stuic-cart-thumbnail-size-sm);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.stuic-cart-item-image {
|
|
160
|
+
width: 100%;
|
|
161
|
+
height: 100%;
|
|
162
|
+
object-fit: cover;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.stuic-cart-item-placeholder {
|
|
166
|
+
width: 100%;
|
|
167
|
+
height: 100%;
|
|
168
|
+
background: var(--stuic-cart-thumbnail-bg);
|
|
169
|
+
opacity: 0.3;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ============================================================================
|
|
173
|
+
ITEM INFO
|
|
174
|
+
============================================================================ */
|
|
175
|
+
|
|
176
|
+
.stuic-cart-item-info {
|
|
177
|
+
flex: 1;
|
|
178
|
+
min-width: 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
a.stuic-cart-item-name {
|
|
182
|
+
font-weight: var(--font-weight-medium);
|
|
183
|
+
display: block;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
text-overflow: ellipsis;
|
|
186
|
+
white-space: nowrap;
|
|
187
|
+
text-decoration: none;
|
|
188
|
+
color: inherit;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
a.stuic-cart-item-name:hover {
|
|
192
|
+
text-decoration: underline;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
span.stuic-cart-item-name {
|
|
196
|
+
font-weight: var(--font-weight-medium);
|
|
197
|
+
display: block;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
text-overflow: ellipsis;
|
|
200
|
+
white-space: nowrap;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.stuic-cart-item-description {
|
|
204
|
+
font-size: var(--text-sm);
|
|
205
|
+
opacity: 0.6;
|
|
206
|
+
margin-top: 0.125rem;
|
|
207
|
+
overflow: hidden;
|
|
208
|
+
text-overflow: ellipsis;
|
|
209
|
+
white-space: nowrap;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.stuic-cart-item-unit-price {
|
|
213
|
+
font-size: var(--text-sm);
|
|
214
|
+
opacity: 0.6;
|
|
215
|
+
margin-top: 0.25rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.stuic-cart-item-quantity-readonly {
|
|
219
|
+
font-size: var(--text-sm);
|
|
220
|
+
opacity: 0.6;
|
|
221
|
+
margin-top: 0.25rem;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ============================================================================
|
|
225
|
+
QUANTITY CONTROLS
|
|
226
|
+
============================================================================ */
|
|
227
|
+
|
|
228
|
+
.stuic-cart-item-controls {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 0.75rem;
|
|
232
|
+
margin-top: 0.75rem;
|
|
233
|
+
flex-wrap: wrap;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.stuic-cart-quantity {
|
|
237
|
+
display: inline-flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
border: 1px solid var(--stuic-cart-quantity-border-color);
|
|
240
|
+
border-radius: var(--radius-md);
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.stuic-cart-quantity-button {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
justify-content: center;
|
|
248
|
+
width: var(--stuic-cart-quantity-button-size);
|
|
249
|
+
height: var(--stuic-cart-quantity-button-size);
|
|
250
|
+
background: transparent;
|
|
251
|
+
border: none;
|
|
252
|
+
cursor: pointer;
|
|
253
|
+
font-size: 1rem;
|
|
254
|
+
line-height: 1;
|
|
255
|
+
color: inherit;
|
|
256
|
+
transition: background var(--stuic-cart-transition);
|
|
257
|
+
-webkit-tap-highlight-color: transparent;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.stuic-cart-quantity-button:hover:not(:disabled) {
|
|
261
|
+
background: var(--stuic-cart-quantity-button-bg-hover);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.stuic-cart-quantity-button:disabled {
|
|
265
|
+
opacity: 0.3;
|
|
266
|
+
cursor: not-allowed;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.stuic-cart-quantity-value {
|
|
270
|
+
display: flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
justify-content: center;
|
|
273
|
+
min-width: 2.5rem;
|
|
274
|
+
height: var(--stuic-cart-quantity-button-size);
|
|
275
|
+
background: transparent;
|
|
276
|
+
border: none;
|
|
277
|
+
cursor: text;
|
|
278
|
+
font-size: var(--text-sm);
|
|
279
|
+
font-weight: var(--font-weight-medium);
|
|
280
|
+
text-align: center;
|
|
281
|
+
color: inherit;
|
|
282
|
+
transition: background var(--stuic-cart-transition);
|
|
283
|
+
-webkit-tap-highlight-color: transparent;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.stuic-cart-quantity-value:hover:not(:disabled) {
|
|
287
|
+
background: var(--stuic-cart-quantity-input-bg);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.stuic-cart-quantity-value:disabled {
|
|
291
|
+
cursor: default;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.stuic-cart-quantity-input {
|
|
295
|
+
width: 3rem;
|
|
296
|
+
height: var(--stuic-cart-quantity-button-size);
|
|
297
|
+
text-align: center;
|
|
298
|
+
font-size: var(--text-sm);
|
|
299
|
+
font-weight: var(--font-weight-medium);
|
|
300
|
+
border: none;
|
|
301
|
+
background: var(--stuic-cart-quantity-input-bg);
|
|
302
|
+
color: inherit;
|
|
303
|
+
outline: none;
|
|
304
|
+
-moz-appearance: textfield;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.stuic-cart-quantity-input::-webkit-inner-spin-button,
|
|
308
|
+
.stuic-cart-quantity-input::-webkit-outer-spin-button {
|
|
309
|
+
-webkit-appearance: none;
|
|
310
|
+
margin: 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.stuic-cart-item-unit {
|
|
314
|
+
font-size: var(--text-sm);
|
|
315
|
+
opacity: 0.6;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ============================================================================
|
|
319
|
+
REMOVE BUTTON
|
|
320
|
+
============================================================================ */
|
|
321
|
+
|
|
322
|
+
.stuic-cart-remove {
|
|
323
|
+
background: none;
|
|
324
|
+
border: none;
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
font-size: var(--text-sm);
|
|
327
|
+
color: var(--stuic-cart-remove-color);
|
|
328
|
+
padding: 0;
|
|
329
|
+
-webkit-tap-highlight-color: transparent;
|
|
330
|
+
transition: color var(--stuic-cart-transition);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.stuic-cart-remove:hover:not(:disabled) {
|
|
334
|
+
text-decoration: underline;
|
|
335
|
+
color: var(--stuic-cart-remove-color-hover);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.stuic-cart-remove:disabled {
|
|
339
|
+
opacity: 0.3;
|
|
340
|
+
cursor: not-allowed;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* ============================================================================
|
|
344
|
+
LINE TOTAL
|
|
345
|
+
============================================================================ */
|
|
346
|
+
|
|
347
|
+
.stuic-cart-item-total {
|
|
348
|
+
flex-shrink: 0;
|
|
349
|
+
text-align: right;
|
|
350
|
+
font-weight: var(--font-weight-bold);
|
|
351
|
+
font-size: var(--text-lg);
|
|
352
|
+
white-space: nowrap;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.stuic-cart-item[data-variant="compact"] .stuic-cart-item-total {
|
|
356
|
+
font-size: var(--text-base);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ============================================================================
|
|
360
|
+
SUMMARY
|
|
361
|
+
============================================================================ */
|
|
362
|
+
|
|
363
|
+
.stuic-cart-summary {
|
|
364
|
+
display: flex;
|
|
365
|
+
justify-content: space-between;
|
|
366
|
+
align-items: center;
|
|
367
|
+
margin-top: 1.5rem;
|
|
368
|
+
padding-top: 1.5rem;
|
|
369
|
+
border-top: 1px solid var(--stuic-cart-summary-border-color);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.stuic-cart-summary[data-variant="compact"] {
|
|
373
|
+
margin-top: 0;
|
|
374
|
+
padding: var(--stuic-cart-compact-item-padding);
|
|
375
|
+
padding-top: calc(var(--stuic-cart-compact-item-padding) + 0.25rem);
|
|
376
|
+
border-top: 1px solid var(--stuic-cart-summary-border-color);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.stuic-cart-summary-label {
|
|
380
|
+
font-size: var(--text-lg);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.stuic-cart-summary[data-variant="compact"] .stuic-cart-summary-label {
|
|
384
|
+
font-size: var(--text-base);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.stuic-cart-summary-total {
|
|
388
|
+
font-size: var(--text-2xl);
|
|
389
|
+
font-weight: var(--font-weight-bold);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.stuic-cart-summary-total[data-variant="compact"] {
|
|
393
|
+
font-size: var(--text-lg);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ============================================================================
|
|
397
|
+
FOOTER
|
|
398
|
+
============================================================================ */
|
|
399
|
+
|
|
400
|
+
.stuic-cart-footer {
|
|
401
|
+
margin-top: 1.5rem;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.stuic-cart-summary[data-variant="compact"] + .stuic-cart-footer {
|
|
405
|
+
margin-top: 0;
|
|
406
|
+
padding: var(--stuic-cart-compact-item-padding);
|
|
407
|
+
border-top: 1px solid var(--stuic-cart-summary-border-color);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Cart, type Props as CartProps, type CartComponentItem, type CartVariant, } from "./Cart.svelte";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Cart, } from "./Cart.svelte";
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
select_row: "Select row",
|
|
24
24
|
};
|
|
25
25
|
let out = m[k] ?? fallback ?? k;
|
|
26
|
-
return isPlainObject(values)
|
|
26
|
+
return isPlainObject(values)
|
|
27
|
+
? replaceMap(out, values as any, {
|
|
28
|
+
preSearchKeyTransform: (k) => `{${k}}`,
|
|
29
|
+
})
|
|
30
|
+
: out;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export interface DataTableColumn<T = Record<string, any>> {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
/* prettier-ignore */
|
|
8
8
|
:root {
|
|
9
9
|
/* Structure */
|
|
10
|
+
--stuic-data-table-layout: fixed;
|
|
10
11
|
--stuic-data-table-radius: var(--radius-md);
|
|
11
12
|
--stuic-data-table-border-width: 1px;
|
|
12
13
|
--stuic-data-table-border-color: var(--stuic-color-border);
|
|
@@ -83,7 +84,7 @@
|
|
|
83
84
|
.stuic-data-table table {
|
|
84
85
|
width: 100%;
|
|
85
86
|
border-collapse: collapse;
|
|
86
|
-
table-layout:
|
|
87
|
+
table-layout: var(--stuic-data-table-layout);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/* ============================================================================
|
|
@@ -605,17 +605,23 @@
|
|
|
605
605
|
isOpen ? BodyScroll.lock() : BodyScroll.unlock();
|
|
606
606
|
});
|
|
607
607
|
|
|
608
|
-
// Click outside handler
|
|
609
|
-
onClickOutside(
|
|
608
|
+
// Click outside handler — only active when open (prevents stale refs on destroy)
|
|
609
|
+
const _clickOutside = onClickOutside(
|
|
610
610
|
() => wrapperEl,
|
|
611
611
|
() => {
|
|
612
612
|
if (closeOnClickOutside && isOpen) {
|
|
613
613
|
isOpen = false;
|
|
614
614
|
triggerEl?.focus();
|
|
615
615
|
}
|
|
616
|
-
}
|
|
616
|
+
},
|
|
617
|
+
{ immediate: false }
|
|
617
618
|
);
|
|
618
619
|
|
|
620
|
+
$effect(() => {
|
|
621
|
+
if (isOpen && wrapperEl) _clickOutside.start();
|
|
622
|
+
else _clickOutside.stop();
|
|
623
|
+
});
|
|
624
|
+
|
|
619
625
|
// Helper to generate item IDs
|
|
620
626
|
function itemId(id: string | number): string {
|
|
621
627
|
return `${dropdownId}-item-${id}`;
|
|
@@ -106,11 +106,18 @@
|
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
// Click outside handler — only active when visible (prevents stale refs on destroy)
|
|
110
|
+
const _clickOutside = onClickOutside(
|
|
110
111
|
() => box,
|
|
111
|
-
() => !noClickOutsideClose && close()
|
|
112
|
+
() => !noClickOutsideClose && close(),
|
|
113
|
+
{ immediate: false }
|
|
112
114
|
);
|
|
113
115
|
|
|
116
|
+
$effect(() => {
|
|
117
|
+
if (visible && box) _clickOutside.start();
|
|
118
|
+
else _clickOutside.stop();
|
|
119
|
+
});
|
|
120
|
+
|
|
114
121
|
$effect(() => {
|
|
115
122
|
// noop if we're undefined ($effect runs immediately as onMount)
|
|
116
123
|
if (visible === undefined || noScrollLock) return;
|
package/dist/index.css
CHANGED
|
@@ -30,6 +30,7 @@ In practice:
|
|
|
30
30
|
@import "./components/Button/index.css";
|
|
31
31
|
@import "./components/ButtonGroupRadio/index.css";
|
|
32
32
|
@import "./components/Collapsible/index.css";
|
|
33
|
+
@import "./components/Cart/index.css";
|
|
33
34
|
@import "./components/Carousel/index.css";
|
|
34
35
|
@import "./components/CommandMenu/index.css";
|
|
35
36
|
@import "./components/DataTable/index.css";
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./components/Avatar/index.js";
|
|
|
29
29
|
export * from "./components/Backdrop/index.js";
|
|
30
30
|
export * from "./components/Button/index.js";
|
|
31
31
|
export * from "./components/ButtonGroupRadio/index.js";
|
|
32
|
+
export * from "./components/Cart/index.js";
|
|
32
33
|
export * from "./components/Carousel/index.js";
|
|
33
34
|
export * from "./components/Collapsible/index.js";
|
|
34
35
|
export * from "./components/ColorScheme/index.js";
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ export * from "./components/Avatar/index.js";
|
|
|
30
30
|
export * from "./components/Backdrop/index.js";
|
|
31
31
|
export * from "./components/Button/index.js";
|
|
32
32
|
export * from "./components/ButtonGroupRadio/index.js";
|
|
33
|
+
export * from "./components/Cart/index.js";
|
|
33
34
|
export * from "./components/Carousel/index.js";
|
|
34
35
|
export * from "./components/Collapsible/index.js";
|
|
35
36
|
export * from "./components/ColorScheme/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/stuic",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"!dist/**/*.test.*",
|
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
"@tailwindcss/forms": "^0.5.11",
|
|
47
47
|
"@tailwindcss/typography": "^0.5.19",
|
|
48
48
|
"@tailwindcss/vite": "^4.1.18",
|
|
49
|
-
"@types/node": "^25.2.
|
|
49
|
+
"@types/node": "^25.2.2",
|
|
50
50
|
"dotenv": "^16.6.1",
|
|
51
51
|
"eslint": "^9.39.2",
|
|
52
52
|
"globals": "^16.5.0",
|
|
53
53
|
"prettier": "^3.8.1",
|
|
54
54
|
"prettier-plugin-svelte": "^3.4.1",
|
|
55
55
|
"publint": "^0.3.17",
|
|
56
|
-
"svelte": "^5.
|
|
56
|
+
"svelte": "^5.50.0",
|
|
57
57
|
"svelte-check": "^4.3.6",
|
|
58
58
|
"tailwindcss": "^4.1.18",
|
|
59
59
|
"tsx": "^4.21.0",
|