From 091eec8b0e693dcb6f297a69b0b9f16519157afe Mon Sep 17 00:00:00 2001
From: aldertnl <81586998+aldertnl@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:55:55 +0100
Subject: [PATCH 1/3] feat(ui): add Reach and WoT feed controls
---
cypress/e2e/feed.cy.ts | 3 +-
cypress/e2e/posts.cy.ts | 1 +
cypress/support/collections.ts | 3 +-
messages/ar.json | 24 +-
messages/de.json | 24 +-
messages/en.json | 24 +-
messages/es.json | 24 +-
messages/fr.json | 24 +-
messages/it.json | 24 +-
messages/ja.json | 24 +-
messages/pt-BR.json | 24 +-
messages/zh.json | 24 +-
.../FilterContent/FilterContent.test.tsx | 128 +-
.../FilterContent/FilterContent.test.tsx.snap | 4150 +++--------------
.../Filters/FilterContent/FilterContent.tsx | 118 +-
.../Filters/FilterDropdown/FilterDropdown.tsx | 89 +
.../FilterLayout/FilterLayout.test.tsx | 106 +-
.../FilterLayout/FilterLayout.test.tsx.snap | 1055 ++---
.../Filters/FilterLayout/FilterLayout.tsx | 74 +-
.../FilterProfileTags.test.tsx | 140 +
.../FilterProfileTags/FilterProfileTags.tsx | 117 +
.../FilterProfileTags.types.ts | 13 +
.../Filters/FilterReach/FilterReach.test.tsx | 193 +-
.../FilterReach/FilterReach.test.tsx.snap | 1453 +++---
.../Filters/FilterReach/FilterReach.tsx | 212 +-
.../Filters/FilterSort/FilterSort.test.tsx | 122 +-
.../FilterSort/FilterSort.test.tsx.snap | 621 ++-
.../Filters/FilterSort/FilterSort.tsx | 38 +-
.../molecules/TagInput/TagInput.tsx | 1 -
.../FeedNavigation/FeedNavigation.test.tsx | 49 +-
.../FeedNavigation.test.tsx.snap | 80 +-
.../FeedNavigation/FeedNavigation.tsx | 33 +-
.../HomeFeedSidebar/HomeFeedSidebar.test.tsx | 141 +-
.../HomeFeedSidebar.test.tsx.snap | 57 +-
.../HomeFeedSidebar/HomeFeedSidebar.tsx | 47 +-
.../HotFeedFilters/HotFeedFilters.tsx | 8 +-
.../organisms/PostHeader/PostHeader.test.tsx | 18 +
.../organisms/PostHeader/PostHeader.tsx | 14 +
.../organisms/PostHeader/PostHeader.types.ts | 1 +
.../organisms/PostInput/PostInput.test.tsx | 24 +-
.../PostInput/PostInput.test.tsx.snap | 37 +-
.../organisms/PostInput/PostInput.tsx | 14 +-
.../SinglePostLeftSidebar.test.tsx | 21 +-
.../SinglePostLeftSidebar.test.tsx.snap | 18 -
.../SinglePostLeftSidebar.tsx | 4 +-
.../Feed/TimelineFeed/TimelineFeed.test.tsx | 88 +-
.../Feed/TimelineFeed/TimelineFeed.tsx | 29 +
.../TimelineFeedContent.tsx | 8 +-
.../models/stream/post/postStream.types.ts | 15 +
.../nexus/stream/posts/postStream.api.ts | 3 +
.../nexus/stream/posts/postStream.test.ts | 54 +
.../services/nexus/stream/posts/postStream.ts | 20 +-
.../nexus/stream/posts/postStream.types.ts | 6 +
.../nexus/stream/posts/postStream.utils.ts | 30 +-
src/core/stores/home/home.actions.ts | 70 +-
src/core/stores/home/home.store.test.ts | 46 +-
src/core/stores/home/home.store.ts | 2 +
src/core/stores/home/home.types.ts | 34 +-
src/core/stores/home/home.utils.test.ts | 76 +-
src/core/stores/home/home.utils.ts | 83 +-
.../useHotStreamId/useHotStreamId.test.ts | 24 +-
src/hooks/useHotStreamId/useHotStreamId.ts | 15 +-
src/hooks/usePostInput/usePostInput.test.ts | 16 +
src/hooks/usePostInput/usePostInput.ts | 6 +
.../useStreamIdFromFilters.test.tsx | 92 +-
.../useStreamIdFromFilters.ts | 21 +-
66 files changed, 4317 insertions(+), 5840 deletions(-)
create mode 100644 src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx
create mode 100644 src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx
create mode 100644 src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.tsx
create mode 100644 src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.types.ts
diff --git a/cypress/e2e/feed.cy.ts b/cypress/e2e/feed.cy.ts
index 1d81efc43f..a943d835e9 100644
--- a/cypress/e2e/feed.cy.ts
+++ b/cypress/e2e/feed.cy.ts
@@ -297,7 +297,8 @@ describe('feed and filters', () => {
cy.signInWithEncryptedFile(backupDownloadFilePath(profile1.username));
cy.get('[data-cy="filter-reach-radiogroup"]').find('[aria-label="Following"]').click();
waitForFeedToLoad();
- cy.get('[data-cy="filter-sort-radiogroup"]').find('[aria-label="Popularity"]').click();
+ cy.get('[data-cy="filter-sort-radiogroup"]').click();
+ cy.get('[role="option"][aria-label="Popularity"]').click();
waitForFeedToLoad();
// * check the posts are in the correct order
diff --git a/cypress/e2e/posts.cy.ts b/cypress/e2e/posts.cy.ts
index d0cec9354c..e339aff08d 100644
--- a/cypress/e2e/posts.cy.ts
+++ b/cypress/e2e/posts.cy.ts
@@ -392,6 +392,7 @@ describe('posts', () => {
cy.location('pathname').should('contain', '/post/');
// switch to wide layout
+ cy.get('[data-cy="filter-layout-dropdown"]').filter(':visible').click();
cy.get('[data-cy="wide-layout-toggle"]').filter(':visible').click();
// add three tags to post
diff --git a/cypress/support/collections.ts b/cypress/support/collections.ts
index 1e301acc59..ff0bfa6f78 100644
--- a/cypress/support/collections.ts
+++ b/cypress/support/collections.ts
@@ -182,7 +182,8 @@ export const deleteCollectionFromHero = () => {
// apply a content filter (e.g. 'All', 'Collections') from the home feed sidebar
export const applyContentFilter = (label: string) => {
- cy.get('[data-testid="filter-content-radiogroup"]').find(`[aria-label="${label}"]`).click();
+ cy.get('[data-testid="filter-content-dropdown"]').click();
+ cy.get(`[role="option"][aria-label="${label}"]`).click();
waitForFeedToLoad();
};
diff --git a/messages/ar.json b/messages/ar.json
index 524e0c3410..611a1a5247 100644
--- a/messages/ar.json
+++ b/messages/ar.json
@@ -1158,10 +1158,30 @@
},
"filters": {
"reach": {
- "title": "النطاق",
+ "title": "منشورات من",
"all": "الكل",
+ "network": "الشبكة",
+ "myNetwork": "شبكتي",
"following": "المتابَعون",
- "friends": "الاصدقاء"
+ "friends": "الاصدقاء",
+ "me": "انا",
+ "profileTag": "وسم الملف الشخصي",
+ "profileTagLimitReached": "{max} وسوم كحد أقصى",
+ "taggedBy": "بواسطة {reach}",
+ "taggedByLabel": "اختر من قام بوسم هذه الملفات الشخصية",
+ "profileTagSummary": {
+ "reach": {
+ "all": "منشورات من أي شخص وسمه {curator} كـ {tags}",
+ "friends": "منشورات من الأصدقاء الذين وسمهم {curator} كـ {tags}",
+ "following": "منشورات من الأشخاص الذين أتابعهم ووسمهم {curator} كـ {tags}",
+ "network": "منشورات من أشخاص في شبكتي وسمهم {curator} كـ {tags}"
+ },
+ "curator": {
+ "me": "أنا",
+ "following": "الأشخاص الذين أتابعهم",
+ "network": "شبكتي"
+ }
+ }
},
"sort": {
"title": "الترتيب",
diff --git a/messages/de.json b/messages/de.json
index c3c4330309..ea18cb1dbd 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1164,10 +1164,30 @@
},
"filters": {
"reach": {
- "title": "Reichweite",
+ "title": "Beiträge von",
"all": "Alle",
+ "network": "Netzwerk",
+ "myNetwork": "Mein Netzwerk",
"following": "Folge ich",
- "friends": "Freunde"
+ "friends": "Freunde",
+ "me": "Ich",
+ "profileTag": "Profil-Tag",
+ "profileTagLimitReached": "max. {max} Tags",
+ "taggedBy": "von {reach}",
+ "taggedByLabel": "Auswählen, wer diese Profile markiert hat",
+ "profileTagSummary": {
+ "reach": {
+ "all": "Beiträge von allen, die von {curator} als {tags} markiert wurden",
+ "friends": "Beiträge von Freunden, die von {curator} als {tags} markiert wurden",
+ "following": "Beiträge von Personen, denen ich folge und die von {curator} als {tags} markiert wurden",
+ "network": "Beiträge von Personen in meinem Netzwerk, die von {curator} als {tags} markiert wurden"
+ },
+ "curator": {
+ "me": "mir",
+ "following": "Personen, denen ich folge",
+ "network": "meinem Netzwerk"
+ }
+ }
},
"sort": {
"title": "Sortieren",
diff --git a/messages/en.json b/messages/en.json
index cf97c76502..7c9549797b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1162,10 +1162,30 @@
},
"filters": {
"reach": {
- "title": "Reach",
+ "title": "Posts from",
"all": "All",
+ "network": "Network",
+ "myNetwork": "My Network",
"following": "Following",
- "friends": "Friends"
+ "friends": "Friends",
+ "me": "Me",
+ "profileTag": "profile tag",
+ "profileTagLimitReached": "{max} tags max",
+ "taggedBy": "by {reach}",
+ "taggedByLabel": "Choose who tagged these profiles",
+ "profileTagSummary": {
+ "reach": {
+ "all": "Posts from anyone tagged as {tags} by {curator}",
+ "friends": "Posts from friends tagged as {tags} by {curator}",
+ "following": "Posts from people I follow tagged as {tags} by {curator}",
+ "network": "Posts from people in my network tagged as {tags} by {curator}"
+ },
+ "curator": {
+ "me": "me",
+ "following": "people I follow",
+ "network": "my network"
+ }
+ }
},
"sort": {
"title": "Sort",
diff --git a/messages/es.json b/messages/es.json
index 8c5b66db12..8ecdc33181 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1164,10 +1164,30 @@
},
"filters": {
"reach": {
- "title": "Alcance",
+ "title": "Publicaciones de",
"all": "Todos",
+ "network": "Red",
+ "myNetwork": "Mi red",
"following": "Siguiendo",
- "friends": "Amigos"
+ "friends": "Amigos",
+ "me": "Yo",
+ "profileTag": "etiqueta de perfil",
+ "profileTagLimitReached": "máximo {max} etiquetas",
+ "taggedBy": "por {reach}",
+ "taggedByLabel": "Elige quién etiquetó estos perfiles",
+ "profileTagSummary": {
+ "reach": {
+ "all": "Publicaciones de cualquiera etiquetado como {tags} por {curator}",
+ "friends": "Publicaciones de amigos etiquetados como {tags} por {curator}",
+ "following": "Publicaciones de personas que sigo etiquetadas como {tags} por {curator}",
+ "network": "Publicaciones de personas de mi red etiquetadas como {tags} por {curator}"
+ },
+ "curator": {
+ "me": "mí",
+ "following": "personas que sigo",
+ "network": "mi red"
+ }
+ }
},
"sort": {
"title": "Ordenar",
diff --git a/messages/fr.json b/messages/fr.json
index 85fbe4d61c..f91de3652f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1162,10 +1162,30 @@
},
"filters": {
"reach": {
- "title": "Portee",
+ "title": "Publications de",
"all": "Tous",
+ "network": "Réseau",
+ "myNetwork": "Mon réseau",
"following": "Abonnements",
- "friends": "Amis"
+ "friends": "Amis",
+ "me": "Moi",
+ "profileTag": "tag de profil",
+ "profileTagLimitReached": "{max} tags maximum",
+ "taggedBy": "par {reach}",
+ "taggedByLabel": "Choisissez qui a tagué ces profils",
+ "profileTagSummary": {
+ "reach": {
+ "all": "Publications de toute personne taguée comme {tags} par {curator}",
+ "friends": "Publications d’amis tagués comme {tags} par {curator}",
+ "following": "Publications de personnes que je suis taguées comme {tags} par {curator}",
+ "network": "Publications de personnes de mon réseau taguées comme {tags} par {curator}"
+ },
+ "curator": {
+ "me": "moi",
+ "following": "les personnes que je suis",
+ "network": "mon réseau"
+ }
+ }
},
"sort": {
"title": "Trier",
diff --git a/messages/it.json b/messages/it.json
index ec283f425d..1b4059d040 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1162,10 +1162,30 @@
},
"filters": {
"reach": {
- "title": "Portata",
+ "title": "Post di",
"all": "Tutti",
+ "network": "Rete",
+ "myNetwork": "La mia rete",
"following": "Seguiti",
- "friends": "Amici"
+ "friends": "Amici",
+ "me": "Io",
+ "profileTag": "tag profilo",
+ "profileTagLimitReached": "massimo {max} tag",
+ "taggedBy": "da {reach}",
+ "taggedByLabel": "Scegli chi ha taggato questi profili",
+ "profileTagSummary": {
+ "reach": {
+ "all": "Post di chiunque sia stato taggato come {tags} da {curator}",
+ "friends": "Post di amici taggati come {tags} da {curator}",
+ "following": "Post di persone che seguo taggate come {tags} da {curator}",
+ "network": "Post di persone nella mia rete taggate come {tags} da {curator}"
+ },
+ "curator": {
+ "me": "me",
+ "following": "persone che seguo",
+ "network": "la mia rete"
+ }
+ }
},
"sort": {
"title": "Ordina",
diff --git a/messages/ja.json b/messages/ja.json
index 8b0ffe1827..71b317c7f4 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -1162,10 +1162,30 @@
},
"filters": {
"reach": {
- "title": "範囲",
+ "title": "投稿元",
"all": "すべて",
+ "network": "ネットワーク",
+ "myNetwork": "自分のネットワーク",
"following": "フォロー中",
- "friends": "友達"
+ "friends": "友達",
+ "me": "自分",
+ "profileTag": "プロフィールタグ",
+ "profileTagLimitReached": "最大{max}個のタグ",
+ "taggedBy": "{reach}による",
+ "taggedByLabel": "これらのプロフィールをタグ付けした範囲を選択",
+ "profileTagSummary": {
+ "reach": {
+ "all": "{curator}が {tags} とタグ付けした人の投稿",
+ "friends": "{curator}が {tags} とタグ付けした友達の投稿",
+ "following": "{curator}が {tags} とタグ付けしたフォロー中の人の投稿",
+ "network": "{curator}が {tags} とタグ付けした自分のネットワーク内の人の投稿"
+ },
+ "curator": {
+ "me": "私",
+ "following": "フォロー中の人",
+ "network": "自分のネットワーク"
+ }
+ }
},
"sort": {
"title": "並べ替え",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 496790bd3b..e15b7d958a 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -1162,10 +1162,30 @@
},
"filters": {
"reach": {
- "title": "Alcance",
+ "title": "Posts de",
"all": "Todos",
+ "network": "Rede",
+ "myNetwork": "Minha rede",
"following": "Seguindo",
- "friends": "Amigos"
+ "friends": "Amigos",
+ "me": "Eu",
+ "profileTag": "tag de perfil",
+ "profileTagLimitReached": "máximo de {max} tags",
+ "taggedBy": "por {reach}",
+ "taggedByLabel": "Escolha quem marcou estes perfis",
+ "profileTagSummary": {
+ "reach": {
+ "all": "Posts de qualquer pessoa marcada como {tags} por {curator}",
+ "friends": "Posts de amigos marcados como {tags} por {curator}",
+ "following": "Posts de pessoas que sigo marcadas como {tags} por {curator}",
+ "network": "Posts de pessoas da minha rede marcadas como {tags} por {curator}"
+ },
+ "curator": {
+ "me": "mim",
+ "following": "pessoas que sigo",
+ "network": "minha rede"
+ }
+ }
},
"sort": {
"title": "Ordenar",
diff --git a/messages/zh.json b/messages/zh.json
index 9deec89071..ef13de7f9c 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -1156,10 +1156,30 @@
},
"filters": {
"reach": {
- "title": "范围",
+ "title": "帖子来源",
"all": "全部",
+ "network": "网络",
+ "myNetwork": "我的网络",
"following": "关注",
- "friends": "好友"
+ "friends": "好友",
+ "me": "我",
+ "profileTag": "个人资料标签",
+ "profileTagLimitReached": "最多 {max} 个标签",
+ "taggedBy": "由{reach}标记",
+ "taggedByLabel": "选择谁标记了这些个人资料",
+ "profileTagSummary": {
+ "reach": {
+ "all": "{curator}标记为 {tags} 的任何人的帖子",
+ "friends": "{curator}标记为 {tags} 的好友帖子",
+ "following": "{curator}标记为 {tags} 的我关注的人的帖子",
+ "network": "{curator}标记为 {tags} 的我网络中的人的帖子"
+ },
+ "curator": {
+ "me": "我",
+ "following": "我关注的人",
+ "network": "我的网络"
+ }
+ }
},
"sort": {
"title": "排序",
diff --git a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx
index 21875839aa..6d1c1a10d8 100644
--- a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx
+++ b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx
@@ -1,31 +1,45 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
import { VISUAL_DISABLED_CONTENT } from '@/organisms/Timeline/Feed/TimelineFeed/TimelineFeedVisual.helpers';
-import { CONTENT, type ContentType } from '@/stores/home/home.types';
+import { CONTENT } from '@/stores/home/home.types';
import { FilterContent } from './FilterContent';
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Element.prototype.hasPointerCapture = vi.fn();
+ Element.prototype.releasePointerCapture = vi.fn();
+ Element.prototype.setPointerCapture = vi.fn();
+});
+
+async function selectContentOption(label: string) {
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('combobox', { name: 'Content' }));
+ await user.click(screen.getByRole('option', { name: label }));
+}
+
describe('FilterContent', () => {
- it('renders with default selected tab', () => {
+ it('renders the current option in a dropdown trigger', () => {
render( );
expect(screen.getByText('Content')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Content' })).toHaveTextContent('All');
});
- it('calls onTabChange when tab is clicked', () => {
+ it('calls onTabChange when an option is selected', async () => {
const onTabChange = vi.fn();
render( );
- fireEvent.click(screen.getByText('Images'));
- expect(onTabChange).toHaveBeenCalledWith('images');
+ await selectContentOption('Images');
+
+ expect(onTabChange).toHaveBeenCalledWith(CONTENT.IMAGES);
});
- it('handles all tab types correctly', () => {
+ it('supports all content options', async () => {
const onTabChange = vi.fn();
render( );
- // Map of tab values to their display labels
- const tabsToTest: Array<{ value: ContentType; label: string }> = [
- { value: CONTENT.ALL, label: 'All' },
+ const options = [
{ value: CONTENT.SHORT, label: 'Posts' },
{ value: CONTENT.LONG, label: 'Articles' },
{ value: CONTENT.COLLECTIONS, label: 'Collections' },
@@ -33,78 +47,78 @@ describe('FilterContent', () => {
{ value: CONTENT.VIDEOS, label: 'Videos' },
{ value: CONTENT.LINKS, label: 'Links' },
{ value: CONTENT.FILES, label: 'Files' },
+ { value: CONTENT.ALL, label: 'All' },
];
- tabsToTest.forEach(({ value, label }) => {
- fireEvent.click(screen.getByText(label));
- expect(onTabChange).toHaveBeenCalledWith(value);
+ for (const { label } of options) {
+ await selectContentOption(label);
+ }
+
+ options.forEach(({ value }, index) => {
+ expect(onTabChange).toHaveBeenNthCalledWith(index + 1, value);
});
- });
+ }, 10_000);
- it('handles tab switching correctly', () => {
- const onTabChange = vi.fn();
- render( );
+ it('updates the trigger when the controlled selection changes', () => {
+ const { rerender } = render( );
+
+ expect(screen.getByRole('combobox', { name: 'Content' })).toHaveTextContent('All');
- // Click on articles tab
- fireEvent.click(screen.getByText('Articles'));
- expect(onTabChange).toHaveBeenCalledWith(CONTENT.LONG); // 'long' is the value
- expect(onTabChange).toHaveBeenCalledTimes(1);
+ rerender( );
+
+ expect(screen.getByRole('combobox', { name: 'Content' })).toHaveTextContent('Articles');
});
- it('handles multiple tab clicks', () => {
+ it('handles multiple option selections', async () => {
const onTabChange = vi.fn();
render( );
- fireEvent.click(screen.getByText('Images'));
- fireEvent.click(screen.getByText('Videos'));
- fireEvent.click(screen.getByText('Files'));
-
- expect(onTabChange).toHaveBeenCalledTimes(3);
- expect(onTabChange).toHaveBeenNthCalledWith(1, 'images');
- expect(onTabChange).toHaveBeenNthCalledWith(2, 'videos');
- expect(onTabChange).toHaveBeenNthCalledWith(3, 'files');
- });
+ await selectContentOption('Images');
+ await selectContentOption('Videos');
+ await selectContentOption('Files');
- it('renders all items as disabled when disabled prop is true', () => {
- render( );
-
- const labels = ['All', 'Posts', 'Articles', 'Collections', 'Images', 'Videos', 'Links', 'Files'];
- labels.forEach((label) => {
- expect(screen.getByLabelText(label)).toHaveAttribute('aria-disabled', 'true');
- });
+ expect(onTabChange).toHaveBeenNthCalledWith(1, CONTENT.IMAGES);
+ expect(onTabChange).toHaveBeenNthCalledWith(2, CONTENT.VIDEOS);
+ expect(onTabChange).toHaveBeenNthCalledWith(3, CONTENT.FILES);
});
- it('does not call onTabChange when disabled', () => {
+ it('disables the dropdown when the filter is disabled', async () => {
const onTabChange = vi.fn();
+ const user = userEvent.setup();
render( );
- fireEvent.click(screen.getByText('Images'));
- fireEvent.click(screen.getByText('Videos'));
- fireEvent.click(screen.getByText('Files'));
+ const trigger = screen.getByRole('combobox', { name: 'Content' });
+ expect(trigger).toBeDisabled();
+ await user.click(trigger);
+
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
expect(onTabChange).not.toHaveBeenCalled();
});
- it('items are not disabled by default', () => {
+ it('renders enabled options by default', async () => {
+ const user = userEvent.setup();
render( );
- const labels = ['All', 'Posts', 'Articles', 'Collections', 'Images', 'Videos', 'Links', 'Files'];
- labels.forEach((label) => {
- expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-disabled', 'true');
- });
+ await user.click(screen.getByRole('combobox', { name: 'Content' }));
+
+ for (const label of ['All', 'Posts', 'Articles', 'Collections', 'Images', 'Videos', 'Links', 'Files']) {
+ expect(screen.getByRole('option', { name: label })).not.toHaveAttribute('aria-disabled', 'true');
+ }
});
- it('disables only the requested tabs', () => {
+ it('disables only the requested options', async () => {
+ const user = userEvent.setup();
render( );
- expect(screen.getByLabelText('Posts')).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('Articles')).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('Collections')).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('Links')).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('Files')).toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('All')).not.toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('Images')).not.toHaveAttribute('aria-disabled', 'true');
- expect(screen.getByLabelText('Videos')).not.toHaveAttribute('aria-disabled', 'true');
+ await user.click(screen.getByRole('combobox', { name: 'Content' }));
+
+ for (const label of ['Posts', 'Articles', 'Collections', 'Links', 'Files']) {
+ expect(screen.getByRole('option', { name: label })).toHaveAttribute('aria-disabled', 'true');
+ }
+ for (const label of ['All', 'Images', 'Videos']) {
+ expect(screen.getByRole('option', { name: label })).not.toHaveAttribute('aria-disabled', 'true');
+ }
});
});
diff --git a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap
index 6e7bd9515c..b766fa6ec6 100644
--- a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap
+++ b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap
@@ -19,357 +19,78 @@ exports[`FilterContent - Snapshots > matches snapshot with All content selected
Content
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
All
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -392,357 +113,85 @@ exports[`FilterContent - Snapshots > matches snapshot with Articles content sele
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
Articles
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -765,357 +214,81 @@ exports[`FilterContent - Snapshots > matches snapshot with Collections content s
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
Collections
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -1138,357 +311,78 @@ exports[`FilterContent - Snapshots > matches snapshot with Files content selecte
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Files
-
-
-
+
+
+
+
+
+
`;
@@ -1511,357 +405,85 @@ exports[`FilterContent - Snapshots > matches snapshot with Images content select
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Images
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -1884,357 +506,75 @@ exports[`FilterContent - Snapshots > matches snapshot with Links content selecte
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
Links
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -2257,357 +597,75 @@ exports[`FilterContent - Snapshots > matches snapshot with Posts content selecte
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
Posts
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -2630,357 +688,77 @@ exports[`FilterContent - Snapshots > matches snapshot with Videos content select
Content
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
Videos
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -3003,357 +781,78 @@ exports[`FilterContent - Snapshots > matches snapshot with default props 1`] = `
Content
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
All
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
@@ -3376,364 +875,79 @@ exports[`FilterContent - Snapshots > matches snapshot with disabled state 1`] =
Content
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
All
-
-
-
-
-
-
-
- Posts
-
-
-
-
-
-
-
-
-
-
- Articles
-
-
-
-
-
-
-
-
-
-
- Collections
-
-
-
-
-
-
-
-
-
- Images
-
-
-
-
-
-
-
-
- Videos
-
-
-
-
-
-
-
-
- Links
-
-
-
-
-
-
-
-
-
- Files
-
-
-
-
+
+
+
+
+
+
`;
diff --git a/src/components/molecules/Filters/FilterContent/FilterContent.tsx b/src/components/molecules/Filters/FilterContent/FilterContent.tsx
index aa7b9ff596..aecd4537e1 100644
--- a/src/components/molecules/Filters/FilterContent/FilterContent.tsx
+++ b/src/components/molecules/Filters/FilterContent/FilterContent.tsx
@@ -1,10 +1,9 @@
'use client';
-import * as React from 'react';
import { CirclePlay, Download, Image, Layers, Library, Link, Newspaper, StickyNote } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CONTENT, type ContentType } from '@/stores/home/home.types';
-import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup';
+import { FilterDropdown } from '../FilterDropdown/FilterDropdown';
import { BaseFilterProps } from '../Filters.types';
interface FilterContentProps extends BaseFilterProps {
@@ -18,73 +17,68 @@ export function FilterContent({
disabledTabs = [],
}: FilterContentProps) {
const t = useTranslations('filters.content');
- const disabledSet = React.useMemo(() => new Set(disabledTabs), [disabledTabs]);
- const isDisabled = React.useCallback(
- (contentType: ContentType) => {
- return disabled || disabledSet.has(contentType) ? true : undefined;
+ const disabledSet = new Set(disabledTabs);
+ const isDisabled = (contentType: ContentType) => disabled || disabledSet.has(contentType) || undefined;
+ const items = [
+ {
+ key: CONTENT.ALL,
+ label: t('all'),
+ icon: Layers,
+ disabled: isDisabled(CONTENT.ALL),
},
- [disabled, disabledSet],
- );
- const items = React.useMemo(
- () => [
- {
- key: CONTENT.ALL,
- label: t('all'),
- icon: Layers,
- disabled: isDisabled(CONTENT.ALL),
- },
- {
- key: CONTENT.SHORT,
- label: t('posts'),
- icon: StickyNote,
- disabled: isDisabled(CONTENT.SHORT),
- },
- {
- key: CONTENT.LONG,
- label: t('articles'),
- icon: Newspaper,
- disabled: isDisabled(CONTENT.LONG),
- },
- {
- key: CONTENT.COLLECTIONS,
- label: t('collections'),
- icon: Library,
- disabled: isDisabled(CONTENT.COLLECTIONS),
- },
- {
- key: CONTENT.IMAGES,
- label: t('images'),
- icon: Image,
- disabled: isDisabled(CONTENT.IMAGES),
- },
- {
- key: CONTENT.VIDEOS,
- label: t('videos'),
- icon: CirclePlay,
- disabled: isDisabled(CONTENT.VIDEOS),
- },
- {
- key: CONTENT.LINKS,
- label: t('links'),
- icon: Link,
- disabled: isDisabled(CONTENT.LINKS),
- },
- {
- key: CONTENT.FILES,
- label: t('files'),
- icon: Download,
- disabled: isDisabled(CONTENT.FILES),
- },
- ],
- [t, isDisabled],
- );
+ {
+ key: CONTENT.SHORT,
+ label: t('posts'),
+ icon: StickyNote,
+ disabled: isDisabled(CONTENT.SHORT),
+ },
+ {
+ key: CONTENT.LONG,
+ label: t('articles'),
+ icon: Newspaper,
+ disabled: isDisabled(CONTENT.LONG),
+ },
+ {
+ key: CONTENT.COLLECTIONS,
+ label: t('collections'),
+ icon: Library,
+ disabled: isDisabled(CONTENT.COLLECTIONS),
+ },
+ {
+ key: CONTENT.IMAGES,
+ label: t('images'),
+ icon: Image,
+ disabled: isDisabled(CONTENT.IMAGES),
+ },
+ {
+ key: CONTENT.VIDEOS,
+ label: t('videos'),
+ icon: CirclePlay,
+ disabled: isDisabled(CONTENT.VIDEOS),
+ },
+ {
+ key: CONTENT.LINKS,
+ label: t('links'),
+ icon: Link,
+ disabled: isDisabled(CONTENT.LINKS),
+ },
+ {
+ key: CONTENT.FILES,
+ label: t('files'),
+ icon: Download,
+ disabled: isDisabled(CONTENT.FILES),
+ },
+ ];
+
return (
-
);
}
diff --git a/src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx b/src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx
new file mode 100644
index 0000000000..b5691a81ad
--- /dev/null
+++ b/src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import * as React from 'react';
+import { FilterHeader, FilterRoot } from '@/atoms/Filter/Filter';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/atoms/Select/Select';
+import { useControlledState } from '@/hooks/useControlledState/useControlledState';
+import type { FilterListItem } from '../Filters.types';
+
+interface FilterDropdownProps {
+ title?: string;
+ ariaLabel?: string;
+ items: FilterListItem[];
+ selectedValue?: T;
+ defaultValue?: T;
+ onChange?: (value: T) => void;
+ dataCy?: string;
+ testId?: string;
+}
+
+export function FilterDropdown({
+ title,
+ ariaLabel,
+ items,
+ selectedValue: controlledValue,
+ defaultValue,
+ onChange,
+ dataCy,
+ testId,
+}: FilterDropdownProps) {
+ const headerId = React.useId();
+ const { value: selectedValue, setValue: setSelectedValue } = useControlledState({
+ value: controlledValue,
+ defaultValue: defaultValue ?? items[0]?.key,
+ onChange,
+ });
+ const selectedItem = items.find((item) => item.key === selectedValue) ?? items[0];
+ const isDisabled = items.every((item) => item.disabled);
+
+ if (!selectedItem) {
+ return null;
+ }
+
+ const SelectedIcon = selectedItem.icon;
+
+ return (
+
+ {title && }
+
+ setSelectedValue(value as T)} disabled={isDisabled}>
+
+
+
+
+
+
+ {selectedItem.label}
+
+
+
+
+
+ {items.map(({ key, label, icon: Icon, disabled, dataCy: itemDataCy }) => (
+
+
+
+
+
+ {label}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx
index 2703992c90..dc9f4f8387 100644
--- a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx
+++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx
@@ -1,88 +1,106 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { LAYOUT, type LayoutType } from '@/stores/home/home.types';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { LAYOUT } from '@/stores/home/home.types';
import { FilterLayout } from './FilterLayout';
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Element.prototype.hasPointerCapture = vi.fn();
+ Element.prototype.releasePointerCapture = vi.fn();
+ Element.prototype.setPointerCapture = vi.fn();
+});
+
+async function selectLayoutOption(label: string) {
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('combobox', { name: 'Layout' }));
+ await user.click(screen.getByRole('option', { name: label }));
+}
+
describe('FilterLayout', () => {
- it('renders with default selected tab', () => {
+ it('renders the current option in a dropdown trigger', () => {
render( );
expect(screen.getByText('Layout')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Layout' })).toHaveTextContent('Columns');
});
- it('calls onTabChange when tab is clicked', () => {
+ it('calls onTabChange when an option is selected', async () => {
const onTabChange = vi.fn();
render( );
- fireEvent.click(screen.getByText('Wide'));
- expect(onTabChange).toHaveBeenCalledWith('wide');
+
+ await selectLayoutOption('Wide');
+
+ expect(onTabChange).toHaveBeenCalledWith(LAYOUT.WIDE);
});
- it('handles all tab types correctly', () => {
+ it('supports all layout options', async () => {
const onTabChange = vi.fn();
render( );
- ([LAYOUT.COLUMNS, LAYOUT.WIDE, LAYOUT.LIST] as LayoutType[]).forEach((tab) => {
- const label = tab === LAYOUT.COLUMNS ? 'Columns' : tab === LAYOUT.WIDE ? 'Wide' : 'List';
- fireEvent.click(screen.getByText(label));
- expect(onTabChange).toHaveBeenCalledWith(tab);
- });
+ await selectLayoutOption('Wide');
+ await selectLayoutOption('List');
+ await selectLayoutOption('Columns');
+
+ expect(onTabChange).toHaveBeenNthCalledWith(1, LAYOUT.WIDE);
+ expect(onTabChange).toHaveBeenNthCalledWith(2, LAYOUT.LIST);
+ expect(onTabChange).toHaveBeenNthCalledWith(3, LAYOUT.COLUMNS);
});
- it('rerenders with different selected tabs', () => {
+ it('updates the trigger when the controlled selection changes', () => {
const { rerender } = render( );
- let columnsItem = screen.getByText('Columns').closest('[data-testid="filter-item"]');
- const wideItem = screen.getByText('Wide').closest('[data-testid="filter-item"]');
- expect(columnsItem).toBeInTheDocument();
- expect(wideItem).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Layout' })).toHaveTextContent('Columns');
rerender( );
- columnsItem = screen.getByText('Columns').closest('[data-testid="filter-item"]');
- const wideItem2 = screen.getByText('Wide').closest('[data-testid="filter-item"]');
- expect(columnsItem).toBeInTheDocument();
- expect(wideItem2).toBeInTheDocument();
- });
- it('renders all items as disabled when disabled prop is true', () => {
- render( );
-
- const labels = ['Columns', 'Wide', 'List'];
- labels.forEach((label) => {
- expect(screen.getByLabelText(label)).toHaveAttribute('aria-disabled', 'true');
- });
+ expect(screen.getByRole('combobox', { name: 'Layout' })).toHaveTextContent('Wide');
});
- it('does not call onTabChange when disabled', () => {
+ it('disables the dropdown when the filter is disabled', async () => {
const onTabChange = vi.fn();
+ const user = userEvent.setup();
render( );
- fireEvent.click(screen.getByText('Columns'));
- fireEvent.click(screen.getByText('Wide'));
- fireEvent.click(screen.getByText('List'));
+ const trigger = screen.getByRole('combobox', { name: 'Layout' });
+ expect(trigger).toBeDisabled();
+
+ await user.click(trigger);
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
expect(onTabChange).not.toHaveBeenCalled();
});
- it('items are not disabled by default', () => {
+ it('renders enabled options by default', async () => {
+ const user = userEvent.setup();
render( );
- const labels = ['Columns', 'Wide', 'List'];
- labels.forEach((label) => {
- expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-disabled', 'true');
- });
+ await user.click(screen.getByRole('combobox', { name: 'Layout' }));
+
+ for (const label of ['Columns', 'Wide', 'List']) {
+ expect(screen.getByRole('option', { name: label })).not.toHaveAttribute('aria-disabled', 'true');
+ }
});
- it('renders visual layout when enabled', () => {
+ it('renders visual layout when enabled', async () => {
+ const user = userEvent.setup();
render( );
- expect(screen.getByText('Visual')).toBeInTheDocument();
+ await user.click(screen.getByRole('combobox', { name: 'Layout' }));
+
+ expect(screen.getByRole('option', { name: 'Visual' })).toBeInTheDocument();
});
- it('falls back to columns for display when visual is selected but hidden', () => {
+ it('falls back to columns for display when visual is selected but hidden', async () => {
+ const user = userEvent.setup();
render( );
- expect(screen.getByLabelText('Columns')).toHaveAttribute('aria-checked', 'true');
- expect(screen.queryByText('Visual')).not.toBeInTheDocument();
+ const trigger = screen.getByRole('combobox', { name: 'Layout' });
+ expect(trigger).toHaveTextContent('Columns');
+
+ await user.click(trigger);
+
+ expect(screen.queryByRole('option', { name: 'Visual' })).not.toBeInTheDocument();
});
});
diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap
index c23fc29788..0945749caf 100644
--- a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap
+++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap
@@ -19,157 +19,82 @@ exports[`FilterLayout - Snapshots > matches snapshot with Columns selected tab 1
Layout
-
-
-
-
-
-
-
-
-
- Columns
-
-
-
-
-
-
-
- Wide
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- List
+
+ Columns
-
-
-
+
+
+
+
+
+
`;
@@ -192,157 +117,79 @@ exports[`FilterLayout - Snapshots > matches snapshot with Wide selected tab 1`]
Layout
-
-
-
-
-
-
-
-
- Columns
+
+
+
+
-
-
-
-
-
-
-
+
Wide
-
-
-
-
-
-
-
-
-
- List
-
-
-
-
+
+
+
+
+
+
`;
@@ -365,157 +212,82 @@ exports[`FilterLayout - Snapshots > matches snapshot with default props 1`] = `
Layout
-
-
-
-
-
-
-
-
- Columns
-
-
-
-
-
-
-
-
- Wide
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- List
+
+ Columns
-
-
-
+
+
+
+
+
+
`;
@@ -538,160 +310,84 @@ exports[`FilterLayout - Snapshots > matches snapshot with disabled state 1`] = `
Layout
-
-
-
-
-
-
-
-
-
- Columns
-
-
-
-
-
-
-
- Wide
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- List
+
+ Columns
-
-
-
+
+
+
+
+
+
`;
@@ -714,156 +410,81 @@ exports[`FilterLayout - Snapshots > matches snapshot with hidden visual selectio
Layout
-
-
-
-
-
-
-
-
- Columns
+
+
+
+
+
-
-
-
-
-
-
-
- Wide
-
-
-
-
-
-
-
-
-
-
- List
+
+ Columns
-
-
-
+
+
+
+
+
+
`;
diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx
index 7caaf1af1a..f4b61386c1 100644
--- a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx
+++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx
@@ -1,10 +1,9 @@
'use client';
-import * as React from 'react';
import { Columns3, LayoutGrid, Rows2, Rows4 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { LAYOUT, type LayoutType } from '@/stores/home/home.types';
-import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup';
+import { FilterDropdown } from '../FilterDropdown/FilterDropdown';
import { BaseFilterProps, FilterListItem } from '../Filters.types';
interface FilterLayoutProps extends BaseFilterProps {
@@ -19,49 +18,48 @@ export function FilterLayout({
}: FilterLayoutProps) {
const t = useTranslations('filters.layout');
const displaySelectedTab = !showVisual && selectedTab === LAYOUT.VISUAL ? LAYOUT.COLUMNS : selectedTab;
- const items = React.useMemo(
- () =>
- [
- {
- key: LAYOUT.COLUMNS,
- label: t('columns'),
- icon: Columns3,
+ const items = [
+ {
+ key: LAYOUT.COLUMNS,
+ label: t('columns'),
+ icon: Columns3,
+ disabled,
+ dataCy: 'columns-layout-toggle',
+ },
+ {
+ key: LAYOUT.WIDE,
+ label: t('wide'),
+ icon: Rows2,
+ disabled,
+ dataCy: 'wide-layout-toggle',
+ },
+ {
+ key: LAYOUT.LIST,
+ label: t('list'),
+ icon: Rows4,
+ disabled,
+ dataCy: 'list-layout-toggle',
+ },
+ showVisual
+ ? {
+ key: LAYOUT.VISUAL,
+ label: t('visual'),
+ icon: LayoutGrid,
disabled,
- dataCy: 'columns-layout-toggle',
- },
- {
- key: LAYOUT.WIDE,
- label: t('wide'),
- icon: Rows2,
- disabled,
- dataCy: 'wide-layout-toggle',
- },
- {
- key: LAYOUT.LIST,
- label: t('list'),
- icon: Rows4,
- disabled,
- dataCy: 'list-layout-toggle',
- },
- showVisual
- ? {
- key: LAYOUT.VISUAL,
- label: t('visual'),
- icon: LayoutGrid,
- disabled,
- dataCy: 'visual-layout-toggle',
- }
- : null,
- ].filter(Boolean) as FilterListItem[],
- [t, disabled, showVisual],
- );
+ dataCy: 'visual-layout-toggle',
+ }
+ : null,
+ ].filter(Boolean) as FilterListItem[];
+
return (
-
);
}
diff --git a/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx
new file mode 100644
index 0000000000..4655f4e985
--- /dev/null
+++ b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx
@@ -0,0 +1,140 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { PROFILE_TAG_SCOPE } from '@/stores/home/home.types';
+import { FilterProfileTags } from './FilterProfileTags';
+
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Element.prototype.hasPointerCapture = vi.fn();
+ Element.prototype.releasePointerCapture = vi.fn();
+ Element.prototype.setPointerCapture = vi.fn();
+});
+
+const defaultProps = {
+ selectedTags: [] as string[],
+ onTagAdd: vi.fn(),
+ onTagRemove: vi.fn(),
+ scope: PROFILE_TAG_SCOPE.NETWORK,
+ onScopeChange: vi.fn(),
+};
+
+describe('FilterProfileTags', () => {
+ it('renders the Tagged as controls and current reach scope', () => {
+ const { container } = render( );
+
+ expect(screen.getByRole('heading', { name: 'Tagged as' })).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('profile tag')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })).toHaveTextContent(
+ 'by my network',
+ );
+ expect(container.querySelector('.lucide-waypoints')).toHaveClass('size-5');
+ });
+
+ it('uses the UsersRound icon and lowercase label for Following', () => {
+ const { container } = render( );
+
+ expect(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })).toHaveTextContent(
+ 'by following',
+ );
+ expect(container.querySelector('.lucide-users-round')).toHaveClass('size-5');
+ });
+
+ it('orders WoT scopes as Network, Following, then Me', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' }));
+
+ expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
+ 'by my network',
+ 'by following',
+ 'by me',
+ ]);
+ });
+
+ it('adds a normalized profile tag from the input', async () => {
+ const onTagAdd = vi.fn();
+ render( );
+
+ const input = screen.getByPlaceholderText('profile tag');
+ fireEvent.change(input, { target: { value: 'Bitcoin' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ await waitFor(() => expect(onTagAdd).toHaveBeenCalledWith('bitcoin'));
+ });
+
+ it('renders selected tags in a removable stack', async () => {
+ const onTagRemove = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ expect(screen.getByText('bitcoin')).toBeInTheDocument();
+ expect(screen.getByText('nostr')).toBeInTheDocument();
+
+ await user.click(screen.getByLabelText('Remove bitcoin tag'));
+ expect(onTagRemove).toHaveBeenCalledWith('bitcoin');
+ });
+
+ it('changes only the independent WoT scope through the compact dropdown', async () => {
+ const onScopeChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' }));
+ await user.click(screen.getByRole('option', { name: 'by following' }));
+
+ expect(onScopeChange).toHaveBeenCalledWith(PROFILE_TAG_SCOPE.FOLLOWING);
+ });
+
+ it('offers Me as the Nexus depth-zero scope', async () => {
+ const onScopeChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' }));
+ await user.click(screen.getByRole('option', { name: 'by me' }));
+
+ expect(onScopeChange).toHaveBeenCalledWith(PROFILE_TAG_SCOPE.ME);
+ });
+
+ it('uses a 100ms collapse transition when hidden', () => {
+ render( );
+
+ expect(screen.getByTestId('filter-profile-tags-collapse')).toHaveClass(
+ 'grid-rows-[0fr]',
+ 'opacity-0',
+ 'pointer-events-none',
+ 'duration-100',
+ );
+ expect(screen.getByTestId('filter-profile-tags-collapse')).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('keeps the previous chips mounted during the collapse animation', async () => {
+ const { rerender } = render( );
+
+ rerender( );
+ expect(screen.getByText('bitcoin')).toBeInTheDocument();
+
+ rerender( );
+ await waitFor(() => expect(screen.queryByText('bitcoin')).not.toBeInTheDocument());
+ });
+
+ it('enforces the five-tag limit', () => {
+ render( );
+
+ expect(screen.getByPlaceholderText('5 tags max')).toBeDisabled();
+ });
+
+ it('keeps the section visible while disabling unauthenticated input interaction', async () => {
+ const onInputClick = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ expect(screen.getByTestId('filter-profile-tags-collapse')).toHaveClass('grid-rows-[1fr]', 'opacity-100');
+ expect(screen.getByPlaceholderText('profile tag')).toBeDisabled();
+
+ await user.click(screen.getByPlaceholderText('profile tag').parentElement!);
+ expect(onInputClick).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.tsx b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.tsx
new file mode 100644
index 0000000000..dd7e351735
--- /dev/null
+++ b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { UserRound, UsersRound, Waypoints } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { Container } from '@/atoms/Container/Container';
+import { FilterHeader, FilterRoot } from '@/atoms/Filter/Filter';
+import { cn } from '@/libs/utils/utils';
+import { FilterDropdown } from '@/molecules/Filters/FilterDropdown/FilterDropdown';
+import { PostTag } from '@/molecules/PostTag/PostTag';
+import { TagInput } from '@/molecules/TagInput/TagInput';
+import { HOME_PROFILE_TAGS_MAX_SELECTED, REACH } from '@/stores/home/home.types';
+import type { FilterProfileTagsProps } from './FilterProfileTags.types';
+
+export function FilterProfileTags({
+ selectedTags,
+ onTagAdd,
+ onTagRemove,
+ scope,
+ onScopeChange,
+ hidden = false,
+ inputDisabled = false,
+ onInputClick,
+ maxTags = HOME_PROFILE_TAGS_MAX_SELECTED,
+}: FilterProfileTagsProps) {
+ const tReach = useTranslations('filters.reach');
+ const tProfile = useTranslations('profile.sidebar');
+ const [displayTags, setDisplayTags] = useState(selectedTags);
+
+ useEffect(() => {
+ if (!hidden) {
+ setDisplayTags(selectedTags);
+ }
+ }, [hidden, selectedTags]);
+
+ const isAtLimit = displayTags.length >= maxTags;
+ const existingTags = displayTags.map((label) => ({ label }));
+ const reachItems = [
+ {
+ key: REACH.NETWORK,
+ label: tReach('taggedBy', { reach: tReach('myNetwork').toLocaleLowerCase() }),
+ icon: Waypoints,
+ },
+ {
+ key: REACH.FOLLOWING,
+ label: tReach('taggedBy', { reach: tReach('following').toLocaleLowerCase() }),
+ icon: UsersRound,
+ },
+ {
+ key: REACH.ME,
+ label: tReach('taggedBy', { reach: tReach('me').toLocaleLowerCase() }),
+ icon: UserRound,
+ },
+ ];
+
+ const handleTagAdd = (tag: string) => {
+ const normalizedTag = tag.trim().toLowerCase();
+ if (normalizedTag && selectedTags.length < maxTags) {
+ onTagAdd(normalizedTag);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {displayTags.map((tag) => (
+ onTagRemove(tag)}
+ className="w-full justify-between"
+ />
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.types.ts b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.types.ts
new file mode 100644
index 0000000000..2326bb1a51
--- /dev/null
+++ b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.types.ts
@@ -0,0 +1,13 @@
+import type { ProfileTagScopeType } from '@/stores/home/home.types';
+
+export interface FilterProfileTagsProps {
+ selectedTags: string[];
+ onTagAdd: (tag: string) => void;
+ onTagRemove: (tag: string) => void;
+ scope: ProfileTagScopeType;
+ onScopeChange: (scope: ProfileTagScopeType) => void;
+ hidden?: boolean;
+ inputDisabled?: boolean;
+ onInputClick?: () => void;
+ maxTags?: number;
+}
diff --git a/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx b/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx
index 61ea7ecf92..3f707d56e1 100644
--- a/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx
+++ b/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx
@@ -7,7 +7,7 @@ describe('FilterReach', () => {
it('renders with default selected tab and proper ARIA attributes', () => {
render( );
- expect(screen.getByText('Reach')).toBeInTheDocument();
+ expect(screen.getByText('Posts from')).toBeInTheDocument();
// Check radiogroup exists
const radiogroup = screen.getByTestId('filter-reach-radiogroup');
@@ -16,6 +16,61 @@ describe('FilterReach', () => {
expect(radiogroup).toHaveAttribute('aria-labelledby');
});
+ it('renders the five Figma rings with All selected by default', () => {
+ render( );
+
+ const radiogroup = screen.getByTestId('filter-reach-radiogroup');
+ expect(radiogroup).toHaveClass('size-[180px]');
+ expect(radiogroup.querySelectorAll('circle')).toHaveLength(5);
+ expect(radiogroup.querySelectorAll('circle.stroke-border')).toHaveLength(4);
+ expect(radiogroup.querySelector('[data-reach-ring="all"]')).toHaveClass('fill-brand/[0.10]', 'stroke-brand');
+ expect(radiogroup.querySelectorAll('circle.fill-transparent')).toHaveLength(4);
+ expect(screen.getByTestId('filter-reach-label')).toHaveTextContent('All');
+ expect(screen.getByTestId('filter-reach-label')).toHaveClass(
+ 'text-brand',
+ 'drop-shadow-[0_1px_2px_rgba(0,0,0,0.75)]',
+ );
+ });
+
+ it('previews a reach in white on hover and restores the selected state on leave', () => {
+ render( );
+
+ const following = screen.getByLabelText('Following');
+ const radiogroup = screen.getByTestId('filter-reach-radiogroup');
+ const followingRing = radiogroup.querySelector('[data-reach-ring="following"]');
+ const friendsRing = radiogroup.querySelector('[data-reach-ring="friends"]');
+ const label = screen.getByTestId('filter-reach-label');
+
+ fireEvent.mouseEnter(following);
+
+ expect(followingRing).toHaveClass('stroke-foreground');
+ expect(friendsRing).toHaveClass('fill-transparent', 'stroke-brand/[0.32]');
+ expect(label).toHaveTextContent('Following');
+ expect(label).toHaveClass('text-foreground');
+
+ fireEvent.mouseLeave(following);
+
+ expect(followingRing).toHaveClass('stroke-border');
+ expect(friendsRing).toHaveClass('fill-brand/[0.10]', 'stroke-brand');
+ expect(label).toHaveTextContent('Friends');
+ expect(label).toHaveClass('text-brand');
+ });
+
+ it('keeps the selected state green when it is hovered', () => {
+ render( );
+
+ const friends = screen.getByLabelText('Friends');
+ const radiogroup = screen.getByTestId('filter-reach-radiogroup');
+ const friendsRing = radiogroup.querySelector('[data-reach-ring="friends"]');
+ const label = screen.getByTestId('filter-reach-label');
+
+ fireEvent.mouseEnter(friends);
+
+ expect(friendsRing).toHaveClass('fill-brand/[0.10]', 'stroke-brand');
+ expect(label).toHaveTextContent('Friends');
+ expect(label).toHaveClass('text-brand');
+ });
+
it('calls onTabChange when tab is clicked', () => {
const mockOnTabChange = vi.fn();
render( );
@@ -31,9 +86,11 @@ describe('FilterReach', () => {
render( );
const tabs = [
- { value: REACH.ALL, label: 'All' },
- { value: REACH.FOLLOWING, label: 'Following' },
+ { value: REACH.ME, label: 'Me' },
{ value: REACH.FRIENDS, label: 'Friends' },
+ { value: REACH.FOLLOWING, label: 'Following' },
+ { value: REACH.NETWORK, label: 'My Network' },
+ { value: REACH.ALL, label: 'All' },
];
tabs.forEach(({ value, label }) => {
@@ -43,27 +100,63 @@ describe('FilterReach', () => {
});
});
+ it.each([
+ { label: 'Me', value: REACH.ME },
+ { label: 'My Network', value: REACH.NETWORK },
+ ])('selects $label and reports the reach change', ({ label, value }) => {
+ const mockOnTabChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByLabelText(label));
+
+ const radiogroup = screen.getByTestId('filter-reach-radiogroup');
+ expect(radiogroup.querySelector(`[data-reach-ring="${value}"]`)).toHaveClass('fill-brand/[0.10]', 'stroke-brand');
+ expect(radiogroup.querySelector('[data-reach-ring="all"]')).toHaveClass('stroke-border');
+ expect(screen.getByLabelText(label)).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByLabelText('All')).toHaveAttribute('aria-checked', 'false');
+ expect(screen.getByTestId('filter-reach-label')).toHaveTextContent(label);
+ expect(mockOnTabChange).toHaveBeenCalledWith(value);
+
+ fireEvent.mouseEnter(screen.getByLabelText('All'));
+
+ expect(radiogroup.querySelector(`[data-reach-ring="${value}"]`)).toHaveClass(
+ 'fill-transparent',
+ 'stroke-brand/[0.32]',
+ );
+ expect(radiogroup.querySelector('[data-reach-ring="all"]')).toHaveClass('stroke-foreground');
+
+ fireEvent.mouseLeave(screen.getByLabelText('All'));
+
+ expect(radiogroup.querySelector(`[data-reach-ring="${value}"]`)).toHaveClass('fill-brand/[0.10]', 'stroke-brand');
+ });
+
it('has proper ARIA attributes for radio items', () => {
render( );
const allRadio = screen.getByLabelText('All');
+ const networkRadio = screen.getByLabelText('My Network');
const followingRadio = screen.getByLabelText('Following');
const friendsRadio = screen.getByLabelText('Friends');
+ const meRadio = screen.getByLabelText('Me');
// Check aria-checked
expect(allRadio).toHaveAttribute('aria-checked', 'false');
+ expect(networkRadio).toHaveAttribute('aria-checked', 'false');
expect(followingRadio).toHaveAttribute('aria-checked', 'true');
expect(friendsRadio).toHaveAttribute('aria-checked', 'false');
+ expect(meRadio).toHaveAttribute('aria-checked', 'false');
// Check aria-label
expect(allRadio).toHaveAttribute('aria-label', 'All');
+ expect(networkRadio).toHaveAttribute('aria-label', 'My Network');
expect(followingRadio).toHaveAttribute('aria-label', 'Following');
expect(friendsRadio).toHaveAttribute('aria-label', 'Friends');
+ expect(meRadio).toHaveAttribute('aria-label', 'Me');
});
it('renders all items as disabled when disabled prop is true', () => {
render( );
- const labels = ['All', 'Following', 'Friends'];
+ const labels = ['Me', 'Friends', 'Following', 'My Network', 'All'];
labels.forEach((label) => {
expect(screen.getByLabelText(label)).toHaveAttribute('aria-disabled', 'true');
});
@@ -73,16 +166,19 @@ describe('FilterReach', () => {
const mockOnTabChange = vi.fn();
render( );
+ fireEvent.click(screen.getByLabelText('Me'));
fireEvent.click(screen.getByLabelText('Following'));
fireEvent.click(screen.getByLabelText('Friends'));
+ fireEvent.click(screen.getByLabelText('My Network'));
expect(mockOnTabChange).not.toHaveBeenCalled();
+ expect(screen.getByLabelText('All')).toHaveAttribute('aria-checked', 'true');
});
it('items are not disabled by default', () => {
render( );
- const labels = ['All', 'Following', 'Friends'];
+ const labels = ['Me', 'Friends', 'Following', 'My Network', 'All'];
labels.forEach((label) => {
expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-disabled', 'true');
});
@@ -93,86 +189,90 @@ describe('FilterReach - Keyboard Navigation', () => {
it('manages tabIndex correctly for keyboard navigation', () => {
render( );
- const allRadio = screen.getByLabelText('All');
- const followingRadio = screen.getByLabelText('Following');
+ const meRadio = screen.getByLabelText('Me');
const friendsRadio = screen.getByLabelText('Friends');
+ const followingRadio = screen.getByLabelText('Following');
+ const networkRadio = screen.getByLabelText('My Network');
+ const allRadio = screen.getByLabelText('All');
// Only selected item should have tabIndex 0
- expect(allRadio).toHaveAttribute('tabIndex', '-1');
- expect(followingRadio).toHaveAttribute('tabIndex', '-1');
+ expect(meRadio).toHaveAttribute('tabIndex', '-1');
expect(friendsRadio).toHaveAttribute('tabIndex', '0');
+ expect(followingRadio).toHaveAttribute('tabIndex', '-1');
+ expect(networkRadio).toHaveAttribute('tabIndex', '-1');
+ expect(allRadio).toHaveAttribute('tabIndex', '-1');
});
it('handles keyboard navigation with ArrowDown', () => {
render( );
const allRadio = screen.getByLabelText('All');
- const followingRadio = screen.getByLabelText('Following');
+ const meRadio = screen.getByLabelText('Me');
allRadio.focus();
fireEvent.keyDown(allRadio, { key: 'ArrowDown' });
- expect(document.activeElement).toBe(followingRadio);
+ expect(document.activeElement).toBe(meRadio);
});
it('handles keyboard navigation with ArrowUp', () => {
render( );
- const allRadio = screen.getByLabelText('All');
+ const friendsRadio = screen.getByLabelText('Friends');
const followingRadio = screen.getByLabelText('Following');
followingRadio.focus();
fireEvent.keyDown(followingRadio, { key: 'ArrowUp' });
- expect(document.activeElement).toBe(allRadio);
+ expect(document.activeElement).toBe(friendsRadio);
});
it('wraps keyboard navigation from last to first with ArrowDown', () => {
- render( );
+ render( );
const allRadio = screen.getByLabelText('All');
- const friendsRadio = screen.getByLabelText('Friends');
+ const meRadio = screen.getByLabelText('Me');
- friendsRadio.focus();
- fireEvent.keyDown(friendsRadio, { key: 'ArrowDown' });
+ allRadio.focus();
+ fireEvent.keyDown(allRadio, { key: 'ArrowDown' });
- expect(document.activeElement).toBe(allRadio);
+ expect(document.activeElement).toBe(meRadio);
});
it('wraps keyboard navigation from first to last with ArrowUp', () => {
render( );
const allRadio = screen.getByLabelText('All');
- const friendsRadio = screen.getByLabelText('Friends');
+ const meRadio = screen.getByLabelText('Me');
- allRadio.focus();
- fireEvent.keyDown(allRadio, { key: 'ArrowUp' });
+ meRadio.focus();
+ fireEvent.keyDown(meRadio, { key: 'ArrowUp' });
- expect(document.activeElement).toBe(friendsRadio);
+ expect(document.activeElement).toBe(allRadio);
});
it('handles Home key to jump to first option', () => {
render( );
- const allRadio = screen.getByLabelText('All');
const friendsRadio = screen.getByLabelText('Friends');
+ const meRadio = screen.getByLabelText('Me');
friendsRadio.focus();
fireEvent.keyDown(friendsRadio, { key: 'Home' });
- expect(document.activeElement).toBe(allRadio);
+ expect(document.activeElement).toBe(meRadio);
});
it('handles End key to jump to last option', () => {
render( );
const allRadio = screen.getByLabelText('All');
- const friendsRadio = screen.getByLabelText('Friends');
+ const meRadio = screen.getByLabelText('Me');
- allRadio.focus();
- fireEvent.keyDown(allRadio, { key: 'End' });
+ meRadio.focus();
+ fireEvent.keyDown(meRadio, { key: 'End' });
- expect(document.activeElement).toBe(friendsRadio);
+ expect(document.activeElement).toBe(allRadio);
});
it('handles selection with Space key', () => {
@@ -199,28 +299,44 @@ describe('FilterReach - Keyboard Navigation', () => {
expect(mockOnTabChange).toHaveBeenCalledWith('friends');
});
+ it('selects Network with the keyboard and reports the reach change', () => {
+ const mockOnTabChange = vi.fn();
+ const { rerender } = render( );
+
+ const networkRadio = screen.getByLabelText('My Network');
+ networkRadio.focus();
+ fireEvent.keyDown(networkRadio, { key: 'Enter' });
+
+ expect(mockOnTabChange).toHaveBeenCalledWith(REACH.NETWORK);
+
+ rerender( );
+
+ expect(networkRadio).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByTestId('filter-reach-label')).toHaveTextContent('My Network');
+ });
+
it('handles ArrowRight key like ArrowDown', () => {
render( );
const allRadio = screen.getByLabelText('All');
- const followingRadio = screen.getByLabelText('Following');
+ const meRadio = screen.getByLabelText('Me');
allRadio.focus();
fireEvent.keyDown(allRadio, { key: 'ArrowRight' });
- expect(document.activeElement).toBe(followingRadio);
+ expect(document.activeElement).toBe(meRadio);
});
it('handles ArrowLeft key like ArrowUp', () => {
render( );
- const allRadio = screen.getByLabelText('All');
+ const friendsRadio = screen.getByLabelText('Friends');
const followingRadio = screen.getByLabelText('Following');
followingRadio.focus();
fireEvent.keyDown(followingRadio, { key: 'ArrowLeft' });
- expect(document.activeElement).toBe(allRadio);
+ expect(document.activeElement).toBe(friendsRadio);
});
});
@@ -241,6 +357,19 @@ describe('FilterReach - Controlled/Uncontrolled', () => {
expect(screen.getByLabelText('All')).toHaveAttribute('aria-checked', 'false');
});
+ it('updates a controlled ME selection when the feed value changes', () => {
+ const { rerender } = render( );
+
+ expect(screen.getByLabelText('Me')).toHaveAttribute('aria-checked', 'true');
+
+ rerender( );
+ expect(screen.getByLabelText('Following')).toHaveAttribute('aria-checked', 'true');
+
+ rerender( );
+ expect(screen.getByLabelText('All')).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByLabelText('Me')).toHaveAttribute('aria-checked', 'false');
+ });
+
it('works as uncontrolled component with defaultSelectedTab', () => {
render( );
diff --git a/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx.snap b/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx.snap
index 55ccdb0935..05af582097 100644
--- a/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx.snap
+++ b/src/components/molecules/Filters/FilterReach/FilterReach.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`FilterReach - Snapshots > matches snapshot in uncontrolled mode with defaultSelectedTab 1`] = `
@@ -16,147 +16,135 @@ exports[`FilterReach - Snapshots > matches snapshot in uncontrolled mode with de
class="text-2xl font-light text-muted-foreground"
data-testid="heading-2"
>
- Reach
+ Posts from
-
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
- Following
-
-
-
-
-
-
-
- Friends
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Friends
+
`;
exports[`FilterReach - Snapshots > matches snapshot with All selected tab 1`] = `
@@ -170,147 +158,135 @@ exports[`FilterReach - Snapshots > matches snapshot with All selected tab 1`] =
class="text-2xl font-light text-muted-foreground"
data-testid="heading-2"
>
- Reach
+ Posts from
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
- Following
-
-
-
-
-
-
-
- Friends
-
-
-
+ All
+
`;
exports[`FilterReach - Snapshots > matches snapshot with Following selected 1`] = `
@@ -324,147 +300,135 @@ exports[`FilterReach - Snapshots > matches snapshot with Following selected 1`]
class="text-2xl font-light text-muted-foreground"
data-testid="heading-2"
>
- Reach
+ Posts from
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
- Following
-
-
-
-
-
-
-
- Friends
-
-
-
+ Following
+
`;
exports[`FilterReach - Snapshots > matches snapshot with Friends selected 1`] = `
@@ -478,147 +442,135 @@ exports[`FilterReach - Snapshots > matches snapshot with Friends selected 1`] =
class="text-2xl font-light text-muted-foreground"
data-testid="heading-2"
>
- Reach
+ Posts from
-
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
- Following
-
-
-
-
-
-
-
- Friends
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Friends
+
`;
exports[`FilterReach - Snapshots > matches snapshot with default props (All selected) 1`] = `
@@ -632,147 +584,135 @@ exports[`FilterReach - Snapshots > matches snapshot with default props (All sele
class="text-2xl font-light text-muted-foreground"
data-testid="heading-2"
>
- Reach
+ Posts from
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
- Following
-
-
-
-
-
-
-
- Friends
-
-
-
+ All
+
`;
exports[`FilterReach - Snapshots > matches snapshot with disabled state 1`] = `
@@ -786,143 +726,138 @@ exports[`FilterReach - Snapshots > matches snapshot with disabled state 1`] = `
class="text-2xl font-light text-muted-foreground"
data-testid="heading-2"
>
- Reach
+ Posts from
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
- Following
-
-
-
-
-
-
-
- Friends
-
-
-
+ All
+
`;
diff --git a/src/components/molecules/Filters/FilterReach/FilterReach.tsx b/src/components/molecules/Filters/FilterReach/FilterReach.tsx
index 05d6dde651..7c7ee5f0ba 100644
--- a/src/components/molecules/Filters/FilterReach/FilterReach.tsx
+++ b/src/components/molecules/Filters/FilterReach/FilterReach.tsx
@@ -1,12 +1,30 @@
'use client';
import * as React from 'react';
-import { HeartHandshake, Radio } from 'lucide-react';
import { useTranslations } from 'next-intl';
-import { UsersRound2 } from '@/icons';
+import { FilterHeader, FilterRoot } from '@/atoms/Filter/Filter';
+import { Typography } from '@/atoms/Typography/Typography';
+import { useControlledState } from '@/hooks/useControlledState/useControlledState';
+import { useRadiogroupKeyboard } from '@/hooks/useRadiogroupKeyboard/useRadiogroupKeyboard';
+import { cn } from '@/libs/utils/utils';
import { REACH, type ReachType } from '@/stores/home/home.types';
-import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup';
-import { BaseFilterProps } from '../Filters.types';
+import type { BaseFilterProps } from '../Filters.types';
+
+interface ReachItem {
+ key: ReachType;
+ label: string;
+ sizeClassName: string;
+ layerClassName: string;
+ disabled?: boolean;
+}
+
+const REACH_RINGS: ReadonlyArray<{ radius: number; value: ReachType }> = [
+ { radius: 89.5, value: REACH.ALL },
+ { radius: 75.5, value: REACH.NETWORK },
+ { radius: 61.5, value: REACH.FOLLOWING },
+ { radius: 47.5, value: REACH.FRIENDS },
+ { radius: 33.5, value: REACH.ME },
+];
export function FilterReach({
selectedTab,
@@ -15,38 +33,160 @@ export function FilterReach({
disabled,
}: BaseFilterProps) {
const t = useTranslations('filters.reach');
- const items = React.useMemo(
- () => [
- {
- key: REACH.ALL,
- label: t('all'),
- icon: Radio,
- disabled,
- },
- {
- key: REACH.FOLLOWING,
- label: t('following'),
- icon: UsersRound2,
- disabled,
- },
- {
- key: REACH.FRIENDS,
- label: t('friends'),
- icon: HeartHandshake,
- disabled,
- },
- ],
- [t, disabled],
- );
+ const headerId = React.useId();
+ const [hoveredValue, setHoveredValue] = React.useState();
+ const items: ReachItem[] = [
+ {
+ key: REACH.ME,
+ label: t('me'),
+ sizeClassName: 'size-[68px]',
+ layerClassName: 'z-60',
+ disabled,
+ },
+ {
+ key: REACH.FRIENDS,
+ label: t('friends'),
+ sizeClassName: 'size-[96px]',
+ layerClassName: 'z-50',
+ disabled,
+ },
+ {
+ key: REACH.FOLLOWING,
+ label: t('following'),
+ sizeClassName: 'size-[124px]',
+ layerClassName: 'z-40',
+ disabled,
+ },
+ {
+ key: REACH.NETWORK,
+ label: t('myNetwork'),
+ sizeClassName: 'size-[152px]',
+ layerClassName: 'z-30',
+ disabled,
+ },
+ {
+ key: REACH.ALL,
+ label: t('all'),
+ sizeClassName: 'size-[180px]',
+ layerClassName: 'z-10',
+ disabled,
+ },
+ ];
+
+ const { value: selectedValue, setValue: setSelectedValue } = useControlledState({
+ value: selectedTab,
+ defaultValue: defaultSelectedTab,
+ onChange: onTabChange,
+ });
+
+ const isPreviewingAnotherValue = hoveredValue !== undefined && hoveredValue !== selectedValue;
+
+ const selectItem = (item: ReachItem) => {
+ if (item.disabled) return;
+ setSelectedValue(item.key);
+ };
+
+ const { listRef, handleKeyDown } = useRadiogroupKeyboard({
+ items,
+ onSelect: selectItem,
+ isDisabled: (item) => item.disabled ?? false,
+ });
+
+ const visibleValue = hoveredValue ?? selectedValue;
+ const visibleLabel = items.find((item) => item.key === visibleValue)?.label;
+
return (
-
+
+
+
+
+
+ {REACH_RINGS.map((ring) => {
+ const isHovered = ring.value === hoveredValue;
+ const isSelected = ring.value === selectedValue;
+ const isSelectedPreview = isPreviewingAnotherValue && isSelected;
+ const fillClassName = isSelected
+ ? isSelectedPreview
+ ? 'fill-transparent'
+ : 'fill-brand/[0.10]'
+ : 'fill-transparent';
+ const strokeClassName = isSelected
+ ? isSelectedPreview
+ ? 'stroke-brand/[0.32]'
+ : 'stroke-brand'
+ : isHovered
+ ? 'stroke-foreground'
+ : 'stroke-border';
+
+ return (
+
+ );
+ })}
+
+
+ {items.map((item, index) => {
+ const isSelected = selectedValue === item.key;
+
+ return (
+ selectItem(item)}
+ onKeyDown={(event) => handleKeyDown(event, index)}
+ onMouseEnter={() => {
+ if (!item.disabled) {
+ setHoveredValue(item.key);
+ }
+ }}
+ onMouseLeave={() => setHoveredValue(undefined)}
+ />
+ );
+ })}
+
+
+ {visibleLabel}
+
+
+
);
}
diff --git a/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx b/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx
index e460581288..15a310b8f4 100644
--- a/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx
+++ b/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx
@@ -1,96 +1,94 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { SORT, type SortType } from '@/stores/home/home.types';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { SORT } from '@/stores/home/home.types';
import { FilterSort } from './FilterSort';
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Element.prototype.hasPointerCapture = vi.fn();
+ Element.prototype.releasePointerCapture = vi.fn();
+ Element.prototype.setPointerCapture = vi.fn();
+});
+
+async function selectSortOption(label: string) {
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('combobox', { name: 'Sort' }));
+ await user.click(screen.getByRole('option', { name: label }));
+}
+
describe('FilterSort', () => {
- it('renders with default selected tab', () => {
+ it('renders the current option in a dropdown trigger', () => {
render( );
expect(screen.getByText('Sort')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Sort' })).toHaveTextContent('Recent');
});
- it('calls onTabChange when tab is clicked', () => {
- const mockOnTabChange = vi.fn();
- render( );
+ it('calls onTabChange when an option is selected', async () => {
+ const onTabChange = vi.fn();
+ render( );
- const popularityElement = screen.getByText('Popularity');
- fireEvent.click(popularityElement);
+ await selectSortOption('Popularity');
- expect(mockOnTabChange).toHaveBeenCalledWith(SORT.ENGAGEMENT); // 'total_engagement'
+ expect(onTabChange).toHaveBeenCalledWith(SORT.ENGAGEMENT);
});
- it('handles all tab types correctly', () => {
- const mockOnTabChange = vi.fn();
- render( );
-
- const tabs: SortType[] = ['timeline', 'total_engagement'];
+ it('supports both sort options', async () => {
+ const onTabChange = vi.fn();
+ render( );
- tabs.forEach((tab) => {
- const element = screen.getByText(tab === 'timeline' ? 'Recent' : 'Popularity');
+ await selectSortOption('Popularity');
+ await selectSortOption('Recent');
- fireEvent.click(element);
- expect(mockOnTabChange).toHaveBeenCalledWith(tab);
- });
+ expect(onTabChange).toHaveBeenNthCalledWith(1, SORT.ENGAGEMENT);
+ expect(onTabChange).toHaveBeenNthCalledWith(2, SORT.TIMELINE);
});
- it('handles tab switching correctly', () => {
- const mockOnTabChange = vi.fn();
- render( );
-
- // Click on popularity tab
- const popularityElement = screen.getByText('Popularity');
- fireEvent.click(popularityElement);
+ it('updates the trigger when the controlled selection changes', () => {
+ const { rerender } = render( );
- expect(mockOnTabChange).toHaveBeenCalledWith(SORT.ENGAGEMENT); // 'total_engagement'
- expect(mockOnTabChange).toHaveBeenCalledTimes(1);
- });
+ expect(screen.getByRole('combobox', { name: 'Sort' })).toHaveTextContent('Recent');
- it('renders all items as disabled when disabled prop is true', () => {
- render( );
+ rerender( );
- const labels = ['Recent', 'Popularity'];
- labels.forEach((label) => {
- expect(screen.getByLabelText(label)).toHaveAttribute('aria-disabled', 'true');
- });
+ expect(screen.getByRole('combobox', { name: 'Sort' })).toHaveTextContent('Popularity');
});
- it('does not call onTabChange when disabled', () => {
- const mockOnTabChange = vi.fn();
- render( );
+ it('disables the dropdown when the filter is disabled', async () => {
+ const onTabChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
- fireEvent.click(screen.getByText('Recent'));
- fireEvent.click(screen.getByText('Popularity'));
+ const trigger = screen.getByRole('combobox', { name: 'Sort' });
+ expect(trigger).toBeDisabled();
- expect(mockOnTabChange).not.toHaveBeenCalled();
- });
-
- it('items are not disabled by default', () => {
- render( );
+ await user.click(trigger);
- const labels = ['Recent', 'Popularity'];
- labels.forEach((label) => {
- expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-disabled', 'true');
- });
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
+ expect(onTabChange).not.toHaveBeenCalled();
});
- it('renders with different selected tabs', () => {
- const { rerender } = render( );
+ it('renders enabled options by default', async () => {
+ const user = userEvent.setup();
+ render( );
- let recentItem = screen.getByText('Recent').closest('[data-testid="filter-item"]');
- let popularityItem = screen.getByText('Popularity').closest('[data-testid="filter-item"]');
+ await user.click(screen.getByRole('combobox', { name: 'Sort' }));
- expect(recentItem).toBeInTheDocument();
- expect(popularityItem).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Recent' })).not.toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByRole('option', { name: 'Popularity' })).not.toHaveAttribute('aria-disabled', 'true');
+ });
- // Rerender with different selected tab
- rerender( );
+ it('uses the secondary foreground color when an option is hovered', async () => {
+ const user = userEvent.setup();
+ render( );
- recentItem = screen.getByText('Recent').closest('[data-testid="filter-item"]');
- popularityItem = screen.getByText('Popularity').closest('[data-testid="filter-item"]');
+ await user.click(screen.getByRole('combobox', { name: 'Sort' }));
- expect(recentItem).toBeInTheDocument();
- expect(popularityItem).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Popularity' })).toHaveClass(
+ 'hover:text-secondary-foreground',
+ 'data-[highlighted]:text-secondary-foreground',
+ );
});
});
diff --git a/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx.snap b/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx.snap
index e3b3570d43..1ba53bd72e 100644
--- a/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx.snap
+++ b/src/components/molecules/Filters/FilterSort/FilterSort.test.tsx.snap
@@ -19,103 +19,72 @@ exports[`FilterSort - Snapshots > matches snapshot with Popularity content selec
Sort
-
-
-
-
-
-
-
-
-
- Recent
+
+
+
-
-
-
-
-
-
+
Popularity
-
-
-
+
+
+
+
+
+
`;
@@ -138,103 +107,85 @@ exports[`FilterSort - Snapshots > matches snapshot with Recent content selected
Sort
-
-
-
-
-
-
-
-
-
- Recent
+
+
+
+
+
+
-
-
-
-
-
-
- Popularity
+
+ Recent
-
-
-
+
+
+
+
+
+
`;
@@ -257,103 +208,85 @@ exports[`FilterSort - Snapshots > matches snapshot with default props 1`] = `
Sort
-
-
-
-
-
-
-
-
-
- Recent
+
+
+
+
+
+
-
-
-
-
-
-
- Popularity
+
+ Recent
-
-
-
+
+
+
+
+
+
`;
@@ -376,104 +309,86 @@ exports[`FilterSort - Snapshots > matches snapshot with disabled state 1`] = `
Sort
-
-
-
-
-
-
-
-
-
- Recent
+
+
+
+
+
+
-
-
-
-
-
-
- Popularity
+
+ Recent
-
-
-
+
+
+
+
+
+
`;
diff --git a/src/components/molecules/Filters/FilterSort/FilterSort.tsx b/src/components/molecules/Filters/FilterSort/FilterSort.tsx
index 15d6707723..bfb3026eab 100644
--- a/src/components/molecules/Filters/FilterSort/FilterSort.tsx
+++ b/src/components/molecules/Filters/FilterSort/FilterSort.tsx
@@ -1,10 +1,9 @@
'use client';
-import * as React from 'react';
import { Flame, SquareAsterisk } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { SORT, type SortType } from '@/stores/home/home.types';
-import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup';
+import { FilterDropdown } from '../FilterDropdown/FilterDropdown';
import { BaseFilterProps } from '../Filters.types';
export function FilterSort({
@@ -14,31 +13,30 @@ export function FilterSort({
disabled,
}: BaseFilterProps) {
const t = useTranslations('filters.sort');
- const items = React.useMemo(
- () => [
- {
- key: SORT.TIMELINE,
- label: t('recent'),
- icon: SquareAsterisk,
- disabled,
- },
- {
- key: SORT.ENGAGEMENT,
- label: t('popularity'),
- icon: Flame,
- disabled,
- },
- ],
- [t, disabled],
- );
+ const items = [
+ {
+ key: SORT.TIMELINE,
+ label: t('recent'),
+ icon: SquareAsterisk,
+ disabled,
+ },
+ {
+ key: SORT.ENGAGEMENT,
+ label: t('popularity'),
+ icon: Flame,
+ disabled,
+ },
+ ];
+
return (
-
);
}
diff --git a/src/components/molecules/TagInput/TagInput.tsx b/src/components/molecules/TagInput/TagInput.tsx
index 85f636f756..e024cd8469 100644
--- a/src/components/molecules/TagInput/TagInput.tsx
+++ b/src/components/molecules/TagInput/TagInput.tsx
@@ -161,7 +161,6 @@ export const TagInput = forwardRef(function TagIn
isDashedVariant && 'border border-dashed border-input shadow-sm transition-all duration-300',
onClick && 'cursor-pointer',
className,
- isAtLimit && 'w-40',
)}
style={style}
// Adding / typing a tag is the only intent here — never navigation.
diff --git a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx
index d3df5c8106..d2db807c0d 100644
--- a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx
+++ b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx
@@ -2,6 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort } from 'pubky-app-specs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { FeedModelSchema } from '@/models/feed/feed.schema';
+import { useHomeStore } from '@/stores/home/home.store';
+import { REACH } from '@/stores/home/home.types';
import { FeedNavigation } from './FeedNavigation';
// Mock next/navigation
@@ -185,6 +187,7 @@ describe('FeedNavigation', () => {
mockRequireAuth.mockImplementation((action: () => unknown) => action());
mockUsePathname.mockReturnValue('/home');
mockGetList.mockResolvedValue([]);
+ useHomeStore.setState({ reach: REACH.ALL });
});
// ── Sanity ───────────────────────────────────────────────────────────────
@@ -195,8 +198,8 @@ describe('FeedNavigation', () => {
const container = screen.getByTestId('container');
expect(container).toBeInTheDocument();
- // Home link should always be present
- expect(screen.getByText('Home')).toBeInTheDocument();
+ // The home-route link reflects the selected Reach.
+ expect(screen.getByText('All')).toBeInTheDocument();
// Create Feed button should always be present
expect(screen.getByText('Create Feed')).toBeInTheDocument();
@@ -204,13 +207,40 @@ describe('FeedNavigation', () => {
// ── Home feed link ──────────────────────────────────────────────────────
- it('renders the Home feed link with correct href', () => {
+ it('renders the home-route feed link with correct href', () => {
render( );
const links = screen.getAllByTestId('link');
const homeLink = links.find((link) => link.getAttribute('href') === '/home');
expect(homeLink).toBeInTheDocument();
- expect(homeLink).toHaveTextContent('Home');
+ expect(homeLink).toHaveTextContent('All');
+ });
+
+ it.each([
+ { reach: REACH.ALL, label: 'All' },
+ { reach: REACH.NETWORK, label: 'My Network' },
+ { reach: REACH.FOLLOWING, label: 'Following' },
+ { reach: REACH.FRIENDS, label: 'Friends' },
+ { reach: REACH.ME, label: 'Me' },
+ ])('reflects $label Reach in the home-route feed tab', ({ reach, label }) => {
+ useHomeStore.setState({ reach });
+
+ render( );
+
+ const homeLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/home');
+ expect(homeLink).toHaveTextContent(label);
+ expect(homeLink?.querySelector(`[data-reach-icon="${reach}"]`)).toBeInTheDocument();
+ });
+
+ it('falls back to All in the home-route feed tab when a guest has a personalized Reach stored', () => {
+ mockIsAuthenticated = false;
+ useHomeStore.setState({ reach: REACH.FRIENDS });
+
+ render( );
+
+ const homeLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/home');
+ expect(homeLink).toHaveTextContent('All');
+ expect(homeLink?.querySelector('[data-reach-icon="all"]')).toBeInTheDocument();
});
it('applies active styling to Home link when pathname is /home', () => {
@@ -270,8 +300,8 @@ describe('FeedNavigation', () => {
const typographies = screen.getAllByTestId('typography');
const feedNames = typographies.map((t) => t.textContent);
- // Home is first, then custom feeds, then Create Feed
- expect(feedNames).toEqual(['Home', 'Alpha Feed', 'Beta Feed', 'Gamma Feed', 'Create Feed']);
+ // The Reach tab is first, then custom feeds, then Create Feed.
+ expect(feedNames).toEqual(['All', 'Alpha Feed', 'Beta Feed', 'Gamma Feed', 'Create Feed']);
});
// ── Active / Inactive custom feed styling ───────────────────────────────
@@ -358,7 +388,7 @@ describe('FeedNavigation', () => {
render( );
- expect(screen.getByText('Home')).toBeInTheDocument();
+ expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.queryByText('Private Feed')).not.toBeInTheDocument();
expect(screen.queryByTestId('custom-feed-dialog-create')).not.toBeInTheDocument();
expect(mockGetList).not.toHaveBeenCalled();
@@ -383,8 +413,8 @@ describe('FeedNavigation', () => {
render( );
- // Should still render Home and Create Feed even when getList fails
- expect(screen.getByText('Home')).toBeInTheDocument();
+ // Should still render the Reach tab and Create Feed even when getList fails.
+ expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('Create Feed')).toBeInTheDocument();
});
@@ -422,6 +452,7 @@ describe('FeedNavigation - Snapshots', () => {
mockRequireAuth.mockImplementation((action: () => unknown) => action());
mockUsePathname.mockReturnValue('/home');
mockGetList.mockResolvedValue([]);
+ useHomeStore.setState({ reach: REACH.ALL });
});
it('matches snapshot with no custom feeds and Home active', () => {
diff --git a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap
index f4a693a177..3f377a8f77 100644
--- a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap
+++ b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap
@@ -21,7 +21,8 @@ exports[`FeedNavigation - Snapshots > matches snapshot with custom feed active (
>
matches snapshot with custom feed active (
xmlns="http://www.w3.org/2000/svg"
>
+
+
+
matches snapshot with custom feed active (
data-override-defaults="true"
data-testid="typography"
>
- Home
+ All
matches snapshot with custom feeds and Hom
>
matches snapshot with custom feeds and Hom
xmlns="http://www.w3.org/2000/svg"
>
+
+
+
matches snapshot with custom feeds and Hom
data-override-defaults="true"
data-testid="typography"
>
- Home
+ All
matches snapshot with multiple custom feed
>
matches snapshot with multiple custom feed
xmlns="http://www.w3.org/2000/svg"
>
+
+
+
matches snapshot with multiple custom feed
data-override-defaults="true"
data-testid="typography"
>
- Home
+ All
matches snapshot with no custom feeds and
>
matches snapshot with no custom feeds and
xmlns="http://www.w3.org/2000/svg"
>
+
+
+
matches snapshot with no custom feeds and
data-override-defaults="true"
data-testid="typography"
>
- Home
+ All
;
+ labelKey: 'all' | 'myNetwork' | 'following' | 'friends' | 'me';
+ }
+> = {
+ [REACH.ALL]: { icon: Radio, labelKey: 'all' },
+ [REACH.NETWORK]: { icon: Waypoints, labelKey: 'myNetwork' },
+ [REACH.FOLLOWING]: { icon: UsersRound2, labelKey: 'following' },
+ [REACH.FRIENDS]: { icon: HeartHandshake, labelKey: 'friends' },
+ [REACH.ME]: { icon: UserRound, labelKey: 'me' },
+};
+
interface FeedNavigationProps {
className?: string;
}
export const FeedNavigation = ({ className }: FeedNavigationProps) => {
const pathname = usePathname();
const tHeader = useTranslations('header');
+ const tReach = useTranslations('filters.reach');
const tDialog = useTranslations('dialogs.customFeed');
const { isAuthenticated, requireAuth } = useRequireAuth();
+ const reach = useHomeStore((state) => state.reach);
+ const effectiveReach = isAuthenticated || reach === REACH.NETWORK ? reach : REACH.ALL;
+ const homeFeedReach = HOME_FEED_REACH[effectiveReach];
+ const HomeFeedReachIcon = homeFeedReach.icon;
const customFeeds = useLiveQuery(
async () => {
try {
@@ -56,8 +79,8 @@ export const FeedNavigation = ({ className }: FeedNavigationProps) => {
}));
const feeds = [
{
- name: tHeader('home'),
- icon:
,
+ name: tReach(homeFeedReach.labelKey),
+ icon:
,
href: APP_ROUTES.HOME,
},
...customFeedsMapped,
diff --git a/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx b/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx
index 0e0c9d30a1..bba144e752 100644
--- a/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx
+++ b/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx
@@ -3,35 +3,60 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TIMELINE_FEED_VARIANT } from '@/config/feed';
import { HomeFeedDrawer, HomeFeedDrawerMobile, HomeFeedSidebar } from './HomeFeedSidebar';
-const { mockSetContent, mockUseHomeStore, mockFilterContent, mockUseFeedLayoutResolution, mockCurrentUserPubky } =
- vi.hoisted(() => ({
- mockSetContent: vi.fn(),
- mockUseHomeStore: vi.fn(),
- mockFilterContent: vi.fn(({ disabledTabs, selectedTab }: { disabledTabs?: string[]; selectedTab?: string }) => (
+const {
+ mockSetContent,
+ mockUseHomeStore,
+ mockFilterContent,
+ mockFilterProfileTags,
+ mockUseFeedLayoutResolution,
+ mockCurrentUserPubky,
+ mockSetReach,
+ mockSetProfileTagScope,
+} = vi.hoisted(() => ({
+ mockSetContent: vi.fn(),
+ mockUseHomeStore: vi.fn(),
+ mockFilterContent: vi.fn(({ disabledTabs, selectedTab }: { disabledTabs?: string[]; selectedTab?: string }) => (
+
+ FilterContent
+
+ )),
+ mockFilterProfileTags: vi.fn(
+ ({ hidden, inputDisabled, scope }: { hidden?: boolean; inputDisabled?: boolean; scope?: string }) => (
- FilterContent
+ FilterProfileTags
- )),
- mockUseFeedLayoutResolution: vi.fn(() => ({
- requestedLayout: 'columns',
- effectiveLayout: 'columns',
- isVisualRequested: false,
- isVisualActive: false,
- isPhoneViewport: false,
- })),
- mockCurrentUserPubky: { value: 'viewer-pubky' as string | null },
- }));
+ ),
+ ),
+ mockUseFeedLayoutResolution: vi.fn(() => ({
+ requestedLayout: 'columns',
+ effectiveLayout: 'columns',
+ isVisualRequested: false,
+ isVisualActive: false,
+ isPhoneViewport: false,
+ })),
+ mockCurrentUserPubky: { value: 'viewer-pubky' as string | null },
+ mockSetReach: vi.fn(),
+ mockSetProfileTagScope: vi.fn(),
+}));
// Mock useHomeStore
vi.mock('@/stores/home/home.types', () => ({
+ HOME_PROFILE_TAGS_MAX_SELECTED: 5,
REACH: {
- ALL: 'all',
- FOLLOWING: 'following',
+ ME: 'me',
FRIENDS: 'friends',
+ FOLLOWING: 'following',
+ NETWORK: 'network',
+ ALL: 'all',
},
CONTENT: {
ALL: 'all',
@@ -94,6 +119,11 @@ vi.mock('@/molecules/Filters/FilterReach/FilterReach', () => {
};
});
+vi.mock('@/molecules/Filters/FilterProfileTags/FilterProfileTags', () => ({
+ FilterProfileTags: (props: { hidden?: boolean; inputDisabled?: boolean; scope?: string }) =>
+ mockFilterProfileTags(props),
+}));
+
vi.mock('@/molecules/Filters/FilterSort/FilterSort', () => {
return {
FilterSort: () =>
FilterSort
,
@@ -102,18 +132,26 @@ vi.mock('@/molecules/Filters/FilterSort/FilterSort', () => {
beforeEach(() => {
mockSetContent.mockClear();
+ mockSetReach.mockClear();
+ mockSetProfileTagScope.mockClear();
mockCurrentUserPubky.value = 'viewer-pubky';
mockUseHomeStore.mockReturnValue({
layout: 'columns',
setLayout: vi.fn(),
reach: 'following',
- setReach: vi.fn(),
+ setReach: mockSetReach,
sort: 'timeline',
setSort: vi.fn(),
content: 'all',
setContent: mockSetContent,
+ profileTags: [],
+ profileTagScope: 'network',
+ addProfileTag: vi.fn(),
+ removeProfileTag: vi.fn(),
+ setProfileTagScope: mockSetProfileTagScope,
});
mockFilterContent.mockClear();
+ mockFilterProfileTags.mockClear();
mockUseFeedLayoutResolution.mockReturnValue({
requestedLayout: 'columns',
effectiveLayout: 'columns',
@@ -128,6 +166,8 @@ describe('HomeFeedSidebar', () => {
render(
);
expect(screen.getByTestId('filter-reach')).toBeInTheDocument();
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-hidden', 'false');
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-scope', 'network');
expect(screen.getByTestId('filter-sort')).toBeInTheDocument();
expect(screen.getByTestId('filter-content')).toBeInTheDocument();
expect(screen.getByTestId('filter-layout')).toBeInTheDocument();
@@ -161,11 +201,16 @@ describe('HomeFeedSidebar', () => {
layout: 'visual',
setLayout: vi.fn(),
reach: 'following',
- setReach: vi.fn(),
+ setReach: mockSetReach,
sort: 'timeline',
setSort: vi.fn(),
content: 'short',
setContent: mockSetContent,
+ profileTags: [],
+ profileTagScope: 'network',
+ addProfileTag: vi.fn(),
+ removeProfileTag: vi.fn(),
+ setProfileTagScope: mockSetProfileTagScope,
});
mockUseFeedLayoutResolution.mockReturnValue({
requestedLayout: 'visual',
@@ -187,6 +232,56 @@ describe('HomeFeedSidebar', () => {
render(
);
expect(screen.getByTestId('filter-reach')).toHaveAttribute('data-selected-tab', 'all');
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-hidden', 'false');
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-input-disabled', 'true');
+ });
+
+ it('keeps Network selected when logged out because it aliases the public All stream', () => {
+ mockCurrentUserPubky.value = null;
+ mockUseHomeStore.mockReturnValue({
+ layout: 'columns',
+ setLayout: vi.fn(),
+ reach: 'network',
+ setReach: mockSetReach,
+ sort: 'timeline',
+ setSort: vi.fn(),
+ content: 'all',
+ setContent: mockSetContent,
+ profileTags: [],
+ profileTagScope: 'network',
+ addProfileTag: vi.fn(),
+ removeProfileTag: vi.fn(),
+ setProfileTagScope: mockSetProfileTagScope,
+ });
+
+ render(
);
+
+ expect(screen.getByTestId('filter-reach')).toHaveAttribute('data-selected-tab', 'network');
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-hidden', 'false');
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-input-disabled', 'true');
+ });
+
+ it('hides Tagged as only for Me reach', () => {
+ mockUseHomeStore.mockReturnValue({
+ layout: 'columns',
+ setLayout: vi.fn(),
+ reach: 'me',
+ setReach: mockSetReach,
+ sort: 'timeline',
+ setSort: vi.fn(),
+ content: 'all',
+ setContent: mockSetContent,
+ profileTags: ['bitcoiner'],
+ profileTagScope: 'following',
+ addProfileTag: vi.fn(),
+ removeProfileTag: vi.fn(),
+ setProfileTagScope: mockSetProfileTagScope,
+ });
+
+ render(
);
+
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-hidden', 'true');
+ expect(screen.getByTestId('filter-profile-tags')).toHaveAttribute('data-scope', 'following');
});
});
diff --git a/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx.snap b/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx.snap
index 995693bd0c..02b4024ab3 100644
--- a/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx.snap
+++ b/src/components/organisms/HomeFeedSidebar/HomeFeedSidebar.test.tsx.snap
@@ -7,10 +7,23 @@ exports[`HomeFeedDrawer - Snapshots > matches snapshot 1`] = `
data-testid="container"
>
- FilterReach
+
+ FilterReach
+
+
+ FilterProfileTags
+
matches snapshot 1`] = `
data-testid="container"
>
- FilterReach
+
+ FilterReach
+
+
+ FilterProfileTags
+
matches snapshot 1`] = `
data-testid="container"
>
- FilterReach
+
+ FilterReach
+
+
+ FilterProfileTags
+
state.currentUserPubky);
const { requireAuth } = useRequireAuth();
const isAuthenticated = Boolean(currentUserPubky);
- const effectiveReach = isAuthenticated ? reach : REACH.ALL;
+ const effectiveReach = isAuthenticated || reach === REACH.NETWORK ? reach : REACH.ALL;
+ const showProfileTags = effectiveReach !== REACH.ME;
const { isPhoneViewport, isVisualActive } = useFeedLayoutResolution(feedVariant);
- // "All" reach is public; "Following"/"Friends" require an account, so prompt Join Pubky in Explore mode.
+ // ALL and its temporary NETWORK alias are public. Personalized reaches require an account.
const handleReachChange = (value: ReachType) => {
- if (value === REACH.ALL) {
+ if (value === REACH.ALL || value === REACH.NETWORK) {
setReach(value);
return;
}
requireAuth(() => setReach(value));
};
+ const handleProfileTagAdd = (tag: string) => {
+ requireAuth(() => addProfileTag(tag));
+ };
const resolvedContent = resolveVisualFeedContent({
content,
variant: feedVariant,
@@ -59,7 +79,24 @@ function HomeFeedFilters({
return (
- {!hideReachFilter && }
+ {!hideReachFilter && (
+
+
+ requireAuth(() => undefined)}
+ />
+
+ )}
{variant === 'sidebar' ? (
diff --git a/src/components/organisms/HotFeedFilters/HotFeedFilters.tsx b/src/components/organisms/HotFeedFilters/HotFeedFilters.tsx
index 8efeef2f39..94d6208fc4 100644
--- a/src/components/organisms/HotFeedFilters/HotFeedFilters.tsx
+++ b/src/components/organisms/HotFeedFilters/HotFeedFilters.tsx
@@ -88,13 +88,13 @@ export function FilterTimeframe({ selectedTab = TIMEFRAME.THIS_MONTH, onTabChang
/**
* useGatedReachChange
*
- * "All" reach is public; "Following"/"Friends" require an account, so prompt
+ * "All" and its temporary "Network" alias are public; personalized reaches prompt
* Join Pubky in Explore mode (logged out) instead of changing the reach.
*/
function useGatedReachChange(setReach: (reach: ReachType) => void) {
const { requireAuth } = useRequireAuth();
return (value: ReachType) => {
- if (value === REACH.ALL) {
+ if (value === REACH.ALL || value === REACH.NETWORK) {
setReach(value);
return;
}
@@ -113,7 +113,7 @@ export function HotFeedSidebar() {
const { reach, setReach, timeframe, setTimeframe } = useHotStore();
const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
const isAuthenticated = Boolean(currentUserPubky);
- const effectiveReach = isAuthenticated ? reach : REACH.ALL;
+ const effectiveReach = isAuthenticated || reach === REACH.NETWORK ? reach : REACH.ALL;
const handleReachChange = useGatedReachChange(setReach);
@@ -137,7 +137,7 @@ export function HotFeedDrawer() {
const { reach, setReach, timeframe, setTimeframe } = useHotStore();
const currentUserPubky = useAuthStore((state) => state.currentUserPubky);
const isAuthenticated = Boolean(currentUserPubky);
- const effectiveReach = isAuthenticated ? reach : REACH.ALL;
+ const effectiveReach = isAuthenticated || reach === REACH.NETWORK ? reach : REACH.ALL;
const handleReachChange = useGatedReachChange(setReach);
diff --git a/src/components/organisms/PostHeader/PostHeader.test.tsx b/src/components/organisms/PostHeader/PostHeader.test.tsx
index 768d79428c..d07b1857ae 100644
--- a/src/components/organisms/PostHeader/PostHeader.test.tsx
+++ b/src/components/organisms/PostHeader/PostHeader.test.tsx
@@ -113,6 +113,12 @@ vi.mock('@/molecules/PostHeaderUserInfo/PostHeaderUserInfo', () => {
};
});
+vi.mock('@/organisms/AvatarWithFallback/AvatarWithFallback', () => ({
+ AvatarWithFallback: vi.fn(({ fallbackSeed, size, 'data-testid': dataTestId }) => (
+
+ )),
+}));
+
// Use real libs - use actual implementations
const mockUsePostDetails = vi.mocked(usePostDetails);
@@ -192,6 +198,18 @@ describe('PostHeader', () => {
expect(screen.getAllByRole('generic').some((el) => el.getAttribute('data-slot') === 'skeleton')).toBe(true);
});
+ it('renders only an avatar for the minimal new-post header state', () => {
+ mockUsePostDetails.mockReturnValue({ postDetails: null, isLoading: false });
+ mockUseUserDetails.mockReturnValue({ userDetails: null, isLoading: false });
+ mockUseAvatarUrl.mockReturnValue(undefined);
+
+ renderPostHeader( );
+
+ expect(screen.getByTestId('post-header-avatar')).toHaveAttribute('data-fallback-seed', 'userpubkykey');
+ expect(screen.queryByTestId('post-header-user-info')).not.toBeInTheDocument();
+ expect(screen.queryByText('Test User')).not.toBeInTheDocument();
+ });
+
it('passes size prop to PostHeaderUserInfo', () => {
mockUsePostDetails.mockReturnValue({
postDetails: {
diff --git a/src/components/organisms/PostHeader/PostHeader.tsx b/src/components/organisms/PostHeader/PostHeader.tsx
index cde265a9ef..00fa41e76f 100644
--- a/src/components/organisms/PostHeader/PostHeader.tsx
+++ b/src/components/organisms/PostHeader/PostHeader.tsx
@@ -7,6 +7,7 @@ import { useRelativeTime } from '@/hooks/useRelativeTime/useRelativeTime';
import { useUserDetails } from '@/hooks/useUserDetails/useUserDetails';
import { PostHeaderTimestamp } from '@/molecules/PostHeaderTimestamp/PostHeaderTimestamp';
import { PostHeaderUserInfo } from '@/molecules/PostHeaderUserInfo/PostHeaderUserInfo';
+import { AvatarWithFallback } from '@/organisms/AvatarWithFallback/AvatarWithFallback';
import { PostHeaderSkeleton } from './PostHeader.skeleton';
import type { PostHeaderProps } from './PostHeader.types';
@@ -17,6 +18,7 @@ export function PostHeader({
showPopover = true,
size = 'normal',
timeAgoPlacement = 'top-right',
+ avatarOnly = false,
}: PostHeaderProps) {
// Extract userId from postId (format: userId:postId or just userId if isReplyInput is true)
const userId = isReplyInput ? postId : postId.split(':')[0];
@@ -34,6 +36,18 @@ export function PostHeader({
const isLoading = !userDetails || (!isReplyInput && !postDetails);
+ if (avatarOnly) {
+ return (
+
+ );
+ }
+
if (isLoading) {
return ;
}
diff --git a/src/components/organisms/PostHeader/PostHeader.types.ts b/src/components/organisms/PostHeader/PostHeader.types.ts
index d2f04ca432..8879326d57 100644
--- a/src/components/organisms/PostHeader/PostHeader.types.ts
+++ b/src/components/organisms/PostHeader/PostHeader.types.ts
@@ -8,4 +8,5 @@ export interface PostHeaderProps {
showPopover?: boolean;
size?: 'normal' | 'large';
timeAgoPlacement?: 'top-right' | 'bottom-left';
+ avatarOnly?: boolean;
}
diff --git a/src/components/organisms/PostInput/PostInput.test.tsx b/src/components/organisms/PostInput/PostInput.test.tsx
index 7a19c8e2d1..79058a2fad 100644
--- a/src/components/organisms/PostInput/PostInput.test.tsx
+++ b/src/components/organisms/PostInput/PostInput.test.tsx
@@ -153,7 +153,7 @@ vi.mock('@/atoms/Typography/Typography', () => {
vi.mock('@/organisms/PostHeader/PostHeader', () => {
return {
- PostHeader: vi.fn(({ postId, isReplyInput, characterLimit, size }) => (
+ PostHeader: vi.fn(({ postId, isReplyInput, characterLimit, size, avatarOnly }) => (
{
data-count={characterLimit?.count}
data-max={characterLimit?.max}
data-size={size}
+ data-avatar-only={avatarOnly ? 'true' : 'false'}
/>
)),
};
@@ -540,10 +541,25 @@ describe('PostInput', () => {
render(
);
expect(screen.getByTestId('post-header')).toBeInTheDocument();
+ expect(screen.getByTestId('post-header')).toHaveAttribute('data-avatar-only', 'false');
expect(screen.getByTestId('textarea')).toBeInTheDocument();
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
});
+ it('uses the minimal avatar and prompt layout for a collapsed new post', () => {
+ mockUsePostInput.mockImplementationOnce((options: UsePostInputOptions) =>
+ createUsePostInputReturn(options, { isExpanded: false }),
+ );
+
+ render(
);
+
+ expect(screen.getByTestId('post-header')).toHaveAttribute('data-avatar-only', 'true');
+ expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
+ expect(
+ screen.getAllByTestId('container').find((container) => container.className.includes('contain-inline-size')),
+ ).toHaveClass('flex-row', 'items-center');
+ });
+
it('passes characterLimit to header and action bar for non-article mode', () => {
mockUsePostReturn.content = 'Hello world';
render(
);
@@ -912,7 +928,7 @@ describe('PostInput', () => {
render(
);
const outerContainer = screen.getAllByTestId('container')[0];
- expect(outerContainer.className).toContain('p-4');
+ expect(outerContainer.className).toContain('p-6');
expect(outerContainer.className).not.toContain('p-12');
const postHeader = screen.getByTestId('post-header');
@@ -930,7 +946,7 @@ describe('PostInput', () => {
const outerContainer = screen.getAllByTestId('container')[0];
expect(outerContainer.className).toContain('p-12');
- expect(outerContainer.className).not.toContain('p-4');
+ expect(outerContainer.className).not.toContain('p-6');
const postHeader = screen.getByTestId('post-header');
expect(postHeader).toHaveAttribute('data-size', 'large');
@@ -961,7 +977,7 @@ describe('PostInput', () => {
);
const outerContainer = screen.getAllByTestId('container')[0];
- expect(outerContainer.className).toContain('p-4');
+ expect(outerContainer.className).toContain('p-6');
expect(outerContainer.className).not.toContain('p-12');
const postHeader = screen.getByTestId('post-header');
diff --git a/src/components/organisms/PostInput/PostInput.test.tsx.snap b/src/components/organisms/PostInput/PostInput.test.tsx.snap
index 105552389e..97b47213d4 100644
--- a/src/components/organisms/PostInput/PostInput.test.tsx.snap
+++ b/src/components/organisms/PostInput/PostInput.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`PostInput - Mobile Snapshots > matches snapshot on mobile viewport 1`] = `