diff --git a/.changeset/heavy-donuts-bow.md b/.changeset/heavy-donuts-bow.md new file mode 100644 index 0000000000..95374f3623 --- /dev/null +++ b/.changeset/heavy-donuts-bow.md @@ -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. diff --git a/app/components/primer/open_project/avatar_fallback.ts b/app/components/primer/open_project/avatar_fallback.ts new file mode 100644 index 0000000000..cf57a8dba8 --- /dev/null +++ b/app/components/primer/open_project/avatar_fallback.ts @@ -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('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. + } + } +} diff --git a/app/components/primer/open_project/avatar_stack.pcss b/app/components/primer/open_project/avatar_stack.pcss new file mode 100644 index 0000000000..fd72c96b6c --- /dev/null +++ b/app/components/primer/open_project/avatar_stack.pcss @@ -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; + } + + & 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; + } + } +} diff --git a/app/components/primer/open_project/avatar_stack.rb b/app/components/primer/open_project/avatar_stack.rb new file mode 100644 index 0000000000..96175f4fee --- /dev/null +++ b/app/components/primer/open_project/avatar_stack.rb @@ -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 + 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 diff --git a/app/components/primer/open_project/avatar_with_fallback.rb b/app/components/primer/open_project/avatar_with_fallback.rb new file mode 100644 index 0000000000..ef8d74271e --- /dev/null +++ b/app/components/primer/open_project/avatar_with_fallback.rb @@ -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 + 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 `` 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 diff --git a/app/components/primer/primer.pcss b/app/components/primer/primer.pcss index cfa7ecfd8f..cb90951286 100644 --- a/app/components/primer/primer.pcss +++ b/app/components/primer/primer.pcss @@ -45,6 +45,7 @@ @import "./alpha/action_bar.pcss"; /* OP specifics */ +@import "./open_project/avatar_stack.pcss"; @import "./open_project/page_header.pcss"; @import "./open_project/drag_handle.pcss"; @import "./open_project/border_grid.pcss"; diff --git a/app/components/primer/primer.ts b/app/components/primer/primer.ts index d22e455704..02695e8306 100644 --- a/app/components/primer/primer.ts +++ b/app/components/primer/primer.ts @@ -30,6 +30,7 @@ import './alpha/tree_view/tree_view' import './alpha/tree_view/tree_view_icon_pair_element' import './alpha/tree_view/tree_view_sub_tree_node_element' import './alpha/tree_view/tree_view_include_fragment_element' +import './open_project/avatar_fallback' import './open_project/page_header_element' import './open_project/zen_mode_button' import './open_project/sub_header_element' diff --git a/previews/primer/open_project/avatar_stack_preview.rb b/previews/primer/open_project/avatar_stack_preview.rb new file mode 100644 index 0000000000..e2ba3625d7 --- /dev/null +++ b/previews/primer/open_project/avatar_stack_preview.rb @@ -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 diff --git a/previews/primer/open_project/avatar_with_fallback_preview.rb b/previews/primer/open_project/avatar_with_fallback_preview.rb new file mode 100644 index 0000000000..5b579d1254 --- /dev/null +++ b/previews/primer/open_project/avatar_with_fallback_preview.rb @@ -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 diff --git a/previews/primer/open_project/avatar_with_fallback_preview/fallback_multiple.html.erb b/previews/primer/open_project/avatar_with_fallback_preview/fallback_multiple.html.erb new file mode 100644 index 0000000000..fe1b6abe84 --- /dev/null +++ b/previews/primer/open_project/avatar_with_fallback_preview/fallback_multiple.html.erb @@ -0,0 +1,7 @@ +
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Alice Johnson", unique_id: 10)) %> + <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Bob Smith", unique_id: 20)) %> + <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Charlie Brown", unique_id: 30)) %> + <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Diana Prince", unique_id: 40)) %> + <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Eve Anderson", unique_id: 50)) %> +
diff --git a/previews/primer/open_project/avatar_with_fallback_preview/fallback_sizes.html.erb b/previews/primer/open_project/avatar_with_fallback_preview/fallback_sizes.html.erb new file mode 100644 index 0000000000..6768bc2d50 --- /dev/null +++ b/previews/primer/open_project/avatar_with_fallback_preview/fallback_sizes.html.erb @@ -0,0 +1,34 @@ +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 16)) %> + 16px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 20)) %> + 20px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 24)) %> + 24px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 32)) %> + 32px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 40)) %> + 40px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 48)) %> + 48px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 64)) %> + 64px +
+
+ <%= render(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 100, size: 80)) %> + 80px +
+
diff --git a/static/arguments.json b/static/arguments.json index 7e3ccd2cdb..2e2652411f 100644 --- a/static/arguments.json +++ b/static/arguments.json @@ -5717,6 +5717,104 @@ } ] }, + { + "component": "OpenProject::AvatarStack", + "status": "open_project", + "a11y_reviewed": false, + "short_name": "OpenProjectAvatarStack", + "source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/avatar_stack.rb", + "lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/avatar_stack/default/", + "parameters": [ + { + "name": "tag", + "type": "Symbol", + "default": "`:div`", + "description": "One of `:div` or `:span`." + }, + { + "name": "align", + "type": "Symbol", + "default": "`:left`", + "description": "One of `:left` or `:right`." + }, + { + "name": "tooltipped", + "type": "Boolean", + "default": "`false`", + "description": "Whether to add a tooltip to the stack or not." + }, + { + "name": "disable_expand", + "type": "Boolean", + "default": "`false`", + "description": "Whether to disable the expand behavior on hover. If true, avatars will not expand." + }, + { + "name": "body_arguments", + "type": "Hash", + "default": "`{}`", + "description": "Parameters to add to the Body. If `tooltipped` is set, has the same arguments as [Tooltip](/components/tooltip). The default tag is `:div` but can be changed using `tag:` to one of `:div` or `:span`." + }, + { + "name": "system_arguments", + "type": "Hash", + "default": "N/A", + "description": "[System arguments](/system-arguments)" + } + ] + }, + { + "component": "OpenProject::AvatarWithFallback", + "status": "open_project", + "a11y_reviewed": false, + "short_name": "OpenProjectAvatarWithFallback", + "source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/avatar_with_fallback.rb", + "lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/avatar_with_fallback/default/", + "parameters": [ + { + "name": "src", + "type": "String", + "default": "`nil`", + "description": "The source url of the avatar image. When nil, renders a fallback with initials." + }, + { + "name": "alt", + "type": "String", + "default": "`nil`", + "description": "Alt text for the avatar. Used for accessibility and to generate initials when src is nil." + }, + { + "name": "size", + "type": "Integer", + "default": "`20`", + "description": "One of `16`, `20`, `24`, `32`, `40`, `48`, `64`, or `80`." + }, + { + "name": "shape", + "type": "Symbol", + "default": "`:circle`", + "description": "Shape of the avatar. One of `:circle` or `:square`." + }, + { + "name": "href", + "type": "String", + "default": "`nil`", + "description": "The URL to link to. If used, component will be wrapped by an `
` tag." + }, + { + "name": "unique_id", + "type": "String, Integer", + "default": "`nil`", + "description": "Unique identifier for generating consistent avatar colors across renders." + }, + { + "name": "system_arguments", + "type": "Hash", + "default": "N/A", + "description": "[System arguments](/system-arguments)" + } + ] + }, { "component": "OpenProject::BorderBox::CollapsibleHeader", "status": "open_project", diff --git a/static/audited_at.json b/static/audited_at.json index c77d55bf54..bf88500793 100644 --- a/static/audited_at.json +++ b/static/audited_at.json @@ -139,6 +139,8 @@ "Primer::IconButton": "", "Primer::LayoutComponent": "", "Primer::Navigation::TabComponent": "", + "Primer::OpenProject::AvatarStack": "", + "Primer::OpenProject::AvatarWithFallback": "", "Primer::OpenProject::BorderBox::CollapsibleHeader": "", "Primer::OpenProject::BorderGrid": "", "Primer::OpenProject::BorderGrid::Cell": "", diff --git a/static/classes.json b/static/classes.json index 3dbe7107a1..66be95e30a 100644 --- a/static/classes.json +++ b/static/classes.json @@ -96,7 +96,8 @@ "Primer::Beta::AvatarStack" ], "AvatarStack-body": [ - "Primer::Beta::AvatarStack" + "Primer::Beta::AvatarStack", + "Primer::OpenProject::AvatarStack" ], "Banner": [ "Primer::Alpha::Banner" diff --git a/static/constants.json b/static/constants.json index c16cff4155..974a43a1f3 100644 --- a/static/constants.json +++ b/static/constants.json @@ -1695,6 +1695,13 @@ "Primer::Navigation::TabComponent": { "GeneratedSlotMethods": "Primer::Navigation::TabComponent::GeneratedSlotMethods" }, + "Primer::OpenProject::AvatarStack": { + "GeneratedSlotMethods": "Primer::OpenProject::AvatarStack::GeneratedSlotMethods" + }, + "Primer::OpenProject::AvatarWithFallback": { + "FONT_STACK": "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'", + "GeneratedSlotMethods": "Primer::OpenProject::AvatarWithFallback::GeneratedSlotMethods" + }, "Primer::OpenProject::BorderBox::CollapsibleHeader": { "GeneratedSlotMethods": "Primer::OpenProject::BorderBox::CollapsibleHeader::GeneratedSlotMethods" }, diff --git a/static/info_arch.json b/static/info_arch.json index 1022a6a112..de8301386e 100644 --- a/static/info_arch.json +++ b/static/info_arch.json @@ -18709,6 +18709,341 @@ "previews": [], "subcomponents": [] }, + { + "fully_qualified_name": "Primer::OpenProject::AvatarStack", + "description": "OpenProject-specific AvatarStack that extends Primer::Beta::AvatarStack\nto support avatar fallbacks with initials.\n\nUses a different slot name (avatar_with_fallbacks) to avoid conflicts with the parent's avatars slot.", + "accessibility_docs": null, + "is_form_component": false, + "is_published": true, + "requires_js": false, + "component": "OpenProject::AvatarStack", + "status": "open_project", + "a11y_reviewed": false, + "short_name": "OpenProjectAvatarStack", + "source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/avatar_stack.rb", + "lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/avatar_stack/default/", + "parameters": [ + { + "name": "tag", + "type": "Symbol", + "default": "`:div`", + "description": "One of `:div` or `:span`." + }, + { + "name": "align", + "type": "Symbol", + "default": "`:left`", + "description": "One of `:left` or `:right`." + }, + { + "name": "tooltipped", + "type": "Boolean", + "default": "`false`", + "description": "Whether to add a tooltip to the stack or not." + }, + { + "name": "disable_expand", + "type": "Boolean", + "default": "`false`", + "description": "Whether to disable the expand behavior on hover. If true, avatars will not expand." + }, + { + "name": "body_arguments", + "type": "Hash", + "default": "`{}`", + "description": "Parameters to add to the Body. If `tooltipped` is set, has the same arguments as {{#link_to_component}}Primer::Tooltip{{/link_to_component}}. The default tag is `:div` but can be changed using `tag:` to one of `:div` or `:span`." + }, + { + "name": "system_arguments", + "type": "Hash", + "default": "N/A", + "description": "{{link_to_system_arguments_docs}}" + } + ], + "slots": [ + { + "name": "avatar_with_fallbacks", + "description": "Required list of stacked avatars with fallback support.", + "parameters": [ + { + "name": "kwargs", + "type": "Hash", + "default": "N/A", + "description": "The same arguments as {{#link_to_component}}Primer::OpenProject::AvatarWithFallback{{/link_to_component}}." + } + ] + } + ], + "methods": [ + { + "name": "avatars", + "description": "Alias avatar_with_fallbacks as avatars for use in the template", + "parameters": [], + "return_types": [] + } + ], + "previews": [ + { + "preview_path": "primer/open_project/avatar_stack/playground", + "name": "playground", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/default", + "name": "default", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/with_fallback_avatars", + "name": "with_fallback_avatars", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/mixed_avatars", + "name": "mixed_avatars", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/align_right_with_fallbacks", + "name": "align_right_with_fallbacks", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/with_tooltip_and_fallbacks", + "name": "with_tooltip_and_fallbacks", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + } + ], + "subcomponents": [] + }, + { + "fully_qualified_name": "Primer::OpenProject::AvatarWithFallback", + "description": "OpenProject-specific Avatar component that extends Primer::Beta::Avatar\nto support fallback rendering with initials when no image source is provided.\n\nWhen `src` is nil, this component renders an SVG with initials extracted from\nthe alt text. The AvatarFallbackElement web component then enhances it client-side\nby applying a consistent background color based on the user's unique_id (using the\nsame hash function as OP Core for consistency).\n\nThis component follows the \"extension over mutation\" pattern - it extends\nPrimer::Beta::Avatar without modifying its interface, ensuring compatibility\nwith upstream changes.", + "accessibility_docs": null, + "is_form_component": false, + "is_published": true, + "requires_js": false, + "component": "OpenProject::AvatarWithFallback", + "status": "open_project", + "a11y_reviewed": false, + "short_name": "OpenProjectAvatarWithFallback", + "source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/avatar_with_fallback.rb", + "lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/avatar_with_fallback/default/", + "parameters": [ + { + "name": "src", + "type": "String", + "default": "`nil`", + "description": "The source url of the avatar image. When nil, renders a fallback with initials." + }, + { + "name": "alt", + "type": "String", + "default": "`nil`", + "description": "Alt text for the avatar. Used for accessibility and to generate initials when src is nil." + }, + { + "name": "size", + "type": "Integer", + "default": "`20`", + "description": "One of `16`, `20`, `24`, `32`, `40`, `48`, `64`, or `80`." + }, + { + "name": "shape", + "type": "Symbol", + "default": "`:circle`", + "description": "Shape of the avatar. One of `:circle` or `:square`." + }, + { + "name": "href", + "type": "String", + "default": "`nil`", + "description": "The URL to link to. If used, component will be wrapped by an `` tag." + }, + { + "name": "unique_id", + "type": "String, Integer", + "default": "`nil`", + "description": "Unique identifier for generating consistent avatar colors across renders." + }, + { + "name": "system_arguments", + "type": "Hash", + "default": "N/A", + "description": "{{link_to_system_arguments_docs}}" + } + ], + "slots": [], + "methods": [], + "previews": [ + { + "preview_path": "primer/open_project/avatar_with_fallback/playground", + "name": "playground", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/default", + "name": "default", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/with_image", + "name": "with_image", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_default", + "name": "fallback_default", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_single_name", + "name": "fallback_single_name", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_multiple", + "name": "fallback_multiple", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_sizes", + "name": "fallback_sizes", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_square", + "name": "fallback_square", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_as_link", + "name": "fallback_as_link", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + } + ], + "subcomponents": [] + }, { "fully_qualified_name": "Primer::OpenProject::BorderBox::CollapsibleHeader", "description": "A component to be used inside Primer::Beta::BorderBox.\nIt will toggle the visibility of the complete Box body", diff --git a/static/previews.json b/static/previews.json index fed481e201..8800d366f2 100644 --- a/static/previews.json +++ b/static/previews.json @@ -1511,6 +1511,131 @@ } ] }, + { + "name": "avatar_with_fallback", + "component": "OpenProject::AvatarWithFallback", + "status": "open_project", + "lookup_path": "primer/open_project/avatar_with_fallback", + "examples": [ + { + "preview_path": "primer/open_project/avatar_with_fallback/playground", + "name": "playground", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/default", + "name": "default", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/with_image", + "name": "with_image", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_default", + "name": "fallback_default", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_single_name", + "name": "fallback_single_name", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_multiple", + "name": "fallback_multiple", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_sizes", + "name": "fallback_sizes", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_square", + "name": "fallback_square", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_with_fallback/fallback_as_link", + "name": "fallback_as_link", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + } + ] + }, { "name": "banner", "component": "Banner", @@ -5961,6 +6086,92 @@ } ] }, + { + "name": "avatar_stack", + "component": "OpenProject::AvatarStack", + "status": "open_project", + "lookup_path": "primer/open_project/avatar_stack", + "examples": [ + { + "preview_path": "primer/open_project/avatar_stack/playground", + "name": "playground", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/default", + "name": "default", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/with_fallback_avatars", + "name": "with_fallback_avatars", + "snapshot": "true", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/mixed_avatars", + "name": "mixed_avatars", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/align_right_with_fallbacks", + "name": "align_right_with_fallbacks", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + }, + { + "preview_path": "primer/open_project/avatar_stack/with_tooltip_and_fallbacks", + "name": "with_tooltip_and_fallbacks", + "snapshot": "false", + "skip_rules": { + "wont_fix": [ + "region" + ], + "will_fix": [ + "color-contrast" + ] + } + } + ] + }, { "name": "overlay", "component": "Overlay", diff --git a/static/statuses.json b/static/statuses.json index 41f159d117..90f691796f 100644 --- a/static/statuses.json +++ b/static/statuses.json @@ -139,6 +139,8 @@ "Primer::IconButton": "deprecated", "Primer::LayoutComponent": "deprecated", "Primer::Navigation::TabComponent": "deprecated", + "Primer::OpenProject::AvatarStack": "open_project", + "Primer::OpenProject::AvatarWithFallback": "open_project", "Primer::OpenProject::BorderBox::CollapsibleHeader": "open_project", "Primer::OpenProject::BorderGrid": "open_project", "Primer::OpenProject::BorderGrid::Cell": "open_project", diff --git a/test/components/component_test.rb b/test/components/component_test.rb index 5d288b5a7e..06298131cc 100644 --- a/test/components/component_test.rb +++ b/test/components/component_test.rb @@ -53,6 +53,10 @@ class PrimerComponentTest < Minitest::Test [Primer::OpenProject::FlexLayout, {}, proc { |component| component.with_row { "Foo" } }], + [Primer::OpenProject::AvatarWithFallback, { src: "https://github.com/github.png", alt: "github" }], + [Primer::OpenProject::AvatarStack, {}, lambda do |component| + component.with_avatar_with_fallback(src: "https://github.com/github.png", alt: "github") + end], [Primer::OpenProject::DragHandle, {}], [Primer::OpenProject::BorderGrid, {}, proc { |component| component.with_row { "Foo" } diff --git a/test/components/open_project/avatar_stack_test.rb b/test/components/open_project/avatar_stack_test.rb new file mode 100644 index 0000000000..2f6f3bc9ff --- /dev/null +++ b/test/components/open_project/avatar_stack_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "components/test_helper" + +class PrimerOpenProjectAvatarStackTest < Minitest::Test + include Primer::ComponentTestHelpers + + def test_renders_with_image_avatars + render_inline(Primer::OpenProject::AvatarStack.new) do |component| + component.with_avatar_with_fallback(src: "https://github.com/github.png", alt: "@github") + end + + assert_selector("div.AvatarStack") do + assert_selector(".AvatarStack-body") do + assert_selector("img.avatar", count: 1) + end + end + end + + def test_renders_with_fallback_avatars + render_inline(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) + end + + assert_selector("div.AvatarStack") do + assert_selector(".AvatarStack-body") do + assert_selector("avatar-fallback", count: 2) + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 2) + end + end + end + + def test_renders_mixed_avatars + render_inline(Primer::OpenProject::AvatarStack.new) do |component| + component.with_avatar_with_fallback(src: "https://github.com/github.png", alt: "@github") + component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 10) + end + + assert_selector("div.AvatarStack") do + assert_selector(".AvatarStack-body") do + # 2 img tags total: 1 with remote src, 1 with data URI fallback + assert_selector("img.avatar", count: 2) + assert_selector("avatar-fallback", count: 1) + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 1) + end + end + end + + def test_renders_three_plus_fallback_avatars + render_inline(Primer::OpenProject::AvatarStack.new) do |component| + component.with_avatar_with_fallback(src: nil, alt: "User 1", unique_id: 1) + component.with_avatar_with_fallback(src: nil, alt: "User 2", unique_id: 2) + component.with_avatar_with_fallback(src: nil, alt: "User 3", unique_id: 3) + end + + assert_selector(".AvatarStack.AvatarStack--three-plus") do + assert_selector(".AvatarStack-body") do + assert_selector("avatar-fallback", count: 3) + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']", count: 3) + end + end + end + + def test_status + assert_component_state(Primer::OpenProject::AvatarStack, :open_project) + end +end diff --git a/test/components/open_project/avatar_with_fallback_test.rb b/test/components/open_project/avatar_with_fallback_test.rb new file mode 100644 index 0000000000..0ce1284c7a --- /dev/null +++ b/test/components/open_project/avatar_with_fallback_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require "components/test_helper" + +class PrimerOpenProjectAvatarWithFallbackTest < Minitest::Test + include Primer::ComponentTestHelpers + + def test_renders_image_avatar_with_src + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github")) + + assert_selector("img.avatar") + refute_selector("avatar-fallback") + end + + def test_renders_fallback_when_src_is_nil + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "OpenProject Admin")) + + # Should render avatar-fallback element wrapping an img with base64 SVG data URI + assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']") do + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + end + end + + def test_renders_fallback_when_src_is_blank + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "", alt: "OpenProject Admin")) + + assert_selector("avatar-fallback[data-alt-text='OpenProject Admin']") do + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + end + end + + def test_defaults_to_size_20 + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github")) + + assert_selector("img.avatar[size=20][height=20][width=20]") + end + + def test_fallback_defaults_to_size_20 + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User")) + + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + end + + def test_falls_back_when_size_isn_t_valid + without_fetch_or_fallback_raises do + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github", size: 1_000_000_000)) + + assert_selector("img.avatar[size=20][height=20][width=20]") + end + end + + def test_defaults_to_circle_avatar + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github")) + + assert_selector("img.avatar.circle") + end + + def test_fallback_defaults_to_circle_avatar + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User")) + + assert_selector("img.avatar.circle[src^='data:image/svg+xml;base64,']") + end + + def test_adds_small_modifier_when_size_is_less_than_threshold + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github", size: Primer::OpenProject::AvatarWithFallback::SMALL_THRESHOLD - 4)) + + assert_selector("img.avatar.avatar-small") + end + + def test_fallback_adds_small_modifier_when_size_is_less_than_threshold + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", size: Primer::OpenProject::AvatarWithFallback::SMALL_THRESHOLD - 4)) + + assert_selector("img.avatar.avatar-small[src^='data:image/svg+xml;base64,']") + end + + def test_sets_size_height_and_width + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github", size: 24)) + + assert_selector("img.avatar[size=24][height=24][width=24]") + end + + def test_fallback_sets_correct_size_class + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", size: 40)) + + # Size is set via attributes, not a dedicated class + assert_selector("img.avatar[size='40'][height='40'][width='40'][src^='data:image/svg+xml;base64,']") + end + + def test_squared_avatar + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github", shape: :square)) + + assert_selector("img.avatar") + refute_selector(".circle") + end + + def test_fallback_squared_avatar + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", shape: :square)) + + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + refute_selector(".circle") + end + + def test_renders_link_wrapper + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github", href: "#given-href")) + + assert_selector("a.avatar") do |(a)| + assert_equal("#given-href", a["href"]) + assert_selector("img") + refute_selector("img.avatar") + end + end + + def test_fallback_renders_link_wrapper + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", href: "#test")) + + # When href is provided, the avatar class is on the tag, not the + assert_selector("avatar-fallback") do + assert_selector("a.avatar[href='#test']") do + assert_selector("img[src^='data:image/svg+xml;base64,']") + end + end + end + + def test_fallback_with_unique_id_in_data_attribute + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", unique_id: 123)) + + # Should have data attributes for client-side processing + assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Test User']") do + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + end + end + + def test_fallback_without_unique_id + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User")) + + # Should still render, just without unique_id data attribute + assert_selector("avatar-fallback[data-alt-text='Test User']") do + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + end + end + + def test_adds_custom_classes + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "https://github.com/github.png", alt: "github", classes: "custom-class")) + + assert_selector("img.avatar.custom-class") + end + + def test_fallback_adds_custom_classes + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Test User", classes: "custom-class")) + + assert_selector("img.avatar.custom-class[src^='data:image/svg+xml;base64,']") + end + + def test_raises_when_both_src_and_alt_are_missing + error = assert_raises(ArgumentError) do + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: nil)) + end + + assert_includes(error.message, "`src` or `alt` is required") + end + + def test_raises_when_both_src_and_alt_are_blank + error = assert_raises(ArgumentError) do + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: "", alt: "")) + end + + assert_includes(error.message, "`src` or `alt` is required") + end + + def test_fallback_with_single_word_name + render_inline(Primer::OpenProject::AvatarWithFallback.new(src: nil, alt: "Alice")) + + assert_selector("avatar-fallback") do + assert_selector("img.avatar[src^='data:image/svg+xml;base64,']") + end + end + + def test_status + assert_component_state(Primer::OpenProject::AvatarWithFallback, :open_project) + end +end