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,20 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["panel"]
|
|
5
|
+
|
|
6
|
+
show(event) {
|
|
7
|
+
event.preventDefault()
|
|
8
|
+
this.panelTarget.hidden = false
|
|
9
|
+
this.panelTarget.style.top = `${event.clientY}px`
|
|
10
|
+
this.panelTarget.style.left = `${event.clientX}px`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
close() {
|
|
14
|
+
this.panelTarget.hidden = true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
closeOnClickOutside({ target }) {
|
|
18
|
+
if (!this.panelTarget.contains(target)) this.close()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class DataTableComponent < ApplicationComponent
|
|
5
|
+
# Sortable, filterable data table with client-side pagination.
|
|
6
|
+
#
|
|
7
|
+
# columns: array of { key:, label:, sortable: true }
|
|
8
|
+
# rows: array of hashes (keys must match column keys)
|
|
9
|
+
# per_page: rows per page (default 10, 0 = no pagination)
|
|
10
|
+
# caption: optional <caption> text
|
|
11
|
+
|
|
12
|
+
WRAPPER = "w-full overflow-auto rounded-lg border border-border"
|
|
13
|
+
TOOLBAR = "flex items-center gap-3 border-b border-border bg-background px-4 py-3"
|
|
14
|
+
SEARCH_CLS = "flex h-8 flex-1 items-center gap-2 rounded-md border border-input bg-background " \
|
|
15
|
+
"px-3 text-sm text-muted-foreground focus-within:border-ring focus-within:ring-[3px] " \
|
|
16
|
+
"focus-within:ring-ring/50 transition"
|
|
17
|
+
SEARCH_INPUT = "w-full bg-transparent outline-none placeholder:text-muted-foreground text-foreground text-sm"
|
|
18
|
+
TABLE_CLS = "w-full caption-bottom text-sm"
|
|
19
|
+
THEAD_CLS = "bg-muted/40"
|
|
20
|
+
TH_CLS = "h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap"
|
|
21
|
+
TH_SORT = "cursor-pointer select-none hover:text-foreground transition-colors"
|
|
22
|
+
TR_CLS = "border-t border-border transition-colors hover:bg-muted/30"
|
|
23
|
+
TD_CLS = "px-4 py-3 align-middle"
|
|
24
|
+
FOOTER_CLS = "flex items-center justify-between border-t border-border bg-background px-4 py-3 " \
|
|
25
|
+
"text-sm text-muted-foreground"
|
|
26
|
+
PAGE_BTN = "inline-flex h-8 w-8 items-center justify-center rounded-md border border-border " \
|
|
27
|
+
"hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none " \
|
|
28
|
+
"disabled:opacity-40 transition"
|
|
29
|
+
SORT_ASC = "▲"
|
|
30
|
+
SORT_DESC = "▼"
|
|
31
|
+
|
|
32
|
+
def initialize(columns:, rows:, per_page: 10, caption: nil, **html_attrs)
|
|
33
|
+
@columns = columns
|
|
34
|
+
@rows = rows
|
|
35
|
+
@per_page = per_page.to_i
|
|
36
|
+
@caption = caption
|
|
37
|
+
@extra_class = html_attrs.delete(:class)
|
|
38
|
+
@html_attrs = html_attrs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
content_tag(:div,
|
|
43
|
+
class: cn(WRAPPER, @extra_class),
|
|
44
|
+
data: {
|
|
45
|
+
controller: "data-table",
|
|
46
|
+
data_table_per_page_value: @per_page,
|
|
47
|
+
data_table_total_value: @rows.size
|
|
48
|
+
},
|
|
49
|
+
**@html_attrs) do
|
|
50
|
+
concat toolbar
|
|
51
|
+
concat table_element
|
|
52
|
+
concat footer if @per_page > 0
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def toolbar
|
|
59
|
+
content_tag(:div, class: TOOLBAR) do
|
|
60
|
+
concat search_box
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def search_box
|
|
65
|
+
content_tag(:label, class: SEARCH_CLS) do
|
|
66
|
+
concat search_icon
|
|
67
|
+
concat tag.input(
|
|
68
|
+
type: "search", class: SEARCH_INPUT,
|
|
69
|
+
placeholder: "Search…",
|
|
70
|
+
data: {
|
|
71
|
+
data_table_target: "search",
|
|
72
|
+
action: "input->data-table#filter"
|
|
73
|
+
})
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def table_element
|
|
78
|
+
content_tag(:table, class: TABLE_CLS) do
|
|
79
|
+
concat content_tag(:caption, @caption, class: "mt-2 text-sm text-muted-foreground") if @caption
|
|
80
|
+
concat thead
|
|
81
|
+
concat tbody
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def thead
|
|
86
|
+
content_tag(:thead, class: THEAD_CLS) do
|
|
87
|
+
content_tag(:tr) do
|
|
88
|
+
safe_join(@columns.map { |col| th_cell(col) })
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def th_cell(col)
|
|
94
|
+
key = col[:key].to_s
|
|
95
|
+
label = col[:label] || key.humanize
|
|
96
|
+
sortable = col.fetch(:sortable, false)
|
|
97
|
+
|
|
98
|
+
content_tag(:th, class: cn(TH_CLS, sortable ? TH_SORT : nil),
|
|
99
|
+
data: sortable ? {
|
|
100
|
+
action: "click->data-table#sort",
|
|
101
|
+
data_table_key_param: key
|
|
102
|
+
} : {}) do
|
|
103
|
+
content_tag(:span, class: "flex items-center gap-1") do
|
|
104
|
+
concat label
|
|
105
|
+
if sortable
|
|
106
|
+
concat content_tag(:span, SORT_ASC,
|
|
107
|
+
class: "text-xs opacity-0 data-[active=asc]:opacity-100",
|
|
108
|
+
data: { data_table_target: "sortIndicator", data_table_sort_key: key, data_table_dir: "asc" })
|
|
109
|
+
concat content_tag(:span, SORT_DESC,
|
|
110
|
+
class: "text-xs opacity-0 data-[active=desc]:opacity-100",
|
|
111
|
+
data: { data_table_target: "sortIndicator", data_table_sort_key: key, data_table_dir: "desc" })
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tbody
|
|
118
|
+
content_tag(:tbody, data: { data_table_target: "body" }) do
|
|
119
|
+
safe_join(@rows.map { |row| tr_row(row) })
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tr_row(row)
|
|
124
|
+
content_tag(:tr, class: TR_CLS, data: { data_table_row: true }) do
|
|
125
|
+
safe_join(@columns.map { |col| td_cell(row, col[:key].to_s) })
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def td_cell(row, key)
|
|
130
|
+
content_tag(:td, row[key.to_sym] || row[key], class: TD_CLS)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def footer
|
|
134
|
+
content_tag(:div, class: FOOTER_CLS) do
|
|
135
|
+
concat content_tag(:span, "Page 1",
|
|
136
|
+
data: { data_table_target: "pageLabel" })
|
|
137
|
+
concat(content_tag(:div, class: "flex items-center gap-1") {
|
|
138
|
+
concat page_btn("‹", "click->data-table#prevPage", "Previous page")
|
|
139
|
+
concat page_btn("›", "click->data-table#nextPage", "Next page")
|
|
140
|
+
})
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def page_btn(label, action, aria)
|
|
145
|
+
content_tag(:button, label, type: "button",
|
|
146
|
+
class: PAGE_BTN,
|
|
147
|
+
"aria-label": aria,
|
|
148
|
+
data: { action: action })
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def search_icon
|
|
152
|
+
content_tag(:svg,
|
|
153
|
+
safe_join([
|
|
154
|
+
content_tag(:circle, nil, cx: "11", cy: "11", r: "8"),
|
|
155
|
+
content_tag(:path, nil, d: "m21 21-4.3-4.3")
|
|
156
|
+
]),
|
|
157
|
+
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
158
|
+
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
159
|
+
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
160
|
+
class: "size-4 shrink-0", "aria-hidden": "true")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["body", "search", "pageLabel", "sortIndicator"]
|
|
5
|
+
static values = {
|
|
6
|
+
perPage: { type: Number, default: 10 },
|
|
7
|
+
total: { type: Number, default: 0 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.#allRows = Array.from(this.bodyTarget.querySelectorAll("tr[data-data-table-row]"))
|
|
12
|
+
this.#filtered = [...this.#allRows]
|
|
13
|
+
this.#page = 1
|
|
14
|
+
this.#sortKey = null
|
|
15
|
+
this.#sortDir = null
|
|
16
|
+
this.#render()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
filter() {
|
|
20
|
+
const q = this.searchTarget.value.trim().toLowerCase()
|
|
21
|
+
this.#filtered = q
|
|
22
|
+
? this.#allRows.filter(row =>
|
|
23
|
+
row.textContent.toLowerCase().includes(q)
|
|
24
|
+
)
|
|
25
|
+
: [...this.#allRows]
|
|
26
|
+
this.#page = 1
|
|
27
|
+
this.#sortKey = null
|
|
28
|
+
this.#sortDir = null
|
|
29
|
+
this.#clearSortIndicators()
|
|
30
|
+
this.#render()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
sort({ params: { key } }) {
|
|
34
|
+
if (this.#sortKey === key) {
|
|
35
|
+
this.#sortDir = this.#sortDir === "asc" ? "desc" : null
|
|
36
|
+
if (!this.#sortDir) this.#sortKey = null
|
|
37
|
+
} else {
|
|
38
|
+
this.#sortKey = key
|
|
39
|
+
this.#sortDir = "asc"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.#clearSortIndicators()
|
|
43
|
+
if (this.#sortKey) {
|
|
44
|
+
const indicator = this.sortIndicatorTargets.find(
|
|
45
|
+
el => el.dataset.dataTableSortKey === this.#sortKey &&
|
|
46
|
+
el.dataset.dataTableDir === this.#sortDir
|
|
47
|
+
)
|
|
48
|
+
if (indicator) indicator.dataset.active = this.#sortDir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (this.#sortKey) {
|
|
52
|
+
const colIdx = this.#columnIndex(this.#sortKey)
|
|
53
|
+
this.#filtered.sort((a, b) => {
|
|
54
|
+
const av = a.cells[colIdx]?.textContent.trim() ?? ""
|
|
55
|
+
const bv = b.cells[colIdx]?.textContent.trim() ?? ""
|
|
56
|
+
const n = parseFloat(av) - parseFloat(bv)
|
|
57
|
+
const cmp = isNaN(n) ? av.localeCompare(bv) : n
|
|
58
|
+
return this.#sortDir === "asc" ? cmp : -cmp
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.#page = 1
|
|
63
|
+
this.#render()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
prevPage() {
|
|
67
|
+
if (this.#page > 1) { this.#page--; this.#render() }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
nextPage() {
|
|
71
|
+
if (this.#page < this.#totalPages) { this.#page++; this.#render() }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#render() {
|
|
75
|
+
const rows = this.#filtered
|
|
76
|
+
const perPage = this.perPageValue
|
|
77
|
+
const total = rows.length
|
|
78
|
+
|
|
79
|
+
const start = perPage > 0 ? (this.#page - 1) * perPage : 0
|
|
80
|
+
const end = perPage > 0 ? start + perPage : total
|
|
81
|
+
|
|
82
|
+
this.#allRows.forEach(row => { row.style.display = "none" })
|
|
83
|
+
rows.slice(start, end).forEach(row => { row.style.display = "" })
|
|
84
|
+
|
|
85
|
+
if (this.hasPageLabelTarget) {
|
|
86
|
+
if (perPage > 0 && total > 0) {
|
|
87
|
+
this.pageLabelTarget.textContent =
|
|
88
|
+
`Page ${this.#page} of ${this.#totalPages} (${total} rows)`
|
|
89
|
+
} else {
|
|
90
|
+
this.pageLabelTarget.textContent = `${total} rows`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#clearSortIndicators() {
|
|
96
|
+
this.sortIndicatorTargets.forEach(el => delete el.dataset.active)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#columnIndex(key) {
|
|
100
|
+
const headers = this.element.querySelectorAll("th[data-data-table-key-param]")
|
|
101
|
+
return Array.from(headers).findIndex(h => h.dataset.dataTableKeyParam === key)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get #totalPages() {
|
|
105
|
+
return this.perPageValue > 0
|
|
106
|
+
? Math.max(1, Math.ceil(this.#filtered.length / this.perPageValue))
|
|
107
|
+
: 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#allRows = []
|
|
111
|
+
#filtered = []
|
|
112
|
+
#page = 1
|
|
113
|
+
#sortKey = null
|
|
114
|
+
#sortDir = null
|
|
115
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class DatePickerComponent < ApplicationComponent
|
|
5
|
+
# Date picker — text input that opens a calendar popover on focus/click.
|
|
6
|
+
#
|
|
7
|
+
# value: Date or nil — initial selected date
|
|
8
|
+
# name: form field name for the hidden input
|
|
9
|
+
# placeholder: displayed when no date is selected (default: "Pick a date")
|
|
10
|
+
# min/max: Date bounds passed to the calendar
|
|
11
|
+
|
|
12
|
+
WRAPPER = "relative inline-block"
|
|
13
|
+
TRIGGER = "flex h-9 w-48 cursor-pointer items-center gap-2 rounded-md border border-input " \
|
|
14
|
+
"bg-background px-3 text-sm text-foreground shadow-xs " \
|
|
15
|
+
"focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition " \
|
|
16
|
+
"aria-expanded:border-ring"
|
|
17
|
+
ICON_CLS = "size-4 shrink-0 text-muted-foreground"
|
|
18
|
+
POPOVER = "absolute left-0 top-full z-50 mt-1 hidden w-max rounded-lg border border-border " \
|
|
19
|
+
"bg-popover p-0 shadow-md data-[open=true]:block"
|
|
20
|
+
|
|
21
|
+
def initialize(value: nil, name: nil, placeholder: "Pick a date", min: nil, max: nil, **html_attrs)
|
|
22
|
+
@value = value
|
|
23
|
+
@name = name
|
|
24
|
+
@placeholder = placeholder
|
|
25
|
+
@min = min
|
|
26
|
+
@max = max
|
|
27
|
+
@extra_class = html_attrs.delete(:class)
|
|
28
|
+
@html_attrs = html_attrs
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
content_tag(:div,
|
|
33
|
+
class: cn(WRAPPER, @extra_class),
|
|
34
|
+
data: { controller: "date-picker" },
|
|
35
|
+
**@html_attrs) do
|
|
36
|
+
concat hidden_input if @name
|
|
37
|
+
concat trigger_button
|
|
38
|
+
concat calendar_popover
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def hidden_input
|
|
45
|
+
tag.input(type: "hidden", name: @name,
|
|
46
|
+
value: @value&.iso8601,
|
|
47
|
+
data: { date_picker_target: "hidden" })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def trigger_button
|
|
51
|
+
label_text = @value ? @value.strftime("%B %-d, %Y") : @placeholder
|
|
52
|
+
content_tag(:button, type: "button",
|
|
53
|
+
class: TRIGGER,
|
|
54
|
+
"aria-expanded": "false",
|
|
55
|
+
"aria-haspopup": "dialog",
|
|
56
|
+
data: {
|
|
57
|
+
date_picker_target: "trigger",
|
|
58
|
+
action: "click->date-picker#toggle"
|
|
59
|
+
}) do
|
|
60
|
+
concat calendar_icon
|
|
61
|
+
concat content_tag(:span, label_text, data: { date_picker_target: "label" })
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def calendar_popover
|
|
66
|
+
content_tag(:div,
|
|
67
|
+
class: POPOVER,
|
|
68
|
+
role: "dialog",
|
|
69
|
+
"aria-modal": "true",
|
|
70
|
+
data: {
|
|
71
|
+
date_picker_target: "popover",
|
|
72
|
+
action: "calendar:change->date-picker#dateSelected"
|
|
73
|
+
}) do
|
|
74
|
+
render UI::CalendarComponent.new(
|
|
75
|
+
selected: @value,
|
|
76
|
+
min: @min,
|
|
77
|
+
max: @max
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def calendar_icon
|
|
83
|
+
content_tag(:svg,
|
|
84
|
+
content_tag(:path, nil,
|
|
85
|
+
d: "M8 2v3m8-3v3M3.5 8h17M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
|
|
86
|
+
"stroke-linecap": "round", "stroke-linejoin": "round"),
|
|
87
|
+
xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
|
|
88
|
+
fill: "none", stroke: "currentColor", "stroke-width": "2",
|
|
89
|
+
class: ICON_CLS, "aria-hidden": "true")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["trigger", "popover", "label", "hidden"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.#outsideHandler = (e) => {
|
|
8
|
+
if (!this.element.contains(e.target)) this.close()
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
toggle() {
|
|
13
|
+
this.isOpen ? this.close() : this.open()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
open() {
|
|
17
|
+
this.popoverTarget.dataset.open = "true"
|
|
18
|
+
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
19
|
+
document.addEventListener("click", this.#outsideHandler)
|
|
20
|
+
this.isOpen = true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
close() {
|
|
24
|
+
this.popoverTarget.dataset.open = "false"
|
|
25
|
+
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
26
|
+
document.removeEventListener("click", this.#outsideHandler)
|
|
27
|
+
this.isOpen = false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
dateSelected(event) {
|
|
31
|
+
const iso = event.detail.date
|
|
32
|
+
const [year, month, day] = iso.split("-").map(Number)
|
|
33
|
+
const d = new Date(year, month - 1, day)
|
|
34
|
+
|
|
35
|
+
this.labelTarget.textContent = d.toLocaleDateString("default", {
|
|
36
|
+
month: "long",
|
|
37
|
+
day: "numeric",
|
|
38
|
+
year: "numeric"
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (this.hasHiddenTarget) this.hiddenTarget.value = iso
|
|
42
|
+
|
|
43
|
+
this.close()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#outsideHandler = null
|
|
47
|
+
isOpen = false
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class DeviceMockupComponent < ApplicationComponent
|
|
5
|
+
VARIANTS = {
|
|
6
|
+
phone: {
|
|
7
|
+
outer: "relative mx-auto h-[600px] w-[300px] rounded-[2.5rem] border-[14px] " \
|
|
8
|
+
"border-foreground bg-foreground shadow-xl",
|
|
9
|
+
screen: "relative h-full w-full overflow-hidden rounded-[2rem] bg-white dark:bg-zinc-900",
|
|
10
|
+
notch: "absolute left-1/2 top-0 z-10 h-6 w-28 -translate-x-1/2 rounded-b-2xl bg-foreground"
|
|
11
|
+
},
|
|
12
|
+
browser: {
|
|
13
|
+
outer: "relative mx-auto overflow-hidden rounded-xl border border-border bg-background shadow-xl",
|
|
14
|
+
bar: "flex h-10 items-center gap-2 border-b border-border bg-muted px-4",
|
|
15
|
+
dots: "flex gap-1.5",
|
|
16
|
+
screen: "overflow-hidden bg-white dark:bg-zinc-900"
|
|
17
|
+
},
|
|
18
|
+
tablet: {
|
|
19
|
+
outer: "relative mx-auto h-[500px] w-[700px] rounded-[1.75rem] border-[12px] " \
|
|
20
|
+
"border-foreground bg-foreground shadow-xl",
|
|
21
|
+
screen: "relative h-full w-full overflow-hidden rounded-[1.25rem] bg-white dark:bg-zinc-900",
|
|
22
|
+
notch: nil
|
|
23
|
+
}
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# variant: :phone (default) | :browser | :tablet
|
|
27
|
+
# url: address bar text for :browser variant
|
|
28
|
+
def initialize(variant: :phone, url: nil, **html_attrs)
|
|
29
|
+
@variant = variant.to_sym
|
|
30
|
+
@url = url
|
|
31
|
+
@extra_class = html_attrs.delete(:class)
|
|
32
|
+
@html_attrs = html_attrs
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
cfg = VARIANTS.fetch(@variant, VARIANTS[:phone])
|
|
37
|
+
|
|
38
|
+
content_tag(:div, class: cn(cfg[:outer], @extra_class), **@html_attrs) do
|
|
39
|
+
if @variant == :browser
|
|
40
|
+
concat browser_bar(cfg)
|
|
41
|
+
concat content_tag(:div, content, class: cfg[:screen])
|
|
42
|
+
else
|
|
43
|
+
concat content_tag(:div, nil, class: cfg[:notch]) if cfg[:notch]
|
|
44
|
+
concat content_tag(:div, content, class: cfg[:screen])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def browser_bar(cfg)
|
|
52
|
+
content_tag(:div, class: cfg[:bar]) do
|
|
53
|
+
concat(content_tag(:div, class: cfg[:dots]) {
|
|
54
|
+
%w[bg-red-400 bg-yellow-400 bg-green-400].each do |color|
|
|
55
|
+
concat content_tag(:div, nil, class: "size-3 rounded-full #{color}")
|
|
56
|
+
end
|
|
57
|
+
})
|
|
58
|
+
if @url
|
|
59
|
+
concat content_tag(:div, @url,
|
|
60
|
+
class: "ml-4 flex-1 truncate rounded-md bg-background px-3 py-1 text-xs text-muted-foreground")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class DialogComponent < ApplicationComponent
|
|
5
|
+
renders_one :trigger
|
|
6
|
+
renders_one :footer
|
|
7
|
+
|
|
8
|
+
OVERLAY = "fixed inset-0 z-50 bg-black/50"
|
|
9
|
+
PANEL = "fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
|
|
10
|
+
"translate-x-[-50%] translate-y-[-50%] gap-4 " \
|
|
11
|
+
"rounded-lg border bg-background p-6 shadow-lg outline-none sm:max-w-lg"
|
|
12
|
+
|
|
13
|
+
def initialize(title: nil, description: nil, **html_attrs)
|
|
14
|
+
@title = title
|
|
15
|
+
@description = description
|
|
16
|
+
@extra_class = html_attrs.delete(:class)
|
|
17
|
+
@html_attrs = html_attrs
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
content_tag(:div, data: { controller: "dialog" }, **@html_attrs) do
|
|
22
|
+
concat content_tag(:span, trigger, data: { action: "click->dialog#open" }, class: "contents") if trigger
|
|
23
|
+
concat panel
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def panel
|
|
30
|
+
content_tag(:div, data: { dialog_target: "panel" }, hidden: true) do
|
|
31
|
+
concat content_tag(:div, nil,
|
|
32
|
+
class: OVERLAY,
|
|
33
|
+
data: { action: "click->dialog#close" },
|
|
34
|
+
"aria-hidden": "true")
|
|
35
|
+
concat content_tag(:div,
|
|
36
|
+
class: cn(PANEL, @extra_class),
|
|
37
|
+
role: "dialog",
|
|
38
|
+
"aria-modal": "true",
|
|
39
|
+
"aria-label": @title,
|
|
40
|
+
data: { action: "keydown.escape@window->dialog#close" }) {
|
|
41
|
+
concat close_button
|
|
42
|
+
concat header_area
|
|
43
|
+
concat content_tag(:div, content, class: "py-1 text-sm text-foreground")
|
|
44
|
+
concat content_tag(:div, footer, class: "mt-6 flex justify-end gap-2") if footer
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def header_area
|
|
50
|
+
return "" if @title.nil? && @description.nil?
|
|
51
|
+
|
|
52
|
+
content_tag(:div, class: "mb-4 pr-6") do
|
|
53
|
+
concat content_tag(:h2, @title, class: "text-lg font-semibold leading-none tracking-tight") if @title
|
|
54
|
+
concat content_tag(:p, @description, class: "mt-2 text-sm text-muted-foreground") if @description
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close_button
|
|
59
|
+
content_tag(:button,
|
|
60
|
+
close_svg,
|
|
61
|
+
type: "button",
|
|
62
|
+
class: "absolute right-4 top-4 rounded-sm p-1 opacity-70 hover:opacity-100 transition-opacity",
|
|
63
|
+
data: { action: "click->dialog#close" },
|
|
64
|
+
"aria-label": "Close")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def close_svg
|
|
68
|
+
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>')
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["panel"]
|
|
5
|
+
|
|
6
|
+
open() {
|
|
7
|
+
this.panelTarget.hidden = false
|
|
8
|
+
document.body.style.overflow = "hidden"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
close() {
|
|
12
|
+
this.panelTarget.hidden = true
|
|
13
|
+
document.body.style.overflow = ""
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UI
|
|
4
|
+
class DrawerComponent < ApplicationComponent
|
|
5
|
+
renders_one :trigger
|
|
6
|
+
renders_one :footer
|
|
7
|
+
|
|
8
|
+
OVERLAY = "fixed inset-0 z-50 bg-black/50"
|
|
9
|
+
PANEL = "fixed inset-x-0 bottom-0 z-50 rounded-t-xl border-t bg-background shadow-xl overflow-y-auto"
|
|
10
|
+
|
|
11
|
+
def initialize(title: nil, description: nil, **html_attrs)
|
|
12
|
+
@title = title
|
|
13
|
+
@description = description
|
|
14
|
+
@extra_class = html_attrs.delete(:class)
|
|
15
|
+
@html_attrs = html_attrs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
content_tag(:div, data: { controller: "drawer" }, **@html_attrs) do
|
|
20
|
+
concat content_tag(:span, trigger, data: { action: "click->drawer#open" }, class: "contents") if trigger
|
|
21
|
+
concat panel
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def panel
|
|
28
|
+
content_tag(:div, data: { drawer_target: "panel" }, hidden: true) do
|
|
29
|
+
concat content_tag(:div, nil,
|
|
30
|
+
class: OVERLAY,
|
|
31
|
+
data: { action: "click->drawer#close" },
|
|
32
|
+
"aria-hidden": "true")
|
|
33
|
+
concat content_tag(:div,
|
|
34
|
+
class: cn(PANEL, @extra_class),
|
|
35
|
+
role: "dialog",
|
|
36
|
+
"aria-modal": "true",
|
|
37
|
+
"aria-label": @title,
|
|
38
|
+
data: { action: "keydown.escape@window->drawer#close" }) {
|
|
39
|
+
concat drag_handle
|
|
40
|
+
concat header_area
|
|
41
|
+
concat content_tag(:div, content, class: "px-4 pb-6 text-sm")
|
|
42
|
+
concat content_tag(:div, footer, class: "px-4 pb-6 flex justify-end gap-2") if footer
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def drag_handle
|
|
48
|
+
content_tag(:div, class: "flex justify-center pt-3 pb-2") {
|
|
49
|
+
content_tag(:div, nil, class: "h-1.5 w-12 rounded-full bg-muted")
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def header_area
|
|
54
|
+
return "" if @title.nil? && @description.nil?
|
|
55
|
+
|
|
56
|
+
content_tag(:div, class: "px-4 pb-4") do
|
|
57
|
+
concat content_tag(:h2, @title, class: "text-lg font-semibold text-foreground") if @title
|
|
58
|
+
concat content_tag(:p, @description, class: "mt-1 text-sm text-muted-foreground") if @description
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["panel"]
|
|
5
|
+
|
|
6
|
+
open() {
|
|
7
|
+
this.panelTarget.hidden = false
|
|
8
|
+
document.body.style.overflow = "hidden"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
close() {
|
|
12
|
+
this.panelTarget.hidden = true
|
|
13
|
+
document.body.style.overflow = ""
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["panel"]
|
|
5
|
+
|
|
6
|
+
toggle() {
|
|
7
|
+
this.panelTarget.hidden = !this.panelTarget.hidden
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
close() {
|
|
11
|
+
this.panelTarget.hidden = true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
closeOnClickOutside({ target }) {
|
|
15
|
+
if (!this.element.contains(target)) this.close()
|
|
16
|
+
}
|
|
17
|
+
}
|