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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/CONTRIBUTING.md +41 -3
- data/README.md +138 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/docs/visual_style.md +80 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +77 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
- data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
- data/lib/senren/rails/agent_rules_writer.rb +65 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/accordion/accordion_component.html.erb +3 -3
- data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
- data/templates/components/alert_dialog/alert_dialog_component.rb +5 -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/breadcrumb/breadcrumb_component.html.erb +8 -6
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/button/button_component.rb +27 -1
- data/templates/components/calendar/calendar_component.html.erb +2 -2
- data/templates/components/card/card_component.html.erb +4 -6
- data/templates/components/carousel/carousel_component.html.erb +1 -1
- data/templates/components/cart/cart_component.html.erb +67 -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 +3 -3
- data/templates/components/collapsible/collapsible_component.html.erb +3 -3
- data/templates/components/combobox/combobox_component.html.erb +3 -3
- data/templates/components/command/command_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/context_menu/context_menu_component.html.erb +6 -3
- data/templates/components/date_picker/date_picker_component.html.erb +3 -3
- data/templates/components/dialog/dialog_component.html.erb +7 -3
- data/templates/components/dialog/dialog_component.rb +12 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/hover_card/hover_card_component.html.erb +6 -3
- data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +2 -2
- data/templates/components/pagination/pagination_component.html.erb +2 -2
- data/templates/components/popover/popover_component.html.erb +6 -3
- 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/progress/progress_component.html.erb +2 -2
- 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/search_input/search_input_component.html.erb +2 -2
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.html.erb +13 -4
- data/templates/components/sheet/sheet_component.rb +12 -1
- data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
- data/templates/components/sidebar/sidebar_component.html.erb +2 -2
- data/templates/components/switch/switch_component.html.erb +1 -1
- data/templates/components/table/table_component.html.erb +2 -2
- data/templates/components/tabs/tabs_component.html.erb +3 -3
- data/templates/components/tooltip/tooltip_component.html.erb +5 -3
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.html.erb +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 +85 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +14 -17
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
33
|
+
@dom_id = id || senren_dom_id(side)
|
|
23
34
|
end
|
|
24
35
|
|
|
25
36
|
attr_reader :dom_id
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
6
|
+
<% end %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 %>"
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
<% end %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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" %>"
|
|
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
|
-
|
|
18
|
+
<% end %>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 =
|
|
11
|
+
@id = id || senren_dom_id(text)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
attr_reader :text, :id
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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.
|
|
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,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
|
-
|
|
18
|
+
|
|
19
|
+
clearTimeout(this._resetTimer)
|
|
20
|
+
this._resetTimer = setTimeout(() => {
|
|
21
|
+
if (this.hasButtonTarget) this.buttonTarget.textContent = original
|
|
22
|
+
}, 1200)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearTimeout(this._resetTimer)
|
|
16
27
|
}
|
|
17
28
|
}
|
|
@@ -43,10 +43,9 @@ export default class extends Controller {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
choose(event) {
|
|
46
|
-
this.
|
|
47
|
-
bubbles: true,
|
|
46
|
+
this.dispatch("select", {
|
|
48
47
|
detail: { label: event.currentTarget.textContent.trim(), href: event.currentTarget.getAttribute("href") }
|
|
49
|
-
})
|
|
48
|
+
})
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
move(delta) {
|
|
@@ -59,7 +58,7 @@ export default class extends Controller {
|
|
|
59
58
|
updateActive() {
|
|
60
59
|
const options = this.visibleOptions
|
|
61
60
|
this.emptyTarget.hidden = options.length > 0
|
|
62
|
-
this.optionTargets.forEach((option) => option.setAttribute("aria-selected", "false"))
|
|
61
|
+
this.optionTargets.forEach((option) => { option.setAttribute("aria-selected", "false") })
|
|
63
62
|
const active = options[this.activeIndex]
|
|
64
63
|
if (!active) {
|
|
65
64
|
this.inputTarget.removeAttribute("aria-activedescendant")
|
|
@@ -2,8 +2,13 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// senren--context-menu
|
|
4
4
|
// Opens on right-click within the trigger; closes on outside click or Escape.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`. The pointer position is transient and belongs to
|
|
7
|
+
// the opening event, so it is applied in the action; everything else — the
|
|
8
|
+
// visibility and the document listeners — is driven by the value.
|
|
5
9
|
export default class extends Controller {
|
|
6
10
|
static targets = ["trigger", "menu"]
|
|
11
|
+
static values = { open: Boolean }
|
|
7
12
|
|
|
8
13
|
connect() {
|
|
9
14
|
this._onDocClick = this._onDocClick.bind(this)
|
|
@@ -11,26 +16,48 @@ export default class extends Controller {
|
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
disconnect() {
|
|
14
|
-
|
|
15
|
-
document.removeEventListener("keydown", this._onKey)
|
|
19
|
+
this._stopListening()
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
open(event) {
|
|
19
23
|
event.preventDefault()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
|
|
25
|
+
if (this.hasMenuTarget) {
|
|
26
|
+
const rect = this.element.getBoundingClientRect()
|
|
27
|
+
this.menuTarget.style.top = `${event.clientY - rect.top}px`
|
|
28
|
+
this.menuTarget.style.left = `${event.clientX - rect.left}px`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.openValue = true
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
close() {
|
|
29
|
-
this.
|
|
35
|
+
this.openValue = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
openValueChanged(isOpen) {
|
|
39
|
+
if (this.hasMenuTarget) this.menuTarget.hidden = !isOpen
|
|
40
|
+
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
document.addEventListener("click", this._onDocClick)
|
|
43
|
+
document.addEventListener("keydown", this._onKey)
|
|
44
|
+
this.dispatch("opened")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this._stopListening()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_stopListening() {
|
|
30
52
|
document.removeEventListener("click", this._onDocClick)
|
|
31
53
|
document.removeEventListener("keydown", this._onKey)
|
|
32
54
|
}
|
|
33
55
|
|
|
34
|
-
_onDocClick(event) {
|
|
35
|
-
|
|
56
|
+
_onDocClick(event) {
|
|
57
|
+
if (!this.menuTarget.contains(event.target)) this.close()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_onKey(event) {
|
|
61
|
+
if (event.key === "Escape") this.close()
|
|
62
|
+
}
|
|
36
63
|
}
|
|
@@ -15,9 +15,14 @@ export default class extends Controller {
|
|
|
15
15
|
const direction = this.directions[key] === "asc" ? "desc" : "asc"
|
|
16
16
|
this.directions[key] = direction
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
rows
|
|
20
|
-
|
|
18
|
+
// Read each cell once (O(n)) rather than once per comparison (O(n log n)),
|
|
19
|
+
// then move the rows in a single batched write.
|
|
20
|
+
const decorated = this.rowTargets.map((row) => ({ row, value: this.valueFor(row, key) }))
|
|
21
|
+
decorated.sort((a, b) => this.compare(a.value, b.value, direction))
|
|
22
|
+
|
|
23
|
+
const fragment = document.createDocumentFragment()
|
|
24
|
+
decorated.forEach((entry) => { fragment.appendChild(entry.row) })
|
|
25
|
+
this.bodyTarget.appendChild(fragment)
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
valueFor(row, key) {
|
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// senren--dialog
|
|
4
|
-
// Local UI: open/close,
|
|
4
|
+
// Local UI: open/close, Escape to close, body scroll lock.
|
|
5
|
+
//
|
|
6
|
+
// State lives in `openValue`, not in the DOM nodes this controller touches.
|
|
7
|
+
// Actions only assign the value; every DOM change happens in openValueChanged.
|
|
8
|
+
//
|
|
9
|
+
// That inversion is what makes the state server-renderable (render
|
|
10
|
+
// open-value="true" and the dialog is open with no JavaScript), survivable
|
|
11
|
+
// across a Turbo morph, and reachable from a Turbo Stream that changes a single
|
|
12
|
+
// attribute. It is the pattern every stateful Senren controller follows.
|
|
5
13
|
export default class extends Controller {
|
|
6
14
|
static targets = ["overlay", "panel", "trigger"]
|
|
7
|
-
static values
|
|
15
|
+
static values = { open: Boolean }
|
|
8
16
|
|
|
9
17
|
connect() {
|
|
10
18
|
this._onKey = this._onKey.bind(this)
|
|
11
|
-
|
|
19
|
+
this._onBeforeCache = this._onBeforeCache.bind(this)
|
|
20
|
+
document.addEventListener("turbo:before-cache", this._onBeforeCache)
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
disconnect() {
|
|
15
24
|
document.removeEventListener("keydown", this._onKey)
|
|
16
|
-
document.
|
|
25
|
+
document.removeEventListener("turbo:before-cache", this._onBeforeCache)
|
|
26
|
+
this._releaseScroll()
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
open(event) {
|
|
20
30
|
event?.preventDefault()
|
|
21
|
-
this.
|
|
31
|
+
this.openValue = true
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
close(event) {
|
|
25
35
|
event?.preventDefault()
|
|
26
|
-
this.
|
|
36
|
+
this.openValue = false
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Stimulus calls this during initialization too, so the server-rendered value
|
|
40
|
+
// paints the first frame.
|
|
41
|
+
openValueChanged(isOpen, wasOpen) {
|
|
42
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
|
|
43
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
|
|
44
|
+
|
|
45
|
+
if (isOpen) {
|
|
46
|
+
document.addEventListener("keydown", this._onKey)
|
|
47
|
+
document.body.style.overflow = "hidden"
|
|
48
|
+
queueMicrotask(() => this.panelTarget?.focus())
|
|
49
|
+
this.dispatch("opened")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
37
52
|
|
|
38
|
-
_hide() {
|
|
39
|
-
this.openValue = false
|
|
40
|
-
if (this.hasOverlayTarget) this.overlayTarget.hidden = true
|
|
41
|
-
if (this.hasPanelTarget) this.panelTarget.hidden = true
|
|
42
53
|
document.removeEventListener("keydown", this._onKey)
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
this._releaseScroll()
|
|
55
|
+
// Only return focus on a real close, not on the initial render.
|
|
56
|
+
if (wasOpen !== undefined) {
|
|
57
|
+
this.triggerTarget?.focus?.()
|
|
58
|
+
this.dispatch("closed")
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
_onKey(event) {
|
|
48
|
-
if (event.key === "Escape") this.
|
|
63
|
+
if (event.key === "Escape") this.close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Turbo snapshots the page before navigating away. Without this the scroll
|
|
67
|
+
// lock is captured while still applied, and a back navigation restores a page
|
|
68
|
+
// that cannot be scrolled.
|
|
69
|
+
_onBeforeCache() {
|
|
70
|
+
this.openValue = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_releaseScroll() {
|
|
74
|
+
document.body.style.overflow = ""
|
|
49
75
|
}
|
|
50
76
|
}
|