@rokkit/actions 1.0.0-next.107 → 1.0.0-next.108

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.107",
3
+ "version": "1.0.0-next.108",
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",
@@ -0,0 +1,260 @@
1
+ import { on } from 'svelte/events'
2
+ import { getClosestAncestorWithAttribute, getEventForKey } from './utils.js'
3
+ import { getPathFromKey } from '@rokkit/core'
4
+
5
+ /**
6
+ * Key mappings for different navigation directions
7
+ */
8
+ const KEY_MAPPINGS = {
9
+ horizontal: {
10
+ prev: ['ArrowLeft'],
11
+ next: ['ArrowRight'],
12
+ collapse: ['ArrowUp'],
13
+ expand: ['ArrowDown'],
14
+ select: ['Enter'],
15
+ toggle: ['Space'],
16
+ delete: ['Delete', 'Backspace']
17
+ },
18
+ vertical: {
19
+ prev: ['ArrowUp'],
20
+ next: ['ArrowDown'],
21
+ collapse: ['ArrowLeft'],
22
+ expand: ['ArrowRight'],
23
+ select: ['Enter'],
24
+ toggle: ['Space'],
25
+ delete: ['Delete', 'Backspace']
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Navigator class to handle keyboard and mouse navigation
31
+ * @class
32
+ */
33
+ class NavigatorController {
34
+ /**
35
+ * @param {HTMLElement} root - The root element
36
+ * @param {Object} wrapper - The data wrapper object
37
+ * @param {Object} options - Configuration options
38
+ */
39
+ constructor(root, wrapper, options = {}) {
40
+ this.root = root
41
+ this.wrapper = wrapper
42
+ this.options = options
43
+ this.keyMappings = KEY_MAPPINGS[options?.direction || 'vertical']
44
+
45
+ this.handleKeyUp = this.handleKeyUp.bind(this)
46
+ this.handleClick = this.handleClick.bind(this)
47
+ }
48
+
49
+ /**
50
+ * Initialize event listeners
51
+ */
52
+ init() {
53
+ return [on(this.root, 'keyup', this.handleKeyUp), on(this.root, 'click', this.handleClick)]
54
+ }
55
+
56
+ /**
57
+ * Check if modifier keys are pressed
58
+ * @param {Event} event - The event object
59
+ * @returns {boolean} - Whether modifier keys are pressed
60
+ */
61
+ hasModifierKey(event) {
62
+ return event.ctrlKey || event.metaKey || event.shiftKey
63
+ }
64
+
65
+ /**
66
+ * Handle keyboard events
67
+ * @param {KeyboardEvent} event - The keyboard event
68
+ */
69
+ handleKeyUp(event) {
70
+ const { key } = event
71
+
72
+ const eventName = getEventForKey(this.keyMappings, key)
73
+
74
+ if (!eventName) return
75
+
76
+ const handled = this.processKeyAction(eventName, event)
77
+
78
+ if (handled) {
79
+ event.stopPropagation()
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Process a key action based on its type
85
+ * @param {string} eventName - The mapped event name
86
+ * @param {KeyboardEvent} event - The original keyboard event
87
+ * @returns {boolean} - Whether the event was handled
88
+ */
89
+ processKeyAction(eventName, event) {
90
+ const actionHandlers = {
91
+ prev: () => this.handleNavigationKey('prev', this.hasModifierKey(event)),
92
+ next: () => this.handleNavigationKey('next', this.hasModifierKey(event)),
93
+ expand: () => this.executeAction('expand'),
94
+ collapse: () => this.executeAction('collapse'),
95
+ select: () => this.executeAction('select'),
96
+ toggle: () => this.executeAction('extend'),
97
+ delete: () => this.executeAction('delete')
98
+ }
99
+
100
+ const handler = actionHandlers[eventName]
101
+ if (handler) {
102
+ return handler() !== false
103
+ }
104
+
105
+ return false
106
+ }
107
+
108
+ /**
109
+ * Handle navigation key presses (arrows)
110
+ * @param {string} direction - The direction to move ('prev' or 'next')
111
+ * @param {boolean} hasModifier - Whether modifier keys are pressed
112
+ * @returns {boolean} - Whether the action was handled
113
+ */
114
+ handleNavigationKey(direction, hasModifier) {
115
+ // First move in the specified direction
116
+ const moved = this.executeAction(direction)
117
+
118
+ if (!moved) return false
119
+
120
+ // If modifier key is pressed and multiSelect is enabled, extend selection
121
+ if (hasModifier && this.wrapper.multiSelect) {
122
+ this.executeAction('extend')
123
+ } else {
124
+ // Otherwise just select the current item
125
+ this.executeAction('select')
126
+ }
127
+
128
+ // Always emit a move event for navigation
129
+ this.emitActionEvent('move')
130
+ return true
131
+ }
132
+
133
+ /**
134
+ * Handle click events
135
+ * @param {MouseEvent} event - The click event
136
+ */
137
+ handleClick(event) {
138
+ const node = getClosestAncestorWithAttribute(event.target, 'data-path')
139
+
140
+ if (!node) return
141
+
142
+ const path = getPathFromKey(node.getAttribute('data-path'))
143
+ // Check if click was on a toggle icon
144
+ const toggleIconClicked = this.handleToggleIconClick(path, event.target)
145
+
146
+ if (toggleIconClicked) return
147
+
148
+ // Move to the clicked item
149
+ this.wrapper.moveTo(path)
150
+
151
+ // Handle selection based on modifier keys
152
+ if (this.hasModifierKey(event) && this.wrapper.multiSelect) {
153
+ this.executeAction('extend', path)
154
+ } else {
155
+ this.executeAction('select', path)
156
+ }
157
+
158
+ // this.executeAction('toggle', path)
159
+ event.stopPropagation()
160
+ }
161
+
162
+ /**
163
+ * Handle clicks on toggle icons
164
+ * @param {number[]} path - The path of the item to perform the action on
165
+ * @param {HTMLElement} target - The clicked element
166
+ * @returns {boolean} - Whether a toggle icon was clicked and handled
167
+ */
168
+ handleToggleIconClick(path, target) {
169
+ const isIcon = target.tagName.toLowerCase() === 'rk-icon'
170
+ const state = target.getAttribute('data-state')
171
+ if (!isIcon || !['closed', 'opened'].includes(state)) return false
172
+
173
+ return this.executeAction('toggle', path)
174
+ }
175
+
176
+ /**
177
+ * Execute an action if available
178
+ * @param {string} actionName - The name of the action to execute
179
+ * @param {number[]} path - The path of the item to perform the action on
180
+ * @returns {boolean} - Whether the action was executed
181
+ */
182
+ executeAction(actionName, path) {
183
+ // Get the action function based on action name
184
+ const action = this.getActionFunction(actionName)
185
+
186
+ if (action) {
187
+ const executed = path ? action(path) : action()
188
+ if (executed) this.emitActionEvent(actionName)
189
+
190
+ return executed
191
+ }
192
+ return false
193
+ }
194
+
195
+ /**
196
+ * Get the appropriate action function based on action name and conditions
197
+ * @param {string} actionName - The name of the action
198
+ * @returns {Function|null} - The action function or null if not available
199
+ */
200
+ getActionFunction(actionName) {
201
+ // Basic navigation and selection actions always available
202
+ const actions = {
203
+ prev: () => this.wrapper.movePrev(),
204
+ next: () => this.wrapper.moveNext(),
205
+ select: (path) => this.wrapper.select(path),
206
+ collapse: (path) => this.wrapper.collapse(path),
207
+ expand: (path) => this.wrapper.expand(path),
208
+ toggle: (path) => this.wrapper.toggleExpansion(path),
209
+ extend: (path) => this.wrapper.extendSelection(path),
210
+ delete: () => this.wrapper.delete()
211
+ }
212
+
213
+ return actions[actionName] || null
214
+ }
215
+
216
+ /**
217
+ * Emit an action event
218
+ * @param {string} eventName - The name of the event to emit
219
+ */
220
+ emitActionEvent(eventName) {
221
+ const data = {
222
+ path: this.wrapper.currentNode?.path,
223
+ value: this.wrapper.currentNode?.value
224
+ }
225
+
226
+ // For select events, include selected nodes
227
+ if (['select', 'extend'].includes(eventName)) {
228
+ data.selected = Array.from(this.wrapper.selectedNodes.values()).map((node) => node.value)
229
+ // Normalize selection events to 'select'
230
+ eventName = 'select'
231
+ } else if (['prev', 'next'].includes(eventName)) {
232
+ eventName = 'move'
233
+ }
234
+
235
+ this.root.dispatchEvent(
236
+ new CustomEvent('action', {
237
+ detail: {
238
+ eventName,
239
+ data
240
+ }
241
+ })
242
+ )
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Navigator action for Svelte components
248
+ * @param {HTMLElement} root - The root element
249
+ * @param {Object} params - Parameters including wrapper and options
250
+ */
251
+ export function navigator(root, { wrapper, options }) {
252
+ const controller = new NavigatorController(root, wrapper, options)
253
+
254
+ $effect(() => {
255
+ const cleanupFunctions = controller.init()
256
+ return () => {
257
+ cleanupFunctions.forEach((cleanup) => cleanup())
258
+ }
259
+ })
260
+ }
@@ -1,259 +1,106 @@
1
1
  import { on } from 'svelte/events'
2
- import { getClosestAncestorWithAttribute, getEventForKey } from './utils.js'
3
- import { getPathFromKey } from '@rokkit/core'
4
-
5
- /**
6
- * Key mappings for different navigation directions
7
- */
8
- const KEY_MAPPINGS = {
9
- horizontal: {
10
- prev: ['ArrowLeft'],
11
- next: ['ArrowRight'],
12
- collapse: ['ArrowUp'],
13
- expand: ['ArrowDown'],
14
- select: ['Enter'],
15
- toggle: ['Space'],
16
- delete: ['Delete', 'Backspace']
17
- },
18
- vertical: {
19
- prev: ['ArrowUp'],
20
- next: ['ArrowDown'],
21
- collapse: ['ArrowLeft'],
22
- expand: ['ArrowRight'],
23
- select: ['Enter'],
24
- toggle: ['Space'],
25
- delete: ['Delete', 'Backspace']
26
- }
2
+ import { omit } from 'ramda'
3
+ import { getClickAction, getKeyboardAction, getPathFromEvent } from './utils'
4
+
5
+ const defaultOptions = { horizontal: false, nested: false, enabled: true }
6
+
7
+ const EVENT_MAP = {
8
+ first: ['move'],
9
+ last: ['move'],
10
+ previous: ['move'],
11
+ next: ['move'],
12
+ select: ['move', 'select'],
13
+ extend: ['move', 'select'],
14
+ collapse: ['toggle'],
15
+ expand: ['toggle'],
16
+ toggle: ['toggle']
27
17
  }
28
18
 
29
19
  /**
30
- * Navigator class to handle keyboard and mouse navigation
31
- * @class
20
+ * The last only indicates that if there is an array only the last event is fired.
21
+ * This is crucial because a click event needs to fire both move and select,
22
+ * however the keyboard should only fire the select event because we are already
23
+ * on the current item
24
+ *
25
+ * @param {HTMLElement} root
26
+ * @param {*} controller
27
+ * @param {*} name
32
28
  */
33
- class NavigatorController {
34
- /**
35
- * @param {HTMLElement} root - The root element
36
- * @param {Object} wrapper - The data wrapper object
37
- * @param {Object} options - Configuration options
38
- */
39
- constructor(root, wrapper, options = {}) {
40
- this.root = root
41
- this.wrapper = wrapper
42
- this.options = options
43
- this.keyMappings = KEY_MAPPINGS[options?.direction || 'vertical']
44
-
45
- this.handleKeyUp = this.handleKeyUp.bind(this)
46
- this.handleClick = this.handleClick.bind(this)
47
- }
48
-
49
- /**
50
- * Initialize event listeners
51
- */
52
- init() {
53
- return [on(this.root, 'keyup', this.handleKeyUp), on(this.root, 'click', this.handleClick)]
54
- }
55
-
56
- /**
57
- * Check if modifier keys are pressed
58
- * @param {Event} event - The event object
59
- * @returns {boolean} - Whether modifier keys are pressed
60
- */
61
- hasModifierKey(event) {
62
- return event.ctrlKey || event.metaKey || event.shiftKey
63
- }
64
-
65
- /**
66
- * Handle keyboard events
67
- * @param {KeyboardEvent} event - The keyboard event
68
- */
69
- handleKeyUp(event) {
70
- const { key } = event
71
-
72
- const eventName = getEventForKey(this.keyMappings, key)
73
-
74
- if (!eventName) return
75
-
76
- const handled = this.processKeyAction(eventName, event)
77
-
78
- if (handled) {
79
- event.stopPropagation()
80
- }
81
- }
82
-
83
- /**
84
- * Process a key action based on its type
85
- * @param {string} eventName - The mapped event name
86
- * @param {KeyboardEvent} event - The original keyboard event
87
- * @returns {boolean} - Whether the event was handled
88
- */
89
- processKeyAction(eventName, event) {
90
- const actionHandlers = {
91
- prev: () => this.handleNavigationKey('prev', this.hasModifierKey(event)),
92
- next: () => this.handleNavigationKey('next', this.hasModifierKey(event)),
93
- expand: () => this.executeAction('expand'),
94
- collapse: () => this.executeAction('collapse'),
95
- select: () => this.executeAction('select'),
96
- toggle: () => this.executeAction('extend'),
97
- delete: () => this.executeAction('delete')
98
- }
99
-
100
- const handler = actionHandlers[eventName]
101
- if (handler) {
102
- return handler() !== false
103
- }
104
-
105
- return false
106
- }
107
-
108
- /**
109
- * Handle navigation key presses (arrows)
110
- * @param {string} direction - The direction to move ('prev' or 'next')
111
- * @param {boolean} hasModifier - Whether modifier keys are pressed
112
- * @returns {boolean} - Whether the action was handled
113
- */
114
- handleNavigationKey(direction, hasModifier) {
115
- // First move in the specified direction
116
- const moved = this.executeAction(direction)
117
-
118
- if (!moved) return false
119
-
120
- // If modifier key is pressed and multiSelect is enabled, extend selection
121
- if (hasModifier && this.wrapper.multiSelect) {
122
- this.executeAction('extend')
123
- } else {
124
- // Otherwise just select the current item
125
- this.executeAction('select')
126
- }
127
-
128
- // Always emit a move event for navigation
129
- this.emitActionEvent('move')
130
- return true
131
- }
132
-
133
- /**
134
- * Handle click events
135
- * @param {MouseEvent} event - The click event
136
- */
137
- handleClick(event) {
138
- const node = getClosestAncestorWithAttribute(event.target, 'data-path')
139
-
140
- if (!node) return
141
-
142
- const path = getPathFromKey(node.getAttribute('data-path'))
143
- // Check if click was on a toggle icon
144
- const toggleIconClicked = this.handleToggleIconClick(path, event.target)
145
-
146
- if (toggleIconClicked) return
147
-
148
- // Move to the clicked item
149
- this.wrapper.moveTo(path)
29
+ export function emitAction(root, controller, name, lastOnly = false) {
30
+ const events = lastOnly ? EVENT_MAP[name].slice(-1) : EVENT_MAP[name]
150
31
 
151
- // Handle selection based on modifier keys
152
- if (this.hasModifierKey(event) && this.wrapper.multiSelect) {
153
- this.executeAction('extend', path)
154
- } else {
155
- this.executeAction('select', path)
156
- }
157
- this.executeAction('toggle', path)
158
- event.stopPropagation()
159
- }
160
-
161
- /**
162
- * Handle clicks on toggle icons
163
- * @param {number[]} path - The path of the item to perform the action on
164
- * @param {HTMLElement} target - The clicked element
165
- * @returns {boolean} - Whether a toggle icon was clicked and handled
166
- */
167
- handleToggleIconClick(path, target) {
168
- const isIcon = target.tagName.toLowerCase() === 'rk-icon'
169
- const state = target.getAttribute('data-state')
170
- if (!isIcon || !['closed', 'opened'].includes(state)) return false
171
-
172
- return this.executeAction('toggle', path)
173
- }
174
-
175
- /**
176
- * Execute an action if available
177
- * @param {string} actionName - The name of the action to execute
178
- * @param {number[]} path - The path of the item to perform the action on
179
- * @returns {boolean} - Whether the action was executed
180
- */
181
- executeAction(actionName, path) {
182
- // Get the action function based on action name
183
- const action = this.getActionFunction(actionName)
184
-
185
- if (action) {
186
- const executed = path ? action(path) : action()
187
- if (executed) this.emitActionEvent(actionName)
188
-
189
- return executed
190
- }
191
- return false
192
- }
193
-
194
- /**
195
- * Get the appropriate action function based on action name and conditions
196
- * @param {string} actionName - The name of the action
197
- * @returns {Function|null} - The action function or null if not available
198
- */
199
- getActionFunction(actionName) {
200
- // Basic navigation and selection actions always available
201
- const actions = {
202
- prev: () => this.wrapper.movePrev(),
203
- next: () => this.wrapper.moveNext(),
204
- select: (path) => this.wrapper.select(path),
205
- collapse: (path) => this.wrapper.collapse(path),
206
- expand: (path) => this.wrapper.expand(path),
207
- toggle: (path) => this.wrapper.toggleExpansion(path),
208
- extend: (path) => this.wrapper.extendSelection(path),
209
- delete: () => this.wrapper.delete()
210
- }
211
-
212
- return actions[actionName] || null
213
- }
214
-
215
- /**
216
- * Emit an action event
217
- * @param {string} eventName - The name of the event to emit
218
- */
219
- emitActionEvent(eventName) {
220
- const data = {
221
- path: this.wrapper.currentNode?.path,
222
- value: this.wrapper.currentNode?.value
223
- }
224
-
225
- // For select events, include selected nodes
226
- if (['select', 'extend'].includes(eventName)) {
227
- data.selected = Array.from(this.wrapper.selectedNodes.values()).map((node) => node.value)
228
- // Normalize selection events to 'select'
229
- eventName = 'select'
230
- } else if (['prev', 'next'].includes(eventName)) {
231
- eventName = 'move'
232
- }
233
-
234
- this.root.dispatchEvent(
32
+ events.forEach((event) => {
33
+ root.dispatchEvent(
235
34
  new CustomEvent('action', {
236
35
  detail: {
237
- eventName,
238
- data
36
+ name: event,
37
+ data: { value: controller.focused, selected: controller.selected }
239
38
  }
240
39
  })
241
40
  )
41
+ })
42
+ }
43
+
44
+ /*
45
+ * Generic action handler for keyboard events.
46
+ *
47
+ * @param {Record<string, () => void>} actions
48
+ * @param {KeyboardEvent} event
49
+ */
50
+ export function handleAction(event, handler, path) {
51
+ if (handler) {
52
+ event.preventDefault()
53
+ event.stopPropagation()
54
+
55
+ return handler(path)
242
56
  }
57
+ return false
243
58
  }
244
59
 
60
+ function getHandlers(wrapper) {
61
+ return {
62
+ first: () => wrapper.moveFirst(),
63
+ last: () => wrapper.moveLast(),
64
+ previous: () => wrapper.movePrev(),
65
+ next: () => wrapper.moveNext(),
66
+ collapse: () => wrapper.collapse(),
67
+ expand: () => wrapper.expand(),
68
+ select: (path) => wrapper.select(path),
69
+ extend: (path) => wrapper.extendSelection(path),
70
+ toggle: (path) => wrapper.toggleExpansion(path)
71
+ }
72
+ }
245
73
  /**
246
- * Navigator action for Svelte components
247
- * @param {HTMLElement} root - The root element
248
- * @param {Object} params - Parameters including wrapper and options
74
+ * A svelte action function that captures keyboard evvents and emits event for corresponding movements.
75
+ *
76
+ * @param {HTMLElement} node
77
+ * @param {import('./types').NavigableOptions} options
78
+ * @returns {import('./types').SvelteActionReturn}
249
79
  */
250
- export function navigator(root, { wrapper, options }) {
251
- const controller = new NavigatorController(root, wrapper, options)
80
+ export function navigator(node, options) {
81
+ const { wrapper } = options
82
+ const config = { ...defaultOptions, ...omit(['wrapper'], options) }
83
+ const handlers = getHandlers(wrapper)
84
+
85
+ const handleKeydown = (event) => {
86
+ const action = getKeyboardAction(event, config)
87
+ const handled = handleAction(event, handlers[action])
88
+ if (handled) emitAction(node, options.wrapper, action, true)
89
+ }
90
+
91
+ const handleClick = (event) => {
92
+ const action = getClickAction(event)
93
+ const path = getPathFromEvent(event)
94
+ const handled = handleAction(event, handlers[action], path)
95
+
96
+ if (handled) emitAction(node, options.wrapper, action)
97
+ }
252
98
 
253
99
  $effect(() => {
254
- const cleanupFunctions = controller.init()
100
+ const cleanup = [on(node, 'keyup', handleKeydown), on(node, 'click', handleClick)]
101
+
255
102
  return () => {
256
- cleanupFunctions.forEach((cleanup) => cleanup())
103
+ cleanup.forEach((fn) => fn())
257
104
  }
258
105
  })
259
106
  }
package/src/types.js CHANGED
@@ -48,3 +48,16 @@
48
48
  * @property {Navigator} wrapper - Whether the navigator is enabled
49
49
  * @property {NavigatorOptions} options - Whether the navigator is vertical or horizontal
50
50
  */
51
+
52
+ /**
53
+ * @typedef {Object} Controller
54
+ * @property {Function} moveNext
55
+ * @property {Function} movePrev
56
+ * @property {Function} moveFirst
57
+ * @property {Function} moveLast
58
+ * @property {Function} [expand]
59
+ * @property {Function} [collapse]
60
+ * @property {Function} select
61
+ * @property {Function} extendSelection
62
+ * @property {Function} [toggleExpansion]
63
+ */
package/src/utils.js CHANGED
@@ -49,20 +49,147 @@ export function handleAction(actions, event) {
49
49
  * @param {import('./types').NavigableHandlers} handlers
50
50
  */
51
51
  export function getKeyboardActions(options, handlers) {
52
+ const { horizontal, nested } = options
52
53
  if (!options.enabled) return {}
53
54
 
54
- const movement = options.horizontal
55
+ const common = {
56
+ Enter: handlers.select,
57
+ ' ': handlers.select
58
+ }
59
+ const movement = horizontal
55
60
  ? { ArrowLeft: handlers.previous, ArrowRight: handlers.next }
56
61
  : { ArrowUp: handlers.previous, ArrowDown: handlers.next }
57
- const change = options.nested
58
- ? options.horizontal
59
- ? { ArrowUp: handlers.collapse, ArrowDown: handlers.expand }
60
- : { ArrowLeft: handlers.collapse, ArrowRight: handlers.expand }
62
+ const change = horizontal
63
+ ? { ArrowUp: handlers.collapse, ArrowDown: handlers.expand }
64
+ : { ArrowLeft: handlers.collapse, ArrowRight: handlers.expand }
65
+
66
+ if (nested) return { ...common, ...movement, ...change }
67
+ return { ...common, ...movement }
68
+ }
69
+
70
+ /**
71
+ * Finds and returns an index path based on data-path attribute
72
+ *
73
+ * @param {MouseEvent} event
74
+ * @returns {number[]|null} null or index path array
75
+ */
76
+ export function getPathFromEvent(event) {
77
+ const node = getClosestAncestorWithAttribute(event.target, 'data-path')
78
+ return node?.getAttribute('data-path')
79
+ // return node ? getPathFromKey(node.getAttribute('data-path')) : null
80
+ }
81
+
82
+ /**
83
+ * Creates a keyboard action mapping based on navigation options
84
+ *
85
+ * @param {Object} options - Navigation options
86
+ * @param {boolean} options.horizontal - Whether navigation is horizontal
87
+ * @param {boolean} options.nested - Whether navigation is nested
88
+ * @returns {Object} Mapping of keys to actions
89
+ */
90
+ function createKeyboardActionMap(options) {
91
+ const { horizontal, nested } = options
92
+
93
+ // Define movement actions based on horizontal option
94
+ const movementActions = horizontal
95
+ ? { ArrowLeft: 'previous', ArrowRight: 'next' }
96
+ : { ArrowUp: 'previous', ArrowDown: 'next' }
97
+
98
+ // Define expand/collapse actions for nested option
99
+ const nestedActions = nested
100
+ ? horizontal
101
+ ? { ArrowUp: 'collapse', ArrowDown: 'expand' }
102
+ : { ArrowLeft: 'collapse', ArrowRight: 'expand' }
61
103
  : {}
104
+
105
+ // Common actions regardless of options
106
+ const commonActions = {
107
+ Enter: 'select',
108
+ ' ': 'select',
109
+ Home: 'first',
110
+ End: 'last'
111
+ }
112
+
113
+ // Combine all possible actions
62
114
  return {
63
- Enter: handlers.select,
64
- ' ': handlers.select,
65
- ...movement,
66
- ...change
115
+ ...commonActions,
116
+ ...movementActions,
117
+ ...nestedActions
67
118
  }
68
119
  }
120
+
121
+ /**
122
+ * Creates a keyboard action mapping based on navigation options
123
+ *
124
+ * @param {Object} options - Navigation options
125
+ * @param {boolean} options.horizontal - Whether navigation is horizontal
126
+ * @param {boolean} options.nested - Whether navigation is nested
127
+ * @returns {Object} Mapping of keys to actions
128
+ */
129
+ function createModifierKeyboardActionMap(options) {
130
+ const { horizontal } = options
131
+ const common = { ' ': 'extend', Home: 'first', End: 'last' }
132
+ const directional = horizontal
133
+ ? { ArrowLeft: 'first', ArrowRight: 'last' }
134
+ : { ArrowUp: 'first', ArrowDown: 'last' }
135
+ return { ...common, ...directional }
136
+ }
137
+ /**
138
+ * Identifies if an element is a collapsible icon
139
+ * @param {HTMLElement} target
140
+ * @returns
141
+ */
142
+ function isNodeToggle(target) {
143
+ return (
144
+ target &&
145
+ target.getAttribute('data-tag') === 'icon' &&
146
+ ['closed', 'opened'].includes(target.getAttribute('data-state'))
147
+ )
148
+ }
149
+ /**
150
+ * Determines an action based on a keyboard event and navigation options
151
+ *
152
+ * @param {KeyboardEvent} event - The keyboard event
153
+ * @param {Object} options - Navigation options
154
+ * @param {boolean} options.horizontal - Whether navigation is horizontal
155
+ * @param {boolean} options.nested - Whether navigation is nested
156
+ * @returns {string|null} The determined action or null if no action matches
157
+ */
158
+ export const getKeyboardAction = (event, options) => {
159
+ const { key, ctrlKey, metaKey } = event
160
+
161
+ // Check for modifier keys first (highest priority)
162
+ if (ctrlKey || metaKey) {
163
+ const modifierMap = createModifierKeyboardActionMap(options)
164
+ return modifierMap[key] || null
165
+ }
166
+
167
+ // Get the action map based on options
168
+ const actionMap = createKeyboardActionMap(options)
169
+
170
+ // Return the action or null if no matching key
171
+ return actionMap[key] || null
172
+ }
173
+
174
+ /**
175
+ * Determines an action based on a click event
176
+ *
177
+ * @param {MouseEvent} event - The click event
178
+ * @returns {string} The determined action
179
+ */
180
+ export const getClickAction = (event) => {
181
+ const { ctrlKey, metaKey, target } = event
182
+
183
+ // Check for modifier keys first (highest priority)
184
+ if (ctrlKey || metaKey) {
185
+ return 'extend'
186
+ }
187
+
188
+ // Check if clicked on icon with collapsed/expanded state
189
+ if (isNodeToggle(target) || isNodeToggle(target.parentElement)) {
190
+ return 'toggle'
191
+ }
192
+
193
+ // Default action
194
+ return 'select'
195
+ }