tailmix 0.4.7 → 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 +46 -205
- 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 +0 -81
- data/examples/_modal_component.arb +17 -25
- data/examples/modal_component.rb +31 -155
- 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 +17 -12
- data/lib/tailmix/definition/payload_proxy.rb +16 -0
- data/lib/tailmix/definition/result.rb +17 -19
- data/lib/tailmix/dev/docs.rb +3 -9
- 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 -62
- 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 +26 -20
- 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/docs/02_dsl_reference.md +0 -266
- data/docs/03_advanced_usage.md +0 -88
- data/docs/04_client_side_bridge.md +0 -119
- data/docs/05_cookbook.md +0 -249
- data/lib/tailmix/definition/contexts/action_builder.rb +0 -31
- data/lib/tailmix/definition/contexts/element_builder.rb +0 -55
- 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
|
@@ -11,22 +11,9 @@ Inspired by modern frontend tools like CVA (Class Variance Authority), Tailmix b
|
|
|
11
11
|
|
|
12
12
|
* **Declarative DSL:** Describe your component's styles with an elegant and intuitive API.
|
|
13
13
|
* **Variants & Compound Variants:** Easily manage different component states (`size`, `color`, etc.) and their combinations.
|
|
14
|
-
* **Stimulus Bridge:** Seamlessly integrate with StimulusJS to create interactive components.
|
|
15
|
-
* **"Hot" UI Updates:** Enable optimistic UI updates with `action` and `action_payload` in tandem with Hotwire/Turbo.
|
|
16
14
|
* **Component Inheritance:** Create base components and extend them to avoid code duplication.
|
|
17
|
-
* **Developer Tools:** Get built-in documentation and code generators right in your console.
|
|
18
15
|
* **Zero Dependencies:** Pure Ruby, ready to work in any project.
|
|
19
16
|
|
|
20
|
-
-----
|
|
21
|
-
|
|
22
|
-
## Quick Links
|
|
23
|
-
|
|
24
|
-
* **[Full Documentation →](/docs)**
|
|
25
|
-
* **[DSL Reference](/docs/02_dsl_reference.md)**
|
|
26
|
-
* **[Cookbook (Recipes)](/docs/05_cookbook.md)**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-----
|
|
30
17
|
|
|
31
18
|
## Installation
|
|
32
19
|
|
|
@@ -48,120 +35,10 @@ bin/rails g tailmix:install
|
|
|
48
35
|
|
|
49
36
|
The core idea of Tailmix is to describe all variants of your component within a Ruby class.
|
|
50
37
|
|
|
51
|
-
### 1. Basic Example: The `
|
|
52
|
-
|
|
53
|
-
Let's create a simple, flexible `Badge` that can have different colors.
|
|
54
|
-
|
|
55
|
-
**Component Definition:**
|
|
56
|
-
|
|
57
|
-
```ruby
|
|
58
|
-
# app/components/badge_component.rb
|
|
59
|
-
class BadgeComponent
|
|
60
|
-
include Tailmix
|
|
61
|
-
attr_reader :ui, :text
|
|
62
|
-
|
|
63
|
-
def initialize(text, color: :gray)
|
|
64
|
-
@ui = tailmix(color: color)
|
|
65
|
-
@text = text
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
tailmix do
|
|
69
|
-
element :badge, "inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full" do
|
|
70
|
-
dimension :color, default: :gray do
|
|
71
|
-
variant :gray, "bg-gray-100 text-gray-800"
|
|
72
|
-
variant :success, "bg-green-100 text-green-800"
|
|
73
|
-
variant :danger, "bg-red-100 text-red-800"
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
**Usage in ERB:**
|
|
81
|
-
In ERB, use the `**` operator to pass the attributes.
|
|
82
|
-
|
|
83
|
-
```erb
|
|
84
|
-
<% badge = BadgeComponent.new("Active", color: :success) %>
|
|
85
|
-
|
|
86
|
-
<span <%= tag.attributes **badge.ui.badge %>>
|
|
87
|
-
<%= badge.text %>
|
|
88
|
-
</span>
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
**Usage in Arbre:**
|
|
92
|
-
Arbre was the primary inspiration for Tailmix. Integration is seamless and does not require the `**` operator.
|
|
93
|
-
|
|
94
|
-
```ruby
|
|
95
|
-
# my_view.arb
|
|
96
|
-
badge = BadgeComponent.new("Active", color: :success)
|
|
97
|
-
ui = badge.ui
|
|
98
|
-
|
|
99
|
-
span ui.badge do
|
|
100
|
-
text_node badge.text
|
|
101
|
-
end
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
### 2\. Adding Interactivity with Stimulus
|
|
105
|
-
|
|
106
|
-
Tailmix allows you to declaratively define Stimulus controllers. Let's build a component to copy text to the clipboard.
|
|
38
|
+
### 1. Basic Example: The `Modal` Component
|
|
107
39
|
|
|
108
40
|
**Component Definition:**
|
|
109
41
|
|
|
110
|
-
```ruby
|
|
111
|
-
# app/components/clipboard_component.rb
|
|
112
|
-
class ClipboardComponent
|
|
113
|
-
include Tailmix
|
|
114
|
-
attr_reader :ui, :text_to_copy
|
|
115
|
-
|
|
116
|
-
def initialize(text_to_copy)
|
|
117
|
-
@ui = tailmix
|
|
118
|
-
@text_to_copy = text_to_copy
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
tailmix do
|
|
122
|
-
element :wrapper, "flex items-center space-x-2" do
|
|
123
|
-
stimulus.controller("clipboard") # data-controller="clipboard"
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
element :source, "p-2 border rounded-md bg-gray-50" do
|
|
127
|
-
stimulus.context("clipboard").target("source") # data-clipboard-target="source"
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
element :button, "px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-md" do
|
|
131
|
-
stimulus.context("clipboard").action(:click, :copy) # data-action="click->clipboard#copy"
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
**Stimulus Controller:**
|
|
138
|
-
Generate a template with `puts ClipboardComponent.dev.stimulus.scaffold` and fill in the logic.
|
|
139
|
-
|
|
140
|
-
```javascript
|
|
141
|
-
// app/javascript/controllers/clipboard_controller.js
|
|
142
|
-
import { Controller } from "@hotwired/stimulus"
|
|
143
|
-
|
|
144
|
-
export default class extends Controller {
|
|
145
|
-
static targets = ["source"]
|
|
146
|
-
|
|
147
|
-
copy() {
|
|
148
|
-
navigator.clipboard.writeText(this.sourceTarget.textContent)
|
|
149
|
-
// Optionally: show a success notification
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Your component is now fully interactive, with its entire structure defined in a single Ruby file.
|
|
155
|
-
|
|
156
|
-
-----
|
|
157
|
-
|
|
158
|
-
## 3. Advanced Example: An Interactive Modal
|
|
159
|
-
|
|
160
|
-
Now let's see the full power of Tailmix by building a common UI pattern: a fully interactive modal component. This example combines multiple elements, shared variants, Stimulus integration, and a client-side action for optimistic updates.
|
|
161
|
-
|
|
162
|
-
Component Definition:
|
|
163
|
-
The `ModalComponent` definition is self-contained. It describes the structure, all possible states, and the interactive behavior of the modal.
|
|
164
|
-
|
|
165
42
|
```ruby
|
|
166
43
|
# app/components/modal_component.rb
|
|
167
44
|
class ModalComponent
|
|
@@ -169,132 +46,96 @@ class ModalComponent
|
|
|
169
46
|
attr_reader :ui
|
|
170
47
|
|
|
171
48
|
tailmix do
|
|
172
|
-
|
|
49
|
+
plugin :auto_focus, on: :open_button, delay: 100
|
|
50
|
+
state :open, default: false, toggle: true
|
|
51
|
+
|
|
173
52
|
element :container do
|
|
174
|
-
stimulus.controller("modal")
|
|
175
|
-
.action_payload(:toggle, as: :toggle_data)
|
|
176
|
-
# dynamic values:
|
|
177
|
-
.value(:user_id, method: :get_current_user_id)
|
|
178
|
-
.value(:generated_at, call: -> { Time.now.iso8601 })
|
|
179
53
|
end
|
|
180
54
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
55
|
+
element :open_button do
|
|
56
|
+
# We attach the `click` event to our auto-generated action.
|
|
57
|
+
on :click, :toggle_open
|
|
184
58
|
end
|
|
185
59
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
variant true, "fixed inset-0 z-50 visible opacity-100 transition-opacity"
|
|
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"
|
|
190
63
|
variant false, "invisible opacity-0"
|
|
191
64
|
end
|
|
192
65
|
end
|
|
193
66
|
|
|
194
|
-
element :overlay
|
|
195
|
-
|
|
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
|
|
196
73
|
end
|
|
197
74
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
dimension :open, default: false do
|
|
75
|
+
element :panel, "relative bg-white rounded-lg shadow-xl" do
|
|
76
|
+
dimension :open do
|
|
201
77
|
variant true, "block"
|
|
202
78
|
variant false, "hidden"
|
|
203
79
|
end
|
|
204
|
-
dimension :size, default: :md do
|
|
205
|
-
variant :sm, "max-w-sm p-4"
|
|
206
|
-
variant :md, "max-w-md p-6"
|
|
207
|
-
end
|
|
208
|
-
stimulus.context("modal").target("panel")
|
|
209
80
|
end
|
|
210
81
|
|
|
211
82
|
element :close_button, "absolute top-2 right-2 p-1 text-gray-500 rounded-full cursor-pointer" do
|
|
212
|
-
|
|
83
|
+
on :click, :toggle_open
|
|
213
84
|
end
|
|
214
85
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
element :base do
|
|
218
|
-
classes "visible opacity-100"
|
|
219
|
-
classes "invisible opacity-0"
|
|
220
|
-
end
|
|
221
|
-
element :overlay do
|
|
222
|
-
classes "fixed inset-0 bg-black/50"
|
|
223
|
-
end
|
|
224
|
-
element :panel do
|
|
225
|
-
classes "block"
|
|
226
|
-
classes "hidden"
|
|
227
|
-
end
|
|
228
|
-
end
|
|
86
|
+
element :title, "text-lg font-semibold text-gray-900 p-4 border-b"
|
|
87
|
+
element :body, "p-4 text-gray-900"
|
|
229
88
|
end
|
|
230
89
|
|
|
231
|
-
def initialize(
|
|
232
|
-
@ui = tailmix(
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def get_current_user_id
|
|
236
|
-
123
|
|
90
|
+
def initialize(open: false, id: nil)
|
|
91
|
+
@ui = tailmix(open: open, id: id)
|
|
237
92
|
end
|
|
238
93
|
end
|
|
239
94
|
```
|
|
240
95
|
|
|
241
|
-
**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
```javascript
|
|
245
|
-
// app/javascript/controllers/modal_controller.js
|
|
246
|
-
import { Controller } from "@hotwired/stimulus"
|
|
247
|
-
import Tailmix from "tailmix"
|
|
248
|
-
|
|
249
|
-
export default class extends Controller {
|
|
250
|
-
static values = { toggleData: Object }
|
|
96
|
+
**Usage in ERB:**
|
|
97
|
+
In ERB, use the `**` operator to pass the attributes.
|
|
251
98
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (event) event.preventDefault();
|
|
99
|
+
```erb
|
|
100
|
+
<% ui = ModalComponent.new(open: false, id: :user_profile_modal).ui %>
|
|
255
101
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
controller: this
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
}
|
|
102
|
+
<div <%= tag.attributes **ui.container.component %>>
|
|
103
|
+
...
|
|
104
|
+
</div>
|
|
263
105
|
```
|
|
264
106
|
|
|
265
|
-
**
|
|
266
|
-
|
|
107
|
+
**Usage in Arbre:**
|
|
108
|
+
Arbre was the primary inspiration for Tailmix. Integration is seamless and does not require the `**` operator.
|
|
267
109
|
|
|
268
110
|
```ruby
|
|
269
|
-
#
|
|
270
|
-
|
|
271
|
-
ui =
|
|
111
|
+
# _example_modal_component.arb
|
|
112
|
+
modal_component = ModalComponent.new(open: false, id: :user_profile_modal)
|
|
113
|
+
ui = modal_component.ui
|
|
114
|
+
|
|
115
|
+
button "Open Modal Outer", tailmix_trigger_for(:user_profile_modal, :toggle_open)
|
|
272
116
|
|
|
273
|
-
div ui.container do
|
|
117
|
+
div ui.container.component do
|
|
274
118
|
button "Open Modal", ui.open_button
|
|
275
119
|
|
|
276
120
|
div ui.base do
|
|
277
121
|
div ui.overlay
|
|
122
|
+
|
|
278
123
|
div ui.panel do
|
|
279
|
-
|
|
280
|
-
|
|
124
|
+
div ui.title do
|
|
125
|
+
h3 "Modal Title"
|
|
126
|
+
button ui.close_button do
|
|
127
|
+
text_node "✖"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
281
130
|
|
|
282
131
|
div ui.body do
|
|
283
|
-
"
|
|
132
|
+
para "This is the main content of the modal. It's powered by the new Tailmix Runtime!"
|
|
284
133
|
end
|
|
285
134
|
end
|
|
286
135
|
end
|
|
287
136
|
end
|
|
288
137
|
```
|
|
289
138
|
|
|
290
|
-
-----
|
|
291
|
-
|
|
292
|
-
## Developer Tools
|
|
293
|
-
|
|
294
|
-
Use the `.dev` namespace to introspect your components right from the console.
|
|
295
|
-
|
|
296
|
-
* **`YourComponent.dev.docs`**: Displays full documentation for all variants and actions.
|
|
297
|
-
* **`YourComponent.dev.stimulus.scaffold`**: Generates a Stimulus controller template.
|
|
298
139
|
|
|
299
140
|
## Contributing
|
|
300
141
|
|
|
@@ -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
|
+
}
|