@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,394 @@
1
+ import { JALALI_MONTHS, WEEKDAYS, WEEKDAYS_SHORT, formatJalali, fromISODate, getIranHoliday, jalaliMonthLength, jalaliWeekday, parseJalali, toFaDigits, toGregorian, toISODate, toJalali, } from "@kashi-ui/persian";
2
+ import { HTMLBase, ICONS, bool, define, emit, esc, fieldHTML, setMessages, syncHidden, uid } from "./base.js";
3
+ const sameDay = (a, b) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
4
+ const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
5
+ const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
6
+ const dayKey = (d) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
7
+ /** چند ماه شمسی جلو یا عقب؛ روز در ماه‌های کوتاه‌تر به آخر ماه می‌چسبد */
8
+ function addJalaliMonths(d, n) {
9
+ const { jy, jm, jd } = toJalali(d);
10
+ const total = jy * 12 + (jm - 1) + n;
11
+ const ny = Math.floor(total / 12);
12
+ const nm = total - ny * 12 + 1;
13
+ return toGregorian(ny, nm, Math.min(jd, jalaliMonthLength(ny, nm)));
14
+ }
15
+ /** تاریخ میلادی YYYY-MM-DD یا شمسی ۱۴۰۳/۰۵/۱۲ */
16
+ const parseDate = (text) => (text ? (fromISODate(text.trim()) ?? parseJalali(text)) : null);
17
+ /**
18
+ * تقویم شمسی با الگوی grid در WAI-ARIA: پیمایش با فلش‌ها (در RTL فلش چپ یعنی روز بعد)،
19
+ * PageUp/PageDown برای ماه و با Shift برای سال، Home/End برای ابتدا و انتهای هفته.
20
+ */
21
+ export class KashiCalendar extends HTMLBase {
22
+ constructor() {
23
+ super(...arguments);
24
+ this.selected = null;
25
+ this.valueSet = false;
26
+ this.focused = startOfDay(new Date());
27
+ this.refocus = false;
28
+ this.rendered = false;
29
+ this.key = uid("kashi-cal");
30
+ this.onClick = (e) => {
31
+ const btn = e.target.closest("button");
32
+ if (!btn || !this.contains(btn))
33
+ return;
34
+ const nav = btn.dataset.nav;
35
+ if (nav)
36
+ this.go(addJalaliMonths(this.focused, nav === "prev" ? -1 : 1), false);
37
+ else if (btn.dataset.today !== undefined) {
38
+ const t = new Date();
39
+ this.pick(new Date(t.getFullYear(), t.getMonth(), t.getDate()));
40
+ }
41
+ else if (btn.dataset.day) {
42
+ const [y, m, d] = btn.dataset.day.split("-").map(Number);
43
+ this.pick(new Date(y, m - 1, d));
44
+ }
45
+ };
46
+ this.onSelect = (e) => {
47
+ const sel = e.target;
48
+ if (!sel.matches?.(".k-calendar__select"))
49
+ return;
50
+ e.stopPropagation();
51
+ const { jy, jm, jd } = toJalali(this.focused);
52
+ const ny = sel.dataset.part === "year" ? Number(sel.value) : jy;
53
+ const nm = sel.dataset.part === "month" ? Number(sel.value) : jm;
54
+ this.go(toGregorian(ny, nm, Math.min(jd, jalaliMonthLength(ny, nm))), false);
55
+ this.querySelector(`.k-calendar__select[data-part="${sel.dataset.part}"]`)?.focus();
56
+ };
57
+ this.onKey = (e) => {
58
+ const btn = e.target;
59
+ if (!btn.classList?.contains("k-calendar__day"))
60
+ return;
61
+ const rtl = getComputedStyle(this).direction === "rtl";
62
+ const f = this.focused;
63
+ const wd = jalaliWeekday(f);
64
+ const moves = {
65
+ ArrowLeft: () => addDays(f, rtl ? 1 : -1),
66
+ ArrowRight: () => addDays(f, rtl ? -1 : 1),
67
+ ArrowUp: () => addDays(f, -7),
68
+ ArrowDown: () => addDays(f, 7),
69
+ Home: () => addDays(f, -wd),
70
+ End: () => addDays(f, 6 - wd),
71
+ PageUp: () => addJalaliMonths(f, e.shiftKey ? -12 : -1),
72
+ PageDown: () => addJalaliMonths(f, e.shiftKey ? 12 : 1),
73
+ };
74
+ if (!moves[e.key])
75
+ return;
76
+ e.preventDefault();
77
+ this.go(moves[e.key]());
78
+ };
79
+ }
80
+ connectedCallback() {
81
+ if (!this.rendered) {
82
+ if (!this.valueSet)
83
+ this.selected = parseDate(this.getAttribute("value"));
84
+ this.focused = this.clamp(startOfDay(this.selected ?? new Date()));
85
+ this.addEventListener("click", this.onClick);
86
+ this.addEventListener("keydown", this.onKey);
87
+ this.addEventListener("change", this.onSelect);
88
+ }
89
+ this.rendered = true;
90
+ this.render();
91
+ }
92
+ attributeChangedCallback(name, old, next) {
93
+ if (!this.rendered || old === next)
94
+ return;
95
+ if (name === "value") {
96
+ this.selected = parseDate(next);
97
+ if (this.selected)
98
+ this.focused = startOfDay(this.selected);
99
+ }
100
+ this.render();
101
+ }
102
+ /** تاریخ انتخاب‌شده به شکل YYYY-MM-DD میلادی */
103
+ get value() {
104
+ return this.selected ? toISODate(this.selected) : "";
105
+ }
106
+ set value(v) {
107
+ this.valueSet = true;
108
+ this.selected = parseDate(v ?? "");
109
+ if (this.selected)
110
+ this.focused = startOfDay(this.selected);
111
+ if (this.rendered)
112
+ this.render();
113
+ }
114
+ get date() {
115
+ return this.selected;
116
+ }
117
+ set date(d) {
118
+ this.value = d ? toISODate(d) : "";
119
+ }
120
+ /** فوکوس روی روز انتخاب‌شده یا امروز */
121
+ focus() {
122
+ this.querySelector('.k-calendar__day[tabindex="0"]')?.focus();
123
+ }
124
+ get min() {
125
+ return parseDate(this.getAttribute("min"));
126
+ }
127
+ get max() {
128
+ return parseDate(this.getAttribute("max"));
129
+ }
130
+ clamp(d) {
131
+ const min = this.min, max = this.max;
132
+ if (min && d < min)
133
+ return startOfDay(min);
134
+ if (max && d > max)
135
+ return startOfDay(max);
136
+ return d;
137
+ }
138
+ disabled(d) {
139
+ const min = this.min, max = this.max;
140
+ return (!!min && d < min) || (!!max && d > max) || !!this.isDateDisabled?.(d);
141
+ }
142
+ holiday(d) {
143
+ if (this.holidayOf)
144
+ return this.holidayOf(d) || null;
145
+ return bool(this, "holidays") ? getIranHoliday(d) : null;
146
+ }
147
+ go(d, focus = true) {
148
+ this.focused = this.clamp(d);
149
+ this.refocus = focus;
150
+ this.render();
151
+ }
152
+ pick(d) {
153
+ if (this.disabled(d))
154
+ return;
155
+ this.selected = d;
156
+ this.focused = d;
157
+ this.refocus = true;
158
+ this.render();
159
+ syncHidden(this, this.getAttribute("name"), this.value);
160
+ emit(this, "kashi-change", { value: this.value, date: d });
161
+ }
162
+ render() {
163
+ const digits = this.getAttribute("digits") === "latin" ? "latin" : "fa";
164
+ const n = (v) => (digits === "fa" ? toFaDigits(v) : String(v));
165
+ const today = startOfDay(new Date());
166
+ const { jy, jm } = toJalali(this.focused);
167
+ const monthStart = toGregorian(jy, jm, 1);
168
+ const length = jalaliMonthLength(jy, jm);
169
+ const cells = [...Array(jalaliWeekday(monthStart)).fill(null), ...Array.from({ length }, (_, i) => addDays(monthStart, i))];
170
+ while (cells.length % 7)
171
+ cells.push(null);
172
+ const min = this.min, max = this.max;
173
+ const from = min ? toJalali(min).jy : toJalali(today).jy - 100;
174
+ const to = max ? toJalali(max).jy : toJalali(today).jy + 20;
175
+ const years = Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i);
176
+ if (!years.includes(jy))
177
+ years.push(jy);
178
+ years.sort((a, b) => a - b);
179
+ const prevOff = !!min && monthStart <= startOfDay(min);
180
+ const nextOff = !!max && toGregorian(jy, jm, length) >= startOfDay(max);
181
+ const rows = [];
182
+ for (let w = 0; w < cells.length; w += 7) {
183
+ rows.push(`<tr>${cells.slice(w, w + 7).map((d) => {
184
+ if (!d)
185
+ return '<td role="gridcell"></td>';
186
+ const h = this.holiday(d);
187
+ const label = formatJalali(d, { style: "full", digits }) + (h ? `، تعطیل${h === "جمعه" ? "" : `: ${h}`}` : "");
188
+ return `<td role="gridcell" aria-selected="${sameDay(d, this.selected)}"><button type="button" class="k-calendar__day" data-day="${dayKey(d)}" tabindex="${sameDay(d, this.focused) ? 0 : -1}" aria-label="${esc(label)}"${h && h !== "جمعه" ? ` title="${esc(h)}"` : ""}${h ? ' data-holiday=""' : ""}${sameDay(d, today) ? ' aria-current="date"' : ""}${this.disabled(d) ? ' aria-disabled="true"' : ""}>${n(toJalali(d).jd)}</button></td>`;
189
+ }).join("")}</tr>`);
190
+ }
191
+ const todayLabel = this.getAttribute("today-label");
192
+ this.innerHTML = `<div class="k-calendar" role="group" aria-label="${esc(this.getAttribute("label") ?? "تقویم")}">
193
+ <div class="k-calendar__header">
194
+ <button type="button" class="k-btn k-btn--tertiary k-btn--icon k-btn--sm" data-nav="prev" aria-label="ماه قبل"${prevOff ? " disabled" : ""}>${ICONS.prev}</button>
195
+ <div class="k-calendar__selects">
196
+ <select class="k-calendar__select" data-part="month" aria-label="ماه">${JALALI_MONTHS.map((m, i) => `<option value="${i + 1}"${i + 1 === jm ? " selected" : ""}>${m}</option>`).join("")}</select>
197
+ <select class="k-calendar__select" data-part="year" aria-label="سال">${years.map((y) => `<option value="${y}"${y === jy ? " selected" : ""}>${n(y)}</option>`).join("")}</select>
198
+ </div>
199
+ <button type="button" class="k-btn k-btn--tertiary k-btn--icon k-btn--sm" data-nav="next" aria-label="ماه بعد"${nextOff ? " disabled" : ""}>${ICONS.next}</button>
200
+ </div>
201
+ <h2 class="k-sr-only" id="${this.key}-title" aria-live="polite">${JALALI_MONTHS[jm - 1]} ${n(jy)}</h2>
202
+ <table class="k-calendar__grid" role="grid" aria-labelledby="${this.key}-title">
203
+ <thead><tr>${WEEKDAYS_SHORT.map((s, i) => `<th scope="col" abbr="${WEEKDAYS[i]}">${s}</th>`).join("")}</tr></thead>
204
+ <tbody>${rows.join("")}</tbody>
205
+ </table>
206
+ ${todayLabel ? `<div class="k-calendar__footer"><button type="button" class="k-btn k-btn--link" data-today>${esc(todayLabel)}</button></div>` : ""}
207
+ </div>`;
208
+ syncHidden(this, this.getAttribute("name"), this.value);
209
+ if (this.refocus) {
210
+ this.refocus = false;
211
+ this.querySelector(`[data-day="${dayKey(this.focused)}"]`)?.focus();
212
+ }
213
+ }
214
+ }
215
+ KashiCalendar.observedAttributes = ["value", "min", "max", "digits", "holidays", "label", "today-label", "name"];
216
+ /**
217
+ * انتخاب تاریخ شمسی: تایپ مستقیم (۱۴۰۳/۰۵/۱۲) یا انتخاب از تقویم.
218
+ * با name، تاریخ میلادی به شکل YYYY-MM-DD در فرم ارسال می‌شود.
219
+ */
220
+ export class KashiDatePicker extends HTMLBase {
221
+ constructor() {
222
+ super(...arguments);
223
+ this.selected = null;
224
+ this.valueSet = false;
225
+ this.invalid = false;
226
+ this.rendered = false;
227
+ this.key = uid("kashi-date");
228
+ this.pop = null;
229
+ this.onOutside = (e) => {
230
+ if (!this.contains(e.target))
231
+ this.close(false);
232
+ };
233
+ }
234
+ connectedCallback() {
235
+ if (!this.rendered && !this.valueSet)
236
+ this.selected = parseDate(this.getAttribute("value"));
237
+ this.render();
238
+ }
239
+ disconnectedCallback() {
240
+ this.close(false);
241
+ }
242
+ attributeChangedCallback(name, old, next) {
243
+ if (!this.rendered || old === next)
244
+ return;
245
+ if (name === "value") {
246
+ this.selected = parseDate(next);
247
+ this.invalid = false;
248
+ this.paint();
249
+ }
250
+ else
251
+ this.render();
252
+ }
253
+ get value() {
254
+ return this.selected ? toISODate(this.selected) : "";
255
+ }
256
+ set value(v) {
257
+ this.valueSet = true;
258
+ this.selected = parseDate(v ?? "");
259
+ this.invalid = false;
260
+ if (this.rendered)
261
+ this.paint();
262
+ }
263
+ get date() {
264
+ return this.selected;
265
+ }
266
+ set date(d) {
267
+ this.value = d ? toISODate(d) : "";
268
+ }
269
+ focus(options) {
270
+ this.input?.focus(options);
271
+ }
272
+ get digits() {
273
+ return this.getAttribute("digits") === "latin" ? "latin" : "fa";
274
+ }
275
+ fmt(d) {
276
+ return d ? formatJalali(d, { digits: this.digits }) : "";
277
+ }
278
+ render() {
279
+ const id = this.key;
280
+ const ariaLabel = this.getAttribute("aria-label");
281
+ const label = this.hasAttribute("label") ? this.getAttribute("label") : ariaLabel ? null : "تاریخ";
282
+ const required = this.hasAttribute("required");
283
+ const disabled = this.hasAttribute("disabled");
284
+ const placeholder = this.getAttribute("placeholder") ?? (this.digits === "fa" ? "۱۴۰۳/۰۱/۰۱" : "1403/01/01");
285
+ this.close(false);
286
+ this.querySelector(":scope > .k-datepicker")?.remove();
287
+ const control = `<div class="k-input"${disabled ? ' data-state="disabled"' : ""}><input id="${id}" class="k-input__control" type="text" dir="ltr" autocomplete="off" placeholder="${esc(placeholder)}"${required ? " required" : ""}${disabled ? " disabled" : ""}${!label && ariaLabel ? ` aria-label="${esc(ariaLabel)}"` : ""}><span class="k-input__addon"><button type="button" class="k-btn k-btn--tertiary k-btn--icon k-btn--sm k-datepicker__toggle" aria-haspopup="dialog" aria-expanded="false"${disabled ? " disabled" : ""}>${ICONS.calendar}</button></span></div>`;
288
+ this.insertAdjacentHTML("afterbegin", `<div class="k-datepicker">${fieldHTML({ id, label, required, control })}</div>`);
289
+ this.input = this.querySelector("input.k-input__control");
290
+ this.toggle = this.querySelector(".k-datepicker__toggle");
291
+ this.input.addEventListener("blur", () => this.commitText());
292
+ this.input.addEventListener("keydown", (e) => {
293
+ if (e.key === "Enter")
294
+ this.commitText();
295
+ });
296
+ this.toggle.addEventListener("click", () => (this.pop ? this.close() : this.open()));
297
+ this.rendered = true;
298
+ this.paint();
299
+ }
300
+ paint() {
301
+ const text = this.fmt(this.selected);
302
+ if (!this.invalid && this.input.value !== text)
303
+ this.input.value = text;
304
+ const open = this.getAttribute("open-label") ?? "انتخاب تاریخ از تقویم";
305
+ this.toggle.setAttribute("aria-label", this.selected ? `${open}، ${formatJalali(this.selected, { style: "full", digits: this.digits })}` : open);
306
+ const message = this.getAttribute("invalid-message") ?? "این تاریخ معتبر نیست؛ آن را به شکل ۱۴۰۳/۰۵/۱۲ وارد کنید.";
307
+ this.input.setCustomValidity(this.invalid ? message : "");
308
+ setMessages(this.querySelector(".k-field"), this.input, this.key, this.getAttribute("error") || (this.invalid ? message : null), this.getAttribute("hint"));
309
+ syncHidden(this, this.getAttribute("name"), this.value);
310
+ }
311
+ commitText() {
312
+ const t = this.input.value.trim();
313
+ if (!t)
314
+ return this.commit(null);
315
+ const d = parseJalali(t);
316
+ const min = parseDate(this.getAttribute("min")), max = parseDate(this.getAttribute("max"));
317
+ if (d && !(min && d < min) && !(max && d > max) && !this.isDateDisabled?.(d))
318
+ this.commit(d);
319
+ else {
320
+ this.invalid = true;
321
+ this.paint();
322
+ }
323
+ }
324
+ commit(d) {
325
+ const changed = !sameDay(d, this.selected) || (d === null) !== (this.selected === null);
326
+ this.selected = d;
327
+ this.invalid = false;
328
+ this.input.value = this.fmt(d);
329
+ this.paint();
330
+ if (changed)
331
+ emit(this, "kashi-change", { value: this.value, date: d });
332
+ }
333
+ /** باز کردن تقویم */
334
+ open() {
335
+ if (this.pop || this.hasAttribute("disabled"))
336
+ return;
337
+ const wrap = this.querySelector(".k-datepicker");
338
+ const box = this.querySelector(".k-input");
339
+ const pop = document.createElement("div");
340
+ pop.className = "k-datepicker__popover";
341
+ pop.id = `${this.key}-calendar`;
342
+ pop.setAttribute("role", "dialog");
343
+ pop.setAttribute("aria-label", this.getAttribute("open-label") ?? "انتخاب تاریخ از تقویم");
344
+ pop.style.insetBlockStart = `${box.offsetTop + box.offsetHeight + 6}px`;
345
+ const cal = document.createElement("kashi-calendar");
346
+ for (const a of ["min", "max", "digits", "holidays"]) {
347
+ const v = this.getAttribute(a);
348
+ if (v !== null)
349
+ cal.setAttribute(a, v);
350
+ }
351
+ if (this.selected)
352
+ cal.setAttribute("value", toISODate(this.selected));
353
+ cal.setAttribute("today-label", this.getAttribute("today-label") ?? "امروز");
354
+ cal.isDateDisabled = this.isDateDisabled;
355
+ cal.addEventListener("kashi-change", (e) => {
356
+ e.stopPropagation();
357
+ this.commit(e.detail.date);
358
+ this.close();
359
+ });
360
+ pop.addEventListener("keydown", (e) => {
361
+ if (e.key === "Escape") {
362
+ e.stopPropagation();
363
+ this.close();
364
+ }
365
+ });
366
+ pop.addEventListener("focusout", (e) => {
367
+ const next = e.relatedTarget;
368
+ if (next && !this.contains(next))
369
+ this.close(false);
370
+ });
371
+ pop.append(cal);
372
+ wrap.append(pop);
373
+ this.pop = pop;
374
+ this.toggle.setAttribute("aria-expanded", "true");
375
+ this.toggle.setAttribute("aria-controls", pop.id);
376
+ document.addEventListener("pointerdown", this.onOutside);
377
+ cal.focus();
378
+ }
379
+ /** بستن تقویم؛ فوکوس به دکمهٔ تقویم برمی‌گردد */
380
+ close(returnFocus = true) {
381
+ if (!this.pop)
382
+ return;
383
+ this.pop.remove();
384
+ this.pop = null;
385
+ document.removeEventListener("pointerdown", this.onOutside);
386
+ this.toggle.setAttribute("aria-expanded", "false");
387
+ this.toggle.removeAttribute("aria-controls");
388
+ if (returnFocus)
389
+ this.toggle.focus();
390
+ }
391
+ }
392
+ KashiDatePicker.observedAttributes = ["label", "hint", "error", "required", "disabled", "value", "min", "max", "digits", "holidays", "name", "placeholder", "invalid-message", "aria-label"];
393
+ define("kashi-calendar", KashiCalendar, "block");
394
+ define("kashi-date-picker", KashiDatePicker, "block");