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
@@ -0,0 +1,23 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // How much of a table one page shows. The choice belongs to the reader rather than
4
+ // to the app, so it is kept in their browser — but in a cookie rather than in local
5
+ // storage, which is where the scheme goes: pagy decides the page on the server, and
6
+ // a cookie is the only storage the server is sent.
7
+ export default class extends Controller {
8
+ static values = { storage: String, to: Number }
9
+
10
+ // Back to the first page, always: page five of twenty is past the end of a hundred
11
+ // to a page, and pagy answers that with an empty table rather than an error. And
12
+ // only the frame, so what is redrawn is the table and the row under it — the answer
13
+ // brings the button back naming the size a click would go to next.
14
+ toggle() {
15
+ document.cookie =
16
+ `${this.storageValue}=${this.toValue}; path=/; max-age=31536000; samesite=lax`
17
+
18
+ const url = new URL(window.location.href)
19
+ url.searchParams.delete('page')
20
+
21
+ window.Turbo.visit(url.href, { frame: 'results', action: 'replace' })
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ // Google's own bootstrap, spelled out: `importLibrary` fetches the API the first time it
2
+ // is asked for a library, and the API then answers it itself. Once per page, however many
3
+ // maps are on it — and a Turbo visit keeps the page, so once per session in practice.
4
+ export function load(key) {
5
+ const maps = (window.google ||= {}).maps ||= {}
6
+ if (maps.importLibrary) return
7
+
8
+ let loading
9
+ maps.importLibrary = (library, ...rest) => {
10
+ loading ||= new Promise((resolve, reject) => {
11
+ const script = document.createElement('script')
12
+ const params = new URLSearchParams({ key, v: 'weekly', loading: 'async', callback: 'google.maps.__ib__' })
13
+ script.src = `https://maps.googleapis.com/maps/api/js?${params}`
14
+ maps.__ib__ = resolve
15
+ script.onerror = () => reject(new Error('The Google Maps API could not be loaded'))
16
+ document.head.append(script)
17
+ })
18
+ return loading.then(() => maps.importLibrary(library, ...rest))
19
+ }
20
+ }
@@ -0,0 +1,88 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { load } from './map/loader.js'
3
+
4
+ // This page of a table on a Google map. A row keeping a place ID has its area filled in
5
+ // on the boundary layer its model named — a county, a ZIP — or, where the model named
6
+ // none, a pin at the place; a row keeping coordinates is a pin there and then, with
7
+ // nothing to look up. The key and the map are the host's, read from its credentials; the
8
+ // rows are the page's, and the map is fitted round whatever it drew.
9
+ export default class extends Controller {
10
+ static values = { key: String, id: String, boundary: String, places: Array, points: Array }
11
+
12
+ async connect() {
13
+ load(this.keyValue)
14
+ const { Map } = await google.maps.importLibrary('maps')
15
+ const { LatLngBounds } = await google.maps.importLibrary('core')
16
+
17
+ // A picture rather than a control: the table's search, sort and pages are how a
18
+ // reader moves through the rows, and the map only shows where this page's are.
19
+ const map = new Map(this.element, {
20
+ mapId: this.idValue, gestureHandling: 'none', zoomControl: false,
21
+ disableDefaultUI: true, keyboardShortcuts: false
22
+ })
23
+ const bounds = new LatLngBounds()
24
+
25
+ await Promise.all([this.place(map, bounds), this.point(map, bounds)])
26
+ if (!bounds.isEmpty()) map.fitBounds(bounds, 10)
27
+ }
28
+
29
+ // The rows named by a place ID: areas on the boundary layer, or pins where there is none.
30
+ async place(map, bounds) {
31
+ if (this.placesValue.length === 0) return
32
+ const { Place } = await google.maps.importLibrary('places')
33
+ const places = this.placesValue.map(id => new Place({ id }))
34
+
35
+ if (this.hasBoundaryValue) return this.fill(map, bounds, places)
36
+ return this.pin(map, bounds, places)
37
+ }
38
+
39
+ // The layer styles every boundary Google knows at that level, and a function saying
40
+ // which of them are ours is what fills them in. The bounds are the places' viewports.
41
+ async fill(map, bounds, places) {
42
+ const ours = new Set(this.placesValue)
43
+ map.getFeatureLayer(this.boundaryValue).style = ({ feature }) => {
44
+ if (ours.has(feature.placeId)) return FILLED
45
+ }
46
+
47
+ await Promise.all(places.map(place =>
48
+ fetched(place, 'viewport').then(() => { if (place.viewport) bounds.union(place.viewport) })
49
+ ))
50
+ }
51
+
52
+ async pin(map, bounds, places) {
53
+ const drop = await this.dropper(map, bounds)
54
+
55
+ await Promise.all(places.map(place =>
56
+ fetched(place, 'location').then(() => { if (place.location) drop(place.location) })
57
+ ))
58
+ }
59
+
60
+ // The rows named by coordinates, which are pins without a lookup.
61
+ async point(map, bounds) {
62
+ if (this.pointsValue.length === 0) return
63
+ const drop = await this.dropper(map, bounds)
64
+
65
+ for (const [lat, lng] of this.pointsValue) drop({ lat, lng })
66
+ }
67
+
68
+ // One marker at a position, and the bounds widened to hold it.
69
+ async dropper(map, bounds) {
70
+ const { AdvancedMarkerElement } = await google.maps.importLibrary('marker')
71
+
72
+ return (position) => {
73
+ new AdvancedMarkerElement({ map, position })
74
+ bounds.extend(position)
75
+ }
76
+ }
77
+ }
78
+
79
+ // A place that cannot be fetched — an ID Google no longer knows — is left off the map
80
+ // rather than taking the rest of the page's rows with it.
81
+ function fetched(place, field) {
82
+ return place.fetchFields({ fields: [field] }).catch(console.error)
83
+ }
84
+
85
+ const FILLED = {
86
+ strokeColor: '#2D85FF', strokeOpacity: 1.0, strokeWeight: 3.0,
87
+ fillColor: '#2D85FF', fillOpacity: 0.5
88
+ }
@@ -0,0 +1,27 @@
1
+ import { OtpInput } from 'bootstrap'
2
+
3
+ // Draws the six slots over the one real field and returns the plugin's instance. A page
4
+ // restored from history comes back with the slots still in it, and a re-render can leave a
5
+ // connected controller holding an instance whose slots have been deleted underneath it — so
6
+ // neither the markup nor the instance is trusted, only taken down and drawn again.
7
+ export function drawSlots(element, field) {
8
+ OtpInput.getInstance(element)?.dispose()
9
+ element.querySelector('.otp-slots')?.remove()
10
+ element.classList.remove('otp-rendered')
11
+ const otp = new OtpInput(element)
12
+
13
+ // A refused code comes back in the field, the way any invalid field keeps what was typed —
14
+ // but a re-render leaves nothing focused, so backspace and a new digit went nowhere.
15
+ // Focused with the caret past the last digit, fixing a typo is one keystroke.
16
+ if (field.value) {
17
+ field.focus()
18
+ field.setSelectionRange(field.value.length, field.value.length)
19
+ }
20
+
21
+ // The plugin rewrites `pattern` to `[0-9]*` from its own type, and `minlength` cannot stand
22
+ // in: the browser enforces tooShort only on a value a person edited, and this plugin writes
23
+ // every value programmatically. Restoring the exact length is what keeps the submit shut
24
+ // until all six digits are in rather than after the first.
25
+ field.pattern = '[0-9]{6}'
26
+ return otp
27
+ }
@@ -0,0 +1,98 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { drawSlots } from './otp/slots.js'
3
+
4
+ // Draws Bootstrap's OTP input: one real field rendered as six slots. Bootstrap initializes
5
+ // it on DOMContentLoaded, which a Turbo visit never fires — and a verification page is only
6
+ // ever reached by one, so without this the reader is left with an empty box.
7
+ export default class extends Controller {
8
+ connect() {
9
+ // Says the slots are coming, which is what lets the stylesheet hold back the bare field.
10
+ this.element.classList.add('otp-drawing')
11
+ this.pasted = false
12
+
13
+ this.draw = () => {
14
+ if (!this.element.isConnected) { return }
15
+
16
+ this.otp = drawSlots(this.element, this.field)
17
+ }
18
+
19
+ // A keystroke past the sixth is one iOS cannot place, and it answers by selecting inside
20
+ // the real field rather than by doing nothing. That field's text and caret are both
21
+ // transparent, but a selection is not its to hide: iOS draws the grab handles itself, and
22
+ // the hidden text they follow lies crammed against the left edge rather than under the
23
+ // slots — so a bar surfaces inside the first one. Collapsed to the end there is nothing
24
+ // left to draw, and only once the code is whole, so selecting to replace a half-typed one
25
+ // still works.
26
+ this.collapse = () => {
27
+ if (document.activeElement !== this.field) { return }
28
+ const { value, selectionStart, selectionEnd } = this.field
29
+ if (value.length < 6 || selectionStart === selectionEnd) { return }
30
+
31
+ this.field.setSelectionRange(value.length, value.length)
32
+ }
33
+
34
+ // The plugin cancels every `beforeinput` and writes the value itself, so typing a code
35
+ // fires no `input` on the field at all — only its own `input.bs.otpInput`, which leaves
36
+ // everything watching the field behind, the submit gating included. Say it again in the
37
+ // language the page speaks, guarded, since the plugin answers `input` with another.
38
+ this.relay = () => {
39
+ if (this.relaying) { return }
40
+ this.relaying = true
41
+ this.field.dispatchEvent(new Event('input', { bubbles: true }))
42
+ this.relaying = false
43
+ }
44
+
45
+ // A pasted code is a whole code, so there is nothing left to confirm: send it. A typed
46
+ // one is not, because the sixth digit may be a typo the typist is about to fix. Emptied
47
+ // first, or a code pasted over six already there is dropped: the field is full and
48
+ // `maxlength` has nowhere to put the new one.
49
+ this.remember = () => {
50
+ this.pasted = true
51
+ this.field.value = ''
52
+ }
53
+ this.forget = (event) => { if (/^[0-9]$/.test(event.key)) { this.pasted = false } }
54
+ this.send = () => {
55
+ if (!this.pasted) { return }
56
+
57
+ this.pasted = false
58
+ this.submit()
59
+ }
60
+
61
+ this.submit = () => this.element.closest('form')?.requestSubmit()
62
+
63
+ // A code that arrives without a keystroke — iOS offering it from Messages, or any other
64
+ // autofill — is written straight into the field, which fires a native `input`; typing
65
+ // never does. So this is either the relay above or a whole code that appeared, and the
66
+ // slots have to be told. `keyup` is one of the three events the plugin re-reads the field
67
+ // on, and it re-reads in place: redrawing takes the row of six down and puts it back up,
68
+ // which is the shrink with the bare field showing yellow underneath. And the plugin
69
+ // announces completeness only for input it handled, so a code that landed this way is
70
+ // sent from here as a pasted one is — tapping the suggestion is the confirmation.
71
+ this.adopt = () => {
72
+ if (this.relaying) { return }
73
+
74
+ this.field.dispatchEvent(new Event('keyup', { bubbles: true }))
75
+ if (this.field.checkValidity()) { this.submit() }
76
+ }
77
+
78
+ this.listeners = [['input', this.adopt], ['input.bs.otpInput', this.relay],
79
+ ['paste', this.remember], ['keydown', this.forget], ['complete.bs.otpInput', this.send]]
80
+ for (const [name, on] of this.listeners) { this.element.addEventListener(name, on) }
81
+ this.draw()
82
+ // A refused code comes back as a re-render that can leave this controller untouched, so
83
+ // the drawing is redone whenever Turbo renders rather than only when Stimulus connects.
84
+ document.addEventListener('turbo:render', this.draw)
85
+ // On the document rather than the field: iOS reports a selection it made itself here.
86
+ document.addEventListener('selectionchange', this.collapse)
87
+ }
88
+
89
+ disconnect() {
90
+ document.removeEventListener('turbo:render', this.draw)
91
+ document.removeEventListener('selectionchange', this.collapse)
92
+ for (const [name, on] of this.listeners) { this.element.removeEventListener(name, on) }
93
+ this.otp?.dispose()
94
+ }
95
+
96
+ // The one real field the six slots are drawn over, and the only thing that holds the code.
97
+ get field() { return this.element.querySelector('.otp-input') }
98
+ }
@@ -0,0 +1,89 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // How a North American phone reads: `555-555-5555`, on a cell
4
+ // that shows one and in a field that takes one. The server hands over ten bare digits
5
+ // and a `data-controller`, and everything about the shape is decided here, so an app
6
+ // that wants `(555) 555-5555` changes this file and no Ruby.
7
+ export default class extends Controller {
8
+ // Ten digits with the separators typed in; an area or exchange code never starts
9
+ // with 0 or 1, which is what NANP forbids and what the server's own check enforces.
10
+ static pattern = '[2-9]\\d{2}-[2-9]\\d{2}-\\d{4}'
11
+ static sample = '555-555-5555'
12
+
13
+ // A field says what shape it wants where the markup left that blank, and both a
14
+ // field and a cell are formatted at once — so a form redrawn after a rejected save
15
+ // shows the separators rather than the digits it was sent.
16
+ connect() {
17
+ if (this.#field) { this.#constrain() }
18
+ this.#format()
19
+ }
20
+
21
+ // Only a digit goes in, and not an eleventh: a full field refuses the key itself rather
22
+ // than taking it and cutting it back, since everything else watching the field — a
23
+ // submit shut until it is valid — would see the eleven digits first and never the ten.
24
+ down(event) {
25
+ if (!event.key) { return }
26
+ if (event.ctrlKey) { return }
27
+ if (event.metaKey) { return }
28
+ if (event.key.length > 1) { return }
29
+ if (!/[0-9.]/.test(event.key)) { event.preventDefault(); return }
30
+ if (this.#digits().length >= 10 && this.#nothingSelected()) { event.preventDefault() }
31
+ }
32
+
33
+ input(event) {
34
+ if (event.inputType === 'deleteContentBackward') { return }
35
+ this.#format()
36
+ }
37
+
38
+ get #field() {
39
+ return this.element instanceof HTMLInputElement
40
+ }
41
+
42
+ #digits() {
43
+ return this.constructor.digits(this.#field ? this.element.value : this.element.textContent)
44
+ }
45
+
46
+ #nothingSelected() {
47
+ return this.element.selectionStart === this.element.selectionEnd
48
+ }
49
+
50
+ #constrain() {
51
+ const { pattern, sample } = this.constructor
52
+ const field = this.element
53
+ field.pattern ||= pattern
54
+ field.placeholder ||= sample
55
+ field.title ||= `Please match the format ${sample}`
56
+ field.inputMode ||= 'numeric'
57
+ }
58
+
59
+ // A value that came in whole — pasted, autofilled — is reshaped, and where that changed
60
+ // it the field says `input` again, so a submit that read the raw paste reads the shape.
61
+ #format() {
62
+ const text = this.#field ? this.element.value : this.element.textContent
63
+ const formatted = this.constructor.format(text)
64
+ if (formatted === text) { return }
65
+
66
+ if (!this.#field) { this.element.textContent = formatted; return }
67
+ this.element.value = formatted
68
+ if (this.saying) { return }
69
+ this.saying = true
70
+ this.element.dispatchEvent(new Event('input', { bubbles: true }))
71
+ this.saying = false
72
+ }
73
+
74
+ // The digits in `text`, up to ten, with a dash after the third and the sixth.
75
+ static format(text) {
76
+ const digits = this.digits(text)
77
+ const parts = [digits.substring(0, 3), digits.substring(3, 6), digits.substring(6, 10)]
78
+
79
+ return parts.filter((part) => part.length > 0).join('-')
80
+ }
81
+
82
+ // The digits alone, less the country code a number arrives with: no NANP area code
83
+ // starts with a 1, so a leading 1 on eleven digits is +1 and nothing else.
84
+ static digits(text) {
85
+ const digits = text.replace(/\D/g, '')
86
+
87
+ return digits.length > 10 && digits.startsWith('1') ? digits.slice(1, 11) : digits.slice(0, 10)
88
+ }
89
+ }
@@ -0,0 +1,28 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Cycles a field's own suggestion, so the placeholder reads as an invitation rather than as
4
+ // one fixed example. Somebody who has started typing is left alone.
5
+ export default class extends Controller {
6
+ static values = { questions: Array, every: { type: Number, default: 10000 } }
7
+
8
+ connect() {
9
+ this.index = 0
10
+ this.timer = setInterval(() => this.#cycle(), this.everyValue)
11
+ }
12
+
13
+ disconnect() {
14
+ clearInterval(this.timer)
15
+ }
16
+
17
+ // The class fades the placeholder out; the text is swapped while it cannot be seen.
18
+ #cycle() {
19
+ if (this.element.value) { return }
20
+
21
+ this.element.classList.add('is-fading')
22
+ setTimeout(() => {
23
+ this.index = (this.index + 1) % this.questionsValue.length
24
+ this.element.placeholder = this.questionsValue[this.index]
25
+ this.element.classList.remove('is-fading')
26
+ }, 300)
27
+ }
28
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { Tooltip } from 'bootstrap'
3
+
4
+ // Largest first, so the first one an instant clears is the one it is said in.
5
+ const UNITS = [
6
+ ['year', 31536000000], ['month', 2592000000], ['week', 604800000],
7
+ ['day', 86400000], ['hour', 3600000], ['minute', 60000],
8
+ ]
9
+
10
+ // How long ago a timestamp was, said again at the moment somebody asks. The server
11
+ // writes the same words with Rails' own helper, which is what a reader without
12
+ // JavaScript gets — but a table is cached and a page is left open, so those words are
13
+ // only true when they are drawn. These are true when they are read.
14
+ export default class extends Controller {
15
+ // Before the `tooltip` controller beside it, which is what makes the instance: this
16
+ // listener is registered first and so runs before Bootstrap's own.
17
+ connect() {
18
+ this.entered = () => this.#refresh()
19
+ this.element.addEventListener('mouseenter', this.entered)
20
+ }
21
+
22
+ disconnect() {
23
+ this.element.removeEventListener('mouseenter', this.entered)
24
+ }
25
+
26
+ // Through `setContent`, since Bootstrap reads a tooltip's words once when it is made
27
+ // and never looks at the attribute again.
28
+ #refresh() {
29
+ const at = new Date(this.element.getAttribute('datetime'))
30
+ if (isNaN(at.getTime())) { return }
31
+
32
+ Tooltip.getInstance(this.element)?.setContent({ '.tooltip-inner': this.#words(at) })
33
+ }
34
+
35
+ #words(at) {
36
+ const format = new Intl.RelativeTimeFormat(document.documentElement.lang || 'en')
37
+ const ms = at - new Date()
38
+
39
+ for (const [unit, size] of UNITS) {
40
+ if (Math.abs(ms) >= size) { return format.format(Math.round(ms / size), unit) }
41
+ }
42
+
43
+ return format.format(Math.round(ms / 1000), 'second')
44
+ }
45
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // A form's submit is shut until every required field has a value, so a reader is told
4
+ // a message cannot be sent before they try, not after.
5
+ export default class extends Controller {
6
+ connect() {
7
+ this.toggle()
8
+ this.element.querySelectorAll('[required]').forEach((field) => {
9
+ field.addEventListener('input', () => this.toggle())
10
+ })
11
+ }
12
+
13
+ toggle() {
14
+ const invalid = this.element.querySelectorAll('[required]:invalid').length > 0
15
+
16
+ this.element.querySelectorAll('[type="submit"]').forEach((button) => { button.disabled = invalid })
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // One masked value and the click that unmasks it. The plaintext arrives with the
4
+ // page rather than being fetched: the mask is against a screenshot, not against
5
+ // whoever is already reading the record.
6
+ export default class extends Controller {
7
+ static targets = ['mask', 'button']
8
+ static values = { plain: String }
9
+
10
+ show() {
11
+ this.maskTarget.textContent = this.plainValue
12
+ // Nothing left for it to do, and a link that reveals what is already revealed
13
+ // reads as though there were more to see.
14
+ this.buttonTarget.remove()
15
+ }
16
+ }
@@ -0,0 +1,99 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // The sidebar's one control over how a page looks. A click moves it to another palette
4
+ // and into the mode it is not in, which is what the icon promises: a moon while the page
5
+ // is light, a sun while it is dark. The choice belongs to the reader rather than to the
6
+ // app, so it is kept in their browser — the layout's own script is what puts it back
7
+ // before the first paint, a controller connecting far too late for that.
8
+ export default class extends Controller {
9
+ static values = { themes: Array, path: String, storage: String }
10
+
11
+ // Turbo merges the head on a visit, which puts the palette the server chose back over
12
+ // the one the reader picked, and the layout's script runs only on a full load. The
13
+ // sidebar is drawn again on every visit, so connecting is the moment to say it again.
14
+ connect() {
15
+ const stored = this.#stored()
16
+
17
+ if (stored) { this.#apply(stored.theme, stored.mode) }
18
+ }
19
+
20
+ // Another palette, in the other mode. Random rather than in order: the eight have no
21
+ // order that means anything. Excluding the one showing is what stops a click looking
22
+ // like it did nothing, which is the whole risk of picking at random.
23
+ rotate() {
24
+ const mode = this.#mode() === 'dark' ? 'light' : 'dark'
25
+ const others = this.themesValue.filter((theme) => theme !== this.#theme())
26
+ const theme = others[Math.floor(Math.random() * others.length)]
27
+
28
+ this.#apply(theme, mode)
29
+ localStorage.setItem(this.storageValue, JSON.stringify({ theme, mode }))
30
+ }
31
+
32
+ // The mode onto the element every `light-dark()` on the page is resolved against, and
33
+ // the palette onto the one link that serves one. A name is checked against the list
34
+ // the server sent: the storage is the reader's own, but what comes back out of it is
35
+ // still not something to put in a URL unread.
36
+ #apply(theme, mode) {
37
+ if (mode === 'light' || mode === 'dark') {
38
+ document.documentElement.dataset.bsTheme = mode
39
+ }
40
+ if (!this.themesValue.includes(theme)) { return }
41
+
42
+ this.#link().href = `${this.pathValue}/${theme}.css`
43
+ }
44
+
45
+ // What the reader picked last, or nothing at all where they never have.
46
+ #stored() {
47
+ try {
48
+ return JSON.parse(localStorage.getItem(this.storageValue))
49
+ } catch (error) {
50
+ return null
51
+ }
52
+ }
53
+
54
+ // Which mode the page is in: whatever has been forced onto it, and otherwise whatever
55
+ // the system asks for, since a page nobody has chosen for follows that.
56
+ #mode() {
57
+ return document.documentElement.dataset.bsTheme ||
58
+ (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
59
+ }
60
+
61
+ // Which palette is showing, read off the link rather than remembered, so a reader who
62
+ // cleared their storage still moves on from the one in front of them.
63
+ #theme() {
64
+ const link = this.#current()
65
+
66
+ return link ? link.href.split('/').pop().replace('.css', '') : null
67
+ }
68
+
69
+ // The palette the page is drawn in, which is the *last* of these links rather than the
70
+ // first. Turbo's head merge appends a stylesheet the new head has and the old one does
71
+ // not — it never replaces one — and the server names the same palette on every page,
72
+ // so a visit made after the reader chose leaves two links and the browser obeys the
73
+ // one at the end. Writing to the other would change nothing anybody can see.
74
+ #current() {
75
+ const links = document.querySelectorAll('link[data-recourse-theme]')
76
+
77
+ return links[links.length - 1]
78
+ }
79
+
80
+ // The one link a palette is served through: whichever is in force, with any the merge
81
+ // left behind it taken away, and a new one where a host named no palette at all and
82
+ // there is none in the head to find.
83
+ #link() {
84
+ const links = [...document.querySelectorAll('link[data-recourse-theme]')]
85
+ const link = links.pop() || this.#appended()
86
+ links.forEach((stale) => stale.remove())
87
+
88
+ return link
89
+ }
90
+
91
+ #appended() {
92
+ const link = document.createElement('link')
93
+ link.rel = 'stylesheet'
94
+ link.dataset.recourseTheme = ''
95
+ document.head.appendChild(link)
96
+
97
+ return link
98
+ }
99
+ }
@@ -0,0 +1,78 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Survives the visit that a submit starts, since the module is not reloaded with
4
+ // the page: it is how the next controller knows the search box was being typed in.
5
+ // Only a submit that replaces the whole page needs it — a frame leaves the form,
6
+ // and the caret in it, exactly where they were.
7
+ let typing = false
8
+
9
+ export default class extends Controller {
10
+ static targets = ['field']
11
+
12
+ connect() {
13
+ // Not on a cached preview: the real render connects a second time, and it is
14
+ // the one whose field can still be typed into.
15
+ if (typing && !document.documentElement.hasAttribute('data-turbo-preview')) {
16
+ typing = false
17
+ this.#restore()
18
+ }
19
+
20
+ // A pick in a combobox is written to its select and said as the select's own
21
+ // `change`, which bubbles, so one listener on the form hears every menu in it and
22
+ // each tick or untick narrows the table straight away. Coalesced to the end of the
23
+ // turn, because emptying a filter unticks every option in it — one request for the
24
+ // table that leaves, not one per option.
25
+ this.picked = () => {
26
+ clearTimeout(this.pick)
27
+ this.pick = setTimeout(() => this.#submit(), 0)
28
+ }
29
+ this.element.addEventListener('change', this.picked)
30
+
31
+ // The frame is not inside the form, so its events never reach it.
32
+ this.reloaded = () => { typing = false; this.#syncSort() }
33
+ document.addEventListener('turbo:frame-load', this.reloaded)
34
+
35
+ // A refresh morph would write the fetched page's older query over what is
36
+ // mid-typing, so the form's subtree sits a morph out — the text, the caret
37
+ // and any open filter menu stay. Only a morph: a page visit still renders
38
+ // every page's own form, which `turbo-permanent` here would carry across.
39
+ this.morphing = (event) => { if (this.element.contains(event.target)) event.preventDefault() }
40
+ document.addEventListener('turbo:before-morph-element', this.morphing)
41
+ }
42
+
43
+ disconnect() {
44
+ this.element.removeEventListener('change', this.picked)
45
+ document.removeEventListener('turbo:frame-load', this.reloaded)
46
+ document.removeEventListener('turbo:before-morph-element', this.morphing)
47
+ clearTimeout(this.timer)
48
+ clearTimeout(this.pick)
49
+ }
50
+
51
+ submit() {
52
+ clearTimeout(this.timer)
53
+ this.timer = setTimeout(() => { typing = true; this.#submit() }, 300)
54
+ }
55
+
56
+ #submit() {
57
+ this.element.requestSubmit()
58
+ }
59
+
60
+ // A heading sorts by navigating the frame, which advances the address bar but
61
+ // leaves this form alone — so the sort it carries has to be read back off the URL,
62
+ // or the next search would reorder the table by whatever was in force before.
63
+ #syncSort() {
64
+ const field = this.element.querySelector('input[name="q[s]"]')
65
+
66
+ if (field) { field.value = new URL(window.location.href).searchParams.get('q[s]') || '' }
67
+ }
68
+
69
+ // The value comes back from the server, so only the caret has to be put back,
70
+ // and at the end of it — typing carries on where it left off.
71
+ #restore() {
72
+ if (!this.hasFieldTarget) { return }
73
+
74
+ const field = this.fieldTarget
75
+ field.focus({ preventScroll: true })
76
+ field.setSelectionRange(field.value.length, field.value.length)
77
+ }
78
+ }