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.
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "unmagic/components/upsert", to: "unmagic/components/upsert.js", preload: true
@@ -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