@marianmeres/stuic 2.0.0-next.5 → 2.0.2

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 (61) hide show
  1. package/dist/actions/file-dropzone.svelte.d.ts +8 -0
  2. package/dist/actions/file-dropzone.svelte.js +43 -0
  3. package/dist/actions/highlight-dragover.svelte.js +16 -3
  4. package/dist/actions/index.d.ts +2 -0
  5. package/dist/actions/index.js +2 -0
  6. package/dist/actions/resizable-width.svelte.d.ts +21 -0
  7. package/dist/actions/resizable-width.svelte.js +162 -0
  8. package/dist/actions/validate.svelte.js +13 -13
  9. package/dist/components/Backdrop/Backdrop.svelte +1 -1
  10. package/dist/components/Button/Button.svelte +1 -1
  11. package/dist/components/Button/Button.svelte.d.ts +1 -1
  12. package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte +47 -12
  13. package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte.d.ts +3 -2
  14. package/dist/components/ButtonGroupRadio/index.css +11 -2
  15. package/dist/components/ButtonGroupRadio/index.d.ts +1 -0
  16. package/dist/components/ButtonGroupRadio/index.js +1 -0
  17. package/dist/components/CommandMenu/CommandMenu.svelte +365 -0
  18. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +25 -0
  19. package/dist/components/CommandMenu/index.d.ts +1 -0
  20. package/dist/components/CommandMenu/index.js +1 -0
  21. package/dist/components/Input/FieldInput.svelte +1 -0
  22. package/dist/components/Input/FieldLikeButton.svelte +16 -7
  23. package/dist/components/Input/FieldLikeButton.svelte.d.ts +1 -1
  24. package/dist/components/Input/FieldOptions.svelte +278 -120
  25. package/dist/components/Input/FieldOptions.svelte.d.ts +15 -8
  26. package/dist/components/Input/_internal/InputWrap.svelte +7 -6
  27. package/dist/components/Modal/Modal.svelte +10 -5
  28. package/dist/components/ModalDialog/ModalDialog.svelte +25 -0
  29. package/dist/components/Notifications/Notifications.svelte +1 -1
  30. package/dist/components/Progress/_internal/Bar.svelte +1 -1
  31. package/dist/components/Spinner/SpinnerUnicode.svelte +130 -0
  32. package/dist/components/Spinner/SpinnerUnicode.svelte.d.ts +12 -0
  33. package/dist/components/Spinner/index.d.ts +1 -0
  34. package/dist/components/Spinner/index.js +1 -0
  35. package/dist/components/TypeaheadInput/TypeaheadInput.svelte +261 -0
  36. package/dist/components/TypeaheadInput/TypeaheadInput.svelte.d.ts +40 -0
  37. package/dist/components/TypeaheadInput/index.d.ts +1 -0
  38. package/dist/components/TypeaheadInput/index.js +1 -0
  39. package/dist/index.css +1 -0
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.js +3 -0
  42. package/dist/types.d.ts +1 -0
  43. package/dist/utils/escape-regex.d.ts +1 -0
  44. package/dist/utils/escape-regex.js +1 -0
  45. package/dist/utils/event-emitter.d.ts +18 -0
  46. package/dist/utils/event-emitter.js +40 -0
  47. package/dist/utils/index.d.ts +5 -0
  48. package/dist/utils/index.js +5 -0
  49. package/dist/utils/is-plain-object.d.ts +2 -0
  50. package/dist/utils/is-plain-object.js +4 -0
  51. package/dist/utils/replace-map.d.ts +5 -0
  52. package/dist/utils/replace-map.js +22 -0
  53. package/dist/utils/seconds.d.ts +7 -0
  54. package/dist/utils/seconds.js +35 -0
  55. package/dist/utils/tw-merge.d.ts +2 -0
  56. package/dist/utils/tw-merge.js +4 -0
  57. package/dist/utils/unaccent.d.ts +6 -0
  58. package/dist/utils/unaccent.js +8 -0
  59. package/package.json +70 -66
  60. package/dist/components/ColResize/ColResize.svelte +0 -0
  61. package/dist/components/ColResize/ColResize.svelte.d.ts +0 -26
@@ -0,0 +1,365 @@
1
+ <script lang="ts" module>
2
+ import { ItemCollection, type Item } from "@marianmeres/item-collection";
3
+ import { Modal } from "../Modal/index.js";
4
+ import { createClog } from "@marianmeres/clog";
5
+ import { FieldInput } from "../Input/index.js";
6
+ import { twMerge } from "../../utils/tw-merge.js";
7
+ import { iconBsSearch as iconSearch } from "@marianmeres/icons-fns/bootstrap/iconBsSearch.js";
8
+ import { X } from "../X/index.js";
9
+ import { Debounced, watch } from "runed";
10
+ import { NotificationsStack } from "../Notifications/index.js";
11
+ import { Spinner } from "../Spinner/index.js";
12
+ import { strHash } from "../../utils/str-hash.js";
13
+ import { qsa } from "../../utils/qsa.js";
14
+ import { replaceMap } from "../../utils/index.js";
15
+ import { isPlainObject } from "../../utils/is-plain-object.js";
16
+ import type { TranslateFn } from "../../types.js";
17
+
18
+ // i18n ready
19
+ function t_default(
20
+ k: string,
21
+ values: false | null | undefined | Record<string, string | number> = null,
22
+ fallback: string | boolean = "",
23
+ i18nSpanWrap: boolean = true
24
+ ) {
25
+ // special case args shortcut: ak values je explicit false, tak to chapeme ako
26
+ // i18nSpanWrap = false
27
+ if (values === false) {
28
+ values = null;
29
+ fallback = "";
30
+ i18nSpanWrap = false;
31
+ }
32
+ const m: Record<string, string> = {
33
+ search_placeholder: "Type to search...",
34
+ x_close: "Clear input or close [esc]",
35
+ no_results: 'No results found for "{{q}}".',
36
+ select_from_list: "Please select from the list",
37
+ };
38
+ let out = m[k] ?? fallback ?? k;
39
+
40
+ return isPlainObject(values) ? replaceMap(out, values as any) : out;
41
+ }
42
+ </script>
43
+
44
+ <script lang="ts">
45
+ const clog = createClog("CommandMenu");
46
+
47
+ interface Props {
48
+ input?: HTMLInputElement;
49
+ value: any;
50
+ getOptions: (s: string, current: Item[]) => Promise<Item[]>;
51
+ renderOptionLabel?: (item: Item) => string;
52
+ renderOptionGroup?: (s: string) => string;
53
+ t?: TranslateFn;
54
+ notifications?: NotificationsStack;
55
+ itemIdPropName?: string;
56
+ searchPlaceholder?: string;
57
+ //
58
+ noScrollLock?: boolean;
59
+ q?: string;
60
+ //
61
+ classOption?: string;
62
+ classOptionActive?: string;
63
+ showAllOnEmptyQ?: boolean;
64
+ }
65
+
66
+ let {
67
+ input = $bindable(),
68
+ value = $bindable(),
69
+ classOption,
70
+ classOptionActive,
71
+ q = "",
72
+ noScrollLock = false,
73
+ getOptions,
74
+ renderOptionLabel,
75
+ renderOptionGroup = (s: string) => `${s}`.replaceAll("_", " "),
76
+ t = t_default,
77
+ notifications,
78
+ itemIdPropName = "id",
79
+ searchPlaceholder,
80
+ showAllOnEmptyQ,
81
+ }: Props = $props();
82
+
83
+ function _renderOptionLabel(item: Item): string {
84
+ return renderOptionLabel?.(item) || `${item[itemIdPropName]}`;
85
+ }
86
+
87
+ function sortFn(a: Item, b: Item) {
88
+ const withOptGroup = (i: Item) => `${i.optgroup || ""}__${_renderOptionLabel(i)}`;
89
+ return withOptGroup(a).localeCompare(withOptGroup(b), undefined, {
90
+ sensitivity: "base",
91
+ });
92
+ }
93
+
94
+ let modal: Modal = $state()!;
95
+ let isFetching = $state(false);
96
+ let modalEl: HTMLDivElement | undefined = $state();
97
+ let optionsBox: HTMLDivElement | undefined = $state();
98
+ let activeEl: HTMLButtonElement | undefined = $state();
99
+
100
+ //
101
+ const _optionsColl = new ItemCollection([], {
102
+ allowNextPrevCycle: false,
103
+ sortFn,
104
+ idPropName: itemIdPropName,
105
+ searchable: { getContent: (item) => _renderOptionLabel(item) },
106
+ });
107
+ let options = $derived($_optionsColl);
108
+
109
+ // scroll the active option into view
110
+ $effect(() => {
111
+ if (modal.visibility().visible && options.active?.[itemIdPropName]) {
112
+ activeEl = qsa(`#${btn_id(options.active[itemIdPropName])}`, optionsBox)[0] as any;
113
+ activeEl?.scrollIntoView({ behavior: "smooth", block: "center" });
114
+ activeEl?.focus();
115
+ } else {
116
+ activeEl = undefined;
117
+ }
118
+ });
119
+
120
+ //
121
+ const debounced = new Debounced(() => q, 150);
122
+ watch([() => debounced.current], ([currQ], [oldQ]) => {
123
+ if (!currQ && !showAllOnEmptyQ) {
124
+ _optionsColl.clear();
125
+ return;
126
+ }
127
+
128
+ isFetching = true;
129
+ getOptions(`${currQ}`, [])
130
+ .then((res) => {
131
+ _optionsColl.clear().addMany(res);
132
+ })
133
+ .catch((e) => {
134
+ console.error(e);
135
+ notifications?.error(`${e}`);
136
+ })
137
+ .finally(() => (isFetching = false));
138
+ });
139
+
140
+ //
141
+ function _normalize_and_group_options(opts: Item[]): Map<string, Item[]> {
142
+ const groupped = new Map<string, Item[]>();
143
+ opts.forEach((o) => {
144
+ const optgLabel = renderOptionGroup(o.optgroup || "");
145
+ if (!groupped.has(optgLabel)) groupped.set(optgLabel, []);
146
+ const optgroup = groupped.get(optgLabel);
147
+ optgroup!.push(o);
148
+ });
149
+ return groupped;
150
+ }
151
+
152
+ export function close() {
153
+ modal.close();
154
+ }
155
+
156
+ export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
157
+ modal.open(openerOrEvent);
158
+ }
159
+
160
+ // internal DRY
161
+ const rand = Math.random().toString(36).slice(2);
162
+ function btn_id(id: string | number, prefix = "btn-") {
163
+ return prefix + rand + strHash(`${id}`.repeat(3));
164
+ }
165
+
166
+ function submit() {
167
+ // happy flow
168
+ if (options.active) {
169
+ value = options.active;
170
+ q = "";
171
+ return close();
172
+ }
173
+
174
+ // seems like we hit enter, but did not choose any option
175
+ if (options.size) {
176
+ return notifications?.error(t("select_from_list"), {
177
+ ttl: 1000,
178
+ });
179
+ }
180
+
181
+ // nothing found...
182
+ if (q) {
183
+ return notifications?.error(t("no_results", { q }), {
184
+ ttl: 1000,
185
+ });
186
+ }
187
+ }
188
+
189
+ // $inspect("options", options).with(clog);
190
+ // $inspect("q", q).with(clog);
191
+ // $inspect("value", value).with(clog);
192
+ </script>
193
+
194
+ <!-- this must be on window as we're catching any typing anywhere -->
195
+ <svelte:window
196
+ onkeydown={(e) => {
197
+ if (modal.visibility().visible) {
198
+ // arrow navigation
199
+ if (["ArrowDown", "ArrowUp"].includes(e.key)) {
200
+ e.preventDefault();
201
+ if (e.key === "ArrowUp") {
202
+ e.metaKey ? _optionsColl.setActiveFirst() : _optionsColl.setActivePrevious();
203
+ } else if (e.key === "ArrowDown") {
204
+ e.metaKey ? _optionsColl.setActiveLast() : _optionsColl.setActiveNext();
205
+ }
206
+ }
207
+ // everything else (except controls) "forward" as an input search
208
+ else if (!["Tab", " ", "Enter"].includes(e.key)) {
209
+ input?.focus();
210
+ }
211
+ }
212
+ }}
213
+ />
214
+
215
+ <Modal
216
+ bind:this={modal}
217
+ onEscape={modal?.close}
218
+ class="bg-transparent dark:bg-transparent"
219
+ classInner="max-w-2xl"
220
+ bind:el={modalEl}
221
+ {noScrollLock}
222
+ >
223
+ <form
224
+ onsubmit={(e) => {
225
+ e.preventDefault();
226
+ // collection.setQuery(`${q}`.trim());
227
+ modal.close();
228
+ // clog("TODO save", `${q}`.trim());
229
+ }}
230
+ class=""
231
+ >
232
+ <FieldInput
233
+ type="text"
234
+ name="q"
235
+ bind:input
236
+ bind:value={q}
237
+ class="search m-4 mb-12 shadow-xl"
238
+ classLabelBox="m-0"
239
+ autocomplete="off"
240
+ placeholder={searchPlaceholder ?? t("search_placeholder")}
241
+ classInputBoxWrap={twMerge(
242
+ // always look like focused
243
+ `border-primary border-input-accent dark:border-input-accent-dark`,
244
+ `ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
245
+ )}
246
+ onkeydown={(e) => {
247
+ if (e.key === "Enter") {
248
+ e.preventDefault();
249
+ submit();
250
+ }
251
+ }}
252
+ >
253
+ {#snippet inputBefore()}
254
+ <div class="flex flex-col items-center justify-center pl-3 opacity-50">
255
+ {@html iconSearch({ size: 14 })}
256
+ </div>
257
+ {/snippet}
258
+ {#snippet inputAfter()}
259
+ <div class="flex pl-2 items-center justify-center opacity-50">
260
+ {#if isFetching}
261
+ <Spinner class="w-4" />
262
+ {/if}
263
+ </div>
264
+ <div class="flex items-center justify-center">
265
+ <button
266
+ type="button"
267
+ class={twMerge(
268
+ "opacity-50 rounded m-1",
269
+ "hover:opacity-100 hover:bg-neutral-200 dark:hover:bg-neutral-800",
270
+ "focus-visible:opacity-100 focus-visible:outline-0",
271
+ "focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
272
+ )}
273
+ onclick={(e) => {
274
+ e.preventDefault();
275
+ if (!`${q || ""}`.trim()) {
276
+ return modal.close();
277
+ }
278
+ q = "";
279
+ input?.focus();
280
+ }}
281
+ >
282
+ <X class="m-2 size-4 " />
283
+ </button>
284
+ </div>
285
+ {/snippet}
286
+ {#snippet inputBelow()}
287
+ {#if options.size}
288
+ <div
289
+ class={twMerge(
290
+ "options block space-y-1 p-1",
291
+ "overflow-y-auto overflow-x-hidden mb-1",
292
+ "border-t border-black/20",
293
+ "max-h-[240px]"
294
+ )}
295
+ bind:this={optionsBox}
296
+ tabindex="-1"
297
+ >
298
+ {#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
299
+ <!-- {console.log(11111, _optgroup, _opts)} -->
300
+ <div class="p-1">
301
+ {#if _optgroup}
302
+ <div
303
+ class="text-xs capitalize opacity-50 border-b border-black/10 mb-1 p-1"
304
+ >
305
+ {_optgroup}
306
+ </div>
307
+ {/if}
308
+ <ul>
309
+ {#each _opts as item (item.id)}
310
+ {@const active =
311
+ item[itemIdPropName] === options.active?.[itemIdPropName]}
312
+ <!-- {@const isSelected = false} -->
313
+ <li class:active>
314
+ <button
315
+ class:active
316
+ type="button"
317
+ class={twMerge(
318
+ "no-focus-visible",
319
+ "text-left rounded-md py-2 px-2.5",
320
+ "min-w-0 w-full overflow-hidden text-ellipsis whitespace-nowrap",
321
+ "border border-transparent",
322
+ "focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
323
+ "focus-visible:outline-0 focus-visible:ring-0",
324
+ "hover:border-neutral-400 dark:hover:border-neutral-500",
325
+ active && "bg-neutral-200 dark:bg-neutral-800",
326
+ classOption,
327
+ // active && "border-neutral-400",
328
+ active && classOptionActive
329
+ )}
330
+ id={btn_id(item[itemIdPropName])}
331
+ tabindex="-1"
332
+ onclick={() => {
333
+ _optionsColl.setActive(item);
334
+ submit();
335
+ }}
336
+ onkeydown={(e) => {
337
+ // need to handle tab here, because the tabindex="-1" is ignored
338
+ // in the focus-trap selectors... so, on Tab, manually focusin input
339
+ if (e.key === "Tab") {
340
+ e.preventDefault();
341
+ input?.focus();
342
+ }
343
+ }}
344
+ >
345
+ <!-- role="checkbox"
346
+ aria-checked={active} -->
347
+ {_renderOptionLabel(item)}
348
+ </button>
349
+ </li>
350
+ {/each}
351
+ </ul>
352
+ </div>
353
+ {/each}
354
+ </div>
355
+ {/if}
356
+ {/snippet}
357
+ </FieldInput>
358
+ </form>
359
+ </Modal>
360
+
361
+ <style>
362
+ ul.options {
363
+ scrollbar-width: thin;
364
+ }
365
+ </style>
@@ -0,0 +1,25 @@
1
+ import { type Item } from "@marianmeres/item-collection";
2
+ import { NotificationsStack } from "../Notifications/index.js";
3
+ import type { TranslateFn } from "../../types.js";
4
+ interface Props {
5
+ input?: HTMLInputElement;
6
+ value: any;
7
+ getOptions: (s: string, current: Item[]) => Promise<Item[]>;
8
+ renderOptionLabel?: (item: Item) => string;
9
+ renderOptionGroup?: (s: string) => string;
10
+ t?: TranslateFn;
11
+ notifications?: NotificationsStack;
12
+ itemIdPropName?: string;
13
+ searchPlaceholder?: string;
14
+ noScrollLock?: boolean;
15
+ q?: string;
16
+ classOption?: string;
17
+ classOptionActive?: string;
18
+ showAllOnEmptyQ?: boolean;
19
+ }
20
+ declare const CommandMenu: import("svelte").Component<Props, {
21
+ close: () => void;
22
+ open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
23
+ }, "value" | "input">;
24
+ type CommandMenu = ReturnType<typeof CommandMenu>;
25
+ export default CommandMenu;
@@ -0,0 +1 @@
1
+ export { default as CommandMenu } from "./CommandMenu.svelte";
@@ -0,0 +1 @@
1
+ export { default as CommandMenu } from "./CommandMenu.svelte";
@@ -105,6 +105,7 @@
105
105
  {labelAfter}
106
106
  {inputBefore}
107
107
  {inputAfter}
108
+ {inputBelow}
108
109
  {below}
109
110
  {required}
110
111
  {disabled}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from "svelte";
2
+ import { onMount, type Snippet } from "svelte";
3
3
  import {
4
4
  validate as validateAction,
5
5
  type ValidateOptions,
@@ -9,6 +9,7 @@
9
9
  import { twMerge } from "../../utils/tw-merge.js";
10
10
  import type { THC } from "../Thc/Thc.svelte";
11
11
  import InputWrap from "./_internal/InputWrap.svelte";
12
+ import { watch } from "runed";
12
13
 
13
14
  type SnippetWithId = Snippet<[{ id: string }]>;
14
15
 
@@ -108,14 +109,22 @@
108
109
  }
109
110
  );
110
111
 
111
- // let rendered = $derived(renderValue?.(value) ?? value);
112
+ //
112
113
  let rendered: string | Snippet<[value: string]> = $derived(_value_renderer(value));
113
114
 
114
- // once button rendered, trigger change on the input, so that the validation re/triggers
115
- $effect(() => {
116
- rendered;
117
- input?.dispatchEvent(new Event("change", { bubbles: true }));
118
- });
115
+ // let renderCount = $state(0);
116
+
117
+ // // once button rendered, trigger change on the input, so that the validation re/triggers
118
+ // // (this is ugly as hell...)
119
+ // watch(
120
+ // () => rendered,
121
+ // (isRendered, wasRendered) => {
122
+ // // ignore first (initial) render
123
+ // // if (isRendered && renderCount++) {
124
+ // // input?.dispatchEvent(new Event("change", { bubbles: true }));
125
+ // // }
126
+ // }
127
+ // );
119
128
 
120
129
  //
121
130
  let validation: ValidationResult | undefined = $state();
@@ -1,4 +1,4 @@
1
- import type { Snippet } from "svelte";
1
+ import { type Snippet } from "svelte";
2
2
  import { type ValidateOptions } from "../../actions/validate.svelte.js";
3
3
  import type { THC } from "../Thc/Thc.svelte";
4
4
  type SnippetWithId = Snippet<[{