@per-diem-calculator/vanilla 1.0.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.
Files changed (103) hide show
  1. package/.prettierrc +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1 -0
  4. package/eslint.config.js +29 -0
  5. package/index.html +11 -0
  6. package/package.json +49 -0
  7. package/public/output.css +2503 -0
  8. package/src/css/_styles.css +8 -0
  9. package/src/css/colors.css +45 -0
  10. package/src/css/fonts.css +9 -0
  11. package/src/css/rows/_heights.css +6 -0
  12. package/src/css/rows/_index.css +15 -0
  13. package/src/css/rows/add.css +18 -0
  14. package/src/css/rows/animate-btns.css +18 -0
  15. package/src/css/rows/animate-row-close.css +18 -0
  16. package/src/css/rows/animate-row-open.css +14 -0
  17. package/src/css/rows/animate-row-other.css +5 -0
  18. package/src/css/rows/btn-add-row.css +41 -0
  19. package/src/css/rows/btn-delete.css +22 -0
  20. package/src/css/rows/btn-expenses-calculate.css +22 -0
  21. package/src/css/rows/btn-expenses-category.css +22 -0
  22. package/src/css/rows/delete.css +10 -0
  23. package/src/css/rows/details.css +22 -0
  24. package/src/css/rows/expense.css +18 -0
  25. package/src/css/rows/location.css +34 -0
  26. package/src/css/rows/summary.css +22 -0
  27. package/src/css/tom-select/defaults.css +530 -0
  28. package/src/css/tom-select/overrides.css +55 -0
  29. package/src/css/tw-shadow-props.css +50 -0
  30. package/src/index.ts +1 -0
  31. package/src/ts/components/Button/Button.ts +50 -0
  32. package/src/ts/components/Button/template.html +34 -0
  33. package/src/ts/components/ExpenseRow/ExpenseRow.ts +397 -0
  34. package/src/ts/components/ExpenseRow/template.html +260 -0
  35. package/src/ts/components/Label/Label.ts +45 -0
  36. package/src/ts/components/Label/template.html +1 -0
  37. package/src/ts/components/LocationCategory/LocationCategory.ts +226 -0
  38. package/src/ts/components/LocationCategory/template.html +520 -0
  39. package/src/ts/components/LocationDate/LocationDate.ts +366 -0
  40. package/src/ts/components/LocationDate/template.html +27 -0
  41. package/src/ts/components/LocationSelect/LocationSelect.ts +299 -0
  42. package/src/ts/components/LocationSelect/template.html +45 -0
  43. package/src/ts/components/index.ts +6 -0
  44. package/src/ts/controller.ts +193 -0
  45. package/src/ts/model.ts +163 -0
  46. package/src/ts/types/config.ts +22 -0
  47. package/src/ts/types/dates.ts +82 -0
  48. package/src/ts/types/expenses.ts +73 -0
  49. package/src/ts/types/locations.ts +25 -0
  50. package/src/ts/utils/config/configDefault.ts +13 -0
  51. package/src/ts/utils/config/index.ts +12 -0
  52. package/src/ts/utils/config/numbers.ts +24 -0
  53. package/src/ts/utils/config/sanitizeConfig.ts +39 -0
  54. package/src/ts/utils/dates/INPUT_DATE_MINMAX.ts +5 -0
  55. package/src/ts/utils/dates/YEAR_REGEX.ts +4 -0
  56. package/src/ts/utils/dates/getDateSlice.ts +54 -0
  57. package/src/ts/utils/dates/getValidAPIYear.ts +17 -0
  58. package/src/ts/utils/dates/index.ts +19 -0
  59. package/src/ts/utils/dates/isDateRaw.ts +90 -0
  60. package/src/ts/utils/dates/isShortMonth.ts +24 -0
  61. package/src/ts/utils/dates/isYYYY.ts +10 -0
  62. package/src/ts/utils/dates/offsetDateString.ts +17 -0
  63. package/src/ts/utils/expenses/INTL_MIE_RATES.ts +2125 -0
  64. package/src/ts/utils/expenses/createExpenseObjs.ts +35 -0
  65. package/src/ts/utils/expenses/getLodgingRateDomestic.ts +73 -0
  66. package/src/ts/utils/expenses/getLodgingRateIntl.ts +119 -0
  67. package/src/ts/utils/expenses/getMieRates.ts +84 -0
  68. package/src/ts/utils/expenses/index.ts +5 -0
  69. package/src/ts/utils/expenses/parseIntlLodgingRates.ts +124 -0
  70. package/src/ts/utils/expenses/returnValidStateExpense.ts +46 -0
  71. package/src/ts/utils/fetch/fetchJsonGSA.ts +29 -0
  72. package/src/ts/utils/fetch/fetchXmlDOD.ts +38 -0
  73. package/src/ts/utils/fetch/index.ts +3 -0
  74. package/src/ts/utils/fetch/memoize.ts +46 -0
  75. package/src/ts/utils/fetch/parseXml.ts +19 -0
  76. package/src/ts/utils/locations/getCitiesDomestic.ts +48 -0
  77. package/src/ts/utils/locations/getCitiesIntl.ts +63 -0
  78. package/src/ts/utils/locations/getCountriesDomestic.ts +237 -0
  79. package/src/ts/utils/locations/getCountriesIntl.ts +34 -0
  80. package/src/ts/utils/locations/index.ts +6 -0
  81. package/src/ts/utils/locations/keepUniqueLocations.ts +12 -0
  82. package/src/ts/utils/locations/locationKeys.ts +10 -0
  83. package/src/ts/utils/locations/returnValidStateLocation.ts +13 -0
  84. package/src/ts/utils/locations/sortLocations.ts +19 -0
  85. package/src/ts/utils/misc/USD.ts +4 -0
  86. package/src/ts/utils/misc/debounce.ts +22 -0
  87. package/src/ts/utils/misc/handlePointerDown.ts +3 -0
  88. package/src/ts/utils/misc/handlePointerUp.ts +22 -0
  89. package/src/ts/utils/misc/inPrimitiveType.ts +4 -0
  90. package/src/ts/utils/misc/index.ts +6 -0
  91. package/src/ts/utils/misc/wait.ts +4 -0
  92. package/src/ts/utils/styles/applyStyles.ts +19 -0
  93. package/src/ts/utils/styles/highlightInput.ts +15 -0
  94. package/src/ts/utils/styles/index.ts +3 -0
  95. package/src/ts/utils/styles/removeStyles.ts +14 -0
  96. package/src/ts/views/Expense/Expense.ts +465 -0
  97. package/src/ts/views/Expense/template.html +176 -0
  98. package/src/ts/views/Location/Location.ts +763 -0
  99. package/src/ts/views/Location/template-row.html +146 -0
  100. package/src/ts/views/Location/template.html +130 -0
  101. package/src/ts/views/index.ts +2 -0
  102. package/tsconfig.json +27 -0
  103. package/vite.config.ts +12 -0
@@ -0,0 +1,366 @@
1
+ // Types
2
+ import type { DateRaw } from '../../types/dates';
3
+
4
+ // Utils
5
+ import { applyStyles, removeStyles } from '../../utils/styles';
6
+ import {
7
+ isDateRawType,
8
+ offsetDateString,
9
+ INPUT_DATE_MIN,
10
+ INPUT_DATE_MAX,
11
+ YEAR_MIN_REGEX,
12
+ YEAR_MAX_REGEX,
13
+ YEAR_INCOMPLETE_REGEX,
14
+ } from '../../utils/dates';
15
+
16
+ // HTML/CSS
17
+ import templateHTML from './template.html?raw';
18
+
19
+ // Template for this Custom Element
20
+ const template = document.createElement('template');
21
+
22
+ // Custom Element
23
+ export class PdcLocationDate extends HTMLElement {
24
+ /* SETUP
25
+ */
26
+ static observedAttributes = ['bg'];
27
+ #attrName: 'start' | 'end';
28
+ #valid = false;
29
+ #styled = false;
30
+ #enabled = false;
31
+ constructor() {
32
+ super();
33
+ this.attachShadow({ mode: 'open' });
34
+ this.#attrName = this.getAttribute('pdc') === 'start' ? 'start' : 'end';
35
+ this.#styled = this.getAttribute('styled') === 'true';
36
+
37
+ if (this.#styled) {
38
+ template.innerHTML = templateHTML;
39
+ applyStyles(this.#shadowRoot);
40
+ } else template.innerHTML = removeStyles(templateHTML);
41
+ this.#shadowRoot.appendChild(template.content.cloneNode(true));
42
+
43
+ this.#label.setAttribute(
44
+ 'text',
45
+ this.#attrName.charAt(0).toUpperCase() + this.#attrName.slice(1),
46
+ );
47
+ if (this.#attrName === 'start') this.restrictStartInput();
48
+ else this.restrictEndInput();
49
+
50
+ this.enableTabIndex(false);
51
+ this.#styleEl();
52
+ this.#addEventListeners();
53
+ }
54
+
55
+ /* EVENTS
56
+ */
57
+ attributeChangedCallback(
58
+ _name: string,
59
+ _oldValue: string,
60
+ newValue: string,
61
+ ) {
62
+ if (!this.#styled) return;
63
+ this.#styleEl(`bg-${newValue === 'white' ? 'white' : 'neutral-50'}`);
64
+ }
65
+
66
+ #addEventListeners() {
67
+ this.#input.addEventListener('change', e => {
68
+ const target = e.target;
69
+ if (!(target instanceof HTMLInputElement)) return;
70
+ if (YEAR_INCOMPLETE_REGEX.test(target.value)) return;
71
+ this.handleInputChange(target.value);
72
+ });
73
+ }
74
+
75
+ /* GET ELS
76
+ */
77
+ get #label() {
78
+ const el = this.#shadowRoot.querySelector('pdc-label');
79
+ if (!el)
80
+ throw new Error(`Failed to render label in Date custom element`);
81
+ return el;
82
+ }
83
+
84
+ get #input() {
85
+ const el = this.#shadowRoot.querySelector('input');
86
+ if (!el)
87
+ throw new Error(`Failed to render input in Date custom element`);
88
+ return el;
89
+ }
90
+
91
+ get #shadowRoot() {
92
+ if (!this.shadowRoot)
93
+ throw new Error(
94
+ `Failed to render shadowRoot in Category custom element`,
95
+ );
96
+ return this.shadowRoot;
97
+ }
98
+
99
+ get #row() {
100
+ const el = this.closest<HTMLElement>('[data-pdc="location-row"]');
101
+ if (!el) throw new Error(`Failed to get row from Date custom element`);
102
+ return el;
103
+ }
104
+
105
+ get #prevRow() {
106
+ const el = this.#row.previousElementSibling;
107
+ const startDate =
108
+ el?.querySelector<PdcLocationDate>('[pdc="start"]')?.pdcValue;
109
+ const endEl = el?.querySelector<PdcLocationDate>('[pdc="end"]');
110
+ const endDate = endEl?.pdcValue;
111
+ return { el, startDate, endEl, endDate };
112
+ }
113
+
114
+ get #nextRow() {
115
+ const el = this.#row.nextElementSibling;
116
+ const startEl = el?.querySelector<PdcLocationDate>('[pdc="start"]');
117
+ const startDate = startEl?.pdcValue;
118
+ const endDate =
119
+ el?.querySelector<PdcLocationDate>('[pdc="end"]')?.pdcValue;
120
+ return { el, startEl, startDate, endDate };
121
+ }
122
+
123
+ get #startEl() {
124
+ const el = this.#row.querySelector<PdcLocationDate>('[pdc="start"]');
125
+ if (!el)
126
+ throw new Error(
127
+ `Failed to get row startDateEl from Date custom element`,
128
+ );
129
+ return el;
130
+ }
131
+
132
+ get #endEl() {
133
+ const el = this.#row.querySelector<PdcLocationDate>('[pdc="end"]');
134
+ if (!el)
135
+ throw new Error(
136
+ `Failed to get row endDateEl from Date custom element`,
137
+ );
138
+ return el;
139
+ }
140
+
141
+ get #errorEl() {
142
+ const el = this.closest('#locations-container')?.querySelector(
143
+ '#error',
144
+ );
145
+ if (!el)
146
+ throw new Error(
147
+ `Failed to get View's error element from Category custom element`,
148
+ );
149
+ return el;
150
+ }
151
+
152
+ /* VISUAL METHODS
153
+ */
154
+ #styleEl(bgColor: 'bg-white' | 'bg-neutral-50' | null = null) {
155
+ if (!this.#styled) return;
156
+ const div = this.#shadowRoot.querySelector('div');
157
+ div?.classList.remove(`bg-white`, `bg-neutral-50`);
158
+ div?.classList.add(bgColor ? bgColor : `bg-${this.getAttribute('bg')}`);
159
+ }
160
+
161
+ focusEl() {
162
+ this.#input.focus();
163
+ }
164
+
165
+ /* UPDATE METHODS
166
+ */
167
+ enableTabIndex(enable: boolean) {
168
+ this.#input.setAttribute('tabindex', enable ? '0' : '-1');
169
+ }
170
+
171
+ enable(enable: boolean) {
172
+ if (enable) this.#input.removeAttribute('disabled');
173
+ else this.#input.setAttribute('disabled', 'true');
174
+ this.enableTabIndex(enable);
175
+ this.#enabled = enable;
176
+ }
177
+
178
+ #setMin(value: DateRaw | null = null) {
179
+ this.#input.setAttribute('min', value ? value : INPUT_DATE_MIN);
180
+ }
181
+
182
+ #setMax(value: DateRaw | null = null) {
183
+ this.#input.setAttribute('max', value ? value : INPUT_DATE_MAX);
184
+ }
185
+
186
+ #setAttr(value: DateRaw | null = null) {
187
+ if (value) this.setAttribute(this.#attrName, value);
188
+ else this.removeAttribute(this.#attrName);
189
+ }
190
+
191
+ #setInputValue(value: DateRaw | null = null) {
192
+ this.#input.value = value ? value : '';
193
+ }
194
+
195
+ #reset() {
196
+ this.#setInputValue();
197
+ this.#setAttr();
198
+ this.focusEl();
199
+ }
200
+
201
+ #resetEnd() {
202
+ this.#endEl.#setInputValue();
203
+ this.#endEl.#setAttr();
204
+ this.#endEl.restrictEndInput();
205
+ }
206
+
207
+ async handleInputChange(date: string | null = null) {
208
+ if (this.#styled) this.#input.classList.remove('success');
209
+ if (!this.#isValidInput(date) || !date) return;
210
+ if (!(await this.#isStartBeforeEnd()) || !isDateRawType(date)) return;
211
+ this.#setInputValue(date);
212
+ this.#setAttr(date);
213
+ await this.#startEl.restrictStartInput();
214
+ this.#endEl.restrictEndInput();
215
+ if (this.#styled) {
216
+ this.#input.classList.remove('error');
217
+ this.#input.classList.add('success');
218
+ this.renderError(false);
219
+ }
220
+ }
221
+
222
+ #updateStartBasedOnPrevEnd() {
223
+ if (!this.#prevRow.endDate) return;
224
+ const correctStart = offsetDateString(this.#prevRow.endDate, 1);
225
+ if (this.#startDateInputVal === correctStart) return;
226
+ this.enable(false);
227
+ this.#setMin(correctStart);
228
+ this.#setInputValue(correctStart);
229
+ this.#setAttr(correctStart);
230
+ this.#input.classList.add('success');
231
+ this.#prevRow.endEl?.enable(false);
232
+ }
233
+
234
+ #updateEndBasedOnNextStart() {
235
+ if (!this.#nextRow.startDate) return;
236
+ this.enable(false);
237
+ const correctEnd = offsetDateString(this.#nextRow.startDate, -1);
238
+ if (this.#endDateInputVal === correctEnd) return;
239
+ this.#setMax(correctEnd);
240
+ this.#setInputValue(correctEnd);
241
+ this.#setAttr(correctEnd);
242
+ this.#input.classList.add('success');
243
+ this.#nextRow.startEl?.enable(false);
244
+ }
245
+
246
+ /* VALIDATION
247
+ */
248
+ renderError(
249
+ enable: boolean,
250
+ msg = `Enter a valid ${this.#attrName} date.`,
251
+ ) {
252
+ if (enable) {
253
+ if (this.#styled) {
254
+ this.#input.classList.add('error');
255
+ this.#errorEl.classList.add('active');
256
+ }
257
+ this.#errorEl.textContent = msg;
258
+ return;
259
+ }
260
+ if (!this.#styled) return;
261
+ this.#errorEl.classList.remove('active');
262
+ }
263
+
264
+ #isValidInput(date: string | null = null) {
265
+ if (
266
+ !date ||
267
+ YEAR_MIN_REGEX.test(date) ||
268
+ YEAR_MAX_REGEX.test(date) ||
269
+ YEAR_INCOMPLETE_REGEX.test(date) ||
270
+ !isDateRawType(date)
271
+ ) {
272
+ this.#reset();
273
+ if (this.#attrName === 'start') this.#resetEnd();
274
+ if (!date) return false;
275
+ if (YEAR_MIN_REGEX.test(date))
276
+ this.renderError(true, `Date must be after 2020`);
277
+ if (YEAR_MAX_REGEX.test(date))
278
+ this.renderError(true, `Date must be before 2041`);
279
+ if (YEAR_INCOMPLETE_REGEX.test(date))
280
+ this.renderError(true, `Enter a valid date.`);
281
+ return false;
282
+ }
283
+ return true;
284
+ }
285
+
286
+ async #isStartBeforeEnd() {
287
+ if (!this.#startDateInputVal) return false;
288
+ if (!this.#endDateInputVal) return true;
289
+ if (
290
+ Date.parse(this.#startDateInputVal) >
291
+ Date.parse(this.#endDateInputVal)
292
+ ) {
293
+ await this.#startEl.restrictStartInput();
294
+ this.#resetEnd();
295
+ this.#endEl.renderError(true, 'End date must be after start date.');
296
+ return false;
297
+ }
298
+ return true;
299
+ }
300
+
301
+ async restrictStartInput() {
302
+ if (!this.#endEl.pdcValue && !this.#prevRow.el) {
303
+ this.enable(true);
304
+ this.#setMax();
305
+ this.#setMin();
306
+ return;
307
+ }
308
+ if (!this.#prevRow.el) this.enable(true);
309
+ if (this.#endEl.pdcValue) this.#setMax(this.#endEl.pdcValue);
310
+ this.#updateStartBasedOnPrevEnd();
311
+ }
312
+
313
+ restrictEndInput() {
314
+ if (!this.#startEl.pdcValue) {
315
+ this.enable(false);
316
+ this.#setMin();
317
+ this.#setMax();
318
+ return;
319
+ } else {
320
+ this.enable(true);
321
+ this.#setMin(this.#startEl.pdcValue);
322
+ }
323
+ this.#updateEndBasedOnNextStart();
324
+ }
325
+
326
+ validate() {
327
+ this.#valid = false;
328
+ const attr = this.getAttribute(this.#attrName);
329
+ if (!(attr && isDateRawType(attr))) {
330
+ this.renderError(true);
331
+ return this.#valid;
332
+ }
333
+ this.#valid = true;
334
+ return this.#valid;
335
+ }
336
+
337
+ /* GET DATA METHODS
338
+ */
339
+ get inputValue() {
340
+ return this.#input.value;
341
+ }
342
+
343
+ get #startDateInputVal() {
344
+ return (
345
+ this.#startEl.inputValue &&
346
+ isDateRawType(this.#startEl.inputValue)
347
+ ) ?
348
+ this.#startEl.inputValue
349
+ : null;
350
+ }
351
+
352
+ get #endDateInputVal() {
353
+ return this.#endEl.inputValue && isDateRawType(this.#endEl.inputValue) ?
354
+ this.#endEl.inputValue
355
+ : null;
356
+ }
357
+
358
+ get isEnabled() {
359
+ return this.#enabled;
360
+ }
361
+
362
+ get pdcValue() {
363
+ const value = this.getAttribute(this.#attrName);
364
+ return !!value && isDateRawType(value) ? value : null;
365
+ }
366
+ }
@@ -0,0 +1,27 @@
1
+ <div
2
+ class="group flex items-center justify-between border-b border-b-neutral-100
3
+ px-3"
4
+ >
5
+ <pdc-label styled="true"></pdc-label>
6
+ <input
7
+ tabindex="0"
8
+ disabled
9
+ type="date"
10
+ id="date"
11
+ min="2021-01-01"
12
+ max="2040-12-31"
13
+ required
14
+ class="[.error]:border-r-error-400 [.success]:border-r-success-400
15
+ focus-within:after:border-b-primary-800
16
+ webkit-calendar-picker-indicator
17
+ [&::-webkit-calendar-picker-indicator:focus]:border-b-primary-800
18
+ relative my-3 w-[68%] border-r-3 border-r-transparent bg-transparent
19
+ py-3 pr-3 text-neutral-800 opacity-100
20
+ transition-[border-color_opacity] after:absolute after:left-0
21
+ after:mt-1.5 after:w-full after:max-w-[95%] after:border-b-3
22
+ after:border-b-transparent after:transition-colors
23
+ after:content-['_'] focus:outline-none disabled:cursor-not-allowed
24
+ disabled:border-r-transparent disabled:!text-neutral-800/30
25
+ sm:w-[75%]"
26
+ />
27
+ </div>
@@ -0,0 +1,299 @@
1
+ // Types
2
+ import type { Location } from '../../types/locations';
3
+
4
+ // Utils
5
+ import { applyStyles, removeStyles } from '../../utils/styles';
6
+ import { debounce, handlePointerDown, handlePointerUp } from '../../utils/misc';
7
+ import TomSelect from 'tom-select';
8
+
9
+ // HTML/CSS
10
+ import templateHTML from './template.html?raw';
11
+
12
+ // Template for this Custom Element
13
+ const template = document.createElement('template');
14
+
15
+ // Custom Element
16
+ export class PdcLocationSelect extends HTMLElement {
17
+ /* SETUP
18
+ */
19
+ static observedAttributes = ['bg'];
20
+ #attrName: 'country' | 'city';
21
+ #valid = false;
22
+ #styled = false;
23
+ #enabled = false;
24
+ #tomSelect: TomSelect;
25
+ constructor() {
26
+ super();
27
+ this.attachShadow({ mode: 'open' });
28
+ this.#attrName =
29
+ this.getAttribute('pdc') === 'country' ? 'country' : 'city';
30
+ this.#styled = this.getAttribute('styled') === 'true';
31
+ this.#tomSelect = this.#render();
32
+ }
33
+
34
+ #render() {
35
+ this.removeAttribute(this.#attrName);
36
+ this.#shadowRoot.innerHTML = '';
37
+ if (this.#styled) {
38
+ template.innerHTML = templateHTML;
39
+ applyStyles(this.#shadowRoot);
40
+ } else template.innerHTML = removeStyles(templateHTML);
41
+ this.#shadowRoot.appendChild(template.content.cloneNode(true));
42
+ this.#label.setAttribute(
43
+ 'text',
44
+ this.#attrName === 'country' ? 'State' : 'City',
45
+ );
46
+ return this.#createTomSelect();
47
+ }
48
+
49
+ /* EVENTS
50
+ */
51
+ attributeChangedCallback(
52
+ _name: string,
53
+ _oldValue: string,
54
+ newValue: string,
55
+ ) {
56
+ if (!this.#styled) return;
57
+ this.#styleEl(`bg-${newValue === 'white' ? 'white' : 'neutral-50'}`);
58
+ }
59
+
60
+ #addEventListeners() {
61
+ // Mouse, touch events
62
+ let pointerStartX = 0;
63
+ let pointerStartY = 0;
64
+ this.#tomSelect.control.addEventListener(
65
+ 'pointerdown',
66
+ (e: PointerEvent) => {
67
+ const result = handlePointerDown(e);
68
+ pointerStartX = result.pointerStartX;
69
+ pointerStartY = result.pointerStartY;
70
+ },
71
+ );
72
+ this.#container.addEventListener('pointerup', (e: PointerEvent) => {
73
+ const result = handlePointerUp(
74
+ e,
75
+ this.#handleClicks.bind(this),
76
+ pointerStartX,
77
+ pointerStartY,
78
+ );
79
+ pointerStartX = result.pointerStartX;
80
+ pointerStartY = result.pointerStartY;
81
+ });
82
+
83
+ // Keyboard events
84
+ this.#container.addEventListener('keydown', (e: KeyboardEvent) => {
85
+ if (!(e.key === 'Enter' || e.key === ' ')) return;
86
+ this.#handleClicks(e);
87
+ });
88
+
89
+ this.#tomSelect.on('change', () => {
90
+ const value = this.#tomSelect.getValue();
91
+ if (Array.isArray(value)) return; // Ensures string value as TomSelect can return string[] if multiple selection enabled
92
+ this.setAttribute(this.#attrName, value);
93
+
94
+ if (this.#styled) {
95
+ this.#renderError(false);
96
+ this.#tomSelect.control.classList.remove('error');
97
+ this.#tomSelect.control.classList.add('success');
98
+ }
99
+ this.#tomSelect.control.setAttribute('tabindex', '-1');
100
+ });
101
+ }
102
+
103
+ #handleClicks = (e: Event) => {
104
+ const target = e.target;
105
+ if (
106
+ !(
107
+ target instanceof HTMLElement &&
108
+ (target.closest('.ts-control') || target.closest('button'))
109
+ )
110
+ )
111
+ return;
112
+ const handler = debounce(() => {
113
+ this.#tomSelect.open();
114
+ });
115
+ handler();
116
+ };
117
+
118
+ /* Get Els
119
+ */
120
+
121
+ get #container() {
122
+ const el = this.#shadowRoot.querySelector('div');
123
+ if (!el)
124
+ throw new Error(
125
+ `Failed to get container for Select custom element`,
126
+ );
127
+ return el;
128
+ }
129
+
130
+ get #select() {
131
+ const el = this.#shadowRoot.querySelector('select');
132
+ if (!el)
133
+ throw new Error(`Failed to get select for Select custom element`);
134
+ return el;
135
+ }
136
+
137
+ get #button() {
138
+ const el = this.#shadowRoot.querySelector('button');
139
+ if (!el)
140
+ throw new Error(`Failed to get button for Select custom element`);
141
+ return el;
142
+ }
143
+
144
+ get #label() {
145
+ const el = this.#shadowRoot.querySelector('pdc-label');
146
+ if (!el)
147
+ throw new Error(`Failed to get label for Select custom element`);
148
+ return el;
149
+ }
150
+
151
+ get #loadingSpinner() {
152
+ const el = this.#shadowRoot.querySelector('#loading-spinner');
153
+ if (!el)
154
+ throw new Error(
155
+ `Failed to get loading spinner element for Select custom element`,
156
+ );
157
+ return el;
158
+ }
159
+
160
+ get #errorEl() {
161
+ const el = this.closest('#locations-container')?.querySelector(
162
+ '#error',
163
+ );
164
+ if (!el)
165
+ throw new Error(
166
+ `Failed to get View's error element from Category custom element`,
167
+ );
168
+ return el;
169
+ }
170
+
171
+ get #shadowRoot() {
172
+ if (!this.shadowRoot)
173
+ throw new Error(
174
+ `Failed to render shadowRoot in Select custom element`,
175
+ );
176
+ return this.shadowRoot;
177
+ }
178
+
179
+ /* VISUAL METHODS
180
+ */
181
+ #styleEl(bgColor: 'bg-white' | 'bg-neutral-50' | null = null) {
182
+ if (!this.#styled) return;
183
+ const div = this.#shadowRoot.querySelector('div');
184
+ div?.classList.remove(`bg-white`, `bg-neutral-50`);
185
+ div?.classList.add(bgColor ? bgColor : `bg-${this.getAttribute('bg')}`);
186
+ }
187
+
188
+ showLoadingSpinner(enabled: boolean) {
189
+ if (!this.#styled) return;
190
+ if (enabled) this.#loadingSpinner.classList.add('active');
191
+ else this.#loadingSpinner.classList.remove('active');
192
+ }
193
+
194
+ focusEl() {
195
+ this.#tomSelect.control.focus();
196
+ }
197
+
198
+ /* UPDATE METHODS
199
+ */
200
+ enableTabIndex(enable: boolean) {
201
+ this.#button.setAttribute('tabindex', enable ? '0' : '-1');
202
+ }
203
+
204
+ enable(enable: boolean) {
205
+ this.#render();
206
+ if (enable) {
207
+ this.#tomSelect.enable();
208
+ this.#container.classList.add('active');
209
+ } else {
210
+ this.#tomSelect.disable();
211
+ this.#container.classList.remove('active');
212
+ }
213
+ this.enableTabIndex(enable);
214
+ this.#enabled = enable;
215
+ }
216
+
217
+ setOptions(locations: Location[]) {
218
+ locations.forEach(location => {
219
+ const value =
220
+ this.#attrName === 'country' ? location.country : location.city;
221
+ if (!location.label || !value)
222
+ throw new Error(
223
+ `Failed to get label when creating the options for ${location}.`,
224
+ );
225
+ const option = document.createElement('option');
226
+ option.setAttribute('value', value);
227
+ option.textContent = location.label;
228
+ this.#select.appendChild(option);
229
+ this.#tomSelect.sync();
230
+ });
231
+ }
232
+
233
+ #createTomSelect() {
234
+ const noResultsText =
235
+ this.#attrName === 'city' ?
236
+ `No results
237
+ found.<br><br>Choose the first available option.<br><br>E.g. Standard Rate, [OTHER], etc.`
238
+ : `No results found.`;
239
+ const tomSelect = new TomSelect(this.#select, {
240
+ placeholder: `Select ${this.#attrName === 'country' ? 'state' : 'city'}`,
241
+ maxItems: 1,
242
+ plugins: ['dropdown_input'],
243
+ selectOnTab: true,
244
+ openOnFocus: false,
245
+ render: {
246
+ no_results: () => {
247
+ return /* HTML */ ` <div class="no-results">
248
+ ${noResultsText}
249
+ </div>`;
250
+ },
251
+ },
252
+ });
253
+ this.#tomSelect = tomSelect;
254
+ this.#tomSelect.disable();
255
+ this.#tomSelect.tabIndex = -1;
256
+ this.enableTabIndex(false);
257
+ this.#addEventListeners();
258
+ this.#styleEl();
259
+ return tomSelect;
260
+ }
261
+
262
+ /* VALIDATION
263
+ */
264
+ #renderError(enable: boolean) {
265
+ if (enable) {
266
+ if (this.#styled) {
267
+ this.#tomSelect.control.classList.add('error');
268
+ this.#errorEl.classList.add('active');
269
+ }
270
+ this.#errorEl.textContent =
271
+ this.#attrName === 'country' ? `Select state.` : `Select city.`;
272
+ return;
273
+ }
274
+ if (!this.#styled) return;
275
+ this.#tomSelect.control.classList.remove('error');
276
+ this.#errorEl.classList.remove('active');
277
+ }
278
+
279
+ validate() {
280
+ this.#valid = false;
281
+ if (!this.hasAttribute(this.#attrName)) {
282
+ if (this.#styled) this.#renderError(true);
283
+ return this.#valid;
284
+ }
285
+ this.#valid = true;
286
+ return this.#valid;
287
+ }
288
+
289
+ /* GET DATA METHODS
290
+ */
291
+ get isEnabled() {
292
+ return this.#enabled;
293
+ }
294
+
295
+ get pdcValue() {
296
+ const value = this.getAttribute(this.#attrName);
297
+ return value ? value : null;
298
+ }
299
+ }