@marianmeres/stuic 2.0.0-next.4 → 2.0.0-next.5

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.
@@ -3,7 +3,7 @@
3
3
  bg-button-bg text-button-text
4
4
  dark:bg-button-bg-dark dark:text-button-text-dark
5
5
  font-mono text-sm text-center
6
- leading-4
6
+ leading-none
7
7
  border-1
8
8
  border-button-border dark:border-button-border-dark
9
9
  rounded-md
@@ -1,4 +1,4 @@
1
- export declare const BUTTON_STUIC_BASE_CLASSES = "\n\t\tbg-button-bg text-button-text \n\t\tdark:bg-button-bg-dark dark:text-button-text-dark\n\t\tfont-mono text-sm text-center \n\t\tleading-4\n\t\tborder-1\n\t\tborder-button-border dark:border-button-border-dark\n\t\trounded-md\n\t\tinline-flex items-center justify-center gap-x-2\n\t\tpx-3 py-1.5\n\n\t\thover:brightness-[1.05]\n\t\tactive:brightness-[0.95]\n\t\tdisabled:hover:brightness-100\n\n\t\tfocus:brightness-[1.05] \n\t\tfocus:border-button-border-focus focus:dark:border-button-border-focus-dark\n\n\t\t focus:outline-4 focus:outline-black/10 focus:dark:outline-white/20\n\t\tfocus-visible:outline-4 focus-visible:outline-black/10 focus-visible:dark:outline-white/20\n\t";
1
+ export declare const BUTTON_STUIC_BASE_CLASSES = "\n\t\tbg-button-bg text-button-text \n\t\tdark:bg-button-bg-dark dark:text-button-text-dark\n\t\tfont-mono text-sm text-center \n\t\tleading-none\n\t\tborder-1\n\t\tborder-button-border dark:border-button-border-dark\n\t\trounded-md\n\t\tinline-flex items-center justify-center gap-x-2\n\t\tpx-3 py-1.5\n\n\t\thover:brightness-[1.05]\n\t\tactive:brightness-[0.95]\n\t\tdisabled:hover:brightness-100\n\n\t\tfocus:brightness-[1.05] \n\t\tfocus:border-button-border-focus focus:dark:border-button-border-focus-dark\n\n\t\t focus:outline-4 focus:outline-black/10 focus:dark:outline-white/20\n\t\tfocus-visible:outline-4 focus-visible:outline-black/10 focus-visible:dark:outline-white/20\n\t";
2
2
  export declare const BUTTON_STUIC_PRESET_CLASSES: any;
3
3
  import type { Snippet } from "svelte";
4
4
  import type { HTMLButtonAttributes } from "svelte/elements";
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ import { ItemCollection, type Item } from "@marianmeres/item-collection";
3
+ import { getId } from "../../utils/get-id.js";
4
+ import { twMerge } from "../../utils/tw-merge.js";
5
+ import type { FieldRadiosOption } from "../Input/types.js";
6
+ import Button from "../Button/Button.svelte";
7
+ //
8
+ import "./index.css";
9
+
10
+ interface ItemColl extends ItemCollection<{ id: string; option: FieldRadiosOption }> {}
11
+
12
+ interface Props {
13
+ value?: string;
14
+ tabindex?: number; // tooShort
15
+ size?: "sm" | "md" | "lg" | string;
16
+ //
17
+ options: (string | FieldRadiosOption)[];
18
+ disabled?: boolean;
19
+ activeIndex?: number | undefined;
20
+ //
21
+ class?: string;
22
+ classButton?: string;
23
+ classButtonActive?: string;
24
+ style?: string;
25
+ // for side-effects, or validation... if would return explicit false, will not activate
26
+ onButtonClick?: (
27
+ index: number,
28
+ coll: ItemColl
29
+ ) => Promise<boolean | undefined | void> | boolean | undefined | void;
30
+ buttonProps?: (index: number, coll: ItemColl) => undefined | Record<string, any>;
31
+ }
32
+
33
+ let {
34
+ options,
35
+ value = $bindable(),
36
+ tabindex = 0,
37
+ disabled,
38
+ size = "md",
39
+ //
40
+ activeIndex = $bindable(undefined),
41
+ //
42
+ class: classProp,
43
+ classButton,
44
+ classButtonActive,
45
+ style,
46
+ onButtonClick,
47
+ buttonProps,
48
+ }: Props = $props();
49
+
50
+ const coll: ItemColl = $derived.by(() => {
51
+ const out = new ItemCollection(
52
+ options.map((o, i) => {
53
+ // normalize string to FieldRadiosOption
54
+ if (typeof o === "string") o = { label: o };
55
+ // normalize FieldRadiosOption to ItemCollection's Item
56
+ return { id: `opt-${i}-${Math.random().toString(36).slice(2, 8)}`, option: o };
57
+ }),
58
+ {}
59
+ );
60
+
61
+ if (activeIndex !== undefined) out.setActiveIndex(activeIndex);
62
+
63
+ return out;
64
+ });
65
+
66
+ $effect(() => {
67
+ return coll.subscribe((c) => {
68
+ value = c.active?.option.value ?? c.active?.option.label;
69
+ activeIndex = c.activeIndex;
70
+ });
71
+ });
72
+
73
+ //
74
+ const CLS = `
75
+ p-1.5 rounded-lg inline-block space-x-2
76
+ bg-button-group-bg text-button-group-text
77
+ dark:bg-button-group-bg-dark dark:text-button-group-text-dark
78
+ border-1
79
+ border-button-group-border dark:border-button-group-border-dark
80
+ `;
81
+
82
+ // we need some active indication by default... use just something subtle here, in the wild
83
+ // this will be styled with classButtonActive
84
+ const CLS_ACTIVE = `
85
+ shadow-[0px_0px_1px_1px_rgba(0_0_0_/_.6)]
86
+ `;
87
+
88
+ let els = $state<Record<number, HTMLButtonElement>>({});
89
+
90
+ async function maybe_activate(index: number, coll: ItemColl) {
91
+ if ((await onButtonClick?.(index, coll)) !== false) {
92
+ coll.setActiveIndex(index);
93
+ els[index].focus();
94
+ }
95
+ }
96
+ </script>
97
+
98
+ {#if coll.size}
99
+ <div
100
+ class={twMerge(CLS, classProp)}
101
+ {style}
102
+ role="radiogroup"
103
+ aria-labelledby={$coll?.active?.id || ""}
104
+ >
105
+ {#each coll.items as item, i}
106
+ <Button
107
+ tabindex={i === 0 ? tabindex : undefined}
108
+ class={twMerge(
109
+ "border-none shadow-none",
110
+ classButton,
111
+ $coll.activeIndex === i && [CLS_ACTIVE, classButtonActive].join(" ")
112
+ )}
113
+ {size}
114
+ role="radio"
115
+ aria-checked={$coll.activeIndex === i}
116
+ onclick={async () => {
117
+ await maybe_activate(i, coll);
118
+ }}
119
+ bind:el={els[i]}
120
+ onkeydown={async (e) => {
121
+ if (e.key === "ArrowRight") {
122
+ await maybe_activate(Math.min(i + 1, coll.size - 1), coll);
123
+ }
124
+ if (e.key === "ArrowLeft") {
125
+ await maybe_activate(Math.max(0, i - 1), coll);
126
+ }
127
+ }}
128
+ id={item.id}
129
+ {...buttonProps?.(i, coll) || {}}
130
+ >
131
+ {item.option.label}
132
+ </Button>
133
+ {/each}
134
+ </div>
135
+ {/if}
@@ -0,0 +1,25 @@
1
+ import { ItemCollection } from "@marianmeres/item-collection";
2
+ import type { FieldRadiosOption } from "../Input/types.js";
3
+ import "./index.css";
4
+ interface ItemColl extends ItemCollection<{
5
+ id: string;
6
+ option: FieldRadiosOption;
7
+ }> {
8
+ }
9
+ interface Props {
10
+ value?: string;
11
+ tabindex?: number;
12
+ size?: "sm" | "md" | "lg" | string;
13
+ options: (string | FieldRadiosOption)[];
14
+ disabled?: boolean;
15
+ activeIndex?: number | undefined;
16
+ class?: string;
17
+ classButton?: string;
18
+ classButtonActive?: string;
19
+ style?: string;
20
+ onButtonClick?: (index: number, coll: ItemColl) => Promise<boolean | undefined | void> | boolean | undefined | void;
21
+ buttonProps?: (index: number, coll: ItemColl) => undefined | Record<string, any>;
22
+ }
23
+ declare const ButtonGroupRadio: import("svelte").Component<Props, {}, "value" | "activeIndex">;
24
+ type ButtonGroupRadio = ReturnType<typeof ButtonGroupRadio>;
25
+ export default ButtonGroupRadio;
@@ -0,0 +1,14 @@
1
+ @import "../../_shared.css";
2
+ @plugin '@tailwindcss/forms';
3
+
4
+ /* prettier-ignore */
5
+ @theme inline {
6
+ --color-button-group-bg: var(--color-button-group-bg, var(--color-neutral-100));
7
+ --color-button-group-bg-dark: var(--color-button-group-bg-dark, var(--color-neutral-600));
8
+
9
+ --color-button-group-text: var(--color-button-group-text, var(--color-black));
10
+ --color-button-group-text-dark: var(--color-button-group-text-dark, var(--color-white));
11
+
12
+ --color-button-group-border: var(--color-button-group-border, var(--color-neutral-300));
13
+ --color-button-group-border-dark: var(--color-button-group-border-dark, var(--color-neutral-800));
14
+ }
@@ -13,7 +13,9 @@
13
13
  submit: "Submit",
14
14
  select_all: "Select all",
15
15
  clear_all: "Clear all",
16
+ clear: "Clear",
16
17
  search_placeholder: "Type to search...",
18
+ search_submit_placeholder: "Type to search and/or submit...",
17
19
  cardinality_full: "Max selection reached",
18
20
  select_from_list: "Please select from the list",
19
21
  x_close: "Clear input or close [esc]",
@@ -222,11 +224,17 @@
222
224
 
223
225
  // second, the selected ones
224
226
  const _selectedColl = new ItemCollection([], {
227
+ // svelte-ignore state_referenced_locally
225
228
  cardinality,
226
229
  sortFn,
227
230
  idPropName: itemIdPropName,
228
231
  });
229
232
 
233
+ // reconfigure if the prop ever changes during runtime (most likely will NOT)
234
+ $effect(() => {
235
+ _selectedColl.configure({ cardinality });
236
+ });
237
+
230
238
  // now, create the reactive, subscribed variants
231
239
  let options = $derived($_optionsColl);
232
240
  let selected = $derived($_selectedColl);
@@ -292,7 +300,41 @@
292
300
  return prefix + strHash(`${id}`.repeat(3));
293
301
  }
294
302
 
295
- // this will set the outer bound value (always string) and close modal... further process is left on the consumer
303
+ // "inner" submit
304
+ function try_submit(force = false) {
305
+ if (innerValue) {
306
+ // doing label search, taking first result
307
+ let found = _optionsColl.search(innerValue)?.[0];
308
+ if (!found) {
309
+ if (!allowUnknown) {
310
+ return notifications?.error(t("select_from_list"), { ttl: 1000 });
311
+ }
312
+ found = { [itemIdPropName]: innerValue };
313
+ }
314
+
315
+ if (!isMultiple) _selectedColl.clear();
316
+
317
+ // actual selection addon
318
+ _selectedColl.add(found);
319
+
320
+ // we might have added a new one, so add it to options as well
321
+ // (will be noop if already exists)...
322
+ if (allowUnknown) {
323
+ _optionsColl.add(found);
324
+ _optionsColl.setActive(found);
325
+ }
326
+
327
+ // maybe submit
328
+ if (_selectedColl.isFull || force) submit();
329
+ }
330
+ // enter on empty input always submits
331
+ else {
332
+ submit();
333
+ }
334
+ }
335
+
336
+ // "outer" submit - will set the outer bound value (always string) and close modal...
337
+ // further process is left on the consumer
296
338
  function submit() {
297
339
  // clog("modal submit", $state.snapshot(selected.items));
298
340
  value = JSON.stringify(selected.items);
@@ -413,40 +455,12 @@
413
455
  tabindex={1}
414
456
  {required}
415
457
  {disabled}
416
- placeholder={searchPlaceholder ?? t("search_placeholder")}
458
+ placeholder={searchPlaceholder ??
459
+ t(allowUnknown ? "search_submit_placeholder" : "search_placeholder")}
417
460
  onkeydown={(e) => {
418
461
  if (e.key === "Enter") {
419
462
  e.preventDefault();
420
-
421
- if (innerValue) {
422
- // doing label search, taking first result
423
- let found = _optionsColl.search(innerValue)?.[0];
424
- if (!found) {
425
- if (!allowUnknown) {
426
- return notifications?.error(t("select_from_list"), { ttl: 1000 });
427
- }
428
- found = { [itemIdPropName]: innerValue };
429
- }
430
-
431
- if (!isMultiple) _selectedColl.clear();
432
-
433
- // actual selection addon
434
- _selectedColl.add(found);
435
-
436
- // we might have added a new one, so add it to options as well
437
- // (will be noop if already exists)...
438
- if (allowUnknown) {
439
- _optionsColl.add(found);
440
- _optionsColl.setActive(found);
441
- }
442
-
443
- // maybe submit
444
- if (_selectedColl.isFull) submit();
445
- }
446
- // enter on empty input always submits
447
- else {
448
- submit();
449
- }
463
+ try_submit();
450
464
  }
451
465
  }}
452
466
  autocomplete="off"
@@ -484,7 +498,7 @@
484
498
  tabindex={5}
485
499
  disabled={!selected.items.length}
486
500
  >
487
- {@html t("clear_all")}
501
+ {@html t(cardinality === 1 ? "clear" : "clear_all")}
488
502
  </button>
489
503
 
490
504
  <span class="p-1 m-1 text-xs">&nbsp;</span>
@@ -585,9 +599,9 @@
585
599
  class="control"
586
600
  type="button"
587
601
  variant="primary"
588
- onclick={(e) => {
602
+ onclick={async (e) => {
589
603
  e.preventDefault();
590
- submit();
604
+ try_submit(true);
591
605
  }}
592
606
  tabindex={3}
593
607
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.0.0-next.4",
3
+ "version": "2.0.0-next.5",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",
@@ -25,32 +25,32 @@
25
25
  "@marianmeres/icons-fns": "^4.3.1",
26
26
  "@marianmeres/random-human-readable": "^1.6.1",
27
27
  "@sveltejs/adapter-auto": "^4.0.0",
28
- "@sveltejs/kit": "^2.21.1",
28
+ "@sveltejs/kit": "^2.21.5",
29
29
  "@sveltejs/package": "^2.3.11",
30
- "@sveltejs/vite-plugin-svelte": "^5.0.3",
31
- "@tailwindcss/cli": "^4.1.8",
30
+ "@sveltejs/vite-plugin-svelte": "^5.1.0",
31
+ "@tailwindcss/cli": "^4.1.10",
32
32
  "@tailwindcss/forms": "^0.5.10",
33
33
  "@tailwindcss/typography": "^0.5.16",
34
- "@tailwindcss/vite": "^4.1.8",
34
+ "@tailwindcss/vite": "^4.1.10",
35
35
  "dotenv": "^16.5.0",
36
36
  "prettier": "^3.5.3",
37
37
  "prettier-plugin-svelte": "^3.4.0",
38
38
  "publint": "^0.3.12",
39
- "svelte": "^5.33.10",
39
+ "svelte": "^5.34.3",
40
40
  "svelte-check": "^4.2.1",
41
- "tailwindcss": "^4.1.8",
41
+ "tailwindcss": "^4.1.10",
42
42
  "typescript": "^5.8.3",
43
43
  "vite": "^6.3.5",
44
- "vitest": "^3.1.4"
44
+ "vitest": "^3.2.3"
45
45
  },
46
46
  "dependencies": {
47
47
  "@marianmeres/clog": "^2.2.3",
48
- "@marianmeres/item-collection": "^1.2.14",
48
+ "@marianmeres/item-collection": "^1.2.15",
49
49
  "@marianmeres/parse-boolean": "^1.1.7",
50
50
  "@marianmeres/ticker": "^1.15.0",
51
51
  "esm-env": "^1.2.2",
52
52
  "runed": "^0.23.4",
53
- "tailwind-merge": "^3.3.0"
53
+ "tailwind-merge": "^3.3.1"
54
54
  },
55
55
  "scripts": {
56
56
  "dev": "vite dev",