bh 6.1.4 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +87 -15
- data/app/javascript/bh/bookmark_controller.js +97 -0
- data/app/javascript/bh/clear_controller.js +22 -0
- data/app/javascript/bh/combobox/menu.js +93 -0
- data/app/javascript/bh/combobox_controller.js +82 -0
- data/app/javascript/bh/confirm.js +100 -0
- data/app/javascript/bh/density_controller.js +21 -0
- data/app/javascript/bh/deselect_controller.js +37 -0
- data/app/javascript/bh/flash.js +35 -0
- data/app/javascript/bh/limit_controller.js +23 -0
- data/app/javascript/bh/map/loader.js +20 -0
- data/app/javascript/bh/map_controller.js +88 -0
- data/app/javascript/bh/otp/slots.js +27 -0
- data/app/javascript/bh/otp_controller.js +98 -0
- data/app/javascript/bh/phone_controller.js +89 -0
- data/app/javascript/bh/placeholder_controller.js +28 -0
- data/app/javascript/bh/relative_time_controller.js +45 -0
- data/app/javascript/bh/require_controller.js +18 -0
- data/app/javascript/bh/reveal_controller.js +16 -0
- data/app/javascript/bh/scheme_controller.js +99 -0
- data/app/javascript/bh/search_controller.js +78 -0
- data/app/javascript/bh/shortcuts_controller.js +42 -0
- data/app/javascript/bh/sortable_controller.js +84 -0
- data/app/javascript/bh/thread_controller.js +47 -0
- data/app/javascript/bh/timezone_controller.js +27 -0
- data/app/javascript/bh/toast_controller.js +35 -0
- data/app/javascript/bh/tooltip_controller.js +23 -0
- data/app/javascript/bh/written.js +20 -0
- data/app/javascript/bh/written_controller.js +28 -0
- data/app/javascript/bh.js +60 -0
- data/app/stylesheets/bh/base.css +27 -0
- data/app/stylesheets/bh/chat.css +77 -0
- data/app/stylesheets/bh/flow.css +94 -0
- data/app/stylesheets/bh/map.css +3 -0
- data/app/stylesheets/bh/pin.css +34 -0
- data/app/stylesheets/bh/search.css +60 -0
- data/app/stylesheets/bh/shell.css +202 -0
- data/app/stylesheets/bh/table.css +130 -0
- data/app/stylesheets/bh/values.css +41 -0
- data/app/stylesheets/bh.css +13 -0
- data/build.mjs +15 -0
- data/config/locales/bh.en.yml +13 -0
- data/lib/bh/engine.rb +21 -6
- data/lib/bh/form_builder/comboboxes.rb +38 -0
- data/lib/bh/form_builder/controls.rb +67 -0
- data/lib/bh/form_builder/pins.rb +30 -0
- data/lib/bh/form_builder.rb +96 -0
- data/lib/bh/helpers/chats/asides.rb +49 -0
- data/lib/bh/helpers/chats/asks.rb +41 -0
- data/lib/bh/helpers/chats.rb +42 -0
- data/lib/bh/helpers/dialogs.rb +47 -0
- data/lib/bh/helpers/flows.rb +49 -0
- data/lib/bh/helpers/heads.rb +27 -0
- data/lib/bh/helpers/notices.rb +55 -0
- data/lib/bh/helpers.rb +12 -0
- data/lib/bh/message.rb +11 -0
- data/lib/bh/version.rb +2 -3
- data/lib/bh.rb +8 -6
- data/package.json +32 -0
- data/public/bh/css/bh.css +1 -0
- data/public/bh/js/bh.js +152 -0
- data/vendor/bootstrap.bundle.min.js +9 -0
- data/vendor/bootstrap.min.css +2 -0
- metadata +101 -23
- data/.gitignore +0 -23
- data/.rspec +0 -3
- data/.travis.yml +0 -15
- data/.yardopts +0 -3
- data/Gemfile +0 -4
- data/app/assets/stylesheets/bh.css +0 -29
- data/app/javascript/controllers/bh/theme_controller.js +0 -16
- data/app/javascript/controllers/phone_controller.js +0 -33
- data/app/javascript/controllers/require_controller.js +0 -20
- data/app/javascript/controllers/submit_controller.js +0 -24
- data/app/views/bh/_grid.html.erb +0 -6
- data/app/views/bh/_table.html.erb +0 -44
- data/bh.gemspec +0 -19
- data/lib/bh/bootstrap_helpers.rb +0 -137
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// Option is the modifier. Control and Command are spoken for — Control+C and
|
|
4
|
+
// Command+C are both copy — while Option is what a browser's own accesskey reaches
|
|
5
|
+
// for on most platforms, and it collides with almost nothing a page would want.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
connect() {
|
|
8
|
+
this.reveal = (event) => this.element.classList.toggle('recourse-keys', event.altKey)
|
|
9
|
+
this.hide = () => this.element.classList.remove('recourse-keys')
|
|
10
|
+
this.press = (event) => this.#press(event)
|
|
11
|
+
|
|
12
|
+
document.addEventListener('keydown', this.reveal)
|
|
13
|
+
document.addEventListener('keyup', this.reveal)
|
|
14
|
+
document.addEventListener('keydown', this.press)
|
|
15
|
+
// Holding Option and switching windows would otherwise leave the hints up.
|
|
16
|
+
window.addEventListener('blur', this.hide)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
document.removeEventListener('keydown', this.reveal)
|
|
21
|
+
document.removeEventListener('keyup', this.reveal)
|
|
22
|
+
document.removeEventListener('keydown', this.press)
|
|
23
|
+
window.removeEventListener('blur', this.hide)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#press(event) {
|
|
27
|
+
if (!event.altKey || event.ctrlKey || event.metaKey) { return }
|
|
28
|
+
|
|
29
|
+
// On a Mac, Option+c is 'ç', so the key pressed is read from the keyboard's own
|
|
30
|
+
// layout-independent code rather than from the character it produced.
|
|
31
|
+
const pressed = event.code.match(/^Key([A-Z])$/)
|
|
32
|
+
if (!pressed) { return }
|
|
33
|
+
|
|
34
|
+
const link = this.element.querySelector(`[data-key="${pressed[1].toLowerCase()}"]`)
|
|
35
|
+
if (!link) { return }
|
|
36
|
+
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
this.hide()
|
|
39
|
+
// Clicked rather than assigned to, so Turbo takes the visit like any other link.
|
|
40
|
+
link.click()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import Sortable from 'sortablejs'
|
|
3
|
+
import { flash } from './flash.js'
|
|
4
|
+
|
|
5
|
+
// Drags a row of an arranged table to another place in it. Only the row that moved is
|
|
6
|
+
// reported, as the place it landed in: the server shifts whatever it displaced,
|
|
7
|
+
// because it is the only one that knows how many rows there are behind this page.
|
|
8
|
+
//
|
|
9
|
+
// The rows are a page rather than the table, so the index a drop reports is short by
|
|
10
|
+
// whatever the pages before it hold — `offset` is what makes up the difference, and a
|
|
11
|
+
// drag therefore moves a row within the page it is on.
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
static values = { offset: Number, message: String }
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
this.sortable = Sortable.create(this.element, {
|
|
17
|
+
handle: '.recourse-handle',
|
|
18
|
+
animation: 150,
|
|
19
|
+
onStart: this.hold,
|
|
20
|
+
onEnd: this.drop,
|
|
21
|
+
})
|
|
22
|
+
// A refresh broadcast morphs the page, and one arriving mid-drag would rewrite the
|
|
23
|
+
// rows under the cursor. The search form sits a morph out the same way.
|
|
24
|
+
document.addEventListener('turbo:before-morph-element', this.freeze)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
disconnect() {
|
|
28
|
+
this.sortable.destroy()
|
|
29
|
+
document.removeEventListener('turbo:before-morph-element', this.freeze)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Arrows, so `this` survives being handed to a listener and the same function object
|
|
33
|
+
// is the one removed again.
|
|
34
|
+
hold = () => {
|
|
35
|
+
this.dragging = true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
drop = ({ item, newIndex, oldIndex }) => {
|
|
39
|
+
this.dragging = false
|
|
40
|
+
if (newIndex === oldIndex) return
|
|
41
|
+
|
|
42
|
+
this.send(item.dataset.sortableUpdateUrl, this.offsetValue + newIndex + 1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
freeze = (event) => {
|
|
46
|
+
if (this.dragging && this.element.contains(event.target)) event.preventDefault()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Nothing is rendered from the answer: the row is already where it was dropped, and
|
|
50
|
+
// redrawing the table under the cursor that dropped it is what this avoids. It is
|
|
51
|
+
// still read, so a move the server refused does not sit there looking saved.
|
|
52
|
+
async send(url, position) {
|
|
53
|
+
const body = new FormData()
|
|
54
|
+
body.append('position', position)
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(url, {
|
|
58
|
+
method: 'PATCH',
|
|
59
|
+
body,
|
|
60
|
+
headers: { Accept: 'application/json', 'X-CSRF-Token': this.token },
|
|
61
|
+
})
|
|
62
|
+
if (!response.ok) return this.revert()
|
|
63
|
+
|
|
64
|
+
// Said only once the row is actually written. The drop moved it on the screen and
|
|
65
|
+
// would have moved it under a request that never landed, so this is the half of
|
|
66
|
+
// the report only the server can give.
|
|
67
|
+
flash(this.messageValue, 'theme-success')
|
|
68
|
+
} catch {
|
|
69
|
+
this.revert()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Put the table back the way the database has it, since the row is sitting somewhere
|
|
74
|
+
// the server never agreed to.
|
|
75
|
+
revert() {
|
|
76
|
+
this.element.closest('turbo-frame')?.reload()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The token in the head rather than one in a form: there is no form here, and the
|
|
80
|
+
// head's is global to the session and fresh per request.
|
|
81
|
+
get token() {
|
|
82
|
+
return document.querySelector('meta[name="csrf-token"]')?.content
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// A thread opens at its newest message with the field ready, and stays at the newest as
|
|
4
|
+
// answers arrive. Every render undoes both -- `autofocus` included, which is why sending a
|
|
5
|
+
// question landed back here with nothing focused -- so it is said again on each one rather
|
|
6
|
+
// than only when Stimulus connects, which a morphed-in answer never triggers.
|
|
7
|
+
//
|
|
8
|
+
// The field itself Turbo is told to leave alone, since an answer landing morphs the page and
|
|
9
|
+
// the server's empty box would be written over a question half typed. Nothing else then
|
|
10
|
+
// empties it, so this does, once the question it held has actually gone.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static targets = ['messages', 'field']
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
this.following = true
|
|
16
|
+
this.follow = () => { this.following = this.#atBottom() }
|
|
17
|
+
this.settle = () => requestAnimationFrame(() => this.#settle())
|
|
18
|
+
this.empty = () => { if (this.hasFieldTarget) { this.fieldTarget.value = '' } }
|
|
19
|
+
|
|
20
|
+
if (this.hasMessagesTarget) { this.messagesTarget.addEventListener('scroll', this.follow) }
|
|
21
|
+
document.addEventListener('turbo:render', this.settle)
|
|
22
|
+
this.element.addEventListener('turbo:submit-end', this.empty)
|
|
23
|
+
this.settle()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
disconnect() {
|
|
27
|
+
if (this.hasMessagesTarget) { this.messagesTarget.removeEventListener('scroll', this.follow) }
|
|
28
|
+
document.removeEventListener('turbo:render', this.settle)
|
|
29
|
+
this.element.removeEventListener('turbo:submit-end', this.empty)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Left where the reader put it once they have scrolled up to read back: an answer landing is
|
|
33
|
+
// no reason to pull the thread out from under them. The field takes focus without the page
|
|
34
|
+
// scrolling to it: a chat below the fold, the showcase's say, must not drag the page down
|
|
35
|
+
// on load, and a chat page is sized to the window, so there the field is in view already.
|
|
36
|
+
#settle() {
|
|
37
|
+
if (this.hasFieldTarget) { this.fieldTarget.focus({ preventScroll: true }) }
|
|
38
|
+
if (this.hasMessagesTarget && this.following) {
|
|
39
|
+
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#atBottom() {
|
|
44
|
+
const { scrollTop, scrollHeight, clientHeight } = this.messagesTarget
|
|
45
|
+
return scrollHeight - scrollTop - clientHeight < 24
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// The reader's own zone, told to the server so it can draw their pages against their
|
|
4
|
+
// clock. A cookie rather than local storage, for the reason the page size uses one:
|
|
5
|
+
// the server does the converting, and a cookie is the only storage the server is sent.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static values = { storage: String }
|
|
8
|
+
|
|
9
|
+
// The page in front of the reader was drawn before this cookie existed, so the first
|
|
10
|
+
// visit from a browser — and the first after they travel — is drawn against the
|
|
11
|
+
// host's zone and then asked for again. Only when the cookie disagrees, so every
|
|
12
|
+
// other visit costs nothing: this writes what is already there and stops.
|
|
13
|
+
connect() {
|
|
14
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
15
|
+
if (!zone || zone === this.#stored()) { return }
|
|
16
|
+
|
|
17
|
+
document.cookie =
|
|
18
|
+
`${this.storageValue}=${encodeURIComponent(zone)}; path=/; max-age=31536000; samesite=lax`
|
|
19
|
+
window.Turbo.visit(window.location.href, { action: 'replace' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#stored() {
|
|
23
|
+
const row = document.cookie.split(/;\s*/).find((one) => one.startsWith(`${this.storageValue}=`))
|
|
24
|
+
|
|
25
|
+
return row ? decodeURIComponent(row.split('=')[1]) : null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { Toast } from 'bootstrap'
|
|
3
|
+
import { DELAY } from './written.js'
|
|
4
|
+
|
|
5
|
+
// The toast arrives from the server already shown, so Bootstrap's show() must never
|
|
6
|
+
// run: it re-adds `showing` and blinks the toast through transparent. But show() is
|
|
7
|
+
// also the only place Bootstrap arms its autohide, so the timer lives here instead,
|
|
8
|
+
// and only the hiding is Bootstrap's — the timed hide and the dismiss X then share
|
|
9
|
+
// one code path and one fade.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static values = { delay: { type: Number, default: DELAY } }
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
// `autohide: false` keeps Bootstrap from arming a rival timer if anything ever
|
|
15
|
+
// does call show() on this element.
|
|
16
|
+
this.toast = Toast.getOrCreateInstance(this.element, { autohide: false })
|
|
17
|
+
this.startTimer()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.stopTimer()
|
|
22
|
+
this.toast.dispose()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Reading the message, or aiming for the X, holds the toast open — Bootstrap's
|
|
26
|
+
// own pause-on-hover only guards the timer *it* armed, so it is redone here.
|
|
27
|
+
stopTimer() {
|
|
28
|
+
clearTimeout(this.timeout)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A fresh full delay on leave, which is also what Bootstrap re-arms.
|
|
32
|
+
startTimer() {
|
|
33
|
+
this.timeout = setTimeout(() => this.toast.hide(), this.delayValue)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { Tooltip } from 'bootstrap'
|
|
3
|
+
|
|
4
|
+
// The tooltip naming an icon-only heading. Bootstrap never wires one on its own,
|
|
5
|
+
// and a Stimulus lifecycle is what survives Turbo redrawing the table: connect
|
|
6
|
+
// makes it, disconnect takes it down before the element goes, so a sorted or
|
|
7
|
+
// searched table never strands one over an element that left.
|
|
8
|
+
//
|
|
9
|
+
// None where nothing can hover: on a touch screen the tap that would open a link
|
|
10
|
+
// is also what shows a tooltip, and a word popping up over a tap reads as a hitch.
|
|
11
|
+
// Asked two ways, since an iPhone answered the first with a tooltip all the same: a
|
|
12
|
+
// screen that cannot hover, or a pointer too coarse to rest on one word of a row.
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
connect() {
|
|
15
|
+
if (matchMedia('(hover: none), (pointer: coarse)').matches) return
|
|
16
|
+
|
|
17
|
+
this.tooltip = Tooltip.getOrCreateInstance(this.element)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.tooltip?.dispose()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The mark a create or an update leaves on the row it landed on, and how long the toast
|
|
2
|
+
// saying so stands — the one clock, which the toast controller reads too.
|
|
3
|
+
export const DELAY = 2000
|
|
4
|
+
|
|
5
|
+
// Marked, and handed back the way to let it go. Letting go swaps one class for the
|
|
6
|
+
// other rather than adding to it: the fade is an animation, and while the first class
|
|
7
|
+
// is still on the row it would paint the tint straight back the moment that animation
|
|
8
|
+
// ended. Off the row entirely, what paints it afterwards is whatever else the cascade
|
|
9
|
+
// says — the tint of a kept row, or nothing.
|
|
10
|
+
export function mark(row) {
|
|
11
|
+
if (!row) { return () => {} }
|
|
12
|
+
|
|
13
|
+
row.classList.remove('recourse-written-out')
|
|
14
|
+
row.classList.add('recourse-written')
|
|
15
|
+
|
|
16
|
+
return () => {
|
|
17
|
+
row.classList.remove('recourse-written')
|
|
18
|
+
row.classList.add('recourse-written-out')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { mark } from './written.js'
|
|
3
|
+
|
|
4
|
+
// The row a create or an update just landed on, marked for exactly as long as the
|
|
5
|
+
// toast that says so. One clock rather than two: the mark goes when the message goes,
|
|
6
|
+
// however long somebody held the toast open by reading it.
|
|
7
|
+
//
|
|
8
|
+
// `hide`, never `hidden`. Bootstrap fires the first as the toast begins to fade and the
|
|
9
|
+
// second only once it has finished, a whole second later — which is a mark still lit
|
|
10
|
+
// over a message that has already gone.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = { row: String }
|
|
13
|
+
|
|
14
|
+
// Only where the row is on this page — a record can be written onto a page it does
|
|
15
|
+
// not appear on, sorted or filtered or paged away — and only where a toast is here
|
|
16
|
+
// to end it, since nothing else would.
|
|
17
|
+
connect() {
|
|
18
|
+
const row = document.getElementById(this.rowValue)
|
|
19
|
+
if (!row || !this.element.querySelector('.toast')) { return }
|
|
20
|
+
|
|
21
|
+
this.fade = mark(row)
|
|
22
|
+
this.element.addEventListener('hide.bs.toast', this.fade)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
if (this.fade) { this.element.removeEventListener('hide.bs.toast', this.fade) }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Everything a page built on Bootstrap 6 runs, in one module: Turbo with the cable element
|
|
2
|
+
// turbo-rails signs its streams for, Bootstrap itself, and a Stimulus application with every
|
|
3
|
+
// controller this gem's markup names. Built by esbuild into /js/bh.js.
|
|
4
|
+
import '@hotwired/turbo-rails'
|
|
5
|
+
import 'bootstrap'
|
|
6
|
+
import { Application } from '@hotwired/stimulus'
|
|
7
|
+
import confirm from './bh/confirm.js'
|
|
8
|
+
import BookmarkController from './bh/bookmark_controller.js'
|
|
9
|
+
import ClearController from './bh/clear_controller.js'
|
|
10
|
+
import ComboboxController from './bh/combobox_controller.js'
|
|
11
|
+
import DensityController from './bh/density_controller.js'
|
|
12
|
+
import DeselectController from './bh/deselect_controller.js'
|
|
13
|
+
import LimitController from './bh/limit_controller.js'
|
|
14
|
+
import MapController from './bh/map_controller.js'
|
|
15
|
+
import OtpController from './bh/otp_controller.js'
|
|
16
|
+
import PhoneController from './bh/phone_controller.js'
|
|
17
|
+
import PlaceholderController from './bh/placeholder_controller.js'
|
|
18
|
+
import RelativeTimeController from './bh/relative_time_controller.js'
|
|
19
|
+
import RequireController from './bh/require_controller.js'
|
|
20
|
+
import RevealController from './bh/reveal_controller.js'
|
|
21
|
+
import SchemeController from './bh/scheme_controller.js'
|
|
22
|
+
import SearchController from './bh/search_controller.js'
|
|
23
|
+
import ShortcutsController from './bh/shortcuts_controller.js'
|
|
24
|
+
import SortableController from './bh/sortable_controller.js'
|
|
25
|
+
import ThreadController from './bh/thread_controller.js'
|
|
26
|
+
import TimezoneController from './bh/timezone_controller.js'
|
|
27
|
+
import ToastController from './bh/toast_controller.js'
|
|
28
|
+
import TooltipController from './bh/tooltip_controller.js'
|
|
29
|
+
import WrittenController from './bh/written_controller.js'
|
|
30
|
+
|
|
31
|
+
// Guarded: a module runs once, but a host that also loads this file from its own
|
|
32
|
+
// layout on a Turbo visit would otherwise connect every controller a second time.
|
|
33
|
+
if (!window.Stimulus) {
|
|
34
|
+
window.Stimulus = Application.start()
|
|
35
|
+
window.Stimulus.register('bookmark', BookmarkController)
|
|
36
|
+
window.Stimulus.register('clear', ClearController)
|
|
37
|
+
window.Stimulus.register('combobox', ComboboxController)
|
|
38
|
+
window.Stimulus.register('density', DensityController)
|
|
39
|
+
window.Stimulus.register('deselect', DeselectController)
|
|
40
|
+
window.Stimulus.register('limit', LimitController)
|
|
41
|
+
window.Stimulus.register('map', MapController)
|
|
42
|
+
window.Stimulus.register('otp', OtpController)
|
|
43
|
+
window.Stimulus.register('phone', PhoneController)
|
|
44
|
+
window.Stimulus.register('placeholder', PlaceholderController)
|
|
45
|
+
window.Stimulus.register('relative-time', RelativeTimeController)
|
|
46
|
+
window.Stimulus.register('require', RequireController)
|
|
47
|
+
window.Stimulus.register('reveal', RevealController)
|
|
48
|
+
window.Stimulus.register('scheme', SchemeController)
|
|
49
|
+
window.Stimulus.register('search', SearchController)
|
|
50
|
+
window.Stimulus.register('shortcuts', ShortcutsController)
|
|
51
|
+
window.Stimulus.register('sortable', SortableController)
|
|
52
|
+
window.Stimulus.register('thread', ThreadController)
|
|
53
|
+
window.Stimulus.register('timezone', TimezoneController)
|
|
54
|
+
window.Stimulus.register('toast', ToastController)
|
|
55
|
+
window.Stimulus.register('tooltip', TooltipController)
|
|
56
|
+
window.Stimulus.register('written', WrittenController)
|
|
57
|
+
|
|
58
|
+
// The browser's confirm() becomes a Bootstrap dialog the module draws on the first ask.
|
|
59
|
+
window.Turbo.config.forms.confirm = confirm
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Body text is Bootstrap's own: the system face at 16px. A host wanting another sets
|
|
2
|
+
`--bs-body-font-family` and `--bs-body-font-size` on `:root` after this stylesheet. */
|
|
3
|
+
|
|
4
|
+
/* Bootstrap reserves the scrollbar's column on every page, scrolling or not, so pages line
|
|
5
|
+
up whether they grow or not; a browser with classic scrollbars then paints an empty track
|
|
6
|
+
down the right of a page that fits. The column comes and goes with the scrollbar here. */
|
|
7
|
+
:root { scrollbar-gutter: auto }
|
|
8
|
+
|
|
9
|
+
/* A dialog reads from the start whatever centers the text around the link that opened it:
|
|
10
|
+
`dialog` draws the two side by side, so the element inherits from the link's place. */
|
|
11
|
+
.dialog { text-align: start }
|
|
12
|
+
|
|
13
|
+
/* Bootstrap's .15s fade reads as vanishing. Only the hide ever transitions here
|
|
14
|
+
— the toast is born visible — and reduced-motion still wins, because its rule
|
|
15
|
+
sets `transition: none` outright rather than through this variable. */
|
|
16
|
+
.toast { --bs-transition-fade: opacity 1s linear; }
|
|
17
|
+
|
|
18
|
+
/* A marked match is part of the word it sits in, so it carries no padding of
|
|
19
|
+
its own: 'Nash' in 'Nashville' must not read as a word standing apart. */
|
|
20
|
+
mark { padding: 0; }
|
|
21
|
+
|
|
22
|
+
/* The icon font drops every glyph an eighth of an em to sit on a text
|
|
23
|
+
baseline, which inside an icon link reads as the arrow resting on the
|
|
24
|
+
line. A sixteenth up clears the baseline without floating — on the
|
|
25
|
+
::before, so the hover transform on the .bi box itself is untouched. */
|
|
26
|
+
.icon-link > .bi::before { vertical-align: .0625em; }
|
|
27
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* A conversation as a column of bubbles: what the reader said on the right in the
|
|
2
|
+
primary's tint, what was said to them on the left in the page's second surface. A
|
|
3
|
+
chat is drawn by the `chat_with` helper and wears these. */
|
|
4
|
+
.chat { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem }
|
|
5
|
+
/* Sits at the foot of its box while short, so the first turn lands just above the field
|
|
6
|
+
that sends the next. An auto margin and not `align-content: end`, which once the thread
|
|
7
|
+
overflows pushes its first message clean out of the top with no way to scroll back. */
|
|
8
|
+
.chat > :first-child { margin-block-start: auto }
|
|
9
|
+
|
|
10
|
+
/* Who said it and when, small, over the bubble it belongs to and on its side. */
|
|
11
|
+
.chat-aside { font-size: var(--bs-font-size-sm); color: var(--bs-fg-2); margin: 0 0.75rem }
|
|
12
|
+
.chat-aside-out { align-self: flex-end; margin-inline-end: 1.25rem }
|
|
13
|
+
.chat-aside-in { align-self: flex-start }
|
|
14
|
+
.chat-aside .fg-success, .chat-aside .fg-danger, .chat-aside .fg-secondary { font-weight: var(--bs-font-weight-semibold) }
|
|
15
|
+
|
|
16
|
+
.chat-bubble {
|
|
17
|
+
max-width: 85%;
|
|
18
|
+
margin: 0;
|
|
19
|
+
/* A conversation is prose, so it reads at its own size whatever scale the host
|
|
20
|
+
set for itself: an admin page compacts its body type for tables dense with figures,
|
|
21
|
+
and a sentence somebody typed is not one of those. */
|
|
22
|
+
font-size: 1rem;
|
|
23
|
+
padding: 0.625rem 0.875rem;
|
|
24
|
+
border: 1px solid;
|
|
25
|
+
border-radius: 1rem;
|
|
26
|
+
/* The same sheen a message thread's bubbles wear. Set as an image so the two sides below
|
|
27
|
+
can name their own color without wiping it, which the `background` shorthand would. */
|
|
28
|
+
background-image: var(--bs-gradient);
|
|
29
|
+
/* An unbroken token wraps rather than widening the bubble past the phone. Whitespace is
|
|
30
|
+
collapsed as anywhere else: a host's line breaks arrive as markup, and the newlines and
|
|
31
|
+
indentation a template leaves around its words must not become room in the bubble. */
|
|
32
|
+
overflow-wrap: anywhere;
|
|
33
|
+
}
|
|
34
|
+
/* The reader's own turn, tinted with the primary rather than filled with it: a solid fill
|
|
35
|
+
under white text is what a button wears, and a question that reads as a button invites a
|
|
36
|
+
press. Squared at the corner nearest them, which tells the two sides apart at a glance. */
|
|
37
|
+
.chat-out {
|
|
38
|
+
align-self: flex-end;
|
|
39
|
+
/* Clear of the scrollbar a long thread puts down the same edge. */
|
|
40
|
+
margin-inline-end: 0.5rem;
|
|
41
|
+
color: var(--bs-primary-fg-emphasis);
|
|
42
|
+
background-color: var(--bs-primary-bg-subtle);
|
|
43
|
+
border-color: var(--bs-primary-border);
|
|
44
|
+
border-end-end-radius: 0.25rem;
|
|
45
|
+
}
|
|
46
|
+
/* The other side's: the page's second surface, squared at its own corner. */
|
|
47
|
+
.chat-in {
|
|
48
|
+
align-self: flex-start;
|
|
49
|
+
color: var(--bs-fg-1);
|
|
50
|
+
background-color: var(--bs-bg-2);
|
|
51
|
+
border-color: var(--bs-border-color);
|
|
52
|
+
border-end-start-radius: 0.25rem;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* An answer on its way reads as one being typed. */
|
|
56
|
+
.chat-typing { display: flex; gap: 0.25rem; align-items: center }
|
|
57
|
+
.chat-typing span {
|
|
58
|
+
width: 0.375rem;
|
|
59
|
+
height: 0.375rem;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
background: var(--bs-fg-2);
|
|
62
|
+
animation: chat-dot 1.2s ease-in-out infinite;
|
|
63
|
+
}
|
|
64
|
+
.chat-typing span:nth-child(2) { animation-delay: 0.15s }
|
|
65
|
+
.chat-typing span:nth-child(3) { animation-delay: 0.3s }
|
|
66
|
+
@keyframes chat-dot { 0%, 60%, 100% { opacity: 0.25 } 30% { opacity: 1 } }
|
|
67
|
+
@media (prefers-reduced-motion: reduce) {
|
|
68
|
+
.chat-typing span { animation: none; opacity: 0.5 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* The field's suggestion fades rather than cutting, so a swap mid-read is not a flicker;
|
|
72
|
+
the `placeholder` controller adds the class while it swaps the words. */
|
|
73
|
+
.chat-ask::placeholder { opacity: 1; transition: opacity 0.3s ease-in-out }
|
|
74
|
+
.chat-ask.is-fading::placeholder { opacity: 0 }
|
|
75
|
+
@media (prefers-reduced-motion: reduce) {
|
|
76
|
+
.chat-ask::placeholder { transition: none }
|
|
77
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* The page `flow` draws: a column taking the rest of the window so the foot rests at the
|
|
2
|
+
bottom of a tall screen and scrolls into view on a short one, everything centered. */
|
|
3
|
+
.flow { flex: 1; display: flex; flex-direction: column; text-align: center }
|
|
4
|
+
.flow > header { display: flex; justify-content: center; margin-bottom: 2rem }
|
|
5
|
+
.flow > h1 { margin: 0 0 0.75rem; font-size: clamp(1.5rem, 4vw, 2rem); font-weight: 700; letter-spacing: -0.02em }
|
|
6
|
+
.flow > p { margin: 0 0 1.5rem }
|
|
7
|
+
/* `display: contents`, so a `button_to` form stops being a box and its button becomes a flex
|
|
8
|
+
item, which is what lets the middot space evenly between two words. A button in the foot is
|
|
9
|
+
a word among words: the foot's size, no padding and no height of its own. */
|
|
10
|
+
.flow > footer { flex: none; margin-top: auto; padding-top: 2.5rem; display: flex; align-items: baseline; justify-content: center; gap: 0.5rem; font-size: 0.875rem }
|
|
11
|
+
.flow > footer form { display: contents }
|
|
12
|
+
.flow > footer .btn { font-size: inherit; padding: 0; min-height: 0 }
|
|
13
|
+
@media (max-width: 575px) { .flow > header { margin-bottom: 1.75rem } }
|
|
14
|
+
|
|
15
|
+
/* The one element on the page allowed a fill, a border and a shadow at once, because it holds
|
|
16
|
+
the only thing the page is for. As wide as the page said, `--flow-width`, and nine tenths of
|
|
17
|
+
the page color rather than `opacity`, which would fade the border and the words with it. */
|
|
18
|
+
.flow-card {
|
|
19
|
+
width: 100%;
|
|
20
|
+
max-width: var(--flow-width, 75rem);
|
|
21
|
+
background: color-mix(in srgb, var(--bs-bg-body) 90%, transparent);
|
|
22
|
+
border: 1px solid var(--bs-primary-border);
|
|
23
|
+
border-radius: 0.375rem;
|
|
24
|
+
box-shadow: -0.5rem 1rem 2.25rem -0.75rem rgb(6 22 40 / 40%);
|
|
25
|
+
}
|
|
26
|
+
/* Centered on the controls rather than the form, since a field does not inherit it and a
|
|
27
|
+
form helper may hard-code `text-start`. */
|
|
28
|
+
/* The card is read down the middle, and so is what a form says about itself: the legend over a
|
|
29
|
+
group, a submit, a single box a page is nothing but. A row is the exception -- its words and
|
|
30
|
+
the box under them are read from where they start, since a label centered over the box it
|
|
31
|
+
names leaves the eye hunting for where each one begins. */
|
|
32
|
+
.flow-card .form-label, .flow-card .form-control, .flow-card .invalid-feedback { text-align: center }
|
|
33
|
+
.flow-card form:has(.flow-field ~ .flow-field, .form-check ~ .form-check) :is(.flow-field, .form-check),
|
|
34
|
+
.flow-card form:has(.flow-field ~ .flow-field, .form-check ~ .form-check) .flow-field .form-label,
|
|
35
|
+
.flow-card form:has(.flow-field ~ .flow-field, .form-check ~ .form-check) .flow-field .form-control { text-align: start }
|
|
36
|
+
|
|
37
|
+
/* A ticked box and the words beside it are a row of two, with room between them: Bootstrap
|
|
38
|
+
hangs the box in the padding by a negative margin, which puts it under centered words. */
|
|
39
|
+
.flow-card .form-check { display: flex; align-items: start; gap: 1rem; padding-left: 0 }
|
|
40
|
+
.flow-card .form-check .form-check-input { flex: none; margin: 0.25em 0 0 }
|
|
41
|
+
|
|
42
|
+
/* A group of controls carries its own spacing, and twice as much under it as over: the words
|
|
43
|
+
over a group belong to it, and even space either side would let them drift between two. */
|
|
44
|
+
.flow-fieldset { margin-block: 1rem 2rem; gap: 1rem }
|
|
45
|
+
|
|
46
|
+
/* The words and the control they name, as one cell, which is what lets a form be laid out in
|
|
47
|
+
columns however much markup a control draws for itself. Named for the flow rather than the
|
|
48
|
+
form: Bootstrap has a `form-field` of its own, whose rules sized our words to themselves and
|
|
49
|
+
held them at the left of a row they were meant to be centered in. */
|
|
50
|
+
.flow-card .flow-field { display: grid; align-content: start }
|
|
51
|
+
|
|
52
|
+
/* A card wide enough for two columns takes them, and everything else in the fieldset -- the
|
|
53
|
+
legend, a submit -- keeps the whole row. The card is what is measured rather than the
|
|
54
|
+
window, so the narrow card of a sign-in page stays one column on the widest screen. */
|
|
55
|
+
.flow-card { container-type: inline-size }
|
|
56
|
+
@container (min-width: 40rem) {
|
|
57
|
+
.flow-card fieldset.d-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 1rem }
|
|
58
|
+
.flow-card fieldset.d-grid > :not(.flow-field) { grid-column: 1 / -1 }
|
|
59
|
+
}
|
|
60
|
+
/* A rule between two ways of doing the same thing, with room either side of it: a card is
|
|
61
|
+
short enough that a thin line needs the space to read as a divide rather than as a scratch. */
|
|
62
|
+
.flow-card hr { margin-block: 2rem }
|
|
63
|
+
|
|
64
|
+
/* The same edge the card wears, rather than Bootstrap's fainter default. */
|
|
65
|
+
.flow-card .dialog { --bs-dialog-border-color: var(--bs-primary-border) }
|
|
66
|
+
|
|
67
|
+
/* A card holding something longer than the page takes the rest of the window and scrolls that
|
|
68
|
+
part inside itself, so the line at its head, whatever sits at its foot and the way out under
|
|
69
|
+
the card stay in sight however long it gets. A thread is one such thing and says so by its
|
|
70
|
+
own markup; anything else -- a contract, a table -- says `flow-scroll` on the part that
|
|
71
|
+
scrolls. `flex: none` before the height: the column is a flex item of `main`, and `flex: 1`
|
|
72
|
+
would size it to its content and let the page grow and scroll instead. `min-height: 0` down
|
|
73
|
+
the chain, since a flex item will not shrink below its content without it. The 2rem is the
|
|
74
|
+
layout's `py-4`, the gutter above and below `main`. A card given a height cannot always
|
|
75
|
+
honour it -- a short window leaves less room than what does not scroll needs -- so it
|
|
76
|
+
scrolls whole rather than letting what it holds out past its own edges. */
|
|
77
|
+
.flow:has(.chat-thread, .flow-scroll, .flow-middle) { flex: none; height: calc(100svh - 2rem) }
|
|
78
|
+
.flow-card:has(.chat-thread, .flow-scroll, .flow-middle) { display: flex; flex-direction: column; flex: 1; min-height: 15rem; overflow: auto }
|
|
79
|
+
.flow-card .chat-thread,
|
|
80
|
+
.flow-card > :has(.flow-scroll) { display: flex; flex-direction: column; flex: 1; min-height: 0 }
|
|
81
|
+
.flow-card .chat, .flow-card .flow-scroll { flex: 1; min-height: 0; overflow-y: auto }
|
|
82
|
+
.flow-card .chat-ask { text-align: center }
|
|
83
|
+
/* A bubble reads from its start, though: a flow centers a card's words, and an answer long
|
|
84
|
+
enough to wrap is a paragraph, which is read down an edge rather than about a middle. */
|
|
85
|
+
.flow-card .chat-bubble { text-align: start }
|
|
86
|
+
|
|
87
|
+
/* A card standing as tall as those but with little in it: what it holds sits in the middle of
|
|
88
|
+
it, down as well as across, rather than at the top of a sheet of white. */
|
|
89
|
+
.flow-card .flow-middle { flex: 1; display: flex; flex-direction: column; justify-content: center }
|
|
90
|
+
/* On a phone the card is the page: it breaks out of the gutter with `margin-inline` rather
|
|
91
|
+
than a width, so it stays in the flow, and loses the side border and the corners with it. */
|
|
92
|
+
@media (width < 768px) {
|
|
93
|
+
.flow-card:has(.chat-thread) { width: auto; margin-inline: calc(50% - 50vw); padding-inline: calc(50vw - 50%); border-inline: 0; border-radius: 0 }
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* The field a 6-digit code is typed into: one real field, drawn by Bootstrap as six slots.
|
|
2
|
+
`pin_field` draws the markup and the `otp` controller draws the slots. */
|
|
3
|
+
|
|
4
|
+
/* Bootstrap's OTP container is a flex row, so it has to be told to center where its card is
|
|
5
|
+
centered and to let the message drop onto a line of its own under the slots. `min-width: 0`,
|
|
6
|
+
or the grid item will not shrink below the width its slots come to, which is the whole
|
|
7
|
+
row hanging off the side of the card before the slots are asked to fit. */
|
|
8
|
+
.otp { justify-content: center; flex-wrap: wrap; min-width: 0 }
|
|
9
|
+
/* Six slots at a fixed width plus the five gaps between them come to 328 pixels, and a
|
|
10
|
+
phone's card holds 327 — so the last slot sat outside it. Sized off the row instead, they
|
|
11
|
+
share whatever there is and never exceed the width they were drawn at. */
|
|
12
|
+
.otp-slots { display: flex; justify-content: center; width: 100% }
|
|
13
|
+
.otp-slot { flex: 1 1 0; min-width: 0; max-width: var(--bs-otp-size) }
|
|
14
|
+
.otp .invalid-feedback { order: 1; flex-basis: 100%; margin-top: 0.5rem }
|
|
15
|
+
|
|
16
|
+
/* .form-control.is-invalid would outrank .otp-input on specificity and draw one red box
|
|
17
|
+
across all six slots; the field takes no such class, and Bootstrap reddens the slots
|
|
18
|
+
themselves from the container. */
|
|
19
|
+
.otp .otp-input.is-invalid { border: 0; box-shadow: none }
|
|
20
|
+
|
|
21
|
+
/* Bootstrap builds the six slots in JavaScript, so one box is what the server can send and
|
|
22
|
+
what the reader sees until they land. Held back for that moment — but on a class the
|
|
23
|
+
controller itself sets, so a controller that never runs leaves a field somebody can still
|
|
24
|
+
type a code into rather than an invisible one. */
|
|
25
|
+
.otp-drawing:not(.otp-rendered) .otp-input { visibility: hidden }
|
|
26
|
+
|
|
27
|
+
/* Bootstrap lays the real field transparently over the slots, so an autofilled one painted
|
|
28
|
+
yellow paints over them. The text stays invisible, and the fill is deferred by longer than
|
|
29
|
+
anybody's session rather than overridden, which is the only thing that holds it off. */
|
|
30
|
+
.otp-input:-webkit-autofill,
|
|
31
|
+
.otp-input:-webkit-autofill:focus {
|
|
32
|
+
-webkit-text-fill-color: transparent;
|
|
33
|
+
transition: background-color 0s 600000s;
|
|
34
|
+
}
|