Skip to content

Commit aa91f6d

Browse files
committed
Finish styling on technologies and skills component
1 parent 5017664 commit aa91f6d

11 files changed

Lines changed: 575 additions & 30 deletions

File tree

_TODO.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,8 @@ This article has different approaches to [print pagination](https://www.customjs
269269
- cover.jpg for reliability-and-testing needs touch up in GIMP
270270
- We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag lik "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag and add the name to the tags: frontmatter key in the index.mdx
271271
- Need an article on OpenStack
272+
273+
## focus-visible
274+
275+
- yellow outline on "Let's talk ->" in backstage component is rounded like the button
276+
- yellow outlines on icon cards in skills + technologies are clipped at the top, and rounded on the play/pause button

src/components/Breadcrumbs/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const breadcrumbSchema =
3838
breadcrumbs.length > 1 && (
3939
<div id="breadcrumbs" class="mt-4">
4040
<nav aria-label="Breadcrumbs" class="breadcrumbs sm:pl-4">
41-
<ol class="flex items-center space-x-2 text-sm sm:text-md text-primary-offset">
41+
<ol class="flex items-center space-x-2 text-sm sm:text-base text-primary-offset">
4242
{breadcrumbs.map((item, index) => (
4343
<li class="flex items-center">
4444
{index > 0 && (

src/components/Home/Backstage/index.astro

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const visibleFeatures = features.slice(0, 4)
3737
<div class="grid grid-cols-1 lg:grid-cols-5 gap-8">
3838
<!-- Left column: headline + image -->
3939
<div class="lg:col-span-2 space-y-4">
40-
<p class="text-md font-bold text-primary uppercase tracking-widest">{pretitle}</p>
40+
<p class="font-bold text-primary uppercase tracking-widest">{pretitle}</p>
4141
<h2 id="backstage-title" class="text-3xl md:text-4xl font-black text-content leading-tight">
4242
Give your developers <span class="text-primary">superpowers</span>
4343
</h2>
@@ -95,7 +95,10 @@ const visibleFeatures = features.slice(0, 4)
9595
<!-- CTA -->
9696
<div class="bg-primary/5 border border-primary/20 rounded-xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
9797
<p class="text-content font-medium">Ready to see what Backstage can do for your team?</p>
98-
<a href="/contact" class="inline-flex items-center gap-2 bg-primary hover:bg-primary-offset text-page-base font-bold px-6 py-3 rounded-full transition-colors whitespace-nowrap">
98+
<a
99+
href="/contact"
100+
class="relative inline-flex items-center gap-2 bg-primary hover:bg-primary-offset text-page-base font-bold px-6 py-3 rounded-full transition-colors whitespace-nowrap focus-visible:outline-none after:pointer-events-none after:absolute after:-inset-1 after:rounded-none after:content-[''] focus-visible:after:border-2 focus-visible:after:border-spotlight"
101+
>
99102
Let's talk
100103
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
101104
</a>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
import Skills from '@components/Home/Skills/index.astro'
3+
import type { CollectionEntry } from 'astro:content'
4+
5+
const skills = [
6+
{
7+
data: {
8+
slug: 'aws',
9+
displayName: 'AWS',
10+
logo: undefined,
11+
},
12+
},
13+
{
14+
data: {
15+
slug: 'kubernetes',
16+
displayName: 'Kubernetes',
17+
logo: undefined,
18+
},
19+
},
20+
{
21+
data: {
22+
slug: 'terraform',
23+
displayName: 'Terraform',
24+
logo: undefined,
25+
},
26+
},
27+
] as unknown as Array<CollectionEntry<'tags'>>
28+
---
29+
30+
<Skills skills={skills} />
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
4+
import { executeRender } from '@test/unit/helpers/litRuntime'
5+
import SkillsFixture from '@components/Home/Skills/client/__fixtures__/skillsCarousel.fixture.astro'
6+
import type { SkillsCarouselElement } from '@components/Home/Skills/client'
7+
8+
type SkillsModule = WebComponentModule<SkillsCarouselElement>
9+
10+
const addButtonEventListenersMock = vi.hoisted(() => vi.fn())
11+
const createAnimationControllerMock = vi.hoisted(() => vi.fn())
12+
13+
type AutoplayPluginInstance = {
14+
play: ReturnType<typeof vi.fn>
15+
stop: ReturnType<typeof vi.fn>
16+
}
17+
18+
const autoplayPluginInstances: AutoplayPluginInstance[] = []
19+
20+
const createAutoplayPluginMock = vi.fn(() => {
21+
const instance: AutoplayPluginInstance = {
22+
play: vi.fn(),
23+
stop: vi.fn(),
24+
}
25+
autoplayPluginInstances.push(instance)
26+
return instance
27+
})
28+
29+
vi.mock('@components/scripts/store', () => ({
30+
createAnimationController: createAnimationControllerMock,
31+
}))
32+
33+
vi.mock('@components/scripts/elementListeners', () => ({
34+
addButtonEventListeners: addButtonEventListenersMock,
35+
}))
36+
37+
vi.mock('embla-carousel', () => {
38+
const createEmbla = vi.fn(() => {
39+
const handlers = new Map<string, Array<() => void>>()
40+
let selectedIndex = 0
41+
42+
const api = {
43+
canScrollPrev: vi.fn(() => false),
44+
canScrollNext: vi.fn(() => false),
45+
scrollPrev: vi.fn(),
46+
scrollNext: vi.fn(),
47+
on: vi.fn((event: string, handler: () => void) => {
48+
const existing = handlers.get(event) ?? []
49+
handlers.set(event, [...existing, handler])
50+
return api
51+
}),
52+
off: vi.fn((event: string, handler: () => void) => {
53+
const existing = handlers.get(event) ?? []
54+
handlers.set(
55+
event,
56+
existing.filter(entry => entry !== handler)
57+
)
58+
return api
59+
}),
60+
destroy: vi.fn(),
61+
scrollSnapList: vi.fn(() => [0, 1, 2]),
62+
selectedScrollSnap: vi.fn(() => selectedIndex),
63+
scrollTo: vi.fn((index: number) => {
64+
selectedIndex = index
65+
}),
66+
}
67+
68+
return api
69+
})
70+
71+
return {
72+
default: createEmbla,
73+
}
74+
})
75+
76+
vi.mock('embla-carousel-autoplay', () => ({
77+
__esModule: true,
78+
default: createAutoplayPluginMock,
79+
}))
80+
81+
describe('Skills carousel component', () => {
82+
let container: AstroContainer
83+
84+
beforeEach(async () => {
85+
container = await AstroContainer.create()
86+
vi.clearAllMocks()
87+
autoplayPluginInstances.length = 0
88+
createAutoplayPluginMock.mockClear()
89+
createAnimationControllerMock.mockReturnValue({
90+
requestPlay: vi.fn(),
91+
requestPause: vi.fn(),
92+
setInstancePauseState: vi.fn(),
93+
clearUserPreference: vi.fn(),
94+
destroy: vi.fn(),
95+
})
96+
})
97+
98+
afterEach(() => {
99+
vi.restoreAllMocks()
100+
vi.useRealTimers()
101+
})
102+
103+
const renderSkills = async (
104+
assertion: (_context: { element: SkillsCarouselElement }) => Promise<void> | void
105+
): Promise<void> => {
106+
await executeRender<SkillsModule>({
107+
container,
108+
component: SkillsFixture,
109+
moduleSpecifier: '@components/Home/Skills/client/index',
110+
selector: 'skills-carousel',
111+
waitForReady: async (element: SkillsCarouselElement) => {
112+
await element.updateComplete
113+
},
114+
assert: async ({ element, module, renderResult }) => {
115+
expect(renderResult).toContain(`<${module.registeredName}`)
116+
await assertion({ element })
117+
},
118+
})
119+
}
120+
121+
it('renders centered labels with nav controls and no dots', async () => {
122+
await renderSkills(async ({ element }) => {
123+
const firstLink = element.querySelector('a')
124+
expect(firstLink?.classList.contains('flex-col')).toBe(true)
125+
expect(firstLink?.classList.contains('items-center')).toBe(true)
126+
expect(firstLink?.classList.contains('text-center')).toBe(true)
127+
128+
expect(element.querySelector('[data-skills-prev]')).toBeTruthy()
129+
expect(element.querySelector('[data-skills-next]')).toBeTruthy()
130+
expect(element.querySelector('[data-skills-autoplay-toggle]')).toBeTruthy()
131+
expect(element.querySelector('[data-skills-dots]')).toBeFalsy()
132+
})
133+
})
134+
135+
it('toggles autoplay state via the play/pause control', async () => {
136+
vi.useFakeTimers()
137+
const buttonHandlers = new Map<Element, () => void>()
138+
addButtonEventListenersMock.mockImplementation((button, handler) => {
139+
buttonHandlers.set(button as Element, handler as () => void)
140+
})
141+
142+
await renderSkills(async ({ element }) => {
143+
const toggle = element.querySelector('[data-skills-autoplay-toggle]') as HTMLButtonElement
144+
expect(toggle).toBeTruthy()
145+
146+
const handler = buttonHandlers.get(toggle)
147+
expect(handler).toBeTruthy()
148+
149+
const autoplayInstance = autoplayPluginInstances[0]
150+
expect(autoplayInstance).toBeTruthy()
151+
152+
vi.runAllTimers()
153+
expect(autoplayInstance?.play).toHaveBeenCalled()
154+
expect(toggle.getAttribute('aria-label')).toBe('Pause skills')
155+
156+
handler?.()
157+
expect(autoplayInstance?.stop).toHaveBeenCalled()
158+
expect(toggle.getAttribute('aria-label')).toBe('Play skills')
159+
160+
handler?.()
161+
vi.runAllTimers()
162+
expect(autoplayInstance?.play).toHaveBeenCalled()
163+
expect(toggle.getAttribute('aria-label')).toBe('Pause skills')
164+
})
165+
})
166+
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { addButtonEventListeners } from '@components/scripts/elementListeners'
2+
import { isType1Element } from '@components/scripts/assertions/elements'
3+
import { defineCustomElement } from '@components/scripts/utils'
4+
import { createE2ELogger, EmblaCarouselBase } from '@components/scripts/embla'
5+
import type { EmblaCarouselConfig, EmblaElementHandles } from '@components/scripts/embla'
6+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
7+
import {
8+
getSkillsEmblaRoot,
9+
getSkillsViewport,
10+
querySkillsAutoplayPauseIcon,
11+
querySkillsAutoplayPlayIcon,
12+
querySkillsAutoplayToggle,
13+
querySkillsNextBtn,
14+
querySkillsPrevBtn,
15+
querySkillsSlides,
16+
} from './selectors'
17+
18+
const SCRIPT_NAME = 'SkillsCarouselElement'
19+
const logForE2E = createE2ELogger('skills')
20+
21+
export class SkillsCarouselElement extends EmblaCarouselBase {
22+
private autoplayToggleBtn: HTMLButtonElement | null = null
23+
24+
protected getConfig(): EmblaCarouselConfig {
25+
return {
26+
emblaOptions: {
27+
loop: true,
28+
align: 'start',
29+
skipSnaps: false,
30+
dragFree: false,
31+
},
32+
autoplayOptions: {
33+
delay: 5000,
34+
stopOnInteraction: true,
35+
stopOnMouseEnter: true,
36+
playOnInit: false,
37+
},
38+
animationId: 'skills-carousel',
39+
scriptName: SCRIPT_NAME,
40+
logPrefix: 'skills',
41+
}
42+
}
43+
44+
protected queryElements(): EmblaElementHandles {
45+
return {
46+
emblaRoot: getSkillsEmblaRoot(this),
47+
viewport: getSkillsViewport(this),
48+
slideCount: querySkillsSlides(this).length,
49+
prevBtn: querySkillsPrevBtn(this),
50+
nextBtn: querySkillsNextBtn(this),
51+
}
52+
}
53+
54+
protected override onInitialized(): void {
55+
this.autoplayToggleBtn = querySkillsAutoplayToggle(this)
56+
this.setupAutoplayToggle()
57+
if (this.hasAutoplaySupport) {
58+
this.resume()
59+
}
60+
}
61+
62+
protected override onTeardown(): void {
63+
this.autoplayToggleBtn = null
64+
}
65+
66+
protected override onAutoplayStateChange(state: 'playing' | 'paused'): void {
67+
this.syncAutoplayToggleButton(state)
68+
}
69+
70+
private setupAutoplayToggle(): void {
71+
if (!this.autoplayToggleBtn) return
72+
73+
const viewportId = this.viewport?.getAttribute('id')
74+
if (viewportId) {
75+
this.autoplayToggleBtn.setAttribute('aria-controls', viewportId)
76+
}
77+
78+
addButtonEventListeners(
79+
this.autoplayToggleBtn,
80+
() => {
81+
const state = this.getAttribute('data-carousel-autoplay')
82+
if (state === 'playing') {
83+
this.pause()
84+
return
85+
}
86+
this.resume()
87+
},
88+
this
89+
)
90+
91+
const initialState =
92+
(this.getAttribute('data-carousel-autoplay') as 'playing' | 'paused' | null) ?? 'paused'
93+
this.syncAutoplayToggleButton(initialState)
94+
}
95+
96+
private syncAutoplayToggleButton(state: 'playing' | 'paused'): void {
97+
if (!this.autoplayToggleBtn) return
98+
99+
const pauseIcon = querySkillsAutoplayPauseIcon(this.autoplayToggleBtn)
100+
const playIcon = querySkillsAutoplayPlayIcon(this.autoplayToggleBtn)
101+
102+
if (state === 'playing') {
103+
this.autoplayToggleBtn.setAttribute('aria-label', 'Pause skills')
104+
this.autoplayToggleBtn.setAttribute('aria-pressed', 'false')
105+
if (isType1Element(pauseIcon)) pauseIcon.classList.remove('hidden')
106+
if (isType1Element(playIcon)) playIcon.classList.add('hidden')
107+
return
108+
}
109+
110+
this.autoplayToggleBtn.setAttribute('aria-label', 'Play skills')
111+
this.autoplayToggleBtn.setAttribute('aria-pressed', 'true')
112+
if (isType1Element(pauseIcon)) pauseIcon.classList.add('hidden')
113+
if (isType1Element(playIcon)) playIcon.classList.remove('hidden')
114+
}
115+
}
116+
117+
declare global {
118+
interface HTMLElementTagNameMap {
119+
'skills-carousel': SkillsCarouselElement
120+
}
121+
}
122+
123+
export const registerSkillsCarouselWebComponent = (tagName = 'skills-carousel') => {
124+
if (typeof window === 'undefined') return
125+
logForE2E('info', 'register:invoke', { tagName })
126+
defineCustomElement(tagName, SkillsCarouselElement)
127+
}
128+
129+
export const webComponentModule: WebComponentModule<SkillsCarouselElement> = {
130+
registeredName: 'skills-carousel',
131+
componentCtor: SkillsCarouselElement,
132+
registerWebComponent: registerSkillsCarouselWebComponent,
133+
}

0 commit comments

Comments
 (0)