openproject-primer_view_components 0.79.1 → 0.80.1

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 +18 -0
  3. data/app/assets/javascripts/components/primer/open_project/avatar_fallback.d.ts +20 -7
  4. data/app/assets/javascripts/primer_view_components.js +1 -1
  5. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  6. data/app/assets/styles/primer_view_components.css +1 -1
  7. data/app/assets/styles/primer_view_components.css.map +1 -1
  8. data/app/components/primer/open_project/avatar_fallback.d.ts +20 -7
  9. data/app/components/primer/open_project/avatar_fallback.js +47 -44
  10. data/app/components/primer/open_project/avatar_fallback.ts +49 -38
  11. data/app/components/primer/open_project/avatar_with_fallback.rb +11 -12
  12. data/app/components/primer/open_project/fieldset.html.erb +8 -0
  13. data/app/components/primer/open_project/fieldset.rb +66 -0
  14. data/app/components/primer/open_project/inline_message.css +1 -0
  15. data/app/components/primer/open_project/inline_message.css.json +13 -0
  16. data/app/components/primer/open_project/inline_message.css.map +1 -0
  17. data/app/components/primer/open_project/inline_message.html.erb +4 -0
  18. data/app/components/primer/open_project/inline_message.pcss +42 -0
  19. data/app/components/primer/open_project/inline_message.rb +65 -0
  20. data/app/components/primer/open_project/page_header.rb +18 -1
  21. data/app/components/primer/primer.pcss +1 -0
  22. data/app/lib/primer/forms/dsl/fieldset_group_input.rb +35 -0
  23. data/app/lib/primer/forms/dsl/form_object.rb +4 -0
  24. data/app/lib/primer/forms/fieldset_group.html.erb +10 -0
  25. data/app/lib/primer/forms/fieldset_group.rb +54 -0
  26. data/lib/primer/view_components/version.rb +1 -1
  27. data/previews/primer/forms_preview/fieldset_group_form.html.erb +40 -0
  28. data/previews/primer/forms_preview.rb +3 -0
  29. data/previews/primer/open_project/inline_message_preview/default.html.erb +5 -0
  30. data/previews/primer/open_project/inline_message_preview/playground.html.erb +5 -0
  31. data/previews/primer/open_project/inline_message_preview.rb +22 -0
  32. data/static/arguments.json +67 -2
  33. data/static/audited_at.json +3 -0
  34. data/static/classes.json +6 -0
  35. data/static/constants.json +22 -0
  36. data/static/form_previews.json +5 -0
  37. data/static/info_arch.json +145 -3
  38. data/static/previews.json +34 -0
  39. data/static/statuses.json +3 -0
  40. metadata +17 -2
@@ -1,15 +1,28 @@
1
+ /**
2
+ * AvatarFallbackElement implements "fallback first" loading pattern:
3
+ * 1. Fallback SVG is rendered immediately as <img> src
4
+ * 2. Real avatar URL is test-loaded in background using new Image()
5
+ * 3. On success, swaps to real image; on failure, fallback stays visible
6
+ *
7
+ * This approach prevents flicker by never showing a broken image state.
8
+ * Inspired by OpenProject's Angular PrincipalRendererService.
9
+ *
10
+ * Note: We read attributes directly via getAttribute() instead of using @attr
11
+ * due to a Catalyst bug where @attr accessors aren't properly initialized
12
+ * when elements have pre-existing attribute values.
13
+ */
1
14
  export declare class AvatarFallbackElement extends HTMLElement {
2
- uniqueId: string;
3
- altText: string;
4
- fallbackSrc: string;
5
15
  private img;
6
- private boundErrorHandler?;
16
+ private testImage;
7
17
  connectedCallback(): void;
8
18
  disconnectedCallback(): void;
9
- private isImageBroken;
10
- private handleImageError;
19
+ /**
20
+ * Test-loads the real avatar URL in background.
21
+ * On success, swaps the visible img to the real URL.
22
+ * On failure, does nothing - fallback stays visible.
23
+ */
24
+ private testLoadImage;
11
25
  private applyColor;
12
26
  private valueHash;
13
27
  private updateSvgColor;
14
- private isFallbackImage;
15
28
  }
@@ -4,57 +4,70 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
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
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { attr, controller } from '@github/catalyst';
7
+ import { controller } from '@github/catalyst';
8
+ /**
9
+ * AvatarFallbackElement implements "fallback first" loading pattern:
10
+ * 1. Fallback SVG is rendered immediately as <img> src
11
+ * 2. Real avatar URL is test-loaded in background using new Image()
12
+ * 3. On success, swaps to real image; on failure, fallback stays visible
13
+ *
14
+ * This approach prevents flicker by never showing a broken image state.
15
+ * Inspired by OpenProject's Angular PrincipalRendererService.
16
+ *
17
+ * Note: We read attributes directly via getAttribute() instead of using @attr
18
+ * due to a Catalyst bug where @attr accessors aren't properly initialized
19
+ * when elements have pre-existing attribute values.
20
+ */
8
21
  let AvatarFallbackElement = class AvatarFallbackElement extends HTMLElement {
9
22
  constructor() {
10
23
  super(...arguments);
11
- this.uniqueId = '';
12
- this.altText = '';
13
- this.fallbackSrc = '';
14
24
  this.img = null;
25
+ this.testImage = null;
15
26
  }
16
27
  connectedCallback() {
17
28
  this.img = this.querySelector('img') ?? null;
18
29
  if (!this.img)
19
30
  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);
31
+ const uniqueId = this.getAttribute('data-unique-id') || '';
32
+ const altText = this.getAttribute('data-alt-text') || '';
33
+ const avatarSrc = this.getAttribute('data-avatar-src') || '';
34
+ // Apply hashed color to fallback SVG immediately
35
+ this.applyColor(this.img, uniqueId, altText);
36
+ // Test-load real avatar URL in background
37
+ if (avatarSrc) {
38
+ this.testLoadImage(avatarSrc);
29
39
  }
30
40
  }
31
41
  disconnectedCallback() {
32
- if (this.boundErrorHandler && this.img) {
33
- this.img.removeEventListener('error', this.boundErrorHandler);
42
+ // Clean up test image and its event handler to prevent memory leaks
43
+ if (this.testImage) {
44
+ this.testImage.onload = null;
45
+ this.testImage = null;
34
46
  }
35
- this.boundErrorHandler = undefined;
36
47
  this.img = null;
37
48
  }
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
- }
49
+ /**
50
+ * Test-loads the real avatar URL in background.
51
+ * On success, swaps the visible img to the real URL.
52
+ * On failure, does nothing - fallback stays visible.
53
+ */
54
+ testLoadImage(url) {
55
+ this.testImage = new Image();
56
+ this.testImage.onload = () => {
57
+ // Success - swap to real image
58
+ if (this.img) {
59
+ this.img.src = url;
60
+ }
61
+ };
62
+ // On error: do nothing, fallback stays visible (no flicker)
63
+ this.testImage.src = url;
51
64
  }
52
- applyColor(img) {
65
+ applyColor(img, uniqueId, altText) {
53
66
  // If either uniqueId or altText is missing, skip color customization so the SVG
54
67
  // keeps its default gray fill defined in the source and no color override is applied.
55
- if (!this.uniqueId || !this.altText)
68
+ if (!uniqueId || !altText)
56
69
  return;
57
- const text = `${this.uniqueId}${this.altText}`;
70
+ const text = `${uniqueId}${altText}`;
58
71
  const hue = this.valueHash(text);
59
72
  const color = `hsl(${hue}, 50%, 30%)`;
60
73
  this.updateSvgColor(img, color);
@@ -72,6 +85,8 @@ let AvatarFallbackElement = class AvatarFallbackElement extends HTMLElement {
72
85
  }
73
86
  updateSvgColor(img, color) {
74
87
  const dataUri = img.src;
88
+ if (!dataUri.startsWith('data:image/svg+xml;base64,'))
89
+ return;
75
90
  const base64 = dataUri.replace('data:image/svg+xml;base64,', '');
76
91
  try {
77
92
  const svg = atob(base64);
@@ -83,19 +98,7 @@ let AvatarFallbackElement = class AvatarFallbackElement extends HTMLElement {
83
98
  // to avoid breaking the component.
84
99
  }
85
100
  }
86
- isFallbackImage(img) {
87
- return img.src === this.fallbackSrc;
88
- }
89
101
  };
90
- __decorate([
91
- attr
92
- ], AvatarFallbackElement.prototype, "uniqueId", void 0);
93
- __decorate([
94
- attr
95
- ], AvatarFallbackElement.prototype, "altText", void 0);
96
- __decorate([
97
- attr
98
- ], AvatarFallbackElement.prototype, "fallbackSrc", void 0);
99
102
  AvatarFallbackElement = __decorate([
100
103
  controller
101
104
  ], AvatarFallbackElement);
@@ -1,61 +1,74 @@
1
- import {attr, controller} from '@github/catalyst'
2
-
1
+ import {controller} from '@github/catalyst'
2
+
3
+ /**
4
+ * AvatarFallbackElement implements "fallback first" loading pattern:
5
+ * 1. Fallback SVG is rendered immediately as <img> src
6
+ * 2. Real avatar URL is test-loaded in background using new Image()
7
+ * 3. On success, swaps to real image; on failure, fallback stays visible
8
+ *
9
+ * This approach prevents flicker by never showing a broken image state.
10
+ * Inspired by OpenProject's Angular PrincipalRendererService.
11
+ *
12
+ * Note: We read attributes directly via getAttribute() instead of using @attr
13
+ * due to a Catalyst bug where @attr accessors aren't properly initialized
14
+ * when elements have pre-existing attribute values.
15
+ */
3
16
  @controller
4
17
  export class AvatarFallbackElement extends HTMLElement {
5
- @attr uniqueId = ''
6
- @attr altText = ''
7
- @attr fallbackSrc = ''
8
-
9
18
  private img: HTMLImageElement | null = null
10
- private boundErrorHandler?: () => void
19
+ private testImage: HTMLImageElement | null = null
11
20
 
12
21
  connectedCallback() {
13
22
  this.img = this.querySelector<HTMLImageElement>('img') ?? null
14
23
  if (!this.img) return
15
24
 
16
- this.boundErrorHandler = () => this.handleImageError(this.img!)
25
+ const uniqueId = this.getAttribute('data-unique-id') || ''
26
+ const altText = this.getAttribute('data-alt-text') || ''
27
+ const avatarSrc = this.getAttribute('data-avatar-src') || ''
17
28
 
18
- // Handle image load errors (404, network failure, etc.)
19
- this.img.addEventListener('error', this.boundErrorHandler)
29
+ // Apply hashed color to fallback SVG immediately
30
+ this.applyColor(this.img, uniqueId, altText)
20
31
 
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)
32
+ // Test-load real avatar URL in background
33
+ if (avatarSrc) {
34
+ this.testLoadImage(avatarSrc)
26
35
  }
27
36
  }
28
37
 
29
38
  disconnectedCallback() {
30
- if (this.boundErrorHandler && this.img) {
31
- this.img.removeEventListener('error', this.boundErrorHandler)
39
+ // Clean up test image and its event handler to prevent memory leaks
40
+ if (this.testImage) {
41
+ this.testImage.onload = null
42
+ this.testImage = null
32
43
  }
33
- this.boundErrorHandler = undefined
34
44
  this.img = null
35
45
  }
36
46
 
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)
47
+ /**
48
+ * Test-loads the real avatar URL in background.
49
+ * On success, swaps the visible img to the real URL.
50
+ * On failure, does nothing - fallback stays visible.
51
+ */
52
+ private testLoadImage(url: string) {
53
+ this.testImage = new Image()
54
+
55
+ this.testImage.onload = () => {
56
+ // Success - swap to real image
57
+ if (this.img) {
58
+ this.img.src = url
59
+ }
50
60
  }
61
+
62
+ // On error: do nothing, fallback stays visible (no flicker)
63
+ this.testImage.src = url
51
64
  }
52
65
 
53
- private applyColor(img: HTMLImageElement) {
66
+ private applyColor(img: HTMLImageElement, uniqueId: string, altText: string) {
54
67
  // If either uniqueId or altText is missing, skip color customization so the SVG
55
68
  // keeps its default gray fill defined in the source and no color override is applied.
56
- if (!this.uniqueId || !this.altText) return
69
+ if (!uniqueId || !altText) return
57
70
 
58
- const text = `${this.uniqueId}${this.altText}`
71
+ const text = `${uniqueId}${altText}`
59
72
  const hue = this.valueHash(text)
60
73
  const color = `hsl(${hue}, 50%, 30%)`
61
74
 
@@ -76,6 +89,8 @@ export class AvatarFallbackElement extends HTMLElement {
76
89
 
77
90
  private updateSvgColor(img: HTMLImageElement, color: string) {
78
91
  const dataUri = img.src
92
+ if (!dataUri.startsWith('data:image/svg+xml;base64,')) return
93
+
79
94
  const base64 = dataUri.replace('data:image/svg+xml;base64,', '')
80
95
 
81
96
  try {
@@ -87,8 +102,4 @@ export class AvatarFallbackElement extends HTMLElement {
87
102
  // to avoid breaking the component.
88
103
  }
89
104
  }
90
-
91
- private isFallbackImage(img: HTMLImageElement): boolean {
92
- return img.src === this.fallbackSrc
93
- }
94
105
  }
@@ -5,14 +5,12 @@ module Primer
5
5
  # OpenProject-specific Avatar component that extends Primer::Beta::Avatar
6
6
  # to support fallback rendering with initials when no image source is provided.
7
7
  #
8
- # When `src` is nil, this component renders an SVG with initials extracted from
9
- # the alt text. The AvatarFallbackElement web component then enhances it client-side
10
- # by applying a consistent background color based on the user's unique_id (using the
11
- # same hash function as OP Core for consistency).
8
+ # Uses a "fallback first" pattern for flicker-free loading:
9
+ # 1. Always renders fallback SVG as initial <img> src (visible immediately)
10
+ # 2. Client-side JS test-loads the real URL in background
11
+ # 3. On success, swaps to real image; on failure, fallback stays visible
12
12
  #
13
- # This component follows the "extension over mutation" pattern - it extends
14
- # Primer::Beta::Avatar without modifying its interface, ensuring compatibility
15
- # with upstream changes.
13
+ # This approach is inspired by OpenProject's Angular PrincipalRendererService.
16
14
  class AvatarWithFallback < Primer::Beta::Avatar
17
15
  status :open_project
18
16
 
@@ -21,8 +19,8 @@ module Primer
21
19
  # - https://github.com/primer/css/blob/main/src/support/variables/typography.scss
22
20
  FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'"
23
21
 
24
- # @param src [String] The source url of the avatar image. When nil or a broken URL, it renders a fallback with initials.
25
- # @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials when src is nil.
22
+ # @param src [String] The source URL of the avatar image. When provided, JavaScript will test-load it in the background and swap to it on success. When nil or blank, only the fallback SVG is displayed.
23
+ # @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials for the fallback SVG.
26
24
  # @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %>
27
25
  # @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %>
28
26
  # @param href [String] The URL to link to. If used, component will be wrapped by an `<a>` tag.
@@ -32,10 +30,11 @@ module Primer
32
30
  require_src_or_alt_arguments(src, alt)
33
31
 
34
32
  @unique_id = unique_id
33
+ @avatar_src = src.presence
35
34
  @fallback_svg = generate_fallback_svg(alt, size)
36
- final_src = src.blank? ? @fallback_svg : src
37
35
 
38
- super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
36
+ # Always render fallback first - JS will swap to real image on successful load
37
+ super(src: @fallback_svg, alt: alt, size: size, shape: shape, href: href, **system_arguments)
39
38
  end
40
39
 
41
40
  def call
@@ -45,7 +44,7 @@ module Primer
45
44
  data: {
46
45
  unique_id: @unique_id,
47
46
  alt_text: @system_arguments[:alt],
48
- fallback_src: @fallback_svg
47
+ avatar_src: @avatar_src
49
48
  }
50
49
  )
51
50
  ) { super }
@@ -0,0 +1,8 @@
1
+ <% unless legend? %>
2
+ <% with_legend { @legend_text } # set the default %>
3
+ <% end %>
4
+
5
+ <%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
6
+ <%= legend %>
7
+ <%= content %>
8
+ <% end %>
@@ -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,4 @@
1
+ <%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
2
+ <%= render(Primer::Beta::Octicon.new(**@icon_arguments)) %>
3
+ <%= content %>
4
+ <% end %>
@@ -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";