@rokkit/actions 1.0.0-next.100

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Jerry Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # Core Components
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@rokkit/actions",
3
+ "version": "1.0.0-next.100",
4
+ "description": "Contains generic actions that can be used in various components.",
5
+ "author": "Jerry Thomas <me@jerrythomas.name>",
6
+ "license": "MIT",
7
+ "main": "index.js",
8
+ "module": "src/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "type": "module",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "devDependencies": {
15
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
16
+ "@testing-library/svelte": "^5.2.1",
17
+ "@types/ramda": "^0.30.2",
18
+ "@vitest/coverage-v8": "^2.1.1",
19
+ "@vitest/ui": "~2.1.1",
20
+ "jsdom": "^25.0.0",
21
+ "svelte": "^4.2.19",
22
+ "typescript": "^5.6.2",
23
+ "vite": "^5.4.6",
24
+ "vitest": "~2.1.1",
25
+ "shared-config": "1.0.0-next.100",
26
+ "validators": "1.0.0-next.100"
27
+ },
28
+ "files": [
29
+ "src/**/*.js",
30
+ "src/**/*.svelte"
31
+ ],
32
+ "exports": {
33
+ "./src": "./src",
34
+ "./package.json": "./package.json",
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./src/index.js",
38
+ "svelte": "./src/index.js"
39
+ }
40
+ },
41
+ "dependencies": {
42
+ "ramda": "^0.30.1",
43
+ "@rokkit/core": "1.0.0-next.100",
44
+ "@rokkit/stores": "1.0.0-next.100"
45
+ },
46
+ "scripts": {
47
+ "format": "prettier --write .",
48
+ "lint": "eslint --fix .",
49
+ "test:ci": "vitest run",
50
+ "test:ui": "vitest --ui",
51
+ "test": "vitest",
52
+ "coverage": "vitest run --coverage",
53
+ "latest": "pnpm upgrade --latest && pnpm test:ci",
54
+ "release": "pnpm publish --access public"
55
+ }
56
+ }
@@ -0,0 +1,34 @@
1
+ import { EventManager } from './lib'
2
+ /**
3
+ * Svelte action function for forwarding keyboard events from a parent element to a child.
4
+ * The child is selected using a CSS selector passed in the options object.
5
+ * Optionally, you can specify which keyboard events you want to forward: "keydown", "keyup", and/or "keypress".
6
+ * By default, all three events are forwarded.
7
+ * The action returns an object with a destroy method.
8
+ * The destroy method removes all event listeners from the parent.
9
+ *
10
+ * @param {HTMLElement} element - The parent element from which keyboard events will be forwarded.
11
+ * @param {import('./types').PushDownOptions} options - The options object.
12
+ * @returns {{destroy: Function}}
13
+ */
14
+ export function delegateKeyboardEvents(
15
+ element,
16
+ { selector, events = ['keydown', 'keyup', 'keypress'] }
17
+ ) {
18
+ const child = element.querySelector(selector)
19
+ const handlers = {}
20
+ const manager = EventManager(element)
21
+
22
+ function forwardEvent(event) {
23
+ child.dispatchEvent(new KeyboardEvent(event.type, event))
24
+ }
25
+
26
+ if (child) {
27
+ events.forEach((event) => (handlers[event] = forwardEvent))
28
+ manager.update(handlers)
29
+ }
30
+
31
+ return {
32
+ destroy: () => manager.reset()
33
+ }
34
+ }
@@ -0,0 +1,33 @@
1
+ const KEYCODE_ESC = 27
2
+
3
+ /**
4
+ * A svelte action function that captures clicks outside the element or escape keypress
5
+ * emits a `dismiss` event. This is useful for closing a modal or dropdown.
6
+ *
7
+ * @param {HTMLElement} node
8
+ * @returns {import('./types').SvelteActionReturn}
9
+ */
10
+ export function dismissable(node) {
11
+ const handleClick = (event) => {
12
+ if (node && !node.contains(event.target) && !event.defaultPrevented) {
13
+ node.dispatchEvent(new CustomEvent('dismiss', node))
14
+ }
15
+ }
16
+ const keyup = (event) => {
17
+ if (event.keyCode === KEYCODE_ESC || event.key === 'Escape') {
18
+ event.stopPropagation()
19
+
20
+ node.dispatchEvent(new CustomEvent('dismiss', node))
21
+ }
22
+ }
23
+
24
+ document.addEventListener('click', handleClick, true)
25
+ document.addEventListener('keyup', keyup, true)
26
+
27
+ return {
28
+ destroy() {
29
+ document.removeEventListener('click', handleClick, true)
30
+ document.removeEventListener('keyup', keyup, true)
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Action for filling a <del>?</del> element in html block.
3
+ *
4
+ * @param {HTMLElement} node
5
+ * @param {import('./types').FillOptions} options
6
+ * @returns
7
+ */
8
+ export function fillable(node, { options, current, check }) {
9
+ const data = { options, current, check }
10
+ const blanks = node.getElementsByTagName('del')
11
+
12
+ function click(event) {
13
+ if (event.target.innerHTML !== '?') {
14
+ clear(event, node)
15
+ }
16
+ }
17
+
18
+ initialize(blanks, click)
19
+
20
+ return {
21
+ update(input) {
22
+ data.options = input.options
23
+ data.current = input.current
24
+ data.check = check
25
+
26
+ fill(blanks, data.options, data.current)
27
+ if (data.check) validate(blanks, data)
28
+ },
29
+ destroy() {
30
+ Object.keys(blanks).forEach((ref) => {
31
+ blanks[ref].removeEventListener('click', click)
32
+ })
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Initialize empty fillable element style and add listener for click
39
+ *
40
+ * @param {HTMLCollection} blanks
41
+ * @param {EventListener} click
42
+ */
43
+ function initialize(blanks, click) {
44
+ Object.keys(blanks).forEach((ref) => {
45
+ blanks[ref].addEventListener('click', click)
46
+ blanks[ref].classList.add('empty')
47
+ blanks[ref].name = `fill-${ref}`
48
+ blanks[ref]['data-index'] = ref
49
+ })
50
+ }
51
+
52
+ /**
53
+ * Fill current blank with provided option
54
+ *
55
+ * @param {HTMLCollection} blanks
56
+ * @param {Array<import('./types.js').FillableData>} options
57
+ * @param {*} current
58
+ */
59
+ function fill(blanks, options, current) {
60
+ if (current > -1 && current < Object.keys(blanks).length) {
61
+ const index = options.findIndex(({ actualIndex }) => actualIndex === current)
62
+ if (index > -1) {
63
+ blanks[current].innerHTML = options[index].value
64
+ blanks[current].classList.remove('empty')
65
+ blanks[current].classList.add('filled')
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Clear all fillable elements
72
+ *
73
+ * @param {EventListener} event
74
+ * @param {HTMLElement} node
75
+ */
76
+ function clear(event, node) {
77
+ event.target.innerHTML = '?'
78
+ event.target.classList.remove('filled')
79
+ event.target.classList.remove('pass')
80
+ event.target.classList.remove('fail')
81
+ event.target.classList.add('empty')
82
+ node.dispatchEvent(
83
+ new CustomEvent('remove', {
84
+ detail: {
85
+ index: event.target.name.split('-')[1],
86
+ value: event.target['data-index']
87
+ }
88
+ })
89
+ )
90
+ }
91
+
92
+ /**
93
+ * Validate the filled values
94
+ *
95
+ * @param {HTMLCollection} blanks
96
+ * @param {import('./types').FillOptions} data
97
+ */
98
+ function validate(blanks, data) {
99
+ Object.keys(blanks).forEach((_, ref) => {
100
+ const index = data.options.findIndex(({ actualIndex }) => actualIndex === ref)
101
+ if (index > -1)
102
+ blanks[ref].classList.add(
103
+ data.options[index].expectedIndex === data.options[index].actualIndex ? 'pass' : 'fail'
104
+ )
105
+ })
106
+ }
@@ -0,0 +1,156 @@
1
+ import { isExpanded } from '@rokkit/core'
2
+
3
+ /**
4
+ * Navigate to last visible child in the hirarchy starting with the provided path
5
+ *
6
+ * @param {Array<import('./types').PathFragment>} path - path to a node in the hierarchy
7
+ * @returns
8
+ */
9
+ export function navigateToLastVisibleChild(path) {
10
+ let current = path[path.length - 1]
11
+
12
+ while (isExpanded(current.items[current.index], current.fields)) {
13
+ const items = current.items[current.index][current.fields.children]
14
+ const level = {
15
+ items,
16
+ index: items.length - 1,
17
+ fields: current.fields.fields ?? current.fields
18
+ }
19
+ path.push(level)
20
+ current = level
21
+ }
22
+
23
+ return path
24
+ }
25
+
26
+ /**
27
+ * Navigate to the next item
28
+ *
29
+ * @param {Array<import('./types').PathFragment>} path - path to a node in the hierarchy
30
+ * @param {Array<*>} items - array of items
31
+ * @param {import('@rokkit/core').FieldMapping} fields - field mapping
32
+ * @returns
33
+ */
34
+ export function moveNext(path, items, fields) {
35
+ if (path.length === 0) {
36
+ return [{ index: 0, items, fields }]
37
+ }
38
+
39
+ const current = path[path.length - 1]
40
+ if (isExpanded(current.items[current.index], current.fields)) {
41
+ path.push({
42
+ items: current.items[current.index][current.fields.children],
43
+ index: 0,
44
+ fields: current.fields.fields || current.fields
45
+ })
46
+ } else if (current.index < current.items.length - 1) {
47
+ current.index++
48
+ } else {
49
+ path = navigateToNextLevel(path)
50
+ }
51
+ return path
52
+ }
53
+
54
+ /**
55
+ * Navigate to the next level
56
+ * @param {Array<import('./types').PathFragment>} path
57
+ * @returns {Array<import('./types').PathFragment>}
58
+ */
59
+ function navigateToNextLevel(path) {
60
+ let level = path.length - 2
61
+ while (level >= 0) {
62
+ const parent = path[level]
63
+ if (parent.index < parent.items.length - 1) {
64
+ parent.index++
65
+ path = path.slice(0, level + 1)
66
+ break
67
+ }
68
+ level--
69
+ }
70
+ return path
71
+ }
72
+ /**
73
+ * Navigate to the previous item
74
+ *
75
+ * @param {Array<import('./types').PathFragment>} path - path to a node in the hierarchy
76
+ * @returns
77
+ */
78
+ export function movePrevious(path) {
79
+ if (path.length === 0) return []
80
+
81
+ const current = path[path.length - 1]
82
+
83
+ if (current.index === 0) {
84
+ if (path.length > 1) path.pop()
85
+ return path
86
+ }
87
+
88
+ current.index--
89
+ if (isExpanded(current.items[current.index], current.fields)) {
90
+ return navigateToLastVisibleChild(path)
91
+ }
92
+ return path
93
+ }
94
+
95
+ /**
96
+ *
97
+ * @param {Array<integer>} indices
98
+ * @param {Array<*>} items
99
+ * @param {import('@rokkit/core').FieldMapping} fields
100
+ * @returns
101
+ */
102
+ export function pathFromIndices(indices, items, fields) {
103
+ const path = []
104
+ let fragment = {}
105
+ indices.forEach((index, level) => {
106
+ if (level === 0) {
107
+ fragment = { index, items, fields }
108
+ } else {
109
+ fragment = {
110
+ index,
111
+ items: fragment.items[fragment.index][fragment.fields.children],
112
+ fields: fragment.fields.fields ?? fragment.fields
113
+ }
114
+ }
115
+ path.push(fragment)
116
+ })
117
+ return path
118
+ }
119
+
120
+ /**
121
+ * Get the indices from the path
122
+ * @param {Array<import('./types').PathFragment>} path
123
+ * @returns {Array<integer>}
124
+ */
125
+ export function indicesFromPath(path) {
126
+ return path.map(({ index }) => index)
127
+ }
128
+
129
+ /**
130
+ * Get the current node from the path
131
+ * @param {Array<import('./types').PathFragment>} path
132
+ * @returns {*}
133
+ */
134
+ export function getCurrentNode(path) {
135
+ if (path.length === 0) return null
136
+ const lastIndex = path.length - 1
137
+ return path[lastIndex].items[path[lastIndex].index]
138
+ }
139
+
140
+ /**
141
+ * Find the item in the hierarchy using the indices
142
+ *
143
+ * @param {Array<*>} items
144
+ * @param {Array<integer>} indices
145
+ * @param {import('@rokkit/core').FieldMapping} fields
146
+ * @returns {*}
147
+ */
148
+ export function findItem(items, indices, fields) {
149
+ let item = items[indices[0]]
150
+ let levelFields = fields
151
+ for (let level = 1; level < indices.length; level++) {
152
+ item = item[levelFields.children][indices[level]]
153
+ levelFields = levelFields.fields ?? levelFields
154
+ }
155
+ return item
156
+ }
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ // skipcq: JS-E1004 - Needed for exposing all types
2
+ export * from './types'
3
+ // skipcq: JS-E1004 - Needed for exposing collection of functions
4
+ export * from './lib'
5
+ export { fillable } from './fillable'
6
+ export { pannable } from './pannable'
7
+ export { navigable } from './navigable'
8
+ export { navigator } from './navigator'
9
+ export { dismissable } from './dismissable'
10
+ export { themable } from './themeable'
11
+ export { swipeable } from './swipeable'
12
+ export { switchable } from './switchable'
13
+ export { delegateKeyboardEvents } from './delegate'
@@ -0,0 +1,35 @@
1
+ export const dimensionAttributes = {
2
+ vertical: {
3
+ scroll: 'scrollTop',
4
+ offset: 'offsetHeight',
5
+ paddingStart: 'paddingTop',
6
+ paddingEnd: 'paddingBottom',
7
+ size: 'height'
8
+ },
9
+ horizontal: {
10
+ scroll: 'scrollLeft',
11
+ offset: 'offsetWidth',
12
+ paddingStart: 'paddingLeft',
13
+ paddingEnd: 'paddingRight',
14
+ size: 'width'
15
+ }
16
+ }
17
+
18
+ export const defaultResizerOptions = {
19
+ horizontal: false,
20
+ minSize: 40,
21
+ minVisible: 1,
22
+ maxVisible: null,
23
+ availableSize: 200,
24
+ start: 0
25
+ }
26
+
27
+ export const defaultVirtualListOptions = {
28
+ itemSelector: 'virtual-list-item',
29
+ contentSelector: 'virtual-list-content',
30
+ enabled: true,
31
+ horizontal: false,
32
+ start: 0,
33
+ limit: null,
34
+ index: null
35
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * EventManager class to manage event listeners on an element.
3
+ *
4
+ * @param {HTMLElement} element - The element to listen for events on.
5
+ * @param {Object} handlers - An object with event names as keys and event handlers as values.
6
+ * @returns {Object} - An object with methods to activate, reset, and update the event listeners.
7
+ */
8
+ export function EventManager(element, handlers = {}) {
9
+ let listening = false
10
+
11
+ /**
12
+ * Activate the event listeners.
13
+ */
14
+ function activate() {
15
+ if (!listening) {
16
+ Object.entries(handlers).forEach(([event, handler]) =>
17
+ element.addEventListener(event, handler)
18
+ )
19
+ listening = true
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Reset the event listeners.
25
+ */
26
+ function reset() {
27
+ if (listening) {
28
+ Object.entries(handlers).forEach(([event, handler]) =>
29
+ element.removeEventListener(event, handler)
30
+ )
31
+ listening = false
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Update the event listeners.
37
+ *
38
+ * @param {Object} newHandlers - An object with event names as keys and event handlers as values.
39
+ * @param {boolean} enabled - Whether to enable or disable the event listeners.
40
+ */
41
+ function update(newHandlers = handlers, enabled = true) {
42
+ if (listening !== enabled || handlers !== newHandlers) {
43
+ reset()
44
+ handlers = newHandlers
45
+ if (enabled) activate()
46
+ }
47
+ }
48
+
49
+ return { activate, reset, update }
50
+ }
@@ -0,0 +1,5 @@
1
+ export { dimensionAttributes, defaultResizerOptions, defaultVirtualListOptions } from './constants'
2
+ // skipcq: JS-E1004 - Needed for exposing all functions
3
+ export * from './internal'
4
+ export { EventManager } from './event-manager'
5
+ export { virtualListViewport } from './viewport'
@@ -0,0 +1,185 @@
1
+ import { compact, hasChildren, isExpanded } from '@rokkit/core'
2
+
3
+ /**
4
+ * Emits a custom event with the given data.
5
+ *
6
+ * @param {HTMLElement} element
7
+ * @param {string} event
8
+ * @param {*} data
9
+ * @returns {void}
10
+ */
11
+ export function emit(element, event, data) {
12
+ element.dispatchEvent(new CustomEvent(event, { detail: data }))
13
+ }
14
+
15
+ /**
16
+ * Maps keyboard events to actions based on the given handlers and options.
17
+ *
18
+ * @param {import('../types').ActionHandlers} handlers
19
+ * @param {import('../types').NavigationOptions} options
20
+ * @returns {import('../types').KeyboardActions}
21
+ */
22
+ export function mapKeyboardEventsToActions(handlers, options) {
23
+ const { next, previous, select, escape } = handlers
24
+ const { horizontal, nested } = {
25
+ horizontal: false,
26
+ nested: false,
27
+ ...options
28
+ }
29
+ const expand = nested ? handlers.expand : null
30
+ const collapse = nested ? handlers.collapse : null
31
+
32
+ return compact({
33
+ ArrowDown: horizontal ? expand : next,
34
+ ArrowUp: horizontal ? collapse : previous,
35
+ ArrowRight: horizontal ? next : expand,
36
+ ArrowLeft: horizontal ? previous : collapse,
37
+ Enter: select,
38
+ Escape: escape,
39
+ ' ': select
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Finds the closest ancestor of the given element that has the given attribute.
45
+ *
46
+ * @param {HTMLElement} element
47
+ * @param {string} attribute
48
+ * @returns {HTMLElement|null}
49
+ */
50
+ export function getClosestAncestorWithAttribute(element, attribute) {
51
+ if (!element) return null
52
+ if (element.getAttribute(attribute)) return element
53
+ return getClosestAncestorWithAttribute(element.parentElement, attribute)
54
+ }
55
+
56
+ /**
57
+ * Sets up event handlers based on the given options.
58
+ * Returns whether or not the event handlers are listening.
59
+ *
60
+ * @param {HTMLElement} element
61
+ * @param {import('../types').EventHandlers} listeners
62
+ * @param {import('../types').TraversableOptions} options
63
+ * @returns {void}
64
+ */
65
+ export function setupListeners(element, listeners, options) {
66
+ const { enabled } = { enabled: true, ...options }
67
+ if (enabled) {
68
+ Object.entries(listeners).forEach(([event, listener]) =>
69
+ element.addEventListener(event, listener)
70
+ )
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Removes event handlers based on the given options.
76
+ * Returns whether or not the event handlers are listening.
77
+ *
78
+ * @param {HTMLElement} element
79
+ * @param {import('../types').EventHandlers} listeners
80
+ * @returns {void}
81
+ */
82
+ export function removeListeners(element, listeners) {
83
+ if (listeners) {
84
+ Object.entries(listeners).forEach(([event, listener]) => {
85
+ element.removeEventListener(event, listener)
86
+ })
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Handles the click event.
92
+ * @param {HTMLElement} element - The root element.
93
+ * @param {CurrentItem} current - A reference to the current Item
94
+ * @returns {CurrentItem} The updated current item.
95
+ */
96
+ export function handleItemClick(element, current) {
97
+ const { item, fields, position } = current
98
+ const detail = { item, position }
99
+
100
+ if (hasChildren(item, fields)) {
101
+ if (isExpanded(item, fields)) {
102
+ item[fields.isOpen] = false
103
+ emit(element, 'collapse', detail)
104
+ } else {
105
+ item[fields.isOpen] = true
106
+ emit(element, 'expand', detail)
107
+ }
108
+ } else {
109
+ emit(element, 'select', detail)
110
+ }
111
+ return current
112
+ }
113
+
114
+ /**
115
+ * Caclulates sum of array values between the given bounds.
116
+ * If a value is null, the default size is used.
117
+ *
118
+ * @param {Array<number|null>} sizes
119
+ * @param {number} lower
120
+ * @param {number} upper
121
+ * @param {number} [defaultSize]
122
+ * @returns {number}
123
+ */
124
+ export function calculateSum(sizes, lower, upper, defaultSize = 40, gap = 0) {
125
+ return (
126
+ sizes
127
+ .slice(lower, upper)
128
+ .map((size) => size ?? defaultSize)
129
+ .reduce((acc, size) => acc + size + gap, 0) - gap
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Updates the sizes array with the given values.
135
+ *
136
+ * @param {Array<number|null>} sizes
137
+ * @param {Array<number>} values
138
+ * @param {number} [offset]
139
+ * @returns {Array<number|null>}
140
+ */
141
+ export function updateSizes(sizes, values, offset = 0) {
142
+ const result = [...sizes.slice(0, offset), ...values, ...sizes.slice(offset + values.length)]
143
+
144
+ return result
145
+ }
146
+
147
+ /**
148
+ * Adjusts the viewport to ensure that the bounds contain the given number of items.
149
+ *
150
+ * @param {import('../types').Bounds} current
151
+ * @param {number} count
152
+ * @param {number} visibleCount
153
+ * @returns {import('../types').Bounds}
154
+ */
155
+ export function fixViewportForVisibileCount(current, count, visibleCount) {
156
+ let { lower, upper } = current
157
+ if (lower < 0) lower = 0
158
+ if (lower + visibleCount > count) {
159
+ upper = count
160
+ lower = Math.max(0, upper - visibleCount)
161
+ } else if (lower + visibleCount !== upper) {
162
+ upper = lower + visibleCount
163
+ }
164
+ return { lower, upper }
165
+ }
166
+
167
+ /**
168
+ * Adjusts the viewport to ensure the given index is visible.
169
+ *
170
+ * @param {number} index
171
+ * @param {import('../types').Bounds} current
172
+ * @param {number} visibleCount
173
+ * @returns {import('../types').Bounds}
174
+ */
175
+ export function fitIndexInViewport(index, current, visibleCount) {
176
+ let { lower, upper } = current
177
+ if (index >= upper) {
178
+ upper = index + 1
179
+ lower = upper - visibleCount
180
+ } else if (index < lower) {
181
+ lower = index
182
+ upper = lower + visibleCount
183
+ }
184
+ return { lower, upper }
185
+ }