Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions components/event-filter/EventFilter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getLiteral } from '../../common/i18n'
import TYPES from '../event-type-chip/types'

const EventFilter = ({ eventTypes, selectedType, onSelectType, totalEvents }) => {
if (eventTypes.length === 0) return null

return (
<div className="event-filter" aria-label={getLiteral('schedule:filter-label')}>
<span className="event-filter__label">
{getLiteral('schedule:filter-label')}
</span>
<div className="event-filter__options">
Comment on lines +8 to +12
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-label on a plain <div> typically isn’t exposed unless the element has an explicit/implicit ARIA role, so the filter group may be unlabeled for assistive tech. Consider using role="group" with aria-labelledby pointing at the visible label, or use a <fieldset> + <legend> so the control group has proper semantics.

Copilot uses AI. Check for mistakes.
<button
type="button"
className="event-filter__option"
data-active={selectedType === 'all' ? true : undefined}
aria-pressed={selectedType === 'all'}
onClick={() => onSelectType('all')}
>
{getLiteral('schedule:filter-all')}
<span className="event-filter__count">{totalEvents}</span>
</button>
{eventTypes.map((type) => (
<button
key={type.value}
type="button"
className="event-filter__option"
data-active={selectedType === type.value ? true : undefined}
aria-pressed={selectedType === type.value}
onClick={() => onSelectType(type.value)}
>
{TYPES[type.value].label}
<span className="event-filter__count">{type.count}</span>
</button>
))}
</div>
</div>
)
}

export default EventFilter
61 changes: 61 additions & 0 deletions components/event-filter/event-filter.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
.event-filter {
display: flex;
flex-direction: column;
gap: spacing(1);
margin-top: spacing(3);

&__label {
@extend %subtitle-2;
color: $white-80;
}

&__options {
display: flex;
flex-wrap: wrap;
gap: spacing(1);
}

&__option {
display: inline-flex;
align-items: center;
gap: spacing(1);
padding: spacing(1) spacing(1.5);
border: 1px solid $white-40;
border-radius: 27px;
background: transparent;
color: $white-80;
@extend %subtitle-2;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;

&:hover,
&:focus-visible,
&[data-active] {
background: $white;
border-color: $white;
color: $purple;
}

&:focus-visible {
outline: 2px solid $green;
outline-offset: 2px;
}
}

&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5em;
padding: 0 spacing(0.5);
border-radius: 999px;
background: $white-20;
font-size: 0.75rem;
}

&__option[data-active] &__count,
&__option:hover &__count,
&__option:focus-visible &__count {
background: rgba($purple, 0.12);
}
}
45 changes: 41 additions & 4 deletions components/events-list/EventsList.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo, useState } from 'react'
import md from 'markdown-it'
import clsx from 'clsx'

Expand All @@ -9,13 +10,36 @@ import { getLiteral } from '../../common/i18n'
import * as ROUTES from '../../common/routes'

import DateTimeChip from '../date-time-chip/DateTimeChip'
import EventFilter from '../event-filter/EventFilter'
import TYPES from '../event-type-chip/types'
import EventTypeChip from '../event-type-chip/EventTypeChip'
import PlayLink from '../play-link/PlayLink'
import Chip from '../chip/Chip'
const EventsList = ({ events }) => {
const dateLabel = (event) =>
`${event.formattedDate.date} to ${event.formattedDate.endDate}`

const [selectedType, setSelectedType] = useState('all')

const eventTypeOptions = useMemo(() => {
const counts = events.reduce((acc, event) => {
acc[event.type] = (acc[event.type] || 0) + 1
return acc
}, {})

return Object.keys(TYPES)
.filter((type) => counts[type])
.map((type) => ({
value: type,
count: counts[type],
}))
}, [events])

const filteredEvents =
selectedType === 'all'
? events
: events.filter((event) => event.type === selectedType)

return (
<section className="events-list">
<div className="events-list__header">
Expand Down Expand Up @@ -50,15 +74,22 @@ const EventsList = ({ events }) => {
{getLiteral('schedule:ics-download')}
</a>
</div>
<EventFilter
eventTypes={eventTypeOptions}
selectedType={selectedType}
onSelectType={setSelectedType}
totalEvents={events.length}
/>
</div>
</div>

<div className="events-list__grid">
{events.map((event, index) => (
{filteredEvents.map((event, index) => (
<div
key={event.slug}
className={clsx('events-list__card', {
'same-date': index > 0 && event.date === events[index - 1].date,
'same-date':
index > 0 && event.date === filteredEvents[index - 1].date,
})}
>
<div className="events-list__date">
Expand Down Expand Up @@ -112,7 +143,7 @@ const EventsList = ({ events }) => {
))}
</div>

{events.length === 0 && (
{events.length === 0 ? (
<p className="events-list__empty">
{getLiteral('schedule:empty-no-events')}{' '}
<a
Expand All @@ -123,7 +154,13 @@ const EventsList = ({ events }) => {
{getLiteral('schedule:empty-host-link')}
</a>
</p>
)}
) : null}

{events.length > 0 && filteredEvents.length === 0 ? (
<p className="events-list__empty">
{getLiteral('schedule:empty-no-matches')}
</p>
) : null}
</section>
)
}
Expand Down
2 changes: 2 additions & 0 deletions content/commons.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
"schedule:add-event": "Add your activity",
"schedule:ics-download": "Subscribe to calendar",
"schedule:ics-label": "Download .ics file with all Maintainer Month events",
"schedule:filter-label": "Filter by event type",
"schedule:filter-all": "All event types",
"schedule:empty-no-events": "Events for Maintainer Month 2026 are coming soon! Want to host one?",
"schedule:empty-host-link": "Submit your event here.",
"schedule:empty-no-matches": "No events match the selected filters.",
Expand Down
3 changes: 2 additions & 1 deletion styles/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
@import '../components/date-time-chip/date-time-chip';
@import '../components/event-detail/event-detail';
@import '../components/event-detail/event-detail-wrapper';
@import '../components/event-filter/event-filter';
@import '../components/events-list/events-list';
@import '../components/footer/footer';
@import '../components/header/header';
Expand All @@ -37,4 +38,4 @@

@import '../components/play-link/play-link';

@import '../components/section-divider/section-divider';
@import '../components/section-divider/section-divider';
61 changes: 61 additions & 0 deletions tests/events-list.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'

import EventsList from '../components/events-list/EventsList'

const baseEvent = {
formattedDate: {
date: 'May 14',
startTime: { utc: '7:00 pm', pt: '12:00 pm' },
endTime: { utc: '8:00 pm', pt: '1:00 pm' },
timeDisplay: 'specific',
},
Comment on lines +5 to +11
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test fixtures don’t include the raw date field (mm/dd) that EventsList uses for the same-date class logic (event.date === ...). Using an event shape that differs from parseEvent() output can make this test pass while masking regressions; add a realistic date (and optionally endDate) to the test events.

Copilot uses AI. Check for mistakes.
language: 'English',
location: 'Virtual',
userName: 'Maintainer Month',
userLink: 'https://github.com/github/maintainermonth',
link: '/schedule/test-event',
linkUrl: 'https://github.com/github/maintainermonth',
metaDesc: 'A test event.',
}

const events = [
{
...baseEvent,
slug: 'meetup-event',
title: 'Maintainer meetup',
type: 'meetup',
},
{
...baseEvent,
slug: 'workshop-event',
title: 'Maintainer workshop',
type: 'workshop',
},
]

describe('EventsList filters', () => {
test('filters schedule cards by event type and restores all events', () => {
render(<EventsList events={events} />)

expect(screen.getByRole('button', { name: /All event types 2/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /Meetup 1/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /Workshop 1/i })).toBeTruthy()
expect(screen.getByText('Maintainer meetup')).toBeTruthy()
expect(screen.getByText('Maintainer workshop')).toBeTruthy()

fireEvent.click(screen.getByRole('button', { name: /Meetup 1/i }))

expect(screen.getByText('Maintainer meetup')).toBeTruthy()
expect(screen.queryByText('Maintainer workshop')).toBeNull()

fireEvent.click(screen.getByRole('button', { name: /Workshop 1/i }))

expect(screen.queryByText('Maintainer meetup')).toBeNull()
expect(screen.getByText('Maintainer workshop')).toBeTruthy()

fireEvent.click(screen.getByRole('button', { name: /All event types 2/i }))

expect(screen.getByText('Maintainer meetup')).toBeTruthy()
expect(screen.getByText('Maintainer workshop')).toBeTruthy()
})
})
Loading