@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,763 @@
1
+ // Types
2
+ import type {
3
+ AllViewLocationsValid,
4
+ Location,
5
+ LocationKeys,
6
+ StateLocationItem,
7
+ } from '../../types/locations';
8
+ import type { ConfigSectionText } from '../../types/config';
9
+
10
+ // Utils
11
+ import { isDateRawType, getYY, getMM, getDD } from '../../utils/dates';
12
+ import { locationKeys } from '../../utils/locations';
13
+ import {
14
+ inPrimitiveType,
15
+ handlePointerDown,
16
+ handlePointerUp,
17
+ debounce,
18
+ wait,
19
+ } from '../../utils/misc';
20
+ import { removeStyles, applyStyles } from '../../utils/styles';
21
+ import {
22
+ APPROX_DAYS_IN_6_MONTHS,
23
+ BTN_ANIMATE_MS,
24
+ MILLISECONDS_IN_DAY,
25
+ ROW_ANIMATE_MS,
26
+ ROW_CLOSED_HEIGHT,
27
+ } from '../../utils/config';
28
+
29
+ // HTML/CSS
30
+ import templateHTML from './template.html?raw';
31
+ import templateRowHTML from './template-row.html?raw';
32
+
33
+ // Custom Elements
34
+ import {
35
+ PdcLocationCategory,
36
+ PdcLocationSelect,
37
+ PdcLocationDate,
38
+ PdcButton,
39
+ PdcLabel,
40
+ } from '../../components';
41
+ customElements.define('pdc-location-date', PdcLocationDate);
42
+ customElements.define('pdc-location-category', PdcLocationCategory);
43
+ customElements.define('pdc-location-select', PdcLocationSelect);
44
+ customElements.define('pdc-button', PdcButton);
45
+ customElements.define('pdc-label', PdcLabel);
46
+
47
+ // Template for this Custom Element
48
+ const template = document.createElement('template');
49
+ const templateRow = document.createElement('template');
50
+
51
+ // Custom Element
52
+ export class PdcLocationView extends HTMLElement {
53
+ /* INITIAL SETUP
54
+ */
55
+ #styled: boolean;
56
+ #valid = false;
57
+ constructor(styled: boolean, config: ConfigSectionText) {
58
+ super();
59
+ this.attachShadow({ mode: 'open' });
60
+
61
+ this.#styled = styled;
62
+ if (this.#styled) {
63
+ template.innerHTML = templateHTML;
64
+ applyStyles(this.#shadowRoot);
65
+ } else template.innerHTML = removeStyles(templateHTML);
66
+ this.#shadowRoot.appendChild(template.content.cloneNode(true));
67
+
68
+ this.#applyConfig(config);
69
+ this.#createEventListeners();
70
+ this.#addRow('initial');
71
+ }
72
+
73
+ #applyConfig = (config: ConfigSectionText) => {
74
+ const heading = this.shadowRoot?.querySelector<HTMLElement>('#heading');
75
+ const body = this.shadowRoot?.querySelector<HTMLElement>('#body');
76
+
77
+ if (heading && config.heading) {
78
+ heading.innerHTML = '';
79
+ heading.insertAdjacentHTML('beforeend', config.heading);
80
+ } else heading?.remove();
81
+
82
+ if (body && config.body) {
83
+ body.innerHTML = '';
84
+ body.insertAdjacentHTML('beforeend', config.body);
85
+ } else body?.remove();
86
+ };
87
+
88
+ /* EVENTS
89
+ */
90
+ #createEventListeners() {
91
+ const viewContainer =
92
+ this.shadowRoot?.querySelector<HTMLElement>('#view-container');
93
+
94
+ // Mouse, touch events
95
+ let pointerStartX = 0;
96
+ let pointerStartY = 0;
97
+ viewContainer?.addEventListener('pointerdown', e => {
98
+ if (!(e instanceof PointerEvent)) return;
99
+ const result = handlePointerDown(e);
100
+ pointerStartX = result.pointerStartX;
101
+ pointerStartY = result.pointerStartY;
102
+ });
103
+ viewContainer?.addEventListener('pointerup', e => {
104
+ if (e instanceof PointerEvent) {
105
+ const result = handlePointerUp(
106
+ e,
107
+ this.#handleClicks.bind(this),
108
+ pointerStartX,
109
+ pointerStartY,
110
+ );
111
+ pointerStartX = result.pointerStartX;
112
+ pointerStartY = result.pointerStartY;
113
+ }
114
+ });
115
+
116
+ // Keyboard events
117
+ viewContainer?.addEventListener('keydown', e => {
118
+ if (!(e.key === 'Enter' || e.key === ' ')) return;
119
+ this.#handleClicks(e);
120
+ });
121
+
122
+ // Resize events
123
+ const debouncedHandleResize = debounce(this.#windowResize.bind(this));
124
+ window.addEventListener('resize', debouncedHandleResize);
125
+ }
126
+
127
+ #handleClicks(e: Event) {
128
+ const target = e.target;
129
+ if (!(target instanceof SVGElement || target instanceof HTMLElement))
130
+ return;
131
+
132
+ const btnEl = target.closest('button');
133
+ const btnPdcEl = target.closest<PdcButton>('pdc-button');
134
+ const row = target.closest<HTMLElement>('[data-pdc="location-row"]');
135
+ switch (true) {
136
+ case btnEl?.getAttribute('id') === 'add-row':
137
+ this.#addRow();
138
+ return;
139
+ case btnEl?.dataset.pdc === 'delete-row':
140
+ this.#deleteRow(row);
141
+ return;
142
+ case btnPdcEl?.getAttribute('id') === 'calculate-expenses':
143
+ this.#validateRows('calculate');
144
+ return;
145
+ case !!target.closest('[data-pdc="location-row-toggle"]'):
146
+ this.#rowToggle(row);
147
+ return;
148
+ case !!target.closest('#expense-category') &&
149
+ e instanceof KeyboardEvent &&
150
+ target instanceof HTMLLabelElement:
151
+ target.click();
152
+ return;
153
+ case !!target.closest('#error'):
154
+ target.closest('#error')?.classList.remove('active');
155
+ return;
156
+ default:
157
+ return;
158
+ }
159
+ }
160
+
161
+ /* GET ELS
162
+ */
163
+
164
+ get #rowsContainer() {
165
+ const rowsContainer =
166
+ this.#shadowRoot.querySelector<HTMLElement>('#rows');
167
+ if (!rowsContainer)
168
+ throw new Error(
169
+ 'Failed to render rows container for location View.',
170
+ );
171
+ return rowsContainer;
172
+ }
173
+
174
+ get #rows() {
175
+ const rows = this.#shadowRoot.querySelectorAll<HTMLElement>(
176
+ '[data-pdc="location-row"]',
177
+ );
178
+ if (!rows)
179
+ throw new Error('Failed to render row elements for location View.');
180
+ return rows;
181
+ }
182
+
183
+ #getRowFromIndex(rowIndex: number) {
184
+ const row = this.#rowsContainer.children[rowIndex];
185
+ if (!(row instanceof HTMLElement))
186
+ throw new Error(
187
+ `Failed to get row using row index of ${rowIndex} in Location view.`,
188
+ );
189
+ return row;
190
+ }
191
+
192
+ #getRowPdcEls(row: Element) {
193
+ const start = row.querySelector<PdcLocationDate>('[pdc="start"]');
194
+ const end = row.querySelector<PdcLocationDate>('[pdc="end"]');
195
+ const category =
196
+ row.querySelector<PdcLocationCategory>('[pdc="category"]');
197
+ const country = row.querySelector<PdcLocationSelect>('[pdc="country"]');
198
+ const city = row.querySelector<PdcLocationSelect>('[pdc="city"]');
199
+ if (!(start && end && category && country && city))
200
+ throw new Error('Failed to render row custom elements.');
201
+ return {
202
+ start,
203
+ end,
204
+ category,
205
+ country,
206
+ city,
207
+ };
208
+ }
209
+
210
+ #getRowAnimatedEls(row: Element) {
211
+ const deleteBtn = row.querySelector<HTMLButtonElement>(
212
+ 'button[data-pdc="delete-row"]',
213
+ );
214
+ const summary = row.querySelector<HTMLElement>(
215
+ '[data-pdc="location-row-summary"]',
216
+ );
217
+ const details = row.querySelector<HTMLElement>(
218
+ '[data-pdc="location-row-details"]',
219
+ );
220
+ if (!(deleteBtn && summary && details))
221
+ throw new Error('Failed to render row summary elements.');
222
+ return {
223
+ deleteBtn,
224
+ summary,
225
+ details,
226
+ };
227
+ }
228
+
229
+ get #viewBtns() {
230
+ const addRow = this.shadowRoot
231
+ ?.querySelector('#add-row')
232
+ ?.closest('div');
233
+ const expenseCategory =
234
+ this.shadowRoot?.querySelector<HTMLElement>('#expense-category');
235
+ const calculateExpenses = this.shadowRoot
236
+ ?.querySelector('#calculate-expenses')
237
+ ?.closest('div');
238
+ if (!(addRow && expenseCategory && calculateExpenses))
239
+ throw new Error('Failed to render buttons for location View.');
240
+ return {
241
+ addRow,
242
+ expenseCategory,
243
+ calculateExpenses,
244
+ };
245
+ }
246
+
247
+ get #errorEl() {
248
+ const errorEl = this.#shadowRoot.querySelector('#error');
249
+ if (!errorEl) throw new Error('Failed to render row summary elements.');
250
+ return errorEl;
251
+ }
252
+
253
+ get #shadowRoot() {
254
+ if (!this.shadowRoot)
255
+ throw new Error(`Failed to render ShadowRoot for location View.`);
256
+ return this.shadowRoot;
257
+ }
258
+
259
+ #getRowIndex(row: Element) {
260
+ if (!row.parentNode)
261
+ throw new Error(`Failed to get row index in Location View.`);
262
+ return Array.from(
263
+ row.parentNode.querySelectorAll('[data-pdc="location-row"]'),
264
+ ).indexOf(row);
265
+ }
266
+
267
+ /* UPDATE METHODS
268
+ */
269
+
270
+ #addRow(initial: 'initial' | null = null) {
271
+ if (!this.#validateRows()) return; // Validate before adding rows
272
+ templateRow.innerHTML =
273
+ this.#styled ? templateRowHTML : removeStyles(templateRowHTML);
274
+ this.#rowsContainer.appendChild(templateRow.content.cloneNode(true));
275
+ const newRow = this.#rowsContainer.lastElementChild;
276
+ if (!(newRow instanceof HTMLElement))
277
+ throw new Error('Failed to render new row');
278
+ this.#rowToggle(newRow, initial ? initial : 'add');
279
+ }
280
+
281
+ async #deleteRow(row: HTMLElement | null) {
282
+ if (!row) return;
283
+ if (this.#rowsContainer.childElementCount === 1) {
284
+ this.#errorEl.classList.add('active');
285
+ this.#errorEl.textContent = '1 row required.';
286
+ return;
287
+ }
288
+ const prevRow = row.previousElementSibling;
289
+ const nextRow = row.nextElementSibling;
290
+ await this.#rowToggle(row, 'delete', nextRow);
291
+
292
+ // Update date input restrictions for existing rows
293
+ if (prevRow) {
294
+ this.#getRowPdcEls(prevRow).start.restrictStartInput();
295
+ this.#getRowPdcEls(prevRow).end.restrictStartInput();
296
+ }
297
+ if (nextRow) {
298
+ this.#getRowPdcEls(nextRow).start.restrictStartInput();
299
+ this.#getRowPdcEls(nextRow).end.restrictStartInput();
300
+ }
301
+
302
+ // Trigger #observer to update state
303
+ this.#rowsContainer.setAttribute('update-state', `true`);
304
+ }
305
+
306
+ #updateRowSummary(location: StateLocationItem): void {
307
+ const { index, start, end, country, city } = location;
308
+ const row = this.#getRowFromIndex(index);
309
+
310
+ const rowNumberEl = row.querySelector(
311
+ '[data-pdc="location-row-number"]',
312
+ );
313
+ const rowSummaryDatesEl = row.querySelector(
314
+ '[data-pdc="location-row-summary-dates"]',
315
+ );
316
+ const rowSummaryLocationEl = row.querySelector(
317
+ '[data-pdc="location-row-summary-countrycity"]',
318
+ );
319
+ if (!(rowNumberEl && rowSummaryDatesEl && rowSummaryLocationEl))
320
+ throw new Error('Failed to render row summary elements.');
321
+
322
+ const rowCount = (index + 1).toString().padStart(2, '0');
323
+ const startDate =
324
+ start ?
325
+ `${getMM(start)}/${getDD(start)}/${getYY(start)}`
326
+ : '\u00A0';
327
+ const endDate =
328
+ end ? ` to ${getMM(end)}/${getDD(end)}/${getYY(end)}` : '\u00A0';
329
+ const countryCity = city && country ? `${city} (${country})` : '\u00A0';
330
+
331
+ rowNumberEl.textContent = rowCount;
332
+ rowSummaryDatesEl.textContent = startDate + endDate;
333
+ rowSummaryLocationEl.textContent = countryCity;
334
+ }
335
+
336
+ createOptions(
337
+ rowIndex: number,
338
+ arr: Location[],
339
+ locationCategory: Extract<LocationKeys, 'country' | 'city'>,
340
+ ) {
341
+ const row = this.#getRowFromIndex(rowIndex);
342
+ const rowSelect = row.querySelector<PdcLocationSelect>(
343
+ `[pdc="${locationCategory}"]`,
344
+ );
345
+ rowSelect?.setOptions(arr);
346
+ }
347
+
348
+ #enableRowTabIndex(row: Element, enable: boolean) {
349
+ // Disable tab index for row's PdcEls
350
+ Object.values(this.#getRowPdcEls(row)).forEach(
351
+ el => el.isEnabled && el.enableTabIndex(enable),
352
+ );
353
+ this.#viewBtns.calculateExpenses
354
+ .querySelector<PdcButton>('pdc-button')
355
+ ?.enableTabIndex(!enable);
356
+ // Activate tabindex for delete icon which is hidden while row open
357
+ [
358
+ this.#getRowAnimatedEls(row).deleteBtn,
359
+ this.#viewBtns.addRow.querySelector('button'),
360
+ ...this.#viewBtns.expenseCategory.querySelectorAll('label'),
361
+ ].forEach(el => el && el.setAttribute('tabindex', enable ? '-1' : '0'));
362
+ }
363
+
364
+ #disableAllTabIndexes() {
365
+ this.#rows.forEach(row => {
366
+ Object.values(this.#getRowPdcEls(row)).forEach(
367
+ el => el.isEnabled && el.enableTabIndex(false),
368
+ this.#getRowAnimatedEls(row).deleteBtn.setAttribute(
369
+ 'tabindex',
370
+ '-1',
371
+ ),
372
+ );
373
+ });
374
+ this.#viewBtns.calculateExpenses
375
+ .querySelector<PdcButton>('pdc-button')
376
+ ?.enableTabIndex(false);
377
+
378
+ [
379
+ this.#viewBtns.addRow.querySelector('button'),
380
+ ...this.#viewBtns.expenseCategory.querySelectorAll('label'),
381
+ ].forEach(el => el && el.setAttribute('tabindex', '-1'));
382
+ }
383
+
384
+ /* VISUAL METHODS
385
+ */
386
+
387
+ #clearErrorEl() {
388
+ this.#errorEl.classList.remove('active');
389
+ }
390
+
391
+ async #rowToggle(
392
+ row: HTMLElement | null,
393
+ toggle: 'open' | 'close' | 'add' | 'initial' | 'delete' | null = null,
394
+ nextRow: Element | null = null,
395
+ ) {
396
+ if (!row) return;
397
+ if (!this.#styled || row.classList.contains('toggling')) return;
398
+ if (!toggle) {
399
+ this.#rowToggle(
400
+ row,
401
+ row.offsetHeight === ROW_CLOSED_HEIGHT ? 'open' : 'close',
402
+ );
403
+ return;
404
+ }
405
+ row.classList.remove(
406
+ 'pdc-row-open',
407
+ 'pdc-row-initial',
408
+ 'pdc-row-add',
409
+ 'pdc-row-close',
410
+ );
411
+ row.classList.add('toggling', `pdc-row-${toggle}`);
412
+ // Pre-toggle adjustments
413
+ if (toggle === 'close' || toggle === 'delete') this.#clearErrorEl();
414
+ if (toggle === 'open') this.#animateBtns('open');
415
+ if (toggle === 'add' || toggle === 'initial') {
416
+ if (this.#rowsContainer.childElementCount > 1 && toggle === 'add')
417
+ this.#animateBtns('open'); // Trigger animation only if there are existing rows
418
+ this.#styleRow(row);
419
+ this.#returnRowObject(row); // Sets row count
420
+ }
421
+ // Fire toggles
422
+ if (toggle === 'open' || toggle === 'add' || toggle === 'initial')
423
+ await this.#animateRow(row, 'open');
424
+ if (toggle === 'close') await this.#animateRow(row, 'close');
425
+ if (toggle === 'delete') await this.#animateRowDelete(row, nextRow);
426
+ if (toggle !== 'delete') {
427
+ row.classList.remove('ring-transparent');
428
+ row.classList.add('ring-neutral-200');
429
+ }
430
+ await wait(BTN_ANIMATE_MS);
431
+ row.classList.remove('toggling');
432
+ }
433
+
434
+ async #animateRow(row: HTMLElement, direction: 'open' | 'close') {
435
+ await wait(0);
436
+ this.#disableAllTabIndexes();
437
+ await wait(ROW_ANIMATE_MS);
438
+ this.#enableRowTabIndex(row, direction === 'open' ? true : false);
439
+ row.style.height = `${direction === 'open' ? row.scrollHeight : row.clientHeight}px`;
440
+ const { details, summary, deleteBtn } = this.#getRowAnimatedEls(row);
441
+ if (direction === 'open') details.removeAttribute('inert');
442
+ else details.setAttribute('inert', '');
443
+ [summary, deleteBtn].forEach(
444
+ el => (el.style.opacity = direction === 'open' ? '0' : '100'),
445
+ );
446
+ details.style.opacity = direction === 'open' ? '100' : '0';
447
+ details.style.transform =
448
+ direction === 'open' ? 'translateX(100%)' : `translateX(0%)`;
449
+ summary.style.transform =
450
+ direction === 'open' ? 'translateY(-200%)' : `translateY(0%)`;
451
+ deleteBtn.style.transform =
452
+ direction === 'open' ? 'translateX(200%)' : `translateX(0%)`;
453
+ if (direction === 'close') this.#animateBtns();
454
+ }
455
+
456
+ async #animateRowDelete(row: HTMLElement, nextRow: Element | null = null) {
457
+ row.classList.remove('ring-neutral-300');
458
+ row.classList.add('ring-transparent');
459
+ await wait(ROW_ANIMATE_MS);
460
+ // Deleted row was only row -> add a blank template row
461
+ row.remove();
462
+ this.#animateBtns();
463
+ if (nextRow) {
464
+ // For any next rows
465
+ const index = this.#getRowIndex(nextRow);
466
+ [...this.#rows]
467
+ .filter((_, i) => i >= index)
468
+ .map(remainingRow => {
469
+ this.#returnRowObject(remainingRow); // Update summary number
470
+ this.#styleRow(remainingRow); // Update background color
471
+ });
472
+ }
473
+ }
474
+
475
+ async #animateBtns(open: 'open' | null = null) {
476
+ const btns = [
477
+ this.#viewBtns.addRow,
478
+ this.#viewBtns.expenseCategory,
479
+ this.#viewBtns.calculateExpenses,
480
+ ];
481
+ const rowsOpen =
482
+ !!open ||
483
+ [...this.#rows].some(row => row.offsetHeight !== ROW_CLOSED_HEIGHT);
484
+ btns.forEach(btn =>
485
+ btn.classList.remove(rowsOpen ? 'rows-closed' : 'rows-open'),
486
+ );
487
+ btns.forEach(btn =>
488
+ btn.classList.add(rowsOpen ? 'rows-open' : 'rows-closed'),
489
+ );
490
+ await wait(BTN_ANIMATE_MS);
491
+ btns.forEach(btn => (btn.style.zIndex = rowsOpen ? '0' : '50'));
492
+ if (rowsOpen) {
493
+ this.#viewBtns.addRow.style.transform = `translateY(-100%)`;
494
+ this.#viewBtns.expenseCategory.style.transform = `translateY(400%)`;
495
+ this.#viewBtns.calculateExpenses.style.transform = `translateY(200%)`;
496
+ } else {
497
+ btns.forEach(btn => (btn.style.transform = `translateY(0%)`));
498
+ }
499
+ }
500
+
501
+ #windowResize = () => {
502
+ [...this.#rows].forEach(row => {
503
+ if (row.offsetHeight === ROW_CLOSED_HEIGHT) return;
504
+ row.style.height =
505
+ this.#getRowAnimatedEls(row).details.scrollHeight + 'px';
506
+ });
507
+ };
508
+
509
+ #styleRow = (row: HTMLElement) => {
510
+ const index = this.#getRowIndex(row);
511
+ const color = index % 2 === 0 ? 'neutral-50' : 'white';
512
+ const oppColor = color === 'neutral-50' ? 'white' : 'neutral-50';
513
+ row.classList.remove('bg-white', 'bg-neutral-50');
514
+ row.classList.add(`bg-${color}`);
515
+ row.style.zIndex = index.toString();
516
+ [...this.#getRowAnimatedEls(row).details.children].forEach((el, i) => {
517
+ el.setAttribute('bg', i % 2 === 0 ? color : oppColor);
518
+ });
519
+ };
520
+
521
+ showLoadingSpinner(
522
+ rowIndex: number,
523
+ enabled: boolean,
524
+ locationCategory: Extract<LocationKeys, 'country' | 'city'>,
525
+ ) {
526
+ if (!this.#styled) return;
527
+ const row = this.#getRowFromIndex(rowIndex);
528
+ row
529
+ .querySelector<PdcLocationSelect>(`[pdc='${locationCategory}']`)
530
+ ?.showLoadingSpinner(enabled);
531
+ }
532
+
533
+ /* VALIDATION
534
+ */
535
+ #getValidators = (): (
536
+ | PdcLocationDate
537
+ | PdcLocationCategory
538
+ | PdcLocationSelect
539
+ )[] => {
540
+ const dates =
541
+ this.#rowsContainer.querySelectorAll<PdcLocationDate>(
542
+ 'pdc-location-date',
543
+ );
544
+ const categories =
545
+ this.#rowsContainer.querySelectorAll<PdcLocationCategory>(
546
+ 'pdc-location-category',
547
+ );
548
+ const selects = this.#rowsContainer.querySelectorAll<PdcLocationSelect>(
549
+ 'pdc-location-select',
550
+ );
551
+ return [...dates, ...categories, ...selects];
552
+ };
553
+
554
+ #validatePdcEl = (
555
+ pdcEl: PdcLocationDate | PdcLocationCategory | PdcLocationSelect,
556
+ ): boolean => {
557
+ if (!pdcEl.validate()) {
558
+ const row = pdcEl.closest<HTMLElement>('[data-pdc="location-row"]');
559
+ if (row?.classList.contains('pdc-row-close'))
560
+ this.#rowToggle(row, 'open');
561
+ }
562
+ return pdcEl.validate();
563
+ };
564
+
565
+ #validateRows = (calculate: 'calculate' | null = null): boolean => {
566
+ const validators = this.#getValidators();
567
+ this.#valid = validators.every(el => this.#validatePdcEl(el));
568
+ if (this.#valid && calculate) {
569
+ if (!this.#tripIsLessThanSixMos()) {
570
+ this.#errorEl.textContent = 'Trip length must be < 6 months';
571
+ this.#errorEl.classList.add('active');
572
+ this.#valid = false;
573
+ return this.#valid;
574
+ }
575
+ this.#rowsContainer.setAttribute('validate', `${this.#valid}`);
576
+ }
577
+ return this.#valid;
578
+ };
579
+
580
+ #tripIsLessThanSixMos() {
581
+ const start = this.#getRowPdcEls(this.#rows[0]).start.pdcValue;
582
+ const end = this.#getRowPdcEls(this.#rows[this.#rows.length - 1]).end
583
+ .pdcValue;
584
+ if (start && end) {
585
+ const startDate = new Date(start);
586
+ const endDate = new Date(end);
587
+ const diffInMs = Math.abs(endDate.getTime() - startDate.getTime());
588
+ const days = Math.ceil(diffInMs / MILLISECONDS_IN_DAY);
589
+ if (days > APPROX_DAYS_IN_6_MONTHS) return false;
590
+ else return true;
591
+ }
592
+ return false;
593
+ }
594
+
595
+ /* CONTROLLER/VIEW METHODS
596
+ */
597
+ controllerHandler(
598
+ controlUpdateFunction: (
599
+ row: StateLocationItem,
600
+ changedAttr: LocationKeys,
601
+ newValue: string | null,
602
+ ) => void,
603
+ controlDeleteFunction: (updatedRows: StateLocationItem[]) => void,
604
+ controlValidateFunction: (viewValidator: AllViewLocationsValid) => void,
605
+ ) {
606
+ this.#observer(
607
+ controlUpdateFunction,
608
+ controlDeleteFunction,
609
+ controlValidateFunction,
610
+ this.#returnRowObject,
611
+ this.#returnAllRowsOjbect,
612
+ this.#returnValidation,
613
+ );
614
+ }
615
+
616
+ #observer(
617
+ controlUpdateFunction: (
618
+ row: StateLocationItem,
619
+ changedAttr: LocationKeys,
620
+ newValue: string | null,
621
+ ) => void,
622
+ controlDeleteFunction: (updatedRows: StateLocationItem[]) => void,
623
+ controlValidateFunction: (viewValidator: AllViewLocationsValid) => void,
624
+ viewUpdateFunction: (target: Element) => StateLocationItem,
625
+ viewDeleteFunction: () => StateLocationItem[],
626
+ viewValidateFunction: () => AllViewLocationsValid,
627
+ ) {
628
+ const callback = (mutations: MutationRecord[]) => {
629
+ mutations.forEach(mutation => {
630
+ const changedAttr = mutation.attributeName;
631
+ if (!(changedAttr && mutation.target instanceof Element))
632
+ return;
633
+ const target = mutation.target;
634
+ const newValue = target.getAttribute(changedAttr);
635
+ if (changedAttr === 'validate' && !!newValue) {
636
+ const result = viewValidateFunction();
637
+ controlValidateFunction(result);
638
+ return;
639
+ }
640
+ if (changedAttr === 'update-state' && !!newValue) {
641
+ const result = viewDeleteFunction();
642
+ controlDeleteFunction(result);
643
+ return;
644
+ }
645
+ if (inPrimitiveType<LocationKeys>(locationKeys, changedAttr)) {
646
+ const result = viewUpdateFunction(target);
647
+ controlUpdateFunction(result, changedAttr, newValue);
648
+ return;
649
+ }
650
+ return;
651
+ });
652
+ };
653
+ const debouncedCallback = debounce(callback);
654
+ const observer = new MutationObserver(debouncedCallback);
655
+ if (this.shadowRoot)
656
+ observer.observe(this.shadowRoot, {
657
+ subtree: true,
658
+ attributeFilter: ['update-state', 'validate', ...locationKeys],
659
+ });
660
+ }
661
+
662
+ #returnRowObject = (target: Element): StateLocationItem => {
663
+ const row = target.closest('[data-pdc="location-row"]');
664
+ if (!row) throw new Error('Failed to get row.');
665
+ const getPdcValue = <
666
+ T extends PdcLocationDate | PdcLocationCategory | PdcLocationSelect,
667
+ >(
668
+ tag: LocationKeys,
669
+ ) => {
670
+ return row.querySelector<T>(`[pdc="${tag}"]`)?.pdcValue;
671
+ };
672
+ const index = this.#getRowIndex(row);
673
+ const start = getPdcValue<PdcLocationDate>('start');
674
+ const end = getPdcValue<PdcLocationDate>('end');
675
+ const category = getPdcValue<PdcLocationCategory>('category');
676
+ const country = getPdcValue<PdcLocationSelect>('country');
677
+ const city = getPdcValue<PdcLocationSelect>('city');
678
+
679
+ const result: StateLocationItem = {
680
+ index,
681
+ ...(start && isDateRawType(start) && { start }),
682
+ ...(end && isDateRawType(end) && { end }),
683
+ ...((category === 'domestic' || category === 'intl') && {
684
+ category,
685
+ }),
686
+ ...(country && { country }),
687
+ ...(city && { city }),
688
+ };
689
+ this.#updateRowSummary(result);
690
+ return result;
691
+ };
692
+
693
+ #returnAllRowsOjbect = (): StateLocationItem[] => {
694
+ const result = [...this.#rows].map(row => {
695
+ return this.#returnRowObject(row);
696
+ });
697
+ this.removeAttribute('update-state');
698
+ return result;
699
+ };
700
+
701
+ #returnValidation = (): AllViewLocationsValid => {
702
+ this.removeAttribute('validate');
703
+ this.#rows.forEach(row => this.#rowToggle(row, 'close'));
704
+ const inputValue = this.shadowRoot?.querySelector<HTMLInputElement>(
705
+ 'input[name="expenses-category"]:checked',
706
+ )?.value;
707
+ const expensesCategory =
708
+ inputValue === 'mie' || inputValue === 'lodging' ?
709
+ inputValue
710
+ : 'both';
711
+ return {
712
+ valid: this.#valid,
713
+ expensesCategory,
714
+ };
715
+ };
716
+
717
+ async restrictRow(index: number, updatedAttr: LocationKeys) {
718
+ const row = this.#getRowFromIndex(index);
719
+ switch (updatedAttr) {
720
+ case 'city':
721
+ this.#rowToggle(row, 'close');
722
+ return;
723
+ case 'country':
724
+ this.#getRowPdcEls(row).city.enable(true);
725
+ return;
726
+ case 'category':
727
+ this.#restrictCategory(row);
728
+ return;
729
+ case 'end':
730
+ this.#restrictEnd(row);
731
+ return;
732
+ case 'start':
733
+ this.#restrictStart(row);
734
+ return;
735
+ }
736
+ }
737
+
738
+ #restrictStart(row: Element): void {
739
+ const { start, end, category, country, city } = this.#getRowPdcEls(row);
740
+ start.restrictStartInput();
741
+ end.restrictEndInput();
742
+ if (!end.pdcValue) {
743
+ category.enable(false);
744
+ country.enable(false);
745
+ city.enable(false);
746
+ }
747
+ }
748
+
749
+ #restrictEnd(row: Element): void {
750
+ const { start, end, category, country, city } = this.#getRowPdcEls(row);
751
+ country.enable(false);
752
+ city.enable(false);
753
+ start.restrictStartInput();
754
+ end.restrictEndInput();
755
+ category.enable(true);
756
+ }
757
+
758
+ #restrictCategory(row: Element) {
759
+ const { country, city } = this.#getRowPdcEls(row);
760
+ city.enable(false);
761
+ country.enable(true);
762
+ }
763
+ }