forked from primer/view_components
-
Notifications
You must be signed in to change notification settings - Fork 1
bug/69230 Extend Primer::Beta::Avatar as Primer::OpenProject::AvatarWithFallback to render initials in a styled SVG when avatar img src is nil/blank
#387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
9f3c2aa
Enhance `Primer::Beta::Avatar` to render initials in a styled SVG whe…
akabiru fa44e92
Mimic Op core JS based color hash function for consistency
akabiru f5a3c63
Refactor avatar fallback to OpenProject-specific component
akabiru 201e2a1
Merge branch 'main' into bug/69230-fix-avatars-with-initials
akabiru 493e4b5
Generating static files
openprojectci d883408
Cleanup refactor work
akabiru 34561d8
Reduce avatar fallback flicker by rendering initials server-side
akabiru cfafc6e
Generating static files
openprojectci 0489e2e
Simplify inheritance structure to reuse super class constructor more …
akabiru b94f630
Manage layout when avatars are wrapped in `<avatar-fallback>`
akabiru 9c4c29d
Generating static files
openprojectci 0005ea8
Address styelint and prettier errors
akabiru 9dac8d5
Cover else branch in extract_initials when name has no spaces
akabiru 0a1ccf6
Levarage `Primer::ConditionalWrapper` to simplify call method
akabiru 0eb3baf
Set correct status on openproject components
akabiru d3e722f
Handle leading or trailing whitespaces in names
akabiru 7bcca2f
Handle potential errors from `atob()` or `btoa()`
akabiru 9a5608c
Use defined type instead of cast
akabiru 3dced39
Generating static files
openprojectci 4682247
Switch back to minor as we're on pre-1.0
akabiru File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@openproject/primer-view-components': minor | ||
| --- | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import {attr, controller} from '@github/catalyst' | ||
|
|
||
| @controller | ||
| export class AvatarFallbackElement extends HTMLElement { | ||
| @attr uniqueId = '' | ||
| @attr altText = '' | ||
|
|
||
| connectedCallback() { | ||
| // If either uniqueId or altText is missing, skip color customization so the SVG | ||
| // keeps its default gray fill defined in the source and no color override is applied. | ||
| if (!this.uniqueId || !this.altText) return | ||
|
|
||
| const img = this.querySelector<HTMLImageElement>('img[src^="data:image/svg+xml"]') | ||
| if (!img) return | ||
|
|
||
| // Generate consistent color based on uniqueId and altText (hash must match OP Core) | ||
| const text = `${this.uniqueId}${this.altText}` | ||
| const hue = this.valueHash(text) | ||
| const color = `hsl(${hue}, 50%, 30%)` | ||
|
|
||
| this.updateSvgColor(img, color) | ||
| } | ||
|
|
||
| /* | ||
| * Mimics OP Core's string hash function to ensure consistent color generation | ||
| * @see https://github.com/opf/openproject/blob/1b6eb3f9e45c3bdb05ce49d2cbe92995b87b4df5/frontend/src/app/shared/components/colors/colors.service.ts#L19-L26 | ||
| */ | ||
| private valueHash(value: string): number { | ||
| let hash = 0 | ||
| for (let i = 0; i < value.length; i++) { | ||
| hash = value.charCodeAt(i) + ((hash << 5) - hash) | ||
| } | ||
| return hash % 360 | ||
| } | ||
|
|
||
| private updateSvgColor(img: HTMLImageElement, color: string) { | ||
| const dataUri = img.src | ||
| const base64 = dataUri.replace('data:image/svg+xml;base64,', '') | ||
|
|
||
| try { | ||
| const svg = atob(base64) | ||
| const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`) | ||
| img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}` | ||
| } catch { | ||
| // If the SVG data is malformed or not valid base64, skip updating the color | ||
| // to avoid breaking the component. | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| /* stylelint-disable selector-max-type, selector-max-specificity */ | ||
| /* Type selectors are required for the avatar-fallback custom element wrapper. | ||
| Specificity overrides are needed to properly style nested avatar stacking. */ | ||
|
|
||
| /* OpenProject AvatarStack - styles for avatar-fallback wrapper elements */ | ||
|
|
||
| .AvatarStack-body { | ||
| /* Make avatar-fallback invisible to layout - inner img acts as direct child */ | ||
| & avatar-fallback { | ||
| display: contents; | ||
| } | ||
|
|
||
| /* | ||
| * Z-index stacking for avatars inside avatar-fallback wrappers. | ||
| * The base CSS uses .avatar:first-child/:last-child but those selectors | ||
| * don't match when .avatar is inside avatar-fallback (not a direct child). | ||
| */ | ||
| & avatar-fallback:first-child .avatar { | ||
| z-index: 3; | ||
| } | ||
|
|
||
akabiru marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| & avatar-fallback:nth-child(2) .avatar { | ||
| z-index: 2; | ||
| } | ||
|
|
||
| /* Hide 4th+ wrapped avatars */ | ||
| & avatar-fallback:nth-child(n + 4) { | ||
| display: none; | ||
| opacity: 0; | ||
| } | ||
|
|
||
| /* Show all on hover/focus */ | ||
| &:hover:not([data-disable-expand]), | ||
| &:focus-within:not([data-disable-expand]) { | ||
| & avatar-fallback:nth-child(n + 4) { | ||
| display: contents; | ||
| opacity: 1; | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Primer | ||
| module OpenProject | ||
| # OpenProject-specific AvatarStack that extends Primer::Beta::AvatarStack | ||
| # to support avatar fallbacks with initials. | ||
| # | ||
| # Uses a different slot name (avatar_with_fallbacks) to avoid conflicts with the parent's avatars slot. | ||
| class AvatarStack < Primer::Beta::AvatarStack | ||
akabiru marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| status :open_project | ||
|
|
||
| # Required list of stacked avatars with fallback support. | ||
| # | ||
| # @param kwargs [Hash] The same arguments as <%= link_to_component(Primer::OpenProject::AvatarWithFallback) %>. | ||
| renders_many :avatar_with_fallbacks, "Primer::OpenProject::AvatarWithFallback" | ||
|
|
||
| # Alias avatar_with_fallbacks as avatars for use in the template | ||
| def avatars | ||
| avatar_with_fallbacks | ||
| end | ||
| end | ||
| end | ||
| end | ||
114 changes: 114 additions & 0 deletions
114
app/components/primer/open_project/avatar_with_fallback.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Primer | ||
| module OpenProject | ||
| # OpenProject-specific Avatar component that extends Primer::Beta::Avatar | ||
| # to support fallback rendering with initials when no image source is provided. | ||
| # | ||
| # When `src` is nil, this component renders an SVG with initials extracted from | ||
| # the alt text. The AvatarFallbackElement web component then enhances it client-side | ||
| # by applying a consistent background color based on the user's unique_id (using the | ||
| # same hash function as OP Core for consistency). | ||
| # | ||
| # This component follows the "extension over mutation" pattern - it extends | ||
| # Primer::Beta::Avatar without modifying its interface, ensuring compatibility | ||
| # with upstream changes. | ||
| class AvatarWithFallback < Primer::Beta::Avatar | ||
akabiru marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| status :open_project | ||
|
|
||
| # @see | ||
| # - https://primer.style/foundations/typography/ | ||
| # - https://github.com/primer/css/blob/main/src/support/variables/typography.scss | ||
| FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'" | ||
|
|
||
| # @param src [String] The source url of the avatar image. When nil, renders a fallback with initials. | ||
| # @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials when src is nil. | ||
| # @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %> | ||
| # @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %> | ||
| # @param href [String] The URL to link to. If used, component will be wrapped by an `<a>` tag. | ||
| # @param unique_id [String, Integer] Unique identifier for generating consistent avatar colors across renders. | ||
| # @param system_arguments [Hash] <%= link_to_system_arguments_docs %> | ||
| def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, href: nil, unique_id: nil, **system_arguments) | ||
| require_src_or_alt_arguments(src, alt) | ||
|
|
||
| @unique_id = unique_id | ||
| @use_fallback = src.blank? | ||
| final_src = @use_fallback ? generate_fallback_svg(alt, size) : src | ||
|
|
||
| super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments) | ||
| end | ||
|
|
||
| def call | ||
| render( | ||
| Primer::ConditionalWrapper.new( | ||
| condition: @use_fallback, | ||
| tag: :"avatar-fallback", | ||
| data: { | ||
| unique_id: @unique_id, | ||
| alt_text: @system_arguments[:alt] | ||
| } | ||
| ) | ||
| ) { super } | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def require_src_or_alt_arguments(src, alt) | ||
| return if src.present? || alt.present? | ||
|
|
||
| raise ArgumentError, "`src` or `alt` is required" | ||
| end | ||
|
|
||
| def generate_fallback_svg(alt, size) | ||
| svg_content = content_tag( | ||
| :svg, | ||
| safe_join([ | ||
| # Use a neutral dark gray as default to minimize flicker in both light/dark modes | ||
| # JS will replace with the hashed color (hsl(hue, 50%, 30%)) | ||
| tag.rect(width: "100%", height: "100%", fill: "hsl(0, 0%, 35%)"), | ||
| content_tag( | ||
| :text, | ||
| extract_initials(alt), | ||
| x: "50%", | ||
| y: "50%", | ||
| "text-anchor": "middle", | ||
| "dominant-baseline": "central", | ||
| fill: "white", | ||
| "font-size": fallback_font_size(size), | ||
| "font-weight": "600", | ||
| "font-family": FONT_STACK, | ||
| style: "user-select: none; text-transform: uppercase;" | ||
| ) | ||
| ]), | ||
| xmlns: "http://www.w3.org/2000/svg", | ||
| width: size, | ||
| height: size, | ||
| viewBox: "0 0 #{size} #{size}", | ||
| ) | ||
|
|
||
| "data:image/svg+xml;base64,#{Base64.strict_encode64(svg_content)}" | ||
| end | ||
|
|
||
| def extract_initials(name) | ||
| name = name.to_s.strip | ||
| return "" if name.empty? | ||
|
|
||
| chars = name.chars | ||
| first = chars[0]&.upcase || "" | ||
|
|
||
| last_space = name.rindex(" ") | ||
| if last_space && last_space < name.length - 1 | ||
| last = name[last_space + 1]&.upcase || "" | ||
| "#{first}#{last}" | ||
| else | ||
| first | ||
| end | ||
| end | ||
|
|
||
| def fallback_font_size(size) | ||
| # Font size is 45% of avatar size for good readability, with a minimum of 8px | ||
| [(size * 0.45).round, 8].max | ||
| end | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Primer | ||
| module OpenProject | ||
| # @label OpenProject AvatarStack | ||
| class AvatarStackPreview < ViewComponent::Preview | ||
| # @label Playground | ||
| # | ||
| # @param number_of_avatars [Integer] number | ||
| # @param with_fallbacks toggle | ||
| # @param align select [["Left", left], ["Right", right]] | ||
| def playground(number_of_avatars: 3, with_fallbacks: true, align: :left) | ||
| render(Primer::OpenProject::AvatarStack.new(align: align)) do |component| | ||
| Array.new(number_of_avatars&.to_i || 1) do |i| | ||
| if with_fallbacks | ||
| component.with_avatar_with_fallback(src: nil, alt: "User #{i + 1}", unique_id: i + 1) | ||
| else | ||
| component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser") | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| # @label Default | ||
| # @snapshot | ||
| def default | ||
| render(Primer::OpenProject::AvatarStack.new) do |component| | ||
| component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser") | ||
| end | ||
| end | ||
|
|
||
| # @label With fallback avatars | ||
| # @snapshot | ||
| def with_fallback_avatars | ||
| render(Primer::OpenProject::AvatarStack.new) do |component| | ||
| component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3) | ||
| end | ||
| end | ||
|
|
||
| # @label Mixed (image and fallback) | ||
| def mixed_avatars | ||
| render(Primer::OpenProject::AvatarStack.new) do |component| | ||
| component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser") | ||
| component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 10) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 20) | ||
| end | ||
| end | ||
|
|
||
| # @label Align right with fallbacks | ||
| def align_right_with_fallbacks | ||
| render(Primer::OpenProject::AvatarStack.new(align: :right)) do |component| | ||
| component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3) | ||
| end | ||
| end | ||
|
|
||
| # @label With tooltip and fallbacks | ||
| def with_tooltip_and_fallbacks | ||
| render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: "Team members" })) do |component| | ||
| component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2) | ||
| component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
71 changes: 71 additions & 0 deletions
71
previews/primer/open_project/avatar_with_fallback_preview.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Primer | ||
| module OpenProject | ||
| # @label AvatarWithFallback | ||
| class AvatarWithFallbackPreview < ViewComponent::Preview | ||
| # @label Playground | ||
| # | ||
| # @param size [Integer] select [16, 20, 24, 32, 40, 48, 64, 80] | ||
| # @param shape [Symbol] select [circle, square] | ||
| # @param href [String] text | ||
| # @param with_src [Boolean] toggle | ||
| def playground(size: 24, shape: :circle, href: nil, with_src: false) | ||
| if with_src | ||
| render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser", size: size, shape: shape, href: href)) | ||
| else | ||
| render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Admin", unique_id: 4, size: size, shape: shape, href: href)) | ||
| end | ||
| end | ||
|
|
||
| # @label Default | ||
| # @snapshot | ||
| def default | ||
| render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")) | ||
| end | ||
|
|
||
| # @label With image src | ||
| # @snapshot | ||
| def with_image | ||
| render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")) | ||
| end | ||
|
|
||
| # @!group Fallback (Initials) | ||
| # | ||
| # @label Fallback with initials | ||
| # @snapshot | ||
| def fallback_default | ||
| render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Admin", unique_id: 4)) | ||
| end | ||
|
|
||
| # @label Fallback single name | ||
| # @snapshot | ||
| def fallback_single_name | ||
| render(Primer::OpenProject::AvatarWithFallback.new(alt: "John", unique_id: 2)) | ||
| end | ||
|
|
||
| # @label Fallback multiple users | ||
| def fallback_multiple | ||
| render_with_template(locals: {}) | ||
| end | ||
|
|
||
| # @label Fallback sizes | ||
| def fallback_sizes | ||
| render_with_template(locals: {}) | ||
| end | ||
|
|
||
| # @label Fallback square shape | ||
| # @snapshot | ||
| def fallback_square | ||
| render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Org", unique_id: 100, shape: :square)) | ||
| end | ||
|
|
||
| # @label Fallback as link | ||
| def fallback_as_link | ||
| render(Primer::OpenProject::AvatarWithFallback.new(alt: "Jane Doe", unique_id: 3, href: "#")) | ||
| end | ||
| # | ||
| # @!endgroup | ||
| end | ||
| end | ||
| end |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.