@svgrid/grid 1.2.22 → 1.2.23
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/dist/GridFooter.svelte +34 -20
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/SvAutoComplete.svelte +100 -0
- package/dist/SvAutoComplete.svelte.d.ts +16 -0
- package/dist/SvButton.svelte +97 -0
- package/dist/SvButton.svelte.d.ts +23 -0
- package/dist/SvCalendar.svelte +669 -0
- package/dist/SvCalendar.svelte.d.ts +70 -0
- package/dist/SvCheckBox.svelte +81 -0
- package/dist/SvCheckBox.svelte.d.ts +19 -0
- package/dist/SvColorInput.svelte +187 -0
- package/dist/SvColorInput.svelte.d.ts +15 -0
- package/dist/SvComboBox.svelte +130 -0
- package/dist/SvComboBox.svelte.d.ts +15 -0
- package/dist/SvCountryInput.svelte +123 -0
- package/dist/SvCountryInput.svelte.d.ts +14 -0
- package/dist/SvDateTimePicker.svelte +322 -0
- package/dist/SvDateTimePicker.svelte.d.ts +37 -0
- package/dist/SvDropDownList.svelte +143 -0
- package/dist/SvDropDownList.svelte.d.ts +15 -0
- package/dist/SvForm.svelte +147 -0
- package/dist/SvForm.svelte.d.ts +29 -0
- package/dist/SvGauge.svelte +99 -0
- package/dist/SvGauge.svelte.d.ts +31 -0
- package/dist/SvGrid.controller.svelte.d.ts +16 -2
- package/dist/SvGrid.controller.svelte.js +63 -8
- package/dist/SvGrid.css +84 -31
- package/dist/SvGrid.helpers.d.ts +4 -0
- package/dist/SvGrid.helpers.js +40 -12
- package/dist/SvGrid.svelte +84 -5
- package/dist/SvGrid.types.d.ts +36 -0
- package/dist/SvGridDropdown.svelte +16 -30
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvListBox.svelte +122 -0
- package/dist/SvListBox.svelte.d.ts +19 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvMaskedInput.svelte.d.ts +15 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +22 -0
- package/dist/SvPasswordInput.svelte +110 -0
- package/dist/SvPasswordInput.svelte.d.ts +22 -0
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvPhoneInput.svelte.d.ts +19 -0
- package/dist/SvRadioGroup.svelte +108 -0
- package/dist/SvRadioGroup.svelte.d.ts +23 -0
- package/dist/SvRating.svelte +111 -0
- package/dist/SvRating.svelte.d.ts +20 -0
- package/dist/SvRepeatButton.svelte +89 -0
- package/dist/SvRepeatButton.svelte.d.ts +21 -0
- package/dist/SvSlider.svelte +182 -0
- package/dist/SvSlider.svelte.d.ts +25 -0
- package/dist/SvSwitchButton.svelte +94 -0
- package/dist/SvSwitchButton.svelte.d.ts +19 -0
- package/dist/SvTabs.svelte +122 -0
- package/dist/SvTabs.svelte.d.ts +26 -0
- package/dist/SvTagsInput.svelte +81 -0
- package/dist/SvTagsInput.svelte.d.ts +19 -0
- package/dist/SvTimePicker.svelte +359 -0
- package/dist/SvTimePicker.svelte.d.ts +27 -0
- package/dist/SvToggleButton.svelte +57 -0
- package/dist/SvToggleButton.svelte.d.ts +16 -0
- package/dist/SvTree.svelte +210 -0
- package/dist/SvTree.svelte.d.ts +29 -0
- package/dist/build-api.js +78 -1
- package/dist/cdn/svgrid.js +10022 -5904
- package/dist/cdn/svgrid.svelte-external.js +8531 -4429
- package/dist/cell-render.d.ts +4 -0
- package/dist/cell-render.js +35 -1
- package/dist/clipboard.js +19 -1
- package/dist/collaboration.d.ts +2 -0
- package/dist/collaboration.js +4 -0
- package/dist/core.d.ts +22 -1
- package/dist/countries.d.ts +15 -0
- package/dist/countries.js +61 -0
- package/dist/datetime/date-core.d.ts +88 -0
- package/dist/datetime/date-core.js +165 -0
- package/dist/datetime/date-format.d.ts +39 -0
- package/dist/datetime/date-format.js +371 -0
- package/dist/datetime/date-restrict.d.ts +23 -0
- package/dist/datetime/date-restrict.js +50 -0
- package/dist/datetime/date-selection.d.ts +25 -0
- package/dist/datetime/date-selection.js +94 -0
- package/dist/datetime/mask.d.ts +21 -0
- package/dist/datetime/mask.js +90 -0
- package/dist/editing.d.ts +1 -1
- package/dist/editing.js +7 -0
- package/dist/editors/cell-editors.d.ts +2 -2
- package/dist/editors/cell-editors.js +3 -1
- package/dist/export-format.d.ts +178 -0
- package/dist/export-format.js +419 -0
- package/dist/hyperformula-adapter.d.ts +4 -0
- package/dist/hyperformula-adapter.js +15 -6
- package/dist/index.d.ts +36 -0
- package/dist/index.js +41 -0
- package/dist/list-option.d.ts +10 -0
- package/dist/list-option.js +7 -0
- package/dist/menus.js +18 -2
- package/dist/popover.d.ts +48 -0
- package/dist/popover.js +65 -0
- package/dist/row-resize.d.ts +4 -4
- package/dist/row-resize.js +8 -5
- package/dist/selection.d.ts +6 -0
- package/dist/selection.js +31 -5
- package/dist/server-data-source.d.ts +43 -0
- package/dist/server-data-source.js +100 -0
- package/dist/sparkline.js +8 -4
- package/dist/svgrid-wrapper.types.d.ts +26 -1
- package/package.json +6 -1
- package/src/GridFooter.svelte +34 -20
- package/src/SvAutoComplete.svelte +100 -0
- package/src/SvButton.svelte +97 -0
- package/src/SvCalendar.svelte +669 -0
- package/src/SvCalendar.test.ts +211 -0
- package/src/SvCheckBox.svelte +81 -0
- package/src/SvColorInput.svelte +187 -0
- package/src/SvComboBox.svelte +130 -0
- package/src/SvCountryInput.svelte +123 -0
- package/src/SvDateTimePicker.svelte +322 -0
- package/src/SvDateTimePicker.test.ts +136 -0
- package/src/SvDropDownList.svelte +143 -0
- package/src/SvForm.svelte +147 -0
- package/src/SvGauge.svelte +99 -0
- package/src/SvGrid.controller.svelte.ts +74 -8
- package/src/SvGrid.css +84 -31
- package/src/SvGrid.helpers.ts +41 -12
- package/src/SvGrid.svelte +84 -5
- package/src/SvGrid.types.ts +33 -0
- package/src/SvGridDropdown.svelte +16 -30
- package/src/SvListBox.svelte +122 -0
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +110 -0
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +108 -0
- package/src/SvRating.svelte +111 -0
- package/src/SvRepeatButton.svelte +89 -0
- package/src/SvSlider.svelte +182 -0
- package/src/SvSwitchButton.svelte +94 -0
- package/src/SvTabs.svelte +122 -0
- package/src/SvTagsInput.svelte +81 -0
- package/src/SvTimePicker.svelte +359 -0
- package/src/SvTimePicker.test.ts +98 -0
- package/src/SvToggleButton.svelte +57 -0
- package/src/SvTree.svelte +210 -0
- package/src/build-api.ts +115 -0
- package/src/cell-render.test.ts +38 -0
- package/src/cell-render.ts +43 -0
- package/src/clipboard.test.ts +18 -0
- package/src/clipboard.ts +24 -1
- package/src/collaboration.test.ts +30 -0
- package/src/collaboration.ts +6 -0
- package/src/core.ts +27 -3
- package/src/countries.ts +71 -0
- package/src/datetime/date-core.test.ts +217 -0
- package/src/datetime/date-core.ts +204 -0
- package/src/datetime/date-format.test.ts +121 -0
- package/src/datetime/date-format.ts +317 -0
- package/src/datetime/date-restrict.ts +60 -0
- package/src/datetime/date-selection.test.ts +129 -0
- package/src/datetime/date-selection.ts +137 -0
- package/src/datetime/mask.test.ts +36 -0
- package/src/datetime/mask.ts +84 -0
- package/src/editing.test.ts +22 -0
- package/src/editing.ts +7 -1
- package/src/editors/cell-editors.ts +7 -1
- package/src/export-data-api.test.ts +126 -0
- package/src/export-format.test.ts +107 -0
- package/src/export-format.ts +598 -0
- package/src/hyperformula-adapter.test.ts +35 -1
- package/src/hyperformula-adapter.ts +18 -6
- package/src/index.ts +71 -0
- package/src/list-option.ts +15 -0
- package/src/menus.ts +21 -2
- package/src/popover.ts +79 -0
- package/src/row-resize.test.ts +25 -0
- package/src/row-resize.ts +8 -5
- package/src/selection.test.ts +36 -4
- package/src/selection.ts +30 -9
- package/src/server-data-source.test.ts +201 -0
- package/src/server-data-source.ts +148 -0
- package/src/sparkline.test.ts +9 -0
- package/src/sparkline.ts +9 -4
- package/src/svgrid-wrapper.types.ts +28 -1
- package/src/svgrid.behavior.test.ts +27 -0
- package/src/svgrid.new-features.wrapper.test.ts +27 -1
- package/src/ui-buttons.test.ts +105 -0
- package/src/ui-composite.test.ts +89 -0
- package/src/ui-inputs.test.ts +92 -0
- package/src/ui-range.test.ts +61 -0
- package/src/ui-selection.test.ts +106 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvCountryInput - a searchable country picker (flag + name + dial code).
|
|
4
|
+
* Emits the ISO 3166-1 alpha-2 code. Parity: Smart country-input pattern.
|
|
5
|
+
* Data from countries.ts; popover via popover.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
8
|
+
import { COUNTRIES, COUNTRY_BY_CODE, flagEmoji } from './countries'
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
value?: string | null
|
|
12
|
+
onChange?: (code: string) => void
|
|
13
|
+
/** Show the dial code beside each country. */
|
|
14
|
+
showDial?: boolean
|
|
15
|
+
placeholder?: string
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
name?: string
|
|
18
|
+
size?: 'sm' | 'md' | 'lg'
|
|
19
|
+
ariaLabel?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let { value = null, onChange, showDial = false, placeholder = 'Select country…', disabled = false, name, size = 'md', ariaLabel }: Props = $props()
|
|
23
|
+
|
|
24
|
+
let open = $state(false)
|
|
25
|
+
let query = $state('')
|
|
26
|
+
let active = $state(0)
|
|
27
|
+
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
28
|
+
let searchEl = $state<HTMLInputElement | null>(null)
|
|
29
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
30
|
+
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
31
|
+
|
|
32
|
+
const selected = $derived(value ? COUNTRY_BY_CODE.get(value) ?? null : null)
|
|
33
|
+
const filtered = $derived(
|
|
34
|
+
query.trim() === ''
|
|
35
|
+
? COUNTRIES
|
|
36
|
+
: COUNTRIES.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()) || c.dial.includes(query) || c.code.toLowerCase() === query.toLowerCase()),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
function updatePos() { if (triggerEl) rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 320, minWidth: 240 }) }
|
|
40
|
+
function openPanel() { if (disabled || open) return; open = true; query = ''; active = 0; updatePos(); queueMicrotask(() => searchEl?.focus()) }
|
|
41
|
+
function close() { open = false; triggerEl?.focus() }
|
|
42
|
+
function pick(code: string) { onChange?.(code); open = false; triggerEl?.focus() }
|
|
43
|
+
function onKeydown(e: KeyboardEvent) {
|
|
44
|
+
if (!open) return
|
|
45
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
|
|
46
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
|
|
47
|
+
else if (e.key === 'Enter') { e.preventDefault(); const c = filtered[active]; if (c) pick(c.code) }
|
|
48
|
+
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
49
|
+
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
$effect(() => {
|
|
53
|
+
if (!open) return
|
|
54
|
+
const rp = () => updatePos()
|
|
55
|
+
window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
|
|
56
|
+
const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; open = false }
|
|
57
|
+
document.addEventListener('pointerdown', od, true)
|
|
58
|
+
return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
|
|
59
|
+
})
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<button bind:this={triggerEl} type="button" class="sv-country sv-country--{size}" class:is-open={open} class:is-disabled={disabled} aria-haspopup="listbox" aria-expanded={open} aria-label={ariaLabel} {disabled} onclick={() => (open ? (open = false) : openPanel())}>
|
|
63
|
+
{#if selected}
|
|
64
|
+
<span class="sv-country__flag" aria-hidden="true">{flagEmoji(selected.code)}</span>
|
|
65
|
+
<span class="sv-country__name">{selected.name}</span>
|
|
66
|
+
{#if showDial}<span class="sv-country__dial">{selected.dial}</span>{/if}
|
|
67
|
+
{:else}
|
|
68
|
+
<span class="sv-country__ph">{placeholder}</span>
|
|
69
|
+
{/if}
|
|
70
|
+
<svg class="sv-country__chev" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
71
|
+
</button>
|
|
72
|
+
|
|
73
|
+
{#if open}
|
|
74
|
+
<div bind:this={panelEl} class="sv-country__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} role="dialog">
|
|
75
|
+
<input bind:this={searchEl} class="sv-country__search" type="text" bind:value={query} placeholder="Search countries…" aria-label="Search countries" onkeydown={onKeydown} oninput={() => { active = 0; updatePos() }} />
|
|
76
|
+
<div class="sv-country__list" role="listbox">
|
|
77
|
+
{#each filtered as c, i (c.code)}
|
|
78
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
|
79
|
+
<div class="sv-country__opt" class:is-active={i === active} class:is-selected={c.code === value} role="option" tabindex="-1" aria-selected={c.code === value} data-idx={i} onclick={() => pick(c.code)} onpointermove={() => (active = i)}>
|
|
80
|
+
<span class="sv-country__flag" aria-hidden="true">{flagEmoji(c.code)}</span>
|
|
81
|
+
<span class="sv-country__name">{c.name}</span>
|
|
82
|
+
<span class="sv-country__dial">{c.dial}</span>
|
|
83
|
+
</div>
|
|
84
|
+
{:else}
|
|
85
|
+
<div class="sv-ddl__empty">No matches</div>
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
{/if}
|
|
90
|
+
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.sv-country {
|
|
94
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
95
|
+
display: inline-flex; align-items: center; gap: 8px; width: 220px;
|
|
96
|
+
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; text-align: left;
|
|
97
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px); padding: 0 10px; cursor: pointer;
|
|
98
|
+
}
|
|
99
|
+
.sv-country--sm { height: 28px; font-size: 12px; }
|
|
100
|
+
.sv-country--md { height: 34px; font-size: 13px; }
|
|
101
|
+
.sv-country--lg { height: 40px; font-size: 15px; }
|
|
102
|
+
.sv-country.is-open, .sv-country:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); outline: none; }
|
|
103
|
+
.sv-country.is-disabled { opacity: 0.6; cursor: not-allowed; }
|
|
104
|
+
.sv-country__flag { font-size: 16px; }
|
|
105
|
+
.sv-country__name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
106
|
+
.sv-country__ph { flex: 1; color: var(--sg-muted, #94a3b8); }
|
|
107
|
+
.sv-country__dial { color: var(--sg-muted, #64748b); font-size: 12px; }
|
|
108
|
+
.sv-country__chev { color: var(--sg-muted, #64748b); flex: none; }
|
|
109
|
+
|
|
110
|
+
:global(.sv-country__panel) {
|
|
111
|
+
z-index: 2147483647; display: flex; flex-direction: column; max-height: 340px; padding: 6px;
|
|
112
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
113
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
|
|
114
|
+
}
|
|
115
|
+
:global(.sv-country__search) {
|
|
116
|
+
height: 32px; margin-bottom: 4px; padding: 0 10px; font: inherit; font-size: 13px;
|
|
117
|
+
border: 1px solid var(--sg-input-border, #cbd5e1); border-radius: 7px; background: var(--sg-input-bg, #fff); color: inherit; outline: none;
|
|
118
|
+
}
|
|
119
|
+
:global(.sv-country__list) { overflow-y: auto; }
|
|
120
|
+
:global(.sv-country__opt) { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
121
|
+
:global(.sv-country__opt.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
122
|
+
:global(.sv-country__opt.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
|
|
123
|
+
</style>
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type DateTimeValue = Date | string | number | null
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* SvDateTimePicker - a text input (formatted via the date-format token engine)
|
|
8
|
+
* plus a dropdown that composes SvCalendar + SvTimePicker behind DATE / TIME
|
|
9
|
+
* tabs. Parity target: Smart `smart-date-time-picker`. Works standalone and as
|
|
10
|
+
* the SvGrid `datetime` cell editor. The dropdown portals to <body> (via
|
|
11
|
+
* popover.ts) so it is never clipped by the grid's scroll container.
|
|
12
|
+
*/
|
|
13
|
+
import SvCalendar from './SvCalendar.svelte'
|
|
14
|
+
import SvTimePicker from './SvTimePicker.svelte'
|
|
15
|
+
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
16
|
+
import { formatDate, parseDate } from './datetime/date-format'
|
|
17
|
+
import { toDate, clampDate, withTime, startOfDay, type DateLike } from './datetime/date-core'
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
value?: DateTimeValue
|
|
21
|
+
onChange?: (value: Date | null) => void
|
|
22
|
+
/** Fired when the value is finalized (Enter, blur, or a single-date pick).
|
|
23
|
+
* Used by the grid to save the cell. */
|
|
24
|
+
onCommit?: (value: Date | null) => void
|
|
25
|
+
/** Fired on Escape / dismiss without committing (grid cancels the edit). */
|
|
26
|
+
onCancel?: () => void
|
|
27
|
+
/** Display / parse mask (token engine). */
|
|
28
|
+
formatString?: string
|
|
29
|
+
min?: DateLike | null
|
|
30
|
+
max?: DateLike | null
|
|
31
|
+
nullable?: boolean
|
|
32
|
+
placeholder?: string
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
readonly?: boolean
|
|
35
|
+
name?: string
|
|
36
|
+
locale?: string
|
|
37
|
+
firstDayOfWeek?: number
|
|
38
|
+
weekNumbers?: boolean
|
|
39
|
+
hourFormat?: '12-hour' | '24-hour'
|
|
40
|
+
minuteInterval?: number
|
|
41
|
+
/** Which tabs the dropdown shows. */
|
|
42
|
+
dropDownDisplayMode?: 'both' | 'calendar' | 'time'
|
|
43
|
+
/** Up/down spinner buttons that bump the value by `stepMinutes`. */
|
|
44
|
+
spinButtons?: boolean
|
|
45
|
+
stepMinutes?: number
|
|
46
|
+
/** Open the dropdown as soon as the field is focused (grid in-cell editing). */
|
|
47
|
+
autoOpen?: boolean
|
|
48
|
+
/** Animate the calendar's month/drill navigation (honors reduced-motion). */
|
|
49
|
+
animate?: boolean | 'slide' | 'fade'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let {
|
|
53
|
+
value = null,
|
|
54
|
+
onChange,
|
|
55
|
+
onCommit,
|
|
56
|
+
onCancel,
|
|
57
|
+
formatString = 'yyyy-MM-dd HH:mm',
|
|
58
|
+
min = null,
|
|
59
|
+
max = null,
|
|
60
|
+
nullable = true,
|
|
61
|
+
placeholder = 'Select date & time',
|
|
62
|
+
disabled = false,
|
|
63
|
+
readonly = false,
|
|
64
|
+
name,
|
|
65
|
+
locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US',
|
|
66
|
+
firstDayOfWeek = 0,
|
|
67
|
+
weekNumbers = false,
|
|
68
|
+
hourFormat = '24-hour',
|
|
69
|
+
minuteInterval = 1,
|
|
70
|
+
dropDownDisplayMode = 'both',
|
|
71
|
+
spinButtons = false,
|
|
72
|
+
stepMinutes = 1,
|
|
73
|
+
autoOpen = false,
|
|
74
|
+
animate = false,
|
|
75
|
+
}: Props = $props()
|
|
76
|
+
|
|
77
|
+
const isInteractive = $derived(!disabled && !readonly)
|
|
78
|
+
const minD = $derived(toDate(min ?? null))
|
|
79
|
+
const maxD = $derived(toDate(max ?? null))
|
|
80
|
+
|
|
81
|
+
let current = $state<Date | null>(null)
|
|
82
|
+
let text = $state('')
|
|
83
|
+
let lastKey = ''
|
|
84
|
+
$effect(() => {
|
|
85
|
+
const d = toDate(value as DateLike)
|
|
86
|
+
const key = d ? String(d.getTime()) : 'null'
|
|
87
|
+
if (key !== lastKey) {
|
|
88
|
+
lastKey = key
|
|
89
|
+
current = d
|
|
90
|
+
text = d ? formatDate(d, formatString, locale) : ''
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// --- Commit helpers ---
|
|
95
|
+
function commit(next: Date | null) {
|
|
96
|
+
const clamped = next ? clampDate(next, minD, maxD) : null
|
|
97
|
+
current = clamped
|
|
98
|
+
lastKey = clamped ? String(clamped.getTime()) : 'null'
|
|
99
|
+
text = clamped ? formatDate(clamped, formatString, locale) : ''
|
|
100
|
+
onChange?.(clamped)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function commitFromText() {
|
|
104
|
+
const raw = text.trim()
|
|
105
|
+
if (!raw) {
|
|
106
|
+
if (nullable) commit(null)
|
|
107
|
+
else text = current ? formatDate(current, formatString, locale) : ''
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const parsed = parseDate(raw, formatString, locale, 2029, current ?? new Date())
|
|
111
|
+
if (parsed) commit(parsed)
|
|
112
|
+
else text = current ? formatDate(current, formatString, locale) : '' // revert on bad input
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bump(deltaMin: number) {
|
|
116
|
+
const base = current ?? new Date()
|
|
117
|
+
commit(new Date(base.getTime() + deltaMin * 60000))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Sub-picker wiring ---
|
|
121
|
+
function onCalendarChange(dates: Date[]) {
|
|
122
|
+
const day = dates[0]
|
|
123
|
+
if (!day) return
|
|
124
|
+
commit(withTime(day, current ?? startOfDay(day)))
|
|
125
|
+
// Date-only picker: a single pick finalizes the value.
|
|
126
|
+
if (dropDownDisplayMode === 'calendar') { open = false; onCommit?.(current) }
|
|
127
|
+
else if (dropDownDisplayMode === 'both') tab = 'time'
|
|
128
|
+
}
|
|
129
|
+
function onTimeChange(t: Date) {
|
|
130
|
+
const base = current ?? new Date()
|
|
131
|
+
commit(withTime(base, t))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Dropdown (portalled) ---
|
|
135
|
+
let open = $state(false)
|
|
136
|
+
let tab = $state<'date' | 'time'>('date')
|
|
137
|
+
let triggerEl = $state<HTMLDivElement | null>(null)
|
|
138
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
139
|
+
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
140
|
+
|
|
141
|
+
function updatePos() {
|
|
142
|
+
if (!triggerEl) return
|
|
143
|
+
panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 360, minWidth: 300 })
|
|
144
|
+
}
|
|
145
|
+
function openPanel() {
|
|
146
|
+
if (!isInteractive || open) return
|
|
147
|
+
tab = dropDownDisplayMode === 'time' ? 'time' : 'date'
|
|
148
|
+
open = true
|
|
149
|
+
updatePos()
|
|
150
|
+
}
|
|
151
|
+
function closePanel() { open = false }
|
|
152
|
+
function toggle() { (open ? closePanel : openPanel)() }
|
|
153
|
+
|
|
154
|
+
$effect(() => {
|
|
155
|
+
if (!open) return
|
|
156
|
+
const reposition = () => updatePos()
|
|
157
|
+
window.addEventListener('scroll', reposition, true)
|
|
158
|
+
window.addEventListener('resize', reposition)
|
|
159
|
+
const onDown = (e: PointerEvent) => {
|
|
160
|
+
const t = e.target as Node | null
|
|
161
|
+
if (!t) return
|
|
162
|
+
if (triggerEl?.contains(t) || panelEl?.contains(t)) return
|
|
163
|
+
closePanel()
|
|
164
|
+
}
|
|
165
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
166
|
+
return () => {
|
|
167
|
+
window.removeEventListener('scroll', reposition, true)
|
|
168
|
+
window.removeEventListener('resize', reposition)
|
|
169
|
+
document.removeEventListener('pointerdown', onDown, true)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
function onInputKeydown(e: KeyboardEvent) {
|
|
174
|
+
if (e.key === 'Enter') { e.preventDefault(); commitFromText(); closePanel(); onCommit?.(current) }
|
|
175
|
+
else if (e.key === 'Escape') { closePanel(); onCancel?.() }
|
|
176
|
+
else if (e.key === 'ArrowDown' && (e.altKey || !text)) { e.preventDefault(); openPanel() }
|
|
177
|
+
else if (spinButtons && e.key === 'ArrowUp') { e.preventDefault(); bump(stepMinutes) }
|
|
178
|
+
else if (spinButtons && e.key === 'ArrowDown') { e.preventDefault(); bump(-stepMinutes) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function focusOpen(node: HTMLInputElement) {
|
|
182
|
+
if (!autoOpen) return
|
|
183
|
+
node.focus()
|
|
184
|
+
openPanel()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onRootFocusOut(e: FocusEvent) {
|
|
188
|
+
const next = e.relatedTarget as Node | null
|
|
189
|
+
// Ignore focus moving within the field or into the portalled panel.
|
|
190
|
+
if (next && (triggerEl?.contains(next) || panelEl?.contains(next))) return
|
|
191
|
+
onCommit?.(current)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const showDateTab = $derived(dropDownDisplayMode !== 'time')
|
|
195
|
+
const showTimeTab = $derived(dropDownDisplayMode !== 'calendar')
|
|
196
|
+
</script>
|
|
197
|
+
|
|
198
|
+
<div class="sv-dtp" class:sv-dtp--disabled={disabled} onfocusout={onRootFocusOut}>
|
|
199
|
+
<div class="sv-dtp__field" bind:this={triggerEl}>
|
|
200
|
+
{#if spinButtons}
|
|
201
|
+
<div class="sv-dtp__spin">
|
|
202
|
+
<button type="button" tabindex="-1" aria-label="Increment" onclick={() => bump(stepMinutes)} disabled={!isInteractive}>▲</button>
|
|
203
|
+
<button type="button" tabindex="-1" aria-label="Decrement" onclick={() => bump(-stepMinutes)} disabled={!isInteractive}>▼</button>
|
|
204
|
+
</div>
|
|
205
|
+
{/if}
|
|
206
|
+
<input
|
|
207
|
+
class="sv-dtp__input"
|
|
208
|
+
type="text"
|
|
209
|
+
bind:value={text}
|
|
210
|
+
{placeholder}
|
|
211
|
+
{disabled}
|
|
212
|
+
{readonly}
|
|
213
|
+
onblur={commitFromText}
|
|
214
|
+
onkeydown={onInputKeydown}
|
|
215
|
+
use:focusOpen
|
|
216
|
+
/>
|
|
217
|
+
{#if nullable && current && isInteractive}
|
|
218
|
+
<button type="button" class="sv-dtp__clear" aria-label="Clear" onclick={() => commit(null)}>×</button>
|
|
219
|
+
{/if}
|
|
220
|
+
<button type="button" class="sv-dtp__toggle" aria-label="Open picker" aria-haspopup="dialog" aria-expanded={open} onclick={toggle} disabled={!isInteractive} tabindex="-1">
|
|
221
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{#if open}
|
|
226
|
+
<div
|
|
227
|
+
class="sv-dtp__panel"
|
|
228
|
+
bind:this={panelEl}
|
|
229
|
+
use:portalToBody
|
|
230
|
+
style:position="fixed"
|
|
231
|
+
style:top={`${panelRect.top}px`}
|
|
232
|
+
style:left={`${panelRect.left}px`}
|
|
233
|
+
role="dialog"
|
|
234
|
+
aria-label="Choose date and time"
|
|
235
|
+
>
|
|
236
|
+
{#if showDateTab && showTimeTab}
|
|
237
|
+
<div class="sv-dtp__tabs" role="tablist">
|
|
238
|
+
<button type="button" role="tab" aria-selected={tab === 'date'} class:is-active={tab === 'date'} onclick={() => (tab = 'date')}>DATE</button>
|
|
239
|
+
<button type="button" role="tab" aria-selected={tab === 'time'} class:is-active={tab === 'time'} onclick={() => (tab = 'time')}>TIME</button>
|
|
240
|
+
</div>
|
|
241
|
+
{/if}
|
|
242
|
+
<div class="sv-dtp__body">
|
|
243
|
+
{#if tab === 'date' && showDateTab}
|
|
244
|
+
<SvCalendar
|
|
245
|
+
value={current}
|
|
246
|
+
selectionMode="one"
|
|
247
|
+
min={min}
|
|
248
|
+
max={max}
|
|
249
|
+
{firstDayOfWeek}
|
|
250
|
+
{weekNumbers}
|
|
251
|
+
{locale}
|
|
252
|
+
{animate}
|
|
253
|
+
onChange={onCalendarChange}
|
|
254
|
+
/>
|
|
255
|
+
{:else if showTimeTab}
|
|
256
|
+
<SvTimePicker
|
|
257
|
+
value={current}
|
|
258
|
+
format={hourFormat}
|
|
259
|
+
{minuteInterval}
|
|
260
|
+
footer
|
|
261
|
+
onChange={onTimeChange}
|
|
262
|
+
/>
|
|
263
|
+
{/if}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
{/if}
|
|
267
|
+
|
|
268
|
+
{#if name}<input type="hidden" {name} value={current ? current.toISOString() : ''} />{/if}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<style>
|
|
272
|
+
.sv-dtp {
|
|
273
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
274
|
+
--_bg: var(--sg-input-bg, var(--sg-bg, #fff));
|
|
275
|
+
--_fg: var(--sg-fg, #0f172a);
|
|
276
|
+
--_muted: var(--sg-muted, #64748b);
|
|
277
|
+
--_border: var(--sg-input-border, var(--sg-border, #cbd5e1));
|
|
278
|
+
--_radius: var(--sg-radius, 8px);
|
|
279
|
+
display: inline-block; position: relative; width: 240px; color: var(--_fg);
|
|
280
|
+
}
|
|
281
|
+
.sv-dtp--disabled { opacity: 0.6; }
|
|
282
|
+
.sv-dtp__field {
|
|
283
|
+
display: flex; align-items: center; gap: 2px;
|
|
284
|
+
background: var(--_bg); border: 1px solid var(--_border); border-radius: var(--_radius);
|
|
285
|
+
padding: 0 4px 0 0; height: 34px;
|
|
286
|
+
}
|
|
287
|
+
.sv-dtp__field:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
|
|
288
|
+
.sv-dtp__input {
|
|
289
|
+
flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit;
|
|
290
|
+
font: inherit; font-size: 13px; padding: 0 8px; height: 100%;
|
|
291
|
+
}
|
|
292
|
+
.sv-dtp__spin { display: flex; flex-direction: column; border-right: 1px solid var(--_border); }
|
|
293
|
+
.sv-dtp__spin button {
|
|
294
|
+
flex: 1; width: 20px; border: 0; background: none; color: var(--_muted); cursor: pointer;
|
|
295
|
+
font-size: 7px; line-height: 1; padding: 0;
|
|
296
|
+
}
|
|
297
|
+
.sv-dtp__spin button:hover:not(:disabled) { color: var(--_accent); }
|
|
298
|
+
.sv-dtp__clear, .sv-dtp__toggle {
|
|
299
|
+
display: grid; place-items: center; width: 26px; height: 26px; flex: none;
|
|
300
|
+
background: none; border: 0; color: var(--_muted); cursor: pointer; border-radius: 6px;
|
|
301
|
+
}
|
|
302
|
+
.sv-dtp__clear { font-size: 17px; line-height: 1; }
|
|
303
|
+
.sv-dtp__clear:hover, .sv-dtp__toggle:hover { color: var(--_accent); background: color-mix(in srgb, var(--_accent) 10%, transparent); }
|
|
304
|
+
|
|
305
|
+
/* Portalled panel - lives under <body>, styled globally. */
|
|
306
|
+
:global(.sv-dtp__panel) {
|
|
307
|
+
z-index: 2147483647;
|
|
308
|
+
background: var(--sg-bg, #fff);
|
|
309
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
310
|
+
border-radius: 12px;
|
|
311
|
+
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
|
|
312
|
+
overflow: hidden;
|
|
313
|
+
}
|
|
314
|
+
:global(.sv-dtp__tabs) { display: flex; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
|
|
315
|
+
:global(.sv-dtp__tabs button) {
|
|
316
|
+
flex: 1; padding: 9px; font: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
|
|
317
|
+
background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer;
|
|
318
|
+
border-bottom: 2px solid transparent;
|
|
319
|
+
}
|
|
320
|
+
:global(.sv-dtp__tabs button.is-active) { color: var(--sg-accent, #2563eb); border-bottom-color: var(--sg-accent, #2563eb); }
|
|
321
|
+
:global(.sv-dtp__body) { display: grid; place-items: center; padding: 8px; }
|
|
322
|
+
</style>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for SvDateTimePicker: the masked input round-trips through the
|
|
3
|
+
* token engine, the dropdown portals to <body> with DATE/TIME tabs, and picking
|
|
4
|
+
* a day preserves the time (and vice versa).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, afterEach } from 'vitest'
|
|
7
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
8
|
+
import SvDateTimePicker from './SvDateTimePicker.svelte'
|
|
9
|
+
|
|
10
|
+
function mountDtp(props: Record<string, unknown>) {
|
|
11
|
+
const target = document.createElement('div')
|
|
12
|
+
document.body.appendChild(target)
|
|
13
|
+
const app = mount(SvDateTimePicker, { target, props: props as any })
|
|
14
|
+
flushSync()
|
|
15
|
+
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The panel portals to <body>, so query the document, not the mount target.
|
|
19
|
+
const panel = () => document.querySelector<HTMLElement>('.sv-dtp__panel')
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.querySelectorAll('.sv-dtp__panel').forEach((n) => n.remove())
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('SvDateTimePicker input', () => {
|
|
26
|
+
it('formats the initial value with the mask', () => {
|
|
27
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7, 9, 4), formatString: 'yyyy-MM-dd HH:mm' })
|
|
28
|
+
try {
|
|
29
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
30
|
+
expect(input.value).toBe('2026-06-07 09:04')
|
|
31
|
+
} finally { destroy() }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('parses typed text on blur and emits a Date', () => {
|
|
35
|
+
let got: Date | null | undefined
|
|
36
|
+
const { target, destroy } = mountDtp({
|
|
37
|
+
value: null, formatString: 'yyyy-MM-dd HH:mm', onChange: (d: Date | null) => (got = d),
|
|
38
|
+
})
|
|
39
|
+
try {
|
|
40
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
41
|
+
input.value = '2026-06-07 14:30'
|
|
42
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
43
|
+
input.dispatchEvent(new FocusEvent('blur'))
|
|
44
|
+
flushSync()
|
|
45
|
+
expect(got).toBeInstanceOf(Date)
|
|
46
|
+
expect(got!.getFullYear()).toBe(2026)
|
|
47
|
+
expect(got!.getHours()).toBe(14)
|
|
48
|
+
expect(got!.getMinutes()).toBe(30)
|
|
49
|
+
} finally { destroy() }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reverts invalid text back to the last good value', () => {
|
|
53
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7, 9, 0), formatString: 'yyyy-MM-dd HH:mm' })
|
|
54
|
+
try {
|
|
55
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
56
|
+
input.value = 'garbage'
|
|
57
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
58
|
+
input.dispatchEvent(new FocusEvent('blur'))
|
|
59
|
+
flushSync()
|
|
60
|
+
expect(input.value).toBe('2026-06-07 09:00')
|
|
61
|
+
} finally { destroy() }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('clamps to max', () => {
|
|
65
|
+
let got: Date | null | undefined
|
|
66
|
+
const { target, destroy } = mountDtp({
|
|
67
|
+
value: null, formatString: 'yyyy-MM-dd HH:mm', max: new Date(2026, 5, 10, 0, 0),
|
|
68
|
+
onChange: (d: Date | null) => (got = d),
|
|
69
|
+
})
|
|
70
|
+
try {
|
|
71
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
72
|
+
input.value = '2026-12-31 23:59'
|
|
73
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
74
|
+
input.dispatchEvent(new FocusEvent('blur'))
|
|
75
|
+
flushSync()
|
|
76
|
+
expect(got!.getTime()).toBe(new Date(2026, 5, 10, 0, 0).getTime())
|
|
77
|
+
} finally { destroy() }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('clear button nulls a nullable value', () => {
|
|
81
|
+
let got: Date | null | undefined = new Date()
|
|
82
|
+
const { target, destroy } = mountDtp({
|
|
83
|
+
value: new Date(2026, 5, 7), nullable: true, onChange: (d: Date | null) => (got = d),
|
|
84
|
+
})
|
|
85
|
+
try {
|
|
86
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__clear')!.click()
|
|
87
|
+
flushSync()
|
|
88
|
+
expect(got).toBeNull()
|
|
89
|
+
} finally { destroy() }
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('SvDateTimePicker dropdown', () => {
|
|
94
|
+
it('opens a portalled panel with DATE and TIME tabs', () => {
|
|
95
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7, 9, 0) })
|
|
96
|
+
try {
|
|
97
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__toggle')!.click()
|
|
98
|
+
flushSync()
|
|
99
|
+
expect(panel()).not.toBeNull()
|
|
100
|
+
const tabs = panel()!.querySelectorAll('[role="tab"]')
|
|
101
|
+
expect(tabs.length).toBe(2)
|
|
102
|
+
expect(tabs[0]!.textContent).toBe('DATE')
|
|
103
|
+
expect(tabs[1]!.textContent).toBe('TIME')
|
|
104
|
+
} finally { destroy() }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('picking a calendar day preserves the time', () => {
|
|
108
|
+
let got: Date | null | undefined
|
|
109
|
+
const { target, destroy } = mountDtp({
|
|
110
|
+
value: new Date(2026, 5, 7, 9, 45), onChange: (d: Date | null) => (got = d),
|
|
111
|
+
})
|
|
112
|
+
try {
|
|
113
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__toggle')!.click()
|
|
114
|
+
flushSync()
|
|
115
|
+
const day20 = Array.from(panel()!.querySelectorAll<HTMLButtonElement>('.sv-cal__day')).find(
|
|
116
|
+
(b) => b.textContent?.trim() === '20' && !b.classList.contains('is-outside'),
|
|
117
|
+
)!
|
|
118
|
+
day20.click()
|
|
119
|
+
flushSync()
|
|
120
|
+
expect(got!.getDate()).toBe(20)
|
|
121
|
+
expect(got!.getHours()).toBe(9) // time preserved
|
|
122
|
+
expect(got!.getMinutes()).toBe(45)
|
|
123
|
+
} finally { destroy() }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('honors dropDownDisplayMode=calendar (no tabs, calendar only)', () => {
|
|
127
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7), dropDownDisplayMode: 'calendar' })
|
|
128
|
+
try {
|
|
129
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__toggle')!.click()
|
|
130
|
+
flushSync()
|
|
131
|
+
expect(panel()!.querySelectorAll('[role="tab"]').length).toBe(0)
|
|
132
|
+
expect(panel()!.querySelector('.sv-cal')).not.toBeNull()
|
|
133
|
+
expect(panel()!.querySelector('.sv-tp')).toBeNull()
|
|
134
|
+
} finally { destroy() }
|
|
135
|
+
})
|
|
136
|
+
})
|