ruby_ui 1.0.0.beta1 → 1.0.0.rc1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +85 -0
  4. data/lib/generators/ruby_ui/component_generator.rb +4 -40
  5. data/lib/generators/ruby_ui/dependencies.yml +74 -0
  6. data/lib/generators/ruby_ui/install/install_generator.rb +21 -22
  7. data/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb +18 -0
  8. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +156 -0
  9. data/lib/generators/ruby_ui/javascript_utils.rb +21 -0
  10. data/lib/ruby_ui/accordion/accordion_controller.js +97 -0
  11. data/lib/ruby_ui/alert/alert.rb +1 -1
  12. data/lib/ruby_ui/alert_dialog/alert_dialog_content.rb +1 -1
  13. data/lib/ruby_ui/alert_dialog/alert_dialog_controller.js +31 -0
  14. data/lib/ruby_ui/alert_dialog/alert_dialog_footer.rb +1 -1
  15. data/lib/ruby_ui/alert_dialog/alert_dialog_header.rb +1 -1
  16. data/lib/ruby_ui/breadcrumb/breadcrumb.rb +17 -0
  17. data/lib/ruby_ui/breadcrumb/breadcrumb_ellipsis.rb +39 -0
  18. data/lib/ruby_ui/breadcrumb/breadcrumb_item.rb +17 -0
  19. data/lib/ruby_ui/breadcrumb/breadcrumb_link.rb +22 -0
  20. data/lib/ruby_ui/breadcrumb/breadcrumb_list.rb +17 -0
  21. data/lib/ruby_ui/breadcrumb/breadcrumb_page.rb +19 -0
  22. data/lib/ruby_ui/breadcrumb/breadcrumb_separator.rb +38 -0
  23. data/lib/ruby_ui/calendar/calendar_controller.js +249 -0
  24. data/lib/ruby_ui/calendar/calendar_input_controller.js +8 -0
  25. data/lib/ruby_ui/carousel/carousel.rb +44 -0
  26. data/lib/ruby_ui/carousel/carousel_content.rb +23 -0
  27. data/lib/ruby_ui/carousel/carousel_controller.js +60 -0
  28. data/lib/ruby_ui/carousel/carousel_item.rb +23 -0
  29. data/lib/ruby_ui/carousel/carousel_next.rb +48 -0
  30. data/lib/ruby_ui/carousel/carousel_previous.rb +49 -0
  31. data/lib/ruby_ui/chart/chart_controller.js +103 -0
  32. data/lib/ruby_ui/checkbox/checkbox_group_controller.js +21 -0
  33. data/lib/ruby_ui/clipboard/clipboard_controller.js +54 -0
  34. data/lib/ruby_ui/collapsible/collapsible_controller.js +47 -0
  35. data/lib/ruby_ui/combobox/combobox.rb +8 -6
  36. data/lib/ruby_ui/combobox/combobox_checkbox.rb +25 -0
  37. data/lib/ruby_ui/combobox/combobox_controller.js +176 -0
  38. data/lib/ruby_ui/combobox/{combobox_empty.rb → combobox_empty_state.rb} +2 -2
  39. data/lib/ruby_ui/combobox/combobox_item.rb +9 -37
  40. data/lib/ruby_ui/combobox/combobox_list.rb +2 -11
  41. data/lib/ruby_ui/combobox/combobox_list_group.rb +20 -0
  42. data/lib/ruby_ui/combobox/combobox_popover.rb +30 -0
  43. data/lib/ruby_ui/combobox/combobox_radio.rb +26 -0
  44. data/lib/ruby_ui/combobox/combobox_search_input.rb +21 -24
  45. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +25 -0
  46. data/lib/ruby_ui/combobox/combobox_trigger.rb +25 -20
  47. data/lib/ruby_ui/command/command_controller.js +136 -0
  48. data/lib/ruby_ui/context_menu/context_menu_controller.js +144 -0
  49. data/lib/ruby_ui/dialog/dialog_content.rb +2 -2
  50. data/lib/ruby_ui/dialog/dialog_controller.js +32 -0
  51. data/lib/ruby_ui/dialog/dialog_footer.rb +1 -1
  52. data/lib/ruby_ui/dialog/dialog_header.rb +1 -1
  53. data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +120 -0
  54. data/lib/ruby_ui/form/form_field_controller.js +61 -0
  55. data/lib/ruby_ui/hover_card/hover_card_controller.js +144 -0
  56. data/lib/ruby_ui/masked_input/masked_input_controller.js +9 -0
  57. data/lib/ruby_ui/popover/popover_controller.js +107 -0
  58. data/lib/ruby_ui/progress/progress.rb +37 -0
  59. data/lib/ruby_ui/radio_button/radio_button.rb +4 -1
  60. data/lib/ruby_ui/select/select_content.rb +1 -1
  61. data/lib/ruby_ui/select/select_controller.js +171 -0
  62. data/lib/ruby_ui/select/select_item_controller.js +11 -0
  63. data/lib/ruby_ui/select/select_value.rb +1 -1
  64. data/lib/ruby_ui/separator/separator.rb +38 -0
  65. data/lib/ruby_ui/sheet/sheet_content.rb +1 -1
  66. data/lib/ruby_ui/sheet/sheet_content_controller.js +7 -0
  67. data/lib/ruby_ui/sheet/sheet_controller.js +9 -0
  68. data/lib/ruby_ui/{combobox/combobox_separator.rb → skeleton/skeleton.rb} +4 -2
  69. data/lib/ruby_ui/switch/switch.rb +24 -0
  70. data/lib/ruby_ui/tabs/tabs_controller.js +45 -0
  71. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +30 -0
  72. data/lib/ruby_ui/tooltip/tooltip_controller.js +37 -0
  73. data/lib/ruby_ui.rb +1 -1
  74. metadata +57 -11
  75. data/lib/ruby_ui/combobox/combobox_content.rb +0 -31
  76. data/lib/ruby_ui/combobox/combobox_group.rb +0 -38
  77. data/lib/ruby_ui/combobox/combobox_input.rb +0 -22
  78. data/lib/ruby_ui/combobox/combobox_value.rb +0 -27
@@ -2,21 +2,37 @@
2
2
 
3
3
  module RubyUI
4
4
  class ComboboxSearchInput < Base
5
- def initialize(placeholder:, **attrs)
5
+ def initialize(placeholder:, **)
6
6
  @placeholder = placeholder
7
- super(**attrs)
7
+ super(**)
8
8
  end
9
9
 
10
10
  def view_template
11
- input_container do
12
- search_icon
11
+ div class: "flex text-muted-foreground items-center border-b px-3" do
12
+ icon
13
13
  input(**attrs)
14
14
  end
15
15
  end
16
16
 
17
17
  private
18
18
 
19
- def search_icon
19
+ def default_attrs
20
+ {
21
+ type: "search",
22
+ class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
23
+ role: "searchbox",
24
+ placeholder: @placeholder,
25
+ data: {
26
+ ruby_ui__combobox_target: "searchInput",
27
+ action: "keyup->ruby-ui--combobox#filterItems search->ruby-ui--combobox#filterItems"
28
+ },
29
+ autocomplete: "off",
30
+ autocorrect: "off",
31
+ spellcheck: "false"
32
+ }
33
+ end
34
+
35
+ def icon
20
36
  svg(
21
37
  xmlns: "http://www.w3.org/2000/svg",
22
38
  viewbox: "0 0 24 24",
@@ -33,24 +49,5 @@ module RubyUI
33
49
  )
34
50
  end
35
51
  end
36
-
37
- def input_container(&)
38
- div(class: "flex items-center border-b px-3", &)
39
- end
40
-
41
- def default_attrs
42
- {
43
- class:
44
- "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
45
- placeholder: @placeholder,
46
- data: {
47
- action: "input->ruby-ui--combobox#onSearchInput",
48
- ruby_ui__combobox_target: "search"
49
- },
50
- autocomplete: "off",
51
- autocorrect: "off",
52
- spellcheck: false
53
- }
54
- end
55
52
  end
56
53
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ComboboxToggleAllCheckbox < Base
5
+ def view_template
6
+ input(type: "checkbox", **attrs)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ class: [
14
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary",
15
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
16
+ "disabled:cursor-not-allowed disabled:opacity-50"
17
+ ],
18
+ data: {
19
+ ruby_ui__combobox_target: "toggleAll",
20
+ action: "change->ruby-ui--combobox#toggleAllItems"
21
+ }
22
+ }
23
+ end
24
+ end
25
+ end
@@ -2,15 +2,38 @@
2
2
 
3
3
  module RubyUI
4
4
  class ComboboxTrigger < Base
5
- def view_template(&block)
5
+ def initialize(placeholder: "", **)
6
+ @placeholder = placeholder
7
+ super(**)
8
+ end
9
+
10
+ def view_template
6
11
  button(**attrs) do
7
- block&.call
12
+ span(class: "truncate", data: {ruby_ui__combobox_target: "triggerContent"}) do
13
+ @placeholder
14
+ end
8
15
  icon
9
16
  end
10
17
  end
11
18
 
12
19
  private
13
20
 
21
+ def default_attrs
22
+ {
23
+ type: "button",
24
+ class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 justify-between",
25
+ data: {
26
+ placeholder: @placeholder,
27
+ ruby_ui__combobox_target: "trigger",
28
+ action: "ruby-ui--combobox#openPopover"
29
+ },
30
+ aria: {
31
+ haspopup: "listbox",
32
+ expanded: "false"
33
+ }
34
+ }
35
+ end
36
+
14
37
  def icon
15
38
  svg(
16
39
  xmlns: "http://www.w3.org/2000/svg",
@@ -30,23 +53,5 @@ module RubyUI
30
53
  )
31
54
  end
32
55
  end
33
-
34
- def default_attrs
35
- {
36
- data: {
37
- action: "ruby-ui--combobox#onTriggerClick",
38
- ruby_ui__combobox_target: "trigger"
39
- },
40
- type: "button",
41
- role: "combobox",
42
- aria: {
43
- expanded: "false",
44
- haspopup: "listbox",
45
- autocomplete: "none",
46
- activedescendant: true
47
- },
48
- class: "flex h-full w-full items-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-[200px] justify-between"
49
- }
50
- end
51
56
  end
52
57
  end
@@ -0,0 +1,136 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import Fuse from "fuse.js";
3
+
4
+ // Connects to data-controller="ruby-ui--command"
5
+ export default class extends Controller {
6
+ static targets = ["input", "group", "item", "empty", "content"];
7
+
8
+ static values = {
9
+ open: {
10
+ type: Boolean,
11
+ default: false,
12
+ },
13
+ };
14
+
15
+ connect() {
16
+ this.inputTarget.focus();
17
+ this.searchIndex = this.buildSearchIndex();
18
+ this.toggleVisibility(this.emptyTargets, false);
19
+ this.selectedIndex = -1;
20
+
21
+ if (this.openValue) {
22
+ this.open();
23
+ }
24
+ }
25
+
26
+ open(e) {
27
+ e.preventDefault();
28
+ document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
29
+ // prevent scroll on body
30
+ document.body.classList.add("overflow-hidden");
31
+ }
32
+
33
+ dismiss() {
34
+ // allow scroll on body
35
+ document.body.classList.remove("overflow-hidden");
36
+ // remove the element
37
+ console.log("this.element", this.element);
38
+ this.element.remove();
39
+ }
40
+
41
+ filter(e) {
42
+ // Deselect any previously selected item
43
+ this.deselectAll();
44
+
45
+ const query = e.target.value.toLowerCase();
46
+ if (query.length === 0) {
47
+ this.resetVisibility();
48
+ return;
49
+ }
50
+
51
+ this.toggleVisibility(this.itemTargets, false);
52
+
53
+ const results = this.searchIndex.search(query);
54
+ results.forEach((result) =>
55
+ this.toggleVisibility([result.item.element], true),
56
+ );
57
+
58
+ this.toggleVisibility(this.emptyTargets, results.length === 0);
59
+ this.updateGroupVisibility();
60
+ }
61
+
62
+ toggleVisibility(elements, isVisible) {
63
+ elements.forEach((el) => el.classList.toggle("hidden", !isVisible));
64
+ }
65
+
66
+ updateGroupVisibility() {
67
+ this.groupTargets.forEach((group) => {
68
+ const hasVisibleItems =
69
+ group.querySelectorAll(
70
+ "[data-ruby-ui--command-target='item']:not(.hidden)",
71
+ ).length > 0;
72
+ this.toggleVisibility([group], hasVisibleItems);
73
+ });
74
+ }
75
+
76
+ resetVisibility() {
77
+ this.toggleVisibility(this.itemTargets, true);
78
+ this.toggleVisibility(this.groupTargets, true);
79
+ this.toggleVisibility(this.emptyTargets, false);
80
+ }
81
+
82
+ buildSearchIndex() {
83
+ const options = {
84
+ keys: ["value"],
85
+ threshold: 0.2,
86
+ includeMatches: true,
87
+ };
88
+ const items = this.itemTargets.map((el) => ({
89
+ value: el.dataset.value,
90
+ element: el,
91
+ }));
92
+ return new Fuse(items, options);
93
+ }
94
+
95
+ handleKeydown(e) {
96
+ const visibleItems = this.itemTargets.filter(
97
+ (item) => !item.classList.contains("hidden"),
98
+ );
99
+ if (e.key === "ArrowDown") {
100
+ e.preventDefault();
101
+ this.updateSelectedItem(visibleItems, 1);
102
+ } else if (e.key === "ArrowUp") {
103
+ e.preventDefault();
104
+ this.updateSelectedItem(visibleItems, -1);
105
+ } else if (e.key === "Enter" && this.selectedIndex !== -1) {
106
+ e.preventDefault();
107
+ visibleItems[this.selectedIndex].click();
108
+ }
109
+ }
110
+
111
+ updateSelectedItem(visibleItems, direction) {
112
+ if (this.selectedIndex >= 0) {
113
+ this.toggleAriaSelected(visibleItems[this.selectedIndex], false);
114
+ }
115
+
116
+ this.selectedIndex += direction;
117
+
118
+ // Ensure the selected index is within the bounds of the visible items
119
+ if (this.selectedIndex < 0) {
120
+ this.selectedIndex = visibleItems.length - 1;
121
+ } else if (this.selectedIndex >= visibleItems.length) {
122
+ this.selectedIndex = 0;
123
+ }
124
+
125
+ this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
126
+ }
127
+
128
+ toggleAriaSelected(element, isSelected) {
129
+ element.setAttribute("aria-selected", isSelected.toString());
130
+ }
131
+
132
+ deselectAll() {
133
+ this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));
134
+ this.selectedIndex = -1;
135
+ }
136
+ }
@@ -0,0 +1,144 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import tippy from "tippy.js";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["trigger", "content", "menuItem"];
6
+ 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
+ }
17
+
18
+ connect() {
19
+ this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later
20
+ this.initializeTippy();
21
+ this.selectedIndex = -1;
22
+ }
23
+
24
+ disconnect() {
25
+ this.destroyTippy();
26
+ }
27
+
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);
55
+ }
56
+
57
+ destroyTippy() {
58
+ if (this.tippy) {
59
+ this.tippy.destroy();
60
+ }
61
+ }
62
+
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`;
68
+ }
69
+ }
70
+
71
+ handleContextMenu(event) {
72
+ event.preventDefault();
73
+ this.open();
74
+ }
75
+
76
+ open() {
77
+ this.tippy.show();
78
+ }
79
+
80
+ close() {
81
+ this.tippy.hide();
82
+ }
83
+
84
+ handleKeydown(e) {
85
+ // return if no menu items (one line fix for)
86
+ if (this.menuItemTargets.length === 0) { return; }
87
+
88
+ if (e.key === 'ArrowDown') {
89
+ e.preventDefault();
90
+ this.updateSelectedItem(1);
91
+ } else if (e.key === 'ArrowUp') {
92
+ e.preventDefault();
93
+ this.updateSelectedItem(-1);
94
+ } else if (e.key === 'Enter' && this.selectedIndex !== -1) {
95
+ e.preventDefault();
96
+ this.menuItemTargets[this.selectedIndex].click();
97
+ }
98
+ }
99
+
100
+ updateSelectedItem(direction) {
101
+ // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index
102
+ this.menuItemTargets.forEach((item, index) => {
103
+ if (item.getAttribute('aria-selected') === 'true') {
104
+ this.selectedIndex = index;
105
+ }
106
+ });
107
+
108
+ if (this.selectedIndex >= 0) {
109
+ this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);
110
+ }
111
+
112
+ this.selectedIndex += direction;
113
+
114
+ if (this.selectedIndex < 0) {
115
+ this.selectedIndex = this.menuItemTargets.length - 1;
116
+ } else if (this.selectedIndex >= this.menuItemTargets.length) {
117
+ this.selectedIndex = 0;
118
+ }
119
+
120
+ this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);
121
+ }
122
+
123
+ toggleAriaSelected(element, isSelected) {
124
+ // Add or remove attribute
125
+ if (isSelected) {
126
+ element.setAttribute('aria-selected', 'true');
127
+ } else {
128
+ element.removeAttribute('aria-selected');
129
+ }
130
+ }
131
+
132
+ deselectAll() {
133
+ this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false));
134
+ this.selectedIndex = -1;
135
+ }
136
+
137
+ addEventListeners() {
138
+ document.addEventListener('keydown', this.boundHandleKeydown);
139
+ }
140
+
141
+ removeEventListeners() {
142
+ document.removeEventListener('keydown', this.boundHandleKeydown);
143
+ }
144
+ }
@@ -34,7 +34,7 @@ module RubyUI
34
34
  {
35
35
  data_state: "open",
36
36
  class: [
37
- "fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
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=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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
38
38
  SIZES[@size]
39
39
  ]
40
40
  }
@@ -43,7 +43,7 @@ module RubyUI
43
43
  def close_button
44
44
  button(
45
45
  type: "button",
46
- class: "absolute right-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",
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",
47
47
  data_action: "click->ruby-ui--dialog#dismiss"
48
48
  ) do
49
49
  svg(
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="dialog"
4
+ export default class extends Controller {
5
+ static targets = ["content"]
6
+ static values = {
7
+ open: {
8
+ type: Boolean,
9
+ default: false
10
+ },
11
+ }
12
+
13
+ connect() {
14
+ if (this.openValue) {
15
+ this.open()
16
+ }
17
+ }
18
+
19
+ 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')
24
+ }
25
+
26
+ dismiss() {
27
+ // allow scroll on body
28
+ document.body.classList.remove('overflow-hidden')
29
+ // remove the element
30
+ this.element.remove()
31
+ }
32
+ }
@@ -10,7 +10,7 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
13
- class: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-y-2 sm:gap-y-0"
13
+ class: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-y-2 sm:gap-y-0 rtl:space-x-reverse"
14
14
  }
15
15
  end
16
16
  end
@@ -10,7 +10,7 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
13
- class: "flex flex-col space-y-1.5 text-center sm:text-left"
13
+ class: "flex flex-col space-y-1.5 text-center sm:text-left rtl:sm:text-right"
14
14
  }
15
15
  end
16
16
  end
@@ -0,0 +1,120 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { computePosition, flip, shift, offset } from "@floating-ui/dom";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["trigger", "content", "menuItem"];
6
+ static values = {
7
+ open: {
8
+ type: Boolean,
9
+ default: false,
10
+ },
11
+ options: {
12
+ type: Object,
13
+ default: {},
14
+ },
15
+ }
16
+
17
+ connect() {
18
+ this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later
19
+ this.selectedIndex = -1;
20
+ }
21
+
22
+ #computeTooltip() {
23
+ computePosition(this.triggerTarget, this.contentTarget, {
24
+ placement: this.optionsValue.placement || "top",
25
+ middleware: [flip(), shift(), offset(8)],
26
+ }).then(({ x, y }) => {
27
+ Object.assign(this.contentTarget.style, {
28
+ left: `${x}px`,
29
+ top: `${y}px`,
30
+ });
31
+ });
32
+ }
33
+
34
+ onClickOutside(event) {
35
+ if (!this.openValue) return;
36
+ if (this.element.contains(event.target)) return;
37
+
38
+ event.preventDefault();
39
+ this.close();
40
+ }
41
+
42
+ toggle() {
43
+ this.contentTarget.classList.contains("hidden") ? this.#open() : this.close();
44
+ }
45
+
46
+ #open() {
47
+ this.openValue = true;
48
+ this.#deselectAll();
49
+ this.#addEventListeners();
50
+ this.#computeTooltip()
51
+ this.contentTarget.classList.remove("hidden");
52
+ }
53
+
54
+ close() {
55
+ this.openValue = false;
56
+ this.#removeEventListeners();
57
+ this.contentTarget.classList.add("hidden");
58
+ }
59
+
60
+ #handleKeydown(e) {
61
+ // return if no menu items (one line fix for)
62
+ if (this.menuItemTargets.length === 0) { return; }
63
+
64
+ if (e.key === 'ArrowDown') {
65
+ e.preventDefault();
66
+ this.#updateSelectedItem(1);
67
+ } else if (e.key === 'ArrowUp') {
68
+ e.preventDefault();
69
+ this.#updateSelectedItem(-1);
70
+ } else if (e.key === 'Enter' && this.selectedIndex !== -1) {
71
+ e.preventDefault();
72
+ this.menuItemTargets[this.selectedIndex].click();
73
+ }
74
+ }
75
+
76
+ #updateSelectedItem(direction) {
77
+ // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index
78
+ this.menuItemTargets.forEach((item, index) => {
79
+ if (item.getAttribute('aria-selected') === 'true') {
80
+ this.selectedIndex = index;
81
+ }
82
+ });
83
+
84
+ if (this.selectedIndex >= 0) {
85
+ this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);
86
+ }
87
+
88
+ this.selectedIndex += direction;
89
+
90
+ if (this.selectedIndex < 0) {
91
+ this.selectedIndex = this.menuItemTargets.length - 1;
92
+ } else if (this.selectedIndex >= this.menuItemTargets.length) {
93
+ this.selectedIndex = 0;
94
+ }
95
+
96
+ this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);
97
+ }
98
+
99
+ #toggleAriaSelected(element, isSelected) {
100
+ // Add or remove attribute
101
+ if (isSelected) {
102
+ element.setAttribute('aria-selected', 'true');
103
+ } else {
104
+ element.removeAttribute('aria-selected');
105
+ }
106
+ }
107
+
108
+ #deselectAll() {
109
+ this.menuItemTargets.forEach(item => this.#toggleAriaSelected(item, false));
110
+ this.selectedIndex = -1;
111
+ }
112
+
113
+ #addEventListeners() {
114
+ document.addEventListener('keydown', this.boundHandleKeydown);
115
+ }
116
+
117
+ #removeEventListeners() {
118
+ document.removeEventListener('keydown', this.boundHandleKeydown);
119
+ }
120
+ }
@@ -0,0 +1,61 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "error"];
5
+ static values = { shouldValidate: false };
6
+
7
+ connect() {
8
+ if (this.hasErrorTarget) {
9
+ if (this.errorTarget.textContent) {
10
+ this.shouldValidateValue = true;
11
+ } else {
12
+ this.errorTarget.classList.add("hidden");
13
+ }
14
+ }
15
+ }
16
+
17
+ onInvalid(error) {
18
+ error.preventDefault();
19
+
20
+ this.shouldValidateValue = true;
21
+ this.#setErrorMessage();
22
+ }
23
+
24
+ onInput() {
25
+ this.#setErrorMessage();
26
+ }
27
+
28
+ onChange() {
29
+ this.#setErrorMessage();
30
+ }
31
+
32
+ #setErrorMessage() {
33
+ if (!this.shouldValidateValue) return;
34
+
35
+ if (this.inputTarget.validity.valid) {
36
+ this.errorTarget.textContent = "";
37
+ this.errorTarget.classList.add("hidden");
38
+ } else {
39
+ this.errorTarget.textContent = this.#getValidationMessage();
40
+ this.errorTarget.classList.remove("hidden");
41
+ }
42
+ }
43
+
44
+ #getValidationMessage() {
45
+ let errorMessage;
46
+
47
+ const { validity, dataset, validationMessage } = this.inputTarget;
48
+
49
+ if (validity.tooLong) errorMessage = dataset.tooLong;
50
+ if (validity.tooShort) errorMessage = dataset.tooShort;
51
+ if (validity.badInput) errorMessage = dataset.badInput;
52
+ if (validity.typeMismatch) errorMessage = dataset.typeMismatch;
53
+ if (validity.stepMismatch) errorMessage = dataset.stepMismatch;
54
+ if (validity.valueMissing) errorMessage = dataset.valueMissing;
55
+ if (validity.rangeOverflow) errorMessage = dataset.rangeOverflow;
56
+ if (validity.rangeUnderflow) errorMessage = dataset.rangeUnderflow;
57
+ if (validity.patternMismatch) errorMessage = dataset.patternMismatch;
58
+
59
+ return errorMessage || validationMessage;
60
+ }
61
+ }