openproject-primer_view_components 0.80.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/app/assets/javascripts/components/primer/open_project/avatar_fallback.d.ts +20 -7
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/components/primer/open_project/avatar_fallback.d.ts +20 -7
- data/app/components/primer/open_project/avatar_fallback.js +47 -44
- data/app/components/primer/open_project/avatar_fallback.ts +49 -38
- data/app/components/primer/open_project/avatar_with_fallback.rb +11 -12
- data/lib/primer/view_components/version.rb +1 -1
- data/static/arguments.json +2 -2
- data/static/info_arch.json +3 -3
- metadata +2 -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
|
|
16
|
+
private testImage;
|
|
7
17
|
connectedCallback(): void;
|
|
8
18
|
disconnectedCallback(): void;
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
this.
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (!
|
|
68
|
+
if (!uniqueId || !altText)
|
|
56
69
|
return;
|
|
57
|
-
const text = `${
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
19
|
-
this.img
|
|
29
|
+
// Apply hashed color to fallback SVG immediately
|
|
30
|
+
this.applyColor(this.img, uniqueId, altText)
|
|
20
31
|
|
|
21
|
-
//
|
|
22
|
-
if (
|
|
23
|
-
this.
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (!
|
|
69
|
+
if (!uniqueId || !altText) return
|
|
57
70
|
|
|
58
|
-
const text = `${
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
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
|
|
25
|
-
# @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
avatar_src: @avatar_src
|
|
49
48
|
}
|
|
50
49
|
)
|
|
51
50
|
) { super }
|
data/static/arguments.json
CHANGED
|
@@ -5775,13 +5775,13 @@
|
|
|
5775
5775
|
"name": "src",
|
|
5776
5776
|
"type": "String",
|
|
5777
5777
|
"default": "`nil`",
|
|
5778
|
-
"description": "The source
|
|
5778
|
+
"description": "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."
|
|
5779
5779
|
},
|
|
5780
5780
|
{
|
|
5781
5781
|
"name": "alt",
|
|
5782
5782
|
"type": "String",
|
|
5783
5783
|
"default": "`nil`",
|
|
5784
|
-
"description": "Alt text for the avatar. Used for accessibility and to generate initials
|
|
5784
|
+
"description": "Alt text for the avatar. Used for accessibility and to generate initials for the fallback SVG."
|
|
5785
5785
|
},
|
|
5786
5786
|
{
|
|
5787
5787
|
"name": "size",
|
data/static/info_arch.json
CHANGED
|
@@ -18866,7 +18866,7 @@
|
|
|
18866
18866
|
},
|
|
18867
18867
|
{
|
|
18868
18868
|
"fully_qualified_name": "Primer::OpenProject::AvatarWithFallback",
|
|
18869
|
-
"description": "OpenProject-specific Avatar component that extends Primer::Beta::Avatar\nto support fallback rendering with initials when no image source is provided.\n\
|
|
18869
|
+
"description": "OpenProject-specific Avatar component that extends Primer::Beta::Avatar\nto support fallback rendering with initials when no image source is provided.\n\nUses a \"fallback first\" pattern for flicker-free loading:\n1. Always renders fallback SVG as initial <img> src (visible immediately)\n2. Client-side JS test-loads the real URL in background\n3. On success, swaps to real image; on failure, fallback stays visible\n\nThis approach is inspired by OpenProject's Angular PrincipalRendererService.",
|
|
18870
18870
|
"accessibility_docs": null,
|
|
18871
18871
|
"is_form_component": false,
|
|
18872
18872
|
"is_published": true,
|
|
@@ -18882,13 +18882,13 @@
|
|
|
18882
18882
|
"name": "src",
|
|
18883
18883
|
"type": "String",
|
|
18884
18884
|
"default": "`nil`",
|
|
18885
|
-
"description": "The source
|
|
18885
|
+
"description": "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."
|
|
18886
18886
|
},
|
|
18887
18887
|
{
|
|
18888
18888
|
"name": "alt",
|
|
18889
18889
|
"type": "String",
|
|
18890
18890
|
"default": "`nil`",
|
|
18891
|
-
"description": "Alt text for the avatar. Used for accessibility and to generate initials
|
|
18891
|
+
"description": "Alt text for the avatar. Used for accessibility and to generate initials for the fallback SVG."
|
|
18892
18892
|
},
|
|
18893
18893
|
{
|
|
18894
18894
|
"name": "size",
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openproject-primer_view_components
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.80.
|
|
4
|
+
version: 0.80.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitHub Open Source
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-01-
|
|
12
|
+
date: 2026-01-27 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: actionview
|