openproject-primer_view_components 0.79.0 → 0.80.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 +18 -0
- data/app/assets/javascripts/components/primer/open_project/avatar_fallback.d.ts +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/avatar_fallback.d.ts +8 -0
- data/app/components/primer/open_project/avatar_fallback.js +44 -4
- data/app/components/primer/open_project/avatar_fallback.ts +49 -4
- data/app/components/primer/open_project/avatar_with_fallback.rb +6 -6
- data/app/components/primer/open_project/fieldset.html.erb +8 -0
- data/app/components/primer/open_project/fieldset.rb +66 -0
- data/app/components/primer/open_project/inline_message.css +1 -0
- data/app/components/primer/open_project/inline_message.css.json +13 -0
- data/app/components/primer/open_project/inline_message.css.map +1 -0
- data/app/components/primer/open_project/inline_message.html.erb +4 -0
- data/app/components/primer/open_project/inline_message.pcss +42 -0
- data/app/components/primer/open_project/inline_message.rb +65 -0
- data/app/components/primer/open_project/page_header.rb +18 -1
- data/app/components/primer/primer.pcss +1 -0
- data/app/lib/primer/forms/dsl/fieldset_group_input.rb +35 -0
- data/app/lib/primer/forms/dsl/form_object.rb +4 -0
- data/app/lib/primer/forms/fieldset_group.html.erb +10 -0
- data/app/lib/primer/forms/fieldset_group.rb +54 -0
- data/lib/primer/view_components/version.rb +1 -1
- data/previews/primer/forms_preview/fieldset_group_form.html.erb +40 -0
- data/previews/primer/forms_preview.rb +3 -0
- data/previews/primer/open_project/avatar_with_fallback_preview/multiple_broken_images.html.erb +10 -0
- data/previews/primer/open_project/avatar_with_fallback_preview.rb +20 -0
- data/previews/primer/open_project/inline_message_preview/default.html.erb +5 -0
- data/previews/primer/open_project/inline_message_preview/playground.html.erb +5 -0
- data/previews/primer/open_project/inline_message_preview.rb +22 -0
- data/static/arguments.json +66 -1
- data/static/audited_at.json +3 -0
- data/static/classes.json +6 -0
- data/static/constants.json +22 -0
- data/static/form_previews.json +5 -0
- data/static/info_arch.json +169 -1
- data/static/previews.json +60 -0
- data/static/statuses.json +3 -0
- metadata +18 -2
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
export declare class AvatarFallbackElement extends HTMLElement {
|
|
2
2
|
uniqueId: string;
|
|
3
3
|
altText: string;
|
|
4
|
+
fallbackSrc: string;
|
|
5
|
+
private img;
|
|
6
|
+
private boundErrorHandler?;
|
|
4
7
|
connectedCallback(): void;
|
|
8
|
+
disconnectedCallback(): void;
|
|
9
|
+
private isImageBroken;
|
|
10
|
+
private handleImageError;
|
|
11
|
+
private applyColor;
|
|
5
12
|
private valueHash;
|
|
6
13
|
private updateSvgColor;
|
|
14
|
+
private isFallbackImage;
|
|
7
15
|
}
|
|
@@ -10,16 +10,50 @@ let AvatarFallbackElement = class AvatarFallbackElement extends HTMLElement {
|
|
|
10
10
|
super(...arguments);
|
|
11
11
|
this.uniqueId = '';
|
|
12
12
|
this.altText = '';
|
|
13
|
+
this.fallbackSrc = '';
|
|
14
|
+
this.img = null;
|
|
13
15
|
}
|
|
14
16
|
connectedCallback() {
|
|
17
|
+
this.img = this.querySelector('img') ?? null;
|
|
18
|
+
if (!this.img)
|
|
19
|
+
return;
|
|
20
|
+
this.boundErrorHandler = () => this.handleImageError(this.img);
|
|
21
|
+
// Handle image load errors (404, network failure, etc.)
|
|
22
|
+
this.img.addEventListener('error', this.boundErrorHandler);
|
|
23
|
+
// Check if image already failed (error event fired before listener attached)
|
|
24
|
+
if (this.isImageBroken(this.img)) {
|
|
25
|
+
this.handleImageError(this.img);
|
|
26
|
+
}
|
|
27
|
+
else if (this.isFallbackImage(this.img)) {
|
|
28
|
+
this.applyColor(this.img);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
disconnectedCallback() {
|
|
32
|
+
if (this.boundErrorHandler && this.img) {
|
|
33
|
+
this.img.removeEventListener('error', this.boundErrorHandler);
|
|
34
|
+
}
|
|
35
|
+
this.boundErrorHandler = undefined;
|
|
36
|
+
this.img = null;
|
|
37
|
+
}
|
|
38
|
+
isImageBroken(img) {
|
|
39
|
+
// Image is broken if loading completed but no actual image data loaded
|
|
40
|
+
// Skip check for data URIs (fallback SVGs) as they're always valid
|
|
41
|
+
return img.complete && img.naturalWidth === 0 && !img.src.startsWith('data:');
|
|
42
|
+
}
|
|
43
|
+
handleImageError(img) {
|
|
44
|
+
// Prevent infinite loop if fallback also fails
|
|
45
|
+
if (this.isFallbackImage(img))
|
|
46
|
+
return;
|
|
47
|
+
if (this.fallbackSrc) {
|
|
48
|
+
img.src = this.fallbackSrc;
|
|
49
|
+
this.applyColor(img);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
applyColor(img) {
|
|
15
53
|
// If either uniqueId or altText is missing, skip color customization so the SVG
|
|
16
54
|
// keeps its default gray fill defined in the source and no color override is applied.
|
|
17
55
|
if (!this.uniqueId || !this.altText)
|
|
18
56
|
return;
|
|
19
|
-
const img = this.querySelector('img[src^="data:image/svg+xml"]');
|
|
20
|
-
if (!img)
|
|
21
|
-
return;
|
|
22
|
-
// Generate consistent color based on uniqueId and altText (hash must match OP Core)
|
|
23
57
|
const text = `${this.uniqueId}${this.altText}`;
|
|
24
58
|
const hue = this.valueHash(text);
|
|
25
59
|
const color = `hsl(${hue}, 50%, 30%)`;
|
|
@@ -49,6 +83,9 @@ let AvatarFallbackElement = class AvatarFallbackElement extends HTMLElement {
|
|
|
49
83
|
// to avoid breaking the component.
|
|
50
84
|
}
|
|
51
85
|
}
|
|
86
|
+
isFallbackImage(img) {
|
|
87
|
+
return img.src === this.fallbackSrc;
|
|
88
|
+
}
|
|
52
89
|
};
|
|
53
90
|
__decorate([
|
|
54
91
|
attr
|
|
@@ -56,6 +93,9 @@ __decorate([
|
|
|
56
93
|
__decorate([
|
|
57
94
|
attr
|
|
58
95
|
], AvatarFallbackElement.prototype, "altText", void 0);
|
|
96
|
+
__decorate([
|
|
97
|
+
attr
|
|
98
|
+
], AvatarFallbackElement.prototype, "fallbackSrc", void 0);
|
|
59
99
|
AvatarFallbackElement = __decorate([
|
|
60
100
|
controller
|
|
61
101
|
], AvatarFallbackElement);
|
|
@@ -4,16 +4,57 @@ import {attr, controller} from '@github/catalyst'
|
|
|
4
4
|
export class AvatarFallbackElement extends HTMLElement {
|
|
5
5
|
@attr uniqueId = ''
|
|
6
6
|
@attr altText = ''
|
|
7
|
+
@attr fallbackSrc = ''
|
|
8
|
+
|
|
9
|
+
private img: HTMLImageElement | null = null
|
|
10
|
+
private boundErrorHandler?: () => void
|
|
7
11
|
|
|
8
12
|
connectedCallback() {
|
|
13
|
+
this.img = this.querySelector<HTMLImageElement>('img') ?? null
|
|
14
|
+
if (!this.img) return
|
|
15
|
+
|
|
16
|
+
this.boundErrorHandler = () => this.handleImageError(this.img!)
|
|
17
|
+
|
|
18
|
+
// Handle image load errors (404, network failure, etc.)
|
|
19
|
+
this.img.addEventListener('error', this.boundErrorHandler)
|
|
20
|
+
|
|
21
|
+
// Check if image already failed (error event fired before listener attached)
|
|
22
|
+
if (this.isImageBroken(this.img)) {
|
|
23
|
+
this.handleImageError(this.img)
|
|
24
|
+
} else if (this.isFallbackImage(this.img)) {
|
|
25
|
+
this.applyColor(this.img)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnectedCallback() {
|
|
30
|
+
if (this.boundErrorHandler && this.img) {
|
|
31
|
+
this.img.removeEventListener('error', this.boundErrorHandler)
|
|
32
|
+
}
|
|
33
|
+
this.boundErrorHandler = undefined
|
|
34
|
+
this.img = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private isImageBroken(img: HTMLImageElement): boolean {
|
|
38
|
+
// Image is broken if loading completed but no actual image data loaded
|
|
39
|
+
// Skip check for data URIs (fallback SVGs) as they're always valid
|
|
40
|
+
return img.complete && img.naturalWidth === 0 && !img.src.startsWith('data:')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private handleImageError(img: HTMLImageElement) {
|
|
44
|
+
// Prevent infinite loop if fallback also fails
|
|
45
|
+
if (this.isFallbackImage(img)) return
|
|
46
|
+
|
|
47
|
+
if (this.fallbackSrc) {
|
|
48
|
+
img.src = this.fallbackSrc
|
|
49
|
+
this.applyColor(img)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private applyColor(img: HTMLImageElement) {
|
|
9
54
|
// If either uniqueId or altText is missing, skip color customization so the SVG
|
|
10
55
|
// keeps its default gray fill defined in the source and no color override is applied.
|
|
11
56
|
if (!this.uniqueId || !this.altText) return
|
|
12
57
|
|
|
13
|
-
const img = this.querySelector<HTMLImageElement>('img[src^="data:image/svg+xml"]')
|
|
14
|
-
if (!img) return
|
|
15
|
-
|
|
16
|
-
// Generate consistent color based on uniqueId and altText (hash must match OP Core)
|
|
17
58
|
const text = `${this.uniqueId}${this.altText}`
|
|
18
59
|
const hue = this.valueHash(text)
|
|
19
60
|
const color = `hsl(${hue}, 50%, 30%)`
|
|
@@ -46,4 +87,8 @@ export class AvatarFallbackElement extends HTMLElement {
|
|
|
46
87
|
// to avoid breaking the component.
|
|
47
88
|
}
|
|
48
89
|
}
|
|
90
|
+
|
|
91
|
+
private isFallbackImage(img: HTMLImageElement): boolean {
|
|
92
|
+
return img.src === this.fallbackSrc
|
|
93
|
+
}
|
|
49
94
|
}
|
|
@@ -21,7 +21,7 @@ module Primer
|
|
|
21
21
|
# - https://github.com/primer/css/blob/main/src/support/variables/typography.scss
|
|
22
22
|
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'"
|
|
23
23
|
|
|
24
|
-
# @param src [String] The source url of the avatar image. When nil, renders a fallback with initials.
|
|
24
|
+
# @param src [String] The source url of the avatar image. When nil or a broken URL, it renders a fallback with initials.
|
|
25
25
|
# @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials when src is nil.
|
|
26
26
|
# @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %>
|
|
27
27
|
# @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %>
|
|
@@ -32,20 +32,20 @@ module Primer
|
|
|
32
32
|
require_src_or_alt_arguments(src, alt)
|
|
33
33
|
|
|
34
34
|
@unique_id = unique_id
|
|
35
|
-
@
|
|
36
|
-
final_src =
|
|
35
|
+
@fallback_svg = generate_fallback_svg(alt, size)
|
|
36
|
+
final_src = src.blank? ? @fallback_svg : src
|
|
37
37
|
|
|
38
38
|
super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def call
|
|
42
42
|
render(
|
|
43
|
-
Primer::
|
|
44
|
-
condition: @use_fallback,
|
|
43
|
+
Primer::BaseComponent.new(
|
|
45
44
|
tag: :"avatar-fallback",
|
|
46
45
|
data: {
|
|
47
46
|
unique_id: @unique_id,
|
|
48
|
-
alt_text: @system_arguments[:alt]
|
|
47
|
+
alt_text: @system_arguments[:alt],
|
|
48
|
+
fallback_src: @fallback_svg
|
|
49
49
|
}
|
|
50
50
|
)
|
|
51
51
|
) { super }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module OpenProject
|
|
5
|
+
# A low-level component for building fieldsets with unopinionated styling.
|
|
6
|
+
#
|
|
7
|
+
# This component is not designed to be used directly, but rather a primitive for
|
|
8
|
+
# authors of other components and form controls.
|
|
9
|
+
class Fieldset < Primer::Component
|
|
10
|
+
status :open_project
|
|
11
|
+
|
|
12
|
+
attr_reader :legend_text
|
|
13
|
+
|
|
14
|
+
renders_one :legend, ->(**system_arguments) {
|
|
15
|
+
LegendComponent.new(visually_hide_legend: @visually_hide_legend, **system_arguments)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# @param legend_text [String] A legend should be short and concise. The String will also be read by assistive technology.
|
|
19
|
+
# @param visually_hide_legend [Boolean] Controls if the legend is visible. If `true`, screen reader only text will be added.
|
|
20
|
+
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
|
|
21
|
+
def initialize(legend_text: nil, visually_hide_legend: false, **system_arguments) # rubocop:disable Lint/MissingSuper
|
|
22
|
+
@legend_text = legend_text
|
|
23
|
+
@visually_hide_legend = visually_hide_legend
|
|
24
|
+
@system_arguments = deny_tag_argument(**system_arguments)
|
|
25
|
+
@system_arguments[:tag] = :fieldset
|
|
26
|
+
|
|
27
|
+
deny_aria_key(
|
|
28
|
+
:label,
|
|
29
|
+
"instead of `aria-label`, include `legend_text` and set `visually_hide_legend` to `true` on the component initializer.",
|
|
30
|
+
**@system_arguments
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render?
|
|
35
|
+
content? && (legend_text.present? || legend?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class LegendComponent < Primer::Component
|
|
39
|
+
status :open_project
|
|
40
|
+
|
|
41
|
+
attr_reader :text
|
|
42
|
+
|
|
43
|
+
def initialize(text: nil, visually_hide_legend: false, **system_arguments) # rubocop:disable Lint/MissingSuper
|
|
44
|
+
@text = text
|
|
45
|
+
|
|
46
|
+
@system_arguments = deny_tag_argument(**system_arguments)
|
|
47
|
+
@system_arguments[:tag] = :legend
|
|
48
|
+
@system_arguments[:classes] = class_names(
|
|
49
|
+
@system_arguments[:classes],
|
|
50
|
+
{ "sr-only" => visually_hide_legend }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def call
|
|
55
|
+
render(Primer::BaseComponent.new(**@system_arguments)) { legend_content }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def legend_content
|
|
61
|
+
@legend_content ||= content || text
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.InlineMessage{align-items:start;color:var(--inline-message-fgColor);column-gap:.5rem;display:grid;font-size:var(--inline-message-fontSize);grid-template-columns:auto 1fr;line-height:var(--inline-message-lineHeight)}.InlineMessage[data-size=small]{--inline-message-fontSize:var(--text-body-size-small);--inline-message-lineHeight:var(--text-body-lineHeight-small,1.6666)}.InlineMessage[data-size=medium]{--inline-message-fontSize:var(--text-body-size-medium);--inline-message-lineHeight:var(--text-body-lineHeight-medium,1.4285)}.InlineMessage[data-variant=warning]{--inline-message-fgColor:var(--fgColor-attention)}.InlineMessage[data-variant=critical]{--inline-message-fgColor:var(--fgColor-danger)}.InlineMessage[data-variant=success]{--inline-message-fgColor:var(--fgColor-success)}.InlineMessage[data-variant=unavailable]{--inline-message-fgColor:var(--fgColor-muted)}.InlineMessageIcon{min-height:calc(var(--inline-message-lineHeight)*var(--inline-message-fontSize))}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "open_project/inline_message",
|
|
3
|
+
"selectors": [
|
|
4
|
+
".InlineMessage",
|
|
5
|
+
".InlineMessage[data-size=small]",
|
|
6
|
+
".InlineMessage[data-size=medium]",
|
|
7
|
+
".InlineMessage[data-variant=warning]",
|
|
8
|
+
".InlineMessage[data-variant=critical]",
|
|
9
|
+
".InlineMessage[data-variant=success]",
|
|
10
|
+
".InlineMessage[data-variant=unavailable]",
|
|
11
|
+
".InlineMessageIcon"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["inline_message.pcss"],"names":[],"mappings":"AAAA,eAUE,iBAAkB,CAHlB,mCAAoC,CACpC,gBAAkB,CAPlB,YAAa,CAEb,wCAAyC,CAMzC,8BAA+B,CAJ/B,4CAgCF,CAzBE,gCACE,qDAAsD,CACtD,oEACF,CAEA,iCACE,sDAAuD,CACvD,qEACF,CAEA,qCACE,iDACF,CAEA,sCACE,8CACF,CAEA,qCACE,+CACF,CAEA,yCACE,6CACF,CAGF,mBACE,gFACF","file":"inline_message.css","sourcesContent":[".InlineMessage {\n display: grid;\n /* stylelint-disable-next-line primer/typography */\n font-size: var(--inline-message-fontSize);\n /* stylelint-disable-next-line primer/typography */\n line-height: var(--inline-message-lineHeight);\n /* stylelint-disable-next-line primer/colors */\n color: var(--inline-message-fgColor);\n column-gap: 0.5rem;\n grid-template-columns: auto 1fr;\n align-items: start;\n\n &[data-size='small'] {\n --inline-message-fontSize: var(--text-body-size-small);\n --inline-message-lineHeight: var(--text-body-lineHeight-small, 1.6666);\n }\n\n &[data-size='medium'] {\n --inline-message-fontSize: var(--text-body-size-medium);\n --inline-message-lineHeight: var(--text-body-lineHeight-medium, 1.4285);\n }\n\n &[data-variant='warning'] {\n --inline-message-fgColor: var(--fgColor-attention);\n }\n\n &[data-variant='critical'] {\n --inline-message-fgColor: var(--fgColor-danger);\n }\n\n &[data-variant='success'] {\n --inline-message-fgColor: var(--fgColor-success);\n }\n\n &[data-variant='unavailable'] {\n --inline-message-fgColor: var(--fgColor-muted);\n }\n}\n\n.InlineMessageIcon {\n min-height: calc(var(--inline-message-lineHeight) * var(--inline-message-fontSize));\n}"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.InlineMessage {
|
|
2
|
+
display: grid;
|
|
3
|
+
/* stylelint-disable-next-line primer/typography */
|
|
4
|
+
font-size: var(--inline-message-fontSize);
|
|
5
|
+
/* stylelint-disable-next-line primer/typography */
|
|
6
|
+
line-height: var(--inline-message-lineHeight);
|
|
7
|
+
/* stylelint-disable-next-line primer/colors */
|
|
8
|
+
color: var(--inline-message-fgColor);
|
|
9
|
+
column-gap: 0.5rem;
|
|
10
|
+
grid-template-columns: auto 1fr;
|
|
11
|
+
align-items: start;
|
|
12
|
+
|
|
13
|
+
&[data-size='small'] {
|
|
14
|
+
--inline-message-fontSize: var(--text-body-size-small);
|
|
15
|
+
--inline-message-lineHeight: var(--text-body-lineHeight-small, 1.6666);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
&[data-size='medium'] {
|
|
19
|
+
--inline-message-fontSize: var(--text-body-size-medium);
|
|
20
|
+
--inline-message-lineHeight: var(--text-body-lineHeight-medium, 1.4285);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
&[data-variant='warning'] {
|
|
24
|
+
--inline-message-fgColor: var(--fgColor-attention);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
&[data-variant='critical'] {
|
|
28
|
+
--inline-message-fgColor: var(--fgColor-danger);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&[data-variant='success'] {
|
|
32
|
+
--inline-message-fgColor: var(--fgColor-success);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&[data-variant='unavailable'] {
|
|
36
|
+
--inline-message-fgColor: var(--fgColor-muted);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.InlineMessageIcon {
|
|
41
|
+
min-height: calc(var(--inline-message-lineHeight) * var(--inline-message-fontSize));
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module OpenProject
|
|
5
|
+
# A simple component to render warning text.
|
|
6
|
+
#
|
|
7
|
+
# The warning text is rendered in the "attention" Primer color and
|
|
8
|
+
# uses a leading alert Octicon for additional emphasis. This component
|
|
9
|
+
# is designed to be used "inline", e.g. table cells, and in places
|
|
10
|
+
# where a Banner component might be overkill.
|
|
11
|
+
class InlineMessage < Primer::Component
|
|
12
|
+
status :open_project
|
|
13
|
+
|
|
14
|
+
SCHEME_ICON_MAPPINGS = {
|
|
15
|
+
warning: :alert,
|
|
16
|
+
critical: :alert,
|
|
17
|
+
success: :"check-circle",
|
|
18
|
+
unavailable: :alert
|
|
19
|
+
}.freeze
|
|
20
|
+
private_constant :SCHEME_ICON_MAPPINGS
|
|
21
|
+
SCHEME_OPTIONS = SCHEME_ICON_MAPPINGS.keys.freeze
|
|
22
|
+
|
|
23
|
+
SCHEME_SMALL_ICON_MAPPINGS = {
|
|
24
|
+
warning: :"alert-fill",
|
|
25
|
+
critical: :"alert-fill",
|
|
26
|
+
success: :"check-circle-fill",
|
|
27
|
+
unavailable: :"alert-fill"
|
|
28
|
+
}.freeze
|
|
29
|
+
private_constant :SCHEME_SMALL_ICON_MAPPINGS
|
|
30
|
+
DEFAULT_SIZE = :medium
|
|
31
|
+
SIZE_OPTIONS = [:small, DEFAULT_SIZE].freeze
|
|
32
|
+
|
|
33
|
+
# @param scheme [Symbol] <%= one_of(Primer::OpenProject::InlineMessage::SCHEME_OPTIONS) %>
|
|
34
|
+
# @param size [Symbol] <%= one_of(Primer::OpenProject::InlineMessage::SIZE_OPTIONS) %>
|
|
35
|
+
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
|
|
36
|
+
def initialize(scheme:, size: DEFAULT_SIZE, **system_arguments) # rubocop:disable Lint/MissingSuper
|
|
37
|
+
resolved_scheme = fetch_or_fallback(SCHEME_OPTIONS, scheme)
|
|
38
|
+
resolved_size = fetch_or_fallback(SIZE_OPTIONS, size, DEFAULT_SIZE)
|
|
39
|
+
|
|
40
|
+
@system_arguments = system_arguments
|
|
41
|
+
@system_arguments[:tag] ||= :div
|
|
42
|
+
@system_arguments[:classes] = class_names(
|
|
43
|
+
@system_arguments[:classes],
|
|
44
|
+
"InlineMessage"
|
|
45
|
+
)
|
|
46
|
+
@system_arguments[:data] = merge_data(
|
|
47
|
+
@system_arguments,
|
|
48
|
+
{ data: { size: resolved_size, variant: resolved_scheme } }
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@icon_arguments = { classes: "InlineMessageIcon" }
|
|
52
|
+
if resolved_size == :small
|
|
53
|
+
@icon_arguments[:icon] = SCHEME_SMALL_ICON_MAPPINGS[resolved_scheme]
|
|
54
|
+
@icon_arguments[:size] = :xsmall
|
|
55
|
+
else
|
|
56
|
+
@icon_arguments[:icon] = SCHEME_ICON_MAPPINGS[resolved_scheme]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render?
|
|
61
|
+
content.present?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -25,6 +25,8 @@ module Primer
|
|
|
25
25
|
DEFAULT_PARENT_LINK_DISPLAY = [:block, :none].freeze
|
|
26
26
|
BREADCRUMB_TRUNCATE_AT = 200
|
|
27
27
|
|
|
28
|
+
DEFAULT_BUTTON_ACTION_SIZE = :medium
|
|
29
|
+
|
|
28
30
|
STATE_DEFAULT = :show
|
|
29
31
|
STATE_EDIT = :edit
|
|
30
32
|
STATE_OPTIONS = [STATE_DEFAULT, STATE_EDIT].freeze
|
|
@@ -68,6 +70,7 @@ module Primer
|
|
|
68
70
|
system_arguments[:icon] = icon
|
|
69
71
|
system_arguments[:"aria-label"] ||= label
|
|
70
72
|
system_arguments = set_action_arguments(system_arguments, scheme: scheme)
|
|
73
|
+
system_arguments = enforce_consistent_button_size!(system_arguments)
|
|
71
74
|
|
|
72
75
|
component = Primer::Beta::IconButton
|
|
73
76
|
create_mobile_alternatives(component, mobile_icon, label, scheme, **system_arguments, &block)
|
|
@@ -78,6 +81,7 @@ module Primer
|
|
|
78
81
|
deny_tag_argument(**system_arguments)
|
|
79
82
|
|
|
80
83
|
system_arguments = set_action_arguments(system_arguments, scheme: scheme)
|
|
84
|
+
system_arguments = enforce_consistent_button_size!(system_arguments)
|
|
81
85
|
|
|
82
86
|
component = Primer::Beta::Button
|
|
83
87
|
create_mobile_alternatives(component, mobile_icon, mobile_label, scheme, **system_arguments, &block)
|
|
@@ -88,6 +92,7 @@ module Primer
|
|
|
88
92
|
deny_tag_argument(**system_arguments)
|
|
89
93
|
|
|
90
94
|
system_arguments = set_action_arguments(system_arguments, scheme: DEFAULT_ACTION_SCHEME)
|
|
95
|
+
system_arguments = enforce_consistent_button_size!(system_arguments)
|
|
91
96
|
|
|
92
97
|
component = Primer::OpenProject::ZenModeButton
|
|
93
98
|
create_mobile_alternatives(component, mobile_icon, mobile_label, DEFAULT_ACTION_SCHEME, **system_arguments, &block)
|
|
@@ -120,6 +125,7 @@ module Primer
|
|
|
120
125
|
|
|
121
126
|
system_arguments[:button_arguments] ||= {}
|
|
122
127
|
system_arguments[:button_arguments] = set_action_arguments(system_arguments[:button_arguments])
|
|
128
|
+
system_arguments[:button_arguments] = enforce_consistent_button_size!(system_arguments[:button_arguments])
|
|
123
129
|
|
|
124
130
|
# Add the options individually to the mobile menu in the template
|
|
125
131
|
@desktop_menu_block = block
|
|
@@ -138,6 +144,7 @@ module Primer
|
|
|
138
144
|
system_arguments[:button_arguments] ||= {}
|
|
139
145
|
system_arguments[:button_arguments][:id] = "dialog-show-#{system_arguments[:dialog_arguments][:id]}"
|
|
140
146
|
system_arguments[:button_arguments] = set_action_arguments(system_arguments[:button_arguments])
|
|
147
|
+
system_arguments[:button_arguments] = enforce_consistent_button_size!(system_arguments[:button_arguments])
|
|
141
148
|
|
|
142
149
|
component = Primer::OpenProject::PageHeader::Dialog
|
|
143
150
|
create_mobile_alternatives(component, mobile_icon, mobile_label, :default, **system_arguments, &block)
|
|
@@ -293,7 +300,6 @@ module Primer
|
|
|
293
300
|
def set_action_arguments(system_arguments, scheme: nil)
|
|
294
301
|
system_arguments[:ml] ||= 2
|
|
295
302
|
system_arguments[:display] = %i[none flex]
|
|
296
|
-
system_arguments[:size] = :medium
|
|
297
303
|
system_arguments[:scheme] = scheme unless scheme.nil?
|
|
298
304
|
system_arguments[:classes] = class_names(
|
|
299
305
|
system_arguments[:classes],
|
|
@@ -304,6 +310,17 @@ module Primer
|
|
|
304
310
|
system_arguments
|
|
305
311
|
end
|
|
306
312
|
|
|
313
|
+
def enforce_consistent_button_size!(system_arguments)
|
|
314
|
+
size = system_arguments.fetch(:size, DEFAULT_BUTTON_ACTION_SIZE)
|
|
315
|
+
@page_header_button_action_size ||= size
|
|
316
|
+
unless size == @page_header_button_action_size
|
|
317
|
+
raise ArgumentError,
|
|
318
|
+
"PageHeader button actions must all use the same size. " \
|
|
319
|
+
"Set the same `size:` for every button-like action (or omit it to use #{DEFAULT_BUTTON_ACTION_SIZE.inspect} everywhere)."
|
|
320
|
+
end
|
|
321
|
+
system_arguments.merge(size: @page_header_button_action_size)
|
|
322
|
+
end
|
|
323
|
+
|
|
307
324
|
def create_mobile_alternatives(component, mobile_icon, mobile_label, scheme, **system_arguments, &block)
|
|
308
325
|
# All actions should collapse into a single actionMenu on mobile
|
|
309
326
|
add_option_to_mobile_menu(system_arguments, mobile_icon, mobile_label, scheme)
|
|
@@ -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/inline_message.pcss";
|
|
53
54
|
@import "./open_project/sub_header.pcss";
|
|
54
55
|
@import "./open_project/side_panel/section.pcss";
|
|
55
56
|
@import "./open_project/border_box/collapsible_header.pcss";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module Forms
|
|
5
|
+
module Dsl
|
|
6
|
+
# :nodoc:
|
|
7
|
+
class FieldsetGroupInput < Input
|
|
8
|
+
include Primer::Forms::Dsl::InputMethods
|
|
9
|
+
include InputMethods
|
|
10
|
+
|
|
11
|
+
attr_reader :builder, :form, :system_arguments
|
|
12
|
+
|
|
13
|
+
def initialize(builder:, form:, **system_arguments)
|
|
14
|
+
@builder = builder
|
|
15
|
+
@form = form
|
|
16
|
+
@system_arguments = system_arguments
|
|
17
|
+
|
|
18
|
+
yield(self) if block_given?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_component
|
|
22
|
+
FieldsetGroup.new(inputs: inputs, builder: builder, form: form, **@system_arguments)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def type
|
|
26
|
+
:group
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def input?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -19,6 +19,10 @@ module Primer
|
|
|
19
19
|
def group(**options, &block)
|
|
20
20
|
add_input InputGroup.new(builder: @builder, form: @form, **options, &block)
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
def fieldset_group(**options, &block)
|
|
24
|
+
add_input FieldsetGroupInput.new(builder: @builder, form: @form, **options, &block)
|
|
25
|
+
end
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
|
|
2
|
+
<%= render(Primer::OpenProject::Fieldset.new(**@fieldset_arguments)) do %>
|
|
3
|
+
<%=
|
|
4
|
+
render(Primer::Beta::Subhead.new) do |component|
|
|
5
|
+
component.with_heading(**@heading_arguments).with_content(@title)
|
|
6
|
+
end
|
|
7
|
+
%>
|
|
8
|
+
<%= render(Primer::Forms::Group.new(**@group_arguments)) %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Primer
|
|
4
|
+
module Forms
|
|
5
|
+
# :nodoc:
|
|
6
|
+
class FieldsetGroup < BaseComponent
|
|
7
|
+
##
|
|
8
|
+
# @param title [String] The title displayed as the heading for the fieldset
|
|
9
|
+
# @param inputs [Array<Primer::Forms::Dsl::Input>] Array of form inputs to be grouped
|
|
10
|
+
# @param builder [ActionView::Helpers::FormBuilder] The form builder instance
|
|
11
|
+
# @param form [Primer::Forms::BaseForm] The form object
|
|
12
|
+
# @param layout [Symbol] Layout style for the input group (default: :default_layout)
|
|
13
|
+
# @param heading_arguments [Hash] Arguments passed to the heading component
|
|
14
|
+
# @option heading_arguments [String] :id The ID for the heading element
|
|
15
|
+
# @option heading_arguments [Symbol] :tag The HTML tag for the heading (default: :h3)
|
|
16
|
+
# @option heading_arguments [Symbol] :size The size of the heading (default: :medium)
|
|
17
|
+
# @param group_arguments [Hash] Arguments passed to the input group component
|
|
18
|
+
# @param system_arguments [Hash] Additional system arguments passed to the section wrapper
|
|
19
|
+
def initialize( # rubocop:disable Metrics/AbcSize
|
|
20
|
+
title:,
|
|
21
|
+
inputs:,
|
|
22
|
+
builder:,
|
|
23
|
+
form:,
|
|
24
|
+
layout: Primer::Forms::Group::DEFAULT_LAYOUT,
|
|
25
|
+
heading_arguments: {},
|
|
26
|
+
group_arguments: {},
|
|
27
|
+
**system_arguments
|
|
28
|
+
)
|
|
29
|
+
super()
|
|
30
|
+
|
|
31
|
+
@title = title
|
|
32
|
+
|
|
33
|
+
@heading_arguments = heading_arguments
|
|
34
|
+
@heading_arguments[:id] ||= "subhead-#{SecureRandom.uuid}"
|
|
35
|
+
@heading_arguments[:tag] ||= :h3
|
|
36
|
+
@heading_arguments[:size] ||= :medium
|
|
37
|
+
|
|
38
|
+
@fieldset_arguments = {
|
|
39
|
+
legend_text: @title,
|
|
40
|
+
visually_hide_legend: true,
|
|
41
|
+
aria: { labelledby: @heading_arguments[:id] }
|
|
42
|
+
}
|
|
43
|
+
@group_arguments = group_arguments.merge(inputs:, builder:, form:, layout:)
|
|
44
|
+
|
|
45
|
+
@system_arguments = system_arguments
|
|
46
|
+
@system_arguments[:tag] = :section
|
|
47
|
+
@system_arguments[:mb] ||= 4
|
|
48
|
+
@system_arguments[:aria] ||= {}
|
|
49
|
+
@system_arguments[:aria][:labelledby] = @heading_arguments[:id]
|
|
50
|
+
@system_arguments[:hidden] = :none if inputs.all?(&:hidden?)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%
|
|
2
|
+
fieldset_group_form = Class.new(ApplicationForm) do
|
|
3
|
+
form do |f|
|
|
4
|
+
f.fieldset_group(
|
|
5
|
+
title: "Delivery preferences",
|
|
6
|
+
caption: "These settings affect how we contact you"
|
|
7
|
+
) do |g|
|
|
8
|
+
g.text_field(
|
|
9
|
+
name: :ultimate_answer,
|
|
10
|
+
label: "Ultimate answer",
|
|
11
|
+
required: true,
|
|
12
|
+
caption: "The answer to life, the universe, and everything"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
g.select_list(
|
|
16
|
+
name: :window,
|
|
17
|
+
label: "Delivery window",
|
|
18
|
+
include_blank: true
|
|
19
|
+
) do |list|
|
|
20
|
+
list.option(label: "Morning", value: "morning")
|
|
21
|
+
list.option(label: "Afternoon", value: "afternoon")
|
|
22
|
+
list.option(label: "Evening", value: "evening")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
g.text_field(
|
|
26
|
+
name: :hours,
|
|
27
|
+
label: "Hours",
|
|
28
|
+
type: :number,
|
|
29
|
+
required: true,
|
|
30
|
+
trailing_visual: { text: { text: "min" } },
|
|
31
|
+
input_width: :xsmall
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
%>
|
|
37
|
+
|
|
38
|
+
<%= primer_form_with(url: "/foo") do |f| %>
|
|
39
|
+
<%= render(fieldset_group_form.new(f)) %>
|
|
40
|
+
<% end %>
|