senren-ui 0.1.6 → 0.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 +104 -0
- data/CONTRIBUTING.md +40 -3
- data/README.md +110 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +13 -17
- data/lib/senren/rails/installer.rb +0 -85
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
</div>
|
|
27
27
|
<% end %>
|
|
28
28
|
|
|
29
|
-
<div id="<%= dom_id %>-editor" role="textbox" aria-multiline="true" aria-label="<%= label %>" contenteditable="true" data-placeholder="<%= placeholder %>" data-senren--rich-text-editor-lite-target="editor" data-action="click->senren--rich-text-editor-lite#openLink input->senren--rich-text-editor-lite#sync input->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#updateToolbar mouseup->senren--rich-text-editor-lite#rememberSelection mouseup->senren--rich-text-editor-lite#updateToolbar touchend->senren--rich-text-editor-lite#rememberSelection paste->senren--rich-text-editor-lite#
|
|
29
|
+
<div id="<%= dom_id %>-editor" role="textbox" aria-multiline="true" aria-label="<%= label %>" contenteditable="true" data-placeholder="<%= placeholder %>" data-senren--rich-text-editor-lite-target="editor" data-action="click->senren--rich-text-editor-lite#openLink input->senren--rich-text-editor-lite#sync input->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#updateToolbar mouseup->senren--rich-text-editor-lite#rememberSelection mouseup->senren--rich-text-editor-lite#updateToolbar touchend->senren--rich-text-editor-lite#rememberSelection paste->senren--rich-text-editor-lite#paste drop->senren--rich-text-editor-lite#paste" class="min-h-40 cursor-text bg-[hsl(var(--senren-background))] p-4 text-left text-sm leading-6 text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-inset focus:ring-[hsl(var(--senren-ring))]">
|
|
30
30
|
<%= sanitize(initial_content, tags: %w[p br strong b em i a ul ol li h1 h2 h3], attributes: %w[href rel target data-align]) %>
|
|
31
31
|
</div>
|
|
32
32
|
<% end %>
|
|
@@ -7,6 +7,13 @@ module Senren
|
|
|
7
7
|
|
|
8
8
|
SIZES = { md: '' }.freeze
|
|
9
9
|
|
|
10
|
+
# BaseComponent defaults to `variant: :default`, which this component does
|
|
11
|
+
# not define, so `.new` with no variant raised instead of rendering. The
|
|
12
|
+
# default is :horizontal (a rule across the flow).
|
|
13
|
+
def initialize(variant: :horizontal, **args)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
10
17
|
def aria_orientation = variant == :vertical ? 'vertical' : 'horizontal'
|
|
11
18
|
end
|
|
12
19
|
end
|
|
@@ -19,7 +19,7 @@ module Senren
|
|
|
19
19
|
|
|
20
20
|
def initialize(side: :right, id: nil, class_name: nil, **html)
|
|
21
21
|
super(variant: side, size: :md, class_name: class_name, **html)
|
|
22
|
-
@dom_id = id ||
|
|
22
|
+
@dom_id = id || senren_dom_id(side)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
attr_reader :dom_id
|
|
@@ -5,10 +5,10 @@ module Senren
|
|
|
5
5
|
VARIANTS = { default: '' }.freeze
|
|
6
6
|
SIZES = { md: '' }.freeze
|
|
7
7
|
|
|
8
|
-
def initialize(text:, class_name: nil, **html)
|
|
8
|
+
def initialize(text:, id: nil, class_name: nil, **html)
|
|
9
9
|
super(variant: :default, size: :md, class_name: class_name, **html)
|
|
10
10
|
@text = text
|
|
11
|
-
@id =
|
|
11
|
+
@id = id || senren_dom_id(text)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
attr_reader :text, :id
|
|
@@ -14,6 +14,13 @@ module Senren
|
|
|
14
14
|
|
|
15
15
|
SIZES = { md: '' }.freeze
|
|
16
16
|
|
|
17
|
+
# BaseComponent defaults to `variant: :default`, which this component does
|
|
18
|
+
# not define, so `.new` with no variant raised instead of rendering. The
|
|
19
|
+
# default is :p (body text).
|
|
20
|
+
def initialize(variant: :p, **args)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
17
24
|
TAG_FOR = {
|
|
18
25
|
h1: :h1, h2: :h2, h3: :h3, h4: :h4,
|
|
19
26
|
p: :p, lead: :p, large: :p, small: :small, muted: :p
|
|
@@ -17,7 +17,7 @@ export default class extends Controller {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
_closeAll() {
|
|
20
|
-
this.triggerTargets.forEach((trigger) => trigger.setAttribute("aria-expanded", "false"))
|
|
20
|
+
this.triggerTargets.forEach((trigger) => { trigger.setAttribute("aria-expanded", "false") })
|
|
21
21
|
this.panelTargets.forEach((panel) => { panel.hidden = true })
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -3,36 +3,60 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
3
3
|
// senren--alert-dialog
|
|
4
4
|
// Same shape as dialog, but role=alertdialog. Triggered by external buttons that include
|
|
5
5
|
// data-action="click->senren--alert-dialog#open" pointing at the controller scope.
|
|
6
|
+
//
|
|
7
|
+
// State lives in `openValue`. Actions assign it; openValueChanged performs every
|
|
8
|
+
// DOM change, so the dialog can be opened from the server and survives a morph.
|
|
6
9
|
export default class extends Controller {
|
|
7
10
|
static targets = ["overlay", "panel", "trigger"]
|
|
11
|
+
static values = { open: Boolean }
|
|
8
12
|
|
|
9
13
|
connect() {
|
|
10
14
|
this._onKey = this._onKey.bind(this)
|
|
15
|
+
this._onBeforeCache = this._onBeforeCache.bind(this)
|
|
16
|
+
document.addEventListener("turbo:before-cache", this._onBeforeCache)
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
disconnect() {
|
|
14
20
|
document.removeEventListener("keydown", this._onKey)
|
|
21
|
+
document.removeEventListener("turbo:before-cache", this._onBeforeCache)
|
|
15
22
|
document.body.style.overflow = ""
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
open(event) {
|
|
19
26
|
event?.preventDefault()
|
|
20
|
-
this.
|
|
21
|
-
this.panelTarget.hidden = false
|
|
22
|
-
document.addEventListener("keydown", this._onKey)
|
|
23
|
-
document.body.style.overflow = "hidden"
|
|
24
|
-
queueMicrotask(() => this.panelTarget?.focus())
|
|
27
|
+
this.openValue = true
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
close(event) {
|
|
28
31
|
event?.preventDefault()
|
|
29
|
-
this.
|
|
30
|
-
|
|
32
|
+
this.openValue = false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
openValueChanged(isOpen, wasOpen) {
|
|
36
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
|
|
37
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
|
|
38
|
+
|
|
39
|
+
if (isOpen) {
|
|
40
|
+
document.addEventListener("keydown", this._onKey)
|
|
41
|
+
document.body.style.overflow = "hidden"
|
|
42
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
43
|
+
this.dispatch("opened")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
document.removeEventListener("keydown", this._onKey)
|
|
32
48
|
document.body.style.overflow = ""
|
|
49
|
+
if (wasOpen !== undefined) this.dispatch("closed")
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
_onKey(event) {
|
|
36
53
|
if (event.key === "Escape") this.close()
|
|
37
54
|
}
|
|
55
|
+
|
|
56
|
+
// Turbo snapshots the page before navigating away; without this the scroll
|
|
57
|
+
// lock is captured while applied and a back navigation restores a page that
|
|
58
|
+
// cannot be scrolled.
|
|
59
|
+
_onBeforeCache() {
|
|
60
|
+
this.openValue = false
|
|
61
|
+
}
|
|
38
62
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--cart
|
|
4
|
+
// Local UI only: quantity steppers and the displayed subtotal. The server owns
|
|
5
|
+
// the cart, so nothing here talks to it — a host app persists changes by
|
|
6
|
+
// listening for senren--cart:changed, or by submitting the surrounding form.
|
|
7
|
+
//
|
|
8
|
+
// State lives in `subtotalCentsValue`, following the pattern in
|
|
9
|
+
// .senren/conventions.md: actions assign values, and subtotalCentsValueChanged
|
|
10
|
+
// performs the DOM work. A server-rendered subtotal therefore paints the first
|
|
11
|
+
// frame, survives a Turbo morph, and can be corrected by a Turbo Stream that
|
|
12
|
+
// changes one attribute.
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static targets = ["line", "quantity", "lineTotal", "subtotal", "count"]
|
|
15
|
+
static values = { currency: String, subtotalCents: Number }
|
|
16
|
+
|
|
17
|
+
increment(event) {
|
|
18
|
+
this._step(event, +1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
decrement(event) {
|
|
22
|
+
this._step(event, -1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
remove(event) {
|
|
26
|
+
const line = event.currentTarget.closest("[data-senren--cart-target~='line']")
|
|
27
|
+
if (!line) return
|
|
28
|
+
|
|
29
|
+
const id = line.dataset.lineId
|
|
30
|
+
line.remove()
|
|
31
|
+
this._recalculate()
|
|
32
|
+
this.dispatch("removed", { detail: { id, subtotalCents: this.subtotalCentsValue } })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The value is the state; this renders it. Called on initialization too, so
|
|
36
|
+
// whatever the server rendered is what shows.
|
|
37
|
+
subtotalCentsValueChanged(cents) {
|
|
38
|
+
if (this.hasSubtotalTarget) this.subtotalTarget.textContent = this.format(cents)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
format(cents) {
|
|
42
|
+
return `${this.currencyValue || "$"}${(Number(cents) / 100).toFixed(2)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_step(event, delta) {
|
|
46
|
+
const line = event.currentTarget.closest("[data-senren--cart-target~='line']")
|
|
47
|
+
if (!line) return
|
|
48
|
+
|
|
49
|
+
const output = line.querySelector("[data-senren--cart-target~='quantity']")
|
|
50
|
+
if (!output) return
|
|
51
|
+
|
|
52
|
+
// Never below one: removing is a separate, explicit action, so a stepper
|
|
53
|
+
// that reaches zero would leave a line the user cannot see the price of.
|
|
54
|
+
const next = Math.max(1, Number(output.textContent.trim() || 1) + delta)
|
|
55
|
+
output.textContent = String(next)
|
|
56
|
+
|
|
57
|
+
this._recalculate()
|
|
58
|
+
this.dispatch("changed", {
|
|
59
|
+
detail: { id: line.dataset.lineId, quantity: next, subtotalCents: this.subtotalCentsValue }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_recalculate() {
|
|
64
|
+
let cents = 0
|
|
65
|
+
let count = 0
|
|
66
|
+
|
|
67
|
+
this.lineTargets.forEach((line) => {
|
|
68
|
+
const price = Number(line.dataset.priceCents || 0)
|
|
69
|
+
const output = line.querySelector("[data-senren--cart-target~='quantity']")
|
|
70
|
+
const quantity = Number(output?.textContent.trim() || 0)
|
|
71
|
+
const total = price * quantity
|
|
72
|
+
|
|
73
|
+
cents += total
|
|
74
|
+
count += quantity
|
|
75
|
+
|
|
76
|
+
const lineTotal = line.querySelector("[data-senren--cart-target~='lineTotal']")
|
|
77
|
+
if (lineTotal) lineTotal.textContent = this.format(total)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (this.hasCountTarget) this.countTarget.textContent = String(count)
|
|
81
|
+
this.subtotalCentsValue = cents
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -9,9 +9,20 @@ export default class extends Controller {
|
|
|
9
9
|
async copy() {
|
|
10
10
|
const value = this.sourceTarget.value || this.sourceTarget.textContent
|
|
11
11
|
await navigator.clipboard.writeText(value)
|
|
12
|
+
// The await is a suspension point: the element may be gone by now.
|
|
13
|
+
if (!this.hasButtonTarget) return
|
|
14
|
+
|
|
12
15
|
const original = this.buttonTarget.textContent
|
|
13
16
|
this.buttonTarget.textContent = this.copiedLabelValue || "Copied"
|
|
14
17
|
if (this.hasStatusTarget) this.statusTarget.textContent = "Copied to clipboard"
|
|
15
|
-
|
|
18
|
+
|
|
19
|
+
clearTimeout(this._resetTimer)
|
|
20
|
+
this._resetTimer = setTimeout(() => {
|
|
21
|
+
if (this.hasButtonTarget) this.buttonTarget.textContent = original
|
|
22
|
+
}, 1200)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearTimeout(this._resetTimer)
|
|
16
27
|
}
|
|
17
28
|
}
|
|
@@ -43,10 +43,9 @@ export default class extends Controller {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
choose(event) {
|
|
46
|
-
this.
|
|
47
|
-
bubbles: true,
|
|
46
|
+
this.dispatch("select", {
|
|
48
47
|
detail: { label: event.currentTarget.textContent.trim(), href: event.currentTarget.getAttribute("href") }
|
|
49
|
-
})
|
|
48
|
+
})
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
move(delta) {
|
|
@@ -59,7 +58,7 @@ export default class extends Controller {
|
|
|
59
58
|
updateActive() {
|
|
60
59
|
const options = this.visibleOptions
|
|
61
60
|
this.emptyTarget.hidden = options.length > 0
|
|
62
|
-
this.optionTargets.forEach((option) => option.setAttribute("aria-selected", "false"))
|
|
61
|
+
this.optionTargets.forEach((option) => { option.setAttribute("aria-selected", "false") })
|
|
63
62
|
const active = options[this.activeIndex]
|
|
64
63
|
if (!active) {
|
|
65
64
|
this.inputTarget.removeAttribute("aria-activedescendant")
|
|
@@ -2,8 +2,13 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// senren--context-menu
|
|
4
4
|
// Opens on right-click within the trigger; closes on outside click or Escape.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`. The pointer position is transient and belongs to
|
|
7
|
+
// the opening event, so it is applied in the action; everything else — the
|
|
8
|
+
// visibility and the document listeners — is driven by the value.
|
|
5
9
|
export default class extends Controller {
|
|
6
10
|
static targets = ["trigger", "menu"]
|
|
11
|
+
static values = { open: Boolean }
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
9
14
|
this._onDocClick = this._onDocClick.bind(this)
|
|
@@ -11,26 +16,48 @@ export default class extends Controller {
|
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
disconnect() {
|
|
14
|
-
|
|
15
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
open(event) {
|
|
19
23
|
event.preventDefault()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
|
|
25
|
+
if (this.hasMenuTarget) {
|
|
26
|
+
const rect = this.element.getBoundingClientRect()
|
|
27
|
+
this.menuTarget.style.top = `${event.clientY - rect.top}px`
|
|
28
|
+
this.menuTarget.style.left = `${event.clientX - rect.left}px`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.openValue = true
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
close() {
|
|
29
|
-
this.
|
|
35
|
+
this.openValue = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
openValueChanged(isOpen) {
|
|
39
|
+
if (this.hasMenuTarget) this.menuTarget.hidden = !isOpen
|
|
40
|
+
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
document.addEventListener("click", this._onDocClick)
|
|
43
|
+
document.addEventListener("keydown", this._onKey)
|
|
44
|
+
this.dispatch("opened")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this._stopListening()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_stopListening() {
|
|
30
52
|
document.removeEventListener("click", this._onDocClick)
|
|
31
53
|
document.removeEventListener("keydown", this._onKey)
|
|
32
54
|
}
|
|
33
55
|
|
|
34
|
-
_onDocClick(event) {
|
|
35
|
-
|
|
56
|
+
_onDocClick(event) {
|
|
57
|
+
if (!this.menuTarget.contains(event.target)) this.close()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_onKey(event) {
|
|
61
|
+
if (event.key === "Escape") this.close()
|
|
62
|
+
}
|
|
36
63
|
}
|
|
@@ -15,9 +15,14 @@ export default class extends Controller {
|
|
|
15
15
|
const direction = this.directions[key] === "asc" ? "desc" : "asc"
|
|
16
16
|
this.directions[key] = direction
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
rows
|
|
20
|
-
|
|
18
|
+
// Read each cell once (O(n)) rather than once per comparison (O(n log n)),
|
|
19
|
+
// then move the rows in a single batched write.
|
|
20
|
+
const decorated = this.rowTargets.map((row) => ({ row, value: this.valueFor(row, key) }))
|
|
21
|
+
decorated.sort((a, b) => this.compare(a.value, b.value, direction))
|
|
22
|
+
|
|
23
|
+
const fragment = document.createDocumentFragment()
|
|
24
|
+
decorated.forEach((entry) => { fragment.appendChild(entry.row) })
|
|
25
|
+
this.bodyTarget.appendChild(fragment)
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
valueFor(row, key) {
|
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// senren--dialog
|
|
4
|
-
// Local UI: open/close,
|
|
4
|
+
// Local UI: open/close, Escape to close, body scroll lock.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`, not in the DOM nodes this controller touches.
|
|
7
|
+
// Actions only assign the value; every DOM change happens in openValueChanged.
|
|
8
|
+
//
|
|
9
|
+
// That inversion is what makes the state server-renderable (render
|
|
10
|
+
// open-value="true" and the dialog is open with no JavaScript), survivable
|
|
11
|
+
// across a Turbo morph, and reachable from a Turbo Stream that changes a single
|
|
12
|
+
// attribute. It is the pattern every stateful Senren controller follows.
|
|
5
13
|
export default class extends Controller {
|
|
6
14
|
static targets = ["overlay", "panel", "trigger"]
|
|
7
|
-
static values
|
|
15
|
+
static values = { open: Boolean }
|
|
8
16
|
|
|
9
17
|
connect() {
|
|
10
18
|
this._onKey = this._onKey.bind(this)
|
|
11
|
-
|
|
19
|
+
this._onBeforeCache = this._onBeforeCache.bind(this)
|
|
20
|
+
document.addEventListener("turbo:before-cache", this._onBeforeCache)
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
disconnect() {
|
|
15
24
|
document.removeEventListener("keydown", this._onKey)
|
|
16
|
-
document.
|
|
25
|
+
document.removeEventListener("turbo:before-cache", this._onBeforeCache)
|
|
26
|
+
this._releaseScroll()
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
open(event) {
|
|
20
30
|
event?.preventDefault()
|
|
21
|
-
this.
|
|
31
|
+
this.openValue = true
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
close(event) {
|
|
25
35
|
event?.preventDefault()
|
|
26
|
-
this.
|
|
36
|
+
this.openValue = false
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Stimulus calls this during initialization too, so the server-rendered value
|
|
40
|
+
// paints the first frame.
|
|
41
|
+
openValueChanged(isOpen, wasOpen) {
|
|
42
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
|
|
43
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
|
|
44
|
+
|
|
45
|
+
if (isOpen) {
|
|
46
|
+
document.addEventListener("keydown", this._onKey)
|
|
47
|
+
document.body.style.overflow = "hidden"
|
|
48
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
49
|
+
this.dispatch("opened")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
37
52
|
|
|
38
|
-
_hide() {
|
|
39
|
-
this.openValue = false
|
|
40
|
-
if (this.hasOverlayTarget) this.overlayTarget.hidden = true
|
|
41
|
-
if (this.hasPanelTarget) this.panelTarget.hidden = true
|
|
42
53
|
document.removeEventListener("keydown", this._onKey)
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
this._releaseScroll()
|
|
55
|
+
// Only return focus on a real close, not on the initial render.
|
|
56
|
+
if (wasOpen !== undefined) {
|
|
57
|
+
this.triggerTarget?.focus?.()
|
|
58
|
+
this.dispatch("closed")
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
_onKey(event) {
|
|
48
|
-
if (event.key === "Escape") this.
|
|
63
|
+
if (event.key === "Escape") this.close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Turbo snapshots the page before navigating away. Without this the scroll
|
|
67
|
+
// lock is captured while still applied, and a back navigation restores a page
|
|
68
|
+
// that cannot be scrolled.
|
|
69
|
+
_onBeforeCache() {
|
|
70
|
+
this.openValue = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_releaseScroll() {
|
|
74
|
+
document.body.style.overflow = ""
|
|
49
75
|
}
|
|
50
76
|
}
|
|
@@ -2,35 +2,36 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// senren--dropdown-menu
|
|
4
4
|
// Local UI: toggle visibility, close on outside click and Escape, basic arrow nav.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`. Actions assign it; openValueChanged performs
|
|
7
|
+
// every DOM change — including the `data-state` attributes the chevron styling
|
|
8
|
+
// reads — and owns the document listeners.
|
|
5
9
|
export default class extends Controller {
|
|
6
10
|
static targets = ["trigger", "menu"]
|
|
11
|
+
static values = { open: Boolean }
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
9
14
|
this._onDocClick = this._onDocClick.bind(this)
|
|
10
15
|
this._onKey = this._onKey.bind(this)
|
|
11
|
-
this._setOpenState(false)
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
disconnect() {
|
|
15
|
-
|
|
16
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
toggle(event) {
|
|
20
23
|
event?.preventDefault()
|
|
21
|
-
this.
|
|
24
|
+
this.openValue = !this.openValue
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
close() {
|
|
25
|
-
this.
|
|
26
|
-
document.removeEventListener("click", this._onDocClick)
|
|
27
|
-
document.removeEventListener("keydown", this._onKey)
|
|
28
|
+
this.openValue = false
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
onTriggerKey(event) {
|
|
31
32
|
if (["Enter", " ", "ArrowDown"].includes(event.key)) {
|
|
32
33
|
event.preventDefault()
|
|
33
|
-
this.
|
|
34
|
+
this.openValue = true
|
|
34
35
|
this._focusItem(0)
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -39,32 +40,44 @@ export default class extends Controller {
|
|
|
39
40
|
const items = this._items()
|
|
40
41
|
const idx = items.indexOf(document.activeElement)
|
|
41
42
|
if (event.key === "ArrowDown") { event.preventDefault(); this._focusItem((idx + 1) % items.length) }
|
|
42
|
-
if (event.key === "ArrowUp")
|
|
43
|
-
if (event.key === "Escape")
|
|
43
|
+
if (event.key === "ArrowUp") { event.preventDefault(); this._focusItem((idx - 1 + items.length) % items.length) }
|
|
44
|
+
if (event.key === "Escape") { this.close(); this.triggerTarget?.focus?.() }
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
document.addEventListener("click", this._onDocClick)
|
|
49
|
-
document.addEventListener("keydown", this._onKey)
|
|
50
|
-
}
|
|
47
|
+
openValueChanged(isOpen) {
|
|
48
|
+
const state = isOpen ? "open" : "closed"
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
const state = open ? "open" : "closed"
|
|
54
|
-
this.menuTarget.hidden = !open
|
|
50
|
+
if (this.hasMenuTarget) this.menuTarget.hidden = !isOpen
|
|
55
51
|
this.element.dataset.state = state
|
|
56
|
-
this.triggerTarget.dataset.state = state
|
|
57
|
-
this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false")
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
if (this.hasTriggerTarget) {
|
|
54
|
+
this.triggerTarget.dataset.state = state
|
|
55
|
+
this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
|
|
56
|
+
|
|
57
|
+
const control = this._triggerControl()
|
|
58
|
+
if (control && control !== this.triggerTarget) {
|
|
59
|
+
control.dataset.state = state
|
|
60
|
+
control.setAttribute("aria-expanded", isOpen ? "true" : "false")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
|
|
64
|
+
chevron.dataset.state = state
|
|
65
|
+
})
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
if (isOpen) {
|
|
69
|
+
document.addEventListener("click", this._onDocClick)
|
|
70
|
+
document.addEventListener("keydown", this._onKey)
|
|
71
|
+
this.dispatch("opened")
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this._stopListening()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_stopListening() {
|
|
79
|
+
document.removeEventListener("click", this._onDocClick)
|
|
80
|
+
document.removeEventListener("keydown", this._onKey)
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
_triggerControl() {
|
|
@@ -14,4 +14,12 @@ export default class extends Controller {
|
|
|
14
14
|
clearTimeout(this._showTimer)
|
|
15
15
|
this._hideTimer = setTimeout(() => { if (this.hasPanelTarget) this.panelTarget.hidden = true }, 120)
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
// Either timer can still be pending when a Turbo navigation removes the
|
|
19
|
+
// element, which retains this controller and its detached subtree until it
|
|
20
|
+
// fires.
|
|
21
|
+
disconnect() {
|
|
22
|
+
clearTimeout(this._showTimer)
|
|
23
|
+
clearTimeout(this._hideTimer)
|
|
24
|
+
}
|
|
17
25
|
}
|
|
@@ -8,6 +8,12 @@ export default class extends Controller {
|
|
|
8
8
|
this.closeOnEscape = this.closeOnEscape.bind(this)
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// open() registers a document-level listener; without this the listener and
|
|
12
|
+
// the detached subtree survive every Turbo navigation made while open.
|
|
13
|
+
disconnect() {
|
|
14
|
+
document.removeEventListener("keydown", this.closeOnEscape)
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
open() {
|
|
12
18
|
this.overlayTarget.hidden = false
|
|
13
19
|
this.panelTarget.hidden = false
|
|
@@ -10,7 +10,14 @@ export default class extends Controller {
|
|
|
10
10
|
static values = { mask: String }
|
|
11
11
|
|
|
12
12
|
connect() {
|
|
13
|
-
|
|
13
|
+
// Keep the bound reference: Stimulus re-connects on Turbo morph/restore,
|
|
14
|
+
// and an unremovable listener would stack up one mask pass per cycle.
|
|
15
|
+
this._boundOnInput = this._onInput.bind(this)
|
|
16
|
+
this.element.addEventListener("input", this._boundOnInput)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
this.element.removeEventListener("input", this._boundOnInput)
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
_onInput(event) {
|