@marianmeres/stuic 3.66.1 → 3.68.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 (75) hide show
  1. package/dist/actions/autoscroll.d.ts +7 -0
  2. package/dist/actions/autoscroll.js +7 -0
  3. package/dist/actions/focus-trap.d.ts +7 -0
  4. package/dist/actions/focus-trap.js +8 -3
  5. package/dist/actions/typeahead.svelte.js +40 -4
  6. package/dist/components/Carousel/Carousel.svelte +9 -2
  7. package/dist/components/Carousel/README.md +8 -2
  8. package/dist/components/Cart/Cart.svelte +3 -0
  9. package/dist/components/Cart/README.md +18 -1
  10. package/dist/components/Checkout/CheckoutOrderReview.svelte +4 -14
  11. package/dist/components/Checkout/README.md +184 -0
  12. package/dist/components/Checkout/_internal/checkout-utils.d.ts +6 -0
  13. package/dist/components/Checkout/_internal/checkout-utils.js +24 -0
  14. package/dist/components/Checkout/index.d.ts +1 -1
  15. package/dist/components/Checkout/index.js +1 -1
  16. package/dist/components/CommandMenu/CommandMenu.svelte +23 -7
  17. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +2 -0
  18. package/dist/components/CronInput/CronInput.svelte +44 -9
  19. package/dist/components/CronInput/CronInput.svelte.d.ts +2 -0
  20. package/dist/components/CronInput/README.md +145 -0
  21. package/dist/components/CronInput/cron-next-run.svelte.d.ts +11 -0
  22. package/dist/components/CronInput/cron-next-run.svelte.js +11 -0
  23. package/dist/components/CronInput/index.css +0 -8
  24. package/dist/components/DataTable/DataTable.svelte +276 -83
  25. package/dist/components/DataTable/DataTable.svelte.d.ts +58 -6
  26. package/dist/components/DataTable/README.md +155 -25
  27. package/dist/components/DataTable/index.css +31 -0
  28. package/dist/components/DropdownMenu/DropdownMenu.svelte +43 -26
  29. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +5 -1
  30. package/dist/components/DropdownMenu/README.md +37 -9
  31. package/dist/components/Input/FieldAssets.svelte +9 -7
  32. package/dist/components/Input/FieldAssets.svelte.d.ts +3 -7
  33. package/dist/components/Input/FieldFile.svelte +13 -7
  34. package/dist/components/Input/FieldFile.svelte.d.ts +4 -7
  35. package/dist/components/Input/FieldInput.svelte +10 -8
  36. package/dist/components/Input/FieldInput.svelte.d.ts +3 -8
  37. package/dist/components/Input/FieldInputLocalized.svelte +8 -7
  38. package/dist/components/Input/FieldInputLocalized.svelte.d.ts +2 -7
  39. package/dist/components/Input/FieldKeyValues.svelte +8 -7
  40. package/dist/components/Input/FieldKeyValues.svelte.d.ts +2 -7
  41. package/dist/components/Input/FieldLikeButton.svelte +9 -7
  42. package/dist/components/Input/FieldLikeButton.svelte.d.ts +3 -7
  43. package/dist/components/Input/FieldObject.svelte +8 -7
  44. package/dist/components/Input/FieldObject.svelte.d.ts +2 -7
  45. package/dist/components/Input/FieldOptions.svelte +9 -7
  46. package/dist/components/Input/FieldOptions.svelte.d.ts +3 -7
  47. package/dist/components/Input/FieldPhoneNumber.svelte +7 -8
  48. package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +3 -8
  49. package/dist/components/Input/FieldSelect.svelte +9 -8
  50. package/dist/components/Input/FieldSelect.svelte.d.ts +3 -8
  51. package/dist/components/Input/FieldSwitch.svelte +9 -7
  52. package/dist/components/Input/FieldSwitch.svelte.d.ts +3 -7
  53. package/dist/components/Input/FieldTextarea.svelte +7 -8
  54. package/dist/components/Input/FieldTextarea.svelte.d.ts +3 -8
  55. package/dist/components/Input/README.md +20 -0
  56. package/dist/components/Input/_internal/InputWrap.svelte +2 -10
  57. package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -10
  58. package/dist/components/Input/types.d.ts +28 -0
  59. package/dist/components/Nav/Nav.svelte +5 -4
  60. package/dist/components/Nav/Nav.svelte.d.ts +2 -2
  61. package/dist/components/Nav/README.md +2 -2
  62. package/dist/components/Nav/index.css +4 -0
  63. package/dist/components/Tree/README.md +189 -0
  64. package/dist/components/Tree/Tree.svelte +46 -2
  65. package/dist/components/Tree/Tree.svelte.d.ts +5 -0
  66. package/dist/utils/input-history.svelte.d.ts +12 -0
  67. package/dist/utils/input-history.svelte.js +12 -0
  68. package/dist/utils/observe-exists.svelte.d.ts +1 -0
  69. package/dist/utils/observe-exists.svelte.js +11 -3
  70. package/dist/utils/switch.svelte.d.ts +12 -0
  71. package/dist/utils/switch.svelte.js +12 -1
  72. package/docs/architecture.md +0 -1
  73. package/docs/testing.md +72 -0
  74. package/docs/upgrading.md +281 -0
  75. package/package.json +12 -13
@@ -28,14 +28,21 @@ loading state, and mobile card layout.
28
28
 
29
29
  ### With Paging
30
30
 
31
+ DataTable integrates with [`@marianmeres/paging-store`](https://github.com/marianmeres/paging-store). Pass its computed result and receive offsets back via `onPageChange`:
32
+
31
33
  ```svelte
34
+ <script lang="ts">
35
+ import { createPagingStore } from "@marianmeres/paging-store";
36
+
37
+ const paging = createPagingStore({ pageSize: 20, totalItems: 100 });
38
+ $: pagingResult = $paging; // or via $derived in Svelte 5 rune mode
39
+ </script>
40
+
32
41
  <DataTable
33
42
  {columns}
34
43
  {data}
35
- page={1}
36
- pageSize={20}
37
- totalItems={100}
38
- onPageChange={(p) => fetchPage(p)}
44
+ paging={pagingResult}
45
+ onPageChange={(offset) => paging.setOffset(offset)}
39
46
  />
40
47
  ```
41
48
 
@@ -55,13 +62,83 @@ loading state, and mobile card layout.
55
62
  </DataTable>
56
63
  ```
57
64
 
65
+ Shift+click a checkbox to toggle a range of rows from the last clicked checkbox to the current one. Disabled rows in the range are skipped. The anchor resets when `data` changes (e.g. on page navigation).
66
+
67
+ ### Select All Across Pages
68
+
69
+ When your data is paged, DataTable can offer a "select all results" affordance that expands selection beyond the current page. Enable it with `allowSelectAllPages`. When the user opts in, selection flips to **all-pages mode**: every row is implicitly selected, and `excluded` holds IDs the user has explicitly deselected.
70
+
71
+ ```svelte
72
+ <script lang="ts">
73
+ let selected = $state(new Set<string | number>());
74
+ let selectedAll = $state(false);
75
+ let excluded = $state(new Set<string | number>());
76
+ </script>
77
+
78
+ <DataTable
79
+ {columns}
80
+ {data}
81
+ {paging}
82
+ {onPageChange}
83
+ selectable
84
+ allowSelectAllPages
85
+ bind:selected
86
+ bind:selectedAll
87
+ bind:excluded
88
+ getRowId={(row) => row.id}
89
+ >
90
+ {#snippet batchActions({ selectedAll, excluded, effectiveCount, totalCount, clearSelection })}
91
+ <span>{effectiveCount} selected{selectedAll ? ` of ${totalCount}` : ""}</span>
92
+ <Button onclick={() => deleteSelection({ selectedAll, excluded })}>Delete</Button>
93
+ <Button variant="ghost" onclick={clearSelection}>Clear</Button>
94
+ {/snippet}
95
+ </DataTable>
96
+ ```
97
+
98
+ **Important — executing batch operations:** in all-pages mode the off-page rows are not loaded locally, so consumers cannot iterate IDs. Execute operations server-side using the active filter minus `excluded`:
99
+
100
+ ```ts
101
+ function deleteSelection({ selectedAll, excluded }) {
102
+ if (selectedAll) {
103
+ // Server-side: DELETE FROM rows WHERE <currentFilter> AND id NOT IN excluded
104
+ return api.delete({ filter: currentFilter, exclude: [...excluded] });
105
+ }
106
+ return api.delete({ ids: [...selected] });
107
+ }
108
+ ```
109
+
110
+ New records inserted while all-pages mode is active are implicitly selected (they aren't in `excluded`). This matches the conventional intent — "delete everything matching X" should include matches that arrive before the operation runs. If you need snapshot-at-click semantics, capture a timestamp or ID list in your consumer.
111
+
112
+ **Customising the banner:** the default banner uses the built-in `t()` keys `select_all_on_page_x`, `select_all_results`, `all_results_selected`, and `clear_selection`. Override markup entirely with the `selectAllBanner` snippet:
113
+
114
+ ```svelte
115
+ <DataTable {columns} {data} {paging} selectable allowSelectAllPages bind:selected bind:selectedAll bind:excluded>
116
+ {#snippet selectAllBanner({ selectedAll, totalCount, selectAll, clearSelection })}
117
+ <div class="my-banner">
118
+ {#if selectedAll}
119
+ <span>All {totalCount} selected.</span>
120
+ <button onclick={clearSelection}>Undo</button>
121
+ {:else}
122
+ <button onclick={selectAll}>Select all {totalCount}</button>
123
+ {/if}
124
+ </div>
125
+ {/snippet}
126
+ </DataTable>
127
+ ```
128
+
129
+ **Filter changes:** when filters change in the consumer, reset the bound selection stores (`selected`, `selectedAll`, `excluded`) explicitly — DataTable doesn't track which filter produced the current state.
130
+
58
131
  ### Custom Cell Rendering
59
132
 
133
+ The `cell` snippet is used for both desktop and mobile layouts. Use the `variant` param if rendering differs per layout.
134
+
60
135
  ```svelte
61
136
  <DataTable {columns} {data}>
62
- {#snippet cell({ column, row, value })}
137
+ {#snippet cell({ column, row, value, rowIndex, variant })}
63
138
  {#if column.key === "status"}
64
139
  <span class="badge">{value}</span>
140
+ {:else if variant === "mobile"}
141
+ <em>{value}</em>
65
142
  {:else}
66
143
  {value}
67
144
  {/if}
@@ -69,6 +146,34 @@ loading state, and mobile card layout.
69
146
  </DataTable>
70
147
  ```
71
148
 
149
+ ### Custom Desktop Row
150
+
151
+ Replace the entire `<tr>` on desktop. When this snippet is provided, DataTable does not render the default row markup (checkbox, cells) — you own it all. Use for custom expandable rows, row grouping, etc.
152
+
153
+ ```svelte
154
+ <DataTable {columns} {data}>
155
+ {#snippet row({ row, columns, rowIndex, isSelected })}
156
+ <tr data-custom-row>
157
+ {#each columns as col}
158
+ <td>{row[col.key]}</td>
159
+ {/each}
160
+ </tr>
161
+ {/snippet}
162
+ </DataTable>
163
+ ```
164
+
165
+ ### Disabling Selection Per Row
166
+
167
+ ```svelte
168
+ <DataTable
169
+ {columns}
170
+ {data}
171
+ selectable
172
+ bind:selected
173
+ selectDisabledBy={(row) => row.locked === true}
174
+ />
175
+ ```
176
+
72
177
  ### Custom Mobile Layout
73
178
 
74
179
  ```svelte
@@ -84,26 +189,48 @@ loading state, and mobile card layout.
84
189
 
85
190
  ## Props
86
191
 
87
- | Prop | Type | Default | Description |
88
- | -------------- | ---------------------------------- | ------------- | --------------------------- |
89
- | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
90
- | `data` | `T[]` | required | Array of row data |
91
- | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
92
- | `page` | `number` | - | Current page (1-based) |
93
- | `pageSize` | `number` | - | Rows per page |
94
- | `totalItems` | `number` | - | Total items count |
95
- | `onPageChange` | `(page: number) => void` | - | Page change callback |
96
- | `selectable` | `boolean` | `false` | Enable selection checkboxes |
97
- | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
98
- | `onRowClick` | `(row, index) => void` | - | Row click callback |
99
- | `loading` | `boolean` | `false` | Show loading overlay |
100
- | `cell` | `Snippet` | - | Custom cell renderer |
101
- | `batchActions` | `Snippet` | - | Batch action bar content |
102
- | `empty` | `Snippet` | - | Custom empty state |
103
- | `mobileRow` | `Snippet` | - | Custom mobile row card |
104
- | `unstyled` | `boolean` | `false` | Skip default styling |
105
- | `class` | `string` | - | Additional CSS classes |
106
- | `el` | `HTMLDivElement` | - | Bindable element ref |
192
+ | Prop | Type | Default | Description |
193
+ | ------------------- | ------------------------------------------ | ------------- | ----------------------------------------------------------------- |
194
+ | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
195
+ | `data` | `T[]` | required | Array of row data |
196
+ | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
197
+ | `paging` | `PagingCalcResult` | - | Paging state (from `@marianmeres/paging-store`) |
198
+ | `onPageChange` | `(offset: number) => void` | - | Called with the new offset when the user navigates pages |
199
+ | `selectable` | `boolean` | `false` | Enable selection checkboxes |
200
+ | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
201
+ | `selectOnRowClick` | `boolean` | `false` | Clicking anywhere on a row toggles its selection |
202
+ | `selectDisabledBy` | `(row, index) => boolean` | - | Return `true` to disable selection for a specific row |
203
+ | `allowSelectAllPages` | `boolean` | `false` | Show a banner offering "select all results" across paged data |
204
+ | `selectedAll` | `boolean` | `false` | All-pages mode flag (bindable). In this mode `excluded` drives selection |
205
+ | `excluded` | `Set<string \| number>` | `new Set()` | Deselected row IDs while in all-pages mode (bindable) |
206
+ | `onRowClick` | `(row, index) => void` | - | Row click callback |
207
+ | `loading` | `boolean` | `false` | Show loading overlay |
208
+ | `small` | `boolean` | `false` | Force mobile/card layout regardless of viewport |
209
+ | `t` | `TranslateFn` | built-in | Optional translation function |
210
+ | `cell` | `Snippet` | - | Custom cell renderer (desktop + mobile) |
211
+ | `row` | `Snippet` | - | Custom desktop `<tr>` renderer (overrides default row) |
212
+ | `mobileRow` | `Snippet` | - | Custom mobile card renderer |
213
+ | `batchActions` | `Snippet` | - | Batch action bar content |
214
+ | `selectAllBanner` | `Snippet` | - | Override default "select all across pages" banner |
215
+ | `empty` | `Snippet` | - | Custom empty state |
216
+ | `unstyled` | `boolean` | `false` | Skip default styling |
217
+ | `class` | `string` | - | Additional CSS classes |
218
+ | `el` | `HTMLDivElement` | - | Bindable element ref |
219
+
220
+ ### Snippet signatures
221
+
222
+ | Snippet | Props |
223
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
224
+ | `cell` | `{ column, row, value, rowIndex, variant: "desktop" \| "mobile" }` |
225
+ | `row` | `{ row, columns, rowIndex, isSelected }` — desktop only |
226
+ | `mobileRow` | `{ row, columns, rowIndex }` — mobile only |
227
+ | `batchActions` | `{ selected, selectedRows, selectedAll, excluded, effectiveCount, totalCount, clearSelection }` |
228
+ | `selectAllBanner` | `{ selectedAll, effectiveCount, totalCount, pageCount, selectAll, clearSelection }` |
229
+ | `empty` | — |
230
+
231
+ > **Note:** "Select all rows" affects only the rows currently in `data` (i.e. the current page when using external paging). Rows for which `selectDisabledBy` returns `true` are excluded from "select all". To select across pages, enable `allowSelectAllPages` and use the banner that appears.
232
+ >
233
+ > **Note:** `clearSelection` (exposed by the `batchActions` snippet) resets all selection state — `selected`, `selectedAll`, and `excluded` — including exiting all-pages mode.
107
234
 
108
235
  ## DataTableColumn
109
236
 
@@ -143,3 +270,6 @@ loading state, and mobile card layout.
143
270
  | `--stuic-data-table-card-radius` | `var(--radius-md)` | Mobile card radius |
144
271
  | `--stuic-data-table-card-padding` | `0.75rem` | Mobile card padding |
145
272
  | `--stuic-data-table-card-gap` | `0.5rem` | Gap between mobile cards |
273
+ | `--stuic-data-table-select-all-bg` | `color-mix(primary 10%)` | Select-all banner background |
274
+ | `--stuic-data-table-select-all-padding-x` | `0.75rem` | Banner horizontal padding |
275
+ | `--stuic-data-table-select-all-padding-y` | `0.5rem` | Banner vertical padding |
@@ -38,6 +38,11 @@
38
38
  --stuic-data-table-batch-padding-x: 0.75rem;
39
39
  --stuic-data-table-batch-padding-y: 0.5rem;
40
40
 
41
+ /* Select-all-across-pages banner */
42
+ --stuic-data-table-select-all-bg: color-mix(in srgb, var(--stuic-color-primary) 10%, var(--stuic-color-background));
43
+ --stuic-data-table-select-all-padding-x: 0.75rem;
44
+ --stuic-data-table-select-all-padding-y: 0.5rem;
45
+
41
46
  /* Checkbox column */
42
47
  --stuic-data-table-checkbox-width: 3rem;
43
48
 
@@ -210,6 +215,25 @@
210
215
  margin-bottom: 0.5rem;
211
216
  }
212
217
 
218
+ /* ============================================================================
219
+ SELECT-ALL-ACROSS-PAGES BANNER
220
+ ============================================================================ */
221
+
222
+ .stuic-data-table-select-all-banner {
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ gap: var(--stuic-data-table-paging-gap);
227
+ padding: var(--stuic-data-table-select-all-padding-y)
228
+ var(--stuic-data-table-select-all-padding-x);
229
+ background: var(--stuic-data-table-select-all-bg);
230
+ border-radius: var(--stuic-data-table-radius, var(--stuic-radius));
231
+ margin-bottom: 0.5rem;
232
+ font-size: var(--stuic-data-table-cell-font-size);
233
+ color: var(--stuic-data-table-header-color);
234
+ text-align: center;
235
+ }
236
+
213
237
  /* ============================================================================
214
238
  LOADING SPINNER OVERLAY
215
239
  ============================================================================ */
@@ -278,6 +302,13 @@
278
302
  background: var(--stuic-data-table-card-bg-selected);
279
303
  }
280
304
 
305
+ .stuic-data-table-card-checkbox {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 0.5rem;
309
+ margin-bottom: 0.25rem;
310
+ }
311
+
281
312
  .stuic-data-table-card-row {
282
313
  display: flex;
283
314
  justify-content: space-between;
@@ -201,6 +201,10 @@
201
201
  triggerEl?: HTMLButtonElement;
202
202
  /** Reference to dropdown element */
203
203
  dropdownEl?: HTMLDivElement;
204
+ /** Reference to the wrapper element */
205
+ el?: HTMLDivElement;
206
+ /** Opt out of stuic base classes for full styling control */
207
+ unstyled?: boolean;
204
208
  /** Optional, used only when css positioning not supported (iPhone)*/
205
209
  noScrollLock?: boolean;
206
210
  }
@@ -298,6 +302,8 @@
298
302
  onSelect,
299
303
  triggerEl = $bindable(),
300
304
  dropdownEl = $bindable(),
305
+ el = $bindable(),
306
+ unstyled = false,
301
307
  scrollbarGutter,
302
308
  noScrollLock,
303
309
  ...rest
@@ -309,7 +315,6 @@
309
315
  const anchorName = `--dropdown-anchor-${triggerId}`;
310
316
 
311
317
  // State
312
- let wrapperEl: HTMLDivElement = $state()!;
313
318
  let activeItemEl: HTMLButtonElement | undefined = $state();
314
319
  const reducedMotion = prefersReducedMotion();
315
320
 
@@ -615,7 +620,7 @@
615
620
 
616
621
  // Click outside handler — only active when open (prevents stale refs on destroy)
617
622
  const _clickOutside = onClickOutside(
618
- () => wrapperEl,
623
+ () => el,
619
624
  () => {
620
625
  if (closeOnClickOutside && isOpen) {
621
626
  isOpen = false;
@@ -626,7 +631,7 @@
626
631
  );
627
632
 
628
633
  $effect(() => {
629
- if (isOpen && wrapperEl) _clickOutside.start();
634
+ if (isOpen && el) _clickOutside.start();
630
635
  else _clickOutside.stop();
631
636
  });
632
637
 
@@ -808,8 +813,8 @@
808
813
  />
809
814
 
810
815
  <div
811
- bind:this={wrapperEl}
812
- class={twMerge(DROPDOWN_MENU_BASE_CLASSES, classProp)}
816
+ bind:this={el}
817
+ class={unstyled ? classProp : twMerge(DROPDOWN_MENU_BASE_CLASSES, classProp)}
813
818
  style:anchor-name={isSupported ? anchorName : undefined}
814
819
  >
815
820
  <!-- Trigger -->
@@ -830,7 +835,7 @@
830
835
  <button
831
836
  bind:this={triggerEl}
832
837
  id={triggerId}
833
- class={twMerge(DROPDOWN_MENU_TRIGGER_CLASSES, classTrigger)}
838
+ class={unstyled ? classTrigger : twMerge(DROPDOWN_MENU_TRIGGER_CLASSES, classTrigger)}
834
839
  onclick={() => (isOpen = !isOpen)}
835
840
  aria-haspopup="menu"
836
841
  aria-expanded={isOpen}
@@ -851,15 +856,15 @@
851
856
 
852
857
  <!-- Backdrop (fallback mode only) -->
853
858
  {#if isOpen && !isSupported && showBackdrop}
859
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
854
860
  <div
855
- class={twMerge(DROPDOWN_MENU_BACKDROP_CLASSES, classBackdrop)}
861
+ class={unstyled ? classBackdrop : twMerge(DROPDOWN_MENU_BACKDROP_CLASSES, classBackdrop)}
856
862
  onclick={() => {
857
863
  if (closeOnClickOutside) {
858
864
  isOpen = false;
859
865
  triggerEl?.focus();
860
866
  }
861
867
  }}
862
- onkeydown={() => {}}
863
868
  role="presentation"
864
869
  transition:fade={{ duration: transitionDuration }}
865
870
  ></div>
@@ -872,11 +877,13 @@
872
877
  id={dropdownId}
873
878
  role="menu"
874
879
  aria-labelledby={triggerId}
875
- class={twMerge(
876
- DROPDOWN_MENU_DROPDOWN_CLASSES,
877
- !isSupported && "w-4/5 max-w-32",
878
- classDropdown
879
- )}
880
+ class={unstyled
881
+ ? twMerge(!isSupported && "w-4/5 max-w-sm", classDropdown)
882
+ : twMerge(
883
+ DROPDOWN_MENU_DROPDOWN_CLASSES,
884
+ !isSupported && "w-4/5 max-w-sm",
885
+ classDropdown
886
+ )}
880
887
  style={dropdownStyle}
881
888
  transition:slide={{ duration: transitionDuration }}
882
889
  >
@@ -886,7 +893,9 @@
886
893
  <button
887
894
  type="button"
888
895
  aria-label="Close"
889
- class="stuic-close-button absolute right-0 top-0 pointer-events-auto"
896
+ class={unstyled
897
+ ? "absolute right-0 top-0 pointer-events-auto"
898
+ : "stuic-close-button absolute right-0 top-0 pointer-events-auto"}
890
899
  onclick={() => {
891
900
  isOpen = false;
892
901
  triggerEl?.focus();
@@ -957,12 +966,16 @@
957
966
  {:else if item.type === "divider"}
958
967
  <div
959
968
  role="separator"
960
- class={twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
969
+ class={unstyled
970
+ ? twMerge(classDivider, item.class)
971
+ : twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
961
972
  ></div>
962
973
  {:else if item.type === "header"}
963
974
  <div
964
975
  role="presentation"
965
- class={twMerge(DROPDOWN_MENU_HEADER_CLASSES, classHeader, item.class)}
976
+ class={unstyled
977
+ ? twMerge(classHeader, item.class)
978
+ : twMerge(DROPDOWN_MENU_HEADER_CLASSES, classHeader, item.class)}
966
979
  >
967
980
  <Thc thc={item.label} />
968
981
  </div>
@@ -1043,20 +1056,24 @@
1043
1056
  {#if childItem.type === "divider"}
1044
1057
  <div
1045
1058
  role="separator"
1046
- class={twMerge(
1047
- DROPDOWN_MENU_DIVIDER_CLASSES,
1048
- classDivider,
1049
- childItem.class
1050
- )}
1059
+ class={unstyled
1060
+ ? twMerge(classDivider, childItem.class)
1061
+ : twMerge(
1062
+ DROPDOWN_MENU_DIVIDER_CLASSES,
1063
+ classDivider,
1064
+ childItem.class
1065
+ )}
1051
1066
  ></div>
1052
1067
  {:else if childItem.type === "header"}
1053
1068
  <div
1054
1069
  role="presentation"
1055
- class={twMerge(
1056
- DROPDOWN_MENU_HEADER_CLASSES,
1057
- classHeader,
1058
- childItem.class
1059
- )}
1070
+ class={unstyled
1071
+ ? twMerge(classHeader, childItem.class)
1072
+ : twMerge(
1073
+ DROPDOWN_MENU_HEADER_CLASSES,
1074
+ classHeader,
1075
+ childItem.class
1076
+ )}
1060
1077
  >
1061
1078
  <Thc thc={childItem.label} />
1062
1079
  </div>
@@ -170,6 +170,10 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
170
170
  triggerEl?: HTMLButtonElement;
171
171
  /** Reference to dropdown element */
172
172
  dropdownEl?: HTMLDivElement;
173
+ /** Reference to the wrapper element */
174
+ el?: HTMLDivElement;
175
+ /** Opt out of stuic base classes for full styling control */
176
+ unstyled?: boolean;
173
177
  /** Optional, used only when css positioning not supported (iPhone)*/
174
178
  noScrollLock?: boolean;
175
179
  }
@@ -179,6 +183,6 @@ export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "stuic-dropdown-menu-dropd
179
183
  export declare const DROPDOWN_MENU_DIVIDER_CLASSES = "stuic-dropdown-menu-divider";
180
184
  export declare const DROPDOWN_MENU_HEADER_CLASSES = "stuic-dropdown-menu-header";
181
185
  export declare const DROPDOWN_MENU_BACKDROP_CLASSES = "stuic-dropdown-menu-backdrop";
182
- declare const DropdownMenu: import("svelte").Component<Props, {}, "isOpen" | "triggerEl" | "dropdownEl">;
186
+ declare const DropdownMenu: import("svelte").Component<Props, {}, "el" | "isOpen" | "triggerEl" | "dropdownEl">;
183
187
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
184
188
  export default DropdownMenu;
@@ -8,13 +8,15 @@ A feature-rich dropdown menu component with CSS Anchor Positioning (with fallbac
8
8
  | ------------------------ | ---------------------- | -------------------- | ---------------------------------------- |
9
9
  | `items` | `DropdownMenuItem[]` | - | Menu items to display |
10
10
  | `isOpen` | `boolean` | `false` | Controlled open state (bindable) |
11
- | `position` | `DropdownMenuPosition` | `"bottom-span-left"` | Popover position relative to trigger |
11
+ | `position` | `DropdownMenuPosition` | `"bottom-span-right"` | Popover position relative to trigger |
12
12
  | `offset` | `string` | `"0.25rem"` | Offset from trigger element (CSS value) |
13
13
  | `maxHeight` | `string` | `"300px"` | Max height of dropdown |
14
14
  | `closeOnSelect` | `boolean` | `true` | Close menu when action item is selected |
15
15
  | `closeOnClickOutside` | `boolean` | `true` | Close on click outside |
16
16
  | `closeOnEscape` | `boolean` | `true` | Close on Escape key |
17
17
  | `forceFallback` | `boolean` | `false` | Force fallback positioning (for testing) |
18
+ | `search` | `boolean \| DropdownMenuSearchConfig` | - | Enable search/filter input (see [Search](#search)) |
19
+ | `unstyled` | `boolean` | `false` | Opt out of stuic base classes |
18
20
  | `class` | `string` | - | Classes for wrapper element |
19
21
  | `classTrigger` | `string` | - | Classes for trigger button |
20
22
  | `classDropdown` | `string` | - | Classes for dropdown container |
@@ -25,6 +27,7 @@ A feature-rich dropdown menu component with CSS Anchor Positioning (with fallbac
25
27
  | `classHeader` | `string` | - | Classes for header items |
26
28
  | `classExpandable` | `string` | - | Classes for expandable section header |
27
29
  | `classExpandableContent` | `string` | - | Classes for expandable section content |
30
+ | `el` | `HTMLDivElement` | - | Wrapper element reference (bindable) |
28
31
  | `triggerEl` | `HTMLButtonElement` | - | Trigger element reference (bindable) |
29
32
  | `dropdownEl` | `HTMLDivElement` | - | Dropdown element reference (bindable) |
30
33
 
@@ -39,15 +42,15 @@ A feature-rich dropdown menu component with CSS Anchor Positioning (with fallbac
39
42
 
40
43
  ### Action Item
41
44
 
42
- Clickable menu item with optional icon and shortcut.
45
+ Clickable menu item with optional leading/trailing content.
43
46
 
44
47
  ```typescript
45
48
  interface DropdownMenuActionItem {
46
49
  type: "action";
47
50
  id: string | number;
48
51
  label: THC; // Text, HTML, or component
49
- icon?: THC; // Optional leading icon
50
- shortcut?: string; // Keyboard shortcut hint
52
+ contentBefore?: THC; // Optional leading content (icon, etc.)
53
+ contentAfter?: THC; // Optional trailing content (shortcut hint, etc.)
51
54
  disabled?: boolean;
52
55
  onSelect?: () => void | boolean;
53
56
  href?: string; // Render as <a> link instead of <button>
@@ -104,7 +107,7 @@ interface DropdownMenuExpandableItem {
104
107
  type: "expandable";
105
108
  id: string | number;
106
109
  label: THC;
107
- icon?: THC;
110
+ contentBefore?: THC;
108
111
  items: DropdownMenuFlatItem[]; // Nested items (no nested expandables)
109
112
  defaultExpanded?: boolean;
110
113
  disabled?: boolean;
@@ -131,6 +134,29 @@ interface DropdownMenuExpandableItem {
131
134
  | `onClose` | - | Called when menu closes |
132
135
  | `onSelect` | `(item: DropdownMenuActionItem)` | Called when action item selected (fallback) |
133
136
 
137
+ ## Search
138
+
139
+ Pass `search` to enable an in-menu filter input. Use `true` for defaults or a config object:
140
+
141
+ ```typescript
142
+ interface DropdownMenuSearchConfig {
143
+ placeholder?: string;
144
+ /** Match algorithm */
145
+ strategy?: "prefix" | "exact" | "fuzzy";
146
+ /** Custom function to extract searchable text from an item */
147
+ getContent?: (item: DropdownMenuActionItem | DropdownMenuExpandableItem) => string;
148
+ /** Auto-focus search input when menu opens */
149
+ autoFocus?: boolean;
150
+ /** Message shown when no results found */
151
+ noResultsMessage?: string;
152
+ }
153
+ ```
154
+
155
+ ```svelte
156
+ <DropdownMenu {items} search />
157
+ <DropdownMenu {items} search={{ placeholder: "Filter…", strategy: "fuzzy" }} />
158
+ ```
159
+
134
160
  ## Keyboard Navigation
135
161
 
136
162
  | Key | Action |
@@ -186,6 +212,8 @@ When `href` is set, the item renders as an `<a>` element instead of `<button>`.
186
212
 
187
213
  ### With Icons and Shortcuts
188
214
 
215
+ Use `contentBefore` for leading content (icons) and `contentAfter` for trailing content (shortcut hints). Both accept any `THC` value — string, HTML, or component.
216
+
189
217
  ```svelte
190
218
  <script lang="ts">
191
219
  import { DropdownMenu } from "stuic";
@@ -197,8 +225,8 @@ When `href` is set, the item renders as an `<a>` element instead of `<button>`.
197
225
  type: "action",
198
226
  id: "edit",
199
227
  label: "Edit",
200
- icon: iconLucideEdit({ size: 16 }),
201
- shortcut: "Cmd+E",
228
+ contentBefore: { html: iconLucideEdit({ size: 16 }) },
229
+ contentAfter: "Cmd+E",
202
230
  onSelect: () => handleEdit(),
203
231
  },
204
232
  { type: "divider" },
@@ -206,8 +234,8 @@ When `href` is set, the item renders as an `<a>` element instead of `<button>`.
206
234
  type: "action",
207
235
  id: "delete",
208
236
  label: "Delete",
209
- icon: iconLucideTrash({ size: 16 }),
210
- shortcut: "Cmd+D",
237
+ contentBefore: { html: iconLucideTrash({ size: 16 }) },
238
+ contentAfter: "Cmd+D",
211
239
  onSelect: () => handleDelete(),
212
240
  },
213
241
  ];
@@ -37,6 +37,7 @@
37
37
  import SpinnerCircleOscillate from "../Spinner/SpinnerCircleOscillate.svelte";
38
38
  import { isTHCNotEmpty, type THC } from "../Thc/Thc.svelte";
39
39
  import InputWrap from "./_internal/InputWrap.svelte";
40
+ import type { InputWrapClassProps } from "./types.js";
40
41
  import Button from "../Button/Button.svelte";
41
42
 
42
43
  const clog = createClog("FieldAssets");
@@ -126,7 +127,7 @@
126
127
 
127
128
  type SnippetWithId = Snippet<[{ id: string }]>;
128
129
 
129
- export interface Props extends Record<string, any> {
130
+ export interface Props extends InputWrapClassProps, Record<string, any> {
130
131
  value: string;
131
132
  label?: SnippetWithId | THC;
132
133
  type?: string;
@@ -145,13 +146,8 @@
145
146
  labelLeft?: boolean;
146
147
  labelLeftWidth?: "normal" | "wide";
147
148
  labelLeftBreakpoint?: number;
149
+ /** Classes for the hidden <input> element */
148
150
  classInput?: string;
149
- classLabel?: string;
150
- classLabelBox?: string;
151
- classInputBox?: string;
152
- classInputBoxWrap?: string;
153
- classDescBox?: string;
154
- classBelowBox?: string;
155
151
  classOption?: string;
156
152
  classOptionActive?: string;
157
153
  classOptgroup?: string;
@@ -205,8 +201,11 @@
205
201
  classLabelBox,
206
202
  classInputBox,
207
203
  classInputBoxWrap,
204
+ classInputBoxWrapInvalid,
208
205
  classDescBox,
206
+ classDescBoxToggle,
209
207
  classBelowBox,
208
+ classValidationBox,
210
209
  //
211
210
  classOption,
212
211
  classOptionActive,
@@ -503,8 +502,11 @@
503
502
  {classLabelBox}
504
503
  {classInputBox}
505
504
  {classInputBoxWrap}
505
+ {classInputBoxWrapInvalid}
506
506
  {classDescBox}
507
+ {classDescBoxToggle}
507
508
  {classBelowBox}
509
+ {classValidationBox}
508
510
  {validation}
509
511
  {style}
510
512
  >
@@ -3,6 +3,7 @@ import { type ValidateOptions } from "../../actions/validate.svelte.js";
3
3
  import type { TranslateFn } from "../../types.js";
4
4
  import { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
5
5
  import { type THC } from "../Thc/Thc.svelte";
6
+ import type { InputWrapClassProps } from "./types.js";
6
7
  export type FieldAssetUrlObj = {
7
8
  thumb: string;
8
9
  full: string;
@@ -23,7 +24,7 @@ export declare function getAssetIcon(ext?: string): CallableFunction;
23
24
  type SnippetWithId = Snippet<[{
24
25
  id: string;
25
26
  }]>;
26
- export interface Props extends Record<string, any> {
27
+ export interface Props extends InputWrapClassProps, Record<string, any> {
27
28
  value: string;
28
29
  label?: SnippetWithId | THC;
29
30
  type?: string;
@@ -42,13 +43,8 @@ export interface Props extends Record<string, any> {
42
43
  labelLeft?: boolean;
43
44
  labelLeftWidth?: "normal" | "wide";
44
45
  labelLeftBreakpoint?: number;
46
+ /** Classes for the hidden <input> element */
45
47
  classInput?: string;
46
- classLabel?: string;
47
- classLabelBox?: string;
48
- classInputBox?: string;
49
- classInputBoxWrap?: string;
50
- classDescBox?: string;
51
- classBelowBox?: string;
52
48
  classOption?: string;
53
49
  classOptionActive?: string;
54
50
  classOptgroup?: string;