Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
37beefd
feat: add TopicTag component
joshblack Nov 10, 2025
6c422b9
test: update snapshots
joshblack Nov 10, 2025
f98b59d
test: update snapshots size
joshblack Nov 10, 2025
115040a
test: update snapshots size
joshblack Nov 10, 2025
3534681
test: add focus snapshots
joshblack Nov 10, 2025
81ecae0
chore: use rounded line-height
joshblack Nov 10, 2025
d74d504
chore: update styles, screenshots
joshblack Nov 10, 2025
77f9e30
feat: add TopicTagGroup
joshblack Nov 10, 2025
eb58ced
chore: clean up types for TopicTagGroup
joshblack Nov 10, 2025
13862cb
chore: fix stylelint errors
joshblack Nov 10, 2025
0b8d16a
Merge branch 'main' of github.com:primer/react into feat/add-topic-ta…
joshblack Nov 20, 2025
c41826c
refactor: set <a> as the default element type
joshblack Nov 20, 2025
bc395e3
chore: add changeset
joshblack Nov 20, 2025
95162f3
docs: update as group story example
joshblack Nov 20, 2025
1a048f1
refactor: update :where style to be scoped
joshblack Nov 20, 2025
74b40ee
Merge branch 'main' into feat/add-topic-tag-component
hectahertz Nov 21, 2025
8da6b00
Merge branch 'main' of github.com:primer/react into feat/add-topic-ta…
joshblack Nov 21, 2025
11ba1d6
refactor: update examples, add button reset styles
joshblack Nov 21, 2025
d727f1d
refactor: create buttonReset helper mixin
joshblack Nov 21, 2025
80688be
test(vrt): update snapshots
joshblack Nov 21, 2025
7a751ad
chore: update snapshots
joshblack Nov 21, 2025
2cb5652
test: fix failures from link changes
joshblack Nov 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/three-wombats-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Add experimental TopicTag and TopicTag.Group components
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions e2e/components/TopicTag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {test, expect} from '@playwright/test'
import {visit} from '../test-helpers/storybook'
import {themes} from '../test-helpers/themes'
import {viewports} from '../test-helpers/viewports'

const stories = [
{
title: 'Default',
id: 'experimental-components-topictag--default',
},
] as const

test.describe('TopicTag', () => {
for (const story of stories) {
test.describe(story.title, () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: story.id,
globals: {
colorScheme: theme,
},
})
await page.setViewportSize({width: 400, height: 200})

// Default state
await expect(page).toHaveScreenshot(`TopicTag.${story.title}.${theme}.png`)

// Hover state
await page.getByText('React').hover()
await expect(page).toHaveScreenshot(`TopicTag.${story.title}.${theme}.hover.png`)

// Focus state
// eslint-disable-next-line github/no-blur
await page.getByText('React').blur()
await page.getByText('React').focus()
await expect(page).toHaveScreenshot(`TopicTag.${story.title}.${theme}.focus.png`)
})
})
}
})
}

test.describe('As Group', () => {
const story = {
title: 'As Group',
id: 'experimental-components-topictag-features--as-group',
}

test('default @vrt', async ({page}) => {
await visit(page, {
id: story.id,
})

// Viewport: xs
await page.setViewportSize({width: viewports['primer.breakpoint.xs'], height: 500})
await expect(page).toHaveScreenshot(`TopicTag.${story.title}.xs.png`)

// Viewport: sm
await page.setViewportSize({width: viewports['primer.breakpoint.sm'], height: 500})
await expect(page).toHaveScreenshot(`TopicTag.${story.title}.sm.png`)

// Viewport: md
await page.setViewportSize({width: viewports['primer.breakpoint.md'], height: 500})
await expect(page).toHaveScreenshot(`TopicTag.${story.title}.md.png`)
})
})
})
17 changes: 17 additions & 0 deletions packages/postcss-preset-primer/src/mixins/buttonReset.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@define-mixin buttonReset {
margin: 0;
display: inline-flex;
padding: 0;
border: 0;
appearance: none;
background: none;
cursor: pointer;
text-align: start;
font: inherit;
color: inherit;
align-items: center;

&::-moz-focus-inner {
border: 0;
}
}
32 changes: 32 additions & 0 deletions packages/react/src/TopicTag/TopicTag.docs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"id": "topic_tag",
"name": "TopicTag",
"status": "draft",
"a11yReviewed": false,
"stories": [],
"importPath": "@primer/react/experimental",
"props": [
{
"name": "as",
"type": "React.ElementType",
"description": "The HTML element or React component to render as the root element"
},
{
"name": "className",
"type": "string",
"description": "Provide a class name for styling on the outermost element"
}
],
"subcomponents": [
{
"name": "TopicTag.Group",
"props": [
{
"name": "className",
"type": "string",
"description": "Provide a class name for styling on the outermost element"
}
]
}
]
}
35 changes: 35 additions & 0 deletions packages/react/src/TopicTag/TopicTag.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type {Meta} from '@storybook/react-vite'
import {TopicTag} from './TopicTag'
import {TopicTagGroup} from './TopicTagGroup'

export default {
title: 'Experimental/Components/TopicTag/Features',
component: TopicTag,
} satisfies Meta<typeof TopicTag>

export const AsButton = () => <TopicTag as="button">react</TopicTag>

export const AsGroup = () => {
const tags = [
'react',
'nodejs',
'javascript',
'd3',
'teachers',
'community',
'education',
'programming',
'curriculum',
'math',
]

return (
<TopicTagGroup>
{tags.map(tag => (
<TopicTag key={tag} href={`/topics/${tag}`}>
{tag}
</TopicTag>
))}
</TopicTagGroup>
)
}
32 changes: 32 additions & 0 deletions packages/react/src/TopicTag/TopicTag.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* Add a reset for when TopicTag is an <a> element since our link styles apply an underline text-decoration by default */
.TopicTag:where(a) {
text-decoration: none;
}

/* Add a reset for when TopicTag is a <button> element */
.TopicTag:where(button) {
@mixin buttonReset;
}

.TopicTag {
background-color: var(--bgColor-accent-muted);
color: var(--fgColor-accent);
font-size: var(--text-body-size-small);
font-weight: var(--base-text-weight-semibold);
line-height: var(--text-body-lineHeight-small);
border-radius: var(--borderRadius-full);
padding: var(--base-size-2) var(--base-size-12);
border: var(--borderWidth-thin) solid var(--topicTag-borderColor, transparent);
display: inline-flex;
white-space: nowrap;

&:hover {
background-color: var(--bgColor-accent-emphasis);
color: var(--fgColor-onEmphasis);
}

&:where(a, button) {
cursor: pointer;
user-select: none;
}
}
13 changes: 13 additions & 0 deletions packages/react/src/TopicTag/TopicTag.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type {Meta, StoryObj} from '@storybook/react-vite'
import {TopicTag} from './TopicTag'

export default {
title: 'Experimental/Components/TopicTag',
component: TopicTag,
} satisfies Meta<typeof TopicTag>

export const Default = () => <TopicTag>react</TopicTag>

export const Playground: StoryObj<typeof TopicTag> = {
render: args => <TopicTag {...args}>react</TopicTag>,
}
39 changes: 39 additions & 0 deletions packages/react/src/TopicTag/TopicTag.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {render, screen} from '@testing-library/react'
import {describe, test, expect, vi} from 'vitest'
import {userEvent} from 'vitest/browser'
import {TopicTag} from '../TopicTag'

describe('TopicTag', () => {
test('defaults to <a> semantics', async () => {
render(<TopicTag href="#">test</TopicTag>)

expect(screen.getByRole('link', {name: 'test'})).toBeInTheDocument()
})

test('support <button> semantics through `as` prop', async () => {
const onClick = vi.fn()
render(
<TopicTag as="button" onClick={onClick}>
test
</TopicTag>,
)

await userEvent.click(screen.getByRole('button', {name: 'test'}))
expect(onClick).toHaveBeenCalled()
})

test('supports `className` merging', () => {
const {container} = render(<TopicTag className="custom-class">test</TopicTag>)
expect(container.firstChild).toHaveClass('custom-class')
})

test('additional props are applied to outermost element', () => {
const {container} = render(
<TopicTag data-testid="test" id="test-id">
test
</TopicTag>,
)
expect(container.firstChild).toHaveAttribute('data-testid', 'test')
expect(container.firstChild).toHaveAttribute('id', 'test-id')
})
})
27 changes: 27 additions & 0 deletions packages/react/src/TopicTag/TopicTag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {clsx} from 'clsx'
import type {ElementType} from 'react'
import classes from './TopicTag.module.css'

type TopicTagProps<As extends ElementType> = {
/**
* The HTML element or React component to render as the root element
*/
as?: As

/**
* Provide a class name for styling on the outermost element
*/
className?: string
} & Omit<React.ComponentPropsWithoutRef<As>, 'as' | 'className'>

function TopicTag<As extends ElementType = 'a'>({as, children, className, ...rest}: TopicTagProps<As>) {
const BaseComponent = as ?? 'a'
return (
<BaseComponent {...rest} className={clsx(className, classes.TopicTag)}>
{children}
</BaseComponent>
)
}

export {TopicTag}
export type {TopicTagProps}
6 changes: 6 additions & 0 deletions packages/react/src/TopicTag/TopicTagGroup.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.TopicTagGroup {
display: flex;
flex-wrap: wrap;
column-gap: var(--base-size-2);
row-gap: var(--base-size-8);
}
20 changes: 20 additions & 0 deletions packages/react/src/TopicTag/TopicTagGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {render} from '@testing-library/react'
import {describe, test, expect} from 'vitest'
import {TopicTagGroup} from './TopicTagGroup'

describe('TopicTagGroup', () => {
test('supports `className` merging', () => {
const {container} = render(<TopicTagGroup className="custom-class">test</TopicTagGroup>)
expect(container.firstChild).toHaveClass('custom-class')
})

test('additional props are applied to outermost element', () => {
const {container} = render(
<TopicTagGroup data-testid="test" id="test-id">
test
</TopicTagGroup>,
)
expect(container.firstChild).toHaveAttribute('data-testid', 'test')
expect(container.firstChild).toHaveAttribute('id', 'test-id')
})
})
16 changes: 16 additions & 0 deletions packages/react/src/TopicTag/TopicTagGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {clsx} from 'clsx'
import type React from 'react'
import classes from './TopicTagGroup.module.css'

type TopicTagGroupProps = React.HTMLAttributes<HTMLElement>

function TopicTagGroup({children, className, ...rest}: TopicTagGroupProps) {
return (
<div {...rest} className={clsx(className, classes.TopicTagGroup)}>
{children}
</div>
)
}

export {TopicTagGroup}
export type {TopicTagGroupProps}
11 changes: 11 additions & 0 deletions packages/react/src/TopicTag/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {TopicTag as TopicTagImpl} from './TopicTag'
import type {TopicTagProps} from './TopicTag'
import {TopicTagGroup} from './TopicTagGroup'
import type {TopicTagGroupProps} from './TopicTagGroup'

const TopicTag = Object.assign(TopicTagImpl, {
Group: TopicTagGroup,
})

export {TopicTag}
export type {TopicTagProps, TopicTagGroupProps}
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ exports[`@primer/react/experimental > should not update exports without a semver
"type TitleProps",
"Tooltip",
"type TooltipProps",
"TopicTag",
"type TopicTagGroupProps",
"type TopicTagProps",
"UnderlinePanels",
"type UnderlinePanelsPanelProps",
"type UnderlinePanelsProps",
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/experimental/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ export type {IssueLabelProps} from './IssueLabel'

export * from '../KeybindingHint'
export * from './Tabs'

export {TopicTag} from '../TopicTag'
export type {TopicTagProps, TopicTagGroupProps} from '../TopicTag'
Loading