openproject-primer_view_components 0.92.0 → 0.93.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 +4 -4
- data/CHANGELOG.md +8 -0
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/assets/styles/primer_view_components.css +1 -1
- data/app/assets/styles/primer_view_components.css.map +1 -1
- data/app/components/primer/open_project/color_field.css +1 -0
- data/app/components/primer/open_project/color_field.css.json +18 -0
- data/app/components/primer/open_project/color_field.css.map +1 -0
- data/app/components/primer/open_project/color_field.pcss +70 -0
- data/app/components/primer/open_project/color_field.rb +40 -0
- data/app/components/primer/primer.d.ts +1 -0
- data/app/components/primer/primer.js +1 -0
- data/app/components/primer/primer.pcss +1 -0
- data/app/components/primer/primer.ts +1 -0
- data/app/lib/primer/forms/color_field.html.erb +10 -0
- data/app/lib/primer/forms/color_field.rb +67 -0
- data/app/lib/primer/forms/dsl/color_field_input.rb +97 -0
- data/app/lib/primer/forms/dsl/input_methods.rb +9 -0
- data/app/lib/primer/forms/primer_color_input.d.ts +17 -0
- data/app/lib/primer/forms/primer_color_input.js +138 -0
- data/app/lib/primer/forms/primer_color_input.ts +121 -0
- data/config/locales/en.yml +4 -0
- data/lib/primer/view_components/version.rb +1 -1
- data/lib/primer/yard/component_manifest.rb +2 -1
- data/lib/primer/yard/lookbook_pages_backend.rb +2 -1
- data/previews/primer/open_project/color_field_preview/in_form.html.erb +6 -0
- data/previews/primer/open_project/color_field_preview/in_multi.html.erb +8 -0
- data/previews/primer/open_project/color_field_preview/multiple.html.erb +4 -0
- data/previews/primer/open_project/color_field_preview.rb +108 -0
- data/static/arguments.json +154 -0
- data/static/audited_at.json +1 -0
- data/static/classes.json +3 -0
- data/static/classnames.cjs +4 -0
- data/static/classnames.js +4 -0
- data/static/constants.json +3 -0
- data/static/info_arch.json +295 -0
- data/static/previews.json +138 -0
- data/static/statuses.json +1 -0
- metadata +16 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ColorField{display:block}.ColorField .ColorField-wrap .FormControl-input{padding-inline-start:calc(var(--base-size-4) + var(--base-size-24) + var(--base-size-8))}.ColorField .ColorField-swatch{position:absolute;inset-block:0;inset-inline-start:var(--base-size-4);display:flex;align-items:center}.ColorField .ColorField-picker{width:var(--base-size-24);height:var(--base-size-24);padding:0;cursor:pointer;background-color:var(--bgColor-muted);border:0;border-radius:var(--borderRadius-small);outline:var(--borderWidth-thin) solid var(--borderColor-translucent);outline-offset:calc(var(--borderWidth-thin)*-1);appearance:none;forced-color-adjust:none}:is(.ColorField .ColorField-picker):disabled{cursor:default}:is(.ColorField .ColorField-picker):focus-visible{outline:2px solid var(--focus-outlineColor);outline-offset:-2px;box-shadow:none}:is(.ColorField .ColorField-picker)::-webkit-color-swatch-wrapper{padding:0}:is(.ColorField .ColorField-picker)::-webkit-color-swatch{border:0;border-radius:var(--borderRadius-small)}:is(.ColorField .ColorField-picker)::-moz-color-swatch{border:0;border-radius:var(--borderRadius-small)}.ColorField[data-blank] .ColorField-picker{outline-style:dashed}.ColorField[data-empty] .ColorField-picker::-webkit-color-swatch{opacity:0}.ColorField[data-empty] .ColorField-picker::-moz-color-swatch{opacity:0}.ColorField:not(:defined) .ColorField-picker:disabled{opacity:1}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "open_project/color_field",
|
|
3
|
+
"selectors": [
|
|
4
|
+
".ColorField",
|
|
5
|
+
".ColorField .ColorField-wrap .FormControl-input",
|
|
6
|
+
".ColorField .ColorField-swatch",
|
|
7
|
+
".ColorField .ColorField-picker",
|
|
8
|
+
":is(.ColorField .ColorField-picker):disabled",
|
|
9
|
+
":is(.ColorField .ColorField-picker):focus-visible",
|
|
10
|
+
":is(.ColorField .ColorField-picker)::-webkit-color-swatch-wrapper",
|
|
11
|
+
":is(.ColorField .ColorField-picker)::-webkit-color-swatch",
|
|
12
|
+
":is(.ColorField .ColorField-picker)::-moz-color-swatch",
|
|
13
|
+
".ColorField[data-blank] .ColorField-picker",
|
|
14
|
+
".ColorField[data-empty] .ColorField-picker::-webkit-color-swatch",
|
|
15
|
+
".ColorField[data-empty] .ColorField-picker::-moz-color-swatch",
|
|
16
|
+
".ColorField:not(:defined) .ColorField-picker:disabled"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["color_field.pcss","../../../../lib/postcss_mixins/focusOutline.pcss"],"names":[],"mappings":"AAAA,YACE,aAoEF,CAlEE,gDACE,wFACF,CAEA,+BACE,iBAAkB,CAClB,aAAc,CACd,qCAAsC,CACtC,YAAa,CACb,kBACF,CAEA,+BACE,yBAA0B,CAC1B,0BAA2B,CAC3B,SAAU,CACV,cAAe,CACf,qCAAsC,CACtC,QAAS,CACT,uCAAwC,CAIxC,oEAAqE,CACrE,+CAAkD,CAClD,eAAgB,CAChB,wBAuBF,CArBE,6CACE,cACF,CAEA,kDClCF,2CAAgC,CAChC,mBAA8B,CAC9B,eDkCE,CAEA,kEACE,SACF,CAEA,0DACE,QAAS,CACT,uCACF,CAEA,uDACE,QAAS,CACT,uCACF,CAGF,2CACE,oBACF,CAEA,iEACE,SACF,CAEA,8DACE,SACF,CAEA,sDACE,SACF","file":"color_field.css","sourcesContent":[".ColorField {\n display: block;\n\n & .ColorField-wrap .FormControl-input {\n padding-inline-start: calc(var(--base-size-4) + var(--base-size-24) + var(--base-size-8));\n }\n\n & .ColorField-swatch {\n position: absolute;\n inset-block: 0;\n inset-inline-start: var(--base-size-4);\n display: flex;\n align-items: center;\n }\n\n & .ColorField-picker {\n width: var(--base-size-24);\n height: var(--base-size-24);\n padding: 0;\n cursor: pointer;\n background-color: var(--bgColor-muted);\n border: 0;\n border-radius: var(--borderRadius-small);\n\n /* An outline, not a border: it is painted over the native swatch, so\n the color fills the whole box and the border sits on top of it. */\n outline: var(--borderWidth-thin) solid var(--borderColor-translucent);\n outline-offset: calc(var(--borderWidth-thin) * -1);\n appearance: none;\n forced-color-adjust: none;\n\n &:disabled {\n cursor: default;\n }\n\n &:focus-visible {\n @mixin focusOutline;\n }\n\n &::-webkit-color-swatch-wrapper {\n padding: 0;\n }\n\n &::-webkit-color-swatch {\n border: 0;\n border-radius: var(--borderRadius-small);\n }\n\n &::-moz-color-swatch {\n border: 0;\n border-radius: var(--borderRadius-small);\n }\n }\n\n &[data-blank] .ColorField-picker {\n outline-style: dashed;\n }\n\n &[data-empty] .ColorField-picker::-webkit-color-swatch {\n opacity: 0;\n }\n\n &[data-empty] .ColorField-picker::-moz-color-swatch {\n opacity: 0;\n }\n\n &:not(:defined) .ColorField-picker:disabled {\n opacity: 1;\n }\n}\n","@define-mixin focusOutline $outlineOffset: -2px, $outlineColor: var(--focus-outlineColor) {\n outline: 2px solid $outlineColor;\n outline-offset: $outlineOffset;\n box-shadow: none;\n}\n"]}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
.ColorField {
|
|
2
|
+
display: block;
|
|
3
|
+
|
|
4
|
+
& .ColorField-wrap .FormControl-input {
|
|
5
|
+
padding-inline-start: calc(var(--base-size-4) + var(--base-size-24) + var(--base-size-8));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
& .ColorField-swatch {
|
|
9
|
+
position: absolute;
|
|
10
|
+
inset-block: 0;
|
|
11
|
+
inset-inline-start: var(--base-size-4);
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
& .ColorField-picker {
|
|
17
|
+
width: var(--base-size-24);
|
|
18
|
+
height: var(--base-size-24);
|
|
19
|
+
padding: 0;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
background-color: var(--bgColor-muted);
|
|
22
|
+
border: 0;
|
|
23
|
+
border-radius: var(--borderRadius-small);
|
|
24
|
+
|
|
25
|
+
/* An outline, not a border: it is painted over the native swatch, so
|
|
26
|
+
the color fills the whole box and the border sits on top of it. */
|
|
27
|
+
outline: var(--borderWidth-thin) solid var(--borderColor-translucent);
|
|
28
|
+
outline-offset: calc(var(--borderWidth-thin) * -1);
|
|
29
|
+
appearance: none;
|
|
30
|
+
forced-color-adjust: none;
|
|
31
|
+
|
|
32
|
+
&:disabled {
|
|
33
|
+
cursor: default;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&:focus-visible {
|
|
37
|
+
@mixin focusOutline;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
&::-webkit-color-swatch-wrapper {
|
|
41
|
+
padding: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
&::-webkit-color-swatch {
|
|
45
|
+
border: 0;
|
|
46
|
+
border-radius: var(--borderRadius-small);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&::-moz-color-swatch {
|
|
50
|
+
border: 0;
|
|
51
|
+
border-radius: var(--borderRadius-small);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
&[data-blank] .ColorField-picker {
|
|
56
|
+
outline-style: dashed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
&[data-empty] .ColorField-picker::-webkit-color-swatch {
|
|
60
|
+
opacity: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&[data-empty] .ColorField-picker::-moz-color-swatch {
|
|
64
|
+
opacity: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
&:not(:defined) .ColorField-picker:disabled {
|
|
68
|
+
opacity: 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module OpenProject
|
|
5
|
+
ColorField = Primer::FormComponents.from_input(Primer::Forms::Dsl::ColorFieldInput)
|
|
6
|
+
|
|
7
|
+
# Color fields are single-line text inputs for hex color codes, paired with a native color
|
|
8
|
+
# picker that doubles as a swatch. They render an `<input type="text">`, which is submitted,
|
|
9
|
+
# and an unnamed `<input type="color">`, which is not. The field can therefore be left blank.
|
|
10
|
+
#
|
|
11
|
+
# Typed text is previewed in the swatch as soon as it is a valid three- or six-digit hex code
|
|
12
|
+
# and is rewritten to uppercase `#RRGGBB` when the field loses focus. Invalid text is left
|
|
13
|
+
# alone; validate it on the server.
|
|
14
|
+
#
|
|
15
|
+
# Space color fields through their container, such as a form group or a stack. As with text
|
|
16
|
+
# fields, system arguments are applied to the text input itself, so a margin passed to the
|
|
17
|
+
# field moves the input away from its swatch.
|
|
18
|
+
#
|
|
19
|
+
# @form_usage
|
|
20
|
+
# class ExampleForm < ApplicationForm
|
|
21
|
+
# form do |example_form|
|
|
22
|
+
# example_form.color_field(attributes)
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
class ColorField < Primer::Component
|
|
26
|
+
status :open_project
|
|
27
|
+
|
|
28
|
+
# @!method initialize
|
|
29
|
+
#
|
|
30
|
+
# @macro form_size_arguments
|
|
31
|
+
# @macro form_full_width_arguments
|
|
32
|
+
# @macro form_input_arguments
|
|
33
|
+
# @macro form_input_width_arguments
|
|
34
|
+
#
|
|
35
|
+
# @param maxlength [Integer] Maximum number of characters the text input accepts. Defaults to 7, the length of `#RRGGBB`.
|
|
36
|
+
# @param placeholder [String] Placeholder text. It is not previewed in the swatch, which stays empty while the field is blank.
|
|
37
|
+
# @param picker_label [String] Accessible name of the native color picker. Defaults to `I18n.t("color_field.picker_label", label: label)`, or to `I18n.t("color_field.picker_label_generic")` when there is no label.
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -23,6 +23,7 @@ import './alpha/tab_container';
|
|
|
23
23
|
import '../../lib/primer/forms/primer_multi_input';
|
|
24
24
|
import '../../lib/primer/forms/primer_text_field';
|
|
25
25
|
import '../../lib/primer/forms/primer_text_area';
|
|
26
|
+
import '../../lib/primer/forms/primer_color_input';
|
|
26
27
|
import '../../lib/primer/forms/toggle_switch_input';
|
|
27
28
|
import './alpha/action_menu/action_menu_element';
|
|
28
29
|
import './alpha/select_panel_element';
|
|
@@ -23,6 +23,7 @@ import './alpha/tab_container';
|
|
|
23
23
|
import '../../lib/primer/forms/primer_multi_input';
|
|
24
24
|
import '../../lib/primer/forms/primer_text_field';
|
|
25
25
|
import '../../lib/primer/forms/primer_text_area';
|
|
26
|
+
import '../../lib/primer/forms/primer_color_input';
|
|
26
27
|
import '../../lib/primer/forms/toggle_switch_input';
|
|
27
28
|
import './alpha/action_menu/action_menu_element';
|
|
28
29
|
import './alpha/select_panel_element';
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
@import "./open_project/drag_handle.pcss";
|
|
51
51
|
@import "./open_project/border_grid.pcss";
|
|
52
52
|
@import "./open_project/input_group.pcss";
|
|
53
|
+
@import "./open_project/color_field.pcss";
|
|
53
54
|
@import "./open_project/inline_message.pcss";
|
|
54
55
|
@import "./open_project/sub_header.pcss";
|
|
55
56
|
@import "./open_project/side_panel/section.pcss";
|
|
@@ -23,6 +23,7 @@ import './alpha/tab_container'
|
|
|
23
23
|
import '../../lib/primer/forms/primer_multi_input'
|
|
24
24
|
import '../../lib/primer/forms/primer_text_field'
|
|
25
25
|
import '../../lib/primer/forms/primer_text_area'
|
|
26
|
+
import '../../lib/primer/forms/primer_color_input'
|
|
26
27
|
import '../../lib/primer/forms/toggle_switch_input'
|
|
27
28
|
import './alpha/action_menu/action_menu_element'
|
|
28
29
|
import './alpha/select_panel_element'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= render(FormControl.new(input: @input)) do %>
|
|
2
|
+
<%= content_tag(:"primer-color-input", **@element_arguments) do %>
|
|
3
|
+
<%= content_tag(:div, **@field_wrap_arguments) do %>
|
|
4
|
+
<%= builder.text_field(@input.name, **@input.input_arguments) %>
|
|
5
|
+
<span class="ColorField-swatch">
|
|
6
|
+
<%= picker_tag %>
|
|
7
|
+
</span>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module Forms
|
|
5
|
+
# :nodoc:
|
|
6
|
+
class ColorField < BaseComponent
|
|
7
|
+
delegate :builder, :form, to: :@input
|
|
8
|
+
|
|
9
|
+
INPUT_WRAP_SIZE = {
|
|
10
|
+
small: "FormControl-input-wrap--small",
|
|
11
|
+
large: "FormControl-input-wrap--large"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(input:)
|
|
15
|
+
@input = input
|
|
16
|
+
|
|
17
|
+
@input.add_input_classes(
|
|
18
|
+
"FormControl-input",
|
|
19
|
+
"FormControl-monospace",
|
|
20
|
+
Primer::Forms::Dsl::Input::SIZE_MAPPINGS[@input.size]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# The custom element is rendered inside FormControl rather than as its
|
|
24
|
+
# tag, so that it survives `form_control: false` (as set by `multi`).
|
|
25
|
+
#
|
|
26
|
+
# Catalyst boolean attributes are true when present, whatever their
|
|
27
|
+
# value, so false must render no attribute at all (nil), not "false".
|
|
28
|
+
@element_arguments = {
|
|
29
|
+
class: "ColorField",
|
|
30
|
+
data: {
|
|
31
|
+
blank: (@input.blank? ? "" : nil),
|
|
32
|
+
empty: (@input.empty? ? "" : nil)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
wrap_classes = [
|
|
37
|
+
"FormControl-input-wrap",
|
|
38
|
+
"ColorField-wrap",
|
|
39
|
+
INPUT_WRAP_SIZE[@input.size]
|
|
40
|
+
]
|
|
41
|
+
wrap_classes << Primer::Forms::Dsl::Input::INPUT_WIDTH_MAPPINGS[@input.input_width] if @input.input_width
|
|
42
|
+
|
|
43
|
+
@field_wrap_arguments = {
|
|
44
|
+
class: class_names(wrap_classes),
|
|
45
|
+
hidden: @input.hidden?
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The native color input. Always rendered disabled: without the
|
|
50
|
+
# controller nothing copies a picked color into the submitted text
|
|
51
|
+
# input, so the controller is what enables it.
|
|
52
|
+
def picker_tag
|
|
53
|
+
@view_context.tag.input(
|
|
54
|
+
type: :color,
|
|
55
|
+
class: "ColorField-picker",
|
|
56
|
+
value: @input.picker_value,
|
|
57
|
+
disabled: true,
|
|
58
|
+
aria: { label: @input.picker_label },
|
|
59
|
+
data: {
|
|
60
|
+
target: "primer-color-input.pickerElement",
|
|
61
|
+
action: "input:primer-color-input#syncInput change:primer-color-input#commit"
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module Forms
|
|
5
|
+
module Dsl
|
|
6
|
+
# :nodoc:
|
|
7
|
+
class ColorFieldInput < Input
|
|
8
|
+
HEX_FORMAT = /\A#?(\h{3}|\h{6})\z/
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :label, :picker_label
|
|
11
|
+
|
|
12
|
+
# Returns the canonical uppercase `#RRGGBB` form of the given text, or
|
|
13
|
+
# `nil` if it is not a three- or six-digit hex color.
|
|
14
|
+
def self.parse_hex(text)
|
|
15
|
+
match = HEX_FORMAT.match(text.to_s.strip)
|
|
16
|
+
return nil unless match
|
|
17
|
+
|
|
18
|
+
digits = match[1]
|
|
19
|
+
digits = digits.gsub(/\h/) { |digit| digit * 2 } if digits.length == 3
|
|
20
|
+
|
|
21
|
+
"##{digits.upcase}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(name:, label:, **system_arguments)
|
|
25
|
+
@name = name
|
|
26
|
+
@label = label
|
|
27
|
+
@picker_label = system_arguments.delete(:picker_label) || default_picker_label
|
|
28
|
+
|
|
29
|
+
caller_target = system_arguments.dig(:data, :target)
|
|
30
|
+
caller_action = system_arguments.dig(:data, :action)
|
|
31
|
+
|
|
32
|
+
super(**system_arguments)
|
|
33
|
+
|
|
34
|
+
input_arguments[:autocomplete] ||= "off"
|
|
35
|
+
# The longest text that parses, `#RRGGBB`.
|
|
36
|
+
input_arguments[:maxlength] ||= 7
|
|
37
|
+
|
|
38
|
+
add_input_data(:target, "primer-color-input.inputElement #{caller_target}".strip)
|
|
39
|
+
add_input_data(
|
|
40
|
+
:action,
|
|
41
|
+
"input:primer-color-input#syncPicker change:primer-color-input#normalize " \
|
|
42
|
+
"paste:primer-color-input#trimPaste #{caller_action}".strip
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
yield(self) if block_given?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The value Rails will render into the text input: the explicit `value:`
|
|
49
|
+
# argument if given, otherwise the attribute of the form's model object.
|
|
50
|
+
def current_value
|
|
51
|
+
builder.text_field_attributes(
|
|
52
|
+
name, object: builder.object, **input_arguments.slice(:value)
|
|
53
|
+
)["value"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def blank?
|
|
57
|
+
current_value.to_s.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The color the native picker starts with: the current value, or `nil`
|
|
61
|
+
# if the field is blank or its value is not a hex color. A placeholder
|
|
62
|
+
# is never previewed.
|
|
63
|
+
def picker_value
|
|
64
|
+
return @picker_value if defined?(@picker_value)
|
|
65
|
+
|
|
66
|
+
@picker_value = blank? ? nil : self.class.parse_hex(current_value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether there is no color to show in the swatch at all.
|
|
70
|
+
def empty?
|
|
71
|
+
picker_value.nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_component
|
|
75
|
+
ColorField.new(input: self)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def type
|
|
79
|
+
:color_field
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def focusable?
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Inputs inside `multi` have no label of their own.
|
|
89
|
+
def default_picker_label
|
|
90
|
+
return I18n.t("color_field.picker_label_generic") if label.blank?
|
|
91
|
+
|
|
92
|
+
I18n.t("color_field.picker_label", label: label)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -86,6 +86,15 @@ module Primer
|
|
|
86
86
|
add_input TextAreaInput.new(builder: builder, form: form, **options, &block)
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Adds a color field to this form.
|
|
90
|
+
#
|
|
91
|
+
# @param options [Hash] The options accepted by the color field input (see forms docs).
|
|
92
|
+
# @param block [Proc] A block that will be yielded a reference to the input object so it can be customized.
|
|
93
|
+
def color_field(**options, &block)
|
|
94
|
+
options = decorate_options(**options)
|
|
95
|
+
add_input ColorFieldInput.new(builder: builder, form: form, **options, &block)
|
|
96
|
+
end
|
|
97
|
+
|
|
89
98
|
# END text input methods
|
|
90
99
|
|
|
91
100
|
# START select input methods
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function parseHex(text: string): string | null;
|
|
2
|
+
export declare class PrimerColorInputElement extends HTMLElement {
|
|
3
|
+
#private;
|
|
4
|
+
inputElement: HTMLInputElement;
|
|
5
|
+
pickerElement: HTMLInputElement;
|
|
6
|
+
blank: boolean;
|
|
7
|
+
empty: boolean;
|
|
8
|
+
connectedCallback(): void;
|
|
9
|
+
disconnectedCallback(): void;
|
|
10
|
+
private syncDisabled;
|
|
11
|
+
syncPicker(): void;
|
|
12
|
+
normalize(): void;
|
|
13
|
+
trimPaste(event: ClipboardEvent): void;
|
|
14
|
+
syncInput(event: Event): void;
|
|
15
|
+
commit(event: Event): void;
|
|
16
|
+
sync(): void;
|
|
17
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
+
};
|
|
12
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
13
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
14
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
15
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
16
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
17
|
+
};
|
|
18
|
+
var _PrimerColorInputElement_abortController, _PrimerColorInputElement_observer;
|
|
19
|
+
import { attr, controller, target } from '@github/catalyst';
|
|
20
|
+
const HEX_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
21
|
+
// Returns the canonical uppercase `#RRGGBB` form of the given text, or null
|
|
22
|
+
// if it is not a three- or six-digit hex color.
|
|
23
|
+
export function parseHex(text) {
|
|
24
|
+
const match = HEX_PATTERN.exec(text.trim());
|
|
25
|
+
if (!match)
|
|
26
|
+
return null;
|
|
27
|
+
let digits = match[1];
|
|
28
|
+
if (digits.length === 3)
|
|
29
|
+
digits = digits.replace(/./g, '$&$&');
|
|
30
|
+
return `#${digits.toUpperCase()}`;
|
|
31
|
+
}
|
|
32
|
+
let PrimerColorInputElement = class PrimerColorInputElement extends HTMLElement {
|
|
33
|
+
constructor() {
|
|
34
|
+
super(...arguments);
|
|
35
|
+
// The text input is empty.
|
|
36
|
+
this.blank = false;
|
|
37
|
+
// There is no color to show in the swatch.
|
|
38
|
+
this.empty = false;
|
|
39
|
+
_PrimerColorInputElement_abortController.set(this, null);
|
|
40
|
+
_PrimerColorInputElement_observer.set(this, null);
|
|
41
|
+
}
|
|
42
|
+
connectedCallback() {
|
|
43
|
+
__classPrivateFieldGet(this, _PrimerColorInputElement_abortController, "f")?.abort();
|
|
44
|
+
const { signal } = (__classPrivateFieldSet(this, _PrimerColorInputElement_abortController, new AbortController(), "f"));
|
|
45
|
+
this.syncDisabled();
|
|
46
|
+
this.sync();
|
|
47
|
+
// The server always renders the picker disabled. Whatever rewrites
|
|
48
|
+
// attributes without reconnecting this element, a Turbo morph for one,
|
|
49
|
+
// puts that back, so the rule is re-applied on every such change.
|
|
50
|
+
__classPrivateFieldGet(this, _PrimerColorInputElement_observer, "f")?.disconnect();
|
|
51
|
+
__classPrivateFieldSet(this, _PrimerColorInputElement_observer, new MutationObserver(() => this.syncDisabled()), "f");
|
|
52
|
+
__classPrivateFieldGet(this, _PrimerColorInputElement_observer, "f").observe(this, { attributes: true, attributeFilter: ['disabled', 'readonly'], subtree: true });
|
|
53
|
+
// The form is outside this element's subtree, so data-action cannot
|
|
54
|
+
// reach it. Reset restores the text value after the event, hence the tick.
|
|
55
|
+
this.inputElement.form?.addEventListener('reset', () => setTimeout(() => this.sync()), { signal });
|
|
56
|
+
}
|
|
57
|
+
disconnectedCallback() {
|
|
58
|
+
__classPrivateFieldGet(this, _PrimerColorInputElement_abortController, "f")?.abort();
|
|
59
|
+
__classPrivateFieldGet(this, _PrimerColorInputElement_observer, "f")?.disconnect();
|
|
60
|
+
}
|
|
61
|
+
// The picker is operable only while its value can reach the submitted text
|
|
62
|
+
// input and that input accepts edits.
|
|
63
|
+
syncDisabled() {
|
|
64
|
+
const disabled = this.inputElement.disabled || this.inputElement.readOnly;
|
|
65
|
+
// Assigning the same value again would still rewrite the attribute and
|
|
66
|
+
// wake the observer, which would then never settle.
|
|
67
|
+
if (this.pickerElement.disabled !== disabled)
|
|
68
|
+
this.pickerElement.disabled = disabled;
|
|
69
|
+
}
|
|
70
|
+
// Text → picker. Bound to `input` on the text input, so the swatch follows
|
|
71
|
+
// every keystroke: text that is not a whole color yet shows none. The
|
|
72
|
+
// picker itself has no empty value and keeps the last color to open on.
|
|
73
|
+
syncPicker() {
|
|
74
|
+
const text = this.inputElement.value.trim();
|
|
75
|
+
const color = parseHex(text);
|
|
76
|
+
this.blank = text === '';
|
|
77
|
+
this.empty = color === null;
|
|
78
|
+
if (color)
|
|
79
|
+
this.pickerElement.value = color.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
// Bound to `change` on the text input.
|
|
82
|
+
normalize() {
|
|
83
|
+
const color = parseHex(this.inputElement.value);
|
|
84
|
+
if (color)
|
|
85
|
+
this.inputElement.value = color;
|
|
86
|
+
}
|
|
87
|
+
// Bound to `paste` on the text input. The browser cuts a paste to the
|
|
88
|
+
// input's `maxlength` first, so padding around a copied color would push
|
|
89
|
+
// its last digits out. The padding is dropped and the rest inserted as an
|
|
90
|
+
// edit of the user's own, which keeps undo, `maxlength` and read-only
|
|
91
|
+
// intact and still ends in a `change` event.
|
|
92
|
+
trimPaste(event) {
|
|
93
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
94
|
+
const trimmed = text.trim();
|
|
95
|
+
if (trimmed === text)
|
|
96
|
+
return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
document.execCommand('insertText', false, trimmed);
|
|
99
|
+
}
|
|
100
|
+
// Picker → text. Bound to `input` on the picker. The picker's own event
|
|
101
|
+
// stops here: the one forwarded from the text input stands in for it, and
|
|
102
|
+
// listeners further up would otherwise hear of each pick twice.
|
|
103
|
+
syncInput(event) {
|
|
104
|
+
/* eslint-disable-next-line no-restricted-syntax */
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
this.inputElement.value = this.pickerElement.value.toUpperCase();
|
|
107
|
+
this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
|
108
|
+
}
|
|
109
|
+
// Bound to `change` on the picker, whose own event stops here as well.
|
|
110
|
+
commit(event) {
|
|
111
|
+
/* eslint-disable-next-line no-restricted-syntax */
|
|
112
|
+
event.stopPropagation();
|
|
113
|
+
this.inputElement.dispatchEvent(new Event('change', { bubbles: true }));
|
|
114
|
+
}
|
|
115
|
+
// Resynchronises from the text input's current value. Call this after
|
|
116
|
+
// setting the value from script, which fires no event.
|
|
117
|
+
sync() {
|
|
118
|
+
this.syncPicker();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
_PrimerColorInputElement_abortController = new WeakMap();
|
|
122
|
+
_PrimerColorInputElement_observer = new WeakMap();
|
|
123
|
+
__decorate([
|
|
124
|
+
target
|
|
125
|
+
], PrimerColorInputElement.prototype, "inputElement", void 0);
|
|
126
|
+
__decorate([
|
|
127
|
+
target
|
|
128
|
+
], PrimerColorInputElement.prototype, "pickerElement", void 0);
|
|
129
|
+
__decorate([
|
|
130
|
+
attr
|
|
131
|
+
], PrimerColorInputElement.prototype, "blank", void 0);
|
|
132
|
+
__decorate([
|
|
133
|
+
attr
|
|
134
|
+
], PrimerColorInputElement.prototype, "empty", void 0);
|
|
135
|
+
PrimerColorInputElement = __decorate([
|
|
136
|
+
controller('primer-color-input')
|
|
137
|
+
], PrimerColorInputElement);
|
|
138
|
+
export { PrimerColorInputElement };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {attr, controller, target} from '@github/catalyst'
|
|
2
|
+
|
|
3
|
+
const HEX_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
|
|
4
|
+
|
|
5
|
+
// Returns the canonical uppercase `#RRGGBB` form of the given text, or null
|
|
6
|
+
// if it is not a three- or six-digit hex color.
|
|
7
|
+
export function parseHex(text: string): string | null {
|
|
8
|
+
const match = HEX_PATTERN.exec(text.trim())
|
|
9
|
+
if (!match) return null
|
|
10
|
+
|
|
11
|
+
let digits = match[1]
|
|
12
|
+
if (digits.length === 3) digits = digits.replace(/./g, '$&$&')
|
|
13
|
+
|
|
14
|
+
return `#${digits.toUpperCase()}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@controller('primer-color-input')
|
|
18
|
+
export class PrimerColorInputElement extends HTMLElement {
|
|
19
|
+
@target inputElement: HTMLInputElement
|
|
20
|
+
@target pickerElement: HTMLInputElement
|
|
21
|
+
|
|
22
|
+
// The text input is empty.
|
|
23
|
+
@attr blank = false
|
|
24
|
+
// There is no color to show in the swatch.
|
|
25
|
+
@attr empty = false
|
|
26
|
+
|
|
27
|
+
#abortController: AbortController | null = null
|
|
28
|
+
#observer: MutationObserver | null = null
|
|
29
|
+
|
|
30
|
+
connectedCallback(): void {
|
|
31
|
+
this.#abortController?.abort()
|
|
32
|
+
const {signal} = (this.#abortController = new AbortController())
|
|
33
|
+
|
|
34
|
+
this.syncDisabled()
|
|
35
|
+
this.sync()
|
|
36
|
+
|
|
37
|
+
// The server always renders the picker disabled. Whatever rewrites
|
|
38
|
+
// attributes without reconnecting this element, a Turbo morph for one,
|
|
39
|
+
// puts that back, so the rule is re-applied on every such change.
|
|
40
|
+
this.#observer?.disconnect()
|
|
41
|
+
this.#observer = new MutationObserver(() => this.syncDisabled())
|
|
42
|
+
this.#observer.observe(this, {attributes: true, attributeFilter: ['disabled', 'readonly'], subtree: true})
|
|
43
|
+
|
|
44
|
+
// The form is outside this element's subtree, so data-action cannot
|
|
45
|
+
// reach it. Reset restores the text value after the event, hence the tick.
|
|
46
|
+
this.inputElement.form?.addEventListener('reset', () => setTimeout(() => this.sync()), {signal})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
disconnectedCallback(): void {
|
|
50
|
+
this.#abortController?.abort()
|
|
51
|
+
this.#observer?.disconnect()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The picker is operable only while its value can reach the submitted text
|
|
55
|
+
// input and that input accepts edits.
|
|
56
|
+
private syncDisabled(): void {
|
|
57
|
+
const disabled = this.inputElement.disabled || this.inputElement.readOnly
|
|
58
|
+
|
|
59
|
+
// Assigning the same value again would still rewrite the attribute and
|
|
60
|
+
// wake the observer, which would then never settle.
|
|
61
|
+
if (this.pickerElement.disabled !== disabled) this.pickerElement.disabled = disabled
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Text → picker. Bound to `input` on the text input, so the swatch follows
|
|
65
|
+
// every keystroke: text that is not a whole color yet shows none. The
|
|
66
|
+
// picker itself has no empty value and keeps the last color to open on.
|
|
67
|
+
syncPicker(): void {
|
|
68
|
+
const text = this.inputElement.value.trim()
|
|
69
|
+
const color = parseHex(text)
|
|
70
|
+
|
|
71
|
+
this.blank = text === ''
|
|
72
|
+
this.empty = color === null
|
|
73
|
+
|
|
74
|
+
if (color) this.pickerElement.value = color.toLowerCase()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Bound to `change` on the text input.
|
|
78
|
+
normalize(): void {
|
|
79
|
+
const color = parseHex(this.inputElement.value)
|
|
80
|
+
if (color) this.inputElement.value = color
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Bound to `paste` on the text input. The browser cuts a paste to the
|
|
84
|
+
// input's `maxlength` first, so padding around a copied color would push
|
|
85
|
+
// its last digits out. The padding is dropped and the rest inserted as an
|
|
86
|
+
// edit of the user's own, which keeps undo, `maxlength` and read-only
|
|
87
|
+
// intact and still ends in a `change` event.
|
|
88
|
+
trimPaste(event: ClipboardEvent): void {
|
|
89
|
+
const text = event.clipboardData?.getData('text/plain') ?? ''
|
|
90
|
+
const trimmed = text.trim()
|
|
91
|
+
if (trimmed === text) return
|
|
92
|
+
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
document.execCommand('insertText', false, trimmed)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Picker → text. Bound to `input` on the picker. The picker's own event
|
|
98
|
+
// stops here: the one forwarded from the text input stands in for it, and
|
|
99
|
+
// listeners further up would otherwise hear of each pick twice.
|
|
100
|
+
syncInput(event: Event): void {
|
|
101
|
+
/* eslint-disable-next-line no-restricted-syntax */
|
|
102
|
+
event.stopPropagation()
|
|
103
|
+
|
|
104
|
+
this.inputElement.value = this.pickerElement.value.toUpperCase()
|
|
105
|
+
this.inputElement.dispatchEvent(new Event('input', {bubbles: true}))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Bound to `change` on the picker, whose own event stops here as well.
|
|
109
|
+
commit(event: Event): void {
|
|
110
|
+
/* eslint-disable-next-line no-restricted-syntax */
|
|
111
|
+
event.stopPropagation()
|
|
112
|
+
|
|
113
|
+
this.inputElement.dispatchEvent(new Event('change', {bubbles: true}))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resynchronises from the text input's current value. Call this after
|
|
117
|
+
// setting the value from script, which fires no event.
|
|
118
|
+
sync(): void {
|
|
119
|
+
this.syncPicker()
|
|
120
|
+
}
|
|
121
|
+
}
|
data/config/locales/en.yml
CHANGED