lycan_ui 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/MIT-LICENSE +20 -0
- data/README.md +33 -0
- data/Rakefile +5 -0
- data/lib/generators/lycan_ui/add_generator.rb +109 -0
- data/lib/generators/lycan_ui/setup_generator.rb +76 -0
- data/lib/generators/lycan_ui/templates/components/accordion.rb +63 -0
- data/lib/generators/lycan_ui/templates/components/alert.rb +35 -0
- data/lib/generators/lycan_ui/templates/components/avatar.rb +38 -0
- data/lib/generators/lycan_ui/templates/components/badge.rb +29 -0
- data/lib/generators/lycan_ui/templates/components/button.rb +49 -0
- data/lib/generators/lycan_ui/templates/components/checkbox.rb +31 -0
- data/lib/generators/lycan_ui/templates/components/collapsible.rb +40 -0
- data/lib/generators/lycan_ui/templates/components/component.rb +72 -0
- data/lib/generators/lycan_ui/templates/components/dialog.rb +129 -0
- data/lib/generators/lycan_ui/templates/components/dropdown.rb +242 -0
- data/lib/generators/lycan_ui/templates/components/input.rb +26 -0
- data/lib/generators/lycan_ui/templates/components/label.rb +30 -0
- data/lib/generators/lycan_ui/templates/components/popover.rb +53 -0
- data/lib/generators/lycan_ui/templates/components/radio.rb +27 -0
- data/lib/generators/lycan_ui/templates/components/select.rb +38 -0
- data/lib/generators/lycan_ui/templates/components/switch.rb +26 -0
- data/lib/generators/lycan_ui/templates/components/textarea.rb +25 -0
- data/lib/generators/lycan_ui/templates/extras/form_builder.rb +90 -0
- data/lib/generators/lycan_ui/templates/javascript/accordion_controller.js +46 -0
- data/lib/generators/lycan_ui/templates/javascript/avatar_controller.js +34 -0
- data/lib/generators/lycan_ui/templates/javascript/collapsible_controller.js +23 -0
- data/lib/generators/lycan_ui/templates/javascript/dialog_controller.js +90 -0
- data/lib/generators/lycan_ui/templates/javascript/dropdown_controller.js +395 -0
- data/lib/generators/lycan_ui/templates/javascript/popover_controller.js +114 -0
- data/lib/generators/lycan_ui/templates/setup/application.tailwind.css +94 -0
- data/lib/generators/lycan_ui/templates/setup/lycan_ui_helper.rb +39 -0
- data/lib/lycan_ui/configuration.rb +32 -0
- data/lib/lycan_ui/railtie.rb +6 -0
- data/lib/lycan_ui/version.rb +3 -0
- data/lib/lycan_ui.rb +8 -0
- data/lib/tasks/lycan_ui_tasks.rake +6 -0
- metadata +107 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Dialog < Component
|
|
5
|
+
def initialize(open: false, remote: false, **attributes)
|
|
6
|
+
@frame = if remote.present?
|
|
7
|
+
if remote.is_a?(String)
|
|
8
|
+
remote
|
|
9
|
+
else
|
|
10
|
+
"dialog"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
super(
|
|
15
|
+
attributes,
|
|
16
|
+
data: {
|
|
17
|
+
controller: "dialog",
|
|
18
|
+
dialog_open_value: @frame.present? ? true : open,
|
|
19
|
+
dialog_frame_value: @frame,
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def template(&block)
|
|
25
|
+
@labelledby = lycan_ui_id
|
|
26
|
+
@controls = lycan_ui_id
|
|
27
|
+
|
|
28
|
+
content = tag.div(**attributes) { yield self }
|
|
29
|
+
|
|
30
|
+
return turbo_frame_tag(@frame) { content } if @frame.present?
|
|
31
|
+
|
|
32
|
+
content
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def trigger(content = nil, **trigger_attributes, &block)
|
|
36
|
+
raise ArgumentError, "Remote dialog cannot have a trigger" if @frame.present?
|
|
37
|
+
|
|
38
|
+
final_attributes = merge_attributes(
|
|
39
|
+
trigger_attributes,
|
|
40
|
+
data: { dialog_target: "trigger", action: "dialog#open" },
|
|
41
|
+
aria: { controls: @controls },
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
render(Button.new(content, **final_attributes), &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
CONTENT_CLASSES = <<~CLASSES.squish
|
|
48
|
+
fixed left-1/2 top-1/2 z-50 open:grid max-w-lg -translate-x-1/2 -translate-y-1/2
|
|
49
|
+
gap-4 border border-surface bg-background p-6 shadow-lg rounded-lg
|
|
50
|
+
backdrop:bg-black/65 not-open:backdrop:hidden
|
|
51
|
+
motion-safe:transition-[opacity_transform_display] transition-discrete
|
|
52
|
+
starting:opacity-0 not-open:opacity-0 opacity-100
|
|
53
|
+
starting:scale-95 not-open:scale-95 scale-100
|
|
54
|
+
starting:-translate-y-[48%] not-open:-translate-y-[48%]
|
|
55
|
+
CLASSES
|
|
56
|
+
CLOSE_CLASSES = <<~CLASSES.squish
|
|
57
|
+
absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background
|
|
58
|
+
transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2
|
|
59
|
+
focus-visible:ring-accent focus-visible:ring-offset-2
|
|
60
|
+
CLASSES
|
|
61
|
+
def content(**content_attributes, &)
|
|
62
|
+
final_attributes = merge_attributes(
|
|
63
|
+
content_attributes,
|
|
64
|
+
class: CONTENT_CLASSES,
|
|
65
|
+
aria: { labelledby: @labelledby },
|
|
66
|
+
data: { dialog_target: "content" },
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
close_btn = tag.button(class: CLOSE_CLASSES, type: "button", data: { action: "dialog#close" }) do
|
|
70
|
+
lucide_icon("x", class: "size-4")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
tag.dialog(id: @controls, **final_attributes) do
|
|
74
|
+
safe_join([ @view_context.capture(&), close_btn ])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def form(**form_attributes, &)
|
|
79
|
+
final_attributes = merge_attributes(
|
|
80
|
+
form_attributes,
|
|
81
|
+
class: "contents",
|
|
82
|
+
data: { action: "turbo:submit-end->dialog#closeOnFormSubmit" },
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if respond_to?(:lycan_ui_form_with)
|
|
86
|
+
lycan_ui_form_with(**final_attributes, &)
|
|
87
|
+
else
|
|
88
|
+
form_with(**final_attributes, &)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def header(**header_attributes, &)
|
|
93
|
+
final_attributes = merge_attributes(
|
|
94
|
+
header_attributes,
|
|
95
|
+
class: "flex flex-col space-y-1.5 text-center sm:text-left",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
tag.div(**final_attributes, &)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def title(content = nil, **title_attributes, &)
|
|
102
|
+
final_attributes = merge_attributes(
|
|
103
|
+
title_attributes,
|
|
104
|
+
class: "text-lg font-semibold leading-none tracking-tight",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
tag.h2(**final_attributes) { determine_content(content, &) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def description(content = nil, **description_attributes, &)
|
|
111
|
+
final_attributes = merge_attributes(
|
|
112
|
+
description_attributes,
|
|
113
|
+
id: @labelledby,
|
|
114
|
+
class: "text-sm text-on-background/90",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
tag.p(**final_attributes) { determine_content(content, &) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def footer(**footer_attributes, &)
|
|
121
|
+
final_attributes = merge_attributes(
|
|
122
|
+
footer_attributes,
|
|
123
|
+
class: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
tag.div(**final_attributes, &)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Dropdown < Component
|
|
5
|
+
def initialize(typeahead: true, **attributes)
|
|
6
|
+
super(
|
|
7
|
+
attributes,
|
|
8
|
+
data: {
|
|
9
|
+
controller: "dropdown",
|
|
10
|
+
action: "keydown->dropdown#handleKeydown",
|
|
11
|
+
dropdown_typeahead_value: typeahead,
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def template(&block)
|
|
17
|
+
@labelledby = lycan_ui_id
|
|
18
|
+
@controls = lycan_ui_id
|
|
19
|
+
|
|
20
|
+
tag.div(**attributes) do
|
|
21
|
+
yield self
|
|
22
|
+
|
|
23
|
+
concat(safe_join(@submenus)) if @submenus&.any?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def trigger(content = nil, **trigger_attributes, &block)
|
|
28
|
+
final_attributes = merge_attributes(
|
|
29
|
+
trigger_attributes,
|
|
30
|
+
data: { dropdown_target: "trigger", action: "dropdown#toggle" },
|
|
31
|
+
aria: { has_popup: true, expanded: false, controls: @controls },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
render(Button.new(content, id: @labelledby, **final_attributes), &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
CONTENT_CLASSES = <<~CLASSES.squish
|
|
38
|
+
absolute z-50 min-w-32 overflow-y-auto overflow-x-hidden shadow-md
|
|
39
|
+
bg-background text-on-background border border-surface p-1 rounded-md
|
|
40
|
+
data-[open=false]:invisible motion-safe:transition-[opacity_transform]
|
|
41
|
+
will-change-[opacity,transform] duration-150
|
|
42
|
+
data-[open=false]:opacity-0 opacity-100
|
|
43
|
+
data-[open=false]:scale-95 scale-100
|
|
44
|
+
data-[open=false]:data-[side=bottom]:-translate-y-2 translate-y-0
|
|
45
|
+
data-[open=false]:data-[side=top]:translate-y-2
|
|
46
|
+
data-[open=false]:data-[side=left]:translate-x-2
|
|
47
|
+
data-[open=false]:data-[side=right]:-translate-x-2
|
|
48
|
+
CLASSES
|
|
49
|
+
def content(**content_attributes, &)
|
|
50
|
+
final_attributes = merge_attributes(
|
|
51
|
+
content_attributes,
|
|
52
|
+
role: "menu",
|
|
53
|
+
class: CONTENT_CLASSES,
|
|
54
|
+
aria: { labelledby: @labelledby },
|
|
55
|
+
data: { open: false, dropdown_target: "content" },
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
tag.div(id: @controls, **final_attributes, &)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def title(content = nil, **title_attributes, &block)
|
|
62
|
+
final_attributes = merge_attributes(title_attributes, { class: "px-2 py-1.5 text-sm font-semibold" })
|
|
63
|
+
|
|
64
|
+
tag.div(**final_attributes) { determine_content(content, &block) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def separator
|
|
68
|
+
tag.div(role: "separator", aria_orientation: "horizontal", class: "-mx-1 my-1 h-px bg-surface")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def group(&block)
|
|
72
|
+
tag.div(role: "group", &block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
ITEM_CLASSES = <<~CLASSES.squish
|
|
76
|
+
relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm
|
|
77
|
+
outline-none transition-colors cursor-pointer focus:bg-accent focus:text-on-accent
|
|
78
|
+
aria-disabled:pointer-events-none aria-disabled:opacity-50
|
|
79
|
+
disabled:pointer-events-none disabled:opacity-50
|
|
80
|
+
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0
|
|
81
|
+
CLASSES
|
|
82
|
+
def submenu(id: lycan_ui_id, &block)
|
|
83
|
+
@current_submenu_id = id
|
|
84
|
+
|
|
85
|
+
yield @current_submenu_id
|
|
86
|
+
|
|
87
|
+
@current_submenu_id = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
SUBMENU_TRIGGER_ACTIONS = <<~ACTIONS.squish
|
|
91
|
+
pointerenter->dropdown#focusItem pointerleave->dropdown#focusTrigger
|
|
92
|
+
pointerenter->dropdown#openSubmenu keydown.right->dropdown#openSubmenu:prevent
|
|
93
|
+
dropdown#openSubmenu
|
|
94
|
+
ACTIONS
|
|
95
|
+
def submenu_trigger(name = nil, **attributes, &block)
|
|
96
|
+
final_attributes = merge_attributes(
|
|
97
|
+
attributes,
|
|
98
|
+
{ class: "aria-expanded:bg-accent aria-expanded:text-on-accent" },
|
|
99
|
+
class: ITEM_CLASSES,
|
|
100
|
+
tabindex: "-1",
|
|
101
|
+
role: "menuitem",
|
|
102
|
+
aria: {
|
|
103
|
+
has_popup: true,
|
|
104
|
+
expanded: false,
|
|
105
|
+
controls: @current_submenu_id,
|
|
106
|
+
},
|
|
107
|
+
data: {
|
|
108
|
+
dropdown_target: "item",
|
|
109
|
+
action: SUBMENU_TRIGGER_ACTIONS,
|
|
110
|
+
dropdown_submenu_param: @current_submenu_id,
|
|
111
|
+
submenu: @current_submenu_id,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
tag.button(**final_attributes) { determine_content(name, &block) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def submenu_content(**content_attributes, &)
|
|
119
|
+
final_attributes = merge_attributes(
|
|
120
|
+
content_attributes,
|
|
121
|
+
role: "menu",
|
|
122
|
+
class: CONTENT_CLASSES,
|
|
123
|
+
data: {
|
|
124
|
+
dropdown_target: "submenu",
|
|
125
|
+
action: "keydown->dropdown#submenuHandleKeydown",
|
|
126
|
+
dropdown_submenu_param: @current_submenu_id,
|
|
127
|
+
open: false,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@submenus ||= []
|
|
132
|
+
@submenus << tag.div(id: @current_submenu_id, **final_attributes, &)
|
|
133
|
+
|
|
134
|
+
# this must return nil to ensure the submenus are rerendered outside
|
|
135
|
+
# the original dropdown menu
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def link(name = nil, options = nil, html_options = nil, &block)
|
|
140
|
+
html_options, options, name = options, name, block if block_given?
|
|
141
|
+
|
|
142
|
+
html_options ||= {}
|
|
143
|
+
disabled = html_options.delete(:disabled)
|
|
144
|
+
close = html_options.delete(:close)
|
|
145
|
+
close = true if close.nil?
|
|
146
|
+
|
|
147
|
+
html_options = merge_attributes(
|
|
148
|
+
html_options,
|
|
149
|
+
class: ITEM_CLASSES,
|
|
150
|
+
role: "menuitem",
|
|
151
|
+
tabindex: "-1",
|
|
152
|
+
aria: { disabled: },
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
close_action = close ? "dropdown#close" : ""
|
|
156
|
+
html_options = if @current_submenu_id.present?
|
|
157
|
+
merge_attributes(html_options, data: {
|
|
158
|
+
dropdown_target: "submenuItem",
|
|
159
|
+
action: "pointerenter->dropdown#focusItem pointerleave->dropdown#focusSubmenuTrigger #{close_action}",
|
|
160
|
+
dropdown_submenu_param: @current_submenu_id,
|
|
161
|
+
submenu: @current_submenu_id,
|
|
162
|
+
})
|
|
163
|
+
else
|
|
164
|
+
merge_attributes(html_options, data: {
|
|
165
|
+
dropdown_target: "item",
|
|
166
|
+
action: "pointerenter->dropdown#focusItem pointerleave->dropdown#focusTrigger #{close_action}",
|
|
167
|
+
})
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if block_given?
|
|
171
|
+
link_to(options, html_options, &block)
|
|
172
|
+
else
|
|
173
|
+
link_to(name, options, html_options)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def action(name = nil, options = nil, html_options = nil, &block)
|
|
178
|
+
html_options, options, name = options, name, block if block_given?
|
|
179
|
+
html_options ||= {}
|
|
180
|
+
|
|
181
|
+
disabled = html_options.delete(:disabled)
|
|
182
|
+
close = html_options.delete(:close)
|
|
183
|
+
close = true if close.nil?
|
|
184
|
+
|
|
185
|
+
html_options = merge_attributes(
|
|
186
|
+
html_options,
|
|
187
|
+
role: "menuitem",
|
|
188
|
+
class: ITEM_CLASSES,
|
|
189
|
+
tabindex: "-1",
|
|
190
|
+
disabled:,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
close_action = close ? "dropdown#close" : ""
|
|
194
|
+
html_options = if @current_submenu_id.present?
|
|
195
|
+
merge_attributes(html_options, data: {
|
|
196
|
+
dropdown_target: "submenuItem",
|
|
197
|
+
action: "pointerenter->dropdown#focusItem pointerleave->dropdown#focusSubmenuTrigger #{close_action}",
|
|
198
|
+
dropdown_submenu_param: @current_submenu_id,
|
|
199
|
+
submenu: @current_submenu_id,
|
|
200
|
+
})
|
|
201
|
+
else
|
|
202
|
+
merge_attributes(html_options, data: {
|
|
203
|
+
dropdown_target: "item",
|
|
204
|
+
action: "pointerenter->dropdown#focusItem pointerleave->dropdown#focusTrigger #{close_action}",
|
|
205
|
+
})
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if block_given?
|
|
209
|
+
button_to(options, html_options, &block)
|
|
210
|
+
else
|
|
211
|
+
button_to(name, options, html_options)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def button(name = nil, close: true, disabled: false, **attributes, &block)
|
|
216
|
+
close_action = close ? "dropdown#close" : ""
|
|
217
|
+
|
|
218
|
+
attributes = merge_attributes(
|
|
219
|
+
attributes,
|
|
220
|
+
role: "menuitem",
|
|
221
|
+
class: ITEM_CLASSES,
|
|
222
|
+
tabindex: "-1",
|
|
223
|
+
disabled:,
|
|
224
|
+
)
|
|
225
|
+
attributes = if @current_submenu_id.present?
|
|
226
|
+
merge_attributes(attributes, data: {
|
|
227
|
+
dropdown_target: "submenuItem",
|
|
228
|
+
action: "pointerenter->dropdown#focusItem pointerleave->dropdown#focusSubmenuTrigger #{close_action}",
|
|
229
|
+
dropdown_submenu_param: @current_submenu_id,
|
|
230
|
+
submenu: @current_submenu_id,
|
|
231
|
+
})
|
|
232
|
+
else
|
|
233
|
+
merge_attributes(attributes, data: {
|
|
234
|
+
dropdown_target: "item",
|
|
235
|
+
action: "pointerenter->dropdown#focusItem pointerleave->dropdown#focusTrigger #{close_action}",
|
|
236
|
+
})
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tag.button(**attributes) { determine_content(name, &block) }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Input < Component
|
|
5
|
+
attr_reader :object_name, :method, :type
|
|
6
|
+
|
|
7
|
+
DEFAULT_CLASSES = <<~CLASSES.squish
|
|
8
|
+
flex h-10 w-full rounded-md px-3 py-2 text-sm bg-surface text-on-surface
|
|
9
|
+
file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-on-surface/50
|
|
10
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent
|
|
11
|
+
disabled:cursor-not-allowed disabled:opacity-50
|
|
12
|
+
CLASSES
|
|
13
|
+
|
|
14
|
+
def initialize(object_name, method, options = {})
|
|
15
|
+
@object_name = object_name
|
|
16
|
+
@method = method
|
|
17
|
+
@type = options.delete(:type) || :text
|
|
18
|
+
|
|
19
|
+
super(options, class: DEFAULT_CLASSES)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def template
|
|
23
|
+
send("#{type}_field", object_name, method, **attributes)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Label < Component
|
|
5
|
+
attr_reader :object_name, :method, :content
|
|
6
|
+
|
|
7
|
+
DEFAULT_CLASSES = "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
8
|
+
|
|
9
|
+
def initialize(object_name, method, content_or_options = nil, options = nil)
|
|
10
|
+
@object_name = object_name
|
|
11
|
+
@method = method
|
|
12
|
+
|
|
13
|
+
attributes = options
|
|
14
|
+
|
|
15
|
+
if content_or_options.is_a?(Hash)
|
|
16
|
+
attributes = content_or_options
|
|
17
|
+
@content = nil
|
|
18
|
+
else
|
|
19
|
+
attributes = options || {}
|
|
20
|
+
@content = content_or_options
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
super(attributes, class: DEFAULT_CLASSES)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render_in(view_context, &)
|
|
27
|
+
view_context.label(object_name, method, content, **attributes, &)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Popover < Component
|
|
5
|
+
def initialize(**attributes)
|
|
6
|
+
super(
|
|
7
|
+
attributes,
|
|
8
|
+
data: {
|
|
9
|
+
controller: "popover",
|
|
10
|
+
}
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def template(&block)
|
|
15
|
+
@labelledby = lycan_ui_id
|
|
16
|
+
@controls = lycan_ui_id
|
|
17
|
+
|
|
18
|
+
tag.div(**attributes) { yield self }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def trigger(content = nil, **trigger_attributes, &block)
|
|
22
|
+
final_attributes = merge_attributes(
|
|
23
|
+
trigger_attributes,
|
|
24
|
+
data: { popover_target: "trigger", action: "popover#toggle" },
|
|
25
|
+
aria: { has_popup: true, expanded: false, controls: @controls },
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
render(Button.new(content, id: @labelledby, **final_attributes), &block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
CONTENT_CLASSES = <<~CLASSES.squish
|
|
32
|
+
absolute z-50 min-w-32 overflow-y-auto overflow-x-hidden shadow-md
|
|
33
|
+
bg-background text-on-background border border-surface p-4 rounded-md
|
|
34
|
+
not-open:invisible not-open:block motion-safe:transition-[opacity_transform]
|
|
35
|
+
will-change-[opacity,transform] duration-150
|
|
36
|
+
not-open:opacity-0 opacity-100
|
|
37
|
+
not-open:scale-95 scale-100
|
|
38
|
+
not-open:data-[side=bottom]:-translate-y-2 translate-y-0
|
|
39
|
+
not-open:data-[side=top]:translate-y-2
|
|
40
|
+
not-open:data-[side=left]:translate-x-2
|
|
41
|
+
not-open:data-[side=right]:-translate-x-2
|
|
42
|
+
CLASSES
|
|
43
|
+
def content(**content_attributes, &)
|
|
44
|
+
final_attributes = merge_attributes(
|
|
45
|
+
content_attributes,
|
|
46
|
+
class: CONTENT_CLASSES,
|
|
47
|
+
data: { popover_target: "content" },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
tag.dialog(id: @controls, **final_attributes, &)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Radio < Component
|
|
5
|
+
attr_reader :object_name, :method, :tag_value
|
|
6
|
+
|
|
7
|
+
DEFAULT_CLASSES = <<~CLASSES.squish
|
|
8
|
+
flex size-4 cursor-pointer appearance-none items-center justify-center
|
|
9
|
+
rounded-full border-2 border-surface motion-safe:transition-all
|
|
10
|
+
before:size-0 before:rounded-full before:bg-accent
|
|
11
|
+
checked:border-accent checked:before:size-2
|
|
12
|
+
motion-safe:before:transition-all
|
|
13
|
+
CLASSES
|
|
14
|
+
|
|
15
|
+
def initialize(object_name, method, tag_value, options = {})
|
|
16
|
+
@object_name = object_name
|
|
17
|
+
@method = method
|
|
18
|
+
@tag_value = tag_value
|
|
19
|
+
|
|
20
|
+
super(options, class: DEFAULT_CLASSES)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def template
|
|
24
|
+
radio_button(object_name, method, tag_value, **attributes)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Select < Component
|
|
5
|
+
attr_reader :object_name, :method, :choices, :options
|
|
6
|
+
|
|
7
|
+
CLASSES = <<~CLASSES.squish
|
|
8
|
+
appearance-none bg-surface text-on-surface rounded-md border border-surface
|
|
9
|
+
px-3 py-2 pr-10 w-full focus-visible:outline-none ring-primary ring-offset-2
|
|
10
|
+
ring-offset-background focus-visible:ring-2 peer/select cursor-pointer
|
|
11
|
+
scheme-light
|
|
12
|
+
|
|
13
|
+
[&_option]:bg-background
|
|
14
|
+
CLASSES
|
|
15
|
+
def initialize(object_name, method, choices = nil, options = {}, html_options = {})
|
|
16
|
+
@object_name = object_name
|
|
17
|
+
@method = method
|
|
18
|
+
@choices = choices
|
|
19
|
+
@options = options
|
|
20
|
+
|
|
21
|
+
super(html_options, class: CLASSES)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
ICON_CLASSES = <<~CLASSES.squish
|
|
25
|
+
size-4 self-center place-self-end mr-2 text-on-surface/80
|
|
26
|
+
pointer-events-none group-hover/select:text-on-surface
|
|
27
|
+
peer-open/select:rotate-180 motion-safe:transition-transform
|
|
28
|
+
CLASSES
|
|
29
|
+
def render_in(view_context, &)
|
|
30
|
+
view_context.tag.div(class: "w-fit h-10 inline-grid *:[grid-area:1/1] group/select") do
|
|
31
|
+
view_context.safe_join([
|
|
32
|
+
view_context.select(object_name, method, choices, options, attributes, &),
|
|
33
|
+
view_context.lucide_icon("chevron-down", class: ICON_CLASSES),
|
|
34
|
+
])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Switch < Component
|
|
5
|
+
attr_reader :object_name, :method, :checked_value, :unchecked_value
|
|
6
|
+
|
|
7
|
+
DEFAULT_CLASSES = <<~CLASSES.squish
|
|
8
|
+
appearance-none cursor-pointer rounded-full disabled:opacity-50 disabled:cursor-not-allowed
|
|
9
|
+
w-10 h-5 bg-background shadow-switch checked:shadow-switch-checked
|
|
10
|
+
motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-in-out
|
|
11
|
+
CLASSES
|
|
12
|
+
|
|
13
|
+
def initialize(object_name, method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
14
|
+
@object_name = object_name
|
|
15
|
+
@method = method
|
|
16
|
+
@checked_value = checked_value
|
|
17
|
+
@unchecked_value = unchecked_value
|
|
18
|
+
|
|
19
|
+
super(options, role: "switch", class: DEFAULT_CLASSES)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def template
|
|
23
|
+
checkbox(object_name, method, attributes, checked_value, unchecked_value)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
class Textarea < Component
|
|
5
|
+
attr_reader :object_name, :method
|
|
6
|
+
|
|
7
|
+
DEFAULT_CLASSES = <<~CLASSES.squish
|
|
8
|
+
flex min-h-20 w-full rounded-md bg-surface text-on-surface
|
|
9
|
+
px-3 py-2 text-base placeholder:text-on-surface/50 md:text-sm
|
|
10
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent
|
|
11
|
+
disabled:cursor-not-allowed disabled:opacity-50
|
|
12
|
+
CLASSES
|
|
13
|
+
|
|
14
|
+
def initialize(object_name, method, options = {})
|
|
15
|
+
@object_name = object_name
|
|
16
|
+
@method = method
|
|
17
|
+
|
|
18
|
+
super(options, class: DEFAULT_CLASSES)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def template
|
|
22
|
+
text_area(object_name, method, **attributes)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LycanUi
|
|
4
|
+
module FormHelper
|
|
5
|
+
class FormBuilder < ActionView::Helpers::FormBuilder
|
|
6
|
+
[
|
|
7
|
+
:date,
|
|
8
|
+
:datetime,
|
|
9
|
+
:email,
|
|
10
|
+
:month,
|
|
11
|
+
:number,
|
|
12
|
+
:password,
|
|
13
|
+
:search,
|
|
14
|
+
:telephone,
|
|
15
|
+
:text,
|
|
16
|
+
:url,
|
|
17
|
+
:week,
|
|
18
|
+
].each do |type|
|
|
19
|
+
define_method("#{type}_field") do |method, options = {}|
|
|
20
|
+
options = objectify_options(options)
|
|
21
|
+
options[:type] = type
|
|
22
|
+
|
|
23
|
+
@template.ui.input(@object_name, method, options)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def button(value = nil, options = {}, &block)
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
value, options = nil, value
|
|
31
|
+
when Symbol
|
|
32
|
+
value, options = nil, { name: field_name(value), id: field_id(value) }.merge!(options.to_h)
|
|
33
|
+
end
|
|
34
|
+
value ||= submit_default_value
|
|
35
|
+
|
|
36
|
+
if block_given?
|
|
37
|
+
value = @template.capture { yield(value) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
formmethod = options[:formmethod]
|
|
41
|
+
if formmethod.present? && !/post|get/i.match?(formmethod) && !options.key?(:name) && !options.key?(:value)
|
|
42
|
+
options.merge!(formmethod: :post, name: "_method", value: formmethod)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
options[:type] ||= :submit
|
|
46
|
+
|
|
47
|
+
@template.ui.button(value, **options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def checkbox(method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
51
|
+
@template.ui.checkbox(@object_name, method, objectify_options(options), checked_value, unchecked_value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def file_field(method, options = {})
|
|
55
|
+
self.multipart = true
|
|
56
|
+
options = objectify_options(options)
|
|
57
|
+
options[:type] = :file
|
|
58
|
+
|
|
59
|
+
@template.ui.input(@object_name, method, options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def label(method, text = nil, options = {}, &block)
|
|
63
|
+
@template.ui.label(@object_name, method, text, objectify_options(options), &block)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def radio_button(method, tag_value, options = {})
|
|
67
|
+
@template.ui.radio(@object_name, method, tag_value, objectify_options(options))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def submit(value = nil, options = {})
|
|
71
|
+
value, options = nil, value if value.is_a?(Hash)
|
|
72
|
+
value ||= submit_default_value
|
|
73
|
+
|
|
74
|
+
button(value, options)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def switch(method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
78
|
+
@template.ui.switch(@object_name, method, objectify_options(options), checked_value, unchecked_value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def textarea(method, options = {})
|
|
82
|
+
@template.ui.textarea(@object_name, method, objectify_options(options))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def lycan_ui_form_with(*, **, &)
|
|
87
|
+
form_with(*, builder: FormBuilder, **, &)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|