diff --git a/components/event-filter/EventFilter.jsx b/components/event-filter/EventFilter.jsx new file mode 100644 index 00000000..2f0a8b9d --- /dev/null +++ b/components/event-filter/EventFilter.jsx @@ -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 ( +
+ + {getLiteral('schedule:filter-label')} + +
+ + {eventTypes.map((type) => ( + + ))} +
+
+ ) +} + +export default EventFilter diff --git a/components/event-filter/event-filter.scss b/components/event-filter/event-filter.scss new file mode 100644 index 00000000..90adcb6f --- /dev/null +++ b/components/event-filter/event-filter.scss @@ -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); + } +} diff --git a/components/events-list/EventsList.jsx b/components/events-list/EventsList.jsx index ae14a903..f08bef31 100644 --- a/components/events-list/EventsList.jsx +++ b/components/events-list/EventsList.jsx @@ -1,3 +1,4 @@ +import { useMemo, useState } from 'react' import md from 'markdown-it' import clsx from 'clsx' @@ -9,6 +10,8 @@ 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' @@ -16,6 +19,27 @@ 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 (
@@ -50,15 +74,22 @@ const EventsList = ({ events }) => { {getLiteral('schedule:ics-download')}
+
- {events.map((event, index) => ( + {filteredEvents.map((event, index) => (
0 && event.date === events[index - 1].date, + 'same-date': + index > 0 && event.date === filteredEvents[index - 1].date, })} >
@@ -112,7 +143,7 @@ const EventsList = ({ events }) => { ))}
- {events.length === 0 && ( + {events.length === 0 ? (

{getLiteral('schedule:empty-no-events')}{' '} { {getLiteral('schedule:empty-host-link')}

- )} + ) : null} + + {events.length > 0 && filteredEvents.length === 0 ? ( +

+ {getLiteral('schedule:empty-no-matches')} +

+ ) : null}
) } diff --git a/content/commons.json b/content/commons.json index eb935fae..9f37bbaa 100644 --- a/content/commons.json +++ b/content/commons.json @@ -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.", diff --git a/styles/styles.scss b/styles/styles.scss index 5c728747..3ef12d23 100644 --- a/styles/styles.scss +++ b/styles/styles.scss @@ -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'; @@ -37,4 +38,4 @@ @import '../components/play-link/play-link'; -@import '../components/section-divider/section-divider'; \ No newline at end of file +@import '../components/section-divider/section-divider'; diff --git a/tests/events-list.test.jsx b/tests/events-list.test.jsx new file mode 100644 index 00000000..173a52df --- /dev/null +++ b/tests/events-list.test.jsx @@ -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', + }, + 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() + + 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() + }) +})