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.
- package/MIT-LICENSE +22 -0
- package/README.md +296 -0
- package/app/fonts/bootstrap-icons.woff2 +0 -0
- package/app/javascript/houseaccount/bookmark_controller.js +97 -0
- package/app/javascript/houseaccount/clear_controller.js +22 -0
- package/app/javascript/houseaccount/combobox/menu.js +93 -0
- package/app/javascript/houseaccount/combobox_controller.js +82 -0
- package/app/javascript/houseaccount/confirm.js +89 -0
- package/app/javascript/houseaccount/density_controller.js +21 -0
- package/app/javascript/houseaccount/deselect_controller.js +37 -0
- package/app/javascript/houseaccount/flash.js +35 -0
- package/app/javascript/houseaccount/limit_controller.js +23 -0
- package/app/javascript/houseaccount/map/loader.js +20 -0
- package/app/javascript/houseaccount/map_controller.js +88 -0
- package/app/javascript/houseaccount/otp/slots.js +27 -0
- package/app/javascript/houseaccount/otp_controller.js +98 -0
- package/app/javascript/houseaccount/phone_controller.js +89 -0
- package/app/javascript/houseaccount/placeholder_controller.js +28 -0
- package/app/javascript/houseaccount/relative_time_controller.js +45 -0
- package/app/javascript/houseaccount/require_controller.js +18 -0
- package/app/javascript/houseaccount/reveal_controller.js +16 -0
- package/app/javascript/houseaccount/scheme_controller.js +99 -0
- package/app/javascript/houseaccount/search_controller.js +78 -0
- package/app/javascript/houseaccount/shortcuts_controller.js +42 -0
- package/app/javascript/houseaccount/thread_controller.js +40 -0
- package/app/javascript/houseaccount/timezone_controller.js +27 -0
- package/app/javascript/houseaccount/toast_controller.js +35 -0
- package/app/javascript/houseaccount/tooltip_controller.js +23 -0
- package/app/javascript/houseaccount/wall/marks.js +8 -0
- package/app/javascript/houseaccount/wall/tools.json +75 -0
- package/app/javascript/houseaccount/wall_controller.js +93 -0
- package/app/javascript/houseaccount/written.js +20 -0
- package/app/javascript/houseaccount/written_controller.js +28 -0
- package/app/javascript/houseaccount.js +60 -0
- package/app/stylesheets/houseaccount/base.css +33 -0
- package/app/stylesheets/houseaccount/chat.css +73 -0
- package/app/stylesheets/houseaccount/flow.css +47 -0
- package/app/stylesheets/houseaccount/icons.css +186 -0
- package/app/stylesheets/houseaccount/lockup.css +58 -0
- package/app/stylesheets/houseaccount/map.css +3 -0
- package/app/stylesheets/houseaccount/pin.css +34 -0
- package/app/stylesheets/houseaccount/search.css +60 -0
- package/app/stylesheets/houseaccount/shell.css +197 -0
- package/app/stylesheets/houseaccount/table.css +105 -0
- package/app/stylesheets/houseaccount/values.css +41 -0
- package/app/stylesheets/houseaccount/wall.css +123 -0
- package/app/stylesheets/houseaccount.css +16 -0
- package/app/stylesheets/theme/bootstrap.css +11 -0
- package/app/stylesheets/theme/dawn.css +189 -0
- package/app/stylesheets/theme/dracula.css +189 -0
- package/app/stylesheets/theme/gruvbox.css +189 -0
- package/app/stylesheets/theme/monokai.css +190 -0
- package/app/stylesheets/theme/nord.css +189 -0
- package/app/stylesheets/theme/one_dark.css +189 -0
- package/app/stylesheets/theme/solarized.css +188 -0
- package/app/stylesheets/theme/tokyo_night.css +189 -0
- package/package.json +33 -0
- package/vendor/bootstrap-icons.min.css +5 -0
- package/vendor/bootstrap.bundle.min.js +9 -0
- package/vendor/bootstrap.min.css +2 -0
- package/vendor/fonts/OFL-quicksand.txt +93 -0
- package/vendor/fonts/bootstrap-icons.woff +0 -0
- package/vendor/fonts/bootstrap-icons.woff2 +0 -0
- package/vendor/fonts/quicksand-500-latin-ext.woff2 +0 -0
- package/vendor/fonts/quicksand-500-latin.woff2 +0 -0
- package/vendor/fonts/quicksand-500-vietnamese.woff2 +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Dialog } from 'bootstrap'
|
|
2
|
+
|
|
3
|
+
// Turbo hands over the whole warning as one string, and the element and the button that
|
|
4
|
+
// asked. The first line is the question and each remaining line a paragraph — real
|
|
5
|
+
// paragraphs here, where a confirm() box had newlines. Always textContent, never
|
|
6
|
+
// innerHTML: the title carries a record's own name, and a name is data. The dialog is
|
|
7
|
+
// built the first time a page asks, so any page loading this bundle has one.
|
|
8
|
+
export default function confirm(message, element, submitter) {
|
|
9
|
+
const dialog = dialogFor()
|
|
10
|
+
const [title, ...lines] = message.split('\n')
|
|
11
|
+
dialog.querySelector('.dialog-title').textContent = title
|
|
12
|
+
dialog.querySelector('.dialog-body').replaceChildren(...paragraphs(lines))
|
|
13
|
+
const answer = dialog.querySelector('.houseaccount-confirm-answer')
|
|
14
|
+
answer.textContent = wordsOn(submitter, element) || 'OK'
|
|
15
|
+
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
// `onclick` rather than addEventListener: reassigning replaces the previous
|
|
18
|
+
// answer's handler, so asking twice on one page never wires the button twice.
|
|
19
|
+
answer.onclick = () => {
|
|
20
|
+
resolve(true)
|
|
21
|
+
Dialog.getOrCreateInstance(dialog).hide()
|
|
22
|
+
}
|
|
23
|
+
// Cancel, Esc and a click on the backdrop all close through here. After an
|
|
24
|
+
// answer the promise is settled, and settling it again is a no-op.
|
|
25
|
+
dialog.addEventListener('hidden.bs.dialog', () => resolve(false), { once: true })
|
|
26
|
+
Dialog.getOrCreateInstance(dialog).show()
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The answer is the very words of the button that asked — `Delete place` — so the dialog
|
|
31
|
+
// never has to know what the page is about, nor in which language. A link with a method
|
|
32
|
+
// arrives as the form Turbo built for it, with no submitter; the link itself still has
|
|
33
|
+
// the focus its click gave it.
|
|
34
|
+
function wordsOn(submitter, element) {
|
|
35
|
+
const form = element?.tagName === 'FORM'
|
|
36
|
+
const source = submitter || (form ? element.querySelector('[type=submit]') || document.activeElement : element)
|
|
37
|
+
|
|
38
|
+
return source?.textContent?.trim() || source?.value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function paragraphs(lines) {
|
|
42
|
+
return lines.filter(line => line).map(line => {
|
|
43
|
+
const paragraph = document.createElement('p')
|
|
44
|
+
paragraph.textContent = line
|
|
45
|
+
return paragraph
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The one dialog every warning on a page speaks through, made on the first ask. Cancel
|
|
50
|
+
// keeps the focus on the safe answer: `showModal` gives it to `autofocus` first.
|
|
51
|
+
// `dialog-slide-down` is the animation, shipped by Bootstrap 6.
|
|
52
|
+
function dialogFor() {
|
|
53
|
+
let dialog = document.querySelector('#houseaccount-confirm')
|
|
54
|
+
if (dialog) { return dialog }
|
|
55
|
+
|
|
56
|
+
dialog = document.createElement('dialog')
|
|
57
|
+
dialog.className = 'dialog dialog-slide-down'
|
|
58
|
+
dialog.id = 'houseaccount-confirm'
|
|
59
|
+
dialog.setAttribute('aria-labelledby', 'houseaccount-confirm-title')
|
|
60
|
+
dialog.innerHTML = `
|
|
61
|
+
<div class='dialog-header'><h1 class='dialog-title' id='houseaccount-confirm-title'></h1></div>
|
|
62
|
+
<div class='dialog-body'></div>
|
|
63
|
+
<div class='dialog-footer'>
|
|
64
|
+
<button type='button' class='btn btn-solid theme-secondary' data-bs-dismiss='dialog' autofocus>${cancel()}</button>
|
|
65
|
+
<button type='button' class='btn btn-solid theme-danger houseaccount-confirm-answer'></button>
|
|
66
|
+
</div>`
|
|
67
|
+
document.body.append(dialog)
|
|
68
|
+
|
|
69
|
+
return dialog
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The one word the page cannot supply, in the page's own language where a host has said
|
|
73
|
+
// it in a `<meta name='houseaccount-cancel'>`, and English otherwise.
|
|
74
|
+
function cancel() {
|
|
75
|
+
return document.querySelector('meta[name="houseaccount-cancel"]')?.content || 'Cancel'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Three things outlive a Turbo visit that starts mid-close: the snapshot, which would
|
|
79
|
+
// restore an open dialog; `dialog-open` on <html>, which is the scroll lock; and
|
|
80
|
+
// `hiding` on the dialog, the class its closing animation runs under. dispose() closes
|
|
81
|
+
// instantly and lifts the lock, but cuts the animation short of the end that would
|
|
82
|
+
// have taken `hiding` off — and a dialog still wearing it opens invisible the next
|
|
83
|
+
// time it is asked, which on a table of Remove buttons is the very next click. In the
|
|
84
|
+
// module, so it registers once.
|
|
85
|
+
document.addEventListener('turbo:before-cache', () => {
|
|
86
|
+
const dialog = document.querySelector('#houseaccount-confirm')
|
|
87
|
+
if (dialog?.open) Dialog.getOrCreateInstance(dialog).dispose()
|
|
88
|
+
dialog?.classList.remove('hiding')
|
|
89
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// The arrows at the foot of the sidebar, drawn on a phone alone: a tap puts the words
|
|
4
|
+
// back beside every icon the chrome shows — the sidebar's entries, the crumbs, the tabs,
|
|
5
|
+
// the foot's own controls — and the next tap takes them away again. The choice goes to
|
|
6
|
+
// the server in a cookie, the way the zone does, and the page is loaded again outright
|
|
7
|
+
// rather than reshaped in place: Safari left the row of entries where it was when the
|
|
8
|
+
// words came out of hiding, one link over the next, until a reload laid it out afresh.
|
|
9
|
+
// A Turbo visit to the same address is no better — an index carries the metas that make
|
|
10
|
+
// a refresh morph, so the visit reshaped the body it had rather than drawing a new one.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = { storage: String }
|
|
13
|
+
|
|
14
|
+
toggle() {
|
|
15
|
+
const expanded = document.body.classList.contains('recourse-expanded')
|
|
16
|
+
const density = expanded ? 'compact' : 'expanded'
|
|
17
|
+
|
|
18
|
+
document.cookie = `${this.storageValue}=${density}; path=/; max-age=31536000; samesite=lax`
|
|
19
|
+
window.location.reload()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { multiple: Boolean }
|
|
5
|
+
|
|
6
|
+
// `All` asks for the options the menu is holding back — they are in it already, and
|
|
7
|
+
// this is what puts them on it.
|
|
8
|
+
all(event) {
|
|
9
|
+
// A menu that sets a value is configured to close on any click inside it, this
|
|
10
|
+
// button included — which would shut it over the options it was clicked to see. The
|
|
11
|
+
// listener doing that is on the document, so stopping the click here is what keeps
|
|
12
|
+
// them in view. A menu that narrows a table closes on outside clicks only, and is
|
|
13
|
+
// unaffected either way.
|
|
14
|
+
event.stopPropagation()
|
|
15
|
+
|
|
16
|
+
const menu = this.element.closest('.menu')
|
|
17
|
+
|
|
18
|
+
for (const waiting of menu.querySelectorAll('.menu-item.d-none')) {
|
|
19
|
+
waiting.classList.remove('d-none')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// And, on a menu that narrows a table, it means every row as well as every option,
|
|
23
|
+
// which is what nothing being ticked says. Clicking each chosen item is what the
|
|
24
|
+
// plugin is already listening for, so the hidden input, the toggle's text and the
|
|
25
|
+
// events stay its business rather than ours — it has no method for this, and
|
|
26
|
+
// reaching into its state would be guessing.
|
|
27
|
+
//
|
|
28
|
+
// Only there. A menu that sets a value cannot mean none of them, and a click on the
|
|
29
|
+
// one already chosen is the plugin being told to choose it again — which closes the
|
|
30
|
+
// menu over the options this button was clicked to see.
|
|
31
|
+
if (!this.multipleValue) return
|
|
32
|
+
|
|
33
|
+
for (const item of menu.querySelectorAll('.menu-item.selected')) {
|
|
34
|
+
item.click()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The toast the server ships, built in the browser for the one message that has no
|
|
2
|
+
// response to arrive with. `data-controller` is what hands it to the toast
|
|
3
|
+
// controller for its timer and its X, so the two kinds fade alike.
|
|
4
|
+
export function flash(message, theme = 'theme-danger') {
|
|
5
|
+
const toast = document.createElement('div')
|
|
6
|
+
toast.className = `toast fade show ${theme}`
|
|
7
|
+
toast.setAttribute('role', 'alert')
|
|
8
|
+
toast.setAttribute('aria-live', 'assertive')
|
|
9
|
+
toast.dataset.controller = 'toast'
|
|
10
|
+
toast.dataset.action = ['mouseenter->toast#stopTimer', 'mouseleave->toast#startTimer',
|
|
11
|
+
'focusin->toast#stopTimer', 'focusout->toast#startTimer'].join(' ')
|
|
12
|
+
const header = document.createElement('div')
|
|
13
|
+
header.className = 'toast-header border-0'
|
|
14
|
+
const text = document.createElement('span')
|
|
15
|
+
text.className = 'me-auto'
|
|
16
|
+
// Never innerHTML: this is a message, and a message is data.
|
|
17
|
+
text.textContent = message
|
|
18
|
+
header.append(text)
|
|
19
|
+
toast.append(header)
|
|
20
|
+
container().append(toast)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A page with nothing to say ships no container at all, so the first message is what
|
|
24
|
+
// makes one.
|
|
25
|
+
function container() {
|
|
26
|
+
let container = document.querySelector('.toast-container')
|
|
27
|
+
if (container) return container
|
|
28
|
+
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
container.className = 'toast-container position-fixed bottom-0 end-0 p-3'
|
|
31
|
+
container.dataset.turboTemporary = ''
|
|
32
|
+
document.body.append(container)
|
|
33
|
+
|
|
34
|
+
return container
|
|
35
|
+
}
|
|
@@ -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 on a HouseAccount page: `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
|
+
}
|