@marianmeres/stuic 3.123.0 → 3.125.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.
@@ -220,6 +220,7 @@
220
220
  <CheckoutOrderSummary
221
221
  totals={order.totals}
222
222
  hasShipping={!!order.delivery_option_id}
223
+ shippingPending={!!order.delivery_option?.pending}
223
224
  {formatPrice}
224
225
  t={tProp}
225
226
  {unstyled}
@@ -206,7 +206,9 @@
206
206
  <div
207
207
  class={unstyled ? undefined : "stuic-checkout-confirmation-delivery-detail"}
208
208
  >
209
- {#if order.delivery_option.price === 0}
209
+ {#if order.delivery_option.pending}
210
+ {t("checkout.summary.pending")}
211
+ {:else if order.delivery_option.price === 0}
210
212
  {t("checkout.summary.free")}
211
213
  {:else}
212
214
  {fp(order.delivery_option.price)}
@@ -233,6 +235,7 @@
233
235
  </CheckoutSectionHeader>
234
236
  <CheckoutOrderSummary
235
237
  totals={order.totals}
238
+ shippingPending={!!order.delivery_option?.pending}
236
239
  formatPrice={formatPriceProp}
237
240
  t={tProp}
238
241
  {unstyled}
@@ -306,7 +306,9 @@
306
306
  <div class={unstyled ? undefined : "stuic-checkout-review-delivery"}>
307
307
  <span>{order.delivery_option.name}</span>
308
308
  <span>
309
- {#if order.delivery_option.price === 0}
309
+ {#if order.delivery_option.pending}
310
+ {t("checkout.summary.pending")}
311
+ {:else if order.delivery_option.price === 0}
310
312
  {t("checkout.summary.free")}
311
313
  {:else}
312
314
  {fp(order.delivery_option.price)}
@@ -15,6 +15,15 @@
15
15
  */
16
16
  hasShipping?: boolean;
17
17
 
18
+ /**
19
+ * Shipping cost is not yet known and will be quoted/calculated later
20
+ * (e.g. international orders quoted by email). When true, the shipping row
21
+ * shows the `checkout.summary.pending` label ("Calculated separately")
22
+ * instead of "Free" or the amount — even when `totals.shipping` is 0.
23
+ * Default: false
24
+ */
25
+ shippingPending?: boolean;
26
+
18
27
  /**
19
28
  * Format a number (in cents) to a display string.
20
29
  * Default: defaultFormatPrice (cents / 100, 2 decimal places)
@@ -58,6 +67,7 @@
58
67
  let {
59
68
  totals,
60
69
  hasShipping = true,
70
+ shippingPending = false,
61
71
  formatPrice: formatPriceProp,
62
72
  row,
63
73
  extraRows,
@@ -72,6 +82,7 @@
72
82
  let fp = $derived(formatPriceProp ?? defaultFormatPrice);
73
83
 
74
84
  let shippingValue = $derived.by(() => {
85
+ if (shippingPending) return t("checkout.summary.pending");
75
86
  if (!hasShipping) return t("checkout.summary.not_selected");
76
87
  if (totals.shipping === 0) return t("checkout.summary.free");
77
88
  return fp(totals.shipping);
@@ -11,6 +11,14 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
11
11
  * Default: true
12
12
  */
13
13
  hasShipping?: boolean;
14
+ /**
15
+ * Shipping cost is not yet known and will be quoted/calculated later
16
+ * (e.g. international orders quoted by email). When true, the shipping row
17
+ * shows the `checkout.summary.pending` label ("Calculated separately")
18
+ * instead of "Free" or the amount — even when `totals.shipping` is 0.
19
+ * Default: false
20
+ */
21
+ shippingPending?: boolean;
14
22
  /**
15
23
  * Format a number (in cents) to a display string.
16
24
  * Default: defaultFormatPrice (cents / 100, 2 decimal places)
@@ -360,6 +360,7 @@
360
360
  <CheckoutOrderSummary
361
361
  totals={order.totals}
362
362
  hasShipping={!!order.delivery_option_id}
363
+ shippingPending={!!order.delivery_option?.pending}
363
364
  {formatPrice}
364
365
  t={tProp}
365
366
  {unstyled}
@@ -19,6 +19,7 @@ const DEFAULTS = {
19
19
  "checkout.summary.total": "Total",
20
20
  "checkout.summary.free": "Free",
21
21
  "checkout.summary.not_selected": "\u2014",
22
+ "checkout.summary.pending": "Calculated separately",
22
23
  // -- CheckoutCartReview (03) --
23
24
  "checkout.cart.title": "Order Summary",
24
25
  "checkout.cart.edit": "Edit Cart",
@@ -74,6 +74,13 @@ export interface CheckoutDeliverySnapshot {
74
74
  name: string;
75
75
  price: number;
76
76
  estimated_time?: string;
77
+ /**
78
+ * Shipping cost is not yet known and will be quoted/calculated later
79
+ * (e.g. international orders quoted by email after the order is placed).
80
+ * When true, components render the `checkout.summary.pending` label instead
81
+ * of the price or "Free", regardless of `price` being 0.
82
+ */
83
+ pending?: boolean;
77
84
  }
78
85
  export interface CheckoutOrderData {
79
86
  status: string;
@@ -18,6 +18,19 @@
18
18
  id?: string;
19
19
  tabindex?: number;
20
20
  renderSize?: "sm" | "md" | "lg" | string;
21
+ /**
22
+ * Max nesting depth rendered in (read-only) preview mode before a node is
23
+ * collapsed to a keys-only / `[n]` summary with a "more…" hint. Prevents
24
+ * deeply nested objects from blowing out horizontally and becoming
25
+ * unreadable. Has no effect on raw edit mode. Defaults to 4.
26
+ */
27
+ previewMaxDepth?: number;
28
+ /**
29
+ * When true (the default), the edit toggle opens the raw JSON editor in a
30
+ * (near) full-screen modal — comfortable for large / deeply nested objects.
31
+ * Set `false` to edit inline in a textarea below the preview instead.
32
+ */
33
+ fullscreenEdit?: boolean;
21
34
  required?: boolean;
22
35
  disabled?: boolean;
23
36
  validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
@@ -38,6 +51,9 @@
38
51
  import { validate as validateAction } from "../../actions/validate.svelte.js";
39
52
  import { getId } from "../../utils/get-id.js";
40
53
  import { twMerge } from "../../utils/tw-merge.js";
54
+ import Button from "../Button/Button.svelte";
55
+ import Modal from "../Modal/Modal.svelte";
56
+ import { Thc, isTHCNotEmpty } from "../Thc/index.js";
41
57
  import InputWrap from "./_internal/InputWrap.svelte";
42
58
 
43
59
  let {
@@ -49,6 +65,8 @@
49
65
  class: classProp,
50
66
  tabindex = 0,
51
67
  renderSize = "sm",
68
+ previewMaxDepth = 4,
69
+ fullscreenEdit = true,
52
70
  required = false,
53
71
  disabled = false,
54
72
  // Renamed local binding to avoid collision with `export function validate()` below.
@@ -113,17 +131,54 @@
113
131
  }
114
132
  } else {
115
133
  validation = undefined;
116
- // Pretty-print the JSON before entering edit mode
117
- try {
118
- const obj = JSON.parse(value || "null");
119
- value = JSON.stringify(obj, null, "\t");
120
- } catch {
121
- // leave value as-is if not parseable
122
- }
134
+ prettyPrint();
123
135
  editMode = true;
124
136
  }
125
137
  }
126
138
 
139
+ /** Pretty-print `value` in place; leaves it untouched if not parseable. */
140
+ function prettyPrint() {
141
+ try {
142
+ value = JSON.stringify(JSON.parse(value || "null"), null, "\t");
143
+ } catch {
144
+ // not parseable — leave as-is
145
+ }
146
+ }
147
+
148
+ // --- Fullscreen modal editor (opt-in via `fullscreenEdit`) ---
149
+ let fsModal: Modal | undefined = $state();
150
+ // Snapshot of `value` captured when the modal opens, so Cancel / Escape can
151
+ // discard edits and restore the original (the inline editor has no cancel).
152
+ let fsSnapshot = "";
153
+
154
+ function openFullscreen(openerOrEvent?: MouseEvent) {
155
+ validation = undefined;
156
+ fsSnapshot = value;
157
+ prettyPrint();
158
+ fsModal?.open(openerOrEvent ?? null);
159
+ }
160
+
161
+ function applyFullscreen() {
162
+ // Same rule as the inline toggle: invalid JSON keeps the editor open.
163
+ try {
164
+ JSON.parse(value || "null");
165
+ validation = undefined;
166
+ fsModal?.close();
167
+ } catch {
168
+ validation = {
169
+ valid: false,
170
+ message: "This field requires attention. Please review and try again.",
171
+ };
172
+ }
173
+ }
174
+
175
+ function cancelFullscreen() {
176
+ // discard edits — restore the value as it was when the modal opened
177
+ value = fsSnapshot;
178
+ validation = undefined;
179
+ fsModal?.close();
180
+ }
181
+
127
182
  // Validation
128
183
  let validation: ValidationResult | undefined = $state();
129
184
  const setValidationResult = (res: ValidationResult) => (validation = res);
@@ -167,7 +222,7 @@
167
222
  }
168
223
 
169
224
  const TEXTAREA_CLS =
170
- "w-full min-h-16 p-2 font-mono text-sm focus:outline-none focus:ring-0";
225
+ "w-full min-h-16 p-2 font-mono text-sm focus:outline-none focus:ring-0 scrollbar-thin";
171
226
 
172
227
  const BTN_CLS = [
173
228
  "toggle-btn",
@@ -198,6 +253,13 @@
198
253
  {/if}
199
254
  {/snippet}
200
255
 
256
+ {#snippet moreHint()}
257
+ <span
258
+ class="ml-1 align-middle text-xs italic opacity-40"
259
+ title="Open the editor to view the full structure">more&hellip;</span
260
+ >
261
+ {/snippet}
262
+
201
263
  {#snippet renderValue(val: unknown, depth: number)}
202
264
  {#if val === null || val === undefined || typeof val === "boolean" || typeof val === "number" || typeof val === "string"}
203
265
  {@render renderPrimitive(val)}
@@ -210,6 +272,9 @@
210
272
  <span class="break-words"
211
273
  >{val.map((v) => (v === null ? "null" : String(v))).join(", ")}</span
212
274
  >
275
+ {:else if depth >= previewMaxDepth}
276
+ <!-- Too deep to render legibly — collapse to count + hint -->
277
+ <span class="opacity-50">[{val.length}]</span>{@render moreHint()}
213
278
  {:else}
214
279
  <div class="flex flex-col gap-2">
215
280
  {#each val as item, i (i)}
@@ -236,6 +301,11 @@
236
301
  <span class="opacity-50"
237
302
  >{"{"}&#8239;{entries.map(([k]) => k).join(", ")}&#8239;{"}"}</span
238
303
  >
304
+ {:else if depth >= previewMaxDepth}
305
+ <!-- Too deep to render legibly — collapse to keys + hint -->
306
+ <span class="opacity-50"
307
+ >{"{"}&#8239;{entries.map(([k]) => k).join(", ")}&#8239;{"}"}</span
308
+ >{@render moreHint()}
239
309
  {:else}
240
310
  <div class={twMerge("flex flex-col", depth > 0 && "ml-2")}>
241
311
  {#each entries as [key, v], i (key)}
@@ -310,6 +380,7 @@
310
380
  class={TEXTAREA_CLS}
311
381
  {tabindex}
312
382
  {disabled}
383
+ wrap="off"
313
384
  use:autogrow={() => ({ enabled: true, value })}
314
385
  ></textarea>
315
386
  {:else}
@@ -331,7 +402,7 @@
331
402
  type="button"
332
403
  class={BTN_CLS}
333
404
  bind:this={toggleBtnEl}
334
- onclick={toggleMode}
405
+ onclick={(e) => (fullscreenEdit ? openFullscreen(e) : toggleMode())}
335
406
  {disabled}
336
407
  use:tooltip={() => ({
337
408
  enabled: true,
@@ -374,3 +445,52 @@
374
445
  };
375
446
  }}
376
447
  />
448
+
449
+ {#if fullscreenEdit}
450
+ <!-- Full-screen raw JSON editor (opened by the edit toggle when `fullscreenEdit`) -->
451
+ <Modal
452
+ bind:this={fsModal}
453
+ noClickOutsideClose
454
+ onEscape={() => {
455
+ // Escape behaves like Cancel — discard edits (the dialog closes itself)
456
+ value = fsSnapshot;
457
+ validation = undefined;
458
+ }}
459
+ classDialog="md:size-full"
460
+ classInner="md:h-full md:max-h-full md:min-h-0 md:w-full md:max-w-full"
461
+ classHeader="p-3 flex items-center gap-2 border-b border-(--stuic-color-border)"
462
+ classMain="p-0 flex flex-col overflow-hidden"
463
+ classFooter="p-3 flex items-center border-t border-(--stuic-color-border)"
464
+ >
465
+ {#snippet header()}
466
+ <span class="flex-1 truncate font-semibold">
467
+ {#if typeof label === "function"}
468
+ {@render label({ id })}
469
+ {:else if isTHCNotEmpty(label)}
470
+ <Thc thc={label as THC} />
471
+ {:else}
472
+ Edit JSON
473
+ {/if}
474
+ </span>
475
+ {/snippet}
476
+
477
+ <textarea
478
+ bind:value
479
+ {disabled}
480
+ wrap="off"
481
+ class="min-h-0 w-full flex-1 resize-none p-3 font-mono text-sm scrollbar-thin focus:outline-none focus:ring-0"
482
+ ></textarea>
483
+
484
+ {#snippet footer()}
485
+ <div class="flex w-full items-center justify-between gap-3">
486
+ <span class="text-sm text-red-600 dark:text-red-400">
487
+ {#if validation && !validation.valid}{validation.message}{/if}
488
+ </span>
489
+ <div class="flex gap-2">
490
+ <Button type="button" variant="ghost" onclick={cancelFullscreen}>Cancel</Button>
491
+ <Button type="button" onclick={applyFullscreen}>Apply</Button>
492
+ </div>
493
+ </div>
494
+ {/snippet}
495
+ </Modal>
496
+ {/if}
@@ -14,6 +14,19 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
14
14
  id?: string;
15
15
  tabindex?: number;
16
16
  renderSize?: "sm" | "md" | "lg" | string;
17
+ /**
18
+ * Max nesting depth rendered in (read-only) preview mode before a node is
19
+ * collapsed to a keys-only / `[n]` summary with a "more…" hint. Prevents
20
+ * deeply nested objects from blowing out horizontally and becoming
21
+ * unreadable. Has no effect on raw edit mode. Defaults to 4.
22
+ */
23
+ previewMaxDepth?: number;
24
+ /**
25
+ * When true (the default), the edit toggle opens the raw JSON editor in a
26
+ * (near) full-screen modal — comfortable for large / deeply nested objects.
27
+ * Set `false` to edit inline in a textarea below the preview instead.
28
+ */
29
+ fullscreenEdit?: boolean;
17
30
  required?: boolean;
18
31
  disabled?: boolean;
19
32
  validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.123.0",
3
+ "version": "3.125.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",