Skip to content
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
.workmanComponent {
&[data-highlight='true'] {
animation: member-highlight 2s ease-out;
}
}

@keyframes member-highlight {
0% {
background-color: color-mix(in srgb, var(--primary-color) 35%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary-color) 25%, transparent);
}
100% {
background-color: transparent;
box-shadow: 0 0 0 0 transparent;
}
}

.memberRow {
display: flex;
width: 100%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { getRoleTaskTranslation, getLanguageCode } from '../api/translations';
interface MemberItemProps {
member: Member;
language: string;
anchorId?: string;
}

const MemberItem: FC<MemberItemProps> = ({ member, language }) => {
const MemberItem: FC<MemberItemProps> = ({ member, language, anchorId }) => {
/**
* Manage the enlarged mode of the image.
* @description When the boolean value is true, image is enlarged.
Expand Down Expand Up @@ -51,7 +52,10 @@ const MemberItem: FC<MemberItemProps> = ({ member, language }) => {
const task = allTasks.join(', ');

return (
<li className={cls.workmanComponent}>
<li
id={anchorId}
className={cls.workmanComponent}
>
<div className={cls.memberRow}>
<div className={cls.centerContainer}>
<div className={cls.textContainer}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"team-title": "Team",
"team-description": "The strength of PRG is its people",
"link-to-prg": "PRG Board",
"go-to-member": "Go to {{name}}",
"search-placeholder": "Search...",

"Tableofcontent-title": "Our team"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"team-title": "Tiimi",
"team-description": "PRG:n voima on sen ihmisissΓ€",
"link-to-prg": "PRG:n hallitus",
"go-to-member": "Siirry henkilΓΆΓΆn {{name}}",
"search-placeholder": "Hae...",

"Tableofcontent-title": "Tiimimme"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,22 @@

}

.MosaicGridButton {
display: flex;
padding: 0;
border: none;
background: none;
cursor: pointer;
overflow: hidden;

&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
}

.MosaicGridImage {
object-fit: cover;
background: var(--base-card-background);
@include link-scale-effect;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MosaicGrid } from './MosaicGrid';
import { Member } from '@/entities/Member/model/types/types';

jest.mock('@/shared/i18n', () => ({
useClientTranslation: jest.fn(),
}));

jest.mock('@/shared/lib/hooks/useSizes', () => ({
__esModule: true,
default: jest.fn(() => ({ isMobileSize: false })),
}));

import { useClientTranslation } from '@/shared/i18n';
import useSizes from '@/shared/lib/hooks/useSizes';
import { envHelper } from '@/shared/const/envHelper';

const members: Member[] = [{ id: 1, name: 'Test Member' }];

const manyMembers: Member[] = Array.from({ length: 14 }, (_, i) => ({
id: i + 1,
name: `Member ${i + 1}`,
}));

const renderMosaic = (props: React.ComponentProps<typeof MosaicGrid>) =>
render(<MosaicGrid {...props} />);

const SCROLL_POLL_INTERVAL = 50;

describe('MosaicGrid', () => {
beforeEach(() => {
document.body.innerHTML = '';
(useSizes as jest.Mock).mockReturnValue({ isMobileSize: false });
(useClientTranslation as jest.Mock).mockReturnValue({
t: (key: string, options?: { name?: string }) =>
key === 'go-to-member' ? `Go to ${options?.name}` : key,
});
});

it('renders a clickable portrait button with an accessible aria-label', () => {
renderMosaic({ members });

expect(screen.getAllByRole('button', { name: 'Go to Test Member' }).length).toBeGreaterThan(
0,
);
});

it('renders the same number of buttons as filled mobile slots (3x3)', () => {
(useSizes as jest.Mock).mockReturnValue({ isMobileSize: true });
renderMosaic({ members });

expect(screen.getAllByRole('button', { name: 'Go to Test Member' })).toHaveLength(9);
});

it('renders 2 rows of 7 slots on desktop when there are fewer than 14 members', () => {
renderMosaic({ members });

expect(screen.getAllByRole('button', { name: 'Go to Test Member' })).toHaveLength(14);
});

it('renders 3 rows of 7 slots on desktop when there are 14 or more members', () => {
renderMosaic({ members: manyMembers });

expect(screen.getAllByRole('button')).toHaveLength(21);
});

it('renders no buttons when there are no members', () => {
renderMosaic({ members: [] });

expect(screen.queryAllByRole('button')).toHaveLength(0);
});

it('uses the portrait asset URL as the image source when a portrait exists', () => {
const memberWithPortrait: Member = {
id: 7,
name: 'Portrait Member',
portrait: { id: 'abc', title: 'portrait' },
};
renderMosaic({ members: [memberWithPortrait] });

expect(screen.getAllByRole('img', { name: 'Portrait Member' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('img', { name: 'Portrait Member' })[0]).toHaveAttribute(
'src',
`${envHelper.directusHost}/assets/abc`,
);
});

it('scrolls to the member row smoothly when the portrait is clicked', () => {
const scrollIntoView = jest.fn();
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
document.body.innerHTML = '<div id="member-1"></div>';

renderMosaic({ members });
fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]);

expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
});

it('highlights the scrolled-to member row', () => {
jest.useFakeTimers();
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
document.body.innerHTML = '<div id="member-1"></div>';
const target = document.getElementById('member-1');

renderMosaic({ members });
expect(target).not.toHaveAttribute('data-highlight');

fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]);
jest.advanceTimersByTime(SCROLL_POLL_INTERVAL * 2);

expect(target).toHaveAttribute('data-highlight', 'true');
jest.runOnlyPendingTimers();
jest.useRealTimers();
});

it('clears the highlight from the member row after the highlight duration', () => {
jest.useFakeTimers();
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
document.body.innerHTML = '<div id="member-1"></div>';
const target = document.getElementById('member-1');

renderMosaic({ members });
fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]);
jest.advanceTimersByTime(SCROLL_POLL_INTERVAL * 2);

expect(target).toHaveAttribute('data-highlight', 'true');

jest.advanceTimersByTime(2000);
expect(target).not.toHaveAttribute('data-highlight');
jest.useRealTimers();
});

it('does nothing when the member row does not exist in the page', () => {
const scrollIntoView = jest.fn();
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});

renderMosaic({ members });
fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]);

expect(scrollIntoView).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,97 @@ import useSizes from '@/shared/lib/hooks/useSizes';
import { useMemo } from 'react';
import { envHelper } from '@/shared/const/envHelper';
import altLogo from '@/shared/assets/images/altLogo.png';
import { useClientTranslation } from '@/shared/i18n';

const SCROLL_HIGHLIGHT_DURATION = 2000;
const SCROLL_SETTLE_TIMEOUT = 3000;
const SCROLL_SETTLE_TOLERANCE = 8;
const SCROLL_POLL_INTERVAL = 50;

/**
* works out how far to scroll to put an element in the middle of the screen
* @description same maths as `block: 'center'` in scrollIntoView, so we can check
* when the smooth scroll has actually landed. clamped to whatever the page can
* really scroll to.
* @param {HTMLElement} element - the element we want in view
* @returns {number} the scrollY value that centres it
*/
const getTargetScrollPosition = (element: HTMLElement): number => {
const viewportHeight = window.innerHeight;
const maxScroll = Math.max(0, document.documentElement.scrollHeight - viewportHeight);
const rect = element.getBoundingClientRect();
const centered = rect.top + window.scrollY - (viewportHeight - rect.height) / 2;
return Math.min(Math.max(centered, 0), maxScroll);
};

/**
* runs a callback once the page has stopped scrolling
* @description smooth scrolling doesn't tell you when it's done, so we just check
* every animation frame until we're close enough to where we wanted to go. gives
* up after a timeout in case we never get there.
* @param {number} targetScrollY - where we ended up wanting to scroll to
* @param {() => void} onArrived - what to run once scrolling has settled
*/
const waitForScrollArrival = (targetScrollY: number, onArrived: () => void) => {
const startTime = performance.now();
const tick = (time: number) => {
if (
time - startTime >= SCROLL_SETTLE_TIMEOUT ||
Math.abs(window.scrollY - targetScrollY) <= SCROLL_SETTLE_TOLERANCE
) {
onArrived();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
};

/**
* gives a member row a quick glow so you can see where you ended up
* @description sets `data-highlight`, which kicks off the `member-highlight`
* animation in MemberItem.module.scss. we strip the attribute and force a
* repaint first, otherwise clicking the same portrait twice won't restart it.
* @param {HTMLElement} element - the member row to highlight
*/
const highlightMember = (element: HTMLElement) => {
element.removeAttribute('data-highlight');
void element.offsetHeight;
element.setAttribute('data-highlight', 'true');
window.setTimeout(() => element.removeAttribute('data-highlight'), SCROLL_HIGHLIGHT_DURATION);
};

/**
* props for the MosaicGrid component
* {Object} MosaicGridProps
* @property {string} [className] - extra class name(s) for the grid
* @property {Member[]} members - members to show as clickable portraits
*/
export interface MosaicGridProps {
className?: string;
members: Member[];
}

const MosaicGrid = ({ className, members }: MosaicGridProps) => {
const { isMobileSize } = useSizes();
const { t } = useClientTranslation('members');
const directusBaseUrl = envHelper.directusHost;

/**
* scrolls down to a member's row and gives it a highlight once we get there
* @description uses the id SectionMembers puts on the member's first row.
* does nothing if there's no match, so portraits on pages without a member
* list just sit there instead of blowing up.
* @param {number} memberId - the Directus id of the member we're after
*/
const scrollToMember = (memberId: number) => {
const element = document.getElementById(`member-${memberId}`);
if (!element) return;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
const targetScrollY = getTargetScrollPosition(element);
waitForScrollArrival(targetScrollY, () => highlightMember(element));
};

// shuffle members to fill the grid randomly
const shuffledMembers = useMemo(() => {
const shuffled = [...members];
Expand Down Expand Up @@ -56,14 +137,21 @@ const MosaicGrid = ({ className, members }: MosaicGridProps) => {
? `${directusBaseUrl}/assets/${member.portrait.id}`
: altLogo;
return member ? (
<Image
<button
key={`${member.id}-${index}`}
src={imageSrc}
alt={member.name}
className={classNames(cls.MosaicGridImage)}
width={252}
height={252}
/>
type="button"
className={cls.MosaicGridButton}
onClick={() => scrollToMember(member.id)}
aria-label={t('go-to-member', { name: member.name })}
>
<Image
src={imageSrc}
alt={member.name}
className={classNames(cls.MosaicGridImage)}
width={252}
height={252}
/>
</button>
) : null;
})}
</div>
Expand Down
Loading
Loading