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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/CONTRIBUTING.md +40 -3
  4. data/README.md +110 -33
  5. data/Rakefile +14 -1
  6. data/docs/components.md +10 -10
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +7 -3
  9. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  10. data/lib/generators/senren/install/install_generator.rb +74 -0
  11. data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
  12. data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
  13. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  14. data/lib/senren/rails/asset_path_guard.rb +128 -0
  15. data/lib/senren/rails/base_component_patch.rb +64 -0
  16. data/lib/senren/rails/component_copier.rb +101 -50
  17. data/lib/senren/rails/component_installer.rb +26 -0
  18. data/lib/senren/rails/doctor.rb +7 -4
  19. data/lib/senren/rails/engine.rb +23 -0
  20. data/lib/senren/rails/host_paths.rb +11 -2
  21. data/lib/senren/rails/marker_block.rb +81 -0
  22. data/lib/senren/rails/registry.rb +10 -4
  23. data/lib/senren/rails/safe_write.rb +169 -0
  24. data/lib/senren/rails/skill_writer.rb +47 -11
  25. data/lib/senren/rails/version.rb +1 -1
  26. data/lib/senren/rails.rb +1 -1
  27. data/lib/senren-ui.rb +15 -0
  28. data/lib/tasks/senren.rake +43 -17
  29. data/registry/components.yml +45 -0
  30. data/registry/recipes.yml +12 -0
  31. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  32. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  33. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  34. data/templates/components/avatar/avatar_component.rb +8 -1
  35. data/templates/components/cart/cart_component.html.erb +61 -0
  36. data/templates/components/cart/cart_component.rb +71 -0
  37. data/templates/components/checkbox/checkbox_component.rb +1 -1
  38. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  39. data/templates/components/command/command_component.rb +1 -1
  40. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  41. data/templates/components/dialog/dialog_component.rb +1 -1
  42. data/templates/components/form/form_component.rb +9 -1
  43. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  44. data/templates/components/product_card/product_card_component.html.erb +38 -0
  45. data/templates/components/product_card/product_card_component.rb +49 -0
  46. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  47. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  48. data/templates/components/separator/separator_component.rb +7 -0
  49. data/templates/components/sheet/sheet_component.rb +1 -1
  50. data/templates/components/tooltip/tooltip_component.rb +2 -2
  51. data/templates/components/typography/typography_component.rb +7 -0
  52. data/templates/controllers/accordion_controller.js +1 -1
  53. data/templates/controllers/alert_dialog_controller.js +31 -7
  54. data/templates/controllers/cart_controller.js +83 -0
  55. data/templates/controllers/clipboard_controller.js +12 -1
  56. data/templates/controllers/command_controller.js +3 -4
  57. data/templates/controllers/context_menu_controller.js +38 -11
  58. data/templates/controllers/data_table_controller.js +8 -3
  59. data/templates/controllers/dialog_controller.js +47 -21
  60. data/templates/controllers/dropdown_menu_controller.js +40 -27
  61. data/templates/controllers/hover_card_controller.js +8 -0
  62. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  63. data/templates/controllers/masked_input_controller.js +8 -1
  64. data/templates/controllers/popover_controller.js +25 -10
  65. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  66. data/templates/controllers/sheet_controller.js +41 -11
  67. metadata +13 -17
  68. 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#syncSoon" 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))]">
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 %>
@@ -12,7 +12,7 @@ module Senren
12
12
  @value = value
13
13
  @label = label
14
14
  @placeholder = placeholder
15
- @dom_id = id || "senren-editor-#{SecureRandom.hex(3)}"
15
+ @dom_id = id || senren_dom_id(name)
16
16
  @toolbar = toolbar
17
17
  @debug = debug
18
18
  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 || "senren-sheet-#{SecureRandom.hex(3)}"
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 = "senren-tooltip-#{SecureRandom.hex(3)}"
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.overlayTarget.hidden = false
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.overlayTarget.hidden = true
30
- this.panelTarget.hidden = true
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
- window.setTimeout(() => { this.buttonTarget.textContent = original }, 1200)
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.element.dispatchEvent(new CustomEvent("senren:command-select", {
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
- document.removeEventListener("click", this._onDocClick)
15
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
16
20
  }
17
21
 
18
22
  open(event) {
19
23
  event.preventDefault()
20
- const rect = this.element.getBoundingClientRect()
21
- this.menuTarget.style.top = (event.clientY - rect.top) + "px"
22
- this.menuTarget.style.left = (event.clientX - rect.left) + "px"
23
- this.menuTarget.hidden = false
24
- document.addEventListener("click", this._onDocClick)
25
- document.addEventListener("keydown", this._onKey)
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.menuTarget.hidden = true
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) { if (!this.menuTarget.contains(event.target)) this.close() }
35
- _onKey(event) { if (event.key === "Escape") this.close() }
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
- const rows = [...this.rowTargets]
19
- rows.sort((a, b) => this.compare(this.valueFor(a, key), this.valueFor(b, key), direction))
20
- rows.forEach((row) => this.bodyTarget.appendChild(row))
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, focus trap, Escape to close, body scroll lock.
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 = { open: Boolean }
15
+ static values = { open: Boolean }
8
16
 
9
17
  connect() {
10
18
  this._onKey = this._onKey.bind(this)
11
- if (this.openValue) this._show()
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.body.style.overflow = ""
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._show()
31
+ this.openValue = true
22
32
  }
23
33
 
24
34
  close(event) {
25
35
  event?.preventDefault()
26
- this._hide()
36
+ this.openValue = false
27
37
  }
28
38
 
29
- _show() {
30
- this.openValue = true
31
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false
32
- if (this.hasPanelTarget) this.panelTarget.hidden = false
33
- document.addEventListener("keydown", this._onKey)
34
- document.body.style.overflow = "hidden"
35
- queueMicrotask(() => this.panelTarget?.focus())
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
- document.body.style.overflow = ""
44
- this.triggerTarget?.focus?.()
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._hide()
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
- document.removeEventListener("click", this._onDocClick)
16
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
17
20
  }
18
21
 
19
22
  toggle(event) {
20
23
  event?.preventDefault()
21
- this.menuTarget.hidden ? this._show() : this.close()
24
+ this.openValue = !this.openValue
22
25
  }
23
26
 
24
27
  close() {
25
- this._setOpenState(false)
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._show()
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") { event.preventDefault(); this._focusItem((idx - 1 + items.length) % items.length) }
43
- if (event.key === "Escape") { this.close(); this.triggerTarget?.focus?.() }
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
- _show() {
47
- this._setOpenState(true)
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
- _setOpenState(open) {
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
- const triggerControl = this._triggerControl()
60
- if (triggerControl && triggerControl !== this.triggerTarget) {
61
- triggerControl.dataset.state = state
62
- triggerControl.setAttribute("aria-expanded", open ? "true" : "false")
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
- this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
66
- chevron.dataset.state = state
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
- this.element.addEventListener("input", this._onInput.bind(this))
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) {