diff --git a/AGENTS.md b/AGENTS.md index d6546036aa..c01b90a869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (78) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -410,6 +410,7 @@ end - `searchable_checkbox` — TomSelect checkbox-style multi-select - `searchable_select` — Tom Select autocomplete - `share_url` — URL sharing/copying +- `slider` — Syncs a slider form field's range handle to its hidden input, shows the picked number in a bubble above the handle, and fills the track; backs the `:slider` answer type - `sortable` — Drag-drop sorting (SortableJS); persists order via a per-row PUT (used by categories index and the registration ticket callouts editor) - `submit_once` — Disables a form's submit button after submit to block duplicate submissions; re-enables on Back/bfcache/Turbo restore - `tabs` — Tab panel navigation diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 64be221b9e..1a4052f7c4 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -177,6 +177,9 @@ application.register("share-url", ShareUrlController) import ScrollToTopController from "./scroll_to_top_controller" application.register("scroll-to-top", ScrollToTopController) +import SliderController from "./slider_controller" +application.register("slider", SliderController) + import SortableController from "./sortable_controller" application.register("sortable", SortableController) diff --git a/app/frontend/javascript/controllers/slider_controller.js b/app/frontend/javascript/controllers/slider_controller.js new file mode 100644 index 0000000000..1c12bc8265 --- /dev/null +++ b/app/frontend/javascript/controllers/slider_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus" + +// A slider form field: dragging the range handle shows the picked number in a +// bubble above it, fills the track up to that point, and writes the whole number +// to a hidden input (the submitted value). The hidden input stays blank until +// the person interacts, so an untouched required slider still fails presence +// validation — a range input on its own would always post its default. +export default class extends Controller { + static targets = ["range", "value", "bubble"] + static values = { min: Number, max: Number } + + // Thumb width, used to nudge the bubble so it stays centered over the handle + // as it travels the track. + thumbWidth = 16 + + connect() { + // A prefilled/re-rendered value lives on the hidden input — mirror it onto + // the handle and show the bubble. + if (this.valueTarget.value !== "") { + this.rangeTarget.value = this.valueTarget.value + this.render() + } else { + this.paintTrack() + } + } + + update() { + this.valueTarget.value = this.rangeTarget.value + this.render() + } + + render() { + this.bubbleTarget.textContent = this.rangeTarget.value + this.bubbleTarget.classList.remove("hidden") + this.positionBubble() + this.paintTrack() + } + + get percent() { + return (this.rangeTarget.value - this.minValue) / (this.maxValue - this.minValue) + } + + positionBubble() { + const offset = (0.5 - this.percent) * this.thumbWidth + this.bubbleTarget.style.left = `calc(${this.percent * 100}% + ${offset}px)` + } + + // Native range inputs only color the thumb, so fill the track from the left up + // to the current value. + paintTrack() { + const filled = this.percent * 100 + this.rangeTarget.style.background = + `linear-gradient(to right, var(--color-primary, #2563eb) ${filled}%, #e5e7eb ${filled}%)` + } +} diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 2980a89531..7e740f26b8 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -141,9 +141,20 @@ class FormField < ApplicationRecord :multi_select_checkbox, :group_header, :single_select_dropdown, - :file_upload + :file_upload, + :slider ] + # Bounds for the :slider answer type. It stores a whole number in this inclusive + # range (the percentage/rating sliders on the annual evaluation are all 0–100); + # per-field configurable bounds are a future enhancement. + SLIDER_MIN = 0 + SLIDER_MAX = 100 + SLIDER_STEP = 1 + # Plain digits only, so a hand-crafted "0x40" or "1_0" (both of which Integer() + # would happily parse) can't slip past as a value the range input never produces. + SLIDER_VALUE_FORMAT = /\A-?\d+\z/ + enum :input_type, [ :text_alphanumeric, :number_integer, @@ -171,6 +182,7 @@ class FormField < ApplicationRecord "single_select_radio" => "Single select radio", "single_select_dropdown" => "Single select dropdown", "multi_select_checkbox" => "Multiple select checkbox", + "slider" => "Slider (0–100)", "file_upload" => "File upload", "no_user_input" => "Informational-only" }.freeze @@ -345,6 +357,18 @@ def max_characters_error(value) "must be #{limit} #{"character".pluralize(limit)} or fewer" end + # Returns a validation error string when a submitted slider value isn't a whole + # number within the slider's bounds, or nil when it passes / does not apply. + # Blank values are left to the presence (required) check. + def slider_range_error(value) + return unless slider? + return if value.blank? + + return if value.to_s.match?(SLIDER_VALUE_FORMAT) && value.to_i.between?(SLIDER_MIN, SLIDER_MAX) + + "must be a whole number between #{SLIDER_MIN} and #{SLIDER_MAX}" + end + # True when this field's selectable options come from Sector/Category data # rather than its own stored answer options. Dynamic fields never offer "Other". def dynamic_options? diff --git a/app/services/form_answer_validator.rb b/app/services/form_answer_validator.rb index 3524055153..ade7540c7d 100644 --- a/app/services/form_answer_validator.rb +++ b/app/services/form_answer_validator.rb @@ -45,6 +45,8 @@ def error_for(field, value) "must be a whole number" elsif field.email_field? && value.to_s !~ EMAIL_FORMAT "must be a valid email address" + elsif field.slider? + field.slider_range_error(value) else field.min_words_error(value) || field.max_characters_error(value) || field.answer_inclusion_error(value) end diff --git a/app/services/form_response_aggregator.rb b/app/services/form_response_aggregator.rb index 77ed6cad9d..09e32d3e2a 100644 --- a/app/services/form_response_aggregator.rb +++ b/app/services/form_response_aggregator.rb @@ -14,7 +14,7 @@ class FormResponseAggregator FieldReport = Struct.new( :field, :label, :kind, :answered_count, :rows, :chart, :multi, :specify_rows, :responses, - :average, :total, :minimum, :maximum, :integer_valued, + :average, :total, :minimum, :maximum, :integer_valued, :percent, keyword_init: true ) @@ -118,14 +118,15 @@ def build_report(field) build_text_report(field, answers) end - # Number-typed free-form fields hold a figure worth averaging and summing - # (counts served, percentages), not free text. + # Sliders and number-typed free-form fields hold a figure worth averaging and + # summing (percentages, counts served), not free text. def numeric_field?(field) - field.number_integer? || field.number_decimal? + field.slider? || field.number_integer? || field.number_decimal? end - # An average / total / range summary of a number question. Non-numeric stray - # values are dropped rather than skewing the figures. + # An average / total / range summary of a numeric question. Sliders are + # percentages (headlined with a %, 0-100); number fields are open-ended counts. + # Non-numeric stray values are dropped rather than skewing the figures. def build_numeric_report(field, answers) values = answers.filter_map { |answer| numeric_value(field, answer.submitted_answer) } count = values.size @@ -136,7 +137,7 @@ def build_numeric_report(field, answers) total: total, average: count.zero? ? nil : total.to_f / count, minimum: values.min, maximum: values.max, - integer_valued: !field.number_decimal? + integer_valued: !field.number_decimal?, percent: field.slider? ) end diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index b9dd29d3b2..274b05d30b 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -177,6 +177,35 @@ <% end %> <% end %> + <% when "slider" %> + <%# A 0–100 slider: dragging the handle shows the picked number in a bubble + above it and stores a whole number in the hidden input. The hidden input + starts blank so an untouched required slider still fails the server's + presence check (a range input would always post its default). %> +
Accepted: <%= FormUploadAsset.accepted_types_label %> (max <%= FormUploadAsset.max_file_size_label %>)
<%= form_label_html(field.hint_text) %>
+<%= form_label_html(field.hint_text) %>
<% end %> <% if field.free_form_text? && field.min_words.to_i.positive? %> -Minimum of <%= field.min_words %> <%= "word".pluralize(field.min_words) %>.
+Minimum of <%= field.min_words %> <%= "word".pluralize(field.min_words) %>.
<% end %> <% if field.free_form_text? && field.max_characters.to_i.positive? %> -Maximum of <%= field.max_characters %> <%= "character".pluralize(field.max_characters) %>.
+Maximum of <%= field.max_characters %> <%= "character".pluralize(field.max_characters) %>.
<% end %> <% if error %> -+
<%= strip_tags(field.name) %> <%= error %>
<% end %> diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb index c46610cffc..3d350540f7 100644 --- a/app/views/forms/_form_field_fields.html.erb +++ b/app/views/forms/_form_field_fields.html.erb @@ -68,7 +68,7 @@No responses yet.
<% else %> + <% if report.percent %> +- Range <%= number_with_delimiter(report.minimum.round(decimals)) %>–<%= number_with_delimiter(report.maximum.round(decimals)) %> + Range <%= number_with_delimiter(report.minimum.round(decimals)) %>–<%= number_with_delimiter(report.maximum.round(decimals)) %><%= suffix %>
<% end %>