proscenium-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/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/lib/proscenium/ui/badge/index.css +75 -0
  5. data/lib/proscenium/ui/badge.rb +32 -0
  6. data/lib/proscenium/ui/breadcrumbs/computed_element.rb +71 -0
  7. data/lib/proscenium/ui/breadcrumbs/control.rb +103 -0
  8. data/lib/proscenium/ui/breadcrumbs/element.rb +16 -0
  9. data/lib/proscenium/ui/breadcrumbs/index.css +84 -0
  10. data/lib/proscenium/ui/breadcrumbs.rb +136 -0
  11. data/lib/proscenium/ui/combobox/index.css +162 -0
  12. data/lib/proscenium/ui/combobox/index.js +420 -0
  13. data/lib/proscenium/ui/combobox.rb +186 -0
  14. data/lib/proscenium/ui/component.rb +22 -0
  15. data/lib/proscenium/ui/flash/index.css +1 -0
  16. data/lib/proscenium/ui/flash/index.js +77 -0
  17. data/lib/proscenium/ui/flash.rb +15 -0
  18. data/lib/proscenium/ui/form/field_methods.rb +95 -0
  19. data/lib/proscenium/ui/form/fields/base.rb +189 -0
  20. data/lib/proscenium/ui/form/fields/checkbox/index.jsx +48 -0
  21. data/lib/proscenium/ui/form/fields/checkbox/index.module.css +9 -0
  22. data/lib/proscenium/ui/form/fields/checkbox/previews/basic.jsx +8 -0
  23. data/lib/proscenium/ui/form/fields/checkbox.rb +32 -0
  24. data/lib/proscenium/ui/form/fields/combobox.rb +117 -0
  25. data/lib/proscenium/ui/form/fields/date.module.css +27 -0
  26. data/lib/proscenium/ui/form/fields/datetime.rb +15 -0
  27. data/lib/proscenium/ui/form/fields/hidden.rb +9 -0
  28. data/lib/proscenium/ui/form/fields/input/index.jsx +71 -0
  29. data/lib/proscenium/ui/form/fields/input/index.module.css +13 -0
  30. data/lib/proscenium/ui/form/fields/input/previews/basic.jsx +8 -0
  31. data/lib/proscenium/ui/form/fields/input.rb +14 -0
  32. data/lib/proscenium/ui/form/fields/radio_group.rb +175 -0
  33. data/lib/proscenium/ui/form/fields/radio_input/index.jsx +44 -0
  34. data/lib/proscenium/ui/form/fields/radio_input/index.module.css +13 -0
  35. data/lib/proscenium/ui/form/fields/radio_input/previews/basic.jsx +8 -0
  36. data/lib/proscenium/ui/form/fields/radio_input.rb +17 -0
  37. data/lib/proscenium/ui/form/fields/rich_textarea.css +23 -0
  38. data/lib/proscenium/ui/form/fields/rich_textarea.js +6 -0
  39. data/lib/proscenium/ui/form/fields/rich_textarea.rb +18 -0
  40. data/lib/proscenium/ui/form/fields/select.jsx +47 -0
  41. data/lib/proscenium/ui/form/fields/select.module.css +46 -0
  42. data/lib/proscenium/ui/form/fields/select.rb +302 -0
  43. data/lib/proscenium/ui/form/fields/tel.css +297 -0
  44. data/lib/proscenium/ui/form/fields/tel.js +83 -0
  45. data/lib/proscenium/ui/form/fields/tel.rb +54 -0
  46. data/lib/proscenium/ui/form/fields/textarea/index.jsx +50 -0
  47. data/lib/proscenium/ui/form/fields/textarea/index.module.css +13 -0
  48. data/lib/proscenium/ui/form/fields/textarea/previews/basic.jsx +8 -0
  49. data/lib/proscenium/ui/form/fields/textarea.rb +18 -0
  50. data/lib/proscenium/ui/form/index.css +52 -0
  51. data/lib/proscenium/ui/form/translation.rb +71 -0
  52. data/lib/proscenium/ui/form.rb +197 -0
  53. data/lib/proscenium/ui/railtie.rb +23 -0
  54. data/lib/proscenium/ui/ujs/class.js +15 -0
  55. data/lib/proscenium/ui/ujs/data_confirm.js +23 -0
  56. data/lib/proscenium/ui/ujs/data_disable_with.js +68 -0
  57. data/lib/proscenium/ui/ujs/index.js +10 -0
  58. data/lib/proscenium/ui/version.rb +7 -0
  59. data/lib/proscenium/ui.rb +36 -0
  60. metadata +177 -0
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium::UI
4
+ class Combobox < Component
5
+ register_element :pui_combobox
6
+
7
+ # @param options [Array] List of options. Each element can be a String, a [label, value]
8
+ # Array, or a Hash with :label and :value keys.
9
+ prop :options, _Array(_Any), default: -> { [] }
10
+
11
+ # @param src [String, nil] URL for async option fetching. When set, options are loaded
12
+ # remotely as the user types.
13
+ prop :src, _String?
14
+
15
+ # @param multiple [Boolean] Whether multiple values can be selected.
16
+ prop :multiple, _Boolean, default: -> { false }
17
+
18
+ # @param placeholder [String, nil] Placeholder text for the input.
19
+ prop :placeholder, _String?
20
+
21
+ # @param name [String, nil] The form field name for the hidden input(s).
22
+ prop :name, _String?
23
+
24
+ # @param value [String, Array<String>, nil] The currently selected value(s).
25
+ prop :value, _Union(String, _Array(String), NilClass), default: -> {}
26
+
27
+ # @param min_chars [Integer] Minimum characters before filtering or fetching begins.
28
+ prop :min_chars, Integer, default: -> { 0 }
29
+
30
+ # @param debounce [Integer] Debounce delay in milliseconds for async search.
31
+ prop :debounce, Integer, default: -> { 300 }
32
+
33
+ # @param disabled [Boolean] Whether the combobox is disabled.
34
+ prop :disabled, _Boolean, default: -> { false }
35
+
36
+ # @param selected_options [Array] Pre-resolved selected options for multi-select, useful when
37
+ # the selected values are not present in the initial options list.
38
+ prop :selected_options, _Array(_Any), default: -> { [] }
39
+
40
+ def self.source_path
41
+ super.sub_ext('').join('index.rb')
42
+ end
43
+
44
+ def view_template
45
+ @uid = "pui-cb-#{object_id}"
46
+
47
+ data = {}
48
+ data[:multiple] = '' if @multiple
49
+ data[:src] = @src if @src
50
+ data[:min_chars] = @min_chars if @min_chars.positive?
51
+ data[:debounce] = @debounce if @debounce != 300
52
+
53
+ pui_combobox(data:, **(@disabled ? { disabled: '' } : {})) do
54
+ hidden_inputs
55
+ tags_container if @multiple
56
+ combobox_input
57
+ listbox
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def hidden_inputs
64
+ if @multiple
65
+ input(type: :hidden, name: @name, value: '')
66
+ current_values.each do |val|
67
+ input(type: :hidden, name: "#{@name}[]", value: val)
68
+ end
69
+ else
70
+ input(type: :hidden, name: @name, value: current_single_value)
71
+ end
72
+ end
73
+
74
+ def tags_container
75
+ div(part: :tags) do
76
+ selected_items.each do |item|
77
+ span(part: :tag, data: { value: item[:value] }) do
78
+ plain item[:label]
79
+ whitespace
80
+ button(part: 'tag-remove', type: :button,
81
+ aria_label: "Remove #{item[:label]}") { plain "\u00D7" }
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def combobox_input
88
+ input(
89
+ type: :text,
90
+ role: :combobox,
91
+ part: :input,
92
+ autocomplete: :off,
93
+ aria_autocomplete: :list,
94
+ aria_expanded: 'false',
95
+ aria_controls: "#{@uid}-listbox",
96
+ placeholder: @placeholder,
97
+ value: initial_input_value,
98
+ **(@disabled ? { disabled: true } : {})
99
+ )
100
+
101
+ return if @multiple
102
+
103
+ button(
104
+ part: :clear,
105
+ type: :button,
106
+ tabindex: -1,
107
+ aria_label: 'Clear selection',
108
+ hidden: initial_input_value.nil?,
109
+ **(@disabled ? { disabled: true } : {})
110
+ ) { plain "\u00D7" }
111
+
112
+ button(
113
+ part: :toggle,
114
+ type: :button,
115
+ tabindex: -1,
116
+ aria_label: 'Toggle options',
117
+ hidden: !!@src,
118
+ **(@disabled ? { disabled: true } : {})
119
+ ) { plain "\u25BE" }
120
+ end
121
+
122
+ def listbox
123
+ ul(role: :listbox, part: :listbox, id: "#{@uid}-listbox", hidden: true) do
124
+ normalized_options.each_with_index do |opt, i|
125
+ li(
126
+ role: :option,
127
+ id: "#{@uid}-option-#{i}",
128
+ data: { value: opt[:value] },
129
+ aria_selected: option_selected?(opt[:value]) ? 'true' : nil
130
+ ) { opt[:label] }
131
+ end
132
+ end
133
+ end
134
+
135
+ def initial_input_value
136
+ return if @multiple
137
+
138
+ selected = normalized_options.find { |opt| option_selected?(opt[:value]) }
139
+ selected&.fetch(:label)
140
+ end
141
+
142
+ def normalized_options
143
+ @normalized_options ||= @options.map do |opt|
144
+ case opt
145
+ when String
146
+ { label: opt, value: opt }
147
+ when Array
148
+ { label: opt[0], value: opt[1].to_s }
149
+ when Hash
150
+ { label: opt[:label], value: opt[:value].to_s }
151
+ end
152
+ end
153
+ end
154
+
155
+ def current_values
156
+ case @value
157
+ when Array then @value
158
+ when String then [@value]
159
+ else []
160
+ end
161
+ end
162
+
163
+ def current_single_value
164
+ case @value
165
+ when String then @value
166
+ else ''
167
+ end
168
+ end
169
+
170
+ def option_selected?(val)
171
+ current_values.include?(val.to_s)
172
+ end
173
+
174
+ def selected_items
175
+ if @selected_options.any?
176
+ @selected_options.filter_map do |opt|
177
+ case opt
178
+ when Hash then { label: opt[:label], value: opt[:value].to_s }
179
+ end
180
+ end
181
+ else
182
+ normalized_options.select { |opt| option_selected?(opt[:value]) }
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'proscenium/phlex'
4
+ require 'literal'
5
+
6
+ module Proscenium::UI
7
+ extend Phlex::Kit
8
+
9
+ class Component < Phlex::HTML
10
+ extend Literal::Properties
11
+ include Proscenium::Phlex::Sideload
12
+ include Proscenium::Phlex::CssModules
13
+
14
+ self.abstract_class = true
15
+ sideload_assets js: { type: 'module' }
16
+
17
+ delegate :controller, to: :view_context
18
+
19
+ # Support Phlex v1 and 2
20
+ alias view_context helpers if Gem::Version.new(Phlex::VERSION) < Gem::Version.new('2')
21
+ end
22
+ end
@@ -0,0 +1 @@
1
+ @import "sourdough-toast/src/sourdough-toast.css";
@@ -0,0 +1,77 @@
1
+ import domMutations from "dom-mutations";
2
+ import { Sourdough, toast } from "sourdough-toast";
3
+
4
+ export function foo() {
5
+ console.log("foo");
6
+ }
7
+
8
+ class HueFlash extends HTMLElement {
9
+ static observedAttributes = ["data-flash-alert", "data-flash-notice"];
10
+
11
+ connectedCallback() {
12
+ this.#initSourdough();
13
+ }
14
+
15
+ async #initSourdough() {
16
+ if ("sourdoughBooted" in window) return;
17
+
18
+ const sourdough = new Sourdough({
19
+ richColors: true,
20
+ yPosition: "bottom",
21
+ xPosition: "center",
22
+ });
23
+ sourdough.boot();
24
+ window.sourdoughBooted = true;
25
+
26
+ // Watch for changes to htl:flashes meta tag
27
+ const flashesSelector = "meta[name='rails:flashes']";
28
+ for await (const mutation of domMutations(document.head, {
29
+ childList: true,
30
+ subtree: true,
31
+ attributes: true,
32
+ })) {
33
+ let $ele = null;
34
+
35
+ if (
36
+ mutation.type === "attributes" &&
37
+ mutation.target.nodeName == "META" &&
38
+ mutation.attributeName == "content"
39
+ ) {
40
+ $ele = mutation.target;
41
+ } else if (mutation.type === "childList") {
42
+ for (const node of mutation.addedNodes) {
43
+ if (node.matches(flashesSelector)) {
44
+ $ele = node;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+
50
+ if ($ele) {
51
+ const flashes = JSON.parse($ele.getAttribute("content"));
52
+ for (const [type, message] of Object.entries(flashes)) {
53
+ if (type === "alert") {
54
+ toast.error(message);
55
+ } else if (type === "notice") {
56
+ toast.success(message);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ attributeChangedCallback(name, _oldValue, newValue) {
64
+ this.#initSourdough();
65
+
66
+ if (newValue === null) return;
67
+
68
+ if (name === "data-flash-alert") {
69
+ toast.warning(newValue);
70
+ } else if (name === "data-flash-notice") {
71
+ toast.success(newValue);
72
+ }
73
+ }
74
+ }
75
+
76
+ !customElements.get("pui-flash") &&
77
+ customElements.define("pui-flash", HueFlash);
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium::UI
4
+ class Flash < Component
5
+ register_element :pui_flash
6
+
7
+ def self.source_path
8
+ super / '../flash/index.rb'
9
+ end
10
+
11
+ def view_template
12
+ pui_flash data: { flash: view_context.flash.to_hash }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Proscenium::UI::Form
4
+ module FieldMethods
5
+ # Renders a hidden input field.
6
+ #
7
+ # @param args [Array<Symbol>] name or nested names of model attribute
8
+ # @param attributes [Hash] passed through to each input
9
+ def hidden_field(*args, **)
10
+ render Fields::Hidden.new(args, @model, self, **)
11
+ end
12
+
13
+ # @param args [Array<Symbol>] name or nested names of model attribute
14
+ # @param attributes [Hash] passed through to each input
15
+ def rich_textarea_field(*args, **attributes)
16
+ merge_bang_attributes! args, attributes
17
+ render Fields::RichTextarea.new(args, @model, self, **attributes)
18
+ end
19
+
20
+ # @param args [Array<Symbol>] name or nested names of model attribute
21
+ # @param attributes [Hash] passed through to each input
22
+ def datetime_local_field(*args, **attributes)
23
+ merge_bang_attributes! args, attributes
24
+ render Fields::Datetime.new(args, @model, self, **attributes)
25
+ end
26
+
27
+ # @param args [Array<Symbol>] name or nested names of model attribute
28
+ # @param attributes [Hash] passed through to each input
29
+ def checkbox_field(*args, **attributes)
30
+ merge_bang_attributes! args, attributes
31
+ render Fields::Checkbox.new(args, @model, self, **attributes)
32
+ end
33
+
34
+ # @param args [Array<Symbol>] name or nested names of model attribute
35
+ # @param attributes [Hash] passed through to each input
36
+ def tel_field(*args, **attributes)
37
+ merge_bang_attributes! args, attributes
38
+ render Fields::Tel.new(args, @model, self, **attributes)
39
+ end
40
+
41
+ # @param args [Array<Symbol>] name or nested names of model attribute
42
+ # @param attributes [Hash] passed through to each input
43
+ def select_field(*args, **attributes, &)
44
+ merge_bang_attributes! args, attributes, additional_bang_attrs: [:typeahead]
45
+ render Fields::Select.new(args, @model, self, **attributes, &)
46
+ end
47
+
48
+ # @see #select_field
49
+ def select_country_field(*args, **attributes)
50
+ merge_bang_attributes! args, attributes
51
+ attributes[:typeahead] = true
52
+ attributes[:options] = '/countries'
53
+ attributes[:component_props] = {
54
+ items_on_search: true,
55
+ input_props: { required: attributes.delete(:required) }
56
+ }
57
+
58
+ select_field(*args, **attributes)
59
+ end
60
+
61
+ # Renders a <textarea> field for the given `attribute`.
62
+ #
63
+ # @param args [Array<Symbol>] name or nested names of model attribute
64
+ # @param attributes [Hash] passed through to each input
65
+ def textarea_field(*args, **attributes)
66
+ merge_bang_attributes! args, attributes
67
+ render Fields::Textarea.new(args, @model, self, **attributes)
68
+ end
69
+
70
+ # Renders a group of radio inputs for each option of the given `field`.
71
+ #
72
+ # @param args [Array<Symbol>] name or nested names of model attribute
73
+ # @param attributes [Hash] passed through to each input
74
+ def radio_group(*args, **attributes)
75
+ attributes[:options] = args.pop if args.last.is_a?(Array)
76
+
77
+ render Fields::RadioGroup.new(args, @model, self, **attributes)
78
+ end
79
+
80
+ def radio_field(...)
81
+ div { radio_input(...) }
82
+ end
83
+
84
+ def radio_input(*args, **)
85
+ render Fields::RadioInput.new(args, @model, self, **)
86
+ end
87
+
88
+ # @param args [Array<Symbol>] name or nested names of model attribute
89
+ # @param attributes [Hash] passed through to each input
90
+ def combobox_field(*args, **attributes)
91
+ merge_bang_attributes! args, attributes
92
+ render Fields::Combobox.new(args, @model, self, **attributes)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Proscenium::UI
6
+ module Form::Fields
7
+ #
8
+ # Abstract class to provide basic rendering of an <input>. All field classes inherit this.
9
+ #
10
+ class Base < Component
11
+ attr_reader :attribute, :model, :form, :attributes
12
+
13
+ register_element :pui_field
14
+
15
+ # In most cases we want to use the main form component stylesheet. Override this method if
16
+ # you want to use a different stylesheet.
17
+ # def self.css_module_path
18
+ # source_path.join('../index.module.css')
19
+ # end
20
+
21
+ # @param attribute [Array]
22
+ # @param model [*]
23
+ # @param form [Proscenium::UI::Form]
24
+ # @param type [Symbol] input type, eg. 'text', 'select'
25
+ # @param error [ActiveModel::Error, String] error message for the attribute.
26
+ # @param attributes [Hash] HTML attributes to pass to the input.
27
+ def initialize(attribute, model, form, type: nil, error: nil, **attributes)
28
+ if attribute.count > 2
29
+ raise ArgumentError, 'attribute cannot be nested more than 2 levels deep'
30
+ end
31
+
32
+ @attribute = attribute
33
+ @model = model
34
+ @form = form
35
+ @field_type = type
36
+ @error = error
37
+ @attributes = attributes
38
+ end
39
+
40
+ private
41
+
42
+ # @return [String] The error message for the attribute.
43
+ def error_message
44
+ @error_message ||= case @error
45
+ when ActiveModel::Error
46
+ @error.message
47
+ when String
48
+ @error
49
+ else
50
+ if model.errors.include?(attribute.join('.'))
51
+ model.errors.where(attribute.join('.')).first&.message
52
+ elsif model.errors.include?(attribute.first)
53
+ model.errors.where(attribute.first).first&.message
54
+ end
55
+ end
56
+ end
57
+
58
+ def error?
59
+ error_message.present?
60
+ end
61
+
62
+ # The main wrapper for the field. This is where the label, input, and error message are
63
+ # rendered.
64
+ #
65
+ # @param tag_name: [Symbol] HTML tag name to use for the wrapper.
66
+ # @param ** [Hash] Additional HTML attributes to pass to the wrapper.
67
+ # @param [Proc] The block to render the field.
68
+ def field(tag_name = :pui_field, **rest, &)
69
+ classes = []
70
+ classes << rest.delete(:class) if rest.key?(:class)
71
+ classes << attributes.delete(:class) if attributes.key?(:class)
72
+
73
+ send(tag_name, class: classes, data: { field_error: error? }, **rest, &)
74
+ end
75
+
76
+ # Builds the template for the label, along with any error message for the attribute.
77
+ #
78
+ # By default, the translated attribute name will be used as the content for the label. You
79
+ # can
80
+ # overide this by providing the `:label` keyword argument in `@arguments`. Passing false as
81
+ # the value to `:label` will omit the label.
82
+ #
83
+ # If a block is given, it will be yielded with the label content, and after the label and
84
+ # error message.
85
+ def label(**kwargs, &block)
86
+ content = attributes.delete(:label)
87
+
88
+ super do
89
+ captured = capture do
90
+ div do
91
+ span { content || translate_label } if content != false
92
+ error? && span(part: :error) { error_message }
93
+ end
94
+ end
95
+
96
+ if !block
97
+ yield_content_with_no_args { captured }
98
+ elsif block.arity == 1
99
+ yield captured
100
+ else
101
+ yield_content_with_no_args { captured }
102
+ yield
103
+ end
104
+ end
105
+ end
106
+
107
+ def hint(content = nil)
108
+ content ||= attributes.delete(:hint)
109
+
110
+ return if content == false
111
+
112
+ content ||= translate(:hints)
113
+ content.present? && div(part: :hint) { unsafe_raw content }
114
+ end
115
+
116
+ def field_type
117
+ @field_type ||= self.class.name.demodulize.underscore
118
+ end
119
+
120
+ def field_name(*names, multiple: false)
121
+ names.prepend attribute.last
122
+
123
+ if nested?
124
+ if nested_attributes_association?
125
+ names.prepend "#{attribute.first}_attributes"
126
+ else
127
+ names.prepend attribute.first
128
+ end
129
+ elsif names.one? && names.first.is_a?(String)
130
+ return names.first
131
+ end
132
+
133
+ form.field_name(*names, multiple:)
134
+ end
135
+
136
+ def field_id(*)
137
+ @field_uid ||= SecureRandom.alphanumeric(10)
138
+ form.field_id(*attribute, @field_uid, *)
139
+ end
140
+
141
+ def translate(namespace, postfix: nil, default: '')
142
+ form.translate namespace, attribute, postfix:, default:
143
+ end
144
+
145
+ def translate_label(default: nil)
146
+ form.translate_label attribute, default:
147
+ end
148
+
149
+ def build_attributes(**attrs)
150
+ attributes.merge(attrs).tap do |x|
151
+ x[:value] ||= value.to_s
152
+ end
153
+ end
154
+
155
+ def value
156
+ attr = attribute.last
157
+ if actual_model.respond_to?(attr)
158
+ actual_model.public_send(attribute.last)
159
+ else
160
+ ''
161
+ end
162
+ end
163
+
164
+ # @return [Boolean] true if the attribute is nested, otherwise false.
165
+ def nested?
166
+ attribute.many?
167
+ end
168
+
169
+ def nested_attributes_association?
170
+ parent_model.respond_to?(:"#{attribute.first}_attributes=")
171
+ end
172
+
173
+ # @return the nested model if nested, otherwise nil.
174
+ def nested_model
175
+ @nested_model ||= nested? ? model.public_send(attribute.first) : nil
176
+ end
177
+
178
+ def actual_model
179
+ @actual_model ||= nested_model || model
180
+ end
181
+
182
+ alias parent_model model
183
+
184
+ def virtual_path
185
+ @virtual_path ||= Proscenium::Resolver.resolve self.class.source_path.sub_ext('.jsx').to_s
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,48 @@
1
+ import clsx from 'clsx'
2
+ import PropTypes from 'prop-types'
3
+
4
+ import dsx from '/hue/lib/hue/utils/dsx'
5
+ import { useFormError } from '../../hooks'
6
+
7
+ import styles from './index.module.css'
8
+
9
+ const Component = ({ label, hint, className, errorAttrName, ...props }) => {
10
+ const [error, hasError] = useFormError(errorAttrName || props.name)
11
+
12
+ return (
13
+ <div className={clsx(styles.fieldWrapper, className)} {...dsx({ fieldError: hasError })}>
14
+ <label>
15
+ <input type="hidden" value="0" name={props.name} />
16
+ <input type="checkbox" value="1" {...props} />
17
+
18
+ <span>
19
+ {label ? <span>{label}</span> : null}
20
+ {hasError ? <span>{error}</span> : null}
21
+ </span>
22
+ </label>
23
+
24
+ {hint ? <div className={styles.hint}>{hint}</div> : null}
25
+ </div>
26
+ )
27
+ }
28
+
29
+ Component.displayName = 'Hue.Form.Fields.Checkbox'
30
+ Component.propTypes = {
31
+ name: PropTypes.string.isRequired,
32
+
33
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.element]),
34
+
35
+ // Custom class name. This will be appended to the default class.
36
+ className: PropTypes.string,
37
+
38
+ // The name of the attribute to use for the error message. Default: 'props.name'.
39
+ errorAttrName: PropTypes.string,
40
+
41
+ id: PropTypes.string,
42
+ hint: PropTypes.string,
43
+ disabled: PropTypes.bool
44
+
45
+ // All remaining non-descript props will be forwarded to the <input> element.
46
+ }
47
+
48
+ export default Component
@@ -0,0 +1,9 @@
1
+ @layer pui {
2
+ .fieldWrapper {
3
+ @mixin checkbox from url('/hue/lib/hue/mixins/checkbox.mixin.css');
4
+ }
5
+
6
+ .hint {
7
+ @mixin fieldHint from url('/hue/lib/hue/mixins/field.mixin.css');
8
+ }
9
+ }
@@ -0,0 +1,8 @@
1
+ import Checkbox from '../'
2
+
3
+ const Component = () => {
4
+ return <Checkbox name="awesome" label="I am awesome?" />
5
+ }
6
+ Component.displayName = 'Hue.Form.Fields.Checkbox.Previews.Basic'
7
+
8
+ export default Component
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium::UI::Form::Fields
4
+ # Renders a checkbox field similar to how Rails handles it.
5
+ #
6
+ # A predicate attribute name can be given:
7
+ #
8
+ # checkbox_field :active?
9
+ #
10
+ class Checkbox < Base
11
+ register_element :pui_checkbox
12
+
13
+ def view_template
14
+ checked = ActiveModel::Type::Boolean.new.cast(value.nil? ? false : value)
15
+
16
+ checked_value = attributes.delete(:checked_value) || '1'
17
+ unchecked_value = attributes.delete(:unchecked_value) || '0'
18
+
19
+ # TODO: use component
20
+ # render Proscenium::UI::Fields::Checkbox::Component.new field_name, checked:
21
+
22
+ field :pui_checkbox do
23
+ label do |content|
24
+ input(name: field_name, type: :hidden, value: unchecked_value, **attributes)
25
+ input(name: field_name, type: :checkbox, value: checked_value, checked:, **attributes)
26
+ yield_content_with_no_args { content }
27
+ end
28
+ hint
29
+ end
30
+ end
31
+ end
32
+ end