From a3198e3be0b4b36facf8df6a1134c7366dc25272 Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Thu, 17 Sep 2026 15:49:41 +0300 Subject: [PATCH 1/8] feat(team): add anchor ids to member rows for portrait scrolling --- .../entities/Member/ui/MemberItem.module.scss | 4 +++ .../src/entities/Member/ui/MemberItem.tsx | 8 +++-- .../SectionMembers/ui/SectionMembers.tsx | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) 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..a7f667823 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,7 @@ +.workmanComponent { + scroll-margin-top: var(--navbar-height); +} + .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/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 + } /> ))} From ccbdb5b36cf63c34998945454def5c4a2135dfec Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Fri, 18 Sep 2026 15:15:32 +0300 Subject: [PATCH 2/8] feat(team): make mosaic portraits scroll to member name --- .../src/shared/i18n/locales/en/members.json | 1 + .../src/shared/i18n/locales/fi/members.json | 1 + .../v2/MosaicGrid/ui/MosaicGrid.module.scss | 8 +++++ .../shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx | 30 ++++++++++++++----- 4 files changed, 33 insertions(+), 7 deletions(-) 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..9fb1b3db6 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,6 +25,14 @@ } +.MosaicGridButton { + display: flex; + padding: 0; + border: none; + background: none; + cursor: pointer; +} + .MosaicGridImage { object-fit: cover; background: var(--base-card-background); 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..c0eb7c7f2 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,6 +6,7 @@ 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'; export interface MosaicGridProps { className?: string; @@ -14,8 +15,16 @@ export interface MosaicGridProps { const MosaicGrid = ({ className, members }: MosaicGridProps) => { const { isMobileSize } = useSizes(); + const { t } = useClientTranslation('members'); const directusBaseUrl = envHelper.directusHost; + const scrollToMember = (memberId: number) => { + const element = document.getElementById(`member-${memberId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + } + }; + // shuffle members to fill the grid randomly const shuffledMembers = useMemo(() => { const shuffled = [...members]; @@ -56,14 +65,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; })}
    From cd5941d4ab4b2cf8c89d0723722e338020f96b26 Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Mon, 21 Sep 2026 15:33:32 +0300 Subject: [PATCH 3/8] style(team): add hover and focus feedback to mosaic portraits --- .../src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.module.scss | 6 ++++++ 1 file changed, 6 insertions(+) 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 9fb1b3db6..bd437e773 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 @@ -31,6 +31,12 @@ border: none; background: none; cursor: pointer; + @include link-scale-effect; + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } } .MosaicGridImage { From 3c2d185621921a82db29a76f2198c158aef0e293 Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Wed, 23 Sep 2026 15:04:47 +0300 Subject: [PATCH 4/8] test(team): add mosaic portrait click test --- .../ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx 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..18c2ba51b --- /dev/null +++ b/frontend-next-migration/src/shared/ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx @@ -0,0 +1,50 @@ +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'; + +const members: Member[] = [{ id: 1, name: 'Test Member' }]; + +describe('MosaicGrid', () => { + beforeEach(() => { + (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', () => { + render(); + + expect(screen.getAllByRole('button', { name: 'Go to Test Member' }).length).toBeGreaterThan( + 0, + ); + }); + + 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 = '
    '; + + render(); + fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + }); +}); From cc5b593d6af7fb85470a672cd9cd43eec26df43a Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Fri, 25 Sep 2026 17:14:55 +0300 Subject: [PATCH 5/8] test(team): cover mosaic grid slot layout and portrait scroll behavior --- .../ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) 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 index 18c2ba51b..c7856ee3d 100644 --- 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 @@ -13,11 +13,21 @@ jest.mock('@/shared/lib/hooks/useSizes', () => ({ 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(); + describe('MosaicGrid', () => { beforeEach(() => { + document.body.innerHTML = ''; (useSizes as jest.Mock).mockReturnValue({ isMobileSize: false }); (useClientTranslation as jest.Mock).mockReturnValue({ t: (key: string, options?: { name?: string }) => @@ -26,13 +36,53 @@ describe('MosaicGrid', () => { }); it('renders a clickable portrait button with an accessible aria-label', () => { - render(); + 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', { @@ -41,10 +91,23 @@ describe('MosaicGrid', () => { }); document.body.innerHTML = '
    '; - render(); + renderMosaic({ members }); fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); }); + + 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(); + }); }); From 09e37a01509c779a55e3521c74f6a284b7554e88 Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Tue, 29 Sep 2026 16:06:01 +0300 Subject: [PATCH 6/8] feat(team): center-scroll and highlight the target member and update test --- .../entities/Member/ui/MemberItem.module.scss | 15 +++++++- .../v2/MosaicGrid/ui/MosaicGrid.module.scss | 3 +- .../ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx | 37 ++++++++++++++++++- .../shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx | 14 +++++-- 4 files changed, 63 insertions(+), 6 deletions(-) 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 a7f667823..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,5 +1,18 @@ .workmanComponent { - scroll-margin-top: var(--navbar-height); + &[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 { 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 bd437e773..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 @@ -31,7 +31,7 @@ border: none; background: none; cursor: pointer; - @include link-scale-effect; + overflow: hidden; &:focus-visible { outline: 2px solid var(--primary-color); @@ -42,4 +42,5 @@ .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 index c7856ee3d..2934daa37 100644 --- 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 @@ -95,7 +95,42 @@ describe('MosaicGrid', () => { fireEvent.click(screen.getAllByRole('button', { name: 'Go to Test Member' })[0]); expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + }); + + it('highlights the scrolled-to member row', () => { + 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]); + + expect(target).toHaveAttribute('data-highlight', 'true'); + }); + + 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]); + + 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', () => { 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 c0eb7c7f2..a55266afe 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 @@ -8,6 +8,8 @@ import { envHelper } from '@/shared/const/envHelper'; import altLogo from '@/shared/assets/images/altLogo.png'; import { useClientTranslation } from '@/shared/i18n'; +const SCROLL_HIGHLIGHT_DURATION = 2000; + export interface MosaicGridProps { className?: string; members: Member[]; @@ -20,9 +22,15 @@ const MosaicGrid = ({ className, members }: MosaicGridProps) => { const scrollToMember = (memberId: number) => { const element = document.getElementById(`member-${memberId}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } + if (!element) return; + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.removeAttribute('data-highlight'); + void element.offsetHeight; + element.setAttribute('data-highlight', 'true'); + window.setTimeout( + () => element.removeAttribute('data-highlight'), + SCROLL_HIGHLIGHT_DURATION, + ); }; // shuffle members to fill the grid randomly From 16c283ec200fdf1c51a2f11ea11a40e6e2071ddb Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Thu, 1 Oct 2026 16:17:35 +0300 Subject: [PATCH 7/8] fix(team): start member highlight after smooth scroll settles --- .../ui/v2/MosaicGrid/ui/MosaicGrid.test.tsx | 7 ++++ .../shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx | 42 +++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) 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 index 2934daa37..f363f34b1 100644 --- 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 @@ -25,6 +25,8 @@ const manyMembers: Member[] = Array.from({ length: 14 }, (_, i) => ({ const renderMosaic = (props: React.ComponentProps) => render(); +const SCROLL_POLL_INTERVAL = 50; + describe('MosaicGrid', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -99,6 +101,7 @@ describe('MosaicGrid', () => { }); it('highlights the scrolled-to member row', () => { + jest.useFakeTimers(); Object.defineProperty(Element.prototype, 'scrollIntoView', { configurable: true, value: jest.fn(), @@ -110,8 +113,11 @@ describe('MosaicGrid', () => { 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', () => { @@ -125,6 +131,7 @@ describe('MosaicGrid', () => { 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'); 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 a55266afe..e24477f28 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 @@ -9,6 +9,39 @@ 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; + +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); +}; + +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); +}; + +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); +}; export interface MosaicGridProps { className?: string; @@ -24,13 +57,8 @@ const MosaicGrid = ({ className, members }: MosaicGridProps) => { const element = document.getElementById(`member-${memberId}`); if (!element) return; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - element.removeAttribute('data-highlight'); - void element.offsetHeight; - element.setAttribute('data-highlight', 'true'); - window.setTimeout( - () => element.removeAttribute('data-highlight'), - SCROLL_HIGHLIGHT_DURATION, - ); + const targetScrollY = getTargetScrollPosition(element); + waitForScrollArrival(targetScrollY, () => highlightMember(element)); }; // shuffle members to fill the grid randomly From e719a26d704f132f49b3ea5b4cd436c5538d3cad Mon Sep 17 00:00:00 2001 From: Casimir43 Date: Fri, 2 Oct 2026 17:27:51 +0300 Subject: [PATCH 8/8] docs(team): add jsdoc to MosaicGrid helpers --- .../shared/ui/v2/MosaicGrid/ui/MosaicGrid.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 e24477f28..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 @@ -13,6 +13,14 @@ 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); @@ -21,6 +29,14 @@ const getTargetScrollPosition = (element: HTMLElement): number => { 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) => { @@ -36,6 +52,13 @@ const waitForScrollArrival = (targetScrollY: number, onArrived: () => void) => { 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; @@ -43,6 +66,12 @@ const highlightMember = (element: HTMLElement) => { 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[]; @@ -53,6 +82,13 @@ const MosaicGrid = ({ className, members }: MosaicGridProps) => { 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;