@faqir-ui/core 1.0.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.
@@ -0,0 +1,110 @@
1
+ // @ui:plugin faqir-collapse
2
+ // @ui:provides l-collapse
3
+ /**
4
+ * faqir-collapse — height auto-animation for a boolean expression. [0.4-11 · §A5]
5
+ *
6
+ * <div l-collapse="open"> … </div>
7
+ *
8
+ * When the bound expression is truthy the element animates from 0 → its natural
9
+ * height and settles at `height: auto`; when falsy it animates back to 0 and is
10
+ * hidden. Honors `prefers-reduced-motion` (snaps, no animation) and leaves NO
11
+ * inline `height` residue in either resting state — so content stays responsive.
12
+ *
13
+ * Self-registers via `Faqir.plugin` when a global `Faqir` is present (load it
14
+ * after faqir-core), and is also exported for bundlers/tests. Zero dependencies,
15
+ * ≤ 2 KB gzip. State it manipulates is inline style only — the DOM contract's
16
+ * `data-*` attributes are untouched.
17
+ */
18
+ (function () {
19
+ "use strict";
20
+
21
+ function prefersReducedMotion() {
22
+ return (
23
+ typeof window !== "undefined" &&
24
+ typeof window.matchMedia === "function" &&
25
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
26
+ );
27
+ }
28
+
29
+ // Settle on transitionend, or a timeout fallback so we never hang when the
30
+ // height does not actually change or the event is missed.
31
+ function whenSettled(el, cb) {
32
+ var done = false;
33
+ function finish(e) {
34
+ if (e && e.target !== el) return;
35
+ if (done) return;
36
+ done = true;
37
+ clearTimeout(timer);
38
+ el.removeEventListener("transitionend", finish);
39
+ cb();
40
+ }
41
+ el.addEventListener("transitionend", finish);
42
+ var timer = setTimeout(finish, 400);
43
+ }
44
+
45
+ // Reset every inline property this plugin owns → no height/overflow/transition
46
+ // residue in a resting state.
47
+ function clearInline(el) {
48
+ el.style.height = "";
49
+ el.style.overflow = "";
50
+ el.style.transition = "";
51
+ }
52
+
53
+ function install(Faqir) {
54
+ Faqir.directive("collapse", function (el, dir, scope) {
55
+ var open = null; // unknown until the first effect run
56
+
57
+ Faqir.effect(function () {
58
+ var next = !!Faqir.evaluate(dir.expression, scope, el);
59
+ if (next === open) return;
60
+ var firstRun = open === null;
61
+ open = next;
62
+
63
+ // First paint or reduced motion: snap to the resting state, no animation.
64
+ if (firstRun || prefersReducedMotion()) {
65
+ clearInline(el);
66
+ el.style.display = next ? "" : "none";
67
+ return;
68
+ }
69
+
70
+ el.style.overflow = "hidden";
71
+ el.style.transition =
72
+ "height var(--motion-enter-duration, var(--duration-normal, 200ms)) " +
73
+ "var(--motion-enter-ease, var(--ease-out, ease))";
74
+
75
+ if (next) {
76
+ // opening: 0 → scrollHeight → auto
77
+ el.style.display = "";
78
+ el.style.height = "0px";
79
+ void el.offsetHeight; // reflow so the 0 start commits
80
+ el.style.height = el.scrollHeight + "px";
81
+ whenSettled(el, function () {
82
+ clearInline(el); // height: auto, no residue
83
+ });
84
+ } else {
85
+ // closing: current height → 0 → hidden
86
+ el.style.height = el.scrollHeight + "px";
87
+ void el.offsetHeight; // reflow so the explicit start commits
88
+ el.style.height = "0px";
89
+ whenSettled(el, function () {
90
+ clearInline(el);
91
+ el.style.display = "none";
92
+ });
93
+ }
94
+ });
95
+ });
96
+ }
97
+
98
+ // CommonJS / bundler export (tests import the installer directly).
99
+ if (typeof module !== "undefined" && module.exports) {
100
+ module.exports = install;
101
+ }
102
+
103
+ // Browser self-registration: attach as soon as a global Faqir exists.
104
+ var F =
105
+ (typeof globalThis !== "undefined" && globalThis.Faqir) ||
106
+ (typeof window !== "undefined" && window.Faqir);
107
+ if (F && typeof F.plugin === "function") {
108
+ F.plugin(install);
109
+ }
110
+ })();
@@ -0,0 +1,64 @@
1
+ // @ui:plugin faqir-intersect
2
+ // @ui:provides l-intersect
3
+ /**
4
+ * faqir-intersect — declarative IntersectionObserver hooks. [0.6-05 · §A5]
5
+ *
6
+ * <section l-intersect="visible = true"
7
+ * l-intersect.leave="visible = false"></section>
8
+ * <div l-intersect.once="loadMore()"></div>
9
+ *
10
+ * The base directive runs on enter; `.leave` runs when the element leaves;
11
+ * `.once` disconnects after its first matching observation. The active entry is
12
+ * available to expressions as `$intersection`. Returning the observer cleanup
13
+ * to faqir-core makes scope teardown disconnect it automatically.
14
+ * Self-registering, zero dependencies, and ≤ 2 KB gzip.
15
+ */
16
+ (function () {
17
+ "use strict";
18
+
19
+ function install(Faqir) {
20
+ Faqir.directive("intersect", function (el, dir, scope) {
21
+ var IO =
22
+ (typeof globalThis !== "undefined" && globalThis.IntersectionObserver) ||
23
+ (typeof window !== "undefined" && window.IntersectionObserver);
24
+ if (typeof IO !== "function") return;
25
+
26
+ var leave = dir.modifiers.indexOf("leave") >= 0;
27
+ var once = dir.modifiers.indexOf("once") >= 0;
28
+ var stopped = false;
29
+ var observer = new IO(function (entries) {
30
+ for (var i = 0; i < entries.length && !stopped; i++) {
31
+ var entry = entries[i];
32
+ var matches = leave ? !entry.isIntersecting : entry.isIntersecting;
33
+ if (!matches) continue;
34
+
35
+ scope.$intersection = entry;
36
+ try {
37
+ Faqir.evaluateAssignment(dir.expression, scope, el);
38
+ } finally {
39
+ delete scope.$intersection;
40
+ }
41
+
42
+ if (once) {
43
+ stopped = true;
44
+ observer.disconnect();
45
+ }
46
+ }
47
+ });
48
+
49
+ observer.observe(el);
50
+ return function () {
51
+ if (stopped) return;
52
+ stopped = true;
53
+ observer.disconnect();
54
+ };
55
+ });
56
+ }
57
+
58
+ if (typeof module !== "undefined" && module.exports) module.exports = install;
59
+
60
+ var F =
61
+ (typeof globalThis !== "undefined" && globalThis.Faqir) ||
62
+ (typeof window !== "undefined" && window.Faqir);
63
+ if (F && typeof F.plugin === "function") F.plugin(install);
64
+ })();
@@ -0,0 +1,339 @@
1
+ // @ui:plugin faqir-mask
2
+ // @ui:provides l-mask
3
+ /**
4
+ * faqir-mask — caret-safe input masking. [0.6-06 · §A5]
5
+ *
6
+ * <input l-model="phone" l-mask="(999) 999-9999">
7
+ *
8
+ * Pattern tokens are `9` (digit), `a` (ASCII letter), and `*` (any character);
9
+ * every other character is a literal inserted by the formatter. The displayed
10
+ * input value is masked while `l-model` receives the raw token characters.
11
+ * `faqir:mask` bubbles after edits with `{ raw, value, caret }` for controllers
12
+ * such as input-otp. The pure `maskEdit()` function owns all edit/caret math;
13
+ * this directive is only a thin beforeinput/paste/model bridge.
14
+ *
15
+ * Self-registering, zero dependencies, and ≤ 2 KB gzip.
16
+ */
17
+ (function () {
18
+ "use strict";
19
+
20
+ var TOKENS = { "9": /[0-9]/, a: /[A-Za-z]/, "*": /[^]/ };
21
+
22
+ function isToken(ch) {
23
+ return Object.prototype.hasOwnProperty.call(TOKENS, ch);
24
+ }
25
+
26
+ function tokenTypes(mask) {
27
+ var out = [];
28
+ for (var i = 0; i < mask.length; i++) if (isToken(mask[i])) out.push(mask[i]);
29
+ return out;
30
+ }
31
+
32
+ function accepts(token, ch) {
33
+ return !!ch && TOKENS[token].test(ch);
34
+ }
35
+
36
+ /** Extract token characters from either raw or already-formatted input. */
37
+ function readRaw(mask, value, startSlot) {
38
+ var types = tokenTypes(mask);
39
+ var slot = startSlot || 0;
40
+ var raw = "";
41
+ var text = String(value == null ? "" : value);
42
+ var maskIndex = 0;
43
+ var seenSlots = 0;
44
+
45
+ while (maskIndex < mask.length && seenSlots < slot) {
46
+ if (isToken(mask[maskIndex])) seenSlots++;
47
+ maskIndex++;
48
+ }
49
+
50
+ for (var i = 0; i < text.length && slot < types.length; i++) {
51
+ var ch = text[i];
52
+ while (maskIndex < mask.length && !isToken(mask[maskIndex])) {
53
+ if (ch === mask[maskIndex]) {
54
+ maskIndex++;
55
+ ch = "";
56
+ break;
57
+ }
58
+ maskIndex++;
59
+ }
60
+ if (!ch) continue;
61
+ if (accepts(types[slot], ch)) {
62
+ raw += ch;
63
+ slot++;
64
+ while (maskIndex < mask.length && !isToken(mask[maskIndex])) maskIndex++;
65
+ maskIndex++;
66
+ }
67
+ }
68
+ return raw;
69
+ }
70
+
71
+ /** Format raw token characters and retain raw-index ↔ display-position maps. */
72
+ function format(mask, rawValue) {
73
+ var raw = readRaw(mask, rawValue, 0);
74
+ var value = "";
75
+ var starts = [];
76
+ var ends = [];
77
+ var rawIndex = 0;
78
+
79
+ for (var i = 0; i < mask.length; i++) {
80
+ var part = mask[i];
81
+ if (isToken(part)) {
82
+ if (rawIndex >= raw.length) break;
83
+ starts[rawIndex] = value.length;
84
+ value += raw[rawIndex++];
85
+ ends[rawIndex - 1] = value.length;
86
+ } else if (raw.length > 0) {
87
+ value += part;
88
+ }
89
+ }
90
+ return { raw: raw, value: value, starts: starts, ends: ends };
91
+ }
92
+
93
+ function rawIndexAt(formatted, displayPosition) {
94
+ var position = Math.max(0, Number(displayPosition) || 0);
95
+ var rawIndex = 0;
96
+ for (var i = 0; i < formatted.ends.length; i++) {
97
+ if (formatted.ends[i] <= position) rawIndex = i + 1;
98
+ else break;
99
+ }
100
+ return rawIndex;
101
+ }
102
+
103
+ function caretAt(formatted, rawIndex) {
104
+ if (rawIndex < formatted.raw.length) return formatted.starts[rawIndex];
105
+ return formatted.value.length;
106
+ }
107
+
108
+ /**
109
+ * Pure edit engine.
110
+ * @param {string} mask
111
+ * @param {string} priorValue canonical masked value before the edit
112
+ * @param {{inputType?:string,data?:string|null,selectionStart?:number,selectionEnd?:number}} edit
113
+ * @returns {{raw:string,value:string,caret:number}}
114
+ */
115
+ function maskEdit(mask, priorValue, edit) {
116
+ mask = String(mask || "");
117
+ var types = tokenTypes(mask);
118
+ if (!types.length) throw new Error("l-mask pattern requires at least one 9, a, or * token");
119
+
120
+ edit = edit || {};
121
+ var prior = format(mask, priorValue);
122
+ var displayStart = edit.selectionStart == null ? prior.value.length : edit.selectionStart;
123
+ var displayEnd = edit.selectionEnd == null ? displayStart : edit.selectionEnd;
124
+ var start = rawIndexAt(prior, Math.min(displayStart, displayEnd));
125
+ var end = rawIndexAt(prior, Math.max(displayStart, displayEnd));
126
+ var inputType = edit.inputType || "insertText";
127
+ var raw = prior.raw;
128
+ var caretRaw = start;
129
+
130
+ if (inputType.indexOf("delete") === 0) {
131
+ if (start !== end) {
132
+ raw = raw.slice(0, start) + raw.slice(end);
133
+ } else if (inputType === "deleteContentBackward" && start > 0) {
134
+ raw = raw.slice(0, start - 1) + raw.slice(start);
135
+ caretRaw = start - 1;
136
+ } else if (inputType === "deleteContentForward") {
137
+ raw = raw.slice(0, start) + raw.slice(start + 1);
138
+ }
139
+ } else {
140
+ var inserted = readRaw(mask, edit.data == null ? "" : edit.data, start);
141
+ raw = (raw.slice(0, start) + inserted + raw.slice(end)).slice(0, types.length);
142
+ caretRaw = start + inserted.length;
143
+ }
144
+
145
+ var next = format(mask, raw);
146
+ return { raw: next.raw, value: next.value, caret: caretAt(next, caretRaw) };
147
+ }
148
+
149
+ function modelExpression(el) {
150
+ for (var i = 0; i < el.attributes.length; i++) {
151
+ if (el.attributes[i].name.indexOf("l-model") === 0) {
152
+ return el.attributes[i].value;
153
+ }
154
+ }
155
+ return "";
156
+ }
157
+
158
+ function install(Faqir) {
159
+ Faqir.directive("mask", function (el, dir, scope) {
160
+ if (!el || !/^(INPUT|TEXTAREA)$/.test(el.tagName)) {
161
+ console.warn("[Faqir] l-mask must be used on an input or textarea");
162
+ return;
163
+ }
164
+ if (el._faqirMask) {
165
+ if (el._faqirMask._scope === scope) return;
166
+ if (typeof el._faqirMask.destroy === "function") el._faqirMask.destroy();
167
+ }
168
+
169
+ var pattern = String(dir.expression || "").trim();
170
+ try {
171
+ maskEdit(pattern, "", {});
172
+ } catch (error) {
173
+ console.warn("[Faqir] " + error.message);
174
+ return;
175
+ }
176
+
177
+ var model = modelExpression(el);
178
+ var destroyed = false;
179
+ var renderId = 0;
180
+ var pendingCaret = null;
181
+ var initial = maskEdit(pattern, "", {
182
+ inputType: "insertFromPaste",
183
+ data: el.value,
184
+ selectionStart: 0,
185
+ selectionEnd: 0,
186
+ });
187
+ var raw = initial.raw;
188
+
189
+ function paint(result, caret) {
190
+ raw = result.raw;
191
+ if (el.value !== result.value) el.value = result.value;
192
+ if (caret !== false && typeof el.setSelectionRange === "function") {
193
+ try {
194
+ el.setSelectionRange(result.caret, result.caret);
195
+ } catch (e) {}
196
+ }
197
+ }
198
+
199
+ function emit(result) {
200
+ el.dispatchEvent(
201
+ new CustomEvent("faqir:mask", {
202
+ bubbles: true,
203
+ detail: { raw: result.raw, value: result.value, caret: result.caret },
204
+ }),
205
+ );
206
+ }
207
+
208
+ function commit(result, shouldEmit) {
209
+ paint(result, true);
210
+ if (model) {
211
+ pendingCaret = result.caret;
212
+ Faqir.evaluateAssignment(model + " = " + JSON.stringify(result.raw), scope, el);
213
+ // l-model also reflects the raw scope value into el.value. Repaint after
214
+ // the reactive flush so the visible value remains masked regardless of
215
+ // directive attribute order.
216
+ var id = ++renderId;
217
+ Faqir.nextTick(function () {
218
+ if (!destroyed && id === renderId) {
219
+ var repaint = formatResult(raw);
220
+ repaint.caret = pendingCaret == null ? repaint.caret : pendingCaret;
221
+ pendingCaret = null;
222
+ paint(repaint, true);
223
+ }
224
+ });
225
+ }
226
+ if (shouldEmit !== false) emit(result);
227
+ return result;
228
+ }
229
+
230
+ function formatResult(value) {
231
+ return maskEdit(pattern, "", {
232
+ inputType: "insertFromPaste",
233
+ data: value,
234
+ selectionStart: 0,
235
+ selectionEnd: 0,
236
+ });
237
+ }
238
+
239
+ function edit(inputType, data, selectionStart, selectionEnd) {
240
+ return commit(
241
+ maskEdit(pattern, el.value, {
242
+ inputType: inputType,
243
+ data: data,
244
+ selectionStart: selectionStart == null ? el.selectionStart : selectionStart,
245
+ selectionEnd: selectionEnd == null ? el.selectionEnd : selectionEnd,
246
+ }),
247
+ true,
248
+ );
249
+ }
250
+
251
+ function setRaw(value, caret, shouldEmit) {
252
+ var result = formatResult(value);
253
+ if (caret != null) {
254
+ var formatted = format(pattern, result.raw);
255
+ result.caret = caretAt(formatted, Math.max(0, Math.min(Number(caret) || 0, result.raw.length)));
256
+ }
257
+ return commit(result, shouldEmit);
258
+ }
259
+
260
+ function onBeforeInput(event) {
261
+ var type = event.inputType || "";
262
+ if (type.indexOf("insert") !== 0 && type.indexOf("delete") !== 0) return;
263
+ event.preventDefault();
264
+ event.stopImmediatePropagation();
265
+ edit(type, event.data);
266
+ }
267
+
268
+ function onPaste(event) {
269
+ var data = event.clipboardData;
270
+ if (!data || typeof data.getData !== "function") return;
271
+ event.preventDefault();
272
+ event.stopImmediatePropagation();
273
+ edit("insertFromPaste", data.getData("text"));
274
+ }
275
+
276
+ function onInput(event) {
277
+ // Fallback for autofill/IME paths that skip cancelable beforeinput.
278
+ event.stopImmediatePropagation();
279
+ var caret = el.selectionStart;
280
+ var result = formatResult(el.value);
281
+ result.caret = Math.min(caret == null ? result.value.length : caret, result.value.length);
282
+ commit(result, true);
283
+ }
284
+
285
+ el.addEventListener("beforeinput", onBeforeInput, true);
286
+ el.addEventListener("paste", onPaste, true);
287
+ el.addEventListener("input", onInput, true);
288
+
289
+ var api;
290
+ function cleanup() {
291
+ if (destroyed) return;
292
+ destroyed = true;
293
+ el.removeEventListener("beforeinput", onBeforeInput, true);
294
+ el.removeEventListener("paste", onPaste, true);
295
+ el.removeEventListener("input", onInput, true);
296
+ if (typeof stopModel === "function") stopModel();
297
+ if (el._faqirMask === api) delete el._faqirMask;
298
+ }
299
+
300
+ api = {
301
+ edit: edit,
302
+ getRaw: function () { return raw; },
303
+ setRaw: setRaw,
304
+ destroy: cleanup,
305
+ _scope: scope,
306
+ };
307
+ el._faqirMask = api;
308
+ paint(initial, false);
309
+
310
+ var stopModel = null;
311
+ if (model) {
312
+ stopModel = Faqir.effect(function () {
313
+ var value = Faqir.evaluate(model, scope, el);
314
+ var id = ++renderId;
315
+ Faqir.nextTick(function () {
316
+ if (!destroyed && id === renderId) {
317
+ var repaint = formatResult(value);
318
+ if (pendingCaret != null && repaint.raw === raw) {
319
+ repaint.caret = pendingCaret;
320
+ pendingCaret = null;
321
+ paint(repaint, true);
322
+ } else {
323
+ paint(repaint, false);
324
+ }
325
+ }
326
+ });
327
+ });
328
+ }
329
+
330
+ return cleanup;
331
+ });
332
+ }
333
+
334
+ install.maskEdit = maskEdit;
335
+ if (typeof module !== "undefined" && module.exports) module.exports = install;
336
+
337
+ var F = typeof globalThis !== "undefined" ? globalThis.Faqir : window.Faqir;
338
+ if (F && typeof F.plugin === "function") F.plugin(install);
339
+ })();
@@ -0,0 +1,129 @@
1
+ // @ui:plugin faqir-persist
2
+ // @ui:provides l-persist, $persist()
3
+ /**
4
+ * faqir-persist — localStorage-backed reactive state. [0.6-05 · §A5]
5
+ *
6
+ * <div l-data="{ count: 0 }" l-persist="count">
7
+ * <button @click="count++" l-text="count"></button>
8
+ * </div>
9
+ *
10
+ * `l-persist="expression"` restores the expression before its first effect and
11
+ * writes every reactive change as JSON. Keys are scoped as
12
+ * `faqir:<namespace>:<key>`; set `data-persist-namespace` or
13
+ * `data-persist-key` when the location-derived defaults are not specific enough.
14
+ * `$persist(key)` reads the current namespace and `$persist(key, value)` writes
15
+ * it, which is useful from `l-init` and event expressions.
16
+ *
17
+ * Storage access is guarded because localStorage can be absent or can throw in
18
+ * privacy/quota modes. Failed writes fall back to module-local memory, keeping
19
+ * the page reactive without throwing. Self-registering, zero dependencies,
20
+ * and ≤ 2 KB gzip.
21
+ */
22
+ (function () {
23
+ "use strict";
24
+
25
+ var memory = Object.create(null);
26
+
27
+ function storage() {
28
+ try {
29
+ var g = typeof globalThis !== "undefined" ? globalThis : window;
30
+ return g.localStorage || null;
31
+ } catch (e) {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function namespace(el) {
37
+ var owner = el.closest && el.closest("[data-persist-namespace]");
38
+ if (owner) return owner.getAttribute("data-persist-namespace") || "default";
39
+ try {
40
+ return (location && location.pathname) || "default";
41
+ } catch (e) {
42
+ return "default";
43
+ }
44
+ }
45
+
46
+ function fullKey(el, key) {
47
+ return "faqir:" + namespace(el) + ":" + key;
48
+ }
49
+
50
+ function readRaw(key) {
51
+ if (Object.prototype.hasOwnProperty.call(memory, key)) return memory[key];
52
+ try {
53
+ var s = storage();
54
+ return s ? s.getItem(key) : null;
55
+ } catch (e) {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeRaw(key, value) {
61
+ try {
62
+ var s = storage();
63
+ if (!s) throw new Error("storage unavailable");
64
+ if (value == null) s.removeItem(key);
65
+ else s.setItem(key, value);
66
+ delete memory[key];
67
+ } catch (e) {
68
+ if (value == null) delete memory[key];
69
+ else memory[key] = value;
70
+ }
71
+ }
72
+
73
+ function decode(raw) {
74
+ if (raw == null) return { found: false };
75
+ try {
76
+ return { found: true, value: JSON.parse(raw) };
77
+ } catch (e) {
78
+ return { found: false };
79
+ }
80
+ }
81
+
82
+ function encode(value) {
83
+ try {
84
+ return JSON.stringify(value);
85
+ } catch (e) {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function install(Faqir) {
91
+ Faqir.magic("persist", function (el) {
92
+ return function (key, value) {
93
+ var storageKey = fullKey(el, String(key));
94
+ if (arguments.length > 1) {
95
+ writeRaw(storageKey, encode(value));
96
+ return value;
97
+ }
98
+ var saved = decode(readRaw(storageKey));
99
+ return saved.found ? saved.value : undefined;
100
+ };
101
+ });
102
+
103
+ Faqir.directive("persist", function (el, dir, scope) {
104
+ var expression = (dir.expression || "").trim();
105
+ if (!expression) {
106
+ console.warn("[Faqir] l-persist requires a state expression");
107
+ return;
108
+ }
109
+
110
+ var key = el.getAttribute("data-persist-key") || expression;
111
+ var storageKey = fullKey(el, key);
112
+ var saved = decode(readRaw(storageKey));
113
+ if (saved.found) {
114
+ Faqir.evaluateAssignment(expression + " = " + JSON.stringify(saved.value), scope, el);
115
+ }
116
+
117
+ return Faqir.effect(function () {
118
+ writeRaw(storageKey, encode(Faqir.evaluate(expression, scope, el)));
119
+ });
120
+ });
121
+ }
122
+
123
+ if (typeof module !== "undefined" && module.exports) module.exports = install;
124
+
125
+ var F =
126
+ (typeof globalThis !== "undefined" && globalThis.Faqir) ||
127
+ (typeof window !== "undefined" && window.Faqir);
128
+ if (F && typeof F.plugin === "function") F.plugin(install);
129
+ })();