{"id":29035345,"url":"https://github.com/smnandre/stimulus-stream-actions","last_synced_at":"2025-07-02T20:02:13.912Z","repository":{"id":301276621,"uuid":"1008734576","full_name":"smnandre/stimulus-stream-actions","owner":"smnandre","description":"Handle Turbo Stream actions in your Stimulus controllers (with custom actions too!)","archived":false,"fork":false,"pushed_at":"2025-06-26T02:49:05.000Z","size":0,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2025-06-26T03:32:12.752Z","etag":null,"topics":["actions","fluffy","stimulus","stimulus-controller","symfony","turbo","turbo-stream","ux"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/smnandre.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null},"funding":{"github":"smnandre"}},"created_at":"2025-06-26T02:43:13.000Z","updated_at":"2025-06-26T02:49:08.000Z","dependencies_parsed_at":null,"dependency_job_id":"ebcc933f-ee6a-40cb-ba98-039d10917adb","html_url":"https://github.com/smnandre/stimulus-stream-actions","commit_stats":null,"previous_names":["smnandre/stimulus-stream-actions"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/smnandre/stimulus-stream-actions","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/smnandre%2Fstimulus-stream-actions","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/smnandre%2Fstimulus-stream-actions/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/smnandre%2Fstimulus-stream-actions/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/smnandre%2Fstimulus-stream-actions/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/smnandre","download_url":"https://codeload.github.com/smnandre/stimulus-stream-actions/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/smnandre%2Fstimulus-stream-actions/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":261995975,"owners_count":23242208,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["actions","fluffy","stimulus","stimulus-controller","symfony","turbo","turbo-stream","ux"],"created_at":"2025-06-26T12:08:13.767Z","updated_at":"2025-06-26T12:08:14.397Z","avatar_url":"https://github.com/smnandre.png","language":"TypeScript","funding_links":["https://github.com/sponsors/smnandre"],"categories":[],"sub_categories":[],"readme":"# Stimulus Stream Actions\n\n\u003e [!TIP]\n\u003e Stop polluting the global `StreamActions` object! Stimulus StreamActions lets you handle custom Turbo Stream actions in a clean, controller-scoped way with automatic cleanup.\n\n## The Problem with Global Stream Actions\n\nCustom Turbo Stream actions typically require **global registration**, creating pollution and maintenance headaches:\n\n```javascript\nimport { StreamActions } from \"@hotwired/turbo\";\n\n// Global pollution - affects entire application\nStreamActions.closeModal = function() { /* ... */ }\nStreamActions.updateCart = function() { /* ... */ }\nStreamActions.showNotification = function() { /* ... */ }\n```\n\n**Problems:**\n- **Global namespace pollution** - All actions share the same space\n- **No automatic cleanup** - Actions persist even when controllers disconnect\n- **Testing difficulties** - Hard to mock and isolate individual actions\n- **No scoping** - Actions can't be controller-specific\n\n## The Solution: Controller-Scoped Actions\n\nScope stream actions directly to your Stimulus controllers with automatic lifecycle management:\n\n```javascript\n// Clean, scoped, automatically managed\nstatic streamActions = {\n  'close_modal': 'closeModal',\n  'update_cart': 'updateCart',\n  'show_notification': 'showNotification'\n}\n```\n\n## Why This Approach is Better\n\n### 🎯 **Real-World Example: E-commerce Cart**\n\n**Before (Global):**\n```javascript\n// Somewhere in your app\nStreamActions.updateCartCount = function(event) {\n  // Which cart? Which controller? Global state confusion.\n  document.querySelector('#cart-count').textContent = event.target.getAttribute('count');\n}\n```\n\n**After (Controller-Scoped):**\n```javascript\nexport default class CartController extends Controller {\n  static streamActions = {\n    'update_cart_count': 'updateCount',\n    'highlight_cart': 'highlightCart'\n  };\n\n  updateCount(streamData) {\n    // Scoped to THIS cart controller instance\n    const count = streamData.get('count');\n    this.element.querySelector('[data-cart-count]').textContent = count;\n  }\n\n  highlightCart(streamData) {\n    // Multiple cart controllers can coexist independently\n    const duration = streamData.getNumber('duration', 2000);\n    this.element.classList.add('cart-updated');\n    setTimeout(() =\u003e this.element.classList.remove('cart-updated'), duration);\n  }\n}\n```\n\n### ✨ **Key Benefits**\n\n- **No Global Pollution** - Actions are scoped to specific controllers\n- **Auto Cleanup** - Actions automatically removed when controller disconnects\n- **Better Testing** - Each controller can be tested in isolation\n- **Type Safety** - Full TypeScript support with proper typing\n- **Zero Build** - No decorators or extra tooling required\n- **Controller Context** - Access to `this.element`, targets, values, etc.\n\n## Installation\n\n```bash\nnpm install @smnandre/stimulus-stream-actions\n```\n\nOr via CDN:\n```javascript\nimport { useStreamActions } from 'https://cdn.jsdelivr.net/npm/@smnandre/stimulus-stream-actions@latest';\n```\n\n## Basic Usage\n\n### 1. Define Stream Actions on Your Controller\n\n```javascript\nimport { Controller } from '@hotwired/stimulus';\nimport { useStreamActions } from '@smnandre/stimulus-stream-actions';\n\nexport default class NotificationController extends Controller {\n  static streamActions = {\n    'show_notification': 'showNotification',\n    'hide_all_notifications': 'hideAll'\n  };\n\n  initialize() {\n    useStreamActions(this);\n  }\n\n  showNotification(streamData) {\n    const message = streamData.get('message');\n    const type = streamData.get('type', 'info'); // with fallback\n    \n    // Use controller context - this.element, this.targets, etc.\n    const notification = this.element.querySelector('[data-notification-template]').cloneNode(true);\n    notification.textContent = message;\n    notification.className = `notification notification--${type}`;\n    this.element.appendChild(notification);\n  }\n\n  hideAll(streamData) {\n    // Work with multiple elements using controller's element as scope\n    this.element.querySelectorAll('.notification').forEach(notification =\u003e {\n      notification.remove();\n    });\n  }\n}\n```\n\n### 2. Server Response (Any Backend)\n\n**HTML Response:**\n```html\n\u003cturbo-stream action=\"show_notification\" message=\"Order completed!\" type=\"success\"\u003e\n  \u003ctemplate\u003e\u003c/template\u003e\n\u003c/turbo-stream\u003e\n\n\u003cturbo-stream action=\"hide_all_notifications\"\u003e\n  \u003ctemplate\u003e\u003c/template\u003e\n\u003c/turbo-stream\u003e\n```\n\n**The stream elements can be generated by any backend framework that supports Turbo Streams.**\n\n## Advanced Configuration\n\nAdd complete controller context and configuration options:\n\n```javascript\nimport { Controller } from '@hotwired/stimulus';\nimport { useStreamActions } from '@smnandre/stimulus-stream-actions';\n\nexport default class ModalController extends Controller {\n  static streamActions = {\n    // Simple string mapping\n    'close_modal': 'closeModal',\n    \n    // Advanced configuration with options\n    'update_content': { \n      method: 'updateContent', \n      preventDefault: false // Allow both custom and default Turbo behavior\n    },\n    \n    // Another simple mapping\n    'highlight_modal': 'highlightModal'\n  };\n\n  initialize() {\n    useStreamActions(this);\n  }\n\n  closeModal(streamData) {\n    const modalId = streamData.get('modal-id');\n    \n    // Close specific modal or all modals in this controller's scope\n    if (modalId) {\n      this.element.querySelector(`#${modalId}`)?.remove();\n    } else {\n      this.element.querySelectorAll('[data-modal]').forEach(modal =\u003e modal.remove());\n    }\n  }\n\n  updateContent(streamData) {\n    const content = streamData.content;\n    // Update all content areas within this controller\n    this.element.querySelectorAll('[data-modal-content]').forEach(area =\u003e {\n      area.innerHTML = content;\n    });\n  }\n\n  highlightModal(streamData) {\n    const duration = streamData.getNumber('duration', 2000);\n    \n    // Highlight all modals in this controller's scope\n    this.element.querySelectorAll('[data-modal]').forEach(modal =\u003e {\n      modal.classList.add('modal--highlighted');\n      setTimeout(() =\u003e modal.classList.remove('modal--highlighted'), duration);\n    });\n  }\n}\n```\n\n## Dynamic Stream Actions\n\nFor scenarios where actions depend on controller state or values:\n\n```javascript\nimport { Controller } from '@hotwired/stimulus';\nimport { useCustomStreamActions } from '@smnandre/stimulus-stream-actions';\n\nexport default class DynamicController extends Controller {\n  static values = { \n    animated: Boolean,\n    mode: String \n  };\n\n  initialize() {\n    // Choose actions based on controller configuration\n    const actions = this.animatedValue ? {\n      'remove_item': 'animatedRemove',\n      'add_item': 'animatedAdd'\n    } : {\n      'remove_item': 'instantRemove', \n      'add_item': 'instantAdd'\n    };\n\n    // Add mode-specific actions\n    if (this.modeValue === 'admin') {\n      actions['bulk_delete'] = 'bulkDelete';\n      actions['bulk_update'] = 'bulkUpdate';\n    }\n\n    useCustomStreamActions(this, actions);\n  }\n\n  animatedRemove(streamData) {\n    const itemId = streamData.get('item-id');\n    const duration = streamData.getNumber('duration', 300);\n    const items = this.element.querySelectorAll(`[data-item-id=\"${itemId}\"]`);\n    \n    items.forEach(item =\u003e {\n      item.style.transition = `opacity ${duration}ms ease-out`;\n      item.style.opacity = '0';\n      setTimeout(() =\u003e item.remove(), duration);\n    });\n  }\n\n  instantRemove(streamData) {\n    const itemId = streamData.get('item-id');\n    this.element.querySelectorAll(`[data-item-id=\"${itemId}\"]`).forEach(item =\u003e {\n      item.remove();\n    });\n  }\n\n  bulkDelete(streamData) {\n    const selector = streamData.get('selector');\n    this.element.querySelectorAll(selector).forEach(item =\u003e item.remove());\n  }\n}\n```\n\n## Real-World Examples\n\n### Multi-Tab Interface Updates\n\n```javascript\nexport default class TabsController extends Controller {\n  static targets = ['tab', 'panel'];\n  static streamActions = {\n    'activate_tab': 'activateTab',\n    'update_tab_content': 'updateTabContent',\n    'add_notification_badge': 'addBadge'\n  };\n\n  activateTab(streamData) {\n    const tabId = streamData.get('tab-id');\n    \n    // Deactivate all tabs in this controller\n    this.tabTargets.forEach(tab =\u003e tab.classList.remove('active'));\n    this.panelTargets.forEach(panel =\u003e panel.classList.remove('active'));\n    \n    // Activate specific tab\n    const activeTab = this.element.querySelector(`[data-tab-id=\"${tabId}\"]`);\n    const activePanel = this.element.querySelector(`[data-panel-id=\"${tabId}\"]`);\n    \n    activeTab?.classList.add('active');\n    activePanel?.classList.add('active');\n  }\n\n  updateTabContent(streamData) {\n    const tabId = streamData.get('tab-id');\n    const content = streamData.content;\n    \n    const panel = this.element.querySelector(`[data-panel-id=\"${tabId}\"]`);\n    if (panel) panel.innerHTML = content;\n  }\n\n  addBadge(streamData) {\n    const tabId = streamData.get('tab-id');\n    const count = streamData.getNumber('count', 0);\n    \n    const tab = this.element.querySelector(`[data-tab-id=\"${tabId}\"]`);\n    if (tab) {\n      // Remove existing badge\n      tab.querySelectorAll('.badge').forEach(badge =\u003e badge.remove());\n      \n      // Add new badge\n      const badge = document.createElement('span');\n      badge.className = 'badge';\n      badge.textContent = count.toString();\n      tab.appendChild(badge);\n    }\n  }\n}\n```\n\n## API Reference\n\n### `useStreamActions(controller)`\nEnables stream actions defined in the static `streamActions` property.\n\n```javascript\nstatic streamActions = {\n  'action_name': 'methodName'\n};\n```\n\n### `useCustomStreamActions(controller, actions)`\nProgrammatically registers stream actions for dynamic scenarios.\n\n```javascript\nuseCustomStreamActions(this, {\n  'dynamic_action': 'handleDynamicAction'\n});\n```\n\n### Stream Action Configuration Schema\n\n```typescript\ntype StreamActionConfig = string | {\n  method: string;              // Controller method name\n  preventDefault?: boolean;    // Prevent default Turbo behavior (default: true)\n};\n\ntype StreamActionMap = Record\u003cstring, StreamActionConfig\u003e;\n```\n\n### Handler Method Signature\n\n```javascript\nhandlerMethod(streamData) {\n  // streamData: Enhanced object with easy attribute access\n  \n  // Easy attribute access\n  const value = streamData.get('custom-attribute');\n  const withFallback = streamData.get('optional-attr', 'default');\n  \n  // Typed attribute access\n  const count = streamData.getNumber('count', 0);\n  const enabled = streamData.getBoolean('enabled');\n  const config = streamData.getJSON('config', {});\n  \n  // Access stream content and target\n  const content = streamData.content;\n  const target = streamData.target;\n  \n  // All attributes as object\n  const allAttrs = streamData.attributes;\n  \n  // Original event if needed\n  const originalEvent = streamData.event;\n  \n  // Use controller context\n  this.element.querySelector('...');\n  this.targets;\n  this.values;\n}\n```\n\n## How It Works\n\n1. **Registration**: Controllers register with a global `StreamActionRegistry` on connect\n2. **Event Interception**: The registry listens for `turbo:before-stream-render` events\n3. **Action Routing**: When a custom action is detected, it routes to the appropriate controller method\n4. **Cleanup**: Controllers automatically unregister when they disconnect\n\n**Scope**: Actions trigger for **all** `\u003cturbo-stream\u003e` elements in the DOM. The `turbo:before-stream-render` event bubbles to the document level, so any controller can handle any stream action.\n\n## Testing\n\n```bash\nnpm install\nnpm test           # Unit tests with Vitest\nnpm run test:e2e   # End-to-end tests with Playwright\n```\n\n## TypeScript Support\n\nFull TypeScript support with proper type definitions:\n\n```typescript\nimport { Controller } from '@hotwired/stimulus';\nimport { useStreamActions, useCustomStreamActions } from '@smnandre/stimulus-stream-actions';\n\nexport default class MyController extends Controller {\n  static streamActions = {\n    'my_action': 'handleMyAction'\n  } as const;\n\n  initialize(): void {\n    useStreamActions(this);\n  }\n\n  handleMyAction(target: Element | null, event: CustomEvent): void {\n    // Fully typed method\n  }\n}\n```\n\n## License\n\nReleased under the [MIT License](LICENSE) by [Simon André](https://github.com/smnandre).","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsmnandre%2Fstimulus-stream-actions","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsmnandre%2Fstimulus-stream-actions","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsmnandre%2Fstimulus-stream-actions/lists"}