Skip to content

Commit 1d51b04

Browse files
committed
feat(devtools): implement accordion-style links preview in SEO tab
This commit enhances the LinksPreviewSection by introducing an accordion-style layout for displaying links, allowing users to expand and collapse groups of links categorized by type (internal, external, non-web, invalid). It adds new styles for the accordion components, improving the visual organization of link reports. Additionally, it refactors the existing link rendering logic to accommodate the new structure, enhancing user experience and accessibility in the SEO analysis features.
1 parent 13e48a8 commit 1d51b04

2 files changed

Lines changed: 179 additions & 40 deletions

File tree

packages/devtools/src/styles/use-styles.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -488,14 +488,6 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
488488
background: ${t(colors.red[50], '#dc262618')};
489489
color: ${t(colors.red[700], '#dc2626')};
490490
`,
491-
seoLinksReportList: css`
492-
margin: 0;
493-
padding: 0;
494-
list-style: none;
495-
display: flex;
496-
flex-direction: column;
497-
gap: 0;
498-
`,
499491
seoLinksReportItem: css`
500492
padding: 8px 0;
501493
border-bottom: 1px solid ${t(colors.gray[200], colors.gray[800])};
@@ -552,6 +544,67 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
552544
text-overflow: ellipsis;
553545
padding-left: 2px;
554546
`,
547+
seoLinksAccordion: css`
548+
display: flex;
549+
flex-direction: column;
550+
gap: 8px;
551+
margin: 0;
552+
padding: 0;
553+
list-style: none;
554+
`,
555+
seoLinksAccordionSection: css`
556+
border: 1px solid ${t(colors.gray[200], colors.gray[800])};
557+
border-radius: 8px;
558+
overflow: hidden;
559+
background: ${t(colors.white, colors.darkGray[900])};
560+
`,
561+
seoLinksAccordionTrigger: css`
562+
display: flex;
563+
align-items: center;
564+
justify-content: space-between;
565+
width: 100%;
566+
gap: 10px;
567+
padding: 8px 10px;
568+
border: none;
569+
background: ${t(colors.gray[50], colors.darkGray[800])};
570+
cursor: pointer;
571+
font-family: inherit;
572+
text-align: left;
573+
color: ${t(colors.gray[900], colors.gray[100])};
574+
font-size: 12px;
575+
font-weight: 600;
576+
&:hover {
577+
background: ${t(colors.gray[100], colors.darkGray[700])};
578+
}
579+
`,
580+
seoLinksAccordionTriggerLeft: css`
581+
display: flex;
582+
align-items: center;
583+
gap: 8px;
584+
min-width: 0;
585+
`,
586+
seoLinksAccordionChevron: css`
587+
flex-shrink: 0;
588+
font-size: 10px;
589+
line-height: 1;
590+
color: ${t(colors.gray[500], colors.gray[500])};
591+
transition: transform 0.15s ease;
592+
`,
593+
seoLinksAccordionChevronOpen: css`
594+
transform: rotate(90deg);
595+
`,
596+
seoLinksAccordionPanel: css`
597+
border-top: 1px solid ${t(colors.gray[200], colors.gray[800])};
598+
padding: 2px 10px 6px;
599+
`,
600+
seoLinksAccordionInnerList: css`
601+
margin: 0;
602+
padding: 0;
603+
list-style: none;
604+
display: flex;
605+
flex-direction: column;
606+
gap: 0;
607+
`,
555608
seoHealthHeaderRow: css`
556609
display: flex;
557610
justify-content: space-between;

packages/devtools/src/tabs/seo-tab/links-preview.tsx

Lines changed: 118 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { For, Show } from 'solid-js'
1+
import { For, Show, createSignal } from 'solid-js'
22
import { Section, SectionDescription } from '@tanstack/devtools-ui'
33
import { useStyles } from '../../styles/use-styles'
44
import { pickSeverityClass, type SeoSeverity } from './seo-severity'
@@ -129,6 +129,32 @@ export function sortLinksForDisplay(links: Array<LinkRow>): Array<LinkRow> {
129129
)
130130
}
131131

132+
const LINK_KIND_GROUPS: Array<LinkKind> = [
133+
'internal',
134+
'external',
135+
'non-web',
136+
'invalid',
137+
]
138+
139+
function groupLinksByKindOrdered(
140+
links: Array<LinkRow>,
141+
): Array<{ kind: LinkKind; items: Array<LinkRow> }> {
142+
const buckets = new Map<LinkKind, Array<LinkRow>>()
143+
for (const k of LINK_KIND_GROUPS) buckets.set(k, [])
144+
for (const row of links) buckets.get(row.kind)!.push(row)
145+
return LINK_KIND_GROUPS.filter((k) => buckets.get(k)!.length > 0).map(
146+
(kind) => ({ kind, items: buckets.get(kind)! }),
147+
)
148+
}
149+
150+
function linksAccordionTriggerId(kind: LinkKind): string {
151+
return `seo-links-accordion-trigger-${kind}`
152+
}
153+
154+
function linksAccordionPanelId(kind: LinkKind): string {
155+
return `seo-links-accordion-panel-${kind}`
156+
}
157+
132158
/**
133159
* Link-level issues (capped) and totals for the SEO overview.
134160
*/
@@ -169,6 +195,10 @@ export function LinksPreviewSection() {
169195
const styles = useStyles()
170196
const links = analyzeLinks()
171197
const linksForReport = sortLinksForDisplay(links)
198+
const groups = groupLinksByKindOrdered(linksForReport)
199+
const [openKinds, setOpenKinds] = createSignal<Set<LinkKind>>(
200+
new Set(groups.map((g) => g.kind)),
201+
)
172202
const issueCount = links.reduce((count, row) => count + row.issues.length, 0)
173203

174204
const counts = links.reduce(
@@ -187,6 +217,15 @@ export function LinksPreviewSection() {
187217
info: s.seoIssueBulletInfo,
188218
})
189219

220+
function toggleKind(kind: LinkKind) {
221+
setOpenKinds((prev) => {
222+
const next = new Set(prev)
223+
if (next.has(kind)) next.delete(kind)
224+
else next.add(kind)
225+
return next
226+
})
227+
}
228+
190229
return (
191230
<Section>
192231
<SectionDescription>
@@ -230,37 +269,84 @@ export function LinksPreviewSection() {
230269
>
231270
<div class={s.serpPreviewBlock}>
232271
<div class={s.serpPreviewLabel}>Links report</div>
233-
<ul class={s.seoLinksReportList}>
234-
<For each={linksForReport}>
235-
{(row) => (
236-
<li class={s.seoLinksReportItem}>
237-
<div class={s.seoLinksReportTopRow}>
238-
<span class={linkKindBadgeClass(s, row.kind)}>
239-
{KIND_LABEL[row.kind]}
240-
</span>
241-
<span class={s.seoLinksAnchorText}>
242-
{row.text || '(no text)'}
243-
</span>
244-
</div>
245-
<div class={s.seoLinksHrefLine}>{row.resolvedHref || row.href}</div>
246-
<Show when={row.issues.length > 0}>
247-
<ul class={s.seoIssueListNested}>
248-
<For each={row.issues}>
249-
{(issue) => (
250-
<li class={s.seoIssueRowCompact}>
251-
<span
252-
class={`${s.seoIssueBullet} ${bulletSev(issue.severity)}`}
253-
>
254-
255-
</span>
256-
<span class={s.seoIssueMessage}>{issue.message}</span>
257-
</li>
258-
)}
259-
</For>
260-
</ul>
261-
</Show>
262-
</li>
263-
)}
272+
<ul class={s.seoLinksAccordion}>
273+
<For each={groups}>
274+
{(group) => {
275+
const expanded = () => openKinds().has(group.kind)
276+
return (
277+
<li class={s.seoLinksAccordionSection}>
278+
<button
279+
type="button"
280+
class={s.seoLinksAccordionTrigger}
281+
aria-expanded={expanded()}
282+
aria-controls={linksAccordionPanelId(group.kind)}
283+
id={linksAccordionTriggerId(group.kind)}
284+
onClick={() => toggleKind(group.kind)}
285+
>
286+
<span class={s.seoLinksAccordionTriggerLeft}>
287+
<span class={linkKindBadgeClass(s, group.kind)}>
288+
{KIND_LABEL[group.kind]}
289+
</span>
290+
<span class={s.seoHealthLabelMuted}>
291+
{group.items.length} link{group.items.length === 1 ? '' : 's'}
292+
</span>
293+
</span>
294+
<span
295+
class={`${s.seoLinksAccordionChevron} ${expanded() ? s.seoLinksAccordionChevronOpen : ''}`}
296+
aria-hidden="true"
297+
>
298+
299+
</span>
300+
</button>
301+
<Show when={expanded()}>
302+
<div
303+
class={s.seoLinksAccordionPanel}
304+
id={linksAccordionPanelId(group.kind)}
305+
role="region"
306+
aria-labelledby={linksAccordionTriggerId(group.kind)}
307+
>
308+
<ul class={s.seoLinksAccordionInnerList}>
309+
<For each={group.items}>
310+
{(row) => (
311+
<li class={s.seoLinksReportItem}>
312+
<div class={s.seoLinksReportTopRow}>
313+
<span class={linkKindBadgeClass(s, row.kind)}>
314+
{KIND_LABEL[row.kind]}
315+
</span>
316+
<span class={s.seoLinksAnchorText}>
317+
{row.text || '(no text)'}
318+
</span>
319+
</div>
320+
<div class={s.seoLinksHrefLine}>
321+
{row.resolvedHref || row.href}
322+
</div>
323+
<Show when={row.issues.length > 0}>
324+
<ul class={s.seoIssueListNested}>
325+
<For each={row.issues}>
326+
{(issue) => (
327+
<li class={s.seoIssueRowCompact}>
328+
<span
329+
class={`${s.seoIssueBullet} ${bulletSev(issue.severity)}`}
330+
>
331+
332+
</span>
333+
<span class={s.seoIssueMessage}>
334+
{issue.message}
335+
</span>
336+
</li>
337+
)}
338+
</For>
339+
</ul>
340+
</Show>
341+
</li>
342+
)}
343+
</For>
344+
</ul>
345+
</div>
346+
</Show>
347+
</li>
348+
)
349+
}}
264350
</For>
265351
</ul>
266352
</div>

0 commit comments

Comments
 (0)