Skip to content

Commit 0489e2e

Browse files
committed
Simplify inheritance structure to reuse super class constructor more effectively
1 parent cfafc6e commit 0489e2e

6 files changed

Lines changed: 79 additions & 109 deletions

File tree

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {attr, controller} from '@github/catalyst'
1+
import { attr, controller } from '@github/catalyst'
22

33
@controller
44
export class AvatarFallbackElement extends HTMLElement {
@@ -8,19 +8,15 @@ export class AvatarFallbackElement extends HTMLElement {
88
connectedCallback() {
99
if (!this.uniqueId || !this.altText) return
1010

11-
const fallbackSvg = this.querySelector('svg[role="img"]')
12-
if (!fallbackSvg) return
11+
const img = this.querySelector('img[src^="data:image/svg+xml"]')
12+
if (!img) return
1313

1414
// Generate consistent color based on uniqueId and altText (hash must match OP Core)
1515
const text = `${this.uniqueId}${this.altText}`
1616
const hue = this.valueHash(text)
1717
const color = `hsl(${hue}, 50%, 30%)`
1818

19-
// Set background color on rect element
20-
const rectElement = fallbackSvg.querySelector('rect')
21-
if (rectElement) {
22-
rectElement.setAttribute('fill', color)
23-
}
19+
this.updateSvgColor(img as HTMLImageElement, color)
2420
}
2521

2622
/*
@@ -34,4 +30,14 @@ export class AvatarFallbackElement extends HTMLElement {
3430
}
3531
return hash % 360
3632
}
33+
34+
private updateSvgColor(img: HTMLImageElement, color: string) {
35+
const dataUri = img.src
36+
const base64 = dataUri.replace('data:image/svg+xml;base64,', '')
37+
const svg = atob(base64)
38+
39+
const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`)
40+
41+
img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}`
42+
}
3743
}

app/components/primer/open_project/avatar_stack.rb

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,8 @@ class AvatarStack < Primer::Beta::AvatarStack
1313
renders_many :avatar_with_fallbacks, "Primer::OpenProject::AvatarWithFallback"
1414

1515
# Alias avatar_with_fallbacks as avatars for use in the template
16-
alias_method :avatars, :avatar_with_fallbacks
17-
18-
def before_render
19-
@system_arguments[:classes] = class_names(
20-
@system_arguments[:classes],
21-
"AvatarStack--two" => avatar_with_fallbacks.size == 2,
22-
"AvatarStack--three-plus" => avatar_with_fallbacks.size > 2
23-
)
24-
end
25-
26-
def render?
27-
avatar_with_fallbacks.any?
16+
def avatars
17+
avatar_with_fallbacks
2818
end
2919
end
3020
end

app/components/primer/open_project/avatar_with_fallback.rb

Lines changed: 42 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -27,71 +27,38 @@ class AvatarWithFallback < Primer::Beta::Avatar
2727
# @param unique_id [String, Integer] Unique identifier for generating consistent avatar colors across renders.
2828
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
2929
def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, href: nil, unique_id: nil, **system_arguments)
30-
@unique_id = unique_id
31-
@src = src
30+
require_src_or_alt_arguments(src, alt)
3231

33-
if src.present?
34-
super(src: src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
35-
else
36-
@alt = alt
37-
@href = href
38-
@size = fetch_or_fallback(SIZE_OPTIONS, size, DEFAULT_SIZE)
39-
@shape = fetch_or_fallback(SHAPE_OPTIONS, shape, DEFAULT_SHAPE)
40-
@system_arguments = deny_tag_argument(**system_arguments)
32+
@unique_id = unique_id
33+
@use_fallback = src.blank?
34+
final_src = @use_fallback ? generate_fallback_svg(alt, size) : src
4135

42-
validate_src_or_alt(nil, alt)
43-
end
36+
super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
4437
end
4538

4639
def call
47-
if @src.present?
48-
super
40+
if @use_fallback
41+
render(Primer::BaseComponent.new(
42+
tag: :"avatar-fallback",
43+
data: {
44+
unique_id: @unique_id,
45+
alt_text: @system_arguments[:alt]
46+
}
47+
)) { super }
4948
else
50-
render_fallback
49+
super
5150
end
5251
end
5352

5453
private
5554

56-
def validate_src_or_alt(src, alt)
55+
def require_src_or_alt_arguments(src, alt)
5756
return if src.present? || alt.present?
5857

5958
raise ArgumentError, "`src` or `alt` is required"
6059
end
6160

62-
def fallback_font_size
63-
# Calculate font size for initials (45% of avatar size, min 8px)
64-
[(@size * 0.45).round, 8].max
65-
end
66-
67-
def fallback_avatar_classes
68-
class_names(
69-
@system_arguments[:classes],
70-
"avatar",
71-
"avatar-#{@size}",
72-
"avatar-small" => @size < SMALL_THRESHOLD,
73-
"circle" => @shape == DEFAULT_SHAPE
74-
)
75-
end
76-
77-
def extract_initials(name)
78-
return "" if name.blank?
79-
80-
chars = name.chars
81-
first = chars[0]&.upcase || ""
82-
83-
last_space = name.rindex(" ")
84-
if last_space && last_space < name.length - 1
85-
last = name[last_space + 1]&.upcase || ""
86-
"#{first}#{last}"
87-
else
88-
first
89-
end
90-
end
91-
92-
def render_fallback
93-
initials = extract_initials(@alt)
94-
61+
def generate_fallback_svg(alt, size)
9562
svg_content = content_tag(
9663
:svg,
9764
safe_join([
@@ -100,40 +67,46 @@ def render_fallback
10067
tag.rect(width: "100%", height: "100%", fill: "hsl(0, 0%, 35%)"),
10168
content_tag(
10269
:text,
103-
initials,
70+
extract_initials(alt),
10471
x: "50%",
10572
y: "50%",
10673
"text-anchor": "middle",
10774
"dominant-baseline": "central",
10875
fill: "white",
109-
"font-size": fallback_font_size,
76+
"font-size": fallback_font_size(size),
11077
"font-weight": "600",
11178
"font-family": FONT_STACK,
11279
style: "user-select: none; text-transform: uppercase;"
11380
)
11481
]),
115-
width: @size,
116-
height: @size,
117-
viewBox: "0 0 #{@size} #{@size}",
118-
role: "img",
119-
"aria-label": @alt,
120-
class: fallback_avatar_classes
82+
xmlns: "http://www.w3.org/2000/svg",
83+
width: size,
84+
height: size,
85+
viewBox: "0 0 #{size} #{size}",
12186
)
12287

123-
render(Primer::BaseComponent.new(
124-
tag: :"avatar-fallback",
125-
data: {
126-
unique_id: @unique_id,
127-
alt_text: @alt
128-
}
129-
)) do
130-
render(Primer::ConditionalWrapper.new(
131-
condition: @href.present?,
132-
component: Primer::Beta::Link,
133-
**(@href.present? ? { href: @href } : {})
134-
)) { svg_content }
88+
"data:image/svg+xml;base64,#{Base64.strict_encode64(svg_content)}"
89+
end
90+
91+
def extract_initials(name)
92+
return "" if name.blank?
93+
94+
chars = name.chars
95+
first = chars[0]&.upcase || ""
96+
97+
last_space = name.rindex(" ")
98+
if last_space && last_space < name.length - 1
99+
last = name[last_space + 1]&.upcase || ""
100+
"#{first}#{last}"
101+
else
102+
first
135103
end
136104
end
105+
106+
def fallback_font_size(size)
107+
# Font size is 45% of avatar size for good readability, with a minimum of 8px
108+
[(size * 0.45).round, 8].max
109+
end
137110
end
138111
end
139112
end

previews/primer/open_project/avatar_with_fallback_preview.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
module Primer
44
module OpenProject
5-
# @label OpenProject Avatar
5+
# @label OpenProject AvatarWithFallback
66
class AvatarWithFallbackPreview < ViewComponent::Preview
77
# @label Playground
88
#

test/components/open_project/avatar_stack_test.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def test_renders_with_fallback_avatars
2626
assert_selector("div.AvatarStack") do
2727
assert_selector(".AvatarStack-body") do
2828
assert_selector("avatar-fallback", count: 2)
29-
assert_selector("svg.avatar[role='img']", count: 2)
29+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 2)
3030
end
3131
end
3232
end
@@ -39,9 +39,10 @@ def test_renders_mixed_avatars
3939

4040
assert_selector("div.AvatarStack") do
4141
assert_selector(".AvatarStack-body") do
42-
assert_selector("img.avatar", count: 1)
42+
# 2 img tags total: 1 with remote src, 1 with data URI fallback
43+
assert_selector("img.avatar", count: 2)
4344
assert_selector("avatar-fallback", count: 1)
44-
assert_selector("svg.avatar[role='img']", count: 1)
45+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 1)
4546
end
4647
end
4748
end
@@ -56,7 +57,7 @@ def test_renders_three_plus_fallback_avatars
5657
assert_selector(".AvatarStack.AvatarStack--three-plus") do
5758
assert_selector(".AvatarStack-body") do
5859
assert_selector("avatar-fallback", count: 3)
59-
assert_selector("svg.avatar[role='img']", count: 3)
60+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 3)
6061
end
6162
end
6263
end

test/components/open_project/avatar_with_fallback_test.rb

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,18 @@ def test_renders_image_avatar_with_src
1515
def test_renders_fallback_when_src_is_nil
1616
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "OpenProject Admin"))
1717

18-
# Should render avatar-fallback element wrapping a svg[role="img"]
18+
# Should render avatar-fallback element wrapping an img with base64 SVG data URI
1919
assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']") do
20-
assert_selector("svg.avatar[role='img'][aria-label='OpenProject Admin']")
20+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
2121
end
22-
refute_selector("img")
2322
end
2423

2524
def test_renders_fallback_when_src_is_blank
2625
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "", alt: "OpenProject Admin"))
2726

2827
assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']") do
29-
assert_selector("svg.avatar[role='img']")
28+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
3029
end
31-
refute_selector("img")
3230
end
3331

3432
def test_defaults_to_size_20
@@ -40,7 +38,7 @@ def test_defaults_to_size_20
4038
def test_fallback_defaults_to_size_20
4139
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User"))
4240

43-
assert_selector("svg.avatar.avatar-20")
41+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
4442
end
4543

4644
def test_falls_back_when_size_isn_t_valid
@@ -60,7 +58,7 @@ def test_defaults_to_circle_avatar
6058
def test_fallback_defaults_to_circle_avatar
6159
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User"))
6260

63-
assert_selector("svg.avatar.circle")
61+
assert_selector("img.avatar.circle[src^='data:image/svg+xml;base64,']")
6462
end
6563

6664
def test_adds_small_modifier_when_size_is_less_than_threshold
@@ -72,7 +70,7 @@ def test_adds_small_modifier_when_size_is_less_than_threshold
7270
def test_fallback_adds_small_modifier_when_size_is_less_than_threshold
7371
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", size: Primer::OpenProject::AvatarWithFallback::SMALL_THRESHOLD - 4))
7472

75-
assert_selector("svg.avatar.avatar-small")
73+
assert_selector("img.avatar.avatar-small[src^='data:image/svg+xml;base64,']")
7674
end
7775

7876
def test_sets_size_height_and_width
@@ -84,7 +82,8 @@ def test_sets_size_height_and_width
8482
def test_fallback_sets_correct_size_class
8583
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", size: 40))
8684

87-
assert_selector("svg.avatar.avatar-40")
85+
# Size is set via attributes, not a dedicated class
86+
assert_selector("img.avatar[size='40'][height='40'][width='40'][src^='data:image/svg+xml;base64,']")
8887
end
8988

9089
def test_squared_avatar
@@ -97,7 +96,7 @@ def test_squared_avatar
9796
def test_fallback_squared_avatar
9897
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", shape: :square))
9998

100-
assert_selector("svg.avatar")
99+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
101100
refute_selector(".circle")
102101
end
103102

@@ -114,9 +113,10 @@ def test_renders_link_wrapper
114113
def test_fallback_renders_link_wrapper
115114
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", href: "#test"))
116115

116+
# When href is provided, the avatar class is on the <a> tag, not the <img>
117117
assert_selector("avatar-fallback") do
118-
assert_selector("a[href='#test']") do
119-
assert_selector("svg.avatar[role='img']")
118+
assert_selector("a.avatar[href='#test']") do
119+
assert_selector("img[src^='data:image/svg+xml;base64,']")
120120
end
121121
end
122122
end
@@ -126,7 +126,7 @@ def test_fallback_with_unique_id_in_data_attribute
126126

127127
# Should have data attributes for client-side processing
128128
assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Test User']") do
129-
assert_selector("svg.avatar[role='img']")
129+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
130130
end
131131
end
132132

@@ -135,7 +135,7 @@ def test_fallback_without_unique_id
135135

136136
# Should still render, just without unique_id data attribute
137137
assert_selector("avatar-fallback[data-alt-text='Test User']") do
138-
assert_selector("svg.avatar[role='img']")
138+
assert_selector("img.avatar[src^='data:image/svg+xml;base64,']")
139139
end
140140
end
141141

@@ -148,7 +148,7 @@ def test_adds_custom_classes
148148
def test_fallback_adds_custom_classes
149149
render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", classes: "custom-class"))
150150

151-
assert_selector("svg.avatar.custom-class")
151+
assert_selector("img.avatar.custom-class[src^='data:image/svg+xml;base64,']")
152152
end
153153

154154
def test_raises_when_both_src_and_alt_are_missing

0 commit comments

Comments
 (0)