@rokkit/actions 1.0.0-next.93 → 1.0.0-next.95

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/actions",
3
- "version": "1.0.0-next.93",
3
+ "version": "1.0.0-next.95",
4
4
  "description": "Contains generic actions that can be used in various components.",
5
5
  "author": "Jerry Thomas <me@jerrythomas.name>",
6
6
  "license": "MIT",
@@ -14,22 +14,20 @@
14
14
  "devDependencies": {
15
15
  "@sveltejs/vite-plugin-svelte": "^3.0.2",
16
16
  "@testing-library/svelte": "^4.1.0",
17
- "@types/ramda": "^0.29.11",
17
+ "@types/ramda": "^0.29.12",
18
18
  "@vitest/coverage-v8": "^1.4.0",
19
19
  "@vitest/ui": "~1.4.0",
20
20
  "jsdom": "^24.0.0",
21
21
  "svelte": "^4.2.12",
22
- "typescript": "^5.4.3",
23
- "vite": "^5.2.7",
22
+ "typescript": "^5.4.4",
23
+ "vite": "^5.2.8",
24
24
  "vitest": "~1.4.0",
25
- "shared-config": "1.0.0-next.93",
26
- "validators": "1.0.0-next.93"
25
+ "shared-config": "1.0.0-next.95",
26
+ "validators": "1.0.0-next.95"
27
27
  },
28
28
  "files": [
29
29
  "src/**/*.js",
30
- "src/**/*.svelte",
31
- "!src/mocks",
32
- "!src/**/*.spec.js"
30
+ "src/**/*.svelte"
33
31
  ],
34
32
  "exports": {
35
33
  "./src": "./src",
@@ -42,13 +40,12 @@
42
40
  },
43
41
  "dependencies": {
44
42
  "ramda": "^0.29.1",
45
- "@rokkit/core": "1.0.0-next.93",
46
- "@rokkit/stores": "1.0.0-next.93"
43
+ "@rokkit/core": "1.0.0-next.95",
44
+ "@rokkit/stores": "1.0.0-next.95"
47
45
  },
48
46
  "scripts": {
49
47
  "format": "prettier --write .",
50
48
  "lint": "eslint --fix .",
51
- "test:ct": "playwright test -c playwright.config.js",
52
49
  "test:ci": "vitest run",
53
50
  "test:ui": "vitest --ui",
54
51
  "test": "vitest",
package/src/fillable.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * @returns
7
7
  */
8
8
  export function fillable(node, { options, current, check }) {
9
- let data = { options, current, check }
10
- let blanks = node.getElementsByTagName('del')
9
+ const data = { options, current, check }
10
+ const blanks = node.getElementsByTagName('del')
11
11
 
12
12
  function click(event) {
13
13
  if (event.target.innerHTML !== '?') {
@@ -18,16 +18,16 @@ export function fillable(node, { options, current, check }) {
18
18
  initialize(blanks, click)
19
19
 
20
20
  return {
21
- update({ options, current }) {
22
- data.options = options
23
- data.current = current
21
+ update(input) {
22
+ data.options = input.options
23
+ data.current = input.current
24
24
  data.check = check
25
25
 
26
26
  fill(blanks, data.options, data.current)
27
27
  if (data.check) validate(blanks, data)
28
28
  },
29
29
  destroy() {
30
- Object.keys(blanks).map((ref) => {
30
+ Object.keys(blanks).forEach((ref) => {
31
31
  blanks[ref].removeEventListener('click', click)
32
32
  })
33
33
  }
@@ -41,10 +41,10 @@ export function fillable(node, { options, current, check }) {
41
41
  * @param {EventListener} click
42
42
  */
43
43
  function initialize(blanks, click) {
44
- Object.keys(blanks).map((ref) => {
44
+ Object.keys(blanks).forEach((ref) => {
45
45
  blanks[ref].addEventListener('click', click)
46
46
  blanks[ref].classList.add('empty')
47
- blanks[ref].name = 'fill-' + ref
47
+ blanks[ref].name = `fill-${ref}`
48
48
  blanks[ref]['data-index'] = ref
49
49
  })
50
50
  }
@@ -58,7 +58,7 @@ function initialize(blanks, click) {
58
58
  */
59
59
  function fill(blanks, options, current) {
60
60
  if (current > -1 && current < Object.keys(blanks).length) {
61
- let index = options.findIndex(({ actualIndex }) => actualIndex === current)
61
+ const index = options.findIndex(({ actualIndex }) => actualIndex === current)
62
62
  if (index > -1) {
63
63
  blanks[current].innerHTML = options[index].value
64
64
  blanks[current].classList.remove('empty')
@@ -96,8 +96,8 @@ function clear(event, node) {
96
96
  * @param {import('./types').FillOptions} data
97
97
  */
98
98
  function validate(blanks, data) {
99
- Object.keys(blanks).map((ref) => {
100
- let index = data.options.findIndex(({ actualIndex }) => actualIndex == ref)
99
+ Object.keys(blanks).forEach((_, ref) => {
100
+ const index = data.options.findIndex(({ actualIndex }) => actualIndex === ref)
101
101
  if (index > -1)
102
102
  blanks[ref].classList.add(
103
103
  data.options[index].expectedIndex === data.options[index].actualIndex ? 'pass' : 'fail'
package/src/hierarchy.js CHANGED
@@ -100,9 +100,9 @@ export function movePrevious(path) {
100
100
  * @returns
101
101
  */
102
102
  export function pathFromIndices(indices, items, fields) {
103
- let path = []
104
- let fragment
105
- indices.map((index, level) => {
103
+ const path = []
104
+ let fragment = {}
105
+ indices.forEach((index, level) => {
106
106
  if (level === 0) {
107
107
  fragment = { index, items, fields }
108
108
  } else {
package/src/index.js CHANGED
@@ -1,5 +1,4 @@
1
- import './types'
2
- export * from './lib/constants'
1
+ export * from './types'
3
2
  export * from './lib'
4
3
  export { fillable } from './fillable'
5
4
  export { pannable } from './pannable'
@@ -10,4 +9,3 @@ export { themable } from './themeable'
10
9
  export { swipeable } from './swipeable'
11
10
  export { switchable } from './switchable'
12
11
  export { delegateKeyboardEvents } from './delegate'
13
- export { virtualListViewport } from './lib/viewport'
package/src/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // export * from './constants'
1
+ export * from './constants'
2
2
  export * from './internal'
3
3
  export * from './event-manager'
4
4
  export * from './viewport'
@@ -1,5 +1,4 @@
1
- import { compact } from '@rokkit/core'
2
- import { hasChildren, isExpanded } from '@rokkit/core'
1
+ import { compact, hasChildren, isExpanded } from '@rokkit/core'
3
2
 
4
3
  /**
5
4
  * Emits a custom event with the given data.
@@ -27,8 +26,8 @@ export function mapKeyboardEventsToActions(handlers, options) {
27
26
  nested: false,
28
27
  ...options
29
28
  }
30
- let expand = nested ? handlers.expand : null
31
- let collapse = nested ? handlers.collapse : null
29
+ const expand = nested ? handlers.expand : null
30
+ const collapse = nested ? handlers.collapse : null
32
31
 
33
32
  return compact({
34
33
  ArrowDown: horizontal ? expand : next,
@@ -140,7 +139,7 @@ export function calculateSum(sizes, lower, upper, defaultSize = 40, gap = 0) {
140
139
  * @returns {Array<number|null>}
141
140
  */
142
141
  export function updateSizes(sizes, values, offset = 0) {
143
- let result = [...sizes.slice(0, offset), ...values, ...sizes.slice(offset + values.length)]
142
+ const result = [...sizes.slice(0, offset), ...values, ...sizes.slice(offset + values.length)]
144
143
 
145
144
  return result
146
145
  }
@@ -15,13 +15,32 @@ export function virtualListViewport(options) {
15
15
  before: 0,
16
16
  after: 0
17
17
  })
18
- let items
18
+ let items = null
19
19
  let averageSize = minSize
20
20
  let visibleCount = maxVisible
21
21
  let value = null
22
22
  let cache = []
23
23
  let index = -1
24
24
 
25
+ const updateBounds = ({ lower, upper }) => {
26
+ const previous = get(bounds)
27
+ if (maxVisible > 0) {
28
+ const visible = calculateSum(cache, lower, upper, averageSize, gap)
29
+ space.update((state) => (state = { ...state, visible }))
30
+ }
31
+ if (previous.lower !== lower) {
32
+ const before = calculateSum(cache, 0, lower, averageSize)
33
+ space.update((state) => (state = { ...state, before }))
34
+ }
35
+ if (previous.upper !== upper) {
36
+ const after = calculateSum(cache, upper, cache.length, averageSize)
37
+ space.update((state) => (state = { ...state, after }))
38
+ }
39
+ if (previous.lower !== lower || previous.upper !== upper) {
40
+ bounds.set({ lower, upper })
41
+ }
42
+ }
43
+
25
44
  const update = (data) => {
26
45
  // const previous = get(bounds)
27
46
 
@@ -76,24 +95,6 @@ export function virtualListViewport(options) {
76
95
  updateBounds(current)
77
96
  }
78
97
  }
79
- const updateBounds = ({ lower, upper }) => {
80
- const previous = get(bounds)
81
- if (maxVisible > 0) {
82
- let visible = calculateSum(cache, lower, upper, averageSize, gap)
83
- space.update((value) => (value = { ...value, visible }))
84
- }
85
- if (previous.lower !== lower) {
86
- let before = calculateSum(cache, 0, lower, averageSize)
87
- space.update((value) => (value = { ...value, before }))
88
- }
89
- if (previous.upper !== upper) {
90
- let after = calculateSum(cache, upper, cache.length, averageSize)
91
- space.update((value) => (value = { ...value, after }))
92
- }
93
- if (previous.lower !== lower || previous.upper !== upper) {
94
- bounds.set({ lower, upper })
95
- }
96
- }
97
98
 
98
99
  const scrollTo = (position) => {
99
100
  const start = Math.round(position / averageSize)
package/src/navigable.js CHANGED
@@ -23,23 +23,24 @@ export function navigable(node, options) {
23
23
  let actions = {} //getKeyboardActions(node, { horizontal, nested })
24
24
  const handleKeydown = (event) => handleAction(actions, event)
25
25
 
26
- function updateListeners(options) {
27
- if (options.enabled) actions = getKeyboardActions(node, options, handlers)
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)
28
33
 
29
- if (options.enabled && !listening) node.addEventListener('keydown', handleKeydown)
30
- else if (!options.enabled && listening) node.removeEventListener('keydown', handleKeydown)
31
- listening = options.enabled
34
+ actions = getKeyboardActions(node, input, handlers)
35
+ if (input.enabled) node.addEventListener('keydown', handleKeydown)
36
+
37
+ listening = input.enabled
32
38
  }
33
39
 
34
40
  updateListeners(options)
35
41
 
36
42
  return {
37
- update: (config) => {
38
- options = { ...options, ...config }
39
- updateListeners(options)
40
- },
41
- destroy: () => {
42
- updateListeners({ enabled: false })
43
- }
43
+ update: (config) => updateListeners(config),
44
+ destroy: () => updateListeners({ enabled: false })
44
45
  }
45
46
  }
package/src/navigator.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { handleAction } from './utils'
2
- import { isNested, hasChildren, isExpanded } from '@rokkit/core'
2
+ import { noop, isNested, hasChildren, isExpanded } from '@rokkit/core'
3
3
  import {
4
4
  moveNext,
5
5
  movePrevious,
@@ -18,21 +18,23 @@ import { mapKeyboardEventsToActions } from './lib'
18
18
  */
19
19
  export function navigator(element, options) {
20
20
  const { fields, enabled = true, vertical = true, idPrefix = 'id-' } = options
21
- let items, path, currentNode
21
+ let items = [],
22
+ path = null,
23
+ currentNode = null
22
24
 
23
- if (!enabled) return { destroy: () => {} }
25
+ if (!enabled) return { destroy: noop }
24
26
 
25
27
  // todo: Update should handle selection value change
26
28
  // should we wait a tick before updating?
27
- const update = (options) => {
29
+ const update = (input) => {
28
30
  const previousNode = currentNode
29
- items = options.items
30
- path = pathFromIndices(options.indices ?? [], items, fields)
31
+ items = input.items
32
+ path = pathFromIndices(input.indices ?? [], items, fields)
31
33
  currentNode = getCurrentNode(path)
32
34
 
33
35
  if (previousNode !== currentNode && currentNode) {
34
36
  const indices = indicesFromPath(path)
35
- let current = element.querySelector('#' + idPrefix + indices.join('-'))
37
+ const current = element.querySelector(`#${idPrefix}${indices.join('-')}`)
36
38
  if (current) {
37
39
  current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
38
40
  }
@@ -59,8 +61,8 @@ export function navigator(element, options) {
59
61
  }
60
62
  const collapse = () => {
61
63
  if (currentNode) {
62
- const collapse = isExpanded(currentNode, path[path.length - 1].fields)
63
- if (collapse) {
64
+ const expanded = isExpanded(currentNode, path[path.length - 1].fields)
65
+ if (expanded) {
64
66
  toggle()
65
67
  } else if (path.length > 0) {
66
68
  path = path.slice(0, -1)
@@ -94,8 +96,8 @@ export function navigator(element, options) {
94
96
 
95
97
  const handleClick = (event) => {
96
98
  event.stopPropagation()
97
- let target = findParentWithDataPath(event.target, element)
98
- let indices = !target
99
+ const target = findParentWithDataPath(event.target, element)
100
+ const indices = !target
99
101
  ? []
100
102
  : target.dataset.path
101
103
  .split(',')
@@ -108,8 +110,8 @@ export function navigator(element, options) {
108
110
  if (hasChildren(currentNode, path[path.length - 1].fields)) {
109
111
  currentNode[path[path.length - 1].fields.isOpen] =
110
112
  !currentNode[path[path.length - 1].fields.isOpen]
111
- const event = currentNode[path[path.length - 1].fields.isOpen] ? 'expand' : 'collapse'
112
- emit(event, element, indices, currentNode)
113
+ const eventName = currentNode[path[path.length - 1].fields.isOpen] ? 'expand' : 'collapse'
114
+ emit(eventName, element, indices, currentNode)
113
115
  } else if (currentNode !== null) emit('select', element, indices, currentNode)
114
116
  emit('move', element, indices, currentNode)
115
117
  // emit('select', element, indices, currentNode)
@@ -138,7 +140,7 @@ export function navigator(element, options) {
138
140
  */
139
141
  export function moveTo(element, path, currentNode, idPrefix) {
140
142
  const indices = indicesFromPath(path)
141
- let current = element.querySelector('#' + idPrefix + indices.join('-'))
143
+ const current = element.querySelector(`#${idPrefix}${indices.join('-')}`)
142
144
  if (current) current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
143
145
 
144
146
  emit('move', element, indices, currentNode)
@@ -175,7 +177,7 @@ function emit(event, element, indices, node) {
175
177
  new CustomEvent(event, {
176
178
  detail: {
177
179
  path: indices,
178
- node: node
180
+ node
179
181
  }
180
182
  })
181
183
  )
package/src/pannable.js CHANGED
@@ -1,14 +1,14 @@
1
+ import { omit } from 'ramda'
1
2
  import { removeListeners, setupListeners } from './lib'
2
3
  /**
3
- * Handle drag and move events
4
+ * Makes an element pannable with mouse or touch events.
4
5
  *
5
- * @param {HTMLElement} node
6
+ * @param {HTMLElement} node The DOM element to apply the panning action.
6
7
  * @returns {import('./types').SvelteActionReturn}
7
8
  */
8
9
  export function pannable(node) {
9
- let x
10
- let y
11
- let listeners = {
10
+ let coords = { x: 0, y: 0 }
11
+ const listeners = {
12
12
  primary: {
13
13
  mousedown: start,
14
14
  touchstart: start
@@ -21,32 +21,17 @@ export function pannable(node) {
21
21
  }
22
22
  }
23
23
 
24
- function track(event, name, delta = {}) {
25
- x = event.clientX || event.touches[0].clientX
26
- y = event.clientY || event.touches[0].clientY
27
- event.stopPropagation()
28
- event.preventDefault()
29
- node.dispatchEvent(
30
- new CustomEvent(name, {
31
- detail: { x, y, ...delta }
32
- })
33
- )
34
- }
35
-
36
24
  function start(event) {
37
- track(event, 'panstart')
25
+ coords = handleEvent(node, event, 'panstart', coords)
38
26
  setupListeners(window, listeners.secondary)
39
27
  }
40
28
 
41
29
  function move(event) {
42
- const dx = (event.clientX || event.touches[0].clientX) - x
43
- const dy = (event.clientY || event.touches[0].clientY) - y
44
-
45
- track(event, 'panmove', { dx, dy })
30
+ coords = handleEvent(node, event, 'panmove', coords)
46
31
  }
47
32
 
48
33
  function stop(event) {
49
- track(event, 'panend')
34
+ coords = handleEvent(node, event, 'panend', coords)
50
35
  removeListeners(window, listeners.secondary)
51
36
  }
52
37
 
@@ -56,3 +41,27 @@ export function pannable(node) {
56
41
  destroy: () => removeListeners(node, listeners.primary)
57
42
  }
58
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
+ }
package/src/swipeable.js CHANGED
@@ -16,13 +16,13 @@ const defaultOptions = {
16
16
  * @returns {import('./types').SvelteActionReturn}
17
17
  */
18
18
  export function swipeable(node, options = defaultOptions) {
19
- let track = {}
19
+ const track = {}
20
20
  let listeners = {}
21
21
 
22
- const updateListeners = (options) => {
22
+ const updateListeners = (props) => {
23
23
  removeListeners(node, listeners)
24
- listeners = getListeners(node, options, track)
25
- setupListeners(node, listeners, options)
24
+ listeners = getListeners(node, props, track)
25
+ setupListeners(node, listeners, props)
26
26
  }
27
27
 
28
28
  options = { ...defaultOptions, ...options }
@@ -49,7 +49,7 @@ export function swipeable(node, options = defaultOptions) {
49
49
  function getListeners(node, options, track) {
50
50
  if (!options.enabled) return {}
51
51
 
52
- let listeners = {
52
+ const listeners = {
53
53
  touchend: (e) => touchEnd(e, node, options, track),
54
54
  touchstart: (e) => touchStart(e, track),
55
55
  mousedown: (e) => touchStart(e, track),
@@ -72,31 +72,75 @@ function touchStart(event, track) {
72
72
  }
73
73
 
74
74
  /**
75
- * Handles the touch end event.
75
+ * Handles the touch end event and triggers a swipe event if the criteria are met.
76
76
  *
77
- * @param {Event} event - The touch event.
78
- * @param {HTMLElement} node - The node where the event is dispatched.
79
- * @param {import(./types).SwipeableOptions} options options - The options for the swipe.
80
- * @param {import(./types).TouchTracker} track - The tracking object.
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
81
  */
82
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) {
83
99
  const touch = event.changedTouches ? event.changedTouches[0] : event
84
100
  const distX = touch.clientX - track.startX
85
101
  const distY = touch.clientY - track.startY
86
102
  const duration = (new Date().getTime() - track.startTime) / 1000
87
- const speed = Math.max(Math.abs(distX), Math.abs(distY)) / duration
88
-
89
- if (speed <= options.minSpeed) return
90
-
91
- const isHorizontalSwipe = options.horizontal && Math.abs(distX) >= options.threshold
92
- const isVerticalSwipe = options.vertical && Math.abs(distY) >= options.threshold
93
-
94
- if (!isHorizontalSwipe && !isVerticalSwipe) return
103
+ return { distance: { x: distX, y: distY }, duration }
104
+ }
95
105
 
96
- const swipeDirection = getSwipeDirection(distX, distY)
97
- node.dispatchEvent(new CustomEvent(`swipe${swipeDirection}`))
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
98
117
  }
99
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
+ */
100
144
  function getSwipeDirection(distX, distY) {
101
145
  if (Math.abs(distX) > Math.abs(distY)) {
102
146
  return distX > 0 ? 'Right' : 'Left'
package/src/switchable.js CHANGED
@@ -1,13 +1,19 @@
1
1
  import { removeListeners, setupListeners } from './lib'
2
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
+ */
3
9
  export function switchable(node, data) {
4
10
  let index = 0
5
11
  let { value, options, disabled } = data
6
12
 
7
- const update = (data) => {
8
- value = data.value === null || data.value === undefined ? options[0] : data.value
9
- options = data.options
10
- disabled = data.disabled
13
+ const update = (input) => {
14
+ value = input.value === null || input.value === undefined ? options[0] : input.value
15
+ options = input.options
16
+ disabled = input.disabled
11
17
  index = options.indexOf(value)
12
18
  }
13
19
 
@@ -17,6 +23,22 @@ export function switchable(node, data) {
17
23
  node.dispatchEvent(new CustomEvent('change', { detail: value }))
18
24
  }
19
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) {
20
42
  const keydown = (e) => {
21
43
  if ([' ', 'Enter', 'ArrowRight', 'ArrowLeft'].includes(e.key)) {
22
44
  e.preventDefault()
@@ -25,16 +47,6 @@ export function switchable(node, data) {
25
47
  toggle(e.key === 'ArrowLeft' ? options.length - 1 : 1)
26
48
  }
27
49
  }
28
- const listeners = {
29
- click: () => toggle(1),
30
- keydown
31
- }
32
-
33
- update(data)
34
- setupListeners(node, listeners, { enabled: !disabled })
35
50
 
36
- return {
37
- update,
38
- destroy: () => removeListeners(node, listeners)
39
- }
51
+ return { keydown, click: () => toggle(1) }
40
52
  }
@@ -1,103 +1,396 @@
1
- import { mappedList, isNested } from '@rokkit/core'
2
- import { pick } from 'ramda'
3
- import {
4
- // getClosestAncestorWithAttribute,
5
- mapKeyboardEventsToActions,
6
- emit,
7
- // handleItemClick,
8
- EventManager
9
- } from './lib'
10
-
11
- const defaultOptions = {
1
+ import { has } from 'ramda'
2
+ import { EventManager } from './lib'
3
+ const defaultConfig = {
4
+ allowDrag: false,
5
+ allowDrop: false,
6
+ pageSize: 10,
12
7
  horizontal: false,
13
- nested: false,
14
- enabled: true
8
+ vertical: true,
9
+ multiselect: false
15
10
  }
16
11
 
17
12
  /**
18
- * An action that can be used to traverse a nested list of items using keyboard and mouse.
13
+ * A svelte action to add keyboard navigation to a list/tree/grid
19
14
  *
20
- * @param {HTMLElement} element
21
- * @param {import('./types').TraversableOptions} data
22
- * @returns
15
+ * @param {HTMLElement} root - The DOM root node to add the action to
16
+ * @param {Object} config - The configuration object
17
+ * @param {Object} config.store - The store object with navigation methods
18
+ * @param {Object} config.options - The configuration options
19
+ * @param {number} config.options.pageSize - The number of items to move on page up/down
20
+ * @param {boolean} config.options.horizontal - The orientation of the list/tree
21
+ * @param {boolean} config.options.vertical - The orientation of the list/tree
23
22
  */
24
- export function traversable(element, options) {
25
- const content = mappedList(options.items, options.fields)
26
- const manager = EventManager(element)
23
+ export function traversable(root, config) {
24
+ let store = config.store
25
+ const manager = EventManager(root, {})
27
26
 
28
- let current = { position: [], item: null }
29
- let handlers = {}
27
+ /**
28
+ * Update the event handlers based on the configuration
29
+ * @param {Object} config - The configuration object
30
+ */
31
+ function update(config) {
32
+ store = config.store
33
+ const options = { ...defaultConfig, ...config.options }
30
34
 
31
- const moveCursor = (direction) => {
32
- const result = content[direction](current.position)
33
- if (result) {
34
- current = result
35
- checkAndEmit('move')
35
+ const listeners = {
36
+ keydown: getKeydownHandler(store, options, root),
37
+ click: getClickHandler(store, options)
36
38
  }
39
+ if (options.allowDrag) listeners.dragstart = getDragStartHandler(store)
40
+ if (options.allowDrop) {
41
+ listeners.dragover = getDragOverHandler(store)
42
+ listeners.drop = getDropHandler(store)
43
+ }
44
+ manager.update(listeners)
37
45
  }
38
46
 
39
- const checkAndEmit = (event) => {
40
- if (current?.item) {
41
- emit(element, event, pick(['item', 'position'], current))
42
- }
47
+ /**
48
+ * Cleanup action on destroy
49
+ */
50
+ function destroy() {
51
+ manager.reset()
52
+ // store.onNavigate(null)
43
53
  }
44
54
 
55
+ update(config)
56
+
57
+ return { destroy, update }
58
+ }
59
+
60
+ /**
61
+ * Get a map of actions for various key combinations
62
+ *
63
+ * @param {Object} store - The store object with navigation methods
64
+ * @param {number} pageSize - The number of items to move on page up/down
65
+ */
66
+ function getKeyHandlers(store, options) {
67
+ const { pageSize, horizontal, vertical } = options
68
+ const isGrid = horizontal && vertical
69
+ const arrowActions = isGrid
70
+ ? getArrowKeyActionsForGrid(store)
71
+ : getArrowKeyActions(store, horizontal)
72
+
45
73
  const actions = {
46
- next: () => moveCursor('next'),
47
- previous: () => moveCursor('previous'),
48
- select: () => checkAndEmit('select'),
49
- escape: () => checkAndEmit('escape'),
50
- collapse: () => checkAndEmit('collapse'),
51
- expand: () => checkAndEmit('expand')
74
+ ...arrowActions,
75
+ PageUp: () => store.moveByOffset(-pageSize),
76
+ PageDown: () => store.moveByOffset(pageSize),
77
+ Home: () => store.moveFirst(),
78
+ End: () => store.moveLast(),
79
+ Enter: () => store.select(),
80
+ Escape: () => store.escape(),
81
+ ' ': () => store.select()
82
+ }
83
+
84
+ const modifierActions = {
85
+ ctrl: getMetaKeyActions(store, horizontal),
86
+ meta: getMetaKeyActions(store, horizontal),
87
+ shift: isGrid ? getShiftKeyActionsForGrid(store) : getShiftKeyActions(store, horizontal)
88
+ }
89
+
90
+ return { actions, modifierActions }
91
+ }
92
+
93
+ /**
94
+ * Get action handlers based on direction
95
+ *
96
+ * @param {Object} store - The store object with navigation methods
97
+ * @param {boolean} horizontal - if the content is navigable horizontally
98
+ */
99
+ function getArrowKeyActions(store, horizontal = false) {
100
+ if (horizontal) {
101
+ return {
102
+ ArrowUp: () => store.collapse(),
103
+ ArrowDown: () => store.expand(),
104
+ ArrowRight: () => store.moveByOffset(1),
105
+ ArrowLeft: () => store.moveByOffset(-1)
106
+ }
107
+ } else {
108
+ return {
109
+ ArrowUp: () => store.moveByOffset(-1),
110
+ ArrowDown: () => store.moveByOffset(1),
111
+ ArrowRight: () => store.expand(),
112
+ ArrowLeft: () => store.collapse()
113
+ }
52
114
  }
115
+ }
53
116
 
54
- const listeners = {
55
- keydown: (event) => {
56
- if (event.key in handlers) handlers[event.key](event)
117
+ /**
118
+ * Get the handler function for the keydown event
119
+ *
120
+ * @param {Object} store - The store object with navigation methods
121
+ * @param {Object} options - The configuration options
122
+ */
123
+ function getClickHandler(store, options) {
124
+ const { multiselect = false } = options
125
+
126
+ function handleClick(event) {
127
+ const modifiers = identifyModifiers(event)
128
+ const indexPath = getTargetIndex(event)
129
+
130
+ if (!indexPath) return
131
+ event.stopPropagation()
132
+
133
+ if (isToggleStateIcon(event.target)) {
134
+ store.toggleExpansion(indexPath)
135
+ } else {
136
+ if (multiselect) {
137
+ handleMultiSelect(store, indexPath, modifiers)
138
+ } else {
139
+ store.moveTo(indexPath)
140
+ store.select(indexPath)
141
+ }
57
142
  }
58
- // click: (event) => {
59
- // const target = getClosestAncestorWithAttribute(event.target, 'data-path')
60
- // if (target) {
61
- // const position = target
62
- // .getAttribute('data-path')
63
- // .split(',')
64
- // .map((i) => +i)
65
- // current = content.findByPosition(position)
66
- // current = handleItemClick(element, current)
67
- // }
68
- // }
69
- }
70
-
71
- const update = (data) => {
72
- options = { ...defaultOptions, ...options, value: current.item, ...data }
73
- options.nested = isNested(options.items, options.fields)
74
- content.update(options.items, options.fields)
75
- handlers = mapKeyboardEventsToActions(actions, options)
76
- manager.update(listeners, options.enabled)
77
- if (options.value !== null) {
78
- current = { ...current, ...content.findByValue(options.value) }
143
+ // dispatchEvents(event.target, store)
144
+ }
145
+
146
+ return handleClick
147
+ }
148
+ /**
149
+ * Get a function to handle the dragstart event
150
+ *
151
+ * @param {Object} store - The store object with navigation methods
152
+ */
153
+ function getDragStartHandler(store) {
154
+ function handleDragStart(event) {
155
+ const index = getTargetIndex(event)
156
+ if (index) store.dragStart(index)
157
+ }
158
+ return handleDragStart
159
+ }
160
+
161
+ /**
162
+ * Get a function to handle the dragover event
163
+ *
164
+ * @param {Object} store - The store object with navigation methods
165
+ */
166
+ function getDragOverHandler(store) {
167
+ function handleDragOver(event) {
168
+ const index = getTargetIndex(event)
169
+ if (index) store.dragOver(index)
170
+ }
171
+ return handleDragOver
172
+ }
173
+
174
+ /**
175
+ * Get a function to handle the drop event
176
+ *
177
+ * @param {Object} store - The store object with navigation methods
178
+ */
179
+ function getDropHandler(store) {
180
+ function handleDrop(event) {
181
+ const index = getTargetIndex(event)
182
+ if (index) store.dropOver(index)
183
+ }
184
+ return handleDrop
185
+ }
186
+ /**
187
+ * Handle multi-select based on the modifier keys pressed
188
+ *
189
+ * @param {Object} store - The store object with navigation methods
190
+ * @param {number[]} index - The index path of the item to select
191
+ * @param {string[]} modifier - The modifier keys pressed
192
+ */
193
+ function handleMultiSelect(store, index, modifier) {
194
+ if (modifier.includes('shift')) {
195
+ store.selectRange(index)
196
+ } else if (modifier.includes('ctrl') || modifier.includes('meta')) {
197
+ store.toggleSelection(index)
198
+ } else {
199
+ store.select(index)
200
+ }
201
+ }
202
+ /**
203
+ * Get the keydown event handler
204
+ *
205
+ * @param {Object} store - The store object with navigation methods
206
+ * @param {Object} options - The configuration options
207
+ * @param {HTMLElement} root - The root element to add the event listener to
208
+ */
209
+ function getKeydownHandler(store, options, root) {
210
+ const handlers = getKeyHandlers(store, options)
211
+
212
+ /**
213
+ * Use the keyboard event map to handle the keydown event
214
+ *
215
+ * @param {KeyboardEvent} event - The keyboard event
216
+ */
217
+ function handleKeydown(event) {
218
+ const action = getAction(event, handlers)
219
+ if (action) {
220
+ event.preventDefault()
221
+ action()
222
+ scrollIntoView(root, store)
223
+ dispatchEvents(root, store)
79
224
  }
80
- // current = handleValueChange(element, data, content, current)
81
225
  }
82
226
 
83
- update(options)
227
+ return handleKeydown
228
+ }
229
+
230
+ /**
231
+ * Get the action for the keydown event
232
+ *
233
+ * @param {KeyboardEvent} event - The keyboard event
234
+ * @param {Object} handlers - The key handlers object
235
+ */
236
+ function getAction(event, handlers) {
237
+ const key = event.key.length === 1 ? event.key.toUpperCase() : event.key
238
+ const modifier = identifyModifiers(event).join('-')
239
+ if (modifier.length === 0) return handlers.actions[key]
240
+
241
+ if (has(modifier, handlers.modifierActions)) {
242
+ return handlers.modifierActions[modifier][key]
243
+ }
244
+ return null
245
+ }
246
+
247
+ /**
248
+ * Identify modifier keys pressed in the event
249
+ *
250
+ * @param {KeyboardEvent} event - The keyboard event
251
+ */
252
+ function identifyModifiers(event) {
253
+ const modifiers = []
254
+
255
+ if (event.ctrlKey) modifiers.push('ctrl')
256
+ if (event.shiftKey) modifiers.push('shift')
257
+ if (event.metaKey) modifiers.push('meta')
258
+
259
+ return modifiers
260
+ }
261
+
262
+ /**
263
+ * Get the meta key actions for a list/tree
264
+ *
265
+ * @param {Object} store - The store object with navigation methods
266
+ * @param {boolean} horizontal - The orientation of the list/tree
267
+ */
268
+ function getMetaKeyActions(store, horizontal = false) {
269
+ const actions = {
270
+ X: () => store.cut(),
271
+ C: () => store.copy(),
272
+ V: () => store.paste(),
273
+ A: () => store.selectAll(),
274
+ D: () => store.selectNone(),
275
+ I: () => store.selectInvert(),
276
+ Z: () => store.undo(),
277
+ Y: () => store.redo()
278
+ }
279
+ const horizontalActions = {
280
+ ArrowRight: () => store.moveLast(),
281
+ ArrowLeft: () => store.moveFirst()
282
+ }
283
+ const verticalActions = {
284
+ ArrowUp: () => store.moveFirst(),
285
+ ArrowDown: () => store.moveLast()
286
+ }
287
+ const arrowActions = horizontal ? horizontalActions : verticalActions
288
+
289
+ return { ...actions, ...arrowActions }
290
+ }
291
+
292
+ /**
293
+ * Get the shift key actions for a list
294
+ *
295
+ * @param {Object} store - The store object with navigation methods
296
+ * @param {boolean} horizontal - The orientation of the list/tree
297
+ */
298
+ function getShiftKeyActions(store, horizontal = false) {
299
+ const actions = {
300
+ Home: () => store.selectRange(-Infinity),
301
+ End: () => store.selectRange(Infinity)
302
+ }
303
+ const horizontalActions = {
304
+ ArrowRight: () => store.selectRange(1),
305
+ ArrowLeft: () => store.selectRange(-1)
306
+ }
307
+ const verticalActions = {
308
+ ArrowUp: () => store.selectRange(-1),
309
+ ArrowDown: () => store.selectRange(1)
310
+ }
311
+ const arrowActions = horizontal ? horizontalActions : verticalActions
312
+
313
+ return { ...actions, ...arrowActions }
314
+ }
84
315
 
316
+ /**
317
+ * Get the arrow key actions for a grid
318
+ *
319
+ * @param {Object} store - The store object with navigation methods
320
+ * @returns {Object} - The map of actions
321
+ */
322
+ function getArrowKeyActionsForGrid(store) {
85
323
  return {
86
- update,
87
- destroy: () => manager.reset()
324
+ ArrowUp: () => store.moveByOffset(-1),
325
+ ArrowDown: () => store.moveByOffset(1),
326
+ ArrowRight: () => store.moveByOffset(0, 1),
327
+ ArrowLeft: () => store.moveByOffset(0, -1),
328
+ Home: () => store.moveByOffset(-Infinity, -Infinity),
329
+ End: () => store.moveByOffset(Infinity, Infinity)
88
330
  }
89
331
  }
90
332
 
91
- // const handleValueChange = (element, data, content, current) => {
92
- // if (data.value !== null && data.value !== current?.value) {
93
- // current = content.findByValue(data.value)
94
- // if (current) scrollIntoView(element, current.position)
95
- // }
96
- // return current
97
- // }
333
+ /**
334
+ * Get the shift key actions for a grid
335
+ *
336
+ * @param {Object} store - The store object with navigation methods
337
+ * @returns {Object} - The map of actions
338
+ */
339
+ function getShiftKeyActionsForGrid(store) {
340
+ return {
341
+ ArrowUp: () => store.selectRange(-1),
342
+ ArrowDown: () => store.selectRange(1),
343
+ ArrowRight: () => store.selectRange(0, 1),
344
+ ArrowLeft: () => store.selectRange(0, -1),
345
+ Home: () => store.selectRange(0, -Infinity),
346
+ End: () => store.selectRange(0, Infinity)
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Identify if an html element is a toggle state icon
352
+ * A toggle state icon element tag is ICON and has a data-state attribute value of 'opened' or 'closed'
353
+ *
354
+ * @param {HTMLElement} element - The html element to check
355
+ */
356
+ function isToggleStateIcon(element) {
357
+ return (
358
+ element.tagName === 'ICON' && ['opened', 'closed'].includes(element.getAttribute('data-state'))
359
+ )
360
+ }
361
+
362
+ /**
363
+ * Get the index of the target element
364
+ *
365
+ * @param {MouseEvent} event - The mouse event
366
+ */
367
+ function getTargetIndex(event) {
368
+ const target = event.target.closest('[data-index]')
369
+ if (target) return target.getAttribute('data-index').split('-').map(Number)
370
+
371
+ return null
372
+ }
98
373
 
99
- // function scrollIntoView(element, position) {
100
- // if (!Array.isArray(position) || position.length === 0) return
101
- // const node = element.querySelector(`[data-index="${position.join(',')}"]`)
102
- // if (node) node.scrollIntoView()
103
- // }
374
+ /**
375
+ * Make the current item visible in the view
376
+ *
377
+ * @param {HTMLElement} root - The root element which contains the items
378
+ * @param {Object} store - The item to make visible
379
+ */
380
+ function scrollIntoView(root, store) {
381
+ const item = store.currentItem()
382
+ const dataIndex = item.indexPath.join('-')
383
+ const node = root.querySelector(`[data-index="${dataIndex}"]`)
384
+ if (node) node.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
385
+ }
386
+
387
+ /**
388
+ * Dispatch custom events based on the state changes
389
+ *
390
+ * @param {HTMLElement} root - The root element to dispatch the events from
391
+ * @param {Object} store - The store object with navigation methods
392
+ */
393
+ function dispatchEvents(root, store) {
394
+ const events = store.getEvents()
395
+ events.forEach((event, detail) => root.dispatchEvent(new CustomEvent(event, { detail })))
396
+ }
package/src/types.js CHANGED
@@ -128,3 +128,5 @@
128
128
  * @property {number} lower
129
129
  * @property {number} upper
130
130
  */
131
+
132
+ export default {}