@svgrid/grid 2.1.22 → 2.2.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/README.md +9 -2
- package/dist/DockNodeView.svelte +306 -0
- package/dist/DockNodeView.svelte.d.ts +7 -0
- package/dist/DockPopoutHost.svelte +17 -0
- package/dist/DockPopoutHost.svelte.d.ts +14 -0
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +178 -178
- package/dist/GridMenus.svelte +83 -3
- package/dist/SvAutoComplete.svelte +5 -3
- package/dist/SvCarousel.svelte +141 -163
- package/dist/SvColorInput.svelte +187 -186
- package/dist/SvColorInput.svelte.d.ts +1 -1
- package/dist/SvComboBox.svelte +7 -3
- package/dist/SvCommand.svelte +28 -85
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +1 -1
- package/dist/SvDateRangeInput.svelte +1 -1
- package/dist/SvDateTimePicker.svelte +9 -3
- package/dist/SvDateTimePicker.svelte.d.ts +3 -0
- package/dist/SvDockLayout.svelte +244 -0
- package/dist/SvDockLayout.svelte.d.ts +77 -0
- package/dist/SvDockManager.svelte +922 -0
- package/dist/SvDockManager.svelte.d.ts +106 -0
- package/dist/SvDrawer.svelte +250 -228
- package/dist/SvDrawer.svelte.d.ts +4 -0
- package/dist/SvDropDownList.svelte +85 -13
- package/dist/SvDropDownList.svelte.d.ts +3 -0
- package/dist/SvField.svelte +21 -4
- package/dist/SvField.svelte.d.ts +1 -0
- package/dist/SvForm.svelte +201 -91
- package/dist/SvForm.svelte.d.ts +15 -22
- package/dist/SvGrid.controller.svelte.d.ts +32 -1
- package/dist/SvGrid.controller.svelte.js +156 -27
- package/dist/SvGrid.css +193 -5
- package/dist/SvGrid.svelte +3131 -2786
- package/dist/SvGrid.types.d.ts +452 -1
- package/dist/SvGridBoard.svelte +2339 -2339
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridDropdown.svelte +689 -682
- package/dist/SvGridSelect.svelte +1 -0
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +36 -14
- package/dist/SvMaskedInput.svelte +4 -3
- package/dist/SvMaskedInput.svelte.d.ts +1 -1
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +117 -165
- package/dist/SvMenuList.svelte.d.ts +2 -13
- package/dist/SvModal.svelte +12 -29
- package/dist/SvMultiSelect.svelte +1 -0
- package/dist/SvNumberInput.svelte +131 -130
- package/dist/SvNumberInput.svelte.d.ts +1 -1
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPagination.svelte +18 -18
- package/dist/SvPasswordInput.svelte +3 -2
- package/dist/SvPasswordInput.svelte.d.ts +1 -1
- package/dist/SvPhoneInput.svelte +3 -2
- package/dist/SvPhoneInput.svelte.d.ts +1 -1
- package/dist/SvPopover.svelte +139 -139
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvStepper.svelte +6 -9
- package/dist/SvTagsInput.svelte +3 -2
- package/dist/SvTagsInput.svelte.d.ts +1 -1
- package/dist/SvTooltip.svelte +127 -138
- package/dist/SvTour.svelte +204 -176
- package/dist/SvTree.svelte +377 -377
- package/dist/SvTreeSelect.svelte +230 -229
- package/dist/build-api.js +76 -54
- package/dist/cdn/svgrid.js +10913 -6450
- package/dist/cdn/svgrid.svelte-external.js +9628 -5481
- package/dist/chart-export.js +8 -8
- package/dist/columns.js +6 -0
- package/dist/conditional-formatting.d.ts +67 -3
- package/dist/conditional-formatting.js +124 -17
- package/dist/createCarousel.svelte.d.ts +72 -0
- package/dist/createCarousel.svelte.js +80 -0
- package/dist/createCommand.svelte.d.ts +62 -0
- package/dist/createCommand.svelte.js +109 -0
- package/dist/createForm.svelte.d.ts +62 -0
- package/dist/createForm.svelte.js +374 -0
- package/dist/createMenu.svelte.d.ts +88 -0
- package/dist/createMenu.svelte.js +153 -0
- package/dist/createOverlay.svelte.d.ts +38 -0
- package/dist/createOverlay.svelte.js +68 -0
- package/dist/createPagination.d.ts +74 -0
- package/dist/createPagination.js +71 -0
- package/dist/createPopoverSelect.svelte.js +6 -12
- package/dist/createStepper.d.ts +44 -0
- package/dist/createStepper.js +26 -0
- package/dist/createTooltip.svelte.d.ts +32 -0
- package/dist/createTooltip.svelte.js +61 -0
- package/dist/datetime/timezone.d.ts +36 -0
- package/dist/datetime/timezone.js +110 -0
- package/dist/dock-context.d.ts +52 -0
- package/dist/dock-context.js +1 -0
- package/dist/dock-manager-model.d.ts +136 -0
- package/dist/dock-manager-model.js +514 -0
- package/dist/dock-model.d.ts +105 -0
- package/dist/dock-model.js +288 -0
- package/dist/dock-popout.d.ts +30 -0
- package/dist/dock-popout.js +81 -0
- package/dist/editor-contract.d.ts +2 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +35 -2
- package/dist/filtering/excel-filters.d.ts +18 -1
- package/dist/filtering/excel-filters.js +56 -0
- package/dist/form-field.d.ts +73 -0
- package/dist/form-field.js +15 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +40 -4
- package/dist/list-nav.d.ts +18 -0
- package/dist/list-nav.js +30 -0
- package/dist/list-option.d.ts +2 -0
- package/dist/menu-item.d.ts +20 -0
- package/dist/menu-item.js +1 -0
- package/dist/menus.d.ts +5 -0
- package/dist/menus.js +68 -16
- package/dist/panel-resize.d.ts +25 -0
- package/dist/panel-resize.js +40 -0
- package/dist/popover.d.ts +29 -3
- package/dist/popover.js +22 -6
- package/dist/recurrence.d.ts +33 -7
- package/dist/recurrence.js +151 -13
- package/dist/row-drag.d.ts +8 -0
- package/dist/row-drag.js +11 -0
- package/dist/scheduler-ical.d.ts +30 -0
- package/dist/scheduler-ical.js +180 -0
- package/dist/scheduler-model.d.ts +269 -0
- package/dist/scheduler-model.js +602 -0
- package/dist/scheduler-view.svelte.d.ts +22 -0
- package/dist/scheduler-view.svelte.js +13 -0
- package/dist/spreadsheet.js +20 -12
- package/dist/ui-app.types.d.ts +3 -0
- package/package.json +14 -10
- package/src/DockNodeView.svelte +306 -0
- package/src/DockPopoutHost.svelte +17 -0
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +178 -178
- package/src/GridMenus.svelte +83 -3
- package/src/SvAutoComplete.svelte +5 -3
- package/src/SvCarousel.svelte +141 -163
- package/src/SvCarousel.test.ts +68 -0
- package/src/SvColorInput.svelte +187 -186
- package/src/SvComboBox.svelte +7 -3
- package/src/SvCommand.svelte +28 -85
- package/src/SvCommand.test.ts +78 -0
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvDateTimePicker.svelte +9 -3
- package/src/SvDockLayout.svelte +244 -0
- package/src/SvDockManager.svelte +922 -0
- package/src/SvDrawer.svelte +250 -228
- package/src/SvDropDownList.svelte +85 -13
- package/src/SvField.svelte +21 -4
- package/src/SvForm.svelte +201 -91
- package/src/SvForm.test.ts +299 -0
- package/src/SvGrid.controller.svelte.ts +3158 -3034
- package/src/SvGrid.css +193 -5
- package/src/SvGrid.svelte +3131 -2786
- package/src/SvGrid.types.ts +1489 -1027
- package/src/SvGridBoard.svelte +2339 -2339
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridDropdown.svelte +689 -682
- package/src/SvGridSelect.svelte +1 -0
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +36 -14
- package/src/SvMaskedInput.svelte +4 -3
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -73
- package/src/SvMenuList.svelte +117 -165
- package/src/SvModal.svelte +12 -29
- package/src/SvModal.test.ts +82 -0
- package/src/SvMultiSelect.svelte +1 -0
- package/src/SvNumberInput.svelte +131 -130
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPagination.svelte +18 -18
- package/src/SvPasswordInput.svelte +3 -2
- package/src/SvPhoneInput.svelte +3 -2
- package/src/SvPopover.svelte +139 -139
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvStepper.svelte +6 -9
- package/src/SvTagsInput.svelte +3 -2
- package/src/SvTooltip.svelte +127 -138
- package/src/SvTooltip.test.ts +73 -0
- package/src/SvTour.svelte +204 -176
- package/src/SvTree.svelte +377 -377
- package/src/SvTreeSelect.svelte +230 -229
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/build-api.ts +870 -847
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/columns.ts +5 -0
- package/src/conditional-formatting.test.ts +104 -0
- package/src/conditional-formatting.ts +176 -19
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCarousel.svelte.ts +104 -0
- package/src/createCombobox.svelte.ts +179 -179
- package/src/createCommand.svelte.ts +125 -0
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +162 -162
- package/src/createForm.svelte.ts +333 -0
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +202 -0
- package/src/createOverlay.svelte.ts +101 -0
- package/src/createPagination.test.ts +65 -0
- package/src/createPagination.ts +84 -0
- package/src/createPopoverSelect.svelte.ts +206 -210
- package/src/createStepper.test.ts +62 -0
- package/src/createStepper.ts +61 -0
- package/src/createTooltip.svelte.ts +86 -0
- package/src/createTree.svelte.ts +319 -319
- package/src/datetime/timezone.test.ts +69 -0
- package/src/datetime/timezone.ts +134 -0
- package/src/dock-context.ts +47 -0
- package/src/dock-manager-model.test.ts +275 -0
- package/src/dock-manager-model.ts +593 -0
- package/src/dock-model.test.ts +163 -0
- package/src/dock-model.ts +368 -0
- package/src/dock-popout.ts +94 -0
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/editor-contract.ts +2 -0
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/filter-operators.test.ts +59 -0
- package/src/filter-operators.ts +35 -2
- package/src/filtering/excel-filters.test.ts +86 -1
- package/src/filtering/excel-filters.ts +65 -0
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +87 -0
- package/src/index.ts +623 -469
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -0
- package/src/list-nav.ts +29 -0
- package/src/list-option.ts +2 -0
- package/src/menu-item.ts +21 -0
- package/src/menus.test.ts +48 -0
- package/src/menus.ts +639 -582
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/panel-resize.ts +52 -0
- package/src/popover.bounds.test.ts +93 -0
- package/src/popover.ts +51 -7
- package/src/recurrence.test.ts +88 -1
- package/src/recurrence.ts +166 -15
- package/src/render-component.ts +28 -28
- package/src/row-drag.ts +10 -0
- package/src/scheduler-ical.test.ts +68 -0
- package/src/scheduler-ical.ts +180 -0
- package/src/scheduler-model.test.ts +546 -0
- package/src/scheduler-model.ts +853 -0
- package/src/scheduler-view.svelte.ts +33 -0
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -304
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +501 -501
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +527 -527
- package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +159 -159
- package/src/ui-app.types.ts +3 -0
- package/src/virtual.test.ts +6 -4
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
package/src/merge-objects.ts
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Merges objects together while keeping their getters alive.
|
|
3
|
-
* Taken from SolidJS:
|
|
4
|
-
* https://github.com/solidjs/solid/blob/24abc825c0996fd2bc8c1de1491efe9a7e743aff/packages/solid/src/server/rendering.ts#L82-L115
|
|
5
|
-
*/
|
|
6
|
-
export function mergeObjects<T>(source: T): T
|
|
7
|
-
export function mergeObjects<T, U>(source: T, source1: U): T & U
|
|
8
|
-
export function mergeObjects<T, U, V>(
|
|
9
|
-
source: T,
|
|
10
|
-
source1: U,
|
|
11
|
-
source2: V,
|
|
12
|
-
): T & U & V
|
|
13
|
-
export function mergeObjects<T, U, V, W>(
|
|
14
|
-
source: T,
|
|
15
|
-
source1: U,
|
|
16
|
-
source2: V,
|
|
17
|
-
source3: W,
|
|
18
|
-
): T & U & V & W
|
|
19
|
-
export function mergeObjects(...sources: any): any {
|
|
20
|
-
const target = {}
|
|
21
|
-
|
|
22
|
-
for (let source of sources) {
|
|
23
|
-
if (typeof source === 'function') source = source()
|
|
24
|
-
|
|
25
|
-
if (source) {
|
|
26
|
-
const descriptors = Object.getOwnPropertyDescriptors(source)
|
|
27
|
-
|
|
28
|
-
for (const key in descriptors) {
|
|
29
|
-
if (key in target) continue
|
|
30
|
-
|
|
31
|
-
Object.defineProperty(target, key, {
|
|
32
|
-
enumerable: true,
|
|
33
|
-
get() {
|
|
34
|
-
for (let i = sources.length - 1; i >= 0; i--) {
|
|
35
|
-
let scopedSource = sources[i]
|
|
36
|
-
if (typeof scopedSource === 'function') scopedSource = scopedSource()
|
|
37
|
-
|
|
38
|
-
const value = (scopedSource || {})[key]
|
|
39
|
-
if (value !== undefined) return value
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return target
|
|
48
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Merges objects together while keeping their getters alive.
|
|
3
|
+
* Taken from SolidJS:
|
|
4
|
+
* https://github.com/solidjs/solid/blob/24abc825c0996fd2bc8c1de1491efe9a7e743aff/packages/solid/src/server/rendering.ts#L82-L115
|
|
5
|
+
*/
|
|
6
|
+
export function mergeObjects<T>(source: T): T
|
|
7
|
+
export function mergeObjects<T, U>(source: T, source1: U): T & U
|
|
8
|
+
export function mergeObjects<T, U, V>(
|
|
9
|
+
source: T,
|
|
10
|
+
source1: U,
|
|
11
|
+
source2: V,
|
|
12
|
+
): T & U & V
|
|
13
|
+
export function mergeObjects<T, U, V, W>(
|
|
14
|
+
source: T,
|
|
15
|
+
source1: U,
|
|
16
|
+
source2: V,
|
|
17
|
+
source3: W,
|
|
18
|
+
): T & U & V & W
|
|
19
|
+
export function mergeObjects(...sources: any): any {
|
|
20
|
+
const target = {}
|
|
21
|
+
|
|
22
|
+
for (let source of sources) {
|
|
23
|
+
if (typeof source === 'function') source = source()
|
|
24
|
+
|
|
25
|
+
if (source) {
|
|
26
|
+
const descriptors = Object.getOwnPropertyDescriptors(source)
|
|
27
|
+
|
|
28
|
+
for (const key in descriptors) {
|
|
29
|
+
if (key in target) continue
|
|
30
|
+
|
|
31
|
+
Object.defineProperty(target, key, {
|
|
32
|
+
enumerable: true,
|
|
33
|
+
get() {
|
|
34
|
+
for (let i = sources.length - 1; i >= 0; i--) {
|
|
35
|
+
let scopedSource = sources[i]
|
|
36
|
+
if (typeof scopedSource === 'function') scopedSource = scopedSource()
|
|
37
|
+
|
|
38
|
+
const value = (scopedSource || {})[key]
|
|
39
|
+
if (value !== undefined) return value
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return target
|
|
48
|
+
}
|
package/src/overlays.test.ts
CHANGED
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Component tests for the overlay trio: SvModal (dialog semantics, Escape /
|
|
3
|
-
* backdrop close), SvPopover (click open + outside close) and SvTooltip
|
|
4
|
-
* (focus-triggered, aria-describedby wiring).
|
|
5
|
-
*/
|
|
6
|
-
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
7
|
-
import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
|
|
8
|
-
import SvModal from './SvModal.svelte'
|
|
9
|
-
import SvPopover from './SvPopover.svelte'
|
|
10
|
-
import SvTooltip from './SvTooltip.svelte'
|
|
11
|
-
|
|
12
|
-
const body = createRawSnippet(() => ({ render: () => `<p class="body">hello</p>` }))
|
|
13
|
-
const anchorBtn = createRawSnippet(() => ({ render: () => `<button class="anchor">open</button>` }))
|
|
14
|
-
|
|
15
|
-
function mountC(Comp: any, props: Record<string, unknown>) {
|
|
16
|
-
const target = document.createElement('div')
|
|
17
|
-
document.body.appendChild(target)
|
|
18
|
-
const app = mount(Comp, { target, props: props as any })
|
|
19
|
-
flushSync()
|
|
20
|
-
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
21
|
-
}
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
document.querySelectorAll('.sv-modal__backdrop, .sv-pop__panel, .sv-tip').forEach((n) => n.remove())
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
describe('SvModal', () => {
|
|
27
|
-
it('renders a labelled modal dialog when open', () => {
|
|
28
|
-
const { destroy } = mountC(SvModal, { open: true, title: 'Edit', children: body })
|
|
29
|
-
try {
|
|
30
|
-
const dialog = document.querySelector<HTMLElement>('.sv-modal')!
|
|
31
|
-
expect(dialog.getAttribute('role')).toBe('dialog')
|
|
32
|
-
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
33
|
-
const titleId = dialog.getAttribute('aria-labelledby')!
|
|
34
|
-
expect(document.getElementById(titleId)!.textContent).toBe('Edit')
|
|
35
|
-
expect(document.querySelector('.sv-modal .body')!.textContent).toBe('hello')
|
|
36
|
-
} finally { destroy() }
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('Escape and the close button both fire onClose', () => {
|
|
40
|
-
let closed = 0
|
|
41
|
-
const { destroy } = mountC(SvModal, { open: true, title: 'X', onClose: () => (closed++), children: body })
|
|
42
|
-
try {
|
|
43
|
-
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
44
|
-
flushSync()
|
|
45
|
-
expect(closed).toBe(1)
|
|
46
|
-
} finally { destroy() }
|
|
47
|
-
})
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
describe('SvPopover', () => {
|
|
51
|
-
it('opens the panel on anchor click and closes on outside pointerdown', () => {
|
|
52
|
-
const { target, destroy } = mountC(SvPopover, { anchor: anchorBtn, children: body })
|
|
53
|
-
try {
|
|
54
|
-
expect(document.querySelector('.sv-pop__panel')).toBeNull()
|
|
55
|
-
target.querySelector<HTMLElement>('.anchor')!.click()
|
|
56
|
-
flushSync()
|
|
57
|
-
const panel = document.querySelector<HTMLElement>('.sv-pop__panel')!
|
|
58
|
-
expect(panel).not.toBeNull()
|
|
59
|
-
expect(panel.getAttribute('role')).toBe('dialog')
|
|
60
|
-
// Outside click closes.
|
|
61
|
-
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
|
62
|
-
flushSync()
|
|
63
|
-
expect(document.querySelector('.sv-pop__panel')).toBeNull()
|
|
64
|
-
} finally { destroy() }
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
describe('SvTooltip', () => {
|
|
69
|
-
it('shows after the delay on focus and wires aria-describedby', () => {
|
|
70
|
-
vi.useFakeTimers()
|
|
71
|
-
try {
|
|
72
|
-
const child = createRawSnippet(() => ({ render: () => `<button class="t">i</button>` }))
|
|
73
|
-
const { target, destroy } = mountC(SvTooltip, { text: 'Tip text', delay: 200, children: child })
|
|
74
|
-
const anchor = target.querySelector<HTMLElement>('.sv-tip__anchor')!
|
|
75
|
-
anchor.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
|
|
76
|
-
vi.advanceTimersByTime(210)
|
|
77
|
-
flushSync()
|
|
78
|
-
const tip = document.querySelector<HTMLElement>('.sv-tip')!
|
|
79
|
-
expect(tip).not.toBeNull()
|
|
80
|
-
expect(tip.getAttribute('role')).toBe('tooltip')
|
|
81
|
-
expect(anchor.getAttribute('aria-describedby')).toBe(tip.id)
|
|
82
|
-
// The wrapper span is never itself focusable, so AT users only ever land
|
|
83
|
-
// on the real interactive child below - it must carry the description
|
|
84
|
-
// too, or it's never announced on focus (see SvTooltip.svelte effect).
|
|
85
|
-
const focusEl = target.querySelector<HTMLElement>('.t')!
|
|
86
|
-
expect(focusEl.getAttribute('aria-describedby')).toBe(tip.id)
|
|
87
|
-
destroy()
|
|
88
|
-
} finally { vi.useRealTimers() }
|
|
89
|
-
})
|
|
90
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for the overlay trio: SvModal (dialog semantics, Escape /
|
|
3
|
+
* backdrop close), SvPopover (click open + outside close) and SvTooltip
|
|
4
|
+
* (focus-triggered, aria-describedby wiring).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
7
|
+
import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
|
|
8
|
+
import SvModal from './SvModal.svelte'
|
|
9
|
+
import SvPopover from './SvPopover.svelte'
|
|
10
|
+
import SvTooltip from './SvTooltip.svelte'
|
|
11
|
+
|
|
12
|
+
const body = createRawSnippet(() => ({ render: () => `<p class="body">hello</p>` }))
|
|
13
|
+
const anchorBtn = createRawSnippet(() => ({ render: () => `<button class="anchor">open</button>` }))
|
|
14
|
+
|
|
15
|
+
function mountC(Comp: any, props: Record<string, unknown>) {
|
|
16
|
+
const target = document.createElement('div')
|
|
17
|
+
document.body.appendChild(target)
|
|
18
|
+
const app = mount(Comp, { target, props: props as any })
|
|
19
|
+
flushSync()
|
|
20
|
+
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
21
|
+
}
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
document.querySelectorAll('.sv-modal__backdrop, .sv-pop__panel, .sv-tip').forEach((n) => n.remove())
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('SvModal', () => {
|
|
27
|
+
it('renders a labelled modal dialog when open', () => {
|
|
28
|
+
const { destroy } = mountC(SvModal, { open: true, title: 'Edit', children: body })
|
|
29
|
+
try {
|
|
30
|
+
const dialog = document.querySelector<HTMLElement>('.sv-modal')!
|
|
31
|
+
expect(dialog.getAttribute('role')).toBe('dialog')
|
|
32
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
33
|
+
const titleId = dialog.getAttribute('aria-labelledby')!
|
|
34
|
+
expect(document.getElementById(titleId)!.textContent).toBe('Edit')
|
|
35
|
+
expect(document.querySelector('.sv-modal .body')!.textContent).toBe('hello')
|
|
36
|
+
} finally { destroy() }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('Escape and the close button both fire onClose', () => {
|
|
40
|
+
let closed = 0
|
|
41
|
+
const { destroy } = mountC(SvModal, { open: true, title: 'X', onClose: () => (closed++), children: body })
|
|
42
|
+
try {
|
|
43
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
44
|
+
flushSync()
|
|
45
|
+
expect(closed).toBe(1)
|
|
46
|
+
} finally { destroy() }
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('SvPopover', () => {
|
|
51
|
+
it('opens the panel on anchor click and closes on outside pointerdown', () => {
|
|
52
|
+
const { target, destroy } = mountC(SvPopover, { anchor: anchorBtn, children: body })
|
|
53
|
+
try {
|
|
54
|
+
expect(document.querySelector('.sv-pop__panel')).toBeNull()
|
|
55
|
+
target.querySelector<HTMLElement>('.anchor')!.click()
|
|
56
|
+
flushSync()
|
|
57
|
+
const panel = document.querySelector<HTMLElement>('.sv-pop__panel')!
|
|
58
|
+
expect(panel).not.toBeNull()
|
|
59
|
+
expect(panel.getAttribute('role')).toBe('dialog')
|
|
60
|
+
// Outside click closes.
|
|
61
|
+
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
|
62
|
+
flushSync()
|
|
63
|
+
expect(document.querySelector('.sv-pop__panel')).toBeNull()
|
|
64
|
+
} finally { destroy() }
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('SvTooltip', () => {
|
|
69
|
+
it('shows after the delay on focus and wires aria-describedby', () => {
|
|
70
|
+
vi.useFakeTimers()
|
|
71
|
+
try {
|
|
72
|
+
const child = createRawSnippet(() => ({ render: () => `<button class="t">i</button>` }))
|
|
73
|
+
const { target, destroy } = mountC(SvTooltip, { text: 'Tip text', delay: 200, children: child })
|
|
74
|
+
const anchor = target.querySelector<HTMLElement>('.sv-tip__anchor')!
|
|
75
|
+
anchor.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
|
|
76
|
+
vi.advanceTimersByTime(210)
|
|
77
|
+
flushSync()
|
|
78
|
+
const tip = document.querySelector<HTMLElement>('.sv-tip')!
|
|
79
|
+
expect(tip).not.toBeNull()
|
|
80
|
+
expect(tip.getAttribute('role')).toBe('tooltip')
|
|
81
|
+
expect(anchor.getAttribute('aria-describedby')).toBe(tip.id)
|
|
82
|
+
// The wrapper span is never itself focusable, so AT users only ever land
|
|
83
|
+
// on the real interactive child below - it must carry the description
|
|
84
|
+
// too, or it's never announced on focus (see SvTooltip.svelte effect).
|
|
85
|
+
const focusEl = target.querySelector<HTMLElement>('.t')!
|
|
86
|
+
expect(focusEl.getAttribute('aria-describedby')).toBe(tip.id)
|
|
87
|
+
destroy()
|
|
88
|
+
} finally { vi.useRealTimers() }
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel-resize - the drag math behind a dropdown panel's bottom resize grip
|
|
3
|
+
* (the "····" handle). Rune-free and DOM-only so every popover select can reuse
|
|
4
|
+
* it: on the grip's `pointerdown` it tracks the pointer and reports a clamped
|
|
5
|
+
* height as it moves, releasing its listeners on `pointerup`.
|
|
6
|
+
*
|
|
7
|
+
* The grip is only meaningful when the panel opens DOWNWARD (it grows toward the
|
|
8
|
+
* bottom of the screen); components hide it on an upward flip, where there is no
|
|
9
|
+
* room below to grow into.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type PanelResizeOptions = {
|
|
13
|
+
/** Panel height (px) at the moment the drag starts. */
|
|
14
|
+
startHeight: number
|
|
15
|
+
/** Smallest height the panel may shrink to. Default 80. */
|
|
16
|
+
min?: number
|
|
17
|
+
/** Largest height the panel may grow to - the live viewport ceiling. */
|
|
18
|
+
max: () => number
|
|
19
|
+
/** Called with the new clamped height on each pointer move. */
|
|
20
|
+
onHeight: (height: number) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Begin a vertical resize from a grip `pointerdown`. Returns nothing; the drag
|
|
25
|
+
* ends itself on `pointerup`. Safe to call from an `onpointerdown` handler.
|
|
26
|
+
*/
|
|
27
|
+
export function startPanelResize(event: PointerEvent, opts: PanelResizeOptions): void {
|
|
28
|
+
// Ignore secondary buttons; only a primary drag resizes.
|
|
29
|
+
if (event.button !== 0) return
|
|
30
|
+
event.preventDefault()
|
|
31
|
+
event.stopPropagation()
|
|
32
|
+
const startY = event.clientY
|
|
33
|
+
const min = opts.min ?? 80
|
|
34
|
+
|
|
35
|
+
const clamp = (h: number) => Math.max(min, Math.min(opts.max(), Math.round(h)))
|
|
36
|
+
const onMove = (e: PointerEvent) => {
|
|
37
|
+
opts.onHeight(clamp(opts.startHeight + (e.clientY - startY)))
|
|
38
|
+
}
|
|
39
|
+
const onUp = () => {
|
|
40
|
+
window.removeEventListener('pointermove', onMove)
|
|
41
|
+
window.removeEventListener('pointerup', onUp)
|
|
42
|
+
window.removeEventListener('pointercancel', onUp)
|
|
43
|
+
document.body.style.userSelect = ''
|
|
44
|
+
document.body.style.cursor = ''
|
|
45
|
+
}
|
|
46
|
+
// Suppress text selection + keep the resize cursor for the whole drag.
|
|
47
|
+
document.body.style.userSelect = 'none'
|
|
48
|
+
document.body.style.cursor = 'ns-resize'
|
|
49
|
+
window.addEventListener('pointermove', onMove)
|
|
50
|
+
window.addEventListener('pointerup', onUp)
|
|
51
|
+
window.addEventListener('pointercancel', onUp)
|
|
52
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds detection for anchored popover panels: flip up/down by available room,
|
|
3
|
+
* cap the panel height to the viewport, and never let an upward flip start
|
|
4
|
+
* off-screen. Pairs with the drag math in panel-resize.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, beforeAll } from 'vitest'
|
|
7
|
+
import { anchoredRect } from './popover'
|
|
8
|
+
|
|
9
|
+
const VIEWPORT_H = 800
|
|
10
|
+
const VIEWPORT_W = 1200
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
Object.defineProperty(window, 'innerHeight', { value: VIEWPORT_H, configurable: true })
|
|
14
|
+
Object.defineProperty(window, 'innerWidth', { value: VIEWPORT_W, configurable: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Minimal DOMRect stand-in - anchoredRect only reads top/bottom/left/width.
|
|
18
|
+
const rect = (top: number, height: number, left = 100, width = 200) =>
|
|
19
|
+
({ top, bottom: top + height, left, width, right: left + width, height, x: left, y: top, toJSON() {} }) as DOMRect
|
|
20
|
+
|
|
21
|
+
describe('anchoredRect - flip direction', () => {
|
|
22
|
+
it('opens downward with room below', () => {
|
|
23
|
+
const r = anchoredRect(rect(100, 32), { estimatedHeight: 280 })
|
|
24
|
+
expect(r.openUpward).toBe(false)
|
|
25
|
+
expect(r.top).toBe(132 + 2) // trigger bottom + gap
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('flips upward when room below is short and there is more above', () => {
|
|
29
|
+
// Trigger near the bottom: 60px below, ~740px above.
|
|
30
|
+
const r = anchoredRect(rect(VIEWPORT_H - 92, 32), { estimatedHeight: 280 })
|
|
31
|
+
expect(r.openUpward).toBe(true)
|
|
32
|
+
// Panel sits above the trigger; its top must stay on-screen.
|
|
33
|
+
expect(r.top).toBeGreaterThanOrEqual(8)
|
|
34
|
+
expect(r.top).toBeLessThan(VIEWPORT_H - 92)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('anchoredRect - height bounds', () => {
|
|
39
|
+
it('caps maxHeight to the room below (never overflows the viewport)', () => {
|
|
40
|
+
// Short viewport so both sides are smaller than the 280 estimate; trigger in
|
|
41
|
+
// the middle with slightly more room below keeps it opening downward.
|
|
42
|
+
Object.defineProperty(window, 'innerHeight', { value: 260, configurable: true })
|
|
43
|
+
try {
|
|
44
|
+
const r = anchoredRect(rect(110, 32), { estimatedHeight: 280 })
|
|
45
|
+
// room below = 260 - 142 - gap(2) - margin(8) = 108
|
|
46
|
+
expect(r.openUpward).toBe(false)
|
|
47
|
+
expect(r.maxHeight).toBeLessThanOrEqual(108)
|
|
48
|
+
expect(r.maxHeight).toBeGreaterThan(0)
|
|
49
|
+
// availHeight is the hard ceiling and is >= the comfortable maxHeight.
|
|
50
|
+
expect(r.availHeight).toBeGreaterThanOrEqual(r.maxHeight)
|
|
51
|
+
} finally {
|
|
52
|
+
Object.defineProperty(window, 'innerHeight', { value: VIEWPORT_H, configurable: true })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('maxHeight is the estimate when there is ample room', () => {
|
|
57
|
+
const r = anchoredRect(rect(100, 32), { estimatedHeight: 200 })
|
|
58
|
+
expect(r.maxHeight).toBe(200)
|
|
59
|
+
expect(r.availHeight).toBeGreaterThanOrEqual(200)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never returns a maxHeight below the minHeight floor', () => {
|
|
63
|
+
// Trigger pinned to the very bottom: almost no room below, lots above -> flips.
|
|
64
|
+
const r = anchoredRect(rect(VIEWPORT_H - 20, 20), { estimatedHeight: 300, minHeight: 96 })
|
|
65
|
+
expect(r.maxHeight).toBeGreaterThanOrEqual(96)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('anchoredRect - bottom anchor (natural-height upward panels)', () => {
|
|
70
|
+
it('returns a bottom offset that pins the panel just above the trigger', () => {
|
|
71
|
+
// Trigger top at 500, gap 2 -> panel bottom edge at 498 -> bottom offset
|
|
72
|
+
// from the viewport bottom = 800 - 500 + 2 = 302. Independent of any height
|
|
73
|
+
// estimate, so a bottom-anchored panel grows to its real content height.
|
|
74
|
+
const r = anchoredRect(rect(500, 32), { estimatedHeight: 999 })
|
|
75
|
+
expect(r.bottom).toBe(VIEWPORT_H - 500 + 2)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('bottom offset does not depend on the (possibly wrong) height estimate', () => {
|
|
79
|
+
const trigger = rect(600, 30)
|
|
80
|
+
const a = anchoredRect(trigger, { estimatedHeight: 40 })
|
|
81
|
+
const b = anchoredRect(trigger, { estimatedHeight: 500 })
|
|
82
|
+
expect(a.bottom).toBe(b.bottom)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('anchoredRect - horizontal', () => {
|
|
87
|
+
it('clamps a wide panel within the viewport', () => {
|
|
88
|
+
const r = anchoredRect(rect(100, 32, VIEWPORT_W - 50, 40), { estimatedHeight: 100, minWidth: 300 })
|
|
89
|
+
expect(r.width).toBe(300)
|
|
90
|
+
expect(r.left + r.width).toBeLessThanOrEqual(VIEWPORT_W)
|
|
91
|
+
expect(r.left).toBeGreaterThanOrEqual(4)
|
|
92
|
+
})
|
|
93
|
+
})
|
package/src/popover.ts
CHANGED
|
@@ -68,7 +68,31 @@ export function popIn(node: HTMLElement, param: { up?: boolean; duration?: numbe
|
|
|
68
68
|
return {}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export type AnchoredRect = {
|
|
71
|
+
export type AnchoredRect = {
|
|
72
|
+
top: number
|
|
73
|
+
left: number
|
|
74
|
+
width: number
|
|
75
|
+
openUpward: boolean
|
|
76
|
+
/**
|
|
77
|
+
* Comfortable panel height: the estimate, capped to the room on the chosen
|
|
78
|
+
* side so the panel never leaves the viewport. Apply as the panel's
|
|
79
|
+
* `max-height` and long content scrolls inside instead of overflowing.
|
|
80
|
+
*/
|
|
81
|
+
maxHeight: number
|
|
82
|
+
/**
|
|
83
|
+
* Absolute height the panel may occupy on the chosen side (room minus the
|
|
84
|
+
* viewport margin). The hard ceiling a user resize must clamp to.
|
|
85
|
+
*/
|
|
86
|
+
availHeight: number
|
|
87
|
+
/**
|
|
88
|
+
* For an upward-flipped panel: distance from the viewport bottom to where the
|
|
89
|
+
* panel's bottom edge sits (just above the trigger). Bottom-anchoring an
|
|
90
|
+
* upward panel with `style:bottom` lets it grow to its natural content height
|
|
91
|
+
* instead of being positioned from a (possibly wrong) height estimate.
|
|
92
|
+
* Undefined until `anchoredRect` computes it.
|
|
93
|
+
*/
|
|
94
|
+
bottom?: number
|
|
95
|
+
}
|
|
72
96
|
|
|
73
97
|
export type AnchorOptions = {
|
|
74
98
|
/** Estimated panel height, used to decide whether to flip upward. */
|
|
@@ -79,19 +103,30 @@ export type AnchorOptions = {
|
|
|
79
103
|
minWidth?: number
|
|
80
104
|
/** Keep the panel within the viewport horizontally. Default true. */
|
|
81
105
|
clampHorizontal?: boolean
|
|
106
|
+
/** Viewport edge kept clear top/bottom so a panel never touches it. Default 8. */
|
|
107
|
+
viewportMargin?: number
|
|
108
|
+
/** Floor for `maxHeight`/`availHeight` so a cramped panel stays usable. Default 96. */
|
|
109
|
+
minHeight?: number
|
|
82
110
|
}
|
|
83
111
|
|
|
84
112
|
/**
|
|
85
113
|
* Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
|
|
86
|
-
* trigger when there isn't room below and there's more room above.
|
|
87
|
-
*
|
|
88
|
-
*
|
|
114
|
+
* trigger when there isn't room below and there's more room above. Generalized
|
|
115
|
+
* with min-width + horizontal clamping for wider panels (date/time popovers),
|
|
116
|
+
* and with vertical bounds detection: `maxHeight` caps the panel to the room on
|
|
117
|
+
* the chosen side (minus a viewport margin) so a long list near a screen edge
|
|
118
|
+
* scrolls internally rather than overflowing, and an upward flip is clamped so
|
|
119
|
+
* its top never leaves the viewport.
|
|
89
120
|
*/
|
|
90
121
|
export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect {
|
|
91
122
|
const gap = opts.gap ?? 2
|
|
92
|
-
const
|
|
93
|
-
const
|
|
123
|
+
const margin = opts.viewportMargin ?? 8
|
|
124
|
+
const minH = opts.minHeight ?? 96
|
|
125
|
+
const spaceBelow = window.innerHeight - triggerRect.bottom - gap - margin
|
|
126
|
+
const spaceAbove = triggerRect.top - gap - margin
|
|
94
127
|
const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow
|
|
128
|
+
const availHeight = Math.max(minH, Math.floor(openUpward ? spaceAbove : spaceBelow))
|
|
129
|
+
const maxHeight = Math.min(opts.estimatedHeight, availHeight)
|
|
95
130
|
const width = Math.max(triggerRect.width, opts.minWidth ?? 0)
|
|
96
131
|
let left = triggerRect.left
|
|
97
132
|
if (opts.clampHorizontal !== false) {
|
|
@@ -99,9 +134,18 @@ export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): Anchore
|
|
|
99
134
|
left = Math.max(4, Math.min(left, maxLeft))
|
|
100
135
|
}
|
|
101
136
|
return {
|
|
102
|
-
|
|
137
|
+
// Upward: pin by the comfortable height and clamp to the top margin so the
|
|
138
|
+
// panel can never start off-screen. Downward: sit just below the trigger.
|
|
139
|
+
top: openUpward
|
|
140
|
+
? Math.max(margin, triggerRect.top - gap - maxHeight)
|
|
141
|
+
: triggerRect.bottom + gap,
|
|
103
142
|
left,
|
|
104
143
|
width,
|
|
105
144
|
openUpward,
|
|
145
|
+
maxHeight,
|
|
146
|
+
availHeight,
|
|
147
|
+
// Bottom-anchor value for upward panels: viewport bottom -> panel bottom
|
|
148
|
+
// edge (which sits `gap` above the trigger top).
|
|
149
|
+
bottom: Math.round(window.innerHeight - triggerRect.top + gap),
|
|
106
150
|
}
|
|
107
151
|
}
|
package/src/recurrence.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { matchesRecurrence, expandRecurrence, type RecurrenceRule } from './recurrence'
|
|
2
|
+
import { matchesRecurrence, expandRecurrence, describeRecurrence, type RecurrenceRule } from './recurrence'
|
|
3
3
|
|
|
4
4
|
// Fixed reference dates (local time). 2026-07-01 is a Wednesday.
|
|
5
5
|
const D = (s: string) => new Date(`${s}T00:00:00`)
|
|
@@ -82,10 +82,97 @@ describe('matchesRecurrence - bounds + combining', () => {
|
|
|
82
82
|
})
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
describe('matchesRecurrence - positional weekday-of-month', () => {
|
|
86
|
+
it('the 1st Tuesday of the month', () => {
|
|
87
|
+
const r: RecurrenceRule = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 }
|
|
88
|
+
expect(matchesRecurrence(D('2026-07-07'), r)).toBe(true) // 1st Tue of Jul
|
|
89
|
+
expect(matchesRecurrence(D('2026-07-14'), r)).toBe(false) // 2nd Tue
|
|
90
|
+
expect(matchesRecurrence(D('2026-08-04'), r)).toBe(true) // 1st Tue of Aug
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('the last Friday of the month', () => {
|
|
94
|
+
const r: RecurrenceRule = { freq: 'monthly', weekdays: [5], weekOfMonth: -1 }
|
|
95
|
+
expect(matchesRecurrence(D('2026-07-31'), r)).toBe(true) // last Fri of Jul
|
|
96
|
+
expect(matchesRecurrence(D('2026-07-24'), r)).toBe(false) // 4th but not last
|
|
97
|
+
expect(matchesRecurrence(D('2026-08-28'), r)).toBe(true) // last Fri of Aug
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('yearly positional: the 2nd Monday of November', () => {
|
|
101
|
+
const r: RecurrenceRule = { freq: 'yearly', month: 10, weekdays: [1], weekOfMonth: 2 }
|
|
102
|
+
expect(matchesRecurrence(D('2026-11-09'), r)).toBe(true) // 2nd Mon of Nov 2026
|
|
103
|
+
expect(matchesRecurrence(D('2026-11-16'), r)).toBe(false) // 3rd Mon
|
|
104
|
+
expect(matchesRecurrence(D('2026-10-12'), r)).toBe(false) // wrong month
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('matchesRecurrence - last day of month + count', () => {
|
|
109
|
+
it('the last day of the month (day: -1) across month lengths', () => {
|
|
110
|
+
const r: RecurrenceRule = { freq: 'monthly', day: -1 }
|
|
111
|
+
expect(matchesRecurrence(D('2026-07-31'), r)).toBe(true) // 31-day month
|
|
112
|
+
expect(matchesRecurrence(D('2026-02-28'), r)).toBe(true) // 28-day Feb
|
|
113
|
+
expect(matchesRecurrence(D('2026-02-27'), r)).toBe(false)
|
|
114
|
+
expect(matchesRecurrence(D('2026-06-30'), r)).toBe(true) // 30-day month
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('ends after N occurrences (count)', () => {
|
|
118
|
+
// 3 weekly Mondays from Jul 6: Jul 6, 13, 20 - then stop.
|
|
119
|
+
const r: RecurrenceRule = { freq: 'weekly', weekdays: [1], from: D('2026-07-06'), count: 3 }
|
|
120
|
+
expect(matchesRecurrence(D('2026-07-06'), r)).toBe(true)
|
|
121
|
+
expect(matchesRecurrence(D('2026-07-20'), r)).toBe(true) // 3rd
|
|
122
|
+
expect(matchesRecurrence(D('2026-07-27'), r)).toBe(false) // 4th - past the count
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
85
126
|
describe('expandRecurrence', () => {
|
|
86
127
|
it('lists the matching dates in a range', () => {
|
|
87
128
|
const r: RecurrenceRule = { freq: 'weekly', weekdays: [1] } // Mondays
|
|
88
129
|
const dates = expandRecurrence(r, D('2026-07-01'), D('2026-07-31')).map((d) => d.getDate())
|
|
89
130
|
expect(dates).toEqual([6, 13, 20, 27])
|
|
90
131
|
})
|
|
132
|
+
|
|
133
|
+
it('stops after `count` occurrences even in a wider window', () => {
|
|
134
|
+
const r: RecurrenceRule = { freq: 'weekly', weekdays: [1], from: D('2026-07-06'), count: 3 }
|
|
135
|
+
const dates = expandRecurrence(r, D('2026-07-01'), D('2026-08-31')).map((d) => d.getDate())
|
|
136
|
+
expect(dates).toEqual([6, 13, 20]) // exactly 3, not every Monday
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('describeRecurrence - human-readable summaries', () => {
|
|
141
|
+
it('empty / falsy rules read as the never label (default blank)', () => {
|
|
142
|
+
expect(describeRecurrence(null)).toBe('')
|
|
143
|
+
expect(describeRecurrence([])).toBe('')
|
|
144
|
+
expect(describeRecurrence(undefined, { never: 'Does not repeat' })).toBe('Does not repeat')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('daily + weekly', () => {
|
|
148
|
+
expect(describeRecurrence({ freq: 'daily' })).toBe('Daily')
|
|
149
|
+
expect(describeRecurrence({ freq: 'daily', interval: 2 })).toBe('Every 2 days')
|
|
150
|
+
expect(describeRecurrence({ freq: 'weekly', weekdays: [1, 2, 3, 4, 5] })).toBe('Weekly on weekdays')
|
|
151
|
+
expect(describeRecurrence({ freq: 'weekly', weekdays: [0, 6] })).toBe('Weekly on weekends')
|
|
152
|
+
expect(describeRecurrence({ freq: 'weekly', weekdays: [1, 3] })).toBe('Weekly on Mon, Wed')
|
|
153
|
+
expect(describeRecurrence({ freq: 'weekly', weekdays: [5], interval: 2 })).toBe('Every 2 weeks on Fri')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('monthly + yearly, positional + last day', () => {
|
|
157
|
+
expect(describeRecurrence({ freq: 'monthly', day: 1 })).toBe('Monthly on day 1')
|
|
158
|
+
expect(describeRecurrence({ freq: 'monthly', day: -1 })).toBe('Monthly on the last day')
|
|
159
|
+
expect(describeRecurrence({ freq: 'monthly', weekdays: [5], weekOfMonth: -1 })).toBe('Monthly on the last Fri')
|
|
160
|
+
expect(describeRecurrence({ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 })).toBe(
|
|
161
|
+
'Yearly on the fourth Thu of Nov',
|
|
162
|
+
)
|
|
163
|
+
expect(describeRecurrence({ freq: 'yearly', month: 11, day: 25 })).toBe('Yearly on day 25 of Dec')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('appends the end condition (count / until)', () => {
|
|
167
|
+
expect(describeRecurrence({ freq: 'weekly', weekdays: [1], count: 8 })).toBe('Weekly on Mon, 8 times')
|
|
168
|
+
expect(describeRecurrence({ freq: 'daily', until: D('2026-12-31') })).toBe('Daily until Dec 31, 2026')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('joins a list of rules', () => {
|
|
172
|
+
const rules: RecurrenceRule[] = [
|
|
173
|
+
{ freq: 'weekly', weekdays: [1] },
|
|
174
|
+
{ freq: 'monthly', day: 1 },
|
|
175
|
+
]
|
|
176
|
+
expect(describeRecurrence(rules)).toBe('Weekly on Mon; Monthly on day 1')
|
|
177
|
+
})
|
|
91
178
|
})
|