@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.
- package/.prettierrc +17 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/eslint.config.js +29 -0
- package/index.html +11 -0
- package/package.json +49 -0
- package/public/output.css +2503 -0
- package/src/css/_styles.css +8 -0
- package/src/css/colors.css +45 -0
- package/src/css/fonts.css +9 -0
- package/src/css/rows/_heights.css +6 -0
- package/src/css/rows/_index.css +15 -0
- package/src/css/rows/add.css +18 -0
- package/src/css/rows/animate-btns.css +18 -0
- package/src/css/rows/animate-row-close.css +18 -0
- package/src/css/rows/animate-row-open.css +14 -0
- package/src/css/rows/animate-row-other.css +5 -0
- package/src/css/rows/btn-add-row.css +41 -0
- package/src/css/rows/btn-delete.css +22 -0
- package/src/css/rows/btn-expenses-calculate.css +22 -0
- package/src/css/rows/btn-expenses-category.css +22 -0
- package/src/css/rows/delete.css +10 -0
- package/src/css/rows/details.css +22 -0
- package/src/css/rows/expense.css +18 -0
- package/src/css/rows/location.css +34 -0
- package/src/css/rows/summary.css +22 -0
- package/src/css/tom-select/defaults.css +530 -0
- package/src/css/tom-select/overrides.css +55 -0
- package/src/css/tw-shadow-props.css +50 -0
- package/src/index.ts +1 -0
- package/src/ts/components/Button/Button.ts +50 -0
- package/src/ts/components/Button/template.html +34 -0
- package/src/ts/components/ExpenseRow/ExpenseRow.ts +397 -0
- package/src/ts/components/ExpenseRow/template.html +260 -0
- package/src/ts/components/Label/Label.ts +45 -0
- package/src/ts/components/Label/template.html +1 -0
- package/src/ts/components/LocationCategory/LocationCategory.ts +226 -0
- package/src/ts/components/LocationCategory/template.html +520 -0
- package/src/ts/components/LocationDate/LocationDate.ts +366 -0
- package/src/ts/components/LocationDate/template.html +27 -0
- package/src/ts/components/LocationSelect/LocationSelect.ts +299 -0
- package/src/ts/components/LocationSelect/template.html +45 -0
- package/src/ts/components/index.ts +6 -0
- package/src/ts/controller.ts +193 -0
- package/src/ts/model.ts +163 -0
- package/src/ts/types/config.ts +22 -0
- package/src/ts/types/dates.ts +82 -0
- package/src/ts/types/expenses.ts +73 -0
- package/src/ts/types/locations.ts +25 -0
- package/src/ts/utils/config/configDefault.ts +13 -0
- package/src/ts/utils/config/index.ts +12 -0
- package/src/ts/utils/config/numbers.ts +24 -0
- package/src/ts/utils/config/sanitizeConfig.ts +39 -0
- package/src/ts/utils/dates/INPUT_DATE_MINMAX.ts +5 -0
- package/src/ts/utils/dates/YEAR_REGEX.ts +4 -0
- package/src/ts/utils/dates/getDateSlice.ts +54 -0
- package/src/ts/utils/dates/getValidAPIYear.ts +17 -0
- package/src/ts/utils/dates/index.ts +19 -0
- package/src/ts/utils/dates/isDateRaw.ts +90 -0
- package/src/ts/utils/dates/isShortMonth.ts +24 -0
- package/src/ts/utils/dates/isYYYY.ts +10 -0
- package/src/ts/utils/dates/offsetDateString.ts +17 -0
- package/src/ts/utils/expenses/INTL_MIE_RATES.ts +2125 -0
- package/src/ts/utils/expenses/createExpenseObjs.ts +35 -0
- package/src/ts/utils/expenses/getLodgingRateDomestic.ts +73 -0
- package/src/ts/utils/expenses/getLodgingRateIntl.ts +119 -0
- package/src/ts/utils/expenses/getMieRates.ts +84 -0
- package/src/ts/utils/expenses/index.ts +5 -0
- package/src/ts/utils/expenses/parseIntlLodgingRates.ts +124 -0
- package/src/ts/utils/expenses/returnValidStateExpense.ts +46 -0
- package/src/ts/utils/fetch/fetchJsonGSA.ts +29 -0
- package/src/ts/utils/fetch/fetchXmlDOD.ts +38 -0
- package/src/ts/utils/fetch/index.ts +3 -0
- package/src/ts/utils/fetch/memoize.ts +46 -0
- package/src/ts/utils/fetch/parseXml.ts +19 -0
- package/src/ts/utils/locations/getCitiesDomestic.ts +48 -0
- package/src/ts/utils/locations/getCitiesIntl.ts +63 -0
- package/src/ts/utils/locations/getCountriesDomestic.ts +237 -0
- package/src/ts/utils/locations/getCountriesIntl.ts +34 -0
- package/src/ts/utils/locations/index.ts +6 -0
- package/src/ts/utils/locations/keepUniqueLocations.ts +12 -0
- package/src/ts/utils/locations/locationKeys.ts +10 -0
- package/src/ts/utils/locations/returnValidStateLocation.ts +13 -0
- package/src/ts/utils/locations/sortLocations.ts +19 -0
- package/src/ts/utils/misc/USD.ts +4 -0
- package/src/ts/utils/misc/debounce.ts +22 -0
- package/src/ts/utils/misc/handlePointerDown.ts +3 -0
- package/src/ts/utils/misc/handlePointerUp.ts +22 -0
- package/src/ts/utils/misc/inPrimitiveType.ts +4 -0
- package/src/ts/utils/misc/index.ts +6 -0
- package/src/ts/utils/misc/wait.ts +4 -0
- package/src/ts/utils/styles/applyStyles.ts +19 -0
- package/src/ts/utils/styles/highlightInput.ts +15 -0
- package/src/ts/utils/styles/index.ts +3 -0
- package/src/ts/utils/styles/removeStyles.ts +14 -0
- package/src/ts/views/Expense/Expense.ts +465 -0
- package/src/ts/views/Expense/template.html +176 -0
- package/src/ts/views/Location/Location.ts +763 -0
- package/src/ts/views/Location/template-row.html +146 -0
- package/src/ts/views/Location/template.html +130 -0
- package/src/ts/views/index.ts +2 -0
- package/tsconfig.json +27 -0
- 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
|
+
}
|