@kashi-ui/elements 0.0.0-stage → 0.24.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,425 @@
1
+ import { formatNumber, normalizePersian, onlyDigits, toEnDigits, toFaDigits } from "@kashi-ui/persian";
2
+ import { HTMLBase, bool, define, emit, esc, fieldHTML, setMessages, uid } from "./base.js";
3
+ const REMOVE = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="m6 6 8 8M14 6l-8 8"/></svg>';
4
+ /** چند فیلد پنهان با یک name، برای ارسال چند مقدار در فرم HTML */
5
+ function syncHiddenList(host, name, values) {
6
+ host.querySelectorAll(":scope > input[type=hidden][data-kashi-value]").forEach((el) => el.remove());
7
+ if (!name)
8
+ return;
9
+ for (const v of values) {
10
+ const h = document.createElement("input");
11
+ h.type = "hidden";
12
+ h.name = name;
13
+ h.value = v;
14
+ h.dataset.kashiValue = "";
15
+ host.append(h);
16
+ }
17
+ }
18
+ /**
19
+ * فیلد برچسب: با Enter، «,» یا «،» اضافه، با Backspace حذف آخرین، و چسباندن چند مورد با هم.
20
+ * با name هر برچسب یک فیلد پنهان هم‌نام در فرم است.
21
+ */
22
+ export class KashiTagInput extends HTMLBase {
23
+ constructor() {
24
+ super(...arguments);
25
+ this.tags = [];
26
+ this.localError = null;
27
+ this.rendered = false;
28
+ this.valueSet = false;
29
+ this.key = uid("kashi-tags");
30
+ }
31
+ connectedCallback() {
32
+ if (!this.rendered && !this.valueSet)
33
+ this.tags = this.parse(this.getAttribute("value"));
34
+ this.render();
35
+ }
36
+ attributeChangedCallback(name, old, next) {
37
+ if (!this.rendered || old === next)
38
+ return;
39
+ if (name === "value")
40
+ this.value = this.parse(next);
41
+ else
42
+ this.render();
43
+ }
44
+ parse(v) {
45
+ return (v ?? "").split(/[,،]/).map((s) => s.trim()).filter(Boolean);
46
+ }
47
+ /** برچسب‌ها به‌شکل آرایه */
48
+ get value() {
49
+ return [...this.tags];
50
+ }
51
+ set value(v) {
52
+ this.valueSet = true;
53
+ this.tags = Array.isArray(v) ? [...v] : this.parse(v);
54
+ if (this.rendered)
55
+ this.paint();
56
+ }
57
+ focus(options) {
58
+ this.input?.focus(options);
59
+ }
60
+ render() {
61
+ const id = this.key;
62
+ const ariaLabel = this.getAttribute("aria-label");
63
+ const label = this.hasAttribute("label") ? this.getAttribute("label") : ariaLabel ? null : "برچسب‌ها";
64
+ const control = `<div class="k-tag-input"><input id="${id}" class="k-tag-input__control"${this.hasAttribute("disabled") ? " disabled" : ""}${!label && ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ""}></div><span class="k-sr-only" aria-live="polite"></span>`;
65
+ this.querySelector(":scope > .k-field")?.remove();
66
+ this.insertAdjacentHTML("afterbegin", fieldHTML({ id, label, control }));
67
+ this.input = this.querySelector(".k-tag-input__control");
68
+ const box = this.querySelector(".k-tag-input");
69
+ box.addEventListener("click", (e) => {
70
+ const rm = e.target.closest(".k-badge__remove");
71
+ if (rm)
72
+ this.removeAt(Number(rm.dataset.i));
73
+ else
74
+ this.input.focus();
75
+ });
76
+ this.input.addEventListener("keydown", (e) => {
77
+ if ((e.key === "Enter" || e.key === "," || e.key === "،") && this.input.value.trim()) {
78
+ e.preventDefault();
79
+ this.add([this.input.value]);
80
+ }
81
+ else if (e.key === "Backspace" && !this.input.value && this.tags.length)
82
+ this.removeAt(this.tags.length - 1);
83
+ });
84
+ this.input.addEventListener("input", () => {
85
+ this.localError = null;
86
+ // صفحه‌کلید موبایل و IME رویداد کلید نمی‌فرستند؛ جداکننده را در متن هم پیدا کن
87
+ const parts = this.input.value.split(/[,،\n\t]/);
88
+ if (parts.length > 1) {
89
+ const rest = parts.pop() ?? "";
90
+ this.add(parts);
91
+ if (rest && !this.localError)
92
+ this.input.value = rest;
93
+ }
94
+ else
95
+ this.paintMessages();
96
+ });
97
+ this.input.addEventListener("blur", () => this.input.value.trim() && this.add([this.input.value]));
98
+ this.rendered = true;
99
+ this.paint();
100
+ }
101
+ add(raw) {
102
+ const max = Number(this.getAttribute("max")) || Infinity;
103
+ const dup = bool(this, "allow-duplicates");
104
+ let next = [...this.tags];
105
+ let added = "";
106
+ for (const r of raw) {
107
+ const t = normalizePersian(r).trim();
108
+ if (!t)
109
+ continue;
110
+ const err = next.length >= max ? `حداکثر ${toFaDigits(max)} مورد` : !dup && next.includes(t) ? "این مورد قبلاً اضافه شده است." : this.validate?.(t);
111
+ if (err) {
112
+ this.localError = err;
113
+ this.commit(next, added);
114
+ this.input.value = t;
115
+ return;
116
+ }
117
+ next = [...next, t];
118
+ added = `${t} اضافه شد`;
119
+ }
120
+ this.localError = null;
121
+ this.input.value = "";
122
+ this.commit(next, added);
123
+ }
124
+ removeAt(i) {
125
+ const t = this.tags[i];
126
+ this.commit(this.tags.filter((_, j) => j !== i), `${t} حذف شد`);
127
+ this.input.focus();
128
+ }
129
+ commit(next, announce) {
130
+ const changed = next.join("\u0000") !== this.tags.join("\u0000");
131
+ this.tags = next;
132
+ this.paint();
133
+ if (announce)
134
+ this.querySelector(".k-field > .k-sr-only").textContent = announce;
135
+ if (changed)
136
+ emit(this, "kashi-change", { value: [...next] });
137
+ }
138
+ paint() {
139
+ const box = this.querySelector(".k-tag-input");
140
+ box.querySelectorAll(".k-badge").forEach((b) => b.remove());
141
+ const tone = this.getAttribute("tone") ?? "brand";
142
+ const off = this.hasAttribute("disabled");
143
+ const badges = this.tags.map((t, i) => `<span class="k-badge${tone !== "neutral" ? ` k-badge--${esc(tone)}` : ""}${off ? "" : " k-badge--removable"}">${esc(t)}${off ? "" : `<button type="button" class="k-badge__remove" data-i="${i}" aria-label="حذف ${esc(t)}">${REMOVE}</button>`}</span>`).join("");
144
+ this.input.insertAdjacentHTML("beforebegin", badges);
145
+ this.input.placeholder = this.tags.length ? "" : (this.getAttribute("placeholder") ?? "");
146
+ this.paintMessages();
147
+ syncHiddenList(this, this.getAttribute("name"), this.tags);
148
+ }
149
+ paintMessages() {
150
+ const error = this.getAttribute("error") || this.localError;
151
+ this.querySelector(".k-tag-input").toggleAttribute("data-invalid", !!error);
152
+ setMessages(this.querySelector(":scope > .k-field"), this.input, this.key, error, this.getAttribute("hint"));
153
+ }
154
+ }
155
+ KashiTagInput.observedAttributes = ["label", "hint", "error", "placeholder", "disabled", "name", "value", "max", "tone", "allow-duplicates", "aria-label"];
156
+ const MINUS = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M5 10h10"/></svg>';
157
+ const PLUS = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M10 5v10M5 10h10"/></svg>';
158
+ /** فیلد عدد با دکمه‌های − و +، ارقام فارسی، جداکنندهٔ هزارگان و کلیدهای ↑↓ و PageUp/PageDown */
159
+ export class KashiNumberInput extends HTMLBase {
160
+ constructor() {
161
+ super(...arguments);
162
+ this.current = null;
163
+ this.rendered = false;
164
+ this.valueSet = false;
165
+ this.key = uid("kashi-num");
166
+ }
167
+ connectedCallback() {
168
+ if (!this.rendered && !this.valueSet)
169
+ this.current = this.num(this.getAttribute("value")) ?? null;
170
+ this.render();
171
+ }
172
+ attributeChangedCallback(name, old, next) {
173
+ if (!this.rendered || old === next)
174
+ return;
175
+ if (name === "value")
176
+ this.value = next === null || next === "" ? null : Number(next);
177
+ else
178
+ this.render();
179
+ }
180
+ get value() {
181
+ return this.current;
182
+ }
183
+ set value(v) {
184
+ this.valueSet = true;
185
+ this.current = v === null || v === undefined || Number.isNaN(Number(v)) ? null : Number(v);
186
+ if (this.rendered)
187
+ this.paint();
188
+ }
189
+ focus(options) {
190
+ this.input?.focus(options);
191
+ }
192
+ num(v) {
193
+ return v === null || v === "" || Number.isNaN(Number(v)) ? undefined : Number(v);
194
+ }
195
+ get min() {
196
+ return this.num(this.getAttribute("min"));
197
+ }
198
+ get max() {
199
+ return this.num(this.getAttribute("max"));
200
+ }
201
+ clamp(n) {
202
+ return Math.min(this.max ?? Infinity, Math.max(this.min ?? -Infinity, n));
203
+ }
204
+ show(n) {
205
+ if (n === null)
206
+ return "";
207
+ const s = this.hasAttribute("thousands") ? formatNumber(n, { digits: "latin" }) : String(n);
208
+ return this.getAttribute("digits") === "latin" ? s : toFaDigits(s).replace(/,/g, "٬");
209
+ }
210
+ render() {
211
+ const id = this.key;
212
+ const ariaLabel = this.getAttribute("aria-label");
213
+ const label = this.hasAttribute("label") ? this.getAttribute("label") : ariaLabel ? null : "تعداد";
214
+ const disabled = this.hasAttribute("disabled");
215
+ const unit = this.getAttribute("unit");
216
+ const control = `<div class="k-number"><div class="k-input"${disabled ? ' data-state="disabled"' : ""}><button type="button" class="k-number__step" data-step="-1" tabindex="-1" aria-label="کاهش"${disabled ? " disabled" : ""}>${MINUS}</button><input id="${id}" class="k-input__control" type="text" inputmode="decimal" role="spinbutton" dir="ltr"${disabled ? " disabled" : ""}${!label && ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ""}>${unit ? `<span class="k-input__addon">${esc(unit)}</span>` : ""}<button type="button" class="k-number__step" data-step="1" tabindex="-1" aria-label="افزایش"${disabled ? " disabled" : ""}>${PLUS}</button></div></div>`;
217
+ this.querySelector(":scope > .k-field")?.remove();
218
+ this.insertAdjacentHTML("afterbegin", fieldHTML({ id, label, control }));
219
+ this.input = this.querySelector("input.k-input__control");
220
+ this.querySelectorAll(".k-number__step").forEach((b) => b.addEventListener("click", () => this.bump(Number(b.dataset.step))));
221
+ this.input.addEventListener("input", () => {
222
+ const raw = this.input.value;
223
+ const d = onlyDigits(raw);
224
+ // هنگام تایپ محدود نمی‌شود تا عدد نیمه‌کاره پاک نشود؛ محدودسازی در blur و دکمه‌ها
225
+ this.set(d ? (/^\s*[-−]/.test(raw) ? -1 : 1) * Number(d) : null, false);
226
+ });
227
+ this.input.addEventListener("blur", () => {
228
+ if (this.current !== null && this.current !== this.clamp(this.current))
229
+ this.set(this.clamp(this.current));
230
+ else
231
+ this.paint();
232
+ });
233
+ this.input.addEventListener("keydown", (e) => {
234
+ const keys = {
235
+ ArrowUp: () => this.bump(1),
236
+ ArrowDown: () => this.bump(-1),
237
+ PageUp: () => this.bump(10),
238
+ PageDown: () => this.bump(-10),
239
+ Home: () => this.min !== undefined && this.set(this.min),
240
+ End: () => this.max !== undefined && this.set(this.max),
241
+ };
242
+ if (!keys[e.key])
243
+ return;
244
+ e.preventDefault();
245
+ keys[e.key]();
246
+ });
247
+ this.rendered = true;
248
+ this.paint();
249
+ }
250
+ bump(k) {
251
+ const step = this.num(this.getAttribute("step")) ?? 1;
252
+ this.set(this.clamp((this.current ?? this.min ?? 0) + k * step));
253
+ }
254
+ set(n, repaint = true) {
255
+ const changed = n !== this.current;
256
+ this.current = n;
257
+ if (repaint)
258
+ this.paint();
259
+ else
260
+ this.paintState();
261
+ if (changed)
262
+ emit(this, "kashi-change", { value: n });
263
+ }
264
+ paint() {
265
+ const shown = this.show(this.current);
266
+ if (this.input.value !== shown)
267
+ this.input.value = shown;
268
+ this.paintState();
269
+ }
270
+ paintState() {
271
+ const el = this.input;
272
+ const c = this.current;
273
+ for (const [a, v] of [["aria-valuenow", c], ["aria-valuemin", this.min], ["aria-valuemax", this.max]]) {
274
+ if (v === null || v === undefined)
275
+ el.removeAttribute(a);
276
+ else
277
+ el.setAttribute(a, String(v));
278
+ }
279
+ if (c === null)
280
+ el.removeAttribute("aria-valuetext");
281
+ else
282
+ el.setAttribute("aria-valuetext", this.show(c));
283
+ const off = this.hasAttribute("disabled");
284
+ const [dec, inc] = this.querySelectorAll(".k-number__step");
285
+ dec.disabled = off || (this.min !== undefined && (c ?? 0) <= this.min);
286
+ inc.disabled = off || (this.max !== undefined && (c ?? 0) >= this.max);
287
+ setMessages(this.querySelector(":scope > .k-field"), el, this.key, this.getAttribute("error"), this.getAttribute("hint"));
288
+ syncHiddenList(this, this.getAttribute("name"), [c === null ? "" : String(c)]);
289
+ }
290
+ }
291
+ KashiNumberInput.observedAttributes = ["label", "hint", "error", "value", "min", "max", "step", "thousands", "digits", "unit", "disabled", "name", "aria-label"];
292
+ const STAR = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2.8l2.8 5.9 6.4.8-4.7 4.4 1.2 6.3L12 17.1l-5.7 3.1 1.2-6.3L2.8 9.5l6.4-.8z"/><path class="k-rating__half" fill="none" d="M12 2.8v14.3l-5.7 3.1 1.2-6.3L2.8 9.5l6.4-.8z"/></svg>';
293
+ const faNum = (n) => toFaDigits(String(n)).replace(".", "٫");
294
+ /** امتیاز ستاره‌ای روی رادیوهای بومی؛ با readonly فقط نمایش (با نیم‌ستاره برای امتیاز اعشاری) */
295
+ export class KashiRating extends HTMLBase {
296
+ constructor() {
297
+ super(...arguments);
298
+ this.current = 0;
299
+ this.valueSet = false;
300
+ this.key = uid("kashi-rate");
301
+ }
302
+ connectedCallback() {
303
+ if (!this.valueSet)
304
+ this.current = Number(this.getAttribute("value")) || 0;
305
+ this.render();
306
+ }
307
+ attributeChangedCallback(name, old, next) {
308
+ if (old === next || !this.isConnected)
309
+ return;
310
+ if (name === "value")
311
+ this.current = Number(next) || 0;
312
+ this.render();
313
+ }
314
+ get value() {
315
+ return this.current;
316
+ }
317
+ set value(v) {
318
+ this.valueSet = true;
319
+ this.current = Number(v) || 0;
320
+ if (this.isConnected)
321
+ this.render();
322
+ }
323
+ render() {
324
+ const max = Number(this.getAttribute("max")) || 5;
325
+ const label = this.getAttribute("label") ?? "امتیاز";
326
+ const size = this.getAttribute("size");
327
+ const cls = `k-rating${size === "sm" || size === "lg" ? ` k-rating--${size}` : ""}`;
328
+ const c = this.current;
329
+ const stars = Array.from({ length: max }, (_, i) => i + 1);
330
+ const valueHtml = this.hasAttribute("show-value") ? `<span class="k-rating__value" aria-hidden="true">${faNum(c)}</span>` : "";
331
+ if (this.hasAttribute("readonly")) {
332
+ this.innerHTML = `<span class="${cls}" data-readonly="" role="img" aria-label="${esc(`${label}: ${faNum(c)} از ${faNum(max)}`)}">${stars.map((n) => `<span class="k-rating__star"${n <= Math.floor(c) ? ' data-filled=""' : ""}${n === Math.ceil(c) && c % 1 >= 0.25 ? ' data-half=""' : ""}>${STAR}</span>`).join("")}${valueHtml}</span>`;
333
+ return;
334
+ }
335
+ const name = this.getAttribute("name") ?? this.key;
336
+ const off = this.hasAttribute("disabled");
337
+ this.innerHTML = `<fieldset class="${cls}"><legend class="k-sr-only">${esc(label)}</legend>${stars.map((n) => `<label class="k-rating__star"${n <= c ? ' data-filled=""' : ""}><input type="radio" class="k-sr-only" name="${esc(name)}" value="${n}"${c === n ? " checked" : ""}${off ? " disabled" : ""} aria-label="${faNum(n)} از ${faNum(max)}">${STAR}</label>`).join("")}${valueHtml}</fieldset>`;
338
+ const fs = this.querySelector("fieldset");
339
+ const fill = (upto) => fs.querySelectorAll(".k-rating__star").forEach((s, i) => s.toggleAttribute("data-filled", i + 1 <= upto));
340
+ fs.addEventListener("change", (e) => {
341
+ this.current = Number(e.target.value);
342
+ fill(this.current);
343
+ emit(this, "kashi-change", { value: this.current });
344
+ });
345
+ fs.addEventListener("pointerover", (e) => {
346
+ const star = e.target.closest(".k-rating__star");
347
+ if (star && !off)
348
+ fill([...fs.querySelectorAll(".k-rating__star")].indexOf(star) + 1);
349
+ });
350
+ fs.addEventListener("pointerleave", () => fill(this.current));
351
+ }
352
+ }
353
+ KashiRating.observedAttributes = ["value", "max", "readonly", "show-value", "size", "label", "name", "disabled"];
354
+ /** شماره‌های صفحه: ۱ … ۴ ۵ ۶ … ۲۰ */
355
+ export function pageRange(page, total, siblings = 1) {
356
+ const out = [1];
357
+ const from = Math.max(2, page - siblings);
358
+ const to = Math.min(total - 1, page + siblings);
359
+ if (from > 2)
360
+ out.push(from === 3 ? 2 : null);
361
+ for (let p = from; p <= to; p++)
362
+ out.push(p);
363
+ if (to < total - 1)
364
+ out.push(to === total - 2 ? total - 1 : null);
365
+ if (total > 1)
366
+ out.push(total);
367
+ return out;
368
+ }
369
+ const chevron = (d) => `<svg data-k-mirror="" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
370
+ /**
371
+ * صفحه‌بندی. با href (مثل "?page={page}") لینک می‌سازد و با رفتن به صفحهٔ دیگر صفحه عوض می‌شود؛
372
+ * بدون آن دکمه است و رویداد kashi-change با شمارهٔ صفحه می‌فرستد.
373
+ */
374
+ export class KashiPagination extends HTMLBase {
375
+ connectedCallback() {
376
+ this.render();
377
+ }
378
+ attributeChangedCallback(_n, old, next) {
379
+ if (old !== next && this.isConnected)
380
+ this.render();
381
+ }
382
+ get page() {
383
+ return Math.max(1, Number(this.getAttribute("page")) || 1);
384
+ }
385
+ set page(p) {
386
+ this.setAttribute("page", String(p));
387
+ }
388
+ get total() {
389
+ return Math.max(1, Number(this.getAttribute("total")) || 1);
390
+ }
391
+ render() {
392
+ const page = Math.min(this.page, this.total), total = this.total;
393
+ const n = (v) => (this.getAttribute("digits") === "latin" ? String(v) : toFaDigits(v));
394
+ const href = this.getAttribute("href");
395
+ const focused = this.contains(document.activeElement) ? document.activeElement.dataset.to : null;
396
+ const item = (to, body, cls, { current = false, disabled = false, aria = "" } = {}) => href && !disabled
397
+ ? `<a class="${cls}" href="${esc(href.replace("{page}", String(to)))}" data-to="${to}"${current ? ' aria-current="page"' : ""}${aria ? ` aria-label="${esc(aria)}"` : ""}>${body}</a>`
398
+ : `<button type="button" class="${cls}" data-to="${to}"${disabled ? " disabled" : ""}${current ? ' aria-current="page"' : ""}${aria ? ` aria-label="${esc(aria)}"` : ""}>${body}</button>`;
399
+ const summary = this.getAttribute("summary");
400
+ this.innerHTML = `<nav class="k-pagination" aria-label="${esc(this.getAttribute("label") ?? "صفحه‌بندی")}">${item(page - 1, `${chevron("m12 5-5 5 5 5")}قبلی`, "k-btn k-btn--secondary k-btn--sm", { disabled: page <= 1 })}${summary ? `<span class="k-pagination__summary">${esc(summary)}</span>` : ""}<ul class="k-pagination__pages" role="list">${pageRange(page, total, Number(this.getAttribute("siblings") ?? 1)).map((p) => `<li>${p === null ? '<span class="k-pagination__ellipsis" aria-hidden="true">…</span>' : item(p, n(p), "k-pagination__page", { current: p === page, aria: `صفحهٔ ${n(p)}` })}</li>`).join("")}</ul>${item(page + 1, `بعدی${chevron("m8 5 5 5-5 5")}`, "k-btn k-btn--secondary k-btn--sm", { disabled: page >= total })}</nav>`;
401
+ this.querySelector("nav").addEventListener("click", (e) => {
402
+ const b = e.target.closest("button[data-to]");
403
+ if (!b || b.disabled)
404
+ return;
405
+ const to = Number(b.dataset.to);
406
+ this.setAttribute("page", String(to));
407
+ emit(this, "kashi-change", { page: to });
408
+ // فوکوس روی شمارهٔ صفحهٔ تازه بماند
409
+ this.querySelector(`.k-pagination__page[data-to="${to}"]`)?.focus();
410
+ });
411
+ if (focused)
412
+ this.querySelector(`[data-to="${focused}"]`)?.focus();
413
+ }
414
+ }
415
+ KashiPagination.observedAttributes = ["page", "total", "siblings", "href", "digits", "label", "summary"];
416
+ /** تبدیل متن سلول به عدد برای مرتب‌سازی: «۱٬۲۵۰٬۰۰۰ تومان» → 1250000 */
417
+ export const cellNumber = (text) => {
418
+ const t = toEnDigits(text).replace(/[٬,\s]/g, "").replace("٫", ".").replace("−", "-");
419
+ const m = t.match(/-?\d+(\.\d+)?/);
420
+ return m ? Number(m[0]) : NaN;
421
+ };
422
+ define("kashi-tag-input", KashiTagInput, "block");
423
+ define("kashi-number-input", KashiNumberInput, "block");
424
+ define("kashi-rating", KashiRating);
425
+ define("kashi-pagination", KashiPagination, "block");
@@ -0,0 +1,41 @@
1
+ import { HTMLBase } from "./base.js";
2
+ /** پایهٔ عناصر نمایشی: با هر تغییر ویژگی دوباره رسم می‌شوند */
3
+ declare abstract class Display extends HTMLBase {
4
+ protected abstract render(): void;
5
+ connectedCallback(): void;
6
+ attributeChangedCallback(): void;
7
+ }
8
+ /** نمایش مبلغ با تومان یا ریال، حالت خلاصه (۱٫۲ میلیون) و مبلغ به حروف برای صفحه‌خوان */
9
+ export declare class KashiMoney extends Display {
10
+ static observedAttributes: string[];
11
+ protected render(): void;
12
+ }
13
+ /** تاریخ شمسی در یک <time> معنایی؛ مثل «۱ فروردین ۱۴۰۵» */
14
+ export declare class KashiDate extends Display {
15
+ static observedAttributes: string[];
16
+ protected render(): void;
17
+ }
18
+ /** «۵ دقیقه پیش» که خودش به‌روز می‌شود؛ تاریخ کامل شمسی در title */
19
+ export declare class KashiRelativeTime extends Display {
20
+ static observedAttributes: string[];
21
+ private timer;
22
+ connectedCallback(): void;
23
+ disconnectedCallback(): void;
24
+ protected render(): void;
25
+ }
26
+ /** شمارش معکوس با ارقام فارسی؛ در پایان رویداد kashi-complete را می‌فرستد */
27
+ export declare class KashiCountdown extends HTMLBase {
28
+ static observedAttributes: string[];
29
+ private end;
30
+ private timer;
31
+ private done;
32
+ connectedCallback(): void;
33
+ disconnectedCallback(): void;
34
+ attributeChangedCallback(name: string, old: string | null, next: string | null): void;
35
+ /** شروع دوباره از ابتدا، مثلاً پس از ارسال دوبارهٔ کد */
36
+ restart(): void;
37
+ /** ثانیه‌های باقی‌مانده */
38
+ get remaining(): number;
39
+ private render;
40
+ }
41
+ export {};
@@ -0,0 +1,144 @@
1
+ import { formatJalali, formatMoney, formatRelative, fromISODate, numberToWords, rialToToman, toFaDigits, tomanToRial } from "@kashi-ui/persian";
2
+ import { HTMLBase, define, emit, esc } from "./base.js";
3
+ /** «2026-03-21» به نیمه‌شب محلی، و بقیهٔ قالب‌ها (با ساعت) با Date */
4
+ const parseDateTime = (text) => {
5
+ if (!text)
6
+ return null;
7
+ const d = fromISODate(text.trim()) ?? new Date(text);
8
+ return Number.isNaN(d.getTime()) ? null : d;
9
+ };
10
+ /** پایهٔ عناصر نمایشی: با هر تغییر ویژگی دوباره رسم می‌شوند */
11
+ class Display extends HTMLBase {
12
+ connectedCallback() {
13
+ this.render();
14
+ }
15
+ attributeChangedCallback() {
16
+ if (this.isConnected)
17
+ this.render();
18
+ }
19
+ }
20
+ /** نمایش مبلغ با تومان یا ریال، حالت خلاصه (۱٫۲ میلیون) و مبلغ به حروف برای صفحه‌خوان */
21
+ export class KashiMoney extends Display {
22
+ render() {
23
+ const value = Number(this.getAttribute("value") ?? 0);
24
+ const unit = this.getAttribute("unit") === "rial" ? "rial" : "toman";
25
+ const from = this.getAttribute("from") === "rial" ? "rial" : this.getAttribute("from") === "toman" ? "toman" : unit;
26
+ const compact = this.hasAttribute("compact");
27
+ const digits = this.getAttribute("digits") === "latin" ? "latin" : "fa";
28
+ const signed = this.hasAttribute("signed");
29
+ const size = this.getAttribute("size") ?? "md";
30
+ const n = from === unit ? value : from === "rial" ? rialToToman(value) : tomanToRial(value);
31
+ const amount = formatMoney(n, { unit, compact, digits, showUnit: false });
32
+ const full = formatMoney(n, { unit, compact });
33
+ const unitText = full.endsWith("همت") ? null : unit === "toman" ? "تومان" : "ریال";
34
+ const spoken = this.getAttribute("words") !== "false" && Number.isSafeInteger(Math.round(n))
35
+ ? `${numberToWords(Math.round(n))} ${unit === "toman" ? "تومان" : "ریال"}`
36
+ : null;
37
+ const tone = signed ? (n > 0 ? "positive" : n < 0 ? "negative" : null) : null;
38
+ const hide = spoken ? ' aria-hidden="true"' : "";
39
+ this.innerHTML = `<data value="${n}" class="k-money k-money--${esc(size)}"${tone ? ` data-tone="${tone}"` : ""}${spoken ? ` title="${esc(spoken)}"` : ""}><span class="k-money__amount"${hide}>${signed && n > 0 ? "+" : ""}${esc(amount)}</span>${unitText ? `<span class="k-money__unit"${hide}>${unitText}</span>` : ""}${spoken ? `<span class="k-sr-only">${n < 0 ? "" : signed && n > 0 ? "مثبت " : ""}${esc(spoken)}</span>` : ""}</data>`;
40
+ }
41
+ }
42
+ KashiMoney.observedAttributes = ["value", "unit", "from", "compact", "digits", "signed", "words", "size"];
43
+ /** تاریخ شمسی در یک <time> معنایی؛ مثل «۱ فروردین ۱۴۰۵» */
44
+ export class KashiDate extends Display {
45
+ render() {
46
+ const d = parseDateTime(this.getAttribute("datetime"));
47
+ if (!d) {
48
+ this.textContent = "";
49
+ return;
50
+ }
51
+ const f = this.getAttribute("format");
52
+ const style = f === "numeric" || f === "full" ? f : "long";
53
+ const digits = this.getAttribute("digits") === "latin" ? "latin" : "fa";
54
+ this.innerHTML = `<time datetime="${esc(this.getAttribute("datetime"))}">${esc(formatJalali(d, { style, digits }))}</time>`;
55
+ }
56
+ }
57
+ KashiDate.observedAttributes = ["datetime", "format", "digits"];
58
+ /** «۵ دقیقه پیش» که خودش به‌روز می‌شود؛ تاریخ کامل شمسی در title */
59
+ export class KashiRelativeTime extends Display {
60
+ connectedCallback() {
61
+ super.connectedCallback();
62
+ clearInterval(this.timer);
63
+ this.timer = setInterval(() => this.render(), Number(this.getAttribute("refresh")) || 60000);
64
+ }
65
+ disconnectedCallback() {
66
+ clearInterval(this.timer);
67
+ }
68
+ render() {
69
+ const d = parseDateTime(this.getAttribute("datetime"));
70
+ if (!d) {
71
+ this.textContent = "";
72
+ return;
73
+ }
74
+ const digits = this.getAttribute("digits") === "latin" ? "latin" : "fa";
75
+ this.innerHTML = `<time datetime="${d.toISOString()}" title="${esc(formatJalali(d, { style: "full", digits }))}">${esc(formatRelative(d, { digits }))}</time>`;
76
+ }
77
+ }
78
+ KashiRelativeTime.observedAttributes = ["datetime", "digits", "refresh"];
79
+ const pad = (n) => String(n).padStart(2, "0");
80
+ /** شمارش معکوس با ارقام فارسی؛ در پایان رویداد kashi-complete را می‌فرستد */
81
+ export class KashiCountdown extends HTMLBase {
82
+ constructor() {
83
+ super(...arguments);
84
+ this.end = 0;
85
+ this.done = false;
86
+ }
87
+ connectedCallback() {
88
+ this.restart();
89
+ }
90
+ disconnectedCallback() {
91
+ clearInterval(this.timer);
92
+ }
93
+ attributeChangedCallback(name, old, next) {
94
+ if (!this.isConnected || old === next)
95
+ return;
96
+ if (name === "to" || name === "seconds")
97
+ this.restart();
98
+ else
99
+ this.render();
100
+ }
101
+ /** شروع دوباره از ابتدا، مثلاً پس از ارسال دوبارهٔ کد */
102
+ restart() {
103
+ const to = parseDateTime(this.getAttribute("to"));
104
+ this.end = to ? to.getTime() : Date.now() + (Number(this.getAttribute("seconds")) || 0) * 1000;
105
+ this.done = false;
106
+ clearInterval(this.timer);
107
+ this.render();
108
+ this.timer = setInterval(() => this.render(), 1000);
109
+ }
110
+ /** ثانیه‌های باقی‌مانده */
111
+ get remaining() {
112
+ return Math.max(0, Math.ceil((this.end - Date.now()) / 1000));
113
+ }
114
+ render() {
115
+ const left = this.remaining;
116
+ const d = Math.floor(left / 86400), h = Math.floor((left % 86400) / 3600), m = Math.floor((left % 3600) / 60), s = left % 60;
117
+ const fa = this.getAttribute("digits") !== "latin";
118
+ const f = (x) => (fa ? toFaDigits(String(x)) : String(x));
119
+ const spoken = [d && `${f(d)} روز`, h && `${f(h)} ساعت`, m && `${f(m)} دقیقه`, `${f(s)} ثانیه`].filter(Boolean).join(" و ");
120
+ const label = this.getAttribute("label");
121
+ const aria = esc(label ? `${label}: ${spoken}` : spoken);
122
+ const doneAttr = left === 0 ? ' data-done=""' : "";
123
+ if (this.getAttribute("variant") === "blocks") {
124
+ const units = [...(d ? [[d, "روز"]] : []), [h, "ساعت"], [m, "دقیقه"], [s, "ثانیه"]];
125
+ this.innerHTML = `<div role="timer" aria-label="${aria}" class="k-countdown k-countdown--blocks"${doneAttr}>${units.map(([v, name]) => `<span class="k-countdown__unit" aria-hidden="true"><span class="k-countdown__num">${f(pad(v))}</span><span class="k-countdown__name">${name}</span></span>`).join("")}</div>`;
126
+ }
127
+ else {
128
+ const clock = (d ? `${d}:` : "") + (h || d ? `${pad(h)}:` : "") + `${pad(m)}:${pad(s)}`;
129
+ this.innerHTML = `<span role="timer" aria-label="${aria}" class="k-countdown"${doneAttr} dir="ltr"><span aria-hidden="true">${f(clock)}</span></span>`;
130
+ }
131
+ if (left === 0) {
132
+ clearInterval(this.timer);
133
+ if (!this.done) {
134
+ this.done = true;
135
+ emit(this, "kashi-complete", {});
136
+ }
137
+ }
138
+ }
139
+ }
140
+ KashiCountdown.observedAttributes = ["to", "seconds", "variant", "digits", "label"];
141
+ define("kashi-money", KashiMoney);
142
+ define("kashi-date", KashiDate);
143
+ define("kashi-relative-time", KashiRelativeTime);
144
+ define("kashi-countdown", KashiCountdown);
@@ -0,0 +1,24 @@
1
+ import { HTMLBase } from "./base.js";
2
+ /**
3
+ * کد تأیید یک‌بارمصرف: یک input واقعی (نه شش input جدا)، پس چسباندن، پاک کردن،
4
+ * صفحه‌خوان و پر شدن خودکار کد پیامکی (autocomplete="one-time-code") کار می‌کنند.
5
+ */
6
+ export declare class KashiOtp extends HTMLBase {
7
+ static observedAttributes: string[];
8
+ private code;
9
+ private caret;
10
+ private valueSet;
11
+ private rendered;
12
+ private key;
13
+ private input;
14
+ connectedCallback(): void;
15
+ attributeChangedCallback(name: string, old: string | null, next: string | null): void;
16
+ get value(): string;
17
+ set value(v: string);
18
+ focus(options?: FocusOptions): void;
19
+ private get length();
20
+ private render;
21
+ private paint;
22
+ /** فقط خانه‌ها؛ مقدار input را دست نمی‌زند تا تایپ کاربر پیش از رویداد input پاک نشود */
23
+ private paintSlots;
24
+ }