bh 6.1.4 → 6.2.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/CHANGELOG.md +38 -0
- data/README.md +87 -15
- data/app/javascript/bh/bookmark_controller.js +97 -0
- data/app/javascript/bh/clear_controller.js +22 -0
- data/app/javascript/bh/combobox/menu.js +93 -0
- data/app/javascript/bh/combobox_controller.js +82 -0
- data/app/javascript/bh/confirm.js +100 -0
- data/app/javascript/bh/density_controller.js +21 -0
- data/app/javascript/bh/deselect_controller.js +37 -0
- data/app/javascript/bh/flash.js +35 -0
- data/app/javascript/bh/limit_controller.js +23 -0
- data/app/javascript/bh/map/loader.js +20 -0
- data/app/javascript/bh/map_controller.js +88 -0
- data/app/javascript/bh/otp/slots.js +27 -0
- data/app/javascript/bh/otp_controller.js +98 -0
- data/app/javascript/bh/phone_controller.js +89 -0
- data/app/javascript/bh/placeholder_controller.js +28 -0
- data/app/javascript/bh/relative_time_controller.js +45 -0
- data/app/javascript/bh/require_controller.js +18 -0
- data/app/javascript/bh/reveal_controller.js +16 -0
- data/app/javascript/bh/scheme_controller.js +99 -0
- data/app/javascript/bh/search_controller.js +78 -0
- data/app/javascript/bh/shortcuts_controller.js +42 -0
- data/app/javascript/bh/sortable_controller.js +84 -0
- data/app/javascript/bh/thread_controller.js +47 -0
- data/app/javascript/bh/timezone_controller.js +27 -0
- data/app/javascript/bh/toast_controller.js +35 -0
- data/app/javascript/bh/tooltip_controller.js +23 -0
- data/app/javascript/bh/written.js +20 -0
- data/app/javascript/bh/written_controller.js +28 -0
- data/app/javascript/bh.js +60 -0
- data/app/stylesheets/bh/base.css +27 -0
- data/app/stylesheets/bh/chat.css +77 -0
- data/app/stylesheets/bh/flow.css +94 -0
- data/app/stylesheets/bh/map.css +3 -0
- data/app/stylesheets/bh/pin.css +34 -0
- data/app/stylesheets/bh/search.css +60 -0
- data/app/stylesheets/bh/shell.css +202 -0
- data/app/stylesheets/bh/table.css +130 -0
- data/app/stylesheets/bh/values.css +41 -0
- data/app/stylesheets/bh.css +13 -0
- data/build.mjs +15 -0
- data/config/locales/bh.en.yml +13 -0
- data/lib/bh/engine.rb +21 -6
- data/lib/bh/form_builder/comboboxes.rb +38 -0
- data/lib/bh/form_builder/controls.rb +67 -0
- data/lib/bh/form_builder/pins.rb +30 -0
- data/lib/bh/form_builder.rb +96 -0
- data/lib/bh/helpers/chats/asides.rb +49 -0
- data/lib/bh/helpers/chats/asks.rb +41 -0
- data/lib/bh/helpers/chats.rb +42 -0
- data/lib/bh/helpers/dialogs.rb +47 -0
- data/lib/bh/helpers/flows.rb +49 -0
- data/lib/bh/helpers/heads.rb +27 -0
- data/lib/bh/helpers/notices.rb +55 -0
- data/lib/bh/helpers.rb +12 -0
- data/lib/bh/message.rb +11 -0
- data/lib/bh/version.rb +2 -3
- data/lib/bh.rb +8 -6
- data/package.json +32 -0
- data/public/bh/css/bh.css +1 -0
- data/public/bh/js/bh.js +152 -0
- data/vendor/bootstrap.bundle.min.js +9 -0
- data/vendor/bootstrap.min.css +2 -0
- metadata +101 -23
- data/.gitignore +0 -23
- data/.rspec +0 -3
- data/.travis.yml +0 -15
- data/.yardopts +0 -3
- data/Gemfile +0 -4
- data/app/assets/stylesheets/bh.css +0 -29
- data/app/javascript/controllers/bh/theme_controller.js +0 -16
- data/app/javascript/controllers/phone_controller.js +0 -33
- data/app/javascript/controllers/require_controller.js +0 -20
- data/app/javascript/controllers/submit_controller.js +0 -24
- data/app/views/bh/_grid.html.erb +0 -6
- data/app/views/bh/_table.html.erb +0 -44
- data/bh.gemspec +0 -19
- data/lib/bh/bootstrap_helpers.rb +0 -137
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* The search and its filters take the width the breadcrumb and the buttons
|
|
2
|
+
leave, and give it back as the viewport narrows: the box shrinks to a floor
|
|
3
|
+
that still shows a word, and below 768px the row is allowed to wrap. */
|
|
4
|
+
.recourse-search { flex: 1 1 auto; max-width: 48rem; }
|
|
5
|
+
/* The box grows and the filters do not, so the field someone types into is the
|
|
6
|
+
widest thing in the row on a large viewport. A toggle is `width: 100%`, which
|
|
7
|
+
as a flex item would otherwise mean the whole form. */
|
|
8
|
+
.recourse-search-box { flex: 2 1 16rem; min-width: 8rem; }
|
|
9
|
+
|
|
10
|
+
.recourse-search .combobox-toggle { flex: 0 1 10rem; }
|
|
11
|
+
/* The per-page switch is a word in the sentence beside it rather than a control
|
|
12
|
+
next to one, so it takes the line's height and not `.btn`'s own minimum —
|
|
13
|
+
which would leave the row under every table taller than the words in it. */
|
|
14
|
+
.recourse-limit { min-height: 0; }
|
|
15
|
+
/* The bookmark square is an icon in a row of text, not a control in a form, so it
|
|
16
|
+
takes the line's height too: `.btn`'s minimum made every row with one taller
|
|
17
|
+
than the rows without. */
|
|
18
|
+
.recourse-bookmark { min-height: 0; }
|
|
19
|
+
/* Stacked, each control is a row of its own rather than a fragment of one:
|
|
20
|
+
there is no second control beside it to share the line with. */
|
|
21
|
+
@media (width < 768px) {
|
|
22
|
+
/* The room the breadcrumb leaves, and never more: a form sized by its content
|
|
23
|
+
widened with the words a picked option put on a toggle, and wrapped under
|
|
24
|
+
the crumbs. A basis of nothing and leave to shrink hold it in its row. */
|
|
25
|
+
.recourse-search { flex: 1 1 0; min-width: 0; max-width: none; }
|
|
26
|
+
.recourse-search-box,
|
|
27
|
+
.recourse-search .combobox-toggle { flex: 1 1 100%; min-width: 0; }
|
|
28
|
+
/* Safari zooms in on a field whose text is under 16px, an absolute figure: the
|
|
29
|
+
small controls are 14px, and so is the body on a phone, so the controls say
|
|
30
|
+
16px themselves and focusing one moves nothing. */
|
|
31
|
+
.recourse-search .form-control { font-size: 16px; }
|
|
32
|
+
/* A page under a record has a trail two or three crumbs long, and what sat beside
|
|
33
|
+
the trail — the search, its filters, a button — was squeezed into what those
|
|
34
|
+
left: `Filter by na`. Under such a trail each takes a row of its own, the form
|
|
35
|
+
the whole width and every control in it a row in turn, and the margin that set
|
|
36
|
+
a button apart from the trail goes, there being no trail beside it. An index at
|
|
37
|
+
the top keeps its form beside its one crumb, where it fits. */
|
|
38
|
+
.recourse-trailed > :not(nav) { flex: 0 0 100%; margin-inline-start: 0; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The button that empties a combobox search sits inside the field it clears. */
|
|
42
|
+
.combobox-search { position: relative; }
|
|
43
|
+
.combobox-search-input { padding-inline-end: 2rem; }
|
|
44
|
+
/* `:not(.d-none)`, because v6's display utilities carry no `!important` and
|
|
45
|
+
this rule is later in the cascade: a plain `display` here would beat the
|
|
46
|
+
`.d-none` the button is hidden with, and show an X over an empty field. */
|
|
47
|
+
.combobox-search-clear:not(.d-none) { display: flex; }
|
|
48
|
+
.combobox-search-clear {
|
|
49
|
+
position: absolute;
|
|
50
|
+
inset-block-start: 50%;
|
|
51
|
+
inset-inline-end: calc(var(--bs-menu-padding-x) + .35rem);
|
|
52
|
+
transform: translateY(-50%);
|
|
53
|
+
padding: .15rem;
|
|
54
|
+
border: 0;
|
|
55
|
+
border-radius: var(--bs-radius-5);
|
|
56
|
+
background: transparent;
|
|
57
|
+
color: var(--bs-fg-2);
|
|
58
|
+
line-height: 1;
|
|
59
|
+
}
|
|
60
|
+
.combobox-search-clear:hover { background-color: var(--bs-bg-1); color: var(--bs-fg-body); }
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/* A crumb that is not a link keeps its resting colors under the cursor.
|
|
2
|
+
Bootstrap lights every .breadcrumb-link on hover, which on a <span>
|
|
3
|
+
promises a click that goes nowhere — and the element is the whole
|
|
4
|
+
discriminator, since a crumb that links is an <a>. Redefining the hover
|
|
5
|
+
tokens, rather than out-cascading the hover rule, leaves Bootstrap's own
|
|
6
|
+
selector in charge and the active crumb its own resting color. */
|
|
7
|
+
span.breadcrumb-link {
|
|
8
|
+
--bs-breadcrumb-link-hover-color: var(--bs-breadcrumb-link-color);
|
|
9
|
+
--bs-breadcrumb-link-hover-bg: transparent;
|
|
10
|
+
}
|
|
11
|
+
span.breadcrumb-link.active {
|
|
12
|
+
--bs-breadcrumb-link-hover-color: var(--bs-breadcrumb-link-active-color);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Nothing here is meant to be wider than the window: the one thing that is, a
|
|
16
|
+
table, scrolls inside its own wrapper. Safari lays a stacked table's hidden
|
|
17
|
+
header row out at its full width all the same, and a page that can be
|
|
18
|
+
scrolled to it is a page Safari zooms out of and draws the navbar short on.
|
|
19
|
+
`clip` on the shell rather than `hidden`: a `hidden` axis turns the other into
|
|
20
|
+
`auto`, and the body then cut off whatever hung below it — a filter's menu, on a
|
|
21
|
+
page whose table had no rows to be taller than the menu. `clip` leaves the
|
|
22
|
+
other axis alone. */
|
|
23
|
+
html { overflow-x: hidden; }
|
|
24
|
+
.recourse-shell { overflow-x: clip; }
|
|
25
|
+
|
|
26
|
+
/* A finger does not hover. The tint a pointer leaves on the row under it, or on
|
|
27
|
+
the link under it, would stay on whatever was last touched — or land on whatever
|
|
28
|
+
the redrawn page put under the finger — so a touch screen gets none. */
|
|
29
|
+
@media (hover: none) {
|
|
30
|
+
.table-hover > tbody > tr:hover > * {
|
|
31
|
+
--bs-table-color-state: initial;
|
|
32
|
+
--bs-table-bg-state: initial;
|
|
33
|
+
}
|
|
34
|
+
.nav-link:hover:not(.active) { background-color: transparent; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* The navbar stacks over the content: a menu opened from a filter drops over the
|
|
38
|
+
first rows of the table rather than under them. */
|
|
39
|
+
.recourse-shell > nav { position: relative; z-index: 1; }
|
|
40
|
+
|
|
41
|
+
/* A focus ring is drawn outside the link it rings, so the link after it paints its own
|
|
42
|
+
background over the half that overlaps — which is how a ring ends up behind the tint
|
|
43
|
+
of the row below it. Lifting the focused link above its siblings keeps the ring whole. */
|
|
44
|
+
.nav-link:focus-visible { position: relative; z-index: 1; }
|
|
45
|
+
|
|
46
|
+
/* The shell is three things — the sidebar, the navbar, the content — written in
|
|
47
|
+
the order a phone reads them: a row of icons at the very top, the trail and the
|
|
48
|
+
search under it, the page under that, scrolling as one. From the width the
|
|
49
|
+
sidebar becomes a column at, a grid seats them the other way round. The navbar
|
|
50
|
+
runs across the top, the sidebar takes the column under it and the content the
|
|
51
|
+
rest, the shell is exactly the viewport, and `main` is the only thing in it that
|
|
52
|
+
scrolls — so the navbar and the sidebar stay where they are however far down the
|
|
53
|
+
table a reader is. `minmax(0, 1fr)` is what lets the second row be shorter than
|
|
54
|
+
what is in it; a `1fr` alone is as tall as its content, and the page would scroll
|
|
55
|
+
after all.
|
|
56
|
+
The sidebar deliberately does not scroll. A window dragged shorter than the
|
|
57
|
+
sidebar itself is the one case the viewport cannot hold, and there the sidebar
|
|
58
|
+
is allowed to spill past the shell and let the browser scroll the page — which is
|
|
59
|
+
what a reader expects of something taller than the screen. Were it to scroll on
|
|
60
|
+
its own instead, the same overflow would be hidden inside it and the page would
|
|
61
|
+
look whole while its foot was unreachable. */
|
|
62
|
+
@media (width >= 768px) {
|
|
63
|
+
.recourse-shell {
|
|
64
|
+
display: grid;
|
|
65
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
66
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
67
|
+
grid-template-areas: 'nav nav' 'aside main';
|
|
68
|
+
height: 100dvh;
|
|
69
|
+
}
|
|
70
|
+
.recourse-shell > nav { grid-area: nav; }
|
|
71
|
+
.recourse-sidebar { grid-area: aside; }
|
|
72
|
+
.recourse-shell > main { grid-area: main; overflow-y: auto; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* The sidebar rules off the navbar below it while it is the band at the top of a
|
|
76
|
+
phone, and off the content beside it once it is a column. v6 generates no
|
|
77
|
+
responsive border utilities, so the switch is written out. */
|
|
78
|
+
.recourse-sidebar {
|
|
79
|
+
border-block-end: var(--bs-border-width) solid var(--bs-border-color);
|
|
80
|
+
}
|
|
81
|
+
@media (width >= 768px) {
|
|
82
|
+
.recourse-sidebar {
|
|
83
|
+
border-block-end: 0;
|
|
84
|
+
border-inline-end: var(--bs-border-width) solid var(--bs-border-color);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Held Option marks the letter each sidebar link answers to. Marked rather than
|
|
89
|
+
bracketed: '[C]ontacts' would shift the whole sidebar two characters wide the
|
|
90
|
+
moment the key went down. Swap the two rules below for the bracketed form. */
|
|
91
|
+
.recourse-keys .recourse-key {
|
|
92
|
+
font-weight: var(--bs-font-weight-semibold);
|
|
93
|
+
text-decoration: underline;
|
|
94
|
+
text-underline-offset: 2px;
|
|
95
|
+
}
|
|
96
|
+
/* .recourse-keys .recourse-key::before { content: '['; }
|
|
97
|
+
.recourse-keys .recourse-key::after { content: ']'; } */
|
|
98
|
+
|
|
99
|
+
/* The foot of the sidebar holds the scheme toggle and the way out side by side,
|
|
100
|
+
each centered in its own half of the row: the toggle alone is in the middle, and
|
|
101
|
+
the two together are at a quarter and at three quarters. Each control sits in
|
|
102
|
+
a wrapper — the toggle's own, the form `button_to` draws — and the wrapper is
|
|
103
|
+
the half, so a button is only as wide as its icon and so is its hover. */
|
|
104
|
+
.recourse-foot { display: flex; }
|
|
105
|
+
.recourse-foot > * { flex: 1 1 0; display: flex; justify-content: center; }
|
|
106
|
+
.recourse-foot .nav-link { border-radius: var(--bs-radius-5); }
|
|
107
|
+
/* A hand cursor alone says little on an icon: a tint under it says it is a control.
|
|
108
|
+
Only where something can hover: a finger's tap leaves `:hover` set until the next
|
|
109
|
+
tap elsewhere, and a tint that stays reads as a button stuck down. */
|
|
110
|
+
@media (hover: hover) {
|
|
111
|
+
.recourse-foot .nav-link:hover { background-color: var(--bs-bg-1); color: var(--bs-fg-body); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* `.nav-item` ships `flex: auto`: in a row every entry would take an equal share
|
|
115
|
+
of the width and in a full-height column an equal share of the height, and the
|
|
116
|
+
foot's auto margin would never have anything left to push against. Every item is
|
|
117
|
+
its own size instead, in both directions. */
|
|
118
|
+
.recourse-sidebar .nav-item { flex: 0 0 auto; }
|
|
119
|
+
|
|
120
|
+
/* On a phone the sidebar is one row of icons across the top, centered, each entry
|
|
121
|
+
with less room around it than a column gives — a dozen have to fit — and wrapping
|
|
122
|
+
to a second line only where a host has more than a screen's width of them. */
|
|
123
|
+
@media (width < 768px) {
|
|
124
|
+
.recourse-sidebar .nav { justify-content: center; --bs-nav-link-padding-x: .5rem; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* The foot sits at the bottom of the sidebar and stretches across it while the
|
|
128
|
+
sidebar is a column, and is simply the last item of the row while it is not
|
|
129
|
+
— the query matches the `md:` the sidebar's own classes use. */
|
|
130
|
+
@media (width >= 768px) {
|
|
131
|
+
/* A floor rather than a height, and never wrapped. `.nav` ships `flex-wrap:
|
|
132
|
+
wrap`, and a column that may wrap, whose items are pinned to their own height
|
|
133
|
+
above, and which is told exactly how tall to be has one way to place a link
|
|
134
|
+
that does not fit: a second column. Which is what a short window used to draw.
|
|
135
|
+
The floor lets it be taller than the space it was given. */
|
|
136
|
+
.recourse-sidebar .nav { min-height: 100%; flex-wrap: nowrap; }
|
|
137
|
+
/* And it rides at the foot of the screen, which the auto margin alone is
|
|
138
|
+
enough to do: the nav's floor is the sidebar's full height, so while the
|
|
139
|
+
links leave room the margin takes all of it and the foot sits on the fold,
|
|
140
|
+
rising with it as the window is shortened. Once the links fill the nav there
|
|
141
|
+
is no room left to take, and the foot comes to rest immediately below the
|
|
142
|
+
last of them and goes under the fold with it, to be scrolled to.
|
|
143
|
+
Deliberately not sticky. Sticking it to the bottom of the viewport would lift
|
|
144
|
+
it off that resting place and hold it over the links it had passed — which is
|
|
145
|
+
what the background color was for, masking whichever one it covered. Both go
|
|
146
|
+
together: nothing is painted over, so nothing needs painting out. */
|
|
147
|
+
.recourse-sidebar .recourse-foot {
|
|
148
|
+
margin-block-start: auto;
|
|
149
|
+
align-self: stretch;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* A phone reads the chrome as pictures. A sidebar entry is its icon, a crumb its
|
|
154
|
+
icon, a tab its icon and its figure — `17` rather than `17 franchises` — and the
|
|
155
|
+
foot's controls their icons, so the sidebar is one row of squares and the trail
|
|
156
|
+
leaves the search its room. The words are there all the same, read but not seen
|
|
157
|
+
the way Bootstrap's `.visually-hidden` does it, and the arrows at the foot put
|
|
158
|
+
them back: the cookie they write has the server draw the shell `.recourse-expanded`,
|
|
159
|
+
and then none of this applies. A tab or a crumb with no icon keeps its word at
|
|
160
|
+
every width, there being nothing else to stand for it.
|
|
161
|
+
The same hiding, at every width, for the foot's words once the sidebar is a
|
|
162
|
+
column: there is room for an icon and not for a word beside it. */
|
|
163
|
+
.recourse-foot-word { margin-inline-start: .4em; }
|
|
164
|
+
@media (width < 768px) {
|
|
165
|
+
.recourse-shell:not(.recourse-expanded) :is(.recourse-nav-word, .recourse-crumb-word,
|
|
166
|
+
.recourse-tab-word, .recourse-foot-word) {
|
|
167
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
168
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
@media (width >= 768px) {
|
|
172
|
+
.recourse-foot-word {
|
|
173
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
174
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
175
|
+
}
|
|
176
|
+
/* Nothing to expand: a column has its words already. */
|
|
177
|
+
.recourse-density { display: none; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* The arrows say where a tap goes, like the moon: parting, with `Expand`, while
|
|
181
|
+
the chrome is icons; meeting, with `Compact`, once the words are out. Both faces
|
|
182
|
+
are drawn and the shell's class picks, so the button needs no script to redraw. */
|
|
183
|
+
.recourse-density-expanded { display: none; }
|
|
184
|
+
.recourse-expanded .recourse-density-compact { display: none; }
|
|
185
|
+
.recourse-expanded .recourse-density-expanded { display: inline; }
|
|
186
|
+
|
|
187
|
+
/* The toggle draws both icons and shows the one that names where a click
|
|
188
|
+
would go: a moon on a light page, a sun on a dark one. Three states rather than
|
|
189
|
+
two, because a page nobody has chosen for carries no attribute at all, and the
|
|
190
|
+
media query is then the only thing that knows which mode it is in. Both are
|
|
191
|
+
declared at every step rather than one left to its default, so that no state
|
|
192
|
+
can end up hiding the pair and leaving a button with nothing in it. */
|
|
193
|
+
.recourse-scheme-light { display: inline; }
|
|
194
|
+
.recourse-scheme-dark { display: none; }
|
|
195
|
+
@media (prefers-color-scheme: dark) {
|
|
196
|
+
:root:not([data-bs-theme='light']) .recourse-scheme-light { display: none; }
|
|
197
|
+
:root:not([data-bs-theme='light']) .recourse-scheme-dark { display: inline; }
|
|
198
|
+
}
|
|
199
|
+
:root[data-bs-theme='light'] .recourse-scheme-light { display: inline; }
|
|
200
|
+
:root[data-bs-theme='light'] .recourse-scheme-dark { display: none; }
|
|
201
|
+
:root[data-bs-theme='dark'] .recourse-scheme-light { display: none; }
|
|
202
|
+
:root[data-bs-theme='dark'] .recourse-scheme-dark { display: inline; }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/* An action column is as wide as a row is tall — an icon in a square — and a
|
|
2
|
+
counter column starts at the same square, widening only when a figure like
|
|
3
|
+
38,405 needs the room: a table laying out to 100% reads the width as a
|
|
4
|
+
preference, never crushes content into it, and hands what these cells do
|
|
5
|
+
not use to the columns carrying text. Not while the table is stacked, where
|
|
6
|
+
every cell is a block and a square one is a squashed one — the query
|
|
7
|
+
matches `.sm:table-stacked`'s. */
|
|
8
|
+
@container (width >= 576px) {
|
|
9
|
+
.recourse-actions, .recourse-counter {
|
|
10
|
+
width: calc(1em * var(--bs-body-line-height) + 2 * var(--bs-table-cell-padding-y));
|
|
11
|
+
white-space: nowrap;
|
|
12
|
+
text-align: center;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Stacked, a cell reads as one line — `Estimate hi: 600`, the heading semibold
|
|
17
|
+
and the value after it — where Bootstrap puts the heading on a line of its own
|
|
18
|
+
and leaves the row's first cell with no heading at all, bold in its place: a
|
|
19
|
+
status opening a row would be a bare badge. Every cell is headed here. And a
|
|
20
|
+
value with no space in it — an address, a token — breaks rather than widening
|
|
21
|
+
the row past the phone. */
|
|
22
|
+
@container (width < 576px) {
|
|
23
|
+
.sm\:table-stacked > tbody > tr > td[data-cell]::before {
|
|
24
|
+
content: attr(data-cell) ': ';
|
|
25
|
+
display: inline;
|
|
26
|
+
font-weight: var(--bs-font-weight-semibold);
|
|
27
|
+
}
|
|
28
|
+
.sm\:table-stacked > tbody > tr > td:first-child { font-weight: inherit; }
|
|
29
|
+
/* A counter's heading goes inside its link, so the whole `Franchises: 21` is
|
|
30
|
+
one thing to tap; the cell's own heading steps aside for it. */
|
|
31
|
+
.sm\:table-stacked > tbody > tr > td.recourse-counter::before { content: none; }
|
|
32
|
+
.sm\:table-stacked > tbody > tr > td.recourse-counter > [data-cell]::before {
|
|
33
|
+
content: attr(data-cell) ': ';
|
|
34
|
+
font-weight: var(--bs-font-weight-semibold);
|
|
35
|
+
}
|
|
36
|
+
.sm\:table-stacked > tbody > tr > td:not(:first-child) + td::before { margin-block-start: 0; }
|
|
37
|
+
.sm\:table-stacked > tbody > tr > td { overflow-wrap: anywhere; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* A counter says what it counts in one of two ways, and the table's own width is
|
|
41
|
+
what picks: the icon and a bare figure where the column is a square, the
|
|
42
|
+
counted model's words where there is room to read them. Bootstrap's `xl`, so
|
|
43
|
+
words arrive on a wide monitor and nowhere narrower — the width above is a
|
|
44
|
+
preference a table never crushes content into, so the column simply widens to
|
|
45
|
+
take them. Against `.table-responsive` like every query here, which is the
|
|
46
|
+
table's width rather than the window's: a sidebar takes a few hundred pixels
|
|
47
|
+
the window knows nothing about. */
|
|
48
|
+
.recourse-counter-word { display: none; }
|
|
49
|
+
@container (width >= 1280px) {
|
|
50
|
+
.recourse-counter-icon, .recourse-counter-figure { display: none; }
|
|
51
|
+
.recourse-counter-word { display: inline; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* A row the viewer has kept, and the only report a click ever gets, so it has to
|
|
55
|
+
be seen. A twelfth of the primary mixed into the page rather than
|
|
56
|
+
`--bs-primary-bg-subtle`: that token is a fixed step of the ramp, far enough
|
|
57
|
+
from the page on a low-contrast palette to cost the text a point of contrast
|
|
58
|
+
it has not got — mixing into the page keeps the tint the same distance from it
|
|
59
|
+
whatever the palette. A background rather than `.table-active`, which sets the
|
|
60
|
+
very variable `.table-hover` sets and would read as the row under the cursor,
|
|
61
|
+
and lose its tint the moment it became one; a background lets the hover shadow
|
|
62
|
+
lay over it. */
|
|
63
|
+
.table > tbody > .recourse-kept > * {
|
|
64
|
+
background-color: color-mix(in srgb, var(--bs-primary-fg) 12%, var(--bs-bg-body));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* The row a write just landed on, tinted for as long as the message saying so
|
|
68
|
+
stands. On the cells rather than on the row, like the kept tint beside it: the
|
|
69
|
+
table collapses its borders, so the cells are what paint, and one tint across
|
|
70
|
+
all of them is what reads as a single row — where anything drawn per cell reads
|
|
71
|
+
as a box around each of them instead.
|
|
72
|
+
A background rather than a border. A border on a collapsing table is a width
|
|
73
|
+
the table then measures around, and taking it away again leaves the line it
|
|
74
|
+
drew sitting between two rows that no longer want one.
|
|
75
|
+
Mixed into the page exactly as the kept tint below is, and never
|
|
76
|
+
`--bs-success-bg-subtle`: that token is `light-dark(--bs-green-100,
|
|
77
|
+
--bs-green-900)`, and a palette builds those steps by mixing toward white or
|
|
78
|
+
black rather than toward the page — so how far it lands from the page is the
|
|
79
|
+
palette's business and not ours. Mixing here keeps it the same distance on all
|
|
80
|
+
nine. The success family rather than the primary one is what tells it apart
|
|
81
|
+
from the kept tint it may land on top of: a different hue reads as a different
|
|
82
|
+
thing, where a second shade of the primary would read as more of the same. */
|
|
83
|
+
.table > tbody > .recourse-written > *,
|
|
84
|
+
.table > tbody > .recourse-written-out > * {
|
|
85
|
+
--recourse-written: color-mix(in srgb, var(--bs-success-fg) 12%, var(--bs-bg-body));
|
|
86
|
+
}
|
|
87
|
+
.table > tbody > .recourse-written > * {
|
|
88
|
+
background-color: var(--recourse-written);
|
|
89
|
+
}
|
|
90
|
+
/* Let go of over the second the toast beside it takes to fade, and started by the
|
|
91
|
+
same event. The class above comes off as this one goes on, so the animation is
|
|
92
|
+
the only thing painting the row while it runs and nothing paints it afterwards
|
|
93
|
+
— which hands a kept row back its own tint rather than leaving it wearing this
|
|
94
|
+
one. */
|
|
95
|
+
.table > tbody > .recourse-written-out > * {
|
|
96
|
+
animation: recourse-written-out 1s linear;
|
|
97
|
+
}
|
|
98
|
+
@keyframes recourse-written-out {
|
|
99
|
+
from { background-color: var(--recourse-written); }
|
|
100
|
+
to { background-color: var(--bs-bg-body); }
|
|
101
|
+
}
|
|
102
|
+
/* The tint itself is not motion; only its going is. */
|
|
103
|
+
@media (prefers-reduced-motion: reduce) {
|
|
104
|
+
.table > tbody > .recourse-written-out > * { animation: none; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* The grip a row of an arranged table is taken hold of by. The cursor is the whole of
|
|
108
|
+
the affordance — the icon says what it is and the pointer says it can be picked up —
|
|
109
|
+
and `grabbing` while it is held, which is what tells a drag from a click that has not
|
|
110
|
+
moved yet. `touch-action: none` on this square alone: a phone scrolls the page by
|
|
111
|
+
dragging anywhere on a table, and only the grip is taken away from that. */
|
|
112
|
+
.recourse-handle {
|
|
113
|
+
cursor: grab;
|
|
114
|
+
touch-action: none;
|
|
115
|
+
}
|
|
116
|
+
.recourse-handle:active { cursor: grabbing; }
|
|
117
|
+
|
|
118
|
+
/* The space the row will land in, which Sortable leaves standing where it would go.
|
|
119
|
+
The same tint a kept row wears, mixed into the page the same way and for the same
|
|
120
|
+
reason: a fixed step of the ramp lands a different distance from the page on every
|
|
121
|
+
palette. On the cells rather than the row, since the table collapses its borders and
|
|
122
|
+
the cells are what paint. */
|
|
123
|
+
.table > tbody > tr.sortable-ghost > * {
|
|
124
|
+
background-color: color-mix(in srgb, var(--bs-primary-fg) 12%, var(--bs-bg-body));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* And the row itself while it is off the ground. Sortable fades it to 0.8; a row under
|
|
128
|
+
the cursor reads as lifted from the tint standing where it left, so nothing here has
|
|
129
|
+
to make it translucent as well. */
|
|
130
|
+
.table > tbody > tr.sortable-drag { opacity: 1; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* One rule per row of the show page, so a value and its heading read as one thing
|
|
2
|
+
and the next pair as another. On the cells rather than between them: a column's
|
|
3
|
+
gutter is padding inside it, so two side by side draw one unbroken line.
|
|
4
|
+
Every row reserves the width a rule takes, in `transparent`, and the show page
|
|
5
|
+
only colors it in — which is what leaves a field at exactly the height of the
|
|
6
|
+
value it edits, row after row down two pages. */
|
|
7
|
+
.recourse-row {
|
|
8
|
+
border-block-end: var(--bs-border-width) solid transparent;
|
|
9
|
+
}
|
|
10
|
+
.recourse-values > .recourse-row {
|
|
11
|
+
border-block-end-color: var(--bs-border-color);
|
|
12
|
+
}
|
|
13
|
+
/* And a value stands as tall as the control that edits it, so a row on one page
|
|
14
|
+
is a row of the same height on the other. Bootstrap gives
|
|
15
|
+
`.form-control-plaintext` a control's padding and line height but no minimum,
|
|
16
|
+
and an icon or a badge is shorter than a box. */
|
|
17
|
+
.recourse-values .form-control-plaintext {
|
|
18
|
+
min-height: var(--bs-control-min-height);
|
|
19
|
+
}
|
|
20
|
+
/* The reveal is a word beside a value rather than a control beside it, so it takes
|
|
21
|
+
the line's height and not `.btn-sm`'s minimum — which would leave a masked row
|
|
22
|
+
taller than the field that edits the same attribute. */
|
|
23
|
+
.form-control-plaintext .btn {
|
|
24
|
+
min-height: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* A payload in a block of its own, bounded so it scrolls rather than grows. `pre`
|
|
28
|
+
for the indentation that makes JSON readable, but wrapping rather than running
|
|
29
|
+
off to the side: a flex item's automatic minimum size is the min-content width
|
|
30
|
+
of whatever is inside it, and `overflow` on this box does not lower it — so one
|
|
31
|
+
unbroken 4,000-character token became the width of the value's column, then of
|
|
32
|
+
`main`, which then wrapped below the sidebar it belongs beside. `pre-wrap` keeps
|
|
33
|
+
the indentation and takes the wrapping, and `anywhere` is what lowers
|
|
34
|
+
min-content, which `break-word` in its place would not. */
|
|
35
|
+
.recourse-payload {
|
|
36
|
+
max-height: 12rem;
|
|
37
|
+
margin-block-end: 0;
|
|
38
|
+
overflow: auto;
|
|
39
|
+
white-space: pre-wrap;
|
|
40
|
+
overflow-wrap: anywhere;
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Everything a page built on Bootstrap 6 is styled by, in one file: Bootstrap as upstream
|
|
2
|
+
ships it, and then this gem's own rules, each in a file named for what it draws.
|
|
3
|
+
Built by esbuild into /css/bh.css; the imports are the reading order. */
|
|
4
|
+
@import '../../vendor/bootstrap.min.css';
|
|
5
|
+
@import 'bh/base.css';
|
|
6
|
+
@import 'bh/shell.css';
|
|
7
|
+
@import 'bh/table.css';
|
|
8
|
+
@import 'bh/map.css';
|
|
9
|
+
@import 'bh/search.css';
|
|
10
|
+
@import 'bh/values.css';
|
|
11
|
+
@import 'bh/chat.css';
|
|
12
|
+
@import 'bh/pin.css';
|
|
13
|
+
@import 'bh/flow.css';
|
data/build.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Builds the stylesheet and the script into public/bh/, under the prefix the engine serves them
|
|
2
|
+
// at, which is also what the gem ships. Every URL inside is relative to the file holding it,
|
|
3
|
+
// so one build answers from the gem and from a CDN carrying the package alike.
|
|
4
|
+
import { build } from 'esbuild'
|
|
5
|
+
|
|
6
|
+
await build({
|
|
7
|
+
entryPoints: ['app/javascript/bh.js', 'app/stylesheets/bh.css'],
|
|
8
|
+
bundle: true,
|
|
9
|
+
minify: true,
|
|
10
|
+
format: 'esm',
|
|
11
|
+
outdir: 'public/bh',
|
|
12
|
+
entryNames: '[ext]/[name]',
|
|
13
|
+
alias: { bootstrap: './vendor/bootstrap.bundle.min.js' },
|
|
14
|
+
logLevel: 'info',
|
|
15
|
+
})
|
data/lib/bh/engine.rb
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
# rails/engine alone raises NoMethodError: railtie.rb calls delegate_missing_to too early.
|
|
2
|
+
require 'rails'
|
|
2
3
|
|
|
3
4
|
module Bh
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
# Hooks the gem into a host app: serves the built stylesheet and script, and the helpers.
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
# Where the built files answer from: one prefix of the gem's own, so a host already
|
|
8
|
+
# serving `/css` from somewhere else keeps it. The slash is kept, since `Rack::Static`
|
|
9
|
+
# matches on `start_with?` and `/bh` would swallow a host's own `/bhutan`.
|
|
10
|
+
PREFIX = '/bh/'.freeze
|
|
11
|
+
|
|
12
|
+
# Neither URL carries a version, so both are asked for again on every full load and
|
|
13
|
+
# answered `304 Not Modified` while the file they name still stands.
|
|
14
|
+
HEADERS = [[:all, { 'cache-control' => 'no-cache' }]].freeze
|
|
8
15
|
|
|
16
|
+
# Before `Rails::Rack::Logger`, so fetching the stylesheet writes no `Started GET` line,
|
|
17
|
+
# and cascading, so a host route under the prefix still answers.
|
|
9
18
|
initializer 'bh.assets' do |app|
|
|
10
|
-
app.
|
|
19
|
+
app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
|
|
20
|
+
urls: [PREFIX], root: Engine.root.join('public').to_s,
|
|
21
|
+
header_rules: HEADERS, cascade: true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer 'bh.helpers' do
|
|
25
|
+
ActiveSupport.on_load(:action_view) { include Bh::Helpers }
|
|
11
26
|
end
|
|
12
27
|
end
|
|
13
28
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require 'action_view'
|
|
2
|
+
|
|
3
|
+
module Bh
|
|
4
|
+
class FormBuilder < ActionView::Helpers::FormBuilder
|
|
5
|
+
# The menu the bundle draws as a combobox: Bootstrap's toggle and a searchable menu built
|
|
6
|
+
# from a select it then hides, which stays the one thing the form submits.
|
|
7
|
+
module Comboboxes
|
|
8
|
+
# The words the menu is read by, which belong to the gem rather than to a page.
|
|
9
|
+
WORDS = %i[all clear more no_results search].freeze
|
|
10
|
+
|
|
11
|
+
# `method` as a combobox over `choices` -- the select `form.select` would draw, carrying
|
|
12
|
+
# what the bundle needs to draw it as one instead. Every argument is that select's, and
|
|
13
|
+
# its own `:prompt` is what the toggle says before anything is picked. The words it is
|
|
14
|
+
# searched, cleared and emptied by are the gem's, in the reader's own language, and
|
|
15
|
+
# `data` given in `html_options` is kept over any of them.
|
|
16
|
+
def combobox(method, choices = nil, options = {}, html_options = {}, &)
|
|
17
|
+
data = { controller: 'combobox', combobox_placeholder_value: options[:prompt],
|
|
18
|
+
**bh_combobox_words, **html_options.fetch(:data, {}), }
|
|
19
|
+
|
|
20
|
+
select method, choices, options, html_options.merge(data: data), &
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# `all` and `more` are Stimulus values the menu reads; the rest are plain attributes.
|
|
26
|
+
# `more` is a template the bundle fills, so it is asked for with its own placeholders as
|
|
27
|
+
# their values -- I18n would otherwise raise on the two it cannot fill.
|
|
28
|
+
def bh_combobox_words
|
|
29
|
+
WORDS.to_h do |word|
|
|
30
|
+
# rubocop:disable-next Style/FormatStringToken -- the bundle fills these, not Ruby
|
|
31
|
+
said = @template.t "bh.#{word}", first: '%{first}', count: '%{count}'
|
|
32
|
+
|
|
33
|
+
[%i[all more].include?(word) ? :"combobox_#{word}_value" : word, said]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require 'action_view'
|
|
2
|
+
|
|
3
|
+
module Bh
|
|
4
|
+
class FormBuilder < ActionView::Helpers::FormBuilder
|
|
5
|
+
# Every other kind of field, at the one size this builder draws them: a page says which kind
|
|
6
|
+
# it wants and nothing about how it looks. Rails hands an undressed control to whatever it
|
|
7
|
+
# has no method for here, which is how a Bootstrap class ends up written in a view.
|
|
8
|
+
module Controls
|
|
9
|
+
# What a menu wears: the control's size, so a menu and a box beside it line up.
|
|
10
|
+
MENU = 'form-select form-select-lg'
|
|
11
|
+
|
|
12
|
+
# What a box or a circle that is ticked wears.
|
|
13
|
+
CHECK = 'form-check-input'
|
|
14
|
+
|
|
15
|
+
# An email address.
|
|
16
|
+
def email_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
17
|
+
|
|
18
|
+
# A number, which brings the keyboard for one.
|
|
19
|
+
def number_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
20
|
+
|
|
21
|
+
# A password, which no name of a column earns on its own.
|
|
22
|
+
def password_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
23
|
+
|
|
24
|
+
# A term to search by.
|
|
25
|
+
def search_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
26
|
+
|
|
27
|
+
# A web address.
|
|
28
|
+
def url_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
29
|
+
|
|
30
|
+
# A day.
|
|
31
|
+
def date_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
32
|
+
|
|
33
|
+
# A time of day.
|
|
34
|
+
def time_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
35
|
+
|
|
36
|
+
# A day and a time together.
|
|
37
|
+
def datetime_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
38
|
+
|
|
39
|
+
# More than a line of words.
|
|
40
|
+
def text_area(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
41
|
+
|
|
42
|
+
# A menu of what a column may be.
|
|
43
|
+
def select(method, choices = nil, options = {}, html_options = {}, &)
|
|
44
|
+
super(method, choices, options, with_classes(html_options, MENU), &)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# One of several, ticked.
|
|
48
|
+
def check_box(method, options = {}, checked = '1', unchecked = '0')
|
|
49
|
+
super(method, with_classes(options, CHECK), checked, unchecked)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One of several, and only one.
|
|
53
|
+
def radio_button(method, value, options = {})
|
|
54
|
+
super(method, value, with_classes(options, CHECK))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A box and the words beside it, which is the one place words sit after a control rather
|
|
58
|
+
# than over it -- so they are not the label a field wears, and are written here instead.
|
|
59
|
+
def check(method, text, options = {})
|
|
60
|
+
words = @template.label_tag field_id(method), text, class: 'form-check-label'
|
|
61
|
+
inside = @template.safe_join [check_box(method, options), words]
|
|
62
|
+
|
|
63
|
+
@template.tag.div inside, class: 'form-check'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require 'action_view'
|
|
2
|
+
|
|
3
|
+
module Bh
|
|
4
|
+
class FormBuilder < ActionView::Helpers::FormBuilder
|
|
5
|
+
# The field Rails has none of: the six slots a code is typed into.
|
|
6
|
+
module Pins
|
|
7
|
+
# What the one real PIN field carries: digits only, six of them, offered by the phone from
|
|
8
|
+
# the text that brought them, and drawn as slots by Bootstrap and the `otp` controller.
|
|
9
|
+
PIN = {
|
|
10
|
+
class: 'otp-input', inputmode: :numeric, pattern: '[0-9]{6}', minlength: 6, maxlength: 6,
|
|
11
|
+
autocomplete: 'one-time-code',
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# `method` as Bootstrap's OTP input: one real field rendered as six slots, the slots
|
|
15
|
+
# reddened where the record refused the code. Every option is the field's. The field is
|
|
16
|
+
# drawn by the view rather than this builder on purpose, so it is never dressed as a
|
|
17
|
+
# `form-control`: a refused one would wear a red box of its own around slots that say so.
|
|
18
|
+
def pin_field(method, **options)
|
|
19
|
+
invalid = @object.respond_to?(:errors) && @object.errors.include?(method)
|
|
20
|
+
classes = @template.class_names 'otp otp-lg', 'is-invalid': invalid
|
|
21
|
+
|
|
22
|
+
field = with_classes PIN.merge(options), PIN[:class]
|
|
23
|
+
|
|
24
|
+
@template.tag.div class: classes, data: { controller: 'otp', bs_otp: true } do
|
|
25
|
+
@template.text_field @object_name, method, **field, object: @object
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|