Skip to content

Commit 55ed98f

Browse files
authored
Merge pull request #387 from opf/bug/69230-fix-avatars-with-initials
bug/69230 Extend `Primer::Beta::Avatar` as `Primer::OpenProject::AvatarWithFallback` to render initials in a styled SVG when avatar img src is nil/blank
2 parents 4588576 + 4682247 commit 55ed98f

21 files changed

+1325
-1
lines changed

.changeset/heavy-donuts-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openproject/primer-view-components': minor
3+
---
4+
5+
Add `Primer::OpenProject::AvatarWithFallback` component with client-side fallback rendering. When no image src is provided, renders an embedded SVG with user initials and consistent colors generated client-side by the AvatarFallbackElement web component. Extends `Primer::Beta::Avatar` without modifying upstream behavior.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {attr, controller} from '@github/catalyst'
2+
3+
@controller
4+
export class AvatarFallbackElement extends HTMLElement {
5+
@attr uniqueId = ''
6+
@attr altText = ''
7+
8+
connectedCallback() {
9+
// If either uniqueId or altText is missing, skip color customization so the SVG
10+
// keeps its default gray fill defined in the source and no color override is applied.
11+
if (!this.uniqueId || !this.altText) return
12+
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+
const text = `${this.uniqueId}${this.altText}`
18+
const hue = this.valueHash(text)
19+
const color = `hsl(${hue}, 50%, 30%)`
20+
21+
this.updateSvgColor(img, color)
22+
}
23+
24+
/*
25+
* Mimics OP Core's string hash function to ensure consistent color generation
26+
* @see https://github.com/opf/openproject/blob/1b6eb3f9e45c3bdb05ce49d2cbe92995b87b4df5/frontend/src/app/shared/components/colors/colors.service.ts#L19-L26
27+
*/
28+
private valueHash(value: string): number {
29+
let hash = 0
30+
for (let i = 0; i < value.length; i++) {
31+
hash = value.charCodeAt(i) + ((hash << 5) - hash)
32+
}
33+
return hash % 360
34+
}
35+
36+
private updateSvgColor(img: HTMLImageElement, color: string) {
37+
const dataUri = img.src
38+
const base64 = dataUri.replace('data:image/svg+xml;base64,', '')
39+
40+
try {
41+
const svg = atob(base64)
42+
const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`)
43+
img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}`
44+
} catch {
45+
// If the SVG data is malformed or not valid base64, skip updating the color
46+
// to avoid breaking the component.
47+
}
48+
}
49+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/* stylelint-disable selector-max-type, selector-max-specificity */
2+
/* Type selectors are required for the avatar-fallback custom element wrapper.
3+
Specificity overrides are needed to properly style nested avatar stacking. */
4+
5+
/* OpenProject AvatarStack - styles for avatar-fallback wrapper elements */
6+
7+
.AvatarStack-body {
8+
/* Make avatar-fallback invisible to layout - inner img acts as direct child */
9+
& avatar-fallback {
10+
display: contents;
11+
}
12+
13+
/*
14+
* Z-index stacking for avatars inside avatar-fallback wrappers.
15+
* The base CSS uses .avatar:first-child/:last-child but those selectors
16+
* don't match when .avatar is inside avatar-fallback (not a direct child).
17+
*/
18+
& avatar-fallback:first-child .avatar {
19+
z-index: 3;
20+
}
21+
22+
& avatar-fallback:nth-child(2) .avatar {
23+
z-index: 2;
24+
}
25+
26+
/* Hide 4th+ wrapped avatars */
27+
& avatar-fallback:nth-child(n + 4) {
28+
display: none;
29+
opacity: 0;
30+
}
31+
32+
/* Show all on hover/focus */
33+
&:hover:not([data-disable-expand]),
34+
&:focus-within:not([data-disable-expand]) {
35+
& avatar-fallback:nth-child(n + 4) {
36+
display: contents;
37+
opacity: 1;
38+
}
39+
}
40+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module Primer
4+
module OpenProject
5+
# OpenProject-specific AvatarStack that extends Primer::Beta::AvatarStack
6+
# to support avatar fallbacks with initials.
7+
#
8+
# Uses a different slot name (avatar_with_fallbacks) to avoid conflicts with the parent's avatars slot.
9+
class AvatarStack < Primer::Beta::AvatarStack
10+
status :open_project
11+
12+
# Required list of stacked avatars with fallback support.
13+
#
14+
# @param kwargs [Hash] The same arguments as <%= link_to_component(Primer::OpenProject::AvatarWithFallback) %>.
15+
renders_many :avatar_with_fallbacks, "Primer::OpenProject::AvatarWithFallback"
16+
17+
# Alias avatar_with_fallbacks as avatars for use in the template
18+
def avatars
19+
avatar_with_fallbacks
20+
end
21+
end
22+
end
23+
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
module Primer
4+
module OpenProject
5+
# OpenProject-specific Avatar component that extends Primer::Beta::Avatar
6+
# to support fallback rendering with initials when no image source is provided.
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).
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.
16+
class AvatarWithFallback < Primer::Beta::Avatar
17+
status :open_project
18+
19+
# @see
20+
# - https://primer.style/foundations/typography/
21+
# - https://github.com/primer/css/blob/main/src/support/variables/typography.scss
22+
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'"
23+
24+
# @param src [String] The source url of the avatar image. When nil, 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.
26+
# @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %>
27+
# @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %>
28+
# @param href [String] The URL to link to. If used, component will be wrapped by an `<a>` tag.
29+
# @param unique_id [String, Integer] Unique identifier for generating consistent avatar colors across renders.
30+
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
31+
def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, href: nil, unique_id: nil, **system_arguments)
32+
require_src_or_alt_arguments(src, alt)
33+
34+
@unique_id = unique_id
35+
@use_fallback = src.blank?
36+
final_src = @use_fallback ? generate_fallback_svg(alt, size) : src
37+
38+
super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
39+
end
40+
41+
def call
42+
render(
43+
Primer::ConditionalWrapper.new(
44+
condition: @use_fallback,
45+
tag: :"avatar-fallback",
46+
data: {
47+
unique_id: @unique_id,
48+
alt_text: @system_arguments[:alt]
49+
}
50+
)
51+
) { super }
52+
end
53+
54+
private
55+
56+
def require_src_or_alt_arguments(src, alt)
57+
return if src.present? || alt.present?
58+
59+
raise ArgumentError, "`src` or `alt` is required"
60+
end
61+
62+
def generate_fallback_svg(alt, size)
63+
svg_content = content_tag(
64+
:svg,
65+
safe_join([
66+
# Use a neutral dark gray as default to minimize flicker in both light/dark modes
67+
# JS will replace with the hashed color (hsl(hue, 50%, 30%))
68+
tag.rect(width: "100%", height: "100%", fill: "hsl(0, 0%, 35%)"),
69+
content_tag(
70+
:text,
71+
extract_initials(alt),
72+
x: "50%",
73+
y: "50%",
74+
"text-anchor": "middle",
75+
"dominant-baseline": "central",
76+
fill: "white",
77+
"font-size": fallback_font_size(size),
78+
"font-weight": "600",
79+
"font-family": FONT_STACK,
80+
style: "user-select: none; text-transform: uppercase;"
81+
)
82+
]),
83+
xmlns: "http://www.w3.org/2000/svg",
84+
width: size,
85+
height: size,
86+
viewBox: "0 0 #{size} #{size}",
87+
)
88+
89+
"data:image/svg+xml;base64,#{Base64.strict_encode64(svg_content)}"
90+
end
91+
92+
def extract_initials(name)
93+
name = name.to_s.strip
94+
return "" if name.empty?
95+
96+
chars = name.chars
97+
first = chars[0]&.upcase || ""
98+
99+
last_space = name.rindex(" ")
100+
if last_space && last_space < name.length - 1
101+
last = name[last_space + 1]&.upcase || ""
102+
"#{first}#{last}"
103+
else
104+
first
105+
end
106+
end
107+
108+
def fallback_font_size(size)
109+
# Font size is 45% of avatar size for good readability, with a minimum of 8px
110+
[(size * 0.45).round, 8].max
111+
end
112+
end
113+
end
114+
end

app/components/primer/primer.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
@import "./alpha/action_bar.pcss";
4646

4747
/* OP specifics */
48+
@import "./open_project/avatar_stack.pcss";
4849
@import "./open_project/page_header.pcss";
4950
@import "./open_project/drag_handle.pcss";
5051
@import "./open_project/border_grid.pcss";

app/components/primer/primer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import './alpha/tree_view/tree_view'
3030
import './alpha/tree_view/tree_view_icon_pair_element'
3131
import './alpha/tree_view/tree_view_sub_tree_node_element'
3232
import './alpha/tree_view/tree_view_include_fragment_element'
33+
import './open_project/avatar_fallback'
3334
import './open_project/page_header_element'
3435
import './open_project/zen_mode_button'
3536
import './open_project/sub_header_element'
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
module Primer
4+
module OpenProject
5+
# @label OpenProject AvatarStack
6+
class AvatarStackPreview < ViewComponent::Preview
7+
# @label Playground
8+
#
9+
# @param number_of_avatars [Integer] number
10+
# @param with_fallbacks toggle
11+
# @param align select [["Left", left], ["Right", right]]
12+
def playground(number_of_avatars: 3, with_fallbacks: true, align: :left)
13+
render(Primer::OpenProject::AvatarStack.new(align: align)) do |component|
14+
Array.new(number_of_avatars&.to_i || 1) do |i|
15+
if with_fallbacks
16+
component.with_avatar_with_fallback(src: nil, alt: "User #{i + 1}", unique_id: i + 1)
17+
else
18+
component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")
19+
end
20+
end
21+
end
22+
end
23+
24+
# @label Default
25+
# @snapshot
26+
def default
27+
render(Primer::OpenProject::AvatarStack.new) do |component|
28+
component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")
29+
end
30+
end
31+
32+
# @label With fallback avatars
33+
# @snapshot
34+
def with_fallback_avatars
35+
render(Primer::OpenProject::AvatarStack.new) do |component|
36+
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1)
37+
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2)
38+
component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3)
39+
end
40+
end
41+
42+
# @label Mixed (image and fallback)
43+
def mixed_avatars
44+
render(Primer::OpenProject::AvatarStack.new) do |component|
45+
component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")
46+
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 10)
47+
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 20)
48+
end
49+
end
50+
51+
# @label Align right with fallbacks
52+
def align_right_with_fallbacks
53+
render(Primer::OpenProject::AvatarStack.new(align: :right)) do |component|
54+
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1)
55+
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2)
56+
component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3)
57+
end
58+
end
59+
60+
# @label With tooltip and fallbacks
61+
def with_tooltip_and_fallbacks
62+
render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: "Team members" })) do |component|
63+
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1)
64+
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2)
65+
component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3)
66+
end
67+
end
68+
end
69+
end
70+
end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
module Primer
4+
module OpenProject
5+
# @label AvatarWithFallback
6+
class AvatarWithFallbackPreview < ViewComponent::Preview
7+
# @label Playground
8+
#
9+
# @param size [Integer] select [16, 20, 24, 32, 40, 48, 64, 80]
10+
# @param shape [Symbol] select [circle, square]
11+
# @param href [String] text
12+
# @param with_src [Boolean] toggle
13+
def playground(size: 24, shape: :circle, href: nil, with_src: false)
14+
if with_src
15+
render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser", size: size, shape: shape, href: href))
16+
else
17+
render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Admin", unique_id: 4, size: size, shape: shape, href: href))
18+
end
19+
end
20+
21+
# @label Default
22+
# @snapshot
23+
def default
24+
render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser"))
25+
end
26+
27+
# @label With image src
28+
# @snapshot
29+
def with_image
30+
render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser"))
31+
end
32+
33+
# @!group Fallback (Initials)
34+
#
35+
# @label Fallback with initials
36+
# @snapshot
37+
def fallback_default
38+
render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Admin", unique_id: 4))
39+
end
40+
41+
# @label Fallback single name
42+
# @snapshot
43+
def fallback_single_name
44+
render(Primer::OpenProject::AvatarWithFallback.new(alt: "John", unique_id: 2))
45+
end
46+
47+
# @label Fallback multiple users
48+
def fallback_multiple
49+
render_with_template(locals: {})
50+
end
51+
52+
# @label Fallback sizes
53+
def fallback_sizes
54+
render_with_template(locals: {})
55+
end
56+
57+
# @label Fallback square shape
58+
# @snapshot
59+
def fallback_square
60+
render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Org", unique_id: 100, shape: :square))
61+
end
62+
63+
# @label Fallback as link
64+
def fallback_as_link
65+
render(Primer::OpenProject::AvatarWithFallback.new(alt: "Jane Doe", unique_id: 3, href: "#"))
66+
end
67+
#
68+
# @!endgroup
69+
end
70+
end
71+
end

0 commit comments

Comments
 (0)