Skip to content

Commit

Permalink
feat: added helpers, components item, pill, switch and toggle
Browse files Browse the repository at this point in the history
  • Loading branch information
jerrythomas committed Jan 11, 2025
1 parent b46074c commit fa75e02
Show file tree
Hide file tree
Showing 66 changed files with 4,854 additions and 6 deletions.
Binary file modified bun.lockb
Binary file not shown.
57 changes: 57 additions & 0 deletions packages/actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Actions

This package provides a set of actions that can be used to perform various tasks.

## Keyboard

The keyboard action can be used to map keyboard events to actions. The following example shows how to map the `K` key combination to an action:

The default behavior is to listen to keyup events.

- [x] Configuration driven
- [x] Custom events can be defined
- [x] Supports mapping an array of keys to an event
- [x] Supports mapping a regex to an event
- [ ] Support key modifiers
- [ ] Support a combination of regex patterns and array of keys

Default configuration

- _add_: alphabet keys cause an `add` event
- _submit_: enter causes a `submit` event
- _cancel_: escape causes a `cancel` event
- _delete_: backspace or delete causes a `delete` event

### Basic Usage

```svelte
<script>
import { keyboard } from '@fumbl/actions'
function handleKey(event) {
console.log(`${event.detail} pressed`)
}
</script>
<div use:keyboard onadd={handleKey}></div>
```

### Custom Events

```svelte
<script>
import { keyboard } from '@fumbl/actions'
function handleKey(event) {
console.log(`${event.detail} pressed`)
}
const config = {
add: ['a', 'b', 'c'],
submit: 'enter',
cancel: 'escape',
delete: ['backspace', 'delete']
}
</script>
<div use:keyboard={config} onadd={handleKey}></div>
```
38 changes: 38 additions & 0 deletions packages/actions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@rokkit/actions",
"version": "1.0.0-next.0",
"description": "Contains generic actions that can be used in various components.",
"author": "Jerry Thomas <[email protected]>",
"license": "MIT",
"main": "index.js",
"module": "src/index.js",
"types": "dist/index.d.ts",
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "tsc --project tsconfig.build.json",
"clean": "rm -rf dist",
"build": "pnpm clean && pnpm prepublishOnly"
},
"files": [
"src/**/*.js",
"src/**/*.svelte"
],
"exports": {
"./src": "./src",
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.js",
"svelte": "./src/index.js"
}
},
"dependencies": {
"ramda": "^0.30.1"
},
"devDependencies": {
"@rokkit/helpers": "workspace:*"
}
}
9 changes: 9 additions & 0 deletions packages/actions/spec/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, it, expect } from 'vitest'
// skipcq: JS-C1003 - Importing all components for verification
import * as actions from '../src/index.js'

describe('actions', () => {
it('should contain all exported actions', () => {
expect(Object.keys(actions)).toEqual(['keyboard'])
})
})
156 changes: 156 additions & 0 deletions packages/actions/spec/keyboard.spec.svelte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { vi, it, expect, describe, beforeEach, afterEach } from 'vitest'
import { flushSync } from 'svelte'
import { keyboard } from '../src/keyboard.svelte.js'
import { toHaveBeenDispatchedWith } from '@rokkit/helpers/matchers'

expect.extend({ toHaveBeenDispatchedWith })

describe('keyboard', () => {
const node = document.createElement('div')
const spies = {
add: vi.fn(),
remove: vi.fn(),
submit: vi.fn()
}

const keys = ['a', 'b', 'Enter', 'Backspace']
keys.forEach((key) => {
const button = document.createElement('button')
button.setAttribute('data-key', key)
node.appendChild(button)
})

beforeEach(() => {
Object.entries(spies).forEach(([event, callback]) => {
node.addEventListener(event, callback)
})
})

afterEach(() => {
Object.entries(spies).forEach(([event, callback]) => {
node.removeEventListener(event, callback)
})
vi.resetAllMocks()
})

it('should add and remove keyup handlers to document', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
const removeEventOnNodeSpy = vi.spyOn(node, 'removeEventListener')
const addEventOnNodeSpy = vi.spyOn(node, 'addEventListener')

const cleanup = $effect.root(() => keyboard(node))
flushSync()

expect(addEventListenerSpy).toHaveBeenCalledTimes(1)
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'keyup', expect.any(Function), {})
expect(addEventOnNodeSpy).toHaveBeenCalledTimes(1)
expect(addEventOnNodeSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), {})

cleanup()
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1)
expect(removeEventListenerSpy).toHaveBeenNthCalledWith(1, 'keyup', expect.any(Function), {})
expect(removeEventOnNodeSpy).toHaveBeenCalledTimes(1)
expect(removeEventOnNodeSpy).toHaveBeenNthCalledWith(1, 'click', expect.any(Function), {})

addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore()
removeEventOnNodeSpy.mockRestore()
addEventOnNodeSpy.mockRestore()
})

it('should not dispatch any event when an unmapped key is pressed', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

document.dispatchEvent(new KeyboardEvent('keyup', { key: '-' }))
expect(spies.add).not.toHaveBeenCalled()
expect(spies.remove).not.toHaveBeenCalled()
expect(spies.submit).not.toHaveBeenCalled()
cleanup()
})

it('should dispatch "add" event when an alphabet is pressed', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

document.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }))
expect(spies.add).toHaveBeenDispatchedWith('a')
expect(spies.remove).not.toHaveBeenCalled()
expect(spies.submit).not.toHaveBeenCalled()

cleanup()
})

it('should dispatch "remove" event when delete is pressed', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Delete' }))
expect(spies.add).not.toHaveBeenCalled()
expect(spies.remove).toHaveBeenDispatchedWith('Delete')
expect(spies.submit).not.toHaveBeenCalled()

cleanup()
})

it('should dispatch "remove" event when backspace is pressed', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Backspace' }))
expect(spies.add).not.toHaveBeenCalled()
expect(spies.remove).toHaveBeenDispatchedWith('Backspace')
expect(spies.submit).not.toHaveBeenCalled()

cleanup()
})

it('should dispatch "submit" event when backspace is pressed', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }))
expect(spies.add).not.toHaveBeenCalled()
expect(spies.remove).not.toHaveBeenCalled()
expect(spies.submit).toHaveBeenDispatchedWith('Enter')

cleanup()
})

it('should dispatch "add" event when an alphabet is clicked', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

node.querySelector('[data-key="a"]').click()
expect(spies.add).toHaveBeenDispatchedWith('a')
expect(spies.remove).not.toHaveBeenCalled()
expect(spies.submit).not.toHaveBeenCalled()

cleanup()
})

it('should dispatch "remove" event when delete is clicked', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

node.querySelector('[data-key="Backspace"]').click()
expect(spies.add).not.toHaveBeenCalled()
expect(spies.remove).toHaveBeenDispatchedWith('Backspace')
expect(spies.submit).not.toHaveBeenCalled()

cleanup()
})

it('should dispatch "submit" event when enter is clicked', () => {
const cleanup = $effect.root(() => keyboard(node))
flushSync()

node.querySelector('[data-key="Enter"]').click()
expect(spies.add).not.toHaveBeenCalled()
expect(spies.remove).not.toHaveBeenCalled()
expect(spies.submit).toHaveBeenDispatchedWith('Enter')

cleanup()
})
})
53 changes: 53 additions & 0 deletions packages/actions/spec/utils.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { getClosestAncestorWithAttribute, getEventForKey } from '../src/utils.js'

describe('utils', () => {
describe('getClosestAncestorWithAttribute', () => {
it('should return null if element does not have the given attribute and is orphan', () => {
const element = document.createElement('div')
expect(getClosestAncestorWithAttribute(element, 'data-test')).toBe(null)
})
it('should return the element if it has the given attribute', () => {
const element = document.createElement('div')
element.setAttribute('data-test', 'test')
expect(getClosestAncestorWithAttribute(element, 'data-test')).toBe(element)
})
it('should return the element if it has the given attribute and is nested', () => {
const parent = document.createElement('div')
const element = document.createElement('div')
element.setAttribute('data-test', 'test')
parent.appendChild(element)
expect(getClosestAncestorWithAttribute(element, 'data-test')).toBe(element)
})
it('should return the closest ancestor if it has the given attribute', () => {
const element = document.createElement('div')
const parent = document.createElement('div')
parent.setAttribute('data-test', 'test')
parent.appendChild(element)
expect(getClosestAncestorWithAttribute(element, 'data-test')).toBe(parent)
})
})

describe('getEventForKey', () => {
const keyMapping = {
event1: ['a', 'b', 'c'],
event2: /d/,
event3: ['e', 'f'],
event4: /g/
}

it('should return the correct event name for a key in an array', () => {
expect(getEventForKey(keyMapping, 'a')).toBe('event1')
expect(getEventForKey(keyMapping, 'f')).toBe('event3')
})

it('should return the correct event name for a key matching a regex', () => {
expect(getEventForKey(keyMapping, 'd')).toBe('event2')
expect(getEventForKey(keyMapping, 'g')).toBe('event4')
})

it('should return null for a key that does not match any event', () => {
expect(getEventForKey(keyMapping, 'z')).toBeNull()
})
})
})
3 changes: 3 additions & 0 deletions packages/actions/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// skipcq: JS-E1004 - Needed for exposing all types
export * from './types.js'
export { keyboard } from './keyboard.svelte.js'
58 changes: 58 additions & 0 deletions packages/actions/src/keyboard.svelte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { on } from 'svelte/events'
import { getClosestAncestorWithAttribute, getEventForKey } from './utils.js'

/**
* Default key mappings
* @type {import('./types.js').KeyboardConfig}
*/
const defaultKeyMappings = {
remove: ['Backspace', 'Delete'],
submit: ['Enter'],
add: /^[a-zA-Z]$/
}

/**
* Handle keyboard events
*
* @param {HTMLElement} root
* @param {import('./types.js').KeyboardConfig} options - Custom key mappings
*/
export function keyboard(root, options = defaultKeyMappings) {
const keyMappings = options || defaultKeyMappings

/**
* Handle keyboard events
*
* @param {KeyboardEvent} event
*/
const keyup = (event) => {
const { key } = event
const eventName = getEventForKey(keyMappings, key)

if (eventName) {
root.dispatchEvent(new CustomEvent(eventName, { detail: key }))
}
}

const click = (event) => {
const node = getClosestAncestorWithAttribute(event.target, 'data-key')

if (node) {
const key = node.getAttribute('data-key')
const eventName = getEventForKey(keyMappings, key)

if (eventName) {
root.dispatchEvent(new CustomEvent(eventName, { detail: key }))
}
}
}

$effect(() => {
const cleanupKeyupEvent = on(document, 'keyup', keyup)
const cleanupClickEvent = on(root, 'click', click)
return () => {
cleanupKeyupEvent()
cleanupClickEvent()
}
})
}
10 changes: 10 additions & 0 deletions packages/actions/src/types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @typedef {Object} EventMapping
* @property {string} event - The event name
* @property {string[]} [keys] - The keys that trigger the event
* @property {RegExp} [pattern] - The pattern that triggers the event
*/

/**
* @typedef {Object<string, (string[]|RegExp) >} KeyboardConfig
*/
Loading

0 comments on commit fa75e02

Please sign in to comment.