ruby_ui 1.2.0 → 1.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -0
  3. data/lib/generators/ruby_ui/component/all_generator.rb +6 -4
  4. data/lib/generators/ruby_ui/component_generator.rb +51 -31
  5. data/lib/generators/ruby_ui/dependencies.yml +32 -10
  6. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
  7. data/lib/generators/ruby_ui/javascript_utils.rb +24 -7
  8. data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
  9. data/lib/ruby_ui/accordion/accordion_controller.js +19 -4
  10. data/lib/ruby_ui/avatar/avatar.rb +3 -0
  11. data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
  12. data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
  13. data/lib/ruby_ui/avatar/avatar_image.rb +9 -1
  14. data/lib/ruby_ui/base.rb +6 -0
  15. data/lib/ruby_ui/calendar/calendar.rb +3 -1
  16. data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
  17. data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
  18. data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
  19. data/lib/ruby_ui/combobox/combobox.rb +1 -7
  20. data/lib/ruby_ui/combobox/combobox_checkbox.rb +7 -1
  21. data/lib/ruby_ui/combobox/combobox_controller.js +56 -244
  22. data/lib/ruby_ui/combobox/combobox_item.rb +7 -5
  23. data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
  24. data/lib/ruby_ui/combobox/combobox_popover.rb +5 -0
  25. data/lib/ruby_ui/combobox/combobox_radio.rb +8 -1
  26. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +7 -1
  27. data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
  28. data/lib/ruby_ui/command/command_controller.js +10 -19
  29. data/lib/ruby_ui/command/command_dialog.rb +4 -1
  30. data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
  31. data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
  32. data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
  33. data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
  34. data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
  35. data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
  36. data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
  37. data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
  38. data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
  39. data/lib/ruby_ui/form/form_docs.rb +89 -0
  40. data/lib/ruby_ui/masked_input/masked_input.rb +1 -11
  41. data/lib/ruby_ui/masked_input/masked_input_controller.js +0 -13
  42. data/lib/ruby_ui/select/select_value.rb +2 -1
  43. data/lib/ruby_ui/sheet/sheet.rb +9 -1
  44. data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
  45. data/lib/ruby_ui/table/table_docs.rb +2 -2
  46. data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
  47. data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
  48. data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
  49. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
  50. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
  51. data/lib/ruby_ui/toast/toast.rb +18 -0
  52. data/lib/ruby_ui/toast/toast_action.rb +27 -0
  53. data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
  54. data/lib/ruby_ui/toast/toast_close.rb +40 -0
  55. data/lib/ruby_ui/toast/toast_controller.js +151 -0
  56. data/lib/ruby_ui/toast/toast_description.rb +18 -0
  57. data/lib/ruby_ui/toast/toast_docs.rb +12 -0
  58. data/lib/ruby_ui/toast/toast_icon.rb +65 -0
  59. data/lib/ruby_ui/toast/toast_item.rb +72 -0
  60. data/lib/ruby_ui/toast/toast_region.rb +124 -0
  61. data/lib/ruby_ui/toast/toast_title.rb +18 -0
  62. data/lib/ruby_ui/toast/toaster_controller.js +306 -0
  63. data/lib/ruby_ui/toggle/toggle.rb +101 -0
  64. data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
  65. data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
  66. data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
  67. data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
  68. data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
  69. data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
  70. data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
  71. data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
  72. data/lib/ruby_ui.rb +3 -1
  73. metadata +30 -14
  74. data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
  75. data/lib/ruby_ui/theme_toggle/set_light_mode.rb +0 -16
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="ruby-ui--command-dialog"
4
+ export default class extends Controller {
5
+ static targets = ["content"];
6
+ static outlets = ["ruby-ui--command"];
7
+
8
+ rubyUiCommandOutletConnected(controller) {
9
+ this.openOutlet = controller;
10
+ }
11
+
12
+ rubyUiCommandOutletDisconnected() {
13
+ this.openOutlet = null;
14
+ }
15
+
16
+ open(e) {
17
+ if (e) {
18
+ e.preventDefault();
19
+ }
20
+
21
+ if (!this.hasContentTarget) {
22
+ return;
23
+ }
24
+
25
+ if (this.openOutlet) {
26
+ this.openOutlet.focusInput();
27
+ return;
28
+ }
29
+
30
+ document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
31
+ // prevent scroll on body
32
+ document.body.classList.add("overflow-hidden");
33
+ }
34
+ }
@@ -8,7 +8,7 @@ module RubyUI
8
8
  ].freeze
9
9
 
10
10
  def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs)
11
- @keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--command#open" }
11
+ @keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--command-dialog#open" }
12
12
  super(**attrs)
13
13
  end
14
14
 
@@ -21,7 +21,7 @@ module RubyUI
21
21
  def default_attrs
22
22
  {
23
23
  data: {
24
- action: ["click->ruby-ui--command#open", @keybindings.join(" ")]
24
+ action: ["click->ruby-ui--command-dialog#open", @keybindings.join(" ")]
25
25
  }
26
26
  }
27
27
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::DataTable < Views::Base
4
- Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true)
4
+ Row = Struct.new(:id, :name, :email, :salary, :status)
5
5
 
6
6
  SAMPLE_ROWS = [
7
7
  Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"),
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyUI
6
+ class DatePicker < Base
7
+ def initialize(
8
+ id: nil,
9
+ name: nil,
10
+ label: "Select a date",
11
+ value: nil,
12
+ placeholder: "Select a date",
13
+ selected_date: value,
14
+ date_format: "yyyy-MM-dd",
15
+ popover_options: {},
16
+ input_attrs: {},
17
+ calendar_attrs: {},
18
+ trigger_attrs: {},
19
+ content_attrs: {},
20
+ **attrs
21
+ )
22
+ @id = id || "date-picker-#{SecureRandom.hex(4)}"
23
+ @name = name
24
+ @label = label
25
+ @value = value || selected_date&.to_s
26
+ @placeholder = placeholder
27
+ @selected_date = selected_date
28
+ @date_format = date_format
29
+ @popover_options = {trigger: "click"}.merge(popover_options)
30
+ @input_attrs = input_attrs
31
+ @calendar_attrs = calendar_attrs
32
+ @trigger_attrs = trigger_attrs
33
+ @content_attrs = content_attrs
34
+ super(**attrs)
35
+ end
36
+
37
+ def view_template
38
+ div(**attrs) do
39
+ RubyUI.Popover(options: @popover_options) do
40
+ RubyUI.PopoverTrigger(**trigger_attrs) do
41
+ div(class: "grid w-full max-w-sm items-center gap-1.5") do
42
+ label(for: @id) { @label } if @label
43
+ RubyUI.Input(**input_attrs)
44
+ end
45
+ end
46
+ RubyUI.PopoverContent(**content_attrs) do
47
+ RubyUI.Calendar(input_id: "##{@id}", selected_date: @selected_date, date_format: @date_format, **calendar_attrs)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def default_attrs
56
+ {
57
+ class: "space-y-4 w-[260px]"
58
+ }
59
+ end
60
+
61
+ def trigger_attrs
62
+ mix({class: "w-full"}, @trigger_attrs)
63
+ end
64
+
65
+ def input_attrs
66
+ mix({
67
+ type: "string",
68
+ placeholder: @placeholder,
69
+ id: @id,
70
+ name: @name,
71
+ value: @value,
72
+ data_controller: "ruby-ui--calendar-input",
73
+ class: "rounded-md border shadow"
74
+ }.compact, @input_attrs)
75
+ end
76
+
77
+ def calendar_attrs
78
+ mix({}, @calendar_attrs)
79
+ end
80
+
81
+ def content_attrs
82
+ mix({}, @content_attrs)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::Docs::DatePicker < Views::Base
4
+ def view_template
5
+ component = "DatePicker"
6
+
7
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8
+ render Docs::Header.new(title: "Date Picker", description: "A date picker component with input.")
9
+
10
+ Heading(level: 2) { "Usage" }
11
+
12
+ render Docs::VisualCodeExample.new(title: "Single Date", context: self) do
13
+ <<~RUBY
14
+ DatePicker(id: "date")
15
+ RUBY
16
+ end
17
+
18
+ render Components::ComponentSetup::Tabs.new(component_name: component)
19
+
20
+ render Docs::ComponentsTable.new(component_files(component))
21
+ end
22
+ end
23
+ end
@@ -17,14 +17,9 @@ module RubyUI
17
17
  end
18
18
 
19
19
  def view_template
20
- template(data: {ruby_ui__dialog_target: "content"}) do
21
- div(data_controller: "ruby-ui--dialog") do
22
- backdrop
23
- div(**attrs) do
24
- yield
25
- close_button
26
- end
27
- end
20
+ dialog(**attrs) do
21
+ yield
22
+ close_button
28
23
  end
29
24
  end
30
25
 
@@ -32,9 +27,10 @@ module RubyUI
32
27
 
33
28
  def default_attrs
34
29
  {
35
- data_state: "open",
30
+ data_ruby_ui__dialog_target: "dialog",
31
+ data_action: "click->ruby-ui--dialog#backdropClick",
36
32
  class: [
37
- "fixed flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full",
33
+ "fixed flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-background/80 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:zoom-in-95 sm:rounded-lg md:w-full",
38
34
  SIZES[@size]
39
35
  ]
40
36
  }
@@ -43,7 +39,7 @@ module RubyUI
43
39
  def close_button
44
40
  button(
45
41
  type: "button",
46
- class: "absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
42
+ class: "absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none",
47
43
  data_action: "click->ruby-ui--dialog#dismiss"
48
44
  ) do
49
45
  svg(
@@ -65,13 +61,5 @@ module RubyUI
65
61
  span(class: "sr-only") { "Close" }
66
62
  end
67
63
  end
68
-
69
- def backdrop
70
- div(
71
- data_state: "open",
72
- data_action: "click->ruby-ui--dialog#dismiss esc->ruby-ui--dialog#dismiss",
73
- class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0"
74
- )
75
- end
76
64
  end
77
65
  end
@@ -1,8 +1,8 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="dialog"
3
+ // Connects to data-controller="ruby-ui--dialog"
4
4
  export default class extends Controller {
5
- static targets = ["content"]
5
+ static targets = ["dialog"]
6
6
  static values = {
7
7
  open: {
8
8
  type: Boolean,
@@ -11,22 +11,34 @@ export default class extends Controller {
11
11
  }
12
12
 
13
13
  connect() {
14
+ this.dialogTarget.addEventListener("close", this.handleClose)
14
15
  if (this.openValue) {
15
16
  this.open()
16
17
  }
17
18
  }
18
19
 
20
+ disconnect() {
21
+ this.dialogTarget.removeEventListener("close", this.handleClose)
22
+ document.body.classList.remove("overflow-hidden")
23
+ }
24
+
19
25
  open(e) {
20
- e?.preventDefault();
21
- document.body.insertAdjacentHTML('beforeend', this.contentTarget.innerHTML)
22
- // prevent scroll on body
23
- document.body.classList.add('overflow-hidden')
26
+ e?.preventDefault()
27
+ this.dialogTarget.showModal()
28
+ document.body.classList.add("overflow-hidden")
24
29
  }
25
30
 
26
31
  dismiss() {
27
- // allow scroll on body
28
- document.body.classList.remove('overflow-hidden')
29
- // remove the element
30
- this.element.remove()
32
+ this.dialogTarget.close()
33
+ }
34
+
35
+ backdropClick(e) {
36
+ if (e.target === this.dialogTarget) {
37
+ this.dismiss()
38
+ }
39
+ }
40
+
41
+ handleClose = () => {
42
+ document.body.classList.remove("overflow-hidden")
31
43
  }
32
44
  }
@@ -2,20 +2,24 @@
2
2
 
3
3
  module RubyUI
4
4
  class DropdownMenuItem < Base
5
- def initialize(href: "#", **attrs)
5
+ def initialize(as: :a, href: "#", **attrs)
6
+ @as = as
6
7
  @href = href
7
8
  super(**attrs)
8
9
  end
9
10
 
10
11
  def view_template(&)
11
- a(**attrs, &)
12
+ if @as == :div
13
+ div(**attrs, &)
14
+ else
15
+ a(**attrs, &)
16
+ end
12
17
  end
13
18
 
14
19
  private
15
20
 
16
21
  def default_attrs
17
- {
18
- href: @href,
22
+ base = {
19
23
  role: "menuitem",
20
24
  class: "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
21
25
  data_action: "click->ruby-ui--dropdown-menu#close",
@@ -23,6 +27,8 @@ module RubyUI
23
27
  tabindex: "-1",
24
28
  data_orientation: "vertical"
25
29
  }
30
+ base[:href] = @href unless @as == :div
31
+ base
26
32
  end
27
33
  end
28
34
  end
@@ -170,6 +170,95 @@ class Views::Docs::Form < Views::Base
170
170
  RUBY
171
171
  end
172
172
 
173
+ Heading(level: 2) { "Rails Integration" }
174
+
175
+ Text do
176
+ plain "RubyUI Form components are plain HTML — they work with any form submission strategy. "
177
+ plain "The recommended approach for Rails apps is to use "
178
+ InlineLink(href: "https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with", target: "_blank") { "form_with" }
179
+ plain " to generate the "
180
+ code(class: "font-mono text-sm") { "action" }
181
+ plain " URL and CSRF token, then pass explicit "
182
+ code(class: "font-mono text-sm") { "name" }
183
+ plain " / "
184
+ code(class: "font-mono text-sm") { "id" }
185
+ plain " attributes to each RubyUI input so the browser serialises them correctly. "
186
+ plain "Server-side errors can be surfaced by rendering "
187
+ code(class: "font-mono text-sm") { "FormFieldError" }
188
+ plain " with content from "
189
+ code(class: "font-mono text-sm") { "model.errors.full_messages_for(:attr)" }
190
+ plain "."
191
+ end
192
+
193
+ Heading(level: 3) { "Minimal Rails form" }
194
+ Codeblock(<<~RUBY, syntax: :ruby)
195
+ # In your Phlex view, call form_with via helpers:
196
+ # form_with(url: users_path, method: :post) passes action + CSRF automatically.
197
+ #
198
+ # You can also set action and the CSRF token manually:
199
+ Form(action: helpers.users_path, method: "post", class: "w-2/3 space-y-6") do
200
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
201
+
202
+ FormField do
203
+ FormFieldLabel(for: "user_email") { "Email" }
204
+ Input(
205
+ type: "email",
206
+ id: "user_email",
207
+ name: "user[email]",
208
+ placeholder: "you@example.com",
209
+ required: true
210
+ )
211
+ FormFieldError()
212
+ end
213
+
214
+ Button(type: "submit") { "Continue" }
215
+ end
216
+ RUBY
217
+
218
+ Heading(level: 3) { "Devise-style login form" }
219
+ Codeblock(<<~RUBY, syntax: :ruby)
220
+ # Full sign-in form mirroring Devise session[email] / session[password] params.
221
+ # Pass backend errors (e.g. "Invalid email or password") into FormFieldError.
222
+ Form(action: helpers.user_session_path, method: "post", class: "space-y-6") do
223
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
224
+
225
+ FormField do
226
+ FormFieldLabel(for: "session_email") { "Email" }
227
+ Input(
228
+ type: "email",
229
+ id: "session_email",
230
+ name: "session[email]",
231
+ placeholder: "you@example.com",
232
+ autocomplete: "email",
233
+ required: true
234
+ )
235
+ FormFieldError { @error_message }
236
+ end
237
+
238
+ FormField do
239
+ FormFieldLabel(for: "session_password") { "Password" }
240
+ Input(
241
+ type: "password",
242
+ id: "session_password",
243
+ name: "session[password]",
244
+ autocomplete: "current-password",
245
+ required: true,
246
+ minlength: "8"
247
+ )
248
+ FormFieldError()
249
+ end
250
+
251
+ FormField do
252
+ div(class: "flex items-center gap-2") do
253
+ Checkbox(id: "session_remember_me", name: "session[remember_me]", value: "1")
254
+ FormFieldLabel(for: "session_remember_me") { "Remember me" }
255
+ end
256
+ end
257
+
258
+ Button(type: "submit", class: "w-full") { "Sign in" }
259
+ end
260
+ RUBY
261
+
173
262
  render Components::ComponentSetup::Tabs.new(component_name: component)
174
263
 
175
264
  render Docs::ComponentsTable.new(component_files(component))
@@ -2,18 +2,8 @@
2
2
 
3
3
  module RubyUI
4
4
  class MaskedInput < Base
5
- def initialize(save_unmasked: false, **attrs)
6
- @save_unmasked = save_unmasked
7
- super(**attrs)
8
- end
9
-
10
5
  def view_template
11
- if @save_unmasked
12
- Input(type: "text", **attrs.merge(name: "#{attrs[:name]}-masked"))
13
- input(type: "hidden", name: attrs[:name], value: attrs[:value])
14
- else
15
- Input(type: "text", **attrs)
16
- end
6
+ Input(type: "text", **attrs)
17
7
  end
18
8
 
19
9
  private
@@ -5,18 +5,5 @@ import { MaskInput } from "maska";
5
5
  export default class extends Controller {
6
6
  connect() {
7
7
  new MaskInput(this.element)
8
- this.#boundSync = this.#sync.bind(this);
9
- this.element.addEventListener("maska", this.#boundSync);
10
- }
11
-
12
- disconnect() {
13
- this.element.removeEventListener("maska", this.#boundSync);
14
- }
15
-
16
- #boundSync = null;
17
-
18
- #sync(event) {
19
- const hidden = this.element.nextElementSibling;
20
- if (hidden?.type === "hidden") hidden.value = event.detail.unmasked;
21
8
  }
22
9
  }
@@ -9,7 +9,8 @@ module RubyUI
9
9
 
10
10
  def view_template(&block)
11
11
  span(**attrs) do
12
- block ? block.call : @placeholder
12
+ value = block ? block.call : @placeholder
13
+ value || @placeholder
13
14
  end
14
15
  end
15
16
 
@@ -2,6 +2,11 @@
2
2
 
3
3
  module RubyUI
4
4
  class Sheet < Base
5
+ def initialize(open: false, **attrs)
6
+ @open = open
7
+ super(**attrs)
8
+ end
9
+
5
10
  def view_template(&)
6
11
  div(**attrs, &)
7
12
  end
@@ -10,7 +15,10 @@ module RubyUI
10
15
 
11
16
  def default_attrs
12
17
  {
13
- data: {controller: "ruby-ui--sheet"}
18
+ data: {
19
+ controller: "ruby-ui--sheet",
20
+ ruby_ui__sheet_open_value: @open.to_s
21
+ }
14
22
  }
15
23
  end
16
24
  end
@@ -3,6 +3,12 @@ import { Controller } from "@hotwired/stimulus"
3
3
  export default class extends Controller {
4
4
  static targets = ["content"]
5
5
 
6
+ static values = { open: false }
7
+
8
+ connect() {
9
+ if (this.openValue) this.open()
10
+ }
11
+
6
12
  open() {
7
13
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
8
14
  }
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::Table < Views::Base
4
- Invoice = Struct.new(:identifier, :status, :method, :amount, keyword_init: true)
5
- User = Struct.new(:avatar_url, :name, :username, :commits, :github_url, keyword_init: true)
4
+ Invoice = Struct.new(:identifier, :status, :method, :amount)
5
+ User = Struct.new(:avatar_url, :name, :username, :commits, :github_url)
6
6
 
7
7
  def view_template
8
8
  component = "Table"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::Tabs < Views::Base
4
- Repo = Struct.new(:github_url, :name, :stars, :version, keyword_init: true)
4
+ Repo = Struct.new(:github_url, :name, :stars, :version)
5
5
 
6
6
  def view_template
7
7
  component = "Tabs"
@@ -2,20 +2,24 @@
2
2
 
3
3
  module RubyUI
4
4
  class TabsTrigger < Base
5
- def initialize(value:, **attrs)
5
+ def initialize(value:, as: :button, **attrs)
6
6
  @value = value
7
+ @as = as
7
8
  super(**attrs)
8
9
  end
9
10
 
10
11
  def view_template(&)
11
- button(**attrs, &)
12
+ if @as == :a
13
+ a(**attrs, &)
14
+ else
15
+ button(**attrs, &)
16
+ end
12
17
  end
13
18
 
14
19
  private
15
20
 
16
21
  def default_attrs
17
- {
18
- type: :button,
22
+ base = {
19
23
  data: {
20
24
  ruby_ui__tabs_target: "trigger",
21
25
  action: "click->ruby-ui--tabs#show",
@@ -29,6 +33,8 @@ module RubyUI
29
33
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
30
34
  ]
31
35
  }
36
+ base[:type] = :button unless @as == :a
37
+ base
32
38
  end
33
39
  end
34
40
  end
@@ -2,8 +2,20 @@
2
2
 
3
3
  module RubyUI
4
4
  class ThemeToggle < Base
5
- def view_template(&)
6
- div(**attrs, &)
5
+ def view_template(&block)
6
+ RubyUI.Toggle(
7
+ variant: :default,
8
+ size: :default,
9
+ aria: {label: "Toggle theme"},
10
+ wrapper: {
11
+ data: {
12
+ controller: "ruby-ui--theme-toggle",
13
+ action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply"
14
+ }
15
+ },
16
+ **attrs,
17
+ &block
18
+ )
7
19
  end
8
20
  end
9
21
  end
@@ -1,30 +1,38 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // Connects to data-controller="ruby-ui--theme-toggle"
4
+ // Sits on the same wrapper as ruby-ui--toggle. Listens for the toggle's
5
+ // ruby-ui--toggle:change event. pressed = dark mode.
3
6
  export default class extends Controller {
4
- initialize() {
5
- this.setTheme()
7
+ connect() {
8
+ this.applyTheme(this.currentTheme())
6
9
  }
7
10
 
8
- setTheme() {
9
- // On page load or when changing themes, best to add inline in `head` to avoid FOUC
10
- if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
11
- document.documentElement.classList.add('dark')
12
- document.documentElement.classList.remove('light')
13
- } else {
14
- document.documentElement.classList.remove('dark')
15
- document.documentElement.classList.add('light')
16
- }
11
+ apply(event) {
12
+ const pressed = event.detail?.pressed
13
+ const theme = pressed ? "dark" : "light"
14
+ localStorage.theme = theme
15
+ this.applyTheme(theme)
17
16
  }
18
17
 
19
- setLightTheme() {
20
- // Whenever the user explicitly chooses light mode
21
- localStorage.theme = 'light'
22
- this.setTheme()
18
+ currentTheme() {
19
+ if (localStorage.theme === "dark") return "dark"
20
+ if (localStorage.theme === "light") return "light"
21
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
23
22
  }
24
23
 
25
- setDarkTheme() {
26
- // Whenever the user explicitly chooses dark mode
27
- localStorage.theme = 'dark'
28
- this.setTheme()
24
+ applyTheme(theme) {
25
+ const html = document.documentElement
26
+ if (theme === "dark") {
27
+ html.classList.add("dark")
28
+ html.classList.remove("light")
29
+ } else {
30
+ html.classList.add("light")
31
+ html.classList.remove("dark")
32
+ }
33
+ // Flip the sibling Toggle controller's pressed value; it will propagate
34
+ // aria-pressed / data-state to the button target.
35
+ const dark = theme === "dark"
36
+ this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false")
29
37
  }
30
38
  }