@kashi-ui/elements 0.24.0 → 0.26.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.
Files changed (46) hide show
  1. package/README.md +8 -3
  2. package/dist/appointment.d.ts +40 -0
  3. package/dist/appointment.js +174 -0
  4. package/dist/base.js +26 -0
  5. package/dist/basic.d.ts +43 -0
  6. package/dist/basic.js +223 -0
  7. package/dist/calendar.d.ts +13 -0
  8. package/dist/calendar.js +46 -1
  9. package/dist/cdn/appointment-DkMxtSWi.js +1 -0
  10. package/dist/cdn/base-DfzNTdQk.js +1 -0
  11. package/dist/cdn/basic-B5n1okZO.js +1 -0
  12. package/dist/cdn/calendar-oMlnK1_D.js +16 -0
  13. package/dist/cdn/charts-SHKq2S4I.js +1 -0
  14. package/dist/cdn/combobox-DiGy7lAB.js +1 -0
  15. package/dist/cdn/controls-C6zwNYWs.js +1 -0
  16. package/dist/cdn/digits-DE1YGT94.js +1 -0
  17. package/dist/cdn/display-DZFW9EEX.js +1 -0
  18. package/dist/cdn/fields-D2hmrvVR.js +1 -0
  19. package/dist/cdn/forms-CeItYHFx.js +1 -0
  20. package/dist/cdn/holidays-BwsAEOI1.js +1 -0
  21. package/dist/cdn/inputs-DuP7P3ka.js +1 -0
  22. package/dist/cdn/jalali-BiM5Kfep.js +1 -0
  23. package/dist/cdn/kashi-elements.js +1 -27
  24. package/dist/cdn/map-w5X2fTQI.js +12 -0
  25. package/dist/cdn/menu-Cga7QOfs.js +1 -0
  26. package/dist/cdn/nav-CwPZ20z-.js +1 -0
  27. package/dist/cdn/overlay-BDzwsBN5.js +1 -0
  28. package/dist/cdn/pickers-BOpaZ-7q.js +1 -0
  29. package/dist/cdn/table-BdF43L5q.js +1 -0
  30. package/dist/cdn/text-D1U32D6K.js +1 -0
  31. package/dist/cdn/time-DXw4vOne.js +1 -0
  32. package/dist/cdn/words-9OdzG7y6.js +1 -0
  33. package/dist/cdn.d.ts +3 -0
  34. package/dist/cdn.js +46 -0
  35. package/dist/charts.d.ts +106 -0
  36. package/dist/charts.js +436 -0
  37. package/dist/forms.d.ts +186 -0
  38. package/dist/forms.js +902 -0
  39. package/dist/index.d.ts +40 -2
  40. package/dist/index.js +13 -1
  41. package/dist/nav.d.ts +163 -0
  42. package/dist/nav.js +918 -0
  43. package/dist/pickers.d.ts +108 -0
  44. package/dist/pickers.js +655 -0
  45. package/package.json +4 -4
  46. /package/dist/cdn/{bank-logos-D670Gw0K.js → dist-D670Gw0K.js} +0 -0
package/dist/nav.js ADDED
@@ -0,0 +1,918 @@
1
+ import { normalizePersian, toFaDigits } from "@kashi-ui/persian";
2
+ import { HTMLBase, afterParse, bool, define, emit, esc, uid } from "./base.js";
3
+ const search = (s) => normalizePersian(s, { digits: "latin" }).toLowerCase();
4
+ const items = (menu) => [...menu.querySelectorAll('[role^="menuitem"]:not([aria-disabled="true"])')];
5
+ const SEARCH = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="9" cy="9" r="5.5"/><path d="m13.5 13.5 3 3"/></svg>';
6
+ const CHEVRON = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m8 5 5 5-5 5"/></svg>';
7
+ /**
8
+ * پالت فرمان با Ctrl+K یا ⌘K: جستجو بین دستورها و صفحه‌ها با ↑↓ و Enter.
9
+ * دستورها از فرزندان <a href> یا <button data-value> (با data-group، data-description، data-shortcut، data-keywords)
10
+ * یا element.items؛ انتخاب هر دستور بدون href رویداد kashi-select می‌فرستد.
11
+ */
12
+ export class KashiCommandPalette extends HTMLBase {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.list = null;
16
+ this.dialog = null;
17
+ this.query = "";
18
+ this.active = 0;
19
+ this.opener = null;
20
+ this.key = uid("kashi-cmd");
21
+ this.onShortcut = (e) => {
22
+ if (this.getAttribute("shortcut") === "false")
23
+ return;
24
+ if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K" || e.code === "KeyK")) {
25
+ e.preventDefault();
26
+ if (this.dialog?.open)
27
+ this.close();
28
+ else
29
+ this.show();
30
+ }
31
+ };
32
+ }
33
+ connectedCallback() {
34
+ afterParse(() => {
35
+ if (!this.isConnected)
36
+ return;
37
+ if (this.list === null)
38
+ this.list = this.readItems();
39
+ if (!this.dialog)
40
+ this.build();
41
+ document.addEventListener("keydown", this.onShortcut);
42
+ });
43
+ }
44
+ disconnectedCallback() {
45
+ document.removeEventListener("keydown", this.onShortcut);
46
+ }
47
+ get items() {
48
+ return this.list ?? [];
49
+ }
50
+ set items(v) {
51
+ this.list = [...v];
52
+ if (this.dialog?.open)
53
+ this.paint();
54
+ }
55
+ readItems() {
56
+ const out = [...this.querySelectorAll(":scope > a, :scope > button")].map((el, i) => ({
57
+ id: el.dataset.value ?? `${i}`,
58
+ label: el.textContent?.trim() ?? "",
59
+ group: el.dataset.group,
60
+ description: el.dataset.description,
61
+ shortcut: el.dataset.shortcut,
62
+ keywords: el.dataset.keywords?.split(/[,،]\s*/),
63
+ href: el instanceof HTMLAnchorElement ? el.getAttribute("href") ?? undefined : undefined,
64
+ }));
65
+ this.querySelectorAll(":scope > a, :scope > button").forEach((el) => el.remove());
66
+ return out;
67
+ }
68
+ build() {
69
+ const d = document.createElement("dialog");
70
+ d.className = "k-dialog k-command";
71
+ const title = this.getAttribute("label") ?? "پالت فرمان";
72
+ d.setAttribute("aria-label", title);
73
+ d.innerHTML = `<div class="k-input"><span class="k-input__addon">${SEARCH}</span><input class="k-input__control" type="text" role="combobox" aria-expanded="true" aria-controls="${this.key}-list" aria-autocomplete="list" aria-label="${esc(title)}" placeholder="${esc(this.getAttribute("placeholder") ?? "جستجوی دستور یا صفحه…")}" autocomplete="off"></div><ul id="${this.key}-list" role="listbox" aria-label="${esc(title)}" class="k-listbox k-listbox--inline k-command__list"></ul><div class="k-command__footer" aria-hidden="true"><span><kbd class="k-kbd">↑</kbd><kbd class="k-kbd">↓</kbd>حرکت</span><span><kbd class="k-kbd">Enter</kbd>انتخاب</span><span><kbd class="k-kbd">Esc</kbd>بستن</span></div><span class="k-sr-only" aria-live="polite"></span>`;
74
+ this.append(d);
75
+ this.dialog = d;
76
+ const input = d.querySelector("input");
77
+ input.addEventListener("input", () => {
78
+ this.query = input.value;
79
+ this.active = 0;
80
+ this.paint();
81
+ });
82
+ input.addEventListener("keydown", (e) => {
83
+ const n = this.filtered().length;
84
+ if (!n)
85
+ return;
86
+ if (e.key === "ArrowDown")
87
+ this.active = (this.active + 1) % n;
88
+ else if (e.key === "ArrowUp")
89
+ this.active = (this.active - 1 + n) % n;
90
+ else if (e.key === "Home")
91
+ this.active = 0;
92
+ else if (e.key === "End")
93
+ this.active = n - 1;
94
+ else if (e.key === "Enter")
95
+ return this.run(this.filtered()[this.active]);
96
+ else
97
+ return;
98
+ e.preventDefault();
99
+ this.paint();
100
+ });
101
+ const list = d.querySelector("ul");
102
+ list.addEventListener("pointerdown", (e) => e.preventDefault());
103
+ list.addEventListener("click", (e) => {
104
+ const li = e.target.closest('[role="option"]');
105
+ if (li)
106
+ this.run(this.filtered()[Number(li.dataset.i)]);
107
+ });
108
+ d.addEventListener("click", (e) => e.target === d && this.close());
109
+ d.addEventListener("close", () => this.opener?.focus?.());
110
+ }
111
+ filtered() {
112
+ const q = search(this.query.trim());
113
+ return this.items.filter((it) => !q || [it.label, it.description ?? "", ...(it.keywords ?? [])].some((t) => search(t).includes(q)));
114
+ }
115
+ /** باز کردن پالت */
116
+ show() {
117
+ if (!this.dialog)
118
+ return;
119
+ this.opener = document.activeElement;
120
+ this.query = "";
121
+ this.active = 0;
122
+ this.dialog.querySelector("input").value = "";
123
+ this.dialog.showModal?.();
124
+ this.paint();
125
+ this.dialog.querySelector("input").focus();
126
+ }
127
+ close() {
128
+ if (this.dialog?.open)
129
+ this.dialog.close();
130
+ }
131
+ run(it) {
132
+ if (!it)
133
+ return;
134
+ this.close();
135
+ if (it.href)
136
+ location.href = it.href;
137
+ else
138
+ emit(this, "kashi-select", { value: it.id, item: it });
139
+ }
140
+ paint() {
141
+ const d = this.dialog;
142
+ const list = this.filtered();
143
+ const q = search(this.query.trim());
144
+ const mark = (t) => {
145
+ const i = q ? search(t).indexOf(q) : -1;
146
+ return i < 0 ? esc(t) : `${esc(t.slice(0, i))}<mark>${esc(t.slice(i, i + q.length))}</mark>${esc(t.slice(i + q.length))}`;
147
+ };
148
+ let last;
149
+ d.querySelector("ul").innerHTML = list.length
150
+ ? list.map((it, i) => {
151
+ const header = it.group && it.group !== last ? `<li role="presentation" class="k-listbox__group">${esc(it.group)}</li>` : "";
152
+ last = it.group;
153
+ return `${header}<li id="${this.key}-list-${i}" data-i="${i}" role="option" aria-selected="${i === this.active}"${i === this.active ? " data-active" : ""} class="k-option"><span class="k-option__body"><span>${mark(it.label)}</span>${it.description ? `<span class="k-option__desc">${esc(it.description)}</span>` : ""}</span>${it.shortcut ? `<span class="k-menu__shortcut" aria-hidden="true">${esc(it.shortcut)}</span>` : ""}</li>`;
154
+ }).join("")
155
+ : '<li role="presentation" class="k-listbox__empty">دستوری پیدا نشد.</li>';
156
+ const input = d.querySelector("input");
157
+ if (list.length)
158
+ input.setAttribute("aria-activedescendant", `${this.key}-list-${this.active}`);
159
+ else
160
+ input.removeAttribute("aria-activedescendant");
161
+ d.querySelector(`#${this.key}-list-${this.active}`)?.scrollIntoView?.({ block: "nearest" });
162
+ d.querySelector(":scope > .k-sr-only").textContent = this.query ? `${toFaDigits(list.length)} نتیجه` : "";
163
+ }
164
+ }
165
+ KashiCommandPalette.observedAttributes = ["placeholder", "label", "shortcut"];
166
+ /**
167
+ * منوی راست‌کلیک: محتوای عنصر ناحیهٔ کلیک است و گزینه‌ها داخل یک <div data-menu> (همان گزینه‌های kashi-dropdown-menu).
168
+ * با راست‌کلیک، کلید ContextMenu یا Shift+F10 باز می‌شود؛ انتخاب گزینه رویداد kashi-select می‌فرستد.
169
+ */
170
+ export class KashiContextMenu extends HTMLBase {
171
+ constructor() {
172
+ super(...arguments);
173
+ this.menu = null;
174
+ this.returnTo = null;
175
+ this.key = uid("kashi-ctx");
176
+ this.onOutside = (e) => {
177
+ if (!this.menu?.contains(e.target))
178
+ this.close(false);
179
+ };
180
+ this.onScroll = () => this.close(false);
181
+ }
182
+ connectedCallback() {
183
+ afterParse(() => this.isConnected && !this.menu && this.build());
184
+ }
185
+ disconnectedCallback() {
186
+ document.removeEventListener("pointerdown", this.onOutside);
187
+ }
188
+ build() {
189
+ const source = this.querySelector(":scope > [data-menu]");
190
+ this.classList.add("k-context-area");
191
+ this.setAttribute("aria-haspopup", "menu");
192
+ this.setAttribute("aria-describedby", `${this.key}-hint`);
193
+ const menu = document.createElement("ul");
194
+ menu.setAttribute("role", "menu");
195
+ menu.setAttribute("aria-label", this.getAttribute("label") ?? "گزینه‌ها");
196
+ menu.className = "k-menu k-menu--context";
197
+ menu.hidden = true;
198
+ for (const el of [...(source?.children ?? [])]) {
199
+ const li = document.createElement("li");
200
+ if (el.tagName === "HR") {
201
+ li.setAttribute("role", "separator");
202
+ li.className = "k-menu__separator";
203
+ }
204
+ else if (el.hasAttribute("data-label")) {
205
+ li.setAttribute("role", "presentation");
206
+ li.className = "k-menu__label";
207
+ li.textContent = el.textContent;
208
+ }
209
+ else {
210
+ li.setAttribute("role", "none");
211
+ el.setAttribute("role", "menuitem");
212
+ el.tabIndex = -1;
213
+ el.classList.add("k-menu__item");
214
+ if (el.dataset.tone === "danger")
215
+ el.classList.add("k-menu__item--danger");
216
+ if (el instanceof HTMLButtonElement)
217
+ el.type = "button";
218
+ el.innerHTML = `<span>${el.innerHTML}</span>${el.dataset.shortcut ? `<span class="k-menu__shortcut" aria-hidden="true">${esc(el.dataset.shortcut)}</span>` : ""}`;
219
+ li.append(el);
220
+ }
221
+ menu.append(li);
222
+ }
223
+ source?.remove();
224
+ this.insertAdjacentHTML("beforeend", `<span id="${this.key}-hint" class="k-sr-only">برای گزینه‌ها راست‌کلیک کنید یا Shift+F10 را بزنید.</span>`);
225
+ this.append(menu);
226
+ this.menu = menu;
227
+ this.addEventListener("contextmenu", (e) => {
228
+ if (menu.contains(e.target))
229
+ return;
230
+ e.preventDefault();
231
+ this.openAt(e.clientX, e.clientY);
232
+ });
233
+ this.addEventListener("keydown", (e) => {
234
+ if (menu.contains(e.target))
235
+ return this.onMenuKey(e);
236
+ if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
237
+ e.preventDefault();
238
+ const r = document.activeElement.getBoundingClientRect();
239
+ this.openAt(r.left + r.width / 2, r.top + r.height / 2);
240
+ }
241
+ });
242
+ menu.addEventListener("click", (e) => {
243
+ const item = e.target.closest('[role="menuitem"]');
244
+ if (!item)
245
+ return;
246
+ emit(this, "kashi-select", { value: item.dataset.value ?? item.textContent?.trim() ?? "" });
247
+ this.close(item.tagName !== "A");
248
+ });
249
+ }
250
+ openAt(x, y) {
251
+ const menu = this.menu;
252
+ this.returnTo = document.activeElement ?? this;
253
+ menu.hidden = false;
254
+ // در لایهٔ بالای صفحه (popover) تا داخل ظرف‌های overflow بریده نشود؛ مرورگرهای قدیمی بدون آن
255
+ const pop = menu;
256
+ if (pop.showPopover) {
257
+ if (!menu.hasAttribute("popover"))
258
+ menu.setAttribute("popover", "manual");
259
+ if (!menu.matches(":popover-open"))
260
+ pop.showPopover();
261
+ }
262
+ const r = menu.getBoundingClientRect();
263
+ const rtl = getComputedStyle(this).direction === "rtl";
264
+ let nx = rtl ? x - r.width : x;
265
+ if (nx < 8)
266
+ nx = rtl ? x : 8;
267
+ nx = Math.max(8, Math.min(nx, innerWidth - r.width - 8));
268
+ const ny = Math.max(8, Math.min(y + r.height > innerHeight - 8 ? y - r.height : y, innerHeight - r.height - 8));
269
+ menu.style.setProperty("--_x", `${nx}px`);
270
+ menu.style.setProperty("--_y", `${ny}px`);
271
+ items(menu)[0]?.focus({ preventScroll: true });
272
+ document.addEventListener("pointerdown", this.onOutside);
273
+ setTimeout(() => addEventListener("scroll", this.onScroll, { passive: true, once: true }), 150);
274
+ }
275
+ close(focusBack = true) {
276
+ const menu = this.menu;
277
+ if (!menu || menu.hidden)
278
+ return;
279
+ const pop = menu;
280
+ if (menu.matches?.(":popover-open"))
281
+ pop.hidePopover?.();
282
+ menu.hidden = true;
283
+ document.removeEventListener("pointerdown", this.onOutside);
284
+ removeEventListener("scroll", this.onScroll);
285
+ if (focusBack)
286
+ this.returnTo?.focus();
287
+ }
288
+ onMenuKey(e) {
289
+ const list = items(this.menu);
290
+ const i = list.indexOf(document.activeElement);
291
+ const go = (j) => list[(j + list.length) % list.length]?.focus();
292
+ if (e.key === "ArrowDown")
293
+ go(i + 1);
294
+ else if (e.key === "ArrowUp")
295
+ go(i - 1);
296
+ else if (e.key === "Home")
297
+ go(0);
298
+ else if (e.key === "End")
299
+ go(list.length - 1);
300
+ else if (e.key === "Escape")
301
+ this.close();
302
+ else if (e.key === "Tab")
303
+ return this.close(false);
304
+ else
305
+ return;
306
+ e.preventDefault();
307
+ }
308
+ }
309
+ KashiContextMenu.observedAttributes = ["label"];
310
+ const ARROW = (next) => `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-k-mirror=""><path d="${next ? "m8 5 5 5-5 5" : "m12 5-5 5 5 5"}"/></svg>`;
311
+ /** اسلایدر محتوا با اسکرول بومی: هر فرزند یک اسلاید؛ per-view تعداد اسلاید در هر نما، کلیدهای جهت و نقطه‌ها */
312
+ export class KashiCarousel extends HTMLBase {
313
+ constructor() {
314
+ super(...arguments);
315
+ this.track = null;
316
+ this.index = 0;
317
+ this.io = null;
318
+ this.key = uid("kashi-carousel");
319
+ }
320
+ connectedCallback() {
321
+ afterParse(() => this.isConnected && !this.track && this.build());
322
+ }
323
+ disconnectedCallback() {
324
+ this.io?.disconnect();
325
+ }
326
+ get perView() {
327
+ return Math.max(1, Number(this.getAttribute("per-view")) || 1);
328
+ }
329
+ get slides() {
330
+ return [...(this.track?.children ?? [])];
331
+ }
332
+ get pages() {
333
+ return Math.max(1, this.slides.length - this.perView + 1);
334
+ }
335
+ build() {
336
+ const label = this.getAttribute("label") ?? "اسلایدر";
337
+ const children = [...this.children];
338
+ const section = document.createElement("section");
339
+ section.className = "k-carousel";
340
+ section.setAttribute("aria-roledescription", "اسلایدر");
341
+ section.setAttribute("aria-label", label);
342
+ section.style.setProperty("--k-carousel-per-view", String(this.perView));
343
+ const track = document.createElement("div");
344
+ track.className = "k-carousel__track";
345
+ track.tabIndex = 0;
346
+ track.setAttribute("aria-live", "polite");
347
+ children.forEach((c, i) => {
348
+ const s = document.createElement("div");
349
+ s.className = "k-carousel__slide";
350
+ s.setAttribute("role", "group");
351
+ s.setAttribute("aria-roledescription", "اسلاید");
352
+ s.setAttribute("aria-label", `${toFaDigits(i + 1)} از ${toFaDigits(children.length)}`);
353
+ s.id = `${this.key}-s${i}`;
354
+ s.append(c);
355
+ track.append(s);
356
+ });
357
+ section.append(track);
358
+ this.replaceChildren(section);
359
+ this.track = track;
360
+ if (this.pages > 1) {
361
+ const dots = this.getAttribute("dots") !== "false";
362
+ section.insertAdjacentHTML("beforeend", `<div class="k-carousel__bar">${dots ? `<ul class="k-carousel__dots" aria-label="اسلایدهای ${esc(label)}">${Array.from({ length: this.pages }, (_, i) => `<li><button type="button" class="k-carousel__dot" data-go="${i}" aria-label="${toFaDigits(i + 1)} از ${toFaDigits(this.pages)}" aria-controls="${this.key}-s${i}"></button></li>`).join("")}</ul>` : "<span></span>"}<div class="k-carousel__nav"><button type="button" class="k-btn k-btn--secondary k-btn--icon k-btn--sm" data-step="-1" aria-label="قبلی">${ARROW(false)}</button><button type="button" class="k-btn k-btn--secondary k-btn--icon k-btn--sm" data-step="1" aria-label="بعدی">${ARROW(true)}</button></div></div>`);
363
+ section.querySelector(".k-carousel__bar").addEventListener("click", (e) => {
364
+ const b = e.target.closest("button");
365
+ if (!b)
366
+ return;
367
+ this.go(b.dataset.go !== undefined ? Number(b.dataset.go) : this.index + Number(b.dataset.step));
368
+ });
369
+ }
370
+ track.addEventListener("keydown", (e) => {
371
+ const rtl = getComputedStyle(track).direction === "rtl";
372
+ const step = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1 }[e.key];
373
+ if (step) {
374
+ e.preventDefault();
375
+ this.go(this.index + step);
376
+ }
377
+ else if (e.key === "Home")
378
+ this.go(0);
379
+ else if (e.key === "End")
380
+ this.go(this.pages - 1);
381
+ });
382
+ if (typeof IntersectionObserver !== "undefined") {
383
+ this.io = new IntersectionObserver((entries) => {
384
+ const vis = entries.filter((x) => x.isIntersecting && x.intersectionRatio > 0.6).map((x) => this.slides.indexOf(x.target));
385
+ if (vis.length)
386
+ this.setIndex(Math.min(...vis));
387
+ }, { root: track, threshold: [0.6, 1] });
388
+ this.slides.forEach((s) => this.io.observe(s));
389
+ }
390
+ this.setIndex(0);
391
+ }
392
+ /** رفتن به اسلاید n (از صفر) */
393
+ go(n) {
394
+ const i = Math.max(0, Math.min(this.pages - 1, n));
395
+ const el = this.slides[i];
396
+ const t = this.track;
397
+ if (!el || !t)
398
+ return;
399
+ const rtl = getComputedStyle(t).direction === "rtl";
400
+ const offset = rtl ? -(t.getBoundingClientRect().right - el.getBoundingClientRect().right) + t.scrollLeft : el.getBoundingClientRect().left - t.getBoundingClientRect().left + t.scrollLeft;
401
+ t.scrollTo?.({ left: offset });
402
+ this.setIndex(i);
403
+ }
404
+ setIndex(i) {
405
+ const changed = i !== this.index;
406
+ this.index = i;
407
+ this.querySelectorAll(".k-carousel__dot").forEach((d, k) => d.setAttribute("aria-current", String(k === i)));
408
+ const [prev, next] = this.querySelectorAll("[data-step]");
409
+ if (prev)
410
+ prev.disabled = i === 0;
411
+ if (next)
412
+ next.disabled = i >= this.pages - 1;
413
+ if (changed)
414
+ emit(this, "kashi-change", { index: i });
415
+ }
416
+ }
417
+ KashiCarousel.observedAttributes = ["label", "per-view", "dots"];
418
+ /**
419
+ * نمای درختی با الگوی Tree در WAI-ARIA. ساختار از یک فهرست تودرتوی معمولی (<ul><li data-id>…<ul>…</ul></li></ul>)
420
+ * یا element.items؛ ↑↓ حرکت، جهت داخل باز کردن (در RTL فلش چپ)، Enter انتخاب.
421
+ */
422
+ export class KashiTreeView extends HTMLBase {
423
+ constructor() {
424
+ super(...arguments);
425
+ this.nodes = null;
426
+ this.open = new Set();
427
+ this.sel = null;
428
+ this.focusId = null;
429
+ this.built = false;
430
+ }
431
+ connectedCallback() {
432
+ afterParse(() => {
433
+ if (!this.isConnected)
434
+ return;
435
+ if (this.nodes === null) {
436
+ const ul = this.querySelector(":scope > ul");
437
+ this.nodes = ul ? this.parse(ul) : [];
438
+ }
439
+ if (!this.built) {
440
+ this.sel = this.getAttribute("selected");
441
+ for (const id of (this.getAttribute("expanded") ?? "").split(/[,،]/).map((s) => s.trim()).filter(Boolean))
442
+ this.open.add(id);
443
+ this.built = true;
444
+ }
445
+ this.render();
446
+ });
447
+ }
448
+ attributeChangedCallback(name, old, next) {
449
+ if (!this.built || old === next)
450
+ return;
451
+ if (name === "selected")
452
+ this.sel = next;
453
+ this.render();
454
+ }
455
+ get items() {
456
+ return this.nodes ?? [];
457
+ }
458
+ set items(v) {
459
+ this.nodes = v;
460
+ if (this.built)
461
+ this.render();
462
+ }
463
+ /** شناسهٔ گرهٔ انتخاب‌شده */
464
+ get value() {
465
+ return this.sel ?? "";
466
+ }
467
+ parse(ul) {
468
+ return [...ul.children].filter((li) => li.tagName === "LI").map((li, i) => {
469
+ const sub = li.querySelector(":scope > ul");
470
+ const label = [...li.childNodes].filter((n) => n !== sub).map((n) => n.textContent).join("").trim();
471
+ const el = li;
472
+ return { id: el.dataset.id ?? `${label}-${i}`, label, meta: el.dataset.meta, disabled: el.hasAttribute("data-disabled"), children: sub ? this.parse(sub) : undefined };
473
+ });
474
+ }
475
+ visible() {
476
+ const out = [];
477
+ const walk = (ns, parent) => {
478
+ for (const n of ns) {
479
+ out.push({ node: n, parent });
480
+ if (n.children?.length && this.open.has(n.id))
481
+ walk(n.children, n.id);
482
+ }
483
+ };
484
+ walk(this.items, null);
485
+ return out;
486
+ }
487
+ render(focus = false) {
488
+ const vis = this.visible();
489
+ const current = this.focusId && vis.some((v) => v.node.id === this.focusId) ? this.focusId : this.sel && vis.some((v) => v.node.id === this.sel) ? this.sel : vis[0]?.node.id;
490
+ const html = (ns, level) => ns.map((n) => {
491
+ const kids = !!n.children?.length;
492
+ const open = this.open.has(n.id);
493
+ return `<li role="treeitem" data-id="${esc(n.id)}" class="k-tree__item" aria-level="${level}"${kids ? ` aria-expanded="${open}"` : ""} aria-selected="${this.sel === n.id}"${n.disabled ? ' aria-disabled="true"' : ""} tabindex="${current === n.id ? 0 : -1}"><div class="k-tree__row">${kids ? `<span class="k-tree__toggle" aria-hidden="true">${CHEVRON}</span>` : '<span class="k-tree__spacer"></span>'}<span class="k-tree__label">${esc(n.label)}</span>${n.meta ? `<span class="k-tree__meta">${esc(n.meta)}</span>` : ""}</div>${kids && open ? `<ul role="group">${html(n.children, level + 1)}</ul>` : ""}</li>`;
494
+ }).join("");
495
+ this.innerHTML = `<ul role="tree" class="k-tree"${this.getAttribute("label") ? ` aria-label="${esc(this.getAttribute("label"))}"` : ""}>${html(this.items, 1)}</ul>`;
496
+ const tree = this.querySelector("ul");
497
+ tree.addEventListener("click", (e) => {
498
+ const li = e.target.closest('[role="treeitem"]');
499
+ if (!li || !e.target.closest(".k-tree__row"))
500
+ return;
501
+ const node = this.find(li.dataset.id);
502
+ if (!node)
503
+ return;
504
+ if (node.children?.length)
505
+ this.toggle(node.id);
506
+ this.focusId = node.id;
507
+ this.choose(node);
508
+ });
509
+ tree.addEventListener("keydown", (e) => this.onKey(e));
510
+ if (focus && current)
511
+ [...this.querySelectorAll("[data-id]")].find((el) => el.dataset.id === current)?.focus();
512
+ }
513
+ find(id, ns = this.items) {
514
+ for (const n of ns) {
515
+ if (n.id === id)
516
+ return n;
517
+ const hit = n.children && this.find(id, n.children);
518
+ if (hit)
519
+ return hit;
520
+ }
521
+ return null;
522
+ }
523
+ toggle(id, open = !this.open.has(id)) {
524
+ if (open)
525
+ this.open.add(id);
526
+ else
527
+ this.open.delete(id);
528
+ emit(this, "kashi-expand", { expanded: [...this.open] });
529
+ }
530
+ choose(n) {
531
+ if (!n.disabled) {
532
+ this.sel = n.id;
533
+ emit(this, "kashi-change", { value: n.id, node: n });
534
+ }
535
+ this.render(true);
536
+ }
537
+ onKey(e) {
538
+ const vis = this.visible();
539
+ const cur = document.activeElement?.dataset?.id;
540
+ const i = vis.findIndex((v) => v.node.id === cur);
541
+ const v = vis[i];
542
+ if (!v)
543
+ return;
544
+ const rtl = getComputedStyle(this).direction === "rtl";
545
+ const kids = !!v.node.children?.length;
546
+ const open = this.open.has(v.node.id);
547
+ const focus = (id) => {
548
+ this.focusId = id;
549
+ this.render(true);
550
+ };
551
+ if (e.key === "ArrowDown")
552
+ vis[i + 1] && focus(vis[i + 1].node.id);
553
+ else if (e.key === "ArrowUp")
554
+ vis[i - 1] && focus(vis[i - 1].node.id);
555
+ else if (e.key === "Home")
556
+ focus(vis[0].node.id);
557
+ else if (e.key === "End")
558
+ focus(vis[vis.length - 1].node.id);
559
+ else if (e.key === (rtl ? "ArrowLeft" : "ArrowRight")) {
560
+ if (kids && !open) {
561
+ this.toggle(v.node.id, true);
562
+ focus(v.node.id);
563
+ }
564
+ else if (kids)
565
+ focus(v.node.children[0].id);
566
+ }
567
+ else if (e.key === (rtl ? "ArrowRight" : "ArrowLeft")) {
568
+ if (kids && open) {
569
+ this.toggle(v.node.id, false);
570
+ focus(v.node.id);
571
+ }
572
+ else if (v.parent)
573
+ focus(v.parent);
574
+ }
575
+ else if (e.key === "Enter" || e.key === " ") {
576
+ this.focusId = v.node.id;
577
+ this.choose(v.node);
578
+ }
579
+ else
580
+ return;
581
+ e.preventDefault();
582
+ }
583
+ }
584
+ KashiTreeView.observedAttributes = ["label", "selected", "expanded"];
585
+ /**
586
+ * تور معرفی: کارت راهنما کنار هر بخش صفحه با برجسته کردن آن. مراحل از فرزندان <div data-target="#id" data-title="…">متن</div>
587
+ * یا element.steps؛ با element.start() یا دکمه‌ای با data-kashi-tour="شناسهٔ تور" شروع می‌شود.
588
+ */
589
+ export class KashiTour extends HTMLBase {
590
+ constructor() {
591
+ super(...arguments);
592
+ this.list = null;
593
+ this.i = 0;
594
+ this.spot = null;
595
+ this.card = null;
596
+ this.returnTo = null;
597
+ this.key = uid("kashi-tour");
598
+ this.place = () => {
599
+ const card = this.card;
600
+ if (!card)
601
+ return;
602
+ const el = document.querySelector(this.steps[this.i].target);
603
+ const rect = el?.getBoundingClientRect() ?? null;
604
+ if (rect) {
605
+ if (!this.spot) {
606
+ this.spot = document.createElement("div");
607
+ this.spot.className = "k-tour__spot";
608
+ this.spot.setAttribute("aria-hidden", "true");
609
+ document.body.append(this.spot);
610
+ }
611
+ Object.assign(this.spot.style, { top: `${rect.top - 6}px`, left: `${rect.left - 6}px`, inlineSize: `${rect.width + 12}px`, blockSize: `${rect.height + 12}px` });
612
+ }
613
+ else {
614
+ this.spot?.remove();
615
+ this.spot = null;
616
+ }
617
+ const c = card.getBoundingClientRect();
618
+ if (!rect) {
619
+ Object.assign(card.style, { top: `${innerHeight / 2 - c.height / 2}px`, left: `${innerWidth / 2 - c.width / 2}px` });
620
+ return;
621
+ }
622
+ const below = rect.bottom + 12 + c.height < innerHeight;
623
+ const top = below ? rect.bottom + 12 : Math.max(12, rect.top - 12 - c.height);
624
+ const left = Math.min(innerWidth - c.width - 12, Math.max(12, rect.left + rect.width / 2 - c.width / 2));
625
+ Object.assign(card.style, { top: `${top}px`, left: `${left}px` });
626
+ };
627
+ }
628
+ connectedCallback() {
629
+ afterParse(() => {
630
+ if (!this.isConnected || this.list !== null)
631
+ return;
632
+ this.list = [...this.querySelectorAll(":scope > [data-target]")].map((el) => ({ target: el.dataset.target, title: el.dataset.title ?? "", content: el.textContent?.trim() ?? "" }));
633
+ this.replaceChildren();
634
+ });
635
+ }
636
+ disconnectedCallback() {
637
+ this.stop(false);
638
+ }
639
+ get steps() {
640
+ return this.list ?? [];
641
+ }
642
+ set steps(v) {
643
+ this.list = [...v];
644
+ }
645
+ /** شروع تور از مرحلهٔ اول */
646
+ start() {
647
+ if (!this.steps.length)
648
+ return;
649
+ this.returnTo = document.activeElement;
650
+ this.i = 0;
651
+ addEventListener("scroll", this.place, { passive: true });
652
+ addEventListener("resize", this.place);
653
+ this.show();
654
+ }
655
+ /** پایان تور */
656
+ stop(focusBack = true) {
657
+ this.spot?.remove();
658
+ this.card?.remove();
659
+ this.spot = this.card = null;
660
+ removeEventListener("scroll", this.place);
661
+ removeEventListener("resize", this.place);
662
+ if (focusBack)
663
+ this.returnTo?.focus?.();
664
+ }
665
+ show() {
666
+ const s = this.steps[this.i];
667
+ const n = this.steps.length;
668
+ const last = this.i === n - 1;
669
+ if (!this.card) {
670
+ this.card = document.createElement("div");
671
+ this.card.className = "k-tour__card";
672
+ this.card.setAttribute("role", "dialog");
673
+ this.card.setAttribute("aria-modal", "false");
674
+ this.card.setAttribute("aria-labelledby", `${this.key}-t`);
675
+ this.card.setAttribute("aria-describedby", `${this.key}-b`);
676
+ this.card.tabIndex = -1;
677
+ this.card.addEventListener("click", (e) => {
678
+ const b = e.target.closest("[data-act]");
679
+ if (!b)
680
+ return;
681
+ const act = b.dataset.act;
682
+ if (act === "skip")
683
+ this.stop();
684
+ else if (act === "prev")
685
+ this.step(-1);
686
+ else if (act === "next")
687
+ this.step(1);
688
+ else if (act === "done") {
689
+ this.stop();
690
+ emit(this, "kashi-finish", {});
691
+ }
692
+ });
693
+ this.card.addEventListener("keydown", (e) => {
694
+ const rtl = getComputedStyle(this.card).direction === "rtl";
695
+ if (e.key === "Escape")
696
+ this.stop();
697
+ else if (e.key === (rtl ? "ArrowLeft" : "ArrowRight") && this.i < this.steps.length - 1)
698
+ this.step(1);
699
+ else if (e.key === (rtl ? "ArrowRight" : "ArrowLeft") && this.i > 0)
700
+ this.step(-1);
701
+ });
702
+ document.body.append(this.card);
703
+ }
704
+ this.card.innerHTML = `<span class="k-tour__step">مرحلهٔ ${toFaDigits(this.i + 1)} از ${toFaDigits(n)}</span><p class="k-tour__title" id="${this.key}-t">${esc(s.title)}</p><p class="k-tour__body" id="${this.key}-b">${esc(s.content)}</p><div class="k-tour__actions"><button type="button" class="k-btn k-btn--link k-tour__skip" data-act="skip">رد کردن</button><span class="k-tour__progress" aria-hidden="true">${this.steps.map((_, k) => `<span${k <= this.i ? ' data-on=""' : ""}></span>`).join("")}</span>${this.i > 0 ? '<button type="button" class="k-btn k-btn--secondary k-btn--sm" data-act="prev">قبلی</button>' : ""}<button type="button" class="k-btn k-btn--primary k-btn--sm" data-act="${last ? "done" : "next"}">${last ? "تمام" : "بعدی"}</button></div>`;
705
+ const el = document.querySelector(s.target);
706
+ const reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
707
+ el?.scrollIntoView?.({ block: "center", behavior: reduce ? "auto" : "smooth" });
708
+ this.place();
709
+ setTimeout(this.place, 400);
710
+ this.card.focus({ preventScroll: true });
711
+ }
712
+ step(d) {
713
+ this.i = Math.max(0, Math.min(this.steps.length - 1, this.i + d));
714
+ this.show();
715
+ }
716
+ }
717
+ /**
718
+ * دو پنل با جداکنندهٔ قابل کشیدن: دو فرزند اول پنل‌ها هستند؛ با کیبورد ←→ (یا ↑↓ در حالت vertical) و دوبار کلیک برای اندازهٔ اولیه.
719
+ */
720
+ export class KashiSplitter extends HTMLBase {
721
+ constructor() {
722
+ super(...arguments);
723
+ this.current = 40;
724
+ this.built = false;
725
+ this.dragging = false;
726
+ }
727
+ connectedCallback() {
728
+ afterParse(() => {
729
+ if (!this.isConnected || this.built)
730
+ return;
731
+ this.current = this.num("size", 40);
732
+ this.build();
733
+ });
734
+ }
735
+ attributeChangedCallback(name, old, next) {
736
+ if (!this.built || old === next)
737
+ return;
738
+ if (name === "size")
739
+ this.set(Number(next) || 40, false);
740
+ else
741
+ this.decorate();
742
+ }
743
+ /** اندازهٔ پنل اول به درصد */
744
+ get value() {
745
+ return this.current;
746
+ }
747
+ set value(v) {
748
+ this.set(v, false);
749
+ }
750
+ num(a, d) {
751
+ const v = Number(this.getAttribute(a));
752
+ return Number.isFinite(v) && this.getAttribute(a) !== null ? v : d;
753
+ }
754
+ get vertical() {
755
+ return this.getAttribute("orientation") === "vertical";
756
+ }
757
+ build() {
758
+ const [a, b] = [...this.children];
759
+ const box = document.createElement("div");
760
+ const pa = document.createElement("div");
761
+ pa.className = "k-splitter__pane";
762
+ if (a)
763
+ pa.append(a);
764
+ const handle = document.createElement("div");
765
+ handle.className = "k-splitter__handle";
766
+ handle.setAttribute("role", "separator");
767
+ handle.tabIndex = 0;
768
+ const pb = document.createElement("div");
769
+ pb.className = "k-splitter__pane";
770
+ if (b)
771
+ pb.append(b);
772
+ box.append(pa, handle, pb);
773
+ this.replaceChildren(box);
774
+ this.built = true;
775
+ handle.addEventListener("pointerdown", (e) => {
776
+ handle.setPointerCapture?.(e.pointerId);
777
+ this.dragging = true;
778
+ box.toggleAttribute("data-dragging", true);
779
+ handle.toggleAttribute("data-dragging", true);
780
+ });
781
+ handle.addEventListener("pointermove", (e) => {
782
+ if (!this.dragging)
783
+ return;
784
+ const r = box.getBoundingClientRect();
785
+ if (this.vertical)
786
+ return this.set(((e.clientY - r.top) / r.height) * 100);
787
+ const rtl = getComputedStyle(box).direction === "rtl";
788
+ this.set(((rtl ? r.right - e.clientX : e.clientX - r.left) / r.width) * 100);
789
+ });
790
+ const end = () => {
791
+ this.dragging = false;
792
+ box.removeAttribute("data-dragging");
793
+ handle.removeAttribute("data-dragging");
794
+ };
795
+ handle.addEventListener("pointerup", end);
796
+ handle.addEventListener("pointercancel", end);
797
+ handle.addEventListener("dblclick", () => this.set(this.num("size", 40)));
798
+ handle.addEventListener("keydown", (e) => {
799
+ const rtl = getComputedStyle(handle).direction === "rtl";
800
+ const keys = this.vertical ? { ArrowUp: -5, ArrowDown: 5 } : { ArrowLeft: rtl ? 5 : -5, ArrowRight: rtl ? -5 : 5 };
801
+ if (e.key in keys)
802
+ this.set(this.current + keys[e.key]);
803
+ else if (e.key === "Home")
804
+ this.set(this.num("min", 15));
805
+ else if (e.key === "End")
806
+ this.set(this.num("max", 85));
807
+ else
808
+ return;
809
+ e.preventDefault();
810
+ });
811
+ this.decorate();
812
+ }
813
+ decorate() {
814
+ const box = this.firstElementChild;
815
+ box.className = `k-splitter${this.vertical ? " k-splitter--vertical" : ""}`;
816
+ const h = this.getAttribute("height");
817
+ box.style.blockSize = h ?? "";
818
+ const handle = box.querySelector(".k-splitter__handle");
819
+ handle.setAttribute("aria-orientation", this.vertical ? "horizontal" : "vertical");
820
+ handle.setAttribute("aria-valuemin", String(this.num("min", 15)));
821
+ handle.setAttribute("aria-valuemax", String(this.num("max", 85)));
822
+ handle.setAttribute("aria-label", this.getAttribute("label") ?? "تغییر اندازهٔ پنل‌ها");
823
+ this.paint();
824
+ }
825
+ set(p, fire = true) {
826
+ const v = Math.round(Math.min(this.num("max", 85), Math.max(this.num("min", 15), p)) * 10) / 10;
827
+ const changed = v !== this.current;
828
+ this.current = v;
829
+ if (this.built)
830
+ this.paint();
831
+ if (fire && changed)
832
+ emit(this, "kashi-change", { value: v });
833
+ }
834
+ paint() {
835
+ const box = this.firstElementChild;
836
+ const pane = box.querySelector(".k-splitter__pane");
837
+ pane.style.inlineSize = this.vertical ? "" : `${this.current}%`;
838
+ pane.style.blockSize = this.vertical ? `${this.current}%` : "";
839
+ const handle = box.querySelector(".k-splitter__handle");
840
+ handle.setAttribute("aria-valuenow", String(this.current));
841
+ handle.setAttribute("aria-valuetext", `${toFaDigits(Math.round(this.current))}٪`);
842
+ }
843
+ }
844
+ KashiSplitter.observedAttributes = ["size", "min", "max", "orientation", "label", "height"];
845
+ /**
846
+ * منوی کناری: همان HTML منوی کناری کاشی (تب HTML صفحهٔ آن) را داخلش بگذارید؛ زیرمنوها
847
+ * (دکمه با aria-controls) با کلیک باز و بسته می‌شوند و با collapsed فقط آیکون‌ها می‌مانند.
848
+ */
849
+ export class KashiSideNav extends HTMLBase {
850
+ constructor() {
851
+ super(...arguments);
852
+ this.built = false;
853
+ }
854
+ connectedCallback() {
855
+ afterParse(() => {
856
+ if (!this.isConnected || this.built)
857
+ return;
858
+ this.built = true;
859
+ this.addEventListener("click", (e) => {
860
+ const b = e.target.closest("button[aria-controls][aria-expanded]");
861
+ if (!b || !this.contains(b))
862
+ return;
863
+ const sub = document.getElementById(b.getAttribute("aria-controls"));
864
+ const open = b.getAttribute("aria-expanded") !== "true";
865
+ b.setAttribute("aria-expanded", String(open));
866
+ if (sub)
867
+ sub.hidden = !open;
868
+ emit(this, "kashi-toggle", { id: sub?.id ?? "", open });
869
+ });
870
+ this.decorate();
871
+ });
872
+ }
873
+ attributeChangedCallback() {
874
+ if (this.built)
875
+ this.decorate();
876
+ }
877
+ decorate() {
878
+ const nav = this.querySelector(".k-sidenav");
879
+ const collapsed = bool(this, "collapsed");
880
+ nav?.classList.toggle("k-sidenav--collapsed", collapsed);
881
+ // در حالت جمع‌شده، نام هر گزینه برای صفحه‌خوان و راهنمای موس
882
+ this.querySelectorAll(".k-sidenav__item").forEach((it) => {
883
+ const text = it.querySelector(".k-sidenav__text")?.textContent?.trim();
884
+ if (!text)
885
+ return;
886
+ if (collapsed) {
887
+ it.setAttribute("aria-label", text);
888
+ it.setAttribute("title", text);
889
+ }
890
+ else {
891
+ it.removeAttribute("aria-label");
892
+ it.removeAttribute("title");
893
+ }
894
+ });
895
+ }
896
+ }
897
+ KashiSideNav.observedAttributes = ["collapsed"];
898
+ // دکمه‌هایی که بدون JavaScript تور یا پالت فرمان را باز می‌کنند: data-kashi-tour="شناسه" و data-kashi-command="شناسه"
899
+ if (typeof document !== "undefined") {
900
+ document.addEventListener("click", (e) => {
901
+ const t = e.target;
902
+ const tb = t?.closest?.("[data-kashi-tour]");
903
+ const tour = tb ? document.getElementById(tb.getAttribute("data-kashi-tour")) : null;
904
+ if (tour?.start)
905
+ tour.start();
906
+ const cb = t?.closest?.("[data-kashi-command]");
907
+ const palette = cb ? document.getElementById(cb.getAttribute("data-kashi-command")) : null;
908
+ if (palette?.show)
909
+ palette.show();
910
+ });
911
+ }
912
+ define("kashi-command-palette", KashiCommandPalette, "contents");
913
+ define("kashi-context-menu", KashiContextMenu, "block");
914
+ define("kashi-carousel", KashiCarousel, "block");
915
+ define("kashi-tree-view", KashiTreeView, "block");
916
+ define("kashi-tour", KashiTour, "contents");
917
+ define("kashi-splitter", KashiSplitter, "block");
918
+ define("kashi-side-nav", KashiSideNav, "block");