@svgrid/grid 2.1.21 → 2.1.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/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/GridMenus.svelte +83 -3
- package/dist/SvAutoComplete.svelte +5 -3
- package/dist/SvCarousel.svelte +20 -42
- package/dist/SvColorInput.svelte +4 -3
- package/dist/SvColorInput.svelte.d.ts +1 -1
- package/dist/SvComboBox.svelte +7 -3
- package/dist/SvCommand.svelte +28 -85
- 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 +69 -47
- 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 +357 -12
- package/dist/SvGrid.types.d.ts +452 -1
- package/dist/SvGridDropdown.svelte +8 -1
- package/dist/SvGridSelect.svelte +1 -0
- package/dist/SvListBox.svelte +36 -14
- package/dist/SvMaskedInput.svelte +4 -3
- package/dist/SvMaskedInput.svelte.d.ts +1 -1
- package/dist/SvMenu.svelte +1 -1
- package/dist/SvMenuList.svelte +23 -71
- package/dist/SvMenuList.svelte.d.ts +2 -13
- package/dist/SvModal.svelte +12 -29
- package/dist/SvMultiSelect.svelte +1 -0
- package/dist/SvNumberInput.svelte +4 -3
- package/dist/SvNumberInput.svelte.d.ts +1 -1
- 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 +1 -1
- package/dist/SvStepper.svelte +6 -9
- package/dist/SvTagsInput.svelte +3 -2
- package/dist/SvTagsInput.svelte.d.ts +1 -1
- package/dist/SvTooltip.svelte +12 -23
- package/dist/SvTour.svelte +32 -4
- package/dist/SvTreeSelect.svelte +1 -0
- package/dist/build-api.js +76 -54
- package/dist/cdn/svgrid.js +10907 -6444
- package/dist/cdn/svgrid.svelte-external.js +9622 -5475
- 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 +5 -1
- package/src/DockNodeView.svelte +306 -0
- package/src/DockPopoutHost.svelte +17 -0
- package/src/GridMenus.svelte +83 -3
- package/src/SvAutoComplete.svelte +5 -3
- package/src/SvCarousel.svelte +20 -42
- package/src/SvCarousel.test.ts +68 -0
- package/src/SvColorInput.svelte +4 -3
- package/src/SvComboBox.svelte +7 -3
- package/src/SvCommand.svelte +28 -85
- package/src/SvCommand.test.ts +78 -0
- 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 +69 -47
- 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 +152 -28
- package/src/SvGrid.css +193 -5
- package/src/SvGrid.svelte +357 -12
- package/src/SvGrid.types.ts +463 -1
- package/src/SvGridDropdown.svelte +8 -1
- package/src/SvGridSelect.svelte +1 -0
- package/src/SvListBox.svelte +36 -14
- package/src/SvMaskedInput.svelte +4 -3
- package/src/SvMenu.svelte +1 -1
- package/src/SvMenu.test.ts +24 -0
- package/src/SvMenuList.svelte +23 -71
- package/src/SvModal.svelte +12 -29
- package/src/SvModal.test.ts +82 -0
- package/src/SvMultiSelect.svelte +1 -0
- package/src/SvNumberInput.svelte +4 -3
- package/src/SvPagination.svelte +18 -18
- package/src/SvPasswordInput.svelte +3 -2
- package/src/SvPhoneInput.svelte +3 -2
- package/src/SvPopover.svelte +1 -1
- package/src/SvStepper.svelte +6 -9
- package/src/SvTagsInput.svelte +3 -2
- package/src/SvTooltip.svelte +12 -23
- package/src/SvTooltip.test.ts +73 -0
- package/src/SvTour.svelte +32 -4
- package/src/SvTreeSelect.svelte +1 -0
- package/src/build-api.ts +74 -51
- package/src/columns.ts +5 -0
- package/src/conditional-formatting.test.ts +104 -0
- package/src/conditional-formatting.ts +176 -19
- package/src/createCarousel.svelte.ts +104 -0
- package/src/createCommand.svelte.ts +125 -0
- package/src/createForm.svelte.ts +333 -0
- 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 +5 -9
- package/src/createStepper.test.ts +62 -0
- package/src/createStepper.ts +61 -0
- package/src/createTooltip.svelte.ts +86 -0
- 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/editor-contract.ts +2 -0
- 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/form-field.ts +87 -0
- package/src/index.ts +158 -4
- 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 +73 -16
- 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/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/spreadsheet.ts +20 -12
- package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
- package/src/ui-app.types.ts +3 -0
- package/src/virtual.test.ts +6 -4
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { enabledIndices, wrapMove } from './list-nav'
|
|
3
|
+
|
|
4
|
+
describe('enabledIndices', () => {
|
|
5
|
+
it('lists every index when nothing is disabled', () => {
|
|
6
|
+
expect(enabledIndices(3)).toEqual([0, 1, 2])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('skips indices the predicate marks disabled', () => {
|
|
10
|
+
expect(enabledIndices(5, (i) => i === 1 || i === 3)).toEqual([0, 2, 4])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns an empty array for a zero count', () => {
|
|
14
|
+
expect(enabledIndices(0)).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('wrapMove', () => {
|
|
19
|
+
const list = [0, 2, 4] // e.g. indices 1 and 3 are disabled
|
|
20
|
+
|
|
21
|
+
it('moves forward to the next enabled index', () => {
|
|
22
|
+
expect(wrapMove(list, 2, 1)).toBe(4)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('wraps past the end back to the head', () => {
|
|
26
|
+
expect(wrapMove(list, 4, 1)).toBe(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('wraps before the head to the tail', () => {
|
|
30
|
+
expect(wrapMove(list, 0, -1)).toBe(4)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('from an absent current, delta 1 lands on the first entry', () => {
|
|
34
|
+
expect(wrapMove(list, -1, 1)).toBe(0)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('from an absent current, delta -1 lands on the last-but-one', () => {
|
|
38
|
+
expect(wrapMove(list, -1, -1)).toBe(2)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('stays put for a single-entry list', () => {
|
|
42
|
+
expect(wrapMove([5], 5, 1)).toBe(5)
|
|
43
|
+
expect(wrapMove([5], 5, -1)).toBe(5)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns -1 for an empty list', () => {
|
|
47
|
+
expect(wrapMove([], 0, 1)).toBe(-1)
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/list-nav.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-nav - the pure, framework-free navigation math shared by the roving-focus
|
|
3
|
+
* cores (createListbox, createPopoverSelect, createMenu). No runes, no DOM - just
|
|
4
|
+
* index arithmetic, so it is unit-testable on its own and behaves identically
|
|
5
|
+
* everywhere. The cores wrap these in `$state`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Indices of items that can take roving focus / selection - i.e. the ones the
|
|
9
|
+
* caller does not mark disabled. `count` is the total item count; `isDisabled`
|
|
10
|
+
* is consulted per index (a separator counts as disabled to the caller). */
|
|
11
|
+
export function enabledIndices(count: number, isDisabled?: (index: number) => boolean): number[] {
|
|
12
|
+
const out: number[] = []
|
|
13
|
+
for (let i = 0; i < count; i++) if (!isDisabled?.(i)) out.push(i)
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The next index when moving `delta` steps through `list`, wrapping around the
|
|
19
|
+
* ends. `current` need not be in `list` (it may sit on a disabled item): when it
|
|
20
|
+
* is absent we start just before the head so `delta: 1` lands on the first entry
|
|
21
|
+
* and `delta: -1` on the last-but-one - the same behaviour every existing core
|
|
22
|
+
* already had. Returns `-1` for an empty list.
|
|
23
|
+
*/
|
|
24
|
+
export function wrapMove(list: readonly number[], current: number, delta: number): number {
|
|
25
|
+
const len = list.length
|
|
26
|
+
if (len === 0) return -1
|
|
27
|
+
const pos = list.indexOf(current)
|
|
28
|
+
return list[(pos + delta + len) % len]!
|
|
29
|
+
}
|
package/src/list-option.ts
CHANGED
|
@@ -5,6 +5,8 @@ export type ListOption = {
|
|
|
5
5
|
disabled?: boolean
|
|
6
6
|
/** Optional group heading this option belongs under. */
|
|
7
7
|
group?: string
|
|
8
|
+
/** Optional color swatch (any CSS color) shown before the label. */
|
|
9
|
+
color?: string
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/** Case-insensitive substring filter over option labels. */
|
package/src/menu-item.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single item in a menu (SvMenu / SvMenuList / SvContextMenu) and the headless
|
|
5
|
+
* `createMenu` core. Kept in a plain `.ts` module so the core can import the type
|
|
6
|
+
* without pulling in the `.svelte` component - which would create a
|
|
7
|
+
* `.svelte.ts` -> `.svelte` -> `.svelte.ts` import cycle that Vite's dev-time
|
|
8
|
+
* import-analysis fails to resolve.
|
|
9
|
+
*/
|
|
10
|
+
export type MenuItem = {
|
|
11
|
+
/** Item text. Omit + set `separator` for a divider. */
|
|
12
|
+
label?: string
|
|
13
|
+
separator?: boolean
|
|
14
|
+
icon?: Snippet
|
|
15
|
+
/** Right-aligned hint, e.g. a keyboard shortcut. */
|
|
16
|
+
shortcut?: string
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
/** Nested submenu items (renders a flyout). */
|
|
19
|
+
children?: MenuItem[]
|
|
20
|
+
onSelect?: () => void
|
|
21
|
+
}
|
package/src/menus.test.ts
CHANGED
|
@@ -98,6 +98,54 @@ describe('createMenus - filter row/menu value updates', () => {
|
|
|
98
98
|
expect(ctx.filterRowValues.name).toBe('foo')
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
it('addFilterToken builds a newline-serialised in/notIn list, ignoring dupes/blanks', () => {
|
|
102
|
+
const ctx = makeCtx()
|
|
103
|
+
const m = createMenus(ctx)
|
|
104
|
+
m.addFilterToken('symbol', 'TSM')
|
|
105
|
+
m.addFilterToken('symbol', 'BP')
|
|
106
|
+
m.addFilterToken('symbol', 'TSM') // dupe - ignored
|
|
107
|
+
m.addFilterToken('symbol', ' ') // blank - ignored
|
|
108
|
+
expect(ctx.filterRowValues.symbol).toBe('TSM\nBP')
|
|
109
|
+
// mirrored into the menu model too, so both filter surfaces agree
|
|
110
|
+
expect(ctx.filterMenuValues.symbol.value).toBe('TSM\nBP')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('removeFilterToken drops a value from the list', () => {
|
|
114
|
+
const ctx = makeCtx({ filterRowValues: { symbol: 'TSM\nBP\nBABA' } })
|
|
115
|
+
const m = createMenus(ctx)
|
|
116
|
+
m.removeFilterToken('symbol', 'BP')
|
|
117
|
+
expect(ctx.filterRowValues.symbol).toBe('TSM\nBABA')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('toggleFilterToken adds when absent and removes when present', () => {
|
|
121
|
+
const ctx = makeCtx()
|
|
122
|
+
const m = createMenus(ctx)
|
|
123
|
+
m.toggleFilterToken('symbol', 'BP')
|
|
124
|
+
expect(ctx.filterRowValues.symbol).toBe('BP')
|
|
125
|
+
m.toggleFilterToken('symbol', 'BP')
|
|
126
|
+
expect(ctx.filterRowValues.symbol).toBe('')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('openInSuggest anchors the dropdown and targets the column; closeInSuggest clears it', () => {
|
|
130
|
+
const ctx = makeCtx()
|
|
131
|
+
const m = createMenus(ctx)
|
|
132
|
+
const anchor = {
|
|
133
|
+
getBoundingClientRect: () => ({ left: 40, bottom: 80 }),
|
|
134
|
+
} as unknown as HTMLElement
|
|
135
|
+
m.openInSuggest(anchor, 'symbol')
|
|
136
|
+
expect(ctx.inSuggestFor).toBe('symbol')
|
|
137
|
+
expect(ctx.inSuggestPos).toMatchObject({ y: 84 })
|
|
138
|
+
m.closeInSuggest()
|
|
139
|
+
expect(ctx.inSuggestFor).toBe(null)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('closeMenus also dismisses the in-suggest dropdown', () => {
|
|
143
|
+
const ctx = makeCtx({ inSuggestFor: 'symbol' })
|
|
144
|
+
const m = createMenus(ctx)
|
|
145
|
+
m.closeMenus()
|
|
146
|
+
expect(ctx.inSuggestFor).toBe(null)
|
|
147
|
+
})
|
|
148
|
+
|
|
101
149
|
it('updateFilterMenuValueTo sets the upper bound for between', () => {
|
|
102
150
|
const ctx = makeCtx({ filterMenuValues: { age: { operator: 'between', value: '5' } } })
|
|
103
151
|
const m = createMenus(ctx)
|
package/src/menus.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type TableFeatures,
|
|
41
41
|
} from "./index";
|
|
42
42
|
import "./sv-grid-scrollbar";
|
|
43
|
+
import { untrack } from "svelte";
|
|
43
44
|
import type { Snippet } from "svelte";
|
|
44
45
|
import { getKeyboardIntent, getNextActiveCell } from "./keyboard";
|
|
45
46
|
import {
|
|
@@ -115,6 +116,7 @@ import {
|
|
|
115
116
|
buildBuckets,
|
|
116
117
|
isInBucket,
|
|
117
118
|
} from "./facet-buckets";
|
|
119
|
+
import { splitInTokens, joinInTokens } from "./filtering/excel-filters";
|
|
118
120
|
import {
|
|
119
121
|
getColumnBaseValue,
|
|
120
122
|
isGroupRow,
|
|
@@ -179,6 +181,34 @@ export function createMenus<
|
|
|
179
181
|
};
|
|
180
182
|
}
|
|
181
183
|
|
|
184
|
+
/** Current `in` / `notIn` token list for a column (from the filter-row value). */
|
|
185
|
+
function filterTokens(columnId: string): string[] {
|
|
186
|
+
return splitInTokens(ctx.filterRowValues[columnId] ?? "");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Add a value to a column's `in` / `notIn` list (no-op if already present). */
|
|
190
|
+
function addFilterToken(columnId: string, token: string) {
|
|
191
|
+
const t = token.trim();
|
|
192
|
+
if (!t) return;
|
|
193
|
+
const tokens = filterTokens(columnId);
|
|
194
|
+
if (!tokens.includes(t)) tokens.push(t);
|
|
195
|
+
updateFilterRow(columnId, joinInTokens(tokens));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Remove a value from a column's `in` / `notIn` list. */
|
|
199
|
+
function removeFilterToken(columnId: string, token: string) {
|
|
200
|
+
updateFilterRow(
|
|
201
|
+
columnId,
|
|
202
|
+
joinInTokens(filterTokens(columnId).filter((t) => t !== token)),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Toggle a value in a column's `in` / `notIn` list (checklist behaviour). */
|
|
207
|
+
function toggleFilterToken(columnId: string, token: string) {
|
|
208
|
+
if (filterTokens(columnId).includes(token)) removeFilterToken(columnId, token);
|
|
209
|
+
else addFilterToken(columnId, token);
|
|
210
|
+
}
|
|
211
|
+
|
|
182
212
|
function toggleCheckboxWithKeyboard(
|
|
183
213
|
event: KeyboardEvent,
|
|
184
214
|
toggle: () => void,
|
|
@@ -193,7 +223,9 @@ export function createMenus<
|
|
|
193
223
|
const menuFilter = ctx.filterMenuValues[columnId];
|
|
194
224
|
return Boolean(
|
|
195
225
|
menuFilter &&
|
|
196
|
-
(menuFilter.operator === "isBlank" ||
|
|
226
|
+
(menuFilter.operator === "isBlank" ||
|
|
227
|
+
menuFilter.operator === "isNotBlank" ||
|
|
228
|
+
menuFilter.value.trim()),
|
|
197
229
|
);
|
|
198
230
|
}
|
|
199
231
|
|
|
@@ -201,9 +233,23 @@ export function createMenus<
|
|
|
201
233
|
ctx.columnMenuFor = null;
|
|
202
234
|
ctx.filterMenuFor = null;
|
|
203
235
|
ctx.operatorMenuFor = null;
|
|
236
|
+
ctx.inSuggestFor = null;
|
|
204
237
|
ctx.chooseColumnsPos = null;
|
|
205
238
|
}
|
|
206
239
|
|
|
240
|
+
/** Open the `in` / `notIn` value-suggestions dropdown under a chip input. */
|
|
241
|
+
function openInSuggest(anchor: HTMLElement, columnId: string) {
|
|
242
|
+
const rect = anchor.getBoundingClientRect();
|
|
243
|
+
ctx.inSuggestPos = { x: clampMenuX(rect.left, 220), y: rect.bottom + 4 };
|
|
244
|
+
ctx.inSuggestQuery = "";
|
|
245
|
+
ctx.inSuggestFor = columnId;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function closeInSuggest() {
|
|
249
|
+
ctx.inSuggestFor = null;
|
|
250
|
+
ctx.inSuggestQuery = "";
|
|
251
|
+
}
|
|
252
|
+
|
|
207
253
|
function openChooseColumns(event: MouseEvent) {
|
|
208
254
|
event.stopPropagation();
|
|
209
255
|
// Submenu behavior: keep the parent operations menu open and float the
|
|
@@ -351,21 +397,27 @@ export function createMenus<
|
|
|
351
397
|
}
|
|
352
398
|
|
|
353
399
|
function clearColumnFilter(columnId: string) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
400
|
+
// `untrack`: read-modify-write on the filter maps. Keeps the method safe to
|
|
401
|
+
// call from a consumer's reactive `$effect` (e.g. via `api.clearFilter`)
|
|
402
|
+
// without capturing these reads as a dependency and looping. See
|
|
403
|
+
// `build-api`'s `setFilter` for the full rationale.
|
|
404
|
+
untrack(() => {
|
|
405
|
+
if (ctx.valueFilters[columnId]) {
|
|
406
|
+
const next = { ...ctx.valueFilters };
|
|
407
|
+
delete next[columnId];
|
|
408
|
+
ctx.valueFilters = next;
|
|
409
|
+
}
|
|
410
|
+
if (ctx.filterMenuValues[columnId]) {
|
|
411
|
+
const next = { ...ctx.filterMenuValues };
|
|
412
|
+
delete next[columnId];
|
|
413
|
+
ctx.filterMenuValues = next;
|
|
414
|
+
}
|
|
415
|
+
if (ctx.filterRowValues[columnId]) {
|
|
416
|
+
const next = { ...ctx.filterRowValues };
|
|
417
|
+
delete next[columnId];
|
|
418
|
+
ctx.filterRowValues = next;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
369
421
|
}
|
|
370
422
|
|
|
371
423
|
// External pagination is controlled: emit the requested page/size and let
|
|
@@ -553,9 +605,14 @@ export function createMenus<
|
|
|
553
605
|
updateFilterOperator,
|
|
554
606
|
updateFilterMenuValue,
|
|
555
607
|
updateFilterMenuValueTo,
|
|
608
|
+
addFilterToken,
|
|
609
|
+
removeFilterToken,
|
|
610
|
+
toggleFilterToken,
|
|
556
611
|
toggleCheckboxWithKeyboard,
|
|
557
612
|
isColumnFiltered,
|
|
558
613
|
closeMenus,
|
|
614
|
+
openInSuggest,
|
|
615
|
+
closeInSuggest,
|
|
559
616
|
openChooseColumns,
|
|
560
617
|
openColumnMenu,
|
|
561
618
|
openFilterMenu,
|
|
@@ -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
|
})
|