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
data/lib/senren/rails/version.rb
CHANGED
data/lib/senren/rails.rb
CHANGED
|
@@ -8,12 +8,13 @@ module Senren
|
|
|
8
8
|
|
|
9
9
|
autoload :Registry, 'senren/rails/registry'
|
|
10
10
|
autoload :ComponentCopier, 'senren/rails/component_copier'
|
|
11
|
+
autoload :ComponentInstaller, 'senren/rails/component_installer'
|
|
11
12
|
autoload :SkillWriter, 'senren/rails/skill_writer'
|
|
12
13
|
autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
|
|
13
14
|
autoload :LlmsWriter, 'senren/rails/llms_writer'
|
|
14
|
-
autoload :Installer, 'senren/rails/installer'
|
|
15
15
|
autoload :Doctor, 'senren/rails/doctor'
|
|
16
16
|
autoload :HostPaths, 'senren/rails/host_paths'
|
|
17
|
+
autoload :MarkerBlock, 'senren/rails/marker_block'
|
|
17
18
|
|
|
18
19
|
def self.gem_root
|
|
19
20
|
GEM_ROOT
|
data/lib/senren-ui.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The entry point Bundler reaches for by default.
|
|
4
|
+
#
|
|
5
|
+
# `gem "senren-ui"` with no `require:` option makes Bundler try "senren-ui",
|
|
6
|
+
# then its hyphen-to-slash fallback "senren/ui". Without this file both fail —
|
|
7
|
+
# and Bundler swallows the second LoadError, so the app boots with no engine,
|
|
8
|
+
# no rake tasks, and no AssetPathGuard, while the generator and `senren:add`
|
|
9
|
+
# keep working because they require "senren/rails" themselves.
|
|
10
|
+
#
|
|
11
|
+
# That combination is the worst possible failure mode: the install looks
|
|
12
|
+
# healthy and the production source-disclosure guard is simply absent. The
|
|
13
|
+
# README documents `require: "senren/rails"`, which is still correct; this makes
|
|
14
|
+
# forgetting it harmless rather than silent.
|
|
15
|
+
require 'senren/rails'
|
data/lib/tasks/senren.rake
CHANGED
|
@@ -3,22 +3,19 @@
|
|
|
3
3
|
require 'senren/rails'
|
|
4
4
|
|
|
5
5
|
namespace :senren do
|
|
6
|
-
desc 'Install one or more Senren components
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
desc 'Install one or more Senren components. Preferred: bin/rails senren:add button dialog. ' \
|
|
7
|
+
"Legacy: bin/rails 'senren:add[button,dialog]'"
|
|
8
|
+
task :add, [:names] do |_t, args|
|
|
9
|
+
names = SenrenRakeArgs.names(args)
|
|
10
|
+
options = SenrenRakeArgs.options
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
20
|
-
|
|
21
|
-
puts "Installed: #{installed.join(', ')}"
|
|
12
|
+
Senren::Rails::ComponentInstaller.new.install(
|
|
13
|
+
names: names,
|
|
14
|
+
client_override: options[:client_override],
|
|
15
|
+
force: options[:force]
|
|
16
|
+
)
|
|
17
|
+
rescue ArgumentError => e
|
|
18
|
+
abort e.message
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
namespace :skill do
|
|
@@ -62,25 +59,47 @@ end
|
|
|
62
59
|
|
|
63
60
|
# Helpers ---------------------------------------------------------------
|
|
64
61
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.flatten
|
|
72
|
-
.flat_map { |s| s.to_s.split(/[,\s]+/) }
|
|
73
|
-
.reject { |s| s.empty? || s.start_with?('-') }
|
|
74
|
-
.uniq
|
|
75
|
-
end
|
|
62
|
+
# Namespaced deliberately. These were top-level `def`s, which Ruby defines as
|
|
63
|
+
# private methods on Object — so loading this rake file gave every class in the
|
|
64
|
+
# host app a `parse_options` and a `parse_names`. `parse_options` is a
|
|
65
|
+
# plausible name for an app to define itself.
|
|
66
|
+
module SenrenRakeArgs
|
|
67
|
+
module_function
|
|
76
68
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
69
|
+
def names(args)
|
|
70
|
+
raw = []
|
|
71
|
+
raw.concat(args.extras)
|
|
72
|
+
raw << args[:names] if args[:names]
|
|
73
|
+
raw.concat(trailing_arguments)
|
|
74
|
+
Senren::Rails::ComponentInstaller.normalize_names(raw)
|
|
75
|
+
end
|
|
84
76
|
|
|
85
|
-
|
|
77
|
+
def options
|
|
78
|
+
words = trailing_arguments
|
|
79
|
+
client_override = true if words.include?('--client')
|
|
80
|
+
client_override = false if words.include?('--no-client')
|
|
81
|
+
|
|
82
|
+
{ client_override: client_override, force: words.include?('--force') }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The words that belong to `senren:add`, and only those.
|
|
86
|
+
#
|
|
87
|
+
# This used to be `ARGV.drop_while { ... }.drop(1)` with no upper bound, so
|
|
88
|
+
# every later argument on the command line was swallowed: `rake
|
|
89
|
+
# 'senren:add[button]' db:seed` tried to install a component named "db:seed"
|
|
90
|
+
# and aborted the whole invocation, and a `--force` intended for a different
|
|
91
|
+
# task silently enabled overwriting here.
|
|
92
|
+
def trailing_arguments
|
|
93
|
+
ARGV
|
|
94
|
+
.drop_while { |arg| !arg.start_with?('senren:') }
|
|
95
|
+
.drop(1)
|
|
96
|
+
.take_while { |arg| !rake_target?(arg) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A namespaced task (`db:seed`) or an environment assignment (`RAILS_ENV=x`).
|
|
100
|
+
# Component names are matched by Registry::NAME_PATTERN and can contain
|
|
101
|
+
# neither character.
|
|
102
|
+
def rake_target?(arg)
|
|
103
|
+
arg.include?(':') || arg.include?('=')
|
|
104
|
+
end
|
|
86
105
|
end
|
data/registry/components.yml
CHANGED
|
@@ -1051,3 +1051,48 @@ components:
|
|
|
1051
1051
|
ai:
|
|
1052
1052
|
use_for: [light formatting (bold/italic/links/lists) only]
|
|
1053
1053
|
avoid: [tables, embeds, media - out of scope for v0.1]
|
|
1054
|
+
|
|
1055
|
+
product_card:
|
|
1056
|
+
category: saas
|
|
1057
|
+
client: false
|
|
1058
|
+
can_have_client: false
|
|
1059
|
+
files:
|
|
1060
|
+
- app/components/senren/product_card_component.rb
|
|
1061
|
+
- app/components/senren/product_card_component.html.erb
|
|
1062
|
+
depends_on: []
|
|
1063
|
+
pairs_with: [cart, badge, button, card]
|
|
1064
|
+
variants: [default, featured]
|
|
1065
|
+
accessibility:
|
|
1066
|
+
- Image alt text repeats the product title; decorative placeholder is aria-hidden.
|
|
1067
|
+
- Out of stock disables the submit button rather than hiding it.
|
|
1068
|
+
ai:
|
|
1069
|
+
use_for:
|
|
1070
|
+
- product listings
|
|
1071
|
+
- catalogue grids
|
|
1072
|
+
- storefront search results
|
|
1073
|
+
avoid:
|
|
1074
|
+
- carrying cart state; adding to a cart is a form submission the server owns
|
|
1075
|
+
|
|
1076
|
+
cart:
|
|
1077
|
+
category: saas
|
|
1078
|
+
client: true
|
|
1079
|
+
can_have_client: true
|
|
1080
|
+
controller: senren--cart
|
|
1081
|
+
files:
|
|
1082
|
+
- app/components/senren/cart_component.rb
|
|
1083
|
+
- app/components/senren/cart_component.html.erb
|
|
1084
|
+
- app/javascript/controllers/senren/cart_controller.js
|
|
1085
|
+
depends_on: []
|
|
1086
|
+
pairs_with: [product_card, button, separator, empty_state]
|
|
1087
|
+
variants: [default, flush]
|
|
1088
|
+
accessibility:
|
|
1089
|
+
- Quantity steppers are grouped with an accessible name per line.
|
|
1090
|
+
- Subtotal is text, not an image, so it is announced on change.
|
|
1091
|
+
ai:
|
|
1092
|
+
use_for:
|
|
1093
|
+
- cart summary panels
|
|
1094
|
+
- checkout side panels
|
|
1095
|
+
- order review before payment
|
|
1096
|
+
avoid:
|
|
1097
|
+
- persisting quantities; the controller is display-only and the server is authoritative
|
|
1098
|
+
- formatting currency in the component; pass price_cents and a formatted price
|
data/registry/recipes.yml
CHANGED
|
@@ -77,3 +77,15 @@ recipes:
|
|
|
77
77
|
- native_select
|
|
78
78
|
- button
|
|
79
79
|
- alert
|
|
80
|
+
|
|
81
|
+
storefront:
|
|
82
|
+
description: Product listing with a cart panel; the server owns the cart, the widget owns the UI.
|
|
83
|
+
components:
|
|
84
|
+
- app_shell
|
|
85
|
+
- top_nav
|
|
86
|
+
- page_header
|
|
87
|
+
- product_card
|
|
88
|
+
- cart
|
|
89
|
+
- badge
|
|
90
|
+
- button
|
|
91
|
+
- empty_state
|
|
@@ -13,7 +13,7 @@ module Senren
|
|
|
13
13
|
|
|
14
14
|
def initialize(variant: :default, id: nil, class_name: nil, **html)
|
|
15
15
|
super(variant: variant, size: :md, class_name: class_name, **html)
|
|
16
|
-
@dom_id = id ||
|
|
16
|
+
@dom_id = id || senren_dom_id
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
attr_reader :dom_id
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<% else %>
|
|
5
5
|
<label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= label %></label>
|
|
6
6
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
7
|
-
<input type="password" readonly value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
|
|
7
|
+
<input type="password" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
|
|
8
8
|
<button type="button" data-senren--api-key-field-target="revealButton" data-action="click->senren--api-key-field#toggle" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]"><%= reveal_label %></button>
|
|
9
9
|
<button type="button" data-action="click->senren--api-key-field#copy" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90"><%= copy_label %></button>
|
|
10
10
|
</div>
|
|
@@ -10,5 +10,12 @@ module Senren
|
|
|
10
10
|
}.freeze
|
|
11
11
|
|
|
12
12
|
SIZES = { md: '' }.freeze
|
|
13
|
+
|
|
14
|
+
# BaseComponent defaults to `variant: :default`, which this component does
|
|
15
|
+
# not define, so `.new` with no variant raised instead of rendering. The
|
|
16
|
+
# default is :square (the neutral ratio).
|
|
17
|
+
def initialize(variant: :square, **args)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
end
|
|
@@ -17,7 +17,14 @@ module Senren
|
|
|
17
17
|
@fallback = fallback.presence || initials.presence || initials_from_alt
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
attr_reader :
|
|
20
|
+
attr_reader :alt, :fallback
|
|
21
|
+
|
|
22
|
+
# Avatar sources are user-controlled by nature — a profile URL is the
|
|
23
|
+
# canonical example — and this one reaches image_tag. Rails' asset_path
|
|
24
|
+
# incidentally neutralises `javascript:`, so this is not XSS, but an
|
|
25
|
+
# off-origin `//tracker.example/x.gif` is a beacon that fires for every
|
|
26
|
+
# viewer of the page. safe_media_url is the media-specific policy.
|
|
27
|
+
def src = @src && safe_media_url(@src)
|
|
21
28
|
|
|
22
29
|
private
|
|
23
30
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
</ul>
|
|
23
23
|
<% end %>
|
|
24
24
|
<% if cta_label.present? %>
|
|
25
|
-
<%= link_to cta_label, cta_href
|
|
25
|
+
<%= link_to cta_label, safe_url(cta_href), class: "mt-6 inline-flex h-10 w-full items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90" %>
|
|
26
26
|
<% end %>
|
|
27
27
|
<% end %>
|
|
28
28
|
<% end %>
|
|
@@ -19,10 +19,10 @@ module Senren
|
|
|
19
19
|
def normalize_items(items)
|
|
20
20
|
Array(items).map do |item|
|
|
21
21
|
if item.is_a?(Hash)
|
|
22
|
-
{ label: item.fetch(:label) { item.fetch('label') }, href: item[:href] || item['href'] }
|
|
22
|
+
{ label: item.fetch(:label) { item.fetch('label') }, href: safe_url(item[:href] || item['href'], fallback: nil) }
|
|
23
23
|
else
|
|
24
24
|
label, href = item
|
|
25
|
-
{ label: label, href: href }
|
|
25
|
+
{ label: label, href: safe_url(href, fallback: nil) }
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% base = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-(--senren-radius) font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed" %>
|
|
2
2
|
<% if as == :a %>
|
|
3
|
-
<%= tag.a content, **root_attrs(base, href: href) %>
|
|
3
|
+
<%= tag.a content, **root_attrs(base, href: safe_url(href)) %>
|
|
4
4
|
<% else %>
|
|
5
5
|
<%= tag.button content, **root_attrs(base, type: type) %>
|
|
6
6
|
<% end %>
|
|
@@ -21,7 +21,7 @@ module Senren
|
|
|
21
21
|
{
|
|
22
22
|
title: slide[:title] || slide['title'],
|
|
23
23
|
description: slide[:description] || slide['description'],
|
|
24
|
-
image_url: slide[:image_url] || slide['image_url'],
|
|
24
|
+
image_url: safe_media_url(slide[:image_url] || slide['image_url']),
|
|
25
25
|
alt: slide[:alt] || slide['alt'],
|
|
26
26
|
badge: slide[:badge] || slide['badge']
|
|
27
27
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<%= tag.section(**root_attrs("flex flex-col rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id, "aria-label": title, data: { controller: "senren--cart", "senren--cart-currency-value": currency, "senren--cart-subtotal-cents-value": subtotal_cents })) do %>
|
|
2
|
+
<header class="flex items-center justify-between border-b border-[hsl(var(--senren-border))] px-4 py-3">
|
|
3
|
+
<h2 class="text-sm font-semibold text-[hsl(var(--senren-foreground))]"><%= title %></h2>
|
|
4
|
+
<span class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="count"><%= total_quantity %></span>
|
|
5
|
+
</header>
|
|
6
|
+
|
|
7
|
+
<% if empty? %>
|
|
8
|
+
<p class="px-4 py-8 text-center text-sm text-[hsl(var(--senren-muted-foreground))]"><%= empty_text %></p>
|
|
9
|
+
<% else %>
|
|
10
|
+
<ul class="divide-y divide-[hsl(var(--senren-border))]">
|
|
11
|
+
<% items.each do |item| %>
|
|
12
|
+
<li class="flex items-center gap-3 px-4 py-3"
|
|
13
|
+
data-senren--cart-target="line"
|
|
14
|
+
data-line-id="<%= item[:id] %>"
|
|
15
|
+
data-price-cents="<%= item[:price_cents] %>">
|
|
16
|
+
<% if (image = safe_media_url(item[:image_url])) %>
|
|
17
|
+
<img src="<%= image %>" alt="" loading="lazy" class="h-12 w-12 shrink-0 rounded-(--senren-radius) object-cover">
|
|
18
|
+
<% else %>
|
|
19
|
+
<div class="h-12 w-12 shrink-0 rounded-(--senren-radius) bg-[hsl(var(--senren-muted)/0.5)]" aria-hidden="true"></div>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<div class="min-w-0 flex-1">
|
|
23
|
+
<p class="truncate text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= item[:name] %></p>
|
|
24
|
+
<p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
|
|
28
|
+
<button type="button"
|
|
29
|
+
data-action="click->senren--cart#decrement"
|
|
30
|
+
aria-label="Decrease <%= item[:name] %> quantity"
|
|
31
|
+
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">−</button>
|
|
32
|
+
|
|
33
|
+
<output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
|
|
34
|
+
data-senren--cart-target="quantity"><%= item[:quantity] %></output>
|
|
35
|
+
|
|
36
|
+
<button type="button"
|
|
37
|
+
data-action="click->senren--cart#increment"
|
|
38
|
+
aria-label="Increase <%= item[:name] %> quantity"
|
|
39
|
+
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">+</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<button type="button"
|
|
43
|
+
data-action="click->senren--cart#remove"
|
|
44
|
+
aria-label="Remove <%= item[:name] %>"
|
|
45
|
+
class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-destructive))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">Remove</button>
|
|
46
|
+
</li>
|
|
47
|
+
<% end %>
|
|
48
|
+
</ul>
|
|
49
|
+
|
|
50
|
+
<footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
|
|
51
|
+
<div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
|
|
52
|
+
Subtotal
|
|
53
|
+
<span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<% if safe_checkout_url %>
|
|
57
|
+
<a href="<%= safe_checkout_url %>" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]"><%= checkout_label %></a>
|
|
58
|
+
<% end %>
|
|
59
|
+
</footer>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% end %>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Senren
|
|
4
|
+
# A shopping cart: line items with quantity steppers, a live subtotal, and
|
|
5
|
+
# removal.
|
|
6
|
+
#
|
|
7
|
+
# The server owns the cart. This component owns the widget. The Stimulus
|
|
8
|
+
# controller only does what does not round-trip pleasantly — stepping a
|
|
9
|
+
# quantity and recomputing the displayed subtotal while the user clicks — and
|
|
10
|
+
# announces the result with senren--cart:changed so a host app can update a
|
|
11
|
+
# header badge without reaching inside.
|
|
12
|
+
#
|
|
13
|
+
# Money arrives twice, and on purpose: `price` already formatted for display,
|
|
14
|
+
# and `price_cents` as an integer for arithmetic. Formatting is locale- and
|
|
15
|
+
# money-library-specific and belongs to the host app. Doing arithmetic on a
|
|
16
|
+
# formatted string is how currency bugs start.
|
|
17
|
+
class CartComponent < BaseComponent
|
|
18
|
+
VARIANTS = {
|
|
19
|
+
default: 'border-[hsl(var(--senren-border))]',
|
|
20
|
+
flush: 'border-transparent shadow-none'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
SIZES = { md: '' }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(items: [], currency: '$', title: 'Cart', empty_text: 'Your cart is empty.',
|
|
26
|
+
checkout_url: nil, checkout_label: 'Checkout', variant: :default,
|
|
27
|
+
id: nil, class_name: nil, **html)
|
|
28
|
+
super(variant: variant, size: :md, class_name: class_name, **html)
|
|
29
|
+
@items = Array(items).map { |item| normalize_item(item) }
|
|
30
|
+
@currency = currency
|
|
31
|
+
@title = title
|
|
32
|
+
@empty_text = empty_text
|
|
33
|
+
@checkout_url = checkout_url
|
|
34
|
+
@checkout_label = checkout_label
|
|
35
|
+
@dom_id = id || senren_dom_id(title)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :items, :currency, :title, :empty_text, :checkout_label, :dom_id
|
|
39
|
+
|
|
40
|
+
def empty? = items.empty?
|
|
41
|
+
def total_quantity = items.sum { |item| item[:quantity] }
|
|
42
|
+
def subtotal_cents = items.sum { |item| item[:price_cents] * item[:quantity] }
|
|
43
|
+
def subtotal = format_cents(subtotal_cents)
|
|
44
|
+
def line_total(item) = format_cents(item[:price_cents] * item[:quantity])
|
|
45
|
+
def safe_checkout_url = @checkout_url && safe_url(@checkout_url)
|
|
46
|
+
|
|
47
|
+
# Kept public so the same rendering is available to a host app updating a
|
|
48
|
+
# line through a Turbo Stream.
|
|
49
|
+
# Kernel.format explicitly: ViewComponent::Base defines its own `format`
|
|
50
|
+
# (the current template format, taking no arguments), which shadows
|
|
51
|
+
# Kernel#format inside a component. On Rails 8.x this happened to resolve;
|
|
52
|
+
# on 7.1 it raised "wrong number of arguments (given 2, expected 0)" as soon
|
|
53
|
+
# as the call ran inside a tag block. Caught by the version matrix.
|
|
54
|
+
def format_cents(cents)
|
|
55
|
+
"#{currency}#{Kernel.format('%.2f', cents.to_i / 100.0)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def normalize_item(item)
|
|
61
|
+
{
|
|
62
|
+
id: item[:id] || item['id'],
|
|
63
|
+
name: item[:name] || item['name'],
|
|
64
|
+
price_cents: (item[:price_cents] || item['price_cents']).to_i,
|
|
65
|
+
quantity: [(item[:quantity] || item['quantity'] || 1).to_i, 1].max,
|
|
66
|
+
image_url: item[:image_url] || item['image_url'],
|
|
67
|
+
remove_url: item[:remove_url] || item['remove_url']
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div <%= tag.attributes(**root_attrs("inline-flex items-center gap-2", data: { controller: "senren--clipboard", "senren--clipboard-copied-label-value": copied_label })) %>>
|
|
2
|
-
<input type="text" readonly value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
|
|
2
|
+
<input type="text" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
|
|
3
3
|
<button type="button" data-senren--clipboard-target="button" data-action="click->senren--clipboard#copy" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90">
|
|
4
4
|
<%= label %>
|
|
5
5
|
</button>
|
|
@@ -11,7 +11,7 @@ module Senren
|
|
|
11
11
|
@placeholder = placeholder
|
|
12
12
|
@label = label
|
|
13
13
|
@empty_text = empty_text
|
|
14
|
-
@dom_id = id ||
|
|
14
|
+
@dom_id = id || senren_dom_id(label)
|
|
15
15
|
@items = normalize_items(items)
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -29,7 +29,7 @@ module Senren
|
|
|
29
29
|
id: data[:id] || data['id'] || "#{dom_id}-option-#{index}",
|
|
30
30
|
label: label,
|
|
31
31
|
description: description,
|
|
32
|
-
href: data[:href] || data['href'],
|
|
32
|
+
href: safe_url(data[:href] || data['href'], fallback: nil),
|
|
33
33
|
keywords: [label, description, keywords].flatten.compact.join(' ')
|
|
34
34
|
}
|
|
35
35
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div <%= tag.attributes(**root_attrs("flex w-full items-center gap-2", data: { controller: "senren--date-picker" })) %>>
|
|
2
|
-
<input type="date" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="
|
|
2
|
+
<input type="date" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="h-10 w-full max-w-56 cursor-pointer rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none transition-colors focus:ring-2 focus:ring-[hsl(var(--senren-ring))] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-60 [&::-webkit-calendar-picker-indicator]:hover:opacity-100 <%= self.class::VARIANTS[variant] %>">
|
|
3
3
|
<button type="button" data-action="click->senren--date-picker#today" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]">Today</button>
|
|
4
4
|
<button type="button" data-action="click->senren--date-picker#clear" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-3 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))]">Clear</button>
|
|
5
5
|
</div>
|
|
@@ -14,7 +14,7 @@ module Senren
|
|
|
14
14
|
def initialize(open: false, id: nil, class_name: nil, **html)
|
|
15
15
|
super(variant: :default, size: :md, class_name: class_name, **html)
|
|
16
16
|
@open = open
|
|
17
|
-
@dom_id = id ||
|
|
17
|
+
@dom_id = id || senren_dom_id
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
attr_reader :open, :dom_id
|
|
@@ -12,23 +12,26 @@ module Senren
|
|
|
12
12
|
super(variant: :default, size: :md, class_name: class_name, **html)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
class ItemTag <
|
|
16
|
-
|
|
15
|
+
class ItemTag < BaseComponent
|
|
16
|
+
VARIANTS = { default: '' }.freeze
|
|
17
|
+
SIZES = { md: '' }.freeze
|
|
18
|
+
ITEM_ACTION = 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey'
|
|
19
|
+
|
|
20
|
+
def initialize(href: nil, method: nil, destructive: false, class_name: nil, **)
|
|
21
|
+
super(variant: :default, size: :md, class_name: class_name, **)
|
|
17
22
|
@href = href
|
|
18
23
|
@method = method
|
|
19
24
|
@destructive = destructive
|
|
20
|
-
@opts = opts
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def call
|
|
24
28
|
klass = 'block w-full text-left px-3 py-2 text-sm rounded-sm hover:bg-[hsl(var(--senren-accent))] focus:bg-[hsl(var(--senren-accent))] outline-none cursor-pointer'
|
|
29
|
+
klass += " #{class_name}" if class_name.present?
|
|
25
30
|
klass += ' text-[hsl(var(--senren-destructive))]' if @destructive
|
|
26
31
|
if @href
|
|
27
|
-
link_to(content, @href, role: 'menuitem', method: @method, class: klass,
|
|
28
|
-
data: { action: 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey' })
|
|
32
|
+
link_to(content, safe_url(@href), role: 'menuitem', method: @method, class: klass, data: { action: ITEM_ACTION }, **html_attrs)
|
|
29
33
|
else
|
|
30
|
-
tag.button(content, type: 'button', role: 'menuitem', class: klass,
|
|
31
|
-
data: { action: 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey' }, **@opts)
|
|
34
|
+
tag.button(content, type: 'button', role: 'menuitem', class: klass, data: { action: ITEM_ACTION }, **html_attrs)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
end
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
<%
|
|
2
|
+
form_opts = {}
|
|
3
|
+
form_opts[:model] = model if model
|
|
4
|
+
form_opts[:url] = url if url
|
|
5
|
+
form_opts[:method] = method if method
|
|
6
|
+
form_opts[:multipart] = multipart if multipart
|
|
7
|
+
%>
|
|
8
|
+
<%= form_with(**form_opts, **root_attrs("space-y-4")) do |f| %>
|
|
2
9
|
<%= content || capture(f, &Proc.new) %>
|
|
3
10
|
<% end %>
|
|
@@ -5,7 +5,9 @@ module Senren
|
|
|
5
5
|
VARIANTS = { default: '' }.freeze
|
|
6
6
|
SIZES = { md: '' }.freeze
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# method: defaults to nil so Rails can infer PATCH for persisted models.
|
|
9
|
+
# Pass method: :post / :patch / :delete explicitly only when needed.
|
|
10
|
+
def initialize(model: nil, url: nil, method: nil, multipart: false, class_name: nil, **html)
|
|
9
11
|
super(variant: :default, size: :md, class_name: class_name, **html)
|
|
10
12
|
@model = model
|
|
11
13
|
@url = url
|
|
@@ -13,6 +15,14 @@ module Senren
|
|
|
13
15
|
@multipart = multipart
|
|
14
16
|
end
|
|
15
17
|
|
|
16
|
-
attr_reader :model, :
|
|
18
|
+
attr_reader :model, :method, :multipart
|
|
19
|
+
|
|
20
|
+
# This one reaches form_with's `action`, which makes it the highest-value
|
|
21
|
+
# URL sink in the library: `//evil.example` is a protocol-relative absolute
|
|
22
|
+
# URL, so a form built from user-controlled input POSTs every field —
|
|
23
|
+
# including the CSRF token — off-origin. Same policy as every other href in
|
|
24
|
+
# the library; it was simply missed because the security test enumerated
|
|
25
|
+
# known components rather than asserting a property over all of them.
|
|
26
|
+
def url = @url && safe_url(@url)
|
|
17
27
|
end
|
|
18
28
|
end
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<%= tag.input(**root_attrs(
|
|
1
|
+
<%= tag.input(**root_attrs(input_classes, id: id, name: name, type: type, value: value, placeholder: placeholder, "aria-invalid": variant == :error)) %>
|
|
@@ -13,6 +13,17 @@ module Senren
|
|
|
13
13
|
lg: 'h-12 text-base px-4'
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
+
# Base classes shared by all input types.
|
|
17
|
+
# NOTE: `flex` is intentionally omitted — it breaks browser-native
|
|
18
|
+
# date/datetime-local/time picker UI on some engines.
|
|
19
|
+
BASE_CLASSES = 'w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] placeholder:text-[hsl(var(--senren-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50'
|
|
20
|
+
|
|
21
|
+
# File-type inputs get a styled button instead of unstyled browser defaults.
|
|
22
|
+
FILE_CLASSES = 'file:mr-3 file:h-full file:cursor-pointer file:border-0 file:border-r file:border-solid file:border-[hsl(var(--senren-border))] file:bg-[hsl(var(--senren-muted))] file:px-3 file:text-sm file:font-medium file:text-[hsl(var(--senren-foreground))]'
|
|
23
|
+
|
|
24
|
+
# Non-file inputs get standard font styling for the placeholder.
|
|
25
|
+
TEXT_FILE_CLASSES = 'file:border-0 file:bg-transparent file:text-sm file:font-medium'
|
|
26
|
+
|
|
16
27
|
def initialize(name:, type: 'text', value: nil, placeholder: nil, id: nil, variant: :default, size: :md,
|
|
17
28
|
class_name: nil, **html)
|
|
18
29
|
super(variant: variant, size: size, class_name: class_name, **html)
|
|
@@ -24,5 +35,13 @@ module Senren
|
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
attr_reader :name, :type, :value, :placeholder, :id
|
|
38
|
+
|
|
39
|
+
def input_classes
|
|
40
|
+
file_type? ? "#{BASE_CLASSES} #{FILE_CLASSES}" : "#{BASE_CLASSES} #{TEXT_FILE_CLASSES}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def file_type?
|
|
44
|
+
type.to_s == 'file'
|
|
45
|
+
end
|
|
27
46
|
end
|
|
28
47
|
end
|
|
@@ -18,7 +18,7 @@ module Senren
|
|
|
18
18
|
@role_name = role_name
|
|
19
19
|
@roles = Array(roles)
|
|
20
20
|
@button_label = button_label
|
|
21
|
-
@dom_id = id ||
|
|
21
|
+
@dom_id = id || senren_dom_id(title)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
attr_reader :title, :description, :email_name, :role_name, :roles, :button_label, :dom_id
|
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
<%= tag.label(**root_attrs("text-sm font-medium leading-none text-[hsl(var(--senren-foreground))] peer-disabled:cursor-not-allowed peer-disabled:opacity-70", for: for_field)) do %>
|
|
2
|
-
<%=
|
|
3
|
-
<% if variant == :required %><span class="text-[hsl(var(--senren-destructive))]" aria-hidden="true"> *</span><% end %>
|
|
2
|
+
<%= label_text %><% if variant == :required %><span class="text-[hsl(var(--senren-destructive))]" aria-hidden="true"> *</span><% end %>
|
|
4
3
|
<% end %>
|