|
| 1 | +# Phlex ↔ Stimulus wiring conventions (ruby_ui) |
| 2 | + |
| 3 | +How RubyUI components connect their Phlex markup to Stimulus controllers. All |
| 4 | +examples are real code from `gem/lib/ruby_ui/`. |
| 5 | + |
| 6 | +## Naming table |
| 7 | + |
| 8 | +A component lives in a snake_case folder; its Stimulus identifier is the folder |
| 9 | +path with `/` → `--` (so the `ruby_ui/` namespace becomes the `ruby-ui--` |
| 10 | +prefix). Phlex converts each `_` in an attribute key to `-`, so `__` becomes |
| 11 | +`--` in the emitted HTML. |
| 12 | + |
| 13 | +| Thing | Folder / Ruby | Stimulus identifier | HTML attribute emitted | |
| 14 | +| --- | --- | --- | --- | |
| 15 | +| Controller | `popover/` → `popover_controller.js` | `ruby-ui--popover` | `data-controller="ruby-ui--popover"` | |
| 16 | +| Target `trigger` | `ruby_ui__popover_target: "trigger"` | — | `data-ruby-ui--popover-target="trigger"` | |
| 17 | +| Value `open` | `ruby_ui__popover_open_value: "false"` | `openValue` | `data-ruby-ui--popover-open-value="false"` | |
| 18 | +| Action | `action: "click->ruby-ui--popover#toggle"` | — | `data-action="click->ruby-ui--popover#toggle"` | |
| 19 | +| Outlet | `ruby_ui__select_ruby_ui__select_item_outlet: ".item"` | — | `data-ruby-ui--select-ruby-ui--select-item-outlet=".item"` | |
| 20 | + |
| 21 | +The controller file itself is anonymous (`export default class extends |
| 22 | +Controller`); the identifier comes from **where it is registered**, not from the |
| 23 | +file. See "Registration" below. |
| 24 | + |
| 25 | +## Two ways to write `data:` in `default_attrs` |
| 26 | + |
| 27 | +Both are valid Phlex and both appear in the codebase. Pick one per component and |
| 28 | +be consistent. |
| 29 | + |
| 30 | +**Nested hash** (most common — `popover.rb`, `clipboard.rb`, `select.rb`): |
| 31 | + |
| 32 | +```ruby |
| 33 | +# gem/lib/ruby_ui/clipboard/clipboard.rb |
| 34 | +def default_attrs |
| 35 | + { |
| 36 | + data: { |
| 37 | + controller: "ruby-ui--clipboard", |
| 38 | + action: "click@window->ruby-ui--clipboard#onClickOutside", |
| 39 | + ruby_ui__clipboard_success_value: @success, |
| 40 | + ruby_ui__clipboard_error_value: @error, |
| 41 | + ruby_ui__clipboard_options_value: @options.to_json |
| 42 | + } |
| 43 | + } |
| 44 | +end |
| 45 | +``` |
| 46 | + |
| 47 | +**Flattened keys** (`dialog_content.rb`) — the whole `data-...` name is one key: |
| 48 | + |
| 49 | +```ruby |
| 50 | +# gem/lib/ruby_ui/dialog/dialog_content.rb |
| 51 | +def default_attrs |
| 52 | + { |
| 53 | + data_ruby_ui__dialog_target: "dialog", |
| 54 | + data_action: "click->ruby-ui--dialog#backdropClick", |
| 55 | + class: [...] |
| 56 | + } |
| 57 | +end |
| 58 | +``` |
| 59 | + |
| 60 | +`default_attrs` is merged with the caller's attributes by `Base#initialize` |
| 61 | +(`gem/lib/ruby_ui/base.rb`) via Phlex `mix`, with Tailwind classes deduped by |
| 62 | +`TailwindMerge`. So callers can add/override `data-*` and classes without |
| 63 | +clobbering the component's wiring. |
| 64 | + |
| 65 | +## Values |
| 66 | + |
| 67 | +Pass values as strings/JSON from Ruby; declare their type in the controller: |
| 68 | + |
| 69 | +```ruby |
| 70 | +# popover.rb |
| 71 | +ruby_ui__popover_options_value: @options.to_json # Object value → JSON string |
| 72 | +ruby_ui__popover_trigger_value: @options[:trigger] || "hover" |
| 73 | +``` |
| 74 | + |
| 75 | +```js |
| 76 | +// popover_controller.js |
| 77 | +static values = { |
| 78 | + open: { type: Boolean, default: false }, |
| 79 | + options: { type: Object, default: {} }, // parsed from the JSON string |
| 80 | + trigger: { type: String, default: "hover" }, |
| 81 | +}; |
| 82 | +``` |
| 83 | + |
| 84 | +React to changes with the auto-called `<name>Changed(newVal, oldVal)` callback |
| 85 | +rather than polling. |
| 86 | + |
| 87 | +## Targets |
| 88 | + |
| 89 | +Child/sub-components mark themselves as targets of the parent controller: |
| 90 | + |
| 91 | +```ruby |
| 92 | +# select_item.rb — an item is a target of the ruby-ui--select controller |
| 93 | +data: { |
| 94 | + controller: "ruby-ui--select-item", |
| 95 | + action: "click->ruby-ui--select#selectItem keydown.enter->ruby-ui--select#selectItem ...", |
| 96 | + ruby_ui__select_target: "item" |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +Multiple actions go in a single space-separated string, as above. |
| 101 | + |
| 102 | +## Actions on non-element scopes |
| 103 | + |
| 104 | +Use `@window` / `@document` scope for outside-click and global handlers: |
| 105 | + |
| 106 | +```ruby |
| 107 | +# clipboard.rb |
| 108 | +action: "click@window->ruby-ui--clipboard#onClickOutside" |
| 109 | +# select.rb |
| 110 | +action: "click@window->ruby-ui--select#clickOutside" |
| 111 | +``` |
| 112 | + |
| 113 | +A trigger sub-component can dispatch to an ancestor controller without declaring |
| 114 | +its own `controller:` — it relies on the parent controller being on an ancestor |
| 115 | +element: |
| 116 | + |
| 117 | +```ruby |
| 118 | +# dialog_trigger.rb |
| 119 | +data: { action: "click->ruby-ui--dialog#open" } |
| 120 | +``` |
| 121 | + |
| 122 | +## Outlets (parent ↔ child controllers) |
| 123 | + |
| 124 | +The parent declares the outlet selector; the child is a separate controller the |
| 125 | +parent can call into: |
| 126 | + |
| 127 | +```ruby |
| 128 | +# select.rb — parent declares the outlet to select-item |
| 129 | +data: { |
| 130 | + controller: "ruby-ui--select", |
| 131 | + ruby_ui__select_ruby_ui__select_item_outlet: ".item" |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +Outlet attribute shape: `data-<controller>-<outlet-identifier>-outlet`, hence the |
| 136 | +doubled `ruby_ui__…_ruby_ui__…_outlet` key in Ruby. |
| 137 | + |
| 138 | +## Registration |
| 139 | + |
| 140 | +- **Importmap** apps (Rails default): controllers are eager-loaded from |
| 141 | + `controllers/` — nothing to register manually. |
| 142 | +- **esbuild/webpack** apps: the manifest `docs/app/javascript/controllers/index.js` |
| 143 | + is auto-generated; regenerate with `rake stimulus:manifest:update`. Each entry is: |
| 144 | + |
| 145 | + ```js |
| 146 | + import RubyUi__PopoverController from "./ruby_ui/popover_controller" |
| 147 | + application.register("ruby-ui--popover", RubyUi__PopoverController) |
| 148 | + ``` |
| 149 | + |
| 150 | + Note the file path segment `ruby_ui/` + `popover_controller` maps to identifier |
| 151 | + `ruby-ui--popover`. |
| 152 | + |
| 153 | +## Dependencies |
| 154 | + |
| 155 | +Declare any JS package the controller imports (e.g. `@floating-ui/dom`, |
| 156 | +`fuse.js`, `embla-carousel`) in two places: |
| 157 | + |
| 158 | +- `gem/package.json` — for the gem's own dev/test. |
| 159 | +- `gem/lib/generators/ruby_ui/dependencies.yml` — per-component map so the |
| 160 | + generator installs it into consumer apps (also lists required gems and other |
| 161 | + components a component depends on). |
0 commit comments