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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/lib/generators/view_primitives/add/add_generator.rb +110 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
- data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
- data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
- data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
- data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
- data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
- data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
- data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
- data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
- data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
- data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
- data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
- data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
- data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
- data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
- data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
- data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
- data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
- data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
- data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
- data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
- data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
- data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
- data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
- data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
- data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
- data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
- data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
- data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
- data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
- data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
- data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
- data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
- data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
- data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
- data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
- data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
- data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
- data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
- data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
- data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
- data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
- data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
- data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
- data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
- data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
- data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
- data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
- data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
- data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
- data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
- data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
- data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
- data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
- data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
- data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
- data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
- data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
- data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
- data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
- data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
- data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
- data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
- data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
- data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
- data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
- data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
- data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
- data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
- data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
- data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
- data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
- data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
- data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
- data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
- data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
- data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
- data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
- data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
- data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
- data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
- data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
- data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
- data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
- data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
- data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
- data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
- data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
- data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
- data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
- data/lib/generators/view_primitives/components.rb +62 -0
- data/lib/generators/view_primitives/detector.rb +43 -0
- data/lib/generators/view_primitives/install/install_generator.rb +65 -0
- data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
- data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
- data/lib/generators/view_primitives/list/list_generator.rb +25 -0
- data/lib/view_primitives/class_helper.rb +11 -0
- data/lib/view_primitives/component_helper.rb +20 -0
- data/lib/view_primitives/railtie.rb +21 -0
- data/lib/view_primitives/version.rb +5 -0
- data/lib/view_primitives.rb +12 -0
- 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
|