tramway 3.0.1 → 3.0.3

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -0
  3. data/app/assets/javascripts/tramway/table_row_preview_controller.js +100 -6
  4. data/app/components/tailwind_component.rb +3 -3
  5. data/app/components/tramway/form/builder.rb +48 -7
  6. data/app/components/tramway/form/checkbox_component.html.haml +2 -2
  7. data/app/components/tramway/form/checkbox_component.rb +14 -0
  8. data/app/components/tramway/form/date_field_component.html.haml +1 -1
  9. data/app/components/tramway/form/datetime_field_component.html.haml +1 -1
  10. data/app/components/tramway/form/label_component.html.haml +1 -1
  11. data/app/components/tramway/form/label_component.rb +2 -1
  12. data/app/components/tramway/form/number_field_component.html.haml +1 -1
  13. data/app/components/tramway/form/select_component.html.haml +1 -1
  14. data/app/components/tramway/form/text_area_component.html.haml +1 -1
  15. data/app/components/tramway/form/text_field_component.html.haml +1 -1
  16. data/app/components/tramway/form/time_field_component.html.haml +1 -1
  17. data/app/components/tramway/form/tramway_select_component.html.haml +1 -1
  18. data/app/components/tramway/navbar_component.html.haml +80 -31
  19. data/app/components/tramway/table/row/preview_component.html.haml +18 -2
  20. data/app/components/tramway/table/row/preview_component.rb +2 -1
  21. data/app/components/tramway/table/row_component.html.haml +9 -2
  22. data/app/components/tramway/table/row_component.rb +17 -0
  23. data/app/components/tramway/table_component.html.haml +0 -2
  24. data/config/tailwind.config.js +21 -6
  25. data/docs/AGENTS.md +109 -1
  26. data/lib/generators/tramway/install/install_generator.rb +77 -2
  27. data/lib/tramway/helpers/views_helper.rb +7 -6
  28. data/lib/tramway/utils/field.rb +6 -2
  29. data/lib/tramway/version.rb +1 -1
  30. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56f897f311d05eaa3d077f0f02a2b28d116bd2bbdfb7f460b05ab3d481b5b57e
4
- data.tar.gz: 4dec440f2451c89ff6b1bd10c93d79ff9a12a99c528129bb19b267bd7f59b95b
3
+ metadata.gz: 1da050191fdef1006bf62ea51fe4d11465eb809c273dc681f55f8937243e53fa
4
+ data.tar.gz: bce78a50e8ce1fdc3b8a126f3797841c5883c3b1810c370abfb00ba9609cc1e9
5
5
  SHA512:
6
- metadata.gz: 732effab54397590ae4320802c803c70fe648765ff200ce0e994e396c13eabed2501361c645a7e2a55019a81cdba4987e26615c7c71e5aa05ab1add573188d06
7
- data.tar.gz: 77a23247e58a804ff62e2c4a1071db86aae1d746832268d39da9e70fcab5e276264b16d60125da72be38ff40e4f2a01c182cd453b56c66eb02766fe649f65f7c
6
+ metadata.gz: 6d7c33ecf3b2544fab033a332e303dfd1cb23fa40dfb4adf8201a702be929ad702c96e5246055d59c88fe4c67e74d857590989ad581b61f398be05fd1ebdaac3
7
+ data.tar.gz: 88a49d7eedda98eb23455e4042b21ce67fc8972a0509e780e8742937b73705ae9cfa6d14fb236b3a1da1ef478e384cba009f776c1711bccd5c98d104ae20d32e
data/README.md CHANGED
@@ -987,6 +987,20 @@ Use the optional `href:` argument on `tramway_row` to turn an entire row into a
987
987
  <% end %>
988
988
  ```
989
989
 
990
+ `tramway_row` also accepts `preview:`. By default preview is enabled (`true`) for non-linked rows and renders the mobile slide-up
991
+ details panel. Pass `preview: false` when you want a row without the preview panel.
992
+
993
+ ```erb
994
+ <%= tramway_row preview: false do %>
995
+ <%= tramway_cell do %>
996
+ <%= user.name %>
997
+ <% end %>
998
+ <%= tramway_cell do %>
999
+ <%= user.email %>
1000
+ <% end %>
1001
+ <% end %>
1002
+ ```
1003
+
990
1004
  When you render a header you can either pass the `headers:` array, as in the examples above, or render custom header content in
991
1005
  the block. `tramway_header` uses the length of the `headers` array to build the grid if the array is present.
992
1006
  If you omit the array and provide custom content, pass the `columns:` argument so the component knows how many grid columns to
@@ -1227,6 +1241,17 @@ In case you need to use Stimulus `change` action with Tramway Select
1227
1241
  <% end %>
1228
1242
  ```
1229
1243
 
1244
+ Remote form example:
1245
+
1246
+ ```erb
1247
+ <%= tramway_form_for @user, remote: true do |f| %>
1248
+ <%= f.text_field :name %>
1249
+ <%= f.email_field :email %>
1250
+ <% end %>
1251
+ ```
1252
+
1253
+ With `remote: true`, Tramway submits the form on each input `change` via inline JavaScript; no additional controller setup is required.
1254
+
1230
1255
  ### Tailwind-styled pagination for Kaminari
1231
1256
 
1232
1257
  Tramway uses [Tailwind](https://tailwindcss.com/) by default. It has tailwind-styled pagination for [kaminari](https://github.com/kaminari/kaminari).
@@ -3,10 +3,25 @@ import { Controller } from "@hotwired/stimulus";
3
3
  export default class TableRowPreview extends Controller {
4
4
  connect() {
5
5
  this.items = JSON.parse(this.element.dataset.items || '{}');
6
+ this.attachSwipeGesture();
7
+ }
8
+
9
+ disconnect() {
10
+ this.detachSwipeGesture();
6
11
  }
7
12
 
8
13
  toggle() {
9
- const rollUp = document.getElementById("roll-up");
14
+ const rollUp = this.rollUpElement();
15
+ if (!rollUp) return;
16
+
17
+ rollUp.classList.remove("animate-roll-down");
18
+ rollUp.classList.add("animate-roll-up");
19
+
20
+ // Show pre-rendered preview content that is hidden by default.
21
+ if (Object.keys(this.items).length === 0) {
22
+ rollUp.classList.remove("hidden");
23
+ return;
24
+ }
10
25
 
11
26
  const existingTable = rollUp.querySelector(".div-table");
12
27
  if (existingTable) {
@@ -18,8 +33,6 @@ export default class TableRowPreview extends Controller {
18
33
  existingTitle.remove();
19
34
  }
20
35
 
21
- if (Object.keys(this.items).length === 0) return;
22
-
23
36
  const titleText = document.createElement("h3");
24
37
 
25
38
  titleText.classList.add("text-xl");
@@ -33,12 +46,93 @@ export default class TableRowPreview extends Controller {
33
46
  rollUp.insertAdjacentElement('afterbegin', table);
34
47
  rollUp.insertAdjacentElement('afterbegin', titleText);
35
48
 
36
- rollUp.classList.toggle("hidden");
49
+ rollUp.classList.remove("hidden");
37
50
  }
38
51
 
39
52
  close() {
40
- const rollUp = document.getElementById("roll-up");
41
- rollUp.classList.add("hidden");
53
+ const rollUp = this.rollUpElement();
54
+ if (!rollUp) return;
55
+
56
+ this.resetDragStyles(rollUp);
57
+ rollUp.classList.remove("animate-roll-up");
58
+ rollUp.classList.add("animate-roll-down");
59
+
60
+ rollUp.addEventListener("animationend", () => {
61
+ rollUp.classList.add("hidden");
62
+ rollUp.classList.remove("animate-roll-down");
63
+ rollUp.classList.add("animate-roll-up");
64
+ }, { once: true });
65
+ }
66
+
67
+ rollUpElement() {
68
+ if (this.element.id === "roll-up") return this.element;
69
+
70
+ return this.element.previousElementSibling || document.getElementById("roll-up");
71
+ }
72
+
73
+ attachSwipeGesture() {
74
+ if (this.element.id !== "roll-up") return;
75
+
76
+ this.startY = null;
77
+ this.startX = null;
78
+ this.currentDeltaY = 0;
79
+
80
+ this.onTouchStart = (event) => {
81
+ if (this.element.classList.contains("hidden")) return;
82
+ if (event.touches.length !== 1) return;
83
+
84
+ this.startY = event.touches[0].clientY;
85
+ this.startX = event.touches[0].clientX;
86
+ this.currentDeltaY = 0;
87
+ this.element.style.transition = "none";
88
+ };
89
+
90
+ this.onTouchMove = (event) => {
91
+ if (this.startY === null || event.touches.length !== 1) return;
92
+
93
+ const deltaY = event.touches[0].clientY - this.startY;
94
+ const deltaX = Math.abs(event.touches[0].clientX - this.startX);
95
+ if (deltaY <= 0 || deltaY <= deltaX) return;
96
+
97
+ this.currentDeltaY = deltaY;
98
+ this.element.style.transform = `translateY(${deltaY}px)`;
99
+ event.preventDefault();
100
+ };
101
+
102
+ this.onTouchEnd = () => {
103
+ if (this.startY === null) return;
104
+
105
+ const shouldClose = this.currentDeltaY > 80;
106
+ this.startY = null;
107
+ this.startX = null;
108
+
109
+ if (shouldClose) {
110
+ this.close();
111
+ return;
112
+ }
113
+
114
+ this.resetDragStyles(this.element);
115
+ };
116
+
117
+ this.element.addEventListener("touchstart", this.onTouchStart, { passive: true });
118
+ this.element.addEventListener("touchmove", this.onTouchMove, { passive: false });
119
+ this.element.addEventListener("touchend", this.onTouchEnd);
120
+ this.element.addEventListener("touchcancel", this.onTouchEnd);
121
+ }
122
+
123
+ detachSwipeGesture() {
124
+ if (this.element.id !== "roll-up") return;
125
+ if (!this.onTouchStart) return;
126
+
127
+ this.element.removeEventListener("touchstart", this.onTouchStart);
128
+ this.element.removeEventListener("touchmove", this.onTouchMove);
129
+ this.element.removeEventListener("touchend", this.onTouchEnd);
130
+ this.element.removeEventListener("touchcancel", this.onTouchEnd);
131
+ }
132
+
133
+ resetDragStyles(element) {
134
+ element.style.transition = "";
135
+ element.style.transform = "";
42
136
  }
43
137
 
44
138
  createTable(items) {
@@ -19,7 +19,7 @@ class TailwindComponent < Tramway::BaseComponent
19
19
  file_button: 'text-sm px-3 py-1',
20
20
  submit_button: 'text-sm px-3 py-1',
21
21
  tramway_select_input: 'text-sm px-2 py-1 h-10',
22
- checkbox_input: 'h-4 w-4'
22
+ checkbox_input: 'min-h-4 min-w-4'
23
23
  },
24
24
  medium: {
25
25
  text_input: 'text-base px-3 py-2',
@@ -27,7 +27,7 @@ class TailwindComponent < Tramway::BaseComponent
27
27
  file_button: 'text-base px-4 py-2',
28
28
  submit_button: 'text-base px-4 py-2',
29
29
  tramway_select_input: 'text-base px-2 py-1 h-12',
30
- checkbox_input: 'h-5 w-5'
30
+ checkbox_input: 'min-h-5 min-w-5'
31
31
  },
32
32
  large: {
33
33
  text_input: 'text-xl px-4 py-3',
@@ -35,7 +35,7 @@ class TailwindComponent < Tramway::BaseComponent
35
35
  file_button: 'text-xl px-5 py-3',
36
36
  submit_button: 'text-xl px-5 py-3',
37
37
  tramway_select_input: 'text-xl px-3 py-2 h-15',
38
- checkbox_input: 'h-6 w-6'
38
+ checkbox_input: 'min-h-6 min-w-6'
39
39
  }
40
40
  }.freeze
41
41
 
@@ -12,13 +12,19 @@ module Tramway
12
12
 
13
13
  def initialize(object_name, object, template, options)
14
14
  @horizontal = options[:horizontal] || false
15
+ @remote = options[:remote_submit] || false
15
16
 
16
17
  options.merge!(class: [options[:class], 'flex flex-row items-center gap-2'].compact.join(' ')) if @horizontal
17
18
 
18
- super
19
+ @form_object_class = options[:form_object_class]
20
+
21
+ if form_object(object)
22
+ super(object_name, form_object(object), template, options)
23
+ else
24
+ super
25
+ end
19
26
 
20
27
  @form_size = options[:size] || options['size'] || :medium
21
- @form_object_class = options[:form_object_class]
22
28
  end
23
29
 
24
30
  def common_field(component_name, input_method, attribute, **options, &)
@@ -137,20 +143,55 @@ module Tramway
137
143
  unbound_method.bind(self)
138
144
  end
139
145
 
140
- def form_object
141
- @form_object_class&.new object
146
+ def form_object(obj = nil)
147
+ return obj if obj.is_a?(Tramway::BaseForm)
148
+ return object if object.is_a?(Tramway::BaseForm)
149
+
150
+ @form_object_class&.new(obj || object)
142
151
  end
143
152
 
144
153
  def get_value(attribute, options)
145
- options[:value] || form_object&.public_send(attribute).presence || object.presence&.public_send(attribute)
154
+ return options[:value] if options.key?(:value)
155
+
156
+ form_obj = form_object
157
+ form_value = form_object_value(form_obj, attribute)
158
+ return form_value unless form_value.nil?
159
+
160
+ ensure_object_responds!(attribute, form_obj)
161
+ object_value(attribute)
162
+ end
163
+
164
+ def form_object_value(form_obj, attribute)
165
+ return if form_obj.blank?
166
+
167
+ form_obj.public_send(attribute)
168
+ end
169
+
170
+ def ensure_object_responds!(attribute, form_obj)
171
+ return unless object.present? && !object.respond_to?(attribute)
172
+
173
+ form_object_part = form_obj.present? ? "#{form_obj.class} or " : ''
174
+ message = "Neither form object nor object respond to #{attribute}. " \
175
+ "You should define #{attribute} method in #{form_object_part}#{object.class}"
176
+
177
+ raise ArgumentError, message
178
+ end
179
+
180
+ def object_value(attribute)
181
+ return if object.blank?
182
+
183
+ object.public_send(attribute)
146
184
  end
147
185
 
148
186
  def default_options(attribute, options)
187
+ options.merge!(horizontal: true) if @horizontal
188
+ options.merge!(onchange: 'this.form.requestSubmit()') if @remote
189
+
149
190
  {
150
191
  attribute:,
151
192
  label: label_build(attribute, options),
152
- for: for_id(attribute),
153
- options: options.merge(horizontal: @horizontal),
193
+ for: options[:id].presence || for_id(attribute),
194
+ options: options,
154
195
  size: form_size
155
196
  }
156
197
  end
@@ -1,7 +1,7 @@
1
- .flex.items-start.gap-2{ class: default_container_classes }
1
+ .flex.items-center.gap-2.cursor-pointer{ class: default_container_classes }
2
2
  - classes = "#{size_class(:checkbox_input)} #{checkbox_base_classes}"
3
3
  = @input.call @attribute, **@options.merge(class: classes)
4
4
  - if @label
5
5
  %div
6
- = component('tramway/form/label', for: @for) do
6
+ = component('tramway/form/label', for: @for, options: { class: label_classes }) do
7
7
  = @label
@@ -4,6 +4,20 @@ module Tramway
4
4
  module Form
5
5
  # Tailwind-styled checkbox field
6
6
  class CheckboxComponent < TailwindComponent
7
+ def label_classes
8
+ default_classes = 'cursor-pointer mb-0'
9
+
10
+ case size
11
+ when :small
12
+ default_classes += ' text-sm'
13
+ when :medium
14
+ default_classes += ' text-base'
15
+ when :large
16
+ default_classes += ' text-lg'
17
+ end
18
+
19
+ default_classes
20
+ end
7
21
  end
8
22
  end
9
23
  end
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,2 +1,2 @@
1
- %label{ for: @for, class: form_label_classes }
1
+ %label{ for: @for, class: "#{form_label_classes} #{options[:class]}" }
2
2
  = content
@@ -5,10 +5,11 @@ module Tramway
5
5
  # Form label for all tailwind-styled forms
6
6
  class LabelComponent < Tramway::BaseComponent
7
7
  option :for
8
+ option :options, optional: true, default: -> { {} }
8
9
 
9
10
  def form_label_classes
10
11
  theme_classes(
11
- classic: 'block text-sm font-semibold mb-2 text-white'
12
+ classic: 'block font-semibold text-white'
12
13
  )
13
14
  end
14
15
  end
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-2') do
4
4
  = @label
5
5
  - classes = "#{size_class(:select_input)} #{select_base_classes}"
6
6
  = @input.call(@attribute, @collection, { selected: @value }, @options.merge(class: classes))
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  .relative{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  %div{ role: :combobox, data: tramway_select_hash, id: "#{@for}_tramway_select", class: tramway_select_classes }
6
6
  - classes = "#{size_class(:tramway_select_input)} #{select_base_classes}"
@@ -36,42 +36,91 @@
36
36
 
37
37
  :javascript
38
38
  (() => {
39
- const button = document.getElementById('mobile-menu-button');
40
- const closeButton = document.getElementById('mobile-menu-close-button');
41
- const menu = document.getElementById('mobile-menu');
42
39
  const root = document.documentElement;
40
+ const MENU_HIDDEN_CLASS = 'hidden';
41
+ const MENU_OFFSCREEN_CLASS = '-translate-x-full';
42
+ const ROOT_LOCK_CLASS = 'overflow-hidden';
43
43
 
44
- if (!button || !closeButton || !menu) {
45
- return;
46
- }
44
+ const closeMenu = (menu, { hideImmediately = false } = {}) => {
45
+ if (!menu) {
46
+ root.classList.remove(ROOT_LOCK_CLASS);
47
+ return;
48
+ }
47
49
 
48
- if (button.dataset.navbarBound === 'true') {
49
- return;
50
- }
50
+ menu.classList.add(MENU_OFFSCREEN_CLASS);
51
+ root.classList.remove(ROOT_LOCK_CLASS);
51
52
 
52
- button.dataset.navbarBound = 'true';
53
-
54
- const toggleMenu = () => {
55
- const isHiddenNow = menu.classList.contains('hidden');
56
-
57
- if (isHiddenNow) {
58
- menu.classList.remove('hidden');
59
- requestAnimationFrame(() => {
60
- menu.classList.remove('-translate-x-full');
61
- root.classList.add('overflow-hidden');
62
- });
63
- } else {
64
- menu.classList.add('-translate-x-full');
65
- root.classList.remove('overflow-hidden');
66
-
67
- menu.addEventListener(
68
- 'transitionend',
69
- () => menu.classList.add('hidden'),
70
- { once: true }
71
- );
53
+ if (hideImmediately) {
54
+ menu.classList.add(MENU_HIDDEN_CLASS);
55
+ return;
72
56
  }
57
+
58
+ menu.addEventListener(
59
+ 'transitionend',
60
+ () => menu.classList.add(MENU_HIDDEN_CLASS),
61
+ { once: true }
62
+ );
73
63
  };
74
64
 
75
- button.addEventListener('click', toggleMenu);
76
- closeButton.addEventListener('click', toggleMenu);
65
+ const openMenu = (menu) => {
66
+ if (!menu) return;
67
+
68
+ menu.classList.remove(MENU_HIDDEN_CLASS);
69
+ requestAnimationFrame(() => {
70
+ menu.classList.remove(MENU_OFFSCREEN_CLASS);
71
+ root.classList.add(ROOT_LOCK_CLASS);
72
+ });
73
+ };
74
+
75
+ const bindNavbar = () => {
76
+ const button = document.getElementById('mobile-menu-button');
77
+ const closeButton = document.getElementById('mobile-menu-close-button');
78
+ const menu = document.getElementById('mobile-menu');
79
+
80
+ if (!button || !closeButton || !menu || button.dataset.navbarBound === 'true') {
81
+ return;
82
+ }
83
+
84
+ button.dataset.navbarBound = 'true';
85
+
86
+ const toggleMenu = () => {
87
+ if (menu.classList.contains(MENU_HIDDEN_CLASS)) {
88
+ openMenu(menu);
89
+ return;
90
+ }
91
+
92
+ closeMenu(menu);
93
+ };
94
+
95
+ button.addEventListener('click', toggleMenu);
96
+ closeButton.addEventListener('click', () => closeMenu(menu));
97
+ menu.querySelectorAll('a').forEach((link) => {
98
+ link.addEventListener('click', () => closeMenu(menu, { hideImmediately: true }));
99
+ });
100
+ };
101
+
102
+ if (window.__tramwayNavbarGlobalBound !== true) {
103
+ window.__tramwayNavbarGlobalBound = true;
104
+
105
+ document.addEventListener('turbo:before-cache', () => {
106
+ closeMenu(document.getElementById('mobile-menu'), { hideImmediately: true });
107
+ });
108
+
109
+ document.addEventListener('turbo:load', () => {
110
+ const menu = document.getElementById('mobile-menu');
111
+ bindNavbar();
112
+
113
+ if (!menu || menu.classList.contains(MENU_HIDDEN_CLASS)) {
114
+ root.classList.remove(ROOT_LOCK_CLASS);
115
+ }
116
+ });
117
+
118
+ window.addEventListener('resize', () => {
119
+ if (window.innerWidth >= 640) {
120
+ closeMenu(document.getElementById('mobile-menu'), { hideImmediately: true });
121
+ }
122
+ });
123
+ }
124
+
125
+ bindNavbar();
77
126
  })();
@@ -13,6 +13,22 @@
13
13
  animation: roll-up 0.5s ease-in-out;
14
14
  }
15
15
 
16
- #roll-up{ class: preview_classes }
17
- %button{ class: close_button_classes, "data-action" => "click->preview#close", "data-controller" => "preview" }
16
+ @keyframes roll-down {
17
+ 0% {
18
+ transform: translateY(0%);
19
+ }
20
+
21
+ 100% {
22
+ transform: translateY(100%);
23
+ }
24
+ }
25
+
26
+ .animate-roll-down {
27
+ animation: roll-down 0.5s ease-in-out;
28
+ }
29
+
30
+ #roll-up{ class: preview_classes, data: { controller: 'table-row-preview' } }
31
+ %button{ class: close_button_classes, "data-action" => "click->table-row-preview#close" }
18
32
  &#x2715;
33
+
34
+ = content
@@ -7,7 +7,8 @@ module Tramway
7
7
  class PreviewComponent < Tramway::BaseComponent
8
8
  def preview_classes
9
9
  theme_classes(
10
- classic: 'fixed hidden inset-x-0 bottom-0 shadow-lg z-50 bg-gray-100 animate-roll-up h-1/2'
10
+ classic: 'fixed hidden md:hidden bottom-0 left-0 right-0 w-screen shadow-lg z-50 bg-gray-900 ' \
11
+ 'animate-roll-up h-1/2 pt-4'
11
12
  )
12
13
  end
13
14
 
@@ -5,11 +5,18 @@
5
5
  = value
6
6
 
7
7
  - else
8
- - cells = Nokogiri::HTML.fragment(content).xpath('./*[@class and contains(concat(" ", normalize-space(@class), " "), " div-table-cell ")]')
8
+ - cells = visible_cells_from(content)
9
9
 
10
10
  - if href.present?
11
11
  = tag.a href:, class: [desktop_row_classes(cells.count), link_row_classes].join(' '), **default_attributes do
12
12
  = content
13
13
  - else
14
- = tag.div class: desktop_row_classes(cells.count), **default_attributes do
14
+ - if preview
15
+ = component 'tramway/table/row/preview' do
16
+ - cells.each_with_index do |cell, index|
17
+ - next if index.zero?
18
+
19
+ = cell.to_s.html_safe
20
+
21
+ = tag.div class: desktop_row_classes(cells.count), data: { action: "click->table-row-preview#toggle", controller: "table-row-preview" }, **default_attributes do
15
22
  = content
@@ -6,6 +6,7 @@ module Tramway
6
6
  class RowComponent < Tramway::BaseComponent
7
7
  option :cells, optional: true, default: -> { [] }
8
8
  option :href, optional: true
9
+ option :preview, optional: true, default: -> { true }
9
10
  option :options, optional: true, default: -> { {} }
10
11
 
11
12
  def default_attributes
@@ -57,6 +58,22 @@ module Tramway
57
58
 
58
59
  private
59
60
 
61
+ def visible_cells_from(content)
62
+ fragment = Nokogiri::HTML.fragment(content)
63
+ parsed_cells = fragment.xpath(
64
+ './*[@class and contains(concat(" ", normalize-space(@class), " "), " div-table-cell ")]'
65
+ )
66
+
67
+ parsed_cells.each { |cell| remove_hidden_class!(cell) }
68
+ end
69
+
70
+ def remove_hidden_class!(node)
71
+ classes = node['class'].to_s.split
72
+ return if classes.empty?
73
+
74
+ node['class'] = classes.reject { |class_name| class_name == 'hidden' }.join(' ')
75
+ end
76
+
60
77
  def ensure_view_context_accessor
61
78
  return if view_context.respond_to?(:tramway_inside_cell=)
62
79
 
@@ -1,5 +1,3 @@
1
- = helpers.component 'tramway/table/row/preview'
2
-
3
1
  - width_class = options[:class]&.include?('w-') ? '' : 'w-full'
4
2
 
5
3
  %div{ class: "#{table_classes} #{options[:class]} #{width_class}".strip, **options.except(:class) }
@@ -118,6 +118,17 @@ module.exports = {
118
118
  'first:block',
119
119
  'rounded-t-xl',
120
120
 
121
+ // === Table row preview panel ===
122
+ 'bottom-0',
123
+ 'left-0',
124
+ 'right-0',
125
+ 'h-1/2',
126
+ 'bg-gray-100',
127
+ 'animate-roll-up',
128
+ 'hover:text-gray-700',
129
+ 'pt-4',
130
+ 'md:hidden',
131
+
121
132
  // === Title Component ===
122
133
  'md:text-4xl',
123
134
 
@@ -333,6 +344,10 @@ module.exports = {
333
344
  'bg-red-100',
334
345
  'text-red-800',
335
346
  'space-x-2',
347
+ 'h-5',
348
+ 'w-5',
349
+ 'rounded-full',
350
+ 'mb-0',
336
351
 
337
352
  // === Tramway select dropdown positioning ===
338
353
  'absolute',
@@ -368,12 +383,12 @@ module.exports = {
368
383
  'h-15',
369
384
  'h-12',
370
385
  'h-10',
371
- 'w-2',
372
- 'h-2',
373
- 'w-4',
374
- 'h-4',
375
- 'w-6',
376
- 'h-6',
386
+ 'min-w-2',
387
+ 'min-h-2',
388
+ 'min-w-4',
389
+ 'min-h-4',
390
+ 'min-w-6',
391
+ 'min-h-6',
377
392
  'px-2',
378
393
  'py-1',
379
394
  'flex-nowrap',
data/docs/AGENTS.md CHANGED
@@ -149,6 +149,9 @@ Use Tramway Flash for user notifications.
149
149
  ### Rule 5
150
150
  Use Tramway Table for tabular data display.
151
151
 
152
+ `tramway_row` supports `href:` for clickable rows and `preview:` for mobile row preview behavior. Keep `preview: true` as the
153
+ default unless the request explicitly needs preview disabled.
154
+
152
155
  ### Rule 6
153
156
  Use Tramway Button for buttons. Always add a color of the button via `color:` or `type:` argument. `color:` argument support directs colors only: red, yellow, blue, etc. `type:` argument supports only lantern colors: will, hope, rage, etc.
154
157
 
@@ -173,6 +176,10 @@ Available `tramway_form_for` helpers:
173
176
  - `tramway_select`
174
177
  - `submit`
175
178
 
179
+ ### Rule 7.1
180
+ Use `tramway_form_for(remote: true)` only when the form must submit asynchronously and update part of the current page (for example: modal forms, inline edits, or list updates without full page reload).
181
+ For standard create/update flows that redirect and show regular flash messages, keep it synchronous (do not set `remote: true`).
182
+
176
183
  ### Rule 8
177
184
  Inherit all components from Tramway::BaseComponent
178
185
 
@@ -187,6 +194,69 @@ For live updates to a rendered `tramway_chat`, use `tramway_chat_append_message(
187
194
  This method is included in all controllers and ActiveRecord models. `message_type` must be `:sent` or `:received`, otherwise
188
195
  it raises `ArgumentError`. `chat_id` must match the stream id used in `tramway_chat`.
189
196
 
197
+ `message_form` object must be a `Tramway::BaseForm` or an inherited class object. It must contain `text` attribute.
198
+ `send_message_path` must be a `POST` route that receives data on sending messsage submition.
199
+
200
+ Here an example of usage:
201
+
202
+ *app/views/chats/show.html.haml*
203
+ ```haml
204
+ = tramway_chat chat_id: @chat.id,
205
+ messages: @chat.messages_for_chat,
206
+ message_form: @message_form,
207
+ send_message_path: chats_messages_path
208
+ ```
209
+
210
+ *app/decorators/chat_decorator.rb*
211
+ ```ruby
212
+ class ChatDecorator < Tramway::BaseDecorator
213
+ def messages_for_chat
214
+ object.messages.map do |message|
215
+ {
216
+ id: message.id,
217
+ type: :sent, # or received
218
+ text: message.text,
219
+ sent_at: message.created_at,
220
+ }
221
+ end
222
+ end
223
+ end
224
+ ```
225
+
226
+ *app/controllers/chats_controller.rb*
227
+ ```ruby
228
+ def show
229
+ @chat = tramway_decorate Chat.find params[:id]
230
+ @message_form = tramway_form chat.messages.build
231
+ end
232
+ ```
233
+
234
+ **app/forms/message_form.rb**
235
+ ```ruby
236
+ class Chats::MessageForm < Tramway::BaseForm
237
+ properties :text, :chat_id
238
+ end
239
+ ```
240
+
241
+ **config/routes.rb**
242
+ ```ruby
243
+ resources :messages, only: :create
244
+ ```
245
+
246
+ **app/controllers/messages_controller.rb**
247
+ ```ruby
248
+ def create
249
+ @message = tramway_form chat.creator.messages.build(chat:)
250
+
251
+ if @message.submit params[:message]
252
+ tramway_chat_append_message chat_id: @message.object.chat.id,
253
+ type: :sent,
254
+ text: @message.object.text,
255
+ sent_at: @message.object.created_at
256
+ end
257
+ end
258
+ ```
259
+
190
260
  ### Rule 9
191
261
  If page `create` or `update` is configured for an entity, use Tramway Form pattern for forms. Visible fields are configured via `form_fields` method.
192
262
 
@@ -501,6 +571,36 @@ and in a controller
501
571
  end
502
572
  ```
503
573
 
574
+ Use dry-monads while call these services.
575
+
576
+ Instead of this
577
+
578
+ ```ruby
579
+ result = SomeService.call(args)
580
+
581
+ case result
582
+ when :success
583
+ # code
584
+ when :failure
585
+ # code
586
+ end
587
+ ```
588
+
589
+ Do this
590
+
591
+ ```ruby
592
+ case SomeService.call(args)
593
+ in Success(result)
594
+ # use result
595
+ in Success(another_result)
596
+ # use another result
597
+ in Failure(reason_or_error)
598
+ # do stuff
599
+ end
600
+ ```
601
+
602
+ Success and Failure must describe all possible results of calling certain service.
603
+
504
604
  ### Rule 27
505
605
  Don't create scopes for enumerated values, use `scope: :shallow` of the enumerize gem.
506
606
 
@@ -532,7 +632,15 @@ Always `tramway_decorate` and `tramway_form` for creating these types of objects
532
632
  In Tramway Decorators, use `delegate_attributes` method instead of `delegate :something, to: :object`
533
633
 
534
634
  ### Rule 33
535
- In case you want to use container on the page, use `tramway_container` helper instead of creating a component for that or using a plain div with Tailwind classes.
635
+ In case you want to use container on the page, use `tramway_container` helper instead of creating a component for that or using a plain div with Tailwind classes. In case you need to use container in layout view, use `tramway_main_container` for this. Here is example of using `tramway_main_container` inside application layout.
636
+
637
+ ```
638
+ = tramway_main_container do
639
+ - if flash.any?
640
+ = tramway_flash text: flash[:notice].presence || flash[:alert],
641
+ type: flash[:notice].present? ? :will : :rage,
642
+ id: 'flash-container'
643
+ ```
536
644
 
537
645
  ## Controller Patterns
538
646
 
@@ -52,10 +52,36 @@ module Tramway
52
52
  @importmap_path ||= File.join(destination_root, 'config/importmap.rb')
53
53
  end
54
54
 
55
+ def controllers_index_path
56
+ @controllers_index_path ||= File.join(destination_root, 'app/javascript/controllers/index.js')
57
+ end
58
+
55
59
  def importmap_tramway_select_pin
56
60
  'pin "@tramway/tramway-select", to: "tramway/tramway-select_controller.js"'
57
61
  end
58
62
 
63
+ def importmap_table_row_preview_pin
64
+ 'pin "@tramway/table-row-preview", to: "tramway/table_row_preview_controller.js"'
65
+ end
66
+
67
+ def importmap_tramway_pins
68
+ [importmap_tramway_select_pin, importmap_table_row_preview_pin]
69
+ end
70
+
71
+ def stimulus_controller_imports
72
+ [
73
+ 'import { TramwaySelect } from "@tramway/tramway-select"',
74
+ 'import { TableRowPreview } from "@tramway/table-row-preview"'
75
+ ]
76
+ end
77
+
78
+ def stimulus_controller_registrations
79
+ [
80
+ "application.register('tramway-select', TramwaySelect)",
81
+ "application.register('table-row-preview', TableRowPreview)"
82
+ ]
83
+ end
84
+
59
85
  def agents_file_path
60
86
  @agents_file_path ||= File.join(destination_root, 'AGENTS.md')
61
87
  end
@@ -159,6 +185,43 @@ module Tramway
159
185
  agents_template
160
186
  )
161
187
  end
188
+
189
+ # rubocop:disable Metrics/MethodLength
190
+ def append_missing_imports(content)
191
+ missing_imports = stimulus_controller_imports.reject { |line| content.include?(line) }
192
+ return content if missing_imports.empty?
193
+
194
+ import_lines = content.each_line.with_index.filter_map do |line, index|
195
+ index if line.lstrip.start_with?('import ')
196
+ end
197
+ insertion = "#{missing_imports.join("\n")}\n"
198
+ updated = content.dup
199
+
200
+ if import_lines.any?
201
+ insertion_index = updated.lines[0..import_lines.max].join.length
202
+ updated.insert(insertion_index, insertion)
203
+ else
204
+ updated.prepend(insertion)
205
+ end
206
+
207
+ updated
208
+ end
209
+ # rubocop:enable Metrics/MethodLength
210
+
211
+ def append_missing_registrations(content)
212
+ missing_registrations = stimulus_controller_registrations.reject { |line| content.include?(line) }
213
+ return content if missing_registrations.empty?
214
+
215
+ insertion = "#{missing_registrations.join("\n")}\n"
216
+ export_match = /^(?:export\s*\{[^}]+\}\s*;?\s*)$/m.match(content)
217
+
218
+ return content.dup.insert(export_match.begin(0), insertion) if export_match
219
+
220
+ updated = content.dup
221
+ updated << "\n" unless updated.empty? || updated.end_with?("\n")
222
+ updated << insertion
223
+ updated
224
+ end
162
225
  end
163
226
  # rubocop:enable Metrics/ModuleLength
164
227
 
@@ -242,13 +305,25 @@ module Tramway
242
305
  return unless File.exist?(importmap_path)
243
306
 
244
307
  content = File.read(importmap_path)
245
- return if content.include?(importmap_tramway_select_pin)
308
+ missing_pins = importmap_tramway_pins.reject { |pin| content.include?(pin) }
309
+ return if missing_pins.empty?
246
310
 
247
311
  File.open(importmap_path, 'a') do |file|
248
312
  file.write("\n") unless content.empty? || content.end_with?("\n")
249
- file.write("#{importmap_tramway_select_pin}\n")
313
+ file.write("#{missing_pins.join("\n")}\n")
250
314
  end
251
315
  end
316
+
317
+ def ensure_stimulus_controller_registration
318
+ return unless File.exist?(controllers_index_path)
319
+
320
+ content = File.read(controllers_index_path)
321
+ updated = append_missing_imports(content)
322
+ updated = append_missing_registrations(updated)
323
+ return if updated == content
324
+
325
+ File.write(controllers_index_path, updated)
326
+ end
252
327
  end
253
328
  end
254
329
  end
@@ -11,12 +11,12 @@ module Tramway
11
11
  def tramway_form_for(object, *, size: :medium, **options, &)
12
12
  form_object_class = object.is_a?(Tramway::BaseForm) ? object.class : nil
13
13
 
14
- form_for(
15
- object,
16
- *,
17
- **options.merge(builder: Tramway::Form::Builder, size: normalize_form_size(size), form_object_class:),
18
- &
19
- )
14
+ form_for(object, *, **options.merge(
15
+ builder: Tramway::Form::Builder,
16
+ size: normalize_form_size(size),
17
+ form_object_class:,
18
+ remote_submit: options[:remote] || false
19
+ ), &)
20
20
  end
21
21
 
22
22
  def tramway_table(**options, &)
@@ -34,6 +34,7 @@ module Tramway
34
34
  component 'tramway/table/row',
35
35
  cells: options.delete(:cells),
36
36
  href: options.delete(:href),
37
+ preview: options.delete(:preview),
37
38
  options:,
38
39
  &
39
40
  end
@@ -42,9 +42,13 @@ module Tramway
42
42
 
43
43
  def field_options(field_data)
44
44
  if field_data.is_a?(Hash)
45
- value = field_data[:value]&.call(object)
45
+ if field_data[:value].present?
46
+ value = field_data[:value]&.call(object)
46
47
 
47
- field_data.merge(value:).except(:type)
48
+ field_data.merge(value:)
49
+ else
50
+ field_data
51
+ end.except(:type)
48
52
  else
49
53
  {}
50
54
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '3.0.1'
4
+ VERSION = '3.0.3'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tramway
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -320,7 +320,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
320
320
  - !ruby/object:Gem::Version
321
321
  version: '0'
322
322
  requirements: []
323
- rubygems_version: 4.0.3
323
+ rubygems_version: 4.0.6
324
324
  specification_version: 4
325
325
  summary: Tramway Rails Engine
326
326
  test_files: []