ruby_ui 1.3.0 → 1.6.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 (69) 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 +12 -2
  6. data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
  7. data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
  8. data/lib/ruby_ui/accordion/accordion_controller.js +28 -6
  9. data/lib/ruby_ui/avatar/avatar_image.rb +5 -1
  10. data/lib/ruby_ui/bubble/bubble.rb +37 -0
  11. data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
  12. data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
  13. data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
  14. data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
  15. data/lib/ruby_ui/combobox/combobox.rb +3 -1
  16. data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
  17. data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
  18. data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
  19. data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
  20. data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
  21. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
  22. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
  23. data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
  24. data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
  25. data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
  26. data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
  27. data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
  28. data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
  29. data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
  30. data/lib/ruby_ui/empty/empty.rb +18 -0
  31. data/lib/ruby_ui/empty/empty_content.rb +18 -0
  32. data/lib/ruby_ui/empty/empty_description.rb +18 -0
  33. data/lib/ruby_ui/empty/empty_docs.rb +69 -0
  34. data/lib/ruby_ui/empty/empty_header.rb +18 -0
  35. data/lib/ruby_ui/empty/empty_media.rb +31 -0
  36. data/lib/ruby_ui/empty/empty_title.rb +18 -0
  37. data/lib/ruby_ui/form/form_docs.rb +89 -0
  38. data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
  39. data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
  40. data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
  41. data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
  42. data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
  43. data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
  44. data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
  45. data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
  46. data/lib/ruby_ui/message/message.rb +23 -0
  47. data/lib/ruby_ui/message/message_avatar.rb +18 -0
  48. data/lib/ruby_ui/message/message_content.rb +18 -0
  49. data/lib/ruby_ui/message/message_docs.rb +173 -0
  50. data/lib/ruby_ui/message/message_footer.rb +18 -0
  51. data/lib/ruby_ui/message/message_group.rb +18 -0
  52. data/lib/ruby_ui/message/message_header.rb +18 -0
  53. data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
  54. data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
  55. data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
  56. data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
  57. data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
  58. data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
  59. data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
  60. data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
  61. data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
  62. data/lib/ruby_ui/popover/popover_content.rb +2 -1
  63. data/lib/ruby_ui/popover/popover_controller.js +95 -32
  64. data/lib/ruby_ui/table/table_docs.rb +2 -2
  65. data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
  66. data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
  67. data/lib/ruby_ui/toast/toaster_controller.js +24 -8
  68. data/lib/ruby_ui.rb +1 -1
  69. metadata +35 -2
@@ -52,10 +52,7 @@ module RubyUI
52
52
  stroke_linejoin: "round"
53
53
  ) do |s|
54
54
  s.path(
55
- d: "m7 15 5 5 5-5"
56
- )
57
- s.path(
58
- d: "m7 9 5-5 5 5"
55
+ d: "m6 9 6 6 6-6"
59
56
  )
60
57
  end
61
58
  end
@@ -2,10 +2,8 @@
2
2
 
3
3
  module RubyUI
4
4
  class ContextMenuContent < Base
5
- def view_template(&block)
6
- template(data: {ruby_ui__context_menu_target: "content"}) do
7
- div(**attrs, &block)
8
- end
5
+ def view_template(&)
6
+ div(**attrs, &)
9
7
  end
10
8
 
11
9
  private
@@ -14,9 +12,10 @@ module RubyUI
14
12
  {
15
13
  role: "menu",
16
14
  aria_orientation: "vertical",
17
- data_state: "open",
15
+ data_state: "closed",
16
+ data: {ruby_ui__context_menu_target: "content"},
18
17
  class:
19
- "z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
18
+ "hidden absolute z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
20
19
  tabindex: "-1",
21
20
  data_orientation: "vertical"
22
21
  }
@@ -1,106 +1,125 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
- import tippy from "tippy.js";
2
+ import {
3
+ computePosition,
4
+ flip,
5
+ shift,
6
+ offset,
7
+ autoUpdate,
8
+ } from "@floating-ui/dom";
3
9
 
4
10
  export default class extends Controller {
5
11
  static targets = ["trigger", "content", "menuItem"];
6
12
  static values = {
7
- options: {
8
- type: Object,
9
- default: {},
10
- },
11
- // make content width of the trigger element (true/false)
12
- matchWidth: {
13
- type: Boolean,
14
- default: false,
15
- }
16
- }
13
+ open: { type: Boolean, default: false },
14
+ options: { type: Object, default: {} },
15
+ // make content width match the trigger element (true/false)
16
+ matchWidth: { type: Boolean, default: false },
17
+ };
17
18
 
18
19
  connect() {
19
- this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later
20
- this.initializeTippy();
20
+ this.cleanup = null;
21
21
  this.selectedIndex = -1;
22
+ this.boundHandleKeydown = this.handleKeydown.bind(this);
22
23
  }
23
24
 
24
25
  disconnect() {
25
- this.destroyTippy();
26
+ this.hide();
26
27
  }
27
28
 
28
- initializeTippy() {
29
- const defaultOptions = {
30
- content: this.contentTarget.innerHTML,
31
- allowHTML: true,
32
- interactive: true,
33
- onShow: (instance) => {
34
- this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width
35
- this.addEventListeners();
36
- },
37
- onHide: () => {
38
- this.removeEventListeners();
39
- this.deselectAll();
40
- },
41
- popperOptions: {
42
- modifiers: [
43
- {
44
- name: "offset",
45
- options: {
46
- offset: [0, 4]
47
- },
48
- },
49
- ],
50
- }
51
- };
52
-
53
- const mergedOptions = { ...this.optionsValue, ...defaultOptions };
54
- this.tippy = tippy(this.triggerTarget, mergedOptions);
29
+ handleContextMenu(event) {
30
+ event.preventDefault();
31
+ this.open();
55
32
  }
56
33
 
57
- destroyTippy() {
58
- if (this.tippy) {
59
- this.tippy.destroy();
34
+ open() {
35
+ this.openValue = true;
36
+ this.contentTarget.classList.remove("hidden");
37
+ this.contentTarget.dataset.state = "open";
38
+ if (this.matchWidthValue) {
39
+ this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;
60
40
  }
41
+ this.addEventListeners();
42
+ this.updatePosition();
61
43
  }
62
44
 
63
- setContentWidth(instance) {
64
- // box-sizing: border-box
65
- const content = instance.popper.querySelector('.tippy-content');
66
- if (content) {
67
- content.style.width = `${instance.reference.offsetWidth}px`;
45
+ close() {
46
+ this.hide();
47
+ }
48
+
49
+ hide() {
50
+ if (!this.openValue) return;
51
+ this.openValue = false;
52
+ this.contentTarget.classList.add("hidden");
53
+ this.contentTarget.dataset.state = "closed";
54
+ this.removeEventListeners();
55
+ this.deselectAll();
56
+ if (this.cleanup) {
57
+ this.cleanup();
58
+ this.cleanup = null;
68
59
  }
69
60
  }
70
61
 
71
- handleContextMenu(event) {
72
- event.preventDefault();
73
- this.open();
62
+ updatePosition() {
63
+ if (this.cleanup) this.cleanup();
64
+
65
+ this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
66
+ computePosition(this.triggerTarget, this.contentTarget, {
67
+ placement: this.optionsValue.placement || "bottom-start",
68
+ middleware: [offset(4), flip(), shift({ padding: 8 })],
69
+ }).then(({ x, y, placement }) => {
70
+ Object.assign(this.contentTarget.style, {
71
+ left: `${x}px`,
72
+ top: `${y}px`,
73
+ });
74
+ this.contentTarget.dataset.side = placement.split("-")[0];
75
+ });
76
+ });
74
77
  }
75
78
 
76
- open() {
77
- this.tippy.show();
79
+ addEventListeners() {
80
+ document.addEventListener("keydown", this.boundHandleKeydown);
81
+ document.addEventListener("click", this.handleOutsidePointer);
82
+ // A right-click outside should dismiss this menu and let the native
83
+ // context menu (or another trigger's menu) take over.
84
+ document.addEventListener("contextmenu", this.handleOutsidePointer);
78
85
  }
79
86
 
80
- close() {
81
- this.tippy.hide();
87
+ removeEventListeners() {
88
+ document.removeEventListener("keydown", this.boundHandleKeydown);
89
+ document.removeEventListener("click", this.handleOutsidePointer);
90
+ document.removeEventListener("contextmenu", this.handleOutsidePointer);
82
91
  }
83
92
 
93
+ handleOutsidePointer = (event) => {
94
+ if (!this.element.contains(event.target)) {
95
+ this.hide();
96
+ }
97
+ };
98
+
84
99
  handleKeydown(e) {
85
- // return if no menu items (one line fix for)
86
- if (this.menuItemTargets.length === 0) { return; }
100
+ if (e.key === "Escape") {
101
+ e.preventDefault();
102
+ this.hide();
103
+ return;
104
+ }
87
105
 
88
- if (e.key === 'ArrowDown') {
106
+ if (this.menuItemTargets.length === 0) return;
107
+
108
+ if (e.key === "ArrowDown") {
89
109
  e.preventDefault();
90
110
  this.updateSelectedItem(1);
91
- } else if (e.key === 'ArrowUp') {
111
+ } else if (e.key === "ArrowUp") {
92
112
  e.preventDefault();
93
113
  this.updateSelectedItem(-1);
94
- } else if (e.key === 'Enter' && this.selectedIndex !== -1) {
114
+ } else if (e.key === "Enter" && this.selectedIndex !== -1) {
95
115
  e.preventDefault();
96
116
  this.menuItemTargets[this.selectedIndex].click();
97
117
  }
98
118
  }
99
119
 
100
120
  updateSelectedItem(direction) {
101
- // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index
102
121
  this.menuItemTargets.forEach((item, index) => {
103
- if (item.getAttribute('aria-selected') === 'true') {
122
+ if (item.getAttribute("aria-selected") === "true") {
104
123
  this.selectedIndex = index;
105
124
  }
106
125
  });
@@ -121,24 +140,17 @@ export default class extends Controller {
121
140
  }
122
141
 
123
142
  toggleAriaSelected(element, isSelected) {
124
- // Add or remove attribute
125
143
  if (isSelected) {
126
- element.setAttribute('aria-selected', 'true');
144
+ element.setAttribute("aria-selected", "true");
127
145
  } else {
128
- element.removeAttribute('aria-selected');
146
+ element.removeAttribute("aria-selected");
129
147
  }
130
148
  }
131
149
 
132
150
  deselectAll() {
133
- this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false));
151
+ this.menuItemTargets.forEach((item) =>
152
+ this.toggleAriaSelected(item, false)
153
+ );
134
154
  this.selectedIndex = -1;
135
155
  }
136
-
137
- addEventListeners() {
138
- document.addEventListener('keydown', this.boundHandleKeydown);
139
- }
140
-
141
- removeEventListeners() {
142
- document.removeEventListener('keydown', this.boundHandleKeydown);
143
- }
144
156
  }
@@ -2,8 +2,9 @@
2
2
 
3
3
  module RubyUI
4
4
  class DataTableColumnToggle < Base
5
- def initialize(columns:, **attrs)
5
+ def initialize(columns:, label: "Columns", **attrs)
6
6
  @columns = columns
7
+ @label = label
7
8
  super(**attrs)
8
9
  end
9
10
 
@@ -12,7 +13,7 @@ module RubyUI
12
13
  render RubyUI::DropdownMenu.new do
13
14
  render RubyUI::DropdownMenuTrigger.new do
14
15
  render RubyUI::Button.new(variant: :outline, size: :sm) do
15
- plain "Columns"
16
+ plain @label
16
17
  # inline chevron-down SVG (lucide 24px, 1px stroke)
17
18
  svg(
18
19
  xmlns: "http://www.w3.org/2000/svg",
@@ -35,8 +36,11 @@ module RubyUI
35
36
  label(class: "flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent") do
36
37
  input(
37
38
  type: "checkbox",
38
- checked: true,
39
- class: "h-4 w-4 rounded border border-input accent-primary cursor-pointer",
39
+ checked: col.fetch(:visible, true),
40
+ class: [
41
+ "h-4 w-4 rounded border border-input accent-primary cursor-pointer",
42
+ "checked:bg-primary checked:text-primary-foreground dark:checked:bg-secondary checked:text-primary checked:border-primary"
43
+ ],
40
44
  data: {
41
45
  column_key: col[:key].to_s,
42
46
  action: "change->ruby-ui--data-table-column-visibility#toggle"
@@ -2,9 +2,19 @@
2
2
  import { Controller } from "@hotwired/stimulus";
3
3
 
4
4
  export default class extends Controller {
5
+ connect() {
6
+ this.element
7
+ .querySelectorAll("input[type=checkbox][data-column-key]")
8
+ .forEach((checkbox) =>
9
+ this._apply(checkbox.dataset.columnKey, checkbox.checked)
10
+ );
11
+ }
12
+
5
13
  toggle(event) {
6
- const key = event.target.dataset.columnKey;
7
- const visible = event.target.checked;
14
+ this._apply(event.target.dataset.columnKey, event.target.checked);
15
+ }
16
+
17
+ _apply(key, visible) {
8
18
  const root = this.element.closest('[data-controller~="ruby-ui--data-table"]');
9
19
  if (!root) return;
10
20
  root
@@ -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"),
@@ -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 open: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
  }
@@ -16,7 +16,6 @@ module RubyUI
16
16
  def default_attrs
17
17
  {
18
18
  class: [
19
- "z-50",
20
19
  "group/dropdown-menu",
21
20
  (strategy == "absolute") ? "is-absolute" : "is-fixed"
22
21
  ],
@@ -43,7 +43,7 @@ export default class extends Controller {
43
43
 
44
44
  #computeTooltip() {
45
45
  computePosition(this.triggerTarget, this.contentTarget, {
46
- placement: this.optionsValue.placement || "top",
46
+ placement: this.optionsValue.placement || "bottom",
47
47
  middleware: [flip(), shift(), offset(8)],
48
48
  strategy: this.optionsValue.strategy || "absolute",
49
49
  }).then(({ x, y }) => {
@@ -73,12 +73,16 @@ export default class extends Controller {
73
73
  this.#deselectAll();
74
74
  this.#addEventListeners();
75
75
  this.#computeTooltip();
76
+ // Lift the open menu above sibling dropdowns/elements. The container has no
77
+ // static z-index, so closed siblings stack in normal flow and never cover it.
78
+ this.element.style.zIndex = "50";
76
79
  this.contentTarget.classList.remove("hidden");
77
80
  }
78
81
 
79
82
  close() {
80
83
  this.openValue = false;
81
84
  this.#removeEventListeners();
85
+ this.element.style.zIndex = "";
82
86
  this.contentTarget.classList.add("hidden");
83
87
  }
84
88
 
@@ -11,7 +11,7 @@ class Views::Docs::DropdownMenu < Views::Base
11
11
  render Docs::VisualCodeExample.new(title: "Example", context: self) do
12
12
  <<~RUBY
13
13
  DropdownMenu do
14
- DropdownMenuTrigger(class: 'w-full') do
14
+ DropdownMenuTrigger do
15
15
  Button(variant: :outline) { "Open" }
16
16
  end
17
17
  DropdownMenuContent do
@@ -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
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class Empty < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "empty"},
14
+ class: "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-3xl border border-dashed p-12 text-center text-balance"
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class EmptyContent < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "empty-content"},
14
+ class: "flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance"
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class EmptyDescription < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "empty-description"},
14
+ class: "text-sm leading-relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary"
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::Docs::Empty < Views::Base
4
+ def view_template
5
+ component = "Empty"
6
+
7
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8
+ render Docs::Header.new(title: "Empty", description: "Use the empty component to display a state when there is no data or content.")
9
+
10
+ Heading(level: 2) { "Usage" }
11
+
12
+ render Docs::VisualCodeExample.new(title: "Default", context: self) do
13
+ <<~RUBY
14
+ Empty do
15
+ EmptyHeader do
16
+ EmptyMedia(variant: :icon) do
17
+ svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "1.5", stroke: "currentColor", class: "size-6") do |s|
18
+ s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155")
19
+ end
20
+ end
21
+ EmptyTitle { "No messages yet" }
22
+ EmptyDescription { "Start a conversation to see your messages here." }
23
+ end
24
+ end
25
+ RUBY
26
+ end
27
+
28
+ render Docs::VisualCodeExample.new(title: "With action", context: self) do
29
+ <<~RUBY
30
+ Empty do
31
+ EmptyHeader do
32
+ EmptyMedia(variant: :icon) do
33
+ svg(xmlns: "http://www.w3.org/2000/svg", fill: "none", viewbox: "0 0 24 24", stroke_width: "1.5", stroke: "currentColor", class: "size-6") do |s|
34
+ s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z")
35
+ end
36
+ end
37
+ EmptyTitle { "No projects" }
38
+ EmptyDescription { "Get started by creating your first project." }
39
+ end
40
+ EmptyContent do
41
+ Button { "Create project" }
42
+ end
43
+ end
44
+ RUBY
45
+ end
46
+
47
+ render Docs::VisualCodeExample.new(title: "Default media", context: self) do
48
+ <<~RUBY
49
+ Empty(class: "border-none") do
50
+ EmptyHeader do
51
+ EmptyMedia(variant: :default) do
52
+ Avatar(size: :lg) do
53
+ AvatarFallback { "RU" }
54
+ end
55
+ end
56
+ EmptyTitle { "No team members" }
57
+ EmptyDescription { "Invite your team to start collaborating." }
58
+ end
59
+ end
60
+ RUBY
61
+ end
62
+
63
+ render Components::ComponentSetup::Tabs.new(component_name: component)
64
+
65
+ # components
66
+ render Docs::ComponentsTable.new(component_files(component))
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class EmptyHeader < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "empty-header"},
14
+ class: "flex max-w-sm flex-col items-center gap-2"
15
+ }
16
+ end
17
+ end
18
+ end