UNPKG

vue3-avatar

Version:

A lightweight, fully customizable, accessible, and SSR-safe user avatar component for Vue 3 and Nuxt. Supports initials, custom images, pixel-art generation (identicons), groups with overflow, and auto-contrast text. Perfect for user profiles, team displa

881 lines (772 loc) 30.9 kB
import { ref, inject, computed, openBlock, createElementBlock, normalizeClass, normalizeStyle, withKeys, withModifiers, renderSlot, toDisplayString, createCommentVNode, defineComponent, h, cloneVNode, Fragment, Comment, Text } from 'vue'; function getInitials(name) { if (!name || typeof name !== 'string') return ''; let words = name.trim().split(/[- ]/); // Filter out empty strings from multiple spaces words = words.filter(word => word !== ""); if (words.length === 0) return ""; if (words.length >= 3) { return words[0][0].toUpperCase() + words[1][0].toUpperCase() + words[words.length - 1][0].toUpperCase(); } else if (words.length === 2) { return words[0][0].toUpperCase() + words[1][0].toUpperCase(); } else if (words.length === 1) { return words[0][0].toUpperCase(); } return ""; } const lightColors = ["#F0F8FF", "#FAEBD7", "#00FFFF", "#7FFFD4", "#F0FFFF", "#F5F5DC", "#FFE4C4", "#FFEBCD", "#DEB887", "#5F9EA0", "#7FFF00", "#D2691E", "#FF7F50", "#6495ED", "#FFF8DC", "#00FFFF", "#B8860B", "#A9A9A9", "#A9A9A9", "#BDB76B", "#FF8C00", "#E9967A", "#8FBC8F", "#00CED1", "#FF1493", "#00BFFF", "#1E90FF", "#FFFAF0", "#FF00FF", "#DCDCDC", "#F8F8FF", "#FFD700", "#DAA520", "#808080", "#808080", "#ADFF2F", "#F0FFF0", "#FF69B4", "#CD5C5C", "#FFFFF0", "#F0E68C", "#E6E6FA", "#FFF0F5", "#7CFC00", "#FFFACD", "#ADD8E6", "#F08080", "#E0FFFF", "#FAFAD2", "#D3D3D3", "#D3D3D3", "#90EE90", "#FFB6C1", "#FFA07A", "#20B2AA", "#87CEFA", "#B0C4DE", "#FFFFE0", "#00FF00", "#32CD32", "#FAF0E6", "#FF00FF", "#66CDAA", "#BA55D3", "#9370D8", "#3CB371", "#7B68EE", "#00FA9A", "#48D1CC", "#F5FFFA", "#FFE4E1", "#FFE4B5", "#FFDEAD", "#FDF5E6", "#FFA500", "#FF4500", "#DA70D6", "#EEE8AA", "#98FB98", "#AFEEEE", "#D87093", "#FFEFD5", "#FFDAB9", "#CD853F", "#FFC0CB", "#DDA0DD", "#B0E0E6", "#FF0000", "#BC8F8F", "#FA8072", "#F4A460", "#FFF5EE", "#C0C0C0", "#87CEEB", "#FFFAFA", "#00FF7F", "#D2B48C", "#D8BFD8", "#FF6347", "#40E0D0", "#EE82EE", "#F5DEB3", "#FFFFFF", "#F5F5F5", "#FFFF00", "#9ACD32"]; const darkColors = ["#000000", "#0000FF", "#8A2BE2", "#A52A2A", "#DC143C", "#00008B", "#008B8B", "#006400", "#8B008B", "#556B2F", "#9932CC", "#8B0000", "#483D8B", "#2F4F4F", "#2F4F4F", "#9400D3", "#696969", "#696969", "#B22222", "#228B22", "#008000", "#4B0082", "#800000", "#0000CD", "#C71585", "#191970", "#000080", "#808000", "#6B8E23", "#800080", "#4169E1", "#8B4513", "#2E8B57", "#A0522D", "#6A5ACD", "#708090", "#708090", "#4682B4", "#008080"]; const legacyBackgroundColors = ['#F44336', '#FF4081', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#FF5722', '#795548', '#9E9E9E', '#607D8B']; function getAsciiValue(name) { if (!name) return 0; const username = name.trim(); let ascii = 0; for (let index = 0; index < username.length; index++) ascii += username.charCodeAt(index); return ascii; } function lightenColor(hex, amt) { if (!/^#[0-9A-Fa-f]{6}$/i.test(hex)) { return '#FFFFFF'; } const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const newR = Math.min(255, r + amt); const newG = Math.min(255, g + amt); const newB = Math.min(255, b + amt); const toHex = n => n.toString(16).padStart(2, '0').toUpperCase(); return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`; } function getAvatarColors(name) { let useLegacyColors = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; const ascii = getAsciiValue(name); if (useLegacyColors) { let backgroundColor = legacyBackgroundColors[0]; if (name && typeof name === 'string') { const index = (name.length || 0) % legacyBackgroundColors.length; backgroundColor = legacyBackgroundColors[index]; } const color = lightenColor(backgroundColor, 80); return { background: backgroundColor, color }; } // Modern logic const darkColor = darkColors[ascii % darkColors.length]; const lightColor = lightColors[ascii % lightColors.length]; // Secondary color for gradients const secondaryColor = darkColors[(ascii + 5) % darkColors.length]; const gradient = `linear-gradient(135deg, ${darkColor} 0%, ${secondaryColor} 100%)`; // Default: Dark Background, Light Text return { background: darkColor, color: lightColor, gradient: gradient, // We also expose the pair if needed for inversion light: lightColor, dark: darkColor }; } /** * Generate a deterministic 8x8 pixel grid from a string * @param {string} str - Input string to hash * @returns {boolean[][]} - 8x8 grid of boolean values */ function generatePixelGrid(str) { if (!str) return Array(8).fill(null).map(() => Array(8).fill(false)); // Simple hash function let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } // Generate symmetric 8x8 grid (mirror horizontally for better aesthetics) const grid = []; for (let y = 0; y < 8; y++) { const row = []; for (let x = 0; x < 4; x++) { // Use different parts of the hash for each position const bitIndex = (y * 4 + x) % 32; const bit = Math.abs(hash) >> bitIndex & 1; row.push(bit === 1); } // Mirror the row for symmetry grid.push([...row, ...row.reverse()]); } return grid; } /** * Color themes for pixel avatars */ const PIXEL_THEMES = { earth: { background: "#8B7355", foreground: "#D4A574" }, neon: { background: "#FF006E", foreground: "#00F5FF" }, ocean: { background: "#006994", foreground: "#4FC3F7" }, forest: { background: "#2D5016", foreground: "#7CB342" }, sunset: { background: "#FF6B35", foreground: "#FFD23F" }, midnight: { background: "#1A1A2E", foreground: "#16213E" }, candy: { background: "#FF69B4", foreground: "#FFB6C1" }, retro: { background: "#8B4513", foreground: "#DEB887" } }; /** * Generate SVG for pixel avatar * @param {boolean[][]} grid - 8x8 boolean grid * @param {object} theme - Color theme with background and foreground * @param {number} size - Size of the avatar * @returns {string} - SVG string */ function generatePixelSVG(grid, theme) { let size = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 40; const pixelSize = size / 8; const pixels = []; for (let y = 0; y < 8; y++) { for (let x = 0; x < 8; x++) { if (grid[y][x]) { pixels.push(`<rect x="${x * pixelSize}" y="${y * pixelSize}" width="${pixelSize}" height="${pixelSize}" fill="${theme.foreground}"/>`); } } } return ` <svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"> <rect width="${size}" height="${size}" fill="${theme.background}"/> ${pixels.join("\n ")} </svg> `.trim(); } /** * Calculate the relative luminance of a color * @param {string} color - Hex color string (e.g., '#FF5733') * @returns {number} - Luminance value between 0 and 1 */ function getLuminance(color) { // Remove # if present const hex = color.replace("#", ""); // Parse RGB values const r = parseInt(hex.substring(0, 2), 16) / 255; const g = parseInt(hex.substring(2, 4), 16) / 255; const b = parseInt(hex.substring(4, 6), 16) / 255; // Apply gamma correction const rLinear = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); const gLinear = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); const bLinear = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); // Calculate relative luminance return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear; } /** * Calculate YIQ value for a color (simpler alternative to luminance) * @param {string} color - Hex color string * @returns {number} - YIQ value */ function getYIQ(color) { const hex = color.replace("#", ""); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return (r * 299 + g * 587 + b * 114) / 1000; } /** * Get contrasting text color (black or white) based on background * @param {string} backgroundColor - Hex color string * @param {string} method - 'luminance' or 'yiq' (default: 'yiq') * @returns {string} - '#FFFFFF' or '#000000' */ function getContrastColor(backgroundColor) { let method = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "yiq"; if (!backgroundColor || !backgroundColor.startsWith("#")) { return "#FFFFFF"; // Default to white for invalid colors } if (method === "luminance") { const luminance = getLuminance(backgroundColor); // Threshold of 0.5 works well for most cases return luminance > 0.5 ? "#000000" : "#FFFFFF"; } else { const yiq = getYIQ(backgroundColor); // YIQ threshold of 128 is standard return yiq >= 128 ? "#000000" : "#FFFFFF"; } } // Symbol for provide/inject const AvatarConfigKey = Symbol("AvatarConfig"); const _hoisted_1 = ["role", "tabindex", "aria-label", "title", "onKeydown"]; const _hoisted_2 = ["height", "width", "src", "loading"]; const _hoisted_3 = ["innerHTML"]; var script$1 = { __name: 'Avatar', props: { name: { type: String, required: true }, color: { type: String }, background: { type: String }, size: { type: Number, default: 40 }, dark: { type: Boolean, default: false }, inline: { type: Boolean, default: false }, rounded: { type: Boolean, default: true }, shape: { type: String, validator: value => ["circle", "square", "squircle", "hexagon"].includes(value) }, imageSrc: { type: String }, alt: { type: String, default: undefined }, loading: { type: String, default: "lazy", validator: value => ["lazy", "eager"].includes(value) }, transition: { type: Boolean, default: true }, border: { type: Boolean, default: true }, borderColor: { type: String, default: "white" }, customAvatarStyle: { type: Object, default: () => ({}) }, status: { type: String, default: null, validator: function (value) { return ["away", "online", "offline", "busy"].includes(value); } }, statusPosition: { type: String, default: "bottom-right", validator: value => ["top-right", "top-left", "bottom-right", "bottom-left"].includes(value) }, customStatusStyle: { type: Object, default: () => ({}) }, sameBorder: { type: Boolean, default: false }, interactive: { type: Boolean, default: false }, /** * @deprecated Use original vue-avatar color palette for backwards compatibility */ useLegacyColors: { type: Boolean, default: false }, useTextColorForBorder: { type: Boolean, default: false }, gradient: { type: Boolean, default: false }, pointer: { type: Boolean, default: false }, onClick: { type: Function, default: null }, variant: { type: String, default: "initials", validator: value => ["initials", "pixel"].includes(value) }, pixelTheme: { type: String, default: "earth", validator: value => Object.keys(PIXEL_THEMES).includes(value) }, autoContrast: { type: Boolean, default: false } }, emits: ["error", "activate", "load"], setup(__props, _ref) { let { emit: __emit } = _ref; const BORDERCOLORS = { ONLINE: "green", OFFLINE: "grey", AWAY: "orange", BUSY: "red" }; const props = __props; const emit = __emit; const imageError = ref(false); const isLoaded = ref(false); const globalConfig = inject(AvatarConfigKey, {}); const getConfig = (key, localValue, defaultValue) => { if (localValue !== undefined && localValue !== defaultValue) return localValue; return globalConfig[key] !== undefined ? globalConfig[key] : defaultValue; }; const isClickable = computed(() => { return props.pointer || props.interactive || typeof props.onClick === "function"; }); function onActivate(event) { if (typeof props.onClick === "function") { props.onClick(event); } if (props.interactive) { emit("activate", event); } } const computedColors = computed(() => { return getAvatarColors(props.name, props.useLegacyColors); }); const displayName = computed(() => { return getInitials(props.name); }); const pixelGrid = computed(() => { return generatePixelGrid(props.name); }); const pixelSVG = computed(() => { const baseTheme = PIXEL_THEMES[props.pixelTheme] || PIXEL_THEMES.earth; // Allow custom overrides via props const customBg = getConfig("background", props.background); const customColor = getConfig("color", props.color); let theme = { background: customBg || baseTheme.background, foreground: customColor || baseTheme.foreground }; // If no custom overrides, handle dark/light toggle // Consistent with initials: dark=true is dark background, dark=false is light background if (!customBg && !customColor && !props.dark) { theme = { background: baseTheme.foreground, foreground: baseTheme.background }; } return generatePixelSVG(pixelGrid.value, theme, props.size); }); const displayBackground = computed(() => { const bg = getConfig("background", props.background); if (bg) return bg; const colors = computedColors.value; if (props.useLegacyColors) return colors.background; if (props.gradient && colors.gradient) return colors.gradient; return props.dark ? colors.dark : colors.light; }); const displayColor = computed(() => { const color = getConfig("color", props.color); if (color) return color; // If auto-contrast is enabled, calculate based on background if (getConfig("autoContrast", props.autoContrast, false)) { const bg = displayBackground.value; // Only apply auto-contrast if background is a valid hex color if (bg && bg.startsWith("#")) { return getContrastColor(bg); } } const colors = computedColors.value; if (props.useLegacyColors) return colors.color; return props.dark ? colors.light : colors.dark; }); const displayBorderColor = computed(() => { return props.useTextColorForBorder ? displayColor.value : getConfig("borderColor", props.borderColor, "white"); }); const fontSize = computed(() => { const size = getConfig("size", props.size, 40); if (displayName.value.length == 1) return size / 2;else if (displayName.value.length == 2) return size / 2.5; if (displayName.value.length == 3) return size / 3;else return 14; }); const statusBackgroundColor = computed(() => { let color; switch (props.status && props.status.toLowerCase()) { case "away": color = BORDERCOLORS.AWAY; break; case "online": color = BORDERCOLORS.ONLINE; break; case "offline": color = BORDERCOLORS.OFFLINE; break; default: color = BORDERCOLORS.BUSY; } return color; }); const statusStyle = computed(() => { // Calculate position based on statusPosition prop const positionStyles = {}; const offset = 0; // Can be adjusted based on shape const pos = getConfig("statusPosition", props.statusPosition, "bottom-right"); if (pos.includes("bottom")) { positionStyles.bottom = `${offset}px`; positionStyles.top = "auto"; } else { positionStyles.top = `${offset}px`; positionStyles.bottom = "auto"; } if (pos.includes("right")) { positionStyles.right = `${offset}px`; positionStyles.left = "auto"; } else { positionStyles.left = `${offset}px`; positionStyles.right = "auto"; } const size = getConfig("size", props.size, 40); const defaultStatusStyle = { height: `${size / 4}px`, width: `${size / 4}px`, backgroundColor: statusBackgroundColor.value, border: `${size / 30}px solid ${props.sameBorder ? displayBorderColor.value : "white"}`, ...positionStyles }; return Object.assign({}, defaultStatusStyle, props.customStatusStyle); }); const imageStyle = computed(() => { const size = getConfig("size", props.size, 40); const defaultImageStyle = { display: props.inline ? "inline-flex" : "flex", borderRadius: shapeStyle.value.borderRadius, clipPath: shapeStyle.value.clipPath, margin: 0, padding: 0, alignItems: "center", justifyContent: "center", border: props.border ? `${size / 20}px solid ${displayBorderColor.value}` : "none" }; return Object.assign({}, defaultImageStyle, props.customAvatarStyle); }); const avatarStyle = computed(() => { const size = getConfig("size", props.size, 40); const defaultAvatarStyle = { color: displayColor.value, width: size + "px", height: size + "px", fontSize: fontSize.value + "px", background: displayBackground.value, display: props.inline && "inline-flex", borderRadius: shapeStyle.value.borderRadius, clipPath: shapeStyle.value.clipPath, border: props.border && `${size / 20}px solid ${displayBorderColor.value}` }; return Object.assign({}, defaultAvatarStyle, props.customAvatarStyle); }); const shapeStyle = computed(() => { const shape = props.shape || (props.rounded ? "circle" : "square"); if (shape === "square") return { borderRadius: "0" }; if (shape === "circle") return { borderRadius: "50%" }; if (shape === "squircle") return { borderRadius: "25%" }; if (shape === "hexagon") return { borderRadius: "0", clipPath: "polygon(25% 5%, 75% 5%, 95% 50%, 75% 95%, 25% 95%, 5% 50%)" }; return { borderRadius: "0" }; }); const rootStyle = computed(() => { const size = getConfig("size", props.size, 40); return { "--va-size": `${size}px`, "--va-bg": displayBackground.value, "--va-color": displayColor.value, "--va-border-color": displayBorderColor.value, "--va-radius": shapeStyle.value.borderRadius, "--va-clip-path": shapeStyle.value.clipPath || "none", "--va-font-size": `${fontSize.value}px` }; }); const accessibleLabel = computed(() => { const label = props.alt || (props.name ? `Avatar of ${props.name}` : "User avatar"); if (props.status) { return `${label}. User is ${props.status}`; } return label; }); function onImageError(event) { imageError.value = true; isLoaded.value = false; emit("error", event); } function onImageLoad(event) { isLoaded.value = true; emit("load", event); } function showImage() { return props.imageSrc && !imageError.value; } return (_ctx, _cache) => { return openBlock(), createElementBlock("div", { class: normalizeClass(["container", { 'is-clickable': isClickable.value }]), style: normalizeStyle(rootStyle.value), role: isClickable.value ? 'button' : 'img', tabindex: isClickable.value ? 0 : undefined, "aria-label": accessibleLabel.value, title: __props.name, onClick: onActivate, onKeydown: [withKeys(withModifiers(onActivate, ["prevent"]), ["enter"]), withKeys(withModifiers(onActivate, ["prevent"]), ["space"])] }, [showImage() && _ctx.$slots.image ? renderSlot(_ctx.$slots, "image", { src: __props.imageSrc, alt: accessibleLabel.value, size: __props.size, style: normalizeStyle(imageStyle.value), class: normalizeClass({ 'image-loaded': isLoaded.value, 'image-transition': __props.transition }), onError: onImageError, onLoad: onImageLoad }, undefined, undefined, 0) : showImage() ? (openBlock(), createElementBlock("img", { key: 1, style: normalizeStyle(imageStyle.value), height: __props.size, width: __props.size, src: __props.imageSrc, loading: __props.loading, class: normalizeClass({ 'image-loaded': isLoaded.value, 'image-transition': __props.transition }), alt: "", onError: onImageError, onLoad: onImageLoad }, null, 46, _hoisted_2)) : !__props.name && _ctx.$slots.placeholder ? renderSlot(_ctx.$slots, "placeholder", { size: __props.size, style: normalizeStyle(avatarStyle.value) }, undefined, undefined, 2) : __props.variant === 'pixel' ? (openBlock(), createElementBlock("div", { key: 3, style: normalizeStyle(avatarStyle.value), class: "avatar avatar-pixel noselect", "aria-hidden": "true", innerHTML: pixelSVG.value }, null, 12, _hoisted_3)) : (openBlock(), createElementBlock("div", { key: 4, style: normalizeStyle(avatarStyle.value), class: "avatar noselect", "aria-hidden": "true" }, toDisplayString(displayName.value), 5)), __props.status || _ctx.$slots.status ? (openBlock(), createElementBlock("div", { key: 5, class: "status-indicator", style: normalizeStyle(statusStyle.value), "aria-hidden": "true" }, [renderSlot(_ctx.$slots, "status")], 4)) : createCommentVNode("", true), renderSlot(_ctx.$slots, "overlay")], 46, _hoisted_1); }; } }; function styleInject(css, ref) { if ( ref === void 0 ) ref = {}; var insertAt = ref.insertAt; if (!css || typeof document === 'undefined') { return; } var head = document.head || document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; if (insertAt === 'top') { if (head.firstChild) { head.insertBefore(style, head.firstChild); } else { head.appendChild(style); } } else { head.appendChild(style); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { style.appendChild(document.createTextNode(css)); } } var css_248z$1 = "\n@import url(\"https://fonts.googleapis.com/css2?family=Domine:wght@700&display=swap\");\n.avatar[data-v-a6123cce] {\n font-family: \"Domine\", serif;\n color: white;\n background: navy;\n font-size: 14px;\n width: 45px;\n height: 45px;\n border-radius: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-weight: 700;\n}\n.avatar-pixel[data-v-a6123cce] {\n padding: 0;\n overflow: hidden;\n}\n.avatar-pixel svg[data-v-a6123cce] {\n display: block;\n}\n.noselect[data-v-a6123cce] {\n -webkit-touch-callout: none; /* iOS Safari */\n -webkit-user-select: none; /* Safari */\n -khtml-user-select: none; /* Konqueror HTML */\n -moz-user-select: none; /* Old versions of Firefox */\n -ms-user-select: none; /* Internet Explorer/Edge */\n user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */\n}\n.container[data-v-a6123cce] {\n position: relative;\n}\n.container.is-clickable[data-v-a6123cce],\n.container.is-clickable[data-v-a6123cce] * {\n cursor: pointer !important;\n}\n.status-indicator[data-v-a6123cce] {\n position: absolute;\n border-radius: 50%;\n}\n.container img.image-transition[data-v-a6123cce] {\n opacity: 0;\n transition: opacity 0.3s ease-in-out;\n}\n.container img.image-transition.image-loaded[data-v-a6123cce] {\n opacity: 1;\n}\n"; styleInject(css_248z$1); script$1.__scopeId = "data-v-a6123cce"; var script = defineComponent({ name: "AvatarGroup", props: { max: { type: Number }, overlap: { type: Number, default: 10 }, borderColor: { type: String, default: "white" }, size: { type: Number, default: 40 }, layout: { type: String, default: "stack", // stack | triangle validator: value => ["stack", "triangle"].includes(value) }, onClick: { type: Function, default: null }, pointer: { type: Boolean, default: false } }, emits: ["overflow-click"], setup(props, _ref) { let { slots, emit } = _ref; const flatten = nodes => { let result = []; for (const node of nodes) { if (node.type === Fragment && Array.isArray(node.children)) { result = result.concat(flatten(node.children)); } else if (node.type !== Comment) { // Skip empty text nodes if (node.type === Text) { if (node.children.trim().length === 0) continue; } result.push(node); } } return result; }; const globalConfig = inject(AvatarConfigKey, {}); const getConfig = (key, localValue, defaultValue) => { if (localValue !== undefined && localValue !== defaultValue) return localValue; return globalConfig[key] !== undefined ? globalConfig[key] : defaultValue; }; return () => { const defaultSlot = slots.default ? slots.default() : []; const children = flatten(defaultSlot); let visible = children; let overflowCount = 0; let effectiveMax = props.max; if (props.layout === "triangle") { const limit = props.max ? Math.min(props.max, 3) : 3; if (children.length > limit) { effectiveMax = limit - 1; } else { effectiveMax = limit; } } if (effectiveMax && children.length > effectiveMax) { visible = children.slice(0, effectiveMax); overflowCount = children.length - effectiveMax; } const allNames = children.map(child => child.props && child.props.name).filter(Boolean).join(", "); const hiddenChildren = overflowCount > 0 ? children.slice(effectiveMax) : []; const hiddenNames = hiddenChildren.map(child => child.props && child.props.name).filter(Boolean).join(", "); // Extract all user data const allUsers = children.map(child => child.props).filter(Boolean); // Extract hidden user data for the event payload const hiddenUsers = hiddenChildren.map(child => child.props).filter(Boolean); const handleOverflowClick = e => { e.stopPropagation(); emit("overflow-click", hiddenUsers, allUsers); }; const handleGroupKeydown = event => { if (!props.onClick || !["Enter", " ", "Spacebar"].includes(event.key)) { return; } event.preventDefault(); props.onClick(event); }; const size = getConfig("size", props.size, 40); const borderColor = getConfig("borderColor", props.borderColor, "white"); const overlap = getConfig("overlap", props.overlap, 10); const overflowBadge = overflowCount > 0 ? h("button", { type: "button", class: "avatar-overflow", title: hiddenNames, "aria-label": `Show ${overflowCount} more avatar${overflowCount === 1 ? "" : "s"}${hiddenNames ? `: ${hiddenNames}` : ""}`, onClick: handleOverflowClick, style: { width: `${size}px`, height: `${size}px`, fontSize: `${size / 2.5}px`, cursor: "pointer" } }, `+${overflowCount}`) : null; const visibleWithProps = visible.map(child => { return cloneVNode(child, { size: size, borderColor: borderColor }); }); return h("div", { class: ["avatar-group", `layout-${props.layout}`, { "is-clickable": props.pointer || !!props.onClick }], title: allNames, role: props.onClick ? "button" : undefined, tabindex: props.onClick ? 0 : undefined, "aria-label": props.onClick ? `Avatar group${allNames ? `: ${allNames}` : ""}` : undefined, onClick: e => props.onClick && props.onClick(e), onKeydown: handleGroupKeydown, style: { "--va-group-overlap": `-${overlap}px`, "--va-size": `${size}px` } }, [...visibleWithProps, overflowBadge]); }; } }); var css_248z = "\n.avatar-group {\n display: flex;\n align-items: center;\n}\n.avatar-group.is-clickable {\n cursor: pointer;\n}\n.avatar-group.is-clickable * {\n cursor: pointer !important;\n}\n.avatar-group.layout-stack > * {\n margin-left: 0;\n}\n.avatar-group.layout-stack > * + * {\n margin-left: var(--va-group-overlap);\n}\n.avatar-group.layout-triangle {\n position: relative;\n display: inline-block;\n width: calc(\n var(--va-size) * 1.8\n ); /* Wider for better visibility of back avatars */\n height: calc(var(--va-size) * 1.45);\n}\n.avatar-group.layout-triangle > * {\n position: absolute !important;\n}\n.avatar-group.layout-triangle > *:nth-child(1) {\n top: 0;\n left: 50%;\n transform: translateX(-50%);\n z-index: 10 !important;\n}\n.avatar-group.layout-triangle > *:nth-child(2) {\n bottom: 0;\n left: 0;\n z-index: 5 !important;\n}\n.avatar-group.layout-triangle > *:nth-child(3) {\n bottom: 0;\n right: 0;\n z-index: 1 !important;\n}\n.avatar-overflow {\n display: flex;\n align-items: center;\n justify-content: center;\n appearance: none;\n background: #ccc;\n color: white;\n border-radius: 50%;\n font-weight: bold;\n font-family: sans-serif;\n border: 2px solid white; /* Hardcoded default? */\n box-sizing: border-box;\n position: relative; /* To stack properly */\n padding: 0;\n}\n.avatar-overflow:focus-visible {\n outline: 2px solid #2563eb;\n outline-offset: 2px;\n}\n"; styleInject(css_248z); // IIFE injects install function into component, allowing component // to be registered via Vue.use() as well as Vue.component(), const install = function (app) { let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; // Provide global configuration const globalDefaults = options.defaults || {}; app.provide(AvatarConfigKey, globalDefaults); app.component("Avatar", script$1); app.component("AvatarGroup", script); }; script$1.install = install; export { script$1 as Avatar, AvatarConfigKey, script as AvatarGroup, script$1 as default };