formblocks 0.1.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 +7 -0
- data/AGENTS.md +57 -0
- data/CHANGELOG.md +24 -0
- data/MIT-LICENSE +20 -0
- data/README.md +472 -0
- data/Rakefile +24 -0
- data/app/controllers/concerns/formblocks/request_context.rb +58 -0
- data/app/controllers/formblocks/application_controller.rb +16 -0
- data/app/controllers/formblocks/assets_controller.rb +27 -0
- data/app/controllers/formblocks/blocks_controller.rb +81 -0
- data/app/controllers/formblocks/dashboard_controller.rb +41 -0
- data/app/controllers/formblocks/forms_controller.rb +71 -0
- data/app/controllers/formblocks/pages_controller.rb +44 -0
- data/app/controllers/formblocks/public/forms_controller.rb +74 -0
- data/app/controllers/formblocks/responses_controller.rb +39 -0
- data/app/controllers/formblocks/settings_controller.rb +30 -0
- data/app/helpers/formblocks/application_helper.rb +93 -0
- data/app/models/concerns/formblocks/attachable.rb +43 -0
- data/app/models/formblocks/application_record.rb +7 -0
- data/app/models/formblocks/block.rb +177 -0
- data/app/models/formblocks/blocks/checkbox.rb +22 -0
- data/app/models/formblocks/blocks/email.rb +23 -0
- data/app/models/formblocks/blocks/heading.rb +13 -0
- data/app/models/formblocks/blocks/hidden.rb +25 -0
- data/app/models/formblocks/blocks/image.rb +14 -0
- data/app/models/formblocks/blocks/input.rb +59 -0
- data/app/models/formblocks/blocks/name.rb +9 -0
- data/app/models/formblocks/blocks/paragraph.rb +13 -0
- data/app/models/formblocks/blocks/phone.rb +13 -0
- data/app/models/formblocks/blocks/radio_group.rb +28 -0
- data/app/models/formblocks/blocks/text.rb +12 -0
- data/app/models/formblocks/blocks/textarea.rb +8 -0
- data/app/models/formblocks/blocks/url.rb +26 -0
- data/app/models/formblocks/csv_export.rb +47 -0
- data/app/models/formblocks/form.rb +197 -0
- data/app/models/formblocks/page.rb +79 -0
- data/app/models/formblocks/response.rb +48 -0
- data/app/models/formblocks/setting.rb +19 -0
- data/app/views/formblocks/blocks/_block.html.erb +102 -0
- data/app/views/formblocks/blocks/_palette.html.erb +16 -0
- data/app/views/formblocks/forms/_header.html.erb +19 -0
- data/app/views/formblocks/forms/edit.html.erb +38 -0
- data/app/views/formblocks/forms/index.html.erb +48 -0
- data/app/views/formblocks/forms/new.html.erb +26 -0
- data/app/views/formblocks/forms/published.html.erb +25 -0
- data/app/views/formblocks/forms/settings.html.erb +43 -0
- data/app/views/formblocks/pages/_page.html.erb +51 -0
- data/app/views/formblocks/public/blocks/_checkbox.html.erb +15 -0
- data/app/views/formblocks/public/blocks/_field.html.erb +13 -0
- data/app/views/formblocks/public/blocks/_heading.html.erb +1 -0
- data/app/views/formblocks/public/blocks/_hidden.html.erb +5 -0
- data/app/views/formblocks/public/blocks/_image.html.erb +3 -0
- data/app/views/formblocks/public/blocks/_paragraph.html.erb +1 -0
- data/app/views/formblocks/public/blocks/_radio_group.html.erb +17 -0
- data/app/views/formblocks/public/blocks/_text.html.erb +6 -0
- data/app/views/formblocks/public/blocks/_textarea.html.erb +5 -0
- data/app/views/formblocks/public/forms/not_found.html.erb +3 -0
- data/app/views/formblocks/public/forms/show.html.erb +37 -0
- data/app/views/formblocks/public/forms/thanks.html.erb +5 -0
- data/app/views/formblocks/responses/index.html.erb +45 -0
- data/app/views/formblocks/responses/show.html.erb +43 -0
- data/app/views/formblocks/settings/show.html.erb +19 -0
- data/app/views/formblocks/shared/_admin.html.erb +21 -0
- data/app/views/formblocks/shared/_brand_fields.html.erb +29 -0
- data/app/views/formblocks/shared/_errors.html.erb +7 -0
- data/app/views/layouts/formblocks/application.html.erb +18 -0
- data/app/views/layouts/formblocks/public.html.erb +20 -0
- data/config/locales/formblocks.en.yml +188 -0
- data/config/routes.rb +35 -0
- data/lib/formblocks/assets/admin.css +212 -0
- data/lib/formblocks/assets/admin.js +215 -0
- data/lib/formblocks/assets/public.css +66 -0
- data/lib/formblocks/assets/public.js +86 -0
- data/lib/formblocks/assets.rb +81 -0
- data/lib/formblocks/configuration.rb +93 -0
- data/lib/formblocks/engine.rb +33 -0
- data/lib/formblocks/seeds.rb +50 -0
- data/lib/formblocks/templates.rb +97 -0
- data/lib/formblocks/version.rb +5 -0
- data/lib/formblocks.rb +74 -0
- data/lib/generators/formblocks/install/install_generator.rb +43 -0
- data/lib/generators/formblocks/install/templates/create_formblocks_tables.rb.tt +65 -0
- data/lib/generators/formblocks/install/templates/initializer.rb.tt +69 -0
- data/lib/generators/formblocks/migration_helpers.rb +40 -0
- data/lib/tasks/formblocks_tasks.rake +10 -0
- metadata +199 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/* Formblocks admin styles.
|
|
2
|
+
*
|
|
3
|
+
* Three layers, and the split is what lets a host replace the layout:
|
|
4
|
+
*
|
|
5
|
+
* --fb-* custom properties, prefixed so they can neither overwrite a
|
|
6
|
+
* host's nor be overwritten by them.
|
|
7
|
+
* .fb-page the page frame (bare html/body). Only the gem's own layout
|
|
8
|
+
* puts this class on <body>, so inside a host admin these
|
|
9
|
+
* rules simply do not apply.
|
|
10
|
+
* .fb-admin everything else, nested. Nothing here styles a bare `body`,
|
|
11
|
+
* `a`, `table` or `*` at the top level, so loading this file
|
|
12
|
+
* inside a host cannot restyle its chrome.
|
|
13
|
+
*/
|
|
14
|
+
.fb-page, .fb-admin {
|
|
15
|
+
--fb-bg: #f6f7f9;
|
|
16
|
+
--fb-surface: #ffffff;
|
|
17
|
+
--fb-surface-2: #f9fafb;
|
|
18
|
+
--fb-border: #e5e7eb;
|
|
19
|
+
--fb-border-strong: #d1d5db;
|
|
20
|
+
--fb-text: #111827;
|
|
21
|
+
--fb-muted: #6b7280;
|
|
22
|
+
--fb-accent: #2563eb;
|
|
23
|
+
--fb-accent-hover: #1d4ed8;
|
|
24
|
+
--fb-accent-soft: #eef3ff;
|
|
25
|
+
--fb-danger: #dc2626;
|
|
26
|
+
--fb-danger-soft: #fef2f2;
|
|
27
|
+
--fb-success: #16a34a;
|
|
28
|
+
--fb-success-soft: #f0fdf4;
|
|
29
|
+
--fb-radius: 10px;
|
|
30
|
+
--fb-radius-sm: 6px;
|
|
31
|
+
--fb-shadow: 0 1px 2px rgba(17, 24, 39, 0.05);
|
|
32
|
+
--fb-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
33
|
+
--fb-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.fb-page { margin: 0; min-height: 100vh; background: var(--fb-bg); color: var(--fb-text); font: 15px/1.5 var(--fb-font); -webkit-font-smoothing: antialiased; }
|
|
37
|
+
|
|
38
|
+
.fb-admin { font: 15px/1.5 var(--fb-font); color: var(--fb-text); }
|
|
39
|
+
.fb-admin *, .fb-admin *::before, .fb-admin *::after { box-sizing: border-box; }
|
|
40
|
+
.fb-admin p { margin: 0 0 8px; }
|
|
41
|
+
.fb-admin .fb-icon { width: 16px; height: 16px; flex: none; vertical-align: -3px; }
|
|
42
|
+
.fb-admin .fb-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
43
|
+
.fb-js .fb-nojs { display: none !important; } /* the no-JavaScript Save buttons; !important outranks .fb-admin .fb-btn */
|
|
44
|
+
|
|
45
|
+
/* Navigation and page frame */
|
|
46
|
+
.fb-admin .fb-nav { display: flex; gap: 4px; align-items: center; padding: 10px 24px; background: var(--fb-surface); border-bottom: 1px solid var(--fb-border); }
|
|
47
|
+
.fb-admin .fb-nav__link { padding: 6px 12px; border-radius: var(--fb-radius-sm); color: var(--fb-muted); text-decoration: none; font-weight: 500; }
|
|
48
|
+
.fb-admin .fb-nav__link:hover { background: var(--fb-surface-2); color: var(--fb-text); }
|
|
49
|
+
.fb-admin .fb-nav__link.is-active { background: var(--fb-accent-soft); color: var(--fb-accent); }
|
|
50
|
+
.fb-admin .fb-container { max-width: 960px; margin: 0 auto; padding: 28px 24px 64px; }
|
|
51
|
+
.fb-admin .fb-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 20px; }
|
|
52
|
+
.fb-admin .fb-flash { padding: 10px 14px; border-radius: var(--fb-radius-sm); margin: 0 0 16px; font-weight: 500; }
|
|
53
|
+
.fb-admin .fb-flash--notice { background: var(--fb-success-soft); color: #166534; }
|
|
54
|
+
.fb-admin .fb-flash--alert { background: var(--fb-danger-soft); color: #991b1b; }
|
|
55
|
+
|
|
56
|
+
/* Type */
|
|
57
|
+
.fb-admin .fb-h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
|
|
58
|
+
.fb-admin .fb-h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
|
59
|
+
.fb-admin .fb-h3 { font-size: 15px; font-weight: 600; margin: 0 0 4px; }
|
|
60
|
+
.fb-admin .fb-muted { color: var(--fb-muted); }
|
|
61
|
+
.fb-admin .fb-small { font-size: 13px; }
|
|
62
|
+
.fb-admin .fb-mono { font-family: var(--fb-mono); font-size: 13px; }
|
|
63
|
+
.fb-admin .fb-link { color: var(--fb-accent); text-decoration: none; }
|
|
64
|
+
.fb-admin .fb-link:hover { text-decoration: underline; }
|
|
65
|
+
.fb-admin .fb-link--strong { color: var(--fb-text); font-weight: 600; }
|
|
66
|
+
.fb-admin .fb-backlink { display: inline-flex; gap: 4px; align-items: center; color: var(--fb-muted); text-decoration: none; font-size: 13px; }
|
|
67
|
+
.fb-admin .fb-backlink:hover { color: var(--fb-text); }
|
|
68
|
+
|
|
69
|
+
/* Buttons */
|
|
70
|
+
.fb-admin .fb-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border: 1px solid var(--fb-border-strong); border-radius: var(--fb-radius-sm); background: var(--fb-surface); color: var(--fb-text); font: inherit; font-weight: 500; line-height: 1.2; cursor: pointer; text-decoration: none; white-space: nowrap; }
|
|
71
|
+
.fb-admin .fb-btn:hover { background: var(--fb-surface-2); }
|
|
72
|
+
.fb-admin .fb-btn--primary { background: var(--fb-accent); border-color: var(--fb-accent); color: #fff; }
|
|
73
|
+
.fb-admin .fb-btn--primary:hover { background: var(--fb-accent-hover); border-color: var(--fb-accent-hover); }
|
|
74
|
+
.fb-admin .fb-btn--ghost { background: transparent; border-color: transparent; color: var(--fb-muted); }
|
|
75
|
+
.fb-admin .fb-btn--ghost:hover { background: var(--fb-surface-2); color: var(--fb-text); }
|
|
76
|
+
.fb-admin .fb-btn--danger { background: var(--fb-danger); border-color: var(--fb-danger); color: #fff; }
|
|
77
|
+
.fb-admin .fb-btn--danger:hover { background: #b91c1c; }
|
|
78
|
+
.fb-admin .fb-btn--sm { padding: 5px 10px; font-size: 13px; }
|
|
79
|
+
.fb-admin .fb-iconbtn { display: inline-flex; padding: 5px; border: 0; background: transparent; color: var(--fb-muted); border-radius: var(--fb-radius-sm); cursor: pointer; }
|
|
80
|
+
.fb-admin .fb-iconbtn:hover { background: var(--fb-surface-2); color: var(--fb-text); }
|
|
81
|
+
.fb-admin .fb-iconbtn--danger:hover { background: var(--fb-danger-soft); color: var(--fb-danger); }
|
|
82
|
+
.fb-admin .fb-iconbtn:disabled { opacity: 0.35; cursor: default; background: transparent; }
|
|
83
|
+
.fb-admin .fb-actions { display: inline-flex; gap: 2px; align-items: center; }
|
|
84
|
+
.fb-admin .fb-actions form, .fb-admin .fb-table__actions form, .fb-admin .fb-builder-footer__actions form { display: inline; }
|
|
85
|
+
.fb-admin .fb-form-actions { display: flex; justify-content: flex-end; }
|
|
86
|
+
|
|
87
|
+
/* Cards and tables */
|
|
88
|
+
.fb-admin .fb-card { background: var(--fb-surface); border: 1px solid var(--fb-border); border-radius: var(--fb-radius); box-shadow: var(--fb-shadow); padding: 20px; }
|
|
89
|
+
.fb-admin .fb-card--flush { padding: 0; overflow-x: auto; } /* a wide table scrolls inside the card on narrow screens */
|
|
90
|
+
.fb-admin .fb-card--danger { border-color: #fecaca; margin-top: 32px; }
|
|
91
|
+
.fb-admin .fb-stack > * + * { margin-top: 16px; }
|
|
92
|
+
.fb-admin .fb-table { width: 100%; border-collapse: collapse; }
|
|
93
|
+
.fb-admin .fb-table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--fb-muted); padding: 10px 16px; border-bottom: 1px solid var(--fb-border); background: var(--fb-surface-2); }
|
|
94
|
+
.fb-admin .fb-table td { padding: 12px 16px; border-bottom: 1px solid var(--fb-border); vertical-align: middle; }
|
|
95
|
+
.fb-admin .fb-table tr:last-child td { border-bottom: 0; }
|
|
96
|
+
.fb-admin .fb-table__actions { text-align: right; white-space: nowrap; }
|
|
97
|
+
.fb-admin .fb-cell-clip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
98
|
+
.fb-admin .fb-empty { text-align: center; padding: 48px 20px; color: var(--fb-muted); }
|
|
99
|
+
.fb-admin .fb-empty p { margin-bottom: 16px; }
|
|
100
|
+
.fb-admin .fb-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|
101
|
+
.fb-admin .fb-badge--draft { background: #f3f4f6; color: #4b5563; }
|
|
102
|
+
.fb-admin .fb-badge--published { background: var(--fb-success-soft); color: #15803d; }
|
|
103
|
+
.fb-admin .fb-count { display: inline-block; min-width: 20px; padding: 0 6px; border-radius: 999px; background: #e5e7eb; font-size: 12px; text-align: center; }
|
|
104
|
+
.fb-admin .fb-more { text-align: center; margin: 16px 0; }
|
|
105
|
+
|
|
106
|
+
/* Form controls */
|
|
107
|
+
.fb-admin .fb-field { margin: 0 0 14px; }
|
|
108
|
+
.fb-admin .fb-label { display: block; font-size: 13px; font-weight: 600; margin: 0 0 4px; }
|
|
109
|
+
.fb-admin .fb-input { display: block; width: 100%; padding: 8px 10px; border: 1px solid var(--fb-border-strong); border-radius: var(--fb-radius-sm); background: var(--fb-surface); color: var(--fb-text); font: inherit; line-height: 1.4; }
|
|
110
|
+
.fb-admin .fb-input:focus { outline: 2px solid var(--fb-accent); outline-offset: -1px; border-color: var(--fb-accent); }
|
|
111
|
+
.fb-admin .fb-input--mono { font-family: var(--fb-mono); font-size: 13px; }
|
|
112
|
+
.fb-admin .fb-input--file { padding: 6px; }
|
|
113
|
+
.fb-admin textarea.fb-input { resize: vertical; min-height: 38px; }
|
|
114
|
+
.fb-admin .fb-hint { font-size: 12px; color: var(--fb-muted); margin: 4px 0 0; }
|
|
115
|
+
.fb-admin .fb-check { display: inline-flex; gap: 8px; align-items: center; font-size: 14px; cursor: pointer; margin: 0 0 12px; }
|
|
116
|
+
.fb-admin .fb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
|
|
117
|
+
.fb-admin .fb-input-group { display: flex; align-items: stretch; }
|
|
118
|
+
.fb-admin .fb-input-group__prefix { display: flex; align-items: center; padding: 0 10px; border: 1px solid var(--fb-border-strong); border-right: 0; border-radius: var(--fb-radius-sm) 0 0 var(--fb-radius-sm); background: var(--fb-surface-2); color: var(--fb-muted); white-space: nowrap; }
|
|
119
|
+
.fb-admin .fb-input-group .fb-input { border-radius: 0 var(--fb-radius-sm) var(--fb-radius-sm) 0; }
|
|
120
|
+
.fb-admin .fb-input-group .fb-input:first-child { border-radius: var(--fb-radius-sm) 0 0 var(--fb-radius-sm); }
|
|
121
|
+
.fb-admin .fb-input-group .fb-btn { border-radius: 0 var(--fb-radius-sm) var(--fb-radius-sm) 0; margin-left: -1px; }
|
|
122
|
+
.fb-admin .fb-color { display: flex; gap: 8px; align-items: center; }
|
|
123
|
+
.fb-admin .fb-color__picker { width: 38px; height: 38px; padding: 2px; border: 1px solid var(--fb-border-strong); border-radius: var(--fb-radius-sm); background: var(--fb-surface); cursor: pointer; flex: none; }
|
|
124
|
+
.fb-admin .fb-logo-preview img { max-height: 64px; max-width: 240px; display: block; margin: 0 0 8px; }
|
|
125
|
+
.fb-admin .field_with_errors { display: contents; }
|
|
126
|
+
.fb-admin .fb-errors { margin: 0 0 14px; padding: 10px 14px 10px 30px; background: var(--fb-danger-soft); color: #991b1b; border-radius: var(--fb-radius-sm); font-size: 14px; }
|
|
127
|
+
|
|
128
|
+
/* Form header and tabs */
|
|
129
|
+
.fb-admin .fb-form-header { margin: 0 0 20px; }
|
|
130
|
+
.fb-admin .fb-form-header__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 6px 0 14px; }
|
|
131
|
+
.fb-admin .fb-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--fb-border); }
|
|
132
|
+
.fb-admin .fb-tab { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; margin-bottom: -1px; color: var(--fb-muted); text-decoration: none; font-weight: 500; border-bottom: 2px solid transparent; }
|
|
133
|
+
.fb-admin .fb-tab:hover { color: var(--fb-text); }
|
|
134
|
+
.fb-admin .fb-tab.is-active { color: var(--fb-accent); border-bottom-color: var(--fb-accent); }
|
|
135
|
+
|
|
136
|
+
/* Template picker */
|
|
137
|
+
.fb-admin .fb-templates { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
|
138
|
+
.fb-admin .fb-template { display: flex; flex-direction: column; gap: 4px; }
|
|
139
|
+
.fb-admin .fb-template form { margin-top: auto; padding-top: 12px; }
|
|
140
|
+
|
|
141
|
+
/* Builder */
|
|
142
|
+
.fb-admin .fb-builder { max-width: 760px; margin: 0 auto; }
|
|
143
|
+
.fb-admin .fb-page-card { background: var(--fb-surface); border: 1px solid var(--fb-border); border-radius: var(--fb-radius); box-shadow: var(--fb-shadow); padding: 20px; margin: 0 0 16px; }
|
|
144
|
+
.fb-admin .fb-page-card__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin: 0 0 16px; }
|
|
145
|
+
.fb-admin .fb-page-card__footer { margin-top: 16px; padding-top: 16px; border-top: 1px dashed var(--fb-border); }
|
|
146
|
+
.fb-admin .fb-add-page { text-align: center; margin: 0 0 16px; }
|
|
147
|
+
.fb-admin .fb-empty-page { text-align: center; padding: 20px; border: 1px dashed var(--fb-border-strong); border-radius: var(--fb-radius-sm); margin: 0 0 12px; }
|
|
148
|
+
.fb-admin .fb-blocks { display: flex; flex-direction: column; gap: 10px; margin: 0 0 12px; }
|
|
149
|
+
.fb-admin .fb-blocks:empty { margin: 0; }
|
|
150
|
+
.fb-admin .fb-block { border: 1px solid var(--fb-border); border-radius: var(--fb-radius-sm); background: var(--fb-surface); }
|
|
151
|
+
.fb-admin .fb-block.fb-dragging { opacity: 0.4; }
|
|
152
|
+
.fb-admin .fb-block__header { display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 4px; border-bottom: 1px solid var(--fb-border); background: var(--fb-surface-2); border-radius: var(--fb-radius-sm) var(--fb-radius-sm) 0 0; }
|
|
153
|
+
.fb-admin .fb-block__grip { border: 0; background: transparent; color: var(--fb-muted); cursor: grab; padding: 4px; display: inline-flex; }
|
|
154
|
+
.fb-admin .fb-block__grip:active { cursor: grabbing; }
|
|
155
|
+
.fb-admin .fb-block__type { flex: 1; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--fb-muted); }
|
|
156
|
+
.fb-admin .fb-block__form { padding: 14px 14px 6px; }
|
|
157
|
+
.fb-admin .fb-block__footer { display: flex; justify-content: flex-end; align-items: center; gap: 8px; min-height: 20px; margin: 0 0 6px; }
|
|
158
|
+
.fb-admin .fb-block__image { margin: 0; }
|
|
159
|
+
.fb-admin .fb-block__image img { max-height: 160px; max-width: 100%; border-radius: var(--fb-radius-sm); display: block; margin: 0 0 10px; }
|
|
160
|
+
.fb-admin .fb-autosave-status { font-size: 12px; color: var(--fb-muted); transition: opacity 0.4s; }
|
|
161
|
+
.fb-admin .fb-autosave-status.is-error { color: var(--fb-danger); }
|
|
162
|
+
|
|
163
|
+
/* Block palette — a native <details>, no JavaScript needed */
|
|
164
|
+
.fb-admin .fb-palette { position: relative; }
|
|
165
|
+
.fb-admin .fb-palette__toggle { list-style: none; }
|
|
166
|
+
.fb-admin .fb-palette__toggle::-webkit-details-marker { display: none; }
|
|
167
|
+
.fb-admin .fb-palette[open] .fb-palette__toggle { background: var(--fb-surface-2); color: var(--fb-text); }
|
|
168
|
+
.fb-admin .fb-palette__menu { position: absolute; z-index: 10; top: calc(100% + 4px); left: 0; display: flex; gap: 16px; padding: 12px; background: var(--fb-surface); border: 1px solid var(--fb-border); border-radius: var(--fb-radius); box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); }
|
|
169
|
+
.fb-admin .fb-palette__group { display: grid; grid-template-columns: 1fr; gap: 2px; align-content: start; min-width: 140px; }
|
|
170
|
+
.fb-admin .fb-palette__group:last-child { grid-template-columns: 1fr 1fr; min-width: 300px; }
|
|
171
|
+
.fb-admin .fb-palette__group form { display: contents; }
|
|
172
|
+
.fb-admin .fb-palette__title { grid-column: 1 / -1; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fb-muted); margin: 0 0 4px; padding: 0 10px; }
|
|
173
|
+
.fb-admin .fb-palette__item { text-align: left; padding: 6px 10px; border: 1px solid transparent; border-radius: var(--fb-radius-sm); background: transparent; color: var(--fb-text); font: inherit; font-size: 14px; cursor: pointer; }
|
|
174
|
+
.fb-admin .fb-palette__item:hover { background: var(--fb-accent-soft); color: var(--fb-accent); }
|
|
175
|
+
|
|
176
|
+
/* The page button: the input IS the preview, click it to edit the text */
|
|
177
|
+
.fb-admin .fb-button-editor { display: flex; align-items: center; gap: 10px; }
|
|
178
|
+
.fb-admin .fb-button-preview { min-width: 90px; max-width: 100%; padding: 10px 18px; border: 2px dashed transparent; border-radius: 8px; background: var(--fb-primary, var(--fb-accent)); color: var(--fb-primary-text, #fff); font: inherit; font-weight: 600; text-align: center; cursor: text; }
|
|
179
|
+
.fb-admin .fb-button-preview:hover, .fb-admin .fb-button-preview:focus { border-color: rgba(0, 0, 0, 0.3); outline: none; }
|
|
180
|
+
|
|
181
|
+
/* Sticky action bar under the builder. The scroll padding keeps anchors and
|
|
182
|
+
* scrollIntoView targets from landing underneath it. */
|
|
183
|
+
html:has(.fb-builder-footer) { scroll-padding-bottom: 110px; }
|
|
184
|
+
.fb-admin .fb-builder-footer { position: sticky; bottom: 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; max-width: 760px; margin: 24px auto 0; padding: 12px 16px; background: var(--fb-surface); border: 1px solid var(--fb-border); border-radius: var(--fb-radius); box-shadow: 0 8px 24px rgba(17, 24, 39, 0.1); }
|
|
185
|
+
.fb-admin .fb-builder-footer p { margin: 0; overflow-wrap: anywhere; }
|
|
186
|
+
.fb-admin .fb-builder-footer__actions { display: flex; gap: 8px; align-items: center; flex: none; }
|
|
187
|
+
|
|
188
|
+
/* Published */
|
|
189
|
+
.fb-admin .fb-published { max-width: 560px; margin: 32px auto; text-align: center; padding: 40px 32px; }
|
|
190
|
+
.fb-admin .fb-published__icon { color: var(--fb-success); margin: 0 0 12px; }
|
|
191
|
+
.fb-admin .fb-published__icon .fb-icon { width: 56px; height: 56px; }
|
|
192
|
+
.fb-admin .fb-published .fb-label { text-align: left; margin-top: 24px; }
|
|
193
|
+
.fb-admin .fb-published__actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 24px; }
|
|
194
|
+
|
|
195
|
+
/* Definition lists (a response) */
|
|
196
|
+
.fb-admin .fb-dl { margin: 0; }
|
|
197
|
+
.fb-admin .fb-dl__row { display: grid; grid-template-columns: 200px 1fr; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--fb-border); }
|
|
198
|
+
.fb-admin .fb-dl__row:last-child { border-bottom: 0; }
|
|
199
|
+
.fb-admin .fb-dl dt { font-weight: 600; color: var(--fb-muted); font-size: 13px; }
|
|
200
|
+
.fb-admin .fb-dl dd { margin: 0; }
|
|
201
|
+
.fb-admin .fb-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
202
|
+
.fb-admin .fb-break { overflow-wrap: anywhere; }
|
|
203
|
+
|
|
204
|
+
@media (max-width: 640px) {
|
|
205
|
+
.fb-admin .fb-container { padding: 16px 16px 48px; }
|
|
206
|
+
.fb-admin .fb-grid { grid-template-columns: 1fr; }
|
|
207
|
+
.fb-admin .fb-dl__row { grid-template-columns: 1fr; gap: 4px; }
|
|
208
|
+
.fb-admin .fb-palette__menu { flex-direction: column; width: 100%; }
|
|
209
|
+
.fb-admin .fb-palette__group, .fb-admin .fb-palette__group:last-child { min-width: 0; grid-template-columns: 1fr; }
|
|
210
|
+
.fb-admin .fb-builder-footer { flex-direction: column; align-items: stretch; }
|
|
211
|
+
.fb-admin .fb-header { flex-wrap: wrap; }
|
|
212
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Formblocks admin — the Stimulus controllers behind the builder.
|
|
3
|
+
*
|
|
4
|
+
* Served by the engine as an ES module. Turbo and Stimulus are imported from
|
|
5
|
+
* the engine as well (they ship with the turbo-rails and stimulus-rails gems
|
|
6
|
+
* the engine depends on), so no host build step or importmap is involved.
|
|
7
|
+
* A host that already runs Turbo keeps its own; a second Turbo would start a
|
|
8
|
+
* second session.
|
|
9
|
+
*
|
|
10
|
+
* Every identifier is prefixed `fb-`, so a host's own Stimulus application
|
|
11
|
+
* (which also scans the document) ignores them, and ours ignores the host's.
|
|
12
|
+
* The double-brace tokens below are filled in with fingerprinted URLs when served.
|
|
13
|
+
*/
|
|
14
|
+
const base = new URL(".", import.meta.url);
|
|
15
|
+
if (!window.Turbo) await import(new URL("{{turbo.js}}", base));
|
|
16
|
+
const { Application, Controller } = await import(new URL("{{stimulus.js}}", base));
|
|
17
|
+
|
|
18
|
+
document.documentElement.classList.add("fb-js");
|
|
19
|
+
|
|
20
|
+
const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || "";
|
|
21
|
+
|
|
22
|
+
// Saves a form on input (debounced) or change (immediately) through Turbo,
|
|
23
|
+
// and reports the outcome in a status target. Text edits answer 204, so the
|
|
24
|
+
// field being typed in is never re-rendered under the cursor.
|
|
25
|
+
class Autosave extends Controller {
|
|
26
|
+
static targets = ["status"];
|
|
27
|
+
static values = {
|
|
28
|
+
saving: { type: String, default: "Saving…" },
|
|
29
|
+
saved: { type: String, default: "Saved" },
|
|
30
|
+
failed: { type: String, default: "Could not save" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
connect() {
|
|
34
|
+
this.dirty = false;
|
|
35
|
+
this.submitting = false;
|
|
36
|
+
this.pending = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnect() {
|
|
40
|
+
clearTimeout(this.timer);
|
|
41
|
+
clearTimeout(this.fadeTimer);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
queue() {
|
|
45
|
+
this.dirty = true;
|
|
46
|
+
clearTimeout(this.timer);
|
|
47
|
+
this.timer = setTimeout(() => this.save(), 700);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
save() {
|
|
51
|
+
clearTimeout(this.timer);
|
|
52
|
+
if (!this.dirty) return;
|
|
53
|
+
if (this.submitting) {
|
|
54
|
+
this.pending = true;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.dirty = false;
|
|
58
|
+
this.submitting = true;
|
|
59
|
+
this.show(this.savingValue, false);
|
|
60
|
+
this.element.requestSubmit();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
finished(event) {
|
|
64
|
+
this.submitting = false;
|
|
65
|
+
const ok = event.detail?.success;
|
|
66
|
+
this.show(ok ? this.savedValue : this.failedValue, !ok);
|
|
67
|
+
if (ok) this.fade();
|
|
68
|
+
if (this.pending) {
|
|
69
|
+
this.pending = false;
|
|
70
|
+
this.save();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
show(text, error) {
|
|
75
|
+
if (!this.hasStatusTarget) return;
|
|
76
|
+
clearTimeout(this.fadeTimer);
|
|
77
|
+
this.statusTarget.textContent = text;
|
|
78
|
+
this.statusTarget.classList.toggle("is-error", error);
|
|
79
|
+
this.statusTarget.style.opacity = 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fade() {
|
|
83
|
+
this.fadeTimer = setTimeout(() => {
|
|
84
|
+
if (this.hasStatusTarget) this.statusTarget.style.opacity = 0;
|
|
85
|
+
}, 1500);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Drag-and-drop ordering with the browser's own drag events — no library.
|
|
90
|
+
// Only the grip handle starts a drag, so text in the inputs stays selectable.
|
|
91
|
+
// The new order is PATCHed as JSON; the move buttons remain for keyboards.
|
|
92
|
+
class Sortable extends Controller {
|
|
93
|
+
static targets = ["item"];
|
|
94
|
+
static values = { url: String };
|
|
95
|
+
|
|
96
|
+
connect() {
|
|
97
|
+
this.handlers = {
|
|
98
|
+
mousedown: (event) => this.arm(event),
|
|
99
|
+
dragstart: (event) => this.dragstart(event),
|
|
100
|
+
dragover: (event) => this.dragover(event),
|
|
101
|
+
drop: (event) => this.drop(event),
|
|
102
|
+
dragend: () => this.dragend(),
|
|
103
|
+
};
|
|
104
|
+
for (const [name, handler] of Object.entries(this.handlers)) this.element.addEventListener(name, handler);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
disconnect() {
|
|
108
|
+
for (const [name, handler] of Object.entries(this.handlers)) this.element.removeEventListener(name, handler);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
arm(event) {
|
|
112
|
+
const handle = event.target.closest?.("[data-fb-sortable-handle]");
|
|
113
|
+
const item = handle?.closest("[data-fb-sortable-target='item']");
|
|
114
|
+
if (item) item.draggable = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
dragstart(event) {
|
|
118
|
+
const item = event.target.closest?.("[data-fb-sortable-target='item']");
|
|
119
|
+
if (!item || !item.draggable) return;
|
|
120
|
+
this.dragging = item;
|
|
121
|
+
event.dataTransfer.effectAllowed = "move";
|
|
122
|
+
try { event.dataTransfer.setData("text/plain", item.dataset.id); } catch (_) { /* IE */ }
|
|
123
|
+
requestAnimationFrame(() => item.classList.add("fb-dragging"));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
dragover(event) {
|
|
127
|
+
if (!this.dragging) return;
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.dataTransfer.dropEffect = "move";
|
|
130
|
+
const over = event.target.closest?.("[data-fb-sortable-target='item']");
|
|
131
|
+
if (!over || over === this.dragging || over.parentNode !== this.element) return;
|
|
132
|
+
const rect = over.getBoundingClientRect();
|
|
133
|
+
const before = event.clientY < rect.top + rect.height / 2;
|
|
134
|
+
this.element.insertBefore(this.dragging, before ? over : over.nextSibling);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
drop(event) {
|
|
138
|
+
if (this.dragging) event.preventDefault();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
dragend() {
|
|
142
|
+
if (!this.dragging) return;
|
|
143
|
+
this.dragging.classList.remove("fb-dragging");
|
|
144
|
+
this.dragging.draggable = false;
|
|
145
|
+
this.dragging = null;
|
|
146
|
+
this.persist();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
persist() {
|
|
150
|
+
const ids = this.itemTargets.map((item) => item.dataset.id);
|
|
151
|
+
fetch(this.urlValue, {
|
|
152
|
+
method: "PATCH",
|
|
153
|
+
credentials: "same-origin",
|
|
154
|
+
headers: {
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
"X-CSRF-Token": csrfToken(),
|
|
157
|
+
Accept: "text/vnd.turbo-stream.html, text/html",
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({ ids }),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Copies the public URL on the "published" page.
|
|
165
|
+
class Clipboard extends Controller {
|
|
166
|
+
static targets = ["source", "label"];
|
|
167
|
+
static values = { copied: { type: String, default: "Copied!" } };
|
|
168
|
+
|
|
169
|
+
async copy() {
|
|
170
|
+
const text = this.sourceTarget.value ?? this.sourceTarget.textContent;
|
|
171
|
+
try {
|
|
172
|
+
await navigator.clipboard.writeText(text);
|
|
173
|
+
} catch (_) {
|
|
174
|
+
this.sourceTarget.select?.();
|
|
175
|
+
document.execCommand?.("copy");
|
|
176
|
+
}
|
|
177
|
+
if (!this.hasLabelTarget) return;
|
|
178
|
+
const original = this.labelTarget.textContent;
|
|
179
|
+
this.labelTarget.textContent = this.copiedValue;
|
|
180
|
+
setTimeout(() => (this.labelTarget.textContent = original), 1500);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Keeps a hex text field and a native color picker in sync. The text field
|
|
185
|
+
// is the value that is saved: blank means "inherit".
|
|
186
|
+
class Color extends Controller {
|
|
187
|
+
static targets = ["picker", "text"];
|
|
188
|
+
|
|
189
|
+
pick() {
|
|
190
|
+
this.textTarget.value = this.pickerTarget.value;
|
|
191
|
+
this.textTarget.dispatchEvent(new Event("input", { bubbles: true }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type() {
|
|
195
|
+
const value = this.textTarget.value.trim();
|
|
196
|
+
if (/^#[0-9a-f]{6}$/i.test(value)) this.pickerTarget.value = value;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Sizes the page-button input to its text, so it looks like the button it is.
|
|
201
|
+
class Autosize extends Controller {
|
|
202
|
+
connect() { this.resize(); }
|
|
203
|
+
|
|
204
|
+
resize() {
|
|
205
|
+
const length = this.element.value.length || this.element.placeholder?.length || 4;
|
|
206
|
+
this.element.style.width = `calc(${length + 1}ch + 36px)`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const application = Application.start();
|
|
211
|
+
application.register("fb-autosave", Autosave);
|
|
212
|
+
application.register("fb-sortable", Sortable);
|
|
213
|
+
application.register("fb-clipboard", Clipboard);
|
|
214
|
+
application.register("fb-color", Color);
|
|
215
|
+
application.register("fb-autosize", Autosize);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Formblocks public form page. Brand colors arrive as --fb-primary and
|
|
2
|
+
* --fb-primary-text on .fb-brand (a nonced <style> in the head), so nothing
|
|
3
|
+
* here needs an inline style attribute. */
|
|
4
|
+
.fb-public {
|
|
5
|
+
--fb-primary: #111827;
|
|
6
|
+
--fb-primary-text: #ffffff;
|
|
7
|
+
--fb-bg: #f6f7f9;
|
|
8
|
+
--fb-surface: #ffffff;
|
|
9
|
+
--fb-border: #d1d5db;
|
|
10
|
+
--fb-text: #111827;
|
|
11
|
+
--fb-muted: #6b7280;
|
|
12
|
+
--fb-danger: #dc2626;
|
|
13
|
+
--fb-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
14
|
+
margin: 0;
|
|
15
|
+
min-height: 100vh;
|
|
16
|
+
background: var(--fb-bg);
|
|
17
|
+
color: var(--fb-text);
|
|
18
|
+
font: 16px/1.5 var(--fb-font);
|
|
19
|
+
-webkit-font-smoothing: antialiased;
|
|
20
|
+
}
|
|
21
|
+
.fb-public *, .fb-public *::before, .fb-public *::after { box-sizing: border-box; }
|
|
22
|
+
.fb-public__main { max-width: 640px; margin: 0 auto; padding: 40px 20px 64px; }
|
|
23
|
+
.fb-public__logo { text-align: center; margin: 0 0 24px; }
|
|
24
|
+
.fb-public__logo img { max-height: 64px; max-width: 240px; }
|
|
25
|
+
.fb-form, .fb-thanks { background: var(--fb-surface); border: 1px solid #e5e7eb; border-radius: 14px; padding: 32px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); }
|
|
26
|
+
.fb-thanks { text-align: center; }
|
|
27
|
+
.fb-step { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
28
|
+
.fb-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
29
|
+
|
|
30
|
+
.fb-heading { font-size: 26px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 12px; }
|
|
31
|
+
.fb-paragraph { color: var(--fb-muted); margin: 0 0 20px; }
|
|
32
|
+
.fb-paragraph p { margin: 0 0 10px; }
|
|
33
|
+
.fb-image { margin: 0 0 20px; }
|
|
34
|
+
.fb-image img { max-width: 100%; border-radius: 10px; display: block; }
|
|
35
|
+
|
|
36
|
+
.fb-field { margin: 0 0 20px; }
|
|
37
|
+
.fb-label { display: block; font-weight: 600; margin: 0 0 6px; }
|
|
38
|
+
.fb-required { color: var(--fb-danger); margin-left: 2px; }
|
|
39
|
+
.fb-optional { color: var(--fb-muted); font-weight: 400; font-size: 13px; margin-left: 6px; }
|
|
40
|
+
.fb-input { display: block; width: 100%; padding: 12px 14px; border: 1px solid var(--fb-border); border-radius: 8px; background: #fff; color: var(--fb-text); font: inherit; }
|
|
41
|
+
.fb-input:focus { outline: 2px solid var(--fb-primary); outline-offset: -1px; border-color: var(--fb-primary); }
|
|
42
|
+
textarea.fb-input { resize: vertical; }
|
|
43
|
+
.fb-field--error .fb-input { border-color: var(--fb-danger); }
|
|
44
|
+
.fb-help { font-size: 14px; color: var(--fb-muted); margin: 6px 0 0; }
|
|
45
|
+
.fb-error { font-size: 14px; color: var(--fb-danger); margin: 6px 0 0; }
|
|
46
|
+
.fb-alert { padding: 12px 14px; background: #fef2f2; color: #991b1b; border-radius: 8px; margin: 0 0 20px; }
|
|
47
|
+
.fb-checkbox, .fb-radio { display: flex; gap: 10px; align-items: flex-start; padding: 6px 0; cursor: pointer; }
|
|
48
|
+
.fb-checkbox input, .fb-radio input { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--fb-primary); flex: none; }
|
|
49
|
+
.fb-field--group { border: 0; padding: 0; margin: 0 0 20px; min-width: 0; }
|
|
50
|
+
.fb-field--group legend { padding: 0; }
|
|
51
|
+
|
|
52
|
+
.fb-step__nav { display: flex; justify-content: space-between; gap: 12px; margin-top: 28px; }
|
|
53
|
+
.fb-step__nav .fb-btn--primary { margin-left: auto; }
|
|
54
|
+
.fb-btn { display: inline-flex; align-items: center; justify-content: center; padding: 12px 22px; border-radius: 8px; border: 1px solid transparent; font: inherit; font-weight: 600; cursor: pointer; }
|
|
55
|
+
.fb-btn--primary { background: var(--fb-primary); color: var(--fb-primary-text); }
|
|
56
|
+
.fb-btn--primary:hover { filter: brightness(0.92); }
|
|
57
|
+
.fb-btn--secondary { background: transparent; border-color: var(--fb-border); color: var(--fb-text); }
|
|
58
|
+
.fb-btn--secondary:hover { background: #f3f4f6; }
|
|
59
|
+
.fb-progress { text-align: center; font-size: 13px; color: var(--fb-muted); margin: 20px 0 0; }
|
|
60
|
+
.fb-hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
|
|
61
|
+
|
|
62
|
+
@media (max-width: 480px) {
|
|
63
|
+
.fb-public__main { padding: 20px 12px 40px; }
|
|
64
|
+
.fb-form, .fb-thanks { padding: 20px; }
|
|
65
|
+
.fb-heading { font-size: 22px; }
|
|
66
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Formblocks public form — one Stimulus controller that turns the page's
|
|
3
|
+
* <fieldset>s into steps. Without JavaScript every step is visible and the
|
|
4
|
+
* form still submits as one; with it, one step shows at a time, "Next"
|
|
5
|
+
* validates the current step with the browser's own constraint validation,
|
|
6
|
+
* and a server-side error opens the step it belongs to.
|
|
7
|
+
*
|
|
8
|
+
* Stimulus is imported from the engine (it ships with stimulus-rails); no
|
|
9
|
+
* Turbo here — a plain form post and redirect is all a visitor needs.
|
|
10
|
+
*/
|
|
11
|
+
const base = new URL(".", import.meta.url);
|
|
12
|
+
const { Application, Controller } = await import(new URL("{{stimulus.js}}", base));
|
|
13
|
+
|
|
14
|
+
document.documentElement.classList.add("fb-js");
|
|
15
|
+
|
|
16
|
+
class Pager extends Controller {
|
|
17
|
+
static targets = ["step", "nav", "progress"];
|
|
18
|
+
static values = { stepLabel: { type: String, default: "Step %{current} of %{total}" } };
|
|
19
|
+
|
|
20
|
+
connect() {
|
|
21
|
+
if (this.stepTargets.length < 2) return;
|
|
22
|
+
const errored = this.stepTargets.findIndex((step) => step.hasAttribute("data-fb-pager-errors"));
|
|
23
|
+
this.index = Math.max(0, errored);
|
|
24
|
+
this.onKeydown = (event) => this.keydown(event);
|
|
25
|
+
this.element.addEventListener("keydown", this.onKeydown);
|
|
26
|
+
this.render();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
if (this.onKeydown) this.element.removeEventListener("keydown", this.onKeydown);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
render() {
|
|
34
|
+
this.stepTargets.forEach((step, i) => { step.hidden = i !== this.index; });
|
|
35
|
+
this.navTargets.forEach((button) => { button.hidden = false; });
|
|
36
|
+
if (this.hasProgressTarget) {
|
|
37
|
+
this.progressTarget.hidden = false;
|
|
38
|
+
this.progressTarget.textContent = this.stepLabelValue
|
|
39
|
+
.replace("%{current}", this.index + 1)
|
|
40
|
+
.replace("%{total}", this.stepTargets.length);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
next() {
|
|
45
|
+
if (!this.valid() || this.index >= this.stepTargets.length - 1) return;
|
|
46
|
+
this.index += 1;
|
|
47
|
+
this.render();
|
|
48
|
+
this.focusStep();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
back() {
|
|
52
|
+
if (this.index === 0) return;
|
|
53
|
+
this.index -= 1;
|
|
54
|
+
this.render();
|
|
55
|
+
this.focusStep();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
valid() {
|
|
59
|
+
const fields = this.stepTargets[this.index].querySelectorAll("input, textarea, select");
|
|
60
|
+
for (const field of fields) {
|
|
61
|
+
if (!field.checkValidity()) {
|
|
62
|
+
field.reportValidity();
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Enter in a text field on a non-final step means "next", not "submit".
|
|
70
|
+
keydown(event) {
|
|
71
|
+
if (event.key !== "Enter" || event.target.tagName === "TEXTAREA" || event.target.type === "submit") return;
|
|
72
|
+
if (this.index < this.stepTargets.length - 1) {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
this.next();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
focusStep() {
|
|
79
|
+
this.element.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
80
|
+
const first = this.stepTargets[this.index].querySelector("input:not([type=hidden]), textarea, select");
|
|
81
|
+
first?.focus({ preventScroll: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const application = Application.start();
|
|
86
|
+
application.register("fb-pager", Pager);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Formblocks
|
|
6
|
+
# The engine's CSS and JavaScript, served by the engine itself rather than
|
|
7
|
+
# the host's asset pipeline — so the builder works in a host with Sprockets,
|
|
8
|
+
# Propshaft, a JS bundler, importmap, or nothing at all. The files live
|
|
9
|
+
# under lib/ so a host that does run a pipeline never ingests them either.
|
|
10
|
+
#
|
|
11
|
+
# Turbo and Stimulus come straight from the turbo-rails and stimulus-rails
|
|
12
|
+
# gems the engine depends on, so the host's bundle decides their versions.
|
|
13
|
+
module Assets
|
|
14
|
+
OWN = {
|
|
15
|
+
'admin.css' => ['admin.css', 'text/css'],
|
|
16
|
+
'admin.js' => ['admin.js', 'text/javascript'],
|
|
17
|
+
'public.css' => ['public.css', 'text/css'],
|
|
18
|
+
'public.js' => ['public.js', 'text/javascript']
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def names
|
|
23
|
+
OWN.keys + %w[turbo.js stimulus.js]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# [source, content_type] or nil for an unknown name.
|
|
27
|
+
def lookup(name)
|
|
28
|
+
case name
|
|
29
|
+
when 'turbo.js' then [read(turbo_path), 'text/javascript']
|
|
30
|
+
when 'stimulus.js' then [read(stimulus_path), 'text/javascript']
|
|
31
|
+
else
|
|
32
|
+
file, type = OWN[name]
|
|
33
|
+
file && [link(read(File.expand_path("assets/#{file}", __dir__))), type]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Content fingerprint for cache-busting URLs: a changed file is a changed
|
|
38
|
+
# URL, so no browser can ever run stale code.
|
|
39
|
+
def fingerprint(name)
|
|
40
|
+
fingerprints[name] ||= Digest::MD5.hexdigest(lookup(name).first)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def path(name)
|
|
44
|
+
"#{Formblocks.config.mount_path.chomp('/')}/assets/#{name}?v=#{fingerprint(name)}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Our modules import Turbo and Stimulus by URL. The `{{turbo.js}}`
|
|
50
|
+
# tokens become fingerprinted paths here, so those imports get the same
|
|
51
|
+
# long-lived caching as everything else.
|
|
52
|
+
def link(source)
|
|
53
|
+
source.gsub(/\{\{(turbo\.js|stimulus\.js)\}\}/) do
|
|
54
|
+
"#{Regexp.last_match(1)}?v=#{fingerprint(Regexp.last_match(1))}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read(path)
|
|
59
|
+
Rails.env.development? ? File.read(path) : (cache[path] ||= File.read(path))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cache
|
|
63
|
+
@cache ||= {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fingerprints
|
|
67
|
+
Rails.env.development? ? {} : (@fingerprints ||= {})
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def turbo_path
|
|
71
|
+
require 'turbo-rails'
|
|
72
|
+
Turbo::Engine.root.join('app/assets/javascripts/turbo.min.js').to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stimulus_path
|
|
76
|
+
require 'stimulus-rails'
|
|
77
|
+
Stimulus::Engine.root.join('app/assets/javascripts/stimulus.min.js').to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|