houseaccount 0.10.1

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 (66) hide show
  1. package/MIT-LICENSE +22 -0
  2. package/README.md +296 -0
  3. package/app/fonts/bootstrap-icons.woff2 +0 -0
  4. package/app/javascript/houseaccount/bookmark_controller.js +97 -0
  5. package/app/javascript/houseaccount/clear_controller.js +22 -0
  6. package/app/javascript/houseaccount/combobox/menu.js +93 -0
  7. package/app/javascript/houseaccount/combobox_controller.js +82 -0
  8. package/app/javascript/houseaccount/confirm.js +89 -0
  9. package/app/javascript/houseaccount/density_controller.js +21 -0
  10. package/app/javascript/houseaccount/deselect_controller.js +37 -0
  11. package/app/javascript/houseaccount/flash.js +35 -0
  12. package/app/javascript/houseaccount/limit_controller.js +23 -0
  13. package/app/javascript/houseaccount/map/loader.js +20 -0
  14. package/app/javascript/houseaccount/map_controller.js +88 -0
  15. package/app/javascript/houseaccount/otp/slots.js +27 -0
  16. package/app/javascript/houseaccount/otp_controller.js +98 -0
  17. package/app/javascript/houseaccount/phone_controller.js +89 -0
  18. package/app/javascript/houseaccount/placeholder_controller.js +28 -0
  19. package/app/javascript/houseaccount/relative_time_controller.js +45 -0
  20. package/app/javascript/houseaccount/require_controller.js +18 -0
  21. package/app/javascript/houseaccount/reveal_controller.js +16 -0
  22. package/app/javascript/houseaccount/scheme_controller.js +99 -0
  23. package/app/javascript/houseaccount/search_controller.js +78 -0
  24. package/app/javascript/houseaccount/shortcuts_controller.js +42 -0
  25. package/app/javascript/houseaccount/thread_controller.js +40 -0
  26. package/app/javascript/houseaccount/timezone_controller.js +27 -0
  27. package/app/javascript/houseaccount/toast_controller.js +35 -0
  28. package/app/javascript/houseaccount/tooltip_controller.js +23 -0
  29. package/app/javascript/houseaccount/wall/marks.js +8 -0
  30. package/app/javascript/houseaccount/wall/tools.json +75 -0
  31. package/app/javascript/houseaccount/wall_controller.js +93 -0
  32. package/app/javascript/houseaccount/written.js +20 -0
  33. package/app/javascript/houseaccount/written_controller.js +28 -0
  34. package/app/javascript/houseaccount.js +60 -0
  35. package/app/stylesheets/houseaccount/base.css +33 -0
  36. package/app/stylesheets/houseaccount/chat.css +73 -0
  37. package/app/stylesheets/houseaccount/flow.css +47 -0
  38. package/app/stylesheets/houseaccount/icons.css +186 -0
  39. package/app/stylesheets/houseaccount/lockup.css +58 -0
  40. package/app/stylesheets/houseaccount/map.css +3 -0
  41. package/app/stylesheets/houseaccount/pin.css +34 -0
  42. package/app/stylesheets/houseaccount/search.css +60 -0
  43. package/app/stylesheets/houseaccount/shell.css +197 -0
  44. package/app/stylesheets/houseaccount/table.css +105 -0
  45. package/app/stylesheets/houseaccount/values.css +41 -0
  46. package/app/stylesheets/houseaccount/wall.css +123 -0
  47. package/app/stylesheets/houseaccount.css +16 -0
  48. package/app/stylesheets/theme/bootstrap.css +11 -0
  49. package/app/stylesheets/theme/dawn.css +189 -0
  50. package/app/stylesheets/theme/dracula.css +189 -0
  51. package/app/stylesheets/theme/gruvbox.css +189 -0
  52. package/app/stylesheets/theme/monokai.css +190 -0
  53. package/app/stylesheets/theme/nord.css +189 -0
  54. package/app/stylesheets/theme/one_dark.css +189 -0
  55. package/app/stylesheets/theme/solarized.css +188 -0
  56. package/app/stylesheets/theme/tokyo_night.css +189 -0
  57. package/package.json +33 -0
  58. package/vendor/bootstrap-icons.min.css +5 -0
  59. package/vendor/bootstrap.bundle.min.js +9 -0
  60. package/vendor/bootstrap.min.css +2 -0
  61. package/vendor/fonts/OFL-quicksand.txt +93 -0
  62. package/vendor/fonts/bootstrap-icons.woff +0 -0
  63. package/vendor/fonts/bootstrap-icons.woff2 +0 -0
  64. package/vendor/fonts/quicksand-500-latin-ext.woff2 +0 -0
  65. package/vendor/fonts/quicksand-500-latin.woff2 +0 -0
  66. package/vendor/fonts/quicksand-500-vietnamese.woff2 +0 -0
package/MIT-LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2026 Claudio Baccigalupo
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # HouseAccount design
2
+
3
+ Everything a HouseAccount page is made of, in one place: the stylesheet and the script every
4
+ page loads, the marks every app is known by, the pages shown when an app is not, and the
5
+ Rails helpers that draw a head, a logo or a chat the same way in every app.
6
+
7
+ Published three ways from this repo:
8
+
9
+ | Surface | Where | What it is for |
10
+ | --- | --- | --- |
11
+ | Site | https://design.houseaccount.com (staging: https://design.houseparty.dev) | A stable URL for every asset, and a showcase of all of them |
12
+ | Gem | `houseaccount` on RubyGems | A Rails engine: serves the same tree from the gem, and the helpers |
13
+ | npm | `houseaccount` | The controllers and stylesheets as importable source |
14
+
15
+ ## How to install
16
+
17
+ ### From RubyGems
18
+
19
+ ```bash
20
+ gem install houseaccount
21
+ ```
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem 'houseaccount', '~> 0.10.0'
26
+ ```
27
+
28
+ `~> 0.10.0` stops short of `0.11`, since below 1.0 a minor release may break whatever it likes.
29
+ Rails 8.1 and Ruby 3.2 are the minimum.
30
+
31
+ ### From npm
32
+
33
+ For a host that bundles the sources itself rather than serving the gem's built tree.
34
+
35
+ ```bash
36
+ npm install houseaccount
37
+ ```
38
+
39
+ ```js
40
+ import 'houseaccount'
41
+ import 'houseaccount/houseaccount.css'
42
+ ```
43
+
44
+ The module exports nothing and is imported for its effect: it starts Stimulus, registers every
45
+ controller the house's markup names, and hands Turbo the dialog it draws for a confirmation.
46
+
47
+ Bootstrap 6 is not on npm, so the bundle ships inside this package and a bundler has to send the
48
+ bare `bootstrap` specifier there:
49
+
50
+ ```js
51
+ // esbuild
52
+ alias: { bootstrap: 'houseaccount/bootstrap' }
53
+ ```
54
+
55
+ A single controller comes from `houseaccount/controllers`, for a host registering its own
56
+ Stimulus application rather than taking the house's:
57
+
58
+ ```js
59
+ import WallController from 'houseaccount/controllers/wall_controller.js'
60
+ ```
61
+
62
+ Pin as a Gemfile does, and for the same reason: `"houseaccount": "~0.10.0"` stops short of `0.11`.
63
+
64
+ A page takes the gem's layout, and wears the same head:
65
+
66
+ ```ruby
67
+ class Unauthenticated::AgentsController < ApplicationController
68
+ layout 'houseaccount'
69
+ end
70
+ ```
71
+
72
+ ```erb
73
+ <% content_for :title, 'Authenticating as admin' %>
74
+ <% content_for :head, tag.meta(name: 'robots', content: 'noindex') %>
75
+ <h1>Authenticating as admin</h1>
76
+ ```
77
+
78
+ <p>
79
+ <img src='screenshot/layout-light.png' width='49%' alt='The page in light mode'>
80
+ <img src='screenshot/layout-dark.png' width='49%' alt='The page in dark mode'>
81
+ </p>
82
+
83
+ The layout is the charset, the title, the CSRF and CSP metas, `houseaccount_head_tags`,
84
+ `yield :head`, then `yield` inside a `main.container-fluid` in the `<body>`, and `notices`
85
+ after it, and nothing else. A host wanting more copies
86
+ it into its own `app/views/layouts/`, which wins on being earlier in the view paths.
87
+
88
+ Which of the family an app is, the gem reads off the Rails application's own name: the
89
+ `Goldrush` of `Goldrush::Application`. That is what its tab wears, what `houseaccount_head_tags`
90
+ links without an argument, and whose page the engine answers `/down.html` with — the app's
91
+ mark in the middle and the house's at the foot. An app the family has no mark for wears the
92
+ cube. The same page stands at `https://design.houseaccount.com/down/fountain` for Heroku's
93
+ `MAINTENANCE_PAGE_URL`, which has to be somewhere the app is not — without the `.html`,
94
+ which Pages redirects away from.
95
+
96
+ With the gem in the bundle an app serves `/css/houseaccount.css`, `/js/houseaccount.js`,
97
+ `/theme/*`, `/font/*`, `/logo/*` and `/favicon/*` itself — and its own version's copy under
98
+ `/v0.10.0/`, which is what the head helper links, cached for a year — answers a 400, 404, 406, 422 or 500
99
+ with the house's own page, and has these helpers in every view:
100
+
101
+ ```erb
102
+ <%# In the <head>: the viewport, color-scheme and Turbo refresh metas, the stylesheet, the script,
103
+ and the icons a tab, a phone and a manifest ask for — this app's, by its name. %>
104
+ <%= houseaccount_head_tags %>
105
+
106
+ <%# The icons alone, for a head of the host's own: this app's unless another of the family is
107
+ named, the way a page for providers wears the house's cube rather than the app's. A host of
108
+ recourse needs no call at all — the gem answers recourse's `recourses/head` with them, so an
109
+ admin's tab wears the app's cube the moment the gem is in the bundle. %>
110
+ <%= houseaccount_icons :houseaccount %>
111
+
112
+ <%# A mark beside its name, as one word and one link. The house's cube and `houseaccount`
113
+ leading to houseaccount.com by default, the way a page signs itself in a footer; any
114
+ app's own with its name and wherever `href` says. Other options are attributes of the
115
+ link. Under the cursor the whole logo turns the mark's blue. %>
116
+ <%= houseaccount_logo %>
117
+ <%= houseaccount_logo app: :fountain, href: root_path, class: 'fs-3' %>
118
+
119
+ <%# A thread of messages, newest at the foot, and — where `url` is given — the field that
120
+ posts the next one there, suggesting `hints` one at a time in its placeholder. %>
121
+ <%= chat_with url: chat_question_path, hints: Provider::Chat::HINTS, messages: messages %>
122
+
123
+ <%# What the flash says, as toasts at the bottom right: green for a notice, red for an alert,
124
+ neutral for any other key; visible at first paint, gone after a moment or a click, held
125
+ while read. A value in the flash that is not words is left alone. %>
126
+ <%= notices %>
127
+
128
+ <%# A link and the dialog it opens: headed, with an X, holding the block, and closed by an
129
+ `Okay` too (`okay:` words it otherwise, `okay: false` leaves it out). The two share an id
130
+ made from the link's words; any other option is an attribute of the link. %>
131
+ <%= dialog 'I’m not on Google Business', title: 'Not a problem' do %>
132
+ We will look your business up ourselves and match it by hand.
133
+ <% end %>
134
+
135
+ <%# The house's form, from `builder: HouseAccount::FormBuilder` or a controller's
136
+ `default_form_builder`: `fieldset` a one-column grid with a legend where given, a label a
137
+ form-label, a text or phone field a large control — the phone one shaped by the `phone`
138
+ controller — a submit or a button the solid pill, a `type: :button` one the outlined pill, a
139
+ disabled one given a `title` saying why on hover, and
140
+ `pin_field` the six slots a code is typed into, one real field offered by the phone from the
141
+ text that brought it. %>
142
+ <%= form.fieldset do %>
143
+ <%= form.label :pin_confirmation, '6-digit code' %>
144
+ <%= form.pin_field :pin_confirmation, required: true %>
145
+ <%= form.submit 'Confirm' %>
146
+ <% end %>
147
+
148
+ <%# An asset on the design site, for a page this app does not serve — a mail, say. %>
149
+ <%= image_tag houseaccount_url('logo/fountain.png') %>
150
+ ```
151
+
152
+ Two more behaviors need no helper either, only an attribute. A `<select>` becomes Bootstrap's
153
+ combobox — a toggle reading the pick, a menu with a search box, a check per pick where it
154
+ is `multiple` — and stays the one thing the form submits:
155
+
156
+ ```erb
157
+ <%= f.select :team_id, Team.pluck(:name, :id), { include_blank: 'None' },
158
+ data: { controller: 'combobox', combobox_placeholder_value: 'Select…',
159
+ search: 'Search…', clear: 'Clear search', no_results: 'No results found' } %>
160
+ ```
161
+
162
+ On a `multiple` select, `data-combobox-all-value` names an `All` row that puts held-back
163
+ options on the menu and `data-combobox-more-value` words the toggle when several are picked
164
+ (`%{first} + %{count} more`); on an option, `data-count` puts a figure beside it and
165
+ `data-hidden` holds it back until `All`. A pick is announced as the select's own `change`.
166
+
167
+ And any link or form with `data-turbo-confirm` asks through a Bootstrap dialog the bundle
168
+ draws on the first ask: the first line of the message is the question, the rest are
169
+ paragraphs, and the answer wears the words of the button that asked. A host in another
170
+ language names the other word in `<meta name='houseaccount-cancel'>`.
171
+
172
+ `flow` draws the page of a signup flow around a card: the mark over the wall, a title, a
173
+ line under it, the card, and the way out at the foot. A layout of the app's own calls it and
174
+ nests on the gem's layout, giving the page as `content_for :content` and rendering the gem's
175
+ as a template:
176
+
177
+ ```erb
178
+ <% content_for :logo, 'houseaccount' %>
179
+
180
+ <% content_for :content do %>
181
+ <%= flow logo: root_path, footer: [button_to('Sign out', session_path, method: :delete, class: 'btn btn-link')] do %>
182
+ <%= yield %>
183
+ <% end %>
184
+ <% end %>
185
+
186
+ <%= render template: 'layouts/houseaccount' %>
187
+ ```
188
+
189
+ `content_for :logo` is which of the family's marks the tab wears, for a page a customer reads
190
+ rather than the app's own people: unsaid, it is this app's, by its name.
191
+
192
+ The words are the view's, said in `content_for`: `:title` is the `h1`, `:subtitle` the line
193
+ under it, drawn only where there is one, and `:width` how wide the card is, a CSS length that
194
+ is `30rem` unless said — a chat's page says `75rem`. What the layout knows goes in the call:
195
+ `footer:` is the pieces of the foot, joined by a middot, none drawing no foot, and `logo:`
196
+ where the mark links, the house's front door unless said.
197
+
198
+ The wall alone is one attribute, `data-controller='wall'`, on any element that should
199
+ stand on it.
200
+
201
+ A message is anything answering six methods, and `HouseAccount::Message` is one for a host
202
+ that has the facts and no model to hang them on:
203
+
204
+ | Method | What it says |
205
+ | --- | --- |
206
+ | `side` | `:left` for the other party's bubble, `:right` for the reader's |
207
+ | `text` | what was said — a string, or markup the host rendered; nil draws the typing dots |
208
+ | `sender` | who said it, shown small above the bubble; nil shows nobody |
209
+ | `at` | when, as `3 minutes ago` with the moment itself in a tooltip; nil shows no time |
210
+ | `delivery` | `:delivered`, `:sent`, `:failed` or `:undelivered`, a ✓ or ✕ in its tone; nil for none |
211
+ | `cost` | dollars the message cost, shown as `· $0.0123`; nil for none |
212
+
213
+ ```ruby
214
+ HouseAccount::Message.new side: :left, text: answer.note, sender: 'Claude', at: answer.created_at,
215
+ cost: Chat.spent_on(answer.input_usage, answer.output_usage)
216
+ ```
217
+
218
+ A code sent to a phone is the same words from every app, `HouseAccount.one_time_code`, in
219
+ each voice they are said in. `written` is the sentence a person reads and, under it, the
220
+ `@host #code` line that offers the code above the keyboard on the phone it arrived on;
221
+ `spoken` is the sentence alone, its digits one at a time so a listener can write them down.
222
+ The host is the application's default one unless `host:` says otherwise, and the words are
223
+ the gem's `config/locales` entry.
224
+
225
+ ```ruby
226
+ HouseAccount.one_time_code(contact.pin).written
227
+ # => "Enter this 6-digit code to proceed: 482913\n\n@houseaccount.com #482913"
228
+ HouseAccount.one_time_code(contact.pin).spoken
229
+ # => "Enter this 6-digit code to proceed: 4, 8, 2, 9, 1, 3"
230
+ ```
231
+
232
+ A page not on the gem links the two files from the site, and nothing else:
233
+
234
+ ```html
235
+ <link rel='stylesheet' href='https://design.houseaccount.com/css/houseaccount.css'>
236
+ <script type='module' src='https://design.houseaccount.com/js/houseaccount.js'></script>
237
+ ```
238
+
239
+ Those follow the latest release. Every release is also kept whole under its own version,
240
+ forever and cached for a year, for a page that would rather move on its own terms:
241
+
242
+ ```html
243
+ <link rel='stylesheet' href='https://design.houseaccount.com/v0.10.0/css/houseaccount.css'>
244
+ <script type='module' src='https://design.houseaccount.com/v0.10.0/js/houseaccount.js'></script>
245
+ ```
246
+
247
+ A host with a Content Security Policy allows that origin for `style-src`, `script-src` and
248
+ `font-src`.
249
+
250
+ ## What is in the bundle
251
+
252
+ `css/houseaccount.css` is Bootstrap 6 alpha as upstream ships it, Bootstrap Icons cut down to
253
+ the glyphs `art/icons.txt` names — the ones `unicon` maps and the few written out by name —
254
+ and then the house's rules by concern under `app/stylesheets/houseaccount/`. Quicksand, the
255
+ lockup's face, is served from `/font` too; nothing on a page reaches another origin. `js/houseaccount.js` is
256
+ Turbo (with the cable element `turbo-rails` signs its streams for), Bootstrap, and a Stimulus
257
+ application with every controller the house's markup names, started once under
258
+ `window.Stimulus`. Everything is drawn for light and dark alike: every color is a Bootstrap
259
+ token or a `light-dark()` pair, and `data-bs-theme` on `<html>` forces either. A phone is
260
+ formatted in the browser: the server hands over ten digits and
261
+ `data-controller='phone'`, and the controller decides `555-555-5555`.
262
+
263
+ ## Development
264
+
265
+ ```bash
266
+ bin/setup # bundle and npm install
267
+ bundle exec rake # build, test at 100% coverage, RuboCop, the file-length ceiling
268
+ ./build-icons.sh # after editing a drawing under art/
269
+ rake icons # after editing art/icons.txt, the glyphs the icon font keeps
270
+ cd test/dummy && bin/rails server
271
+ ```
272
+
273
+ The built `public/css`, `public/js`, `public/font` and `public/theme` are committed: the gem
274
+ serves them, and `spec.files` is `git ls-files`. `rake assets` rebuilds and fails until the
275
+ result is committed, so a source edit cannot ship without its build.
276
+
277
+ While the gem is read from a path, an app in development serves every file `no-cache`, so a
278
+ rebuild shows on the next reload; only a production app keeps the versioned copy for a year.
279
+
280
+ Every build also writes `public/v<version>/`, whole, with every URL inside pointing under
281
+ that prefix, until the tag `v<version>` exists; from then on the folder is never written
282
+ again, and a page that pinned it is owed the same bytes for good.
283
+
284
+ A release, in order: `CHANGELOG.md`, the version in `lib/houseaccount/version.rb` and
285
+ `package.json`, `bundle exec rake` so the versioned copy is the release's, then commit, tag
286
+ `v<version>`, `gem build` and `gem push`, and fast-forward `production` to `main` so the
287
+ site carries both the latest and the versioned copy.
288
+
289
+ ## Cloudflare
290
+
291
+ The site is a Cloudflare Pages project named `design` on this repo: production branch
292
+ `production`, no build command, output directory `public`, custom domain
293
+ `design.houseaccount.com`. `main` deploys as the branch alias `main.design.pages.dev`, which
294
+ `design.houseparty.dev` is a CNAME to. `public/_headers` adds the CORS header a cross-origin
295
+ module script and font need, and marks the hashed fonts immutable; everything else keeps
296
+ Pages' default of revalidating on every load, so a URL never has to change between versions.
Binary file
@@ -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
+ }