studio-engine 0.86.0 → 0.88.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +4 -1
- data/app/controllers/studio/admin_surveys_controller.rb +94 -0
- data/app/controllers/studio/surveys_controller.rb +173 -0
- data/app/helpers/studio/site_footer_helper.rb +9 -6
- data/app/models/studio/survey_response.rb +150 -0
- data/app/views/studio/admin_surveys/index.html.erb +41 -0
- data/app/views/studio/admin_surveys/show.html.erb +99 -0
- data/app/views/studio/surveys/_question.html.erb +77 -0
- data/app/views/studio/surveys/_script.html.erb +288 -0
- data/app/views/studio/surveys/_styles.html.erb +95 -0
- data/app/views/studio/surveys/not_found.html.erb +11 -0
- data/app/views/studio/surveys/show.html.erb +60 -0
- data/app/views/studio/surveys/sign_in_required.html.erb +15 -0
- data/app/views/studio/surveys/thanks.html.erb +16 -0
- data/app/views/style/_site_footer.html.erb +4 -2
- data/db/migrate/20261005120000_create_studio_survey_responses.rb +47 -0
- data/docs/SITE_FOOTER.md +91 -12
- data/lib/studio/engine.rb +9 -0
- data/lib/studio/site_footer.rb +71 -8
- data/lib/studio/survey/breakdown.rb +96 -0
- data/lib/studio/survey/export.rb +65 -0
- data/lib/studio/survey.rb +303 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +120 -10
- metadata +18 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<%# /admin/surveys/:slug — one survey's results (Studio::AdminSurveysController).
|
|
2
|
+
Distribution bars for scale and choice questions (percent of those who
|
|
3
|
+
answered that question), a scrolling list for text answers, and filters
|
|
4
|
+
that the CSV export carries along. %>
|
|
5
|
+
<% title = @survey&.title || @slug %>
|
|
6
|
+
<% content_for(:title, "#{title} · Surveys") %>
|
|
7
|
+
<% filter_params = { from: @filters[:from]&.iso8601, to: @filters[:to]&.iso8601, completed: (@filters[:completed] ? "1" : nil) }.compact %>
|
|
8
|
+
<div class="max-w-4xl mx-auto px-4 py-6" data-admin-survey="<%= @slug %>">
|
|
9
|
+
<a href="<%= admin_surveys_path %>" class="text-sm text-secondary hover:underline">← All surveys</a>
|
|
10
|
+
<div class="flex flex-wrap items-end justify-between gap-3 mt-2 mb-6">
|
|
11
|
+
<div>
|
|
12
|
+
<h1 class="text-2xl font-extrabold text-heading"><%= title %></h1>
|
|
13
|
+
<p class="text-xs text-muted font-mono">/surveys/<%= @slug %><% if @survey %> · version <%= @survey.version %><% end %></p>
|
|
14
|
+
</div>
|
|
15
|
+
<a href="<%= admin_survey_export_path(@slug, **filter_params) %>" class="btn btn-neutral btn-sm" data-survey-export>Export CSV</a>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<form method="get" action="<%= admin_survey_path(@slug) %>" class="card p-4 mb-6 flex flex-wrap items-end gap-4" data-survey-filters>
|
|
19
|
+
<label class="flex flex-col gap-1 text-sm">
|
|
20
|
+
<span class="label-upper">From</span>
|
|
21
|
+
<input type="date" name="from" value="<%= @filters[:from]&.iso8601 %>" class="input-field py-2">
|
|
22
|
+
</label>
|
|
23
|
+
<label class="flex flex-col gap-1 text-sm">
|
|
24
|
+
<span class="label-upper">To</span>
|
|
25
|
+
<input type="date" name="to" value="<%= @filters[:to]&.iso8601 %>" class="input-field py-2">
|
|
26
|
+
</label>
|
|
27
|
+
<label class="flex items-center gap-2 text-sm text-body py-2">
|
|
28
|
+
<input type="checkbox" name="completed" value="1" <%= "checked" if @filters[:completed] %>>
|
|
29
|
+
Completed only
|
|
30
|
+
</label>
|
|
31
|
+
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
|
32
|
+
<% if filter_params.any? %><a href="<%= admin_survey_path(@slug) %>" class="text-sm text-secondary hover:underline py-2">Clear</a><% end %>
|
|
33
|
+
</form>
|
|
34
|
+
|
|
35
|
+
<div class="grid grid-cols-3 gap-3 mb-6">
|
|
36
|
+
<div class="card p-4 text-center">
|
|
37
|
+
<div class="label-upper mb-1">Started</div>
|
|
38
|
+
<div class="text-2xl font-bold text-heading tabular-nums" data-stat="started"><%= @started %></div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="card p-4 text-center">
|
|
41
|
+
<div class="label-upper mb-1">Completed</div>
|
|
42
|
+
<div class="text-2xl font-bold text-heading tabular-nums" data-stat="completed"><%= @completed %></div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="card p-4 text-center">
|
|
45
|
+
<div class="label-upper mb-1">Completion</div>
|
|
46
|
+
<div class="text-2xl font-bold text-heading tabular-nums" data-stat="rate"><%= @started.zero? ? "—" : "#{Studio::Survey::Breakdown.percent(@completed, @started)}%" %></div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<% if @breakdown.nil? %>
|
|
51
|
+
<div class="empty-state">
|
|
52
|
+
<p class="text-heading font-semibold mb-1">This survey's definition was removed</p>
|
|
53
|
+
<p class="text-sm text-muted">Its responses are still stored — the CSV export lists every answer under its original question key.</p>
|
|
54
|
+
</div>
|
|
55
|
+
<% else %>
|
|
56
|
+
<div class="flex flex-col gap-4">
|
|
57
|
+
<% @breakdown.results.each_with_index do |result, i| %>
|
|
58
|
+
<% q = result.question %>
|
|
59
|
+
<section class="card p-5" data-survey-question="<%= q.key %>">
|
|
60
|
+
<div class="flex flex-wrap items-baseline justify-between gap-2 mb-4">
|
|
61
|
+
<h2 class="text-base font-bold text-heading"><span class="text-muted font-medium mr-1"><%= i + 1 %>.</span><%= q.label %></h2>
|
|
62
|
+
<p class="text-xs text-muted">
|
|
63
|
+
<%= pluralize(result.answered, "answer") %><% if result.average %> · average <span class="font-semibold text-heading" data-survey-average><%= result.average %></span> / 5<% end %>
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<% if result.text? %>
|
|
68
|
+
<% if result.entries.empty? %>
|
|
69
|
+
<p class="text-sm text-muted">No answers yet.</p>
|
|
70
|
+
<% else %>
|
|
71
|
+
<ul class="flex flex-col gap-2 overflow-y-auto pr-1" style="max-height: 22rem;" data-survey-text-answers>
|
|
72
|
+
<% result.entries.each do |entry| %>
|
|
73
|
+
<li class="bg-inset rounded-lg px-3 py-2">
|
|
74
|
+
<p class="text-sm text-body whitespace-pre-line break-words"><%= entry.text %></p>
|
|
75
|
+
<p class="text-xs text-muted mt-1"><%= entry.respondent %><% if entry.at %> · <%= entry.at.strftime("%b %-d, %Y") %><% end %></p>
|
|
76
|
+
</li>
|
|
77
|
+
<% end %>
|
|
78
|
+
</ul>
|
|
79
|
+
<% end %>
|
|
80
|
+
<% else %>
|
|
81
|
+
<ul class="flex flex-col gap-2" data-survey-buckets>
|
|
82
|
+
<% result.buckets.each do |bucket| %>
|
|
83
|
+
<li class="grid items-center gap-3" style="grid-template-columns: minmax(6rem, 11rem) 1fr 4.5rem;" data-survey-bucket="<%= bucket.value %>">
|
|
84
|
+
<span class="text-sm text-body truncate" title="<%= bucket.label %>">
|
|
85
|
+
<% if bucket.face %><span aria-hidden="true" class="mr-1"><%= bucket.face %></span><% end %><%= bucket.label %><% end_label = { 1 => q.low_label, 5 => q.high_label }[bucket.value] if q.type == :rating %><% if end_label %> <span class="text-xs text-muted">· <%= end_label %></span><% end %><% if bucket.retired %> <span class="text-xs text-muted">(retired)</span><% end %>
|
|
86
|
+
</span>
|
|
87
|
+
<span class="h-3 rounded-full bg-inset overflow-hidden" role="img" aria-label="<%= bucket.percent %> percent">
|
|
88
|
+
<span class="block h-full rounded-full" style="width: <%= bucket.percent %>%; background: var(--color-cta);"></span>
|
|
89
|
+
</span>
|
|
90
|
+
<span class="text-sm tabular-nums text-right text-heading"><span data-survey-percent><%= bucket.percent %>%</span> <span class="text-xs text-muted">(<span data-survey-count><%= bucket.count %></span>)</span></span>
|
|
91
|
+
</li>
|
|
92
|
+
<% end %>
|
|
93
|
+
</ul>
|
|
94
|
+
<% end %>
|
|
95
|
+
</section>
|
|
96
|
+
<% end %>
|
|
97
|
+
</div>
|
|
98
|
+
<% end %>
|
|
99
|
+
</div>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<%# One survey question as a fieldset (studio/surveys/show). It is a complete
|
|
2
|
+
form control without JavaScript: native radios, checkboxes and text fields
|
|
3
|
+
named answers[key], with `required` so the no-JS form validates in the
|
|
4
|
+
browser too. The script enhances it into one screen of the stepper. %>
|
|
5
|
+
<% key = question.key %>
|
|
6
|
+
<% dom = "studio-survey-#{key}" %>
|
|
7
|
+
<% value = response&.answer_value(key) %>
|
|
8
|
+
<% ends = %i[emoji_scale rating].include?(question.type) && (question.low_label || question.high_label) %>
|
|
9
|
+
<% described = [("#{dom}-help" if question.help), ("#{dom}-scale" if ends), "#{dom}-error"].compact.join(" ") %>
|
|
10
|
+
<fieldset class="studio-survey__step" id="<%= dom %>" data-survey-step
|
|
11
|
+
data-key="<%= key %>" data-type="<%= question.type %>" data-required="<%= question.required? %>"
|
|
12
|
+
aria-describedby="<%= described %>">
|
|
13
|
+
<legend class="studio-survey__question" id="<%= dom %>-label" tabindex="-1" data-survey-focus>
|
|
14
|
+
<span class="studio-survey__qnum">Question <%= index + 1 %> of <%= total %></span>
|
|
15
|
+
<%= question.label %><% if question.required? %><span class="studio-survey__req" aria-hidden="true">*</span><span class="studio-survey__sr"> (required)</span><% end %>
|
|
16
|
+
</legend>
|
|
17
|
+
<% if question.help %>
|
|
18
|
+
<p class="studio-survey__help" id="<%= dom %>-help"><%= question.help %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<div class="studio-survey__body">
|
|
22
|
+
<% case question.type %>
|
|
23
|
+
<% when :emoji_scale, :rating %>
|
|
24
|
+
<div class="studio-survey__scale">
|
|
25
|
+
<% question.options.each_with_index do |opt, i| %>
|
|
26
|
+
<label class="studio-survey__tile" data-survey-choice>
|
|
27
|
+
<input type="radio" class="studio-survey__input" name="answers[<%= key %>]" value="<%= opt.value %>"
|
|
28
|
+
<%= "checked" if value.to_s == opt.value.to_s %> <%= "required" if question.required? %>
|
|
29
|
+
<% if opt.face %>aria-label="<%= opt.label %>"<% end %>>
|
|
30
|
+
<% if opt.face %>
|
|
31
|
+
<span class="studio-survey__face" aria-hidden="true"><%= opt.face %></span>
|
|
32
|
+
<span class="studio-survey__tile-label" aria-hidden="true"><%= opt.label %></span>
|
|
33
|
+
<% else %>
|
|
34
|
+
<span class="studio-survey__num"><%= opt.label %></span>
|
|
35
|
+
<% end %>
|
|
36
|
+
<span class="studio-survey__kbd" aria-hidden="true"><%= i + 1 %></span>
|
|
37
|
+
</label>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
<% if ends %>
|
|
41
|
+
<div class="studio-survey__ends" aria-hidden="true">
|
|
42
|
+
<span>1 · <%= question.low_label %></span><span><%= question.high_label %> · 5</span>
|
|
43
|
+
</div>
|
|
44
|
+
<p class="studio-survey__sr" id="<%= dom %>-scale">1 means <%= question.low_label || "lowest" %>, 5 means <%= question.high_label || "highest" %>.</p>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% when :choice, :multi_choice %>
|
|
47
|
+
<% multi = question.multi? %>
|
|
48
|
+
<% chosen = Array(value).map(&:to_s) %>
|
|
49
|
+
<div class="studio-survey__options">
|
|
50
|
+
<% if multi %><input type="hidden" name="answers[<%= key %>][]" value=""><% end %>
|
|
51
|
+
<% question.options.each_with_index do |opt, i| %>
|
|
52
|
+
<label class="studio-survey__option" data-survey-choice>
|
|
53
|
+
<input type="<%= multi ? "checkbox" : "radio" %>" class="studio-survey__input"
|
|
54
|
+
name="answers[<%= key %>]<%= "[]" if multi %>" value="<%= opt.value %>"
|
|
55
|
+
<%= "checked" if chosen.include?(opt.value.to_s) %> <%= "required" if question.required? && !multi %>>
|
|
56
|
+
<span class="studio-survey__mark<%= " studio-survey__mark--box" if multi %>" aria-hidden="true"></span>
|
|
57
|
+
<span class="studio-survey__option-label"><%= opt.label %></span>
|
|
58
|
+
<% if i < 9 %><span class="studio-survey__kbd" aria-hidden="true"><%= i + 1 %></span><% end %>
|
|
59
|
+
</label>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
<% when :short_text %>
|
|
63
|
+
<input type="text" class="input-field studio-survey__text" id="<%= dom %>-input" name="answers[<%= key %>]"
|
|
64
|
+
value="<%= value %>" maxlength="<%= question.max_length %>" aria-labelledby="<%= dom %>-label"
|
|
65
|
+
placeholder="<%= question.placeholder || "Type your answer" %>" autocomplete="off"
|
|
66
|
+
<%= "required" if question.required? %> data-survey-text>
|
|
67
|
+
<% when :long_text %>
|
|
68
|
+
<textarea class="input-field studio-survey__text" id="<%= dom %>-input" name="answers[<%= key %>]" rows="5"
|
|
69
|
+
maxlength="<%= question.max_length %>" aria-labelledby="<%= dom %>-label"
|
|
70
|
+
placeholder="<%= question.placeholder || "Type your answer" %>"
|
|
71
|
+
<%= "required" if question.required? %> data-survey-text><%= value %></textarea>
|
|
72
|
+
<p class="studio-survey__counter" aria-hidden="true"><span data-survey-counter><%= value.to_s.length %></span> / <%= question.max_length %></p>
|
|
73
|
+
<% end %>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<p class="studio-survey__error" id="<%= dom %>-error" role="alert" data-survey-error <%= "hidden" unless error %>><%= error %></p>
|
|
77
|
+
</fieldset>
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
<%# The survey stepper: progressive enhancement over studio/surveys/show.
|
|
2
|
+
Plain JavaScript (no Alpine dependency), idempotent per root so a Turbo
|
|
3
|
+
render that re-runs it never double-binds. Bound roots are tracked in a
|
|
4
|
+
WeakSet, never in a DOM attribute: Turbo's snapshot cache clones the page,
|
|
5
|
+
and a clone keeps every attribute but none of the listeners, so a marker
|
|
6
|
+
in the DOM would make a restored page look set up while it is dead.
|
|
7
|
+
Autosave is best effort: the final Submit posts the whole form, so a
|
|
8
|
+
failed autosave loses nothing. %>
|
|
9
|
+
<%= javascript_tag nonce: true do %>
|
|
10
|
+
(function () {
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
// Shared across re-runs of this script (Turbo evaluates it on every visit).
|
|
14
|
+
var bound = window.__studioSurveyBound || (window.__studioSurveyBound = new WeakSet());
|
|
15
|
+
|
|
16
|
+
function init(root) {
|
|
17
|
+
if (bound.has(root)) return;
|
|
18
|
+
bound.add(root);
|
|
19
|
+
|
|
20
|
+
var form = root.querySelector("[data-survey-form]");
|
|
21
|
+
var steps = Array.prototype.slice.call(root.querySelectorAll("[data-survey-step]"));
|
|
22
|
+
var total = steps.length - 1;
|
|
23
|
+
var back = root.querySelector("[data-survey-back]");
|
|
24
|
+
var next = root.querySelector("[data-survey-next]");
|
|
25
|
+
var submit = root.querySelector("[data-survey-submit]");
|
|
26
|
+
var top = root.querySelector("[data-survey-top]");
|
|
27
|
+
var bar = root.querySelector("[data-survey-progress]");
|
|
28
|
+
var fill = root.querySelector("[data-survey-progress-fill]");
|
|
29
|
+
var count = root.querySelector("[data-survey-count]");
|
|
30
|
+
var status = root.querySelector("[data-survey-status]");
|
|
31
|
+
var hint = root.querySelector("[data-survey-hint]");
|
|
32
|
+
// A page restored from Turbo's cache comes back on the step it was left
|
|
33
|
+
// on; resume there rather than at the server's original resume point.
|
|
34
|
+
var restored = -1;
|
|
35
|
+
// The GLOBAL token, not the form's: with per_form_csrf_tokens on, the
|
|
36
|
+
// form's hidden token is bound to the POST and refused on the PATCH.
|
|
37
|
+
var csrf = root.dataset.csrf || "";
|
|
38
|
+
var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
39
|
+
var saved = {};
|
|
40
|
+
var timers = {};
|
|
41
|
+
var current = -1;
|
|
42
|
+
var pointerPick = false;
|
|
43
|
+
var submitting = false;
|
|
44
|
+
|
|
45
|
+
form.noValidate = true;
|
|
46
|
+
if (root.classList.contains("is-enhanced")) {
|
|
47
|
+
Array.prototype.forEach.call(root.querySelectorAll("[data-survey-step]"), function (step, i) {
|
|
48
|
+
if (step.classList.contains("is-active")) restored = i;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
root.classList.add("is-enhanced");
|
|
52
|
+
top.hidden = false;
|
|
53
|
+
|
|
54
|
+
function typeOf(step) { return step.dataset.type || ""; }
|
|
55
|
+
function isChoice(step) { return /^(emoji_scale|rating|choice)$/.test(typeOf(step)); }
|
|
56
|
+
function isMulti(step) { return typeOf(step) === "multi_choice"; }
|
|
57
|
+
function inputs(step) { return Array.prototype.slice.call(step.querySelectorAll("input.studio-survey__input")); }
|
|
58
|
+
|
|
59
|
+
function valueOf(step) {
|
|
60
|
+
if (isMulti(step)) return inputs(step).filter(function (i) { return i.checked; }).map(function (i) { return i.value; });
|
|
61
|
+
if (isChoice(step)) {
|
|
62
|
+
var picked = inputs(step).filter(function (i) { return i.checked; })[0];
|
|
63
|
+
return picked ? picked.value : "";
|
|
64
|
+
}
|
|
65
|
+
var field = step.querySelector("[data-survey-text]");
|
|
66
|
+
return field ? field.value.trim() : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function answered(step) {
|
|
70
|
+
var v = valueOf(step);
|
|
71
|
+
return Array.isArray(v) ? v.length > 0 : v !== "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setError(step, message) {
|
|
75
|
+
var el = step.querySelector("[data-survey-error]");
|
|
76
|
+
if (!el) return;
|
|
77
|
+
el.textContent = message || "";
|
|
78
|
+
el.hidden = !message;
|
|
79
|
+
step.classList.toggle("is-invalid", !!message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function save(step) {
|
|
83
|
+
var key = step.dataset.key;
|
|
84
|
+
if (!key || submitting) return Promise.resolve(true);
|
|
85
|
+
var value = valueOf(step);
|
|
86
|
+
var fingerprint = JSON.stringify(value);
|
|
87
|
+
if (saved[key] === fingerprint) return Promise.resolve(true);
|
|
88
|
+
|
|
89
|
+
var body = new FormData();
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
if (value.length === 0) body.append("value[]", "");
|
|
92
|
+
value.forEach(function (v) { body.append("value[]", v); });
|
|
93
|
+
} else {
|
|
94
|
+
body.append("value", value);
|
|
95
|
+
}
|
|
96
|
+
var url = root.dataset.answerBase + encodeURIComponent(key);
|
|
97
|
+
status.textContent = "Saving…";
|
|
98
|
+
return fetch(url, {
|
|
99
|
+
method: "PATCH",
|
|
100
|
+
body: body,
|
|
101
|
+
credentials: "same-origin",
|
|
102
|
+
headers: { "Accept": "application/json", "X-CSRF-Token": csrf }
|
|
103
|
+
}).then(function (res) {
|
|
104
|
+
// fetch resolves on a 4xx/5xx — the status is the only signal.
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
107
|
+
if (res.status === 422 && data.error) setError(step, data.error);
|
|
108
|
+
status.textContent = "Not saved yet — your answers still go in when you submit.";
|
|
109
|
+
return false;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
saved[key] = fingerprint;
|
|
113
|
+
status.textContent = "Saved";
|
|
114
|
+
return true;
|
|
115
|
+
}).catch(function () {
|
|
116
|
+
status.textContent = "Offline? Your answers still go in when you submit.";
|
|
117
|
+
return false;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function render(index, direction) {
|
|
122
|
+
steps.forEach(function (step, i) {
|
|
123
|
+
var active = i === index;
|
|
124
|
+
step.hidden = !active;
|
|
125
|
+
step.classList.toggle("is-active", active);
|
|
126
|
+
if (active) {
|
|
127
|
+
if (direction && !reduce) step.dataset.dir = direction; else delete step.dataset.dir;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
current = index;
|
|
131
|
+
var onIntro = index === 0;
|
|
132
|
+
var last = index === steps.length - 1;
|
|
133
|
+
back.hidden = onIntro;
|
|
134
|
+
next.hidden = last;
|
|
135
|
+
next.textContent = onIntro ? "Start" : "Next";
|
|
136
|
+
submit.hidden = !last;
|
|
137
|
+
// The number-key shortcut only means something on a choice or scale.
|
|
138
|
+
var step = steps[index];
|
|
139
|
+
hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
|
|
140
|
+
|
|
141
|
+
var position = onIntro ? 0 : index;
|
|
142
|
+
fill.style.width = (total ? (position / total) * 100 : 0) + "%";
|
|
143
|
+
bar.setAttribute("aria-valuenow", String(position));
|
|
144
|
+
// The intro already says how many questions there are.
|
|
145
|
+
count.textContent = onIntro ? "" : "Question " + index + " of " + total;
|
|
146
|
+
|
|
147
|
+
var focusTarget = steps[index].querySelector("[data-survey-focus]");
|
|
148
|
+
if (focusTarget && direction) focusTarget.focus({ preventScroll: true });
|
|
149
|
+
if (direction) window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function go(index) {
|
|
153
|
+
if (index < 0 || index >= steps.length || index === current) return;
|
|
154
|
+
render(index, index > current ? "fwd" : "back");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function forward() {
|
|
158
|
+
var step = steps[current];
|
|
159
|
+
if (step.dataset.key) {
|
|
160
|
+
if (step.dataset.required === "true" && !answered(step)) {
|
|
161
|
+
setError(step, "This one is required.");
|
|
162
|
+
var first = step.querySelector("input:not([type=hidden]), textarea");
|
|
163
|
+
if (first) first.focus();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setError(step, "");
|
|
167
|
+
}
|
|
168
|
+
if (current === steps.length - 1) {
|
|
169
|
+
// No autosave here: the POST carries every answer, and a PATCH racing
|
|
170
|
+
// it would land after completion and be refused.
|
|
171
|
+
submitForm();
|
|
172
|
+
} else {
|
|
173
|
+
if (step.dataset.key) save(step);
|
|
174
|
+
go(current + 1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function submitForm() {
|
|
179
|
+
if (form.requestSubmit) form.requestSubmit(submit); else form.submit();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The first required question still missing an answer, for a Submit that
|
|
183
|
+
// jumps ahead (Enter on the last step, or a mid-survey reload).
|
|
184
|
+
form.addEventListener("submit", function (event) {
|
|
185
|
+
for (var i = 1; i < steps.length; i++) {
|
|
186
|
+
if (steps[i].dataset.required === "true" && !answered(steps[i])) {
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
go(i);
|
|
189
|
+
setError(steps[i], "This one is required.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
Object.keys(timers).forEach(function (key) { clearTimeout(timers[key]); });
|
|
194
|
+
submitting = true;
|
|
195
|
+
submit.disabled = true;
|
|
196
|
+
submit.textContent = "Sending…";
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
back.addEventListener("click", function () { go(current - 1); });
|
|
200
|
+
next.addEventListener("click", forward);
|
|
201
|
+
|
|
202
|
+
root.addEventListener("pointerdown", function (event) {
|
|
203
|
+
pointerPick = !!event.target.closest("[data-survey-choice]");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
root.addEventListener("change", function (event) {
|
|
207
|
+
var step = event.target.closest("[data-survey-step]");
|
|
208
|
+
if (!step || !step.dataset.key || step.hidden) return;
|
|
209
|
+
setError(step, "");
|
|
210
|
+
if (isChoice(step)) {
|
|
211
|
+
save(step);
|
|
212
|
+
// Advance on a tap or a number key, never on arrow keys: arrows move
|
|
213
|
+
// through a radio group and must not jump the reader off the question.
|
|
214
|
+
if (pointerPick) {
|
|
215
|
+
pointerPick = false;
|
|
216
|
+
setTimeout(function () { if (steps[current] === step) forward(); }, reduce ? 0 : 260);
|
|
217
|
+
}
|
|
218
|
+
} else if (isMulti(step)) {
|
|
219
|
+
save(step);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
root.addEventListener("input", function (event) {
|
|
224
|
+
var field = event.target.closest("[data-survey-text]");
|
|
225
|
+
if (!field) return;
|
|
226
|
+
var step = field.closest("[data-survey-step]");
|
|
227
|
+
var counter = step.querySelector("[data-survey-counter]");
|
|
228
|
+
if (counter) counter.textContent = String(field.value.length);
|
|
229
|
+
clearTimeout(timers[step.dataset.key]);
|
|
230
|
+
timers[step.dataset.key] = setTimeout(function () { save(step); }, 900);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
root.addEventListener("keydown", function (event) {
|
|
234
|
+
if (event.defaultPrevented || event.altKey) return;
|
|
235
|
+
// A tap on an already-chosen option fires pointerdown but no change, so
|
|
236
|
+
// the flag would outlive it and make the next arrow key advance.
|
|
237
|
+
pointerPick = false;
|
|
238
|
+
var step = steps[current];
|
|
239
|
+
var target = event.target;
|
|
240
|
+
var inText = target.matches && target.matches("textarea, input[type=text]");
|
|
241
|
+
|
|
242
|
+
if (event.key === "Enter") {
|
|
243
|
+
if (target.tagName === "BUTTON" || target.tagName === "A") return;
|
|
244
|
+
if (target.tagName === "TEXTAREA" && !(event.metaKey || event.ctrlKey)) return;
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
forward();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (inText || event.metaKey || event.ctrlKey || !/^[1-9]$/.test(event.key)) return;
|
|
251
|
+
if (!step.dataset.key || !(isChoice(step) || isMulti(step))) return;
|
|
252
|
+
var option = inputs(step)[parseInt(event.key, 10) - 1];
|
|
253
|
+
if (!option) return;
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
option.checked = isMulti(step) ? !option.checked : true;
|
|
256
|
+
option.focus({ preventScroll: true });
|
|
257
|
+
pointerPick = isChoice(step);
|
|
258
|
+
option.dispatchEvent(new Event("change", { bubbles: true }));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
var resume = root.dataset.resumeIndex;
|
|
262
|
+
var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10) + 1;
|
|
263
|
+
var hasErrors = root.dataset.hasErrors === "true";
|
|
264
|
+
// A fresh visitor sees the intro; a returning one lands on their next
|
|
265
|
+
// question, and a failed submit on the first question it flagged.
|
|
266
|
+
if (restored >= 0) start = restored;
|
|
267
|
+
render(start > 0 ? start : 0, null);
|
|
268
|
+
if (restored < 0 && (start > 0 || hasErrors)) {
|
|
269
|
+
var focusTarget = steps[current].querySelector("[data-survey-focus]");
|
|
270
|
+
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
|
271
|
+
if (start > 0 && !hasErrors) status.textContent = "Welcome back — picking up where you left off.";
|
|
272
|
+
}
|
|
273
|
+
// Answers already on the page are already stored.
|
|
274
|
+
steps.forEach(function (step) { if (step.dataset.key) saved[step.dataset.key] = JSON.stringify(valueOf(step)); });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function boot() {
|
|
278
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-studio-survey]"), init);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
|
|
282
|
+
// Turbo re-runs this script on every visit; bind the turbo:load hook once.
|
|
283
|
+
if (!window.__studioSurveyTurboHook) {
|
|
284
|
+
window.__studioSurveyTurboHook = true;
|
|
285
|
+
document.addEventListener("turbo:load", boot);
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
288
|
+
<% end %>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<%# Survey styles, scoped under .studio-survey. Plain CSS on the theme's CSS
|
|
2
|
+
custom properties (--color-cta, --color-surface, ...) rather than Tailwind
|
|
3
|
+
utilities, so the page looks the same in every host whatever its Tailwind
|
|
4
|
+
scan picks up, and follows the host's dark and light palettes for free.
|
|
5
|
+
Motion is opt-out: prefers-reduced-motion drops every transition. %>
|
|
6
|
+
<style>
|
|
7
|
+
/* overflow-x: clip, not hidden: the slide-in must never widen the page, and
|
|
8
|
+
clip makes no scroll container, so the sticky nav still sticks. */
|
|
9
|
+
.studio-survey { --sv-accent: var(--color-cta, #8E82FE); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
|
|
10
|
+
.studio-survey [hidden] { display: none !important; }
|
|
11
|
+
.studio-survey__shell { width: 100%; max-width: 36rem; }
|
|
12
|
+
.studio-survey__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
13
|
+
.studio-survey__top { margin-bottom: 1.75rem; }
|
|
14
|
+
.studio-survey__progress { height: 6px; border-radius: 999px; background: var(--color-surface-alt); overflow: hidden; }
|
|
15
|
+
.studio-survey__progress-fill { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--sv-accent); transition: width .35s cubic-bezier(.2, .8, .2, 1); }
|
|
16
|
+
.studio-survey__count { min-height: 1.25rem; margin: .5rem 0 0; font-size: .8125rem; line-height: 1.25rem; color: var(--color-text-muted); }
|
|
17
|
+
.studio-survey__step { border: 0; margin: 0 0 2.25rem; padding: 0; min-width: 0; }
|
|
18
|
+
.studio-survey__title { margin: 0 0 .75rem; font-size: 1.75rem; line-height: 1.2; font-weight: 800; color: var(--color-text); outline: none; }
|
|
19
|
+
.studio-survey__intro-copy { margin: 0 0 1rem; font-size: 1.0625rem; line-height: 1.55; color: var(--color-text-body); white-space: pre-line; }
|
|
20
|
+
.studio-survey__meta { margin: 0; font-size: .8125rem; color: var(--color-text-muted); }
|
|
21
|
+
.studio-survey__question { display: block; width: 100%; padding: 0; margin: 0 0 .5rem; font-size: 1.375rem; line-height: 1.3; font-weight: 700; color: var(--color-text); outline: none; }
|
|
22
|
+
.studio-survey__qnum { display: block; margin-bottom: .375rem; font-size: .75rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--color-text-muted); }
|
|
23
|
+
.studio-survey.is-enhanced .studio-survey__qnum { display: none; }
|
|
24
|
+
.studio-survey__req { color: var(--sv-accent); margin-left: .125rem; }
|
|
25
|
+
.studio-survey__help { margin: 0 0 1rem; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
|
|
26
|
+
.studio-survey__body { margin-top: 1rem; }
|
|
27
|
+
.studio-survey__error { margin: .75rem 0 0; font-size: .875rem; font-weight: 600; color: var(--color-danger-ink, var(--color-danger)); }
|
|
28
|
+
.studio-survey__input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
|
|
29
|
+
|
|
30
|
+
.studio-survey__scale { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .5rem; }
|
|
31
|
+
.studio-survey__tile { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .3rem; min-height: 4.75rem; padding: .75rem .25rem; border: 2px solid var(--color-border); border-radius: 1rem; background: var(--color-surface); color: var(--color-text-body); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: transform .12s ease, border-color .15s ease, background-color .15s ease; }
|
|
32
|
+
.studio-survey__tile:hover { border-color: var(--color-border-strong); }
|
|
33
|
+
.studio-survey__tile:active { transform: scale(.95); }
|
|
34
|
+
.studio-survey__face { font-size: 2.125rem; line-height: 1; transition: transform .18s cubic-bezier(.3, 1.6, .5, 1); }
|
|
35
|
+
.studio-survey__num { font-size: 1.5rem; line-height: 1; font-weight: 800; color: var(--color-text); }
|
|
36
|
+
.studio-survey__tile-label { font-size: .6875rem; line-height: 1.15; text-align: center; color: var(--color-text-secondary); }
|
|
37
|
+
.studio-survey__ends { display: flex; justify-content: space-between; gap: 1rem; margin-top: .5rem; font-size: .75rem; color: var(--color-text-muted); }
|
|
38
|
+
|
|
39
|
+
.studio-survey__options { display: flex; flex-direction: column; gap: .5rem; }
|
|
40
|
+
.studio-survey__option { position: relative; display: flex; align-items: center; gap: .75rem; min-height: 3.25rem; padding: .75rem 1rem; border: 2px solid var(--color-border); border-radius: .875rem; background: var(--color-surface); color: var(--color-text-body); font-size: 1rem; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: border-color .15s ease, background-color .15s ease; }
|
|
41
|
+
.studio-survey__option:hover { border-color: var(--color-border-strong); }
|
|
42
|
+
.studio-survey__mark { flex: none; display: grid; place-items: center; width: 1.25rem; height: 1.25rem; border: 2px solid var(--color-text-muted); border-radius: 999px; background: var(--color-surface); }
|
|
43
|
+
.studio-survey__mark--box { border-radius: .375rem; }
|
|
44
|
+
.studio-survey__option-label { flex: 1 1 auto; min-width: 0; }
|
|
45
|
+
|
|
46
|
+
.studio-survey__tile:has(input:checked), .studio-survey__option:has(input:checked) { border-color: var(--sv-accent); background: color-mix(in srgb, var(--sv-accent) 14%, var(--color-surface)); color: var(--color-text); }
|
|
47
|
+
.studio-survey__tile:has(input:checked) .studio-survey__face { transform: scale(1.18); }
|
|
48
|
+
.studio-survey__tile:has(input:checked) .studio-survey__tile-label { color: var(--color-text); font-weight: 600; }
|
|
49
|
+
.studio-survey__option:has(input:checked) .studio-survey__mark { border-color: var(--sv-accent); background: var(--sv-accent); box-shadow: inset 0 0 0 3px var(--color-surface); }
|
|
50
|
+
.studio-survey__option:has(input:checked) .studio-survey__mark--box { box-shadow: none; }
|
|
51
|
+
.studio-survey__option:has(input:checked) .studio-survey__mark--box::after { content: ""; width: .35rem; height: .65rem; margin-top: -.15rem; border: solid var(--btn-primary-fg, #fff); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
|
52
|
+
.studio-survey__tile:has(input:focus-visible), .studio-survey__option:has(input:focus-visible) { outline: 3px solid color-mix(in srgb, var(--sv-accent) 70%, transparent); outline-offset: 2px; }
|
|
53
|
+
|
|
54
|
+
.studio-survey__kbd { display: none; font-size: .625rem; font-weight: 600; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: .25rem; padding: 0 .3rem; line-height: 1.4; }
|
|
55
|
+
.studio-survey__tile .studio-survey__kbd { position: absolute; top: .3rem; right: .35rem; }
|
|
56
|
+
.studio-survey__option .studio-survey__kbd { margin-left: auto; }
|
|
57
|
+
@media (hover: hover) and (pointer: fine) { .studio-survey.is-enhanced .studio-survey__option .studio-survey__kbd { display: inline-block; } }
|
|
58
|
+
/* On a scale tile the badge needs the room a phone-width tile does not have. */
|
|
59
|
+
@media (hover: hover) and (pointer: fine) and (min-width: 640px) { .studio-survey.is-enhanced .studio-survey__tile .studio-survey__kbd { display: inline-block; } }
|
|
60
|
+
|
|
61
|
+
.studio-survey__text { width: 100%; font-size: 1rem; }
|
|
62
|
+
textarea.studio-survey__text { min-height: 9rem; resize: vertical; line-height: 1.5; }
|
|
63
|
+
.studio-survey__counter { margin: .375rem 0 0; text-align: right; font-size: .75rem; color: var(--color-text-muted); }
|
|
64
|
+
|
|
65
|
+
.studio-survey__nav { display: flex; align-items: center; gap: .75rem; }
|
|
66
|
+
.studio-survey.is-enhanced .studio-survey__nav { position: sticky; bottom: 0; padding: .75rem 0 calc(.75rem + env(safe-area-inset-bottom, 0px)); background: linear-gradient(to top, var(--color-page) 72%, transparent); }
|
|
67
|
+
.studio-survey__nav .btn { min-height: 3rem; min-width: 6.5rem; }
|
|
68
|
+
.studio-survey__forward { margin-left: auto; }
|
|
69
|
+
.studio-survey__status { min-height: 1.25rem; margin: .5rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
|
|
70
|
+
.studio-survey__hint { margin: .25rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
|
|
71
|
+
@media (hover: none), (pointer: coarse) { .studio-survey__hint { display: none; } }
|
|
72
|
+
|
|
73
|
+
.studio-survey__card { padding: 2rem 1.5rem; text-align: center; border: 1px solid var(--color-border); border-radius: 1.25rem; background: var(--color-surface); }
|
|
74
|
+
.studio-survey__burst { font-size: 3rem; line-height: 1; margin-bottom: .75rem; }
|
|
75
|
+
|
|
76
|
+
@keyframes studio-survey-in-fwd { from { opacity: 0; transform: translateX(1.5rem); } to { opacity: 1; transform: none; } }
|
|
77
|
+
@keyframes studio-survey-in-back { from { opacity: 0; transform: translateX(-1.5rem); } to { opacity: 1; transform: none; } }
|
|
78
|
+
@keyframes studio-survey-shake { 0%, 100% { transform: none; } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
|
|
79
|
+
.studio-survey__step.is-active[data-dir="fwd"] { animation: studio-survey-in-fwd .3s cubic-bezier(.2, .8, .2, 1); }
|
|
80
|
+
.studio-survey__step.is-active[data-dir="back"] { animation: studio-survey-in-back .3s cubic-bezier(.2, .8, .2, 1); }
|
|
81
|
+
.studio-survey__step.is-invalid .studio-survey__body { animation: studio-survey-shake .3s ease; }
|
|
82
|
+
@media (prefers-reduced-motion: reduce) {
|
|
83
|
+
.studio-survey *, .studio-survey *::before, .studio-survey *::after { animation: none !important; transition: none !important; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@media (min-width: 640px) {
|
|
87
|
+
.studio-survey { padding-top: 3rem; }
|
|
88
|
+
.studio-survey__question { font-size: 1.625rem; }
|
|
89
|
+
.studio-survey__title { font-size: 2.125rem; }
|
|
90
|
+
.studio-survey__scale { gap: .75rem; }
|
|
91
|
+
.studio-survey__tile { min-height: 5.5rem; }
|
|
92
|
+
.studio-survey__face { font-size: 2.5rem; }
|
|
93
|
+
.studio-survey__tile-label { font-size: .75rem; }
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%# An unknown survey slug. Says nothing about why. %>
|
|
2
|
+
<% content_for(:title, "Survey not found") %>
|
|
3
|
+
<%= render "studio/surveys/styles" %>
|
|
4
|
+
<main class="studio-survey" data-studio-survey-missing>
|
|
5
|
+
<div class="studio-survey__shell">
|
|
6
|
+
<div class="studio-survey__card">
|
|
7
|
+
<h1 class="studio-survey__title">Survey not found</h1>
|
|
8
|
+
<p class="studio-survey__intro-copy">This survey doesn't exist or has closed.</p>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
</main>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<%# /surveys/:slug — the survey (Studio::SurveysController#show, docs/SURVEYS.md).
|
|
2
|
+
|
|
3
|
+
ONE FORM, TWO PRESENTATIONS. Without JavaScript every question renders in
|
|
4
|
+
order and the Submit button posts the lot. The script below adds
|
|
5
|
+
.is-enhanced and turns the same markup into one question per screen, with a
|
|
6
|
+
progress bar, Back and Next, number-key and Enter shortcuts, and an
|
|
7
|
+
autosave on each answer. Submit is still the same POST either way, so the
|
|
8
|
+
server's validation is the one rule. %>
|
|
9
|
+
<% total = @survey.questions.size %>
|
|
10
|
+
<% minutes = [(total / 3.0).ceil, 1].max %>
|
|
11
|
+
<% content_for(:title, @survey.title) %>
|
|
12
|
+
<%= render "studio/surveys/styles" %>
|
|
13
|
+
|
|
14
|
+
<main class="studio-survey" data-studio-survey
|
|
15
|
+
data-answer-base="<%= studio_survey_path(@survey.slug) %>/answers/"
|
|
16
|
+
data-csrf="<%= form_authenticity_token %>"
|
|
17
|
+
data-resume-index="<%= @resume_index %>"
|
|
18
|
+
data-has-errors="<%= @errors.any? %>"
|
|
19
|
+
data-total="<%= total %>">
|
|
20
|
+
<div class="studio-survey__shell">
|
|
21
|
+
<div class="studio-survey__top" data-survey-top hidden>
|
|
22
|
+
<div class="studio-survey__progress" role="progressbar" aria-label="Survey progress"
|
|
23
|
+
aria-valuemin="0" aria-valuemax="<%= total %>" aria-valuenow="0" data-survey-progress>
|
|
24
|
+
<span class="studio-survey__progress-fill" data-survey-progress-fill></span>
|
|
25
|
+
</div>
|
|
26
|
+
<p class="studio-survey__count" aria-live="polite" data-survey-count></p>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<%= form_with url: studio_survey_path(@survey.slug), method: :post, local: true,
|
|
30
|
+
html: { class: "studio-survey__form", data: { survey_form: true } } do %>
|
|
31
|
+
<section class="studio-survey__step" data-survey-step data-intro aria-labelledby="studio-survey-title">
|
|
32
|
+
<h1 class="studio-survey__title" id="studio-survey-title" tabindex="-1" data-survey-focus><%= @survey.title %></h1>
|
|
33
|
+
<% if @survey.intro %>
|
|
34
|
+
<p class="studio-survey__intro-copy"><%= @survey.intro %></p>
|
|
35
|
+
<% end %>
|
|
36
|
+
<p class="studio-survey__meta">
|
|
37
|
+
<%= pluralize(total, "question") %> · about <%= pluralize(minutes, "minute") %><% if @survey.required_keys.any? %> · <span aria-hidden="true">*</span> required<% end %>
|
|
38
|
+
</p>
|
|
39
|
+
<% if @errors.any? %>
|
|
40
|
+
<p class="studio-survey__error" role="alert">A few answers need another look before this can be sent.</p>
|
|
41
|
+
<% end %>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<% @survey.questions.each_with_index do |question, index| %>
|
|
45
|
+
<%= render "studio/surveys/question", question: question, index: index, total: total,
|
|
46
|
+
response: @response, error: @errors[question.key] %>
|
|
47
|
+
<% end %>
|
|
48
|
+
|
|
49
|
+
<div class="studio-survey__nav">
|
|
50
|
+
<button type="button" class="btn btn-neutral" data-survey-back hidden>Back</button>
|
|
51
|
+
<button type="button" class="btn btn-primary studio-survey__forward" data-survey-next hidden>Start</button>
|
|
52
|
+
<button type="submit" class="btn btn-primary studio-survey__forward" data-survey-submit>Submit</button>
|
|
53
|
+
</div>
|
|
54
|
+
<p class="studio-survey__status" aria-live="polite" data-survey-status></p>
|
|
55
|
+
<p class="studio-survey__hint" data-survey-hint hidden>Press a number to choose · Enter to continue</p>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
</main>
|
|
59
|
+
|
|
60
|
+
<%= render "studio/surveys/script" %>
|