diff --git a/app/components/GameCard.jsx b/app/components/GameCard.jsx
index 5bcd33c7..1b720bd1 100644
--- a/app/components/GameCard.jsx
+++ b/app/components/GameCard.jsx
@@ -8,13 +8,13 @@ import {
Text,
Heading,
Skeleton,
- Tag,
} from '@chakra-ui/react';
import { LuTrash2 } from 'react-icons/lu';
import usePlaceholder from '../hooks/usePlaceholder';
import Card from './Card';
import CardLink from './CardLink';
+import TagList from './TagList';
export const GameCardSkeleton = () => (
@@ -144,27 +144,7 @@ function GameCard({
{name}
- {tags && (
-
- {tags.map((tag) => (
-
- {tag.name}
-
- ))}
-
- )}
+ {tags?.length > 0 && }
);
}
diff --git a/app/components/TagList.jsx b/app/components/TagList.jsx
new file mode 100644
index 00000000..02b46530
--- /dev/null
+++ b/app/components/TagList.jsx
@@ -0,0 +1,42 @@
+import { Flex, Tag } from '@chakra-ui/react';
+import { Link } from 'react-router';
+
+/**
+ * A game's tags as a list, so screen readers, copy-paste and crawlers get
+ * separate items instead of one run-on word (#201). With `linked`, each
+ * tag links to the games list filtered by it.
+ */
+const TagList = ({ tags, size = 'sm', fontSize = '0.6rem', linked = false, ...rest }) => (
+
+ {tags.map((tag) => {
+ const chip = (
+
+ {tag.name}
+
+ );
+ return (
+
+ {linked ? {chip} : chip}
+
+ );
+ })}
+
+);
+
+export default TagList;
diff --git a/app/routes/game.$id.jsx b/app/routes/game.$id.jsx
index 1e7b111a..a6dfe54c 100644
--- a/app/routes/game.$id.jsx
+++ b/app/routes/game.$id.jsx
@@ -8,12 +8,11 @@ import {
Button,
useDisclosure,
IconButton,
- Tag,
- Link as ChakraLink,
List,
Flex,
Dialog,
Portal,
+ Link as ChakraLink,
} from '@chakra-ui/react';
import { LuPlus, LuPencil, LuExternalLink } from 'react-icons/lu';
@@ -37,6 +36,7 @@ import Markdown from '../components/Markdown';
import MotionGallery from '../components/MotionGallery';
import ActionMenu from '../components/ActionMenu';
import { ogUrl } from '../utils/meta';
+import TagList from '../components/TagList';
const uuidRegex =
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
@@ -211,29 +211,8 @@ const Game = () => {
/>
- {tags && (
-
- {tags.map((tag) => (
-
-
- {tag.name}
-
-
- ))}
-
+ {tags?.length > 0 && (
+
)}
{about && (
diff --git a/e2e/games.spec.js b/e2e/games.spec.js
index 2e4a6556..b01eb410 100644
--- a/e2e/games.spec.js
+++ b/e2e/games.spec.js
@@ -42,3 +42,18 @@ test('the games tag filter works with the keyboard', async ({ page }) => {
await expect(tag).toHaveAttribute('aria-pressed', 'false');
await expect(page).not.toHaveURL(/tags=/);
});
+
+// #201: tags were bare chips in a
, so text tools read "soloplatformaction".
+test('game tags are lists of separate items', async ({ page }) => {
+ await page.goto('/games');
+ const tagList = page.locator('main').getByRole('list', { name: 'Tags' }).first();
+ await expect(tagList).toBeVisible();
+ const names = await tagList.getByRole('listitem').allInnerTexts();
+ expect(names.length).toBeGreaterThan(1);
+ const text = await tagList.innerText();
+ for (const name of names) expect(text.split(/\s*\n\s*/)).toContain(name.trim());
+
+ await tagList.locator('xpath=ancestor::*[.//a[starts-with(@href, "/game/")]][1]').locator('a[href^="/game/"]').first().click();
+ // The game's own tags (related game cards below have their own lists).
+ await expect(page.locator('main').getByRole('list', { name: 'Tags' }).first()).toBeVisible();
+});