@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,420 @@
1
+ import { HTMLBase, ICONS, afterParse, bool, define, emit, esc, uid } from "./base.js";
2
+ /**
3
+ * پنجرهٔ گفتگو روی <dialog> بومی با showModal: فوکوس داخل پنجره می‌ماند، Esc می‌بندد،
4
+ * بقیهٔ صفحه از دسترس صفحه‌خوان خارج می‌شود و پس از بستن، فوکوس به دکمهٔ بازکننده برمی‌گردد.
5
+ * محتوای عنصر بدنهٔ پنجره است و عنصرهای slot="footer" در پایین پنجره می‌آیند.
6
+ */
7
+ export class KashiDialog extends HTMLBase {
8
+ constructor() {
9
+ super(...arguments);
10
+ this.dialog = null;
11
+ this.opener = null;
12
+ this.key = uid("kashi-dialog");
13
+ }
14
+ connectedCallback() {
15
+ afterParse(() => {
16
+ if (!this.isConnected)
17
+ return;
18
+ if (!this.dialog)
19
+ this.build();
20
+ this.sync();
21
+ });
22
+ }
23
+ attributeChangedCallback(name, old, next) {
24
+ if (!this.dialog || old === next)
25
+ return;
26
+ if (name === "open")
27
+ this.sync();
28
+ else
29
+ this.decorate();
30
+ }
31
+ get open() {
32
+ return this.hasAttribute("open");
33
+ }
34
+ set open(v) {
35
+ this.toggleAttribute("open", !!v);
36
+ }
37
+ /** باز کردن پنجره */
38
+ show() {
39
+ this.open = true;
40
+ }
41
+ /** بستن پنجره؛ returnValue در رویداد kashi-close می‌آید */
42
+ close(returnValue) {
43
+ if (this.dialog?.open)
44
+ this.dialog.close(returnValue);
45
+ else
46
+ this.open = false;
47
+ }
48
+ build() {
49
+ const footer = [...this.children].filter((c) => c.getAttribute("slot") === "footer");
50
+ const body = [...this.childNodes].filter((n) => !footer.includes(n));
51
+ const d = document.createElement("dialog");
52
+ d.setAttribute("aria-labelledby", `${this.key}-title`);
53
+ d.innerHTML = `<div class="k-dialog__header"><div class="k-dialog__heading"><h2 class="k-dialog__title" id="${this.key}-title"></h2><p class="k-dialog__description" id="${this.key}-desc"></p></div><button type="button" class="k-btn k-btn--tertiary k-btn--icon k-btn--sm k-dialog__close">${ICONS.close}</button></div>`;
54
+ const hasBody = body.some((n) => n.nodeType === 1 || n.textContent?.trim());
55
+ if (hasBody) {
56
+ const b = document.createElement("div");
57
+ b.className = "k-dialog__body";
58
+ b.append(...body);
59
+ d.append(b);
60
+ }
61
+ if (footer.length) {
62
+ const f = document.createElement("div");
63
+ f.className = "k-dialog__footer";
64
+ for (const el of footer) {
65
+ el.removeAttribute("slot");
66
+ f.append(el);
67
+ }
68
+ d.append(f);
69
+ }
70
+ this.replaceChildren(d);
71
+ this.dialog = d;
72
+ d.querySelector(".k-dialog__close").addEventListener("click", () => this.close());
73
+ d.addEventListener("click", (e) => {
74
+ // کلیک روی خود <dialog> یعنی کلیک روی پس‌زمینه
75
+ if (e.target === d && bool(this, "dismissible", true))
76
+ this.close();
77
+ const closer = e.target.closest("[data-kashi-close]");
78
+ if (closer && d.contains(closer))
79
+ this.close(closer.getAttribute("data-kashi-close") || undefined);
80
+ });
81
+ d.addEventListener("close", () => {
82
+ this.removeAttribute("open");
83
+ this.opener?.focus?.();
84
+ emit(this, "kashi-close", { returnValue: d.returnValue });
85
+ });
86
+ this.decorate();
87
+ }
88
+ decorate() {
89
+ const d = this.dialog;
90
+ const size = this.getAttribute("size") ?? "md";
91
+ const placement = this.getAttribute("placement") ?? "center";
92
+ d.className = ["k-dialog", size !== "md" && `k-dialog--${size}`, placement !== "center" && `k-dialog--${placement}`, this.getAttribute("tone") === "danger" && "k-dialog--danger"].filter(Boolean).join(" ");
93
+ d.dataset.placement = placement;
94
+ d.querySelector(".k-dialog__title").textContent = this.getAttribute("heading") ?? "";
95
+ const desc = d.querySelector(".k-dialog__description");
96
+ const description = this.getAttribute("description");
97
+ desc.textContent = description ?? "";
98
+ desc.hidden = !description;
99
+ if (description)
100
+ d.setAttribute("aria-describedby", desc.id);
101
+ else
102
+ d.removeAttribute("aria-describedby");
103
+ d.querySelector(".k-dialog__close").setAttribute("aria-label", this.getAttribute("close-label") ?? "بستن");
104
+ }
105
+ sync() {
106
+ const d = this.dialog;
107
+ if (!d || !this.isConnected)
108
+ return;
109
+ if (this.open && !d.open) {
110
+ this.opener = document.activeElement;
111
+ d.showModal?.();
112
+ emit(this, "kashi-open", {});
113
+ }
114
+ else if (!this.open && d.open)
115
+ d.close();
116
+ }
117
+ }
118
+ KashiDialog.observedAttributes = ["open", "heading", "description", "tone", "size", "placement", "close-label"];
119
+ /**
120
+ * زبانه‌ها از روی بخش‌های داخل عنصر ساخته می‌شوند: هر فرزند با data-tab یک زبانه است.
121
+ * الگوی Tabs در WAI-ARIA؛ کلیدهای جهت‌دار در راست‌به‌چپ برعکس عمل می‌کنند. بدون JavaScript همهٔ بخش‌ها دیده می‌شوند.
122
+ */
123
+ export class KashiTabs extends HTMLBase {
124
+ constructor() {
125
+ super(...arguments);
126
+ this.list = null;
127
+ this.key = uid("kashi-tabs");
128
+ this.ro = null;
129
+ }
130
+ connectedCallback() {
131
+ afterParse(() => this.isConnected && this.setup());
132
+ }
133
+ setup() {
134
+ if (!this.list)
135
+ this.build();
136
+ this.decorate();
137
+ this.select(this.getAttribute("value") ?? this.panels[0]?.dataset.value ?? "", false);
138
+ if (typeof ResizeObserver !== "undefined" && !this.ro) {
139
+ this.ro = new ResizeObserver(() => this.measure());
140
+ this.ro.observe(this.list);
141
+ }
142
+ }
143
+ disconnectedCallback() {
144
+ this.ro?.disconnect();
145
+ this.ro = null;
146
+ }
147
+ attributeChangedCallback(name, old, next) {
148
+ if (!this.list || old === next)
149
+ return;
150
+ if (name === "value")
151
+ this.select(next ?? "", false);
152
+ else
153
+ this.decorate();
154
+ }
155
+ /** مقدار زبانهٔ فعال */
156
+ get value() {
157
+ return this.getAttribute("value") ?? "";
158
+ }
159
+ set value(v) {
160
+ this.setAttribute("value", v);
161
+ }
162
+ get panels() {
163
+ return [...this.children].filter((c) => c instanceof HTMLElement && c.hasAttribute("data-tab"));
164
+ }
165
+ build() {
166
+ const list = document.createElement("div");
167
+ list.setAttribute("role", "tablist");
168
+ list.className = "k-tabs__list";
169
+ this.panels.forEach((p, i) => {
170
+ const value = p.dataset.value || String(i + 1);
171
+ p.dataset.value = value;
172
+ const tab = document.createElement("button");
173
+ tab.type = "button";
174
+ tab.setAttribute("role", "tab");
175
+ tab.className = "k-tabs__tab";
176
+ tab.id = `${this.key}-tab-${i}`;
177
+ tab.dataset.value = value;
178
+ tab.textContent = p.dataset.tab ?? "";
179
+ if (p.hasAttribute("data-disabled"))
180
+ tab.disabled = true;
181
+ p.id || (p.id = `${this.key}-panel-${i}`);
182
+ tab.setAttribute("aria-controls", p.id);
183
+ p.setAttribute("role", "tabpanel");
184
+ p.setAttribute("aria-labelledby", tab.id);
185
+ p.tabIndex = 0;
186
+ p.classList.add("k-tabs__panel");
187
+ list.append(tab);
188
+ });
189
+ const indicator = document.createElement("span");
190
+ indicator.className = "k-tabs__indicator";
191
+ indicator.setAttribute("aria-hidden", "true");
192
+ list.append(indicator);
193
+ list.addEventListener("click", (e) => {
194
+ const tab = e.target.closest('[role="tab"]');
195
+ if (tab && !tab.disabled)
196
+ this.select(tab.dataset.value, true);
197
+ });
198
+ list.addEventListener("keydown", (e) => this.onKey(e));
199
+ this.prepend(list);
200
+ this.list = list;
201
+ }
202
+ decorate() {
203
+ const variant = this.getAttribute("variant") ?? "line";
204
+ const vertical = this.getAttribute("orientation") === "vertical";
205
+ for (const c of [...this.classList])
206
+ if (c.startsWith("k-tabs"))
207
+ this.classList.remove(c);
208
+ this.classList.add("k-tabs", `k-tabs--${variant}`);
209
+ if (bool(this, "block"))
210
+ this.classList.add("k-tabs--block");
211
+ if (vertical)
212
+ this.classList.add("k-tabs--vertical");
213
+ if (this.getAttribute("size") === "sm")
214
+ this.classList.add("k-tabs--sm");
215
+ this.dataset.orientation = vertical ? "vertical" : "horizontal";
216
+ this.list.setAttribute("aria-orientation", vertical ? "vertical" : "horizontal");
217
+ const label = this.getAttribute("label");
218
+ if (label)
219
+ this.list.setAttribute("aria-label", label);
220
+ this.measure();
221
+ }
222
+ select(value, user) {
223
+ const tabs = [...this.list.querySelectorAll('[role="tab"]')];
224
+ if (!tabs.some((t) => t.dataset.value === value))
225
+ value = tabs[0]?.dataset.value ?? "";
226
+ for (const t of tabs) {
227
+ const on = t.dataset.value === value;
228
+ t.setAttribute("aria-selected", String(on));
229
+ t.tabIndex = on ? 0 : -1;
230
+ }
231
+ for (const p of this.panels)
232
+ p.hidden = p.dataset.value !== value;
233
+ if (this.getAttribute("value") !== value) {
234
+ // تغییر ویژگی دوباره select را صدا می‌زند؛ همان مقدار است و کاری نمی‌کند
235
+ this.setAttribute("value", value);
236
+ }
237
+ this.measure();
238
+ if (user)
239
+ emit(this, "kashi-change", { value });
240
+ }
241
+ /** نشانگر زبانهٔ فعال که با تغییر زبانه می‌لغزد */
242
+ measure() {
243
+ const list = this.list;
244
+ const tab = list?.querySelector('[role="tab"][aria-selected="true"]');
245
+ if (!list || !tab || !tab.offsetWidth) {
246
+ list?.removeAttribute("data-indicator");
247
+ return;
248
+ }
249
+ list.setAttribute("data-indicator", "");
250
+ list.style.setProperty("--_ix", `${tab.offsetLeft}px`);
251
+ list.style.setProperty("--_iy", `${tab.offsetTop}px`);
252
+ list.style.setProperty("--_iw", `${tab.offsetWidth}px`);
253
+ list.style.setProperty("--_ih", `${tab.offsetHeight}px`);
254
+ }
255
+ onKey(e) {
256
+ const tabs = [...this.list.querySelectorAll('[role="tab"]:not(:disabled)')];
257
+ const i = tabs.indexOf(document.activeElement);
258
+ if (i < 0)
259
+ return;
260
+ const rtl = getComputedStyle(this.list).direction === "rtl";
261
+ const moves = this.getAttribute("orientation") === "vertical"
262
+ ? { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: tabs.length - 1 }
263
+ : { ArrowLeft: rtl ? i + 1 : i - 1, ArrowRight: rtl ? i - 1 : i + 1, Home: 0, End: tabs.length - 1 };
264
+ if (!(e.key in moves))
265
+ return;
266
+ e.preventDefault();
267
+ const next = tabs[(moves[e.key] + tabs.length) % tabs.length];
268
+ next.focus();
269
+ this.select(next.dataset.value, true);
270
+ }
271
+ }
272
+ KashiTabs.observedAttributes = ["value", "variant", "block", "orientation", "size", "label"];
273
+ /**
274
+ * راهنمای کوتاه طبق WCAG 1.4.13: با hover و فوکوس نمایش داده می‌شود، روی خودش هم hover پذیر است
275
+ * و با Esc بسته می‌شود. با aria-describedby به اولین فرزند (معمولاً یک دکمه) متصل است.
276
+ */
277
+ export class KashiTooltip extends HTMLBase {
278
+ constructor() {
279
+ super(...arguments);
280
+ this.tip = null;
281
+ this.key = uid("kashi-tip");
282
+ this.onKey = (e) => {
283
+ if (e.key === "Escape") {
284
+ clearTimeout(this.timer);
285
+ this.tip.hidden = true;
286
+ }
287
+ };
288
+ }
289
+ connectedCallback() {
290
+ afterParse(() => this.isConnected && this.setup());
291
+ }
292
+ setup() {
293
+ if (!this.tip) {
294
+ this.classList.add("k-tooltip-anchor");
295
+ const tip = document.createElement("span");
296
+ tip.setAttribute("role", "tooltip");
297
+ tip.id = this.key;
298
+ tip.hidden = true;
299
+ this.append(tip);
300
+ this.tip = tip;
301
+ const trigger = this.firstElementChild !== tip ? this.firstElementChild : null;
302
+ if (trigger)
303
+ trigger.setAttribute("aria-describedby", [trigger.getAttribute("aria-describedby"), this.key].filter(Boolean).join(" "));
304
+ this.addEventListener("mouseenter", () => this.show(Number(this.getAttribute("delay") ?? 300)));
305
+ this.addEventListener("mouseleave", () => this.hide());
306
+ this.addEventListener("focusin", () => this.show(0));
307
+ this.addEventListener("focusout", () => this.hide());
308
+ }
309
+ this.decorate();
310
+ }
311
+ disconnectedCallback() {
312
+ clearTimeout(this.timer);
313
+ document.removeEventListener("keydown", this.onKey);
314
+ }
315
+ attributeChangedCallback() {
316
+ if (this.tip)
317
+ this.decorate();
318
+ }
319
+ decorate() {
320
+ const placement = this.getAttribute("placement");
321
+ this.tip.className = `k-tooltip${placement && placement !== "top" ? ` k-tooltip--${placement}` : ""}`;
322
+ this.tip.textContent = this.getAttribute("content") ?? "";
323
+ }
324
+ show(wait) {
325
+ clearTimeout(this.timer);
326
+ this.timer = setTimeout(() => {
327
+ this.tip.hidden = false;
328
+ document.addEventListener("keydown", this.onKey);
329
+ }, wait);
330
+ }
331
+ hide() {
332
+ clearTimeout(this.timer);
333
+ this.timer = setTimeout(() => {
334
+ this.tip.hidden = true;
335
+ document.removeEventListener("keydown", this.onKey);
336
+ }, 100);
337
+ }
338
+ }
339
+ KashiTooltip.observedAttributes = ["content", "placement"];
340
+ /** دکمهٔ کپی در کلیپ‌بورد با بازخورد «کپی شد» برای چشم و صفحه‌خوان */
341
+ export class KashiCopyButton extends HTMLBase {
342
+ constructor() {
343
+ super(...arguments);
344
+ this.state = "idle";
345
+ this.built = false;
346
+ }
347
+ connectedCallback() {
348
+ if (!this.built) {
349
+ this.built = true;
350
+ this.addEventListener("click", (e) => {
351
+ if (e.target.closest("button"))
352
+ this.copy();
353
+ });
354
+ }
355
+ this.render();
356
+ }
357
+ disconnectedCallback() {
358
+ clearTimeout(this.timer);
359
+ }
360
+ attributeChangedCallback() {
361
+ if (this.built)
362
+ this.render();
363
+ }
364
+ /** متنی که کپی می‌شود: ویژگی value، یا مقدار/متن عنصری که target به آن اشاره می‌کند */
365
+ get text() {
366
+ const value = this.getAttribute("value");
367
+ if (value !== null)
368
+ return value;
369
+ const target = this.getAttribute("target");
370
+ const el = target ? document.querySelector(target) : null;
371
+ return el ? (typeof el.value === "string" ? el.value : (el.textContent ?? "").trim()) : "";
372
+ }
373
+ async copy() {
374
+ try {
375
+ await navigator.clipboard.writeText(this.text);
376
+ this.state = "copied";
377
+ emit(this, "kashi-copy", { value: this.text });
378
+ }
379
+ catch {
380
+ this.state = "failed";
381
+ }
382
+ this.render();
383
+ clearTimeout(this.timer);
384
+ this.timer = setTimeout(() => {
385
+ this.state = "idle";
386
+ this.render();
387
+ }, Number(this.getAttribute("timeout")) || 2000);
388
+ }
389
+ render() {
390
+ const copy = this.getAttribute("label") ?? "کپی";
391
+ const copied = this.getAttribute("copied-label") ?? "کپی شد";
392
+ const text = this.state === "copied" ? copied : this.state === "failed" ? "کپی نشد" : copy;
393
+ const iconOnly = bool(this, "icon-only");
394
+ const icon = this.state === "copied" ? ICONS.check : ICONS.copy;
395
+ const variant = this.getAttribute("variant") ?? "secondary";
396
+ const size = this.getAttribute("size") ?? "sm";
397
+ const focused = this.contains(document.activeElement);
398
+ this.innerHTML = `<button type="button" class="k-btn k-btn--${esc(variant)}${size !== "md" ? ` k-btn--${esc(size)}` : ""}${iconOnly ? " k-btn--icon" : ""}" data-variant="${esc(variant)}" data-size="${esc(size)}"${iconOnly ? ` aria-label="${esc(text)}"` : ""}${this.state === "copied" ? ' data-copied=""' : ""}>${icon}${iconOnly ? "" : esc(text)}</button><span class="k-sr-only" aria-live="polite">${this.state === "idle" ? "" : esc(text)}</span>`;
399
+ if (focused)
400
+ this.querySelector("button").focus();
401
+ }
402
+ }
403
+ KashiCopyButton.observedAttributes = ["value", "target", "label", "copied-label", "icon-only", "variant", "size"];
404
+ define("kashi-dialog", KashiDialog, "contents");
405
+ define("kashi-tabs", KashiTabs, "block");
406
+ define("kashi-tooltip", KashiTooltip);
407
+ define("kashi-copy-button", KashiCopyButton);
408
+ // دکمه‌هایی که بدون JavaScript پنجره را باز یا می‌بندند: data-kashi-open="شناسهٔ پنجره"
409
+ if (typeof document !== "undefined") {
410
+ document.addEventListener("click", (e) => {
411
+ const opener = e.target?.closest?.("[data-kashi-open]");
412
+ if (opener) {
413
+ const dialog = document.getElementById(opener.getAttribute("data-kashi-open"));
414
+ if (dialog?.show) {
415
+ e.preventDefault();
416
+ dialog.show();
417
+ }
418
+ }
419
+ });
420
+ }
@@ -0,0 +1,53 @@
1
+ import { HTMLBase } from "./base.js";
2
+ export interface TableColumn {
3
+ key: string;
4
+ header: string;
5
+ sortable?: boolean;
6
+ numeric?: boolean;
7
+ /** متن سلول؛ پیش‌فرض مقدار row[key] */
8
+ format?: (value: unknown, row: Record<string, unknown>) => string;
9
+ }
10
+ /**
11
+ * جدول داده با مرتب‌سازی، انتخاب ردیف و جستجو. دو راه:
12
+ * ۱) یک <table> معمولی داخلش بگذارید: ستون‌های th[data-sort] مرتب‌شدنی‌اند (data-numeric برای عدد) و tr[data-id] شناسهٔ ردیف است.
13
+ * ۲) یا با JavaScript: element.columns = [...] و element.rows = [...].
14
+ * بدون JavaScript همان جدول معمولی با ظاهر کاشی دیده می‌شود.
15
+ */
16
+ export declare class KashiDataTable extends HTMLBase {
17
+ static observedAttributes: string[];
18
+ private cols;
19
+ private data;
20
+ private table;
21
+ private sort;
22
+ private query;
23
+ private key;
24
+ /** شناسهٔ ردیف در حالت JavaScript @default row.id */
25
+ getRowId: (row: Record<string, unknown>) => string;
26
+ connectedCallback(): void;
27
+ attributeChangedCallback(_n: string, old: string | null, next: string | null): void;
28
+ /** ستون‌ها در حالت JavaScript */
29
+ get columns(): TableColumn[];
30
+ set columns(c: TableColumn[]);
31
+ /** ردیف‌ها در حالت JavaScript */
32
+ get rows(): Record<string, unknown>[];
33
+ set rows(r: Record<string, unknown>[]);
34
+ /** شناسهٔ ردیف‌های انتخاب‌شده */
35
+ get selected(): string[];
36
+ private setup;
37
+ private wrap;
38
+ private buildFromData;
39
+ private decorateHeader;
40
+ private headRow;
41
+ private headCells;
42
+ private bodyRows;
43
+ /** شمارهٔ ستون بدون ستون چک‌باکس */
44
+ private dataIndex;
45
+ private offset;
46
+ private decorateRows;
47
+ private setRow;
48
+ private selectionChanged;
49
+ private visibleRows;
50
+ private searchBox;
51
+ /** مرتب‌سازی و جستجو را روی ردیف‌ها اعمال می‌کند */
52
+ private apply;
53
+ }