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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +87 -15
  4. data/app/javascript/bh/bookmark_controller.js +97 -0
  5. data/app/javascript/bh/clear_controller.js +22 -0
  6. data/app/javascript/bh/combobox/menu.js +93 -0
  7. data/app/javascript/bh/combobox_controller.js +82 -0
  8. data/app/javascript/bh/confirm.js +100 -0
  9. data/app/javascript/bh/density_controller.js +21 -0
  10. data/app/javascript/bh/deselect_controller.js +37 -0
  11. data/app/javascript/bh/flash.js +35 -0
  12. data/app/javascript/bh/limit_controller.js +23 -0
  13. data/app/javascript/bh/map/loader.js +20 -0
  14. data/app/javascript/bh/map_controller.js +88 -0
  15. data/app/javascript/bh/otp/slots.js +27 -0
  16. data/app/javascript/bh/otp_controller.js +98 -0
  17. data/app/javascript/bh/phone_controller.js +89 -0
  18. data/app/javascript/bh/placeholder_controller.js +28 -0
  19. data/app/javascript/bh/relative_time_controller.js +45 -0
  20. data/app/javascript/bh/require_controller.js +18 -0
  21. data/app/javascript/bh/reveal_controller.js +16 -0
  22. data/app/javascript/bh/scheme_controller.js +99 -0
  23. data/app/javascript/bh/search_controller.js +78 -0
  24. data/app/javascript/bh/shortcuts_controller.js +42 -0
  25. data/app/javascript/bh/sortable_controller.js +84 -0
  26. data/app/javascript/bh/thread_controller.js +47 -0
  27. data/app/javascript/bh/timezone_controller.js +27 -0
  28. data/app/javascript/bh/toast_controller.js +35 -0
  29. data/app/javascript/bh/tooltip_controller.js +23 -0
  30. data/app/javascript/bh/written.js +20 -0
  31. data/app/javascript/bh/written_controller.js +28 -0
  32. data/app/javascript/bh.js +60 -0
  33. data/app/stylesheets/bh/base.css +27 -0
  34. data/app/stylesheets/bh/chat.css +77 -0
  35. data/app/stylesheets/bh/flow.css +94 -0
  36. data/app/stylesheets/bh/map.css +3 -0
  37. data/app/stylesheets/bh/pin.css +34 -0
  38. data/app/stylesheets/bh/search.css +60 -0
  39. data/app/stylesheets/bh/shell.css +202 -0
  40. data/app/stylesheets/bh/table.css +130 -0
  41. data/app/stylesheets/bh/values.css +41 -0
  42. data/app/stylesheets/bh.css +13 -0
  43. data/build.mjs +15 -0
  44. data/config/locales/bh.en.yml +13 -0
  45. data/lib/bh/engine.rb +21 -6
  46. data/lib/bh/form_builder/comboboxes.rb +38 -0
  47. data/lib/bh/form_builder/controls.rb +67 -0
  48. data/lib/bh/form_builder/pins.rb +30 -0
  49. data/lib/bh/form_builder.rb +96 -0
  50. data/lib/bh/helpers/chats/asides.rb +49 -0
  51. data/lib/bh/helpers/chats/asks.rb +41 -0
  52. data/lib/bh/helpers/chats.rb +42 -0
  53. data/lib/bh/helpers/dialogs.rb +47 -0
  54. data/lib/bh/helpers/flows.rb +49 -0
  55. data/lib/bh/helpers/heads.rb +27 -0
  56. data/lib/bh/helpers/notices.rb +55 -0
  57. data/lib/bh/helpers.rb +12 -0
  58. data/lib/bh/message.rb +11 -0
  59. data/lib/bh/version.rb +2 -3
  60. data/lib/bh.rb +8 -6
  61. data/package.json +32 -0
  62. data/public/bh/css/bh.css +1 -0
  63. data/public/bh/js/bh.js +152 -0
  64. data/vendor/bootstrap.bundle.min.js +9 -0
  65. data/vendor/bootstrap.min.css +2 -0
  66. metadata +101 -23
  67. data/.gitignore +0 -23
  68. data/.rspec +0 -3
  69. data/.travis.yml +0 -15
  70. data/.yardopts +0 -3
  71. data/Gemfile +0 -4
  72. data/app/assets/stylesheets/bh.css +0 -29
  73. data/app/javascript/controllers/bh/theme_controller.js +0 -16
  74. data/app/javascript/controllers/phone_controller.js +0 -33
  75. data/app/javascript/controllers/require_controller.js +0 -20
  76. data/app/javascript/controllers/submit_controller.js +0 -24
  77. data/app/views/bh/_grid.html.erb +0 -6
  78. data/app/views/bh/_table.html.erb +0 -44
  79. data/bh.gemspec +0 -19
  80. data/lib/bh/bootstrap_helpers.rb +0 -137
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a4c459020371af21cd272fa41567285f83bbe72a5dcbd2682490216bf85efa4
4
- data.tar.gz: 7077b7c2a8c13f3e87e96e3cb2bdf33b3ea33d8c1ddf42987b7866911a168505
3
+ metadata.gz: ef7f3ebf21a2ea184e386c3cd4d056f6af079e1d7a719b41625fead3088a99db
4
+ data.tar.gz: a90c49219789528eb167365731de1af5ca25867b6ee9a9d40bfe2869a6ff330b
5
5
  SHA512:
6
- metadata.gz: 4179e6bec45030ef83c35ca311c5cb873f51436377f92611394489ba05cf29a027447101da3f2bcb52a130116d3b99253de4451c53d998844bcaaad0f1549404
7
- data.tar.gz: 1acbded0532918d0a81673d2e32716529c348c7ee18880ea5ffa2e389a0071ffd50b837ec005934d4a7d21a9e7b25ed99ccb0e52022ab8bb1d622b92602544ec
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
- A set of Ruby helpers that streamlines the use of
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
- Bootstrap 6 is a great framework, but requires many lines of HTML code
8
- even for simple components.
5
+ [![Every component Bh draws, on one page](screenshot/page.jpg)](https://claudiob.github.io/bh/)
9
6
 
10
- With Bh, you can achieve the same result with fewer lines of code
7
+ <p align='center'>
8
+ <a href='https://claudiob.github.io/bh/'><strong>Every component, drawn and explained&nbsp;→</strong></a>
9
+ </p>
11
10
 
12
- How to install
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
- 1. Add `gem 'bh'` to the `Gemfile` file of your Rails app.
16
- 2. Add this line to the `config/importmap.rb` file:
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
- pin_all_from Bh::Engine.root.join('app/javascript/controllers'), under: 'controllers'
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
- Available methods
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
- See https://github.com/claudiob/bh/blob/master/lib/bh/bootstrap_helpers.rb
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
+ }