senren-ui 0.1.5 → 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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -2
  3. data/CONTRIBUTING.md +79 -9
  4. data/README.md +149 -14
  5. data/Rakefile +14 -1
  6. data/docs/components.md +222 -0
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +38 -0
  9. data/lib/commands/senren/add/add_command.rb +35 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +74 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
  13. data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
  14. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  15. data/lib/senren/rails/asset_path_guard.rb +128 -0
  16. data/lib/senren/rails/base_component_patch.rb +64 -0
  17. data/lib/senren/rails/component_copier.rb +138 -18
  18. data/lib/senren/rails/component_installer.rb +73 -0
  19. data/lib/senren/rails/doctor.rb +7 -4
  20. data/lib/senren/rails/engine.rb +23 -0
  21. data/lib/senren/rails/host_paths.rb +11 -2
  22. data/lib/senren/rails/marker_block.rb +81 -0
  23. data/lib/senren/rails/registry.rb +73 -35
  24. data/lib/senren/rails/safe_write.rb +169 -0
  25. data/lib/senren/rails/skill_writer.rb +48 -12
  26. data/lib/senren/rails/version.rb +1 -1
  27. data/lib/senren/rails.rb +2 -1
  28. data/lib/senren-ui.rb +15 -0
  29. data/lib/tasks/senren.rake +53 -34
  30. data/registry/components.yml +45 -0
  31. data/registry/recipes.yml +12 -0
  32. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  33. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  34. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  35. data/templates/components/avatar/avatar_component.rb +8 -1
  36. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
  37. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
  38. data/templates/components/button/button_component.html.erb +1 -1
  39. data/templates/components/carousel/carousel_component.rb +1 -1
  40. data/templates/components/cart/cart_component.html.erb +61 -0
  41. data/templates/components/cart/cart_component.rb +71 -0
  42. data/templates/components/checkbox/checkbox_component.rb +1 -1
  43. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  44. data/templates/components/command/command_component.rb +2 -2
  45. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  46. data/templates/components/dialog/dialog_component.rb +1 -1
  47. data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
  48. data/templates/components/form/form_component.html.erb +8 -1
  49. data/templates/components/form/form_component.rb +12 -2
  50. data/templates/components/input/input_component.html.erb +1 -1
  51. data/templates/components/input/input_component.rb +19 -0
  52. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  53. data/templates/components/label/label_component.html.erb +1 -2
  54. data/templates/components/label/label_component.rb +12 -2
  55. data/templates/components/link/link_component.html.erb +1 -1
  56. data/templates/components/native_select/native_select_component.html.erb +19 -5
  57. data/templates/components/native_select/native_select_component.rb +17 -5
  58. data/templates/components/pagination/pagination_component.rb +2 -1
  59. data/templates/components/product_card/product_card_component.html.erb +38 -0
  60. data/templates/components/product_card/product_card_component.rb +49 -0
  61. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  62. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  63. data/templates/components/separator/separator_component.rb +7 -0
  64. data/templates/components/sheet/sheet_component.rb +1 -1
  65. data/templates/components/sidebar/sidebar_component.rb +2 -2
  66. data/templates/components/switch/switch_component.html.erb +2 -2
  67. data/templates/components/tooltip/tooltip_component.rb +2 -2
  68. data/templates/components/top_nav/top_nav_component.rb +2 -2
  69. data/templates/components/typography/typography_component.rb +7 -0
  70. data/templates/controllers/accordion_controller.js +1 -1
  71. data/templates/controllers/alert_dialog_controller.js +31 -7
  72. data/templates/controllers/cart_controller.js +83 -0
  73. data/templates/controllers/clipboard_controller.js +12 -1
  74. data/templates/controllers/command_controller.js +3 -4
  75. data/templates/controllers/context_menu_controller.js +38 -11
  76. data/templates/controllers/data_table_controller.js +8 -3
  77. data/templates/controllers/dialog_controller.js +47 -21
  78. data/templates/controllers/dropdown_menu_controller.js +40 -27
  79. data/templates/controllers/hover_card_controller.js +8 -0
  80. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  81. data/templates/controllers/masked_input_controller.js +8 -1
  82. data/templates/controllers/popover_controller.js +25 -10
  83. data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
  84. data/templates/controllers/sheet_controller.js +41 -11
  85. metadata +20 -6
  86. data/lib/senren/rails/installer.rb +0 -84
@@ -9,11 +9,21 @@ module Senren
9
9
 
10
10
  SIZES = { md: '' }.freeze
11
11
 
12
- def initialize(for_field:, variant: :default, class_name: nil, **html)
12
+ # Accept optional text: param as fallback for inline block syntax.
13
+ # This ensures both patterns work reliably:
14
+ # render(Senren::LabelComponent.new(for_field: "name", text: "Name"))
15
+ # render(Senren::LabelComponent.new(for_field: "name")) { "Name" }
16
+ def initialize(for_field:, text: nil, variant: :default, class_name: nil, **html)
13
17
  super(variant: variant, size: :md, class_name: class_name, **html)
14
18
  @for_field = for_field
19
+ @text = text
15
20
  end
16
21
 
17
- attr_reader :for_field
22
+ attr_reader :for_field, :text
23
+
24
+ # Resolve label text: content block > text param > empty string
25
+ def label_text
26
+ content.presence || @text || ''
27
+ end
18
28
  end
19
29
  end
@@ -1 +1 @@
1
- <%= tag.a content, **root_attrs("inline-flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] rounded-sm", href: href, **external_attrs) %>
1
+ <%= tag.a content, **root_attrs("inline-flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] rounded-sm", href: safe_url(href), **external_attrs) %>
@@ -1,5 +1,6 @@
1
- <%= tag.div(**wrapper_attrs) do %>
2
- <%= tag.select(**select_attrs) do %>
1
+ <% if native_arrow? %>
2
+ <%# Native mode: no wrapper div, no SVG arrow — let the browser render its own. %>
3
+ <%= tag.select(**root_select_attrs) do %>
3
4
  <% if prompt %>
4
5
  <option value=""><%= prompt %></option>
5
6
  <% end %>
@@ -8,7 +9,20 @@
8
9
  <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
9
10
  <% end %>
10
11
  <% end %>
11
- <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
12
- <path d="m5 8 5 5 5-5"></path>
13
- </svg>
12
+ <% else %>
13
+ <%# Custom arrow mode: wrapper div + positioned SVG. %>
14
+ <%= tag.div(class: "group relative w-full", data: { senren_component: senren_component_name }) do %>
15
+ <%= tag.select(**select_attrs) do %>
16
+ <% if prompt %>
17
+ <option value=""><%= prompt %></option>
18
+ <% end %>
19
+ <% options.each do |opt| %>
20
+ <% value, label = Array === opt ? opt : [opt, opt] %>
21
+ <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
22
+ <% end %>
23
+ <% end %>
24
+ <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
25
+ <path d="m5 8 5 5 5-5"></path>
26
+ </svg>
27
+ <% end %>
14
28
  <% end %>
@@ -13,20 +13,23 @@ module Senren
13
13
  lg: 'h-12 text-base px-4'
14
14
  }.freeze
15
15
 
16
- def initialize(name:, options:, selected: nil, id: nil, prompt: nil, variant: :default, size: :md, class_name: nil,
17
- **html)
16
+ # native_arrow: true → keep browser/OS native arrow (appearance-auto)
17
+ # native_arrow: false → use custom SVG arrow (appearance-none + SVG overlay)
18
+ def initialize(name:, options:, selected: nil, id: nil, prompt: nil, native_arrow: true,
19
+ variant: :default, size: :md, class_name: nil, **html)
18
20
  super(variant: variant, size: size, class_name: class_name, **html)
19
21
  @name = name
20
22
  @options = options
21
23
  @selected = selected
22
24
  @id = id || name.to_s.parameterize
23
25
  @prompt = prompt
26
+ @native_arrow = native_arrow
24
27
  end
25
28
 
26
29
  attr_reader :name, :options, :selected, :id, :prompt
27
30
 
28
- def wrapper_attrs
29
- { class: 'group relative w-full', data: { senren_component: senren_component_name } }
31
+ def native_arrow?
32
+ @native_arrow
30
33
  end
31
34
 
32
35
  def select_attrs
@@ -39,9 +42,18 @@ module Senren
39
42
  )
40
43
  end
41
44
 
45
+ def root_select_attrs
46
+ attrs = select_attrs
47
+ data = (attrs[:data] || {}).merge(senren_component: senren_component_name)
48
+
49
+ attrs.merge(data: data)
50
+ end
51
+
42
52
  def select_classes
53
+ appearance = native_arrow? ? 'appearance-auto' : 'appearance-none pr-9'
43
54
  [
44
- 'flex w-full cursor-pointer appearance-none rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] pr-9 text-[hsl(var(--senren-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
55
+ 'w-full cursor-pointer rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
56
+ appearance,
45
57
  self.class::VARIANTS[variant],
46
58
  self.class::SIZES[size],
47
59
  class_name,
@@ -18,7 +18,8 @@ module Senren
18
18
  def page_url(page)
19
19
  return '#' unless path
20
20
 
21
- path.respond_to?(:call) ? path.call(page) : path.to_s.gsub(':page', page.to_s)
21
+ safe_page = page.to_i.clamp(1, total_pages)
22
+ safe_url(path.respond_to?(:call) ? path.call(safe_page) : path.to_s.gsub(':page', safe_page.to_s))
22
23
  end
23
24
  end
24
25
  end
@@ -0,0 +1,38 @@
1
+ <%= tag.article(**root_attrs("flex flex-col overflow-hidden rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id)) do %>
2
+ <div class="relative aspect-square w-full bg-[hsl(var(--senren-muted)/0.4)]">
3
+ <% if safe_image_url %>
4
+ <img src="<%= safe_image_url %>" alt="<%= title %>" loading="lazy" class="h-full w-full object-cover">
5
+ <% else %>
6
+ <div class="flex h-full w-full items-center justify-center text-sm text-[hsl(var(--senren-muted-foreground))]" aria-hidden="true">
7
+ No image
8
+ </div>
9
+ <% end %>
10
+
11
+ <% if badge %>
12
+ <span class="absolute left-2 top-2 inline-flex items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-2 py-0.5 text-xs font-medium text-[hsl(var(--senren-primary-foreground))]">
13
+ <%= badge %>
14
+ </span>
15
+ <% end %>
16
+ </div>
17
+
18
+ <div class="flex flex-1 flex-col gap-2 p-4">
19
+ <h3 class="text-sm font-medium leading-tight text-[hsl(var(--senren-foreground))]">
20
+ <%= title %>
21
+ </h3>
22
+
23
+ <% if description %>
24
+ <p class="line-clamp-2 text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
25
+ <% end %>
26
+
27
+ <p class="mt-auto text-base font-semibold text-[hsl(var(--senren-foreground))]"><%= price %></p>
28
+
29
+ <%# A plain form: the server owns the cart, and Turbo handles the response. %>
30
+ <%= form_with url: safe_action_url, method: method, class: "mt-1" do %>
31
+ <button type="submit"
32
+ <%= "disabled" unless available? %>
33
+ class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:cursor-not-allowed disabled:opacity-50">
34
+ <%= button_label %>
35
+ </button>
36
+ <% end %>
37
+ </div>
38
+ <% end %>
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ # A product tile with an add-to-cart action.
5
+ #
6
+ # Adding to a cart is an ordinary form submission, so this component ships no
7
+ # Stimulus controller. That is deliberate: a listing page renders this tile
8
+ # many times, and a controller here would download JavaScript on a page that
9
+ # does not need any.
10
+ #
11
+ # Price is passed in already formatted. Currency formatting is locale- and
12
+ # money-library-specific and belongs to the host app; a UI library that
13
+ # guesses gets it wrong in another country.
14
+ class ProductCardComponent < BaseComponent
15
+ VARIANTS = {
16
+ default: 'border-[hsl(var(--senren-border))]',
17
+ featured: 'border-[hsl(var(--senren-primary))] ring-1 ring-[hsl(var(--senren-primary)/0.35)]'
18
+ }.freeze
19
+
20
+ SIZES = { md: '' }.freeze
21
+
22
+ def initialize(title:, price:, url:, image_url: nil, description: nil, badge: nil,
23
+ action_label: 'Add to cart', available: true, method: :post,
24
+ variant: :default, id: nil, class_name: nil, **html)
25
+ super(variant: variant, size: :md, class_name: class_name, **html)
26
+ @title = title
27
+ @price = price
28
+ @url = url
29
+ @image_url = image_url
30
+ @description = description
31
+ @badge = badge
32
+ @action_label = action_label
33
+ @available = available
34
+ @method = method
35
+ @dom_id = id || senren_dom_id(title)
36
+ end
37
+
38
+ attr_reader :title, :price, :image_url, :description, :badge, :action_label, :method, :dom_id
39
+
40
+ def available? = @available == true
41
+
42
+ # Untrusted values reach href/src attributes, so both go through the shared
43
+ # guards rather than being interpolated directly.
44
+ def safe_action_url = safe_url(@url)
45
+ def safe_image_url = safe_media_url(@image_url)
46
+
47
+ def button_label = available? ? action_label : 'Out of stock'
48
+ end
49
+ end
@@ -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
@@ -24,12 +24,12 @@ module Senren
24
24
  if item.is_a?(Hash)
25
25
  {
26
26
  label: item[:label] || item['label'],
27
- href: item[:href] || item['href'] || '#',
27
+ href: safe_url(item[:href] || item['href']),
28
28
  active: item[:active] || item['active']
29
29
  }
30
30
  else
31
31
  label, href = item
32
- { label: label, href: href || '#', active: false }
32
+ { label: label, href: safe_url(href), active: false }
33
33
  end
34
34
  end
35
35
  end
@@ -1,4 +1,4 @@
1
- <label class="inline-flex items-center gap-3 cursor-pointer">
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
3
  <input type="checkbox" role="switch" id="<%= id %>" name="<%= name %>" value="<%= value %>" <%= "checked" if checked %> 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>
@@ -8,4 +8,4 @@
8
8
  <% else %>
9
9
  <%= content %>
10
10
  <% end %>
11
- </label>
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
@@ -31,12 +31,12 @@ module Senren
31
31
  if item.is_a?(Hash)
32
32
  {
33
33
  label: item[:label] || item['label'],
34
- href: item[:href] || item['href'] || '#',
34
+ href: safe_url(item[:href] || item['href']),
35
35
  active: item[:active] || item['active']
36
36
  }
37
37
  else
38
38
  label, href = item
39
- { label: label, href: href || '#', active: false }
39
+ { label: label, href: safe_url(href), active: false }
40
40
  end
41
41
  end
42
42
  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,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) {