@rokkit/actions 1.0.0-next.99 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,189 @@
1
- # Core Components
1
+ # @rokkit/actions
2
+
3
+ Svelte actions and DOM utilities for Rokkit components.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @rokkit/actions
9
+ # or
10
+ bun add @rokkit/actions
11
+ ```
12
+
13
+ ## Overview
14
+
15
+ `@rokkit/actions` provides the DOM event layer that `@rokkit/ui` components build on. The main export is `Navigator` — a class that wires keyboard, click, and focus events on a container element to a `Wrapper` or `ListController` instance. The package also includes standalone Svelte `use:` actions for effects like ripple, magnetic snap, reveal animations, dismissal on click-outside, and declarative keyboard shortcuts.
16
+
17
+ ## Usage
18
+
19
+ ### navigator — keyboard and click navigation
20
+
21
+ The `navigator` action connects a container element's DOM events to a Wrapper controller. It handles `ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight`, `Home`, `End`, `Enter`, `Space`, typeahead, and click-to-select.
22
+
23
+ ```svelte
24
+ <script>
25
+ import { navigator } from '@rokkit/actions'
26
+ import { ProxyTree, Wrapper } from '@rokkit/states'
27
+
28
+ const tree = new ProxyTree(items)
29
+ const wrapper = new Wrapper(tree, { onselect })
30
+ </script>
31
+
32
+ <ul use:navigator={{ controller: wrapper, orientation: 'vertical' }}>
33
+ {#each wrapper.flatView as node (node.key)}
34
+ <li data-path={node.key}>{node.proxy.label}</li>
35
+ {/each}
36
+ </ul>
37
+ ```
38
+
39
+ The `data-path` attribute on each item is required — Navigator uses it to resolve which item was clicked or focused.
40
+
41
+ ### Navigator class (imperative usage)
42
+
43
+ ```js
44
+ import { Navigator } from '@rokkit/actions'
45
+
46
+ const nav = new Navigator(containerEl, wrapper, {
47
+ orientation: 'vertical', // 'vertical' | 'horizontal'
48
+ collapsible: true // enable expand/collapse key handling
49
+ })
50
+
51
+ // Clean up when done
52
+ nav.destroy()
53
+ ```
54
+
55
+ ### keyboard — declarative shortcut binding
56
+
57
+ ```svelte
58
+ <script>
59
+ import { keyboard } from '@rokkit/actions'
60
+ </script>
61
+
62
+ <div
63
+ use:keyboard={{ submit: 'enter', cancel: 'escape' }}
64
+ onsubmit={() => save()}
65
+ oncancel={() => close()}
66
+ >
67
+ ...
68
+ </div>
69
+ ```
70
+
71
+ Default mappings: alphabet keys dispatch `add`, Enter dispatches `submit`, Escape dispatches `cancel`, Backspace/Delete dispatch `delete`.
72
+
73
+ ### ripple — click ripple effect
74
+
75
+ ```svelte
76
+ <script>
77
+ import { ripple } from '@rokkit/actions'
78
+ </script>
79
+
80
+ <button use:ripple>Click me</button>
81
+
82
+ <!-- With options -->
83
+ <button use:ripple={{ color: 'white', opacity: 0.2, duration: 400 }}>Click me</button>
84
+ ```
85
+
86
+ ### hoverLift — elevation shadow on hover
87
+
88
+ ```svelte
89
+ <script>
90
+ import { hoverLift } from '@rokkit/actions'
91
+ </script>
92
+
93
+ <div use:hoverLift>Card content</div>
94
+ ```
95
+
96
+ ### magnetic — snap-to-cursor effect
97
+
98
+ ```svelte
99
+ <script>
100
+ import { magnetic } from '@rokkit/actions'
101
+ </script>
102
+
103
+ <button use:magnetic>Hover me</button>
104
+ ```
105
+
106
+ ### reveal — intersection observer reveal animation
107
+
108
+ ```svelte
109
+ <script>
110
+ import { reveal } from '@rokkit/actions'
111
+ </script>
112
+
113
+ <section use:reveal>Fades in when scrolled into view</section>
114
+ ```
115
+
116
+ ### dismissable — click-outside dismissal
117
+
118
+ ```svelte
119
+ <script>
120
+ import { dismissable } from '@rokkit/actions'
121
+
122
+ let open = $state(false)
123
+ </script>
124
+
125
+ <div use:dismissable={{ enabled: open, ondismiss: () => (open = false) }}>Dropdown content</div>
126
+ ```
127
+
128
+ ### swipeable — touch swipe detection
129
+
130
+ ```svelte
131
+ <script>
132
+ import { swipeable } from '@rokkit/actions'
133
+ </script>
134
+
135
+ <div use:swipeable onswipeleft={() => next()} onswiperight={() => prev()}>Swipeable content</div>
136
+ ```
137
+
138
+ ### themable — apply theme CSS variables
139
+
140
+ ```svelte
141
+ <script>
142
+ import { themable } from '@rokkit/actions'
143
+ </script>
144
+
145
+ <div use:themable={{ theme: 'ocean' }}>Themed content</div>
146
+ ```
147
+
148
+ ## API Reference
149
+
150
+ ### Navigator options
151
+
152
+ | Option | Type | Default | Description |
153
+ | ------------- | ---------------------------- | ------------ | ------------------------------------- |
154
+ | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Arrow key axis for prev/next movement |
155
+ | `collapsible` | `boolean` | `false` | Enable expand/collapse via arrow keys |
156
+
157
+ ### buildKeymap / resolveAction
158
+
159
+ Low-level utilities for constructing custom keymaps:
160
+
161
+ ```js
162
+ import { buildKeymap, resolveAction, ACTIONS } from '@rokkit/actions'
163
+
164
+ const keymap = buildKeymap({ orientation: 'vertical', collapsible: true })
165
+ const action = resolveAction(keymap, event) // returns action string or null
166
+ ```
167
+
168
+ ## Exports
169
+
170
+ | Export | Type | Description |
171
+ | --------------- | ------------- | ---------------------------------------------- |
172
+ | `Navigator` | Class | DOM event wiring for Wrapper/ListController |
173
+ | `navigator` | Svelte action | `use:navigator` wrapper around Navigator class |
174
+ | `keyboard` | Svelte action | Declarative keyboard shortcut binding |
175
+ | `ripple` | Svelte action | Material Design ink ripple on click |
176
+ | `hoverLift` | Svelte action | Elevation shadow on hover |
177
+ | `magnetic` | Svelte action | Snap-to-cursor magnetic effect |
178
+ | `reveal` | Svelte action | Intersection-observer reveal animation |
179
+ | `dismissable` | Svelte action | Click-outside dismissal |
180
+ | `pannable` | Svelte action | Pan / drag detection |
181
+ | `swipeable` | Svelte action | Touch swipe detection |
182
+ | `themable` | Svelte action | Apply theme CSS vars to element |
183
+ | `buildKeymap` | Function | Build a keymap for given orientation/options |
184
+ | `resolveAction` | Function | Resolve a keyboard event to an action string |
185
+ | `ACTIONS` | Object | Named action constants |
186
+
187
+ ---
188
+
189
+ Part of [Rokkit](https://github.com/jerrythomas/rokkit) — a Svelte 5 component library and design system.
package/package.json CHANGED
@@ -1,33 +1,29 @@
1
1
  {
2
2
  "name": "@rokkit/actions",
3
- "version": "1.0.0-next.99",
3
+ "version": "1.0.1",
4
4
  "description": "Contains generic actions that can be used in various components.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jerrythomas/rokkit.git"
8
+ },
5
9
  "author": "Jerry Thomas <me@jerrythomas.name>",
6
10
  "license": "MIT",
7
- "main": "index.js",
8
- "module": "src/index.js",
9
- "types": "dist/index.d.ts",
10
11
  "type": "module",
11
12
  "publishConfig": {
12
13
  "access": "public"
13
14
  },
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.99",
26
- "validators": "1.0.0-next.99"
15
+ "scripts": {
16
+ "prepublishOnly": "cp ../../LICENSE . && tsc --project tsconfig.build.json",
17
+ "postpublish": "rm -f LICENSE",
18
+ "clean": "rm -rf dist",
19
+ "build": "bun clean && bun prepublishOnly"
27
20
  },
28
21
  "files": [
29
22
  "src/**/*.js",
30
- "src/**/*.svelte"
23
+ "dist/**/*.d.ts",
24
+ "README.md",
25
+ "package.json",
26
+ "LICENSE"
31
27
  ],
32
28
  "exports": {
33
29
  "./src": "./src",
@@ -39,18 +35,11 @@
39
35
  }
40
36
  },
41
37
  "dependencies": {
42
- "ramda": "^0.30.1",
43
- "@rokkit/core": "1.0.0-next.99",
44
- "@rokkit/stores": "1.0.0-next.99"
38
+ "ramda": "^0.32.0",
39
+ "@rokkit/core": "latest"
45
40
  },
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"
41
+ "devDependencies": {
42
+ "@rokkit/helpers": "latest",
43
+ "@rokkit/states": "latest"
55
44
  }
56
- }
45
+ }
@@ -1,11 +1,10 @@
1
1
  import { EventManager } from './lib'
2
+
2
3
  /**
3
4
  * Svelte action function for forwarding keyboard events from a parent element to a child.
4
5
  * The child is selected using a CSS selector passed in the options object.
5
6
  * Optionally, you can specify which keyboard events you want to forward: "keydown", "keyup", and/or "keypress".
6
7
  * 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
8
  *
10
9
  * @param {HTMLElement} element - The parent element from which keyboard events will be forwarded.
11
10
  * @param {import('./types').PushDownOptions} options - The options object.
@@ -23,12 +22,13 @@ export function delegateKeyboardEvents(
23
22
  child.dispatchEvent(new KeyboardEvent(event.type, event))
24
23
  }
25
24
 
26
- if (child) {
27
- events.forEach((event) => (handlers[event] = forwardEvent))
28
- manager.update(handlers)
29
- }
30
-
31
- return {
32
- destroy: () => manager.reset()
33
- }
25
+ $effect(() => {
26
+ if (child) {
27
+ events.forEach((event) => {
28
+ handlers[event] = forwardEvent
29
+ })
30
+ manager.update(handlers)
31
+ }
32
+ return () => manager.reset()
33
+ })
34
34
  }
@@ -1,3 +1,4 @@
1
+ import { on } from 'svelte/events'
1
2
  const KEYCODE_ESC = 27
2
3
 
3
4
  /**
@@ -5,29 +6,28 @@ const KEYCODE_ESC = 27
5
6
  * emits a `dismiss` event. This is useful for closing a modal or dropdown.
6
7
  *
7
8
  * @param {HTMLElement} node
8
- * @returns {import('./types').SvelteActionReturn}
9
9
  */
10
10
  export function dismissable(node) {
11
11
  const handleClick = (event) => {
12
12
  if (node && !node.contains(event.target) && !event.defaultPrevented) {
13
- node.dispatchEvent(new CustomEvent('dismiss', node))
13
+ node.dispatchEvent(new CustomEvent('dismiss'))
14
14
  }
15
15
  }
16
+
16
17
  const keyup = (event) => {
17
18
  if (event.keyCode === KEYCODE_ESC || event.key === 'Escape') {
18
19
  event.stopPropagation()
19
-
20
- node.dispatchEvent(new CustomEvent('dismiss', node))
20
+ node.dispatchEvent(new CustomEvent('dismiss', { detail: node }))
21
21
  }
22
22
  }
23
23
 
24
- document.addEventListener('click', handleClick, true)
25
- document.addEventListener('keyup', keyup, true)
24
+ $effect(() => {
25
+ const cleanupClickEvent = on(document, 'click', handleClick)
26
+ const cleanupKeyupEvent = on(document, 'keyup', keyup)
26
27
 
27
- return {
28
- destroy() {
29
- document.removeEventListener('click', handleClick, true)
30
- document.removeEventListener('keyup', keyup, true)
28
+ return () => {
29
+ cleanupClickEvent()
30
+ cleanupKeyupEvent()
31
31
  }
32
- }
32
+ })
33
33
  }
@@ -1,39 +1,4 @@
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
-
1
+ import { on } from 'svelte/events'
37
2
  /**
38
3
  * Initialize empty fillable element style and add listener for click
39
4
  *
@@ -41,12 +6,16 @@ export function fillable(node, { options, current, check }) {
41
6
  * @param {EventListener} click
42
7
  */
43
8
  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
9
+ const registry = []
10
+ Array.from(blanks).forEach((blank, ref) => {
11
+ blank.innerHTML = '?'
12
+ blank.classList.add('empty')
13
+ blank.name = `fill-${ref}`
14
+ blank['data-index'] = ref
15
+ const cleanup = on(blank, 'click', click)
16
+ registry.push(cleanup)
49
17
  })
18
+ return registry
50
19
  }
51
20
 
52
21
  /**
@@ -56,13 +25,21 @@ function initialize(blanks, click) {
56
25
  * @param {Array<import('./types.js').FillableData>} options
57
26
  * @param {*} current
58
27
  */
59
- function fill(blanks, options, current) {
28
+ function fill(blanks, { options, current }, node) {
60
29
  if (current > -1 && current < Object.keys(blanks).length) {
61
30
  const index = options.findIndex(({ actualIndex }) => actualIndex === current)
62
31
  if (index > -1) {
63
32
  blanks[current].innerHTML = options[index].value
64
33
  blanks[current].classList.remove('empty')
65
34
  blanks[current].classList.add('filled')
35
+ node.dispatchEvent(
36
+ new CustomEvent('fill', {
37
+ detail: {
38
+ index: current,
39
+ value: options[index].value
40
+ }
41
+ })
42
+ )
66
43
  }
67
44
  }
68
45
  }
@@ -73,17 +50,19 @@ function fill(blanks, options, current) {
73
50
  * @param {EventListener} event
74
51
  * @param {HTMLElement} node
75
52
  */
76
- function clear(event, node) {
53
+ function clear(event, node, options) {
54
+ const item = options.find(({ value }) => value === event.target.innerHTML)
77
55
  event.target.innerHTML = '?'
78
56
  event.target.classList.remove('filled')
79
57
  event.target.classList.remove('pass')
80
58
  event.target.classList.remove('fail')
81
59
  event.target.classList.add('empty')
60
+
82
61
  node.dispatchEvent(
83
62
  new CustomEvent('remove', {
84
63
  detail: {
85
- index: event.target.name.split('-')[1],
86
- value: event.target['data-index']
64
+ index: event.target['data-index'],
65
+ value: item.value
87
66
  }
88
67
  })
89
68
  )
@@ -104,3 +83,33 @@ function validate(blanks, data) {
104
83
  )
105
84
  })
106
85
  }
86
+
87
+ /**
88
+ * Action for filling a <del>?</del> element in html block.
89
+ *
90
+ * @param {HTMLElement} node
91
+ * @param {import('./types').FillOptions} options
92
+ * @returns
93
+ */
94
+ export function fillable(node, data) {
95
+ const blanks = node.getElementsByTagName('del')
96
+
97
+ function click(event) {
98
+ if (event.target.innerHTML !== '?') {
99
+ clear(event, node, data.options)
100
+ } else {
101
+ data.current = event.target['data-index']
102
+ fill(blanks, data, node)
103
+ }
104
+ }
105
+
106
+ $effect(() => {
107
+ const registry = initialize(blanks, click)
108
+
109
+ if (data.check) validate(blanks, data)
110
+
111
+ return () => {
112
+ registry.forEach((cleanup) => cleanup())
113
+ }
114
+ })
115
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Hover lift action — adds translateY + elevated shadow on hover.
3
+ * Sets transition on mount, applies transform + box-shadow on mouseenter, resets on mouseleave.
4
+ *
5
+ * @param {HTMLElement} node
6
+ * @param {HoverLiftOptions} [options]
7
+ *
8
+ * @typedef {Object} HoverLiftOptions
9
+ * @property {string} [distance='-0.25rem'] Translate distance on hover (negative = up)
10
+ * @property {string} [shadow='0 10px 25px -5px rgba(0,0,0,0.1)'] Box shadow on hover
11
+ * @property {number} [duration=200] Transition duration (ms)
12
+ */
13
+
14
+ function resolveHoverLiftOpts(options) {
15
+ return {
16
+ distance: '-0.25rem',
17
+ shadow: '0 10px 25px -5px rgba(0,0,0,0.1)',
18
+ duration: 200,
19
+ ...options
20
+ }
21
+ }
22
+
23
+ function isReducedMotion() {
24
+ return (
25
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
26
+ )
27
+ }
28
+
29
+ function applyHoverLift(node, opts) {
30
+ const originalTransform = node.style.transform
31
+ const originalBoxShadow = node.style.boxShadow
32
+ const originalTransition = node.style.transition
33
+
34
+ node.style.transition = `transform ${opts.duration}ms ease, box-shadow ${opts.duration}ms ease`
35
+
36
+ function onEnter() {
37
+ node.style.transform = `translateY(${opts.distance})`
38
+ node.style.boxShadow = opts.shadow
39
+ }
40
+
41
+ function onLeave() {
42
+ node.style.transform = originalTransform
43
+ node.style.boxShadow = originalBoxShadow
44
+ }
45
+
46
+ node.addEventListener('mouseenter', onEnter)
47
+ node.addEventListener('mouseleave', onLeave)
48
+
49
+ return () => {
50
+ node.removeEventListener('mouseenter', onEnter)
51
+ node.removeEventListener('mouseleave', onLeave)
52
+ node.style.transform = originalTransform
53
+ node.style.boxShadow = originalBoxShadow
54
+ node.style.transition = originalTransition
55
+ }
56
+ }
57
+
58
+ export function hoverLift(node, options = {}) {
59
+ $effect(() => {
60
+ const opts = resolveHoverLiftOpts(options)
61
+ if (isReducedMotion()) return
62
+ return applyHoverLift(node, opts)
63
+ })
64
+ }
package/src/index.js CHANGED
@@ -1,13 +1,20 @@
1
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'
2
+ export * from './types.js'
3
+ export { Navigator } from './navigator.js'
4
+ export { Trigger } from './trigger.js'
5
+ export { buildKeymap, resolveAction, ACTIONS } from './keymap.js'
6
+ export { keyboard } from './keyboard.svelte.js'
7
+ export { pannable } from './pannable.svelte.js'
8
+ export { swipeable } from './swipeable.svelte.js'
9
+ export { navigator } from './navigator.svelte.js'
10
+ export { themable } from './themable.svelte.js'
11
+ export { skinnable } from './skinnable.svelte.js'
12
+ export { dismissable } from './dismissable.svelte.js'
13
+ export { navigable } from './navigable.svelte.js'
14
+ export { fillable } from './fillable.svelte.js'
15
+ export { delegateKeyboardEvents } from './delegate.svelte.js'
16
+ export { reveal } from './reveal.svelte.js'
17
+ export { hoverLift } from './hover-lift.svelte.js'
18
+ export { magnetic } from './magnetic.svelte.js'
19
+ export { ripple } from './ripple.svelte.js'
20
+ export { tooltip } from './tooltip.svelte.js'