bh 6.1.4 → 6.3.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +103 -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/app/stylesheets/theme/bootstrap.css +10 -0
  44. data/app/stylesheets/theme/dawn.css +189 -0
  45. data/app/stylesheets/theme/dracula.css +189 -0
  46. data/app/stylesheets/theme/gruvbox.css +189 -0
  47. data/app/stylesheets/theme/monokai.css +190 -0
  48. data/app/stylesheets/theme/nord.css +189 -0
  49. data/app/stylesheets/theme/one_dark.css +189 -0
  50. data/app/stylesheets/theme/solarized.css +188 -0
  51. data/app/stylesheets/theme/tokyo_night.css +189 -0
  52. data/build.mjs +21 -0
  53. data/config/locales/bh.en.yml +13 -0
  54. data/lib/bh/engine.rb +21 -6
  55. data/lib/bh/form_builder/comboboxes.rb +38 -0
  56. data/lib/bh/form_builder/controls.rb +67 -0
  57. data/lib/bh/form_builder/pins.rb +30 -0
  58. data/lib/bh/form_builder.rb +96 -0
  59. data/lib/bh/helpers/chats/asides.rb +49 -0
  60. data/lib/bh/helpers/chats/asks.rb +41 -0
  61. data/lib/bh/helpers/chats.rb +42 -0
  62. data/lib/bh/helpers/dialogs.rb +47 -0
  63. data/lib/bh/helpers/flows.rb +49 -0
  64. data/lib/bh/helpers/heads.rb +27 -0
  65. data/lib/bh/helpers/notices.rb +55 -0
  66. data/lib/bh/helpers.rb +12 -0
  67. data/lib/bh/message.rb +11 -0
  68. data/lib/bh/version.rb +2 -3
  69. data/lib/bh.rb +8 -6
  70. data/package.json +20 -0
  71. data/public/bh/css/bh.css +1 -0
  72. data/public/bh/js/bh.js +152 -0
  73. data/public/bh/theme/bootstrap.css +10 -0
  74. data/public/bh/theme/dawn.css +189 -0
  75. data/public/bh/theme/dracula.css +189 -0
  76. data/public/bh/theme/gruvbox.css +189 -0
  77. data/public/bh/theme/monokai.css +190 -0
  78. data/public/bh/theme/nord.css +189 -0
  79. data/public/bh/theme/one_dark.css +189 -0
  80. data/public/bh/theme/solarized.css +188 -0
  81. data/public/bh/theme/tokyo_night.css +189 -0
  82. data/vendor/bootstrap.bundle.min.js +9 -0
  83. data/vendor/bootstrap.min.css +2 -0
  84. metadata +119 -23
  85. data/.gitignore +0 -23
  86. data/.rspec +0 -3
  87. data/.travis.yml +0 -15
  88. data/.yardopts +0 -3
  89. data/Gemfile +0 -4
  90. data/app/assets/stylesheets/bh.css +0 -29
  91. data/app/javascript/controllers/bh/theme_controller.js +0 -16
  92. data/app/javascript/controllers/phone_controller.js +0 -33
  93. data/app/javascript/controllers/require_controller.js +0 -20
  94. data/app/javascript/controllers/submit_controller.js +0 -24
  95. data/app/views/bh/_grid.html.erb +0 -6
  96. data/app/views/bh/_table.html.erb +0 -44
  97. data/bh.gemspec +0 -19
  98. 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: 8e8820e04b1b71fbfacf44696b6fec712f77987c217dbedf5c2314867600bf42
4
+ data.tar.gz: 761251633d36f3e62dd92d9784c71d44a2306bc6c3358d24b3420f3347cebff6
5
5
  SHA512:
6
- metadata.gz: 4179e6bec45030ef83c35ca311c5cb873f51436377f92611394489ba05cf29a027447101da3f2bcb52a130116d3b99253de4451c53d998844bcaaad0f1549404
7
- data.tar.gz: 1acbded0532918d0a81673d2e32716529c348c7ee18880ea5ffa2e389a0071ffd50b837ec005934d4a7d21a9e7b25ed99ccb0e52022ab8bb1d622b92602544ec
6
+ metadata.gz: 84be37f63354fe89c52d6532cb55a5c2db53127a568c228d58acc53d7d3a0ff3cbb98cad224b7bdd76138a3a02ffcf2b3a338cacda2ec0f12427e26fd10db361
7
+ data.tar.gz: bd2d39d1d0377f5e5730f90ad6400edf736b1cc281b63e2a491c8c6cf7bf922e3822a6f1525e5f7ae775f3ff48de3f089f2c526d9a09b992f6c53a875b9d9eb7
data/CHANGELOG.md CHANGED
@@ -6,6 +6,56 @@ 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.3.0 - 2026-09-18
12
+
13
+ * [FEATURE] The nine palettes, served at `/bh/theme/<name>.css`
14
+
15
+ `bootstrap`, `dawn`, `dracula`, `gruvbox`, `monokai`, `nord`, `one_dark`, `solarized` and
16
+ `tokyo_night`, each restating all thirteen steps of every Bootstrap family it repaints. They
17
+ arrived with the `scheme` controller in 6.2.0 and the controller had nothing to swap: it
18
+ reads `${path}/${theme}.css`, and no such file shipped. Now it does.
19
+
20
+ They are copied beside the stylesheet rather than bundled into it, because a page links one
21
+ at a time and swaps it for another, which is the whole point of them.
22
+
23
+ ## 6.2.0 - 2026-09-18
24
+
25
+ A breaking change that keeps its major on purpose. 6.0 through 6.1.4 were an alpha in
26
+ everything but the number — Bootstrap 3 wrappers with a Bootstrap 6 stopgap bolted on,
27
+ cut while Bootstrap 6 was itself pre-release — so there is no settled API here to have
28
+ broken, and nothing is owed the major a real break would earn. Those numbers are taken
29
+ and cannot be withdrawn, RubyGems keeping anything published over thirty days ago, so
30
+ 6.2.0 steps over them and lands above 6.1.4, where `bundle update` reaches it.
31
+
32
+ Until this line settles it breaks on a minor: pin `~> 6.2.0`, never `~> 6`.
33
+
34
+ * [BREAKING CHANGE] Everything. Bh is now Bootstrap 6 components rather than Bootstrap 3
35
+ wrappers, and no helper of 1.x or 6.1.x survives: `head_tags`, `script_tags`, `navbar`,
36
+ `navbar_brand`, `navbar_nav`, `navbar_toggler`, `navbar_collapse`, `navbar_collapsable`,
37
+ `card`, `nav`, `nav_link_to`, `nav_link_options_for`, `table`, `column`, `grid`, `grid_row`,
38
+ `grid_column`, `turbo_link_to` and `edit_link_to` are all gone, along with the `bh/_table`
39
+ and `bh/_grid` partials and the loose `phone`, `require`, `submit` and `bh--theme`
40
+ controllers.
41
+ * [FEATURE] `Bh::FormBuilder`, which dresses every field Rails draws and adds the two it has
42
+ none of: `phone_field`, shaped as it is typed, and `pin_field`, the six slots a 6-digit code
43
+ is typed into.
44
+ * [FEATURE] `combobox`, a `<select>` drawn as a searchable menu that stays the one thing the
45
+ form submits.
46
+ * [FEATURE] `dialog`, `notices`, `chat_with` and `flow`.
47
+ * [FEATURE] `bh_head_tags`, and an engine that serves `/bh/css/bh.css` and `/bh/js/bh.js` — one
48
+ stylesheet and one script, Bootstrap 6 included, built by esbuild and shipped in the gem.
49
+ * [FEATURE] Twenty-two Stimulus controllers, registered by the bundle, and a Bootstrap dialog
50
+ standing in for the browser's `confirm()`.
51
+ * [FEATURE] A dummy app under `test/dummy` drawing every helper on one page, which `rails s`
52
+ from the root of a clone runs.
53
+
54
+
55
+ ## 6.1.5 - 2026-06-29
56
+
57
+ * [FEATURE] Point to main boostrap v6 CSS
58
+
9
59
  ## 6.1.4 - 2026-06-24
10
60
 
11
61
  * [FEATURE] Include Boostrap Icons CSS
data/README.md CHANGED
@@ -1,25 +1,113 @@
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.3.0'
25
+ ```
26
+
27
+ `~> 6.3.0` stops short of `6.4`, 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
+ ## The palettes
44
+
45
+ Nine of them, served at `/bh/theme/<name>.css` and linked one at a time, after the stylesheet:
46
+ `bootstrap`, `dawn`, `dracula`, `gruvbox`, `monokai`, `nord`, `one_dark`, `solarized`,
47
+ `tokyo_night`. Each restates all thirteen steps of every Bootstrap family it repaints, plus
48
+ `--bs-white`, `--bs-black` and the three text tones. Every accent clears 3:1 against its label
49
+ and every text tone 4:1, in both modes.
50
+
51
+ `bootstrap` declares nothing: upstream's palette comes back by dropping the others' block
52
+ rather than by writing one, and it is named so a toggle can reach it — a reader who rotates
53
+ through nine and never finds the one the pages started in has been shown a door with no handle
54
+ on the inside.
55
+
56
+ The `scheme` controller does the swapping, and the choice belongs to the reader:
57
+
58
+ ```erb
59
+ <button type='button' data-controller='scheme' data-action='scheme#rotate'
60
+ data-scheme-themes-value='["bootstrap","dawn","dracula","gruvbox","monokai","nord","one_dark","solarized","tokyo_night"]'
61
+ data-scheme-path-value='/bh/theme' data-scheme-storage-value='scheme'>
62
+ Another palette
63
+ </button>
64
+ ```
65
+
66
+ A click moves to another palette and into the mode it is not in. Put the stored choice back
67
+ before the first paint, from the layout's own script: a controller connects far too late for
68
+ that, and the page would otherwise flash the palette the server chose.
69
+
70
+ ## What you get
17
71
 
18
72
  ```ruby
19
- pin_all_from Bh::Engine.root.join('app/javascript/controllers'), under: 'controllers'
73
+ class ApplicationController < ActionController::Base
74
+ default_form_builder Bh::FormBuilder
75
+ end
20
76
  ```
21
77
 
22
- Available methods
23
- =================
78
+ | | |
79
+ | --- | --- |
80
+ | `fieldset` `label` `submit` `button` | the form's furniture, with the pill Bootstrap draws |
81
+ | every `*_field`, `text_area`, `select`, `check` | dressed at one size, so a view says which kind and nothing about how it looks |
82
+ | `phone_field` | a North American number, shaped `555-555-5555` as it is typed |
83
+ | `pin_field` | one real field drawn as six slots, offered by the phone from the text that brought it |
84
+ | `combobox` | a `<select>` drawn as a searchable menu, still the one thing the form submits |
85
+ | `dialog` | a link and the `<dialog>` it opens, sharing an id made from the link's own words |
86
+ | `notices` | the flash as toasts, held while they are read |
87
+ | `chat_with` | a thread of bubbles, and the field that posts the next one |
88
+ | `flow` | the page of a signup flow: a header, a title, one card, a footer |
89
+ | 22 Stimulus controllers | registered by the bundle, called by `data-controller` |
90
+ | 9 palettes | served at `/bh/theme/`, swapped by the `scheme` controller |
91
+
92
+ The page above draws every one of them beside the line of Ruby that produces it.
93
+
94
+ ## Trying it out
95
+
96
+ The gem carries a dummy app drawing every helper on one page, and a `bin/rails` at the root, so
97
+ there is no `cd` first. From the root of a clone:
98
+
99
+ ```bash
100
+ bin/setup
101
+ rails s
102
+ ```
103
+
104
+ `localhost:3000` is every component at once, `?flash=1` adds the toasts, and `/flow` is the
105
+ signup page.
106
+
107
+ ## Elsewhere
108
+
109
+ - [The page](https://claudiob.github.io/bh/) — every component, and how to reach it
110
+ - [API reference](https://rubydoc.info/gems/bh) — built from what RubyGems holds
111
+ - [CHANGELOG](CHANGELOG.md) — what each release is, and which of the three it is
24
112
 
25
- See https://github.com/claudiob/bh/blob/master/lib/bh/bootstrap_helpers.rb
113
+ 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
+ }