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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +124 -2
- data/CONTRIBUTING.md +79 -9
- data/README.md +149 -14
- data/Rakefile +14 -1
- data/docs/components.md +222 -0
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +38 -0
- data/lib/commands/senren/add/add_command.rb +35 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
- data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +138 -18
- data/lib/senren/rails/component_installer.rb +73 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +73 -35
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +48 -12
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +2 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +53 -34
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/carousel/carousel_component.rb +1 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +2 -2
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
- data/templates/components/form/form_component.html.erb +8 -1
- data/templates/components/form/form_component.rb +12 -2
- data/templates/components/input/input_component.html.erb +1 -1
- data/templates/components/input/input_component.rb +19 -0
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/label/label_component.html.erb +1 -2
- data/templates/components/label/label_component.rb +12 -2
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +19 -5
- data/templates/components/native_select/native_select_component.rb +17 -5
- data/templates/components/pagination/pagination_component.rb +2 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/sidebar/sidebar_component.rb +2 -2
- data/templates/components/switch/switch_component.html.erb +2 -2
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +20 -6
- data/lib/senren/rails/installer.rb +0 -84
|
@@ -9,11 +9,21 @@ module Senren
|
|
|
9
9
|
|
|
10
10
|
SIZES = { md: '' }.freeze
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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#
|
|
29
|
+
<div id="<%= dom_id %>-editor" role="textbox" aria-multiline="true" aria-label="<%= label %>" contenteditable="true" data-placeholder="<%= placeholder %>" data-senren--rich-text-editor-lite-target="editor" data-action="click->senren--rich-text-editor-lite#openLink input->senren--rich-text-editor-lite#sync input->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#rememberSelection keyup->senren--rich-text-editor-lite#updateToolbar mouseup->senren--rich-text-editor-lite#rememberSelection mouseup->senren--rich-text-editor-lite#updateToolbar touchend->senren--rich-text-editor-lite#rememberSelection paste->senren--rich-text-editor-lite#paste drop->senren--rich-text-editor-lite#paste" class="min-h-40 cursor-text bg-[hsl(var(--senren-background))] p-4 text-left text-sm leading-6 text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-inset focus:ring-[hsl(var(--senren-ring))]">
|
|
30
30
|
<%= sanitize(initial_content, tags: %w[p br strong b em i a ul ol li h1 h2 h3], attributes: %w[href rel target data-align]) %>
|
|
31
31
|
</div>
|
|
32
32
|
<% end %>
|
|
@@ -7,6 +7,13 @@ module Senren
|
|
|
7
7
|
|
|
8
8
|
SIZES = { md: '' }.freeze
|
|
9
9
|
|
|
10
|
+
# BaseComponent defaults to `variant: :default`, which this component does
|
|
11
|
+
# not define, so `.new` with no variant raised instead of rendering. The
|
|
12
|
+
# default is :horizontal (a rule across the flow).
|
|
13
|
+
def initialize(variant: :horizontal, **args)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
10
17
|
def aria_orientation = variant == :vertical ? 'vertical' : 'horizontal'
|
|
11
18
|
end
|
|
12
19
|
end
|
|
@@ -19,7 +19,7 @@ module Senren
|
|
|
19
19
|
|
|
20
20
|
def initialize(side: :right, id: nil, class_name: nil, **html)
|
|
21
21
|
super(variant: side, size: :md, class_name: class_name, **html)
|
|
22
|
-
@dom_id = id ||
|
|
22
|
+
@dom_id = id || senren_dom_id(side)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
attr_reader :dom_id
|
|
@@ -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
|
|
32
|
+
{ label: label, href: safe_url(href), active: false }
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
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
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
21
|
-
this.panelTarget.hidden = false
|
|
22
|
-
document.addEventListener("keydown", this._onKey)
|
|
23
|
-
document.body.style.overflow = "hidden"
|
|
24
|
-
queueMicrotask(() => this.panelTarget?.focus())
|
|
27
|
+
this.openValue = true
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
close(event) {
|
|
28
31
|
event?.preventDefault()
|
|
29
|
-
this.
|
|
30
|
-
|
|
32
|
+
this.openValue = false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
openValueChanged(isOpen, wasOpen) {
|
|
36
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
|
|
37
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
|
|
38
|
+
|
|
39
|
+
if (isOpen) {
|
|
40
|
+
document.addEventListener("keydown", this._onKey)
|
|
41
|
+
document.body.style.overflow = "hidden"
|
|
42
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
43
|
+
this.dispatch("opened")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
document.removeEventListener("keydown", this._onKey)
|
|
32
48
|
document.body.style.overflow = ""
|
|
49
|
+
if (wasOpen !== undefined) this.dispatch("closed")
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
_onKey(event) {
|
|
36
53
|
if (event.key === "Escape") this.close()
|
|
37
54
|
}
|
|
55
|
+
|
|
56
|
+
// Turbo snapshots the page before navigating away; without this the scroll
|
|
57
|
+
// lock is captured while applied and a back navigation restores a page that
|
|
58
|
+
// cannot be scrolled.
|
|
59
|
+
_onBeforeCache() {
|
|
60
|
+
this.openValue = false
|
|
61
|
+
}
|
|
38
62
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// senren--cart
|
|
4
|
+
// Local UI only: quantity steppers and the displayed subtotal. The server owns
|
|
5
|
+
// the cart, so nothing here talks to it — a host app persists changes by
|
|
6
|
+
// listening for senren--cart:changed, or by submitting the surrounding form.
|
|
7
|
+
//
|
|
8
|
+
// State lives in `subtotalCentsValue`, following the pattern in
|
|
9
|
+
// .senren/conventions.md: actions assign values, and subtotalCentsValueChanged
|
|
10
|
+
// performs the DOM work. A server-rendered subtotal therefore paints the first
|
|
11
|
+
// frame, survives a Turbo morph, and can be corrected by a Turbo Stream that
|
|
12
|
+
// changes one attribute.
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static targets = ["line", "quantity", "lineTotal", "subtotal", "count"]
|
|
15
|
+
static values = { currency: String, subtotalCents: Number }
|
|
16
|
+
|
|
17
|
+
increment(event) {
|
|
18
|
+
this._step(event, +1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
decrement(event) {
|
|
22
|
+
this._step(event, -1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
remove(event) {
|
|
26
|
+
const line = event.currentTarget.closest("[data-senren--cart-target~='line']")
|
|
27
|
+
if (!line) return
|
|
28
|
+
|
|
29
|
+
const id = line.dataset.lineId
|
|
30
|
+
line.remove()
|
|
31
|
+
this._recalculate()
|
|
32
|
+
this.dispatch("removed", { detail: { id, subtotalCents: this.subtotalCentsValue } })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The value is the state; this renders it. Called on initialization too, so
|
|
36
|
+
// whatever the server rendered is what shows.
|
|
37
|
+
subtotalCentsValueChanged(cents) {
|
|
38
|
+
if (this.hasSubtotalTarget) this.subtotalTarget.textContent = this.format(cents)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
format(cents) {
|
|
42
|
+
return `${this.currencyValue || "$"}${(Number(cents) / 100).toFixed(2)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_step(event, delta) {
|
|
46
|
+
const line = event.currentTarget.closest("[data-senren--cart-target~='line']")
|
|
47
|
+
if (!line) return
|
|
48
|
+
|
|
49
|
+
const output = line.querySelector("[data-senren--cart-target~='quantity']")
|
|
50
|
+
if (!output) return
|
|
51
|
+
|
|
52
|
+
// Never below one: removing is a separate, explicit action, so a stepper
|
|
53
|
+
// that reaches zero would leave a line the user cannot see the price of.
|
|
54
|
+
const next = Math.max(1, Number(output.textContent.trim() || 1) + delta)
|
|
55
|
+
output.textContent = String(next)
|
|
56
|
+
|
|
57
|
+
this._recalculate()
|
|
58
|
+
this.dispatch("changed", {
|
|
59
|
+
detail: { id: line.dataset.lineId, quantity: next, subtotalCents: this.subtotalCentsValue }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_recalculate() {
|
|
64
|
+
let cents = 0
|
|
65
|
+
let count = 0
|
|
66
|
+
|
|
67
|
+
this.lineTargets.forEach((line) => {
|
|
68
|
+
const price = Number(line.dataset.priceCents || 0)
|
|
69
|
+
const output = line.querySelector("[data-senren--cart-target~='quantity']")
|
|
70
|
+
const quantity = Number(output?.textContent.trim() || 0)
|
|
71
|
+
const total = price * quantity
|
|
72
|
+
|
|
73
|
+
cents += total
|
|
74
|
+
count += quantity
|
|
75
|
+
|
|
76
|
+
const lineTotal = line.querySelector("[data-senren--cart-target~='lineTotal']")
|
|
77
|
+
if (lineTotal) lineTotal.textContent = this.format(total)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (this.hasCountTarget) this.countTarget.textContent = String(count)
|
|
81
|
+
this.subtotalCentsValue = cents
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -9,9 +9,20 @@ export default class extends Controller {
|
|
|
9
9
|
async copy() {
|
|
10
10
|
const value = this.sourceTarget.value || this.sourceTarget.textContent
|
|
11
11
|
await navigator.clipboard.writeText(value)
|
|
12
|
+
// The await is a suspension point: the element may be gone by now.
|
|
13
|
+
if (!this.hasButtonTarget) return
|
|
14
|
+
|
|
12
15
|
const original = this.buttonTarget.textContent
|
|
13
16
|
this.buttonTarget.textContent = this.copiedLabelValue || "Copied"
|
|
14
17
|
if (this.hasStatusTarget) this.statusTarget.textContent = "Copied to clipboard"
|
|
15
|
-
|
|
18
|
+
|
|
19
|
+
clearTimeout(this._resetTimer)
|
|
20
|
+
this._resetTimer = setTimeout(() => {
|
|
21
|
+
if (this.hasButtonTarget) this.buttonTarget.textContent = original
|
|
22
|
+
}, 1200)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearTimeout(this._resetTimer)
|
|
16
27
|
}
|
|
17
28
|
}
|
|
@@ -43,10 +43,9 @@ export default class extends Controller {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
choose(event) {
|
|
46
|
-
this.
|
|
47
|
-
bubbles: true,
|
|
46
|
+
this.dispatch("select", {
|
|
48
47
|
detail: { label: event.currentTarget.textContent.trim(), href: event.currentTarget.getAttribute("href") }
|
|
49
|
-
})
|
|
48
|
+
})
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
move(delta) {
|
|
@@ -59,7 +58,7 @@ export default class extends Controller {
|
|
|
59
58
|
updateActive() {
|
|
60
59
|
const options = this.visibleOptions
|
|
61
60
|
this.emptyTarget.hidden = options.length > 0
|
|
62
|
-
this.optionTargets.forEach((option) => option.setAttribute("aria-selected", "false"))
|
|
61
|
+
this.optionTargets.forEach((option) => { option.setAttribute("aria-selected", "false") })
|
|
63
62
|
const active = options[this.activeIndex]
|
|
64
63
|
if (!active) {
|
|
65
64
|
this.inputTarget.removeAttribute("aria-activedescendant")
|
|
@@ -2,8 +2,13 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// senren--context-menu
|
|
4
4
|
// Opens on right-click within the trigger; closes on outside click or Escape.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`. The pointer position is transient and belongs to
|
|
7
|
+
// the opening event, so it is applied in the action; everything else — the
|
|
8
|
+
// visibility and the document listeners — is driven by the value.
|
|
5
9
|
export default class extends Controller {
|
|
6
10
|
static targets = ["trigger", "menu"]
|
|
11
|
+
static values = { open: Boolean }
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
9
14
|
this._onDocClick = this._onDocClick.bind(this)
|
|
@@ -11,26 +16,48 @@ export default class extends Controller {
|
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
disconnect() {
|
|
14
|
-
|
|
15
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
open(event) {
|
|
19
23
|
event.preventDefault()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
|
|
25
|
+
if (this.hasMenuTarget) {
|
|
26
|
+
const rect = this.element.getBoundingClientRect()
|
|
27
|
+
this.menuTarget.style.top = `${event.clientY - rect.top}px`
|
|
28
|
+
this.menuTarget.style.left = `${event.clientX - rect.left}px`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.openValue = true
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
close() {
|
|
29
|
-
this.
|
|
35
|
+
this.openValue = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
openValueChanged(isOpen) {
|
|
39
|
+
if (this.hasMenuTarget) this.menuTarget.hidden = !isOpen
|
|
40
|
+
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
document.addEventListener("click", this._onDocClick)
|
|
43
|
+
document.addEventListener("keydown", this._onKey)
|
|
44
|
+
this.dispatch("opened")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this._stopListening()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_stopListening() {
|
|
30
52
|
document.removeEventListener("click", this._onDocClick)
|
|
31
53
|
document.removeEventListener("keydown", this._onKey)
|
|
32
54
|
}
|
|
33
55
|
|
|
34
|
-
_onDocClick(event) {
|
|
35
|
-
|
|
56
|
+
_onDocClick(event) {
|
|
57
|
+
if (!this.menuTarget.contains(event.target)) this.close()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_onKey(event) {
|
|
61
|
+
if (event.key === "Escape") this.close()
|
|
62
|
+
}
|
|
36
63
|
}
|
|
@@ -15,9 +15,14 @@ export default class extends Controller {
|
|
|
15
15
|
const direction = this.directions[key] === "asc" ? "desc" : "asc"
|
|
16
16
|
this.directions[key] = direction
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
rows
|
|
20
|
-
|
|
18
|
+
// Read each cell once (O(n)) rather than once per comparison (O(n log n)),
|
|
19
|
+
// then move the rows in a single batched write.
|
|
20
|
+
const decorated = this.rowTargets.map((row) => ({ row, value: this.valueFor(row, key) }))
|
|
21
|
+
decorated.sort((a, b) => this.compare(a.value, b.value, direction))
|
|
22
|
+
|
|
23
|
+
const fragment = document.createDocumentFragment()
|
|
24
|
+
decorated.forEach((entry) => { fragment.appendChild(entry.row) })
|
|
25
|
+
this.bodyTarget.appendChild(fragment)
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
valueFor(row, key) {
|