@lekoala/date-picker 0.4.0 → 0.5.1

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 (39) hide show
  1. package/README.md +294 -265
  2. package/custom-elements.json +50 -0
  3. package/dist/date-picker.css +20 -15
  4. package/dist/date-picker.js +366 -149
  5. package/dist/date-picker.min.css +1 -1
  6. package/dist/date-picker.min.js +13 -13
  7. package/dist/date-picker.standalone.min.js +34 -29
  8. package/dist/types/control-classes.d.ts +40 -0
  9. package/dist/types/control-classes.d.ts.map +1 -0
  10. package/dist/types/date-calendar.d.ts +35 -43
  11. package/dist/types/date-calendar.d.ts.map +1 -1
  12. package/dist/types/date-picker.d.ts +44 -53
  13. package/dist/types/date-picker.d.ts.map +1 -1
  14. package/dist/types/date.d.ts +4 -2
  15. package/dist/types/date.d.ts.map +1 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/index.d.ts.map +1 -1
  18. package/dist/types/intl.d.ts.map +1 -1
  19. package/dist/types/messages.d.ts +2 -0
  20. package/dist/types/messages.d.ts.map +1 -1
  21. package/dist/types/source.d.ts +1 -0
  22. package/dist/types/source.d.ts.map +1 -1
  23. package/dist/types/upgrade-properties.d.ts +8 -0
  24. package/dist/types/upgrade-properties.d.ts.map +1 -0
  25. package/docs/API.md +442 -408
  26. package/docs/DECISIONS.md +74 -62
  27. package/docs/USE_CASES.md +156 -144
  28. package/package.json +3 -3
  29. package/src/control-classes.js +80 -0
  30. package/src/date-calendar.js +124 -27
  31. package/src/date-field.js +1 -1
  32. package/src/date-picker.css +20 -15
  33. package/src/date-picker.js +133 -74
  34. package/src/date.js +8 -3
  35. package/src/index.js +1 -0
  36. package/src/intl.js +39 -10
  37. package/src/messages.js +12 -0
  38. package/src/source.js +14 -4
  39. package/src/upgrade-properties.js +14 -0
@@ -1,4 +1,13 @@
1
1
  import { CalendarModel } from "./calendar-model.js";
2
+ import {
3
+ getDefaultControlClasses,
4
+ normalizeControlClasses,
5
+ resolveControlClasses,
6
+ sameControlClasses,
7
+ setDefaultControlClasses,
8
+ subscribeDefaultControlClasses,
9
+ usesDefaultControlClasses,
10
+ } from "./control-classes.js";
2
11
  import {
3
12
  addDays,
4
13
  clampDate,
@@ -20,8 +29,9 @@ import {
20
29
  resolveLocale,
21
30
  weekdayNames,
22
31
  } from "./intl.js";
23
- import { getDefaultMessages } from "./messages.js";
32
+ import { getDefaultMessages, setDefaultMessages, subscribeDefaultMessages } from "./messages.js";
24
33
  import { normalizeDateStates } from "./source.js";
34
+ import { upgradeProperties } from "./upgrade-properties.js";
25
35
 
26
36
  let uid = 0;
27
37
 
@@ -127,6 +137,26 @@ export class DateCalendarElement extends HTMLElement {
127
137
  "month-format",
128
138
  ];
129
139
 
140
+ /** @public @param {Partial<import("./control-classes.js").ControlClasses>} classes */
141
+ static setDefaultControlClasses(classes) {
142
+ setDefaultControlClasses(classes);
143
+ }
144
+
145
+ /** @public */
146
+ static getDefaultControlClasses() {
147
+ return getDefaultControlClasses();
148
+ }
149
+
150
+ /** @public @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES>} messages */
151
+ static setDefaultMessages(messages) {
152
+ setDefaultMessages(messages);
153
+ }
154
+
155
+ /** @public */
156
+ static getDefaultMessages() {
157
+ return getDefaultMessages();
158
+ }
159
+
130
160
  constructor() {
131
161
  super();
132
162
  this._id = `date-calendar-${++uid}`;
@@ -155,7 +185,16 @@ export class DateCalendarElement extends HTMLElement {
155
185
  /** @type {DateRange} */
156
186
  this._paintedRange = { start: "", end: "" };
157
187
  this._paintedPreview = false;
158
- this._messages = getDefaultMessages();
188
+ /** @type {Map<string, DateState>} States of the current rendered grid. */
189
+ this._renderedStates = new Map();
190
+ /** @type {typeof import("./messages.js").DEFAULT_MESSAGES | null} */
191
+ this._messages = null;
192
+ /** @type {Partial<import("./control-classes.js").ControlClasses> | null} */
193
+ this._controlClasses = null;
194
+ /** @type {(() => void) | null} */
195
+ this._stopDefaultMessages = null;
196
+ /** @type {(() => void) | null} */
197
+ this._stopDefaultControlClasses = null;
159
198
  this._onClick = this._onClick.bind(this);
160
199
  this._onChange = this._onChange.bind(this);
161
200
  this._onKeyDown = this._onKeyDown.bind(this);
@@ -164,16 +203,46 @@ export class DateCalendarElement extends HTMLElement {
164
203
 
165
204
  connectedCallback() {
166
205
  if (this._connected) return;
206
+ upgradeProperties(this, [
207
+ "locale",
208
+ "min",
209
+ "max",
210
+ "firstDay",
211
+ "selection",
212
+ "monthFormat",
213
+ "fixedWeeks",
214
+ "showWeekNumbers",
215
+ "controlClasses",
216
+ "messages",
217
+ "source",
218
+ "dateState",
219
+ "renderDay",
220
+ "isDateDisabled",
221
+ "highlightedRange",
222
+ "value",
223
+ "display",
224
+ ]);
225
+ this._syncModel();
226
+ upgradeProperties(this, ["focusedDate"]);
227
+ this._stopDefaultControlClasses = subscribeDefaultControlClasses(() => {
228
+ if (usesDefaultControlClasses(this._controlClasses)) this.render();
229
+ });
230
+ this._stopDefaultMessages = subscribeDefaultMessages(() => {
231
+ if (this._messages === null) this.render();
232
+ });
167
233
  this._connected = true;
168
234
  this.addEventListener("click", this._onClick);
169
235
  this.addEventListener("change", this._onChange);
170
236
  this.addEventListener("keydown", this._onKeyDown);
171
237
  this.addEventListener("focusin", this._onFocusIn);
172
- this._syncModel();
173
238
  this.render();
174
239
  }
175
240
 
176
241
  disconnectedCallback() {
242
+ this._stopDefaultControlClasses?.();
243
+ this._stopDefaultControlClasses = null;
244
+ this._stopDefaultMessages?.();
245
+ this._stopDefaultMessages = null;
177
246
  this._connected = false;
178
247
  this._loadController?.abort();
179
248
  this._loadController = null;
@@ -323,13 +392,31 @@ export class DateCalendarElement extends HTMLElement {
323
392
  this.toggleAttribute("show-week-numbers", Boolean(value));
324
393
  }
325
394
 
326
- /** @public */
395
+ /**
396
+ * Author classes for header controls; omitted roles follow current global defaults.
397
+ * @public
398
+ * @returns {import("./control-classes.js").ControlClasses}
399
+ */
400
+ get controlClasses() {
401
+ return resolveControlClasses(this._controlClasses);
402
+ }
403
+
404
+ /** @param {Partial<import("./control-classes.js").ControlClasses> | null} value */
405
+ set controlClasses(value) {
406
+ const previous = this.controlClasses;
407
+ this._controlClasses = normalizeControlClasses(value);
408
+ if (sameControlClasses(previous, this.controlClasses)) return;
409
+ if (this._connected) this.render();
410
+ }
411
+
412
+ /** @public @returns {typeof import("./messages.js").DEFAULT_MESSAGES} */
327
413
  get messages() {
328
- return this._messages;
414
+ return this._messages ?? getDefaultMessages();
329
415
  }
330
416
 
417
+ /** @param {Partial<typeof import("./messages.js").DEFAULT_MESSAGES> | null} value */
331
418
  set messages(value) {
332
- this._messages = { ...getDefaultMessages(), ...(value || {}) };
419
+ this._messages = value == null ? null : { ...getDefaultMessages(), ...value };
333
420
  if (this._connected) this.render();
334
421
  }
335
422
 
@@ -420,16 +507,10 @@ export class DateCalendarElement extends HTMLElement {
420
507
  }
421
508
 
422
509
  _weeks(display = this.display) {
423
- const weeks = getMonthWeeks(clampDisplayMonth(display, this.firstDay), { firstDay: this.firstDay });
424
- if (!this.fixedWeeks || weeks.length >= 6) return weeks;
425
- let cursor = weeks[weeks.length - 1]?.[6] || "";
426
- while (weeks.length < 6 && cursor) {
427
- const week = [];
428
- for (let i = 1; i <= 7; i++) week.push(addDays(cursor, i));
429
- weeks.push(week);
430
- cursor = week[6];
431
- }
432
- return weeks;
510
+ return getMonthWeeks(clampDisplayMonth(display, this.firstDay), {
511
+ firstDay: this.firstDay,
512
+ fixedWeeks: this.fixedWeeks,
513
+ });
433
514
  }
434
515
 
435
516
  /** @param {HTMLElement} element @returns {string} */
@@ -633,8 +714,8 @@ export class DateCalendarElement extends HTMLElement {
633
714
  return [
634
715
  formatLongDate(date, this.locale),
635
716
  description,
636
- rangeLabel ? this._messages[rangeLabel] : "",
637
- state.disabled ? this._messages.unavailable : "",
717
+ rangeLabel ? this.messages[rangeLabel] : "",
718
+ state.disabled ? this.messages.unavailable : "",
638
719
  ]
639
720
  .filter(Boolean)
640
721
  .join(". ");
@@ -647,17 +728,27 @@ export class DateCalendarElement extends HTMLElement {
647
728
  * hover of a live range preview.
648
729
  */
649
730
  _updateRangeDom() {
731
+ const previousRange = this._paintedRange;
732
+ const previousPreview = this._paintedPreview;
650
733
  this._paintedRange = { ...this._highlightedRange };
651
734
  this._paintedPreview = this.hasAttribute("data-range-preview");
652
735
  for (const cell of this.querySelectorAll(".dp-day[data-date]")) {
653
736
  if (!(cell instanceof HTMLTableCellElement)) continue;
654
737
  const date = cell.dataset.date || "";
655
738
  const position = rangePosition(date, this._highlightedRange);
739
+ if (position === rangePosition(date, previousRange) && previousPreview === this._paintedPreview)
740
+ continue;
656
741
  for (const [name, on] of Object.entries(rangeAttributeMap(position))) {
657
742
  if (on) cell.setAttribute(name, "true");
658
743
  else cell.removeAttribute(name);
659
744
  }
660
- cell.setAttribute("aria-label", this._dayLabel(date, this.getDateState(date), position));
745
+ // A moving preview changes only the band. Its accessible labels always
746
+ // omit range roles; entering/leaving preview still updates those labels.
747
+ if (!previousPreview || !this._paintedPreview) {
748
+ const state = this._renderedStates.get(date) || {};
749
+ const label = this._dayLabel(date, state, position);
750
+ if (cell.getAttribute("aria-label") !== label) cell.setAttribute("aria-label", label);
751
+ }
661
752
  }
662
753
  }
663
754
 
@@ -828,7 +919,7 @@ export class DateCalendarElement extends HTMLElement {
828
919
  const maxYearAttr = this.max ? ` max="${Number(this.max.slice(0, 4))}"` : "";
829
920
  const showWeeks = this.showWeekNumbers && this.firstDay === 1;
830
921
  const weekHeader = showWeeks
831
- ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this._messages.week)}">#</th>`
922
+ ? `<th scope="col" class="dp-week-heading" abbr="${escapeHtml(this.messages.week)}">#</th>`
832
923
  : "";
833
924
 
834
925
  const monthOptions = names
@@ -847,6 +938,7 @@ export class DateCalendarElement extends HTMLElement {
847
938
  )
848
939
  .join("");
849
940
 
941
+ this._renderedStates.clear();
850
942
  const rows = weeks
851
943
  .map((week) => {
852
944
  const weekNumber = showWeeks
@@ -855,6 +947,7 @@ export class DateCalendarElement extends HTMLElement {
855
947
  const cells = week
856
948
  .map((date) => {
857
949
  const state = this.getDateState(date);
950
+ this._renderedStates.set(date, state);
858
951
  const outside = monthKey(date) !== display;
859
952
  const selected = this.value === date;
860
953
  const focused = this.focusedDate === date;
@@ -879,10 +972,14 @@ export class DateCalendarElement extends HTMLElement {
879
972
  // select via `data-year-first`.
880
973
  const yearFirst = monthYearOrder(locale)[0] === "year";
881
974
  this.toggleAttribute("data-year-first", yearFirst);
882
- const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this._messages.month)}</label>
883
- <select id="${this._id}-month" class="dp-month-select" aria-label="${escapeHtml(this._messages.month)}">${monthOptions}</select>`;
884
- const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this._messages.year)}</label>
885
- <input id="${this._id}-year" class="dp-year-input" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this._messages.year)}">`;
975
+ const classes = this.controlClasses;
976
+ const monthClass = escapeHtml(classes.month || "dp-skin");
977
+ const yearClass = escapeHtml(classes.year || "dp-skin");
978
+ const navClass = escapeHtml(classes.nav || "dp-skin");
979
+ const monthControl = `<label class="dp-visually-hidden" for="${this._id}-month">${escapeHtml(this.messages.month)}</label>
980
+ <select id="${this._id}-month" class="dp-month-select ${monthClass}" aria-label="${escapeHtml(this.messages.month)}">${monthOptions}</select>`;
981
+ const yearControl = `<label class="dp-visually-hidden" for="${this._id}-year">${escapeHtml(this.messages.year)}</label>
982
+ <input id="${this._id}-year" class="dp-year-input ${yearClass}" type="number" inputmode="numeric" value="${displayYear}"${minYearAttr}${maxYearAttr} aria-label="${escapeHtml(this.messages.year)}">`;
886
983
 
887
984
  this._rendering = true;
888
985
  this.innerHTML = `
@@ -890,8 +987,8 @@ export class DateCalendarElement extends HTMLElement {
890
987
  <div class="dp-calendar-header">
891
988
  ${yearFirst ? yearControl : monthControl}
892
989
  ${yearFirst ? monthControl : yearControl}
893
- <button type="button" class="dp-nav dp-prev" data-calendar-action="previous" aria-label="${escapeHtml(this._messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 10 4-4 4 4"/></svg></button>
894
- <button type="button" class="dp-nav dp-next" data-calendar-action="next" aria-label="${escapeHtml(this._messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 6 4 4 4-4"/></svg></button>
990
+ <button type="button" class="dp-nav dp-prev ${navClass}" data-calendar-action="previous" aria-label="${escapeHtml(this.messages.previousMonth)}"${prevDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 10 4-4 4 4"/></svg></button>
991
+ <button type="button" class="dp-nav dp-next ${navClass}" data-calendar-action="next" aria-label="${escapeHtml(this.messages.nextMonth)}"${nextDisabled ? " disabled" : ""}><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 6 4 4 4-4"/></svg></button>
895
992
  </div>
896
993
  <h2 id="${headingId}" class="dp-calendar-heading dp-visually-hidden" aria-live="polite">${escapeHtml(formatMonthYear(`${display}-15`, locale))}</h2>
897
994
  <table id="${gridId}" class="dp-grid" role="grid" aria-labelledby="${headingId}">
@@ -925,7 +1022,7 @@ export class DateCalendarElement extends HTMLElement {
925
1022
  const date = cell.dataset.date || "";
926
1023
  const extra = cell.querySelector(".dp-day-extra");
927
1024
  if (!(extra instanceof HTMLElement)) continue;
928
- const content = this._renderDay(date, this.getDateState(date));
1025
+ const content = this._renderDay(date, this._renderedStates.get(date) || {});
929
1026
  if (content instanceof Node) extra.append(content);
930
1027
  else if (content !== undefined && content !== null) extra.textContent = String(content);
931
1028
  }
package/src/date-field.js CHANGED
@@ -138,7 +138,7 @@ export class DateFieldController {
138
138
  input.setCustomValidity("");
139
139
  return;
140
140
  }
141
- input.setCustomValidity(text === canonical ? "" : this.messages.invalidDate);
141
+ input.setCustomValidity(this.messages.invalidDate);
142
142
  }
143
143
 
144
144
  /**
@@ -46,7 +46,7 @@ date-calendar {
46
46
 
47
47
  .dp-calendar-header {
48
48
  display: grid;
49
- grid-template-columns: minmax(0, 1fr) auto auto auto;
49
+ grid-template-columns: minmax(0, 1fr) var(--dp-year-inline-size) auto auto;
50
50
  /* Stretch (not center): once a UA natural height exceeds
51
51
  --dp-header-control-size, the row grows to the tallest control and the
52
52
  others stretch to match, so month/year/nav stay equal. */
@@ -62,12 +62,16 @@ date-calendar {
62
62
  track stays under the month select so the fixed-width year input never
63
63
  stretches. Visual mirroring itself is left to the inherited `dir`. */
64
64
  date-calendar[data-year-first] .dp-calendar-header {
65
- grid-template-columns: auto minmax(0, 1fr) auto auto;
65
+ grid-template-columns: var(--dp-year-inline-size) minmax(0, 1fr) auto auto;
66
66
  }
67
67
 
68
68
  .dp-calendar-header :where(button, select, input) {
69
- min-block-size: var(--dp-header-control-size);
70
69
  box-sizing: border-box;
70
+ min-inline-size: 0;
71
+ }
72
+
73
+ .dp-calendar-header .dp-skin {
74
+ min-block-size: var(--dp-header-control-size);
71
75
  /* Shared vertical metrics: UA default padding/line-height differ per control
72
76
  (up to ~13px apart), which only shows once --dp-header-control-size drops
73
77
  below content size. */
@@ -80,8 +84,7 @@ date-calendar[data-year-first] .dp-calendar-header {
80
84
  line-height: 1.2;
81
85
  }
82
86
 
83
- .dp-calendar-header input {
84
- min-inline-size: 0;
87
+ .dp-calendar-header input.dp-skin {
85
88
  padding-inline: 0.45rem;
86
89
  }
87
90
 
@@ -91,11 +94,13 @@ date-calendar[data-year-first] .dp-calendar-header {
91
94
  control and the others stretch to match. */
92
95
  .dp-month-select {
93
96
  inline-size: 100%;
94
- min-inline-size: 0;
95
97
  /* Long month names in a narrow track (compact consumers, long locales)
96
98
  would paint under the caret: truncate with an ellipsis instead. */
97
99
  overflow: hidden;
98
100
  text-overflow: ellipsis;
101
+ }
102
+
103
+ .dp-month-select.dp-skin {
99
104
  appearance: none;
100
105
  padding-inline: 0.45rem 2rem;
101
106
  background-image:
@@ -111,18 +116,18 @@ date-calendar[data-year-first] .dp-calendar-header {
111
116
  /* Inherited RTL mirrors the header layout via the grid. The logical
112
117
  `padding-inline` already flips, so only the physically-positioned caret has
113
118
  to follow it to the other side. */
114
- [dir="rtl"] .dp-month-select,
115
- html[dir="rtl"] .dp-month-select {
119
+ [dir="rtl"] .dp-month-select.dp-skin,
120
+ html[dir="rtl"] .dp-month-select.dp-skin {
116
121
  background-position:
117
122
  0.9rem 50%,
118
123
  0.65rem 50%;
119
124
  }
120
125
 
121
126
  .dp-year-input {
122
- inline-size: var(--dp-year-inline-size);
127
+ inline-size: 100%;
123
128
  }
124
129
 
125
- .dp-nav {
130
+ .dp-nav.dp-skin {
126
131
  display: grid;
127
132
  place-items: center;
128
133
  inline-size: var(--dp-header-control-size);
@@ -139,19 +144,19 @@ html[dir="rtl"] .dp-month-select {
139
144
  pointer-events: none;
140
145
  }
141
146
 
142
- .dp-calendar-header :where(button, select, input):disabled,
147
+ .dp-calendar-header .dp-skin:disabled,
143
148
  .dp-picker-button:disabled {
144
149
  cursor: not-allowed;
145
150
  opacity: 0.45;
146
151
  }
147
152
 
148
- .dp-nav:hover:not(:disabled) {
153
+ .dp-nav.dp-skin:hover:not(:disabled) {
149
154
  background: var(--dp-hover);
150
155
  }
151
156
 
152
157
  /* Inner outlines survive forced colors and keep focus inside each control.
153
158
  The picker trigger uses a tighter inset around its icon below. */
154
- .dp-calendar-header :where(button, select, input):focus-visible,
159
+ .dp-calendar-header .dp-skin:focus-visible,
155
160
  date-picker > input:not([type="hidden"]):focus-visible,
156
161
  .dp-day:focus-visible {
157
162
  outline: var(--dp-focus-width) solid var(--dp-focus);
@@ -446,8 +451,8 @@ date-picker[range] > .dp-picker-button[data-endpoint="end"] {
446
451
  @media (forced-colors: active) {
447
452
  /* The author-drawn caret is a gradient and disappears here: hand the
448
453
  control back to the browser instead of trying to rebuild it. */
449
- .dp-month-select,
450
- [dir="rtl"] .dp-month-select {
454
+ .dp-month-select.dp-skin,
455
+ [dir="rtl"] .dp-month-select.dp-skin {
451
456
  appearance: auto;
452
457
  background-image: none;
453
458
  padding-inline: 0.45rem;