senren-ui 0.1.6 → 0.3.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/CONTRIBUTING.md +41 -3
  4. data/README.md +138 -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/docs/visual_style.md +80 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +77 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
  13. data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
  14. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  15. data/lib/senren/rails/agent_rules_writer.rb +65 -19
  16. data/lib/senren/rails/asset_path_guard.rb +128 -0
  17. data/lib/senren/rails/base_component_patch.rb +64 -0
  18. data/lib/senren/rails/component_copier.rb +101 -50
  19. data/lib/senren/rails/component_installer.rb +26 -0
  20. data/lib/senren/rails/doctor.rb +7 -4
  21. data/lib/senren/rails/engine.rb +23 -0
  22. data/lib/senren/rails/host_paths.rb +11 -2
  23. data/lib/senren/rails/marker_block.rb +81 -0
  24. data/lib/senren/rails/registry.rb +10 -4
  25. data/lib/senren/rails/safe_write.rb +169 -0
  26. data/lib/senren/rails/skill_writer.rb +47 -11
  27. data/lib/senren/rails/version.rb +1 -1
  28. data/lib/senren/rails.rb +1 -1
  29. data/lib/senren-ui.rb +15 -0
  30. data/lib/tasks/senren.rake +43 -17
  31. data/registry/components.yml +45 -0
  32. data/registry/recipes.yml +12 -0
  33. data/templates/components/accordion/accordion_component.html.erb +3 -3
  34. data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
  35. data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
  36. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  37. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  38. data/templates/components/avatar/avatar_component.rb +8 -1
  39. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  40. data/templates/components/button/button_component.html.erb +1 -1
  41. data/templates/components/button/button_component.rb +27 -1
  42. data/templates/components/calendar/calendar_component.html.erb +2 -2
  43. data/templates/components/card/card_component.html.erb +4 -6
  44. data/templates/components/carousel/carousel_component.html.erb +1 -1
  45. data/templates/components/cart/cart_component.html.erb +67 -0
  46. data/templates/components/cart/cart_component.rb +71 -0
  47. data/templates/components/checkbox/checkbox_component.rb +1 -1
  48. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  49. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  50. data/templates/components/combobox/combobox_component.html.erb +3 -3
  51. data/templates/components/command/command_component.html.erb +1 -1
  52. data/templates/components/command/command_component.rb +1 -1
  53. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  54. data/templates/components/date_picker/date_picker_component.html.erb +3 -3
  55. data/templates/components/dialog/dialog_component.html.erb +7 -3
  56. data/templates/components/dialog/dialog_component.rb +12 -1
  57. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  58. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  59. data/templates/components/form/form_component.rb +9 -1
  60. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
  62. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  63. data/templates/components/link/link_component.html.erb +1 -1
  64. data/templates/components/native_select/native_select_component.html.erb +2 -2
  65. data/templates/components/pagination/pagination_component.html.erb +2 -2
  66. data/templates/components/popover/popover_component.html.erb +6 -3
  67. data/templates/components/product_card/product_card_component.html.erb +38 -0
  68. data/templates/components/product_card/product_card_component.rb +49 -0
  69. data/templates/components/progress/progress_component.html.erb +2 -2
  70. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  72. data/templates/components/search_input/search_input_component.html.erb +2 -2
  73. data/templates/components/separator/separator_component.rb +7 -0
  74. data/templates/components/sheet/sheet_component.html.erb +13 -4
  75. data/templates/components/sheet/sheet_component.rb +12 -1
  76. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  77. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  78. data/templates/components/switch/switch_component.html.erb +1 -1
  79. data/templates/components/table/table_component.html.erb +2 -2
  80. data/templates/components/tabs/tabs_component.html.erb +3 -3
  81. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  82. data/templates/components/tooltip/tooltip_component.rb +2 -2
  83. data/templates/components/top_nav/top_nav_component.html.erb +2 -2
  84. data/templates/components/typography/typography_component.rb +7 -0
  85. data/templates/controllers/accordion_controller.js +1 -1
  86. data/templates/controllers/alert_dialog_controller.js +31 -7
  87. data/templates/controllers/cart_controller.js +85 -0
  88. data/templates/controllers/clipboard_controller.js +12 -1
  89. data/templates/controllers/command_controller.js +3 -4
  90. data/templates/controllers/context_menu_controller.js +38 -11
  91. data/templates/controllers/data_table_controller.js +8 -3
  92. data/templates/controllers/dialog_controller.js +47 -21
  93. data/templates/controllers/dropdown_menu_controller.js +40 -27
  94. data/templates/controllers/hover_card_controller.js +8 -0
  95. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  96. data/templates/controllers/masked_input_controller.js +8 -1
  97. data/templates/controllers/popover_controller.js +25 -10
  98. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  99. data/templates/controllers/sheet_controller.js +41 -11
  100. metadata +14 -17
  101. data/lib/senren/rails/installer.rb +0 -85
@@ -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
@@ -1,4 +1,7 @@
1
- <div data-controller="senren--sheet" data-senren-component="sheet" id="<%= dom_id %>">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both -- which is why a Stimulus
3
+ value could not be set from the server. %>
4
+ <%= tag.div(**wrapper_attrs(id: dom_id, data: { controller: "senren--sheet" })) do %>
2
5
  <% if trigger? %>
3
6
  <span data-action="click->senren--sheet#open" data-senren--sheet-target="trigger">
4
7
  <%= trigger %>
@@ -8,11 +11,12 @@
8
11
  <% end %>
9
12
 
10
13
  <div data-senren--sheet-target="overlay" hidden
14
+ data-action="click->senren--sheet#close"
11
15
  class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"></div>
12
16
 
13
17
  <div data-senren--sheet-target="panel" hidden role="dialog" aria-modal="true"
14
18
  data-open="false"
15
- class="fixed z-50 bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] shadow-lg transition-transform duration-200 <%= self.class::VARIANTS[variant] %>">
19
+ class="<%= panel_class("fixed z-50 bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] shadow-lg transition-transform duration-200 #{ self.class::VARIANTS[variant] }") %>">
16
20
  <div class="flex h-full flex-col p-6">
17
21
  <% if title? %>
18
22
  <h2 class="text-lg font-semibold"><%= title %></h2>
@@ -20,7 +24,12 @@
20
24
  <% if description? %>
21
25
  <p class="text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
22
26
  <% end %>
23
- <div class="mt-4 flex-1 overflow-auto">
27
+ <%# -mx-2 px-2 gives the scroll container room for focus rings.
28
+ overflow-auto clips whatever draws outside a child's border box,
29
+ and a ring does exactly that -- a focused input sitting flush
30
+ against the edge had its ring shaved off on both sides. The
31
+ negative margin cancels the padding, so nothing moves. %>
32
+ <div class="-mx-2 mt-4 flex-1 overflow-auto px-2">
24
33
  <%= body || content %>
25
34
  </div>
26
35
  <% if footer? %>
@@ -34,4 +43,4 @@
34
43
  <span aria-hidden="true">×</span>
35
44
  </button>
36
45
  </div>
37
- </div>
46
+ <% end %>
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Senren
4
+ # Two ancestor traps to know about, because neither is visible in this file
5
+ # and both look like a z-index bug from the outside:
6
+ #
7
+ # * A `z-index` on any positioned ancestor creates a stacking context, and
8
+ # a fixed overlay cannot escape one. The panel then competes only inside
9
+ # that context, so a sticky header with a lower z-index still covers it.
10
+ # `position: relative` alone is fine; it is the z-index that traps.
11
+ # * `transform`, `filter`, `backdrop-filter`, `will-change` and an opacity
12
+ # below 1 create a stacking context too, with the same effect.
13
+ #
14
+ # Symptom in both cases: raising the panel's z-index changes nothing.
4
15
  class SheetComponent < BaseComponent
5
16
  renders_one :trigger
6
17
  renders_one :title
@@ -19,7 +30,7 @@ module Senren
19
30
 
20
31
  def initialize(side: :right, id: nil, class_name: nil, **html)
21
32
  super(variant: side, size: :md, class_name: class_name, **html)
22
- @dom_id = id || "senren-sheet-#{SecureRandom.hex(3)}"
33
+ @dom_id = id || senren_dom_id(side)
23
34
  end
24
35
 
25
36
  attr_reader :dom_id
@@ -1,6 +1,6 @@
1
- <span <%= tag.attributes(**root_attrs("inline-flex items-center gap-1 text-xs text-[hsl(var(--senren-muted-foreground))]")) %>>
1
+ <%= tag.span(**root_attrs("inline-flex items-center gap-1 text-xs text-[hsl(var(--senren-muted-foreground))]")) do %>
2
2
  <% (keys.presence || [content]).compact.each_with_index do |key, index| %>
3
3
  <% if index.positive? %><span aria-hidden="true">+</span><% end %>
4
4
  <kbd class="rounded border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted))] px-1.5 py-0.5 font-mono text-[11px] text-[hsl(var(--senren-foreground))] shadow-sm"><%= key %></kbd>
5
5
  <% end %>
6
- </span>
6
+ <% end %>
@@ -1,4 +1,4 @@
1
- <aside <%= tag.attributes(**root_attrs("flex min-h-80 flex-col overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))] transition-[width] duration-300 ease-in-out", data: { controller: "senren--sidebar" })) %>>
1
+ <%= tag.aside(**root_attrs("flex min-h-80 flex-col overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))] transition-[width] duration-300 ease-in-out", data: { controller: "senren--sidebar" })) do %>
2
2
  <div class="mb-4 flex items-center justify-between gap-2 px-2">
3
3
  <div data-senren--sidebar-target="brand" class="truncate font-display text-sm font-semibold tracking-tight"><%= brand %></div>
4
4
  <button type="button" data-senren--sidebar-target="toggleButton" class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-[hsl(var(--senren-muted-foreground))] transition-colors hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-accent-foreground))]" data-action="click->senren--sidebar#toggle" aria-label="Toggle sidebar" aria-expanded="<%= variant != :compact %>">
@@ -21,4 +21,4 @@
21
21
  <% if content? %>
22
22
  <div data-senren--sidebar-target="footer" class="mt-auto pt-4 text-xs text-[hsl(var(--senren-muted-foreground))]"><%= content %></div>
23
23
  <% end %>
24
- </aside>
24
+ <% end %>
@@ -1,6 +1,6 @@
1
1
  <%= tag.label(**root_attrs("inline-flex items-center gap-3 cursor-pointer")) do %>
2
2
  <span class="relative inline-flex h-6 w-11 items-center rounded-full bg-[hsl(var(--senren-muted))] has-[:checked]:bg-[hsl(var(--senren-primary))] transition-colors">
3
- <input type="checkbox" role="switch" id="<%= id %>" name="<%= name %>" value="<%= value %>" <%= "checked" if checked %> class="peer sr-only">
3
+ <input type="checkbox" role="switch" id="<%= id %>" name="<%= name %>" value="<%= value %>" <% if checked %>checked<% end %> class="peer sr-only">
4
4
  <span aria-hidden="true" class="absolute left-0.5 top-0.5 inline-block h-5 w-5 transform rounded-full bg-[hsl(var(--senren-background))] transition-transform peer-checked:translate-x-5"></span>
5
5
  </span>
6
6
  <% if label %>
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-full overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))]")) %>>
1
+ <%= tag.div(**root_attrs("w-full overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))]")) do %>
2
2
  <table class="w-full border-collapse text-left text-sm <%= self.class::VARIANTS[variant] %>">
3
3
  <% if caption.present? %>
4
4
  <caption class="sr-only"><%= caption %></caption>
@@ -23,4 +23,4 @@
23
23
  <% end %>
24
24
  </tbody>
25
25
  </table>
26
- </div>
26
+ <% end %>
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-full", data: { controller: "senren--tabs" })) %>>
1
+ <%= tag.div(**root_attrs("w-full", data: { controller: "senren--tabs" })) do %>
2
2
  <div role="tablist" aria-label="<%= label %>" class="<%= self.class::VARIANTS[variant] %> flex flex-wrap items-center gap-1">
3
3
  <% items.each do |item| %>
4
4
  <% selected = active_item?(item) %>
@@ -10,9 +10,9 @@
10
10
  <div class="mt-4">
11
11
  <% items.each do |item| %>
12
12
  <% selected = active_item?(item) %>
13
- <section id="<%= item[:id] %>-panel" role="tabpanel" aria-labelledby="<%= item[:id] %>-tab" data-senren--tabs-target="panel" data-panel-id="<%= item[:id] %>" data-state="<%= selected ? "active" : "inactive" %>" <%= "hidden" unless selected %> class="rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-4 text-sm text-[hsl(var(--senren-card-foreground))]">
13
+ <section id="<%= item[:id] %>-panel" role="tabpanel" aria-labelledby="<%= item[:id] %>-tab" data-senren--tabs-target="panel" data-panel-id="<%= item[:id] %>" data-state="<%= selected ? "active" : "inactive" %>" <% unless selected %>hidden<% end %> class="rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-4 text-sm text-[hsl(var(--senren-card-foreground))]">
14
14
  <%= item[:content].presence || content %>
15
15
  </section>
16
16
  <% end %>
17
17
  </div>
18
- </div>
18
+ <% end %>
@@ -1,9 +1,11 @@
1
- <span data-controller="senren--tooltip" class="relative inline-block" data-senren-component="tooltip">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both. %>
3
+ <%= tag.span(**wrapper_attrs("relative inline-block", data: { controller: "senren--tooltip" })) do %>
2
4
  <span tabindex="0"
3
5
  aria-describedby="<%= id %>"
4
6
  data-action="mouseenter->senren--tooltip#show mouseleave->senren--tooltip#hide focus->senren--tooltip#show blur->senren--tooltip#hide">
5
7
  <%= content %>
6
8
  </span>
7
9
  <span id="<%= id %>" role="tooltip" hidden data-senren--tooltip-target="bubble"
8
- class="absolute z-50 mt-1 whitespace-nowrap rounded-md bg-[hsl(var(--senren-foreground))] text-[hsl(var(--senren-background))] px-2 py-1 text-xs shadow"><%= text %></span>
9
- </span>
10
+ class="<%= panel_class("absolute z-50 mt-1 whitespace-nowrap rounded-md bg-[hsl(var(--senren-foreground))] text-[hsl(var(--senren-background))] px-2 py-1 text-xs shadow") %>"><%= text %></span>
11
+ <% end %>
@@ -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
@@ -1,4 +1,4 @@
1
- <header <%= tag.attributes(**root_attrs("sticky top-0 z-30 border-b border-[hsl(var(--senren-border))] backdrop-blur-md")) %>>
1
+ <%= tag.header(**root_attrs("sticky top-0 z-30 border-b border-[hsl(var(--senren-border))] backdrop-blur-md")) do %>
2
2
  <div class="mx-auto flex h-14 w-full max-w-[1400px] items-center gap-6 px-4 lg:px-8">
3
3
  <div class="min-w-0 flex-none font-display text-sm font-semibold text-[hsl(var(--senren-foreground))]">
4
4
  <% if brand? %><%= brand %><% else %>Senren<% end %>
@@ -18,4 +18,4 @@
18
18
  <div class="ml-auto flex items-center gap-2"><%= actions %></div>
19
19
  <% end %>
20
20
  </div>
21
- </header>
21
+ <% end %>
@@ -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,85 @@
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
+ // Read before the node goes away.
31
+ const removeUrl = line.dataset.removeUrl || null
32
+ line.remove()
33
+ this._recalculate()
34
+ this.dispatch("removed", { detail: { id, removeUrl, subtotalCents: this.subtotalCentsValue } })
35
+ }
36
+
37
+ // The value is the state; this renders it. Called on initialization too, so
38
+ // whatever the server rendered is what shows.
39
+ subtotalCentsValueChanged(cents) {
40
+ if (this.hasSubtotalTarget) this.subtotalTarget.textContent = this.format(cents)
41
+ }
42
+
43
+ format(cents) {
44
+ return `${this.currencyValue || "$"}${(Number(cents) / 100).toFixed(2)}`
45
+ }
46
+
47
+ _step(event, delta) {
48
+ const line = event.currentTarget.closest("[data-senren--cart-target~='line']")
49
+ if (!line) return
50
+
51
+ const output = line.querySelector("[data-senren--cart-target~='quantity']")
52
+ if (!output) return
53
+
54
+ // Never below one: removing is a separate, explicit action, so a stepper
55
+ // that reaches zero would leave a line the user cannot see the price of.
56
+ const next = Math.max(1, Number(output.textContent.trim() || 1) + delta)
57
+ output.textContent = String(next)
58
+
59
+ this._recalculate()
60
+ this.dispatch("changed", {
61
+ detail: { id: line.dataset.lineId, quantity: next, subtotalCents: this.subtotalCentsValue }
62
+ })
63
+ }
64
+
65
+ _recalculate() {
66
+ let cents = 0
67
+ let count = 0
68
+
69
+ this.lineTargets.forEach((line) => {
70
+ const price = Number(line.dataset.priceCents || 0)
71
+ const output = line.querySelector("[data-senren--cart-target~='quantity']")
72
+ const quantity = Number(output?.textContent.trim() || 0)
73
+ const total = price * quantity
74
+
75
+ cents += total
76
+ count += quantity
77
+
78
+ const lineTotal = line.querySelector("[data-senren--cart-target~='lineTotal']")
79
+ if (lineTotal) lineTotal.textContent = this.format(total)
80
+ })
81
+
82
+ if (this.hasCountTarget) this.countTarget.textContent = String(count)
83
+ this.subtotalCentsValue = cents
84
+ }
85
+ }
@@ -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
  }