@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.
@@ -0,0 +1,123 @@
1
+ import { writable, get } from 'svelte/store'
2
+ import { pick } from 'ramda'
3
+ import {
4
+ updateSizes,
5
+ calculateSum,
6
+ fixViewportForVisibileCount,
7
+ fitIndexInViewport
8
+ } from './internal'
9
+
10
+ export function virtualListViewport(options) {
11
+ const { gap = 0 } = options
12
+ let { minSize = 40, maxVisible = 0, visibleSize } = options
13
+ let current = { lower: 0, upper: 0 }
14
+ const bounds = writable({ lower: 0, upper: 0 })
15
+ const space = writable({
16
+ before: 0,
17
+ after: 0
18
+ })
19
+ let items = null
20
+ let averageSize = minSize
21
+ let visibleCount = maxVisible
22
+ let value = null
23
+ let cache = []
24
+ let index = -1
25
+
26
+ const updateBounds = ({ lower, upper }) => {
27
+ const previous = get(bounds)
28
+ if (maxVisible > 0) {
29
+ const visible = calculateSum(cache, lower, upper, averageSize, gap)
30
+ space.update((state) => (state = { ...state, visible }))
31
+ }
32
+ if (previous.lower !== lower) {
33
+ const before = calculateSum(cache, 0, lower, averageSize)
34
+ space.update((state) => (state = { ...state, before }))
35
+ }
36
+ if (previous.upper !== upper) {
37
+ const after = calculateSum(cache, upper, cache.length, averageSize)
38
+ space.update((state) => (state = { ...state, after }))
39
+ }
40
+ if (previous.lower !== lower || previous.upper !== upper) {
41
+ bounds.set({ lower, upper })
42
+ }
43
+ }
44
+
45
+ const update = (data) => {
46
+ // const previous = get(bounds)
47
+
48
+ data = {
49
+ start: current.lower,
50
+ end: current.upper,
51
+ value,
52
+ ...data
53
+ }
54
+ items = data.items ?? items
55
+ minSize = data.minSize ?? minSize
56
+ maxVisible = data.maxVisible ?? maxVisible
57
+ visibleSize = data.visibleSize ?? visibleSize
58
+
59
+ if (items.length !== cache.length) {
60
+ cache = Array.from({ length: items.length }).fill(null)
61
+ if (items.length === 0) index = -1
62
+ }
63
+ current = { lower: data.start, upper: data.end }
64
+
65
+ cache = updateSizes(cache, data.sizes ?? [], current.lower)
66
+ averageSize =
67
+ cache.length === 0
68
+ ? minSize
69
+ : calculateSum(cache, 0, cache.length, averageSize) / cache.length
70
+
71
+ let visible = calculateSum(cache, current.lower, current.upper, averageSize, gap)
72
+
73
+ if (maxVisible > 0) {
74
+ visibleCount = maxVisible
75
+ } else {
76
+ while (visible < visibleSize) visible += averageSize
77
+ while (visible - averageSize > visibleSize) visible -= averageSize
78
+ visibleCount = Math.ceil(visible / averageSize)
79
+ }
80
+ current = fixViewportForVisibileCount(current, cache.length, visibleCount)
81
+
82
+ // recalculate the lower, upper bounds based on current index
83
+ if (items.length > 0 && data.value && data.value !== value) {
84
+ index = items.findIndex((item) => item === data.value)
85
+ if (index > -1) {
86
+ value = data.value
87
+ current = fitIndexInViewport(index, current, visibleCount)
88
+ }
89
+ }
90
+ updateBounds(current)
91
+ }
92
+ const moveByOffset = (offset) => {
93
+ if (cache.length > 0) {
94
+ index = Math.max(0, Math.min(index + offset, cache.length - 1))
95
+ current = fitIndexInViewport(index, current, visibleCount)
96
+ updateBounds(current)
97
+ }
98
+ }
99
+
100
+ const scrollTo = (position) => {
101
+ const start = Math.round(position / averageSize)
102
+ if (start !== current.lower) update({ start })
103
+ }
104
+
105
+ update(options)
106
+
107
+ return {
108
+ bounds: pick(['subscribe'], bounds),
109
+ space: pick(['subscribe'], space),
110
+ get index() {
111
+ return index
112
+ },
113
+ update,
114
+ scrollTo,
115
+ moveByOffset,
116
+ next: () => moveByOffset(1),
117
+ previous: () => moveByOffset(-1),
118
+ nextPage: () => moveByOffset(visibleCount),
119
+ previousPage: () => moveByOffset(-visibleCount),
120
+ first: () => moveByOffset(-cache.length),
121
+ last: () => moveByOffset(cache.length + 1)
122
+ }
123
+ }
@@ -0,0 +1,46 @@
1
+ import { handleAction, getKeyboardActions } from './utils'
2
+
3
+ const defaultOptions = { horizontal: true, nested: false, enabled: true }
4
+ /**
5
+ * A svelte action function that captures keyboard evvents and emits event for corresponding movements.
6
+ *
7
+ * @param {HTMLElement} node
8
+ * @param {import('./types').NavigableOptions} options
9
+ * @returns {import('./types').SvelteActionReturn}
10
+ */
11
+ export function navigable(node, options) {
12
+ options = { ...defaultOptions, ...options }
13
+
14
+ let listening = false
15
+ const handlers = {
16
+ previous: () => node.dispatchEvent(new CustomEvent('previous')),
17
+ next: () => node.dispatchEvent(new CustomEvent('next')),
18
+ collapse: () => node.dispatchEvent(new CustomEvent('collapse')),
19
+ expand: () => node.dispatchEvent(new CustomEvent('expand')),
20
+ select: () => node.dispatchEvent(new CustomEvent('select'))
21
+ }
22
+
23
+ let actions = {} //getKeyboardActions(node, { horizontal, nested })
24
+ const handleKeydown = (event) => handleAction(actions, event)
25
+
26
+ /**
27
+ * Update the listeners based on the input configuration.
28
+ * @param {import('./types').NavigableOptions} input
29
+ */
30
+ function updateListeners(input) {
31
+ options = { ...options, ...input }
32
+ if (listening) node.removeEventListener('keydown', handleKeydown)
33
+
34
+ actions = getKeyboardActions(node, input, handlers)
35
+ if (input.enabled) node.addEventListener('keydown', handleKeydown)
36
+
37
+ listening = input.enabled
38
+ }
39
+
40
+ updateListeners(options)
41
+
42
+ return {
43
+ update: (config) => updateListeners(config),
44
+ destroy: () => updateListeners({ enabled: false })
45
+ }
46
+ }
@@ -0,0 +1,182 @@
1
+ import { handleAction } from './utils'
2
+ import { noop, isNested, hasChildren, isExpanded } from '@rokkit/core'
3
+ import {
4
+ moveNext,
5
+ movePrevious,
6
+ pathFromIndices,
7
+ indicesFromPath,
8
+ getCurrentNode
9
+ } from './hierarchy'
10
+ import { mapKeyboardEventsToActions } from './lib'
11
+ /**
12
+ * Keyboard navigation for Lists and NestedLists. The data is either nested or not and is not
13
+ * expected to switch from nested to simple list or vice-versa.
14
+ *
15
+ * @param {HTMLElement} element - Root element for the actionn
16
+ * @param {import('./types').NavigatorOptions} options - Configuration options for the action
17
+ * @returns
18
+ */
19
+ export function navigator(element, options) {
20
+ const { fields, enabled = true, vertical = true, idPrefix = 'id-' } = options
21
+ let items = [],
22
+ path = null,
23
+ currentNode = null
24
+
25
+ if (!enabled) return { destroy: noop }
26
+
27
+ const update = (input) => {
28
+ const previousNode = currentNode
29
+ items = input.items
30
+ path = pathFromIndices(input.indices ?? [], items, fields)
31
+ currentNode = getCurrentNode(path)
32
+
33
+ if (previousNode !== currentNode && currentNode) {
34
+ const indices = indicesFromPath(path)
35
+ const current = element.querySelector(`#${idPrefix}${indices.join('-')}`)
36
+ if (current) {
37
+ current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
38
+ }
39
+ }
40
+ }
41
+
42
+ const next = () => {
43
+ const previousNode = currentNode
44
+ path = moveNext(path, items, fields)
45
+ currentNode = getCurrentNode(path)
46
+ if (previousNode !== currentNode) moveTo(element, path, currentNode, idPrefix)
47
+ }
48
+
49
+ const previous = () => {
50
+ const previousNode = currentNode
51
+ path = movePrevious(path)
52
+ if (path.length > 0) {
53
+ currentNode = getCurrentNode(path)
54
+ if (previousNode !== currentNode) moveTo(element, path, currentNode, idPrefix)
55
+ }
56
+ }
57
+ const select = () => {
58
+ if (currentNode) emit('select', element, indicesFromPath(path), currentNode)
59
+ }
60
+ const collapse = () => {
61
+ if (currentNode) {
62
+ const expanded = isExpanded(currentNode, path[path.length - 1].fields)
63
+ if (expanded) {
64
+ toggle()
65
+ } else if (path.length > 0) {
66
+ path = path.slice(0, -1)
67
+ currentNode = getCurrentNode(path)
68
+ select()
69
+ }
70
+ }
71
+ }
72
+ const expand = () => {
73
+ if (currentNode && hasChildren(currentNode, path[path.length - 1].fields)) {
74
+ toggle()
75
+ }
76
+ }
77
+ function toggle() {
78
+ const expanded = isExpanded(currentNode, path[path.length - 1].fields)
79
+ const event = expanded ? 'collapse' : 'expand'
80
+ currentNode[path[path.length - 1].fields.isOpen] = !expanded
81
+ emit(event, element, indicesFromPath(path), currentNode)
82
+ }
83
+ const handlers = { next, previous, select, collapse, expand }
84
+
85
+ update(options)
86
+
87
+ const nested = isNested(items, fields)
88
+ const actions = mapKeyboardEventsToActions(handlers, {
89
+ horizontal: !vertical,
90
+ nested
91
+ })
92
+
93
+ const handleKeyDown = (event) => handleAction(actions, event)
94
+
95
+ const handleClick = (event) => {
96
+ event.stopPropagation()
97
+ const target = findParentWithDataPath(event.target, element)
98
+ const indices = !target
99
+ ? []
100
+ : target.dataset.path
101
+ .split(',')
102
+ .filter((item) => item !== '')
103
+ .map((item) => Number(item))
104
+
105
+ if (indices.length > 0 && event.target.tagName !== 'DETAIL') {
106
+ path = pathFromIndices(indices, items, fields)
107
+ currentNode = getCurrentNode(path)
108
+ if (hasChildren(currentNode, path[path.length - 1].fields)) {
109
+ currentNode[path[path.length - 1].fields.isOpen] =
110
+ !currentNode[path[path.length - 1].fields.isOpen]
111
+ const eventName = currentNode[path[path.length - 1].fields.isOpen] ? 'expand' : 'collapse'
112
+ emit(eventName, element, indices, currentNode)
113
+ } else if (currentNode !== null) emit('select', element, indices, currentNode)
114
+ emit('move', element, indices, currentNode)
115
+ // emit('select', element, indices, currentNode)
116
+ }
117
+ }
118
+
119
+ element.addEventListener('keydown', handleKeyDown)
120
+ element.addEventListener('click', handleClick)
121
+
122
+ return {
123
+ update,
124
+ destroy() {
125
+ element.removeEventListener('keydown', handleKeyDown)
126
+ element.removeEventListener('click', handleClick)
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Move to the element with the given path
133
+ *
134
+ * @param {HTMLElement} element
135
+ * @param {*} path
136
+ * @param {*} currentNode
137
+ * @param {*} idPrefix
138
+ */
139
+ export function moveTo(element, path, currentNode, idPrefix) {
140
+ const indices = indicesFromPath(path)
141
+ const current = element.querySelector(`#${idPrefix}${indices.join('-')}`)
142
+ if (current) current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
143
+
144
+ emit('move', element, indices, currentNode)
145
+ }
146
+
147
+ /**
148
+ * Find the parent element with data-path attribute
149
+ *
150
+ * @param {HTMLElement} element
151
+ * @param {HTMLElement} root
152
+ * @returns {HTMLElement}
153
+ */
154
+ export function findParentWithDataPath(element, root) {
155
+ if (element.hasAttribute('data-path')) return element
156
+ let parent = element.parentNode
157
+
158
+ while (parent && parent !== root && !parent.hasAttribute('data-path')) {
159
+ parent = parent.parentNode
160
+ }
161
+
162
+ return parent !== root ? parent : null
163
+ }
164
+
165
+ /**
166
+ * Emit a custom event on the element with the path and node as detail
167
+ *
168
+ * @param {string} event
169
+ * @param {HTMLElement} element
170
+ * @param {Array<integer>} indices
171
+ * @param {*} node
172
+ */
173
+ function emit(event, element, indices, node) {
174
+ element.dispatchEvent(
175
+ new CustomEvent(event, {
176
+ detail: {
177
+ path: indices,
178
+ node
179
+ }
180
+ })
181
+ )
182
+ }
@@ -0,0 +1,67 @@
1
+ import { omit } from 'ramda'
2
+ import { removeListeners, setupListeners } from './lib'
3
+ /**
4
+ * Makes an element pannable with mouse or touch events.
5
+ *
6
+ * @param {HTMLElement} node The DOM element to apply the panning action.
7
+ * @returns {import('./types').SvelteActionReturn}
8
+ */
9
+ export function pannable(node) {
10
+ let coords = { x: 0, y: 0 }
11
+ const listeners = {
12
+ primary: {
13
+ mousedown: start,
14
+ touchstart: start
15
+ },
16
+ secondary: {
17
+ mousemove: move,
18
+ mouseup: stop,
19
+ touchmove: move,
20
+ touchend: stop
21
+ }
22
+ }
23
+
24
+ function start(event) {
25
+ coords = handleEvent(node, event, 'panstart', coords)
26
+ setupListeners(window, listeners.secondary)
27
+ }
28
+
29
+ function move(event) {
30
+ coords = handleEvent(node, event, 'panmove', coords)
31
+ }
32
+
33
+ function stop(event) {
34
+ coords = handleEvent(node, event, 'panend', coords)
35
+ removeListeners(window, listeners.secondary)
36
+ }
37
+
38
+ setupListeners(node, listeners.primary)
39
+
40
+ return {
41
+ destroy: () => removeListeners(node, listeners.primary)
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Handles the panning event.
47
+ *
48
+ * @param {HTMLElement} node - The node where the event is dispatched.
49
+ * @param {Event} event - The event object.
50
+ * @param {string} name - The name of the event.
51
+ * @param {import('./types').Coords} coords - The previous coordinates of the event.
52
+ */
53
+ function handleEvent(node, event, name, coords) {
54
+ const x = event.clientX || event.touches[0].clientX
55
+ const y = event.clientY || event.touches[0].clientY
56
+ const detail = { x, y }
57
+
58
+ if (name === 'panmove') {
59
+ detail.dx = x - coords.x
60
+ detail.dy = y - coords.y
61
+ }
62
+
63
+ event.stopPropagation()
64
+ event.preventDefault()
65
+ node.dispatchEvent(new CustomEvent(name, { detail }))
66
+ return omit(['dx', 'dy'], detail)
67
+ }
@@ -0,0 +1,150 @@
1
+ import { removeListeners, setupListeners } from './lib'
2
+
3
+ const defaultOptions = {
4
+ horizontal: true,
5
+ vertical: false,
6
+ threshold: 100,
7
+ enabled: true,
8
+ minSpeed: 300
9
+ }
10
+
11
+ /**
12
+ * A svelte action function that captures swipe actions and emits event for corresponding movements.
13
+ *
14
+ * @param {HTMLElement} node
15
+ * @param {import(./types).SwipeableOptions} options
16
+ * @returns {import('./types').SvelteActionReturn}
17
+ */
18
+ export function swipeable(node, options = defaultOptions) {
19
+ const track = {}
20
+ let listeners = {}
21
+
22
+ const updateListeners = (props) => {
23
+ removeListeners(node, listeners)
24
+ listeners = getListeners(node, props, track)
25
+ setupListeners(node, listeners, props)
26
+ }
27
+
28
+ options = { ...defaultOptions, ...options }
29
+ updateListeners(options)
30
+
31
+ return {
32
+ update: (data) => {
33
+ options = { ...options, ...data }
34
+ updateListeners(options)
35
+ },
36
+ destroy() {
37
+ removeListeners(node, listeners)
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Returns the listeners for the swipeable action.
44
+ * @param {HTMLElement} node - The node where the event is dispatched.
45
+ * @param {import(./types).SwipeableOptions} options - The options for the swipe.
46
+ * @param {import(./types).TouchTracker} track - The tracking object.
47
+ * @returns {import(./types).Listeners}
48
+ */
49
+ function getListeners(node, options, track) {
50
+ if (!options.enabled) return {}
51
+
52
+ const listeners = {
53
+ touchend: (e) => touchEnd(e, node, options, track),
54
+ touchstart: (e) => touchStart(e, track),
55
+ mousedown: (e) => touchStart(e, track),
56
+ mouseup: (e) => touchEnd(e, node, options, track)
57
+ }
58
+ return listeners
59
+ }
60
+
61
+ /**
62
+ * Handles the touch start event.
63
+ *
64
+ * @param {Event} event
65
+ * @param {import(./types).TouchTracker} track
66
+ */
67
+ function touchStart(event, track) {
68
+ const touch = event.touches ? event.touches[0] : event
69
+ track.startX = touch.clientX
70
+ track.startY = touch.clientY
71
+ track.startTime = new Date().getTime()
72
+ }
73
+
74
+ /**
75
+ * Handles the touch end event and triggers a swipe event if the criteria are met.
76
+ *
77
+ * @param {Event} event - The event object representing the touch or mouse event.
78
+ * @param {HTMLElement} node - The HTML element on which the swipe event will be dispatched.
79
+ * @param {object} options - Configuration options for determining swipe behavior.
80
+ * @param {object} track - An object tracking the start point and time of the touch or swipe action.
81
+ */
82
+ function touchEnd(event, node, options, track) {
83
+ const { distance, duration } = getTouchMetrics(event, track)
84
+ if (!isSwipeFastEnough(distance, duration, options.minSpeed)) return
85
+
86
+ const swipeDetails = getSwipeDetails(distance, options)
87
+ if (!swipeDetails.isValid) return
88
+ node.dispatchEvent(new CustomEvent(`swipe${swipeDetails.direction}`))
89
+ }
90
+
91
+ /**
92
+ * Calculates and returns the distance and duration of the swipe.
93
+ *
94
+ * @param {Event} event - The event object that initiated the touchEnd.
95
+ * @param {object} track - The tracking object holding the start of the touch action.
96
+ * @returns {{distance: {x: number, y: number}, duration: number}} The distance swiped (x and y) and the duration of the swipe.
97
+ */
98
+ function getTouchMetrics(event, track) {
99
+ const touch = event.changedTouches ? event.changedTouches[0] : event
100
+ const distX = touch.clientX - track.startX
101
+ const distY = touch.clientY - track.startY
102
+ const duration = (new Date().getTime() - track.startTime) / 1000
103
+ return { distance: { x: distX, y: distY }, duration }
104
+ }
105
+
106
+ /**
107
+ * Checks if the swipe was fast enough according to the minimum speed requirement.
108
+ *
109
+ * @param {{x: number, y: number}} distance - The distance of the swipe action.
110
+ * @param {number} duration - The duration of the swipe action in seconds.
111
+ * @param {number} minSpeed - The minimum speed threshold for the swipe action.
112
+ * @returns {boolean} True if the swipe is fast enough, otherwise false.
113
+ */
114
+ function isSwipeFastEnough(distance, duration, minSpeed) {
115
+ const speed = Math.max(Math.abs(distance.x), Math.abs(distance.y)) / duration
116
+ return speed > minSpeed
117
+ }
118
+
119
+ /**
120
+ * Determines swipe validity and direction based on horizontal/vertical preferences and thresholds.
121
+ *
122
+ * @param {{x: number, y: number}} distance - The distance of the swipe.
123
+ * @param {object} options - Configuration options such as direction preferences and thresholds.
124
+ * @returns {{isValid: boolean, direction?: string}} Object indicating whether the swipe is valid, and if so, its direction.
125
+ */
126
+ function getSwipeDetails(distance, options) {
127
+ const isHorizontalSwipe = options.horizontal && Math.abs(distance.x) >= options.threshold
128
+ const isVerticalSwipe = options.vertical && Math.abs(distance.y) >= options.threshold
129
+ if (isHorizontalSwipe || isVerticalSwipe) {
130
+ return {
131
+ isValid: true,
132
+ direction: getSwipeDirection(distance.x, distance.y)
133
+ }
134
+ }
135
+ return { isValid: false }
136
+ }
137
+ /**
138
+ * Returns the swipe direction based on the distance in the x and y axis.
139
+ *
140
+ * @param {number} distX - The distance in the x axis.
141
+ * @param {number} distY - The distance in the y axis.
142
+ * @returns {string} The swipe direction.
143
+ */
144
+ function getSwipeDirection(distX, distY) {
145
+ if (Math.abs(distX) > Math.abs(distY)) {
146
+ return distX > 0 ? 'Right' : 'Left'
147
+ } else {
148
+ return distY > 0 ? 'Down' : 'Up'
149
+ }
150
+ }
@@ -0,0 +1,52 @@
1
+ import { removeListeners, setupListeners } from './lib'
2
+
3
+ /**
4
+ * A switchable action that allows the user to cycle through a list of options
5
+ *
6
+ * @param {HTMLElement} node
7
+ * @param {Object} data
8
+ */
9
+ export function switchable(node, data) {
10
+ let index = 0
11
+ let { value, options, disabled } = data
12
+
13
+ const update = (input) => {
14
+ value = input.value === null || input.value === undefined ? options[0] : input.value
15
+ options = input.options
16
+ disabled = input.disabled
17
+ index = options.indexOf(value)
18
+ }
19
+
20
+ const toggle = (increment = 1) => {
21
+ index = (index + increment) % options.length
22
+ value = options[index]
23
+ node.dispatchEvent(new CustomEvent('change', { detail: value }))
24
+ }
25
+
26
+ const listeners = getEventHandlers(options, toggle)
27
+
28
+ update(data)
29
+ setupListeners(node, listeners, { enabled: !disabled })
30
+
31
+ return {
32
+ update,
33
+ destroy: () => removeListeners(node, listeners)
34
+ }
35
+ }
36
+ /**
37
+ * Returns a keydown handler for the switchable component
38
+ *
39
+ * @param {Object} options
40
+ */
41
+ function getEventHandlers(options, toggle) {
42
+ const keydown = (e) => {
43
+ if ([' ', 'Enter', 'ArrowRight', 'ArrowLeft'].includes(e.key)) {
44
+ e.preventDefault()
45
+ e.stopPropagation()
46
+
47
+ toggle(e.key === 'ArrowLeft' ? options.length - 1 : 1)
48
+ }
49
+ }
50
+
51
+ return { keydown, click: () => toggle(1) }
52
+ }
@@ -0,0 +1,42 @@
1
+ import { theme } from '@rokkit/stores'
2
+
3
+ /**
4
+ * A svelte action function that adds theme classes to the element
5
+ *
6
+ * @param {HTMLElement} node
7
+ */
8
+ export function themable(node) {
9
+ let previous = {}
10
+
11
+ theme.subscribe((data) => {
12
+ switchClass(node, data.name, previous.name)
13
+ switchClass(node, data.mode, previous.mode)
14
+ // switchPalette(node, data.palette, previous.palette)
15
+ previous = data
16
+ })
17
+ }
18
+
19
+ /**
20
+ * Switch the class on the node
21
+ *
22
+ * @param {HTMLElement} node
23
+ * @param {string} current
24
+ * @param {string} previous
25
+ * @returns
26
+ */
27
+ function switchClass(node, current, previous) {
28
+ if (current && current !== previous) {
29
+ node.classList.remove(previous)
30
+ node.classList.add(current)
31
+ }
32
+ }
33
+
34
+ // function switchPalette(node, current, previous) {
35
+ // Object.keys(current).map((key) => {
36
+ // if (!equals(current[key], previous[key])) {
37
+ // Object.keys(current[key]).map((shade) => {
38
+ // node.style.setProperty(`--${key}-${shade}`, current[key][shade])
39
+ // })
40
+ // }
41
+ // })
42
+ // }