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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +33 -0
  4. data/Rakefile +5 -0
  5. data/lib/generators/lycan_ui/add_generator.rb +109 -0
  6. data/lib/generators/lycan_ui/setup_generator.rb +76 -0
  7. data/lib/generators/lycan_ui/templates/components/accordion.rb +63 -0
  8. data/lib/generators/lycan_ui/templates/components/alert.rb +35 -0
  9. data/lib/generators/lycan_ui/templates/components/avatar.rb +38 -0
  10. data/lib/generators/lycan_ui/templates/components/badge.rb +29 -0
  11. data/lib/generators/lycan_ui/templates/components/button.rb +49 -0
  12. data/lib/generators/lycan_ui/templates/components/checkbox.rb +31 -0
  13. data/lib/generators/lycan_ui/templates/components/collapsible.rb +40 -0
  14. data/lib/generators/lycan_ui/templates/components/component.rb +72 -0
  15. data/lib/generators/lycan_ui/templates/components/dialog.rb +129 -0
  16. data/lib/generators/lycan_ui/templates/components/dropdown.rb +242 -0
  17. data/lib/generators/lycan_ui/templates/components/input.rb +26 -0
  18. data/lib/generators/lycan_ui/templates/components/label.rb +30 -0
  19. data/lib/generators/lycan_ui/templates/components/popover.rb +53 -0
  20. data/lib/generators/lycan_ui/templates/components/radio.rb +27 -0
  21. data/lib/generators/lycan_ui/templates/components/select.rb +38 -0
  22. data/lib/generators/lycan_ui/templates/components/switch.rb +26 -0
  23. data/lib/generators/lycan_ui/templates/components/textarea.rb +25 -0
  24. data/lib/generators/lycan_ui/templates/extras/form_builder.rb +90 -0
  25. data/lib/generators/lycan_ui/templates/javascript/accordion_controller.js +46 -0
  26. data/lib/generators/lycan_ui/templates/javascript/avatar_controller.js +34 -0
  27. data/lib/generators/lycan_ui/templates/javascript/collapsible_controller.js +23 -0
  28. data/lib/generators/lycan_ui/templates/javascript/dialog_controller.js +90 -0
  29. data/lib/generators/lycan_ui/templates/javascript/dropdown_controller.js +395 -0
  30. data/lib/generators/lycan_ui/templates/javascript/popover_controller.js +114 -0
  31. data/lib/generators/lycan_ui/templates/setup/application.tailwind.css +94 -0
  32. data/lib/generators/lycan_ui/templates/setup/lycan_ui_helper.rb +39 -0
  33. data/lib/lycan_ui/configuration.rb +32 -0
  34. data/lib/lycan_ui/railtie.rb +6 -0
  35. data/lib/lycan_ui/version.rb +3 -0
  36. data/lib/lycan_ui.rb +8 -0
  37. data/lib/tasks/lycan_ui_tasks.rake +6 -0
  38. 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