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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef7f3ebf21a2ea184e386c3cd4d056f6af079e1d7a719b41625fead3088a99db
|
|
4
|
+
data.tar.gz: a90c49219789528eb167365731de1af5ca25867b6ee9a9d40bfe2869a6ff330b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 825bf2c4c8f0c4188ab1457519936a69f8f51c4b382e1ae551fa7fe3cb7f02692be84d76f64fb9e08677f2957dfc13798d43ff4f5cad0c1f34f96af57866b86c
|
|
7
|
+
data.tar.gz: bbeb033f50f5120dbcc8e1b201cf31cb25f2ccde1a408adc0fcb2932538b3b7024d35831eafa3c272ab2e295959f60d0d4ce9650bfa4ec344eb4251a619b9763
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,44 @@ For more information about changelogs, check
|
|
|
6
6
|
[Keep a Changelog](http://keepachangelog.com) and
|
|
7
7
|
[Vandamme](http://tech-angels.github.io/vandamme).
|
|
8
8
|
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## 6.2.0 - 2026-09-18
|
|
12
|
+
|
|
13
|
+
A breaking change that keeps its major on purpose. 6.0 through 6.1.4 were an alpha in
|
|
14
|
+
everything but the number — Bootstrap 3 wrappers with a Bootstrap 6 stopgap bolted on,
|
|
15
|
+
cut while Bootstrap 6 was itself pre-release — so there is no settled API here to have
|
|
16
|
+
broken, and nothing is owed the major a real break would earn. Those numbers are taken
|
|
17
|
+
and cannot be withdrawn, RubyGems keeping anything published over thirty days ago, so
|
|
18
|
+
6.2.0 steps over them and lands above 6.1.4, where `bundle update` reaches it.
|
|
19
|
+
|
|
20
|
+
Until this line settles it breaks on a minor: pin `~> 6.2.0`, never `~> 6`.
|
|
21
|
+
|
|
22
|
+
* [BREAKING CHANGE] Everything. Bh is now Bootstrap 6 components rather than Bootstrap 3
|
|
23
|
+
wrappers, and no helper of 1.x or 6.1.x survives: `head_tags`, `script_tags`, `navbar`,
|
|
24
|
+
`navbar_brand`, `navbar_nav`, `navbar_toggler`, `navbar_collapse`, `navbar_collapsable`,
|
|
25
|
+
`card`, `nav`, `nav_link_to`, `nav_link_options_for`, `table`, `column`, `grid`, `grid_row`,
|
|
26
|
+
`grid_column`, `turbo_link_to` and `edit_link_to` are all gone, along with the `bh/_table`
|
|
27
|
+
and `bh/_grid` partials and the loose `phone`, `require`, `submit` and `bh--theme`
|
|
28
|
+
controllers.
|
|
29
|
+
* [FEATURE] `Bh::FormBuilder`, which dresses every field Rails draws and adds the two it has
|
|
30
|
+
none of: `phone_field`, shaped as it is typed, and `pin_field`, the six slots a 6-digit code
|
|
31
|
+
is typed into.
|
|
32
|
+
* [FEATURE] `combobox`, a `<select>` drawn as a searchable menu that stays the one thing the
|
|
33
|
+
form submits.
|
|
34
|
+
* [FEATURE] `dialog`, `notices`, `chat_with` and `flow`.
|
|
35
|
+
* [FEATURE] `bh_head_tags`, and an engine that serves `/bh/css/bh.css` and `/bh/js/bh.js` — one
|
|
36
|
+
stylesheet and one script, Bootstrap 6 included, built by esbuild and shipped in the gem.
|
|
37
|
+
* [FEATURE] Twenty-two Stimulus controllers, registered by the bundle, and a Bootstrap dialog
|
|
38
|
+
standing in for the browser's `confirm()`.
|
|
39
|
+
* [FEATURE] A dummy app under `test/dummy` drawing every helper on one page, which `rails s`
|
|
40
|
+
from the root of a clone runs.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## 6.1.5 - 2026-06-29
|
|
44
|
+
|
|
45
|
+
* [FEATURE] Point to main boostrap v6 CSS
|
|
46
|
+
|
|
9
47
|
## 6.1.4 - 2026-06-24
|
|
10
48
|
|
|
11
49
|
* [FEATURE] Include Boostrap Icons CSS
|
data/README.md
CHANGED
|
@@ -1,25 +1,97 @@
|
|
|
1
|
-
Bh · Bootstrap Helpers
|
|
2
|
-
======================
|
|
1
|
+
# Bh · Bootstrap Helpers
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
[Bootstrap 6 components](https://deploy-preview-42067--twbs-bootstrap.netlify.app/) in HTML views.
|
|
3
|
+
**Bootstrap 6 in a Rails app, with the markup already written.**
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
even for simple components.
|
|
5
|
+
[](https://claudiob.github.io/bh/)
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
<p align='center'>
|
|
8
|
+
<a href='https://claudiob.github.io/bh/'><strong>Every component, drawn and explained →</strong></a>
|
|
9
|
+
</p>
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
Bootstrap is a fine framework that asks for a lot of HTML. Bh answers with a form builder that
|
|
12
|
+
dresses every field a page can ask for, helpers for the components Bootstrap ships behavior for
|
|
13
|
+
and no markup — a combobox, a 6-digit code, a dialog, a toast, a thread of messages — and the
|
|
14
|
+
one stylesheet and one script that carry them, served by the engine.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
## How to install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install bh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem 'bh', '~> 6.2.0'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`~> 6.2.0` stops short of `6.3`, and that is the pin to hold: **until this line settles it
|
|
28
|
+
breaks on a minor, not on a major.** Rails 8.1 and Ruby 3.2 are the minimum.
|
|
29
|
+
|
|
30
|
+
6.2.0 breaks everything before it and keeps its major anyway. 6.0 through 6.1.4 were an alpha
|
|
31
|
+
in everything but the number — Bootstrap 3 wrappers with a Bootstrap 6 stopgap bolted on, cut
|
|
32
|
+
while Bootstrap 6 was itself pre-release — so there was no settled API to break. Those numbers
|
|
33
|
+
are taken and cannot be withdrawn, RubyGems keeping anything published over thirty days ago, so
|
|
34
|
+
this release steps over them.
|
|
35
|
+
|
|
36
|
+
With the gem in the bundle an app serves `/bh/css/bh.css` and `/bh/js/bh.js` itself. One helper
|
|
37
|
+
puts both in the head, along with everything Bootstrap and Turbo read:
|
|
38
|
+
|
|
39
|
+
```erb
|
|
40
|
+
<%= bh_head_tags %>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What you get
|
|
17
44
|
|
|
18
45
|
```ruby
|
|
19
|
-
|
|
46
|
+
class ApplicationController < ActionController::Base
|
|
47
|
+
default_form_builder Bh::FormBuilder
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| | |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `fieldset` `label` `submit` `button` | the form's furniture, with the pill Bootstrap draws |
|
|
54
|
+
| every `*_field`, `text_area`, `select`, `check` | dressed at one size, so a view says which kind and nothing about how it looks |
|
|
55
|
+
| `phone_field` | a North American number, shaped `555-555-5555` as it is typed |
|
|
56
|
+
| `pin_field` | one real field drawn as six slots, offered by the phone from the text that brought it |
|
|
57
|
+
| `combobox` | a `<select>` drawn as a searchable menu, still the one thing the form submits |
|
|
58
|
+
| `dialog` | a link and the `<dialog>` it opens, sharing an id made from the link's own words |
|
|
59
|
+
| `notices` | the flash as toasts, held while they are read |
|
|
60
|
+
| `chat_with` | a thread of bubbles, and the field that posts the next one |
|
|
61
|
+
| `flow` | the page of a signup flow: a header, a title, one card, a footer |
|
|
62
|
+
| 22 Stimulus controllers | registered by the bundle, called by `data-controller` |
|
|
63
|
+
|
|
64
|
+
The page above draws every one of them beside the line of Ruby that produces it.
|
|
65
|
+
|
|
66
|
+
## Trying it out
|
|
67
|
+
|
|
68
|
+
The gem carries a dummy app drawing every helper on one page, and a `bin/rails` at the root, so
|
|
69
|
+
there is no `cd` first. From the root of a clone:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bin/setup
|
|
73
|
+
rails s
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`localhost:3000` is every component at once, `?flash=1` adds the toasts, and `/flow` is the
|
|
77
|
+
signup page.
|
|
78
|
+
|
|
79
|
+
## From npm
|
|
80
|
+
|
|
81
|
+
For a host that bundles the sources itself rather than serving the built tree. The package is
|
|
82
|
+
`bh6`, since `bh` on npm is somebody else's.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install bh6
|
|
20
86
|
```
|
|
21
87
|
|
|
22
|
-
|
|
23
|
-
|
|
88
|
+
Bootstrap 6 is not on npm at all, so its bundle ships inside the package and a bundler has to
|
|
89
|
+
send the bare `bootstrap` specifier there — `alias: { bootstrap: 'bh6/bootstrap' }`.
|
|
90
|
+
|
|
91
|
+
## Elsewhere
|
|
92
|
+
|
|
93
|
+
- [The page](https://claudiob.github.io/bh/) — every component, and how to reach it
|
|
94
|
+
- [API reference](https://rubydoc.info/gems/bh) — built from what RubyGems holds
|
|
95
|
+
- [CHANGELOG](CHANGELOG.md) — what each release is, and which of the three it is
|
|
24
96
|
|
|
25
|
-
|
|
97
|
+
MIT licensed. Drawn for [houseaccount](https://houseaccount.com/) by the Earl of Bubblehum.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { flash } from './flash.js'
|
|
3
|
+
|
|
4
|
+
// The square that keeps a row, answered before the server does. The icon flips under
|
|
5
|
+
// the cursor and the request goes in the background, so the table is never redrawn and
|
|
6
|
+
// the row stays where the eye left it — until the next load, where kept-first belongs.
|
|
7
|
+
//
|
|
8
|
+
// The form is still a real one. Without this controller it submits, redirects and
|
|
9
|
+
// reloads, which is the same floor every other button here degrades to.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static values = { kept: Boolean, error: String }
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this.form = this.element.closest('form')
|
|
15
|
+
this.row = this.element.closest('tr')
|
|
16
|
+
this.icon = this.element.querySelector('i')
|
|
17
|
+
this.form.addEventListener('submit', this.submit)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.form.removeEventListener('submit', this.submit)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// An arrow so `this` survives being handed to the listener, and so the same
|
|
25
|
+
// function object is the one removed again.
|
|
26
|
+
submit = (event) => {
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
const kept = !this.keptValue
|
|
29
|
+
// Read the form before flipping it. The verb it is still wearing is the one this
|
|
30
|
+
// click means — `post` to keep the row, `delete` to drop it — while `render`
|
|
31
|
+
// dresses it for the click after this one, which is the opposite.
|
|
32
|
+
const body = new FormData(this.form)
|
|
33
|
+
this.render(kept)
|
|
34
|
+
this.send(body, kept)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// What the eye reads, what a screen reader reads, and what the next click will do:
|
|
38
|
+
// the path never changes, only the verb Rails wrote into the form.
|
|
39
|
+
render(kept) {
|
|
40
|
+
this.keptValue = kept
|
|
41
|
+
this.icon.className = kept ? 'bi bi-bookmark-fill' : 'bi bi-bookmark'
|
|
42
|
+
this.element.setAttribute('aria-pressed', kept)
|
|
43
|
+
this.method.value = kept ? 'delete' : 'post'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The token in the head, not the one in the form. Rails scopes a form's own token
|
|
47
|
+
// to the method it was drawn with and this square flips that method; the form's is
|
|
48
|
+
// inside a cached fragment besides, so it belongs to whichever session drew the
|
|
49
|
+
// table. The head's is global to the session and fresh per request, and Rails takes
|
|
50
|
+
// whichever of the two is valid.
|
|
51
|
+
get token() {
|
|
52
|
+
return document.querySelector('meta[name="csrf-token"]')?.content
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Rails' own override field, which `button_to` writes only for a delete — so a
|
|
56
|
+
// square that started hollow has none until the first click makes one.
|
|
57
|
+
get method() {
|
|
58
|
+
let field = this.form.querySelector('input[name="_method"]')
|
|
59
|
+
if (!field) {
|
|
60
|
+
field = document.createElement('input')
|
|
61
|
+
field.type = 'hidden'
|
|
62
|
+
field.name = '_method'
|
|
63
|
+
this.form.prepend(field)
|
|
64
|
+
}
|
|
65
|
+
return field
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The response is never rendered, but it is read: a 500, a dropped connection or
|
|
69
|
+
// an expired session would otherwise leave a filled square that was never saved.
|
|
70
|
+
// `Accept` is what tells the server this one wants no flash and no redirect.
|
|
71
|
+
async send(body, kept) {
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(this.form.action, {
|
|
74
|
+
method: 'post',
|
|
75
|
+
body,
|
|
76
|
+
headers: { Accept: 'application/json', 'X-CSRF-Token': this.token },
|
|
77
|
+
})
|
|
78
|
+
if (!response.ok) return this.revert(kept)
|
|
79
|
+
|
|
80
|
+
// The report, and the reason there is no toast: the row takes or loses its tint,
|
|
81
|
+
// which says which rows are kept. The icon flipped on the click and would have
|
|
82
|
+
// flipped under a request that never landed, so this is the half only the server
|
|
83
|
+
// can give.
|
|
84
|
+
this.row?.classList.toggle('recourse-kept', kept)
|
|
85
|
+
} catch {
|
|
86
|
+
this.revert(kept)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Put the square back, and say why — the one time this column speaks, since a click
|
|
91
|
+
// that worked is reported by the row taking color. Nothing to put back but the
|
|
92
|
+
// square: the tint is never laid on until the row is written.
|
|
93
|
+
revert(kept) {
|
|
94
|
+
this.render(!kept)
|
|
95
|
+
flash(this.errorValue)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ['input', 'button']
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.toggle()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
toggle() {
|
|
11
|
+
this.buttonTarget.classList.toggle('d-none', !this.inputTarget.value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
clear() {
|
|
15
|
+
this.inputTarget.value = ''
|
|
16
|
+
// The combobox filters its menu on `input`, so it has to hear one to put every
|
|
17
|
+
// row back. Bootstrap listens on the field itself, and the event bubbles anyway.
|
|
18
|
+
this.inputTarget.dispatchEvent(new Event('input', { bubbles: true }))
|
|
19
|
+
this.inputTarget.focus()
|
|
20
|
+
this.toggle()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The markup Bootstrap's combobox wants, built from a `<select>`: the toggle and the menu,
|
|
2
|
+
// as the two nodes to put after it. Every word on them comes from the select or its
|
|
3
|
+
// controller's values, so nothing here is in any one language.
|
|
4
|
+
|
|
5
|
+
// The toggle takes the select's size and its error, and is labelled the way the select was.
|
|
6
|
+
export function menuFor(select, controller) {
|
|
7
|
+
const toggle = element('button', 'form-control combobox-toggle', { type: 'button' })
|
|
8
|
+
if (select.classList.contains('form-select-sm')) { toggle.classList.add('form-control-sm') }
|
|
9
|
+
if (select.classList.contains('is-invalid')) { toggle.classList.add('is-invalid') }
|
|
10
|
+
toggle.dataset.bsToggle = 'combobox'
|
|
11
|
+
toggle.dataset.bsSearch = 'true'
|
|
12
|
+
toggle.dataset.bsPlaceholder = controller.placeholderValue
|
|
13
|
+
if (select.multiple) { toggle.dataset.bsMultiple = 'true' }
|
|
14
|
+
if (select.required) { toggle.setAttribute('aria-required', 'true') }
|
|
15
|
+
labelled(toggle, select)
|
|
16
|
+
toggle.append(element('span', 'combobox-value', {}, controller.placeholderValue),
|
|
17
|
+
element('i', 'bi bi-chevron-down combobox-caret'))
|
|
18
|
+
|
|
19
|
+
return [toggle, menu(select, controller)]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The label that pointed at the select points at what a reader now clicks instead.
|
|
23
|
+
function labelled(toggle, select) {
|
|
24
|
+
const label = select.id && document.querySelector(`label[for='${select.id}']`)
|
|
25
|
+
if (label) { toggle.setAttribute('aria-labelledby', label.id ||= `${select.id}-label`) }
|
|
26
|
+
for (const name of ['aria-label', 'aria-describedby']) {
|
|
27
|
+
if (select.hasAttribute(name)) { toggle.setAttribute(name, select.getAttribute(name)) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function menu(select, controller) {
|
|
32
|
+
const menu = element('div', 'menu')
|
|
33
|
+
menu.append(search(select))
|
|
34
|
+
if (controller.hasAllValue && select.multiple) { menu.append(all(select, controller.allValue), element('div', 'menu-divider')) }
|
|
35
|
+
for (const option of select.options) { menu.append(item(option, select.multiple)) }
|
|
36
|
+
menu.append(element('div', 'combobox-no-results d-none', {}, select.dataset.noResults || ''))
|
|
37
|
+
|
|
38
|
+
return menu
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The box that narrows a long menu, with the X that empties it inside the field.
|
|
42
|
+
function search(select) {
|
|
43
|
+
const box = element('div', 'combobox-search')
|
|
44
|
+
box.dataset.controller = 'clear'
|
|
45
|
+
const input = element('input', 'form-control combobox-search-input', {
|
|
46
|
+
type: 'text', autocomplete: 'off', placeholder: select.dataset.search || '', 'aria-label': select.dataset.search || '',
|
|
47
|
+
})
|
|
48
|
+
input.dataset.clearTarget = 'input'
|
|
49
|
+
input.dataset.action = 'input->clear#toggle'
|
|
50
|
+
const clear = element('button', 'combobox-search-clear d-none', { type: 'button', 'aria-label': select.dataset.clear || '' })
|
|
51
|
+
clear.dataset.clearTarget = 'button'
|
|
52
|
+
clear.dataset.action = 'clear#clear'
|
|
53
|
+
clear.append(element('i', 'bi bi-x-lg'))
|
|
54
|
+
box.append(input, clear)
|
|
55
|
+
|
|
56
|
+
return box
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// `All`: no `data-bs-value`, so the plugin passes it by and the deselect controller has it.
|
|
60
|
+
function all(select, words) {
|
|
61
|
+
const row = element('button', 'menu-item', { type: 'button' }, words)
|
|
62
|
+
row.dataset.controller = 'deselect'
|
|
63
|
+
row.dataset.action = 'deselect#all'
|
|
64
|
+
row.dataset.deselectMultipleValue = String(select.multiple)
|
|
65
|
+
|
|
66
|
+
return row
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One option as one row: its words, a figure beside them where it has one, a check where
|
|
70
|
+
// the menu is multiple, and `d-none` where it waits for `All`. An empty option is the way
|
|
71
|
+
// back to nothing, and takes the plugin's own empty value.
|
|
72
|
+
function item(option, multiple) {
|
|
73
|
+
const row = element('button', 'menu-item', { type: 'button', 'aria-selected': String(option.selected) })
|
|
74
|
+
row.dataset.bsValue = option.value
|
|
75
|
+
if (option.selected) { row.classList.add('selected') }
|
|
76
|
+
if ('hidden' in option.dataset && !option.selected) { row.classList.add('d-none') }
|
|
77
|
+
const content = element('span', 'menu-item-content')
|
|
78
|
+
content.append(element('span', '', {}, option.textContent))
|
|
79
|
+
row.append(content)
|
|
80
|
+
if (option.dataset.count) { row.append(element('span', 'combobox-count fg-2', {}, option.dataset.count)) }
|
|
81
|
+
if (multiple) { row.append(element('i', 'bi bi-check menu-item-check')) }
|
|
82
|
+
|
|
83
|
+
return row
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function element(tag, classes, attributes = {}, text = null) {
|
|
87
|
+
const node = document.createElement(tag)
|
|
88
|
+
if (classes) { node.className = classes }
|
|
89
|
+
for (const [name, value] of Object.entries(attributes)) { node.setAttribute(name, value) }
|
|
90
|
+
if (text !== null) { node.textContent = text }
|
|
91
|
+
|
|
92
|
+
return node
|
|
93
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { Combobox } from 'bootstrap'
|
|
3
|
+
import { menuFor } from './combobox/menu.js'
|
|
4
|
+
|
|
5
|
+
// A `<select data-controller='combobox'>` becomes Bootstrap's combobox: a toggle that reads
|
|
6
|
+
// the picked option, a menu with a search box, and a check per pick where the select is
|
|
7
|
+
// `multiple`. The select stays, hidden, as the one thing the form submits — the plugin is
|
|
8
|
+
// told no `name`, so it writes no hidden input of its own — and every pick in the menu is
|
|
9
|
+
// written back to it and announced as the select's own `change`. A page without this
|
|
10
|
+
// script has a working select; a page with it has the same select dressed.
|
|
11
|
+
//
|
|
12
|
+
// A multiple menu reads as `California + 1 more` rather than the plugin's `2 selected`,
|
|
13
|
+
// in the words `data-combobox-more-value` gives it. The select's own `data-` attributes
|
|
14
|
+
// say the rest: a placeholder for the toggle, an `All` row that puts held-back options on
|
|
15
|
+
// the menu, and on an option `data-count` for a figure beside it and `data-hidden` to hold
|
|
16
|
+
// it back until `All` asks.
|
|
17
|
+
export default class extends Controller {
|
|
18
|
+
static values = { more: String, placeholder: String, all: String }
|
|
19
|
+
|
|
20
|
+
connect() {
|
|
21
|
+
this.#dress()
|
|
22
|
+
this.morphed = () => this.#remake()
|
|
23
|
+
document.addEventListener('turbo:morph', this.morphed)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
disconnect() {
|
|
27
|
+
document.removeEventListener('turbo:morph', this.morphed)
|
|
28
|
+
this.#undress()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#dress() {
|
|
32
|
+
this.widget = menuFor(this.element, this)
|
|
33
|
+
this.element.after(...this.widget)
|
|
34
|
+
this.element.hidden = true
|
|
35
|
+
this.combobox = Combobox.getOrCreateInstance(this.toggle)
|
|
36
|
+
this.#name()
|
|
37
|
+
this.picked = () => this.#pick()
|
|
38
|
+
this.toggle.addEventListener('change.bs.combobox', this.picked)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#undress() {
|
|
42
|
+
this.toggle.removeEventListener('change.bs.combobox', this.picked)
|
|
43
|
+
this.combobox.dispose()
|
|
44
|
+
this.widget.forEach((node) => node.remove())
|
|
45
|
+
this.element.hidden = false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get toggle() { return this.widget[0] }
|
|
49
|
+
|
|
50
|
+
get menu() { return this.widget[1] }
|
|
51
|
+
|
|
52
|
+
// Remade whole rather than repaired: a morph rewrote the select's options and what is
|
|
53
|
+
// selected among them, and a widget built from it again is the one way to be sure the
|
|
54
|
+
// toggle's text, the menu's ticks and the plugin's state all say the same thing.
|
|
55
|
+
#remake() {
|
|
56
|
+
if (!this.element.isConnected) { return }
|
|
57
|
+
|
|
58
|
+
this.#undress()
|
|
59
|
+
this.#dress()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What the menu says, written onto the select, and said again as the select's own event
|
|
63
|
+
// so a form listening for a change hears one — the plugin's event never leaves the toggle.
|
|
64
|
+
#pick() {
|
|
65
|
+
const picked = new Set([...this.menu.querySelectorAll('.menu-item.selected')].map((item) => item.dataset.bsValue))
|
|
66
|
+
for (const option of this.element.options) { option.selected = picked.has(option.value) }
|
|
67
|
+
this.#name()
|
|
68
|
+
this.element.dispatchEvent(new Event('change', { bubbles: true }))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `California + 1 more` over the plugin's `2 selected`. Only a multiple menu with more
|
|
72
|
+
// than one pick: with one the plugin names it, and with none it shows the placeholder.
|
|
73
|
+
#name() {
|
|
74
|
+
const picked = this.menu.querySelectorAll('.menu-item.selected')
|
|
75
|
+
if (!this.element.multiple || picked.length < 2) { return }
|
|
76
|
+
|
|
77
|
+
const first = picked[0].querySelector('.menu-item-content > span:first-child')
|
|
78
|
+
this.toggle.querySelector('.combobox-value').textContent = this.moreValue
|
|
79
|
+
.replace('%{first}', first.textContent)
|
|
80
|
+
.replace('%{count}', picked.length - 1)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Dialog } from 'bootstrap'
|
|
2
|
+
|
|
3
|
+
// Turbo hands over the whole warning as one string, and the element and the button that
|
|
4
|
+
// asked. The first line is the question and each remaining line a paragraph — real
|
|
5
|
+
// paragraphs here, where a confirm() box had newlines. Always textContent, never
|
|
6
|
+
// innerHTML: the title carries a record's own name, and a name is data. The dialog is
|
|
7
|
+
// built the first time a page asks, so any page loading this bundle has one.
|
|
8
|
+
export default function confirm(message, element, submitter) {
|
|
9
|
+
const dialog = dialogFor()
|
|
10
|
+
const [title, ...lines] = message.split('\n')
|
|
11
|
+
const body = dialog.querySelector('.dialog-body')
|
|
12
|
+
dialog.querySelector('.dialog-title').textContent = title
|
|
13
|
+
body.replaceChildren(...paragraphs(lines))
|
|
14
|
+
body.hidden = !body.children.length
|
|
15
|
+
const answer = dialog.querySelector('.bh-confirm-answer')
|
|
16
|
+
answer.textContent = wordsOn(submitter, element) || 'OK'
|
|
17
|
+
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
// `onclick` rather than addEventListener: reassigning replaces the previous
|
|
20
|
+
// answer's handler, so asking twice on one page never wires the button twice.
|
|
21
|
+
answer.onclick = () => {
|
|
22
|
+
resolve(true)
|
|
23
|
+
Dialog.getOrCreateInstance(dialog).hide()
|
|
24
|
+
}
|
|
25
|
+
// Cancel, Esc and a click on the backdrop all close through here. After an
|
|
26
|
+
// answer the promise is settled, and settling it again is a no-op.
|
|
27
|
+
dialog.addEventListener('hidden.bs.dialog', () => resolve(false), { once: true })
|
|
28
|
+
Dialog.getOrCreateInstance(dialog).show()
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The answer is the very words of the button that asked — `Delete place` — so the dialog
|
|
33
|
+
// never has to know what the page is about, nor in which language. A link with a method
|
|
34
|
+
// arrives as the hidden form Turbo built for it, with no submitter and nothing to read.
|
|
35
|
+
function wordsOn(submitter, element) {
|
|
36
|
+
const form = element?.tagName === 'FORM'
|
|
37
|
+
const source = submitter || (form ? element.querySelector('[type=submit]') || asked : element)
|
|
38
|
+
|
|
39
|
+
return source?.textContent?.trim() || source?.value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The element a warning was asked from, remembered on the way down so it is there a frame
|
|
43
|
+
// later when Turbo submits. Focus cannot answer this: Safari does not focus a link it
|
|
44
|
+
// follows, so `document.activeElement` is `<body>` on an iPhone — and `<body>` reads back
|
|
45
|
+
// the text of the whole page, which the answer button then wore.
|
|
46
|
+
let asked = null
|
|
47
|
+
|
|
48
|
+
document.addEventListener('click', ({ target }) => {
|
|
49
|
+
asked = target.closest?.('[data-turbo-confirm]') || null
|
|
50
|
+
}, true)
|
|
51
|
+
|
|
52
|
+
function paragraphs(lines) {
|
|
53
|
+
return lines.filter(line => line).map(line => {
|
|
54
|
+
const paragraph = document.createElement('p')
|
|
55
|
+
paragraph.textContent = line
|
|
56
|
+
return paragraph
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The one dialog every warning on a page speaks through, made on the first ask. Cancel
|
|
61
|
+
// keeps the focus on the safe answer: `showModal` gives it to `autofocus` first.
|
|
62
|
+
// `dialog-slide-down` is the animation, shipped by Bootstrap 6.
|
|
63
|
+
function dialogFor() {
|
|
64
|
+
let dialog = document.querySelector('#bh-confirm')
|
|
65
|
+
if (dialog) { return dialog }
|
|
66
|
+
|
|
67
|
+
dialog = document.createElement('dialog')
|
|
68
|
+
dialog.className = 'dialog dialog-slide-down'
|
|
69
|
+
dialog.id = 'bh-confirm'
|
|
70
|
+
dialog.setAttribute('aria-labelledby', 'bh-confirm-title')
|
|
71
|
+
dialog.innerHTML = `
|
|
72
|
+
<div class='dialog-header'><h1 class='dialog-title' id='bh-confirm-title'></h1></div>
|
|
73
|
+
<div class='dialog-body'></div>
|
|
74
|
+
<div class='dialog-footer'>
|
|
75
|
+
<button type='button' class='btn btn-solid theme-secondary' data-bs-dismiss='dialog' autofocus>${cancel()}</button>
|
|
76
|
+
<button type='button' class='btn btn-solid theme-danger bh-confirm-answer'></button>
|
|
77
|
+
</div>`
|
|
78
|
+
document.body.append(dialog)
|
|
79
|
+
|
|
80
|
+
return dialog
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The one word the page cannot supply, in the page's own language where a host has said
|
|
84
|
+
// it in a `<meta name='bh-cancel'>`, and English otherwise.
|
|
85
|
+
function cancel() {
|
|
86
|
+
return document.querySelector('meta[name="bh-cancel"]')?.content || 'Cancel'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Three things outlive a Turbo visit that starts mid-close: the snapshot, which would
|
|
90
|
+
// restore an open dialog; `dialog-open` on <html>, which is the scroll lock; and
|
|
91
|
+
// `hiding` on the dialog, the class its closing animation runs under. dispose() closes
|
|
92
|
+
// instantly and lifts the lock, but cuts the animation short of the end that would
|
|
93
|
+
// have taken `hiding` off — and a dialog still wearing it opens invisible the next
|
|
94
|
+
// time it is asked, which on a table of Remove buttons is the very next click. In the
|
|
95
|
+
// module, so it registers once.
|
|
96
|
+
document.addEventListener('turbo:before-cache', () => {
|
|
97
|
+
const dialog = document.querySelector('#bh-confirm')
|
|
98
|
+
if (dialog?.open) Dialog.getOrCreateInstance(dialog).dispose()
|
|
99
|
+
dialog?.classList.remove('hiding')
|
|
100
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// The arrows at the foot of the sidebar, drawn on a phone alone: a tap puts the words
|
|
4
|
+
// back beside every icon the chrome shows — the sidebar's entries, the crumbs, the tabs,
|
|
5
|
+
// the foot's own controls — and the next tap takes them away again. The choice goes to
|
|
6
|
+
// the server in a cookie, the way the zone does, and the page is loaded again outright
|
|
7
|
+
// rather than reshaped in place: Safari left the row of entries where it was when the
|
|
8
|
+
// words came out of hiding, one link over the next, until a reload laid it out afresh.
|
|
9
|
+
// A Turbo visit to the same address is no better — an index carries the metas that make
|
|
10
|
+
// a refresh morph, so the visit reshaped the body it had rather than drawing a new one.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = { storage: String }
|
|
13
|
+
|
|
14
|
+
toggle() {
|
|
15
|
+
const expanded = document.body.classList.contains('recourse-expanded')
|
|
16
|
+
const density = expanded ? 'compact' : 'expanded'
|
|
17
|
+
|
|
18
|
+
document.cookie = `${this.storageValue}=${density}; path=/; max-age=31536000; samesite=lax`
|
|
19
|
+
window.location.reload()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { multiple: Boolean }
|
|
5
|
+
|
|
6
|
+
// `All` asks for the options the menu is holding back — they are in it already, and
|
|
7
|
+
// this is what puts them on it.
|
|
8
|
+
all(event) {
|
|
9
|
+
// A menu that sets a value is configured to close on any click inside it, this
|
|
10
|
+
// button included — which would shut it over the options it was clicked to see. The
|
|
11
|
+
// listener doing that is on the document, so stopping the click here is what keeps
|
|
12
|
+
// them in view. A menu that narrows a table closes on outside clicks only, and is
|
|
13
|
+
// unaffected either way.
|
|
14
|
+
event.stopPropagation()
|
|
15
|
+
|
|
16
|
+
const menu = this.element.closest('.menu')
|
|
17
|
+
|
|
18
|
+
for (const waiting of menu.querySelectorAll('.menu-item.d-none')) {
|
|
19
|
+
waiting.classList.remove('d-none')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// And, on a menu that narrows a table, it means every row as well as every option,
|
|
23
|
+
// which is what nothing being ticked says. Clicking each chosen item is what the
|
|
24
|
+
// plugin is already listening for, so the hidden input, the toggle's text and the
|
|
25
|
+
// events stay its business rather than ours — it has no method for this, and
|
|
26
|
+
// reaching into its state would be guessing.
|
|
27
|
+
//
|
|
28
|
+
// Only there. A menu that sets a value cannot mean none of them, and a click on the
|
|
29
|
+
// one already chosen is the plugin being told to choose it again — which closes the
|
|
30
|
+
// menu over the options this button was clicked to see.
|
|
31
|
+
if (!this.multipleValue) return
|
|
32
|
+
|
|
33
|
+
for (const item of menu.querySelectorAll('.menu-item.selected')) {
|
|
34
|
+
item.click()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The toast the server ships, built in the browser for the one message that has no
|
|
2
|
+
// response to arrive with. `data-controller` is what hands it to the toast
|
|
3
|
+
// controller for its timer and its X, so the two kinds fade alike.
|
|
4
|
+
export function flash(message, theme = 'theme-danger') {
|
|
5
|
+
const toast = document.createElement('div')
|
|
6
|
+
toast.className = `toast fade show ${theme}`
|
|
7
|
+
toast.setAttribute('role', 'alert')
|
|
8
|
+
toast.setAttribute('aria-live', 'assertive')
|
|
9
|
+
toast.dataset.controller = 'toast'
|
|
10
|
+
toast.dataset.action = ['mouseenter->toast#stopTimer', 'mouseleave->toast#startTimer',
|
|
11
|
+
'focusin->toast#stopTimer', 'focusout->toast#startTimer'].join(' ')
|
|
12
|
+
const header = document.createElement('div')
|
|
13
|
+
header.className = 'toast-header border-0'
|
|
14
|
+
const text = document.createElement('span')
|
|
15
|
+
text.className = 'me-auto'
|
|
16
|
+
// Never innerHTML: this is a message, and a message is data.
|
|
17
|
+
text.textContent = message
|
|
18
|
+
header.append(text)
|
|
19
|
+
toast.append(header)
|
|
20
|
+
container().append(toast)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A page with nothing to say ships no container at all, so the first message is what
|
|
24
|
+
// makes one.
|
|
25
|
+
function container() {
|
|
26
|
+
let container = document.querySelector('.toast-container')
|
|
27
|
+
if (container) return container
|
|
28
|
+
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
container.className = 'toast-container position-fixed bottom-0 end-0 p-3'
|
|
31
|
+
container.dataset.turboTemporary = ''
|
|
32
|
+
document.body.append(container)
|
|
33
|
+
|
|
34
|
+
return container
|
|
35
|
+
}
|