feat(site-builder): Font Awesome 7.3.1 icons (per-network brands) in editor and exports
Exporters emit fa-solid/fa-brands/fa-regular classes through utils/fa-icon.ts; SocialLinks gets X/TikTok/Bluesky/Threads/Discord/Mastodon/Reddit/Telegram; the editor loads all.min.css + v4-shims (SRI) so editor chrome FA4 names work. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P
This commit is contained in:
1 parent
bb97e5b396
commit
7b3baa815c
17 files changed
+157
-48
No files matched your search
+3
-1
@@ -7,7 +7,9 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome Free 7.3.1 + the v4 shims (editor chrome still uses FA4 names like fa fa-trash-o) -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css" rel="stylesheet" integrity="sha384-qrALq7+6jBOZIQsNnT6xGkMDru64qD6uTlDra39xrt2SoXl4pO3FX6Roz/RpR/BS" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/v4-shims.min.css" rel="stylesheet" integrity="sha384-BOBgwI2GEcZ4l/UA/+WOVnLChe2S4c7UhUCtdmX1m0v7Wdyk0S/tDoJOBci2ywvl" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,7 +6,7 @@ const toHtml = (Icon as any).toHtml;
|
||||
describe('Icon.toHtml normal rendering', () => {
|
||||
test('renders icon class, size/color style, and link href', () => {
|
||||
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
|
||||
expect(html).toContain('class="fa fa-star"');
|
||||
expect(html).toContain('class="fa-solid fa-star"');
|
||||
expect(html).toContain('font-size:32px');
|
||||
expect(html).toContain('color:#3b82f6');
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
import { faClass } from '../../utils/fa-icon';
|
||||
|
||||
interface IconProps {
|
||||
icon?: string;
|
||||
@@ -46,7 +47,7 @@ export const Icon: UserComponent<IconProps> = ({
|
||||
|
||||
const iconEl = (
|
||||
<i
|
||||
className={`fa ${icon}`}
|
||||
className={faClass(icon)}
|
||||
style={{ fontSize: size, color, lineHeight: 1 }}
|
||||
/>
|
||||
);
|
||||
@@ -130,7 +131,7 @@ Icon.craft = {
|
||||
} = props;
|
||||
|
||||
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
|
||||
let iconHtml = `<i class="fa ${escapeAttr(icon)}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
|
||||
let iconHtml = `<i class="${escapeAttr(faClass(icon))}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
|
||||
|
||||
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||
if (hasBg) {
|
||||
|
||||
@@ -6,7 +6,7 @@ const toHtml = (SearchBar as any).toHtml;
|
||||
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
|
||||
test('the input-adjacent search icon is aria-hidden', () => {
|
||||
const { html } = toHtml({}, '');
|
||||
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
|
||||
const icons = html.match(/<i class="fa-solid fa-magnifying-glass"[^>]*>/g) || [];
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
>
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
<i
|
||||
className="fa fa-search"
|
||||
className="fa-solid fa-magnifying-glass"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '14px',
|
||||
@@ -99,7 +99,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-search" style={{ fontSize: '13px' }} />
|
||||
<i className="fa-solid fa-magnifying-glass" style={{ fontSize: '13px' }} />
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
@@ -151,7 +151,7 @@ SearchBar.craft = {
|
||||
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid ${tokenRef('border')};border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:${tokenRef('bg')};color:${tokenRef('text')};outline:none;box-sizing:border-box`;
|
||||
|
||||
const btnHtml = showButton
|
||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:${tokenRef('bg')};background-color:${tokenRef('primary')};border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
|
||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:${tokenRef('bg')};background-color:${tokenRef('primary')};border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa-solid fa-magnifying-glass" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
|
||||
: '';
|
||||
|
||||
// F2: previously a purely decorative <form> -- no action/method/input
|
||||
@@ -165,7 +165,7 @@ SearchBar.craft = {
|
||||
return {
|
||||
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<div style="position:relative;flex:1">
|
||||
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:${tokenRef('muted')};font-size:14px;pointer-events:none" aria-hidden="true"></i>
|
||||
<i class="fa-solid fa-magnifying-glass" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:${tokenRef('muted')};font-size:14px;pointer-events:none" aria-hidden="true"></i>
|
||||
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||
</div>
|
||||
${btnHtml}
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('SocialLinks.toHtml accessibility (F2.5)', () => {
|
||||
|
||||
test('the icon glyph itself is aria-hidden', () => {
|
||||
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
|
||||
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
|
||||
expect(html).toMatch(/<i class="fa-brands fa-x-twitter"[^>]*aria-hidden="true"/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,18 +26,24 @@ interface SocialLinksProps {
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
facebook: 'fa-facebook',
|
||||
twitter: 'fa-twitter',
|
||||
instagram: 'fa-instagram',
|
||||
linkedin: 'fa-linkedin',
|
||||
youtube: 'fa-youtube',
|
||||
github: 'fa-github',
|
||||
tiktok: 'fa-music',
|
||||
pinterest: 'fa-pinterest',
|
||||
snapchat: 'fa-snapchat',
|
||||
whatsapp: 'fa-whatsapp',
|
||||
spotify: 'fa-spotify',
|
||||
twitch: 'fa-twitch',
|
||||
facebook: 'fa-brands fa-facebook',
|
||||
twitter: 'fa-brands fa-x-twitter',
|
||||
instagram: 'fa-brands fa-instagram',
|
||||
linkedin: 'fa-brands fa-linkedin',
|
||||
youtube: 'fa-brands fa-youtube',
|
||||
github: 'fa-brands fa-github',
|
||||
tiktok: 'fa-brands fa-tiktok',
|
||||
pinterest: 'fa-brands fa-pinterest',
|
||||
snapchat: 'fa-brands fa-snapchat',
|
||||
whatsapp: 'fa-brands fa-whatsapp',
|
||||
spotify: 'fa-brands fa-spotify',
|
||||
bluesky: 'fa-brands fa-bluesky',
|
||||
threads: 'fa-brands fa-threads',
|
||||
discord: 'fa-brands fa-discord',
|
||||
mastodon: 'fa-brands fa-mastodon',
|
||||
reddit: 'fa-brands fa-reddit',
|
||||
telegram: 'fa-brands fa-telegram',
|
||||
twitch: 'fa-brands fa-twitch',
|
||||
};
|
||||
|
||||
const platformLabels: Record<string, string> = {
|
||||
@@ -53,6 +59,12 @@ const platformLabels: Record<string, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
spotify: 'Spotify',
|
||||
twitch: 'Twitch',
|
||||
bluesky: 'Bluesky',
|
||||
threads: 'Threads',
|
||||
discord: 'Discord',
|
||||
mastodon: 'Mastodon',
|
||||
reddit: 'Reddit',
|
||||
telegram: 'Telegram',
|
||||
};
|
||||
|
||||
const defaultLinks: SocialLink[] = [
|
||||
@@ -116,7 +128,7 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => {
|
||||
const iconClass = platformIcons[link.platform] || 'fa-link';
|
||||
const iconClass = platformIcons[link.platform] || 'fa-solid fa-link';
|
||||
const shapeStyle = getShapeStyle(iconShape, iconSize);
|
||||
const hasBg = iconShape !== 'none';
|
||||
|
||||
@@ -136,7 +148,7 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
|
||||
...shapeStyle,
|
||||
}}
|
||||
>
|
||||
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
|
||||
<i className={iconClass} style={{ fontSize: iconSize }} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
@@ -210,7 +222,7 @@ SocialLinks.craft = {
|
||||
};
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const iconClass = platformIcons[link.platform] || 'fa-link';
|
||||
const iconClass = platformIcons[link.platform] || 'fa-solid fa-link';
|
||||
const title = platformLabels[link.platform] || link.platform;
|
||||
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
|
||||
if (hasBg) {
|
||||
@@ -220,7 +232,7 @@ SocialLinks.craft = {
|
||||
// aria-hidden and the accessible name lives on the link (aria-label,
|
||||
// mirroring the existing `title` tooltip since title support in
|
||||
// screen readers is inconsistent).
|
||||
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('StarRating.toHtml accessibility (F2.2)', () => {
|
||||
|
||||
test('individual star glyphs are aria-hidden', () => {
|
||||
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/g) || [];
|
||||
const glyphs = html.match(/<i class="fa-solid fa-star"[^>]*>/g) || [];
|
||||
expect(glyphs.length).toBeGreaterThan(0);
|
||||
glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2
|
||||
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
|
||||
const malicious = '5" onmouseover="alert(1)';
|
||||
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
|
||||
const glyphs = html.match(/<i class="fa-solid fa-star"/g) || [];
|
||||
// Falls back to a sane default star count rather than looping 0 or NaN times.
|
||||
expect(glyphs.length).toBeGreaterThan(0);
|
||||
expect(glyphs.length).toBeLessThanOrEqual(50);
|
||||
@@ -66,7 +66,7 @@ describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2
|
||||
|
||||
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
|
||||
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
|
||||
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
|
||||
const glyphs = html.match(/<i class="fa-solid fa-star"/g) || [];
|
||||
expect(glyphs.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
stars.push(
|
||||
<i
|
||||
key={i}
|
||||
className="fa fa-star"
|
||||
className="fa-solid fa-star"
|
||||
style={{ color: filledColor, fontSize: size }}
|
||||
/>
|
||||
);
|
||||
@@ -48,9 +48,9 @@ export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
// Half star
|
||||
stars.push(
|
||||
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
|
||||
<i className="fa fa-star" style={{ color: emptyColor }} />
|
||||
<i className="fa-solid fa-star" style={{ color: emptyColor }} />
|
||||
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
|
||||
<i className="fa fa-star" style={{ color: filledColor }} />
|
||||
<i className="fa-solid fa-star" style={{ color: filledColor }} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -59,7 +59,7 @@ export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
stars.push(
|
||||
<i
|
||||
key={i}
|
||||
className="fa fa-star"
|
||||
className="fa-solid fa-star"
|
||||
style={{ color: emptyColor, fontSize: size }}
|
||||
/>
|
||||
);
|
||||
@@ -136,11 +136,11 @@ StarRating.craft = {
|
||||
let starsHtml = '';
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
if (i <= Math.floor(rating)) {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
starsHtml += `<i class="fa-solid fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
|
||||
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
|
||||
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa-solid fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa-solid fa-star" style="color:${filledColor}"></i></span></span>`;
|
||||
} else {
|
||||
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
starsHtml += `<i class="fa-solid fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ const VIDEO_PLACEHOLDER = (
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
|
||||
<i className="fa-solid fa-circle-play" style={{ fontSize: 36, opacity: 0.5 }} />
|
||||
<span>Add a video URL in settings</span>
|
||||
</div>
|
||||
);
|
||||
@@ -217,7 +217,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
|
||||
<i className="fa-solid fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay */}
|
||||
|
||||
@@ -30,8 +30,8 @@ describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
||||
|
||||
test('decorative chevron icons in arrows are aria-hidden', () => {
|
||||
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
|
||||
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
||||
expect(html).toMatch(/<i class="fa-solid fa-chevron-left" aria-hidden="true"><\/i>/);
|
||||
expect(html).toMatch(/<i class="fa-solid fa-chevron-right" aria-hidden="true"><\/i>/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -194,10 +194,10 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
|
||||
{showArrows && items.length > 1 && (
|
||||
<>
|
||||
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
|
||||
<i className="fa fa-chevron-left" />
|
||||
<i className="fa-solid fa-chevron-left" />
|
||||
</button>
|
||||
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
|
||||
<i className="fa fa-chevron-right" />
|
||||
<i className="fa-solid fa-chevron-right" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -329,8 +329,8 @@ ContentSlider.craft = {
|
||||
}).join('\n ');
|
||||
|
||||
const arrowsHtml = showArrows && items.length > 1
|
||||
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
|
||||
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
|
||||
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i></button>
|
||||
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
|
||||
: '';
|
||||
|
||||
const dotsHtml = showDots && items.length > 1
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('Testimonials.toHtml single-layout export parity', () => {
|
||||
describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
|
||||
test('star glyphs are aria-hidden', () => {
|
||||
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
|
||||
const stars = html.match(/<i class="fa fa-star[^"]*"[^>]*>/g) || [];
|
||||
const stars = html.match(/<i class="fa-(?:solid|regular) fa-star"[^>]*>/g) || [];
|
||||
expect(stars.length).toBeGreaterThan(0);
|
||||
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ function renderStars(count: number, color: string): React.ReactNode {
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<i
|
||||
key={i}
|
||||
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
|
||||
className={i <= count ? 'fa-solid fa-star' : 'fa-regular fa-star'}
|
||||
style={{ color, fontSize: '14px' }}
|
||||
/>
|
||||
))}
|
||||
@@ -68,7 +68,7 @@ function starsHtml(count: number, color: string): string {
|
||||
// non-numeric/garbage value).
|
||||
const safeCount = Number(count) || 0;
|
||||
const stars = [1, 2, 3, 4, 5].map((i) =>
|
||||
`<i class="fa ${i <= safeCount ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
|
||||
`<i class="${i <= safeCount ? 'fa-solid fa-star' : 'fa-regular fa-star'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
|
||||
).join('');
|
||||
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="${escapeAttr(`Rating: ${safeCount} out of 5`)}">${stars}</div>`;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">+ Add Platform...</option>
|
||||
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch'].map((p) => (
|
||||
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch', 'bluesky', 'threads', 'discord', 'mastodon', 'reddit', 'telegram'].map((p) => (
|
||||
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { faClass } from './fa-icon';
|
||||
import { SocialLinks } from '../components/basic/SocialLinks';
|
||||
|
||||
describe('faClass (FA4 name -> FA7 class string)', () => {
|
||||
test('solid default, brand and regular mappings', () => {
|
||||
expect(faClass('fa-star')).toBe('fa-solid fa-star');
|
||||
expect(faClass('fa-facebook')).toBe('fa-brands fa-facebook');
|
||||
expect(faClass('fa-star-o')).toBe('fa-regular fa-star');
|
||||
expect(faClass('fa-picture-o')).toBe('fa-regular fa-image');
|
||||
expect(faClass('fa-star-half-o')).toBe('fa-regular fa-star-half-stroke');
|
||||
});
|
||||
test('legacy "fa fa-x" and already-styled values are normalised, extra tokens kept', () => {
|
||||
expect(faClass('fa fa-github')).toBe('fa-brands fa-github');
|
||||
expect(faClass('fa-brands fa-x-twitter')).toBe('fa-brands fa-x-twitter');
|
||||
expect(faClass('fa-star fa-spin')).toBe('fa-solid fa-star fa-spin');
|
||||
});
|
||||
test('empty falls back to the star', () => {
|
||||
expect(faClass('')).toBe('fa-solid fa-star');
|
||||
expect(faClass(undefined)).toBe('fa-solid fa-star');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocialLinks exports per-network FA7 brand icons', () => {
|
||||
const toHtml = (SocialLinks as any).toHtml;
|
||||
const cases: Array<[string, string]> = [
|
||||
['twitter', 'fa-x-twitter'], ['tiktok', 'fa-tiktok'], ['bluesky', 'fa-bluesky'], ['threads', 'fa-threads'],
|
||||
['discord', 'fa-discord'], ['mastodon', 'fa-mastodon'], ['facebook', 'fa-facebook'], ['spotify', 'fa-spotify'],
|
||||
['twitch', 'fa-twitch'], ['youtube', 'fa-youtube'], ['reddit', 'fa-reddit'], ['telegram', 'fa-telegram'],
|
||||
];
|
||||
test.each(cases)('%s -> fa-brands %s', (platform, icon) => {
|
||||
const { html } = toHtml({ links: [{ platform, url: '#' }] }, '');
|
||||
expect(html).toContain(`<i class="fa-brands ${icon}"`);
|
||||
expect(html).not.toMatch(/class="fa /);
|
||||
});
|
||||
test('an unknown platform falls back to a solid link icon', () => {
|
||||
expect(toHtml({ links: [{ platform: 'nope', url: '#' }] }, '').html).toContain('<i class="fa-solid fa-link"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Font Awesome 7 (Free) class helper. The builder used Font Awesome 4 names
|
||||
* (`fa fa-facebook`); v7 needs a style class per icon (`fa-brands fa-facebook`,
|
||||
* `fa-solid fa-star`, `fa-regular fa-star`). Everything the exporters emit goes
|
||||
* through here so the mapping lives in one place.
|
||||
*/
|
||||
|
||||
/** Brand glyphs (Font Awesome Brands font). Legacy FA4 brand names are a subset. */
|
||||
const BRANDS = new Set([
|
||||
'facebook', 'facebook-f', 'facebook-square', 'twitter', 'twitter-square', 'x-twitter', 'instagram', 'linkedin',
|
||||
'linkedin-in', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch',
|
||||
'bluesky', 'threads', 'discord', 'mastodon', 'reddit', 'telegram', 'google', 'apple', 'amazon', 'paypal',
|
||||
'stripe', 'vimeo', 'wordpress', 'slack', 'dribbble', 'behance', 'medium', 'tumblr', 'soundcloud', 'skype',
|
||||
]);
|
||||
|
||||
/** FA4 `-o` (outline) names whose v7 regular-style name differs from `<name>` minus `-o`. */
|
||||
const REGULAR_RENAMES: Record<string, string> = {
|
||||
'picture-o': 'image',
|
||||
'star-half-o': 'star-half-stroke',
|
||||
'trash-o': 'trash-can',
|
||||
'file-o': 'file',
|
||||
'folder-o': 'folder',
|
||||
'envelope-o': 'envelope',
|
||||
};
|
||||
|
||||
const STYLE_TOKENS = new Set([
|
||||
'fa-solid', 'fa-regular', 'fa-brands', 'fa-light', 'fa-thin', 'fa-duotone', 'fa-classic', 'fas', 'far', 'fab',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Class string for an icon value: a bare/legacy name (`fa-star`, `fa-star-o`,
|
||||
* `fa-facebook`, `fa fa-star`) or an already-styled one (`fa-brands fa-x-twitter`).
|
||||
* Returns e.g. `fa-solid fa-star`. Extra tokens (fa-spin, fa-2x) pass through.
|
||||
*/
|
||||
export function faClass(icon: string | undefined, fallback = 'fa-star'): string {
|
||||
const tokens = String(icon ?? '').trim().split(/\s+/).filter((t) => t && t !== 'fa');
|
||||
if (tokens.length === 0) tokens.push(fallback);
|
||||
if (tokens.some((t) => STYLE_TOKENS.has(t))) return tokens.join(' ');
|
||||
const idx = tokens.findIndex((t) => /^fa-/.test(t));
|
||||
if (idx < 0) return ['fa-solid', ...tokens].join(' ');
|
||||
const name = tokens[idx].slice(3);
|
||||
const rest = tokens.filter((_, i) => i !== idx);
|
||||
let style = 'fa-solid';
|
||||
let out = name;
|
||||
if (BRANDS.has(name)) {
|
||||
style = 'fa-brands';
|
||||
} else if (name in REGULAR_RENAMES) {
|
||||
style = 'fa-regular';
|
||||
out = REGULAR_RENAMES[name];
|
||||
} else if (/-o$/.test(name)) {
|
||||
style = 'fa-regular';
|
||||
out = name.slice(0, -2);
|
||||
}
|
||||
return [style, `fa-${out}`, ...rest].join(' ');
|
||||
}
|
||||
Reference in new issue
Block a user