Skip to content

Commit e29cb78

Browse files
committed
[Toolkit][Shadcn] Add questionnaire recipe
1 parent 565e9f1 commit e29cb78

34 files changed

Lines changed: 3076 additions & 0 deletions

File tree

src/Toolkit/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Add support for blocks: ready-to-use page sections built from a kit's components, installed and previewed as a unit
77
- [Shadcn] Add the `login-01` and `login-02` login blocks
88
- [Shadcn] Add `input-otp` recipe
9+
- [Shadcn] Add `questionnaire` recipe
910
- [Shadcn] Add `menubar` recipe
1011
- [Shadcn] Add `navigation-menu` recipe
1112
- [Shadcn] Add `popover` recipe

src/Toolkit/kits/shadcn/questionnaire/README.md

Lines changed: 705 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
4+
const DEFAULT_ERROR = 'Please answer this question.';
5+
6+
export default class extends Controller {
7+
static targets = [
8+
'item',
9+
'progress',
10+
'progressCurrent',
11+
'progressTotal',
12+
'progressStep',
13+
'answer',
14+
'freeform',
15+
'error',
16+
'previous',
17+
'skip',
18+
'next',
19+
'submit',
20+
];
21+
static values = { activeItem: String, shortcuts: String };
22+
23+
// The freeform input shares its name with the fixed choices, so while it is empty its name is
24+
// parked here to keep it from submitting a value that shadows the selected answer.
25+
_freeformNames = new WeakMap();
26+
27+
freeformTargetConnected(input) {
28+
this._freeformNames.set(input, input.getAttribute('name'));
29+
this._syncFreeformName(input);
30+
}
31+
32+
connect() {
33+
this._assignShortcuts();
34+
35+
if (!this._itemByName(this.activeItemValue)) {
36+
const [first] = this._navigableItems();
37+
this.activeItemValue = first ? first.dataset.name : '';
38+
}
39+
40+
this._render();
41+
}
42+
43+
activeItemValueChanged() {
44+
this._render();
45+
}
46+
47+
next() {
48+
if (!this._validate(this.activeItem)) return;
49+
50+
// Only promote to `answered`, so navigating past a skipped item does not undo the skip.
51+
if (this._isAnswered(this.activeItem)) {
52+
this._setStatus(this.activeItem, 'answered');
53+
}
54+
this._move(1);
55+
}
56+
57+
previous() {
58+
this._clearError(this.activeItem);
59+
this._move(-1);
60+
}
61+
62+
skip() {
63+
const item = this.activeItem;
64+
if (!item) return;
65+
66+
this._reset(item);
67+
this._setStatus(item, 'skipped');
68+
this._clearError(item);
69+
this._move(1);
70+
}
71+
72+
onSubmit(event) {
73+
if (!this._validate(this.activeItem)) {
74+
event.preventDefault();
75+
return;
76+
}
77+
78+
if (this._isAnswered(this.activeItem)) {
79+
this._setStatus(this.activeItem, 'answered');
80+
}
81+
}
82+
83+
onAnswerChange(event) {
84+
const item = this._itemOf(event.target);
85+
if (!item || !this.answerTargets.includes(event.target)) return;
86+
87+
if (event.target.dataset.slot === 'questionnaire-choice-input') {
88+
this._clearFreeform(item);
89+
}
90+
91+
this._setStatus(item, this._isAnswered(item) ? 'answered' : 'unanswered');
92+
this._clearError(item);
93+
this._render();
94+
}
95+
96+
onAnswerInput(event) {
97+
const input = event.target;
98+
if (!this.freeformTargets.includes(input)) return;
99+
100+
const item = this._itemOf(input);
101+
if (!item) return;
102+
103+
this._syncFreeformName(input);
104+
105+
if (input.value && item.dataset.multiple !== 'true') {
106+
this._choicesOf(item).forEach((choice) => (choice.checked = false));
107+
}
108+
109+
this._setStatus(item, this._isAnswered(item) ? 'answered' : 'unanswered');
110+
this._clearError(item);
111+
this._render();
112+
}
113+
114+
onKeyDown(event) {
115+
if (event.metaKey || event.ctrlKey || event.altKey) return;
116+
117+
const item = this.activeItem;
118+
if (!item) return;
119+
120+
if (event.key === 'Enter') {
121+
if (event.target.type === 'submit') return;
122+
123+
event.preventDefault();
124+
if (this._isLast(item)) {
125+
this.element.requestSubmit();
126+
} else {
127+
this.next();
128+
}
129+
return;
130+
}
131+
132+
if (this.freeformTargets.includes(event.target)) return;
133+
134+
const choice = this._choicesOf(item).find(
135+
(input) => input.closest('[data-slot="questionnaire-choice"]')?.dataset.shortcut === event.key.toUpperCase()
136+
);
137+
if (!choice || choice.disabled) return;
138+
139+
event.preventDefault();
140+
choice.checked = item.dataset.multiple === 'true' ? !choice.checked : true;
141+
choice.dispatchEvent(new Event('change', { bubbles: true }));
142+
}
143+
144+
get activeItem() {
145+
return this._itemByName(this.activeItemValue);
146+
}
147+
148+
_move(offset) {
149+
const items = this._navigableItems();
150+
const index = items.indexOf(this.activeItem);
151+
const target = items[index + offset];
152+
153+
if (target) {
154+
this.activeItemValue = target.dataset.name;
155+
}
156+
}
157+
158+
_render() {
159+
const items = this._navigableItems();
160+
const active = this.activeItem;
161+
162+
this.itemTargets.forEach((item) => {
163+
const isActive = item === active;
164+
item.dataset.active = isActive ? 'true' : 'false';
165+
item.toggleAttribute('hidden', !isActive);
166+
item.toggleAttribute('inert', !isActive);
167+
});
168+
169+
const current = Math.max(items.indexOf(active) + 1, 1);
170+
const total = Math.max(items.length, 1);
171+
172+
this.progressTargets.forEach((progress) => {
173+
progress.setAttribute('aria-valuenow', String(current));
174+
progress.setAttribute('aria-valuemax', String(total));
175+
});
176+
this.progressCurrentTargets.forEach((element) => (element.textContent = String(current)));
177+
this.progressTotalTargets.forEach((element) => (element.textContent = String(total)));
178+
this.progressStepTargets.forEach((step, index) => {
179+
step.dataset.active = index < current ? 'true' : 'false';
180+
});
181+
182+
const first = active === items[0];
183+
const last = this._isLast(active);
184+
185+
this._toggle(this.previousTargets, !first);
186+
this._toggle(this.skipTargets, Boolean(active) && active.dataset.required !== 'true');
187+
this._toggle(this.nextTargets, !last);
188+
this._toggle(this.submitTargets, last);
189+
}
190+
191+
_toggle(elements, visible) {
192+
elements.forEach((element) => {
193+
element.toggleAttribute('hidden', !visible);
194+
element.disabled = !visible;
195+
});
196+
}
197+
198+
_validate(item) {
199+
if (!item || item.dataset.required !== 'true' || this._isAnswered(item)) return true;
200+
201+
this._showError(item);
202+
203+
return false;
204+
}
205+
206+
_showError(item) {
207+
item.setAttribute('aria-invalid', 'true');
208+
209+
const [error] = this._errorsOf(item);
210+
if (!error) return;
211+
212+
if (!error.textContent.trim()) {
213+
error.textContent = DEFAULT_ERROR;
214+
}
215+
error.removeAttribute('hidden');
216+
item.setAttribute('aria-describedby', error.id);
217+
218+
const [answer] = this._answersOf(item).filter((input) => !input.disabled);
219+
answer?.focus();
220+
}
221+
222+
_clearError(item) {
223+
if (!item) return;
224+
225+
item.removeAttribute('aria-invalid');
226+
item.removeAttribute('aria-describedby');
227+
this._errorsOf(item).forEach((error) => error.setAttribute('hidden', ''));
228+
}
229+
230+
_reset(item) {
231+
this._choicesOf(item).forEach((choice) => (choice.checked = false));
232+
this._clearFreeform(item);
233+
}
234+
235+
_clearFreeform(item) {
236+
this._freeformOf(item).forEach((input) => {
237+
input.value = '';
238+
this._syncFreeformName(input);
239+
});
240+
}
241+
242+
_syncFreeformName(input) {
243+
const name = this._freeformNames.get(input);
244+
if (!name) return;
245+
246+
if (input.value.trim()) {
247+
input.setAttribute('name', name);
248+
} else {
249+
input.removeAttribute('name');
250+
}
251+
}
252+
253+
_isAnswered(item) {
254+
return (
255+
this._choicesOf(item).some((choice) => choice.checked) ||
256+
this._freeformOf(item).some((input) => input.value.trim())
257+
);
258+
}
259+
260+
_isLast(item) {
261+
const items = this._navigableItems();
262+
263+
return !item || item === items[items.length - 1];
264+
}
265+
266+
_setStatus(item, status) {
267+
if (item) {
268+
item.dataset.status = status;
269+
}
270+
}
271+
272+
_assignShortcuts() {
273+
const mode = this.shortcutsValue;
274+
275+
this.itemTargets.forEach((item) => {
276+
this._choicesOf(item).forEach((choice, index) => {
277+
const label = choice.closest('[data-slot="questionnaire-choice"]');
278+
if (!label) return;
279+
280+
const shortcut = mode === 'letters' ? LETTERS[index] : mode === 'numbers' ? String(index + 1) : null;
281+
282+
if (!shortcut) {
283+
delete label.dataset.shortcut;
284+
285+
return;
286+
}
287+
288+
label.dataset.shortcut = shortcut;
289+
const badge = label.querySelector('[data-slot="questionnaire-choice-shortcut"]');
290+
if (badge) {
291+
badge.textContent = shortcut;
292+
}
293+
});
294+
});
295+
}
296+
297+
_navigableItems() {
298+
return this.itemTargets.filter((item) => !item.disabled);
299+
}
300+
301+
_itemByName(name) {
302+
return name ? this._navigableItems().find((item) => item.dataset.name === name) : undefined;
303+
}
304+
305+
_itemOf(element) {
306+
return this.itemTargets.find((item) => item.contains(element));
307+
}
308+
309+
_answersOf(item) {
310+
return this.answerTargets.filter((input) => item.contains(input));
311+
}
312+
313+
_choicesOf(item) {
314+
return this._answersOf(item).filter((input) => input.dataset.slot === 'questionnaire-choice-input');
315+
}
316+
317+
_freeformOf(item) {
318+
return this.freeformTargets.filter((input) => item.contains(input));
319+
}
320+
321+
_errorsOf(item) {
322+
return this.errorTargets.filter((error) => item.contains(error));
323+
}
324+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"$schema": "../../../schema-kit-recipe-v1.json",
3+
"type": "component",
4+
"name": "Questionnaire",
5+
"version-added": "3.5",
6+
"copy-files": {
7+
"assets/": "assets/",
8+
"templates/": "templates/"
9+
},
10+
"dependencies": {
11+
"recipe": ["button"],
12+
"composer": [
13+
"twig/html-extra:^3.24.0",
14+
"symfony/ux-icons",
15+
"symfony/ux-twig-component:^3.5",
16+
"tales-from-a-dev/twig-tailwind-extra:^1.3.0"
17+
]
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{# @prop id string Unique identifier used to generate internal Questionnaire IDs. #}
2+
{# @prop defaultItem string Name of the `Questionnaire:Item` shown on initial render. #}
3+
{# @prop shortcuts 'letters'|'numbers'|null Keyboard shortcut style assigned to each answer. #}
4+
{# @block content The questionnaire structure, typically includes `Questionnaire:Progress`, `Questionnaire:Item` and `Questionnaire:Actions`. #}
5+
{%- props id = 'questionnaire', defaultItem = '', shortcuts = null -%}
6+
{%- do provide('questionnaire.id', id) -%}
7+
{%- do provide('questionnaire.defaultItem', defaultItem) -%}
8+
<form
9+
id="{{ id }}"
10+
data-slot="questionnaire"
11+
data-questionnaire-active-item-value="{{ defaultItem }}"
12+
data-questionnaire-shortcuts-value="{{ shortcuts }}"
13+
{{ attributes.defaults({
14+
class: 'flex w-full min-w-0 flex-col gap-4'|tailwind_classes,
15+
'data-controller': 'questionnaire',
16+
'data-action': 'keydown->questionnaire#onKeyDown submit->questionnaire#onSubmit change->questionnaire#onAnswerChange input->questionnaire#onAnswerInput',
17+
}) }}
18+
>
19+
{%- block content %}{% endblock -%}
20+
</form>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{# @block content The navigation buttons, typically `Questionnaire:Previous`, `Questionnaire:Skip`, `Questionnaire:Next` and `Questionnaire:Submit`. #}
2+
<div
3+
data-slot="questionnaire-actions"
4+
{{ attributes.defaults({
5+
class: 'grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 sm:min-h-8'|tailwind_classes,
6+
}) }}
7+
>
8+
{%- block content %}{% endblock -%}
9+
</div>

0 commit comments

Comments
 (0)