@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.
package/dist/menu.js ADDED
@@ -0,0 +1,281 @@
1
+ import { HTMLBase, afterParse, define, emit, esc, uid } from "./base.js";
2
+ const CHECK = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 10.5 3.2 3L15 7"/></svg>';
3
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
4
+ const items = (menu) => [...menu.querySelectorAll('[role^="menuitem"]:not([aria-disabled="true"])')];
5
+ /**
6
+ * منوی کشویی با الگوی Menu Button در WAI-ARIA. اولین فرزند دکمهٔ بازکننده است و بقیه گزینه‌ها:
7
+ * <button> (با data-value، data-shortcut، data-tone="danger"، data-checked، disabled)، <a href>، <hr> برای جداکننده
8
+ * و هر عنصر data-label برای عنوان گروه. ↑↓ حرکت، Home/End، پرش با حرف اول، Esc بستن.
9
+ */
10
+ export class KashiDropdownMenu extends HTMLBase {
11
+ constructor() {
12
+ super(...arguments);
13
+ this.menu = null;
14
+ this.trigger = null;
15
+ this.key = uid("kashi-menu");
16
+ this.onOutside = (e) => {
17
+ if (!this.contains(e.target))
18
+ this.close(false);
19
+ };
20
+ }
21
+ connectedCallback() {
22
+ afterParse(() => this.isConnected && !this.menu && this.build());
23
+ }
24
+ disconnectedCallback() {
25
+ document.removeEventListener("pointerdown", this.onOutside);
26
+ }
27
+ attributeChangedCallback() {
28
+ if (this.menu)
29
+ this.decorate();
30
+ }
31
+ get open() {
32
+ return !!this.menu && !this.menu.hidden;
33
+ }
34
+ build() {
35
+ const [trigger, ...rest] = [...this.children];
36
+ if (!trigger)
37
+ return;
38
+ this.classList.add("k-menu-anchor");
39
+ this.trigger = trigger;
40
+ trigger.id || (trigger.id = `${this.key}-trigger`);
41
+ trigger.setAttribute("aria-haspopup", "menu");
42
+ trigger.setAttribute("aria-expanded", "false");
43
+ const menu = document.createElement("ul");
44
+ menu.id = `${this.key}-menu`;
45
+ menu.setAttribute("role", "menu");
46
+ menu.hidden = true;
47
+ for (const el of rest) {
48
+ const li = document.createElement("li");
49
+ if (el.tagName === "HR") {
50
+ li.setAttribute("role", "separator");
51
+ li.className = "k-menu__separator";
52
+ el.remove();
53
+ }
54
+ else if (el.hasAttribute("data-label")) {
55
+ li.setAttribute("role", "presentation");
56
+ li.className = "k-menu__label";
57
+ li.textContent = el.textContent;
58
+ el.remove();
59
+ }
60
+ else {
61
+ li.setAttribute("role", "none");
62
+ const checkable = el.hasAttribute("data-checked");
63
+ el.setAttribute("role", checkable ? "menuitemcheckbox" : "menuitem");
64
+ el.tabIndex = -1;
65
+ el.classList.add("k-menu__item");
66
+ if (el.dataset.tone === "danger")
67
+ el.classList.add("k-menu__item--danger");
68
+ if (el instanceof HTMLButtonElement) {
69
+ el.type = "button";
70
+ // دکمهٔ غیرفعال فوکوس‌پذیر می‌ماند تا صفحه‌خوان آن را بخواند
71
+ if (el.disabled) {
72
+ el.disabled = false;
73
+ el.setAttribute("aria-disabled", "true");
74
+ }
75
+ }
76
+ const text = el.innerHTML;
77
+ el.innerHTML = `${checkable ? `<span class="k-menu__check" aria-hidden="true"></span>` : ""}<span>${text}</span>${el.dataset.shortcut ? `<span class="k-menu__shortcut" aria-hidden="true">${esc(el.dataset.shortcut)}</span>` : ""}`;
78
+ if (checkable)
79
+ this.paintCheck(el);
80
+ li.append(el);
81
+ }
82
+ menu.append(li);
83
+ }
84
+ this.append(menu);
85
+ this.menu = menu;
86
+ this.decorate();
87
+ trigger.addEventListener("click", () => (this.open ? this.close(false) : this.show("first")));
88
+ trigger.addEventListener("keydown", (e) => {
89
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
90
+ e.preventDefault();
91
+ this.show(e.key === "ArrowUp" ? "last" : "first");
92
+ }
93
+ });
94
+ menu.addEventListener("keydown", (e) => this.onKey(e));
95
+ menu.addEventListener("click", (e) => {
96
+ const item = e.target.closest('[role^="menuitem"]');
97
+ if (!item || item.getAttribute("aria-disabled") === "true")
98
+ return;
99
+ if (item.getAttribute("role") === "menuitemcheckbox") {
100
+ item.dataset.checked = String(item.dataset.checked !== "true");
101
+ this.paintCheck(item);
102
+ }
103
+ emit(this, "kashi-select", { value: item.dataset.value ?? item.textContent?.trim() ?? "", checked: item.dataset.checked === undefined ? undefined : item.dataset.checked === "true" });
104
+ if (item.getAttribute("role") === "menuitem")
105
+ this.close(item.tagName !== "A");
106
+ });
107
+ }
108
+ paintCheck(el) {
109
+ const on = el.dataset.checked === "true";
110
+ el.setAttribute("aria-checked", String(on));
111
+ el.querySelector(".k-menu__check").innerHTML = on ? CHECK : "";
112
+ }
113
+ decorate() {
114
+ const label = this.getAttribute("label");
115
+ this.menu.className = `k-menu${this.getAttribute("align") === "end" ? " k-menu--end" : ""}`;
116
+ if (label) {
117
+ this.menu.setAttribute("aria-label", label);
118
+ this.menu.removeAttribute("aria-labelledby");
119
+ }
120
+ else {
121
+ this.menu.setAttribute("aria-labelledby", this.trigger.id);
122
+ this.menu.removeAttribute("aria-label");
123
+ }
124
+ }
125
+ /** باز کردن منو و فوکوس روی اولین یا آخرین گزینه */
126
+ show(focus = "first") {
127
+ if (!this.menu)
128
+ return;
129
+ this.menu.hidden = false;
130
+ this.trigger.setAttribute("aria-expanded", "true");
131
+ this.trigger.setAttribute("aria-controls", this.menu.id);
132
+ const list = items(this.menu);
133
+ (focus === "last" ? list.at(-1) : list[0])?.focus();
134
+ document.addEventListener("pointerdown", this.onOutside);
135
+ }
136
+ /** بستن منو؛ با focusTrigger فوکوس به دکمهٔ بازکننده برمی‌گردد */
137
+ close(focusTrigger = true) {
138
+ if (!this.menu || this.menu.hidden)
139
+ return;
140
+ this.menu.hidden = true;
141
+ this.trigger.setAttribute("aria-expanded", "false");
142
+ this.trigger.removeAttribute("aria-controls");
143
+ document.removeEventListener("pointerdown", this.onOutside);
144
+ if (focusTrigger)
145
+ this.trigger.focus();
146
+ }
147
+ onKey(e) {
148
+ const list = items(this.menu);
149
+ const i = list.indexOf(document.activeElement);
150
+ const go = (j) => list[(j + list.length) % list.length]?.focus();
151
+ if (e.key === "ArrowDown")
152
+ go(i + 1);
153
+ else if (e.key === "ArrowUp")
154
+ go(i - 1);
155
+ else if (e.key === "Home")
156
+ go(0);
157
+ else if (e.key === "End")
158
+ go(list.length - 1);
159
+ else if (e.key === "Escape")
160
+ this.close();
161
+ else if (e.key === "Tab")
162
+ return this.close(false);
163
+ else if (e.key.length === 1 && /\S/.test(e.key)) {
164
+ // پرش با حرف اول
165
+ const k = e.key.toLowerCase();
166
+ const rest = [...list.slice(i + 1), ...list.slice(0, i + 1)];
167
+ rest.find((el) => el.textContent?.trim().toLowerCase().startsWith(k))?.focus();
168
+ }
169
+ else
170
+ return;
171
+ e.preventDefault();
172
+ }
173
+ }
174
+ KashiDropdownMenu.observedAttributes = ["label", "align"];
175
+ /**
176
+ * پنجرهٔ کوچک غیرمدال کنار یک دکمه؛ اولین فرزند دکمهٔ بازکننده است و بقیه محتوای پنجره.
177
+ * فوکوس به اولین عنصر قابل فوکوس داخل آن می‌رود، Esc می‌بندد و فوکوس را به دکمه برمی‌گرداند.
178
+ */
179
+ export class KashiPopover extends HTMLBase {
180
+ constructor() {
181
+ super(...arguments);
182
+ this.panel = null;
183
+ this.trigger = null;
184
+ this.key = uid("kashi-pop");
185
+ this.onOutside = (e) => {
186
+ if (!this.contains(e.target))
187
+ this.close(false);
188
+ };
189
+ }
190
+ connectedCallback() {
191
+ afterParse(() => this.isConnected && !this.panel && this.build());
192
+ }
193
+ disconnectedCallback() {
194
+ document.removeEventListener("pointerdown", this.onOutside);
195
+ }
196
+ attributeChangedCallback() {
197
+ if (this.panel)
198
+ this.decorate();
199
+ }
200
+ get open() {
201
+ return !!this.panel && !this.panel.hidden;
202
+ }
203
+ set open(v) {
204
+ if (v)
205
+ this.show();
206
+ else
207
+ this.close(false);
208
+ }
209
+ build() {
210
+ const [trigger, ...rest] = [...this.childNodes].filter((n) => n.nodeType === 1 || n.textContent?.trim());
211
+ if (!trigger)
212
+ return;
213
+ this.classList.add("k-popover-anchor");
214
+ this.trigger = trigger;
215
+ trigger.setAttribute("aria-haspopup", "dialog");
216
+ trigger.setAttribute("aria-expanded", "false");
217
+ const panel = document.createElement("div");
218
+ panel.id = `${this.key}-panel`;
219
+ panel.setAttribute("role", "dialog");
220
+ panel.tabIndex = -1;
221
+ panel.hidden = true;
222
+ panel.innerHTML = `<p class="k-popover__title" id="${this.key}-title"></p><div class="k-popover__body"></div>`;
223
+ panel.querySelector(".k-popover__body").append(...rest);
224
+ this.append(panel);
225
+ this.panel = panel;
226
+ this.decorate();
227
+ trigger.addEventListener("click", () => (this.open ? this.close(false) : this.show()));
228
+ panel.addEventListener("keydown", (e) => {
229
+ if (e.key === "Escape") {
230
+ e.stopPropagation();
231
+ this.close(true);
232
+ }
233
+ });
234
+ panel.addEventListener("focusout", (e) => {
235
+ const next = e.relatedTarget;
236
+ if (next && !this.contains(next))
237
+ this.close(false);
238
+ });
239
+ }
240
+ decorate() {
241
+ const p = this.panel;
242
+ const [side, align] = (this.getAttribute("placement") ?? "bottom-start").split("-");
243
+ p.className = ["k-popover", side === "top" && "k-popover--top", align === "end" && "k-popover--end"].filter(Boolean).join(" ");
244
+ const heading = this.getAttribute("heading");
245
+ const title = p.querySelector(".k-popover__title");
246
+ title.textContent = heading ?? "";
247
+ title.hidden = !heading;
248
+ if (heading) {
249
+ p.setAttribute("aria-labelledby", title.id);
250
+ p.removeAttribute("aria-label");
251
+ }
252
+ else {
253
+ p.removeAttribute("aria-labelledby");
254
+ p.setAttribute("aria-label", this.getAttribute("label") ?? "");
255
+ }
256
+ }
257
+ show() {
258
+ if (!this.panel || this.open)
259
+ return;
260
+ this.panel.hidden = false;
261
+ this.trigger.setAttribute("aria-expanded", "true");
262
+ this.trigger.setAttribute("aria-controls", this.panel.id);
263
+ (this.panel.querySelector(`.k-popover__body :is(${FOCUSABLE})`) ?? this.panel).focus();
264
+ document.addEventListener("pointerdown", this.onOutside);
265
+ emit(this, "kashi-open", {});
266
+ }
267
+ close(focusTrigger = true) {
268
+ if (!this.panel || this.panel.hidden)
269
+ return;
270
+ this.panel.hidden = true;
271
+ this.trigger.setAttribute("aria-expanded", "false");
272
+ this.trigger.removeAttribute("aria-controls");
273
+ document.removeEventListener("pointerdown", this.onOutside);
274
+ if (focusTrigger)
275
+ this.trigger.focus();
276
+ emit(this, "kashi-close", {});
277
+ }
278
+ }
279
+ KashiPopover.observedAttributes = ["heading", "placement", "label"];
280
+ define("kashi-dropdown-menu", KashiDropdownMenu);
281
+ define("kashi-popover", KashiPopover);
@@ -0,0 +1,79 @@
1
+ import { HTMLBase } from "./base.js";
2
+ /**
3
+ * پنجرهٔ گفتگو روی <dialog> بومی با showModal: فوکوس داخل پنجره می‌ماند، Esc می‌بندد،
4
+ * بقیهٔ صفحه از دسترس صفحه‌خوان خارج می‌شود و پس از بستن، فوکوس به دکمهٔ بازکننده برمی‌گردد.
5
+ * محتوای عنصر بدنهٔ پنجره است و عنصرهای slot="footer" در پایین پنجره می‌آیند.
6
+ */
7
+ export declare class KashiDialog extends HTMLBase {
8
+ static observedAttributes: string[];
9
+ private dialog;
10
+ private opener;
11
+ private key;
12
+ connectedCallback(): void;
13
+ attributeChangedCallback(name: string, old: string | null, next: string | null): void;
14
+ get open(): boolean;
15
+ set open(v: boolean);
16
+ /** باز کردن پنجره */
17
+ show(): void;
18
+ /** بستن پنجره؛ returnValue در رویداد kashi-close می‌آید */
19
+ close(returnValue?: string): void;
20
+ private build;
21
+ private decorate;
22
+ private sync;
23
+ }
24
+ /**
25
+ * زبانه‌ها از روی بخش‌های داخل عنصر ساخته می‌شوند: هر فرزند با data-tab یک زبانه است.
26
+ * الگوی Tabs در WAI-ARIA؛ کلیدهای جهت‌دار در راست‌به‌چپ برعکس عمل می‌کنند. بدون JavaScript همهٔ بخش‌ها دیده می‌شوند.
27
+ */
28
+ export declare class KashiTabs extends HTMLBase {
29
+ static observedAttributes: string[];
30
+ private list;
31
+ private key;
32
+ private ro;
33
+ connectedCallback(): void;
34
+ private setup;
35
+ disconnectedCallback(): void;
36
+ attributeChangedCallback(name: string, old: string | null, next: string | null): void;
37
+ /** مقدار زبانهٔ فعال */
38
+ get value(): string;
39
+ set value(v: string);
40
+ private get panels();
41
+ private build;
42
+ private decorate;
43
+ private select;
44
+ /** نشانگر زبانهٔ فعال که با تغییر زبانه می‌لغزد */
45
+ private measure;
46
+ private onKey;
47
+ }
48
+ /**
49
+ * راهنمای کوتاه طبق WCAG 1.4.13: با hover و فوکوس نمایش داده می‌شود، روی خودش هم hover پذیر است
50
+ * و با Esc بسته می‌شود. با aria-describedby به اولین فرزند (معمولاً یک دکمه) متصل است.
51
+ */
52
+ export declare class KashiTooltip extends HTMLBase {
53
+ static observedAttributes: string[];
54
+ private tip;
55
+ private timer;
56
+ private key;
57
+ connectedCallback(): void;
58
+ private setup;
59
+ disconnectedCallback(): void;
60
+ attributeChangedCallback(): void;
61
+ private decorate;
62
+ private show;
63
+ private hide;
64
+ private onKey;
65
+ }
66
+ /** دکمهٔ کپی در کلیپ‌بورد با بازخورد «کپی شد» برای چشم و صفحه‌خوان */
67
+ export declare class KashiCopyButton extends HTMLBase {
68
+ static observedAttributes: string[];
69
+ private state;
70
+ private timer;
71
+ private built;
72
+ connectedCallback(): void;
73
+ disconnectedCallback(): void;
74
+ attributeChangedCallback(): void;
75
+ /** متنی که کپی می‌شود: ویژگی value، یا مقدار/متن عنصری که target به آن اشاره می‌کند */
76
+ get text(): string;
77
+ copy(): Promise<void>;
78
+ private render;
79
+ }