quicksilver_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/app/assets/tailwind/alert.css +35 -0
- data/app/assets/tailwind/badge.css +27 -0
- data/app/assets/tailwind/button.css +35 -0
- data/app/assets/tailwind/form.css +35 -0
- data/app/assets/tailwind/link.css +23 -0
- data/app/assets/tailwind/modal.css +43 -0
- data/app/assets/tailwind/quicksilver_ui/engine.css +7 -0
- data/app/assets/tailwind/typography.css +112 -0
- data/app/helpers/app_form_builder.rb +94 -0
- data/app/helpers/app_form_helper.rb +7 -0
- data/app/javascript/controllers/autogrow_controller.js +19 -0
- data/app/javascript/controllers/dismissable_controller.js +35 -0
- data/app/javascript/controllers/dropdown_controller.js +59 -0
- data/app/javascript/controllers/modal_controller.js +45 -0
- data/app/javascript/controllers/tabs_controller.js +62 -0
- data/app/javascript/mixins/use_floating_ui.js +104 -0
- data/app/views/form/base_tag.rb +42 -0
- data/app/views/form/checkbox.rb +62 -0
- data/app/views/form/date_field.rb +11 -0
- data/app/views/form/email_field.rb +7 -0
- data/app/views/form/error.rb +15 -0
- data/app/views/form/file_field.rb +12 -0
- data/app/views/form/group.rb +97 -0
- data/app/views/form/hint.rb +15 -0
- data/app/views/form/input.rb +11 -0
- data/app/views/form/label.rb +19 -0
- data/app/views/form/password_field.rb +7 -0
- data/app/views/form/phone_field.rb +7 -0
- data/app/views/form/radio_button.rb +37 -0
- data/app/views/form/search_field.rb +7 -0
- data/app/views/form/select.rb +46 -0
- data/app/views/form/text_field.rb +7 -0
- data/app/views/form/textarea.rb +27 -0
- data/app/views/form/toggle.rb +35 -0
- data/app/views/ui/accordion.rb +67 -0
- data/app/views/ui/alert.rb +84 -0
- data/app/views/ui/avatar.rb +57 -0
- data/app/views/ui/badge.rb +35 -0
- data/app/views/ui/base.rb +29 -0
- data/app/views/ui/dropdown/item.rb +49 -0
- data/app/views/ui/dropdown.rb +111 -0
- data/app/views/ui/icon.rb +46 -0
- data/app/views/ui/modal.rb +96 -0
- data/app/views/ui/toast.rb +90 -0
- data/lib/generators/quicksilver_ui/affordance/affordance_generator.rb +102 -0
- data/lib/generators/quicksilver_ui/component/all_generator.rb +32 -0
- data/lib/generators/quicksilver_ui/component/component_generator.rb +194 -0
- data/lib/generators/quicksilver_ui/form/all_generator.rb +32 -0
- data/lib/generators/quicksilver_ui/form/form_generator.rb +164 -0
- data/lib/generators/quicksilver_ui/form/templates/app_form_builder.rb +39 -0
- data/lib/generators/quicksilver_ui/form/templates/app_form_helper.rb +7 -0
- data/lib/generators/quicksilver_ui/install/install_generator.rb +42 -0
- data/lib/generators/quicksilver_ui/install/templates/base.rb +29 -0
- data/lib/generators/quicksilver_ui/install/templates/initializer.rb +16 -0
- data/lib/quicksilver_ui/dependencies.rb +191 -0
- data/lib/quicksilver_ui/engine.rb +18 -0
- data/lib/quicksilver_ui/version.rb +5 -0
- data/lib/quicksilver_ui.rb +37 -0
- metadata +98 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFloatingUI stimulus mixin
|
|
3
|
+
*
|
|
4
|
+
* Adds FloatingUI positioning capabilities to a Stimulus controller
|
|
5
|
+
* Requires the controller to have:
|
|
6
|
+
* - referenceElement (element to position relative to)
|
|
7
|
+
* - floatingElement (element to be positioned)
|
|
8
|
+
* - positioningOptions (FloatingUI options object)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
computePosition,
|
|
13
|
+
flip,
|
|
14
|
+
shift,
|
|
15
|
+
offset,
|
|
16
|
+
autoUpdate
|
|
17
|
+
} from "@floating-ui/dom"
|
|
18
|
+
|
|
19
|
+
export const useFloatingUI = (controller, referenceElement, floatingElement, positioningOptions = {}, options = {}) => {
|
|
20
|
+
if (!referenceElement) {
|
|
21
|
+
throw new Error('useFloatingUI requires a referenceElement parameter')
|
|
22
|
+
}
|
|
23
|
+
if (!floatingElement) {
|
|
24
|
+
throw new Error('useFloatingUI requires a floatingElement parameter')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
autoUpdateOnShow = true
|
|
29
|
+
} = options
|
|
30
|
+
|
|
31
|
+
let finalPositioningOptions
|
|
32
|
+
if (Object.keys(positioningOptions).length === 0) {
|
|
33
|
+
finalPositioningOptions = {
|
|
34
|
+
placement: 'bottom-start',
|
|
35
|
+
middleware: [
|
|
36
|
+
offset(4),
|
|
37
|
+
flip(),
|
|
38
|
+
shift({ padding: 8 })
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
finalPositioningOptions = positioningOptions
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const originalDisconnect = controller.disconnect?.bind(controller) || (() => { })
|
|
46
|
+
|
|
47
|
+
const updatePosition = () => {
|
|
48
|
+
computePosition(
|
|
49
|
+
referenceElement,
|
|
50
|
+
floatingElement,
|
|
51
|
+
finalPositioningOptions
|
|
52
|
+
).then(({ x, y, placement, middlewareData }) => {
|
|
53
|
+
Object.assign(floatingElement.style, {
|
|
54
|
+
left: `${x}px`,
|
|
55
|
+
top: `${y}px`,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (controller.onPositionUpdate) {
|
|
59
|
+
controller.onPositionUpdate({ x, y, placement, middlewareData })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const setupAutoUpdate = () => {
|
|
65
|
+
if (!autoUpdateOnShow) return
|
|
66
|
+
|
|
67
|
+
if (!controller.floatingUICleanup) {
|
|
68
|
+
controller.floatingUICleanup = autoUpdate(
|
|
69
|
+
referenceElement,
|
|
70
|
+
floatingElement,
|
|
71
|
+
updatePosition
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cleanupAutoUpdate = () => {
|
|
77
|
+
if (controller.floatingUICleanup) {
|
|
78
|
+
controller.floatingUICleanup()
|
|
79
|
+
controller.floatingUICleanup = null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const methodsToAdd = {
|
|
84
|
+
updatePosition,
|
|
85
|
+
setupAutoUpdate,
|
|
86
|
+
cleanupAutoUpdate,
|
|
87
|
+
|
|
88
|
+
disconnect() {
|
|
89
|
+
cleanupAutoUpdate()
|
|
90
|
+
originalDisconnect()
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
showWithPositioning() {
|
|
94
|
+
setupAutoUpdate()
|
|
95
|
+
updatePosition()
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
hideWithPositioning() {
|
|
99
|
+
cleanupAutoUpdate()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
Object.assign(controller, methodsToAdd)
|
|
104
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::BaseTag < UI::Base
|
|
4
|
+
ALLOWED_OPTIONS = [:readonly, :disabled, :name, :autofocus, :data].freeze
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def allowed_options
|
|
8
|
+
ALLOWED_OPTIONS
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
prop :form, AppFormBuilder, reader: :private
|
|
13
|
+
prop :method, _Union(Symbol, String), reader: :private
|
|
14
|
+
prop :value, _Any?, reader: :private
|
|
15
|
+
prop :options, Hash, :**, reader: :private
|
|
16
|
+
|
|
17
|
+
def id
|
|
18
|
+
form.field_id(method)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
form.field_name(method)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def value
|
|
26
|
+
return @value if @value
|
|
27
|
+
return if form.object.blank?
|
|
28
|
+
return unless form.object.respond_to?(method)
|
|
29
|
+
|
|
30
|
+
form.object.public_send(method)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def options_with_defaults
|
|
36
|
+
options.with_defaults(default_options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def default_options
|
|
40
|
+
{id:, name:, value:, data:}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Checkbox < Form::BaseTag
|
|
4
|
+
ALLOWED_OPTIONS = [:multiple, :checked, :include_hidden].freeze
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def allowed_options
|
|
8
|
+
super + ALLOWED_OPTIONS
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
prop :multiple, _Boolean?, default: false, reader: :private
|
|
13
|
+
prop :include_hidden, _Union(_Boolean, String), default: true, reader: :private
|
|
14
|
+
|
|
15
|
+
def view_template
|
|
16
|
+
div do
|
|
17
|
+
input(type: :hidden, name:, value: hidden_value) if include_hidden
|
|
18
|
+
|
|
19
|
+
input(type: :checkbox, class: classes, **options_with_defaults, data:)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def type = :checkbox
|
|
26
|
+
|
|
27
|
+
def name
|
|
28
|
+
return "#{super}[]" if multiple?
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_classes
|
|
34
|
+
"ui-form-checkbox"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def multiple?
|
|
38
|
+
multiple
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_options
|
|
42
|
+
checked_value = if form.object&.respond_to?(method)
|
|
43
|
+
value == form.object.public_send(method)
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
super.merge(checked: checked_value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value
|
|
52
|
+
options[:value]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def include_hidden?
|
|
56
|
+
include_hidden.present?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hidden_value
|
|
60
|
+
include_hidden || value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::FileField < Form::Input
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def type = :file
|
|
7
|
+
|
|
8
|
+
def default_classes
|
|
9
|
+
"text-sm text-gray-900
|
|
10
|
+
file:mr-4 file:max-w-fit file:px-2 file:py-1 file:no-underline file:text-sm file:font-medium file:text-gray-950 file:border file:border-gray-900 file:hover:bg-gray-900 file:hover:text-white file:focus-visible:outline-2 file:focus-visible:outline-offset-2 file:focus-visible:outline-gray-900"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Group < UI::Base
|
|
4
|
+
prop :form, AppFormBuilder, reader: :private
|
|
5
|
+
prop :method, _Union(Symbol, String), reader: :private
|
|
6
|
+
prop :type, _Union(:text), reader: :private
|
|
7
|
+
prop :options, Hash, :**, reader: :private
|
|
8
|
+
|
|
9
|
+
def view_template
|
|
10
|
+
div(class: classes, **data_attributes) do
|
|
11
|
+
render_field
|
|
12
|
+
|
|
13
|
+
div(class: "space-y-0.5") do
|
|
14
|
+
errors.each do |error|
|
|
15
|
+
render Form::Error.new(form:, method:, text: error, **error_options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
render Form::Hint.new(form:, method:, text: hint_text, **hint_options) if hint?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def label_text
|
|
26
|
+
options[:label]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def label_options
|
|
30
|
+
options[:label_options] || {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def label?
|
|
34
|
+
label_text.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def input_options
|
|
38
|
+
options.slice(*input_class.allowed_options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def error?
|
|
42
|
+
return false if form.object.blank?
|
|
43
|
+
|
|
44
|
+
form.object.errors.where(method).any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def errors
|
|
48
|
+
return [] unless error?
|
|
49
|
+
|
|
50
|
+
form.object.errors.where(method).map(&:full_message)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error_options
|
|
54
|
+
options[:error_options] || {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def data_attributes
|
|
58
|
+
return {} unless error?
|
|
59
|
+
|
|
60
|
+
{data: {invalid: true}}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hint_text
|
|
64
|
+
options[:hint]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def hint_options
|
|
68
|
+
options[:hint_options] || {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def hint?
|
|
72
|
+
hint_text.present?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def default_classes
|
|
76
|
+
"space-y-1"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def input_class
|
|
80
|
+
case type
|
|
81
|
+
when :text then Form::TextField
|
|
82
|
+
else
|
|
83
|
+
raise "Type #{type} has no input_class. Add one."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_field
|
|
88
|
+
send("render_#{type}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_text
|
|
92
|
+
div(class: "flex flex-col gap-1") do
|
|
93
|
+
render Form::Label.new(form:, method:, text: label_text, **label_options)
|
|
94
|
+
render Form::TextField.new(form:, method:, data:, **input_options)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Hint < Form::BaseTag
|
|
4
|
+
prop :text, _Nilable(String), reader: :private
|
|
5
|
+
|
|
6
|
+
def view_template
|
|
7
|
+
p(class: classes) { text || method }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def default_classes
|
|
13
|
+
"ui-form-hint"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Label < Form::BaseTag
|
|
4
|
+
prop :text, _Nilable(String), reader: :private
|
|
5
|
+
|
|
6
|
+
def view_template
|
|
7
|
+
label(class: classes, **options_with_defaults) { text || method }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def default_classes
|
|
13
|
+
"ui-form-label"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def default_options
|
|
17
|
+
{for: id}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::RadioButton < Form::BaseTag
|
|
4
|
+
ALLOWED_OPTIONS = [:checked, :tag_value].freeze
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def allowed_options
|
|
8
|
+
super + ALLOWED_OPTIONS
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
prop :tag_value, _Any, reader: :private
|
|
13
|
+
|
|
14
|
+
def view_template
|
|
15
|
+
input(type: :radio, class: classes, data:, **options_with_defaults)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def default_classes
|
|
21
|
+
"ui-form-radio"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def default_options
|
|
25
|
+
checked_value = if form.object&.respond_to?(method)
|
|
26
|
+
value == form.object.public_send(method)
|
|
27
|
+
else
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super.merge(checked: checked_value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def value
|
|
35
|
+
options[:value]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Select < Form::BaseTag
|
|
4
|
+
prop :choices, Array, reader: :private
|
|
5
|
+
prop :selected, _Any?, predicate: :private, reader: :private
|
|
6
|
+
prop :include_blank, _Union(_Boolean, String), default: true, predicate: :private, reader: :private
|
|
7
|
+
prop :prompt, _String?, reader: :private
|
|
8
|
+
prop :disabled, _Any?, reader: :private
|
|
9
|
+
prop :include_hidden, _Boolean, default: true, predicate: :private, reader: :private
|
|
10
|
+
prop :html_options, Hash, default: {}.freeze, reader: :private
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
input(type: :hidden, name:, value: "", autocomplete: "off") if include_hidden?
|
|
14
|
+
select(id:, name:, class: classes, **html_options) do
|
|
15
|
+
if prompt
|
|
16
|
+
option(value: "") { prompt }
|
|
17
|
+
elsif include_blank?
|
|
18
|
+
blank_text = include_blank.is_a?(String) ? include_blank : nil
|
|
19
|
+
option(value: "") { blank_text }
|
|
20
|
+
end
|
|
21
|
+
choices.each do |choice|
|
|
22
|
+
option(value: value_for(choice), selected: selected_for?(choice), disabled: disabled_for?(choice)) { text_for(choice) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def default_classes = "ui-form-select"
|
|
30
|
+
|
|
31
|
+
def value_for(choice) = choice.last
|
|
32
|
+
|
|
33
|
+
def text_for(choice) = choice.first
|
|
34
|
+
|
|
35
|
+
def selected_for?(choice) = value_for(choice) == selected
|
|
36
|
+
|
|
37
|
+
def disabled_for?(choice)
|
|
38
|
+
return false unless disabled
|
|
39
|
+
|
|
40
|
+
if disabled.is_a?(Array)
|
|
41
|
+
disabled.include?(value_for(choice))
|
|
42
|
+
else
|
|
43
|
+
value_for(choice) == disabled
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Textarea < Form::Input
|
|
4
|
+
prop :rows, _Integer?, reader: :private
|
|
5
|
+
prop :autogrow, _Boolean, default: false, predicate: :private, reader: :private
|
|
6
|
+
|
|
7
|
+
def view_template
|
|
8
|
+
textarea(class: classes, **options_with_defaults) do
|
|
9
|
+
value
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def default_classes = "ui-form-control"
|
|
16
|
+
|
|
17
|
+
def default_options
|
|
18
|
+
super.merge(rows:).tap do |opts|
|
|
19
|
+
if autogrow?
|
|
20
|
+
opts[:data] = (opts[:data] || {}).merge(
|
|
21
|
+
controller: "autogrow",
|
|
22
|
+
action: "input->autogrow#input"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Toggle < Form::Checkbox
|
|
4
|
+
def view_template
|
|
5
|
+
label for: options[:id] || id, class: classes do
|
|
6
|
+
input(type: :hidden, name:, value: hidden_value) if include_hidden
|
|
7
|
+
input(type: :checkbox, class: "hidden", data:, **options_with_defaults)
|
|
8
|
+
|
|
9
|
+
div class: toggle_classes do
|
|
10
|
+
render UI::Icon.new(name: :x_mark, size: :xs, class: "group-has-checked:hidden")
|
|
11
|
+
render UI::Icon.new(name: :check, size: :xs, class: "hidden group-has-checked:block")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def default_classes
|
|
19
|
+
"group relative shrink-0 h-4 w-8 block ring ring-gray-800 bg-gray-300
|
|
20
|
+
transition-colors has-disabled:bg-gray-100 has-disabled:ring-gray-300
|
|
21
|
+
hover:bg-gray-400 has-checked:bg-gray-900 has-checked:ring-gray-900
|
|
22
|
+
has-checked:hover:bg-gray-700 has-checked:hover:ring-gray-700
|
|
23
|
+
has-checked:has-disabled:hover:bg-gray-100
|
|
24
|
+
has-checked:has-disabled:hover:ring-gray-300"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def toggle_classes
|
|
28
|
+
"absolute inline-flex items-center justify-center size-4 ring ring-gray-800
|
|
29
|
+
bg-white transition-all group-has-checked:ring-gray-900
|
|
30
|
+
group-has-checked:translate-x-full group-has-disabled:ring-gray-300
|
|
31
|
+
group-has-disabled:bg-gray-200 group-has-checked:group-hover:ring-gray-700
|
|
32
|
+
group-has-disabled:text-gray-600
|
|
33
|
+
group-has-checked:group-has-disabled:group-hover:ring-gray-400"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class UI::Accordion < UI::Base
|
|
4
|
+
prop :content_class, _Nilable(String), reader: :private
|
|
5
|
+
prop :size, _Union("sm", "md", "lg"), default: :md, reader: :private do |value|
|
|
6
|
+
value.to_s.inquiry
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(**props)
|
|
10
|
+
super
|
|
11
|
+
@items = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def item(heading:, open: false, icon: nil, &block)
|
|
15
|
+
@items << {heading:, open:, icon:, block:}
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def view_template(&block)
|
|
20
|
+
vanish(&block) if block_given?
|
|
21
|
+
|
|
22
|
+
div(class: classes) do
|
|
23
|
+
@items.each do |item|
|
|
24
|
+
details(class: "group", open: item[:open]) do
|
|
25
|
+
summary(class: summary_classes) do
|
|
26
|
+
span(class: "inline-flex items-center gap-2") do
|
|
27
|
+
plain item[:heading]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
div(class: "text-gray-900") do
|
|
31
|
+
render Icon(name: :chevron_down, variant: "outline", class: "size-3 block transition-all duration-300 group-open:rotate-180")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
div(class: content_classes) do
|
|
35
|
+
item[:block].call
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def default_classes
|
|
45
|
+
"max-w-lg divide-y divide-gray-200 bg-white rounded-lg"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def summary_classes
|
|
49
|
+
class_names(
|
|
50
|
+
"flex cursor-pointer list-none items-center justify-between text-primary-900",
|
|
51
|
+
"py-2 px-3 text-lg font-medium": size.lg?,
|
|
52
|
+
"py-1 px-2 text-sm": size.md?,
|
|
53
|
+
"py-1 px-1.5 text-sm": size.sm?
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def content_classes
|
|
58
|
+
TAILWIND_MERGER.merge(
|
|
59
|
+
[class_names(
|
|
60
|
+
"text-gray-900",
|
|
61
|
+
"pb-4 px-3": size.lg?,
|
|
62
|
+
"pb-2 px-2 text-sm": size.md?,
|
|
63
|
+
"pb-1 px-1.5 text-xs": size.sm?
|
|
64
|
+
), content_class].join(" ")
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|