lato_cms 3.4.0 → 3.5.0

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -9
  3. data/app/assets/javascripts/lato_cms/controllers/lato_cms_advanced_editor_controller.js +58 -6
  4. data/app/assets/javascripts/lato_cms/controllers/lato_cms_color_field_controller.js +11 -1
  5. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_form_controller.js +48 -5
  6. data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_toggle_controller.js +6 -0
  7. data/app/assets/javascripts/lato_cms/controllers/lato_cms_json_field_controller.js +3 -2
  8. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +40 -2
  9. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +6 -5
  10. data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_reorder_controller.js +25 -0
  11. data/app/assets/javascripts/lato_cms/controllers/lato_cms_page_preview_controller.js +6 -1
  12. data/app/assets/javascripts/lato_cms/controllers/lato_cms_repeater_controller.js +8 -0
  13. data/app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js +2 -0
  14. data/app/assets/javascripts/lato_cms/controllers/lato_cms_upload_controller.js +4 -1
  15. data/app/assets/stylesheets/lato_cms/application.scss +57 -0
  16. data/app/controllers/lato_cms/application_controller.rb +23 -1
  17. data/app/controllers/lato_cms/media_controller.rb +4 -2
  18. data/app/controllers/lato_cms/pages_controller.rb +51 -22
  19. data/app/helpers/lato_cms/media_helper.rb +1 -1
  20. data/app/helpers/lato_cms/pages_helper.rb +15 -12
  21. data/app/jobs/lato_cms/export_sitemap_csv_job.rb +8 -1
  22. data/app/models/lato_cms/llm_client.rb +11 -0
  23. data/app/models/lato_cms/media.rb +41 -11
  24. data/app/models/lato_cms/page.rb +45 -7
  25. data/app/models/lato_cms/page_field.rb +64 -4
  26. data/app/models/lato_cms/template_manager.rb +49 -3
  27. data/app/views/lato_cms/media/_form_update.html.erb +9 -7
  28. data/app/views/lato_cms/media/_translations_field.html.erb +8 -5
  29. data/app/views/lato_cms/media/_upload_progress.html.erb +4 -2
  30. data/app/views/lato_cms/media/picker_action.html.erb +21 -14
  31. data/app/views/lato_cms/media/show.html.erb +2 -1
  32. data/app/views/lato_cms/pages/_component_accordion.html.erb +14 -8
  33. data/app/views/lato_cms/pages/_form_update.html.erb +5 -0
  34. data/app/views/lato_cms/pages/_repeater_item.html.erb +6 -4
  35. data/app/views/lato_cms/pages/_translations.html.erb +2 -1
  36. data/app/views/lato_cms/pages/fields/_color.html.erb +14 -6
  37. data/app/views/lato_cms/pages/fields/_json.html.erb +5 -2
  38. data/app/views/lato_cms/pages/fields/_media_field.html.erb +12 -5
  39. data/app/views/lato_cms/pages/fields/_media_item.html.erb +14 -3
  40. data/app/views/lato_cms/pages/fields/_text.html.erb +31 -22
  41. data/app/views/lato_cms/pages/index.html.erb +3 -2
  42. data/app/views/lato_cms/pages/show.html.erb +26 -9
  43. data/config/locales/en.yml +27 -1
  44. data/config/locales/it.yml +27 -1
  45. data/lib/lato_cms/config.rb +26 -1
  46. data/lib/lato_cms/version.rb +1 -1
  47. data/lib/tasks/lato_cms_tasks.rake +18 -11
  48. metadata +1 -15
@@ -22,6 +22,7 @@ module LatoCms
22
22
  validates :field_id, presence: true
23
23
  validates :field_id, uniqueness: { scope: [:page_id, :template_component_id] }
24
24
  validate :media_present_if_required
25
+ validate :value_matches_field_type
25
26
 
26
27
  REPEATER_ORDER_FIELD_ID = "__repeater_order".freeze
27
28
 
@@ -50,16 +51,32 @@ module LatoCms
50
51
  end
51
52
  end
52
53
 
54
+ RICH_TEXT_ALIGNMENTS = %w[left center right justify].freeze
55
+ COLOR_FORMAT = /\A#(\h{3}|\h{6})\z/
56
+
57
+ # Same allowlist as the editor's client-side sanitizer: `style` is reduced
58
+ # to text-align, the only style the toolbar produces, so a value posted
59
+ # outside the editor cannot keep arbitrary CSS either.
53
60
  def self.sanitize_rich_text(value)
54
- String.new(ActionController::Base.helpers.sanitize(value, tags: RICH_TEXT_TAGS, attributes: RICH_TEXT_ATTRIBUTES).to_s)
61
+ html = ActionController::Base.helpers.sanitize(value, tags: RICH_TEXT_TAGS, attributes: RICH_TEXT_ATTRIBUTES).to_s
62
+ return String.new(html) unless html.include?('style=')
63
+
64
+ fragment = Nokogiri::HTML::DocumentFragment.parse(html)
65
+ fragment.css('[style]').each do |element|
66
+ alignment = element['style'][/(?:\A|;)\s*text-align\s*:\s*([a-z]+)/i, 1].to_s.downcase
67
+ RICH_TEXT_ALIGNMENTS.include?(alignment) ? element['style'] = "text-align: #{alignment};" : element.remove_attribute('style')
68
+ end
69
+ String.new(fragment.to_html)
55
70
  end
56
71
 
72
+ # The field definition comes from the template this field was saved
73
+ # under, never from the stored component_id: that id used to be taken
74
+ # from the request, and a forged one turned a `text` field (sanitized)
75
+ # into a `string` one (not sanitized).
57
76
  def field_config
58
77
  return nil if repeater_order?
59
78
 
60
- component = LatoCms::TemplateManager.find_component(component_id)
61
- return nil unless component
62
- component.dig('fields', base_field_id)
79
+ LatoCms::TemplateManager.find_template_component_fields(template_id, template_component_id)&.dig(base_field_id)
63
80
  end
64
81
 
65
82
  def repeater_order?
@@ -177,6 +194,49 @@ module LatoCms
177
194
  errors.add(:base, I18n.t('lato_cms.field_required_attachment_error')) if page_field_media.reload.empty?
178
195
  end
179
196
 
197
+ # Server-side counterpart of the browser checks (`required`, option
198
+ # lists, number and color inputs): a request built by hand skips those,
199
+ # and the API hands the stored value to the frontend as-is.
200
+ def value_matches_field_type
201
+ return if repeater_order? || ATTACHMENT_FIELD_TYPES.include?(field_type)
202
+
203
+ if value_blank_for_type?
204
+ errors.add(:value, :blank) if field_required?
205
+ return
206
+ end
207
+
208
+ case field_type
209
+ when 'number'
210
+ errors.add(:value, :not_a_number) unless value.to_s.strip.match?(/\A-?\d+(\.\d+)?\z/)
211
+ when 'color'
212
+ errors.add(:value, :invalid) unless value.to_s.match?(COLOR_FORMAT)
213
+ when 'select'
214
+ errors.add(:value, :inclusion) if option_values.any? && !option_values.include?(value.to_s)
215
+ when 'multiselect'
216
+ errors.add(:value, :inclusion) if option_values.any? && (Array(parsed_value).map(&:to_s) - option_values).any?
217
+ when 'date', 'datetime'
218
+ errors.add(:value, :invalid) unless valid_date_value?
219
+ end
220
+ end
221
+
222
+ def value_blank_for_type?
223
+ return true if value.blank?
224
+
225
+ field_type == 'multiselect' && Array(parsed_value).compact_blank.empty?
226
+ end
227
+
228
+ def valid_date_value?
229
+ field_type == 'date' ? Date.iso8601(value.to_s) : DateTime.parse(value.to_s)
230
+ true
231
+ rescue ArgumentError, Date::Error
232
+ false
233
+ end
234
+
235
+ # `options` may list plain strings or `{ value:, label: }` hashes.
236
+ def option_values
237
+ Array(field_settings['options']).map { |option| (option.is_a?(Hash) ? option['value'] : option).to_s }
238
+ end
239
+
180
240
  def parse_value
181
241
  return if value.blank?
182
242
 
@@ -10,11 +10,11 @@ module LatoCms
10
10
  end
11
11
 
12
12
  def templates
13
- load_yaml_files(templates_path)
13
+ cached_yaml_files(templates_path)
14
14
  end
15
15
 
16
16
  def components
17
- load_yaml_files(components_path)
17
+ cached_yaml_files(components_path)
18
18
  end
19
19
 
20
20
  def find_template(template_id)
@@ -27,6 +27,15 @@ module LatoCms
27
27
  components.find { |c| c['id'] == component_id.to_s }
28
28
  end
29
29
 
30
+ # The field definitions of a template component, resolved through the
31
+ # template (template -> template component -> component). This is the
32
+ # authoritative source for a field's type: the component id stored on a
33
+ # PageField, or sent by a client, is never trusted to pick it.
34
+ def find_template_component_fields(template_id, template_component_id)
35
+ component_id = find_template(template_id)&.dig('components', template_component_id.to_s, 'component_id')
36
+ find_component(component_id)&.dig('fields')
37
+ end
38
+
30
39
  def template_options
31
40
  templates.map { |t| [t['name'], t['id']] }
32
41
  end
@@ -49,12 +58,49 @@ module LatoCms
49
58
  end
50
59
  end
51
60
 
61
+ def reset_cache!
62
+ @cache = {}
63
+ end
64
+
52
65
  private
53
66
 
67
+ # Parsed YAML is kept per process: every field read (type, name,
68
+ # settings) goes through here, so re-reading the files each time cost
69
+ # hundreds of parses per request, on a public endpoint too. When code
70
+ # reloading is on (development) the cache is keyed by the files' mtimes,
71
+ # so edited templates show up without a restart. Entries are deep-frozen
72
+ # because they are shared by every caller.
73
+ def cached_yaml_files(path)
74
+ @cache ||= {}
75
+ fingerprint = reloading? ? yaml_fingerprint(path) : nil
76
+ entry = @cache[path.to_s]
77
+ return entry[:data] if entry && entry[:fingerprint] == fingerprint
78
+
79
+ data = deep_freeze(load_yaml_files(path))
80
+ @cache[path.to_s] = { fingerprint: fingerprint, data: data }
81
+ data
82
+ end
83
+
84
+ def reloading?
85
+ Rails.application.config.respond_to?(:enable_reloading) && Rails.application.config.enable_reloading
86
+ end
87
+
88
+ def yaml_fingerprint(path)
89
+ Dir.glob(File.join(path, '*.yml')).sort.map { |file| [file, File.mtime(file).to_f] }
90
+ end
91
+
92
+ def deep_freeze(object)
93
+ case object
94
+ when Hash then object.each_value { |value| deep_freeze(value) }
95
+ when Array then object.each { |value| deep_freeze(value) }
96
+ end
97
+ object.freeze
98
+ end
99
+
54
100
  def load_yaml_files(path)
55
101
  return [] unless Dir.exist?(path)
56
102
 
57
- Dir.glob(File.join(path, '*.yml')).filter_map do |file|
103
+ Dir.glob(File.join(path, '*.yml')).sort.filter_map do |file|
58
104
  YAML.load_file(file)
59
105
  rescue Psych::SyntaxError => e
60
106
  Rails.logger.error("LatoCms: Failed to parse YAML file #{file}: #{e.message}")
@@ -5,15 +5,17 @@
5
5
  <%# Two tabs: the metadata form, and the file swap — same media, but editing
6
6
  its name is a local change while replacing its file edits every page
7
7
  using it, so they don't share a submit button. %>
8
- <ul class="nav nav-tabs mb-3">
9
- <li class="nav-item">
10
- <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_details" type="button">
8
+ <ul class="nav nav-tabs mb-3" role="tablist">
9
+ <li class="nav-item" role="presentation">
10
+ <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_details" type="button"
11
+ role="tab" id="<%= tab_id %>_details_tab" aria-controls="<%= tab_id %>_details" aria-selected="true">
11
12
  <%= t('lato_cms.media_update_tab_details') %>
12
13
  </button>
13
14
  </li>
14
15
  <% if lato_cms_admin? %>
15
- <li class="nav-item">
16
- <button class="nav-link" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_replace" type="button">
16
+ <li class="nav-item" role="presentation">
17
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#<%= tab_id %>_replace" type="button"
18
+ role="tab" id="<%= tab_id %>_replace_tab" aria-controls="<%= tab_id %>_replace" aria-selected="false">
17
19
  <%= t('lato_cms.media_replace_file_title') %>
18
20
  </button>
19
21
  </li>
@@ -21,7 +23,7 @@
21
23
  </ul>
22
24
 
23
25
  <div class="tab-content">
24
- <div class="tab-pane fade show active" id="<%= tab_id %>_details">
26
+ <div class="tab-pane fade show active" id="<%= tab_id %>_details" role="tabpanel" aria-labelledby="<%= tab_id %>_details_tab">
25
27
  <%= form_with model: media, url: lato_cms.media_update_action_path(media), method: :patch, data: { turbo_frame: '_self', controller: 'lato-form lato-cms-reload-on-modal-close' } do |form| %>
26
28
  <%= lato_form_notices class: %w[mb-3] %>
27
29
  <%= lato_form_errors media, class: %w[mb-3] %>
@@ -64,7 +66,7 @@
64
66
  </div>
65
67
 
66
68
  <% if lato_cms_admin? %>
67
- <div class="tab-pane fade" id="<%= tab_id %>_replace">
69
+ <div class="tab-pane fade" id="<%= tab_id %>_replace" role="tabpanel" aria-labelledby="<%= tab_id %>_replace_tab">
68
70
  <%= render 'lato_cms/media/form_replace_file', media: media %>
69
71
  </div>
70
72
  <% end %>
@@ -4,12 +4,14 @@
4
4
  <% label = t("lato_cms.media_#{attribute}") %>
5
5
 
6
6
  <div class="mb-3">
7
- <label class="form-label"><%= label %></label>
7
+ <div class="form-label" id="<%= dom_id(media, "#{attribute}_label") %>"><%= label %></div>
8
8
 
9
- <ul class="nav nav-tabs">
9
+ <ul class="nav nav-tabs" role="tablist" aria-labelledby="<%= dom_id(media, "#{attribute}_label") %>">
10
10
  <% LatoCms.config.locales.each_with_index do |locale, index| %>
11
- <li class="nav-item">
11
+ <li class="nav-item" role="presentation">
12
12
  <button class="nav-link <%= 'active' if index.zero? %>" data-bs-toggle="tab"
13
+ role="tab" id="<%= dom_id(media, "#{attribute}_#{locale}_tab") %>" aria-selected="<%= index.zero? %>"
14
+ aria-controls="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>"
13
15
  data-bs-target="#<%= dom_id(media, "#{attribute}_#{locale}_pane") %>" type="button">
14
16
  <%= locale_to_flag(locale) %> <%= locale.to_s.upcase %>
15
17
  </button>
@@ -19,8 +21,9 @@
19
21
 
20
22
  <div class="tab-content border border-top-0 rounded-bottom p-3">
21
23
  <% LatoCms.config.locales.each_with_index do |locale, index| %>
22
- <div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>">
23
- <%= lato_form_item_input_text form, :"#{attribute}_#{locale}" %>
24
+ <div class="tab-pane fade <%= 'show active' if index.zero? %>" id="<%= dom_id(media, "#{attribute}_#{locale}_pane") %>"
25
+ role="tabpanel" aria-labelledby="<%= dom_id(media, "#{attribute}_#{locale}_tab") %>">
26
+ <%= lato_form_item_input_text form, :"#{attribute}_#{locale}", 'aria-label': "#{label} (#{locale.to_s.upcase})" %>
24
27
  </div>
25
28
  <% end %>
26
29
  </div>
@@ -2,7 +2,9 @@
2
2
  submit starts, then fed the real bytes-sent percentage. %>
3
3
  <div class="d-none my-3" data-lato-cms-upload-target="progress">
4
4
  <div class="progress" style="height: 6px;">
5
- <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%;" role="progressbar" data-lato-cms-upload-target="bar"></div>
5
+ <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 0%;" role="progressbar"
6
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-labelledby="<%= progress_label_id = "lato-cms-upload-label-#{SecureRandom.hex(4)}" %>"
7
+ data-lato-cms-upload-target="bar"></div>
6
8
  </div>
7
- <div class="small text-muted mt-1" data-lato-cms-upload-target="label">0%</div>
9
+ <div class="small text-muted mt-1" id="<%= progress_label_id %>" role="status" aria-live="polite" data-lato-cms-upload-target="label">0%</div>
8
10
  </div>
@@ -5,21 +5,23 @@
5
5
  data-lato-cms-media-picker-multiple-value="<%= multiple %>"
6
6
  data-lato-cms-media-picker-selected-label-value="<%= t('lato_cms.media_picker_selected_label') %>">
7
7
 
8
- <ul class="nav nav-tabs mb-3">
9
- <li class="nav-item">
10
- <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-browse" type="button">
8
+ <ul class="nav nav-tabs mb-3" role="tablist">
9
+ <li class="nav-item" role="presentation">
10
+ <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-browse" type="button"
11
+ role="tab" id="lato-cms-media-picker-browse-tab" aria-controls="lato-cms-media-picker-browse" aria-selected="true">
11
12
  <%= t('lato_cms.media_picker_tab_browse') %>
12
13
  </button>
13
14
  </li>
14
- <li class="nav-item">
15
- <button class="nav-link" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-upload" type="button">
15
+ <li class="nav-item" role="presentation">
16
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#lato-cms-media-picker-upload" type="button"
17
+ role="tab" id="lato-cms-media-picker-upload-tab" aria-controls="lato-cms-media-picker-upload" aria-selected="false">
16
18
  <%= t('lato_cms.media_picker_tab_upload') %>
17
19
  </button>
18
20
  </li>
19
21
  </ul>
20
22
 
21
23
  <div class="tab-content">
22
- <div class="tab-pane fade show active" id="lato-cms-media-picker-browse">
24
+ <div class="tab-pane fade show active" id="lato-cms-media-picker-browse" role="tabpanel" aria-labelledby="lato-cms-media-picker-browse-tab">
23
25
  <%= form_with url: lato_cms.media_picker_action_path, method: :get, data: { turbo_frame: params[:frame_id] } do %>
24
26
  <%= hidden_field_tag :frame_id, params[:frame_id] %>
25
27
  <%= hidden_field_tag :multiple, multiple %>
@@ -32,8 +34,9 @@
32
34
  class: "btn btn-sm #{params[:type] == type ? 'btn-primary' : 'btn-outline-secondary'}", data: { turbo_frame: params[:frame_id] } %>
33
35
  <% end %>
34
36
  </div>
35
- <%= text_field_tag :q, params[:q], class: 'form-control form-control-sm', placeholder: t('lato_cms.media_search_placeholder') %>
36
- <button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-search"></i></button>
37
+ <%= search_field_tag :q, params[:q], class: 'form-control form-control-sm', placeholder: t('lato_cms.media_search_placeholder'),
38
+ 'aria-label': t('lato_cms.media_search_label') %>
39
+ <button type="submit" class="btn btn-sm btn-outline-primary" aria-label="<%= t('lato_cms.media_search_cta') %>"><i class="bi bi-search" aria-hidden="true"></i></button>
37
40
  </div>
38
41
  <% end %>
39
42
 
@@ -45,7 +48,11 @@
45
48
  <% else %>
46
49
  <div class="lato-cms-media-picker__grid mb-3" data-lato-cms-media-picker-target="grid">
47
50
  <% @media.each do |media| %>
48
- <div class="lato-cms-media-picker__item"
51
+ <%# A button: reachable and operable from the keyboard. In
52
+ multiple mode aria-pressed carries the selection state. %>
53
+ <button type="button" class="lato-cms-media-picker__item"
54
+ aria-label="<%= t('lato_cms.media_field_select', name: media.name) %> (<%= t("lato_cms.media_type_#{media.media_type}", default: media.media_type) %>)"
55
+ <% if multiple %>aria-pressed="false"<% end %>
49
56
  data-media-id="<%= media.id %>"
50
57
  data-media-name="<%= media.name %>"
51
58
  data-media-thumb="<%= media.thumbnail_url %>"
@@ -55,8 +62,8 @@
55
62
  data-action="click->lato-cms-media-picker#select"
56
63
  data-lato-cms-media-picker-target="item">
57
64
  <%= lato_cms_media_thumb(media, size: 100) %>
58
- <span class="small text-truncate d-block"><%= media.name %></span>
59
- </div>
65
+ <span class="small text-truncate d-block" aria-hidden="true"><%= media.name %></span>
66
+ </button>
60
67
  <% end %>
61
68
  </div>
62
69
 
@@ -65,7 +72,7 @@
65
72
 
66
73
  <% if multiple %>
67
74
  <div class="d-flex justify-content-between align-items-center mt-2">
68
- <span data-lato-cms-media-picker-target="selectedCount">0 <%= t('lato_cms.media_picker_selected_label') %></span>
75
+ <span data-lato-cms-media-picker-target="selectedCount" role="status" aria-live="polite">0 <%= t('lato_cms.media_picker_selected_label') %></span>
69
76
  <button type="button" class="btn btn-primary" data-action="lato-cms-media-picker#confirm" data-lato-cms-media-picker-target="confirmButton" disabled>
70
77
  <%= t('lato_cms.media_picker_confirm') %>
71
78
  </button>
@@ -73,8 +80,8 @@
73
80
  <% end %>
74
81
  </div>
75
82
 
76
- <div class="tab-pane fade" id="lato-cms-media-picker-upload">
77
- <div data-lato-cms-media-picker-target="uploadNotice"></div>
83
+ <div class="tab-pane fade" id="lato-cms-media-picker-upload" role="tabpanel" aria-labelledby="lato-cms-media-picker-upload-tab">
84
+ <div data-lato-cms-media-picker-target="uploadNotice" role="status" aria-live="polite"></div>
78
85
  <%# Name/alt text are intentionally not collected here: they default
79
86
  to the filename on upload and are only ever edited afterwards
80
87
  from the Media library's own edit form.
@@ -1,4 +1,5 @@
1
- <%= lato_page_head @media.name, [
1
+ <%# h(): the name is user content and lato_page_head renders it raw. %>
2
+ <%= lato_page_head h(@media.name), [
2
3
  { label: t('lato_cms.media_index_title'), path: lato_cms.media_path },
3
4
  { label: @media.name }
4
5
  ] %>
@@ -38,13 +38,13 @@
38
38
  <div class="d-flex align-items-center gap-3">
39
39
  <div class="form-check form-switch m-0">
40
40
  <%= hidden_field_tag :enabled, '0' %>
41
- <%= check_box_tag :enabled, '1', enabled_component, class: 'form-check-input', id: "toggle-#{accordion_id}", onchange: 'this.form.requestSubmit()', 'aria-label': t('lato_cms.component_enabled_label'), data: { lato_cms_component_toggle_target: 'checkbox' } %>
41
+ <%= check_box_tag :enabled, '1', enabled_component, class: 'form-check-input', id: "toggle-#{accordion_id}", 'aria-label': t('lato_cms.component_enabled_label'), data: { lato_cms_component_toggle_target: 'checkbox', action: 'change->lato-cms-component-toggle#submit' } %>
42
42
  </div>
43
43
  <div class="d-flex align-items-center gap-2 small fw-medium <%= enabled_component ? 'text-success' : 'text-muted' %>" data-lato-cms-component-toggle-target="stateWrapper">
44
44
  <i class="<%= enabled_component ? 'bi bi-eye' : 'bi bi-eye-slash' %>" data-lato-cms-component-toggle-target="stateIcon"></i>
45
45
  <span data-lato-cms-component-toggle-target="stateLabel"><%= enabled_component ? t('lato_cms.component_active_label') : t('lato_cms.component_inactive_label') %></span>
46
46
  </div>
47
- <span data-lato-cms-component-toggle-target="status"></span>
47
+ <span data-lato-cms-component-toggle-target="status" role="status" aria-live="polite"></span>
48
48
  </div>
49
49
  <% end %>
50
50
  <% end %>
@@ -52,8 +52,9 @@
52
52
 
53
53
  <% if clonable_translations.any? %>
54
54
  <div class="dropdown flex-shrink-0" data-controller="lato-tooltip" title="<%= t('lato_cms.component_clone_cta') %>">
55
- <button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
56
- <i class="bi bi-files"></i>
55
+ <button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
56
+ aria-label="<%= t('lato_cms.component_clone_cta') %>">
57
+ <i class="bi bi-files" aria-hidden="true"></i>
57
58
  </button>
58
59
  <ul class="dropdown-menu dropdown-menu-end">
59
60
  <% clonable_translations.each do |sibling| %>
@@ -93,9 +94,14 @@
93
94
  <div data-lato-cms-component-toggle-target="fieldsContainer" class="<%= enabled_component ? '' : 'd-none' %>">
94
95
  <% end %>
95
96
  <%= form_with url: lato_cms.pages_save_fields_action_path(page), method: :post, multipart: true,
96
- data: { turbo: false, controller: 'lato-cms-component-form' } do |form| %>
97
+ data: {
98
+ turbo: false, controller: 'lato-cms-component-form',
99
+ lato_cms_component_form_saving_label_value: t('lato_cms.fields_saving'),
100
+ lato_cms_component_form_saved_label_value: t('lato_cms.fields_saved'),
101
+ lato_cms_component_form_failed_label_value: t('lato_cms.fields_save_failed'),
102
+ lato_cms_component_form_error_label_value: t('lato_cms.fields_save_error')
103
+ } do |form| %>
97
104
  <%= hidden_field_tag :template_component_id, tc[:template_component_id] %>
98
- <%= hidden_field_tag :component_id, tc[:component_id] %>
99
105
 
100
106
  <% if tc[:repeater] %>
101
107
  <%= render 'lato_cms/pages/repeater', tc: tc, component_fields: component_fields %>
@@ -103,7 +109,7 @@
103
109
  <% tc[:fields].each do |field_id, field_config| %>
104
110
  <% page_field = component_fields.find { |f| f.field_id == field_id } %>
105
111
 
106
- <div class="mb-3">
112
+ <div class="mb-3" data-lato-cms-persisted-field-id="<%= field_id %>">
107
113
  <%# The dom id prefix carries the template component: every
108
114
  component of the page is rendered at once, and two of them
109
115
  can expose the same field id (a template mounting one
@@ -124,7 +130,7 @@
124
130
  <% end %>
125
131
 
126
132
  <div class="d-flex justify-content-end align-items-center gap-3 mt-1">
127
- <span data-lato-cms-component-form-target="status"></span>
133
+ <span data-lato-cms-component-form-target="status" role="status" aria-live="polite"></span>
128
134
  <button type="submit" class="btn btn-sm btn-success" data-lato-cms-component-form-target="submitBtn">
129
135
  <i class="bi bi-check-lg me-1"></i><%= t('lato_cms.fields_save_component') %>
130
136
  </button>
@@ -24,6 +24,11 @@
24
24
  <% template_options = [[t('lato_cms.form_no_template_option'), '']] + LatoCms::TemplateManager.template_options %>
25
25
  <%= lato_form_item_label form, :template_id, t('lato_cms.form_template') %>
26
26
  <%= lato_form_item_input_select form, :template_id, template_options %>
27
+ <% if page.persisted? && page.fields.exists? %>
28
+ <div class="form-text">
29
+ <i class="bi bi-info-circle me-1" aria-hidden="true"></i><%= t('lato_cms.form_template_change_warning') %>
30
+ </div>
31
+ <% end %>
27
32
  <% if page.template_id.present? && !page.template_available? %>
28
33
  <div class="form-text text-danger">
29
34
  <i class="bi bi-exclamation-triangle me-1"></i>
@@ -7,10 +7,12 @@
7
7
  <button type="button" class="lato-cms-repeater-item__toggle btn btn-link p-0 flex-grow-1 text-start text-decoration-none text-body <%= 'collapsed' unless expanded %>"
8
8
  data-bs-toggle="collapse" data-bs-target="#<%= collapse_id %>" aria-expanded="<%= expanded %>" aria-controls="<%= collapse_id %>">
9
9
  <i class="bi bi-chevron-down me-2 small"></i>
10
- <strong class="small"><%= tc[:name] %> #<span data-lato-cms-repeater-target="position"><%= item_index.to_i + 1 %></span></strong>
10
+ <strong class="small" id="repeater-title-<%= item_id %>"><%= tc[:name] %> #<span data-lato-cms-repeater-target="position"><%= item_index.to_i + 1 %></span></strong>
11
11
  </button>
12
- <button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" data-action="lato-cms-repeater#remove">
13
- <i class="bi bi-trash me-1"></i><%= t('lato_cms.repeater_remove_item') %>
12
+ <%# Named "Remove <component> #n": the title span keeps the live position. %>
13
+ <button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" data-action="lato-cms-repeater#remove"
14
+ id="repeater-remove-<%= item_id %>" aria-labelledby="repeater-remove-<%= item_id %> repeater-title-<%= item_id %>">
15
+ <i class="bi bi-trash me-1" aria-hidden="true"></i><%= t('lato_cms.repeater_remove_item') %>
14
16
  </button>
15
17
  </div>
16
18
 
@@ -25,7 +27,7 @@
25
27
  <% input_name_prefix = "repeater_items[#{item_id}][#{field_id}]" %>
26
28
  <% dom_id_prefix = "repeater_items_#{item_id}_#{field_id}" %>
27
29
 
28
- <div class="mb-3">
30
+ <div class="mb-3" data-lato-cms-persisted-field-id="<%= item_id %>.<%= field_id %>">
29
31
  <%= lato_cms_render_field(
30
32
  field_id: field_id,
31
33
  field_config: field_config,
@@ -28,7 +28,8 @@
28
28
  class: 'd-flex align-items-center gap-2 flex-grow-1 justify-content-end' do %>
29
29
  <%= select_tag :translation_page_id,
30
30
  options_from_collection_for_select(candidates, :id, :title),
31
- class: 'form-select form-select-sm', style: 'max-width: 260px;' %>
31
+ class: 'form-select form-select-sm', style: 'max-width: 260px;',
32
+ id: "translation_page_id_#{locale}", 'aria-label': t('lato_cms.translation_select_label', locale: locale.upcase) %>
32
33
  <%= submit_tag t('lato_cms.translation_link_cta'), class: 'btn btn-sm btn-outline-primary' %>
33
34
  <% end %>
34
35
  <% else %>
@@ -1,20 +1,28 @@
1
1
  <% label = field_config['name'] || field_id.humanize %>
2
2
  <% required = field_config['required'] == true %>
3
- <% current_value = page_field&.value.presence || '#000000' %>
3
+ <% current_value = page_field&.value.presence %>
4
4
  <% input_name = lato_cms_field_input_name(field_id, input_name_prefix: local_assigns[:input_name_prefix]) %>
5
5
  <% input_id = lato_cms_field_dom_id(field_id, dom_id_prefix: local_assigns[:dom_id_prefix]) %>
6
6
 
7
7
  <label class="form-label" for="<%= input_id %>">
8
8
  <%= label %><%= ' *' if required %>
9
9
  </label>
10
- <div class="d-flex align-items-center gap-2" data-controller="lato-cms-color-field">
10
+ <%# The submitted value lives in the hidden input: a native color input always
11
+ holds a color (#000000 when empty), so an untouched field used to be saved
12
+ as black. It stays empty until a color is picked, and can be cleared. %>
13
+ <div class="d-flex align-items-center gap-2" data-controller="lato-cms-color-field"
14
+ data-lato-cms-color-field-empty-label-value="<%= t('lato_cms.field_color_empty') %>">
15
+ <input type="hidden" name="<%= input_name %>" value="<%= current_value %>" data-lato-cms-color-field-target="value">
11
16
  <input type="color"
12
17
  class="form-control form-control-color"
13
- name="<%= input_name %>"
14
18
  id="<%= input_id %>"
15
- value="<%= current_value %>"
19
+ value="<%= current_value || '#000000' %>"
16
20
  data-lato-cms-color-field-target="input"
17
21
  data-action="input->lato-cms-color-field#update"
18
- <%= 'required' if required %>>
19
- <code class="text-muted small" data-lato-cms-color-field-target="hex"><%= current_value.upcase %></code>
22
+ <%= 'aria-required="true"'.html_safe if required %>>
23
+ <code class="text-muted small" data-lato-cms-color-field-target="hex" aria-live="polite"><%= current_value&.upcase || t('lato_cms.field_color_empty') %></code>
24
+ <% unless required %>
25
+ <button type="button" class="btn btn-sm btn-link px-0" data-action="lato-cms-color-field#clear"
26
+ aria-label="<%= t('lato_cms.field_color_clear') %>: <%= label %>"><%= t('lato_cms.field_color_clear') %></button>
27
+ <% end %>
20
28
  </div>
@@ -11,7 +11,9 @@
11
11
  <label class="form-label" for="<%= input_id %>">
12
12
  <%= label %><%= ' *' if required %>
13
13
  </label>
14
- <div data-controller="lato-cms-json-field">
14
+ <div data-controller="lato-cms-json-field"
15
+ data-lato-cms-json-field-valid-label-value="<%= t('lato_cms.field_json_valid') %>"
16
+ data-lato-cms-json-field-invalid-label-value="<%= t('lato_cms.field_json_invalid', error: '%{error}') %>">
15
17
  <textarea
16
18
  class="form-control font-monospace"
17
19
  name="<%= input_name %>"
@@ -19,7 +21,8 @@
19
21
  rows="<%= settings['rows'] || 6 %>"
20
22
  data-lato-cms-json-field-target="input"
21
23
  data-action="input->lato-cms-json-field#validate"
24
+ aria-describedby="<%= input_id %>_status"
22
25
  <%= 'required' if required %>
23
26
  <% if settings['placeholder'] %>placeholder="<%= settings['placeholder'] %>"<% end %>><%= current_value %></textarea>
24
- <div data-lato-cms-json-field-target="status" class="form-text"></div>
27
+ <div data-lato-cms-json-field-target="status" class="form-text" id="<%= input_id %>_status" role="status" aria-live="polite"></div>
25
28
  </div>
@@ -12,11 +12,17 @@
12
12
  <% picker_type = kind == 'file' ? nil : kind %>
13
13
  <% wide = !multiple && %w[image video].include?(kind) %>
14
14
 
15
- <label class="form-label">
15
+ <%# Not a <label>: there is no single input to point it at, so the field is
16
+ a group labelled by this element. %>
17
+ <div class="form-label" id="<%= dom_id %>_label">
16
18
  <%= label %><%= ' *' if required %>
17
- </label>
19
+ </div>
18
20
 
19
- <div data-controller="lato-cms-media-field<%= ' lato-cms-media-reorder' if multiple %>"
21
+ <div role="group" aria-labelledby="<%= dom_id %>_label"
22
+ data-controller="lato-cms-media-field<%= ' lato-cms-media-reorder' if multiple %>"
23
+ data-lato-cms-media-field-remove-label-value="<%= t('lato_cms.field_file_remove') %>"
24
+ data-lato-cms-media-field-move-before-label-value="<%= t('lato_cms.media_move_before') %>"
25
+ data-lato-cms-media-field-move-after-label-value="<%= t('lato_cms.media_move_after') %>"
20
26
  data-lato-cms-media-field-kind-value="<%= kind %>"
21
27
  data-lato-cms-media-field-multiple-value="<%= multiple %>"
22
28
  data-lato-cms-media-field-frame-id-value="<%= frame_id %>"
@@ -57,7 +63,7 @@
57
63
  action: 'click->lato-cms-media-field#openPicker',
58
64
  action_title: t('lato_cms.media_picker_title'), action_size: 'lg'
59
65
  } do %>
60
- <i class="bi bi-images"></i>
66
+ <i class="bi bi-images" aria-hidden="true"></i>
61
67
  <span><%= t('lato_cms.media_choose_from_library') %></span>
62
68
  <% end %>
63
69
  </div>
@@ -65,7 +71,8 @@
65
71
  <%# Real-but-hidden input anchors the browser's native required-validation
66
72
  bubble (a display:none required input fails silently as "not
67
73
  focusable"). Kept in sync with selection state by the Stimulus controller. %>
68
- <input type="text" class="visually-hidden" value="<%= current_media.first&.id %>"
74
+ <input type="text" class="visually-hidden" value="<%= current_media.first&.id %>" tabindex="-1"
75
+ aria-labelledby="<%= dom_id %>_label"
69
76
  data-lato-cms-media-field-target="validationAnchor"
70
77
  <%= 'required' if required && current_media.empty? %>>
71
78
  </div>
@@ -16,7 +16,7 @@
16
16
  <video controls preload="<%= media.poster_url ? 'none' : 'metadata' %>" poster="<%= media.poster_url %>"
17
17
  class="lato-cms-media-field__player" src="<%= media.url %>"></video>
18
18
  <% elsif media.thumbnail_url %>
19
- <img src="<%= media.thumbnail_url %>" class="lato-cms-media-field__thumb" alt="<%= media.alt_text %>">
19
+ <img src="<%= media.thumbnail_url %>" class="lato-cms-media-field__thumb" alt="<%= media.alt_text.presence || media.name %>">
20
20
  <% else %>
21
21
  <div class="lato-cms-media-field__icon"><i class="bi bi-paperclip"></i></div>
22
22
  <% end %>
@@ -25,9 +25,20 @@
25
25
  <span class="lato-cms-media-field__name"><%= media.name %></span>
26
26
  </div>
27
27
 
28
- <button type="button" class="lato-cms-media-field__remove" title="<%= t('lato_cms.field_file_remove') %>" aria-label="<%= t('lato_cms.field_file_remove') %>" data-action="lato-cms-media-field#remove">
29
- <i class="bi bi-x-lg"></i>
28
+ <button type="button" class="lato-cms-media-field__remove" title="<%= t('lato_cms.field_file_remove') %>" aria-label="<%= t('lato_cms.field_file_remove') %> <%= media.name %>" data-action="lato-cms-media-field#remove">
29
+ <i class="bi bi-x-lg" aria-hidden="true"></i>
30
30
  </button>
31
+ <% if multiple %>
32
+ <%# Keyboard alternative to drag & drop reordering. %>
33
+ <button type="button" class="lato-cms-media-field__move lato-cms-media-field__move--before" title="<%= t('lato_cms.media_move_before') %>"
34
+ aria-label="<%= t('lato_cms.media_move_before') %> <%= media.name %>" data-action="lato-cms-media-reorder#moveBefore">
35
+ <i class="bi bi-arrow-left" aria-hidden="true"></i>
36
+ </button>
37
+ <button type="button" class="lato-cms-media-field__move lato-cms-media-field__move--after" title="<%= t('lato_cms.media_move_after') %>"
38
+ aria-label="<%= t('lato_cms.media_move_after') %> <%= media.name %>" data-action="lato-cms-media-reorder#moveAfter">
39
+ <i class="bi bi-arrow-right" aria-hidden="true"></i>
40
+ </button>
41
+ <% end %>
31
42
 
32
43
  <input type="hidden" name="<%= hidden_name %>" value="<%= media.id %>" data-lato-cms-media-field-target="hiddenInput">
33
44
  </div>