Skip to content

Commit 3b45808

Browse files
Merge pull request #38 from kmcfaul/core-tabs
feat(Tabs): add logic for component tabs & nav routing
2 parents fa04cce + bf21743 commit 3b45808

File tree

10 files changed

+257
-12
lines changed

10 files changed

+257
-12
lines changed

src/components/NavEntry.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { NavItem } from '@patternfly/react-core'
2+
import { kebabCase } from 'change-case'
23

34
export interface TextContentEntry {
45
id: string
56
data: {
67
id: string
78
section: string
9+
tab?: string
810
}
911
collection: string
1012
}
@@ -18,8 +20,17 @@ export const NavEntry = ({ entry, isActive }: NavEntryProps) => {
1820
const { id } = entry
1921
const { id: entryTitle, section } = entry.data
2022

23+
const _id =
24+
section === 'components' || section === 'layouts'
25+
? kebabCase(entryTitle)
26+
: id
2127
return (
22-
<NavItem itemId={id} to={`/${section}/${id}`} isActive={isActive} id={`nav-entry-${id}`}>
28+
<NavItem
29+
itemId={_id}
30+
to={`/${section}/${_id}`}
31+
isActive={isActive}
32+
id={`nav-entry-${_id}`}
33+
>
2334
{entryTitle}
2435
</NavItem>
2536
)

src/components/NavSection.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NavExpandable } from '@patternfly/react-core'
22
import { sentenceCase } from 'change-case'
33
import { NavEntry, type TextContentEntry } from './NavEntry'
4+
import { kebabCase } from 'change-case'
45

56
interface NavSectionProps {
67
entries: TextContentEntry[]
@@ -21,8 +22,30 @@ export const NavSection = ({
2122

2223
const isActive = sortedNavEntries.some((entry) => entry.id === activeItem)
2324

24-
const items = sortedNavEntries.map((entry) => (
25-
<NavEntry key={entry.id} entry={entry} isActive={activeItem === entry.id} />
25+
let navItems = sortedNavEntries
26+
if (sectionId === 'components' || sectionId === 'layouts') {
27+
// only display unique entry.data.id in the nav list if the section is components
28+
navItems = [
29+
...sortedNavEntries
30+
.reduce((map, entry) => {
31+
if (!map.has(entry.data.id)) {
32+
map.set(entry.data.id, entry)
33+
}
34+
return map
35+
}, new Map())
36+
.values(),
37+
]
38+
}
39+
40+
const items = navItems.map((entry) => (
41+
<NavEntry
42+
key={entry.id}
43+
entry={entry}
44+
isActive={
45+
activeItem === entry.id ||
46+
window.location.pathname.includes(kebabCase(entry.data.id))
47+
}
48+
/>
2649
))
2750

2851
return (

src/components/Navigation.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export const Navigation: React.FunctionComponent<NavigationProps> = ({
1313
const [activeItem, setActiveItem] = useState('')
1414

1515
useEffect(() => {
16-
setActiveItem(window.location.pathname.split('/').reverse()[0])
16+
// TODO: Needs an alternate solution because of /tab in the path
17+
setActiveItem(window.location.pathname.split('/').reverse()[0])
1718
}, [])
1819

1920
const onNavSelect = (

src/components/Page.astro

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
import styles from '@patternfly/react-styles/css/components/Page/page'
3-
import { Content, PageSection } from '@patternfly/react-core'
3+
import { PageSection } from '@patternfly/react-core'
44
---
55

66
<div class={styles.page}>
@@ -10,9 +10,7 @@ import { Content, PageSection } from '@patternfly/react-core'
1010
<div class={styles.pageMainContainer}>
1111
<main class={styles.pageMain}>
1212
<PageSection transition:animate="none">
13-
<Content>
14-
<slot />
15-
</Content>
13+
<slot />
1614
</PageSection>
1715
</main>
1816
</div>

src/components/__tests__/NavSection.test.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ const mockEntries: TextContentEntry[] = [
2020
},
2121
]
2222

23+
const dupedEntries: TextContentEntry[] = [
24+
{
25+
id: 'entry1',
26+
data: { id: 'Entry1', section: 'components' },
27+
collection: 'react',
28+
},
29+
{
30+
id: 'entry2',
31+
data: { id: 'Entry1', section: 'components' },
32+
collection: 'react',
33+
},
34+
{
35+
id: 'entry3',
36+
data: { id: 'Entry2', section: 'components' },
37+
collection: 'react',
38+
},
39+
]
40+
2341
it('renders without crashing', () => {
2442
render(
2543
<NavSection
@@ -141,3 +159,45 @@ it('matches snapshot', () => {
141159
)
142160
expect(asFragment()).toMatchSnapshot()
143161
})
162+
163+
it('dedupes and renders correct number of entries for components section', () => {
164+
Object.defineProperty(window, 'location', {
165+
value: {
166+
pathname: '/foo/components',
167+
},
168+
writable: true,
169+
})
170+
171+
render(
172+
<NavSection
173+
entries={dupedEntries}
174+
sectionId="components"
175+
activeItem="entry1"
176+
/>,
177+
)
178+
179+
expect(screen.getAllByRole('listitem')).toHaveLength(3)
180+
expect(screen.getByRole('link', { name: 'Entry1' })).toBeInTheDocument()
181+
expect(screen.getByRole('link', { name: 'Entry2' })).toBeInTheDocument()
182+
})
183+
184+
it('dedupes and renders correct number of entries for layouts section', () => {
185+
Object.defineProperty(window, 'location', {
186+
value: {
187+
pathname: '/foo/layouts',
188+
},
189+
writable: true,
190+
})
191+
192+
render(
193+
<NavSection
194+
entries={dupedEntries}
195+
sectionId="layouts"
196+
activeItem="entry1"
197+
/>,
198+
)
199+
200+
expect(screen.getAllByRole('listitem')).toHaveLength(3)
201+
expect(screen.getByRole('link', { name: 'Entry1' })).toBeInTheDocument()
202+
expect(screen.getByRole('link', { name: 'Entry2' })).toBeInTheDocument()
203+
})

src/content.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,20 @@ function defineContent(contentObj: CollectionDefinition) {
1414
return
1515
}
1616

17+
// TODO: Expand for other packages that remain under the react umbrella (Table, CodeEditor, etc)
18+
const tabMap: any = {
19+
'react-component-docs': 'react',
20+
'core-component-docs': 'html'
21+
};
22+
1723
return defineCollection({
1824
loader: glob({ base: dir, pattern }),
1925
schema: z.object({
2026
id: z.string(),
2127
section: z.string(),
28+
subsection: z.string().optional(),
2229
title: z.string().optional(),
30+
tab: z.string().optional().default(tabMap[name])
2331
}),
2432
})
2533
}

src/content.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,14 @@
1-
export const content = [{ base: 'textContent', pattern: '*.md', name: "textContent" }, { base: 'dir', pattern: '*.md', name: "dir" }]
1+
export const content = [
2+
{ base: 'textContent', pattern: '*.md', name: "textContent" }
3+
// TODO: Remove. Uncomment for local testing.
4+
// {
5+
// "packageName":"@patternfly/react-core",
6+
// "pattern":"**/examples/**/*.md", // had to update this pattern to bring in demos docs
7+
// "name":"react-component-docs"
8+
// },
9+
// {
10+
// "packageName":"@patternfly/patternfly",
11+
// "pattern":"**/examples/**/*.md", // had to update this pattern to bring in demos docs
12+
// "name":"core-component-docs"
13+
// }
14+
]

src/globals.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export const componentTabs: any = {};
2+
3+
export const tabNames: any = {
4+
'react': 'React',
5+
'react-demos': 'React demos',
6+
'react-deprecated': 'React deprecated',
7+
'html': 'HTML',
8+
'html-demos': 'HTML demos'
9+
};
10+
11+
export const buildTab = (entry: any, tab: string) => {
12+
const tabEntry = componentTabs[entry.data.id]
13+
14+
// if no dictionary entry exists, and tab data exists
15+
if(tabEntry === undefined && tab) {
16+
componentTabs[entry.data.id] = [tab]
17+
// if dictionary entry & tab data exists, and entry does not include tab
18+
} else if (tabEntry && tab && !tabEntry.includes(tab)) {
19+
componentTabs[entry.data.id] = [...tabEntry, tab];
20+
}
21+
}
22+
23+
export const sortTabs = () => {
24+
const defaultOrder = 50;
25+
const sourceOrder: any = {
26+
react: 1,
27+
'react-next': 1.1,
28+
'react-demos': 2,
29+
'react-deprecated': 2.1,
30+
html: 3,
31+
'html-demos': 4,
32+
'design-guidelines': 99,
33+
'accessibility': 100,
34+
'upgrade-guide': 101,
35+
'release-notes': 102,
36+
};
37+
38+
const sortSources = (s1: string, s2: string) => {
39+
const s1Index = sourceOrder[s1] || defaultOrder;
40+
const s2Index = sourceOrder[s2] || defaultOrder;
41+
if (s1Index === defaultOrder && s2Index === defaultOrder) {
42+
return s1.localeCompare(s2);
43+
}
44+
45+
return s1Index > s2Index ? 1 : -1;
46+
}
47+
48+
// Sort tabs entries based on above sort order
49+
// Ensures all tabs are displayed in a consistent order & which tab gets displayed for a component route without a tab
50+
Object.values(componentTabs).map((tabs: any) => {
51+
tabs.sort(sortSources)
52+
})
53+
}
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,34 @@ import { getCollection, render } from 'astro:content'
33
import { Title } from '@patternfly/react-core'
44
import MainLayout from '../../layouts/Main.astro'
55
import { content } from "../../content"
6+
import { kebabCase } from 'change-case'
7+
import { componentTabs } from '../../globals'
68
79
export async function getStaticPaths() {
810
const collections = await Promise.all(content.map(async (entry) => await getCollection(entry.name as 'textContent')))
911
1012
return collections.flat().map((entry) => ({
11-
params: { id: entry.id, section: entry.data.section },
13+
params: { page: kebabCase(entry.data.id), section: entry.data.section },
1214
props: { entry, title: entry.data.title },
1315
})
1416
)
1517
}
1618
19+
1720
const { entry } = Astro.props
18-
const { title } = entry.data
21+
const { title, id, section } = entry.data
1922
const { Content } = await render(entry)
23+
24+
if(section === 'components') { // if section is components, rewrite to first tab content
25+
return Astro.rewrite(`/components/${kebabCase(id)}/${componentTabs[id][0]}`);
26+
}
2027
---
2128

2229
<MainLayout>
2330
{
2431
title && (
2532
<Title headingLevel="h1" size="4xl">
26-
{title}
33+
{title}
2734
</Title>
2835
)
2936
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
import { getCollection, render } from 'astro:content'
3+
import { Title, PageSection, Content as PFContent } from '@patternfly/react-core'
4+
import MainLayout from '../../../layouts/Main.astro'
5+
import { content } from "../../../content"
6+
import { kebabCase } from 'change-case'
7+
import { componentTabs, tabNames, buildTab, sortTabs } from '../../../globals';
8+
9+
export async function getStaticPaths() {
10+
const collections = await Promise.all(content.map(async (entry) => await getCollection(entry.name as 'textContent')))
11+
12+
const flatCol = collections.flat().map((entry) => {
13+
// Build tabs dictionary
14+
let tab = entry.data.tab;
15+
if(tab) { // check for demos/deprecated
16+
if(entry.id.includes('demos')) {
17+
tab = `${tab}-demos`;
18+
} else if (entry.id.includes('deprecated')) {
19+
tab = `${tab}-deprecated`;
20+
}
21+
}
22+
buildTab(entry, tab);
23+
24+
return {
25+
params: { page: kebabCase(entry.data.id), section: entry.data.section, tab },
26+
props: { entry, ...entry.data },
27+
}
28+
})
29+
30+
sortTabs()
31+
return flatCol;
32+
}
33+
34+
const { entry } = Astro.props
35+
const { title, id, section } = entry.data
36+
const { Content } = await render(entry)
37+
const currentPath = Astro.url.pathname;
38+
---
39+
40+
<MainLayout>
41+
{
42+
title && (
43+
<Title headingLevel="h1" size="4xl">
44+
{title}
45+
</Title>
46+
)
47+
}
48+
{componentTabs[id] && (
49+
<PageSection id="ws-sticky-nav-tabs" stickyOnBreakpoint={{ default: 'top' }} type="tabs">
50+
<div class="pf-v6-c-tabs pf-m-page-insets pf-m-no-border-bottom">
51+
<ul class="pf-v6-c-tabs__list">
52+
{componentTabs[id].map((tab: string) => (
53+
// eslint-disable-next-line react/jsx-key
54+
<li
55+
class={`pf-v6-c-tabs__item${currentPath === `/${section}/${kebabCase(id)}/${tab}` ? ' pf-m-current' : ''}`}
56+
>
57+
<a class="pf-v6-c-tabs__link" href={`/${section}/${kebabCase(id)}/${tab}`}>
58+
{tabNames[tab]}
59+
</a>
60+
</li>
61+
))}
62+
</ul>
63+
</div>
64+
</PageSection>
65+
)}
66+
<PageSection id="main-content" isFilled>
67+
<PFContent>
68+
<Content />
69+
</PFContent>
70+
</PageSection>
71+
</MainLayout>

0 commit comments

Comments
 (0)