view_primitives 0.1.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 (140) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +198 -0
  5. data/lib/generators/view_primitives/add/add_generator.rb +110 -0
  6. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
  7. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
  8. data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
  9. data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
  10. data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
  11. data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
  12. data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
  13. data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
  14. data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
  15. data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
  16. data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
  17. data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
  18. data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
  19. data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
  20. data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
  21. data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
  22. data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
  23. data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
  24. data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
  25. data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
  26. data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
  27. data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
  28. data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
  29. data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
  30. data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
  31. data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
  32. data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
  33. data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
  34. data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
  35. data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
  36. data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
  37. data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
  38. data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
  39. data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
  40. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
  41. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
  42. data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
  43. data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
  44. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
  45. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
  46. data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
  47. data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
  48. data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
  49. data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
  50. data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
  51. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
  52. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
  53. data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
  54. data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
  55. data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
  56. data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
  57. data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
  58. data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
  59. data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
  60. data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
  61. data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
  62. data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
  63. data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
  64. data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
  65. data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
  66. data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
  67. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
  68. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
  69. data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
  70. data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
  71. data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
  72. data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
  73. data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
  74. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
  75. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
  76. data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
  77. data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
  78. data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
  79. data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
  80. data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
  81. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
  82. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
  83. data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
  84. data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
  85. data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
  86. data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
  87. data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
  88. data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
  89. data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
  90. data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
  91. data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
  92. data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
  93. data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
  94. data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
  95. data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
  96. data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
  97. data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
  98. data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
  99. data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
  100. data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
  101. data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
  102. data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
  103. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
  104. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
  105. data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
  106. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
  107. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
  108. data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
  109. data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
  110. data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
  111. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
  112. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
  113. data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
  114. data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
  115. data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
  116. data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
  117. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
  118. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
  119. data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
  120. data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
  121. data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
  122. data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
  123. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
  124. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
  125. data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
  126. data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
  127. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
  128. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
  129. data/lib/generators/view_primitives/components.rb +62 -0
  130. data/lib/generators/view_primitives/detector.rb +43 -0
  131. data/lib/generators/view_primitives/install/install_generator.rb +65 -0
  132. data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
  133. data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
  134. data/lib/generators/view_primitives/list/list_generator.rb +25 -0
  135. data/lib/view_primitives/class_helper.rb +11 -0
  136. data/lib/view_primitives/component_helper.rb +20 -0
  137. data/lib/view_primitives/railtie.rb +21 -0
  138. data/lib/view_primitives/version.rb +5 -0
  139. data/lib/view_primitives.rb +12 -0
  140. metadata +267 -0
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ open() {
7
+ this.panelTarget.hidden = false
8
+ document.body.style.overflow = "hidden"
9
+ }
10
+
11
+ close() {
12
+ this.panelTarget.hidden = true
13
+ document.body.style.overflow = ""
14
+ }
15
+ }
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SidebarComponent < ApplicationComponent
5
+ # Collapsible application sidebar with nav groups.
6
+ #
7
+ # Usage:
8
+ # ui :sidebar do |s|
9
+ # s.with_group(label: "Main") do |g|
10
+ # g.with_item(label: "Dashboard", href: "/", icon: :home, active: true)
11
+ # g.with_item(label: "Settings", href: "/settings", icon: :settings)
12
+ # end
13
+ # end
14
+
15
+ RAIL_CLS = "group peer fixed inset-y-0 left-0 z-30 flex h-full flex-col " \
16
+ "border-r border-border bg-background transition-[width] duration-300 " \
17
+ "data-[collapsed=true]:w-16 data-[collapsed=false]:w-64"
18
+
19
+ HEADER_CLS = "flex h-14 items-center justify-between border-b border-border px-4"
20
+
21
+ TOGGLE_CLS = "inline-flex size-8 items-center justify-center rounded-md " \
22
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
23
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
24
+
25
+ NAV_CLS = "flex-1 overflow-y-auto px-2 py-3"
26
+
27
+ GROUP_LABEL = "mb-1 px-3 text-xs font-medium uppercase tracking-wide text-muted-foreground " \
28
+ "transition-opacity group-data-[collapsed=true]:opacity-0 group-data-[collapsed=true]:h-0 " \
29
+ "group-data-[collapsed=true]:overflow-hidden group-data-[collapsed=true]:mb-0"
30
+
31
+ ITEM_CLS = "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors " \
32
+ "overflow-hidden " \
33
+ "group-data-[collapsed=true]:justify-center group-data-[collapsed=true]:gap-0 " \
34
+ "hover:bg-accent hover:text-accent-foreground " \
35
+ "aria-[current]:bg-accent aria-[current]:text-foreground aria-[current]:font-semibold " \
36
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none"
37
+
38
+ ITEM_LABEL = "transition-[opacity,width] group-data-[collapsed=true]:w-0 " \
39
+ "group-data-[collapsed=true]:opacity-0 group-data-[collapsed=true]:overflow-hidden " \
40
+ "whitespace-nowrap"
41
+
42
+ renders_many :groups, "UI::SidebarComponent::GroupComponent"
43
+ renders_many :items, "UI::SidebarComponent::ItemComponent"
44
+
45
+ # brand: text shown in the header
46
+ # collapsed: initial collapsed state (default: false)
47
+ def initialize(brand: nil, collapsed: false, **html_attrs)
48
+ @brand = brand
49
+ @collapsed = collapsed
50
+ @extra_class = html_attrs.delete(:class)
51
+ @html_attrs = html_attrs
52
+ end
53
+
54
+ def call
55
+ content_tag(:aside,
56
+ class: cn(RAIL_CLS, @extra_class),
57
+ "data-collapsed": @collapsed.to_s,
58
+ data: { controller: "sidebar" },
59
+ **@html_attrs) do
60
+ concat header
61
+ concat nav_body
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def header
68
+ content_tag(:div, class: HEADER_CLS) do
69
+ concat content_tag(:span, @brand,
70
+ class: "truncate font-semibold group-data-[collapsed=true]:hidden") if @brand
71
+ concat toggle_btn
72
+ end
73
+ end
74
+
75
+ def toggle_btn
76
+ content_tag(:button, type: "button",
77
+ class: TOGGLE_CLS,
78
+ "aria-label": "Toggle sidebar",
79
+ data: { action: "click->sidebar#toggle" }) { chevron_icon }
80
+ end
81
+
82
+ def nav_body
83
+ content_tag(:nav, class: NAV_CLS) do
84
+ concat safe_join(groups) if groups.any?
85
+ concat content_tag(:div, safe_join(items), class: "space-y-0.5") if items.any?
86
+ concat content if content?
87
+ end
88
+ end
89
+
90
+ def chevron_icon
91
+ content_tag(:svg,
92
+ content_tag(:path, nil, d: "m15 18-6-6 6-6", "stroke-linecap": "round", "stroke-linejoin": "round"),
93
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
94
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
95
+ class: "size-4 transition-transform group-data-[collapsed=true]:rotate-180",
96
+ "aria-hidden": "true")
97
+ end
98
+
99
+ class GroupComponent < ApplicationComponent
100
+ renders_many :items, "UI::SidebarComponent::ItemComponent"
101
+
102
+ def initialize(label: nil, **html_attrs)
103
+ @label = label
104
+ @html_attrs = html_attrs
105
+ end
106
+
107
+ def call
108
+ content_tag(:div, class: "mb-4", **@html_attrs) do
109
+ concat content_tag(:p, @label, class: SidebarComponent::GROUP_LABEL) if @label
110
+ concat content_tag(:div, safe_join(items), class: "space-y-0.5")
111
+ concat content if content?
112
+ end
113
+ end
114
+ end
115
+
116
+ class ItemComponent < ApplicationComponent
117
+ ICONS = {
118
+ home: "M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M9 22V12h6v10",
119
+ dashboard: "M3 3h7v9H3z M14 3h7v5h-7z M14 12h7v9h-7z M3 16h7v6H3z",
120
+ folder: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
121
+ tasks: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
122
+ settings: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z",
123
+ users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
124
+ chart: "M18 20V10 M12 20V4 M6 20v-6",
125
+ mail: "M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z M22 6l-10 7L2 6",
126
+ bell: "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9 M13.73 21a2 2 0 0 1-3.46 0",
127
+ credit_card: "M1 4h22v16H1z M1 10h22",
128
+ logout: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9",
129
+ }.freeze
130
+
131
+ def initialize(label:, href: "#", active: false, icon: nil, **html_attrs)
132
+ @label = label
133
+ @href = href
134
+ @active = active
135
+ @icon = icon
136
+ @html_attrs = html_attrs
137
+ end
138
+
139
+ def call
140
+ content_tag(:a,
141
+ href: @href,
142
+ class: SidebarComponent::ITEM_CLS,
143
+ "aria-current": (@active ? "page" : nil),
144
+ **@html_attrs) do
145
+ concat icon_or_fallback
146
+ concat content_tag(:span, @label, class: SidebarComponent::ITEM_LABEL)
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def icon_or_fallback
153
+ path = @icon && ICONS[@icon.to_sym]
154
+ if path
155
+ content_tag(:svg,
156
+ content_tag(:path, nil, d: path, "stroke-linecap": "round", "stroke-linejoin": "round"),
157
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
158
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
159
+ class: "size-4 shrink-0",
160
+ "aria-hidden": "true")
161
+ else
162
+ content_tag(:span, @label[0],
163
+ class: "hidden size-5 shrink-0 items-center justify-center rounded text-xs font-semibold group-data-[collapsed=true]:flex",
164
+ "aria-hidden": "true")
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,11 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ toggle() {
5
+ const collapsed = this.element.dataset.collapsed === "true"
6
+ this.element.dataset.collapsed = String(!collapsed)
7
+ }
8
+
9
+ open() { this.element.dataset.collapsed = "false" }
10
+ close() { this.element.dataset.collapsed = "true" }
11
+ }
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SkeletonComponent < ApplicationComponent
5
+ BASE = "bg-accent animate-pulse rounded-md"
6
+
7
+ def initialize(**html_attrs)
8
+ @extra_class = html_attrs.delete(:class)
9
+ @html_attrs = html_attrs
10
+ end
11
+
12
+ def call
13
+ content_tag(:div, nil, class: cn(BASE, @extra_class), **@html_attrs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SpeedDialComponent < ApplicationComponent
5
+ # Floating action button that expands into a stack of sub-action buttons.
6
+ #
7
+ # Usage:
8
+ # ui :speed_dial, icon: :plus do |dial|
9
+ # dial.with_action(label: "New document", icon: :file, href: "/docs/new")
10
+ # dial.with_action(label: "Upload", icon: :upload, data: { action: "..." })
11
+ # end
12
+
13
+ FAB_CLS = "relative z-50 inline-flex size-14 items-center justify-center rounded-full " \
14
+ "bg-primary text-primary-foreground shadow-lg transition-transform " \
15
+ "hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[3px] " \
16
+ "focus-visible:ring-ring/50 active:scale-95"
17
+
18
+ PANEL_CLS = "absolute bottom-16 right-0 flex flex-col-reverse items-end gap-2"
19
+
20
+ ACTION_CLS = "flex items-center gap-2 rounded-full bg-background px-4 py-2 text-sm font-medium " \
21
+ "shadow-md border border-border transition-all " \
22
+ "hover:bg-accent hover:text-accent-foreground " \
23
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none " \
24
+ "whitespace-nowrap"
25
+
26
+ PLUS_PATH = "M12 5v14M5 12h14"
27
+
28
+ renders_many :actions, "UI::SpeedDialComponent::ActionComponent"
29
+
30
+ # position: :bottom_right (default) | :bottom_left | :bottom_center
31
+ def initialize(position: :bottom_right, **html_attrs)
32
+ @position = position.to_sym
33
+ @extra_class = html_attrs.delete(:class)
34
+ @html_attrs = html_attrs
35
+ end
36
+
37
+ def call
38
+ position_cls = {
39
+ bottom_right: "fixed bottom-6 right-6",
40
+ bottom_left: "fixed bottom-6 left-6",
41
+ bottom_center: "fixed bottom-6 left-1/2 -translate-x-1/2"
42
+ }.fetch(@position, "fixed bottom-6 right-6")
43
+
44
+ content_tag(:div,
45
+ class: cn("relative", position_cls, @extra_class),
46
+ data: {
47
+ controller: "speed-dial",
48
+ action: "click@document->speed-dial#closeOnClickOutside"
49
+ },
50
+ **@html_attrs) do
51
+ concat action_panel
52
+ concat fab_button
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def fab_button
59
+ content_tag(:button,
60
+ type: "button",
61
+ class: FAB_CLS,
62
+ "aria-expanded": "false",
63
+ "aria-label": "Open actions",
64
+ data: {
65
+ speed_dial_target: "fab",
66
+ action: "click->speed-dial#toggle"
67
+ }) do
68
+ plus_icon
69
+ end
70
+ end
71
+
72
+ def action_panel
73
+ content_tag(:div,
74
+ class: PANEL_CLS,
75
+ hidden: true,
76
+ data: { speed_dial_target: "panel" }) do
77
+ safe_join(actions)
78
+ end
79
+ end
80
+
81
+ def plus_icon
82
+ content_tag(:svg,
83
+ content_tag(:path, nil, d: PLUS_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
84
+ xmlns: "http://www.w3.org/2000/svg",
85
+ viewBox: "0 0 24 24",
86
+ fill: "none",
87
+ stroke: "currentColor",
88
+ "stroke-width": "2",
89
+ class: "size-6 transition-transform duration-200",
90
+ "aria-hidden": "true",
91
+ data: { speed_dial_target: "icon" })
92
+ end
93
+
94
+ class ActionComponent < ApplicationComponent
95
+ def initialize(label:, href: nil, icon: nil, **html_attrs)
96
+ @label = label
97
+ @href = href
98
+ @icon = icon
99
+ @html_attrs = html_attrs
100
+ end
101
+
102
+ def call
103
+ tag_name = @href ? :a : :button
104
+ attrs = { class: SpeedDialComponent::ACTION_CLS, **@html_attrs }
105
+ attrs[:href] = @href if @href
106
+ attrs[:type] = "button" if tag_name == :button
107
+ content_tag(tag_name, @label, **attrs)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,22 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["fab", "panel", "icon"]
5
+
6
+ toggle() {
7
+ const open = this.panelTarget.hidden
8
+ this._setOpen(open)
9
+ }
10
+
11
+ closeOnClickOutside(event) {
12
+ if (!this.element.contains(event.target)) this._setOpen(false)
13
+ }
14
+
15
+ _setOpen(open) {
16
+ this.panelTarget.hidden = !open
17
+ this.fabTarget.setAttribute("aria-expanded", String(open))
18
+ if (this.hasIconTarget) {
19
+ this.iconTarget.style.transform = open ? "rotate(45deg)" : ""
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SpinnerComponent < ApplicationComponent
5
+ BASE = "inline-block animate-spin rounded-full border-2 border-current border-t-transparent"
6
+
7
+ SIZES = {
8
+ sm: "size-4",
9
+ default: "size-6",
10
+ lg: "size-10"
11
+ }.freeze
12
+
13
+ def initialize(size: :default, **html_attrs)
14
+ @size = size.to_sym
15
+ @extra_class = html_attrs.delete(:class)
16
+ @html_attrs = html_attrs
17
+ end
18
+
19
+ def call
20
+ content_tag(:span,
21
+ content_tag(:span, "Loading...", class: "sr-only"),
22
+ class: cn(BASE, SIZES.fetch(@size, SIZES[:default]), @extra_class),
23
+ role: "status",
24
+ **@html_attrs)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class StepperComponent < ApplicationComponent
5
+ # steps: [{ label:, description: (optional), status: :complete | :current | :pending }]
6
+ def initialize(steps:, orientation: :horizontal, **html_attrs)
7
+ @steps = steps
8
+ @orientation = orientation.to_sym
9
+ @extra_class = html_attrs.delete(:class)
10
+ @html_attrs = html_attrs
11
+ end
12
+
13
+ def call
14
+ wrapper_class = @orientation == :vertical \
15
+ ? "flex flex-col gap-0" \
16
+ : "flex items-start gap-0"
17
+
18
+ content_tag(:ol,
19
+ class: cn(wrapper_class, @extra_class),
20
+ "aria-label": "Progress",
21
+ **@html_attrs) do
22
+ safe_join(@steps.each_with_index.map { |step, i| step_item(step, i) })
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def step_item(step, index)
29
+ is_last = index == @steps.size - 1
30
+ status = step.fetch(:status, :pending).to_sym
31
+
32
+ if @orientation == :vertical
33
+ vertical_item(step, status, is_last)
34
+ else
35
+ horizontal_item(step, status, is_last)
36
+ end
37
+ end
38
+
39
+ def horizontal_item(step, status, is_last)
40
+ content_tag(:li, class: "flex items-center #{is_last ? '' : 'flex-1'}") do
41
+ concat step_circle(status, step[:label])
42
+ concat content_tag(:p, step[:label], class: cn("ml-2 text-sm font-medium whitespace-nowrap", label_color(status))) unless step[:label].nil?
43
+ concat connector(:horizontal, status) unless is_last
44
+ end
45
+ end
46
+
47
+ def vertical_item(step, status, is_last)
48
+ content_tag(:li, class: "relative flex gap-4") do
49
+ concat content_tag(:div, class: "flex flex-col items-center") {
50
+ concat step_circle(status, step[:label])
51
+ concat connector(:vertical, status) unless is_last
52
+ }
53
+ concat content_tag(:div, class: "pb-6 pt-0.5 min-w-0") {
54
+ concat content_tag(:p, step[:label], class: cn("text-sm font-medium", label_color(status)))
55
+ concat content_tag(:p, step[:description], class: "mt-0.5 text-xs text-muted-foreground") if step[:description]
56
+ }
57
+ end
58
+ end
59
+
60
+ def step_circle(status, label)
61
+ base = "flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold border-2"
62
+ case status
63
+ when :complete
64
+ content_tag(:span, check_svg,
65
+ class: cn(base, "border-primary bg-primary text-primary-foreground"),
66
+ "aria-label": "Completed")
67
+ when :current
68
+ content_tag(:span, "●",
69
+ class: cn(base, "border-primary text-primary"),
70
+ "aria-current": "step")
71
+ else
72
+ content_tag(:span, "○",
73
+ class: cn(base, "border-muted-foreground text-muted-foreground"),
74
+ "aria-label": "Pending")
75
+ end
76
+ end
77
+
78
+ def connector(direction, status)
79
+ filled = status == :complete
80
+ if direction == :horizontal
81
+ content_tag(:div, nil, class: cn("h-0.5 flex-1 mx-2", filled ? "bg-primary" : "bg-border"))
82
+ else
83
+ content_tag(:div, nil, class: cn("w-0.5 flex-1 my-1", filled ? "bg-primary" : "bg-border"))
84
+ end
85
+ end
86
+
87
+ def label_color(status)
88
+ case status
89
+ when :complete then "text-foreground"
90
+ when :current then "text-primary"
91
+ else "text-muted-foreground"
92
+ end
93
+ end
94
+
95
+ def check_svg
96
+ raw('<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>')
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SwitchComponent < ApplicationComponent
5
+ # sr-only input, track label, and thumb are all siblings inside a relative wrapper
6
+ # so every element can use peer-checked: / peer-focus-visible: / peer-disabled: directly.
7
+ WRAPPER = "relative inline-flex h-[1.15rem] w-8 shrink-0"
8
+ TRACK = "absolute inset-0 cursor-pointer rounded-full border border-transparent shadow-xs " \
9
+ "transition-all bg-input peer-checked:bg-primary dark:bg-input/80 " \
10
+ "peer-focus-visible:border-ring peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 " \
11
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
12
+ THUMB = "pointer-events-none absolute inset-y-0 left-[1px] my-auto z-10 block size-4 rounded-full " \
13
+ "bg-background ring-0 transition-transform " \
14
+ "translate-x-0 peer-checked:translate-x-[calc(100%-2px)]"
15
+
16
+ def initialize(label: nil, checked: false, **html_attrs)
17
+ @label = label
18
+ @checked = checked
19
+ @id = html_attrs[:id] || html_attrs[:name]&.gsub(/[\[\]]+/, "_") || "switch_#{object_id}"
20
+ @extra_class = html_attrs.delete(:class)
21
+ @html_attrs = html_attrs
22
+ end
23
+
24
+ def call
25
+ content_tag(:div, class: cn("inline-flex items-center gap-2", @extra_class)) do
26
+ concat switch_widget
27
+ concat text_label if @label
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def switch_widget
34
+ content_tag(:div, class: WRAPPER) do
35
+ input_attrs = { type: "checkbox", id: @id, class: "peer sr-only", role: "switch",
36
+ "aria-checked": @checked.to_s }
37
+ input_attrs[:checked] = true if @checked
38
+ input_attrs.merge!(@html_attrs)
39
+ concat content_tag(:input, nil, **input_attrs)
40
+ concat content_tag(:label, nil, for: @id, class: TRACK)
41
+ concat content_tag(:span, nil, class: THUMB, "aria-hidden": "true")
42
+ end
43
+ end
44
+
45
+ def text_label
46
+ content_tag(:label, @label,
47
+ for: @id,
48
+ class: "cursor-pointer text-sm font-medium leading-none")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,50 @@
1
+ <%= tag.div(
2
+ class: cn("w-full", @extra_class),
3
+ data: { controller: "tabs", tabs_index_value: @default_index },
4
+ **@html_attrs
5
+ ) do %>
6
+ <div role="tablist"
7
+ class="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
8
+ <% @items_data.each_with_index do |item, i| %>
9
+ <button type="button"
10
+ role="tab"
11
+ data-action="click->tabs#select"
12
+ data-tabs-index-param="<%= i %>"
13
+ data-tabs-target="trigger"
14
+ data-state="<%= i == @default_index ? 'active' : 'inactive' %>"
15
+ aria-selected="<%= i == @default_index %>"
16
+ class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow">
17
+ <%= item[:title] %>
18
+ </button>
19
+ <% end %>
20
+ <% tabs.each_with_index do |tab, i| %>
21
+ <button type="button"
22
+ role="tab"
23
+ data-action="click->tabs#select"
24
+ data-tabs-index-param="<%= @items_data.size + i %>"
25
+ data-tabs-target="trigger"
26
+ data-state="<%= (@items_data.size + i) == @default_index ? 'active' : 'inactive' %>"
27
+ aria-selected="<%= (@items_data.size + i) == @default_index %>"
28
+ class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow">
29
+ <%= tab.title %>
30
+ </button>
31
+ <% end %>
32
+ </div>
33
+
34
+ <% @items_data.each_with_index do |item, i| %>
35
+ <div role="tabpanel"
36
+ data-tabs-target="panel"
37
+ class="mt-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
38
+ <%= i != @default_index ? "hidden" : "" %>>
39
+ <%= item[:content] %>
40
+ </div>
41
+ <% end %>
42
+ <% tabs.each_with_index do |tab, i| %>
43
+ <div role="tabpanel"
44
+ data-tabs-target="panel"
45
+ class="mt-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
46
+ <%= (@items_data.size + i) != @default_index ? "hidden" : "" %>>
47
+ <%= tab.call %>
48
+ </div>
49
+ <% end %>
50
+ <% end %>
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class TabsComponent < ApplicationComponent
5
+ renders_many :tabs, "UI::TabsItemComponent"
6
+
7
+ # items: array shorthand — [{ title:, content: }]
8
+ # default_index: which tab is open on load (0-based)
9
+ def initialize(items: nil, default_index: 0, **html_attrs)
10
+ @items_data = Array(items)
11
+ @default_index = default_index.to_i
12
+ @extra_class = html_attrs.delete(:class)
13
+ @html_attrs = html_attrs
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { index: Number }
5
+ static targets = ["trigger", "panel"]
6
+
7
+ connect() {
8
+ this.#render(this.indexValue)
9
+ }
10
+
11
+ select({ params: { index } }) {
12
+ this.indexValue = index
13
+ this.#render(index)
14
+ }
15
+
16
+ #render(active) {
17
+ this.triggerTargets.forEach((trigger, i) => {
18
+ const isActive = i === active
19
+ trigger.dataset.state = isActive ? "active" : "inactive"
20
+ trigger.setAttribute("aria-selected", isActive)
21
+ })
22
+ this.panelTargets.forEach((panel, i) => {
23
+ panel.hidden = i !== active
24
+ })
25
+ }
26
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class TabsItemComponent < ApplicationComponent
5
+ attr_reader :title
6
+
7
+ def initialize(title:)
8
+ @title = title
9
+ end
10
+
11
+ def call
12
+ content
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class TextareaComponent < ApplicationComponent
5
+ BASE = "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 " \
6
+ "text-base shadow-xs transition-[color,box-shadow] outline-none " \
7
+ "placeholder:text-muted-foreground " \
8
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
9
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
10
+ "disabled:cursor-not-allowed disabled:opacity-50 " \
11
+ "md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40"
12
+
13
+ def initialize(**html_attrs)
14
+ @extra_class = html_attrs.delete(:class)
15
+ @html_attrs = html_attrs
16
+ end
17
+
18
+ def call
19
+ content_tag(:textarea, content,
20
+ class: cn(BASE, @extra_class),
21
+ **@html_attrs)
22
+ end
23
+ end
24
+ end