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,96 @@
|
|
|
1
|
+
require 'action_view'
|
|
2
|
+
require_relative 'form_builder/comboboxes'
|
|
3
|
+
require_relative 'form_builder/controls'
|
|
4
|
+
require_relative 'form_builder/pins'
|
|
5
|
+
|
|
6
|
+
module Bh
|
|
7
|
+
# The form: what a label, a field and a button of one wear, and the field Rails has
|
|
8
|
+
# none of. Given to one form as `builder:`, or to every form of a controller through
|
|
9
|
+
# `default_form_builder`. What a view passes is kept: its classes join the builder's, its
|
|
10
|
+
# `data` is merged key by key with the builder's, and any other option of its overrides.
|
|
11
|
+
class FormBuilder < ActionView::Helpers::FormBuilder
|
|
12
|
+
include Comboboxes, Controls, Pins
|
|
13
|
+
|
|
14
|
+
# What a field wears: Bootstrap's control, at the size a thumb finds.
|
|
15
|
+
CONTROL = 'form-control form-control-lg'
|
|
16
|
+
|
|
17
|
+
# What a button wears: the large pill in the primary color, solid or outlined.
|
|
18
|
+
PILL = 'btn btn-lg rounded-5 theme-primary'
|
|
19
|
+
|
|
20
|
+
# What clears a button of the field or the button above it.
|
|
21
|
+
SPACE = 'mt-3 md:mt-4'
|
|
22
|
+
|
|
23
|
+
# What a phone field carries: the bundle's `phone` controller, which shapes the number as
|
|
24
|
+
# it is typed, and the keyboard and autofill a phone offers for one.
|
|
25
|
+
PHONE = {
|
|
26
|
+
autocomplete: 'tel',
|
|
27
|
+
data: { controller: 'phone', action: 'keydown->phone#down input->phone#input' },
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# The fieldset a form's controls sit in: a grid, with `legend` over them where one is
|
|
31
|
+
# given, wearing nothing of ours. It carries
|
|
32
|
+
# its own spacing, twice as much under it as over, so one group reads as belonging to the
|
|
33
|
+
# words above it rather than sitting evenly between two. Every option is the fieldset's.
|
|
34
|
+
def fieldset(legend = nil, **options, &)
|
|
35
|
+
heading = (@template.tag.legend legend if legend)
|
|
36
|
+
inside = @template.safe_join [heading, @template.capture(&)].compact
|
|
37
|
+
|
|
38
|
+
@template.tag.fieldset inside, **with_classes(options, 'd-grid flow-fieldset')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A label is Bootstrap's `form-label`, whatever else it is given -- and given both words
|
|
42
|
+
# and a block, the control the words name as well, which Rails' own label has no meaning
|
|
43
|
+
# for: it drops the words and renders the block. Wrapped, the pair is one element, so a
|
|
44
|
+
# form laid out in columns has a cell to place and neither an id nor a stylesheet counting
|
|
45
|
+
# children has to bind them -- which a control drawing markup of its own, a combobox say,
|
|
46
|
+
# would break on connect.
|
|
47
|
+
def label(method, text = nil, options = {}, &block)
|
|
48
|
+
return label(method, nil, text, &block) if text.is_a? Hash
|
|
49
|
+
return super(method, text, with_classes(options, 'form-label'), &block) unless text && block
|
|
50
|
+
|
|
51
|
+
words = @template.tag.span text, class: 'form-label'
|
|
52
|
+
inside = @template.safe_join [words, @template.capture(&block)]
|
|
53
|
+
|
|
54
|
+
@template.tag.label inside, for: field_id(method), **with_classes(options, 'flow-field')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A text field is a large control.
|
|
58
|
+
def text_field(method, options = {}) = super(method, with_classes(options, CONTROL))
|
|
59
|
+
|
|
60
|
+
# A phone field is a large control shaped by the `phone` controller as it is typed.
|
|
61
|
+
def phone_field(method, options = {})
|
|
62
|
+
super(method, PHONE.deep_merge(with_classes(options, CONTROL)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A submit is the builder's button, solid.
|
|
66
|
+
def submit(value = nil, options = {})
|
|
67
|
+
super(value, with_classes(options, "#{PILL} btn-solid #{SPACE}"))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A button is the builder's too: solid where it submits, which is what one does unless told
|
|
71
|
+
# `type: :button`, and outlined where it does not. A disabled one given a `title` says why
|
|
72
|
+
# on hover — `Available soon` — from a wrapper around it, since a disabled button hears no
|
|
73
|
+
# mouse; the wrapper then takes the button's place in the column.
|
|
74
|
+
def button(value = nil, options = {}, &)
|
|
75
|
+
return button(nil, value, &) if value.is_a? Hash
|
|
76
|
+
|
|
77
|
+
wrapped = options[:disabled] && options[:title]
|
|
78
|
+
fill = options[:type].to_s == 'button' ? 'btn-outline' : 'btn-solid'
|
|
79
|
+
classes = @template.class_names PILL, fill, (SPACE unless wrapped)
|
|
80
|
+
button = super(value, with_classes(options.except(:title), classes), &)
|
|
81
|
+
|
|
82
|
+
wrapped ? tooltipped(button, options[:title]) : button
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def tooltipped(button, title)
|
|
88
|
+
@template.tag.span button, class: "d-grid #{SPACE}",
|
|
89
|
+
data: { controller: 'tooltip', bs_title: title }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def with_classes(options, classes)
|
|
93
|
+
options.merge class: @template.class_names(classes, options[:class])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
module Helpers
|
|
3
|
+
module Chats
|
|
4
|
+
# Who sent a message, when, whether it arrived and what it cost, small above its bubble.
|
|
5
|
+
module Asides
|
|
6
|
+
# Which bubble: the other party's comes in on the left, the reader's goes out on the right.
|
|
7
|
+
SIDES = { left: 'in', right: 'out' }.freeze
|
|
8
|
+
|
|
9
|
+
# How a delivery reads, and in which of Bootstrap's tones: a mark for arrived, a mark
|
|
10
|
+
# for sent and not yet, a cross for either way it failed.
|
|
11
|
+
DELIVERIES = {
|
|
12
|
+
delivered: %w[✓ success], sent: %w[✓ secondary],
|
|
13
|
+
failed: %w[✕ danger], undelivered: %w[✕ danger],
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def bh_chat_aside(message)
|
|
19
|
+
parts = [
|
|
20
|
+
message.sender, bh_chat_time(message.at),
|
|
21
|
+
bh_chat_delivery(message.delivery), bh_chat_cost(message.cost),
|
|
22
|
+
]
|
|
23
|
+
return if parts.compact.empty?
|
|
24
|
+
|
|
25
|
+
side = SIDES.fetch message.side
|
|
26
|
+
tag.small safe_join(parts.compact, ' '), class: "chat-aside chat-aside-#{side}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def bh_chat_time(at)
|
|
30
|
+
return unless at
|
|
31
|
+
|
|
32
|
+
tag.time t('bh.ago', time: time_ago_in_words(at)),
|
|
33
|
+
datetime: at.xmlschema, title: l(at, format: :long)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def bh_chat_delivery(delivery)
|
|
37
|
+
return unless delivery
|
|
38
|
+
|
|
39
|
+
mark, tone = DELIVERIES.fetch delivery.to_sym
|
|
40
|
+
tag.strong mark, class: "fg-#{tone}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bh_chat_cost(cost)
|
|
44
|
+
"· #{number_to_currency cost, precision: 4}" if cost
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
module Helpers
|
|
3
|
+
module Chats
|
|
4
|
+
# The field under a thread that sends the next question.
|
|
5
|
+
module Asks
|
|
6
|
+
# A solid button in the primary, rounded like the field beside it.
|
|
7
|
+
SEND_CLASSES = 'btn btn-solid rounded-3 theme-primary ms-3'
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# `replace`, so the redirect back to the page the form is on is a page refresh to Turbo
|
|
12
|
+
# — morphed in place, scroll kept — rather than a visit that lands at the top.
|
|
13
|
+
def bh_chat_ask(url, hints)
|
|
14
|
+
data = { controller: 'require', turbo_action: 'replace' }
|
|
15
|
+
form_with url:, class: 'd-flex align-items-center', data: do
|
|
16
|
+
safe_join [
|
|
17
|
+
tag.label(t('bh.ask'), for: 'ask', class: 'visually-hidden'),
|
|
18
|
+
text_field_tag(:ask, nil, **bh_chat_ask_options(hints)),
|
|
19
|
+
submit_tag(t('bh.send'), class: SEND_CLASSES),
|
|
20
|
+
], "\n"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bh_chat_ask_options(hints)
|
|
25
|
+
{
|
|
26
|
+
id: 'ask', required: true, autofocus: true, autocomplete: 'off',
|
|
27
|
+
class: 'form-control rounded-4 flex-grow-1 chat-ask', placeholder: hints.first,
|
|
28
|
+
data: { thread_target: 'field', turbo_permanent: '',
|
|
29
|
+
**bh_chat_placeholder_data(hints), },
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def bh_chat_placeholder_data(hints)
|
|
34
|
+
return {} if hints.size < 2
|
|
35
|
+
|
|
36
|
+
{ controller: 'placeholder', placeholder_questions_value: hints.to_json }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require_relative 'chats/asides'
|
|
2
|
+
require_relative 'chats/asks'
|
|
3
|
+
|
|
4
|
+
module Bh
|
|
5
|
+
module Helpers
|
|
6
|
+
# A conversation drawn as the bubbles every Bh chat wears.
|
|
7
|
+
module Chats
|
|
8
|
+
include Asides, Asks
|
|
9
|
+
|
|
10
|
+
# `messages` as a thread that opens at its newest and, where `url` is given, a field
|
|
11
|
+
# under it that posts the next message there, suggesting `hints` one at a time.
|
|
12
|
+
def chat_with(messages:, url: nil, hints: [])
|
|
13
|
+
parts = [bh_chat_messages(messages)]
|
|
14
|
+
parts.push bh_chat_ask(url, hints) if url
|
|
15
|
+
|
|
16
|
+
tag.div safe_join(parts, "\n"), class: 'chat-thread', data: { controller: 'thread' }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def bh_chat_messages(messages)
|
|
22
|
+
tag.div class: 'chat', data: { thread_target: 'messages' } do
|
|
23
|
+
safe_join messages.map { |message| bh_chat_message message }, "\n"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def bh_chat_message(message)
|
|
28
|
+
parts = [bh_chat_aside(message), bh_chat_bubble(message)]
|
|
29
|
+
|
|
30
|
+
safe_join parts.compact, "\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def bh_chat_bubble(message)
|
|
34
|
+
side = Asides::SIDES.fetch message.side
|
|
35
|
+
return tag.p message.text, class: "chat-bubble chat-#{side}" if message.text
|
|
36
|
+
|
|
37
|
+
tag.p safe_join(Array.new(3) { tag.span }), class: "chat-bubble chat-#{side} chat-typing",
|
|
38
|
+
aria: { label: t('bh.typing') }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
module Helpers
|
|
3
|
+
# A dialog a page opens from a link, with none of Bootstrap's markup written by hand.
|
|
4
|
+
module Dialogs
|
|
5
|
+
# A link reading `text` and the dialog it opens: headed `title` with an X to close it,
|
|
6
|
+
# holding the block, and closed as well by a footer button reading `okay` — `Okay`
|
|
7
|
+
# unless told otherwise, or none at all for `false`. The two share an id made from the
|
|
8
|
+
# link's own words, so a page needs to invent none; Bootstrap's own `data-bs-toggle`
|
|
9
|
+
# does the opening, and the browser's element brings the backdrop, the focus trap and
|
|
10
|
+
# Esc. Any other option is an attribute of the link.
|
|
11
|
+
def dialog(text, title:, okay: t('bh.okay'), **options, &)
|
|
12
|
+
id = "dialog-#{text.parameterize}"
|
|
13
|
+
data = { **options.fetch(:data, {}), bs_toggle: 'dialog', bs_target: "##{id}" }
|
|
14
|
+
parts = [bh_dialog_header(id, title), tag.div(capture(&), class: 'dialog-body')]
|
|
15
|
+
parts.push bh_dialog_footer(okay) if okay
|
|
16
|
+
|
|
17
|
+
safe_join [
|
|
18
|
+
link_to(text, "##{id}", **options, data:),
|
|
19
|
+
tag.dialog(safe_join(parts), class: 'dialog dialog-slide-down', id:,
|
|
20
|
+
aria: { labelledby: "#{id}-title" }),
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def bh_dialog_header(id, title)
|
|
27
|
+
tag.div class: 'dialog-header' do
|
|
28
|
+
heading = tag.h2 title, class: 'dialog-title', id: "#{id}-title"
|
|
29
|
+
|
|
30
|
+
safe_join [heading, bh_dialog_close]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def bh_dialog_close
|
|
35
|
+
tag.button type: 'button', class: 'btn-close ms-auto', data: { bs_dismiss: 'dialog' },
|
|
36
|
+
aria: { label: t('bh.close') }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def bh_dialog_footer(okay)
|
|
40
|
+
tag.div class: 'dialog-footer' do
|
|
41
|
+
tag.button okay, type: 'button', class: 'btn btn-solid theme-secondary',
|
|
42
|
+
data: { bs_dismiss: 'dialog' }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
module Helpers
|
|
3
|
+
# The page of a signup flow: a mark over whatever it stands on, a title, a line, one card,
|
|
4
|
+
# the way out.
|
|
5
|
+
module Flows
|
|
6
|
+
# How wide the card is unless a page says otherwise: as wide as a thread, a table or a
|
|
7
|
+
# contract wants. A page asking one question says a narrower one.
|
|
8
|
+
WIDTH = '75rem'
|
|
9
|
+
|
|
10
|
+
# Draws the page around the card's content: `header` over what the view said in
|
|
11
|
+
# `content_for` — its `:title`, its `:subtitle`, how wide the card is in `:width` — and
|
|
12
|
+
# the pieces of `footer` under the card joined by a middot. No header draws no header,
|
|
13
|
+
# no subtitle no line and an empty footer no foot: a page says only what it has. Every
|
|
14
|
+
# other option is an attribute of the column, which is where a host names the controller
|
|
15
|
+
# that paints what the page stands on.
|
|
16
|
+
def flow(header: nil, footer: [], **options, &)
|
|
17
|
+
parts = [
|
|
18
|
+
(tag.header header if header),
|
|
19
|
+
*bh_flow_words,
|
|
20
|
+
bh_flow_card(content_for(:width), &),
|
|
21
|
+
bh_flow_footer(footer),
|
|
22
|
+
]
|
|
23
|
+
classes = class_names 'flow', options[:class]
|
|
24
|
+
|
|
25
|
+
tag.div safe_join(parts.compact), class: classes, **options.except(:class)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def bh_flow_words
|
|
31
|
+
[tag.h1(content_for(:title)), (tag.p content_for :subtitle if content_for? :subtitle)]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def bh_flow_card(width, &)
|
|
35
|
+
width = width.presence || WIDTH
|
|
36
|
+
style = "--flow-width: #{width}" unless width == WIDTH
|
|
37
|
+
|
|
38
|
+
tag.div capture(&), class: 'flow-card p-3 md:p-4 mx-auto', style:
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bh_flow_footer(pieces)
|
|
42
|
+
pieces = Array(pieces).compact_blank
|
|
43
|
+
return if pieces.empty?
|
|
44
|
+
|
|
45
|
+
tag.footer safe_join(pieces, tag.span('·', class: 'fg-2', aria: { hidden: true }))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
module Helpers
|
|
3
|
+
# What a page built on Bootstrap 6 puts in its `<head>`.
|
|
4
|
+
module Heads
|
|
5
|
+
# One line per tag, each at the depth a head's children sit at in a layout indented
|
|
6
|
+
# by two: `<html>`, then `<head>`, then these.
|
|
7
|
+
INDENT = "\n "
|
|
8
|
+
|
|
9
|
+
# The metas Bootstrap and Turbo read, and the stylesheet and script this gem serves.
|
|
10
|
+
# `color-scheme` is what lets `light dark` follow the system, so most pages need no
|
|
11
|
+
# theme attribute at all. A page refresh Turbo is sent morphs the page in place and
|
|
12
|
+
# keeps the scroll, so an answer landing in a thread leaves the reader where they were.
|
|
13
|
+
# The icon font is the host's to link: which glyphs a page draws is the host's business,
|
|
14
|
+
# and a gem reaching for somebody else's CDN on its own is not.
|
|
15
|
+
def bh_head_tags
|
|
16
|
+
safe_join [
|
|
17
|
+
tag.meta(name: 'viewport', content: 'width=device-width, initial-scale=1'),
|
|
18
|
+
tag.meta(name: 'color-scheme', content: 'light dark'),
|
|
19
|
+
tag.meta(name: 'turbo-refresh-method', content: 'morph'),
|
|
20
|
+
tag.meta(name: 'turbo-refresh-scroll', content: 'preserve'),
|
|
21
|
+
tag.link(rel: 'stylesheet', href: "#{Engine::PREFIX}css/bh.css"),
|
|
22
|
+
tag.script('', type: 'module', src: "#{Engine::PREFIX}js/bh.js"),
|
|
23
|
+
], INDENT
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
module Helpers
|
|
3
|
+
# What the flash says, as the toasts every Bh page shows them in.
|
|
4
|
+
module Notices
|
|
5
|
+
# Bootstrap's tone for each key, so a notice and an alert read apart; a key of the
|
|
6
|
+
# host's own reads neutral.
|
|
7
|
+
TONES = { 'notice' => 'theme-success', 'alert' => 'theme-danger' }.freeze
|
|
8
|
+
|
|
9
|
+
# The toast controller's hooks: the timed hide stops while a reader hovers or focuses
|
|
10
|
+
# a toast, so a message stays while someone reads it or aims for the X.
|
|
11
|
+
HOLD = 'mouseenter->toast#stopTimer mouseleave->toast#startTimer ' \
|
|
12
|
+
'focusin->toast#stopTimer focusout->toast#startTimer'
|
|
13
|
+
|
|
14
|
+
# Every message in the flash as a toast at the bottom right, visible at first paint and
|
|
15
|
+
# gone after a moment or a click. Only messages: a value that is not words is data some
|
|
16
|
+
# other part of a host keeps in the flash, and is not something to show. Nothing at all
|
|
17
|
+
# where there is nothing to say. `data-turbo-temporary`, so a toast born visible does
|
|
18
|
+
# not replay from the page's snapshot on every Back.
|
|
19
|
+
def notices
|
|
20
|
+
messages = flash.to_hash.select { |_, message| message.is_a? String }
|
|
21
|
+
return if messages.empty?
|
|
22
|
+
|
|
23
|
+
toasts = messages.map { |key, message| bh_toast key, message }
|
|
24
|
+
|
|
25
|
+
tag.div safe_join(toasts), class: 'toast-container position-fixed bottom-0 end-0 p-3',
|
|
26
|
+
data: { turbo_temporary: '' }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def bh_toast(key, message)
|
|
32
|
+
classes = class_names 'toast fade show', TONES.fetch(key, 'theme-primary')
|
|
33
|
+
options = {
|
|
34
|
+
role: 'alert', aria: { live: 'assertive', atomic: true },
|
|
35
|
+
data: { controller: 'toast', action: HOLD },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
tag.div class: classes, **options do
|
|
39
|
+
safe_join [bh_toast_header(message), tag.div(class: 'toast-body d-none')]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bh_toast_header(message)
|
|
44
|
+
tag.div class: 'toast-header border-0' do
|
|
45
|
+
safe_join [tag.span(message, class: 'me-auto'), bh_toast_close]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bh_toast_close
|
|
50
|
+
tag.button type: 'button', class: 'btn-close', data: { bs_dismiss: 'toast' },
|
|
51
|
+
aria: { label: t('bh.close') }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/bh/helpers.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative 'helpers/chats'
|
|
2
|
+
require_relative 'helpers/dialogs'
|
|
3
|
+
require_relative 'helpers/flows'
|
|
4
|
+
require_relative 'helpers/heads'
|
|
5
|
+
require_relative 'helpers/notices'
|
|
6
|
+
|
|
7
|
+
module Bh
|
|
8
|
+
# Everything a view may call, in one module the engine puts on every Action View.
|
|
9
|
+
module Helpers
|
|
10
|
+
include Chats, Dialogs, Flows, Heads, Notices
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/bh/message.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Bh
|
|
2
|
+
# One message of a chat, as `chat_with` draws it: which `side` its bubble is on (`:left`
|
|
3
|
+
# for the other party, `:right` for the reader), its `text`, who the `sender` was, when it
|
|
4
|
+
# was said (`at`), how its `delivery` went (`:delivered`, `:sent`, `:failed`,
|
|
5
|
+
# `:undelivered` or nil) and what it `cost` in dollars, where anything did. Any object
|
|
6
|
+
# answering the same six is a message too; this is for a host that has the facts and no
|
|
7
|
+
# model to hang them on. A message with no text yet is drawn as being typed.
|
|
8
|
+
Message = Data.define :side, :text, :sender, :at, :delivery, :cost do
|
|
9
|
+
def initialize(side:, text: nil, sender: nil, at: nil, delivery: nil, cost: nil) = super
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/bh/version.rb
CHANGED
data/lib/bh.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
require_relative 'bh/version'
|
|
2
|
+
require_relative 'bh/message'
|
|
3
|
+
require_relative 'bh/helpers'
|
|
4
|
+
require_relative 'bh/form_builder'
|
|
5
|
+
require_relative 'bh/engine'
|
|
5
6
|
|
|
7
|
+
# Bootstrap 6 in a Rails app: the form builder that dresses every field a page asks for, the
|
|
8
|
+
# components Bootstrap ships behavior for and no markup, and the one stylesheet and script
|
|
9
|
+
# that carry them.
|
|
6
10
|
module Bh
|
|
7
|
-
class Error < StandardError; end
|
|
8
|
-
# Your code goes here...
|
|
9
11
|
end
|
data/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bh6",
|
|
3
|
+
"version": "6.2.0",
|
|
4
|
+
"description": "Bootstrap 6 in a Rails app: the form builder that dresses every field, the components Bootstrap ships behavior for and no markup, and the Stimulus controllers behind them.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": "github:claudiob/bh",
|
|
8
|
+
"files": [
|
|
9
|
+
"app/javascript",
|
|
10
|
+
"app/stylesheets",
|
|
11
|
+
"public/bh",
|
|
12
|
+
"vendor",
|
|
13
|
+
"MIT-LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./app/javascript/bh.js",
|
|
17
|
+
"./controllers/*": "./app/javascript/bh/*",
|
|
18
|
+
"./bh.css": "./app/stylesheets/bh.css",
|
|
19
|
+
"./bootstrap": "./vendor/bootstrap.bundle.min.js"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node build.mjs"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@hotwired/stimulus": ">=3.2",
|
|
26
|
+
"@hotwired/turbo-rails": ">=8.0",
|
|
27
|
+
"sortablejs": "^1.15.7"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"esbuild": ">=0.28"
|
|
31
|
+
}
|
|
32
|
+
}
|