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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +76 -145
  4. data/app/javascript/tailmix/runtime/action_dispatcher.js +132 -0
  5. data/app/javascript/tailmix/runtime/component.js +130 -0
  6. data/app/javascript/tailmix/runtime/index.js +130 -0
  7. data/app/javascript/tailmix/runtime/plugins.js +35 -0
  8. data/app/javascript/tailmix/runtime/updater.js +140 -0
  9. data/docs/01_getting_started.md +39 -0
  10. data/examples/_modal_component.arb +17 -25
  11. data/examples/button.rb +81 -0
  12. data/examples/helpers.rb +25 -0
  13. data/examples/modal_component.rb +32 -164
  14. data/lib/tailmix/definition/builders/action_builder.rb +39 -0
  15. data/lib/tailmix/definition/{contexts → builders}/actions/element_builder.rb +1 -1
  16. data/lib/tailmix/definition/{contexts → builders}/attribute_builder.rb +1 -1
  17. data/lib/tailmix/definition/builders/component_builder.rb +81 -0
  18. data/lib/tailmix/definition/{contexts → builders}/dimension_builder.rb +2 -2
  19. data/lib/tailmix/definition/builders/element_builder.rb +83 -0
  20. data/lib/tailmix/definition/builders/reactor_builder.rb +43 -0
  21. data/lib/tailmix/definition/builders/rule_builder.rb +58 -0
  22. data/lib/tailmix/definition/builders/state_builder.rb +21 -0
  23. data/lib/tailmix/definition/{contexts → builders}/variant_builder.rb +17 -2
  24. data/lib/tailmix/definition/context_builder.rb +19 -13
  25. data/lib/tailmix/definition/merger.rb +2 -1
  26. data/lib/tailmix/definition/payload_proxy.rb +16 -0
  27. data/lib/tailmix/definition/result.rb +17 -18
  28. data/lib/tailmix/dev/docs.rb +32 -7
  29. data/lib/tailmix/dev/tools.rb +0 -5
  30. data/lib/tailmix/dsl.rb +3 -5
  31. data/lib/tailmix/engine.rb +11 -1
  32. data/lib/tailmix/html/attributes.rb +22 -12
  33. data/lib/tailmix/middleware/registry_cleaner.rb +17 -0
  34. data/lib/tailmix/registry.rb +37 -0
  35. data/lib/tailmix/runtime/action_proxy.rb +29 -0
  36. data/lib/tailmix/runtime/attribute_builder.rb +102 -0
  37. data/lib/tailmix/runtime/attribute_cache.rb +23 -0
  38. data/lib/tailmix/runtime/context.rb +51 -47
  39. data/lib/tailmix/runtime/facade_builder.rb +8 -5
  40. data/lib/tailmix/runtime/state.rb +36 -0
  41. data/lib/tailmix/runtime/state_proxy.rb +34 -0
  42. data/lib/tailmix/runtime.rb +0 -1
  43. data/lib/tailmix/version.rb +1 -1
  44. data/lib/tailmix/view_helpers.rb +49 -0
  45. data/lib/tailmix.rb +4 -2
  46. metadata +34 -21
  47. data/app/javascript/tailmix/finder.js +0 -15
  48. data/app/javascript/tailmix/index.js +0 -7
  49. data/app/javascript/tailmix/mutator.js +0 -28
  50. data/app/javascript/tailmix/runner.js +0 -7
  51. data/app/javascript/tailmix/stimulus_adapter.js +0 -37
  52. data/lib/tailmix/definition/contexts/action_builder.rb +0 -31
  53. data/lib/tailmix/definition/contexts/element_builder.rb +0 -41
  54. data/lib/tailmix/definition/contexts/stimulus_builder.rb +0 -101
  55. data/lib/tailmix/dev/stimulus_generator.rb +0 -124
  56. data/lib/tailmix/runtime/stimulus/compiler.rb +0 -59
@@ -0,0 +1,130 @@
1
+ import {Component} from './component';
2
+ import {PluginManager} from './plugins';
3
+
4
+ /**
5
+ * The Tailmix global object that manages the lifecycle of components and plugins.
6
+ * It provides methods for starting the application, hydration of components,
7
+ */
8
+ const Tailmix = {
9
+ _namedInstances: {},
10
+ _components: new Map(),
11
+ _definitions: {},
12
+ _pluginManager: new PluginManager(),
13
+ _observer: null,
14
+
15
+ /**
16
+ * Starts the Tailmix application by loading component definitions and initializing components.
17
+ */
18
+ start() {
19
+ this.loadDefinitions();
20
+ this._namedInstances = {};
21
+ this._components.clear();
22
+
23
+ this.hydrate(document.body);
24
+ this.observeChanges();
25
+ },
26
+
27
+ /**
28
+ * Hydrate components within the specified root element.
29
+ * @param rootElement
30
+ */
31
+ hydrate(rootElement) {
32
+ const componentElements = rootElement.querySelectorAll('[data-tailmix-component]');
33
+ componentElements.forEach(element => {
34
+ if (this._components.has(element)) return;
35
+
36
+ const componentName = element.dataset.tailmixComponent;
37
+ const definition = this._definitions[componentName];
38
+ if (!definition) { /* ... */
39
+ return;
40
+ }
41
+
42
+ const component = new Component(element, definition, this._pluginManager);
43
+ this._components.set(element, component);
44
+
45
+ const componentId = element.dataset.tailmixId;
46
+ if (componentId) {
47
+ this._namedInstances[componentId] = component;
48
+ }
49
+ });
50
+
51
+ // External trigger binding
52
+ const triggerElements = rootElement.querySelectorAll('[data-tailmix-trigger-for]');
53
+ triggerElements.forEach(element => {
54
+ const targetId = element.dataset.tailmixTriggerFor;
55
+ const targetComponent = this._namedInstances[targetId];
56
+ if (targetComponent) {
57
+ targetComponent.dispatcher.bindAction(element);
58
+ }
59
+ });
60
+ },
61
+
62
+ /**
63
+ * Loads and parses the component definitions from a script tag with the attribute `data-tailmix-definitions`.
64
+ * If a valid JSON content is found, it assigns it to the `definitions` variable.
65
+ * Logs an error message to the console in case of parsing failure.
66
+ *
67
+ * @return {void} Does not return a value.
68
+ */
69
+ loadDefinitions() {
70
+ const definitionsTag = document.querySelector('script[data-tailmix-definitions]');
71
+ if (definitionsTag) {
72
+ try {
73
+ this._definitions = JSON.parse(definitionsTag.textContent);
74
+ } catch (e) {
75
+ console.error("Tailmix: Failed to parse component definitions.", e);
76
+ }
77
+ }
78
+ },
79
+
80
+ /**
81
+ * Retrieves a component associated with the nearest ancestor element
82
+ * that has the `data-tailmix-component` attribute.
83
+ *
84
+ * @param {Element} element - The DOM element from which the component search begins.
85
+ * @return {*} The component associated with the identified ancestor element, or `undefined` if no component is found.
86
+ */
87
+ getComponent(element) {
88
+ const root = element.closest('[data-tailmix-component]');
89
+ return root ? this._components.get(root) : undefined;
90
+ },
91
+
92
+ registerPlugin(plugin) {
93
+ this._pluginManager.register(plugin);
94
+ },
95
+
96
+ /**
97
+ * Observes changes in the DOM and rehydrates components when they are added or modified.
98
+ */
99
+ observeChanges() {
100
+ if (this._observer) this._observer.disconnect();
101
+
102
+ this._observer = new MutationObserver(mutations => {
103
+ for (const mutation of mutations) {
104
+ if (mutation.type === 'childList') {
105
+ mutation.addedNodes.forEach(node => {
106
+ if (node.nodeType === Node.ELEMENT_NODE) {
107
+ // If the component itself was added
108
+ if (node.matches('[data-tailmix-component]')) {
109
+ this.hydrate(node);
110
+ }
111
+ // If a parent was added, which may contain components
112
+ this.hydrate(node);
113
+ }
114
+ });
115
+ }
116
+ }
117
+ });
118
+
119
+ this._observer.observe(document.body, {childList: true, subtree: true});
120
+ }
121
+ };
122
+
123
+ // Initialize Tailmix on page load
124
+ document.addEventListener("turbo:load", () => {
125
+ console.log("Tailmix starting...");
126
+ Tailmix.start();
127
+ });
128
+
129
+
130
+ export default Tailmix;
@@ -0,0 +1,35 @@
1
+ class PluginManager {
2
+ constructor() {
3
+ this.plugins = [];
4
+ }
5
+
6
+ /**
7
+ * Registers a plugin with the plugin manager.
8
+ * @param plugin
9
+ */
10
+ register(plugin) {
11
+ if (!plugin.name) {
12
+ console.error("Tailmix Plugin Error: a plugin must have a name.", plugin);
13
+ return;
14
+ }
15
+ this.plugins.push(plugin);
16
+ }
17
+
18
+ /**
19
+ * Connects a component to all registered plugins.
20
+ * @param component
21
+ */
22
+ connect(component) {
23
+ this.plugins.forEach(plugin => {
24
+ if (typeof plugin.connect === 'function') {
25
+ const pluginConfig = component.definition.plugins?.[plugin.name];
26
+ if (pluginConfig) {
27
+ plugin.connect(component.api, pluginConfig);
28
+ }
29
+ }
30
+ });
31
+ }
32
+ }
33
+
34
+ export {PluginManager};
35
+
@@ -0,0 +1,140 @@
1
+ export class Updater {
2
+ constructor(component) {
3
+ this.component = component;
4
+ this.definition = component.definition;
5
+ }
6
+
7
+ run(newState, oldState) {
8
+ for (const elementName in this.definition.elements) {
9
+ const elementNode = this.component.elements[elementName];
10
+ const elementDef = this.definition.elements[elementName];
11
+
12
+ if (!elementNode || !elementDef) continue;
13
+
14
+ this.updateElement(elementNode, elementDef, newState, oldState);
15
+ }
16
+ }
17
+
18
+ updateElement(elementNode, elementDef, newState, oldState) {
19
+ this.updateClasses(elementNode, elementDef, newState);
20
+ this.updateAttributes(elementNode, elementDef, newState);
21
+ this.updateContent(elementNode, elementDef, newState);
22
+ }
23
+
24
+ updateClasses(elementNode, elementDef, newState) {
25
+ const targetClasses = this.calculateTargetClasses(elementDef, newState);
26
+ const currentClasses = new Set(elementNode.classList);
27
+
28
+ // We compare current classes with target classes and apply the difference.
29
+ targetClasses.forEach(cls => {
30
+ if (!currentClasses.has(cls)) {
31
+ elementNode.classList.add(cls);
32
+ }
33
+ });
34
+
35
+ currentClasses.forEach(cls => {
36
+ if (!targetClasses.has(cls)) {
37
+ // We avoid removing base classes that were originally in HTML.
38
+ // (This is a simple heuristic; it can be improved if base classes are in the definition)
39
+ const isBaseClass = !this.isVariantClass(elementDef, cls);
40
+ if (!targetClasses.has(cls) && !isBaseClass) {
41
+ elementNode.classList.remove(cls);
42
+ }
43
+ }
44
+ });
45
+ }
46
+
47
+ updateAttributes(elementNode, elementDef, newState) {
48
+ if (elementDef.attribute_bindings) {
49
+ for (const attrName in elementDef.attribute_bindings) {
50
+ if (["text", "html"].includes(attrName)) continue;
51
+
52
+ const stateKey = elementDef.attribute_bindings[attrName];
53
+ const newValue = newState[stateKey];
54
+
55
+ // We update the attribute only if it has changed.
56
+ if (elementNode.getAttribute(attrName) !== newValue) {
57
+ if (newValue === null || newValue === undefined) {
58
+ elementNode.removeAttribute(attrName);
59
+ } else {
60
+ elementNode.setAttribute(attrName, newValue);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ updateContent(elementNode, elementDef, newState) {
68
+ const bindings = elementDef.attribute_bindings;
69
+ if (!bindings) return;
70
+
71
+ // Обработка `bind :text`
72
+ const textStateKey = bindings.text;
73
+ if (textStateKey !== undefined) {
74
+ const newText = newState[textStateKey] || '';
75
+ if (elementNode.textContent !== newText) {
76
+ elementNode.textContent = newText;
77
+ }
78
+ }
79
+
80
+ // Обработка `bind :html`
81
+ const htmlStateKey = bindings.html;
82
+ if (htmlStateKey !== undefined) {
83
+ const newHtml = newState[htmlStateKey] || '';
84
+ if (elementNode.innerHTML !== newHtml) {
85
+ elementNode.innerHTML = newHtml;
86
+ }
87
+ }
88
+ }
89
+
90
+ calculateTargetClasses(elementDef, state) {
91
+ const classes = new Set();
92
+
93
+ // 1. We add base classes (if any are in the definition).
94
+ // (We skip this for now, as they are already in the HTML)
95
+
96
+ // 2. We apply classes from active variants (dimensions).
97
+ if (elementDef.dimensions) {
98
+ for (const dimName in elementDef.dimensions) {
99
+ const dimDef = elementDef.dimensions[dimName];
100
+ const stateValue = state[dimName] !== undefined ? state[dimName] : dimDef.default;
101
+
102
+ const variantDef = dimDef.variants?.[stateValue];
103
+ if (variantDef?.classes) {
104
+ variantDef.classes.forEach(cls => classes.add(cls));
105
+ }
106
+ }
107
+ }
108
+
109
+ // 3. We apply classes from active compound variants.
110
+ if (elementDef.compound_variants) {
111
+ elementDef.compound_variants.forEach(cv => {
112
+ const conditions = cv.on;
113
+ const modifications = cv.modifications;
114
+
115
+ const isMatch = Object.entries(conditions).every(([key, value]) => {
116
+ return state[key] === value;
117
+ });
118
+
119
+ if (isMatch && modifications.classes) {
120
+ modifications.classes.forEach(cls => classes.add(cls));
121
+ }
122
+ });
123
+ }
124
+
125
+ return classes;
126
+ }
127
+
128
+ isVariantClass(elementDef, className) {
129
+ if (elementDef.dimensions) {
130
+ for (const dimName in elementDef.dimensions) {
131
+ const dim = elementDef.dimensions[dimName];
132
+ for (const variantName in dim.variants) {
133
+ if (dim.variants[variantName].classes?.includes(className)) return true;
134
+ }
135
+ }
136
+ }
137
+ // ... a check can also be added for compound_variants
138
+ return false;
139
+ }
140
+ }
@@ -0,0 +1,39 @@
1
+ # Getting Started with Tailmix
2
+
3
+ Welcome to Tailmix! This guide will walk you through installing the gem, setting up your project, and creating your first component.
4
+
5
+ ## Philosophy
6
+
7
+ Tailmix is built on the idea of **co-location**. Instead of defining component styles in separate CSS, SCSS, or CSS-in-JS files, you define them directly within the Ruby class that represents your component. This creates self-contained, highly reusable, and easily maintainable UI components.
8
+
9
+ The core of Tailmix is a powerful and expressive **DSL (Domain-Specific Language)** that allows you to declaratively define how a component should look based on its properties or "variants".
10
+
11
+ ## Installation
12
+
13
+ Getting started with Tailmix involves two simple steps: adding the gem and installing the JavaScript bridge.
14
+
15
+ ### 1. Add the Gem
16
+
17
+ Add `tailmix` to your application's Gemfile:
18
+
19
+ ```bash
20
+ bundle add tailmix
21
+ ```
22
+
23
+ ### 2. Install JavaScript Assets
24
+
25
+ Run the installer to set up the necessary JavaScript files for the client-side bridge (used by actions).
26
+
27
+ ```bash
28
+ bin/rails g tailmix:install
29
+ ```
30
+
31
+ This command will add tailmix to your importmap.rb and ensure its JavaScript is available in your application.
32
+
33
+ ### Your First Component: A Badge
34
+ Let's create a simple BadgeComponent to see Tailmix in action.
35
+
36
+ #### 1. Define the Component Class
37
+
38
+
39
+
@@ -1,36 +1,28 @@
1
1
  # Arbre view:
2
2
 
3
- modal = SuperModalComponent.new(size: :lg, open: true)
3
+ modal_component = ModalComponent.new(open: false, id: :user_profile_modal)
4
+ ui = modal_component.ui
4
5
 
5
- # modal.lock!
6
- ui = modal.ui
6
+ button "Open Modal Outer", tailmix_trigger_for(:user_profile_modal, :toggle_open)
7
7
 
8
- div ui.base do
9
- div ui.overlay
10
- div ui.open do
11
- "open"
12
- end
8
+ div ui.container.component do
9
+ button "Open Modal", ui.open_button
13
10
 
14
- div ui.panel do
15
- button ui.close_button do
16
- span "Close"
17
- end
11
+ div ui.base do
12
+ div ui.overlay
18
13
 
19
- h3 ui.title do
20
- "Payment Successful"
21
- end
22
-
23
- div ui.body do
24
- "Your payment has been successfully submitted. We’ve sent you an email with all of the details of your order."
25
- end
26
-
27
- div ui.footer do
28
- button ui.confirm_button do
29
- span "Confirm Purchase"
30
- div ui.spinner do
31
- span "Loading..."
14
+ div ui.panel do
15
+ div ui.title do
16
+ h3 "Modal Title"
17
+ button ui.close_button do
18
+ text_node "✖"
32
19
  end
33
20
  end
21
+
22
+ div ui.body do
23
+ para "This is the main content of the modal. It's powered by the new Tailmix Runtime!"
24
+ end
34
25
  end
35
26
  end
36
27
  end
28
+
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/tailmix"
4
+ require_relative "helpers"
5
+
6
+ class Button
7
+ include Tailmix
8
+
9
+ tailmix do
10
+ element :button do
11
+ dimension :intent, default: :primary do
12
+ variant :primary, "bg-blue-500"
13
+ variant :danger, "bg-red-500"
14
+ end
15
+
16
+ dimension :size, default: :medium do
17
+ variant :medium, "p-4"
18
+ variant :small, "p-2"
19
+ end
20
+
21
+ compound_variant on: { intent: :danger, size: :small } do
22
+ classes "font-bold"
23
+ data special: "true"
24
+ end
25
+ end
26
+ end
27
+
28
+ attr_reader :ui
29
+ def initialize(intent: :primary, size: :medium)
30
+ @ui = tailmix(intent: intent, size: size)
31
+ end
32
+ end
33
+
34
+
35
+ puts "-" * 100
36
+ puts Button.dev.docs
37
+
38
+ # == Tailmix Docs for Button ==
39
+ # Signature: `initialize(intent: :primary, size: :medium)`
40
+ #
41
+ # Dimensions:
42
+ # - intent (default: :primary)
43
+ # - :primary:
44
+ # - classes : "bg-blue-500"
45
+ # - :danger:
46
+ # - classes : "bg-red-500"
47
+ # - size (default: :medium)
48
+ # - :medium:
49
+ # - classes : "p-4"
50
+ # - :small:
51
+ # - classes : "p-2"
52
+ #
53
+ # Compound Variants:
54
+ # - on element `:button`:
55
+ # - on: { intent: :danger, size: :small }
56
+ # - classes : "font-bold"
57
+ # - data: {:special=>"true"}
58
+ #
59
+ # No actions defined.
60
+ #
61
+ # button
62
+ # classes :-> bg-red-500 p-4
63
+ # data :-> {}
64
+ # aria :-> {}
65
+ # tailmix :-> {"data-tailmix-button"=>"intent:danger,size:medium"}
66
+
67
+ not_compound_component = Button.new(intent: :danger, size: :medium)
68
+ print_component_ui(not_compound_component)
69
+ # button
70
+ # classes :-> bg-red-500 p-4
71
+ # data :-> {}
72
+ # aria :-> {}
73
+ # tailmix :-> {"data-tailmix-button"=>"intent:danger,size:medium"}
74
+
75
+ compound_component = Button.new(intent: :danger, size: :small)
76
+ print_component_ui(compound_component)
77
+ # button
78
+ # classes :-> bg-red-500 p-2 font-bold
79
+ # data :-> {"data-special"=>"true"}
80
+ # aria :-> {}
81
+ # tailmix :-> {"data-tailmix-button"=>"intent:danger,size:small"}
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ def stringify_keys(obj)
4
+ case obj
5
+ when Hash
6
+ obj.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
7
+ when Array
8
+ obj.map { |v| stringify_keys(v) }
9
+ else
10
+ obj
11
+ end
12
+ end
13
+
14
+ def print_component_ui(component_instance)
15
+ component_instance.class.dev.elements.each do |element_name|
16
+ element = component_instance.ui.send(element_name)
17
+ puts element_name
18
+ element.each_attribute do |attribute|
19
+ attribute.each do |key, value|
20
+ puts " #{key} :-> #{value}"
21
+ end
22
+ puts ""
23
+ end
24
+ end
25
+ end