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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/lib/proscenium/ui/badge/index.css +75 -0
- data/lib/proscenium/ui/badge.rb +32 -0
- data/lib/proscenium/ui/breadcrumbs/computed_element.rb +71 -0
- data/lib/proscenium/ui/breadcrumbs/control.rb +103 -0
- data/lib/proscenium/ui/breadcrumbs/element.rb +16 -0
- data/lib/proscenium/ui/breadcrumbs/index.css +84 -0
- data/lib/proscenium/ui/breadcrumbs.rb +136 -0
- data/lib/proscenium/ui/combobox/index.css +162 -0
- data/lib/proscenium/ui/combobox/index.js +420 -0
- data/lib/proscenium/ui/combobox.rb +186 -0
- data/lib/proscenium/ui/component.rb +22 -0
- data/lib/proscenium/ui/flash/index.css +1 -0
- data/lib/proscenium/ui/flash/index.js +77 -0
- data/lib/proscenium/ui/flash.rb +15 -0
- data/lib/proscenium/ui/form/field_methods.rb +95 -0
- data/lib/proscenium/ui/form/fields/base.rb +189 -0
- data/lib/proscenium/ui/form/fields/checkbox/index.jsx +48 -0
- data/lib/proscenium/ui/form/fields/checkbox/index.module.css +9 -0
- data/lib/proscenium/ui/form/fields/checkbox/previews/basic.jsx +8 -0
- data/lib/proscenium/ui/form/fields/checkbox.rb +32 -0
- data/lib/proscenium/ui/form/fields/combobox.rb +117 -0
- data/lib/proscenium/ui/form/fields/date.module.css +27 -0
- data/lib/proscenium/ui/form/fields/datetime.rb +15 -0
- data/lib/proscenium/ui/form/fields/hidden.rb +9 -0
- data/lib/proscenium/ui/form/fields/input/index.jsx +71 -0
- data/lib/proscenium/ui/form/fields/input/index.module.css +13 -0
- data/lib/proscenium/ui/form/fields/input/previews/basic.jsx +8 -0
- data/lib/proscenium/ui/form/fields/input.rb +14 -0
- data/lib/proscenium/ui/form/fields/radio_group.rb +175 -0
- data/lib/proscenium/ui/form/fields/radio_input/index.jsx +44 -0
- data/lib/proscenium/ui/form/fields/radio_input/index.module.css +13 -0
- data/lib/proscenium/ui/form/fields/radio_input/previews/basic.jsx +8 -0
- data/lib/proscenium/ui/form/fields/radio_input.rb +17 -0
- data/lib/proscenium/ui/form/fields/rich_textarea.css +23 -0
- data/lib/proscenium/ui/form/fields/rich_textarea.js +6 -0
- data/lib/proscenium/ui/form/fields/rich_textarea.rb +18 -0
- data/lib/proscenium/ui/form/fields/select.jsx +47 -0
- data/lib/proscenium/ui/form/fields/select.module.css +46 -0
- data/lib/proscenium/ui/form/fields/select.rb +302 -0
- data/lib/proscenium/ui/form/fields/tel.css +297 -0
- data/lib/proscenium/ui/form/fields/tel.js +83 -0
- data/lib/proscenium/ui/form/fields/tel.rb +54 -0
- data/lib/proscenium/ui/form/fields/textarea/index.jsx +50 -0
- data/lib/proscenium/ui/form/fields/textarea/index.module.css +13 -0
- data/lib/proscenium/ui/form/fields/textarea/previews/basic.jsx +8 -0
- data/lib/proscenium/ui/form/fields/textarea.rb +18 -0
- data/lib/proscenium/ui/form/index.css +52 -0
- data/lib/proscenium/ui/form/translation.rb +71 -0
- data/lib/proscenium/ui/form.rb +197 -0
- data/lib/proscenium/ui/railtie.rb +23 -0
- data/lib/proscenium/ui/ujs/class.js +15 -0
- data/lib/proscenium/ui/ujs/data_confirm.js +23 -0
- data/lib/proscenium/ui/ujs/data_disable_with.js +68 -0
- data/lib/proscenium/ui/ujs/index.js +10 -0
- data/lib/proscenium/ui/version.rb +7 -0
- data/lib/proscenium/ui.rb +36 -0
- 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,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
|