senren-ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +238 -0
  3. data/CONTRIBUTING.md +11 -0
  4. data/README.md +64 -0
  5. data/docs/i18n.md +194 -0
  6. data/docs/visual_style.md +80 -0
  7. data/lib/generators/senren/install/install_generator.rb +39 -0
  8. data/lib/generators/senren/install/templates/base_component.rb.tt +136 -7
  9. data/lib/generators/senren/install/templates/conventions.md.tt +57 -0
  10. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  11. data/lib/senren/rails/agent_rules_writer.rb +7 -0
  12. data/lib/senren/rails/base_component_patch.rb +24 -0
  13. data/lib/senren/rails/component_copier.rb +21 -0
  14. data/lib/senren/rails/host_paths.rb +5 -0
  15. data/lib/senren/rails/locale_installer.rb +90 -0
  16. data/lib/senren/rails/version.rb +1 -1
  17. data/lib/senren/rails.rb +1 -0
  18. data/lib/tasks/senren.rake +9 -0
  19. data/templates/components/accordion/accordion_component.html.erb +24 -6
  20. data/templates/components/accordion/accordion_component.rb +2 -1
  21. data/templates/components/alert_dialog/alert_dialog_component.html.erb +7 -4
  22. data/templates/components/alert_dialog/alert_dialog_component.rb +4 -0
  23. data/templates/components/api_key_field/api_key_field_component.html.erb +2 -2
  24. data/templates/components/api_key_field/api_key_field_component.rb +8 -1
  25. data/templates/components/app_shell/app_shell_component.rb +2 -1
  26. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  27. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -1
  28. data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +1 -1
  29. data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +4 -2
  30. data/templates/components/button/button_component.html.erb +1 -1
  31. data/templates/components/button/button_component.rb +27 -1
  32. data/templates/components/calendar/calendar_component.html.erb +2 -2
  33. data/templates/components/calendar/calendar_component.rb +37 -2
  34. data/templates/components/card/card_component.html.erb +4 -6
  35. data/templates/components/carousel/carousel_component.html.erb +6 -6
  36. data/templates/components/carousel/carousel_component.rb +12 -1
  37. data/templates/components/cart/cart_component.html.erb +13 -7
  38. data/templates/components/cart/cart_component.rb +11 -2
  39. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  40. data/templates/components/clipboard/clipboard_component.rb +6 -1
  41. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  42. data/templates/components/collapsible/collapsible_component.rb +1 -1
  43. data/templates/components/combobox/combobox_component.html.erb +3 -3
  44. data/templates/components/combobox/combobox_component.rb +2 -1
  45. data/templates/components/command/command_component.html.erb +1 -1
  46. data/templates/components/command/command_component.rb +3 -1
  47. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  48. data/templates/components/data_table/data_table_component.html.erb +3 -3
  49. data/templates/components/data_table/data_table_component.rb +16 -1
  50. data/templates/components/date_picker/date_picker_component.html.erb +5 -5
  51. data/templates/components/date_picker/date_picker_component.rb +2 -1
  52. data/templates/components/dialog/dialog_component.html.erb +9 -5
  53. data/templates/components/dialog/dialog_component.rb +11 -0
  54. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  55. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  56. data/templates/components/filter_bar/filter_bar_component.rb +1 -1
  57. data/templates/components/form/form_component.html.erb +1 -1
  58. data/templates/components/form/form_component.rb +21 -0
  59. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  60. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +11 -9
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +11 -3
  62. data/templates/components/link/link_component.html.erb +1 -1
  63. data/templates/components/native_select/native_select_component.html.erb +2 -2
  64. data/templates/components/native_select/native_select_component.rb +12 -3
  65. data/templates/components/pagination/pagination_component.html.erb +4 -4
  66. data/templates/components/pagination/pagination_component.rb +2 -1
  67. data/templates/components/popover/popover_component.html.erb +6 -3
  68. data/templates/components/product_card/product_card_component.html.erb +3 -3
  69. data/templates/components/product_card/product_card_component.rb +2 -2
  70. data/templates/components/progress/progress_component.html.erb +2 -2
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +2 -16
  72. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +32 -1
  73. data/templates/components/search_input/search_input_component.html.erb +2 -2
  74. data/templates/components/search_input/search_input_component.rb +2 -1
  75. data/templates/components/sheet/sheet_component.html.erb +15 -6
  76. data/templates/components/sheet/sheet_component.rb +11 -0
  77. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  78. data/templates/components/sidebar/sidebar_component.html.erb +4 -4
  79. data/templates/components/sidebar/sidebar_component.rb +4 -1
  80. data/templates/components/switch/switch_component.html.erb +1 -1
  81. data/templates/components/table/table_component.html.erb +2 -2
  82. data/templates/components/tabs/tabs_component.html.erb +3 -3
  83. data/templates/components/tabs/tabs_component.rb +4 -2
  84. data/templates/components/theme_toggle/theme_toggle_component.html.erb +17 -3
  85. data/templates/components/theme_toggle/theme_toggle_component.rb +6 -1
  86. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  87. data/templates/components/top_nav/top_nav_component.html.erb +3 -3
  88. data/templates/components/top_nav/top_nav_component.rb +3 -2
  89. data/templates/controllers/accordion_controller.js +68 -3
  90. data/templates/controllers/api_key_field_controller.js +3 -2
  91. data/templates/controllers/carousel_controller.js +6 -1
  92. data/templates/controllers/cart_controller.js +3 -1
  93. data/templates/controllers/clipboard_controller.js +3 -3
  94. data/templates/controllers/rich_text_editor_lite_controller.js +2 -2
  95. data/templates/controllers/theme_toggle_controller.js +13 -3
  96. data/templates/locales/senren.en.yml +151 -0
  97. data/templates/locales/senren.vi.yml +151 -0
  98. metadata +6 -1
@@ -12,9 +12,15 @@
12
12
  <li class="flex items-center gap-3 px-4 py-3"
13
13
  data-senren--cart-target="line"
14
14
  data-line-id="<%= item[:id] %>"
15
+ <%# Emitted so the `removed` event can carry it. The component
16
+ accepted remove_url: and normalised it, then never wrote it
17
+ anywhere -- an advertised input that reached nothing, so the
18
+ consumer listening for the event had no way to learn where to
19
+ send the delete. %>
20
+ <% if item[:remove_url].present? %>data-remove-url="<%= safe_url(item[:remove_url]) %>"<% end %>
15
21
  data-price-cents="<%= item[:price_cents] %>">
16
22
  <% 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">
23
+ <%= image_tag image, alt: "", loading: "lazy", class: "h-12 w-12 shrink-0 rounded-(--senren-radius) object-cover" %>
18
24
  <% else %>
19
25
  <div class="h-12 w-12 shrink-0 rounded-(--senren-radius) bg-[hsl(var(--senren-muted)/0.5)]" aria-hidden="true"></div>
20
26
  <% end %>
@@ -24,10 +30,10 @@
24
30
  <p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
25
31
  </div>
26
32
 
27
- <div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
33
+ <div class="flex items-center gap-1" role="group" aria-label="<%= quantity_label(item) %>">
28
34
  <button type="button"
29
35
  data-action="click->senren--cart#decrement"
30
- aria-label="Decrease <%= item[:name] %> quantity"
36
+ aria-label="<%= decrease_label(item) %>"
31
37
  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))]">&minus;</button>
32
38
 
33
39
  <output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
@@ -35,21 +41,21 @@
35
41
 
36
42
  <button type="button"
37
43
  data-action="click->senren--cart#increment"
38
- aria-label="Increase <%= item[:name] %> quantity"
44
+ aria-label="<%= increase_label(item) %>"
39
45
  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
46
  </div>
41
47
 
42
48
  <button type="button"
43
49
  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>
50
+ aria-label="<%= remove_item_label(item) %>"
51
+ 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_label %></button>
46
52
  </li>
47
53
  <% end %>
48
54
  </ul>
49
55
 
50
56
  <footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
51
57
  <div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
52
- Subtotal
58
+ <%= subtotal_label %>
53
59
  <span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
54
60
  </div>
55
61
 
@@ -22,8 +22,9 @@ module Senren
22
22
 
23
23
  SIZES = { md: '' }.freeze
24
24
 
25
- def initialize(items: [], currency: '$', title: 'Cart', empty_text: 'Your cart is empty.',
26
- checkout_url: nil, checkout_label: 'Checkout', variant: :default,
25
+ def initialize(items: [], currency: '$', title: senren_t('cart.title', default: 'Cart'),
26
+ empty_text: senren_t('cart.empty', default: 'Your cart is empty.'),
27
+ checkout_url: nil, checkout_label: senren_t('cart.checkout', default: 'Checkout'), variant: :default,
27
28
  id: nil, class_name: nil, **html)
28
29
  super(variant: variant, size: :md, class_name: class_name, **html)
29
30
  @items = Array(items).map { |item| normalize_item(item) }
@@ -37,6 +38,14 @@ module Senren
37
38
 
38
39
  attr_reader :items, :currency, :title, :empty_text, :checkout_label, :dom_id
39
40
 
41
+ def subtotal_label = senren_t('cart.subtotal', default: 'Subtotal')
42
+ def remove_label = senren_t('cart.remove', default: 'Remove')
43
+
44
+ def quantity_label(item) = senren_t('cart.quantity_group', default: '%{name} quantity', name: item[:name])
45
+ def decrease_label(item) = senren_t('cart.decrease', default: 'Decrease %{name} quantity', name: item[:name])
46
+ def increase_label(item) = senren_t('cart.increase', default: 'Increase %{name} quantity', name: item[:name])
47
+ def remove_item_label(item) = senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name])
48
+
40
49
  def empty? = items.empty?
41
50
  def total_quantity = items.sum { |item| item[:quantity] }
42
51
  def subtotal_cents = items.sum { |item| item[:price_cents] * item[:quantity] }
@@ -1,7 +1,7 @@
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 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))]">
1
+ <%= tag.div(**root_attrs("inline-flex items-center gap-2", data: { controller: "senren--clipboard", "senren--clipboard-copied-label-value": copied_label, "senren--clipboard-copied-status-value": copied_status })) do %>
2
+ <input type="text" readonly autocomplete="off" 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>
6
6
  <span class="sr-only" aria-live="polite" data-senren--clipboard-target="status"></span>
7
- </div>
7
+ <% end %>
@@ -5,7 +5,8 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(value:, label: 'Copy', copied_label: 'Copied', class_name: nil, **html)
8
+ def initialize(value:, label: senren_t('clipboard.copy', default: 'Copy'),
9
+ copied_label: senren_t('clipboard.copied', default: 'Copied'), class_name: nil, **html)
9
10
  super(variant: :default, size: :md, class_name: class_name, **html)
10
11
  @label = label
11
12
  @value = value
@@ -13,5 +14,9 @@ module Senren
13
14
  end
14
15
 
15
16
  attr_reader :label, :value, :copied_label
17
+
18
+ # What a screen reader is told once the copy has happened. Passed to the
19
+ # controller as a Stimulus value so no sentence lives in JavaScript.
20
+ def copied_status = senren_t('clipboard.copied_status', default: 'Copied to clipboard')
16
21
  end
17
22
  end
@@ -1,9 +1,9 @@
1
- <div <%= tag.attributes(**root_attrs("rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))]", data: { controller: "senren--collapsible" })) %>>
1
+ <%= tag.div(**root_attrs("rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))]", data: { controller: "senren--collapsible" })) do %>
2
2
  <button type="button" class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium text-[hsl(var(--senren-card-foreground))]" aria-expanded="<%= open? %>" data-senren--collapsible-target="trigger" data-action="click->senren--collapsible#toggle">
3
3
  <span><%= trigger || title %></span>
4
4
  <span aria-hidden="true" class="text-[hsl(var(--senren-muted-foreground))]">+</span>
5
5
  </button>
6
- <div data-senren--collapsible-target="panel" <%= "hidden" unless open? %> class="border-t border-[hsl(var(--senren-border))] px-4 py-3 text-sm text-[hsl(var(--senren-muted-foreground))]">
6
+ <div data-senren--collapsible-target="panel" <% unless open? %>hidden<% end %> class="border-t border-[hsl(var(--senren-border))] px-4 py-3 text-sm text-[hsl(var(--senren-muted-foreground))]">
7
7
  <%= body || content %>
8
8
  </div>
9
- </div>
9
+ <% end %>
@@ -6,7 +6,7 @@ module Senren
6
6
  VARIANTS = { default: '' }.freeze
7
7
  SIZES = { md: '' }.freeze
8
8
 
9
- def initialize(title: 'Details', open: false, class_name: nil, **html)
9
+ def initialize(title: senren_t('collapsible.details', default: 'Details'), open: false, class_name: nil, **html)
10
10
  super(variant: :default, size: :md, class_name: class_name, **html)
11
11
  @title = title
12
12
  @open = open
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("relative w-full", data: { controller: "senren--combobox" })) %>>
1
+ <%= tag.div(**root_attrs("relative w-full", data: { controller: "senren--combobox" })) do %>
2
2
  <input type="hidden" name="<%= name %>" value="<%= value %>" data-senren--combobox-target="value">
3
3
  <button type="button" class="group flex h-10 w-full cursor-pointer items-center justify-between rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-left text-sm text-[hsl(var(--senren-foreground))] transition-colors hover:bg-[hsl(var(--senren-muted)/0.35)] <%= self.class::VARIANTS[variant] %>" aria-haspopup="listbox" aria-expanded="false" data-state="closed" data-senren--combobox-target="button" data-action="click->senren--combobox#toggle">
4
4
  <span data-senren--combobox-target="label"><%= selected_label || placeholder %></span>
@@ -7,7 +7,7 @@
7
7
  </svg>
8
8
  </button>
9
9
  <div data-senren--combobox-target="panel" hidden class="absolute z-50 mt-2 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] p-2 shadow-md">
10
- <input type="text" placeholder="<%= placeholder %>" data-senren--combobox-target="search" data-action="input->senren--combobox#filter keydown->senren--combobox#onKey" class="mb-2 h-9 w-full rounded-md border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-2 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
10
+ <input type="text" autocomplete="off" placeholder="<%= placeholder %>" data-senren--combobox-target="search" data-action="input->senren--combobox#filter keydown->senren--combobox#onKey" class="mb-2 h-9 w-full rounded-md border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-2 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
11
11
  <div role="listbox" class="max-h-56 overflow-auto">
12
12
  <% options.each do |option| %>
13
13
  <button type="button" role="option" data-senren--combobox-target="option" data-value="<%= option[:value] %>" data-label="<%= option[:label] %>" data-action="click->senren--combobox#choose" class="block w-full cursor-pointer rounded-md px-2 py-2 text-left text-sm text-[hsl(var(--senren-popover-foreground))] hover:bg-[hsl(var(--senren-accent))]">
@@ -16,4 +16,4 @@
16
16
  <% end %>
17
17
  </div>
18
18
  </div>
19
- </div>
19
+ <% end %>
@@ -8,7 +8,8 @@ module Senren
8
8
  }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(name:, options:, value: nil, placeholder: 'Search...', variant: :default, class_name: nil, **html)
11
+ def initialize(name:, options:, value: nil, placeholder: senren_t('combobox.placeholder', default: 'Search...'),
12
+ variant: :default, class_name: nil, **html)
12
13
  super(variant: variant, size: :md, class_name: class_name, **html)
13
14
  @name = name
14
15
  @options = normalize_options(options)
@@ -4,7 +4,7 @@
4
4
  <% else %>
5
5
  <label for="<%= dom_id %>-input" class="sr-only"><%= label %></label>
6
6
  <div class="border-b border-[hsl(var(--senren-border))] p-2">
7
- <input id="<%= dom_id %>-input" type="search" autocomplete="off" placeholder="<%= placeholder %>" aria-label="<%= label %>" aria-controls="<%= dom_id %>-list" aria-activedescendant="" data-senren--command-target="input" data-action="input->senren--command#filter keydown->senren--command#onKey" class="h-10 w-full rounded-(--senren-radius) bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none placeholder:text-[hsl(var(--senren-muted-foreground))] focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
7
+ <input id="<%= dom_id %>-input" type="search" autocomplete="off" placeholder="<%= placeholder %>" aria-label="<%= label %>" aria-controls="<%= dom_id %>-list" data-senren--command-target="input" data-action="input->senren--command#filter keydown->senren--command#onKey" class="h-10 w-full rounded-(--senren-radius) bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none placeholder:text-[hsl(var(--senren-muted-foreground))] focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
8
8
  </div>
9
9
  <div id="<%= dom_id %>-list" role="listbox" aria-label="<%= label %>" data-senren--command-target="list" class="max-h-72 overflow-y-auto p-1.5">
10
10
  <% items.each_with_index do |item, index| %>
@@ -5,7 +5,9 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(items: [], placeholder: 'Type a command...', label: 'Command menu', empty_text: 'No results found.',
8
+ def initialize(items: [], placeholder: senren_t('command.placeholder', default: 'Type a command...'),
9
+ label: senren_t('command.label', default: 'Command menu'),
10
+ empty_text: senren_t('command.empty', default: 'No results found.'),
9
11
  id: nil, class_name: nil, **html)
10
12
  super(variant: :default, size: :md, class_name: class_name, **html)
11
13
  @placeholder = placeholder
@@ -1,11 +1,14 @@
1
- <div data-controller="senren--context-menu" class="relative" data-senren-component="context_menu">
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("relative", data: { controller: "senren--context-menu" })) do %>
2
5
  <div data-senren--context-menu-target="trigger" data-action="contextmenu->senren--context-menu#open">
3
6
  <%= trigger %>
4
7
  </div>
5
8
  <div data-senren--context-menu-target="menu" role="menu" hidden
6
- class="absolute z-50 w-56 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-1 shadow-md">
9
+ class="<%= panel_class("absolute z-50 w-56 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-1 shadow-md") %>">
7
10
  <% items.each do |it| %>
8
11
  <%= it %>
9
12
  <% end %>
10
13
  </div>
11
- </div>
14
+ <% end %>
@@ -1,4 +1,4 @@
1
- <%= tag.div(**root_attrs("overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] shadow-sm", data: { controller: "senren--data-table" })) do %>
1
+ <%= tag.div(**root_attrs("relative overflow-hidden rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] shadow-sm", data: { controller: "senren--data-table" })) do %>
2
2
  <% if toolbar? %>
3
3
  <div class="border-b border-[hsl(var(--senren-border))] p-3"><%= toolbar %></div>
4
4
  <% end %>
@@ -18,7 +18,7 @@
18
18
  <% if sortable? %>
19
19
  <button type="button" class="inline-flex cursor-pointer items-center gap-1 rounded-sm hover:text-[hsl(var(--senren-foreground))]" data-action="click->senren--data-table#sort" data-sort-key="<%= column_sort_key(column) %>">
20
20
  <%= column_label(column) %>
21
- <span aria-hidden="true">Sort</span>
21
+ <span aria-hidden="true"><%= senren_t('data_table.sort', default: 'Sort') %></span>
22
22
  </button>
23
23
  <% else %>
24
24
  <%= column_label(column) %>
@@ -32,7 +32,7 @@
32
32
  <tr class="transition-colors hover:bg-[hsl(var(--senren-muted)/0.35)]" data-senren--data-table-target="row">
33
33
  <% columns.each do |column| %>
34
34
  <% value = cell_value(row, column) %>
35
- <td class="px-4 py-3" data-sort-key="<%= column_sort_key(column) %>" data-sort-value="<%= value %>"><%= value %></td>
35
+ <td class="px-4 py-3" data-sort-key="<%= column_sort_key(column) %>" data-sort-value="<%= sort_value(value) %>"><%= value %></td>
36
36
  <% end %>
37
37
  </tr>
38
38
  <% end %>
@@ -6,7 +6,8 @@ module Senren
6
6
  VARIANTS = { default: '' }.freeze
7
7
  SIZES = { md: '' }.freeze
8
8
 
9
- def initialize(columns: [], rows: [], caption: nil, empty_text: 'No records found.', sortable: true,
9
+ def initialize(columns: [], rows: [], caption: nil,
10
+ empty_text: senren_t('data_table.empty', default: 'No records found.'), sortable: true,
10
11
  variant: :default, class_name: nil, **html)
11
12
  super(variant: variant, size: :md, class_name: class_name, **html)
12
13
  @columns = Array(columns)
@@ -33,6 +34,20 @@ module Senren
33
34
  column_key(column).to_s
34
35
  end
35
36
 
37
+ # The text a cell shows, for its data-sort-value. A cell holding markup (a
38
+ # badge, a link) is html_safe, which ERB does not escape, so writing it
39
+ # into the attribute as-is pasted the markup inside the quotes and broke
40
+ # the <td>. Its decoded text is a plain String, which ERB escapes once.
41
+ #
42
+ # Asks the string, not the value: ERB decides on `to_s.html_safe?`, so an
43
+ # object whose to_s returns markup breaks the attribute just the same.
44
+ def sort_value(value)
45
+ text = value.to_s
46
+ return text unless text.html_safe?
47
+
48
+ Nokogiri::HTML5.fragment(text.to_str).text
49
+ end
50
+
36
51
  private
37
52
 
38
53
  def column_key(column)
@@ -1,5 +1,5 @@
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="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
- <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
- <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
- </div>
1
+ <%= tag.div(**root_attrs("flex w-full items-center gap-2", data: { controller: "senren--date-picker" })) do %>
2
+ <input type="date" autocomplete="off" 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
+ <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))]"><%= senren_t('date_picker.today', default: 'Today') %></button>
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))]"><%= senren_t('date_picker.clear', default: 'Clear') %></button>
5
+ <% end %>
@@ -8,7 +8,8 @@ module Senren
8
8
  }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(name:, value: nil, id: nil, placeholder: 'Select date', variant: :default, class_name: nil, **html)
11
+ def initialize(name:, value: nil, id: nil, placeholder: senren_t('date_picker.placeholder', default: 'Select date'),
12
+ variant: :default, class_name: nil, **html)
12
13
  super(variant: variant, size: :md, class_name: class_name, **html)
13
14
  @name = name
14
15
  @value = value
@@ -1,21 +1,25 @@
1
- <div data-controller="senren--dialog" data-senren-component="dialog" data-senren--dialog-open-value="<%= open %>" id="<%= dom_id %>">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both -- which is why a Stimulus
3
+ value could not be set from the server. %>
4
+ <%= tag.div(**wrapper_attrs(id: dom_id, data: { controller: "senren--dialog", "senren--dialog-open-value": open })) do %>
2
5
  <% if trigger? %>
3
6
  <span data-action="click->senren--dialog#open" data-senren--dialog-target="trigger" aria-controls="<%= dom_id %>-panel">
4
7
  <%= trigger %>
5
8
  </span>
6
9
  <% else %>
7
10
  <button type="button" data-action="click->senren--dialog#open"
8
- class="hidden" data-senren--dialog-target="trigger" aria-controls="<%= dom_id %>-panel">Open</button>
11
+ class="hidden" data-senren--dialog-target="trigger" aria-controls="<%= dom_id %>-panel"><%= senren_t('shared.open', default: 'Open') %></button>
9
12
  <% end %>
10
13
 
11
14
  <div data-senren--dialog-target="overlay" hidden
15
+ data-action="click->senren--dialog#close"
12
16
  class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"></div>
13
17
 
14
18
  <div data-senren--dialog-target="panel" hidden
15
19
  role="dialog" aria-modal="true" aria-labelledby="<%= dom_id %>-title"
16
20
  id="<%= dom_id %>-panel"
17
21
  tabindex="-1"
18
- class="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-6 shadow-lg focus:outline-none">
22
+ class="<%= panel_class("fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-6 shadow-lg focus:outline-none") %>">
19
23
  <% if title? %>
20
24
  <h2 id="<%= dom_id %>-title" class="text-lg font-semibold leading-none tracking-tight"><%= title %></h2>
21
25
  <% end %>
@@ -30,9 +34,9 @@
30
34
  <% end %>
31
35
 
32
36
  <button type="button" data-action="click->senren--dialog#close"
33
- aria-label="Close"
37
+ aria-label="<%= senren_t('shared.close', default: 'Close') %>"
34
38
  class="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
35
39
  <span aria-hidden="true">×</span>
36
40
  </button>
37
41
  </div>
38
- </div>
42
+ <% 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 DialogComponent < BaseComponent
5
16
  renders_one :trigger
6
17
  renders_one :title
@@ -1,12 +1,15 @@
1
- <div data-controller="senren--dropdown-menu" class="relative inline-block" data-state="closed" data-senren-component="dropdown_menu">
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("relative inline-block", data: { controller: "senren--dropdown-menu", state: "closed" })) do %>
2
5
  <div class="inline-flex cursor-pointer" data-state="closed" aria-haspopup="menu" aria-expanded="false" data-senren--dropdown-menu-target="trigger" data-action="click->senren--dropdown-menu#toggle keydown->senren--dropdown-menu#onTriggerKey">
3
6
  <%= trigger %>
4
7
  </div>
5
8
 
6
9
  <div data-senren--dropdown-menu-target="menu" role="menu" hidden
7
- class="absolute right-0 z-50 mt-2 w-56 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-1 shadow-md">
10
+ class="<%= panel_class("absolute right-0 z-50 mt-2 w-56 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-1 shadow-md") %>">
8
11
  <% items.each do |it| %>
9
12
  <%= it %>
10
13
  <% end %>
11
14
  </div>
12
- </div>
15
+ <% end %>
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Senren
4
+ # A trigger and an absolutely positioned menu.
5
+ #
6
+ # The menu is taller than the trigger and escapes it, so an ancestor with
7
+ # `overflow-hidden` will clip it -- usually a card, table wrapper, or list
8
+ # container that has the class only to make its border radius clip children.
9
+ # Symptom: the menu opens but is cut off at the container's edge. Remove the
10
+ # overflow, or round the first and last rows instead of the wrapper.
4
11
  class DropdownMenuComponent < BaseComponent
5
12
  renders_one :trigger
6
13
  renders_many :items, 'ItemTag'
@@ -17,6 +24,10 @@ module Senren
17
24
  SIZES = { md: '' }.freeze
18
25
  ITEM_ACTION = 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey'
19
26
 
27
+ BASE_CLASSES = 'block w-full text-left px-3 py-2 text-sm rounded-sm ' \
28
+ 'hover:bg-[hsl(var(--senren-accent))] focus:bg-[hsl(var(--senren-accent))] ' \
29
+ 'outline-none cursor-pointer'
30
+
20
31
  def initialize(href: nil, method: nil, destructive: false, class_name: nil, **)
21
32
  super(variant: :default, size: :md, class_name: class_name, **)
22
33
  @href = href
@@ -25,15 +36,56 @@ module Senren
25
36
  end
26
37
 
27
38
  def call
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?
30
- klass += ' text-[hsl(var(--senren-destructive))]' if @destructive
31
39
  if @href
32
- link_to(content, safe_url(@href), role: 'menuitem', method: @method, class: klass, data: { action: ITEM_ACTION }, **html_attrs)
40
+ link_to(content, safe_url(@href), role: 'menuitem', class: item_classes, **item_attrs)
33
41
  else
34
- tag.button(content, type: 'button', role: 'menuitem', class: klass, data: { action: ITEM_ACTION }, **html_attrs)
42
+ tag.button(content, type: 'button', role: 'menuitem', class: item_classes, **item_attrs)
35
43
  end
36
44
  end
45
+
46
+ private
47
+
48
+ # `class:` is merged, not substituted.
49
+ #
50
+ # It used to reach `call` only through **html_attrs, which the tag helper
51
+ # splatted over the computed class -- so `class: "font-bold"` produced an
52
+ # item wearing nothing but font-bold. Losing the hover style is cosmetic;
53
+ # losing `focus:bg-` is not, because it is the only indication a keyboard
54
+ # user has of where they are while arrowing through the menu.
55
+ #
56
+ # Same defect `data:` had, and the fix for `data:` was not extended here.
57
+ def item_classes
58
+ [
59
+ BASE_CLASSES,
60
+ ('text-[hsl(var(--senren-destructive))]' if @destructive),
61
+ class_name.presence,
62
+ caller_class.presence
63
+ ].compact.join(' ')
64
+ end
65
+
66
+ # Two defects lived in the old one-line version of `call`.
67
+ #
68
+ # It wrote `data: { action: ITEM_ACTION }` and then splatted html_attrs
69
+ # after it, so any caller passing `data:` replaced the whole hash and
70
+ # silently lost close-on-click and keyboard handling. This is the same
71
+ # defect `root_attrs` had for `data:`, fixed there and never here --
72
+ # merging is the fix in both places.
73
+ #
74
+ # And `method:` was passed to link_to, which was a rails-ujs option.
75
+ # Rails 7 dropped rails-ujs, so it rendered a `method` attribute on an
76
+ # `<a>` that does nothing at all: a documented parameter that had been
77
+ # inert since the library targeted Rails 7.1. Turbo reads
78
+ # data-turbo-method -- and only on a link, which is why `method:` without
79
+ # `href:` emits nothing rather than an attribute a <button> cannot act on.
80
+ #
81
+ # A caller's own action is appended rather than dropped: an item that
82
+ # tracks a click still has to close its menu.
83
+ def item_attrs
84
+ data = merge_data(caller_data, { action: ITEM_ACTION })
85
+ data = data.merge(turbo_method: @method) if @method && @href
86
+
87
+ html_attrs_without_class_and_data.merge(data: data)
88
+ end
37
89
  end
38
90
  end
39
91
  end
@@ -5,7 +5,7 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(label: 'Filters', class_name: nil, **html)
8
+ def initialize(label: senren_t('filter_bar.label', default: 'Filters'), class_name: nil, **html)
9
9
  super(variant: :default, size: :md, class_name: class_name, **html)
10
10
  @label = label
11
11
  end
@@ -6,5 +6,5 @@
6
6
  form_opts[:multipart] = multipart if multipart
7
7
  %>
8
8
  <%= form_with(**form_opts, **root_attrs("space-y-4")) do |f| %>
9
- <%= content || capture(f, &Proc.new) %>
9
+ <%= form_body(f) %>
10
10
  <% end %>
@@ -17,6 +17,27 @@ module Senren
17
17
 
18
18
  attr_reader :model, :method, :multipart
19
19
 
20
+ # ViewComponent's `content` calls the render block with the component as
21
+ # its argument, so `|f|` would be this component, not the form builder.
22
+ # And because a component is an ActionView::Base, `f.text_field :title,
23
+ # class: "x"` would not raise: it would reach the template-level helper and
24
+ # render `name="title[{class: "x"}]"`. Keep the block and call it with the
25
+ # builder form_with yields instead.
26
+ def render_in(view_context, **, &block)
27
+ @form_block = block
28
+ super
29
+ end
30
+
31
+ # Mirrors ViewComponent's own `content`: the block runs under the caller's
32
+ # virtual path, so relative `t('.key')` lookups inside it resolve against
33
+ # the caller's template. (A block and with_content together never get
34
+ # here: ViewComponent raises DuplicateContentError first.)
35
+ def form_body(builder)
36
+ return content unless @form_block
37
+
38
+ with_captured_virtual_path(@old_virtual_path) { view_context.capture(builder, &@form_block) }
39
+ end
40
+
20
41
  # This one reaches form_with's `action`, which makes it the highest-value
21
42
  # URL sink in the library: `//evil.example` is a protocol-relative absolute
22
43
  # URL, so a form built from user-controlled input POSTs every field —
@@ -1,10 +1,13 @@
1
- <span data-controller="senren--hover-card" class="relative inline-block" data-senren-component="hover_card">
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.span(**wrapper_attrs("relative inline-block", data: { controller: "senren--hover-card" })) do %>
2
5
  <span tabindex="0"
3
6
  data-action="mouseenter->senren--hover-card#show mouseleave->senren--hover-card#hide focus->senren--hover-card#show blur->senren--hover-card#hide">
4
7
  <%= trigger %>
5
8
  </span>
6
9
  <span hidden data-senren--hover-card-target="panel"
7
- class="absolute z-40 mt-2 w-64 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-4 shadow-md">
10
+ class="<%= panel_class("absolute z-40 mt-2 w-64 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-4 shadow-md") %>">
8
11
  <%= content_panel || content %>
9
12
  </span>
10
- </span>
13
+ <% end %>
@@ -7,7 +7,8 @@
7
7
  </button>
8
8
  <% end %>
9
9
 
10
- <div hidden data-senren--invite-member-dialog-target="overlay" class="fixed inset-0 z-40 bg-[hsl(var(--senren-foreground)/0.42)] backdrop-blur-sm"></div>
10
+ <div hidden data-senren--invite-member-dialog-target="overlay"
11
+ data-action="click->senren--invite-member-dialog#close" class="fixed inset-0 z-40 bg-[hsl(var(--senren-foreground)/0.42)] backdrop-blur-sm"></div>
11
12
  <div hidden id="<%= dom_id %>" data-senren--invite-member-dialog-target="panel" role="dialog" aria-modal="true" aria-labelledby="<%= dom_id %>-title" tabindex="-1" class="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] p-6 text-[hsl(var(--senren-popover-foreground))] shadow-lg">
12
13
  <h2 id="<%= dom_id %>-title" class="font-display text-lg font-semibold"><%= title %></h2>
13
14
  <% if description.present? %>
@@ -18,18 +19,19 @@
18
19
  <% if content? %>
19
20
  <%= content %>
20
21
  <% else %>
21
- <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-email">Email</label>
22
- <input id="<%= dom_id %>-email" name="<%= email_name %>" type="email" placeholder="teammate@company.com" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-3 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
23
- <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-role">Role</label>
24
- <select id="<%= dom_id %>-role" name="<%= role_name %>" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-3 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
25
- <% roles.each do |role| %><option><%= role %></option><% end %>
26
- </select>
22
+ <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-email"><%= senren_t('invite_member_dialog.email', default: 'Email') %></label>
23
+ <input id="<%= dom_id %>-email" name="<%= email_name %>" type="email" autocomplete="email" placeholder="<%= senren_t('invite_member_dialog.email_placeholder', default: 'teammate@company.com') %>" class="h-10 w-full rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-background))] px-3 text-sm outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
24
+ <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]" for="<%= dom_id %>-role"><%= senren_t('invite_member_dialog.role', default: 'Role') %></label>
25
+ <%# Was a hand-written <select> with the styling copied out of %>
26
+ <%# NativeSelectComponent, which meant it silently missed anything that %>
27
+ <%# component gained later -- the chevron and its rotation among them. %>
28
+ <%= render Senren::NativeSelectComponent.new(name: role_name, id: "#{dom_id}-role", options: roles) %>
27
29
  <% end %>
28
30
  </div>
29
31
 
30
32
  <div class="mt-6 flex justify-end gap-2">
31
- <button type="button" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-4 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]" data-action="click->senren--invite-member-dialog#close">Cancel</button>
32
- <% if footer? %><%= footer %><% else %><button type="submit" 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">Send invite</button><% end %>
33
+ <button type="button" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-4 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]" data-action="click->senren--invite-member-dialog#close"><%= senren_t('invite_member_dialog.cancel', default: 'Cancel') %></button>
34
+ <% if footer? %><%= footer %><% else %><button type="submit" 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"><%= senren_t('invite_member_dialog.send', default: 'Send invite') %></button><% end %>
33
35
  </div>
34
36
  </div>
35
37
  <% end %>
@@ -8,9 +8,17 @@ module Senren
8
8
  VARIANTS = { default: '' }.freeze
9
9
  SIZES = { md: '' }.freeze
10
10
 
11
- def initialize(title: 'Invite teammate', description: 'Send an invitation to join this workspace.',
12
- email_name: 'email', role_name: 'role', roles: %w[Member
13
- Admin], button_label: 'Invite member', id: nil, class_name: nil, **html)
11
+ # The role option values stay `Member` and `Admin`, because that is what the
12
+ # form submits and what the host's code compares against. Only the label a
13
+ # person reads is translated.
14
+ def initialize(title: senren_t('invite_member_dialog.title', default: 'Invite teammate'),
15
+ description: senren_t('invite_member_dialog.description',
16
+ default: 'Send an invitation to join this workspace.'),
17
+ email_name: 'email', role_name: 'role',
18
+ roles: [['Member', senren_t('invite_member_dialog.role_member', default: 'Member')],
19
+ ['Admin', senren_t('invite_member_dialog.role_admin', default: 'Admin')]],
20
+ button_label: senren_t('invite_member_dialog.button', default: 'Invite member'),
21
+ id: nil, class_name: nil, **html)
14
22
  super(variant: :default, size: :md, class_name: class_name, **html)
15
23
  @title = title
16
24
  @description = description
@@ -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: safe_url(href), **external_attrs) %>
1
+ <%= tag.a content, href: safe_url(href), **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", **external_attrs) %>