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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/tailwind/alert.css +35 -0
  3. data/app/assets/tailwind/badge.css +27 -0
  4. data/app/assets/tailwind/button.css +35 -0
  5. data/app/assets/tailwind/form.css +35 -0
  6. data/app/assets/tailwind/link.css +23 -0
  7. data/app/assets/tailwind/modal.css +43 -0
  8. data/app/assets/tailwind/quicksilver_ui/engine.css +7 -0
  9. data/app/assets/tailwind/typography.css +112 -0
  10. data/app/helpers/app_form_builder.rb +94 -0
  11. data/app/helpers/app_form_helper.rb +7 -0
  12. data/app/javascript/controllers/autogrow_controller.js +19 -0
  13. data/app/javascript/controllers/dismissable_controller.js +35 -0
  14. data/app/javascript/controllers/dropdown_controller.js +59 -0
  15. data/app/javascript/controllers/modal_controller.js +45 -0
  16. data/app/javascript/controllers/tabs_controller.js +62 -0
  17. data/app/javascript/mixins/use_floating_ui.js +104 -0
  18. data/app/views/form/base_tag.rb +42 -0
  19. data/app/views/form/checkbox.rb +62 -0
  20. data/app/views/form/date_field.rb +11 -0
  21. data/app/views/form/email_field.rb +7 -0
  22. data/app/views/form/error.rb +15 -0
  23. data/app/views/form/file_field.rb +12 -0
  24. data/app/views/form/group.rb +97 -0
  25. data/app/views/form/hint.rb +15 -0
  26. data/app/views/form/input.rb +11 -0
  27. data/app/views/form/label.rb +19 -0
  28. data/app/views/form/password_field.rb +7 -0
  29. data/app/views/form/phone_field.rb +7 -0
  30. data/app/views/form/radio_button.rb +37 -0
  31. data/app/views/form/search_field.rb +7 -0
  32. data/app/views/form/select.rb +46 -0
  33. data/app/views/form/text_field.rb +7 -0
  34. data/app/views/form/textarea.rb +27 -0
  35. data/app/views/form/toggle.rb +35 -0
  36. data/app/views/ui/accordion.rb +67 -0
  37. data/app/views/ui/alert.rb +84 -0
  38. data/app/views/ui/avatar.rb +57 -0
  39. data/app/views/ui/badge.rb +35 -0
  40. data/app/views/ui/base.rb +29 -0
  41. data/app/views/ui/dropdown/item.rb +49 -0
  42. data/app/views/ui/dropdown.rb +111 -0
  43. data/app/views/ui/icon.rb +46 -0
  44. data/app/views/ui/modal.rb +96 -0
  45. data/app/views/ui/toast.rb +90 -0
  46. data/lib/generators/quicksilver_ui/affordance/affordance_generator.rb +102 -0
  47. data/lib/generators/quicksilver_ui/component/all_generator.rb +32 -0
  48. data/lib/generators/quicksilver_ui/component/component_generator.rb +194 -0
  49. data/lib/generators/quicksilver_ui/form/all_generator.rb +32 -0
  50. data/lib/generators/quicksilver_ui/form/form_generator.rb +164 -0
  51. data/lib/generators/quicksilver_ui/form/templates/app_form_builder.rb +39 -0
  52. data/lib/generators/quicksilver_ui/form/templates/app_form_helper.rb +7 -0
  53. data/lib/generators/quicksilver_ui/install/install_generator.rb +42 -0
  54. data/lib/generators/quicksilver_ui/install/templates/base.rb +29 -0
  55. data/lib/generators/quicksilver_ui/install/templates/initializer.rb +16 -0
  56. data/lib/quicksilver_ui/dependencies.rb +191 -0
  57. data/lib/quicksilver_ui/engine.rb +18 -0
  58. data/lib/quicksilver_ui/version.rb +5 -0
  59. data/lib/quicksilver_ui.rb +37 -0
  60. 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::DateField < Form::Input
4
+ def value
5
+ super&.to_date&.iso8601
6
+ end
7
+
8
+ private
9
+
10
+ def type = :date
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::EmailField < Form::Input
4
+ private
5
+
6
+ def type = :email
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::Error < Form::BaseTag
4
+ prop :text, _Nilable(String), reader: :private
5
+
6
+ def view_template
7
+ p(class: classes) { text }
8
+ end
9
+
10
+ private
11
+
12
+ def default_classes
13
+ "ui-form-error"
14
+ end
15
+ 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::Input < Form::BaseTag
4
+ def view_template
5
+ input(type:, class: classes, **options_with_defaults)
6
+ end
7
+
8
+ private
9
+
10
+ def default_classes = "ui-form-control"
11
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::PasswordField < Form::Input
4
+ private
5
+
6
+ def type = :password
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::PhoneField < Form::Input
4
+ private
5
+
6
+ def type = :tel
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::SearchField < Form::Input
4
+ private
5
+
6
+ def type = :search
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Form::TextField < Form::Input
4
+ private
5
+
6
+ def type = :text
7
+ 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