@marianmeres/stuic 3.115.0 → 3.117.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 (101) hide show
  1. package/API.md +297 -304
  2. package/dist/actions/dim-behind/index.css +4 -1
  3. package/dist/actions/focus-trap.fixture.svelte +16 -0
  4. package/dist/actions/focus-trap.fixture.svelte.d.ts +7 -0
  5. package/dist/actions/focus-trap.js +3 -1
  6. package/dist/components/Accordion/README.md +17 -17
  7. package/dist/components/Accordion/index.css +4 -2
  8. package/dist/components/AssetsPreview/README.md +7 -7
  9. package/dist/components/AssetsPreview/_internal/assets-preview-types.d.ts +1 -2
  10. package/dist/components/AssetsPreview/_internal/assets-preview-utils.d.ts +1 -1
  11. package/dist/components/AssetsPreview/_internal/assets-preview-utils.js +9 -3
  12. package/dist/components/Avatar/Avatar.svelte +1 -3
  13. package/dist/components/Avatar/README.md +33 -27
  14. package/dist/components/Book/Book.svelte +6 -1
  15. package/dist/components/Book/README.md +22 -20
  16. package/dist/components/Book/index.css +4 -2
  17. package/dist/components/Button/README.md +17 -17
  18. package/dist/components/Card/Card.svelte +25 -8
  19. package/dist/components/Card/README.md +52 -56
  20. package/dist/components/Card/index.css +2 -1
  21. package/dist/components/Carousel/Carousel.svelte +1 -3
  22. package/dist/components/Carousel/README.md +28 -28
  23. package/dist/components/Cart/Cart.svelte +2 -1
  24. package/dist/components/Cart/README.md +25 -25
  25. package/dist/components/Checkout/CheckoutGuestOrLoginForm.svelte +8 -3
  26. package/dist/components/Checkout/CheckoutShippingStep.svelte +1 -2
  27. package/dist/components/Checkout/README.md +143 -130
  28. package/dist/components/CommandMenu/CommandMenu.fixture.svelte +24 -0
  29. package/dist/components/CommandMenu/CommandMenu.fixture.svelte.d.ts +7 -0
  30. package/dist/components/CommandMenu/CommandMenu.svelte +10 -13
  31. package/dist/components/CommandMenu/_internal/command-menu-utils.d.ts +22 -0
  32. package/dist/components/CommandMenu/_internal/command-menu-utils.js +37 -0
  33. package/dist/components/CronInput/CronInput.svelte +64 -60
  34. package/dist/components/CronInput/README.md +46 -46
  35. package/dist/components/DataTable/DataTable.svelte +5 -1
  36. package/dist/components/DataTable/README.md +78 -63
  37. package/dist/components/DropdownMenu/DropdownMenu.svelte +14 -29
  38. package/dist/components/DropdownMenu/README.md +33 -27
  39. package/dist/components/DropdownMenu/_internal/dropdown-menu-search.d.ts +21 -0
  40. package/dist/components/DropdownMenu/_internal/dropdown-menu-search.js +47 -0
  41. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte +2 -9
  42. package/dist/components/EmailVerifyForm/README.md +30 -30
  43. package/dist/components/Header/Header.svelte +161 -165
  44. package/dist/components/Header/README.md +7 -7
  45. package/dist/components/IconSwap/README.md +20 -15
  46. package/dist/components/IconSwap/index.css +2 -1
  47. package/dist/components/ImageCycler/ImageCycler.svelte +19 -5
  48. package/dist/components/ImageCycler/ImageCycler.svelte.d.ts +14 -10
  49. package/dist/components/ImageCycler/README.md +15 -15
  50. package/dist/components/ImageCycler/index.css +26 -20
  51. package/dist/components/Input/FieldFile.svelte +1 -3
  52. package/dist/components/Input/FieldInput.svelte +1 -3
  53. package/dist/components/Input/FieldKeyValues.svelte +2 -6
  54. package/dist/components/Input/FieldObject.svelte +2 -1
  55. package/dist/components/Input/README.md +11 -11
  56. package/dist/components/Input/node_modules/.vite/vitest/d2a04d71301a8915217dd5faf81d12cffd6cd958/_svelte_metadata.json +1 -0
  57. package/dist/components/Input/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/_svelte_metadata.json +1 -0
  58. package/dist/components/KbdShortcut/index.css +2 -1
  59. package/dist/components/LoginForm/LoginForm.svelte +1 -7
  60. package/dist/components/LoginForm/README.md +46 -46
  61. package/dist/components/ModalDialog/ModalDialog.fixture.svelte +19 -0
  62. package/dist/components/ModalDialog/ModalDialog.fixture.svelte.d.ts +4 -0
  63. package/dist/components/ModalDialog/index.css +2 -1
  64. package/dist/components/Notifications/index.css +24 -6
  65. package/dist/components/OtpInput/OtpInput.svelte +0 -0
  66. package/dist/components/OtpInput/README.md +15 -19
  67. package/dist/components/OtpInput/index.css +1 -4
  68. package/dist/components/OtpInput/index.d.ts +1 -1
  69. package/dist/components/OtpInput/index.js +1 -1
  70. package/dist/components/Pill/README.md +41 -40
  71. package/dist/components/Pill/index.css +3 -6
  72. package/dist/components/PricingTable/README.md +86 -86
  73. package/dist/components/PricingTable/index.css +20 -35
  74. package/dist/components/RegisterForm/README.md +60 -60
  75. package/dist/components/RegisterForm/RegisterForm.svelte +1 -7
  76. package/dist/components/Separator/README.md +7 -7
  77. package/dist/components/SlidingPanels/SlidingPanels.fixture.svelte +20 -0
  78. package/dist/components/SlidingPanels/SlidingPanels.fixture.svelte.d.ts +6 -0
  79. package/dist/components/TabbedMenu/index.css +6 -3
  80. package/dist/components/Tree/README.md +67 -67
  81. package/dist/components/UserAvatarMenu/UserAvatarMenu.svelte +1 -5
  82. package/dist/components/WithSidePanel/index.css +4 -4
  83. package/dist/index.css +12 -8
  84. package/dist/utils/design-tokens.d.ts +1 -1
  85. package/dist/utils/design-tokens.js +1 -1
  86. package/docs/architecture.md +7 -7
  87. package/docs/component-testing/00-overview-and-roadmap.md +19 -19
  88. package/docs/component-testing/01-framework-setup.md +6 -6
  89. package/docs/component-testing/02-test-conventions.md +6 -5
  90. package/docs/component-testing/03-component-coverage-roadmap.md +27 -27
  91. package/docs/component-testing/04-hard-cases-and-e2e.md +8 -8
  92. package/docs/component-testing/05-ci.md +3 -3
  93. package/docs/component-testing/PROGRESS.md +118 -26
  94. package/docs/component-testing/README.md +8 -8
  95. package/docs/conventions.md +25 -25
  96. package/docs/domains/components.md +386 -385
  97. package/docs/domains/theming.md +24 -24
  98. package/docs/domains/utils.md +22 -25
  99. package/docs/testing.md +2 -2
  100. package/docs/upgrading.md +32 -28
  101. package/package.json +2 -1
@@ -8,30 +8,30 @@ There is deliberately **no top-level `<Checkout>`** orchestrator. If you want a
8
8
 
9
9
  ### Step containers (one per route/screen)
10
10
 
11
- | Component | Purpose |
12
- | --- | --- |
13
- | `CheckoutReviewStep` | Guest/login + cart review (entry point) |
14
- | `CheckoutShippingStep` | Address + delivery option selection |
15
- | `CheckoutConfirmStep` | Final order review + place-order action |
16
- | `CheckoutCompleteStep` | Post-purchase confirmation screen |
11
+ | Component | Purpose |
12
+ | ---------------------- | --------------------------------------- |
13
+ | `CheckoutReviewStep` | Guest/login + cart review (entry point) |
14
+ | `CheckoutShippingStep` | Address + delivery option selection |
15
+ | `CheckoutConfirmStep` | Final order review + place-order action |
16
+ | `CheckoutCompleteStep` | Post-purchase confirmation screen |
17
17
 
18
18
  Each step container renders a `CheckoutProgress` indicator (unless `hideProgress`), its own heading, and exposes `onBack` / `onContinue` callbacks. The consumer maps those callbacks to route navigation or state changes.
19
19
 
20
20
  ### Building blocks (composed inside steps, or used standalone)
21
21
 
22
- | Component | Purpose |
23
- | --- | --- |
24
- | `CheckoutProgress` | Multi-step progress indicator (accessible stepper) |
25
- | `CheckoutCartReview` | Editable line-item list |
26
- | `CheckoutOrderSummary` | Totals (subtotal/tax/shipping/discount/total) |
27
- | `CheckoutOrderReview` | Read-only order dump (items + addresses + delivery) |
28
- | `CheckoutOrderConfirmation` | Completed-order summary with order number & next steps |
29
- | `CheckoutGuestOrLoginForm` | Guest / login switcher (segmented pill) |
30
- | `CheckoutGuestForm` | Guest-checkout fields |
31
- | `CheckoutLoginForm` | Login (adapts the generic `LoginForm` to checkout i18n) |
32
- | `CheckoutAddressForm` | Structured address input |
33
- | `CheckoutDeliveryOptions` | Delivery-method selector |
34
- | `CheckoutSectionHeader` | Consistent section heading |
22
+ | Component | Purpose |
23
+ | --------------------------- | ------------------------------------------------------- |
24
+ | `CheckoutProgress` | Multi-step progress indicator (accessible stepper) |
25
+ | `CheckoutCartReview` | Editable line-item list |
26
+ | `CheckoutOrderSummary` | Totals (subtotal/tax/shipping/discount/total) |
27
+ | `CheckoutOrderReview` | Read-only order dump (items + addresses + delivery) |
28
+ | `CheckoutOrderConfirmation` | Completed-order summary with order number & next steps |
29
+ | `CheckoutGuestOrLoginForm` | Guest / login switcher (segmented pill) |
30
+ | `CheckoutGuestForm` | Guest-checkout fields |
31
+ | `CheckoutLoginForm` | Login (adapts the generic `LoginForm` to checkout i18n) |
32
+ | `CheckoutAddressForm` | Structured address input |
33
+ | `CheckoutDeliveryOptions` | Delivery-method selector |
34
+ | `CheckoutSectionHeader` | Consistent section heading |
35
35
 
36
36
  ## State ownership
37
37
 
@@ -39,25 +39,25 @@ The consumer owns the entire order shape — typically `CheckoutOrderData` from
39
39
 
40
40
  ```ts
41
41
  import type {
42
- CheckoutOrderData,
43
- CheckoutAddressData,
44
- CheckoutCustomerFormData,
45
- CheckoutLoginFormData,
46
- CheckoutDeliveryOption,
42
+ CheckoutOrderData,
43
+ CheckoutAddressData,
44
+ CheckoutCustomerFormData,
45
+ CheckoutLoginFormData,
46
+ CheckoutDeliveryOption,
47
47
  } from "@marianmeres/stuic";
48
48
  ```
49
49
 
50
50
  **Field → owner table:**
51
51
 
52
- | Field | Owned by | Passed to |
53
- | --- | --- | --- |
54
- | `currentStep` (or equivalent) | Route/page state | `CheckoutProgress`, step visibility logic |
55
- | `CheckoutCustomerFormData` | Page `$state` | `CheckoutGuestForm` (two-way bind) |
56
- | `CheckoutLoginFormData` | Page `$state` | `CheckoutLoginForm` (two-way bind) |
57
- | `shippingAddress`, `billingAddress` | Page `$state` | `CheckoutShippingStep` (two-way bind) — re-used by `CheckoutConfirmStep` for display |
58
- | `selectedDeliveryId` | Page `$state` | `CheckoutShippingStep` (two-way bind) |
59
- | `CheckoutOrderData` (assembled) | Page `$state` / server | `CheckoutOrderReview`, `CheckoutConfirmStep`, `CheckoutCompleteStep` (read-only) |
60
- | Per-field validation errors | Page `$state` (derived from server response) | Forms via `errors` prop; merged with internal errors |
52
+ | Field | Owned by | Passed to |
53
+ | ----------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ |
54
+ | `currentStep` (or equivalent) | Route/page state | `CheckoutProgress`, step visibility logic |
55
+ | `CheckoutCustomerFormData` | Page `$state` | `CheckoutGuestForm` (two-way bind) |
56
+ | `CheckoutLoginFormData` | Page `$state` | `CheckoutLoginForm` (two-way bind) |
57
+ | `shippingAddress`, `billingAddress` | Page `$state` | `CheckoutShippingStep` (two-way bind) — re-used by `CheckoutConfirmStep` for display |
58
+ | `selectedDeliveryId` | Page `$state` | `CheckoutShippingStep` (two-way bind) |
59
+ | `CheckoutOrderData` (assembled) | Page `$state` / server | `CheckoutOrderReview`, `CheckoutConfirmStep`, `CheckoutCompleteStep` (read-only) |
60
+ | Per-field validation errors | Page `$state` (derived from server response) | Forms via `errors` prop; merged with internal errors |
61
61
 
62
62
  Each form exposes a bindable value plus an `errors` prop for server-driven validation messages. Internal client-side validation (via `validateCustomerForm` / `validateAddress` / `validateLoginForm`) fires on submit and populates internal error state, which is merged with the `errors` prop for display.
63
63
 
@@ -67,10 +67,10 @@ Client-side validation helpers live in `@marianmeres/stuic`:
67
67
 
68
68
  ```ts
69
69
  import {
70
- validateCustomerForm,
71
- validateAddress,
72
- validateLoginForm,
73
- validateEmail,
70
+ validateCustomerForm,
71
+ validateAddress,
72
+ validateLoginForm,
73
+ validateEmail,
74
74
  } from "@marianmeres/stuic";
75
75
  ```
76
76
 
@@ -78,8 +78,8 @@ Each returns `CheckoutValidationError[]`:
78
78
 
79
79
  ```ts
80
80
  interface CheckoutValidationError {
81
- field: string; // e.g. "email" or "shipping.street"
82
- message: string;
81
+ field: string; // e.g. "email" or "shipping.street"
82
+ message: string;
83
83
  }
84
84
  ```
85
85
 
@@ -87,36 +87,41 @@ interface CheckoutValidationError {
87
87
 
88
88
  ```svelte
89
89
  <script lang="ts">
90
- import {
91
- CheckoutShippingStep,
92
- CheckoutAddressForm,
93
- validateAddress,
94
- type CheckoutAddressData,
95
- type CheckoutValidationError,
96
- } from "@marianmeres/stuic";
97
-
98
- let shippingAddress = $state<CheckoutAddressData>(/* ... */);
99
- let errors = $state<CheckoutValidationError[]>([]);
100
-
101
- async function onContinue() {
102
- // 1) Client-side gate
103
- const clientErrors = validateAddress(shippingAddress, "shipping", t);
104
- if (clientErrors.length) {
105
- errors = clientErrors;
106
- return;
107
- }
108
- // 2) Server round-trip; merge any server errors
109
- const res = await submitShipping(shippingAddress);
110
- if (!res.ok) {
111
- errors = res.errors;
112
- return;
113
- }
114
- // 3) Advance
115
- goto("/checkout/confirm");
116
- }
90
+ import {
91
+ CheckoutShippingStep,
92
+ CheckoutAddressForm,
93
+ validateAddress,
94
+ type CheckoutAddressData,
95
+ type CheckoutValidationError,
96
+ } from "@marianmeres/stuic";
97
+
98
+ let shippingAddress = $state<CheckoutAddressData>(/* ... */);
99
+ let errors = $state<CheckoutValidationError[]>([]);
100
+
101
+ async function onContinue() {
102
+ // 1) Client-side gate
103
+ const clientErrors = validateAddress(shippingAddress, "shipping", t);
104
+ if (clientErrors.length) {
105
+ errors = clientErrors;
106
+ return;
107
+ }
108
+ // 2) Server round-trip; merge any server errors
109
+ const res = await submitShipping(shippingAddress);
110
+ if (!res.ok) {
111
+ errors = res.errors;
112
+ return;
113
+ }
114
+ // 3) Advance
115
+ goto("/checkout/confirm");
116
+ }
117
117
  </script>
118
118
 
119
- <CheckoutShippingStep bind:shippingAddress {errors} {onContinue} onBack={() => history.back()} />
119
+ <CheckoutShippingStep
120
+ bind:shippingAddress
121
+ {errors}
122
+ {onContinue}
123
+ onBack={() => history.back()}
124
+ />
120
125
  ```
121
126
 
122
127
  The step component **does not auto-advance**. It calls `onContinue` when the user clicks "Continue"; the consumer decides whether to actually advance, retry, or show errors.
@@ -129,8 +134,9 @@ The built-in `defaultFormatPrice(cents)` returns `"12.99"`. Replace it via each
129
134
 
130
135
  ```ts
131
136
  const formatPrice = (cents: number) =>
132
- new Intl.NumberFormat("sk-SK", { style: "currency", currency: "EUR" })
133
- .format(cents / 100);
137
+ new Intl.NumberFormat("sk-SK", { style: "currency", currency: "EUR" }).format(
138
+ cents / 100
139
+ );
134
140
  ```
135
141
 
136
142
  ## i18n
@@ -147,67 +153,74 @@ By default `CheckoutGuestOrLoginForm` in tabbed mode renders an inline `<Checkou
147
153
 
148
154
  ```svelte
149
155
  <script lang="ts">
150
- import {
151
- CheckoutGuestOrLoginForm,
152
- createEmptyCustomerFormData,
153
- createEmptyLoginFormData,
154
- type LoginOrRegisterFormMode,
155
- type LoginFormData,
156
- type RegisterFormData,
157
- } from "@marianmeres/stuic";
158
-
159
- let formData = $state(createEmptyCustomerFormData());
160
- let loginFormData = $state(createEmptyLoginFormData());
161
-
162
- let mode = $state<LoginOrRegisterFormMode>("login");
163
- let verifyEmail = $state("");
164
- let isSubmitting = $state(false);
165
- let formError = $state<string | null>(null);
166
-
167
- const loginProps = $derived({ error: formError ?? undefined, showRememberMe: true });
168
- const registerProps = $derived({ error: formError ?? undefined });
169
- const verifyProps = $derived({ error: formError ?? undefined, heading: false as const });
170
-
171
- async function onLogin(d: LoginFormData) {
172
- // ... call API; on `requiresVerification`, flip:
173
- // verifyEmail = d.email; mode = "verify";
174
- }
175
- async function onRegister(d: RegisterFormData) {
176
- // ... call API; on success, flip to verify:
177
- // verifyEmail = d.email; mode = "verify";
178
- }
179
- async function onVerify(code: string) {
180
- // ... call API; on success, modal closes via consumer-managed state.
181
- }
182
- async function onResendCode() { /* ... */ }
156
+ import {
157
+ CheckoutGuestOrLoginForm,
158
+ createEmptyCustomerFormData,
159
+ createEmptyLoginFormData,
160
+ type LoginOrRegisterFormMode,
161
+ type LoginFormData,
162
+ type RegisterFormData,
163
+ } from "@marianmeres/stuic";
164
+
165
+ let formData = $state(createEmptyCustomerFormData());
166
+ let loginFormData = $state(createEmptyLoginFormData());
167
+
168
+ let mode = $state<LoginOrRegisterFormMode>("login");
169
+ let verifyEmail = $state("");
170
+ let isSubmitting = $state(false);
171
+ let formError = $state<string | null>(null);
172
+
173
+ const loginProps = $derived({ error: formError ?? undefined, showRememberMe: true });
174
+ const registerProps = $derived({ error: formError ?? undefined });
175
+ const verifyProps = $derived({
176
+ error: formError ?? undefined,
177
+ heading: false as const,
178
+ });
179
+
180
+ async function onLogin(d: LoginFormData) {
181
+ // ... call API; on `requiresVerification`, flip:
182
+ // verifyEmail = d.email; mode = "verify";
183
+ }
184
+ async function onRegister(d: RegisterFormData) {
185
+ // ... call API; on success, flip to verify:
186
+ // verifyEmail = d.email; mode = "verify";
187
+ }
188
+ async function onVerify(code: string) {
189
+ // ... call API; on success, modal closes via consumer-managed state.
190
+ }
191
+ async function onResendCode() {
192
+ /* ... */
193
+ }
183
194
  </script>
184
195
 
185
196
  <CheckoutGuestOrLoginForm
186
- formMode="tabbed"
187
- guestForm={{ formData, onSubmit: handleStartCheckout, isSubmitting, errors: [] }}
188
- loginForm={{ formData: loginFormData, onSubmit: onLogin, isSubmitting }}
189
- loginOrRegisterModal={{
190
- mode,
191
- verifyEmail,
192
- onLogin,
193
- onRegister,
194
- onVerify,
195
- onResendCode,
196
- onForgotPassword: () => {/* ... */},
197
- onModeChange: (next) => {
198
- // mirror mode changes back into our local state and clear errors
199
- mode = next;
200
- formError = null;
201
- },
202
- isSubmitting,
203
- loginProps,
204
- registerProps,
205
- verifyProps,
206
- onClose: () => {
207
- formError = null;
208
- mode = "login";
209
- },
210
- }}
197
+ formMode="tabbed"
198
+ guestForm={{ formData, onSubmit: handleStartCheckout, isSubmitting, errors: [] }}
199
+ loginForm={{ formData: loginFormData, onSubmit: onLogin, isSubmitting }}
200
+ loginOrRegisterModal={{
201
+ mode,
202
+ verifyEmail,
203
+ onLogin,
204
+ onRegister,
205
+ onVerify,
206
+ onResendCode,
207
+ onForgotPassword: () => {
208
+ /* ... */
209
+ },
210
+ onModeChange: (next) => {
211
+ // mirror mode changes back into our local state and clear errors
212
+ mode = next;
213
+ formError = null;
214
+ },
215
+ isSubmitting,
216
+ loginProps,
217
+ registerProps,
218
+ verifyProps,
219
+ onClose: () => {
220
+ formError = null;
221
+ mode = "login";
222
+ },
223
+ }}
211
224
  />
212
225
  ```
213
226
 
@@ -237,9 +250,9 @@ Returns `true` when either address is missing or when every `CheckoutAddressData
237
250
 
238
251
  ```ts
239
252
  import {
240
- createEmptyAddress,
241
- createEmptyCustomerFormData,
242
- createEmptyLoginFormData,
253
+ createEmptyAddress,
254
+ createEmptyCustomerFormData,
255
+ createEmptyLoginFormData,
243
256
  } from "@marianmeres/stuic";
244
257
  ```
245
258
 
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ // Conventions escape hatch (docs/component-testing/02-test-conventions.md):
3
+ // CommandMenu is imperative-only (open()/close() via a ref; it builds on
4
+ // ModalDialog which has no bindable `visible` prop), so a `.svelte.test.ts`
5
+ // file can't drive it directly. This fixture holds the `bind:this` ref and
6
+ // exposes an opener button that calls `.open()`. `getOptions` (a required
7
+ // async prop) is forwarded; `value` is bound so a test can inspect the
8
+ // selected option after a pick.
9
+ import CommandMenu from "./CommandMenu.svelte";
10
+
11
+ let cmd = $state<CommandMenu>();
12
+ let { getOptions, value = $bindable() } = $props();
13
+ </script>
14
+
15
+ <button data-testid="opener" onclick={() => cmd?.open()}>open</button>
16
+
17
+ <!--
18
+ Mirror the bound `value` into the DOM so a test can observe what the menu
19
+ selected (render() from vitest-browser-svelte does not hand back bound props).
20
+ The selected option is an item-collection Item; we surface its `id`.
21
+ -->
22
+ <span data-testid="selected">{value?.id ?? ""}</span>
23
+
24
+ <CommandMenu bind:this={cmd} bind:value {getOptions} />
@@ -0,0 +1,7 @@
1
+ import CommandMenu from "./CommandMenu.svelte";
2
+ declare const CommandMenu: import("svelte").Component<{
3
+ getOptions: any;
4
+ value?: any;
5
+ }, {}, "value">;
6
+ type CommandMenu = ReturnType<typeof CommandMenu>;
7
+ export default CommandMenu;
@@ -65,6 +65,11 @@
65
65
 
66
66
  <script lang="ts">
67
67
  import Button from "../Button/Button.svelte";
68
+ import {
69
+ normalizeAndGroupOptions,
70
+ renderOptionLabelOf,
71
+ sortByOptgroupLabel,
72
+ } from "./_internal/command-menu-utils.js";
68
73
 
69
74
  const clog = createClog("CommandMenu");
70
75
 
@@ -86,15 +91,14 @@
86
91
  unstyled = false,
87
92
  }: Props = $props();
88
93
 
94
+ // Pure helpers extracted to _internal/command-menu-utils.ts (node-tested). These
95
+ // thin wrappers keep reading the live props/closures and delegate the logic.
89
96
  function _renderOptionLabel(item: Item): string {
90
- return renderOptionLabel?.(item) || `${item[itemIdPropName]}`;
97
+ return renderOptionLabelOf(item, renderOptionLabel, itemIdPropName);
91
98
  }
92
99
 
93
100
  function sortFn(a: Item, b: Item) {
94
- const withOptGroup = (i: Item) => `${i.optgroup || ""}__${_renderOptionLabel(i)}`;
95
- return withOptGroup(a).localeCompare(withOptGroup(b), undefined, {
96
- sensitivity: "base",
97
- });
101
+ return sortByOptgroupLabel(a, b, _renderOptionLabel);
98
102
  }
99
103
 
100
104
  let modalDialog: ModalDialog = $state()!;
@@ -155,14 +159,7 @@
155
159
 
156
160
  //
157
161
  function _normalize_and_group_options(opts: Item[]): Map<string, Item[]> {
158
- const groupped = new Map<string, Item[]>();
159
- opts.forEach((o) => {
160
- const optgLabel = renderOptionGroup(o.optgroup || "");
161
- if (!groupped.has(optgLabel)) groupped.set(optgLabel, []);
162
- const optgroup = groupped.get(optgLabel);
163
- optgroup!.push(o);
164
- });
165
- return groupped;
162
+ return normalizeAndGroupOptions(opts, renderOptionGroup);
166
163
  }
167
164
 
168
165
  export function close() {
@@ -0,0 +1,22 @@
1
+ import type { Item } from "@marianmeres/item-collection";
2
+ /**
3
+ * Pure option/result helpers for CommandMenu, extracted from the component so they
4
+ * can be unit-tested in the fast node project (no DOM). The actual searchable
5
+ * matching is delegated to `@marianmeres/item-collection`; these are the structural
6
+ * pieces around the results — how a result's label is derived, how results sort, and
7
+ * how they group by optgroup for rendering.
8
+ */
9
+ /** Derive an option's display label: the consumer renderer, else its id prop. */
10
+ export declare function renderOptionLabelOf(item: Item, renderOptionLabel: ((item: Item) => string) | undefined, itemIdPropName: string): string;
11
+ /**
12
+ * Compare two options by "{optgroup}__{label}" using a case-insensitive locale
13
+ * compare, so options sort grouped-then-alphabetical. `renderLabel` derives the
14
+ * label part (typically `renderOptionLabelOf` bound to the component's props).
15
+ */
16
+ export declare function sortByOptgroupLabel(a: Item, b: Item, renderLabel: (item: Item) => string): number;
17
+ /**
18
+ * Group options into a Map keyed by their (rendered) optgroup label, preserving
19
+ * input order within each group. `renderOptionGroup` maps a raw optgroup key to its
20
+ * display label (e.g. underscores → spaces).
21
+ */
22
+ export declare function normalizeAndGroupOptions(opts: Item[], renderOptionGroup: (s: string) => string): Map<string, Item[]>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure option/result helpers for CommandMenu, extracted from the component so they
3
+ * can be unit-tested in the fast node project (no DOM). The actual searchable
4
+ * matching is delegated to `@marianmeres/item-collection`; these are the structural
5
+ * pieces around the results — how a result's label is derived, how results sort, and
6
+ * how they group by optgroup for rendering.
7
+ */
8
+ /** Derive an option's display label: the consumer renderer, else its id prop. */
9
+ export function renderOptionLabelOf(item, renderOptionLabel, itemIdPropName) {
10
+ return renderOptionLabel?.(item) || `${item[itemIdPropName]}`;
11
+ }
12
+ /**
13
+ * Compare two options by "{optgroup}__{label}" using a case-insensitive locale
14
+ * compare, so options sort grouped-then-alphabetical. `renderLabel` derives the
15
+ * label part (typically `renderOptionLabelOf` bound to the component's props).
16
+ */
17
+ export function sortByOptgroupLabel(a, b, renderLabel) {
18
+ const withOptGroup = (i) => `${i.optgroup || ""}__${renderLabel(i)}`;
19
+ return withOptGroup(a).localeCompare(withOptGroup(b), undefined, {
20
+ sensitivity: "base",
21
+ });
22
+ }
23
+ /**
24
+ * Group options into a Map keyed by their (rendered) optgroup label, preserving
25
+ * input order within each group. `renderOptionGroup` maps a raw optgroup key to its
26
+ * display label (e.g. underscores → spaces).
27
+ */
28
+ export function normalizeAndGroupOptions(opts, renderOptionGroup) {
29
+ const groupped = new Map();
30
+ opts.forEach((o) => {
31
+ const optgLabel = renderOptionGroup(o.optgroup || "");
32
+ if (!groupped.has(optgLabel))
33
+ groupped.set(optgLabel, []);
34
+ groupped.get(optgLabel).push(o);
35
+ });
36
+ return groupped;
37
+ }