diff --git a/frontend-next-migration/src/entities/Member/ui/MemberItem.module.scss b/frontend-next-migration/src/entities/Member/ui/MemberItem.module.scss index 151663b1d..6717ceafc 100644 --- a/frontend-next-migration/src/entities/Member/ui/MemberItem.module.scss +++ b/frontend-next-migration/src/entities/Member/ui/MemberItem.module.scss @@ -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%; diff --git a/frontend-next-migration/src/entities/Member/ui/MemberItem.tsx b/frontend-next-migration/src/entities/Member/ui/MemberItem.tsx index 0ce7353c3..4382fd8c7 100644 --- a/frontend-next-migration/src/entities/Member/ui/MemberItem.tsx +++ b/frontend-next-migration/src/entities/Member/ui/MemberItem.tsx @@ -11,9 +11,10 @@ import { getRoleTaskTranslation, getLanguageCode } from '../api/translations'; interface MemberItemProps { member: Member; language: string; + anchorId?: string; } -const MemberItem: FC = ({ member, language }) => { +const MemberItem: FC = ({ member, language, anchorId }) => { /** * Manage the enlarged mode of the image. * @description When the boolean value is true, image is enlarged. @@ -51,7 +52,10 @@ const MemberItem: FC = ({ member, language }) => { const task = allTasks.join(', '); return ( -
  • +
  • diff --git a/frontend-next-migration/src/shared/i18n/locales/en/members.json b/frontend-next-migration/src/shared/i18n/locales/en/members.json index c05c03a32..0cba60745 100644 --- a/frontend-next-migration/src/shared/i18n/locales/en/members.json +++ b/frontend-next-migration/src/shared/i18n/locales/en/members.json @@ -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" diff --git a/frontend-next-migration/src/shared/i18n/locales/fi/members.json b/frontend-next-migration/src/shared/i18n/locales/fi/members.json index 06ba7237d..c3bb1e24a 100644 --- a/frontend-next-migration/src/shared/i18n/locales/fi/members.json +++ b/frontend-next-migration/src/shared/i18n/locales/fi/members.json @@ -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" diff --git a/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.module.scss b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.module.scss index 910c41f29..12bc53121 100644 --- a/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.module.scss +++ b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.module.scss @@ -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; } \ No newline at end of file diff --git a/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx new file mode 100644 index 000000000..f363f34b1 --- /dev/null +++ b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx @@ -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) => + render(); + +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 = '
    '; + + 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 = '
    '; + 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 = '
    '; + 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(); + }); +}); diff --git a/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx index e14b5b3df..8823aab2f 100644 --- a/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx +++ b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx @@ -6,7 +6,72 @@ 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[]; @@ -14,8 +79,24 @@ export interface MosaicGridProps { 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]; @@ -56,14 +137,21 @@ const MosaicGrid = ({ className, members }: MosaicGridProps) => { ? `${directusBaseUrl}/assets/${member.portrait.id}` : altLogo; return member ? ( - {member.name} + type="button" + className={cls.MosaicGridButton} + onClick={() => scrollToMember(member.id)} + aria-label={t('go-to-member', { name: member.name })} + > + {member.name} + ) : null; })}
    diff --git a/frontend-next-migration/src/widgets/SectionMembers/ui/SectionMembers.tsx b/frontend-next-migration/src/widgets/SectionMembers/ui/SectionMembers.tsx index de363e3b5..e08b60e1e 100644 --- a/frontend-next-migration/src/widgets/SectionMembers/ui/SectionMembers.tsx +++ b/frontend-next-migration/src/widgets/SectionMembers/ui/SectionMembers.tsx @@ -28,6 +28,25 @@ export const SectionMembers: FC = ({ className = '' }) => { const { teamsMap } = organizeMembers(members, lng); + const firstOccurrenceMemberIds = new Set(); + const seenMemberIds = new Set(); + teamsMap.forEach((team) => { + team.members.forEach((member) => { + if (!seenMemberIds.has(member.id)) { + seenMemberIds.add(member.id); + firstOccurrenceMemberIds.add(member.id); + } + }); + team.departments.forEach((department) => { + department.members.forEach((member) => { + if (!seenMemberIds.has(member.id)) { + seenMemberIds.add(member.id); + firstOccurrenceMemberIds.add(member.id); + } + }); + }); + }); + return (
    {/* = ({ className = '' }) => { key={member.id} member={member} language={lng} + anchorId={ + firstOccurrenceMemberIds.has(member.id) + ? `member-${member.id}` + : undefined + } /> ))} @@ -67,6 +91,11 @@ export const SectionMembers: FC = ({ className = '' }) => { key={member.id} member={member} language={lng} + anchorId={ + firstOccurrenceMemberIds.has(member.id) + ? `member-${member.id}` + : undefined + } /> ))}