keystone_ui 0.38.0 → 0.39.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 +4 -4
- data/app/components/keystone/ui/navigation_component.html.erb +8 -8
- data/app/components/keystone/ui/navigation_component.rb +10 -17
- data/lib/keystone_ui/engine.rb +6 -0
- data/lib/keystone_ui/navigation_check.rb +37 -0
- data/lib/keystone_ui/version.rb +1 -1
- data/lib/keystone_ui/visible_navigation.rb +38 -0
- data/the_local/agents/keystone_ui-develop.md +504 -628
- data/the_local/agents/keystone_ui-info.md +83 -227
- data/the_local/agents/keystone_ui-install.md +20 -16
- data/the_local/interface.yml +1 -0
- metadata +3 -1
|
@@ -1,488 +1,111 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: keystone_ui-develop
|
|
3
|
-
description: Use PROACTIVELY for building or editing screens in a Rails app that has
|
|
4
|
-
tools: Read, Write, Edit, Grep
|
|
3
|
+
description: Use PROACTIVELY for building or editing screens in a Rails app that has keystone_ui — page shells with Back links and breadcrumbs, forms and fields, data tables with saved columns, desktop navigation, sidebars and bottom tabs, action menus for a record's Edit and Delete, dashboards with stat cards, charts, funnels and goal buckets, amounts shown in green or red as a gain or loss, info buttons and breakdowns, marketing sections, a light/dark theme switch — MUST BE USED instead of hand-writing ERB and Tailwind for UI.
|
|
4
|
+
tools: Bash, Read, Write, Edit, Grep
|
|
5
5
|
scope: UI — pages, forms, tables, navigation, dashboards
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
This local builds screens by composing
|
|
8
|
+
This local builds screens by composing keystone_ui's `ui_*` view helpers in ERB.
|
|
9
9
|
It always works the same way: pick the page shell, fill it with the helpers that
|
|
10
10
|
match the content, and write no Tailwind classes of its own.
|
|
11
11
|
|
|
12
|
-
## What
|
|
12
|
+
## What keystone_ui is
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
treatments, which matters because these apps are often viewed in a native
|
|
21
|
-
webview.
|
|
14
|
+
keystone_ui is a Rails engine that supplies an app's UI as view helpers. Each
|
|
15
|
+
helper renders one named piece — a page shell, a section, a form field, a data
|
|
16
|
+
table, navigation, a stat card, a chart — with its styling and dark-mode
|
|
17
|
+
treatment owned by the gem, so two screens built from the same helpers cannot
|
|
18
|
+
drift apart. It is mobile-first, because these apps are often shown in a native
|
|
19
|
+
web view: several helpers render only below or only from the `lg:` breakpoint.
|
|
22
20
|
|
|
23
|
-
Fire on any request to build or change a screen, view, form, table, navigation
|
|
24
|
-
or dashboard in an app that has
|
|
25
|
-
not available in the app yet, that is
|
|
26
|
-
this one's.
|
|
21
|
+
Fire on any request to build or change a screen, view, form, table, navigation
|
|
22
|
+
or dashboard in an app that has keystone_ui installed. If the `ui_*` helpers are
|
|
23
|
+
not available in the app yet, that is `keystone_ui-install`'s job.
|
|
27
24
|
|
|
28
25
|
## Interface
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
`
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- `
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- `
|
|
57
|
-
|
|
58
|
-
- `
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- `
|
|
63
|
-
|
|
64
|
-
- `
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
- `
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- `
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
- `ui_show_page(title:, back_url: nil, subtitle: nil, trail: nil)` — the shell
|
|
92
|
-
marker for a detail screen. It renders nothing where it is called. It
|
|
93
|
-
publishes the title, subtitle, and back URL for the navbar, and hands
|
|
94
|
-
`ui_page` a "Back" link to `back_url` shown from `lg:` up. Call it before
|
|
95
|
-
`ui_page`, outside `ui_page`'s block. It shows no title, so put
|
|
96
|
-
`ui_page_header` inside the `ui_page` block for the desktop title. `trail:`
|
|
97
|
-
and `back_url:` work as they do on `ui_form_page`, including the breadcrumbs
|
|
98
|
-
under the "Back" link, the supplied trail, the fallback to the trail's last
|
|
99
|
-
link, `trail: []` for a navigation tab's own page, and the
|
|
100
|
-
`KeystoneUi::MissingBackLink` and `KeystoneUi::IncompleteTrail` errors, and
|
|
101
|
-
the breadcrumbs end with `title` unlinked.
|
|
102
|
-
- `ui_breadcrumbs(trail:, current: nil)` — a line of links shown only from `lg:`
|
|
103
|
-
up. `trail:` is `[[label, href], ...]`, from the top level down, and each pair
|
|
104
|
-
renders as a link, separated by `›`. `current:` is the page being shown,
|
|
105
|
-
rendered last, unlinked, and marked as the current page. On a form or detail
|
|
106
|
-
screen pass `trail:` to the shell instead of calling this helper.
|
|
107
|
-
|
|
108
|
-
### Navigation
|
|
109
|
-
|
|
110
|
-
- `ui_navbar(sticky: true)` — takes a block yielding the navbar. Fill named slots
|
|
111
|
-
on it: `logo`, `desktop_links`, `desktop_right`, `mobile_left`,
|
|
112
|
-
`mobile_center`, `mobile_right`. Desktop slots are hidden below `lg:` and the
|
|
113
|
-
mobile slots above it. `desktop_right` renders only when `desktop_links` is
|
|
114
|
-
also filled.
|
|
115
|
-
- `ui_navigation` — no keywords, takes a block holding the page content. Placed
|
|
116
|
-
in the layout around `yield`, it draws the tabs the app declares with
|
|
117
|
-
`config.navigation_group` as a top bar of menus from `lg:` up, then the
|
|
118
|
-
content. It leaves out tabs whose permission check fails and groups with no
|
|
119
|
-
tab left, and below `lg:` it draws only the content. Inside the block, a
|
|
120
|
-
`navigation.with_logo do ... end` block puts the app's logo at the left end
|
|
121
|
-
of the top bar, and a `navigation.with_menus do ... end` block puts menus
|
|
122
|
-
such as the account menu and the user menu at its right end, so the layout
|
|
123
|
-
draws no second bar for them. When the preference supplied for
|
|
124
|
-
`:navigation` holds `{ "placement" => "left" }` or `"right"`, it draws a
|
|
125
|
-
sidebar on that side of the content from `lg:` up instead, with each group's
|
|
126
|
-
label above its tabs, the logo at its top and the menus at its bottom, and
|
|
127
|
-
below `lg:` the content takes the full width. Any other placement draws the
|
|
128
|
-
top bar. When that value holds an `"order"` list of
|
|
129
|
-
`{ "group" => label, "tabs" => [tab key strings] }` entries, both placements
|
|
130
|
-
draw the named groups and tabs first in that order, then the rest in
|
|
131
|
-
declared order, ignoring names no longer declared. When the app sets `config.current_tab_supplier`, the tab whose key
|
|
132
|
-
it returns for the page and that tab's group are shown as active in either
|
|
133
|
-
placement, and nothing is marked when it returns `nil`, a key no declared
|
|
134
|
-
tab has, or is not set. Check whether the app's Keystone UI
|
|
135
|
-
initializer declares navigation groups before adding desktop tabs by hand;
|
|
136
|
-
declaring them is `keystone_ui-install`'s job.
|
|
137
|
-
- `ui_nav_item(label:, href:, active: false)` — one desktop navigation link.
|
|
138
|
-
- `ui_nav_dropdown(title:, area:, active: false)` — takes a block. A navbar
|
|
139
|
-
dropdown; the block holds the menu links.
|
|
140
|
-
- `ui_bottom_nav` — no keywords, takes a block. The mobile bottom tab bar; hidden
|
|
141
|
-
above `lg:` and inside a Hotwire Native webview.
|
|
142
|
-
- `ui_bottom_nav_item(label:, href:, icon:, active: false)` — one bottom tab.
|
|
143
|
-
`icon:` is a raw SVG string.
|
|
144
|
-
- `ui_mobile_header(title:, back_url:, subtitle: nil)` — a back chevron plus
|
|
145
|
-
centered title for mobile; hidden above `lg:`. Place it in the navbar's
|
|
146
|
-
`mobile_left` slot. `back_url:` must be passed, and `nil` renders the title
|
|
147
|
-
with no back chevron.
|
|
148
|
-
- `ui_action_menu` — no keywords, takes a block. An ellipsis (⋯) button that
|
|
149
|
-
opens a dropdown of actions, shown at every screen size. Fill the block with
|
|
150
|
-
`ui_action_menu_item` calls.
|
|
151
|
-
- `ui_action_menu_item(label:, href:, method: :get, confirm: nil)` — one entry
|
|
152
|
-
in an action menu. With `method: :get` it is a link to `href`. Any other
|
|
153
|
-
method, such as `:post`, `:patch` or `:delete`, renders a button inside its
|
|
154
|
-
own small form that sends that method to `href`, so never place such an item
|
|
155
|
-
inside a `ui_form` block, since a form cannot contain another form.
|
|
156
|
-
`confirm:` is a question Turbo asks before the item sends. With no
|
|
157
|
-
`confirm:`, a `method: :delete` item asks "<label> this? This cannot be
|
|
158
|
-
undone.", and every other method sends without asking. A `method: :get` item
|
|
159
|
-
is a plain link and ignores `confirm:`.
|
|
160
|
-
- `ui_mobile_actions` — no keywords, takes a block. An ellipsis dropdown for
|
|
161
|
-
mobile actions; the block holds the menu items. Hidden above `lg:`. Use
|
|
162
|
-
`ui_action_menu` instead when the actions must also be reachable on desktop.
|
|
163
|
-
- `ui_settings_link(label:, href:)` — a full-width settings row with a chevron.
|
|
164
|
-
- `ui_theme_toggle` — no keywords, no block. A row of three buttons, Light, Dark
|
|
165
|
-
and System, that switches the page's theme at once and remembers the choice in
|
|
166
|
-
the `keystone_theme` cookie for a year. System follows the operating system.
|
|
167
|
-
The button for the page's current mode renders pressed. When the current mode
|
|
168
|
-
is a custom palette supplied by another gem, no button is pressed, because the
|
|
169
|
-
toggle does not offer custom.
|
|
170
|
-
|
|
171
|
-
### Forms
|
|
172
|
-
|
|
173
|
-
- `ui_form(action:, method: :post, multipart: false, data: nil)` — takes a block.
|
|
174
|
-
The `<form>` wrapper. `method:` may be `:patch`/`:put`/`:delete` and is
|
|
175
|
-
translated for Rails. Set `multipart: true` when the form contains a file
|
|
176
|
-
upload.
|
|
177
|
-
- `ui_form_field(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [], include_blank: nil, disabled: false, suggestions: [])`
|
|
178
|
-
— a labeled field with hint and error text. This is the default way to render
|
|
179
|
-
an input. `type:` `:text` `:number` `:email` `:password` `:date` `:textarea`
|
|
180
|
-
`:checkbox` `:select`. `attribute:` is used verbatim as the input's `name`, so
|
|
181
|
-
pass the full param name the controller expects, e.g. `"quote[title]"`, and
|
|
182
|
-
pass `label:` whenever the attribute is a nested name. `label:` defaults to the
|
|
183
|
-
attribute with underscores turned to spaces and the first letter capitalized.
|
|
184
|
-
`options:` is for `:select` and takes `[[label, value], ...]`; `value:` picks
|
|
185
|
-
the selected option, and a non-required select gets a leading empty option,
|
|
186
|
-
worded by `include_blank:` (for example `"Not set yet"`) or left blank without
|
|
187
|
-
it. Never add your own empty choice to `options:` as well. A
|
|
188
|
-
`:checkbox` renders its label beside the box, submits `"0"` when unchecked and
|
|
189
|
-
`"1"` when checked, and pre-checks when `value:` is `"1"`. `errors:` is an
|
|
190
|
-
array of message strings. `suggestions:` is an array of strings the browser
|
|
191
|
-
offers while the field's text is typed, and the user can still type a value
|
|
192
|
-
that is not in it. It applies to `:text`, `:number`, `:email`, `:password`
|
|
193
|
-
and `:date` fields, and a `:textarea`, `:checkbox` or `:select` ignores it.
|
|
194
|
-
The suggestion list's `id` is built from `attribute:`, so two fields with
|
|
195
|
-
suggestions on one screen need different `attribute:` values.
|
|
196
|
-
- `ui_input(name:, type: :text, value: nil, placeholder: nil, disabled: false, min: nil, max: nil, step: nil)`
|
|
197
|
-
— a bare styled input with no label. `type:` `:text` `:number` `:email`
|
|
198
|
-
`:password` `:date`.
|
|
199
|
-
- `ui_textarea(name:, value: nil, rows: 3, placeholder: nil, disabled: false)` —
|
|
200
|
-
a bare styled textarea.
|
|
201
|
-
- `ui_select(name:, options: [], selected: nil, include_blank: nil, disabled: false)`
|
|
202
|
-
— a bare styled select. `options:` is `[[label, value], ...]`;
|
|
203
|
-
`include_blank:` is the text of a leading empty option.
|
|
204
|
-
- `ui_multi_select(name:, label:, options:, selected: [])` — a dropdown of
|
|
205
|
-
checkboxes all posting under `name`, used verbatim; pass an array name such as
|
|
206
|
-
`"status[]"` so every checked value arrives. `options:` is
|
|
207
|
-
`[[label, value], ...]`; `selected:` is the values to pre-check. The trigger
|
|
208
|
-
reads "All <label>" when nothing is checked and "N selected" otherwise.
|
|
209
|
-
- `ui_file_upload(name:, label: nil, accept: nil, multiple: false, hint: nil)` —
|
|
210
|
-
a drop zone with drag-and-drop and selected-file feedback. Requires the
|
|
211
|
-
enclosing form to be multipart.
|
|
212
|
-
- `ui_color_picker(name:, value: "#000000", label: nil)` — a swatch that opens a
|
|
213
|
-
hue/saturation panel and writes the hex into a hidden input named `name`.
|
|
214
|
-
- `ui_radio_card(name:, value:, label:, hint: nil, info: nil, checked: false)`
|
|
215
|
-
— a selectable card backed by a real radio input; selection styling is pure
|
|
216
|
-
CSS. `hint:` is a line of text always shown under the label. `info:` is
|
|
217
|
-
longer text about the option, kept hidden: passing it adds an info button on
|
|
218
|
-
the row with the label, named "About <label>" for screen readers, and the
|
|
219
|
-
text shows in a panel floating below the card while the button is hovered,
|
|
220
|
-
and tapping the button toggles it. With no `info:` the card has no info
|
|
221
|
-
button.
|
|
222
|
-
- `ui_checkbox_row(name:, value:, label:, hint: nil, info: nil, checked: false)`
|
|
223
|
-
— a real checkbox with its label and optional hint inside one `<label>`, so a
|
|
224
|
-
tap anywhere on the row toggles the box. A checked row submits `value` under
|
|
225
|
-
`name`; give several rows the same array name, e.g. `"shown[]"`, to submit the
|
|
226
|
-
checked values as a list. An unchecked row submits nothing. `hint:` is a line
|
|
227
|
-
of text always shown under the label. `info:` works as it does on
|
|
228
|
-
`ui_radio_card`: passing it adds an info button on the line with the label,
|
|
229
|
-
named "About <label>" for screen readers, and the text shows in a panel
|
|
230
|
-
floating below the row while the button is hovered, and tapping the button
|
|
231
|
-
toggles it. With no `info:` the row has no info button.
|
|
232
|
-
- `ui_option_card(name:, value:, selected: false, input_data: {}, label_data: {})`
|
|
233
|
-
— takes a block. A radio whose visible body is whatever the block renders.
|
|
234
|
-
`input_data:`/`label_data:` become `data-*` attributes on the input and label.
|
|
235
|
-
|
|
236
|
-
### Tables
|
|
237
|
-
|
|
238
|
-
- `ui_data_table(items:, columns:, empty_message: nil, sort: nil, sort_direction: nil, sort_url: nil, hidden_columns: [], key: nil)`
|
|
239
|
-
— takes a block yielding the table. `items:` are records or hashes; each cell
|
|
240
|
-
value is read by calling the column key on the item, falling back to `item[key]`.
|
|
241
|
-
`columns:` accepts plain `{ key: "Label" }` hashes or `Keystone::Ui::Column`
|
|
242
|
-
objects. In the block, `table.link(:column_key) { |item| url }` turns that
|
|
243
|
-
column's cells into links and `table.actions { |item| ... }` appends a
|
|
244
|
-
right-aligned actions column. With no actions column, the last data
|
|
245
|
-
column's header and cells are both right-aligned, so put the column of
|
|
246
|
-
amounts last to line them up under their header. Each row's actions render inside an action
|
|
247
|
-
menu, so the `actions` block holds `ui_action_menu_item` calls and nothing
|
|
248
|
-
else, never buttons or bare links. Sorting requires all three of `sort:` (the
|
|
249
|
-
current column key), `sort_direction:` (`:asc`/`:desc`), and `sort_url:` (a
|
|
250
|
-
lambda taking `(column_key, direction)` and returning a URL); headers then
|
|
251
|
-
render as links that flip direction. `hidden_columns:` drops columns
|
|
252
|
-
server-side and only affects columns declared `hideable: true`. `key:` (a
|
|
253
|
-
symbol or string) names the table so it looks up a saved layout through the
|
|
254
|
-
app's preference supplier. When the supplier returns a saved value for the
|
|
255
|
-
key that lists `"hidden_columns"`, the hideable columns in that list replace
|
|
256
|
-
the ones passed in `hidden_columns:`, and a saved empty list shows every
|
|
257
|
-
column. When the saved value is `nil` or lists no `"hidden_columns"`, the
|
|
258
|
-
table keeps the columns `hidden_columns:` hides. When the saved value lists
|
|
259
|
-
`"column_order"`, an array of column keys, the hideable columns render in
|
|
260
|
-
that order, followed by any hideable columns the list leaves out in the order
|
|
261
|
-
they were declared. Columns that are not hideable keep their declared place,
|
|
262
|
-
and the hideable ones fill the remaining places. With no `"column_order"` the
|
|
263
|
-
columns keep their declared order. Whenever the supplier
|
|
264
|
-
returns a save address, the table renders a "Columns" menu in a row above
|
|
265
|
-
itself, aligned right, that saves to it as `ui_column_picker` does and shows
|
|
266
|
-
the same message when a save fails, including for a person with nothing saved
|
|
267
|
-
yet. That menu lists the hideable columns in
|
|
268
|
-
the order the table shows them and leaves ticked exactly the ones the table
|
|
269
|
-
shows. When the
|
|
270
|
-
supplier returns nothing, when no supplier is set, or when no `key:` is
|
|
271
|
-
passed, the table renders from `hidden_columns:` with no Columns menu. In
|
|
272
|
-
every case `hidden_columns:` is the table's default layout.
|
|
273
|
-
- `Keystone::Ui::Column.new(key, header_text, mobile_hidden: false, sortable: false, hideable: false, locked: false)`
|
|
274
|
-
— a column with per-column options, for when a `{ key: "Label" }` hash is not
|
|
275
|
-
enough. `mobile_hidden:` hides the column below `sm:`; `sortable:` opts it into
|
|
276
|
-
sort headers; `hideable:` lets the Columns menu hide it and move it, and a
|
|
277
|
-
saved `"column_order"` place it. `locked: true` on the table's first column
|
|
278
|
-
keeps its header and cells in view while the rest of the table scrolls
|
|
279
|
-
sideways. A locked column that is not first renders as an ordinary column. A
|
|
280
|
-
locked column is never hideable, even with `hideable: true`: it is left out of
|
|
281
|
-
the Columns menu, `hidden_columns:` and a saved layout cannot hide it, and a
|
|
282
|
-
saved `"column_order"` cannot move it.
|
|
283
|
-
- `ui_column_picker(columns:, hidden_columns: [], save_url: nil)` — a "Columns"
|
|
284
|
-
dropdown with one row per `hideable` column, in the order `columns:` lists
|
|
285
|
-
them. Each row has a checkbox and an up and a down button that move the
|
|
286
|
-
column one place; the first row's up button and the last row's down button
|
|
287
|
-
are disabled, and after a move the disabled buttons follow the new first and
|
|
288
|
-
last rows. A hidden column's name renders greyed, and unticking a box greys
|
|
289
|
-
its name at once. Pass it the same columns, in the order the table shows
|
|
290
|
-
them, and the same hidden keys as the table. Ticking, unticking and moving
|
|
291
|
-
change only the open menu and send nothing. When the menu closes, by its
|
|
292
|
-
Columns button or by a click outside it, it sends one `PATCH save_url` with
|
|
293
|
-
JSON `{ "hidden_columns": ["key", ...], "column_order": ["key", ...] }` and a
|
|
294
|
-
`X-CSRF-Token` header, then reloads the page. A menu closed with nothing
|
|
295
|
-
changed sends nothing. `column_order` lists every hideable column's key in
|
|
296
|
-
the menu's order when it closes. With no `save_url:` it sends nothing. The app must provide that endpoint and persist
|
|
297
|
-
both lists. The endpoint must answer with a success status when it has saved
|
|
298
|
-
them. When it answers with an error status, or the request cannot reach the
|
|
299
|
-
server, the menu does not reload the page: it shows "Your column changes were
|
|
300
|
-
not saved." under the Columns button, and puts its boxes and its order back
|
|
301
|
-
to what the table shows. The picker does not reorder the table: beside a table without
|
|
302
|
-
`key:`, the app must pass the table and the picker its columns in the saved
|
|
303
|
-
order itself. A table
|
|
304
|
-
given `key:` renders its own Columns menu when the supplier gives a save
|
|
305
|
-
address, so never add `ui_column_picker` beside such a table.
|
|
306
|
-
|
|
307
|
-
### Content and status
|
|
308
|
-
|
|
309
|
-
- `ui_button(label:, href: nil, variant: :primary, size: :md, type: :submit, data: nil)`
|
|
310
|
-
— renders an `<a>` when `href:` is given and a `<button>` otherwise. `variant:`
|
|
311
|
-
`:primary` `:secondary` `:danger`; `size:` `:sm` `:md` `:lg`; `type:` applies
|
|
312
|
-
only to the button form.
|
|
313
|
-
- `ui_badge(label:, variant: :neutral, class: nil)` — a pill. `variant:`
|
|
314
|
-
`:neutral` `:success` `:danger` `:warning` `:info`.
|
|
315
|
-
- `ui_figure(text:, tone: :neutral)` — no block. One figure, such as an amount,
|
|
316
|
-
as inline text with no pill or box around it. `tone:` `:neutral` prints it in
|
|
317
|
-
the surrounding text colour, `:success` in green and `:danger` in red; any
|
|
318
|
-
other symbol raises `KeyError`. `text:` is printed as given, so format
|
|
319
|
-
numbers, currency and any minus sign before passing it. Its output can be
|
|
320
|
-
passed anywhere a string is shown, such as a `ui_data_table` cell value or
|
|
321
|
-
column label.
|
|
322
|
-
- `ui_alert(message:, type: :info, title: nil, dismissible: false, class: nil)` —
|
|
323
|
-
a banner.
|
|
324
|
-
`type:` `:info` `:success` `:warning` `:error`. `dismissible: true` adds a
|
|
325
|
-
close control.
|
|
326
|
-
- `ui_progress(value:, max:, label: nil)` — a labeled progress bar. The percent
|
|
327
|
-
is `value / max`, rounded and clamped at 100.
|
|
328
|
-
- `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)`
|
|
329
|
-
— a single metric tile. `variant:` `:neutral` `:success` `:danger` `:warning`
|
|
330
|
-
`:info` colors the value. `change:` is a signed number rendered as `▲ 4.2%` in
|
|
331
|
-
green when positive, `▼` in red when negative, plain when zero. `href:` makes
|
|
332
|
-
the value a link to its drill-down screen; only the value is linked, never the
|
|
333
|
-
whole card. Passing `definition:` and/or `calculation:` adds an info button
|
|
334
|
-
whose details show in a panel floating below the card while the button is
|
|
335
|
-
hovered or focused, and tapping the button toggles it.
|
|
336
|
-
- `ui_copy_button(text:, label: "Copy", success_message: "Copied!", error_message: "Failed!")`
|
|
337
|
-
— copies `text:` to the clipboard.
|
|
338
|
-
- `ui_code(language: nil, caption: nil)` — takes a block holding the code. The
|
|
339
|
-
caption is a header strip above the block; `language:` sets the `language-*`
|
|
340
|
-
class for a highlighter.
|
|
341
|
-
- `ui_disclosure(open: false)` — takes a block yielding the component. Fill its
|
|
342
|
-
`summary` slot with the clickable header; the rest of the block is the body.
|
|
343
|
-
Native `<details>` — no JavaScript.
|
|
344
|
-
- `ui_calculation(groups:, summary: "How this is worked out")` — no block. Shows
|
|
345
|
-
how a figure was reached, closed by default under a row reading `summary:`.
|
|
346
|
-
`groups:` is `[{ title:, lines: [{ label:, working:, result: }, ...] }, ...]`;
|
|
347
|
-
`title:` is optional, `lines:` is required and a group without it raises
|
|
348
|
-
`KeyError`, and a line with any key other than those three raises
|
|
349
|
-
`ArgumentError`. Each line renders as three columns — label, working, result
|
|
350
|
-
— with the result right-aligned. Every value is printed as given, so format
|
|
351
|
-
numbers and currency before passing them. Place it directly under the figure
|
|
352
|
-
it explains, such as a `ui_stat_card`.
|
|
353
|
-
- `ui_info(summary:)` — an info button, named "More about this" for screen
|
|
354
|
-
readers, placed inline beside the thing it explains, and sits level with the
|
|
355
|
-
middle of the text beside it. `summary:` is a short
|
|
356
|
-
line of text shown in a floating panel while the button is hovered. The block
|
|
357
|
-
is optional and holds the full detail. With a block, tapping or clicking the
|
|
358
|
-
button toggles a second floating panel holding the block's content, and the
|
|
359
|
-
summary stays hover-only. With no block, tapping the button toggles the
|
|
360
|
-
summary itself. A second tap closes what the first opened, a click anywhere
|
|
361
|
-
else on the page closes the detail panel, and scrolling the page closes both
|
|
362
|
-
panels. Each panel opens just under the button with its right edge on the
|
|
363
|
-
button's right edge, the first time it opens as well as every later time,
|
|
364
|
-
and stays at least 8 pixels from the left edge of the screen. The detail
|
|
365
|
-
panel is wider than the summary panel, and the text in both wraps, so pass
|
|
366
|
-
plain sentences and add no line breaks or widths of your own. The panels are
|
|
367
|
-
placed against the screen, so the button can sit
|
|
368
|
-
inside a table or any other container that clips its contents without the
|
|
369
|
-
panels being cut off. Both panels render inside a `<span>`, so the block may
|
|
370
|
-
hold text and inline elements only, such as a `ui_breakdown`, and never a
|
|
371
|
-
`<div>`, list or table. Hovering and tapping both need the gem's Stimulus
|
|
372
|
-
controllers registered, and without them the button shows nothing.
|
|
373
|
-
- `ui_breakdown(lines:, total:)` — no block. A list of amounts ending in their
|
|
374
|
-
total. `lines:` is `[{ amount:, label: }, ...]` and `total:` is one
|
|
375
|
-
`{ amount:, label: }`, both required. Each line renders its amount with its
|
|
376
|
-
label beside it, and the total renders last, set apart from the lines above
|
|
377
|
-
it. Every value is printed as given and nothing is added up, so compute the
|
|
378
|
-
total and format numbers and currency before passing them. How the amounts
|
|
379
|
-
and labels line up, and how the total is set apart, come from the
|
|
380
|
-
`ks-breakdown` classes in the keystone_ui-styles gem, and the helper sets no
|
|
381
|
-
widths or spacing of its own. It renders inline elements only, so it can sit
|
|
382
|
-
inside a `ui_info` block.
|
|
383
|
-
- `ui_accordion(items: [])` — a stack of independently expandable rows. `items:`
|
|
384
|
-
is `[{ question:, answer: }, ...]`.
|
|
385
|
-
- `ui_tab_switcher(tabs:)` — takes a block. `tabs:` is an array of label strings;
|
|
386
|
-
the first is active on load. The block renders below the tab bar. Selecting a
|
|
387
|
-
tab dispatches a `tab-switcher:change` event carrying the clicked index —
|
|
388
|
-
showing and hiding the matching panels is the app's job.
|
|
389
|
-
- `ui_modal(title:, size: :md)` — takes a block holding the body. `size:` `:sm`
|
|
390
|
-
`:md` `:lg` `:xl`. Renders hidden, and closes on its own close button and on a
|
|
391
|
-
backdrop click. Nothing in the gem opens it: the modal's outermost element
|
|
392
|
-
carries the `hidden` class, and the app's own code must remove that class to
|
|
393
|
-
show it. A `data-action="modal#open"` placed outside the modal does nothing.
|
|
394
|
-
- `ui_swipe_deck(items:, empty_title: "All done!", empty_subtitle: nil)` — takes
|
|
395
|
-
a block yielding the deck. Call `deck.item { |item| ... }` in the block to
|
|
396
|
-
render one card's face. Accepting a card (button or swipe right) dispatches a
|
|
397
|
-
bubbling `swipe-deck:complete` event and rejecting (button or swipe left)
|
|
398
|
-
dispatches `swipe-deck:skip`. Both carry `detail.itemId` — the item's `id`, or
|
|
399
|
-
its position in `items:` when it has none — and `detail.card`; `complete` also
|
|
400
|
-
carries `detail.value`, read from an input inside the card's face marked
|
|
401
|
-
`data-swipe-deck-value`, or `null`. The app must listen and persist the
|
|
402
|
-
outcome.
|
|
403
|
-
|
|
404
|
-
### Charts and analytics
|
|
405
|
-
|
|
406
|
-
- `ui_chart_card(title:, height: :md)` — takes a block holding a chart. `height:`
|
|
407
|
-
`:sm` `:md` `:lg`.
|
|
408
|
-
- `ui_line_chart(series:, labels: nil, dates: nil, height: :md)` — a line chart
|
|
409
|
-
with one line per series. `series:` is
|
|
410
|
-
`[{ name:, data:, color:, dashed: }, ...]` where `data:` is the array of
|
|
411
|
-
values, and `color:` (a CSS color string for the line) and `dashed: true` are
|
|
412
|
-
optional. `height:` `:sm` `:md` `:lg`. Pass exactly one of `labels:` and
|
|
413
|
-
`dates:` for the horizontal axis: passing both, or neither, raises
|
|
414
|
-
`ArgumentError`. `labels:` is an array of strings, one per value, spaced
|
|
415
|
-
evenly and shown as written. `dates:` is an array of `Date`, `Time` or
|
|
416
|
-
`DateTime` values, one per value, matched to each series' `data:` by
|
|
417
|
-
position. A dated chart places each point by its day, so a gap of a week is
|
|
418
|
-
seven times as wide as a gap of a day. Its axis runs from the first day to
|
|
419
|
-
the last, marks only whole days, and reads each day as a date such as
|
|
420
|
-
"Oct 2, 2026", which is also the heading a hovered point shows. The time of
|
|
421
|
-
day is dropped, so pass one value per day.
|
|
422
|
-
- `ui_funnel(steps:, shape: :bars)` — a conversion funnel. `steps:` is
|
|
423
|
-
`[{ label:, value:, color: }, ...]` in order, with `color:` optional. Each
|
|
424
|
-
step's width is its value as a share of the first step's value, and the
|
|
425
|
-
percent shown between two steps is the second step's value divided by the
|
|
426
|
-
first's. Each step takes the next colour in the order `:accent` `:sky`
|
|
427
|
-
`:violet` `:amber` `:rose`, starting again after `:rose`; a step passing one
|
|
428
|
-
of those symbols as `color:` uses it instead, and any other symbol raises.
|
|
429
|
-
Values are printed as given. Divide-by-zero safe, no JavaScript. `shape:`
|
|
430
|
-
`:bars` draws one bar per step, left-aligned, with the label and value on a
|
|
431
|
-
row above it and a `↓ N%` caption between two bars. `shape: :joined` draws
|
|
432
|
-
one shape: each step's value over its label in a column on the left, each
|
|
433
|
-
step's block centred at its width, and between two blocks a neutral band
|
|
434
|
-
that narrows from the block above to the block below, with the percent on it.
|
|
435
|
-
Any other `shape:` raises `ArgumentError`.
|
|
436
|
-
- `ui_bucket(goal:, actual:, label: nil, over: :success)` — an upright container
|
|
437
|
-
for one target, filled from the bottom toward `goal:`. It shows the optional
|
|
438
|
-
`label` on top, then `goal`, the container, `actual`, and the percent reached.
|
|
439
|
-
`goal` and `actual` must be numbers: a string such as `"9,000"`, or `nil`,
|
|
440
|
-
raises `ArgumentError` at render time. They are printed as Ruby prints them,
|
|
441
|
-
with no thousands separators, so `9000` shows as `9000` and `9000.0` as
|
|
442
|
-
`9000.0`. A value read from a decimal column is a `BigDecimal`, which prints
|
|
443
|
-
as `0.9e4`, so convert it with `to_i` or `to_f` first. The percent is
|
|
444
|
-
`actual / goal * 100` rounded and **not**
|
|
445
|
-
clamped (so 150% shows as 150%), and it is 0 when `goal` is zero. The fill
|
|
446
|
-
stops at the top once the goal is reached. Within the goal the fill uses the
|
|
447
|
-
accent colour; over it the fill turns green with `over: :success` or amber with
|
|
448
|
-
`over: :warning`, and any other symbol raises. No JavaScript.
|
|
449
|
-
- `ui_bucket_series(buckets:)` — a row of buckets that wraps onto more rows on
|
|
450
|
-
narrow screens. `buckets:` is an array of hashes, each taking the same keywords
|
|
451
|
-
as `ui_bucket`, e.g. `[{ goal: 10, actual: 7, label: "Mon" }, ...]`.
|
|
452
|
-
- `ui_pipeline(title:, boxes:, links:, subtitle: nil)` — a staged flow diagram
|
|
453
|
-
for event flows, approval chains, or state machines. `boxes:` is
|
|
454
|
-
`[{ label:, count:, accent:, action: }, ...]` where `count:` and `accent:`
|
|
455
|
-
(`:amber` `:emerald` `:danger` `:muted`) are optional, and `action:` is
|
|
456
|
-
`{ url:, label:, params:, variant: }` rendering a button that POSTs `params`
|
|
457
|
-
to `url`. `links:` has exactly one fewer entry than `boxes:`, each
|
|
458
|
-
`{ url:, params:, broken: }`, rendering a ✓/✗ toggle between two boxes that
|
|
459
|
-
POSTs to flip its state. The app owns every endpoint these post to.
|
|
460
|
-
|
|
461
|
-
### Marketing sections
|
|
462
|
-
|
|
463
|
-
- `ui_hero(title:, subtitle: nil, badge: nil, layout: :split)` — takes a block
|
|
464
|
-
holding the call-to-action buttons, and yields the component so its `aside`
|
|
465
|
-
slot can hold an image or panel. `layout:` `:split` (content beside the aside)
|
|
466
|
-
or `:centered`.
|
|
467
|
-
- `ui_cta_banner(title:, subtitle: nil)` — takes a block holding the
|
|
468
|
-
call-to-action buttons.
|
|
469
|
-
- `ui_feature_grid(title:, features:, subtitle: nil)` — a responsive grid of
|
|
470
|
-
feature cards. `features:` is `[{ icon:, title:, description: }, ...]` where
|
|
471
|
-
`icon:` is a raw SVG or HTML string.
|
|
27
|
+
- `ui_page` — the outer wrapper of a screen, holding the page's content.
|
|
28
|
+
- `ui_form_page` — marks a screen as a form screen and gives it its title, Back link and breadcrumbs.
|
|
29
|
+
- `ui_show_page` — marks a screen as a detail screen and gives it its Back link and breadcrumbs.
|
|
30
|
+
- `ui_page_header` — the desktop title of a page, with an optional control on its right.
|
|
31
|
+
- `ui_breadcrumbs` — a line of links to the pages above the current one, shown from `lg:` up.
|
|
32
|
+
- `ui_section` — a titled block of content with an optional link and action menu in its header.
|
|
33
|
+
- `ui_grid` — a responsive grid with a column count per breakpoint.
|
|
34
|
+
- `ui_panel` — a bordered card surface holding any content.
|
|
35
|
+
- `ui_card_link` — a panel that is one link as a whole.
|
|
36
|
+
- `ui_card` — a fixed card of title, summary and call-to-action link.
|
|
37
|
+
- `ui_navbar` — a navigation bar with named slots for desktop and mobile.
|
|
38
|
+
- `ui_navigation` — draws the app's declared navigation groups as a top bar or a sidebar around the page content.
|
|
39
|
+
- `ui_nav_item` — one desktop navigation link.
|
|
40
|
+
- `ui_nav_dropdown` — a navbar dropdown menu.
|
|
41
|
+
- `ui_bottom_nav` — the mobile bottom tab bar.
|
|
42
|
+
- `ui_bottom_nav_item` — one tab in the bottom tab bar.
|
|
43
|
+
- `ui_mobile_header` — a mobile back arrow and centred title.
|
|
44
|
+
- `ui_mobile_actions` — an ellipsis menu of actions shown only below `lg:`.
|
|
45
|
+
- `ui_action_menu` — an ellipsis menu of actions shown at every screen size.
|
|
46
|
+
- `ui_action_menu_item` — one action in an action menu, as a link or a button sending a method.
|
|
47
|
+
- `ui_settings_link` — a full-width settings row with a chevron.
|
|
48
|
+
- `ui_theme_toggle` — Light, Dark and System buttons that switch and remember the page's theme.
|
|
49
|
+
- `ui_form` — the form element wrapping a form's fields.
|
|
50
|
+
- `ui_form_field` — a labelled input, textarea, checkbox or select with hint and error text.
|
|
51
|
+
- `ui_input` — a bare styled input with no label.
|
|
52
|
+
- `ui_textarea` — a bare styled textarea with no label.
|
|
53
|
+
- `ui_select` — a bare styled select with no label.
|
|
54
|
+
- `ui_multi_select` — a dropdown of checkboxes posting every checked value.
|
|
55
|
+
- `ui_file_upload` — a file drop zone with selected-file feedback.
|
|
56
|
+
- `ui_color_picker` — a colour swatch that writes a hex value into a hidden input.
|
|
57
|
+
- `ui_radio_card` — a selectable card backed by a radio input.
|
|
58
|
+
- `ui_checkbox_row` — a checkbox with its label and hint, toggled by a tap anywhere on the row.
|
|
59
|
+
- `ui_option_card` — a radio whose visible body is whatever its block renders.
|
|
60
|
+
- `ui_data_table` — a table of records with links, row actions, sorting and hideable columns.
|
|
61
|
+
- `Keystone::Ui::Column` — a table column with options a `{ key: "Label" }` hash cannot carry.
|
|
62
|
+
- `ui_column_picker` — a Columns menu that saves which columns are hidden and their order to an app endpoint.
|
|
63
|
+
- `ui_button` — a link or a button styled as a button.
|
|
64
|
+
- `ui_badge` — a coloured pill holding a short label.
|
|
65
|
+
- `ui_figure` — one amount as inline text, coloured as a gain or a loss.
|
|
66
|
+
- `ui_alert` — a banner message, optionally dismissible.
|
|
67
|
+
- `ui_progress` — a labelled progress bar.
|
|
68
|
+
- `ui_stat_card` — a single metric tile with optional change, link and info button.
|
|
69
|
+
- `ui_copy_button` — a button that copies text to the clipboard.
|
|
70
|
+
- `ui_code` — a block of code with an optional caption.
|
|
71
|
+
- `ui_disclosure` — a native expandable section with a clickable summary.
|
|
72
|
+
- `ui_calculation` — the steps behind a figure, closed under a summary row.
|
|
73
|
+
- `ui_info` — an inline info button showing a summary, and optional detail, in floating panels.
|
|
74
|
+
- `ui_breakdown` — a list of amounts ending in their total.
|
|
75
|
+
- `ui_accordion` — a stack of question-and-answer rows that expand independently.
|
|
76
|
+
- `ui_tab_switcher` — a row of tabs that reports which one was selected.
|
|
77
|
+
- `ui_modal` — a hidden dialog the app's own code opens.
|
|
78
|
+
- `ui_swipe_deck` — a stack of cards accepted or rejected by button or swipe.
|
|
79
|
+
- `ui_chart_card` — a titled card holding a chart.
|
|
80
|
+
- `ui_line_chart` — a line chart with one line per series, over labels or dates.
|
|
81
|
+
- `ui_funnel` — a conversion funnel drawn as bars or one joined shape.
|
|
82
|
+
- `ui_bucket` — an upright container filled toward a goal.
|
|
83
|
+
- `ui_bucket_series` — a wrapping row of buckets.
|
|
84
|
+
- `ui_pipeline` — a staged flow diagram with buttons and breakable links that post to the app.
|
|
85
|
+
- `ui_hero` — a marketing hero with title, call-to-action buttons and an aside.
|
|
86
|
+
- `ui_cta_banner` — a marketing banner holding call-to-action buttons.
|
|
87
|
+
- `ui_feature_grid` — a responsive grid of feature cards.
|
|
472
88
|
|
|
473
89
|
## How to use it
|
|
474
90
|
|
|
91
|
+
Every entry point is a view helper called from ERB, apart from
|
|
92
|
+
`Keystone::Ui::Column`. Keywords with defaults are optional and the rest are
|
|
93
|
+
required. A symbol option outside the listed values raises at render time, with
|
|
94
|
+
two exceptions: `ui_page`'s `padding:` treats any value other than `:none` as
|
|
95
|
+
`:standard`, and a `ui_pipeline` box's `accent:` falls back to `:muted`.
|
|
96
|
+
|
|
475
97
|
1. Confirm the helpers are available in the app. If they are not, stop and hand
|
|
476
|
-
off to `keystone_ui-install
|
|
98
|
+
off to `keystone_ui-install`, and do not hand-write the markup meanwhile.
|
|
477
99
|
|
|
478
100
|
2. Find the closest existing screen in `app/views/` and read it. Match its
|
|
479
|
-
composition before inventing one
|
|
101
|
+
composition before inventing one, since that screen is the house style.
|
|
102
|
+
|
|
103
|
+
3. Pick the page shell.
|
|
480
104
|
|
|
481
|
-
|
|
482
|
-
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
- anything else → `ui_page`, with `ui_page_header` for the desktop title.
|
|
105
|
+
- A form screen → `ui_form_page`, then `ui_page` holding the form.
|
|
106
|
+
- A detail screen → `ui_show_page`, then `ui_page` holding `ui_page_header`
|
|
107
|
+
and the details.
|
|
108
|
+
- Anything else → `ui_page`, with `ui_page_header` for the desktop title.
|
|
486
109
|
|
|
487
110
|
```erb
|
|
488
111
|
<%= ui_form_page(title: "New quote", back_url: quotes_path) %>
|
|
@@ -493,169 +116,422 @@ outer element. See Conventions before using it.
|
|
|
493
116
|
<% end %>
|
|
494
117
|
```
|
|
495
118
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
`
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
`
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
119
|
+
- `ui_page(max_width: :full, padding: :standard, top_offset: nil, class: nil)`
|
|
120
|
+
takes a block. `max_width:` is `:sm` `:md` `:lg` `:xl` `:full`,
|
|
121
|
+
`padding:` is `:standard` or `:none`, and `top_offset:` is `:sm` `:md`
|
|
122
|
+
`:lg` `:xl` to clear a fixed navbar. At its top, above the block, it
|
|
123
|
+
renders what an earlier `ui_form_page` or `ui_show_page` on the same screen
|
|
124
|
+
handed it: the "Back" link, then the breadcrumbs, then the form page's
|
|
125
|
+
title.
|
|
126
|
+
- `ui_form_page(title:, back_url: nil, subtitle: nil, trail: nil)` renders
|
|
127
|
+
nothing where it is called. It publishes the title and back URL for the
|
|
128
|
+
navbar's mobile header, and hands `ui_page` a "Back" link to `back_url`
|
|
129
|
+
shown from `lg:` up, then the title and subtitle shown from `md:` up.
|
|
130
|
+
- `ui_show_page(title:, back_url: nil, subtitle: nil, trail: nil)` renders
|
|
131
|
+
nothing where it is called. It publishes the title, subtitle and back URL
|
|
132
|
+
for the navbar, and hands `ui_page` the "Back" link. It shows no title, so
|
|
133
|
+
put `ui_page_header` inside the `ui_page` block.
|
|
134
|
+
- Call the shell before `ui_page` and outside its block, or the "Back" link,
|
|
135
|
+
breadcrumbs and title do not appear. Never add a second back link or
|
|
136
|
+
button to a shell's screen.
|
|
137
|
+
- `trail:` is `[[label, href], ...]` from the top level down. A non-empty
|
|
138
|
+
trail adds breadcrumbs under the "Back" link, ending with `title`
|
|
139
|
+
unlinked, shown from `lg:` up. With no `trail:`, the shell uses the trail
|
|
140
|
+
the app supplies for the request, if it supplies one. With no
|
|
141
|
+
`back_url:`, the back URL is the `href` of the trail's last link. A
|
|
142
|
+
`trail:` or `back_url:` passed to the shell always wins over the supplied
|
|
143
|
+
one.
|
|
144
|
+
- `trail: []` marks a page a navigation tab opens directly. It shows no
|
|
145
|
+
"Back" link and no breadcrumbs, and with no `back_url:` it publishes no
|
|
146
|
+
back URL, so the mobile header shows no back arrow.
|
|
147
|
+
- With no `back_url:`, no trail from either place, or a trail whose last
|
|
148
|
+
link has no `href`, rendering raises `KeystoneUi::MissingBackLink` naming
|
|
149
|
+
the page's title. A trail with any link whose label or `href` is `nil` or
|
|
150
|
+
blank raises `KeystoneUi::IncompleteTrail` naming the page's title, even
|
|
151
|
+
when `back_url:` is passed.
|
|
152
|
+
- Check whether the app's keystone_ui initializer sets a `trail_supplier`.
|
|
153
|
+
If it does, a screen whose supplied trail is right passes neither `trail:`
|
|
154
|
+
nor `back_url:`. If it does not, every form and detail screen passes
|
|
155
|
+
`back_url:` or `trail:`. Setting up a supplier is `keystone_ui-install`'s
|
|
156
|
+
job.
|
|
157
|
+
- Ask the developer whether a screen shows breadcrumbs and which parent
|
|
158
|
+
screens its trail names. Ask which screens a navigation tab opens
|
|
159
|
+
directly, since those pass `trail: []`.
|
|
160
|
+
- `ui_page_header(title:, subtitle: nil, action_url: nil, action_label: "Add new", class: nil)`
|
|
161
|
+
takes a block yielding the header and is hidden below `sm:`. Call
|
|
162
|
+
`header.action { ... }` in the block to place a control on its right, and
|
|
163
|
+
only what `action` receives is rendered. `action_url:` publishes that URL
|
|
164
|
+
and label for a mobile navbar to pick up.
|
|
165
|
+
- `ui_breadcrumbs(trail:, current: nil)` renders `trail:` as links separated
|
|
166
|
+
by `›`, then `current:` unlinked and marked as the current page, shown
|
|
167
|
+
only from `lg:` up. On a form or detail screen pass `trail:` to the shell
|
|
168
|
+
instead of calling it.
|
|
169
|
+
- Below `lg:` the back link comes from `ui_mobile_header`, rendered by the
|
|
170
|
+
navbar from the title and back URL the shells publish. If the layout does
|
|
171
|
+
not render `ui_mobile_header` from that context, ask the developer whether
|
|
172
|
+
to wire it before adding more screens that depend on it.
|
|
173
|
+
|
|
174
|
+
4. Lay out the body. Each of these takes a block and they nest.
|
|
175
|
+
|
|
176
|
+
- `ui_section(title: nil, subtitle: nil, action: nil, menu: [], spacing: :md, class: nil)`
|
|
177
|
+
— `action:` is `{ label:, href: }` and renders a right-aligned link.
|
|
178
|
+
`menu:` is `[{ label:, href:, method: }, ...]`, each hash taking the
|
|
179
|
+
keywords of `ui_action_menu_item`, and renders an action menu at the right
|
|
180
|
+
of the header. `spacing:` is `:sm` `:md` `:lg`. The header, with its
|
|
181
|
+
`action:` and `menu:`, renders only when `title:` is given.
|
|
182
|
+
- `ui_grid(cols: { default: 1 }, gap: :md, gap_x: nil, gap_y: nil)` —
|
|
183
|
+
`cols:` maps `:default` `:sm` `:md` `:lg` to a count from 1 to 12, such as
|
|
184
|
+
`{ default: 1, md: 3 }`. `gap:` is `:sm` `:md` `:lg` `:xl`, and passing
|
|
185
|
+
`gap_x:` or `gap_y:` replaces `gap:` entirely.
|
|
186
|
+
- `ui_panel(padding: :md, radius: :lg, shadow: true)` — `padding:` is `:sm`
|
|
187
|
+
`:md` `:lg` and `radius:` is `:md` `:lg` `:xl`.
|
|
188
|
+
- `ui_card_link(href:, padding: :md, shadow: true)` — `padding:` is `:sm`
|
|
189
|
+
`:md` `:lg`.
|
|
190
|
+
- `ui_card(title:, summary:, link:, cta: "Read more", edge_to_edge: false, class: nil)`
|
|
191
|
+
takes no block. `edge_to_edge: true` drops the side border and corner
|
|
192
|
+
rounding below `sm:` so the card spans the full width on mobile.
|
|
193
|
+
|
|
194
|
+
5. Wire navigation in the layout.
|
|
195
|
+
|
|
196
|
+
- `ui_navigation` takes no keywords and a block holding the page content, so
|
|
197
|
+
it goes around `yield`. It draws the tabs the app declares with
|
|
198
|
+
`config.navigation_group` as a top bar of menus from `lg:` up, then the
|
|
199
|
+
content, and below `lg:` it draws only the content. It leaves out tabs
|
|
200
|
+
whose permission check fails and groups with no tab left.
|
|
201
|
+
- Inside its block, `navigation.with_logo do ... end` puts the app's logo at
|
|
202
|
+
the left of the top bar, and `navigation.with_menus do ... end` puts menus
|
|
203
|
+
such as the account menu at its right, so the layout draws no second bar.
|
|
204
|
+
With no group left, it draws only the content unless a logo or menus are
|
|
205
|
+
set, in which case the top bar holds just those.
|
|
206
|
+
- When the preference supplied for `:navigation` holds
|
|
207
|
+
`{ "placement" => "left" }` or `"right"`, it draws a sidebar on that side
|
|
208
|
+
from `lg:` up instead, with each group's label above its tabs, the logo at
|
|
209
|
+
its top and the menus at its bottom. Any other placement draws the top
|
|
210
|
+
bar. An `"order"` list of `{ "group" => label, "tabs" => [tab key strings] }`
|
|
211
|
+
entries draws the named groups and tabs first, in that order, then the
|
|
212
|
+
rest in declared order, ignoring names no longer declared.
|
|
213
|
+
- When the app sets `config.current_tab_supplier`, the tab whose key it
|
|
214
|
+
returns and that tab's group show as active in either placement. A `nil`
|
|
215
|
+
key, a key no declared tab has, or no supplier marks nothing.
|
|
216
|
+
- Check whether the app's initializer declares navigation groups before
|
|
217
|
+
adding desktop tabs by hand. Declaring groups, the placement and order
|
|
218
|
+
preference, and the current tab supplier are `keystone_ui-install`'s job.
|
|
219
|
+
- `ui_navbar(sticky: true)` takes a block yielding the navbar. Fill its
|
|
220
|
+
slots: `logo`, `desktop_links`, `desktop_right`, `mobile_left`,
|
|
221
|
+
`mobile_center`, `mobile_right`. Desktop slots are hidden below `lg:` and
|
|
222
|
+
mobile slots from `lg:` up. `desktop_right` renders only when
|
|
223
|
+
`desktop_links` is also filled.
|
|
224
|
+
- `ui_nav_item(label:, href:, active: false)` is one desktop link.
|
|
225
|
+
`ui_nav_dropdown(title:, area:, active: false)` takes a block holding the
|
|
226
|
+
menu links.
|
|
227
|
+
- `ui_bottom_nav` takes no keywords and a block of
|
|
228
|
+
`ui_bottom_nav_item(label:, href:, icon:, active: false)` calls, where
|
|
229
|
+
`icon:` is a raw SVG string. The bar is hidden from `lg:` up and inside a
|
|
230
|
+
Hotwire Native web view.
|
|
231
|
+
- `ui_mobile_header(title:, back_url:, subtitle: nil)` is hidden from `lg:`
|
|
232
|
+
up and goes in the navbar's `mobile_left` slot. `back_url:` must be
|
|
233
|
+
passed, and `nil` renders the title with no back arrow.
|
|
234
|
+
- `ui_settings_link(label:, href:)` renders one settings row.
|
|
235
|
+
- `ui_theme_toggle` takes no keywords and no block. Its buttons switch the
|
|
236
|
+
theme at once and store the choice in the `keystone_theme` cookie for a
|
|
237
|
+
year, and System follows the operating system. The button for the current
|
|
238
|
+
mode renders pressed, and none is pressed when the mode is a custom
|
|
239
|
+
palette supplied by another gem. Ask the developer where it goes, such as
|
|
240
|
+
a settings screen, the navbar's `desktop_right` or a mobile menu. It stays
|
|
241
|
+
correct across page loads and Turbo visits only when the layout's `<html`
|
|
242
|
+
tag carries the theme attributes and the gem's Stimulus controllers are
|
|
243
|
+
registered. If the tag carries nothing for the theme, stop and hand that
|
|
244
|
+
part to `keystone_ui-install`.
|
|
245
|
+
|
|
246
|
+
6. Build forms.
|
|
247
|
+
|
|
248
|
+
- `ui_form(action:, method: :post, multipart: false, data: nil)` takes a
|
|
249
|
+
block. `method:` may be `:patch`, `:put` or `:delete`, sent as Rails
|
|
250
|
+
expects. Set `multipart: true` when the form holds a file upload.
|
|
251
|
+
- `ui_form_field(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [], include_blank: nil, disabled: false, suggestions: [])`
|
|
252
|
+
is the default way to render a field. `type:` is `:text` `:number`
|
|
253
|
+
`:email` `:password` `:date` `:textarea` `:checkbox` `:select`.
|
|
254
|
+
- `attribute:` is used as the input's `name` as given, so pass the full
|
|
255
|
+
param name, such as `"quote[title]"`, and pass `label:` whenever it is a
|
|
256
|
+
nested name. `label:` defaults to the attribute with underscores turned to
|
|
257
|
+
spaces and the first letter capitalized. `errors:` is an array of message
|
|
258
|
+
strings.
|
|
259
|
+
- A `:select` takes `options:` as `[[label, value], ...]`, and `value:`
|
|
260
|
+
picks the selected option. A select that is not required gets a leading
|
|
261
|
+
empty option, worded by `include_blank:` or left blank without it, so
|
|
262
|
+
never add an empty choice to `options:` as well.
|
|
263
|
+
- A `:checkbox` renders its label beside the box, submits `"0"` unchecked
|
|
264
|
+
and `"1"` checked, and is checked when `value:` is `"1"`.
|
|
265
|
+
- `suggestions:` is an array of strings the browser offers while typing, and
|
|
266
|
+
the user can still type any value. It applies to `:text` `:number`
|
|
267
|
+
`:email` `:password` `:date` and is ignored by the other types. Its list
|
|
268
|
+
is identified by `attribute:`, so two fields with suggestions on one
|
|
269
|
+
screen need different `attribute:` values.
|
|
270
|
+
- A value that must be one of a fixed list is a `:select`, and a free value
|
|
271
|
+
with common choices is a text field with `suggestions:`. Ask the developer
|
|
272
|
+
which a field is and which values it offers.
|
|
273
|
+
- `ui_input(name:, type: :text, value: nil, placeholder: nil, disabled: false, min: nil, max: nil, step: nil)`
|
|
274
|
+
takes `:text` `:number` `:email` `:password` `:date`.
|
|
275
|
+
`ui_textarea(name:, value: nil, rows: 3, placeholder: nil, disabled: false)`
|
|
276
|
+
and `ui_select(name:, options: [], selected: nil, include_blank: nil, disabled: false)`
|
|
277
|
+
are the bare forms of the other two. Use them only where a label does not
|
|
278
|
+
belong.
|
|
279
|
+
- `ui_multi_select(name:, label:, options:, selected: [])` posts every
|
|
280
|
+
checked value under `name` as given, so pass an array name such as
|
|
281
|
+
`"status[]"`. `options:` is `[[label, value], ...]` and `selected:` is the
|
|
282
|
+
values to check. Its button reads "All <label>" with nothing checked and
|
|
283
|
+
"N selected" otherwise.
|
|
284
|
+
- `ui_file_upload(name:, label: nil, accept: nil, multiple: false, hint: nil)`
|
|
285
|
+
needs the enclosing form to be multipart.
|
|
286
|
+
- `ui_color_picker(name:, value: "#000000", label: nil)` writes the chosen
|
|
287
|
+
hex value into a hidden input named `name`.
|
|
288
|
+
- `ui_radio_card(name:, value:, label:, hint: nil, info: nil, checked: false)`
|
|
289
|
+
and `ui_checkbox_row(name:, value:, label:, hint: nil, info: nil, checked: false)`
|
|
290
|
+
share `hint:` and `info:`. `hint:` is a line always shown under the label.
|
|
291
|
+
`info:` adds an info button beside the label, named "About <label>" for
|
|
292
|
+
screen readers, that shows the text in a panel below while hovered and
|
|
293
|
+
toggles it when tapped. Text every reader needs goes in `hint:` and text
|
|
294
|
+
only some want goes in `info:`, so ask the developer which is which.
|
|
295
|
+
- A checked `ui_checkbox_row` submits `value` under `name`, an unchecked one
|
|
296
|
+
submits nothing, and rows sharing an array name such as `"shown[]"`
|
|
297
|
+
submit the checked values as a list.
|
|
298
|
+
- `ui_option_card(name:, value:, selected: false, input_data: {}, label_data: {})`
|
|
299
|
+
takes a block rendering the card's body. `input_data:` and `label_data:`
|
|
300
|
+
become `data-*` attributes on the input and the label.
|
|
301
|
+
|
|
302
|
+
7. Put the actions on a record, such as Edit and Delete, in an action menu
|
|
303
|
+
rather than a row of buttons: `menu:` on the `ui_section` showing the record,
|
|
304
|
+
`table.actions` for a table row, or `ui_action_menu` anywhere else.
|
|
305
|
+
|
|
306
|
+
- `ui_action_menu` takes no keywords and a block of `ui_action_menu_item`
|
|
307
|
+
calls, and shows at every screen size. `ui_mobile_actions` takes the same
|
|
308
|
+
block and is hidden from `lg:` up, so use it only for actions that
|
|
309
|
+
desktop reaches some other way.
|
|
310
|
+
- `ui_action_menu_item(label:, href:, method: :get, confirm: nil)` is a link
|
|
311
|
+
with `method: :get`. Any other method renders a button in its own small
|
|
312
|
+
form sending that method, so never place such an item inside a `ui_form`
|
|
313
|
+
block.
|
|
314
|
+
- `confirm:` is a question Turbo asks before the item sends. With no
|
|
315
|
+
`confirm:`, a `:delete` item asks "<label> this? This cannot be undone."
|
|
316
|
+
and every other method sends without asking. A `:get` item ignores
|
|
317
|
+
`confirm:`. Ask the developer whether a `:post`, `:patch` or `:put` action
|
|
318
|
+
needs a question.
|
|
319
|
+
|
|
320
|
+
8. Build tables.
|
|
321
|
+
|
|
322
|
+
- `ui_data_table(items:, columns:, empty_message: nil, sort: nil, sort_direction: nil, sort_url: nil, hidden_columns: [], key: nil)`
|
|
323
|
+
takes a block yielding the table. `items:` are records or hashes, and a
|
|
324
|
+
cell's value is the column key called on the item, or `item[key]`.
|
|
325
|
+
- `columns:` takes `{ key: "Label" }` hashes when every column is plain.
|
|
326
|
+
Switch the whole set to `Keystone::Ui::Column` objects once one column
|
|
327
|
+
needs an option.
|
|
328
|
+
- `Keystone::Ui::Column.new(key, header_text, mobile_hidden: false, sortable: false, hideable: false, locked: false)`
|
|
329
|
+
— `mobile_hidden:` hides the column below `sm:`, `sortable:` gives it a
|
|
330
|
+
sort header, and `hideable:` lets it be hidden and moved.
|
|
331
|
+
`locked: true` on the first column keeps it in view while the rest scrolls
|
|
332
|
+
sideways, a locked column that is not first renders as an ordinary one,
|
|
333
|
+
and a locked column is never hideable. Ask the developer whether a wide
|
|
334
|
+
table locks its first column.
|
|
335
|
+
- In the block, `table.link(:column_key) { |item| url }` turns a column's
|
|
336
|
+
cells into links, and `table.actions { |item| ... }` adds a right-aligned
|
|
337
|
+
actions column whose block holds only `ui_action_menu_item` calls. With no
|
|
338
|
+
actions column, the last column is right-aligned, so put a column of
|
|
339
|
+
amounts last.
|
|
340
|
+
- Sorting needs all three of `sort:` (the current column key),
|
|
341
|
+
`sort_direction:` (`:asc` or `:desc`) and `sort_url:` (a lambda taking
|
|
342
|
+
`(column_key, direction)` and returning a URL). Headers then render as
|
|
343
|
+
links that flip direction.
|
|
344
|
+
- `hidden_columns:` is the table's default layout and drops only hideable
|
|
345
|
+
columns. The declared order is the default order.
|
|
346
|
+
- For a table whose columns a user chooses and keeps, check whether the
|
|
347
|
+
app's initializer sets a `preference_supplier`. If it does, pass `key:`
|
|
348
|
+
and the default `hidden_columns:`, and add no `ui_column_picker`. A saved
|
|
349
|
+
`"hidden_columns"` list replaces `hidden_columns:`, an empty saved list
|
|
350
|
+
shows every column, and a saved `"column_order"` places the hideable
|
|
351
|
+
columns in that order while other columns keep their place. When the
|
|
352
|
+
supplier returns a save address, the table renders its own Columns menu
|
|
353
|
+
above it. Ask the developer which key names the table, which columns are
|
|
354
|
+
hideable, and which are hidden by default.
|
|
355
|
+
- If the app sets no supplier, ask the developer whether to use
|
|
356
|
+
`ui_column_picker` with an endpoint the app owns, or to hand setting up a
|
|
357
|
+
supplier to `keystone_ui-install`.
|
|
358
|
+
- `ui_column_picker(columns:, hidden_columns: [], save_url: nil)` lists the
|
|
359
|
+
hideable columns in the order given, each with a checkbox and up and down
|
|
360
|
+
buttons. Pass it the columns in the order the table shows them and the
|
|
361
|
+
same hidden keys. When the menu closes with a change, it sends one
|
|
362
|
+
`PATCH save_url` with a CSRF token and the JSON
|
|
363
|
+
`{ "hidden_columns": [...], "column_order": [...] }`, then reloads.
|
|
364
|
+
`column_order` lists every hideable key. The app's endpoint must store both
|
|
365
|
+
lists and answer with a success status. On an error status or no
|
|
366
|
+
connection the menu shows "Your column changes were not saved." and puts
|
|
367
|
+
its boxes and order back. It does not reorder the table, so the app passes
|
|
368
|
+
both the table and the picker their columns in the saved order.
|
|
369
|
+
|
|
370
|
+
9. Show content and status.
|
|
371
|
+
|
|
372
|
+
- `ui_button(label:, href: nil, variant: :primary, size: :md, type: :submit, data: nil)`
|
|
373
|
+
renders a link with `href:` and a button without it. `variant:` is
|
|
374
|
+
`:primary` `:secondary` `:danger`, `size:` is `:sm` `:md` `:lg`, and
|
|
375
|
+
`type:` applies only to the button.
|
|
376
|
+
- `ui_badge(label:, variant: :neutral, class: nil)` — `variant:` is
|
|
377
|
+
`:neutral` `:success` `:danger` `:warning` `:info`.
|
|
378
|
+
- `ui_alert(message:, type: :info, title: nil, dismissible: false, class: nil)`
|
|
379
|
+
— `type:` is `:info` `:success` `:warning` `:error`, and `dismissible: true`
|
|
380
|
+
adds a close button.
|
|
381
|
+
- `ui_progress(value:, max:, label: nil)` shows `value / max` as a percent,
|
|
382
|
+
rounded and capped at 100.
|
|
383
|
+
- `ui_copy_button(text:, label: "Copy", success_message: "Copied!", error_message: "Failed!")`.
|
|
384
|
+
- `ui_code(language: nil, caption: nil)` takes a block holding the code.
|
|
385
|
+
`caption:` is a strip above it and `language:` sets the `language-*`
|
|
386
|
+
class for a highlighter.
|
|
387
|
+
- `ui_disclosure(open: false)` takes a block yielding the component. Fill
|
|
388
|
+
its `summary` slot with the header, and the rest of the block is the body.
|
|
389
|
+
- `ui_accordion(items: [])` — `items:` is `[{ question:, answer: }, ...]`.
|
|
390
|
+
|
|
391
|
+
10. Explain figures.
|
|
392
|
+
|
|
393
|
+
- `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)`
|
|
394
|
+
— `variant:` is `:neutral` `:success` `:danger` `:warning` `:info` and
|
|
395
|
+
colours the value. `change:` is a signed number shown as `▲ 4.2%` in
|
|
396
|
+
green, `▼` in red, or plain at zero. `href:` links the value only, so
|
|
397
|
+
never wrap a stat card in `ui_card_link`. `definition:` and
|
|
398
|
+
`calculation:` add an info button whose panel shows while hovered or
|
|
399
|
+
focused and toggles when tapped.
|
|
400
|
+
- `ui_figure(text:, tone: :neutral)` prints one amount inline. `tone:`
|
|
401
|
+
`:neutral` uses the surrounding text colour, `:success` green and
|
|
402
|
+
`:danger` red, and any other symbol raises `KeyError`. Its output can go
|
|
403
|
+
anywhere a string is shown, such as a table cell. Use it, not a badge or
|
|
404
|
+
a colour class, for a gain or a loss, and ask the developer which amounts
|
|
405
|
+
are coloured and what counts as a gain.
|
|
406
|
+
- `ui_calculation(groups:, summary: "How this is worked out")` shows the
|
|
407
|
+
steps behind a figure, closed under a row reading `summary:`. `groups:`
|
|
408
|
+
is `[{ title:, lines: [{ label:, working:, result: }, ...] }, ...]`.
|
|
409
|
+
`title:` is optional, a group without `lines:` raises `KeyError`, and a
|
|
410
|
+
line with another key raises `ArgumentError`. Put it directly under the
|
|
411
|
+
figure it explains, and ask the developer which steps a reader needs.
|
|
412
|
+
- `ui_info(summary:)` is an info button beside the thing it explains,
|
|
413
|
+
named "More about this" for screen readers. `summary:` is a short line
|
|
414
|
+
shown while hovered. With a block, a tap toggles a wider panel holding
|
|
415
|
+
the block, and the summary stays hover-only. With no block, a tap
|
|
416
|
+
toggles the summary. A click elsewhere closes the detail and scrolling
|
|
417
|
+
closes both. The panels are not cut off by a table or other clipping
|
|
418
|
+
container. The block may hold only text and inline elements, such as a
|
|
419
|
+
`ui_breakdown`, never a `<div>`, list or table. Pass plain sentences with
|
|
420
|
+
no line breaks.
|
|
421
|
+
- `ui_breakdown(lines:, total:)` — `lines:` is `[{ amount:, label: }, ...]`
|
|
422
|
+
and `total:` is one `{ amount:, label: }`. It adds nothing up, so
|
|
423
|
+
compute the total. It renders inline, so it can sit in a `ui_info` block.
|
|
424
|
+
Ask the developer which amounts it lists and how the summary is worded.
|
|
425
|
+
- Every value these print is shown as given, so format numbers, currency
|
|
426
|
+
and minus signs before passing them.
|
|
427
|
+
|
|
428
|
+
11. Draw charts and analytics.
|
|
429
|
+
|
|
430
|
+
- `ui_chart_card(title:, height: :md)` takes a block holding a chart, with
|
|
431
|
+
`height:` `:sm` `:md` `:lg`.
|
|
432
|
+
- `ui_line_chart(series:, labels: nil, dates: nil, height: :md)` —
|
|
433
|
+
`series:` is `[{ name:, data:, color:, dashed: }, ...]`, where `color:`
|
|
434
|
+
is a CSS colour and `dashed: true` is optional. Pass exactly one of
|
|
435
|
+
`labels:` and `dates:`, or it raises `ArgumentError`. `labels:` are
|
|
436
|
+
strings spaced evenly. `dates:` are `Date`, `Time` or `DateTime` values,
|
|
437
|
+
one per value, placed by day and read as dates such as "Oct 2, 2026",
|
|
438
|
+
with the time of day dropped. Use `dates:` for calendar days and never
|
|
439
|
+
format dates into `labels:`. A day with no value leaves a wider gap, so
|
|
440
|
+
ask the developer whether a missing day is left out or passed as zero.
|
|
441
|
+
- `ui_funnel(steps:, shape: :bars)` — `steps:` is
|
|
442
|
+
`[{ label:, value:, color: }, ...]` in order. Each width is the value's
|
|
443
|
+
share of the first step's value, and the percent between two steps is
|
|
444
|
+
the second divided by the first. Colours run `:accent` `:sky` `:violet`
|
|
445
|
+
`:amber` `:rose` and repeat, and `color:` picks one of those or raises.
|
|
446
|
+
`:bars` draws one bar per step with a `↓ N%` caption between bars.
|
|
447
|
+
`:joined` draws one shape with a narrowing band holding the percent
|
|
448
|
+
between blocks. Any other `shape:` raises `ArgumentError`. Ask the
|
|
449
|
+
developer which shape they want.
|
|
450
|
+
- `ui_bucket(goal:, actual:, label: nil, over: :success)` shows the label,
|
|
451
|
+
`goal`, the container, `actual` and the percent reached. `goal` and
|
|
452
|
+
`actual` must be numbers, so a string or `nil` raises `ArgumentError`.
|
|
453
|
+
They print as Ruby prints them, so convert a `BigDecimal` with `to_i` or
|
|
454
|
+
`to_f`. The percent is not capped and is 0 for a zero goal. Over the goal
|
|
455
|
+
the fill turns green with `over: :success` or amber with `over: :warning`.
|
|
456
|
+
- `ui_bucket_series(buckets:)` takes an array of hashes with the keywords
|
|
457
|
+
of `ui_bucket`, such as `[{ goal: 10, actual: 7, label: "Mon" }, ...]`.
|
|
458
|
+
- `ui_pipeline(title:, boxes:, links:, subtitle: nil)` — `boxes:` is
|
|
459
|
+
`[{ label:, count:, accent:, action: }, ...]`, with `accent:` one of
|
|
460
|
+
`:amber` `:emerald` `:danger` `:muted`, and `action:` is
|
|
461
|
+
`{ url:, label:, params:, variant: }`, a button that POSTs `params` to
|
|
462
|
+
`url`. `links:` has one fewer entry than `boxes:`, each
|
|
463
|
+
`{ url:, params:, broken: }`, a toggle between two boxes that POSTs to
|
|
464
|
+
flip its state.
|
|
465
|
+
|
|
466
|
+
12. Add interactive pieces whose outcome the app owns.
|
|
467
|
+
|
|
468
|
+
- `ui_modal(title:, size: :md)` takes a block holding the body, with
|
|
469
|
+
`size:` `:sm` `:md` `:lg` `:xl`. It renders with the `hidden` class and
|
|
470
|
+
closes on its close button and on a backdrop click. Nothing in the gem
|
|
471
|
+
opens it, so the app's code removes `hidden`.
|
|
472
|
+
- `ui_tab_switcher(tabs:)` takes an array of label strings and a block
|
|
473
|
+
rendered below the tabs. The first tab is active on load, and a click
|
|
474
|
+
sends a `tab-switcher:change` event with the clicked index. Showing and
|
|
475
|
+
hiding the panels is the app's code.
|
|
476
|
+
- `ui_swipe_deck(items:, empty_title: "All done!", empty_subtitle: nil)`
|
|
477
|
+
takes a block yielding the deck, where `deck.item { |item| ... }` renders
|
|
478
|
+
one card's face. Accepting sends a bubbling `swipe-deck:complete` event
|
|
479
|
+
and rejecting sends `swipe-deck:skip`. Both carry `detail.itemId` (the
|
|
480
|
+
item's `id`, or its position) and `detail.card`, and `complete` carries
|
|
481
|
+
`detail.value` from an input marked `data-swipe-deck-value`, or `null`.
|
|
482
|
+
- For these, the pipeline's URLs and the column picker's save URL, do not
|
|
483
|
+
invent routes or how the outcome is stored. Ask the developer, then
|
|
484
|
+
build what they pick.
|
|
485
|
+
|
|
486
|
+
13. Build marketing sections.
|
|
487
|
+
|
|
488
|
+
- `ui_hero(title:, subtitle: nil, badge: nil, layout: :split)` takes a
|
|
489
|
+
block holding the call-to-action buttons and yields the component, so
|
|
490
|
+
its `aside` slot can hold an image or panel. `layout:` is `:split` or
|
|
491
|
+
`:centered`.
|
|
492
|
+
- `ui_cta_banner(title:, subtitle: nil)` takes a block holding the
|
|
493
|
+
buttons.
|
|
494
|
+
- `ui_feature_grid(title:, features:, subtitle: nil)` — `features:` is
|
|
495
|
+
`[{ icon:, title:, description: }, ...]`, where `icon:` is a raw SVG or
|
|
496
|
+
HTML string.
|
|
497
|
+
|
|
498
|
+
14. Read back what you wrote and delete every Tailwind class and inline `style`
|
|
499
|
+
you added. If the result still needs one, a different helper fits, so go
|
|
500
|
+
back to the steps above. Bring it to the developer only if no helper fits.
|
|
501
|
+
|
|
502
|
+
If a piece renders unstyled where it should not — an info button that
|
|
503
|
+
shows nothing, a breakdown in one run of text, a `:success` or `:danger`
|
|
504
|
+
figure in plain colour, a joined funnel with no band, a locked column the
|
|
505
|
+
others show through, or a Columns menu with no greyed name for a hidden
|
|
506
|
+
column — the app's Stimulus controllers or keystone_ui-styles version are
|
|
507
|
+
out of date. Stop and hand that part to `keystone_ui-install`, and add no
|
|
508
|
+
classes to fix it.
|
|
623
509
|
|
|
624
510
|
## Conventions
|
|
625
511
|
|
|
626
|
-
- **Helpers only.** Call `ui_*` helpers from ERB
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
- **Never hand-write Tailwind for something a helper covers.**
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
- **
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
the
|
|
652
|
-
through it, so anything else emitted inside their block is discarded.
|
|
653
|
-
- **Mobile is not an afterthought.** Several helpers render only on one side of
|
|
654
|
-
the `lg:` (or `sm:`) breakpoint — page headers, mobile headers, mobile actions,
|
|
655
|
-
bottom navigation, breadcrumbs. The action menu is the exception and shows at
|
|
656
|
-
every size. A screen needs both treatments; check the small viewport
|
|
657
|
-
before calling it done.
|
|
658
|
-
- Out of scope for this local: installing or upgrading the gem, changing the
|
|
659
|
-
palette or theme defaults, and editing the components themselves. Building a
|
|
660
|
-
new UI primitive belongs in the gem, not in a host app's views — raise it with
|
|
661
|
-
the developer rather than approximating it locally.
|
|
512
|
+
- **Helpers only.** Call `ui_*` helpers from ERB and never name a component
|
|
513
|
+
class in the app. `Keystone::Ui::Column` is the one exception, since it is
|
|
514
|
+
passed as an argument and renders nothing.
|
|
515
|
+
- **Never hand-write Tailwind for something a helper covers.** The gem owns
|
|
516
|
+
spacing, colour, borders, radius, shadow and dark mode.
|
|
517
|
+
- **Never restyle a helper from outside** — no wrapper that overrides its
|
|
518
|
+
padding or width, and no CSS aimed at its markup. Choose a different option
|
|
519
|
+
instead, or say the helper does not fit.
|
|
520
|
+
- **`class:` needs the developer's approval.** `ui_page`, `ui_section`,
|
|
521
|
+
`ui_page_header`, `ui_card`, `ui_alert` and `ui_badge` append it to their
|
|
522
|
+
outer element, where it can override anything the gem sets. Name the class
|
|
523
|
+
and the reason and wait for a yes.
|
|
524
|
+
- **Semantic colour only.** Themed colour is `accent-*` and `surface-*`. Never
|
|
525
|
+
write a literal colour into a view.
|
|
526
|
+
- **Options are per helper.** A button's `variant:` and a badge's `variant:`
|
|
527
|
+
take different symbols, so use the values listed for that helper.
|
|
528
|
+
- **Containers take blocks, leaves take keywords.** The data table, page header
|
|
529
|
+
and swipe deck render only what is registered through the object they yield,
|
|
530
|
+
so anything else emitted in their block is dropped.
|
|
531
|
+
- **Check both screen sizes.** Page headers, mobile headers, mobile actions,
|
|
532
|
+
bottom navigation, breadcrumbs and `ui_navigation`'s bars render on one side
|
|
533
|
+
of `lg:` or `sm:` only, so a screen needs both treatments.
|
|
534
|
+
- Out of scope: installing or upgrading the gem, its configuration, the theme
|
|
535
|
+
attributes on the layout, and the palette, which belong to
|
|
536
|
+
`keystone_ui-install`. A new UI piece belongs in the gem, so raise it with
|
|
537
|
+
the developer rather than building it in the app's views.
|