@ktfth/stickjs 3.0.1 → 3.0.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ktfth/stickjs",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Declarative behavior for HTML elements. Zero dependencies.",
5
5
  "main": "stick.js",
6
6
  "types": "stick.d.ts",
@@ -9,21 +9,40 @@
9
9
 
10
10
  /* ── Tokens ─────────────────────────────────────────── */
11
11
  :root {
12
- /* Colors */
13
- --stk-bg: #ffffff;
14
- --stk-fg: #09090b;
15
- --stk-muted: #71717a;
16
- --stk-muted-fg: #f4f4f5;
17
- --stk-border: #e4e4e7;
18
- --stk-ring: #18181b;
19
- --stk-primary: #18181b;
20
- --stk-primary-fg: #fafafa;
21
- --stk-destructive: #ef4444;
22
- --stk-destructive-fg: #fafafa;
23
- --stk-success: #22c55e;
24
- --stk-success-fg: #fafafa;
25
- --stk-warning: #f59e0b;
26
- --stk-warning-fg: #09090b;
12
+ /* DARK FIX — semantic tokens (override --sui-* to theme your app) */
13
+ --sui-bg: #ffffff;
14
+ --sui-surface: #f4f4f5;
15
+ --sui-border: #e4e4e7;
16
+ --sui-text: #09090b;
17
+ --sui-text-muted: #71717a;
18
+ --sui-accent: #18181b;
19
+ --sui-accent-fg: #fafafa;
20
+ --sui-success: #22c55e;
21
+ --sui-success-fg: #fafafa;
22
+ --sui-warning: #f59e0b;
23
+ --sui-warning-fg: #09090b;
24
+ --sui-danger: #ef4444;
25
+ --sui-danger-fg: #fafafa;
26
+ --sui-info: #3b82f6;
27
+ --sui-info-fg: #fafafa;
28
+ --sui-ring: #18181b;
29
+ --sui-ring-shadow: rgba(24,24,27,0.1);
30
+
31
+ /* DARK FIX — backward-compatible aliases (--stk-* now reference --sui-*) */
32
+ --stk-bg: var(--sui-bg);
33
+ --stk-fg: var(--sui-text);
34
+ --stk-muted: var(--sui-text-muted);
35
+ --stk-muted-fg: var(--sui-surface);
36
+ --stk-border: var(--sui-border);
37
+ --stk-ring: var(--sui-ring);
38
+ --stk-primary: var(--sui-accent);
39
+ --stk-primary-fg: var(--sui-accent-fg);
40
+ --stk-destructive: var(--sui-danger);
41
+ --stk-destructive-fg: var(--sui-danger-fg);
42
+ --stk-success: var(--sui-success);
43
+ --stk-success-fg: var(--sui-success-fg);
44
+ --stk-warning: var(--sui-warning);
45
+ --stk-warning-fg: var(--sui-warning-fg);
27
46
 
28
47
  /* Radius */
29
48
  --stk-radius-sm: 4px;
@@ -67,31 +86,35 @@
67
86
  --stk-z-tooltip: 400;
68
87
  }
69
88
 
70
- /* ── Dark mode ──────────────────────────────────────── */
89
+ /* DARK FIX — dark mode tokens ──────────────────────── */
71
90
  @media (prefers-color-scheme: dark) {
72
91
  :root:not([data-theme="light"]) {
73
- --stk-bg: #09090b;
74
- --stk-fg: #fafafa;
75
- --stk-muted: #a1a1aa;
76
- --stk-muted-fg: #27272a;
77
- --stk-border: #27272a;
78
- --stk-ring: #d4d4d8;
79
- --stk-primary: #fafafa;
80
- --stk-primary-fg: #18181b;
92
+ --sui-bg: #09090b;
93
+ --sui-surface: #27272a;
94
+ --sui-border: #27272a;
95
+ --sui-text: #fafafa;
96
+ --sui-text-muted: #a1a1aa;
97
+ --sui-accent: #fafafa;
98
+ --sui-accent-fg: #18181b;
99
+ --sui-ring: #d4d4d8;
100
+ --sui-ring-shadow: rgba(212,212,216,0.2);
101
+ --sui-info: #60a5fa;
81
102
  --stk-shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
82
103
  --stk-shadow-md: 0 4px 6px -1px rgba(0,0,0,0.4);
83
104
  --stk-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.5);
84
105
  }
85
106
  }
86
107
  [data-theme="dark"] {
87
- --stk-bg: #09090b;
88
- --stk-fg: #fafafa;
89
- --stk-muted: #a1a1aa;
90
- --stk-muted-fg: #27272a;
91
- --stk-border: #27272a;
92
- --stk-ring: #d4d4d8;
93
- --stk-primary: #fafafa;
94
- --stk-primary-fg: #18181b;
108
+ --sui-bg: #09090b;
109
+ --sui-surface: #27272a;
110
+ --sui-border: #27272a;
111
+ --sui-text: #fafafa;
112
+ --sui-text-muted: #a1a1aa;
113
+ --sui-accent: #fafafa;
114
+ --sui-accent-fg: #18181b;
115
+ --sui-ring: #d4d4d8;
116
+ --sui-ring-shadow: rgba(212,212,216,0.2);
117
+ --sui-info: #60a5fa;
95
118
  --stk-shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
96
119
  --stk-shadow-md: 0 4px 6px -1px rgba(0,0,0,0.4);
97
120
  --stk-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.5);
@@ -145,14 +168,15 @@
145
168
  border: 1px solid var(--stk-border); background: var(--stk-bg); color: var(--stk-fg);
146
169
  text-decoration: none;
147
170
  }
148
- .stk-btn:hover { background: var(--stk-muted-fg); }
171
+ /* DARK FIX — explicit color on every :hover prevents dark-on-dark */
172
+ .stk-btn:hover { background: var(--stk-muted-fg); color: var(--stk-fg); }
149
173
  .stk-btn:focus-visible { outline: 2px solid var(--stk-ring); outline-offset: 2px; }
150
174
  .stk-btn-primary { background: var(--stk-primary); color: var(--stk-primary-fg); border-color: var(--stk-primary); }
151
- .stk-btn-primary:hover { background: var(--stk-primary); opacity: 0.9; }
175
+ .stk-btn-primary:hover { background: var(--stk-primary); color: var(--stk-primary-fg); opacity: 0.9; }
152
176
  .stk-btn-destructive { background: var(--stk-destructive); color: var(--stk-destructive-fg); border-color: var(--stk-destructive); }
153
- .stk-btn-destructive:hover { background: var(--stk-destructive); opacity: 0.9; }
177
+ .stk-btn-destructive:hover { background: var(--stk-destructive); color: var(--stk-destructive-fg); opacity: 0.9; }
154
178
  .stk-btn-ghost { background: transparent; border-color: transparent; }
155
- .stk-btn-ghost:hover { background: var(--stk-muted-fg); }
179
+ .stk-btn-ghost:hover { background: var(--stk-muted-fg); color: var(--stk-fg); }
156
180
  .stk-btn-sm { padding: var(--stk-space-1) var(--stk-space-2); font-size: var(--stk-text-xs); }
157
181
  .stk-btn-lg { padding: var(--stk-space-3) var(--stk-space-6); font-size: var(--stk-text-base); }
158
182
 
@@ -482,9 +506,11 @@
482
506
 
483
507
  .stk-table tbody tr:last-child td { border-bottom: none; }
484
508
 
509
+ /* DARK FIX — explicit color prevents dark text on dark hover bg */
485
510
  .stk-table tbody tr:hover {
486
511
  background: var(--stk-muted-fg);
487
- transition: background var(--stk-duration) var(--stk-easing);
512
+ color: var(--stk-fg);
513
+ transition: background var(--stk-duration) var(--stk-easing), color var(--stk-duration) var(--stk-easing);
488
514
  }
489
515
 
490
516
  /* Sortable headers */
@@ -539,7 +565,7 @@
539
565
  .stk-table-filter:focus {
540
566
  outline: none;
541
567
  border-color: var(--stk-ring);
542
- box-shadow: 0 0 0 2px rgba(24,24,27,0.1);
568
+ box-shadow: 0 0 0 2px var(--sui-ring-shadow); /* DARK FIX */
543
569
  }
544
570
 
545
571
  /* Pagination bar */
@@ -716,7 +742,7 @@
716
742
  .stk-stepper .stk-input:focus {
717
743
  outline: none;
718
744
  border-color: var(--stk-ring);
719
- box-shadow: 0 0 0 2px rgba(24,24,27,0.1);
745
+ box-shadow: 0 0 0 2px var(--sui-ring-shadow); /* DARK FIX */
720
746
  }
721
747
 
722
748
  .stk-stepper .stk-label {
@@ -750,7 +776,7 @@
750
776
  .stk-autocomplete-input:focus {
751
777
  outline: none;
752
778
  border-color: var(--stk-ring);
753
- box-shadow: 0 0 0 2px rgba(24,24,27,0.1);
779
+ box-shadow: 0 0 0 2px var(--sui-ring-shadow); /* DARK FIX */
754
780
  }
755
781
 
756
782
  .stk-autocomplete-dropdown {
@@ -795,6 +821,215 @@
795
821
  font-weight: 700;
796
822
  }
797
823
 
824
+ /* ══════════════════════════════════════════════════════
825
+ DARK FIX — Generic element styles & missing components
826
+ ══════════════════════════════════════════════════════ */
827
+
828
+ /* ── DARK FIX: Generic dialog base (BUG 4) ─────────── */
829
+ dialog {
830
+ margin: auto;
831
+ position: fixed;
832
+ inset: 0;
833
+ height: fit-content;
834
+ width: min(90%, 420px);
835
+ border: none;
836
+ border-radius: var(--stk-radius-lg, 12px);
837
+ padding: 0;
838
+ background: var(--sui-bg);
839
+ color: var(--sui-text);
840
+ box-shadow: var(--stk-shadow-lg);
841
+ z-index: var(--stk-z-modal, 200);
842
+ }
843
+ dialog::backdrop {
844
+ background: rgba(0,0,0,0.6);
845
+ }
846
+ dialog[open] {
847
+ animation: stk-dialog-in var(--stk-duration-slow, 300ms) var(--stk-easing);
848
+ }
849
+
850
+ /* ── DARK FIX: Generic form hover/focus (BUG 5) ────── */
851
+ input:hover,
852
+ select:hover,
853
+ textarea:hover {
854
+ color: var(--sui-text);
855
+ background-color: var(--sui-surface);
856
+ }
857
+ input:focus,
858
+ select:focus,
859
+ textarea:focus {
860
+ color: var(--sui-text);
861
+ outline: none;
862
+ border-color: var(--sui-ring);
863
+ box-shadow: 0 0 0 2px var(--sui-ring-shadow); /* DARK FIX */
864
+ }
865
+
866
+ /* ── DARK FIX: Generic .btn aliases (BUG 2 / BUG 3) ─ */
867
+ .btn {
868
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--stk-space-2, 8px);
869
+ padding: var(--stk-space-2, 8px) var(--stk-space-4, 16px);
870
+ border-radius: var(--stk-radius-md, 8px);
871
+ font-size: var(--stk-text-sm, 0.875rem); font-weight: 500;
872
+ cursor: pointer;
873
+ transition: all var(--stk-duration, 150ms) var(--stk-easing);
874
+ border: 1px solid var(--sui-border);
875
+ background: var(--sui-bg);
876
+ color: var(--sui-text);
877
+ text-decoration: none;
878
+ font-family: var(--stk-font);
879
+ }
880
+ .btn:hover {
881
+ background: var(--sui-surface);
882
+ color: var(--sui-text);
883
+ }
884
+ .btn:focus-visible {
885
+ outline: 2px solid var(--sui-ring);
886
+ outline-offset: 2px;
887
+ }
888
+
889
+ .btn-primary {
890
+ background: var(--sui-accent);
891
+ color: var(--sui-accent-fg);
892
+ border-color: var(--sui-accent);
893
+ }
894
+ .btn-primary:hover {
895
+ background: var(--sui-accent);
896
+ color: var(--sui-accent-fg);
897
+ opacity: 0.9;
898
+ }
899
+
900
+ .btn-danger {
901
+ background: var(--sui-danger);
902
+ color: var(--sui-danger-fg);
903
+ border-color: var(--sui-danger);
904
+ }
905
+ .btn-danger:hover {
906
+ background: var(--sui-danger);
907
+ color: var(--sui-danger-fg);
908
+ opacity: 0.9;
909
+ }
910
+
911
+ .btn-ghost {
912
+ background: transparent;
913
+ border-color: transparent;
914
+ color: var(--sui-text);
915
+ }
916
+ .btn-ghost:hover {
917
+ background: var(--sui-surface);
918
+ color: var(--sui-text);
919
+ }
920
+
921
+ .btn-sm { padding: var(--stk-space-1, 4px) var(--stk-space-2, 8px); font-size: var(--stk-text-xs, 0.75rem); }
922
+ .btn-lg { padding: var(--stk-space-3, 12px) var(--stk-space-6, 24px); font-size: var(--stk-text-base, 1rem); }
923
+
924
+ /* ── DARK FIX: Generic table row hover (BUG 1) ─────── */
925
+ table:not(.stk-table) tbody tr:hover td {
926
+ color: var(--sui-text);
927
+ }
928
+
929
+ /* ── DARK FIX: Badge component (BUG 6) ─────────────── */
930
+ .badge, .stk-badge {
931
+ display: inline-flex; align-items: center;
932
+ padding: var(--stk-space-1, 4px) var(--stk-space-2, 8px);
933
+ font-size: var(--stk-text-xs, 0.75rem); font-weight: 500;
934
+ border-radius: var(--stk-radius-full, 9999px);
935
+ line-height: 1.4;
936
+ background: var(--sui-surface);
937
+ color: var(--sui-text);
938
+ }
939
+ .badge-info, .stk-badge-info {
940
+ background: var(--sui-info);
941
+ color: var(--sui-info-fg);
942
+ }
943
+ .badge-success, .stk-badge-success {
944
+ background: var(--sui-success);
945
+ color: var(--sui-success-fg);
946
+ }
947
+ .badge-warning, .stk-badge-warning {
948
+ background: var(--sui-warning);
949
+ color: var(--sui-warning-fg);
950
+ }
951
+ .badge-danger, .stk-badge-danger {
952
+ background: var(--sui-danger);
953
+ color: var(--sui-danger-fg);
954
+ }
955
+
956
+ /* ── DARK FIX: Alert component (BUG 6) ─────────────── */
957
+ .alert, .stk-alert {
958
+ display: flex; align-items: flex-start; gap: var(--stk-space-3, 12px);
959
+ padding: var(--stk-space-3, 12px) var(--stk-space-4, 16px);
960
+ border-radius: var(--stk-radius-md, 8px);
961
+ font-size: var(--stk-text-sm, 0.875rem);
962
+ border: 1px solid var(--sui-border);
963
+ background: var(--sui-bg);
964
+ color: var(--sui-text);
965
+ }
966
+ .alert-success, .stk-alert-success {
967
+ border-left: 3px solid var(--sui-success);
968
+ }
969
+ .alert-danger, .stk-alert-danger {
970
+ border-left: 3px solid var(--sui-danger);
971
+ }
972
+ .alert-warning, .stk-alert-warning {
973
+ border-left: 3px solid var(--sui-warning);
974
+ }
975
+ .alert-info, .stk-alert-info {
976
+ border-left: 3px solid var(--sui-info);
977
+ }
978
+
979
+ /* ── DARK FIX: Form group component (BUG 6) ────────── */
980
+ .form-group, .stk-form-group {
981
+ display: flex; flex-direction: column;
982
+ gap: var(--stk-space-1, 4px);
983
+ margin-bottom: var(--stk-space-4, 16px);
984
+ }
985
+ .form-group label, .stk-form-group label {
986
+ font-size: var(--stk-text-sm, 0.875rem); font-weight: 500;
987
+ color: var(--sui-text);
988
+ }
989
+ .form-group input,
990
+ .form-group select,
991
+ .form-group textarea,
992
+ .stk-form-group input,
993
+ .stk-form-group select,
994
+ .stk-form-group textarea {
995
+ display: block; width: 100%;
996
+ padding: var(--stk-space-2, 8px) var(--stk-space-3, 12px);
997
+ border: 1px solid var(--sui-border);
998
+ border-radius: var(--stk-radius-md, 8px);
999
+ font-size: var(--stk-text-sm, 0.875rem);
1000
+ font-family: var(--stk-font);
1001
+ color: var(--sui-text);
1002
+ background: var(--sui-bg);
1003
+ transition: border-color var(--stk-duration, 150ms) var(--stk-easing),
1004
+ box-shadow var(--stk-duration, 150ms) var(--stk-easing);
1005
+ }
1006
+ .form-group input::placeholder,
1007
+ .form-group textarea::placeholder,
1008
+ .stk-form-group input::placeholder,
1009
+ .stk-form-group textarea::placeholder {
1010
+ color: var(--sui-text-muted);
1011
+ }
1012
+
1013
+ /* ── DARK FIX: Section header component (BUG 6) ────── */
1014
+ .section-header, .stk-section-header {
1015
+ display: flex; align-items: center; justify-content: space-between;
1016
+ gap: var(--stk-space-4, 16px);
1017
+ margin-bottom: var(--stk-space-4, 16px);
1018
+ padding-bottom: var(--stk-space-3, 12px);
1019
+ border-bottom: 1px solid var(--sui-border);
1020
+ }
1021
+ .section-header h1, .section-header h2, .section-header h3,
1022
+ .stk-section-header h1, .stk-section-header h2, .stk-section-header h3 {
1023
+ margin: 0;
1024
+ font-size: var(--stk-text-lg, 1.125rem); font-weight: 600;
1025
+ color: var(--sui-text);
1026
+ }
1027
+ .section-header p, .stk-section-header p {
1028
+ margin: 0;
1029
+ font-size: var(--stk-text-sm, 0.875rem);
1030
+ color: var(--sui-text-muted);
1031
+ }
1032
+
798
1033
  .stk-autocomplete-empty {
799
1034
  padding: var(--stk-space-4) var(--stk-space-3);
800
1035
  text-align: center;
package/stick.js CHANGED
@@ -22,6 +22,13 @@
22
22
  * data-stick-loading="Loading…" button label while fetch is in-flight
23
23
  * data-stick-headers='{"Authorization":"Bearer token"}' fetch headers (JSON)
24
24
  * data-stick-json='{"key":"{{value}}"}' send JSON body instead of FormData
25
+ * data-stick-map="#sel:Field, …" JSON field → DOM mapping for fetch-json handler
26
+ * data-stick-then="handler:tgt:p, …" post-fetch action chain (sequential)
27
+ * data-stick-accept="json" parse fetch response as JSON array
28
+ * data-stick-template="#tpl" <template> for JSON array rendering (with accept=json)
29
+ * data-stick-state-loading="…" state actions when fetch starts
30
+ * data-stick-state-done="…" state actions when fetch succeeds
31
+ * data-stick-state-error="…" state actions when fetch fails
25
32
  *
26
33
  * Target selectors (data-stick-target):
27
34
  * "#id" querySelector (default)
@@ -45,6 +52,7 @@
45
52
  * {{url:key}} URLSearchParams value from current page URL (e.g. {{url:q}} → ?q=value)
46
53
  * {{data-*}} any data attribute, e.g. {{data-id}}
47
54
  * {{attr}} any attribute, e.g. {{href}}, {{src}}
55
+ * {{#sel.prop}} cross-element: querySelector(sel).prop (e.g. {{#myInput.value}})
48
56
  *
49
57
  * Example: data-stick="input:fetch:/api/search?q={{value}}"
50
58
  * data-stick="click:dispatch:item-selected:{{data-id}}"
@@ -80,7 +88,7 @@
80
88
  }(typeof window !== 'undefined' ? window : this, function () {
81
89
  'use strict';
82
90
 
83
- const VERSION = '3.0.0';
91
+ const VERSION = '3.1.0';
84
92
  const handlers = {};
85
93
  const listenerMap = new WeakMap(); // el → [{event, fn, options}]
86
94
  let _debug = false;
@@ -135,9 +143,44 @@
135
143
  }
136
144
  }
137
145
 
138
- // Resolve {{token}} placeholders using properties of el
146
+ // Resolve {{key}} tokens from a plain object (for JSON template rendering)
147
+ function interpolateFromObj(text, obj) {
148
+ return text.replace(/\{\{([\w-]+)\}\}/g, (_, key) => obj[key] !== undefined ? String(obj[key]) : '');
149
+ }
150
+
151
+ // Walk a cloned fragment and interpolate {{tokens}} from a plain object
152
+ function interpolateCloneFromObj(fragment, obj) {
153
+ var walk = document.createTreeWalker(fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
154
+ var node;
155
+ while ((node = walk.nextNode())) {
156
+ if (node.nodeType === Node.TEXT_NODE) {
157
+ if (node.nodeValue.includes('{{')) node.nodeValue = interpolateFromObj(node.nodeValue, obj);
158
+ } else {
159
+ [...node.attributes].forEach(function(attr) {
160
+ if (attr.value.includes('{{')) attr.value = interpolateFromObj(attr.value, obj);
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ // Resolve {{token}} placeholders — supports cross-element {{#selector.prop}}
139
167
  function interpolate(param, el) {
140
- return param.replace(/\{\{([\w-]+)\}\}/g, (_, key) => {
168
+ // Cross-element interpolation: {{#selector.prop}} resolves from another DOM element
169
+ param = param.replace(/\{\{(#[^}]+)\}\}/g, function(_, ref) {
170
+ var lastDot = ref.lastIndexOf('.');
171
+ if (lastDot <= 0) return '';
172
+ var sel = ref.slice(0, lastDot);
173
+ var prop = ref.slice(lastDot + 1);
174
+ var found = (typeof document !== 'undefined') ? document.querySelector(sel) : null;
175
+ if (!found) return '';
176
+ if (prop === 'value') return found.value ?? '';
177
+ if (prop === 'text' || prop === 'textContent') return found.textContent?.trim() ?? '';
178
+ if (prop === 'checked') return String(found.checked ?? false);
179
+ if (prop.startsWith('data-')) return found.dataset[toCamel(prop.slice(5))] ?? '';
180
+ return found[prop] !== undefined ? String(found[prop]) : (found.getAttribute(prop) ?? '');
181
+ });
182
+ // Local element interpolation
183
+ return param.replace(/\{\{([\w:-]+)\}\}/g, (_, key) => {
141
184
  if (key === 'value') return el.value ?? '';
142
185
  if (key === 'text') return el.textContent.trim();
143
186
  if (key === 'id') return el.id ?? '';
@@ -224,6 +267,57 @@
224
267
  });
225
268
  }
226
269
 
270
+ // ── state & then helpers ────────────────────────────────────────
271
+
272
+ // Parse state action: "handler:param:#target" or "handler:#target"
273
+ function parseStateAction(str) {
274
+ str = str.trim();
275
+ if (!str) return null;
276
+ var parts = str.split(':');
277
+ if (parts.length < 2) return null;
278
+ return { handler: parts[0], targetSel: parts[parts.length - 1].trim(), param: parts.length > 2 ? parts.slice(1, -1).join(':').trim() : '' };
279
+ }
280
+
281
+ // Apply state actions (Feature 5) — e.g. "add-class:loading:#container, disable:#btn"
282
+ function applyStateActions(actionsStr) {
283
+ if (!actionsStr) return;
284
+ actionsStr.split(',').forEach(function(raw) {
285
+ var parsed = parseStateAction(raw);
286
+ if (!parsed) return;
287
+ var target = document.querySelector(parsed.targetSel);
288
+ if (!target || typeof handlers[parsed.handler] !== 'function') return;
289
+ try { handlers[parsed.handler](target, parsed.param, { type: 'state' }, target); }
290
+ catch (err) { console.error('[Stick:state]', err); }
291
+ });
292
+ }
293
+
294
+ // Parse then action: "handler:target:param" or "handler:target"
295
+ function parseThenEntry(str) {
296
+ str = str.trim();
297
+ if (!str) return null;
298
+ var i1 = str.indexOf(':');
299
+ if (i1 === -1) return { handler: str, targetSel: null, param: '' };
300
+ var rest = str.slice(i1 + 1);
301
+ var i2 = rest.indexOf(':');
302
+ if (i2 === -1) return { handler: str.slice(0, i1), targetSel: rest.trim(), param: '' };
303
+ return { handler: str.slice(0, i1), targetSel: rest.slice(0, i2).trim(), param: rest.slice(i2 + 1).trim() };
304
+ }
305
+
306
+ // Execute post-fetch then chain (Feature 2) — "handler:target:param, …"
307
+ async function executeThenChain(actionsStr, sourceEl) {
308
+ if (!actionsStr) return;
309
+ var actions = actionsStr.split(',');
310
+ for (var i = 0; i < actions.length; i++) {
311
+ var parsed = parseThenEntry(actions[i]);
312
+ if (!parsed) continue;
313
+ var target = parsed.targetSel ? document.querySelector(parsed.targetSel) : sourceEl;
314
+ if (!target) { console.warn('[Stick:then] target not found:', parsed.targetSel); continue; }
315
+ if (typeof handlers[parsed.handler] !== 'function') { console.warn('[Stick:then] unknown handler:', parsed.handler); continue; }
316
+ try { await handlers[parsed.handler](sourceEl, parsed.param, { type: 'then' }, target); }
317
+ catch (err) { console.error('[Stick:then] handler "' + parsed.handler + '" threw:', err); }
318
+ }
319
+ }
320
+
227
321
  // ── core ──────────────────────────────────────────────────────────
228
322
 
229
323
  const Stick = {
@@ -597,15 +691,17 @@
597
691
  const val = localStorage.getItem(p);
598
692
  if (val !== null) t.value !== undefined ? (t.value = val) : (t.textContent = val);
599
693
  })
600
- // Network
694
+ // Network — supports accept=json (F4), state lifecycle (F5), then chain (F2)
601
695
  .add('fetch', async (el, param, e, target) => {
602
696
  e?.preventDefault?.();
603
- const method = (el.dataset.stickMethod || 'GET').toUpperCase();
604
- const swapMode = el.dataset.stickSwap || 'innerHTML';
605
- const loading = el.dataset.stickLoading || '';
606
- const errorSel = el.dataset.stickError;
607
- const errorEl = errorSel ? document.querySelector(errorSel) : null;
608
- const prev = el.textContent;
697
+ const method = (el.dataset.stickMethod || 'GET').toUpperCase();
698
+ const swapMode = el.dataset.stickSwap || 'innerHTML';
699
+ const loading = el.dataset.stickLoading || '';
700
+ const errorSel = el.dataset.stickError;
701
+ const errorEl = errorSel ? document.querySelector(errorSel) : null;
702
+ const prev = el.textContent;
703
+ const acceptJson = el.dataset.stickAccept === 'json';
704
+ const templateSel = el.dataset.stickTemplate;
609
705
 
610
706
  let headers = {};
611
707
  try { headers = JSON.parse(el.dataset.stickHeaders || '{}'); } catch (_) {}
@@ -613,6 +709,7 @@
613
709
  if (loading) el.textContent = loading;
614
710
  el.setAttribute('aria-busy', 'true');
615
711
  if (errorEl) errorEl.hidden = true;
712
+ applyStateActions(el.dataset.stickStateLoading);
616
713
 
617
714
  try {
618
715
  let body;
@@ -625,19 +722,75 @@
625
722
  body = new FormData(el.closest('form') || el);
626
723
  }
627
724
  }
628
- const res = await fetch(param, { method, body, headers });
725
+ const res = await fetch(param, { method, body, headers });
629
726
  if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
630
- const text = await res.text();
631
- swap(target, text, swapMode);
632
- Stick.init(target);
727
+
728
+ if (acceptJson) {
729
+ // Feature 4: JSON response → clone template per item into target
730
+ const json = await res.json();
731
+ const tpl = templateSel ? document.querySelector(templateSel) : null;
732
+ if (tpl && tpl.tagName === 'TEMPLATE') {
733
+ target.innerHTML = '';
734
+ var items = Array.isArray(json) ? json : [json];
735
+ items.forEach(function(item) {
736
+ var clone = tpl.content.cloneNode(true);
737
+ interpolateCloneFromObj(clone, item);
738
+ target.appendChild(clone);
739
+ });
740
+ Stick.init(target);
741
+ } else {
742
+ console.warn('[Stick:fetch] accept=json requires data-stick-template pointing to a <template>');
743
+ }
744
+ } else {
745
+ const text = await res.text();
746
+ swap(target, text, swapMode);
747
+ Stick.init(target);
748
+ }
749
+
750
+ applyStateActions(el.dataset.stickStateDone);
751
+ await executeThenChain(el.dataset.stickThen, el);
633
752
  } catch (err) {
634
753
  console.error('[Stick:fetch]', err);
754
+ applyStateActions(el.dataset.stickStateError);
635
755
  if (errorEl) { errorEl.textContent = err.message; errorEl.hidden = false; }
636
- else target.textContent = `Error: ${err.message}`;
756
+ else if (!el.dataset.stickStateError) target.textContent = `Error: ${err.message}`;
637
757
  } finally {
638
758
  if (loading) el.textContent = prev;
639
759
  el.removeAttribute('aria-busy');
640
760
  }
761
+ })
762
+ // Feature 1: fetch-json — GET URL, map JSON fields to DOM elements via data-stick-map
763
+ .add('fetch-json', async (el, param, e, target) => {
764
+ var mapStr = el.dataset.stickMap;
765
+ el.setAttribute('aria-busy', 'true');
766
+ applyStateActions(el.dataset.stickStateLoading);
767
+
768
+ try {
769
+ var res = await fetch(param, { method: 'GET' });
770
+ if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
771
+ var json = await res.json();
772
+
773
+ if (mapStr) {
774
+ mapStr.split(',').forEach(function(entry) {
775
+ entry = entry.trim();
776
+ if (!entry) return;
777
+ var idx = entry.indexOf(':');
778
+ if (idx === -1) return;
779
+ var sel = entry.slice(0, idx).trim();
780
+ var field = entry.slice(idx + 1).trim();
781
+ var found = document.querySelector(sel);
782
+ if (found && json[field] !== undefined) found.textContent = String(json[field]);
783
+ });
784
+ }
785
+
786
+ applyStateActions(el.dataset.stickStateDone);
787
+ await executeThenChain(el.dataset.stickThen, el);
788
+ } catch (err) {
789
+ console.error('[Stick:fetch-json]', err);
790
+ applyStateActions(el.dataset.stickStateError);
791
+ } finally {
792
+ el.removeAttribute('aria-busy');
793
+ }
641
794
  });
642
795
 
643
796
  // ── auto-init ─────────────────────────────────────────────────────