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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class QrCodeComponent < ApplicationComponent
5
+ # Renders a QR code image.
6
+ #
7
+ # Usage options:
8
+ # 1. src: — pass a pre-rendered image URL (e.g. from an API or Rails asset)
9
+ # 2. Block — pass raw SVG or HTML generated by a gem such as rqrcode:
10
+ #
11
+ # <%= ui :qr_code, size: 200 do %>
12
+ # <%= RQRCode::QRCode.new("https://example.com").as_svg(viewbox: true).html_safe %>
13
+ # <% end %>
14
+
15
+ WRAPPER_CLS = "inline-flex items-center justify-center overflow-hidden rounded-lg bg-white p-3"
16
+
17
+ # src: image URL for a pre-rendered QR (renders an <img>)
18
+ # alt: accessible label for the <img> (default: "QR code")
19
+ # size: pixel dimensions applied to the <img> (ignored when using block content)
20
+ def initialize(src: nil, alt: "QR code", size: 200, **html_attrs)
21
+ @src = src
22
+ @alt = alt
23
+ @size = size
24
+ @extra_class = html_attrs.delete(:class)
25
+ @html_attrs = html_attrs
26
+ end
27
+
28
+ def call
29
+ content_tag(:div, class: cn(WRAPPER_CLS, @extra_class), **@html_attrs) do
30
+ if @src
31
+ tag.img(src: @src, alt: @alt, width: @size, height: @size,
32
+ class: "block", loading: "lazy")
33
+ else
34
+ content
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class RadioGroupComponent < ApplicationComponent
5
+ # items: [{ value:, label:, checked: (optional) }]
6
+ def initialize(name:, items: [], **html_attrs)
7
+ @name = name
8
+ @items = items
9
+ @extra_class = html_attrs.delete(:class)
10
+ @html_attrs = html_attrs
11
+ end
12
+
13
+ def call
14
+ content_tag(:div,
15
+ class: cn("grid gap-2", @extra_class),
16
+ role: "radiogroup",
17
+ **@html_attrs) do
18
+ if @items.any?
19
+ safe_join(@items.map { |item| radio_item(item) })
20
+ else
21
+ content
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def radio_item(item)
29
+ id = "#{@name}_#{item[:value].to_s.gsub(/\W/, "_")}"
30
+ content_tag(:div, class: "flex items-center gap-2") do
31
+ concat radio_input(item, id)
32
+ concat radio_label(item, id)
33
+ end
34
+ end
35
+
36
+ def radio_input(item, id)
37
+ attrs = { type: "radio", name: @name, value: item[:value], id: id,
38
+ class: "h-4 w-4 border border-primary text-primary accent-primary " \
39
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " \
40
+ "disabled:cursor-not-allowed disabled:opacity-50" }
41
+ attrs[:checked] = true if item[:checked]
42
+ content_tag(:input, nil, **attrs)
43
+ end
44
+
45
+ def radio_label(item, id)
46
+ content_tag(:label, item[:label],
47
+ for: id,
48
+ class: "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class RangeComponent < ApplicationComponent
5
+ BASE = "w-full cursor-pointer appearance-none rounded-full bg-input outline-none " \
6
+ "h-2 accent-primary " \
7
+ "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
8
+ "disabled:pointer-events-none disabled:opacity-50 " \
9
+ "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none " \
10
+ "[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary " \
11
+ "[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background " \
12
+ "[&::-webkit-slider-thumb]:shadow-xs [&::-webkit-slider-thumb]:transition-[color,box-shadow] " \
13
+ "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:appearance-none " \
14
+ "[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary " \
15
+ "[&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background " \
16
+ "[&::-moz-range-thumb]:border-solid [&::-moz-range-thumb]:shadow-xs"
17
+
18
+ # min / max / step / value: native range attributes
19
+ def initialize(min: 0, max: 100, step: 1, value: nil, **html_attrs)
20
+ @min = min
21
+ @max = max
22
+ @step = step
23
+ @value = value
24
+ @extra_class = html_attrs.delete(:class)
25
+ @html_attrs = html_attrs
26
+ end
27
+
28
+ def call
29
+ attrs = {
30
+ type: "range",
31
+ min: @min,
32
+ max: @max,
33
+ step: @step,
34
+ class: cn(BASE, @extra_class)
35
+ }
36
+ attrs[:value] = @value unless @value.nil?
37
+ content_tag(:input, nil, **attrs, **@html_attrs)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class RatingComponent < ApplicationComponent
5
+ STAR_PATH = "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
6
+
7
+ def initialize(value: 0, max: 5, **html_attrs)
8
+ @value = value.to_f.clamp(0, max)
9
+ @max = max
10
+ @filled_count = @value.round
11
+ @extra_class = html_attrs.delete(:class)
12
+ @html_attrs = html_attrs
13
+ end
14
+
15
+ def call
16
+ content_tag(:div,
17
+ stars,
18
+ class: cn("inline-flex gap-0.5", @extra_class),
19
+ role: "img",
20
+ "aria-label": "Rating: #{@value} out of #{@max}",
21
+ **@html_attrs)
22
+ end
23
+
24
+ private
25
+
26
+ def stars
27
+ @max.times.map { |i| star(i + 1 <= @filled_count) }.join.html_safe
28
+ end
29
+
30
+ def star(filled)
31
+ content_tag(:svg,
32
+ content_tag(:path, nil, d: STAR_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
33
+ class: filled ? "size-5 text-yellow-400" : "size-5 text-muted-foreground",
34
+ xmlns: "http://www.w3.org/2000/svg",
35
+ viewBox: "0 0 24 24",
36
+ fill: filled ? "currentColor" : "none",
37
+ stroke: "currentColor",
38
+ "stroke-width": "2",
39
+ "aria-hidden": "true")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { value: Number, url: String }
5
+ static targets = ["star", "input"]
6
+
7
+ preview({ params: { index } }) {
8
+ this.#render(index)
9
+ }
10
+
11
+ resetPreview() {
12
+ this.#render(this.valueValue)
13
+ }
14
+
15
+ select({ params: { index } }) {
16
+ this.valueValue = index
17
+ if (this.hasInputTarget) this.inputTarget.value = index
18
+ if (this.urlValue) this.#submit(index)
19
+ }
20
+
21
+ #render(upTo) {
22
+ this.starTargets.forEach((star, i) => {
23
+ const filled = i < upTo
24
+ star.classList.toggle("text-yellow-400", filled)
25
+ star.classList.toggle("text-muted-foreground", !filled)
26
+ const svg = star.querySelector("svg")
27
+ if (svg) svg.setAttribute("fill", filled ? "currentColor" : "none")
28
+ })
29
+ }
30
+
31
+ async #submit(value) {
32
+ const token = document.querySelector('meta[name="csrf-token"]')?.content
33
+ try {
34
+ await fetch(this.urlValue, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ "Accept": "application/json",
39
+ ...(token && { "X-CSRF-Token": token })
40
+ },
41
+ body: JSON.stringify({ value })
42
+ })
43
+ } catch {
44
+ // network errors are silently ignored — host app handles them
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class RatingInputComponent < ApplicationComponent
5
+ STAR_PATH = "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
6
+
7
+ # value: current rating (integer)
8
+ # max: total stars (default 5)
9
+ # name: hidden input name for use inside a <form>
10
+ # url: endpoint for direct AJAX submission on click
11
+ def initialize(value: 0, max: 5, name: nil, url: nil, **html_attrs)
12
+ @value = value.to_i.clamp(0, max)
13
+ @max = max
14
+ @name = name
15
+ @url = url
16
+ @extra_class = html_attrs.delete(:class)
17
+ @html_attrs = html_attrs
18
+ end
19
+
20
+ def call
21
+ content_tag(:div,
22
+ class: cn("inline-flex items-center gap-0.5", @extra_class),
23
+ data: controller_data,
24
+ **@html_attrs) do
25
+ concat stars
26
+ concat hidden_input if @name
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def controller_data
33
+ data = { controller: "rating", rating_value_value: @value }
34
+ data[:rating_url_value] = @url if @url
35
+ data
36
+ end
37
+
38
+ def stars
39
+ @max.times.map { |i| star_button(i + 1) }.join.html_safe
40
+ end
41
+
42
+ def star_button(index)
43
+ filled = index <= @value
44
+ content_tag(:button,
45
+ star_svg(filled),
46
+ type: "button",
47
+ class: cn(
48
+ "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
49
+ filled ? "text-yellow-400" : "text-muted-foreground"
50
+ ),
51
+ data: {
52
+ rating_target: "star",
53
+ action: "mouseenter->rating#preview mouseleave->rating#resetPreview click->rating#select",
54
+ rating_index_param: index
55
+ },
56
+ "aria-label": "Rate #{index} out of #{@max}")
57
+ end
58
+
59
+ def star_svg(filled)
60
+ content_tag(:svg,
61
+ content_tag(:path, nil, d: STAR_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
62
+ class: "size-6 pointer-events-none",
63
+ xmlns: "http://www.w3.org/2000/svg",
64
+ viewBox: "0 0 24 24",
65
+ fill: filled ? "currentColor" : "none",
66
+ stroke: "currentColor",
67
+ "stroke-width": "2",
68
+ "aria-hidden": "true")
69
+ end
70
+
71
+ def hidden_input
72
+ content_tag(:input, nil,
73
+ type: "hidden",
74
+ name: @name,
75
+ value: @value,
76
+ data: { rating_target: "input" })
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ResizableComponent < ApplicationComponent
5
+ # Drag-to-resize panel layout — two panels separated by a draggable handle.
6
+ #
7
+ # Usage:
8
+ # ui :resizable, direction: :horizontal do |r|
9
+ # r.with_panel(min: 20, default: 30) { left_content }
10
+ # r.with_panel { right_content }
11
+ # end
12
+
13
+ WRAPPER_CLS = "flex overflow-hidden rounded-lg border border-border"
14
+
15
+ PANEL_CLS = "overflow-auto"
16
+
17
+ HANDLE_CLS = "group relative flex items-center justify-center " \
18
+ "bg-border transition-colors hover:bg-ring/30 focus-visible:outline-none " \
19
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
20
+ "data-[direction=horizontal]:w-px data-[direction=horizontal]:cursor-col-resize " \
21
+ "data-[direction=vertical]:h-px data-[direction=vertical]:cursor-row-resize"
22
+
23
+ HANDLE_GRIP = "z-10 flex h-4 w-3 items-center justify-center rounded-sm border border-border bg-border"
24
+
25
+ renders_many :panels, "UI::ResizableComponent::PanelComponent"
26
+
27
+ # direction: :horizontal (default) | :vertical
28
+ def initialize(direction: :horizontal, **html_attrs)
29
+ @direction = direction.to_sym
30
+ @extra_class = html_attrs.delete(:class)
31
+ @html_attrs = html_attrs
32
+ end
33
+
34
+ def call
35
+ is_row = @direction == :horizontal
36
+ flex_dir = is_row ? "flex-row" : "flex-col"
37
+
38
+ content_tag(:div,
39
+ class: cn(WRAPPER_CLS, flex_dir, @extra_class),
40
+ data: {
41
+ controller: "resizable",
42
+ resizable_direction_value: @direction
43
+ },
44
+ **@html_attrs) do
45
+ panels.each_with_index do |panel, i|
46
+ concat panel
47
+ concat handle unless i == panels.size - 1
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def handle
55
+ content_tag(:div,
56
+ class: HANDLE_CLS,
57
+ "data-direction": @direction,
58
+ tabindex: "0",
59
+ role: "separator",
60
+ "aria-orientation": @direction == :horizontal ? "vertical" : "horizontal",
61
+ data: {
62
+ resizable_target: "handle",
63
+ action: "mousedown->resizable#startDrag touchstart->resizable#startDrag"
64
+ }) do
65
+ content_tag(:div, nil, class: HANDLE_GRIP)
66
+ end
67
+ end
68
+
69
+ class PanelComponent < ApplicationComponent
70
+ def initialize(min: 10, max: 90, default: nil, **html_attrs)
71
+ @min = min
72
+ @max = max
73
+ @default = default
74
+ @html_attrs = html_attrs
75
+ end
76
+
77
+ def call
78
+ style = @default ? "flex: 0 0 #{@default}%" : "flex: 1"
79
+ content_tag(:div, content,
80
+ class: ResizableComponent::PANEL_CLS,
81
+ style: style,
82
+ data: {
83
+ resizable_target: "panel",
84
+ resizable_min_param: @min,
85
+ resizable_max_param: @max
86
+ },
87
+ **@html_attrs)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["handle", "panel"]
5
+ static values = { direction: { type: String, default: "horizontal" } }
6
+
7
+ startDrag(event) {
8
+ event.preventDefault()
9
+ const isH = this.directionValue === "horizontal"
10
+ const handle = event.currentTarget
11
+ const idx = this.handleTargets.indexOf(handle)
12
+ const a = this.panelTargets[idx]
13
+ const b = this.panelTargets[idx + 1]
14
+ const container = this.element
15
+
16
+ const onMove = (e) => {
17
+ const clientPos = (e.touches ? e.touches[0] : e)[isH ? "clientX" : "clientY"]
18
+ const rect = container.getBoundingClientRect()
19
+ const total = isH ? rect.width : rect.height
20
+ const offset = clientPos - (isH ? rect.left : rect.top)
21
+ const pct = Math.min(90, Math.max(10, (offset / total) * 100))
22
+ a.style.flex = `0 0 ${pct}%`
23
+ b.style.flex = `0 0 ${100 - pct}%`
24
+ }
25
+
26
+ const onUp = () => {
27
+ document.removeEventListener("mousemove", onMove)
28
+ document.removeEventListener("mouseup", onUp)
29
+ document.removeEventListener("touchmove", onMove)
30
+ document.removeEventListener("touchend", onUp)
31
+ }
32
+
33
+ document.addEventListener("mousemove", onMove)
34
+ document.addEventListener("mouseup", onUp)
35
+ document.addEventListener("touchmove", onMove, { passive: false })
36
+ document.addEventListener("touchend", onUp)
37
+ }
38
+ }
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ScrollAreaComponent < ApplicationComponent
5
+ # Custom-styled scrollbar container using CSS pseudo-elements.
6
+ # Works without a plugin in Tailwind v4 via arbitrary property syntax.
7
+
8
+ ORIENTATIONS = {
9
+ vertical: "overflow-y-auto",
10
+ horizontal: "overflow-x-auto",
11
+ both: "overflow-auto"
12
+ }.freeze
13
+
14
+ # Thin, themed scrollbar applied to the viewport
15
+ SCROLLBAR_CLS = "[scrollbar-width:thin] " \
16
+ "[scrollbar-color:var(--color-border)_transparent] " \
17
+ "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5 " \
18
+ "[&::-webkit-scrollbar-track]:bg-transparent " \
19
+ "[&::-webkit-scrollbar-thumb]:rounded-full " \
20
+ "[&::-webkit-scrollbar-thumb]:bg-border"
21
+
22
+ # orientation: :vertical (default) | :horizontal | :both
23
+ # max_h: Tailwind max-height class, e.g. "max-h-72" (vertical / both)
24
+ # max_w: Tailwind max-width class, e.g. "max-w-sm" (horizontal / both)
25
+ def initialize(orientation: :vertical, max_h: "max-h-72", max_w: nil, **html_attrs)
26
+ @orientation = orientation.to_sym
27
+ @max_h = max_h
28
+ @max_w = max_w
29
+ @extra_class = html_attrs.delete(:class)
30
+ @html_attrs = html_attrs
31
+ end
32
+
33
+ def call
34
+ overflow = ORIENTATIONS.fetch(@orientation, ORIENTATIONS[:vertical])
35
+ content_tag(:div,
36
+ content,
37
+ class: cn(overflow, SCROLLBAR_CLS, @max_h, @max_w, @extra_class),
38
+ **@html_attrs)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SearchInputComponent < ApplicationComponent
5
+ WRAPPER = "relative w-full"
6
+ ICON_WRAP = "pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted-foreground"
7
+ SEARCH_PATH = "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
8
+ INPUT_BASE = "h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pl-9 pr-3 text-base shadow-xs " \
9
+ "transition-[color,box-shadow] outline-none " \
10
+ "placeholder:text-muted-foreground " \
11
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
12
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
13
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
14
+ "md:text-sm dark:bg-input/30"
15
+
16
+ # placeholder: default "Search…"
17
+ # name / id / value: passed through as html_attrs
18
+ def initialize(placeholder: "Search…", **html_attrs)
19
+ @placeholder = placeholder
20
+ @extra_class = html_attrs.delete(:class)
21
+ @html_attrs = html_attrs
22
+ end
23
+
24
+ def call
25
+ content_tag(:div, class: WRAPPER) do
26
+ concat icon_span
27
+ concat content_tag(:input, nil,
28
+ type: "search",
29
+ placeholder: @placeholder,
30
+ class: cn(INPUT_BASE, @extra_class),
31
+ **@html_attrs)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def icon_span
38
+ svg = content_tag(:svg,
39
+ content_tag(:path, nil, d: SEARCH_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
40
+ xmlns: "http://www.w3.org/2000/svg",
41
+ viewBox: "0 0 24 24",
42
+ fill: "none",
43
+ stroke: "currentColor",
44
+ "stroke-width": "1.5",
45
+ class: "size-4",
46
+ "aria-hidden": "true")
47
+ content_tag(:span, svg, class: ICON_WRAP)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SelectComponent < ApplicationComponent
5
+ BASE = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " \
6
+ "focus:outline-none focus:ring-1 focus:ring-ring " \
7
+ "disabled:cursor-not-allowed disabled:opacity-50"
8
+
9
+ # options: array of strings, or [value, label] pairs, or { value: label } hash
10
+ def initialize(options: [], selected: nil, include_blank: false, **html_attrs)
11
+ @options = options
12
+ @selected = selected
13
+ @include_blank = include_blank
14
+ @extra_class = html_attrs.delete(:class)
15
+ @html_attrs = html_attrs
16
+ end
17
+
18
+ def call
19
+ content_tag(:select, class: cn(BASE, @extra_class), **@html_attrs) do
20
+ safe_join(option_tags)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def option_tags
27
+ tags = []
28
+ tags << content_tag(:option, "", value: "") if @include_blank
29
+ normalized_options.each do |(val, label)|
30
+ attrs = { value: val }
31
+ attrs[:selected] = true if val.to_s == @selected.to_s
32
+ tags << content_tag(:option, label, **attrs)
33
+ end
34
+ tags
35
+ end
36
+
37
+ def normalized_options
38
+ case @options
39
+ when Hash then @options.map { |v, l| [v, l] }
40
+ when Array then @options.map { |o| o.is_a?(Array) ? o : [o, o] }
41
+ else []
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SeparatorComponent < ApplicationComponent
5
+ ORIENTATIONS = {
6
+ horizontal: "bg-border h-px w-full shrink-0",
7
+ vertical: "bg-border h-full w-px shrink-0"
8
+ }.freeze
9
+
10
+ def initialize(orientation: :horizontal, decorative: true, **html_attrs)
11
+ @orientation = orientation.to_sym
12
+ @decorative = decorative
13
+ @extra_class = html_attrs.delete(:class)
14
+ @html_attrs = html_attrs
15
+ end
16
+
17
+ def call
18
+ content_tag(:div, nil,
19
+ role: (@decorative ? "none" : "separator"),
20
+ "aria-orientation": @orientation.to_s,
21
+ class: cn(ORIENTATIONS[@orientation], @extra_class),
22
+ **@html_attrs)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class SheetComponent < ApplicationComponent
5
+ renders_one :trigger
6
+ renders_one :footer
7
+
8
+ OVERLAY = "fixed inset-0 z-50 bg-black/50"
9
+
10
+ SIDES = {
11
+ right: "fixed inset-y-0 right-0 h-full w-3/4 max-w-sm border-l",
12
+ left: "fixed inset-y-0 left-0 h-full w-3/4 max-w-sm border-r",
13
+ top: "fixed inset-x-0 top-0 h-auto max-h-[60vh] border-b",
14
+ bottom: "fixed inset-x-0 bottom-0 h-auto max-h-[60vh] border-t"
15
+ }.freeze
16
+
17
+ def initialize(title: nil, description: nil, side: :right, **html_attrs)
18
+ @title = title
19
+ @description = description
20
+ @side = side.to_sym
21
+ @extra_class = html_attrs.delete(:class)
22
+ @html_attrs = html_attrs
23
+ end
24
+
25
+ def call
26
+ content_tag(:div, data: { controller: "sheet" }, **@html_attrs) do
27
+ concat content_tag(:span, trigger, data: { action: "click->sheet#open" }, class: "contents") if trigger
28
+ concat panel
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def panel
35
+ content_tag(:div, data: { sheet_target: "panel" }, hidden: true) do
36
+ concat content_tag(:div, nil,
37
+ class: OVERLAY,
38
+ data: { action: "click->sheet#close" },
39
+ "aria-hidden": "true")
40
+ concat content_tag(:div,
41
+ class: cn("z-50 bg-background p-6 shadow-xl overflow-y-auto",
42
+ SIDES.fetch(@side, SIDES[:right]),
43
+ @extra_class),
44
+ role: "dialog",
45
+ "aria-modal": "true",
46
+ "aria-label": @title,
47
+ data: { action: "keydown.escape@window->sheet#close" }) {
48
+ concat close_button
49
+ concat header_area
50
+ concat content_tag(:div, content, class: "flex-1 text-sm")
51
+ concat content_tag(:div, footer, class: "mt-6 flex justify-end gap-2") if footer
52
+ }
53
+ end
54
+ end
55
+
56
+ def header_area
57
+ return "" if @title.nil? && @description.nil?
58
+
59
+ content_tag(:div, class: "mb-4 pr-6") do
60
+ concat content_tag(:h2, @title, class: "text-lg font-semibold leading-none tracking-tight") if @title
61
+ concat content_tag(:p, @description, class: "mt-2 text-sm text-muted-foreground") if @description
62
+ end
63
+ end
64
+
65
+ def close_button
66
+ content_tag(:button,
67
+ close_svg,
68
+ type: "button",
69
+ class: "absolute right-4 top-4 rounded-sm p-1 opacity-70 hover:opacity-100 transition-opacity",
70
+ data: { action: "click->sheet#close" },
71
+ "aria-label": "Close")
72
+ end
73
+
74
+ def close_svg
75
+ raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>')
76
+ end
77
+ end
78
+ end