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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/app/assets/javascripts/primer_view_components.js +1 -1
  4. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  5. data/app/assets/styles/primer_view_components.css +1 -1
  6. data/app/assets/styles/primer_view_components.css.map +1 -1
  7. data/app/components/primer/open_project/color_field.css +1 -0
  8. data/app/components/primer/open_project/color_field.css.json +18 -0
  9. data/app/components/primer/open_project/color_field.css.map +1 -0
  10. data/app/components/primer/open_project/color_field.pcss +70 -0
  11. data/app/components/primer/open_project/color_field.rb +40 -0
  12. data/app/components/primer/primer.d.ts +1 -0
  13. data/app/components/primer/primer.js +1 -0
  14. data/app/components/primer/primer.pcss +1 -0
  15. data/app/components/primer/primer.ts +1 -0
  16. data/app/lib/primer/forms/color_field.html.erb +10 -0
  17. data/app/lib/primer/forms/color_field.rb +67 -0
  18. data/app/lib/primer/forms/dsl/color_field_input.rb +97 -0
  19. data/app/lib/primer/forms/dsl/input_methods.rb +9 -0
  20. data/app/lib/primer/forms/primer_color_input.d.ts +17 -0
  21. data/app/lib/primer/forms/primer_color_input.js +138 -0
  22. data/app/lib/primer/forms/primer_color_input.ts +121 -0
  23. data/config/locales/en.yml +4 -0
  24. data/lib/primer/view_components/version.rb +1 -1
  25. data/lib/primer/yard/component_manifest.rb +2 -1
  26. data/lib/primer/yard/lookbook_pages_backend.rb +2 -1
  27. data/previews/primer/open_project/color_field_preview/in_form.html.erb +6 -0
  28. data/previews/primer/open_project/color_field_preview/in_multi.html.erb +8 -0
  29. data/previews/primer/open_project/color_field_preview/multiple.html.erb +4 -0
  30. data/previews/primer/open_project/color_field_preview.rb +108 -0
  31. data/static/arguments.json +154 -0
  32. data/static/audited_at.json +1 -0
  33. data/static/classes.json +3 -0
  34. data/static/classnames.cjs +4 -0
  35. data/static/classnames.js +4 -0
  36. data/static/constants.json +3 -0
  37. data/static/info_arch.json +295 -0
  38. data/static/previews.json +138 -0
  39. data/static/statuses.json +1 -0
  40. 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
+ }
@@ -9,6 +9,10 @@ en:
9
9
  button_filter: "Filter"
10
10
  button_save: "Save"
11
11
 
12
+ color_field:
13
+ picker_label: "Pick color for %{label}"
14
+ picker_label_generic: "Pick color"
15
+
12
16
  drag_handle:
13
17
  button_drag: "Drag to reorder"
14
18
 
@@ -5,7 +5,7 @@ module Primer
5
5
  module ViewComponents
6
6
  module VERSION
7
7
  MAJOR = 0
8
- MINOR = 92
8
+ MINOR = 93
9
9
  PATCH = 0
10
10
 
11
11
  STRING = [MAJOR, MINOR, PATCH].join(".")