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
JavaScript
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 };