unmagic-components 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/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +337 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +45 -0
- data/app/assets/stylesheets/unmagic/components.css +382 -0
- data/config/importmap.rb +3 -0
- data/lib/unmagic/components/action_view_helpers.rb +168 -0
- data/lib/unmagic/components/configuration.rb +36 -0
- data/lib/unmagic/components/detail_list/item.rb +31 -0
- data/lib/unmagic/components/detail_list.rb +65 -0
- data/lib/unmagic/components/engine.rb +39 -0
- data/lib/unmagic/components/form_builder.rb +205 -0
- data/lib/unmagic/components/renderers/empty_state.rb +22 -0
- data/lib/unmagic/components/renderers/pagination.rb +62 -0
- data/lib/unmagic/components/table/column.rb +46 -0
- data/lib/unmagic/components/table.rb +254 -0
- data/lib/unmagic/components/table_tag.rb +93 -0
- data/lib/unmagic/components/version.rb +7 -0
- data/lib/unmagic/components.rb +43 -0
- data/lib/unmagic-components.rb +3 -0
- metadata +137 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/* unmagic-components
|
|
2
|
+
*
|
|
3
|
+
* Plain CSS on purpose. Tailwind only generates classes it can see and it does
|
|
4
|
+
* not scan installed gems, so any utility written inside this gem's Ruby would
|
|
5
|
+
* silently render unstyled in a host app. Owning the CSS here also means the
|
|
6
|
+
* components work in an app that doesn't use Tailwind at all.
|
|
7
|
+
*
|
|
8
|
+
* Theming is one layer of custom properties. Every value falls back to a
|
|
9
|
+
* Tailwind palette default, so the components look right with no configuration;
|
|
10
|
+
* a host overrides by setting --unmagic-* wherever its own theme is defined:
|
|
11
|
+
*
|
|
12
|
+
* :root {
|
|
13
|
+
* --unmagic-surface: var(--surface);
|
|
14
|
+
* --unmagic-border: var(--border);
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Dark mode needs nothing here. Host tokens that already flip carry these with
|
|
18
|
+
* them, which is why this file declares no dark variant of its own and makes no
|
|
19
|
+
* assumption about how the host selects a theme.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/* ------------------------------------------------------------------ Table */
|
|
23
|
+
|
|
24
|
+
/* Content-sized by default, so a Markdown table wearing this class hugs its
|
|
25
|
+
content rather than stretching. table_tag adds the --full modifier, which is
|
|
26
|
+
what an index table wants. */
|
|
27
|
+
.UnmagicTable {
|
|
28
|
+
font-size: 0.875rem;
|
|
29
|
+
line-height: 1.25rem;
|
|
30
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
31
|
+
background-color: var(--unmagic-surface, var(--color-white, #fff));
|
|
32
|
+
border: 1px solid var(--unmagic-border, var(--color-neutral-200, #e5e5e5));
|
|
33
|
+
border-radius: 0.75rem;
|
|
34
|
+
border-collapse: separate;
|
|
35
|
+
border-spacing: 0;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.UnmagicTable--full {
|
|
40
|
+
width: 100%;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.UnmagicTable--fixed {
|
|
44
|
+
table-layout: fixed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.UnmagicTable__caption {
|
|
48
|
+
position: absolute;
|
|
49
|
+
width: 1px;
|
|
50
|
+
height: 1px;
|
|
51
|
+
padding: 0;
|
|
52
|
+
margin: -1px;
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
clip-path: inset(50%);
|
|
55
|
+
white-space: nowrap;
|
|
56
|
+
border-width: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.UnmagicTable thead th {
|
|
60
|
+
padding: 0.75rem 1rem;
|
|
61
|
+
font-size: 0.75rem;
|
|
62
|
+
line-height: 1rem;
|
|
63
|
+
font-weight: 600;
|
|
64
|
+
text-align: left;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
color: var(--unmagic-text-2, var(--color-neutral-600, #525252));
|
|
67
|
+
background-color: var(--unmagic-surface-2, var(--color-neutral-50, #fafafa));
|
|
68
|
+
border-bottom: 1px solid var(--unmagic-border, var(--color-neutral-200, #e5e5e5));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.UnmagicTable tbody td {
|
|
72
|
+
padding: 0.75rem 1rem;
|
|
73
|
+
vertical-align: middle;
|
|
74
|
+
border-bottom: 1px solid var(--unmagic-border, var(--color-neutral-200, #e5e5e5));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The border rides on the cells rather than the row: with border-collapse
|
|
78
|
+
separate, a border set on a <tr> is not painted. */
|
|
79
|
+
.UnmagicTable tbody tr:last-child > td {
|
|
80
|
+
border-bottom: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.UnmagicTable tbody tr:hover > td {
|
|
84
|
+
background-color: var(--unmagic-hover, var(--color-neutral-50, #fafafa));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* A row carrying a companion details row below it drops its bottom border, so
|
|
88
|
+
the pair reads as one row. */
|
|
89
|
+
.UnmagicTable tbody tr.has-details > td {
|
|
90
|
+
border-bottom: 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.UnmagicTable tbody tr.UnmagicTable__details > td {
|
|
94
|
+
padding-top: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.UnmagicTable .is-right {
|
|
98
|
+
text-align: right;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.UnmagicTable .is-center {
|
|
102
|
+
text-align: center;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.UnmagicTable .is-numeric {
|
|
106
|
+
font-variant-numeric: tabular-nums;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.UnmagicTable__sort {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 0.25rem;
|
|
113
|
+
color: inherit;
|
|
114
|
+
text-decoration: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.UnmagicTable__sort:hover {
|
|
118
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* --------------------------------------------------------------- Skeleton */
|
|
122
|
+
|
|
123
|
+
.UnmagicSkeleton {
|
|
124
|
+
position: relative;
|
|
125
|
+
height: 1rem;
|
|
126
|
+
border-radius: 0.25rem;
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
background-color: var(--unmagic-skeleton, var(--color-neutral-200, #e5e5e5));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.UnmagicSkeleton.is-right {
|
|
132
|
+
margin-left: auto;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* The shimmer is a translated ::after so it stays on the compositor; the radius
|
|
136
|
+
and sizing stay on the element itself, which clips the band to the shape. */
|
|
137
|
+
.UnmagicSkeleton::after {
|
|
138
|
+
content: "";
|
|
139
|
+
position: absolute;
|
|
140
|
+
inset: 0;
|
|
141
|
+
background-image: linear-gradient(
|
|
142
|
+
90deg,
|
|
143
|
+
transparent,
|
|
144
|
+
var(--unmagic-skeleton-shimmer, rgb(255 255 255 / 0.7)),
|
|
145
|
+
transparent
|
|
146
|
+
);
|
|
147
|
+
transform: translateX(-100%);
|
|
148
|
+
animation: unmagic-skeleton-shimmer 1.5s ease-in-out infinite;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@keyframes unmagic-skeleton-shimmer {
|
|
152
|
+
100% {
|
|
153
|
+
transform: translateX(100%);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@media (prefers-reduced-motion: reduce) {
|
|
158
|
+
.UnmagicSkeleton::after {
|
|
159
|
+
animation: none;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------ Empty state */
|
|
164
|
+
|
|
165
|
+
.UnmagicEmptyState {
|
|
166
|
+
padding: 2rem;
|
|
167
|
+
text-align: center;
|
|
168
|
+
font-size: 0.875rem;
|
|
169
|
+
line-height: 1.25rem;
|
|
170
|
+
color: var(--unmagic-text-3, var(--color-neutral-500, #737373));
|
|
171
|
+
border: 1px dashed var(--unmagic-border-strong, var(--color-neutral-300, #d4d4d4));
|
|
172
|
+
border-radius: 0.5rem;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ------------------------------------------------------------- Pagination */
|
|
176
|
+
|
|
177
|
+
.UnmagicPagination {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
justify-content: center;
|
|
181
|
+
gap: 1rem;
|
|
182
|
+
margin-top: 1.25rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.UnmagicPagination__link {
|
|
186
|
+
padding: 0.25rem 0.75rem;
|
|
187
|
+
font-size: 0.875rem;
|
|
188
|
+
line-height: 1.25rem;
|
|
189
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
190
|
+
text-decoration: none;
|
|
191
|
+
background-color: var(--unmagic-surface, var(--color-white, #fff));
|
|
192
|
+
border: 1px solid var(--unmagic-border-strong, var(--color-neutral-300, #d4d4d4));
|
|
193
|
+
border-radius: 0.375rem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.UnmagicPagination__link:hover {
|
|
197
|
+
background-color: var(--unmagic-hover, var(--color-neutral-50, #fafafa));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.UnmagicPagination__link[aria-disabled="true"] {
|
|
201
|
+
opacity: 0.5;
|
|
202
|
+
pointer-events: none;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* -------------------------------------------------------- Description list */
|
|
206
|
+
|
|
207
|
+
.UnmagicDescriptionList {
|
|
208
|
+
display: grid;
|
|
209
|
+
grid-template-columns: max-content 1fr;
|
|
210
|
+
gap: 0.5rem 1.5rem;
|
|
211
|
+
font-size: 0.875rem;
|
|
212
|
+
line-height: 1.25rem;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.UnmagicDescriptionList dt {
|
|
216
|
+
grid-column: 1;
|
|
217
|
+
color: var(--unmagic-text-2, var(--color-neutral-600, #525252));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.UnmagicDescriptionList dd {
|
|
221
|
+
grid-column: 2;
|
|
222
|
+
margin: 0;
|
|
223
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* The stacked variant is a different layout, not a restyle of the inline one:
|
|
227
|
+
small caps labels above their values, in two columns once there is room. */
|
|
228
|
+
.UnmagicDescriptionList--stacked {
|
|
229
|
+
display: grid;
|
|
230
|
+
grid-template-columns: 1fr;
|
|
231
|
+
gap: 1.25rem 2rem;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@media (min-width: 40rem) {
|
|
235
|
+
.UnmagicDescriptionList--stacked {
|
|
236
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.UnmagicDescriptionList--stacked > .is-full {
|
|
240
|
+
grid-column: span 2 / span 2;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.UnmagicDescriptionList--stacked dt {
|
|
245
|
+
grid-column: auto;
|
|
246
|
+
font-size: 0.75rem;
|
|
247
|
+
line-height: 1rem;
|
|
248
|
+
font-weight: 500;
|
|
249
|
+
text-transform: uppercase;
|
|
250
|
+
letter-spacing: 0.025em;
|
|
251
|
+
color: var(--unmagic-text-3, var(--color-neutral-400, #a3a3a3));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.UnmagicDescriptionList--stacked dd {
|
|
255
|
+
grid-column: auto;
|
|
256
|
+
margin-top: 0.25rem;
|
|
257
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ------------------------------------------------------------------- Forms */
|
|
261
|
+
|
|
262
|
+
/* Structure only. What a control itself looks like is left to the host: apps
|
|
263
|
+
style inputs in incompatible ways — a class on every input, or a bare-element
|
|
264
|
+
rule — and picking one here would be wrong in the other. */
|
|
265
|
+
|
|
266
|
+
.UnmagicField {
|
|
267
|
+
display: flex;
|
|
268
|
+
flex-direction: column;
|
|
269
|
+
gap: 0.375rem;
|
|
270
|
+
margin-bottom: 1rem;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.UnmagicField--in-row {
|
|
274
|
+
flex: 1 1 0%;
|
|
275
|
+
margin-bottom: 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.UnmagicField--inline {
|
|
279
|
+
margin-bottom: 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.UnmagicFieldGroup {
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-direction: column;
|
|
285
|
+
gap: 1rem;
|
|
286
|
+
margin-bottom: 1rem;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@media (min-width: 40rem) {
|
|
290
|
+
.UnmagicFieldGroup {
|
|
291
|
+
flex-direction: row;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.UnmagicFieldGroup--inline {
|
|
296
|
+
display: flex;
|
|
297
|
+
flex-wrap: wrap;
|
|
298
|
+
align-items: center;
|
|
299
|
+
gap: 0.5rem;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.UnmagicLabel {
|
|
303
|
+
font-size: 0.875rem;
|
|
304
|
+
font-weight: 500;
|
|
305
|
+
color: var(--unmagic-text-2, var(--color-neutral-600, #525252));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.UnmagicLabel__required {
|
|
309
|
+
color: var(--unmagic-bad, var(--color-red-500, #ef4444));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.UnmagicHint {
|
|
313
|
+
margin: 0;
|
|
314
|
+
font-size: 0.875rem;
|
|
315
|
+
color: var(--unmagic-text-3, var(--color-neutral-500, #737373));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.UnmagicError {
|
|
319
|
+
margin: 0;
|
|
320
|
+
font-size: 0.875rem;
|
|
321
|
+
color: var(--unmagic-bad, var(--color-red-600, #dc2626));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.UnmagicFormErrors {
|
|
325
|
+
margin-bottom: 1rem;
|
|
326
|
+
padding: 0.5rem 0.75rem;
|
|
327
|
+
font-size: 0.875rem;
|
|
328
|
+
border-radius: 0.375rem;
|
|
329
|
+
color: var(--unmagic-bad, var(--color-red-700, #b91c1c));
|
|
330
|
+
border: 1px solid var(--unmagic-bad-border, var(--color-red-200, #fecaca));
|
|
331
|
+
background-color: var(--unmagic-bad-surface, var(--color-red-50, #fef2f2));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.UnmagicCheckField {
|
|
335
|
+
display: flex;
|
|
336
|
+
align-items: flex-start;
|
|
337
|
+
gap: 0.75rem;
|
|
338
|
+
cursor: pointer;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.UnmagicCheckField__text {
|
|
342
|
+
display: flex;
|
|
343
|
+
flex-direction: column;
|
|
344
|
+
gap: 0.125rem;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.UnmagicCheckField__label {
|
|
348
|
+
font-size: 0.875rem;
|
|
349
|
+
font-weight: 500;
|
|
350
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.UnmagicCheckList {
|
|
354
|
+
display: flex;
|
|
355
|
+
flex-direction: column;
|
|
356
|
+
gap: 0.5rem;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.UnmagicCheckList--inline {
|
|
360
|
+
display: flex;
|
|
361
|
+
flex-wrap: wrap;
|
|
362
|
+
gap: 0.5rem 1.5rem;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* A plain, unobtrusive button, so an unconfigured form still has one that looks
|
|
366
|
+
deliberate. Most apps point config.submit_class at their own button helper. */
|
|
367
|
+
.UnmagicButton {
|
|
368
|
+
padding: 0.375rem 0.875rem;
|
|
369
|
+
font-size: 0.875rem;
|
|
370
|
+
font-weight: 500;
|
|
371
|
+
border-radius: 0.375rem;
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
border: 1px solid var(--unmagic-border-strong, var(--color-neutral-300, #d4d4d4));
|
|
374
|
+
background-color: var(--unmagic-surface, var(--color-white, #fff));
|
|
375
|
+
color: var(--unmagic-text, var(--color-neutral-900, #171717));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.UnmagicButton--primary {
|
|
379
|
+
border-color: transparent;
|
|
380
|
+
background-color: var(--unmagic-accent, var(--color-neutral-900, #171717));
|
|
381
|
+
color: var(--unmagic-on-accent, var(--color-white, #fff));
|
|
382
|
+
}
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
# Mixed into ActionView by the engine, so every template can call these.
|
|
6
|
+
module ActionViewHelpers
|
|
7
|
+
# Declarative tables in the spirit of form_for: table_for yields a builder
|
|
8
|
+
# that collects column definitions, then renders the table chrome — the
|
|
9
|
+
# card, header (with optional sort links), empty state, and pagination — so
|
|
10
|
+
# index views only describe their cells.
|
|
11
|
+
#
|
|
12
|
+
# <%= table_for @users do |table| %>
|
|
13
|
+
# <% table.empty "No team members yet." %>
|
|
14
|
+
# <% table.column "Name" do |user| %>
|
|
15
|
+
# <%= user.name %>
|
|
16
|
+
# <% end %>
|
|
17
|
+
# <% table.column "Created", sort: :created_at, direction: :desc do |user| %>
|
|
18
|
+
# <%= user.created_at.to_fs(:short) %>
|
|
19
|
+
# <% end %>
|
|
20
|
+
# <% table.column "Actions", align: :right do |user| %>
|
|
21
|
+
# <%= button_to "Remove", user_path(user), method: :delete %>
|
|
22
|
+
# <% end %>
|
|
23
|
+
# <% end %>
|
|
24
|
+
#
|
|
25
|
+
# Linking to a record is the view's job — render the cell's primary text as
|
|
26
|
+
# a link.
|
|
27
|
+
#
|
|
28
|
+
# sort: a column with sort: renders its header as a link that toggles
|
|
29
|
+
# ?sort/?direction; direction: declares the column's first direction.
|
|
30
|
+
# Pass sorted_by:/sort_direction: when the applied default isn't in
|
|
31
|
+
# params, and sort_url: ->(key, direction) { url } when sorting is
|
|
32
|
+
# carried by other params (e.g. nested search params).
|
|
33
|
+
# defer: true wraps the table in a turbo frame pointing back at the current
|
|
34
|
+
# URL: the initial request renders only a skeleton — the collection
|
|
35
|
+
# is never touched — and the frame then fetches the real rows.
|
|
36
|
+
# Sorting and paging navigate within the frame, advancing the URL.
|
|
37
|
+
# Pairs with a lazy collection so the skeleton render costs nothing;
|
|
38
|
+
# the action needs no special casing.
|
|
39
|
+
# width: pins a column through a <colgroup>, switching the table to a fixed
|
|
40
|
+
# layout. A CSS length ("40%", "170px") rides on the <col> as a
|
|
41
|
+
# style; anything else is used as a class name, so a Tailwind host
|
|
42
|
+
# can pass "w-[40%]". Give every column of a deferred table a width
|
|
43
|
+
# so the skeleton and the rows that replace it lay out identically.
|
|
44
|
+
#
|
|
45
|
+
# Empty states come in two flavours. table.empty is the blank slate for a
|
|
46
|
+
# genuinely empty dataset; table.no_results is shown instead when a
|
|
47
|
+
# search/filter matched nothing. The table tells them apart on its own: a
|
|
48
|
+
# collection reporting filtered? picks no_results automatically. A plain
|
|
49
|
+
# relation or array isn't filterable, so it always uses empty.
|
|
50
|
+
#
|
|
51
|
+
# Pagination renders through the configured pagination seam, from whatever
|
|
52
|
+
# the configured pagy_for seam resolves (paginate: false to suppress, or
|
|
53
|
+
# pass a pagy object directly).
|
|
54
|
+
#
|
|
55
|
+
# Any other option rides on the <table> itself — class:, data:, aria — so a
|
|
56
|
+
# view can space or annotate it without a wrapper. `id:` is the exception:
|
|
57
|
+
# it names the deferred turbo frame, not the table.
|
|
58
|
+
def table_for(collection, defer: false, id: nil, paginate: true, columns: nil, **options, &block)
|
|
59
|
+
builder = Components::Table.new(self, collection, **options)
|
|
60
|
+
render(columns, table: builder) if columns
|
|
61
|
+
capture(builder, &block) if block
|
|
62
|
+
|
|
63
|
+
pagy = table_pagy_for(collection, paginate)
|
|
64
|
+
|
|
65
|
+
if defer
|
|
66
|
+
frame_id = id || "#{controller.controller_name}_table"
|
|
67
|
+
|
|
68
|
+
if turbo_frame_request_id == frame_id
|
|
69
|
+
turbo_frame_tag frame_id, target: "_top" do
|
|
70
|
+
builder.render(pagy: pagy, turbo_frame: frame_id)
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
turbo_frame_tag frame_id, src: request.original_url, target: "_top" do
|
|
74
|
+
builder.skeleton
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
builder.render(pagy: pagy)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The single <tr> a table would render for one record — what a Turbo Stream
|
|
83
|
+
# broadcast upserts into a live table's tbody.
|
|
84
|
+
#
|
|
85
|
+
# The columns have to be declared somewhere both the table and the broadcast
|
|
86
|
+
# can reach, so they move into a partial that takes a `table` local and does
|
|
87
|
+
# nothing but declare them:
|
|
88
|
+
#
|
|
89
|
+
# <%# tasks/_columns.html.erb %>
|
|
90
|
+
# <% table.column "Kind", width: "30%" do |task| %>
|
|
91
|
+
# <%= link_to task.kind, task %>
|
|
92
|
+
# <% end %>
|
|
93
|
+
#
|
|
94
|
+
# The page renders the table with them:
|
|
95
|
+
#
|
|
96
|
+
# <%= table_for @tasks, columns: "tasks/columns", rows_id: "task_rows" %>
|
|
97
|
+
#
|
|
98
|
+
# and a broadcast renders one row with the same file, so the two can never
|
|
99
|
+
# drift:
|
|
100
|
+
#
|
|
101
|
+
# <%# tasks/_row.html.erb %>
|
|
102
|
+
# <%= row_for task, columns: "tasks/columns" %>
|
|
103
|
+
#
|
|
104
|
+
# broadcast_action_to "tasks", action: :upsert, target: "task_rows",
|
|
105
|
+
# partial: "tasks/row", locals: { task: self }
|
|
106
|
+
#
|
|
107
|
+
# Pass the same row_class: the table uses so a broadcast row matches. The
|
|
108
|
+
# companion details row is not included — a stream action carries one element.
|
|
109
|
+
def row_for(record, columns:, **options)
|
|
110
|
+
builder = Components::Table.new(self, [ record ], **options)
|
|
111
|
+
render(columns, table: builder)
|
|
112
|
+
builder.row(record)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Renders the same table chrome from plain data, so a static table gets the
|
|
116
|
+
# same look without table_for's record/sort/pagination machinery.
|
|
117
|
+
#
|
|
118
|
+
# <%= table_tag [ "Name", "Score" ], [ [ "Ann", 42 ], [ "Bob", 7 ] ], aligns: [ nil, :right ] %>
|
|
119
|
+
#
|
|
120
|
+
# headers and each row are arrays of cells. A cell is a value (rendered
|
|
121
|
+
# as-is — pass a safe string for markup) or a { content:, **attrs } hash to
|
|
122
|
+
# set attributes on its th/td. A row may itself be a { cells:, **attrs }
|
|
123
|
+
# hash to set attributes on its <tr>. aligns and widths are per-column, and
|
|
124
|
+
# rows_id puts an id on the <tbody>. Pass headers: nil (or []) to omit the
|
|
125
|
+
# thead.
|
|
126
|
+
def table_tag(headers, rows, **options)
|
|
127
|
+
Components::TableTag.new(self, headers, rows, **options).render
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Declarative <dl> detail lists in the spirit of table_for.
|
|
131
|
+
#
|
|
132
|
+
# <%= detail_list do |list| %>
|
|
133
|
+
# <% list.item "Created", candidate.created_at.to_fs(:short) %>
|
|
134
|
+
# <% list.item "Salary", candidate.salary_expectation %>
|
|
135
|
+
# <% end %>
|
|
136
|
+
#
|
|
137
|
+
# <%= detail_list variant: :stacked do |list| %>
|
|
138
|
+
# <% list.item "Client ID", @application.uid, class: "font-mono" %>
|
|
139
|
+
# <% list.item "Redirect URIs", span: :full do %>
|
|
140
|
+
# ...
|
|
141
|
+
# <% end %>
|
|
142
|
+
# <% end %>
|
|
143
|
+
#
|
|
144
|
+
# variant: :inline (default) lays labels beside values in a two-column grid;
|
|
145
|
+
# :stacked puts small uppercase labels above values, two columns on wide
|
|
146
|
+
# screens. class: adds to the <dl>.
|
|
147
|
+
#
|
|
148
|
+
# A blank value — or a block that captures nothing — renders as an em dash.
|
|
149
|
+
# Pass a block for markup-heavy values. An item's class: adds to its <dd>;
|
|
150
|
+
# span: :full stretches a stacked item across both columns.
|
|
151
|
+
def detail_list(variant: :inline, **options, &block)
|
|
152
|
+
builder = Components::DetailList.new(self, variant: variant, **options)
|
|
153
|
+
capture(builder, &block)
|
|
154
|
+
builder.render
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def table_pagy_for(collection, paginate)
|
|
160
|
+
case paginate
|
|
161
|
+
when true then Components.configuration.pagy_for.call(self, collection)
|
|
162
|
+
when false, nil then nil
|
|
163
|
+
else paginate
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
# The seams the components render through. Each is a callable, and each has a
|
|
6
|
+
# working default, so the gem depends on no host helper and on no pagination
|
|
7
|
+
# library. An app that already owns these concerns points them at its own
|
|
8
|
+
# versions in an initializer.
|
|
9
|
+
class Configuration
|
|
10
|
+
attr_writer :empty_state, :pagination, :pagy_for, :submit_class
|
|
11
|
+
|
|
12
|
+
# Renders a table's blank slate. Called with (view, content), where content
|
|
13
|
+
# is already-captured markup or a plain string.
|
|
14
|
+
def empty_state
|
|
15
|
+
@empty_state ||= Renderers::EmptyState.default
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Renders a table's pager. Called with (view, pagy:, turbo_frame:).
|
|
19
|
+
def pagination
|
|
20
|
+
@pagination ||= Renderers::Pagination.default
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Resolves the pagy object a table should page with. Called with
|
|
24
|
+
# (view, collection); returning nil suppresses the pager.
|
|
25
|
+
def pagy_for
|
|
26
|
+
@pagy_for ||= Renderers::Pagination.default_pagy_for
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The classes on a form's submit button. Called with (view, variant), so an
|
|
30
|
+
# app can hand back whatever its own button helper produces.
|
|
31
|
+
def submit_class
|
|
32
|
+
@submit_class ||= ->(_view, variant) { "UnmagicButton UnmagicButton--#{variant}" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
class DetailList
|
|
6
|
+
class Item
|
|
7
|
+
attr_reader :label, :block
|
|
8
|
+
|
|
9
|
+
def initialize(label:, value:, block:, span: nil, **options)
|
|
10
|
+
@label = label
|
|
11
|
+
@value = value
|
|
12
|
+
@block = block
|
|
13
|
+
@span = span
|
|
14
|
+
@classes = options[:class]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def full_span? = @span == :full
|
|
18
|
+
|
|
19
|
+
# A blank value renders as an em dash, so call sites don't need their own
|
|
20
|
+
# `.presence || "—"`.
|
|
21
|
+
def value
|
|
22
|
+
@value.presence || "—"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dd_classes(base = nil)
|
|
26
|
+
[ base, @classes ].compact.presence&.join(" ")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Components
|
|
5
|
+
# Collects the items a `detail_list` block declares and renders them as a
|
|
6
|
+
# <dl>. See ActionViewHelpers#detail_list for the public API.
|
|
7
|
+
class DetailList
|
|
8
|
+
VARIANTS = %i[inline stacked].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(view, variant:, **options)
|
|
11
|
+
unless VARIANTS.include?(variant)
|
|
12
|
+
raise ArgumentError, "unknown detail_list variant #{variant.inspect} (expected one of #{VARIANTS.inspect})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@view = view
|
|
16
|
+
@variant = variant
|
|
17
|
+
@classes = options[:class]
|
|
18
|
+
@items = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def item(label, value = nil, **options, &block)
|
|
22
|
+
@items << Item.new(label: label, value: value, block: block, **options)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render
|
|
27
|
+
classes = view.class_names(
|
|
28
|
+
"UnmagicDescriptionList",
|
|
29
|
+
{ "UnmagicDescriptionList--stacked" => stacked? },
|
|
30
|
+
@classes,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
tag.dl class: classes do
|
|
34
|
+
safe_join @items.map { |item| stacked? ? stacked_item(item) : inline_item(item) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :view
|
|
41
|
+
|
|
42
|
+
delegate :tag, :safe_join, to: :view, private: true
|
|
43
|
+
|
|
44
|
+
def stacked? = @variant == :stacked
|
|
45
|
+
|
|
46
|
+
def inline_item(item)
|
|
47
|
+
safe_join [ tag.dt(item.label), tag.dd(value(item), class: item.dd_classes) ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stacked_item(item)
|
|
51
|
+
tag.div class: ("is-full" if item.full_span?) do
|
|
52
|
+
safe_join [ tag.dt(item.label), tag.dd(value(item), class: item.dd_classes) ]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def value(item)
|
|
57
|
+
if item.block
|
|
58
|
+
view.capture(&item.block).presence || "—"
|
|
59
|
+
else
|
|
60
|
+
item.value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|