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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CarouselComponent < ApplicationComponent
5
+ # Scrollable carousel with prev/next controls and optional dot indicators.
6
+ #
7
+ # Usage:
8
+ # ui :carousel do |c|
9
+ # c.with_slide { image_tag "slide1.jpg" }
10
+ # c.with_slide { image_tag "slide2.jpg" }
11
+ # end
12
+
13
+ TRACK_CLS = "flex"
14
+ SLIDE_CLS = "min-w-full shrink-0"
15
+
16
+ BTN_BASE = "absolute top-1/2 z-10 -translate-y-1/2 inline-flex size-9 items-center justify-center " \
17
+ "rounded-full bg-background/80 backdrop-blur border border-border shadow-sm " \
18
+ "transition hover:bg-background disabled:opacity-40 " \
19
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none"
20
+ BTN_PREV = "left-2"
21
+ BTN_NEXT = "right-2"
22
+
23
+ DOTS_CLS = "mt-3 flex justify-center gap-1.5"
24
+ DOT_CLS = "size-2 rounded-full bg-muted-foreground/40 transition " \
25
+ "data-[active=true]:bg-primary data-[active=true]:w-4"
26
+
27
+ CHEVRON_L = "m15 18-6-6 6-6"
28
+ CHEVRON_R = "m9 18 6-6-6-6"
29
+
30
+ renders_many :slides
31
+
32
+ # loop: wrap around at the ends (default: true)
33
+ # indicators: show dot indicators (default: true)
34
+ # autoplay: interval in ms, 0 to disable (default: 0)
35
+ def initialize(loop: true, indicators: true, autoplay: 0, **html_attrs)
36
+ @loop = loop
37
+ @indicators = indicators
38
+ @autoplay = autoplay.to_i
39
+ @extra_class = html_attrs.delete(:class)
40
+ @html_attrs = html_attrs
41
+ end
42
+
43
+ def call
44
+ content_tag(:div,
45
+ class: cn("relative overflow-hidden", @extra_class),
46
+ data: {
47
+ controller: "carousel",
48
+ carousel_loop_value: @loop,
49
+ carousel_autoplay_value: @autoplay
50
+ },
51
+ **@html_attrs) do
52
+ concat track
53
+ concat prev_btn
54
+ concat next_btn
55
+ concat dots if @indicators && slides.size > 1
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def track
62
+ content_tag(:div, class: TRACK_CLS, data: { carousel_target: "track" }) do
63
+ safe_join(slides.map { |s| content_tag(:div, s, class: SLIDE_CLS) })
64
+ end
65
+ end
66
+
67
+ def prev_btn
68
+ content_tag(:button, type: "button",
69
+ class: cn(BTN_BASE, BTN_PREV),
70
+ "aria-label": "Previous slide",
71
+ data: { action: "click->carousel#prev" }) { chevron(CHEVRON_L) }
72
+ end
73
+
74
+ def next_btn
75
+ content_tag(:button, type: "button",
76
+ class: cn(BTN_BASE, BTN_NEXT),
77
+ "aria-label": "Next slide",
78
+ data: { action: "click->carousel#next" }) { chevron(CHEVRON_R) }
79
+ end
80
+
81
+ def dots
82
+ content_tag(:div, class: DOTS_CLS, data: { carousel_target: "dots" }) do
83
+ safe_join(slides.each_with_index.map { |_, i|
84
+ content_tag(:button, nil,
85
+ type: "button",
86
+ class: DOT_CLS,
87
+ "aria-label": "Go to slide #{i + 1}",
88
+ "data-active": i.zero?.to_s,
89
+ data: { action: "click->carousel#goTo", carousel_index_param: i })
90
+ })
91
+ end
92
+ end
93
+
94
+ def chevron(path)
95
+ content_tag(:svg,
96
+ content_tag(:path, nil, d: path, "stroke-linecap": "round", "stroke-linejoin": "round"),
97
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
98
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
99
+ class: "size-4", "aria-hidden": "true")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["track", "dots"]
5
+ static values = { loop: { type: Boolean, default: true }, autoplay: { type: Number, default: 0 } }
6
+
7
+ connect() {
8
+ this._index = 0
9
+ this._count = this.trackTarget.children.length
10
+ if (this.autoplayValue > 0) {
11
+ this._timer = setInterval(() => this.next(), this.autoplayValue)
12
+ }
13
+ }
14
+
15
+ disconnect() {
16
+ clearInterval(this._timer)
17
+ }
18
+
19
+ next() {
20
+ this._go(this._index + 1)
21
+ }
22
+
23
+ prev() {
24
+ this._go(this._index - 1)
25
+ }
26
+
27
+ goTo({ params: { index } }) {
28
+ this._go(index)
29
+ }
30
+
31
+ _go(index) {
32
+ if (this.loopValue) {
33
+ index = ((index % this._count) + this._count) % this._count
34
+ } else {
35
+ index = Math.max(0, Math.min(index, this._count - 1))
36
+ }
37
+ this._index = index
38
+ this.trackTarget.style.transform = `translateX(-${index * 100}%)`
39
+ this._updateDots()
40
+ }
41
+
42
+ _updateDots() {
43
+ if (!this.hasDotsTarget) return
44
+ Array.from(this.dotsTarget.children).forEach((dot, i) => {
45
+ dot.dataset.active = String(i === this._index)
46
+ })
47
+ }
48
+ }
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ChartComponent < ApplicationComponent
5
+ # Chart wrapper — renders a <canvas> wired to chart_controller.js.
6
+ # chart_controller.js is an adapter for Chart.js; the library itself is
7
+ # NOT bundled — add it to your importmap before use:
8
+ #
9
+ # # config/importmap.rb
10
+ # pin "chart.js", to: "https://esm.sh/chart.js@4"
11
+ #
12
+ # Usage:
13
+ # ui :chart, type: :bar, labels: ["Jan", "Feb", "Mar"],
14
+ # datasets: [
15
+ # { label: "Revenue", data: [100, 200, 150] },
16
+ # { label: "Costs", data: [80, 140, 110], background_color: "#ef4444" }
17
+ # ]
18
+ #
19
+ # Options:
20
+ # type: :bar | :line | :pie | :doughnut | :radar | :polarArea (default: :bar)
21
+ # labels: array of x-axis labels
22
+ # datasets: array of dataset hashes; snake_case keys are camelized for Chart.js
23
+ # (e.g. background_color: → backgroundColor:)
24
+ # options: hash merged into Chart.js `options` (e.g. { responsive: false })
25
+
26
+ TYPES = %w[bar line pie doughnut radar polarArea].freeze
27
+
28
+ WRAPPER_CLS = "relative"
29
+
30
+ def initialize(type: :bar, labels: [], datasets: [], options: {}, **html_attrs)
31
+ @type = TYPES.include?(type.to_s) ? type.to_s : "bar"
32
+ @labels = labels
33
+ @datasets = datasets
34
+ @chart_options = options
35
+ @extra_class = html_attrs.delete(:class)
36
+ @html_attrs = html_attrs
37
+ end
38
+
39
+ def call
40
+ content_tag(:div, class: cn(WRAPPER_CLS, @extra_class)) do
41
+ tag.canvas(
42
+ data: {
43
+ controller: "chart",
44
+ chart_type_value: @type,
45
+ chart_config_value: config_json
46
+ },
47
+ **@html_attrs
48
+ )
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def config_json
55
+ ds = @datasets.map { |d| camelize_keys(d).compact }
56
+ {labels: @labels, datasets: ds, options: @chart_options}.to_json
57
+ end
58
+
59
+ def camelize_keys(hash)
60
+ hash.transform_keys { |k| k.to_s.camelize(:lower) }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ // Requires Chart.js — add to your importmap before use:
2
+ // pin "chart.js", to: "https://esm.sh/chart.js@4"
3
+ import { Controller } from "@hotwired/stimulus"
4
+ import { Chart, registerables } from "chart.js"
5
+
6
+ Chart.register(...registerables)
7
+
8
+ export default class extends Controller {
9
+ static values = {
10
+ type: { type: String, default: "bar" },
11
+ config: { type: String, default: "{}" }
12
+ }
13
+
14
+ #chart = null
15
+
16
+ connect() {
17
+ const { labels, datasets, options = {} } = JSON.parse(this.configValue)
18
+ this.#chart = new Chart(this.element, {
19
+ type: this.typeValue,
20
+ data: { labels, datasets },
21
+ options: { responsive: true, maintainAspectRatio: true, ...options }
22
+ })
23
+ }
24
+
25
+ disconnect() {
26
+ this.#chart?.destroy()
27
+ this.#chart = null
28
+ }
29
+ }
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ChatBubbleComponent < ApplicationComponent
5
+ # sent: true → right-aligned, primary-colored bubble
6
+ # sent: false → left-aligned, muted bubble (default)
7
+
8
+ BUBBLE_BASE = "max-w-[80%] rounded-2xl px-4 py-2 text-sm leading-relaxed"
9
+ BUBBLE_SENT = "bg-primary text-primary-foreground rounded-br-none"
10
+ BUBBLE_RECV = "bg-muted text-foreground rounded-bl-none"
11
+
12
+ TIMESTAMP_BASE = "mt-1 text-xs text-muted-foreground"
13
+
14
+ # sent: true for outgoing messages, false for incoming (default)
15
+ # timestamp: optional time string rendered below the bubble
16
+ # avatar: optional URL for a small avatar image (incoming only)
17
+ def initialize(sent: false, timestamp: nil, avatar: nil, **html_attrs)
18
+ @sent = sent
19
+ @timestamp = timestamp
20
+ @avatar = avatar
21
+ @extra_class = html_attrs.delete(:class)
22
+ @html_attrs = html_attrs
23
+ end
24
+
25
+ def call
26
+ wrapper_cls = cn("flex items-end gap-2", @sent ? "flex-row-reverse" : "flex-row", @extra_class)
27
+
28
+ content_tag(:div, class: wrapper_cls, **@html_attrs) do
29
+ concat avatar_img if @avatar && !@sent
30
+ concat bubble_block
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def avatar_img
37
+ content_tag(:img, nil,
38
+ src: @avatar,
39
+ alt: "",
40
+ class: "size-7 rounded-full object-cover shrink-0",
41
+ "aria-hidden": "true")
42
+ end
43
+
44
+ def bubble_block
45
+ content_tag(:div, class: cn("flex flex-col", @sent ? "items-end" : "items-start")) do
46
+ concat content_tag(:div, content,
47
+ class: cn(BUBBLE_BASE, @sent ? BUBBLE_SENT : BUBBLE_RECV))
48
+ concat content_tag(:p, @timestamp,
49
+ class: TIMESTAMP_BASE) if @timestamp
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CheckboxComponent < ApplicationComponent
5
+ BASE = "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none " \
6
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
7
+ "disabled:cursor-not-allowed disabled:opacity-50 " \
8
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
9
+ "checked:border-primary checked:bg-primary checked:text-primary-foreground " \
10
+ "dark:bg-input/30 dark:checked:bg-primary"
11
+
12
+ def initialize(label: nil, checked: false, **html_attrs)
13
+ @label = label
14
+ @checked = checked
15
+ @id = html_attrs[:id] || html_attrs[:name]&.gsub(/\W/, "_")
16
+ @extra_class = html_attrs.delete(:class)
17
+ @html_attrs = html_attrs
18
+ end
19
+
20
+ def call
21
+ if @label
22
+ content_tag(:div, class: "flex items-center gap-2") do
23
+ concat checkbox_input
24
+ concat label_tag
25
+ end
26
+ else
27
+ checkbox_input
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def checkbox_input
34
+ attrs = @html_attrs.merge(
35
+ type: "checkbox",
36
+ class: cn(BASE, @extra_class)
37
+ )
38
+ attrs[:checked] = true if @checked
39
+ attrs[:id] = @id if @id
40
+ content_tag(:input, nil, **attrs)
41
+ end
42
+
43
+ def label_tag
44
+ content_tag(:label,
45
+ @label,
46
+ for: @id,
47
+ class: "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50")
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CollapsibleComponent < ApplicationComponent
5
+ # CSS-only collapse via native <details>/<summary>.
6
+ # trigger slot: content for the summary row (button, icon, label, etc.)
7
+ # open: render pre-expanded (default: false)
8
+
9
+ SUMMARY_CLS = "flex cursor-pointer list-none items-center justify-between gap-2 " \
10
+ "[&::-webkit-details-marker]:hidden"
11
+ CONTENT_CLS = "mt-2"
12
+
13
+ renders_one :trigger
14
+
15
+ def initialize(open: false, **html_attrs)
16
+ @open = open
17
+ @extra_class = html_attrs.delete(:class)
18
+ @html_attrs = html_attrs
19
+ end
20
+
21
+ def call
22
+ attrs = { class: cn(@extra_class), **@html_attrs }
23
+ attrs[:open] = true if @open
24
+
25
+ content_tag(:details, **attrs) do
26
+ concat content_tag(:summary, trigger, class: SUMMARY_CLS)
27
+ concat content_tag(:div, content, class: CONTENT_CLS)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ComboboxComponent < ApplicationComponent
5
+ INPUT = "flex h-9 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs " \
6
+ "placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
7
+ PANEL = "absolute z-50 top-full left-0 mt-1 w-full overflow-hidden rounded-md border " \
8
+ "bg-popover text-popover-foreground shadow-md"
9
+ LIST = "max-h-[200px] overflow-y-auto p-1"
10
+ OPTION = "relative flex w-full cursor-pointer select-none items-center rounded-sm " \
11
+ "px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
12
+ EMPTY = "py-4 text-center text-sm text-muted-foreground"
13
+
14
+ def initialize(name:, options: [], value: nil, placeholder: "Select...", **html_attrs)
15
+ @name = name
16
+ @options = options
17
+ @value = value&.to_s
18
+ @placeholder = placeholder
19
+ @extra_class = html_attrs.delete(:class)
20
+ @html_attrs = html_attrs
21
+ end
22
+
23
+ def call
24
+ content_tag(:div,
25
+ class: cn("relative", @extra_class),
26
+ data: {
27
+ controller: "combobox",
28
+ action: "click@document->combobox#closeOnClickOutside"
29
+ },
30
+ **@html_attrs) do
31
+ concat hidden_input
32
+ concat text_input
33
+ concat dropdown
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def hidden_input
40
+ tag.input(type: "hidden", name: @name, value: @value, data: { combobox_target: "hidden" })
41
+ end
42
+
43
+ def text_input
44
+ selected_label = @options.find { |o| o[:value].to_s == @value }&.dig(:label)
45
+ tag.input(
46
+ type: "text",
47
+ placeholder: @placeholder,
48
+ value: selected_label,
49
+ autocomplete: "off",
50
+ class: INPUT,
51
+ data: {
52
+ combobox_target: "input",
53
+ action: "focus->combobox#open input->combobox#filter"
54
+ }
55
+ )
56
+ end
57
+
58
+ def dropdown
59
+ content_tag(:div,
60
+ data: { combobox_target: "panel" },
61
+ hidden: true,
62
+ class: PANEL) do
63
+ concat content_tag(:div, class: LIST) {
64
+ concat options_list
65
+ concat content_tag(:div, "No results.",
66
+ class: EMPTY,
67
+ data: { combobox_target: "empty" },
68
+ hidden: true)
69
+ }
70
+ end
71
+ end
72
+
73
+ def options_list
74
+ safe_join(@options.map { |opt|
75
+ content_tag(:button, opt[:label],
76
+ type: "button",
77
+ class: OPTION,
78
+ data: {
79
+ combobox_target: "option",
80
+ combobox_value: opt[:value],
81
+ combobox_label: opt[:label],
82
+ action: "click->combobox#select"
83
+ })
84
+ })
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "hidden", "panel", "option", "empty"]
5
+
6
+ open() {
7
+ this.panelTarget.hidden = false
8
+ this.filter()
9
+ }
10
+
11
+ close() {
12
+ this.panelTarget.hidden = true
13
+ const selected = this.optionTargets.find(o => o.dataset.comboboxValue === this.hiddenTarget.value)
14
+ this.inputTarget.value = selected ? selected.dataset.comboboxLabel : ""
15
+ }
16
+
17
+ filter() {
18
+ const query = this.inputTarget.value.toLowerCase()
19
+ let visible = 0
20
+ this.optionTargets.forEach(option => {
21
+ const match = option.dataset.comboboxLabel.toLowerCase().includes(query)
22
+ option.hidden = !match
23
+ if (match) visible++
24
+ })
25
+ this.emptyTarget.hidden = visible > 0
26
+ }
27
+
28
+ select(event) {
29
+ const { comboboxValue, comboboxLabel } = event.currentTarget.dataset
30
+ this.hiddenTarget.value = comboboxValue
31
+ this.inputTarget.value = comboboxLabel
32
+ this.panelTarget.hidden = true
33
+ }
34
+
35
+ closeOnClickOutside({ target }) {
36
+ if (!this.element.contains(target)) this.close()
37
+ }
38
+ }
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class CommandComponent < ApplicationComponent
5
+ renders_one :trigger
6
+
7
+ OVERLAY = "fixed inset-0 z-50 bg-black/80"
8
+ DIALOG = "fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] " \
9
+ "overflow-hidden rounded-lg border bg-background shadow-lg"
10
+ SEARCH = "flex h-10 w-full items-center gap-2 border-b px-3"
11
+ LIST = "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto"
12
+ EMPTY = "py-6 text-center text-sm text-muted-foreground"
13
+
14
+ # Wrap each group of items in a div with this class.
15
+ GROUP_WRAPPER = "overflow-hidden p-1 text-foreground"
16
+ # Apply to the heading element (p/span) inside a group wrapper.
17
+ GROUP = "px-2 py-1.5 text-xs font-medium text-muted-foreground"
18
+ # Apply to each actionable item button/link.
19
+ ITEM = "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm " \
20
+ "px-2 py-1.5 text-sm outline-none " \
21
+ "hover:bg-accent hover:text-accent-foreground " \
22
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
23
+ "[&_svg:not([class*='text-'])]:text-muted-foreground"
24
+ # Place inside an ITEM as the last child to show a keyboard shortcut on the right.
25
+ SHORTCUT = "ml-auto text-xs tracking-widest text-muted-foreground"
26
+ # Horizontal rule between groups (use a plain <hr> tag).
27
+ SEPARATOR = "-mx-1 h-px bg-border"
28
+
29
+ def initialize(**html_attrs)
30
+ @extra_class = html_attrs.delete(:class)
31
+ @html_attrs = html_attrs
32
+ end
33
+
34
+ def call
35
+ content_tag(:div, data: { controller: "command" }, **@html_attrs) do
36
+ concat content_tag(:span, trigger, data: { action: "click->command#open" }, class: "contents") if trigger
37
+ concat panel
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def panel
44
+ content_tag(:div, data: { command_target: "panel" }, hidden: true) do
45
+ concat content_tag(:div, nil,
46
+ class: OVERLAY,
47
+ data: { action: "click->command#close" },
48
+ "aria-hidden": "true")
49
+ concat content_tag(:div,
50
+ class: cn(DIALOG, @extra_class),
51
+ role: "dialog",
52
+ "aria-modal": "true",
53
+ data: { action: "keydown.escape@window->command#close" }) {
54
+ concat search_bar
55
+ concat content_tag(:div, class: LIST, data: { command_target: "list" }) {
56
+ concat content
57
+ }
58
+ concat content_tag(:div, "No results found.",
59
+ class: EMPTY,
60
+ data: { command_target: "empty" },
61
+ hidden: true)
62
+ }
63
+ end
64
+ end
65
+
66
+ def search_bar
67
+ content_tag(:div, class: SEARCH) do
68
+ concat search_icon
69
+ concat tag.input(
70
+ type: "text",
71
+ placeholder: "Type a command or search...",
72
+ class: "flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground",
73
+ data: {
74
+ command_target: "input",
75
+ action: "input->command#filter"
76
+ }
77
+ )
78
+ end
79
+ end
80
+
81
+ def search_icon
82
+ 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" class="shrink-0 text-muted-foreground" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>')
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,50 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel", "input", "list", "empty"]
5
+
6
+ connect() {
7
+ this._onKeydown = this._onKeydown.bind(this)
8
+ document.addEventListener("keydown", this._onKeydown)
9
+ }
10
+
11
+ disconnect() {
12
+ document.removeEventListener("keydown", this._onKeydown)
13
+ }
14
+
15
+ _onKeydown(event) {
16
+ if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
17
+ event.preventDefault()
18
+ this.panelTarget.hidden ? this.open() : this.close()
19
+ }
20
+ }
21
+
22
+ open() {
23
+ this.panelTarget.hidden = false
24
+ document.body.style.overflow = "hidden"
25
+ this.inputTarget.value = ""
26
+ this.inputTarget.focus()
27
+ this.filter()
28
+ }
29
+
30
+ close() {
31
+ this.panelTarget.hidden = true
32
+ document.body.style.overflow = ""
33
+ }
34
+
35
+ filter() {
36
+ const query = this.inputTarget.value.toLowerCase().trim()
37
+ const items = this.listTarget.querySelectorAll("[data-command-value]")
38
+ items.forEach(item => {
39
+ item.hidden = query.length > 0 && !item.dataset.commandValue.toLowerCase().includes(query)
40
+ })
41
+
42
+ this.listTarget.querySelectorAll("[data-command-group]").forEach(group => {
43
+ const hasVisible = Array.from(group.querySelectorAll("[data-command-value]")).some(i => !i.hidden)
44
+ group.hidden = !hasVisible
45
+ })
46
+
47
+ const totalVisible = Array.from(items).filter(i => !i.hidden).length
48
+ this.emptyTarget.hidden = totalVisible > 0
49
+ }
50
+ }
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class ContextMenuComponent < ApplicationComponent
5
+ renders_one :menu
6
+
7
+ PANEL = "fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 " \
8
+ "text-popover-foreground shadow-md"
9
+ ITEM = "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm " \
10
+ "px-2 py-1.5 text-sm outline-none " \
11
+ "hover:bg-accent hover:text-accent-foreground " \
12
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
13
+ "[&_svg:not([class*='text-'])]:text-muted-foreground"
14
+ SEPARATOR = "-mx-1 my-1 h-px bg-border"
15
+ LABEL_CLS = "px-2 py-1.5 text-sm font-medium text-foreground"
16
+
17
+ def initialize(**html_attrs)
18
+ @extra_class = html_attrs.delete(:class)
19
+ @html_attrs = html_attrs
20
+ end
21
+
22
+ def call
23
+ content_tag(:div,
24
+ class: cn("select-none", @extra_class),
25
+ data: {
26
+ controller: "context-menu",
27
+ action: "contextmenu->context-menu#show click@document->context-menu#closeOnClickOutside"
28
+ },
29
+ **@html_attrs) do
30
+ concat content
31
+ concat panel
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def panel
38
+ content_tag(:div,
39
+ data: { "context-menu-target": "panel" },
40
+ hidden: true,
41
+ class: PANEL,
42
+ style: "top: 0; left: 0") do
43
+ concat menu
44
+ end
45
+ end
46
+ end
47
+ end