@nectary/components 0.43.0 → 0.44.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.
@@ -1,4 +1,4 @@
1
- import { defineCustomElement, getAttribute, getBooleanAttribute, getCsvSet, getFirstCsvValue, getReactEventHandler, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv } from '../utils';
1
+ import { defineCustomElement, getAttribute, getBooleanAttribute, unpackCsv, getFirstCsvValue, getReactEventHandler, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv } from '../utils';
2
2
  const templateHTML = '<style>:host{display:block}#wrapper{display:flex;flex-direction:column;box-sizing:border-box;width:100%;height:100%}::slotted(sinch-accordion-item){flex-shrink:1}</style><div id="wrapper"><slot></slot></div>';
3
3
  const template = document.createElement('template');
4
4
  template.innerHTML = templateHTML;
@@ -69,9 +69,9 @@ defineCustomElement('sinch-accordion', class extends NectaryElement {
69
69
  };
70
70
  #onValueChange(csv) {
71
71
  if (this.multiple) {
72
- const values = getCsvSet(csv);
72
+ const values = unpackCsv(csv);
73
73
  for (const $option of this.#$slot.assignedElements()) {
74
- const isChecked = !getBooleanAttribute($option, 'disabled') && values.has(getAttribute($option, 'value', ''));
74
+ const isChecked = !getBooleanAttribute($option, 'disabled') && values.includes(getAttribute($option, 'value', ''));
75
75
  updateBooleanAttribute($option, 'data-checked', isChecked);
76
76
  }
77
77
  } else {
@@ -3,7 +3,7 @@ import '../tooltip';
3
3
  import '../icons/check';
4
4
  import { getSwatchColorFg } from '../color-swatch/utils';
5
5
  import { lightColorNames, darkColorNames, vibrantColorNames } from '../theme/colors';
6
- import { attrValueToPixels, defineCustomElement, dispatchContextConnectEvent, dispatchContextDisconnectEvent, getAttribute, getBooleanAttribute, getCsvSet, getIntegerAttribute, getReactEventHandler, getRect, NectaryElement, updateAttribute, updateBooleanAttribute, updateExplicitBooleanAttribute, updateIntegerAttribute } from '../utils';
6
+ import { attrValueToPixels, defineCustomElement, dispatchContextConnectEvent, dispatchContextDisconnectEvent, getAttribute, getBooleanAttribute, unpackCsv, getIntegerAttribute, getReactEventHandler, getRect, NectaryElement, updateAttribute, updateBooleanAttribute, updateExplicitBooleanAttribute, updateIntegerAttribute } from '../utils';
7
7
  const optionTemplateHTML = '<div class="option" role="option"><sinch-tooltip inverted class="tooltip"><div class="swatch-wrapper"><sinch-color-swatch class="swatch"></sinch-color-swatch><sinch-icon-check class="check"></sinch-icon-check></div></sinch-tooltip></div>';
8
8
  const templateHTML = '<style>:host{display:block;outline:0}#listbox{display:flex;flex-direction:row;flex-wrap:wrap;padding:4px 10px;overflow-y:auto}#listbox:empty{display:none}.option{padding:12px 6px;--sinch-color-icon:var(--sinch-color-stormy-500)}.swatch-wrapper{position:relative;cursor:pointer;width:32px;height:32px}.swatch-wrapper::after{content:"";position:absolute;width:34px;height:34px;inset:-3px;border:2px solid transparent;border-radius:50%;pointer-events:none}.option[data-selected]:not([data-checked]) .swatch-wrapper::after{border-color:var(--sinch-color-border-focus)}.check{display:none;position:absolute;left:4px;top:4px;pointer-events:none;--sinch-size-icon:24px}.option[data-checked] .check{display:block}</style><div id="listbox" role="presentation"></div>';
9
9
  import { getParentOption } from './utils';
@@ -137,7 +137,7 @@ defineCustomElement('sinch-color-menu', class extends NectaryElement {
137
137
  return;
138
138
  }
139
139
  this.#prevColorsValue = colorsValue;
140
- const colorNames = getCsvSet(colorsValue ?? `${lightColorNames},${vibrantColorNames},${darkColorNames}`);
140
+ const colorNames = unpackCsv(colorsValue ?? `${lightColorNames},${vibrantColorNames},${darkColorNames}`);
141
141
  const fragment = document.createDocumentFragment();
142
142
  for (const color of colorNames) {
143
143
  if (color.length === 0) {
@@ -6,9 +6,9 @@ import '../icons/keyboard-double-arrow-left';
6
6
  import '../icons/delete-outline';
7
7
  import '../icons/today';
8
8
  import '../text';
9
- import { defineCustomElement, getAttribute, getReactEventHandler, getRect, NectaryElement, setClass, updateAttribute, updateBooleanAttribute } from '../utils';
10
- const templateHTML = '<style>:host{display:block;outline:0}#content{width:fit-content;box-sizing:border-box;padding:16px;display:flex;flex-direction:column;gap:8px}#month{display:flex;flex-direction:column;row-gap:8px}.week{display:flex;flex-direction:row;column-gap:8px}.week.empty{display:none}.day{all:initial;font:var(--sinch-font-text-xs);color:var(--sinch-color-text-default);text-align:center;border-radius:var(--sinch-shape-radius-m);width:24px;height:24px;line-height:22px;cursor:pointer;border:1px solid transparent;background-color:transparent;box-sizing:border-box;user-select:none}.day.today{border:1px solid var(--sinch-color-stormy-500)}.day:disabled{cursor:initial;color:var(--sinch-color-snow-700)}.day:focus-visible{outline:1px solid var(--sinch-color-border-focus);outline-offset:1px}@supports not selector(:focus-visible){.day:focus{outline:1px solid var(--sinch-color-border-focus);outline-offset:1px}}.day.selected{background-color:var(--sinch-color-stormy-500);color:var(--sinch-color-snow-100)}.day:hover:not(:disabled):not(.selected){background-color:var(--sinch-color-snow-600)}#week-day-names{display:flex;flex-direction:row;gap:8px;height:24px}.week-day-name{font:var(--sinch-font-text-xs);font-weight:var(--sinch-font-weight-emphasized);color:var(--sinch-color-text-default);text-align:center;width:24px;height:24px;line-height:24px;user-select:none;text-transform:uppercase}#content-header{display:flex;flex-direction:row;height:32px;align-items:center}#date{flex:1;text-align:center;text-transform:capitalize}#prev-year{margin-left:-4px}#next-year{margin-right:-4px}</style><div id="content"><div id="content-header"><sinch-icon-button id="prev-year" small><sinch-icon-keyboard-double-arrow-left slot="icon"></sinch-icon-keyboard-double-arrow-left></sinch-icon-button><sinch-icon-button id="prev-month" small><sinch-icon-keyboard-arrow-left slot="icon"></sinch-icon-keyboard-arrow-left></sinch-icon-button><sinch-text id="date" type="m" emphasized aria-live="polite"></sinch-text><sinch-icon-button id="next-month" small><sinch-icon-keyboard-arrow-right slot="icon"></sinch-icon-keyboard-arrow-right></sinch-icon-button><sinch-icon-button id="next-year" small><sinch-icon-keyboard-double-arrow-right slot="icon"></sinch-icon-keyboard-double-arrow-right></sinch-icon-button></div><div id="week-day-names"><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div></div><div id="month"><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div></div></div>';
11
- import { areDatesEqual, assertDate, assertLocale, assertMinMax, assertValue, canGoNextMonth, canGoNextYear, canGoPrevMonth, canGoPrevYear, clampMaxDate, clampMinDate, dateToIso, decMonth, decYear, getCalendarMonth, getDayNames, getMonthNames, incMonth, incYear, isDateBetween, isoToDate, isValidDate, today } from './utils';
9
+ import { defineCustomElement, getAttribute, getBooleanAttribute, getReactEventHandler, getRect, isAttrTrue, NectaryElement, packCsv, setClass, unpackCsv, updateAttribute, updateBooleanAttribute } from '../utils';
10
+ const templateHTML = '<style>:host{display:block;outline:0}#content{width:fit-content;box-sizing:border-box;padding:16px;display:flex;flex-direction:column;gap:8px}#month{display:flex;flex-direction:column;row-gap:8px}.week{display:flex;flex-direction:row;column-gap:8px}.week.empty{display:none}.day{all:initial;font:var(--sinch-font-text-xs);color:var(--sinch-color-text-default);text-align:center;border-radius:var(--sinch-shape-radius-m);width:24px;height:24px;line-height:22px;cursor:pointer;border:1px solid transparent;background-color:transparent;box-sizing:border-box;user-select:none}.day.today{border:1px solid var(--sinch-color-tropical-500)}.day:disabled{cursor:initial;color:var(--sinch-color-snow-700)}.day:focus-visible{outline:1px solid var(--sinch-color-border-focus);outline-offset:1px}@supports not selector(:focus-visible){.day:focus{outline:1px solid var(--sinch-color-border-focus);outline-offset:1px}}.day.range{background-color:var(--sinch-color-tropical-100)}.day.selected{background-color:var(--sinch-color-tropical-500);color:var(--sinch-color-snow-100)}.day:hover:enabled:not(.selected){background-color:var(--sinch-color-tropical-200)}#week-day-names{display:flex;flex-direction:row;gap:8px;height:24px}.week-day-name{font:var(--sinch-font-text-xs);font-weight:var(--sinch-font-weight-emphasized);color:var(--sinch-color-text-default);text-align:center;width:24px;height:24px;line-height:24px;user-select:none;text-transform:uppercase}#content-header{display:flex;flex-direction:row;height:32px;align-items:center}#date{flex:1;text-align:center;text-transform:capitalize}#prev-year{margin-left:-4px}#next-year{margin-right:-4px}</style><div id="content"><div id="content-header"><sinch-icon-button id="prev-year" small><sinch-icon-keyboard-double-arrow-left slot="icon"></sinch-icon-keyboard-double-arrow-left></sinch-icon-button><sinch-icon-button id="prev-month" small><sinch-icon-keyboard-arrow-left slot="icon"></sinch-icon-keyboard-arrow-left></sinch-icon-button><sinch-text id="date" type="m" emphasized aria-live="polite"></sinch-text><sinch-icon-button id="next-month" small><sinch-icon-keyboard-arrow-right slot="icon"></sinch-icon-keyboard-arrow-right></sinch-icon-button><sinch-icon-button id="next-year" small><sinch-icon-keyboard-double-arrow-right slot="icon"></sinch-icon-keyboard-double-arrow-right></sinch-icon-button></div><div id="week-day-names"><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div><div class="week-day-name"></div></div><div id="month"><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div><div class="week"><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button><button class="day"></button></div></div></div>';
11
+ import { areDatesEqual, assertDate, assertLocale, assertMinMax, assertValue, canGoNextMonth, canGoNextYear, canGoPrevMonth, canGoPrevYear, clampMaxDate, clampMinDate, cloneDate, dateToIso, decMonth, decYear, getCalendarMonth, getDayNames, getMonthNames, incMonth, incYear, isDateBetween, isoToDate, isValidDate, sortDates, today } from './utils';
12
12
  const template = document.createElement('template');
13
13
  template.innerHTML = templateHTML;
14
14
  defineCustomElement('sinch-date-picker', class extends NectaryElement {
@@ -16,7 +16,9 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
16
16
  #$weeks;
17
17
  #$days;
18
18
  #$weekDayNames;
19
- #date = null;
19
+ #uiDate = null;
20
+ #date1 = null;
21
+ #date2 = null;
20
22
  #minDate = null;
21
23
  #maxDate = null;
22
24
  #$prevMonth;
@@ -25,6 +27,8 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
25
27
  #$nextYear;
26
28
  #$date;
27
29
  #monthNames;
30
+ #controller = null;
31
+ #isHoverSubscribed = false;
28
32
  constructor() {
29
33
  super();
30
34
  const shadowRoot = this.attachShadow();
@@ -48,25 +52,23 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
48
52
  this.#$weekDayNames = Array.from(shadowRoot.querySelectorAll('#week-day-names > .week-day-name'));
49
53
  }
50
54
  connectedCallback() {
51
- this.#$prevMonth.addEventListener('click', this.#onPrevMonthClick);
52
- this.#$nextMonth.addEventListener('click', this.#onNextMonthClick);
53
- this.#$prevYear.addEventListener('click', this.#onPrevYearClick);
54
- this.#$nextYear.addEventListener('click', this.#onNextYearClick);
55
- this.#$month.addEventListener('click', this.#onDateClick);
56
- this.addEventListener('-change', this.#onChangeReactHandler);
57
-
55
+ this.#controller = new AbortController();
56
+ const options = {
57
+ signal: this.#controller.signal
58
+ };
59
+ this.#$prevMonth.addEventListener('click', this.#onPrevMonthClick, options);
60
+ this.#$nextMonth.addEventListener('click', this.#onNextMonthClick, options);
61
+ this.#$prevYear.addEventListener('click', this.#onPrevYearClick, options);
62
+ this.#$nextYear.addEventListener('click', this.#onNextYearClick, options);
63
+ this.#$month.addEventListener('click', this.#onDateClick, options);
64
+ this.addEventListener('-change', this.#onChangeReactHandler, options);
58
65
  }
59
-
60
66
  disconnectedCallback() {
61
- this.#$prevMonth.removeEventListener('click', this.#onPrevMonthClick);
62
- this.#$nextMonth.removeEventListener('click', this.#onNextMonthClick);
63
- this.#$prevYear.removeEventListener('click', this.#onPrevYearClick);
64
- this.#$nextYear.removeEventListener('click', this.#onNextYearClick);
65
- this.#$month.removeEventListener('click', this.#onDateClick);
66
- this.removeEventListener('-change', this.#onChangeReactHandler);
67
+ this.#controller.abort();
68
+ this.#unsubscribeRangeHover();
67
69
  }
68
70
  static get observedAttributes() {
69
- return ['value', 'min', 'max', 'locale', 'prev-year-aria-label', 'next-year-aria-label', 'prev-month-aria-label', 'next-month-aria-label'];
71
+ return ['value', 'min', 'max', 'locale', 'range', 'prev-year-aria-label', 'next-year-aria-label', 'prev-month-aria-label', 'next-month-aria-label'];
70
72
  }
71
73
  attributeChangedCallback(name, prevValue, newVal) {
72
74
  if (newVal === prevValue) {
@@ -76,19 +78,7 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
76
78
  case 'value':
77
79
  {
78
80
  assertValue(newVal);
79
- this.#date = newVal.length > 0 ? isoToDate(newVal) : today();
80
- if (!isValidDate(this.#date)) {
81
- this.#date = today();
82
- }
83
-
84
- if (this.#minDate !== null) {
85
- clampMinDate(this.#date, this.#minDate);
86
- }
87
-
88
- if (this.#maxDate !== null) {
89
- clampMaxDate(this.#date, this.#maxDate);
90
- }
91
- this.#render();
81
+ this.#onValueChange();
92
82
  break;
93
83
  }
94
84
  case 'min':
@@ -97,8 +87,8 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
97
87
  this.#minDate = isoToDate(newVal);
98
88
  assertDate(this.#minDate, name, newVal);
99
89
 
100
- if (this.#date !== null) {
101
- clampMinDate(this.#date, this.#minDate);
90
+ if (this.#uiDate !== null) {
91
+ clampMinDate(this.#uiDate, this.#minDate);
102
92
  }
103
93
  this.#render();
104
94
  break;
@@ -109,8 +99,8 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
109
99
  this.#maxDate = isoToDate(newVal);
110
100
  assertDate(this.#maxDate, name, newVal);
111
101
 
112
- if (this.#date !== null) {
113
- clampMaxDate(this.#date, this.#maxDate);
102
+ if (this.#uiDate !== null) {
103
+ clampMaxDate(this.#uiDate, this.#maxDate);
114
104
  }
115
105
  this.#render();
116
106
  break;
@@ -126,6 +116,16 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
126
116
  this.#render();
127
117
  break;
128
118
  }
119
+ case 'range':
120
+ {
121
+ const isRange = isAttrTrue(newVal);
122
+ if (isRange) {
123
+ this.#onValueChange();
124
+ } else {
125
+ this.#unsubscribeRangeHover();
126
+ }
127
+ break;
128
+ }
129
129
  case 'prev-year-aria-label':
130
130
  {
131
131
  updateAttribute(this.#$prevYear, 'aria-label', newVal);
@@ -155,7 +155,7 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
155
155
  updateAttribute(this, 'locale', value);
156
156
  }
157
157
  get locale() {
158
- return getAttribute(this, 'locale');
158
+ return getAttribute(this, 'locale', '');
159
159
  }
160
160
  set value(value) {
161
161
  updateAttribute(this, 'value', value);
@@ -175,6 +175,12 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
175
175
  get max() {
176
176
  return getAttribute(this, 'max', '');
177
177
  }
178
+ set range(isRanged) {
179
+ updateBooleanAttribute(this, 'range', isRanged);
180
+ }
181
+ get range() {
182
+ return getBooleanAttribute(this, 'range');
183
+ }
178
184
  set prevMonthAriaLabel(value) {
179
185
  updateAttribute(this, 'prev-month-aria-label', value);
180
186
  }
@@ -217,50 +223,148 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
217
223
  }
218
224
  #onPrevMonthClick = e => {
219
225
  e.stopPropagation();
220
- decMonth(this.#date, this.#minDate);
226
+ decMonth(this.#uiDate, this.#minDate);
221
227
  this.#render();
222
228
  };
223
229
  #onNextMonthClick = e => {
224
230
  e.stopPropagation();
225
- incMonth(this.#date, this.#maxDate);
231
+ incMonth(this.#uiDate, this.#maxDate);
226
232
  this.#render();
227
233
  };
228
234
  #onPrevYearClick = e => {
229
235
  e.stopPropagation();
230
- decYear(this.#date, this.#minDate);
236
+ decYear(this.#uiDate, this.#minDate);
231
237
  this.#render();
232
238
  };
233
239
  #onNextYearClick = e => {
234
240
  e.stopPropagation();
235
- incYear(this.#date, this.#maxDate);
241
+ incYear(this.#uiDate, this.#maxDate);
236
242
  this.#render();
237
243
  };
244
+ #onDateMouseEnter = e => {
245
+ if (this.#date1 !== null && this.#date2 === null) {
246
+ const hoverDateIso = e.target.getAttribute('data-date');
247
+ if (hoverDateIso === null) {
248
+ return;
249
+ }
250
+ const hoverDate = isoToDate(hoverDateIso);
251
+ const todayDate = today();
252
+ for (const week of this.#$days) {
253
+ for (const $day of week) {
254
+ if ($day.hasAttribute('disabled')) {
255
+ continue;
256
+ }
257
+ const dayDate = isoToDate($day.getAttribute('data-date'));
258
+ setClass($day, 'range', !areDatesEqual(todayDate, dayDate) && (isDateBetween(dayDate, this.#date1, hoverDate) || isDateBetween(dayDate, hoverDate, this.#date1)));
259
+ }
260
+ }
261
+ }
262
+ };
238
263
  #onDateClick = e => {
239
264
  e.stopPropagation();
240
265
  const dateIso = e.target.getAttribute('data-date');
241
266
  if (dateIso === null || dateIso.length === 0) {
242
267
  return;
243
268
  }
244
- this.#dispatchChangeEvent(dateIso);
269
+ if (this.range) {
270
+ if (this.#date1 !== null && this.#date2 === null) {
271
+ const date2 = isoToDate(dateIso);
272
+ if (areDatesEqual(this.#date1, date2)) {
273
+ return;
274
+ }
275
+ const dateTuple = sortDates([this.#date1, date2]);
276
+ const value = packCsv(dateTuple.map(dateToIso));
277
+ this.#date1 = dateTuple[0];
278
+ this.#date2 = dateTuple[1];
279
+ this.#unsubscribeRangeHover();
280
+ this.#render();
281
+ this.dispatchEvent(new CustomEvent('change', {
282
+ detail: value,
283
+ bubbles: true
284
+ }));
285
+ this.dispatchEvent(new CustomEvent('-change', {
286
+ detail: value
287
+ }));
288
+ return;
289
+ }
290
+ this.#date1 = isoToDate(dateIso);
291
+ this.#date2 = null;
292
+ this.#subscribeRangeHover();
293
+ this.#render();
294
+ return;
295
+ }
296
+
297
+ this.dispatchEvent(new CustomEvent('change', {
298
+ detail: dateIso,
299
+ bubbles: true
300
+ }));
301
+ this.dispatchEvent(new CustomEvent('-change', {
302
+ detail: dateIso
303
+ }));
245
304
  };
305
+ #onValueChange() {
306
+ const value = this.value;
307
+ this.#date1 = null;
308
+ this.#date2 = null;
309
+ if (this.range) {
310
+ const isoDates = unpackCsv(value);
311
+ if (isoDates.length === 2) {
312
+ const date1 = isoToDate(isoDates[0]);
313
+ const date2 = isoToDate(isoDates[1]);
314
+ if (isValidDate(date1) && isValidDate(date2)) {
315
+ this.#date1 = date1;
316
+ this.#date2 = date2;
317
+
318
+ if (this.#uiDate === null) {
319
+ this.#uiDate = cloneDate(this.#date2);
320
+ }
321
+ }
322
+ } else if (isoDates.length === 1) {
323
+ const date1 = isoToDate(isoDates[0]);
324
+ if (isValidDate(date1)) {
325
+ this.#uiDate = date1;
326
+ }
327
+ }
328
+ } else {
329
+ const valueDate = isoToDate(value);
330
+ if (isValidDate(valueDate)) {
331
+ this.#date1 = valueDate;
332
+ this.#uiDate = cloneDate(this.#date1);
333
+ }
334
+ }
335
+ if (this.#uiDate === null) {
336
+ this.#uiDate = today();
337
+ }
338
+
339
+ if (this.#minDate !== null) {
340
+ clampMinDate(this.#uiDate, this.#minDate);
341
+ }
342
+
343
+ if (this.#maxDate !== null) {
344
+ clampMaxDate(this.#uiDate, this.#maxDate);
345
+ }
346
+ this.#render();
347
+ }
246
348
  #render() {
247
- if (this.#date === null || this.#minDate === null || this.#maxDate === null || this.locale === null) {
349
+ if (this.#uiDate === null || this.#minDate === null || this.#maxDate === null || this.locale === null) {
248
350
  return;
249
351
  }
250
- const valueDate = isoToDate(this.value);
251
- const todayDate = new Date();
252
- const month = getCalendarMonth(this.#date);
253
- updateBooleanAttribute(this.#$prevMonth, 'disabled', canGoPrevMonth(this.#date, this.#minDate) === false);
254
- updateBooleanAttribute(this.#$nextMonth, 'disabled', canGoNextMonth(this.#date, this.#maxDate) === false);
255
- updateBooleanAttribute(this.#$prevYear, 'disabled', canGoPrevYear(this.#date, this.#minDate) === false);
256
- updateBooleanAttribute(this.#$nextYear, 'disabled', canGoNextYear(this.#date, this.#maxDate) === false);
257
- this.#$date.textContent = `${this.#monthNames[this.#date.getMonth()]} ${this.#date.getFullYear()}`;
258
- this.#$days.forEach(($week, wi) => {
352
+ const todayDate = today();
353
+ const month = getCalendarMonth(this.#uiDate);
354
+ updateBooleanAttribute(this.#$prevMonth, 'disabled', canGoPrevMonth(this.#uiDate, this.#minDate) === false);
355
+ updateBooleanAttribute(this.#$nextMonth, 'disabled', canGoNextMonth(this.#uiDate, this.#maxDate) === false);
356
+ updateBooleanAttribute(this.#$prevYear, 'disabled', canGoPrevYear(this.#uiDate, this.#minDate) === false);
357
+ updateBooleanAttribute(this.#$nextYear, 'disabled', canGoNextYear(this.#uiDate, this.#maxDate) === false);
358
+ this.#$date.textContent = `${this.#monthNames[this.#uiDate.getMonth()]} ${this.#uiDate.getFullYear()}`;
359
+ for (let wi = 0; wi < this.#$days.length; wi++) {
360
+ const $week = this.#$days[wi];
259
361
  let isEmptyWeek = true;
260
- $week.forEach(($day, di) => {
362
+ for (let di = 0; di < $week.length; di++) {
363
+ const $day = $week[di];
261
364
  const week = month[wi];
262
365
  const day = week?.[di];
263
366
  $day.classList.remove('selected');
367
+ $day.classList.remove('range');
264
368
  $day.classList.remove('today');
265
369
  if (day == null) {
266
370
  $day.textContent = '';
@@ -278,25 +382,30 @@ defineCustomElement('sinch-date-picker', class extends NectaryElement {
278
382
  $day.setAttribute('disabled', '');
279
383
  $day.setAttribute('aria-hidden', 'true');
280
384
  }
281
- if (areDatesEqual(day, valueDate)) {
385
+ if (areDatesEqual(day, this.#date1) || areDatesEqual(day, this.#date2)) {
282
386
  $day.classList.add('selected');
283
387
  } else if (areDatesEqual(day, todayDate)) {
284
388
  $day.classList.add('today');
389
+ } else if (isDateBetween(day, this.#date1, this.#date2)) {
390
+ $day.classList.add('range');
285
391
  }
286
392
  isEmptyWeek = false;
287
393
  }
288
- });
394
+ }
289
395
  setClass(this.#$weeks[wi], 'empty', isEmptyWeek);
290
- });
396
+ }
291
397
  }
292
- #dispatchChangeEvent(value) {
293
- this.dispatchEvent(new CustomEvent('change', {
294
- detail: value,
295
- bubbles: true
296
- }));
297
- this.dispatchEvent(new CustomEvent('-change', {
298
- detail: value
299
- }));
398
+ #subscribeRangeHover() {
399
+ if (!this.#isHoverSubscribed) {
400
+ this.#$month.addEventListener('mouseover', this.#onDateMouseEnter);
401
+ this.#isHoverSubscribed = true;
402
+ }
403
+ }
404
+ #unsubscribeRangeHover() {
405
+ if (this.#isHoverSubscribed) {
406
+ this.#$month.removeEventListener('mouseover', this.#onDateMouseEnter);
407
+ this.#isHoverSubscribed = false;
408
+ }
300
409
  }
301
410
  #onChangeReactHandler = e => {
302
411
  getReactEventHandler(this, 'on-change')?.(e);
@@ -9,6 +9,8 @@ export declare type TSinchDatePickerElement = HTMLElement & {
9
9
  max: string;
10
10
  /** BCP 47 language tag (e.g. en-US), which changes day and month display names in the calendar */
11
11
  locale: string;
12
+ /** Date range mode */
13
+ range: boolean;
12
14
  /** Label that is used for a11y */
13
15
  prevYearAriaLabel: string;
14
16
  /** Label that is used for a11y */
@@ -32,6 +34,8 @@ export declare type TSinchDatePickerElement = HTMLElement & {
32
34
  setAttribute(name: 'max', value: string): void;
33
35
  /** BCP 47 language tag (e.g. en-US), which changes day and month display names in the calendar */
34
36
  setAttribute(name: 'locale', value: string): void;
37
+ /** Date range mode */
38
+ setAttribute(name: 'range', value: ''): void;
35
39
  /** Label that is used for a11y */
36
40
  setAttribute(name: 'prev-year-aria-label', value: string): void;
37
41
  /** Label that is used for a11y */
@@ -50,6 +54,8 @@ export declare type TSinchDatePickerReact = TSinchElementReact<TSinchDatePickerE
50
54
  max: string;
51
55
  /** BCP 47 language tag (e.g. en-US), which changes day and month display names in the calendar */
52
56
  locale: string;
57
+ /** Date range mode */
58
+ range?: boolean;
53
59
  /** Label that is used for a11y */
54
60
  'aria-label': string;
55
61
  /** Label that is used for a11y */
@@ -3,9 +3,9 @@ declare type TCalendarOptions = {
3
3
  };
4
4
  declare type TMaybeDate = Date | null;
5
5
  export declare const getCalendarMonth: (date: Date, options?: TCalendarOptions) => TMaybeDate[][];
6
- export declare const today: () => Date;
7
6
  export declare const dateToIso: (date: Date) => string;
8
7
  export declare const isoToDate: (value: string) => Date;
8
+ export declare const today: () => Date;
9
9
  export declare const getDayNames: (locale: string) => string[];
10
10
  export declare const getMonthNames: (locale: string) => string[];
11
11
  declare type TAssertMinMax = (value: string | null, attrName: string) => asserts value is string;
@@ -17,6 +17,7 @@ export declare const assertLocale: TAssertLocale;
17
17
  declare type TAssertDate = (value: any, attrName: string, attrValue: string) => asserts value is Date;
18
18
  export declare const isValidDate: (value: any) => value is Date;
19
19
  export declare const assertDate: TAssertDate;
20
+ export declare const compareDates: (a: Date, b: Date) => number;
20
21
  export declare const clampMinDate: (date: Date, min: Date) => void;
21
22
  export declare const clampMaxDate: (date: Date, max: Date) => void;
22
23
  export declare const incMonth: (date: Date, max: Date) => void;
@@ -27,6 +28,8 @@ export declare const canGoPrevMonth: (date: Date, min: Date) => boolean;
27
28
  export declare const canGoNextMonth: (date: Date, max: Date) => boolean;
28
29
  export declare const canGoNextYear: (date: Date, max: Date) => boolean;
29
30
  export declare const canGoPrevYear: (date: Date, min: Date) => boolean;
30
- export declare const isDateBetween: (date: Date, min: Date, max: Date) => boolean;
31
- export declare const areDatesEqual: (a: Date, b: Date) => boolean;
31
+ export declare const isDateBetween: (date: Date, min: Date | null, max: Date | null) => boolean;
32
+ export declare const areDatesEqual: (a: Date, b: Date | null) => boolean;
33
+ export declare const cloneDate: (date: Date) => Date;
34
+ export declare const sortDates: (dateTuple: [Date, Date]) => [Date, Date];
32
35
  export {};
@@ -33,15 +33,15 @@ export const getCalendarMonth = (date, options) => {
33
33
  }
34
34
  return month;
35
35
  };
36
- export const today = () => {
37
- return new Date();
38
- };
39
36
  export const dateToIso = date => {
40
37
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
41
38
  };
42
39
  export const isoToDate = value => {
43
40
  return new Date(`${value.substring(0, 10)}T00:00:00`);
44
41
  };
42
+ export const today = () => {
43
+ return isoToDate(dateToIso(new Date()));
44
+ };
45
45
  export const getDayNames = locale => {
46
46
  const formatter = new Intl.DateTimeFormat(locale, {
47
47
  weekday: 'narrow',
@@ -85,7 +85,7 @@ export const assertDate = (value, attrName, attrValue) => {
85
85
  throw new Error(`sinch-date-picker: invalid "${attrName}" attribute: ${attrValue}`);
86
86
  }
87
87
  };
88
- const compareDates = (a, b) => {
88
+ export const compareDates = (a, b) => {
89
89
  return a.getTime() - b.getTime();
90
90
  };
91
91
  export const clampMinDate = (date, min) => {
@@ -131,8 +131,23 @@ export const canGoPrevYear = (date, min) => {
131
131
  return compareDates(prevYear, min) >= 0;
132
132
  };
133
133
  export const isDateBetween = (date, min, max) => {
134
+ if (min === null || max === null) {
135
+ return false;
136
+ }
134
137
  return compareDates(date, min) >= 0 && compareDates(max, date) >= 0;
135
138
  };
136
139
  export const areDatesEqual = (a, b) => {
140
+ if (b === null) {
141
+ return false;
142
+ }
137
143
  return compareDates(a, b) === 0;
144
+ };
145
+ export const cloneDate = date => {
146
+ return new Date(date.getTime());
147
+ };
148
+ export const sortDates = dateTuple => {
149
+ if (compareDates(dateTuple[0], dateTuple[1]) > 0) {
150
+ return [dateTuple[1], dateTuple[0]];
151
+ }
152
+ return dateTuple;
138
153
  };
@@ -44,7 +44,7 @@ export declare type TSinchFileDropReact = TSinchElementReact<TSinchFileDropEleme
44
44
  /** Placeholder */
45
45
  placeholder: string;
46
46
  /** Change value handler */
47
- 'on-change'?: (e: CustomEvent<File[]>) => void;
47
+ 'on-change': (e: CustomEvent<File[]>) => void;
48
48
  /** Invalid handler */
49
- 'on-invalid'?: (e: CustomEvent<TSinchFileDropInvalidType>) => void;
49
+ 'on-invalid': (e: CustomEvent<TSinchFileDropInvalidType>) => void;
50
50
  };
@@ -28,5 +28,5 @@ export declare type TSinchFilePickerReact = TSinchElementReact<TSinchFilePickerE
28
28
  /** Change value handler */
29
29
  'on-change': (e: CustomEvent<File[]>) => void;
30
30
  /** Invalid handler */
31
- 'on-invalid'?: (e: CustomEvent<TSinchFilePickerInvalidType>) => void;
31
+ 'on-invalid': (e: CustomEvent<TSinchFilePickerInvalidType>) => void;
32
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectary/components",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "files": [
5
5
  "theme.css",
6
6
  "theme/*.css",
@@ -1,4 +1,4 @@
1
- import { defineCustomElement, getAttribute, getBooleanAttribute, getCsvSet, getFirstCsvValue, getReactEventHandler, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv } from '../utils';
1
+ import { defineCustomElement, getAttribute, getBooleanAttribute, unpackCsv, getFirstCsvValue, getReactEventHandler, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv } from '../utils';
2
2
  const templateHTML = '<style>:host{display:block;outline:0}#wrapper{display:flex;flex-direction:row}</style><div id="wrapper"><slot></slot></div>';
3
3
  const template = document.createElement('template');
4
4
  template.innerHTML = templateHTML;
@@ -69,9 +69,9 @@ defineCustomElement('sinch-segmented-icon-control', class extends NectaryElement
69
69
  };
70
70
  #onValueChange(csv) {
71
71
  if (this.multiple) {
72
- const values = getCsvSet(csv);
72
+ const values = unpackCsv(csv);
73
73
  for (const $option of this.#$slot.assignedElements()) {
74
- const isChecked = !getBooleanAttribute($option, 'disabled') && values.has(getAttribute($option, 'value', ''));
74
+ const isChecked = !getBooleanAttribute($option, 'disabled') && values.includes(getAttribute($option, 'value', ''));
75
75
  updateBooleanAttribute($option, 'data-checked', isChecked);
76
76
  }
77
77
  } else {
@@ -1,4 +1,4 @@
1
- import { attrValueToPixels, defineCustomElement, dispatchContextConnectEvent, dispatchContextDisconnectEvent, getAttribute, getBooleanAttribute, getCsvSet, getFirstCsvValue, getIntegerAttribute, getReactEventHandler, isAttrTrue, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv, updateExplicitBooleanAttribute, updateIntegerAttribute } from '../utils';
1
+ import { attrValueToPixels, defineCustomElement, dispatchContextConnectEvent, dispatchContextDisconnectEvent, getAttribute, getBooleanAttribute, unpackCsv, getFirstCsvValue, getIntegerAttribute, getReactEventHandler, isAttrTrue, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv, updateExplicitBooleanAttribute, updateIntegerAttribute } from '../utils';
2
2
  const templateHTML = '<style>:host{display:block;outline:0}#listbox{overflow-y:auto}</style><div id="listbox" role="presentation"><slot></slot></div>';
3
3
  const ITEM_HEIGHT = 40;
4
4
  const template = document.createElement('template');
@@ -159,9 +159,9 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
159
159
  };
160
160
  #onValueChange(csv) {
161
161
  if (this.multiple) {
162
- const values = getCsvSet(csv);
162
+ const values = unpackCsv(csv);
163
163
  for (const $option of this.#getOptionElements()) {
164
- const isChecked = !getBooleanAttribute($option, 'disabled') && values.has(getAttribute($option, 'value', ''));
164
+ const isChecked = !getBooleanAttribute($option, 'disabled') && values.includes(getAttribute($option, 'value', ''));
165
165
  updateBooleanAttribute($option, 'data-checked', isChecked);
166
166
  }
167
167
  } else {
@@ -1,17 +1,17 @@
1
- import { defineCustomElement, getCsvSet } from '../utils';
1
+ import { defineCustomElement, unpackCsv } from '../utils';
2
2
  defineCustomElement('sinch-stop-events', class extends HTMLElement {
3
3
  constructor() {
4
4
  super();
5
5
  this.style.display = 'contents';
6
6
  }
7
7
  connectedCallback() {
8
- const events = getCsvSet(this.getAttribute('events'));
8
+ const events = unpackCsv(this.getAttribute('events'));
9
9
  for (const event of events) {
10
10
  this.addEventListener(event, this.#stopEvent);
11
11
  }
12
12
  }
13
13
  disconnectedCallback() {
14
- const events = getCsvSet(this.getAttribute('events'));
14
+ const events = unpackCsv(this.getAttribute('events'));
15
15
  for (const event of events) {
16
16
  this.removeEventListener(event, this.#stopEvent);
17
17
  }
@@ -1,4 +1,4 @@
1
- import { defineCustomElement, getAttribute, getBooleanAttribute, getCsvSet, getFirstCsvValue, getIntegerAttribute, getReactEventHandler, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv, updateIntegerAttribute } from '../utils';
1
+ import { defineCustomElement, getAttribute, getBooleanAttribute, unpackCsv, getFirstCsvValue, getIntegerAttribute, getReactEventHandler, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv, updateIntegerAttribute } from '../utils';
2
2
  const templateHTML = '<style>:host{display:block;outline:0;--sinch-grid-num-columns:1}#wrapper{display:grid;grid-template-columns:repeat(var(--sinch-grid-num-columns),auto);gap:16px;width:fit-content}:host([small]:not([small=false])) #wrapper{gap:8px}:host([cols="2"]){--sinch-grid-num-columns:2}:host([cols="3"]){--sinch-grid-num-columns:3}:host([cols="4"]){--sinch-grid-num-columns:4}:host([cols="5"]){--sinch-grid-num-columns:5}:host([cols="6"]){--sinch-grid-num-columns:6}:host([cols="7"]){--sinch-grid-num-columns:7}:host([cols="8"]){--sinch-grid-num-columns:8}</style><div id="wrapper"><slot></slot></div>';
3
3
  const template = document.createElement('template');
4
4
  template.innerHTML = templateHTML;
@@ -91,9 +91,9 @@ defineCustomElement('sinch-tile-control', class extends NectaryElement {
91
91
  };
92
92
  #onValueChange(csv) {
93
93
  if (this.multiple) {
94
- const values = getCsvSet(csv);
94
+ const values = unpackCsv(csv);
95
95
  for (const $option of this.#$slot.assignedElements()) {
96
- const isChecked = !getBooleanAttribute($option, 'disabled') && values.has(getAttribute($option, 'value', ''));
96
+ const isChecked = !getBooleanAttribute($option, 'disabled') && values.includes(getAttribute($option, 'value', ''));
97
97
  updateBooleanAttribute($option, 'data-checked', isChecked);
98
98
  }
99
99
  } else {
package/utils/csv.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- export declare const getCsvSet: (acc: string) => Set<string>;
2
- export declare const updateCsv: (acc: string, value: string, setActive: boolean) => string;
1
+ export declare const CSV_DELIMITER = ",";
2
+ export declare const packCsv: (values: string[]) => string;
3
+ export declare const unpackCsv: (csv: string) => string[];
4
+ export declare const updateCsv: (csv: string, value: string, setActive: boolean) => string;
3
5
  export declare const getFirstCsvValue: (acc: string) => string | null;
package/utils/csv.js CHANGED
@@ -1,21 +1,22 @@
1
- const unpackCsv = csv => {
2
- return csv === '' ? [] : csv.split(',');
1
+ export const CSV_DELIMITER = ',';
2
+ export const packCsv = values => {
3
+ return values.join(CSV_DELIMITER);
3
4
  };
4
- const packCsv = values => {
5
- return Array.from(values).join(',');
5
+ export const unpackCsv = csv => {
6
+ return csv.length === 0 ? [] : csv.split(CSV_DELIMITER);
6
7
  };
7
- export const getCsvSet = acc => {
8
- return new Set(unpackCsv(acc));
9
- };
10
- export const updateCsv = (acc, value, setActive) => {
11
- const values = getCsvSet(acc);
8
+ export const updateCsv = (csv, value, setActive) => {
9
+ const values = unpackCsv(csv);
10
+ const index = values.indexOf(value);
12
11
  if (setActive) {
13
- values.add(value);
14
- } else {
15
- values.delete(value);
12
+ if (index < 0) {
13
+ values.push(value);
14
+ }
15
+ } else if (index >= 0) {
16
+ values.splice(index, 1);
16
17
  }
17
18
  return packCsv(values);
18
19
  };
19
20
  export const getFirstCsvValue = acc => {
20
- return acc === '' ? null : unpackCsv(acc)[0];
21
+ return acc.length === 0 ? null : unpackCsv(acc)[0];
21
22
  };