@yewdiel21/motion-kit 0.10.0 → 0.14.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.
- package/README.md +155 -93
- package/dist/auto/index.d.ts +53 -3
- package/dist/auto/index.js +101 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-IKQ5FODN.js → chunk-6P4P7NU6.js} +2 -218
- package/dist/chunk-6P4P7NU6.js.map +1 -0
- package/dist/chunk-CU6SXW3E.js +20334 -0
- package/dist/chunk-CU6SXW3E.js.map +1 -0
- package/dist/{chunk-XAKC2QY7.js → chunk-EY4BJKC4.js} +3 -28
- package/dist/chunk-EY4BJKC4.js.map +1 -0
- package/dist/chunk-QP4QJOEN.js +72 -0
- package/dist/chunk-QP4QJOEN.js.map +1 -0
- package/dist/css/components/activity-feed.css +240 -0
- package/dist/css/components/attachment.css +266 -0
- package/dist/css/components/breadcrumb.css +110 -0
- package/dist/css/components/chart-tooltip.css +168 -0
- package/dist/css/components/chart.css +296 -0
- package/dist/css/components/code-block.css +264 -0
- package/dist/css/components/color-area.css +82 -0
- package/dist/css/components/color-field.css +67 -0
- package/dist/css/components/color-picker.css +131 -0
- package/dist/css/components/color-slider.css +113 -0
- package/dist/css/components/command-search.css +252 -0
- package/dist/css/components/context-menu.css +23 -0
- package/dist/css/components/credit-card.css +288 -0
- package/dist/css/components/data-table.css +591 -0
- package/dist/css/components/date-range-picker.css +168 -0
- package/dist/css/components/date-time-picker.css +178 -0
- package/dist/css/components/drawer.css +58 -0
- package/dist/css/components/editable-table.css +201 -0
- package/dist/css/components/empty-state.css +61 -0
- package/dist/css/components/file-explorer.css +633 -0
- package/dist/css/components/filter-builder.css +232 -0
- package/dist/css/components/hold-confirm.css +87 -0
- package/dist/css/components/hover-card.css +13 -7
- package/dist/css/components/image-preview.css +195 -0
- package/dist/css/components/json-viewer.css +272 -0
- package/dist/css/components/like-button.css +78 -0
- package/dist/css/components/link-preview.css +275 -0
- package/dist/css/components/loading-button.css +77 -0
- package/dist/css/components/multi-select.css +267 -0
- package/dist/css/components/notification-bell.css +127 -0
- package/dist/css/components/password-strength.css +183 -0
- package/dist/css/components/popover.css +25 -0
- package/dist/css/components/progress.css +140 -0
- package/dist/css/components/questionnaire.css +217 -0
- package/dist/css/components/range-calendar.css +186 -0
- package/dist/css/components/range-slider.css +192 -0
- package/dist/css/components/resizable-panel.css +243 -0
- package/dist/css/components/rich-text-editor.css +284 -0
- package/dist/css/components/scratch-card.css +75 -0
- package/dist/css/components/skeleton.css +108 -0
- package/dist/css/components/split-view.css +19 -0
- package/dist/css/components/star-rating.css +80 -0
- package/dist/css/components/stat-card.css +164 -0
- package/dist/css/components/swipe-actions.css +105 -0
- package/dist/css/components/tag-input.css +244 -0
- package/dist/css/components/time-field.css +88 -0
- package/dist/css/components/timeline.css +219 -0
- package/dist/css/components/tooltip.css +79 -0
- package/dist/css/components/tree-view.css +210 -0
- package/dist/css/components/virtual-list.css +195 -0
- package/dist/css/tokens.css +185 -181
- package/dist/elements/index.d.ts +674 -5
- package/dist/elements/index.js +1361 -8
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-GDGwQnJE.d.ts → engine-Cn6IhE3z.d.ts} +1 -1
- package/dist/engine-QECQnGz1.d.ts +33 -0
- package/dist/index.css +9104 -189
- package/dist/index.d.ts +89 -5
- package/dist/index.js +243 -9
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +579 -23
- package/dist/react/index.js +2042 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BmBzKH94.d.ts → reduced-BBUusld6.d.ts} +9 -2
- package/dist/reveal-DEvp6U6T.d.ts +2381 -0
- package/dist/{store-DYb3tTsZ.d.ts → store-DjkTBX7f.d.ts} +2 -2
- package/package.json +1 -1
- package/registry.json +406 -1
- package/src/auto/auto-init.ts +98 -0
- package/src/components/activity-feed/README.md +56 -0
- package/src/components/activity-feed/activity-feed.css +239 -0
- package/src/components/activity-feed/element.ts +36 -0
- package/src/components/activity-feed/engine.ts +351 -0
- package/src/components/activity-feed/react.tsx +32 -0
- package/src/components/attachment/README.md +27 -0
- package/src/components/attachment/attachment.css +265 -0
- package/src/components/attachment/element.ts +50 -0
- package/src/components/attachment/engine.ts +469 -0
- package/src/components/attachment/react.tsx +65 -0
- package/src/components/breadcrumb/README.md +38 -0
- package/src/components/breadcrumb/breadcrumb.css +109 -0
- package/src/components/breadcrumb/element.ts +27 -0
- package/src/components/breadcrumb/engine.ts +249 -0
- package/src/components/breadcrumb/react.tsx +30 -0
- package/src/components/chart/README.md +24 -0
- package/src/components/chart/chart.css +295 -0
- package/src/components/chart/element.ts +41 -0
- package/src/components/chart/engine.ts +811 -0
- package/src/components/chart/react.tsx +50 -0
- package/src/components/chart-tooltip/README.md +53 -0
- package/src/components/chart-tooltip/chart-tooltip.css +167 -0
- package/src/components/chart-tooltip/element.ts +37 -0
- package/src/components/chart-tooltip/engine.ts +442 -0
- package/src/components/chart-tooltip/react.tsx +47 -0
- package/src/components/code-block/README.md +19 -0
- package/src/components/code-block/code-block.css +263 -0
- package/src/components/code-block/element.ts +34 -0
- package/src/components/code-block/engine.ts +254 -0
- package/src/components/code-block/highlight.ts +244 -0
- package/src/components/code-block/react.tsx +94 -0
- package/src/components/color-area/README.md +50 -0
- package/src/components/color-area/color-area.css +81 -0
- package/src/components/color-area/element.ts +26 -0
- package/src/components/color-area/engine.ts +246 -0
- package/src/components/color-area/react.tsx +42 -0
- package/src/components/color-field/README.md +43 -0
- package/src/components/color-field/color-field.css +66 -0
- package/src/components/color-field/element.ts +26 -0
- package/src/components/color-field/engine.ts +174 -0
- package/src/components/color-field/react.tsx +42 -0
- package/src/components/color-picker/README.md +45 -0
- package/src/components/color-picker/color-picker.css +130 -0
- package/src/components/color-picker/element.ts +45 -0
- package/src/components/color-picker/engine.ts +314 -0
- package/src/components/color-picker/react.tsx +47 -0
- package/src/components/color-slider/README.md +55 -0
- package/src/components/color-slider/color-slider.css +112 -0
- package/src/components/color-slider/element.ts +26 -0
- package/src/components/color-slider/engine.ts +234 -0
- package/src/components/color-slider/react.tsx +45 -0
- package/src/components/command-search/README.md +15 -0
- package/src/components/command-search/command-search.css +251 -0
- package/src/components/command-search/element.ts +36 -0
- package/src/components/command-search/engine.ts +356 -0
- package/src/components/command-search/match.ts +53 -0
- package/src/components/command-search/react.tsx +66 -0
- package/src/components/context-menu/README.md +31 -0
- package/src/components/context-menu/context-menu.css +22 -0
- package/src/components/context-menu/element.ts +40 -0
- package/src/components/context-menu/engine.ts +153 -0
- package/src/components/context-menu/react.tsx +57 -0
- package/src/components/credit-card/README.md +20 -0
- package/src/components/credit-card/card.ts +77 -0
- package/src/components/credit-card/credit-card.css +287 -0
- package/src/components/credit-card/element.ts +33 -0
- package/src/components/credit-card/engine.ts +410 -0
- package/src/components/credit-card/react.tsx +36 -0
- package/src/components/data-table/README.md +83 -0
- package/src/components/data-table/data-table.css +590 -0
- package/src/components/data-table/element.ts +54 -0
- package/src/components/data-table/engine.ts +1059 -0
- package/src/components/data-table/react.tsx +94 -0
- package/src/components/date-range-picker/README.md +51 -0
- package/src/components/date-range-picker/date-range-picker.css +167 -0
- package/src/components/date-range-picker/element.ts +52 -0
- package/src/components/date-range-picker/engine.ts +352 -0
- package/src/components/date-range-picker/react.tsx +57 -0
- package/src/components/date-time-picker/README.md +48 -0
- package/src/components/date-time-picker/date-time-picker.css +177 -0
- package/src/components/date-time-picker/element.ts +47 -0
- package/src/components/date-time-picker/engine.ts +350 -0
- package/src/components/date-time-picker/react.tsx +54 -0
- package/src/components/dialog/README.md +103 -102
- package/src/components/dialog/element.ts +123 -118
- package/src/components/dialog/engine.ts +449 -432
- package/src/components/drawer/README.md +28 -0
- package/src/components/drawer/drawer.css +57 -0
- package/src/components/drawer/element.ts +21 -0
- package/src/components/drawer/engine.ts +56 -0
- package/src/components/drawer/react.tsx +85 -0
- package/src/components/editable-table/README.md +67 -0
- package/src/components/editable-table/editable-table.css +200 -0
- package/src/components/editable-table/element.ts +45 -0
- package/src/components/editable-table/engine.ts +775 -0
- package/src/components/editable-table/react.tsx +79 -0
- package/src/components/empty-state/README.md +41 -0
- package/src/components/empty-state/element.ts +23 -0
- package/src/components/empty-state/empty-state.css +60 -0
- package/src/components/empty-state/engine.ts +232 -0
- package/src/components/empty-state/react.tsx +34 -0
- package/src/components/file-explorer/README.md +67 -0
- package/src/components/file-explorer/element.ts +39 -0
- package/src/components/file-explorer/engine.ts +1307 -0
- package/src/components/file-explorer/file-explorer.css +632 -0
- package/src/components/file-explorer/react.tsx +77 -0
- package/src/components/filter-builder/README.md +47 -0
- package/src/components/filter-builder/element.ts +34 -0
- package/src/components/filter-builder/engine.ts +365 -0
- package/src/components/filter-builder/filter-builder.css +231 -0
- package/src/components/filter-builder/react.tsx +36 -0
- package/src/components/hold-confirm/README.md +13 -0
- package/src/components/hold-confirm/element.ts +24 -0
- package/src/components/hold-confirm/engine.ts +216 -0
- package/src/components/hold-confirm/hold-confirm.css +86 -0
- package/src/components/hold-confirm/react.tsx +28 -0
- package/src/components/hover-card/README.md +6 -2
- package/src/components/hover-card/engine.ts +68 -6
- package/src/components/hover-card/hover-card.css +13 -7
- package/src/components/image-preview/README.md +16 -0
- package/src/components/image-preview/element.ts +26 -0
- package/src/components/image-preview/engine.ts +325 -0
- package/src/components/image-preview/image-preview.css +194 -0
- package/src/components/image-preview/react.tsx +43 -0
- package/src/components/json-viewer/README.md +56 -0
- package/src/components/json-viewer/element.ts +37 -0
- package/src/components/json-viewer/engine.ts +440 -0
- package/src/components/json-viewer/json-viewer.css +271 -0
- package/src/components/json-viewer/react.tsx +56 -0
- package/src/components/like-button/README.md +13 -0
- package/src/components/like-button/element.ts +47 -0
- package/src/components/like-button/engine.ts +145 -0
- package/src/components/like-button/like-button.css +77 -0
- package/src/components/like-button/react.tsx +39 -0
- package/src/components/link-preview/README.md +19 -0
- package/src/components/link-preview/element.ts +29 -0
- package/src/components/link-preview/engine.ts +192 -0
- package/src/components/link-preview/link-preview.css +274 -0
- package/src/components/link-preview/react.tsx +101 -0
- package/src/components/loading-button/README.md +45 -0
- package/src/components/loading-button/element.ts +34 -0
- package/src/components/loading-button/engine.ts +201 -0
- package/src/components/loading-button/loading-button.css +76 -0
- package/src/components/loading-button/react.tsx +43 -0
- package/src/components/multi-select/README.md +48 -0
- package/src/components/multi-select/element.ts +28 -0
- package/src/components/multi-select/engine.ts +294 -0
- package/src/components/multi-select/multi-select.css +266 -0
- package/src/components/multi-select/react.tsx +39 -0
- package/src/components/notification-bell/README.md +14 -0
- package/src/components/notification-bell/element.ts +48 -0
- package/src/components/notification-bell/engine.ts +202 -0
- package/src/components/notification-bell/notification-bell.css +126 -0
- package/src/components/notification-bell/react.tsx +57 -0
- package/src/components/password-strength/README.md +17 -0
- package/src/components/password-strength/element.ts +22 -0
- package/src/components/password-strength/engine.ts +212 -0
- package/src/components/password-strength/password-strength.css +182 -0
- package/src/components/password-strength/react.tsx +36 -0
- package/src/components/password-strength/score.ts +62 -0
- package/src/components/popover/README.md +44 -0
- package/src/components/popover/element.ts +31 -0
- package/src/components/popover/engine.ts +114 -0
- package/src/components/popover/popover.css +24 -0
- package/src/components/popover/react.tsx +50 -0
- package/src/components/progress/README.md +17 -0
- package/src/components/progress/element.ts +30 -0
- package/src/components/progress/engine.ts +173 -0
- package/src/components/progress/progress.css +139 -0
- package/src/components/progress/react.tsx +35 -0
- package/src/components/questionnaire/README.md +32 -0
- package/src/components/questionnaire/element.ts +118 -0
- package/src/components/questionnaire/engine.ts +255 -0
- package/src/components/questionnaire/questionnaire.css +216 -0
- package/src/components/questionnaire/react.tsx +140 -0
- package/src/components/range-calendar/README.md +58 -0
- package/src/components/range-calendar/element.ts +42 -0
- package/src/components/range-calendar/engine.ts +390 -0
- package/src/components/range-calendar/range-calendar.css +185 -0
- package/src/components/range-calendar/react.tsx +56 -0
- package/src/components/range-slider/README.md +23 -0
- package/src/components/range-slider/element.ts +53 -0
- package/src/components/range-slider/engine.ts +316 -0
- package/src/components/range-slider/range-slider.css +191 -0
- package/src/components/range-slider/react.tsx +66 -0
- package/src/components/resizable-panel/README.md +61 -0
- package/src/components/resizable-panel/element.ts +36 -0
- package/src/components/resizable-panel/engine.ts +551 -0
- package/src/components/resizable-panel/react.tsx +74 -0
- package/src/components/resizable-panel/resizable-panel.css +242 -0
- package/src/components/rich-text-editor/README.md +57 -0
- package/src/components/rich-text-editor/element.ts +34 -0
- package/src/components/rich-text-editor/engine.ts +673 -0
- package/src/components/rich-text-editor/react.tsx +60 -0
- package/src/components/rich-text-editor/rich-text-editor.css +283 -0
- package/src/components/scratch-card/README.md +15 -0
- package/src/components/scratch-card/element.ts +30 -0
- package/src/components/scratch-card/engine.ts +257 -0
- package/src/components/scratch-card/react.tsx +40 -0
- package/src/components/scratch-card/scratch-card.css +74 -0
- package/src/components/skeleton/README.md +15 -0
- package/src/components/skeleton/element.ts +32 -0
- package/src/components/skeleton/engine.ts +104 -0
- package/src/components/skeleton/react.tsx +41 -0
- package/src/components/skeleton/skeleton.css +107 -0
- package/src/components/split-view/README.md +50 -0
- package/src/components/split-view/element.ts +34 -0
- package/src/components/split-view/engine.ts +114 -0
- package/src/components/split-view/react.tsx +63 -0
- package/src/components/split-view/split-view.css +18 -0
- package/src/components/star-rating/README.md +14 -0
- package/src/components/star-rating/element.ts +26 -0
- package/src/components/star-rating/engine.ts +217 -0
- package/src/components/star-rating/react.tsx +33 -0
- package/src/components/star-rating/star-rating.css +79 -0
- package/src/components/stat-card/README.md +42 -0
- package/src/components/stat-card/element.ts +40 -0
- package/src/components/stat-card/engine.ts +283 -0
- package/src/components/stat-card/react.tsx +36 -0
- package/src/components/stat-card/stat-card.css +163 -0
- package/src/components/swipe-actions/README.md +19 -0
- package/src/components/swipe-actions/element.ts +26 -0
- package/src/components/swipe-actions/engine.ts +260 -0
- package/src/components/swipe-actions/react.tsx +80 -0
- package/src/components/swipe-actions/swipe-actions.css +104 -0
- package/src/components/tag-input/README.md +42 -0
- package/src/components/tag-input/element.ts +39 -0
- package/src/components/tag-input/engine.ts +313 -0
- package/src/components/tag-input/react.tsx +54 -0
- package/src/components/tag-input/tag-input.css +243 -0
- package/src/components/time-field/README.md +54 -0
- package/src/components/time-field/element.ts +38 -0
- package/src/components/time-field/engine.ts +110 -0
- package/src/components/time-field/react.tsx +45 -0
- package/src/components/time-field/time-field.css +87 -0
- package/src/components/timeline/README.md +57 -0
- package/src/components/timeline/element.ts +31 -0
- package/src/components/timeline/engine.ts +300 -0
- package/src/components/timeline/react.tsx +59 -0
- package/src/components/timeline/timeline.css +218 -0
- package/src/components/tooltip/README.md +46 -0
- package/src/components/tooltip/element.ts +22 -0
- package/src/components/tooltip/engine.ts +288 -0
- package/src/components/tooltip/react.tsx +30 -0
- package/src/components/tooltip/tooltip.css +78 -0
- package/src/components/tree-view/README.md +61 -0
- package/src/components/tree-view/element.ts +32 -0
- package/src/components/tree-view/engine.ts +569 -0
- package/src/components/tree-view/react.tsx +78 -0
- package/src/components/tree-view/tree-view.css +209 -0
- package/src/components/virtual-list/README.md +81 -0
- package/src/components/virtual-list/element.ts +42 -0
- package/src/components/virtual-list/engine.ts +678 -0
- package/src/components/virtual-list/react.tsx +101 -0
- package/src/components/virtual-list/virtual-list.css +194 -0
- package/src/core/chips.ts +196 -0
- package/src/core/color.ts +304 -0
- package/src/core/date-range.ts +67 -0
- package/src/core/dates.ts +11 -1
- package/src/core/files.ts +107 -0
- package/src/core/filter.ts +229 -0
- package/src/core/gsap.ts +80 -65
- package/src/core/listbox.ts +332 -0
- package/src/core/mark.ts +29 -0
- package/src/core/position.ts +89 -0
- package/src/core/relative-time.ts +77 -0
- package/src/core/segment-field.ts +373 -0
- package/src/core/segments.ts +153 -0
- package/src/core/tree.ts +116 -0
- package/src/core/virtual.ts +79 -0
- package/src/css/tokens.css +186 -182
- package/src/elements/index.ts +147 -0
- package/src/index.ts +72 -0
- package/src/motion/micro.ts +248 -0
- package/src/motion/sheet.ts +88 -65
- package/src/motion/surface.ts +9 -0
- package/src/react/index.ts +57 -0
- package/dist/chunk-IKQ5FODN.js.map +0 -1
- package/dist/chunk-J6OCFNGU.js +0 -177
- package/dist/chunk-J6OCFNGU.js.map +0 -1
- package/dist/chunk-TFNRIZHU.js +0 -3977
- package/dist/chunk-TFNRIZHU.js.map +0 -1
- package/dist/chunk-XAKC2QY7.js.map +0 -1
- package/dist/reveal-jsX-uwUz.d.ts +0 -625
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createScope } from '../../core/scope';
|
|
2
|
+
import { inert, type Instance } from '../../core/types';
|
|
3
|
+
import { createSteps } from '../steps/engine';
|
|
4
|
+
import { shake } from '../../motion/micro';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Questionnaire: a native form split into [data-mk-question] steps, moved by
|
|
8
|
+
* the steps engine (flow out/in, counter, focus on the title). Next validates
|
|
9
|
+
* the current step first — required text, choices and checkbox groups (one
|
|
10
|
+
* checked is enough) — marks invalid fields, writes the message and focuses
|
|
11
|
+
* the first one. Submitting on the last step completes once; native reset
|
|
12
|
+
* clears errors and completion and returns to the first step.
|
|
13
|
+
* Parts: [data-mk-question], [data-mk-question-next|prev], [data-mk-question-error],
|
|
14
|
+
* [data-mk-question-complete], [data-mk-counter] (step number).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type QuestionnaireAnswers = Record<string, string | string[]>;
|
|
18
|
+
|
|
19
|
+
export interface QuestionnaireLabels {
|
|
20
|
+
required?: string;
|
|
21
|
+
choice?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface QuestionnaireOptions {
|
|
25
|
+
index?: number;
|
|
26
|
+
labels?: QuestionnaireLabels;
|
|
27
|
+
onChange?: (index: number, answers: QuestionnaireAnswers) => void;
|
|
28
|
+
onComplete?: (answers: QuestionnaireAnswers) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QuestionnaireInstance extends Instance {
|
|
32
|
+
/** Validate the current step and go on (or complete on the last one). */
|
|
33
|
+
next(): Promise<boolean>;
|
|
34
|
+
prev(): Promise<void>;
|
|
35
|
+
/** Native form reset: clears answers, errors and completion. */
|
|
36
|
+
reset(): void;
|
|
37
|
+
readonly answers: QuestionnaireAnswers;
|
|
38
|
+
readonly index: number;
|
|
39
|
+
readonly count: number;
|
|
40
|
+
readonly complete: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Field = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
44
|
+
const FIELDS = 'input[name], select[name], textarea[name]';
|
|
45
|
+
const SKIP = new Set(['button', 'submit', 'reset', 'image', 'file']);
|
|
46
|
+
let sequence = 0;
|
|
47
|
+
|
|
48
|
+
/** Answers of a form keyed by field name: checkboxes and multi-selects give arrays. */
|
|
49
|
+
export function readAnswers(form: HTMLElement): QuestionnaireAnswers {
|
|
50
|
+
const answers: QuestionnaireAnswers = {};
|
|
51
|
+
for (const field of form.querySelectorAll<Field>(FIELDS)) {
|
|
52
|
+
if (field.disabled || SKIP.has(field.type)) continue;
|
|
53
|
+
const { name } = field;
|
|
54
|
+
if (field instanceof HTMLInputElement && field.type === 'checkbox') {
|
|
55
|
+
const list = (answers[name] as string[] | undefined) ?? [];
|
|
56
|
+
if (field.checked) list.push(field.value);
|
|
57
|
+
answers[name] = list;
|
|
58
|
+
} else if (field instanceof HTMLInputElement && field.type === 'radio') {
|
|
59
|
+
if (field.checked) answers[name] = field.value;
|
|
60
|
+
else answers[name] ??= '';
|
|
61
|
+
} else if (field instanceof HTMLSelectElement && field.multiple) {
|
|
62
|
+
answers[name] = [...field.selectedOptions].map((option) => option.value);
|
|
63
|
+
} else answers[name] = field.value;
|
|
64
|
+
}
|
|
65
|
+
return answers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createQuestionnaire(root: HTMLElement | null, options: QuestionnaireOptions = {}): QuestionnaireInstance {
|
|
69
|
+
const form = root instanceof HTMLFormElement ? root : root?.querySelector('form') ?? null;
|
|
70
|
+
const sections = form ? [...form.querySelectorAll<HTMLElement>('[data-mk-question]')] : [];
|
|
71
|
+
if (!root || !form || !sections.length) {
|
|
72
|
+
return inert({ next: async () => false, prev: async () => {}, reset() {}, answers: {}, index: -1, count: 0, complete: false });
|
|
73
|
+
}
|
|
74
|
+
const scope = createScope();
|
|
75
|
+
const labels = { required: 'Completa este campo.', choice: 'Elige al menos una opción.', ...options.labels };
|
|
76
|
+
for (const section of sections) if (!section.hasAttribute('data-mk-step')) scope.attr(section, 'data-mk-step', '');
|
|
77
|
+
const start = Math.min(sections.length - 1, Math.max(0, Math.round(options.index ?? 0)));
|
|
78
|
+
let index = start;
|
|
79
|
+
let complete = false;
|
|
80
|
+
// Our own navigation buttons only (not [data-mk-step-*]): steps never skips validation.
|
|
81
|
+
const steps = createSteps(form, { index: start, animateFirst: false });
|
|
82
|
+
scope.add(() => steps.destroy());
|
|
83
|
+
scope.attr(form, 'novalidate', '');
|
|
84
|
+
scope.attr(form, 'data-mk-questionnaire', '');
|
|
85
|
+
|
|
86
|
+
const fieldsOf = (section: HTMLElement) => [...section.querySelectorAll<Field>(FIELDS)].filter((field) => !field.disabled && !SKIP.has(field.type));
|
|
87
|
+
const errorOf = (section: HTMLElement) => section.querySelector<HTMLElement>('[data-mk-question-error]') ?? form.querySelector<HTMLElement>('[data-mk-question-error]');
|
|
88
|
+
const errorTexts = new Map<HTMLElement, string | null>();
|
|
89
|
+
const writeError = (element: HTMLElement | null, text: string) => {
|
|
90
|
+
if (!element) return;
|
|
91
|
+
if (!errorTexts.has(element)) errorTexts.set(element, element.textContent);
|
|
92
|
+
element.textContent = text;
|
|
93
|
+
};
|
|
94
|
+
scope.add(() => errorTexts.forEach((text, element) => (element.textContent = text)));
|
|
95
|
+
for (const element of form.querySelectorAll<HTMLElement>('[data-mk-question-error]')) {
|
|
96
|
+
if (!element.id) scope.attr(element, 'id', `mk-question-error-${++sequence}`);
|
|
97
|
+
if (!element.matches('output') && !element.hasAttribute('role')) scope.attr(element, 'role', 'alert');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const markValid = (field: Field) => {
|
|
101
|
+
scope.attr(field, 'aria-invalid', null);
|
|
102
|
+
scope.attr(field, 'aria-errormessage', null);
|
|
103
|
+
};
|
|
104
|
+
const clearErrors = () => {
|
|
105
|
+
for (const field of form.querySelectorAll<Field>(FIELDS)) markValid(field);
|
|
106
|
+
for (const element of form.querySelectorAll<HTMLElement>('[data-mk-question-error]')) writeError(element, '');
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Invalid fields of a step and the first message. */
|
|
110
|
+
const check = (section: HTMLElement) => {
|
|
111
|
+
const invalid: Field[] = [];
|
|
112
|
+
let message = '';
|
|
113
|
+
const groups = new Map<string, HTMLInputElement[]>();
|
|
114
|
+
for (const field of fieldsOf(section)) {
|
|
115
|
+
if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
|
|
116
|
+
const group = groups.get(field.name) ?? [];
|
|
117
|
+
group.push(field);
|
|
118
|
+
groups.set(field.name, group);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const empty = field.required && !field.value.trim();
|
|
122
|
+
if (empty || !field.checkValidity()) {
|
|
123
|
+
invalid.push(field);
|
|
124
|
+
message ||= empty ? labels.required : field.validationMessage || labels.required;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const group of groups.values()) {
|
|
128
|
+
const required = group.some((field) => field.required) || Boolean(group[0]!.closest('[data-mk-required]'));
|
|
129
|
+
if (required && !group.some((field) => field.checked)) {
|
|
130
|
+
invalid.push(...group);
|
|
131
|
+
message ||= labels.choice;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { invalid, message };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const validate = (section: HTMLElement) => {
|
|
138
|
+
clearErrors();
|
|
139
|
+
const { invalid, message } = check(section);
|
|
140
|
+
if (!invalid.length) return true;
|
|
141
|
+
const error = errorOf(section);
|
|
142
|
+
writeError(error, message);
|
|
143
|
+
for (const field of invalid) {
|
|
144
|
+
scope.attr(field, 'aria-invalid', 'true');
|
|
145
|
+
if (error?.id) scope.attr(field, 'aria-errormessage', error.id);
|
|
146
|
+
}
|
|
147
|
+
invalid[0]!.focus();
|
|
148
|
+
shake(invalid[0]!.closest('[data-mk-question-options], [data-mk-question-field]') ?? invalid[0]!);
|
|
149
|
+
root.dispatchEvent(new CustomEvent('mk:invalid', { bubbles: true, detail: { index, fields: invalid } }));
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const render = () => {
|
|
154
|
+
scope.attr(form, 'data-mk-complete', complete ? '' : null);
|
|
155
|
+
form.style.setProperty('--mk-question-progress', String(complete ? 1 : (index + 1) / sections.length));
|
|
156
|
+
for (const panel of form.querySelectorAll<HTMLElement>('[data-mk-question-complete]')) scope.attr(panel, 'hidden', complete ? null : '');
|
|
157
|
+
};
|
|
158
|
+
scope.attr(form, 'style', form.getAttribute('style'));
|
|
159
|
+
|
|
160
|
+
const finish = () => {
|
|
161
|
+
if (complete) return;
|
|
162
|
+
complete = true;
|
|
163
|
+
render();
|
|
164
|
+
const answers = readAnswers(form);
|
|
165
|
+
const panel = form.querySelector<HTMLElement>('[data-mk-question-complete]');
|
|
166
|
+
if (panel) {
|
|
167
|
+
if (!panel.hasAttribute('tabindex')) scope.attr(panel, 'tabindex', '-1');
|
|
168
|
+
panel.focus({ preventScroll: true });
|
|
169
|
+
}
|
|
170
|
+
options.onComplete?.(answers);
|
|
171
|
+
root.dispatchEvent(new CustomEvent('mk:complete', { bubbles: true, detail: { answers } }));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const go = async (to: number) => {
|
|
175
|
+
const previous = index;
|
|
176
|
+
index = to;
|
|
177
|
+
render();
|
|
178
|
+
await steps.goTo(to);
|
|
179
|
+
if (scope.disposed) return;
|
|
180
|
+
const answers = readAnswers(form);
|
|
181
|
+
options.onChange?.(index, answers);
|
|
182
|
+
root.dispatchEvent(new CustomEvent('mk:question', { bubbles: true, detail: { index, previous, answers } }));
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Everything before the first await runs at once: a submit completes synchronously.
|
|
186
|
+
const next = async () => {
|
|
187
|
+
if (scope.disposed) return false;
|
|
188
|
+
if (complete) return true;
|
|
189
|
+
if (!validate(sections[index]!)) return false;
|
|
190
|
+
if (index === sections.length - 1) {
|
|
191
|
+
finish();
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
await go(index + 1);
|
|
195
|
+
return !scope.disposed;
|
|
196
|
+
};
|
|
197
|
+
const prev = async () => {
|
|
198
|
+
if (scope.disposed || complete || index === 0) return;
|
|
199
|
+
clearErrors();
|
|
200
|
+
await go(index - 1);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
scope.on(form, 'click', (event) => {
|
|
204
|
+
const trigger = (event.target as Element | null)?.closest<HTMLElement>('[data-mk-question-next], [data-mk-question-prev]');
|
|
205
|
+
if (!trigger || !form.contains(trigger)) return;
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
if (trigger.hasAttribute('data-mk-question-next')) void next();
|
|
208
|
+
else void prev();
|
|
209
|
+
});
|
|
210
|
+
scope.on(form, 'submit', (event) => {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
void next();
|
|
213
|
+
});
|
|
214
|
+
// A corrected field stops being invalid as soon as it is.
|
|
215
|
+
const recheck = (event: Event) => {
|
|
216
|
+
const field = (event.target as Element | null)?.closest<Field>(FIELDS);
|
|
217
|
+
if (!field || field.getAttribute('aria-invalid') !== 'true') return;
|
|
218
|
+
const section = field.closest<HTMLElement>('[data-mk-question]');
|
|
219
|
+
if (!section) return;
|
|
220
|
+
const { invalid } = check(section);
|
|
221
|
+
for (const candidate of fieldsOf(section)) if (!invalid.includes(candidate)) markValid(candidate);
|
|
222
|
+
if (!invalid.length) writeError(errorOf(section), '');
|
|
223
|
+
};
|
|
224
|
+
scope.on(form, 'input', recheck);
|
|
225
|
+
scope.on(form, 'change', recheck);
|
|
226
|
+
scope.on(form, 'reset', (event) => {
|
|
227
|
+
if (event.defaultPrevented) return;
|
|
228
|
+
clearErrors();
|
|
229
|
+
complete = false;
|
|
230
|
+
if (index !== 0) void go(0);
|
|
231
|
+
else render();
|
|
232
|
+
});
|
|
233
|
+
render();
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
next,
|
|
237
|
+
prev,
|
|
238
|
+
reset() {
|
|
239
|
+
if (!scope.disposed) form.reset();
|
|
240
|
+
},
|
|
241
|
+
get answers() {
|
|
242
|
+
return readAnswers(form);
|
|
243
|
+
},
|
|
244
|
+
get index() {
|
|
245
|
+
return index;
|
|
246
|
+
},
|
|
247
|
+
get count() {
|
|
248
|
+
return sections.length;
|
|
249
|
+
},
|
|
250
|
+
get complete() {
|
|
251
|
+
return complete;
|
|
252
|
+
},
|
|
253
|
+
destroy: () => scope.dispose(),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/* questionnaire — one question at a time on a soft card: a thin progress line, large title, pill choices. */
|
|
2
|
+
.mk-questionnaire,
|
|
3
|
+
mk-questionnaire > form {
|
|
4
|
+
--mk-question-radius: var(--mk-radius-lg, 24px);
|
|
5
|
+
--mk-question-option-radius: var(--mk-radius-md, 16px);
|
|
6
|
+
display: grid;
|
|
7
|
+
gap: 1.25rem;
|
|
8
|
+
padding: clamp(1.25rem, 4vw, 2rem);
|
|
9
|
+
border-radius: var(--mk-question-radius);
|
|
10
|
+
background: var(--mk-surface-1);
|
|
11
|
+
box-shadow: inset 0 0 0 1px var(--mk-outline-soft);
|
|
12
|
+
color: var(--mk-text);
|
|
13
|
+
font-family: var(--mk-font);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
mk-questionnaire {
|
|
17
|
+
display: block;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-progress] {
|
|
21
|
+
height: 4px;
|
|
22
|
+
overflow: hidden;
|
|
23
|
+
border-radius: var(--mk-radius-full);
|
|
24
|
+
background: color-mix(in srgb, var(--mk-text) 9%, transparent);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-bar] {
|
|
28
|
+
display: block;
|
|
29
|
+
height: 100%;
|
|
30
|
+
border-radius: inherit;
|
|
31
|
+
background: var(--mk-accent);
|
|
32
|
+
transform: scaleX(var(--mk-question-progress, 0));
|
|
33
|
+
transform-origin: left center;
|
|
34
|
+
transition: transform 0.7s cubic-bezier(0.38, 0.49, 0, 1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* A full-width column (steps' own flex centring does not apply here). */
|
|
38
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question] {
|
|
39
|
+
display: grid;
|
|
40
|
+
grid-template-columns: minmax(0, 1fr);
|
|
41
|
+
gap: 1rem;
|
|
42
|
+
align-content: start;
|
|
43
|
+
justify-content: normal;
|
|
44
|
+
justify-items: stretch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question][hidden],
|
|
48
|
+
:is(.mk-questionnaire, mk-questionnaire)[data-mk-complete] [data-mk-question],
|
|
49
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-complete][hidden] {
|
|
50
|
+
display: none !important;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-step] {
|
|
54
|
+
margin: 0;
|
|
55
|
+
color: var(--mk-muted);
|
|
56
|
+
font-size: 0.8125rem;
|
|
57
|
+
letter-spacing: 0.02em;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question] h3 {
|
|
61
|
+
margin: 0;
|
|
62
|
+
font-size: clamp(1.35rem, 3vw, 1.75rem);
|
|
63
|
+
font-weight: 500;
|
|
64
|
+
line-height: 1.15;
|
|
65
|
+
letter-spacing: -0.02em;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question] h3:focus {
|
|
69
|
+
outline: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-required] {
|
|
73
|
+
color: var(--mk-accent);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-description] {
|
|
77
|
+
margin: -0.5rem 0 0;
|
|
78
|
+
color: var(--mk-muted);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
:is(.mk-questionnaire, mk-questionnaire) :is(input[type='text'], input[type='email'], input[type='number'], textarea, select) {
|
|
82
|
+
width: 100%;
|
|
83
|
+
min-height: 3.25rem;
|
|
84
|
+
padding: 0.75rem 1.1rem;
|
|
85
|
+
border: 1px solid var(--mk-outline-soft);
|
|
86
|
+
border-radius: var(--mk-question-option-radius);
|
|
87
|
+
background: var(--mk-surface-3);
|
|
88
|
+
color: var(--mk-text);
|
|
89
|
+
font: inherit;
|
|
90
|
+
transition:
|
|
91
|
+
border-color 0.2s ease,
|
|
92
|
+
background-color 0.2s ease;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
:is(.mk-questionnaire, mk-questionnaire) textarea {
|
|
96
|
+
resize: vertical;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
:is(.mk-questionnaire, mk-questionnaire) :is(input, textarea, select):focus-visible {
|
|
100
|
+
border-color: var(--mk-accent);
|
|
101
|
+
outline: 2px solid color-mix(in srgb, var(--mk-accent) 35%, transparent);
|
|
102
|
+
outline-offset: 1px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-options] {
|
|
106
|
+
display: grid;
|
|
107
|
+
gap: 0.5rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Melonmind label.style-1: padding and radius spring; a chosen option becomes a pill. */
|
|
111
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option] {
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: 0.75rem;
|
|
115
|
+
min-height: 3.25rem;
|
|
116
|
+
padding: 0.5rem 1.1rem;
|
|
117
|
+
border-radius: 16px;
|
|
118
|
+
background: var(--mk-surface-3);
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
user-select: none;
|
|
121
|
+
transition:
|
|
122
|
+
padding 300ms cubic-bezier(0.38, 0.49, 0, 2),
|
|
123
|
+
background-color 300ms cubic-bezier(0.38, 0.49, 0, 1.5),
|
|
124
|
+
color 300ms cubic-bezier(0.38, 0.49, 0, 1.5),
|
|
125
|
+
border-radius 200ms cubic-bezier(0.38, 0.49, 0, 1.5);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option]:hover {
|
|
129
|
+
padding-inline: 1.3rem;
|
|
130
|
+
background: var(--mk-surface-4);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option]:active {
|
|
134
|
+
animation: mk-question-press 500ms cubic-bezier(0.37, 1.42, 0.37, 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option]:has(input:checked) {
|
|
138
|
+
padding-inline: 1.5rem;
|
|
139
|
+
border-radius: 64px;
|
|
140
|
+
background: var(--mk-accent-soft);
|
|
141
|
+
color: var(--mk-on-accent-soft);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@keyframes mk-question-press {
|
|
145
|
+
50% {
|
|
146
|
+
transform: scale(0.98, 0.94);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option]:has(input:focus-visible) {
|
|
151
|
+
outline: 2px solid var(--mk-accent);
|
|
152
|
+
outline-offset: 2px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option] input {
|
|
156
|
+
width: 1.15rem;
|
|
157
|
+
height: 1.15rem;
|
|
158
|
+
margin: 0;
|
|
159
|
+
accent-color: var(--mk-accent);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
:is(.mk-questionnaire, mk-questionnaire) [aria-invalid='true']:not([type='checkbox'], [type='radio']) {
|
|
163
|
+
border-color: var(--mk-error);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-option]:has([aria-invalid='true']) {
|
|
167
|
+
border-color: color-mix(in srgb, var(--mk-error) 60%, transparent);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-actions] {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-wrap: wrap;
|
|
173
|
+
justify-content: flex-end;
|
|
174
|
+
gap: 0.5rem;
|
|
175
|
+
margin-top: 0.25rem;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-error] {
|
|
179
|
+
margin: 0;
|
|
180
|
+
color: var(--mk-error);
|
|
181
|
+
font-size: 0.875rem;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-error]:empty {
|
|
185
|
+
display: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-complete] {
|
|
189
|
+
display: grid;
|
|
190
|
+
gap: 1rem;
|
|
191
|
+
justify-items: start;
|
|
192
|
+
animation: mk-question-done 0.45s var(--mk-ease-sheet, ease-out) both;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-complete]:focus {
|
|
196
|
+
outline: none;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@keyframes mk-question-done {
|
|
200
|
+
from {
|
|
201
|
+
opacity: 0;
|
|
202
|
+
transform: translateY(8px);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@media (prefers-reduced-motion: reduce) {
|
|
207
|
+
:is(.mk-questionnaire, mk-questionnaire) :is([data-mk-question-bar], [data-mk-question-option], [data-mk-question-complete]) {
|
|
208
|
+
transition: none;
|
|
209
|
+
animation: none;
|
|
210
|
+
transform: none;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
:is(.mk-questionnaire, mk-questionnaire) [data-mk-question-bar] {
|
|
214
|
+
transform: scaleX(var(--mk-question-progress, 0));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, useState, type FormHTMLAttributes, type ReactNode } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createQuestionnaire, type QuestionnaireAnswers, type QuestionnaireInstance, type QuestionnaireLabels } from './engine';
|
|
5
|
+
|
|
6
|
+
export type QuestionType = 'text' | 'textarea' | 'single' | 'multiple';
|
|
7
|
+
|
|
8
|
+
export interface QuestionOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Question {
|
|
14
|
+
/** Field name and answer key. */
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
type: QuestionType;
|
|
18
|
+
options?: Array<string | QuestionOption>;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
description?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface QuestionnaireProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onChange' | 'children'> {
|
|
25
|
+
questions: Question[];
|
|
26
|
+
/** Answers the fields start with. */
|
|
27
|
+
defaultAnswers?: QuestionnaireAnswers;
|
|
28
|
+
labels?: QuestionnaireLabels & { next?: string; prev?: string; finish?: string; restart?: string; step?: (index: number, count: number) => ReactNode };
|
|
29
|
+
onChange?: (index: number, answers: QuestionnaireAnswers) => void;
|
|
30
|
+
onComplete?: (answers: QuestionnaireAnswers) => void;
|
|
31
|
+
/** Shown after the last step; a function receives the answers. */
|
|
32
|
+
complete?: ReactNode | ((answers: QuestionnaireAnswers) => ReactNode);
|
|
33
|
+
instanceRef?: { current: QuestionnaireInstance | null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const optionOf = (option: string | QuestionOption): QuestionOption => (typeof option === 'string' ? { value: option, label: option } : option);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* <Questionnaire questions={[{ id: 'name', label: 'Nombre', type: 'text', required: true }, …]} onComplete={save} />.
|
|
40
|
+
* Fields are native and uncontrolled, so answers survive steps and rerenders; the app owns persistence.
|
|
41
|
+
*/
|
|
42
|
+
export function Questionnaire({ questions, defaultAnswers = {}, labels = {}, onChange, onComplete, complete, instanceRef, className, ...props }: QuestionnaireProps) {
|
|
43
|
+
const ref = useRef<HTMLFormElement>(null);
|
|
44
|
+
const [answers, setAnswers] = useState<QuestionnaireAnswers | null>(null);
|
|
45
|
+
const change = useLatest(onChange);
|
|
46
|
+
const completed = useLatest(onComplete);
|
|
47
|
+
const key = JSON.stringify(questions.map(({ id, type, required, options }) => [id, type, required, options]));
|
|
48
|
+
const { required, choice } = labels;
|
|
49
|
+
useIsomorphicLayoutEffect(() => {
|
|
50
|
+
const created = createQuestionnaire(ref.current, {
|
|
51
|
+
labels: { required, choice },
|
|
52
|
+
onChange: (index, current) => change.current?.(index, current),
|
|
53
|
+
onComplete: (current) => {
|
|
54
|
+
setAnswers(current);
|
|
55
|
+
completed.current?.(current);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (instanceRef) instanceRef.current = created;
|
|
59
|
+
return () => {
|
|
60
|
+
created.destroy();
|
|
61
|
+
if (instanceRef) instanceRef.current = null;
|
|
62
|
+
};
|
|
63
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
64
|
+
}, [key, required, choice]);
|
|
65
|
+
const count = questions.length;
|
|
66
|
+
const defaults = (id: string) => defaultAnswers[id];
|
|
67
|
+
return (
|
|
68
|
+
<form {...props} ref={ref} noValidate className={['mk-questionnaire', className].filter(Boolean).join(' ')} onReset={() => setAnswers(null)}>
|
|
69
|
+
<div data-mk-question-progress="" aria-hidden="true">
|
|
70
|
+
<span data-mk-question-bar="" />
|
|
71
|
+
</div>
|
|
72
|
+
{questions.map((question, i) => {
|
|
73
|
+
const titleId = `${question.id}-title`;
|
|
74
|
+
const describedBy = question.description ? `${question.id}-description` : undefined;
|
|
75
|
+
const value = defaults(question.id);
|
|
76
|
+
return (
|
|
77
|
+
<section key={question.id} data-mk-question="" data-mk-step="" hidden={i !== 0} aria-labelledby={titleId}>
|
|
78
|
+
<p data-mk-question-step="">{labels.step ? labels.step(i, count) : `Pregunta ${i + 1} de ${count}`}</p>
|
|
79
|
+
<h3 id={titleId} data-mk-step-title="" data-mk-flow-item="">
|
|
80
|
+
{question.label}
|
|
81
|
+
{question.required ? <span data-mk-question-required="" aria-hidden="true"> *</span> : null}
|
|
82
|
+
</h3>
|
|
83
|
+
{question.description && (
|
|
84
|
+
<p id={describedBy} data-mk-question-description="" data-mk-flow-item="">
|
|
85
|
+
{question.description}
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
<div data-mk-question-field="" data-mk-flow-item="">
|
|
89
|
+
{question.type === 'text' && (
|
|
90
|
+
<input name={question.id} type="text" required={question.required} aria-labelledby={titleId} aria-describedby={describedBy} placeholder={question.placeholder} defaultValue={typeof value === 'string' ? value : ''} />
|
|
91
|
+
)}
|
|
92
|
+
{question.type === 'textarea' && (
|
|
93
|
+
<textarea name={question.id} rows={4} required={question.required} aria-labelledby={titleId} aria-describedby={describedBy} placeholder={question.placeholder} defaultValue={typeof value === 'string' ? value : ''} />
|
|
94
|
+
)}
|
|
95
|
+
{(question.type === 'single' || question.type === 'multiple') && (
|
|
96
|
+
<div role={question.type === 'single' ? 'radiogroup' : 'group'} aria-labelledby={titleId} aria-describedby={describedBy} data-mk-question-options="">
|
|
97
|
+
{(question.options ?? []).map(optionOf).map((option) => (
|
|
98
|
+
<label key={option.value} data-mk-question-option="">
|
|
99
|
+
<input
|
|
100
|
+
type={question.type === 'single' ? 'radio' : 'checkbox'}
|
|
101
|
+
name={question.id}
|
|
102
|
+
value={option.value}
|
|
103
|
+
required={question.required}
|
|
104
|
+
defaultChecked={Array.isArray(value) ? value.includes(option.value) : value === option.value}
|
|
105
|
+
/>
|
|
106
|
+
<span>{option.label}</span>
|
|
107
|
+
</label>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
<div data-mk-question-actions="" data-mk-flow-item="">
|
|
113
|
+
{i > 0 && (
|
|
114
|
+
<button type="button" className="mk-button mk-button--text" data-mk-question-prev="">
|
|
115
|
+
{labels.prev ?? 'Anterior'}
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
{i < count - 1 ? (
|
|
119
|
+
<button type="button" className="mk-button" data-mk-question-next="">
|
|
120
|
+
{labels.next ?? 'Siguiente'}
|
|
121
|
+
</button>
|
|
122
|
+
) : (
|
|
123
|
+
<button type="submit" className="mk-button">
|
|
124
|
+
{labels.finish ?? 'Finalizar'}
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
<p data-mk-question-error="" role="alert" />
|
|
132
|
+
<div data-mk-question-complete="" hidden>
|
|
133
|
+
{answers && (typeof complete === 'function' ? complete(answers) : complete)}
|
|
134
|
+
<button type="reset" className="mk-button mk-button--tonal">
|
|
135
|
+
{labels.restart ?? 'Empezar de nuevo'}
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</form>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# range-calendar
|
|
2
|
+
|
|
3
|
+
Calendario para elegir un rango de fechas en dos clics, de uno o dos meses. El primer clic (o Enter) fija el inicio, que rebota; mientras eliges el final, al señalar se ve una banda más clara con borde discontinuo; el segundo clic cierra el rango (en cualquier dirección) y su banda se dibuja desde el inicio hasta el final (curva wipe, 12 ms entre días, 0,35 s como mucho); el día final rebota. Los días que no se pueden elegir (`min`, `max`, `unavailable`) aparecen tachados y un rango no puede cruzarlos: si lo intentas, el día pulsado pasa a ser el nuevo inicio. Al cambiar de mes, la cuadrícula se desliza como en `calendar`.
|
|
4
|
+
|
|
5
|
+
## HTML / Astro
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<mk-range-calendar class="mk-range-calendar" visible-months="2" min="2026-11-10"
|
|
9
|
+
unavailable="2026-12-24,2026-12-25"></mk-range-calendar>
|
|
10
|
+
<mk-range-calendar class="mk-range-calendar" start="2026-11-23" end="2026-11-26"></mk-range-calendar>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## React
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null);
|
|
17
|
+
<RangeCalendar value={range} onChange={setRange} visibleMonths={2} unavailable={(day) => day.getDay() === 0} />
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Opciones
|
|
21
|
+
|
|
22
|
+
| Opción | Atributo | Por defecto | Qué hace |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `value` | `start`, `end` | — | Rango `{start, end}` (Date o `'AAAA-MM-DD'`). |
|
|
25
|
+
| `month` | `month` | inicio del rango, o hoy | Primer mes visible. |
|
|
26
|
+
| `visibleMonths` | `visible-months` | `1` | `1` o `2` meses. |
|
|
27
|
+
| `firstDay` | `first-day` | `1` (lunes) | Primer día de la semana (0 = domingo). |
|
|
28
|
+
| `min`, `max` | `min`, `max` | — | Límites. |
|
|
29
|
+
| `unavailable` | `unavailable` (lista con comas) | — | Días `'AAAA-MM-DD'` o una función `(día) => boolean`. |
|
|
30
|
+
| `locale` | `locale` | `lang` de la página, o `es` | Nombres de meses y días. |
|
|
31
|
+
| `today` | `today` | hoy | Para demos y pruebas. |
|
|
32
|
+
| `labels` | — | Mes anterior / Mes siguiente | Nombres de los botones. |
|
|
33
|
+
| `selection` | — | `'range'` | `'single'`: un clic elige un solo día (lo usa `date-time-picker`). |
|
|
34
|
+
| `onChange(range)` | — | — | Rango cerrado `{start, end}` (Date). |
|
|
35
|
+
|
|
36
|
+
## Teclado
|
|
37
|
+
|
|
38
|
+
| Tecla | Acción |
|
|
39
|
+
|---|---|
|
|
40
|
+
| ← / → | Día anterior / siguiente |
|
|
41
|
+
| ↑ / ↓ | Semana anterior / siguiente |
|
|
42
|
+
| Inicio / Fin | Principio / final de la semana |
|
|
43
|
+
| RePág / AvPág | Mes anterior / siguiente (con Mayús: año) |
|
|
44
|
+
| Enter / Espacio | El primer día fija el inicio; el segundo cierra el rango |
|
|
45
|
+
|
|
46
|
+
Solo un día es tabulable (tabindex itinerante); al salir de los meses visibles con el teclado, el calendario cambia de mes.
|
|
47
|
+
|
|
48
|
+
## API y eventos
|
|
49
|
+
|
|
50
|
+
`setValue(range | null, animate = true)`, `next()`, `prev()`, `focusDay(día, animate = true)` (con `animate=false` cambia de mes sin deslizar), `value`, `month`, `destroy()`. `bandDuration(días)` dice cuántos segundos tarda en asentarse un rango (banda y rebote del día final). Evento `mk:change` con `{ start: 'AAAA-MM-DD', end: 'AAAA-MM-DD', length }` (días incluidos).
|
|
51
|
+
|
|
52
|
+
## Estado de los días
|
|
53
|
+
|
|
54
|
+
Atributos en cada botón de día: `data-mk-today`, `data-mk-anchor`, `data-mk-in-range`, `data-mk-range-start`, `data-mk-range-end`, `data-mk-preview` (y `-start`/`-end`), `aria-disabled="true"`. La celda (`role="gridcell"`) lleva `aria-selected="true"` en los días del rango.
|
|
55
|
+
|
|
56
|
+
## Variables CSS
|
|
57
|
+
|
|
58
|
+
`--mk-range-day` (2.5rem; 2.25rem bajo 380 px), `--mk-range-band` (`--mk-accent-soft`). Esquinas exteriores de 20 px, botones de 8 px. Con movimiento reducido no hay deslizamiento, banda animada ni rebotes.
|