tailmix 0.4.6 → 0.4.8
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +76 -145
- data/app/javascript/tailmix/runtime/action_dispatcher.js +132 -0
- data/app/javascript/tailmix/runtime/component.js +130 -0
- data/app/javascript/tailmix/runtime/index.js +130 -0
- data/app/javascript/tailmix/runtime/plugins.js +35 -0
- data/app/javascript/tailmix/runtime/updater.js +140 -0
- data/docs/01_getting_started.md +39 -0
- data/examples/_modal_component.arb +17 -25
- data/examples/button.rb +81 -0
- data/examples/helpers.rb +25 -0
- data/examples/modal_component.rb +32 -164
- data/lib/tailmix/definition/builders/action_builder.rb +39 -0
- data/lib/tailmix/definition/{contexts → builders}/actions/element_builder.rb +1 -1
- data/lib/tailmix/definition/{contexts → builders}/attribute_builder.rb +1 -1
- data/lib/tailmix/definition/builders/component_builder.rb +81 -0
- data/lib/tailmix/definition/{contexts → builders}/dimension_builder.rb +2 -2
- data/lib/tailmix/definition/builders/element_builder.rb +83 -0
- data/lib/tailmix/definition/builders/reactor_builder.rb +43 -0
- data/lib/tailmix/definition/builders/rule_builder.rb +58 -0
- data/lib/tailmix/definition/builders/state_builder.rb +21 -0
- data/lib/tailmix/definition/{contexts → builders}/variant_builder.rb +17 -2
- data/lib/tailmix/definition/context_builder.rb +19 -13
- data/lib/tailmix/definition/merger.rb +2 -1
- data/lib/tailmix/definition/payload_proxy.rb +16 -0
- data/lib/tailmix/definition/result.rb +17 -18
- data/lib/tailmix/dev/docs.rb +32 -7
- data/lib/tailmix/dev/tools.rb +0 -5
- data/lib/tailmix/dsl.rb +3 -5
- data/lib/tailmix/engine.rb +11 -1
- data/lib/tailmix/html/attributes.rb +22 -12
- data/lib/tailmix/middleware/registry_cleaner.rb +17 -0
- data/lib/tailmix/registry.rb +37 -0
- data/lib/tailmix/runtime/action_proxy.rb +29 -0
- data/lib/tailmix/runtime/attribute_builder.rb +102 -0
- data/lib/tailmix/runtime/attribute_cache.rb +23 -0
- data/lib/tailmix/runtime/context.rb +51 -47
- data/lib/tailmix/runtime/facade_builder.rb +8 -5
- data/lib/tailmix/runtime/state.rb +36 -0
- data/lib/tailmix/runtime/state_proxy.rb +34 -0
- data/lib/tailmix/runtime.rb +0 -1
- data/lib/tailmix/version.rb +1 -1
- data/lib/tailmix/view_helpers.rb +49 -0
- data/lib/tailmix.rb +4 -2
- metadata +34 -21
- data/app/javascript/tailmix/finder.js +0 -15
- data/app/javascript/tailmix/index.js +0 -7
- data/app/javascript/tailmix/mutator.js +0 -28
- data/app/javascript/tailmix/runner.js +0 -7
- data/app/javascript/tailmix/stimulus_adapter.js +0 -37
- data/lib/tailmix/definition/contexts/action_builder.rb +0 -31
- data/lib/tailmix/definition/contexts/element_builder.rb +0 -41
- data/lib/tailmix/definition/contexts/stimulus_builder.rb +0 -101
- data/lib/tailmix/dev/stimulus_generator.rb +0 -124
- data/lib/tailmix/runtime/stimulus/compiler.rb +0 -59
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25625758329e8524ce4fa37154be93d929b43ce85dfc5bb5669e8646b82ebd88
|
|
4
|
+
data.tar.gz: 1a98f9b38e7799819579309691e6692ec94ac5cf1358159c6c6431cc1d16c920
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3761c68a9b82be223d3c78f5e201347c9554165eceb42e3c00b2191c5cd7f6a128f5fd251ff5bd26328118d962f136d10ca552acfa5ee49a5819035910c27c9b
|
|
7
|
+
data.tar.gz: 21fc2e32708b3bb8c8ed8d14b83535d5f51d17d78abe6eec14138283e23b17059b3b2c76799cebd019e9f6578f0057cb8dfa4cd49aaa8aabddbdc859ea7af852
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,214 +1,145 @@
|
|
|
1
1
|
# Tailmix
|
|
2
2
|
|
|
3
|
-
**Tailmix** is a powerful, declarative
|
|
3
|
+
**Tailmix** is a powerful, declarative engine for managing HTML attributes in Ruby UI components. It allows you to co-locate all presentational logic—including CSS classes, data attributes, and ARIA roles—directly within your component's code, creating truly self-contained and maintainable components.
|
|
4
4
|
|
|
5
|
-
[
|
|
6
|
-
[
|
|
5
|
+
[](https://badge.fury.io/rb/tailmix)
|
|
6
|
+
[](https://github.com/alexander-s-f/tailmix/actions/workflows/main.yml)
|
|
7
7
|
|
|
8
8
|
Inspired by modern frontend tools like CVA (Class Variance Authority), Tailmix brings a robust styling engine to your server-side components (built with Arbre, ViewComponent, Phlex, etc.).
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Key Features
|
|
11
|
+
|
|
12
|
+
* **Declarative DSL:** Describe your component's styles with an elegant and intuitive API.
|
|
13
|
+
* **Variants & Compound Variants:** Easily manage different component states (`size`, `color`, etc.) and their combinations.
|
|
14
|
+
* **Component Inheritance:** Create base components and extend them to avoid code duplication.
|
|
15
|
+
* **Zero Dependencies:** Pure Ruby, ready to work in any project.
|
|
11
16
|
|
|
12
|
-
* **Co-location & Isolation:** Define all style variants for a component directly within its class. No more hunting for styles in separate files. Each component is fully self-contained.
|
|
13
|
-
* **Declarative First:** A beautiful DSL to declaratively describe your component's appearance based on variants like state, size, etc.
|
|
14
|
-
* **Imperative Power:** A rich runtime API to dynamically add, remove, or toggle classes, perfect for server-side updates via Hotwire/Turbo.
|
|
15
|
-
* **Framework-Agnostic:** Written in pure Ruby with zero dependencies, ready to be used in any project.
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
18
19
|
|
|
19
20
|
Add the gem to your Gemfile:
|
|
20
21
|
|
|
21
|
-
```ruby
|
|
22
|
-
gem 'tailmix'
|
|
23
|
-
````
|
|
24
|
-
|
|
25
|
-
Or install it from the command line:
|
|
26
|
-
|
|
27
22
|
```bash
|
|
28
23
|
bundle add tailmix
|
|
29
24
|
```
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
Then, run the installer to set up the JavaScript assets (required for `action` and `Stimulus` integration):
|
|
32
27
|
|
|
33
28
|
```bash
|
|
34
29
|
bin/rails g tailmix:install
|
|
35
30
|
```
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+
-----
|
|
38
33
|
|
|
39
|
-
|
|
34
|
+
## Usage
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
- `dimension :name, default: :value`: Defines a variant or "dimension" (e.g., `size` or `color`).
|
|
43
|
-
- `variant :value, "classes"`: Defines the classes for a specific variant.
|
|
44
|
-
- `action :name, method: :add | :toggle | :remove`: Defines a named set of UI mutations that can be applied on the server (`.apply!`) or passed to the client (`action_payload`).
|
|
45
|
-
- `stimulus`: A powerful nested DSL for declaratively describing Stimulus `data-*` attributes.
|
|
36
|
+
The core idea of Tailmix is to describe all variants of your component within a Ruby class.
|
|
46
37
|
|
|
47
|
-
|
|
38
|
+
### 1. Basic Example: The `Modal` Component
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
#### 1. Define the Component (`app/components/modal_component.rb`)
|
|
51
|
-
|
|
52
|
-
This is a plain Ruby class that contains all the style and behavior logic.
|
|
40
|
+
**Component Definition:**
|
|
53
41
|
|
|
54
42
|
```ruby
|
|
43
|
+
# app/components/modal_component.rb
|
|
55
44
|
class ModalComponent
|
|
56
45
|
include Tailmix
|
|
57
46
|
attr_reader :ui
|
|
58
47
|
|
|
59
48
|
tailmix do
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
49
|
+
plugin :auto_focus, on: :open_button, delay: 100
|
|
50
|
+
state :open, default: false, toggle: true
|
|
51
|
+
|
|
52
|
+
element :container do
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
element :open_button do
|
|
56
|
+
# We attach the `click` event to our auto-generated action.
|
|
57
|
+
on :click, :toggle_open
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
element :base do
|
|
61
|
+
dimension :open do
|
|
62
|
+
variant true, "fixed inset-0 z-50 flex items-center justify-center visible opacity-100 transition-opacity"
|
|
63
63
|
variant false, "invisible opacity-0"
|
|
64
64
|
end
|
|
65
|
-
stimulus.controller("modal")
|
|
66
65
|
end
|
|
67
66
|
|
|
68
|
-
element :overlay
|
|
69
|
-
|
|
67
|
+
element :overlay do
|
|
68
|
+
dimension :open do
|
|
69
|
+
variant true, "fixed inset-0 bg-black/50"
|
|
70
|
+
variant false, "hidden"
|
|
71
|
+
end
|
|
72
|
+
on :click, :toggle_open
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
element :panel, "relative bg-white rounded-lg shadow-xl" do
|
|
73
|
-
dimension :
|
|
74
|
-
variant
|
|
75
|
-
variant
|
|
76
|
+
dimension :open do
|
|
77
|
+
variant true, "block"
|
|
78
|
+
variant false, "hidden"
|
|
76
79
|
end
|
|
77
80
|
end
|
|
78
81
|
|
|
79
|
-
element :close_button, "absolute top-2 right-2 p-1 text-gray-
|
|
80
|
-
|
|
82
|
+
element :close_button, "absolute top-2 right-2 p-1 text-gray-500 rounded-full cursor-pointer" do
|
|
83
|
+
on :click, :toggle_open
|
|
81
84
|
end
|
|
82
85
|
|
|
83
|
-
element :
|
|
84
|
-
|
|
85
|
-
.action(:click, :show_pending_state)
|
|
86
|
-
.action_payload(:show_pending_state, as: :pending_data)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
action :show_pending_state, method: :add do
|
|
90
|
-
element :confirm_button do
|
|
91
|
-
classes "is-loading"
|
|
92
|
-
data pending: true
|
|
93
|
-
end
|
|
94
|
-
end
|
|
86
|
+
element :title, "text-lg font-semibold text-gray-900 p-4 border-b"
|
|
87
|
+
element :body, "p-4 text-gray-900"
|
|
95
88
|
end
|
|
96
89
|
|
|
97
|
-
def initialize(open: false,
|
|
98
|
-
@ui = tailmix(open: open,
|
|
90
|
+
def initialize(open: false, id: nil)
|
|
91
|
+
@ui = tailmix(open: open, id: id)
|
|
99
92
|
end
|
|
100
93
|
end
|
|
101
94
|
```
|
|
102
95
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Thanks to Tailmix's design, you can pass `ui` objects directly to many rendering helpers.
|
|
106
|
-
|
|
107
|
-
##### Arbre
|
|
108
|
-
|
|
109
|
-
The API is seamless. The `ui` object behaves like an attributes hash automatically.
|
|
110
|
-
|
|
111
|
-
Ruby
|
|
112
|
-
|
|
113
|
-
```ruby
|
|
114
|
-
# app/views/components/my_modal.arb
|
|
115
|
-
# 1. Instantiate the component with the desired variants
|
|
116
|
-
modal = ModalComponent.new(open: true, size: :sm)
|
|
96
|
+
**Usage in ERB:**
|
|
97
|
+
In ERB, use the `**` operator to pass the attributes.
|
|
117
98
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
div modal.ui.overlay
|
|
99
|
+
```erb
|
|
100
|
+
<% ui = ModalComponent.new(open: false, id: :user_profile_modal).ui %>
|
|
121
101
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
end
|
|
126
|
-
end
|
|
102
|
+
<div <%= tag.attributes **ui.container.component %>>
|
|
103
|
+
...
|
|
104
|
+
</div>
|
|
127
105
|
```
|
|
128
106
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
In ERB, the idiomatic way to pass a hash-like object as attributes is with the double splat (`**`) operator.
|
|
132
|
-
|
|
133
|
-
Фрагмент кода
|
|
134
|
-
|
|
135
|
-
```
|
|
136
|
-
<%# app/views/pages/home.html.erb %>
|
|
137
|
-
<% modal = ModalComponent.new(open: true, size: :sm) %>
|
|
138
|
-
|
|
139
|
-
<%= tag.div **modal.ui.base do %>
|
|
140
|
-
<%= tag.div **modal.ui.overlay %>
|
|
141
|
-
|
|
142
|
-
<%= tag.div **modal.ui.panel do %>
|
|
143
|
-
<%# ... your content ... %>
|
|
144
|
-
<%= tag.button "Confirm", **modal.ui.confirm_button %>
|
|
145
|
-
<% end %>
|
|
146
|
-
<% end %>
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
#### 3. Bring it to Life with Stimulus
|
|
150
|
-
|
|
151
|
-
The `action_payload` helper makes it easy to connect server-side definitions with client-side behavior.
|
|
152
|
-
|
|
153
|
-
JavaScript
|
|
154
|
-
|
|
155
|
-
```js
|
|
156
|
-
// app/javascript/controllers/form_submission_controller.js
|
|
157
|
-
import { Controller } from "@hotwired/stimulus"
|
|
158
|
-
import Tailmix from "tailmix"
|
|
159
|
-
|
|
160
|
-
export default class extends Controller {
|
|
161
|
-
static values = { pendingData: Object }
|
|
162
|
-
|
|
163
|
-
showPendingState(event) {
|
|
164
|
-
event.preventDefault()
|
|
165
|
-
|
|
166
|
-
// Instantly apply UI changes from the payload
|
|
167
|
-
Tailmix.run({
|
|
168
|
-
config: this.pendingDataValue,
|
|
169
|
-
controller: this
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// ... then submit the form or send an AJAX request
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
## Developer Tools
|
|
178
|
-
|
|
179
|
-
Tailmix comes with built-in introspection tools to improve your development experience. Access them via the `.dev` namespace on your component class.
|
|
180
|
-
|
|
181
|
-
#### Component Documentation
|
|
182
|
-
|
|
183
|
-
Get a cheat sheet of all available `dimensions` and `actions` right in your console.
|
|
107
|
+
**Usage in Arbre:**
|
|
108
|
+
Arbre was the primary inspiration for Tailmix. Integration is seamless and does not require the `**` operator.
|
|
184
109
|
|
|
185
110
|
```ruby
|
|
186
|
-
|
|
187
|
-
|
|
111
|
+
# _example_modal_component.arb
|
|
112
|
+
modal_component = ModalComponent.new(open: false, id: :user_profile_modal)
|
|
113
|
+
ui = modal_component.ui
|
|
188
114
|
|
|
189
|
-
|
|
115
|
+
button "Open Modal Outer", tailmix_trigger_for(:user_profile_modal, :toggle_open)
|
|
190
116
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
```ruby
|
|
194
|
-
puts ModalComponent.dev.stimulus.scaffold("modal")
|
|
195
|
-
```
|
|
117
|
+
div ui.container.component do
|
|
118
|
+
button "Open Modal", ui.open_button
|
|
196
119
|
|
|
197
|
-
|
|
120
|
+
div ui.base do
|
|
121
|
+
div ui.overlay
|
|
198
122
|
|
|
199
|
-
|
|
123
|
+
div ui.panel do
|
|
124
|
+
div ui.title do
|
|
125
|
+
h3 "Modal Title"
|
|
126
|
+
button ui.close_button do
|
|
127
|
+
text_node "✖"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
200
130
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
131
|
+
div ui.body do
|
|
132
|
+
para "This is the main content of the modal. It's powered by the new Tailmix Runtime!"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
206
136
|
end
|
|
207
137
|
```
|
|
208
138
|
|
|
139
|
+
|
|
209
140
|
## Contributing
|
|
210
141
|
|
|
211
|
-
Bug reports and pull requests are welcome on GitHub
|
|
142
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
212
143
|
|
|
213
144
|
## License
|
|
214
145
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export class ActionDispatcher {
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates an instance of ActionDispatcher.
|
|
5
|
+
* @param {Component} component - The component instance to which the dispatcher is attached.
|
|
6
|
+
*/
|
|
7
|
+
constructor(component) {
|
|
8
|
+
this.component = component;
|
|
9
|
+
const internalActionElements = this.component.element.querySelectorAll('[data-tailmix-action]');
|
|
10
|
+
internalActionElements.forEach(element => this.bindAction(element));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Binds an action to a specific element based on its data-tailmix-action attribute.
|
|
15
|
+
* The attribute value should be in the format "eventName->actionName".
|
|
16
|
+
* @param {HTMLElement} element - The element to which the action is bound.
|
|
17
|
+
* @return {void}
|
|
18
|
+
*/
|
|
19
|
+
bindAction(element) {
|
|
20
|
+
const actionString = element.dataset.tailmixAction;
|
|
21
|
+
const [eventName, actionName] = actionString.split('->');
|
|
22
|
+
|
|
23
|
+
if (!eventName || !actionName) {
|
|
24
|
+
console.warn(`Tailmix: Invalid action string "${actionString}"`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const actionDefinition = this.component.definition.actions[actionName];
|
|
29
|
+
if (!actionDefinition) {
|
|
30
|
+
console.warn(`Tailmix: Action "${actionName}" not found in definition.`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
element.addEventListener(eventName, (event) => {
|
|
35
|
+
// Let's make sure the trigger is not part of another component.
|
|
36
|
+
if (element.dataset.tailmixTriggerFor && element.closest('[data-tailmix-component]') !== this.component.element) {
|
|
37
|
+
// Logic to prevent double triggering, if needed
|
|
38
|
+
}
|
|
39
|
+
this.dispatch(actionDefinition, event, element);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Dispatches an action based on the provided action definition and event input.
|
|
45
|
+
* Processes each transition within the action definition and performs state updates
|
|
46
|
+
* or other specified operations on the component.
|
|
47
|
+
* @param {Object} actionDefinition - The definition of the action containing transitions to be processed.
|
|
48
|
+
* @param {Object} event - The event triggering the dispatch, providing context for the action.
|
|
49
|
+
* @param {HTMLElement} triggerElement - The element that triggered the action.
|
|
50
|
+
* @return {void}
|
|
51
|
+
*/
|
|
52
|
+
dispatch(actionDefinition, event, triggerElement) {
|
|
53
|
+
let runtimePayload = {};
|
|
54
|
+
const withMapAttr = triggerElement.dataset.tailmixActionWith;
|
|
55
|
+
if (withMapAttr) {
|
|
56
|
+
const withMap = JSON.parse(withMapAttr);
|
|
57
|
+
for (const key in withMap) {
|
|
58
|
+
runtimePayload[key] = this.component.state[withMap[key]];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
actionDefinition.transitions.forEach(transition => {
|
|
63
|
+
this.executeTransition(transition, runtimePayload, event);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Executes a transition within an action definition.
|
|
69
|
+
* This method handles different transition types and applies updates to the component's state.'
|
|
70
|
+
* @param transition
|
|
71
|
+
* @param runtimePayload
|
|
72
|
+
* @param event
|
|
73
|
+
*/
|
|
74
|
+
executeTransition(transition, runtimePayload, event) {
|
|
75
|
+
const {type, payload} = transition;
|
|
76
|
+
switch (type) {
|
|
77
|
+
case 'set':
|
|
78
|
+
const resolvedValue = this.resolveValue(payload.value, runtimePayload, event);
|
|
79
|
+
this.component.update({[payload.key]: resolvedValue});
|
|
80
|
+
break;
|
|
81
|
+
case 'toggle':
|
|
82
|
+
this.component.update({[payload.key]: !this.component.state[payload.key]});
|
|
83
|
+
break;
|
|
84
|
+
case 'refresh':
|
|
85
|
+
this.handleRefresh(payload, runtimePayload);
|
|
86
|
+
break;
|
|
87
|
+
case 'dispatch':
|
|
88
|
+
const detail = this.resolveValue(payload.detail, runtimePayload, event);
|
|
89
|
+
this.component.dispatch(payload.name, detail);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Handles the refresh transition by fetching data from an endpoint based on the provided payload.
|
|
96
|
+
* @param {Object} payload - The payload containing the refresh configuration.
|
|
97
|
+
* @param {Object} runtimePayload - The runtime payload containing values to be used in the endpoint URL.
|
|
98
|
+
* @return {void}
|
|
99
|
+
*/
|
|
100
|
+
handleRefresh(payload, runtimePayload) {
|
|
101
|
+
const stateDef = this.component.definition.states[payload.key];
|
|
102
|
+
if (!stateDef?.endpoint) {
|
|
103
|
+
console.warn(`Tailmix: No endpoint defined for state "${payload.key}"`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
if (payload.params) {
|
|
109
|
+
for (const key in payload.params) {
|
|
110
|
+
const stateKey = payload.params[key];
|
|
111
|
+
params.append(key, this.component.state[stateKey]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const url = `${stateDef.endpoint.url}?${params.toString()}`;
|
|
116
|
+
|
|
117
|
+
// fetch(url, { headers: { 'Accept': 'text/vnd.turbo-stream.html' } })
|
|
118
|
+
// .then(r => r.text())
|
|
119
|
+
// .then(html => Turbo.renderStreamMessage(html));
|
|
120
|
+
console.log(`TODO: Fetch data for "${payload.key}" from ${url}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
resolveValue(valueTemplate, runtimePayload, event) {
|
|
124
|
+
if (valueTemplate && valueTemplate.__type === 'payload_value') {
|
|
125
|
+
const keyPath = valueTemplate.key.split('.'); // e.g., "event.target.value"
|
|
126
|
+
let result = {event, ...runtimePayload};
|
|
127
|
+
keyPath.forEach(key => result = result?.[key]);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
return valueTemplate;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {ActionDispatcher} from './action_dispatcher';
|
|
2
|
+
import {Updater} from './updater';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a component instance that manages the state and behavior of a specific UI element.
|
|
6
|
+
* The Component class is responsible for loading and initializing the component's state,
|
|
7
|
+
* finding and managing its elements, and updating the UI based on the component's state.
|
|
8
|
+
*/
|
|
9
|
+
export class Component {
|
|
10
|
+
constructor(element, definition, pluginManager) {
|
|
11
|
+
this.element = element;
|
|
12
|
+
this.definition = definition;
|
|
13
|
+
this._state = this.loadInitialState();
|
|
14
|
+
this.elements = this.findElements();
|
|
15
|
+
this.updater = new Updater(this);
|
|
16
|
+
this.dispatcher = new ActionDispatcher(this);
|
|
17
|
+
|
|
18
|
+
// --- API ---
|
|
19
|
+
this.api = {
|
|
20
|
+
get state() {
|
|
21
|
+
return {...this._state};
|
|
22
|
+
},
|
|
23
|
+
setState: (newState) => this.update(newState),
|
|
24
|
+
element: (name) => this.elements[name],
|
|
25
|
+
runAction: (name, payload) => this.dispatcher.runActionByName(name, payload),
|
|
26
|
+
dispatch: (name, detail) => this.dispatch(name, detail),
|
|
27
|
+
on: (name, callback) => this.element.addEventListener(`tailmix:${name}`, callback),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
this.initializeModels();
|
|
31
|
+
this.updater.run(this._state, {});
|
|
32
|
+
pluginManager.connect(this);
|
|
33
|
+
|
|
34
|
+
console.log(`Tailmix component "${this.definition.name || 'Unnamed'}" initialized.`, this);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Gets the current state of the component.
|
|
39
|
+
* @return {Object} The current state of the component.
|
|
40
|
+
*/
|
|
41
|
+
get state() {
|
|
42
|
+
return this._state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Updates the component's state with the provided new state and triggers the update mechanism.
|
|
47
|
+
* The previous state is logged for debugging purposes.
|
|
48
|
+
'
|
|
49
|
+
* @param newState
|
|
50
|
+
*/
|
|
51
|
+
update(newState) {
|
|
52
|
+
const oldState = {...this._state};
|
|
53
|
+
Object.assign(this._state, newState);
|
|
54
|
+
this.element.dataset.tailmixState = JSON.stringify(this._state);
|
|
55
|
+
this.updater.run(this._state, oldState);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dispatches a custom event with the specified name and detail.
|
|
60
|
+
* @param {string} name - The name of the event to be dispatched.
|
|
61
|
+
* @param {any} detail - The detail object to be attached to the event.
|
|
62
|
+
*/
|
|
63
|
+
dispatch(name, detail) {
|
|
64
|
+
const event = new CustomEvent(`tailmix:${name}`, {bubbles: true, detail});
|
|
65
|
+
this.element.dispatchEvent(event);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Loads and parses the initial state from the data attribute.
|
|
70
|
+
* @return {Object} The parsed initial state.
|
|
71
|
+
*/
|
|
72
|
+
loadInitialState() {
|
|
73
|
+
const initialState = JSON.parse(this.element.dataset.tailmixState || '{}');
|
|
74
|
+
const stateSchema = this.definition.states || {};
|
|
75
|
+
|
|
76
|
+
for (const key in stateSchema) {
|
|
77
|
+
if (initialState[key] === undefined && stateSchema[key].default !== undefined) {
|
|
78
|
+
initialState[key] = stateSchema[key].default;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return initialState;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Finds and retrieves all elements with a specific data attribute (`data-tailmix-element`)
|
|
86
|
+
* within the current root element, and maps their associated names to the DOM nodes.
|
|
87
|
+
* The root element itself is included if it also has the attribute with a defined name.
|
|
88
|
+
*
|
|
89
|
+
* @return {Object} An object where the keys are the names specified in the `data-tailmix-element`
|
|
90
|
+
* attribute, and the values are the corresponding DOM nodes.
|
|
91
|
+
*/
|
|
92
|
+
findElements() {
|
|
93
|
+
const elements = {};
|
|
94
|
+
const elementNodes = this.element.querySelectorAll('[data-tailmix-element]');
|
|
95
|
+
elementNodes.forEach(node => {
|
|
96
|
+
const name = node.dataset.tailmixElement;
|
|
97
|
+
elements[name] = node;
|
|
98
|
+
});
|
|
99
|
+
// We also add the root element itself, if it has a name.
|
|
100
|
+
if (this.element.dataset.tailmixElement) {
|
|
101
|
+
elements[this.element.dataset.tailmixElement] = this.element;
|
|
102
|
+
}
|
|
103
|
+
return elements;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initializes models by binding event listeners to elements and updating state accordingly.
|
|
108
|
+
* This method iterates through the component's elements and model bindings,
|
|
109
|
+
* and sets up event listeners for each attribute that needs to be updated.
|
|
110
|
+
*
|
|
111
|
+
* @return {void} This method does not return a value.
|
|
112
|
+
*/
|
|
113
|
+
initializeModels() {
|
|
114
|
+
for (const elName in this.definition.elements) {
|
|
115
|
+
const element = this.elements[elName];
|
|
116
|
+
const modelBindings = this.definition.elements[elName].model_bindings;
|
|
117
|
+
if (!element || !modelBindings) continue;
|
|
118
|
+
|
|
119
|
+
for (const attrName in modelBindings) {
|
|
120
|
+
const binding = modelBindings[attrName];
|
|
121
|
+
element.addEventListener(binding.event, (event) => {
|
|
122
|
+
this.update({[binding.state]: event.target[attrName]});
|
|
123
|
+
if (binding.action) {
|
|
124
|
+
// It is possible to add action execution after model update.
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|