https://github.com/ogshawnlee/faq-accordion-card
Solution to the FAQ Accordion Card challenge by Frontend Mentor. Built with Svelte with the built-in Transition API + TypeScript + WindiCSS + Malachite UI + Vite.
https://github.com/ogshawnlee/faq-accordion-card
Last synced: 3 months ago
JSON representation
Solution to the FAQ Accordion Card challenge by Frontend Mentor. Built with Svelte with the built-in Transition API + TypeScript + WindiCSS + Malachite UI + Vite.
- Host: GitHub
- URL: https://github.com/ogshawnlee/faq-accordion-card
- Owner: OGShawnLee
- Created: 2022-05-14T13:51:06.000Z (about 3 years ago)
- Default Branch: main
- Last Pushed: 2022-05-15T13:05:10.000Z (about 3 years ago)
- Last Synced: 2025-01-08T06:41:06.512Z (5 months ago)
- Language: Svelte
- Homepage: faq-accordion-card-eta-orpin.vercel.app
- Size: 295 KB
- Stars: 1
- Watchers: 2
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# Frontend Mentor - FAQ Accordion Card Solution
This is a solution to the [FAQ accordion card challenge on Frontend Mentor](https://www.frontendmentor.io/challenges/faq-accordion-card-XlyjD0Oam). Frontend Mentor challenges help you improve your coding skills by building realistic projects.
## Table of contents
- [Frontend Mentor - FAQ Accordion Card Solution](#frontend-mentor---faq-accordion-card-solution)
- [Table of contents](#table-of-contents)
- [Overview](#overview)
- [The challenge](#the-challenge)
- [Screenshot](#screenshot)
- [Links](#links)
- [My process](#my-process)
- [Built with](#built-with)
- [What I learned](#what-i-learned)
- [Useful resources](#useful-resources)
- [Author](#author)## Overview
### The challenge
Users should be able to
- View the optimal layout for the component depending on their device's screen size
- See hover states for all interactive elements on the page
- Hide/Show the answer to a question when the question is clicked### Screenshot

### Links
- Solution URL: [Right here!](https://www.frontendmentor.io/solutions/animated-faq-section-with-fully-functional-accordion-component-S1F6YuRI5)
- Live Site URL: [Deployed on Vercel](https://faq-accordion-card-eta-orpin.vercel.app/)## My process
### Built with
- Semantic HTML5 markup
- WindiCSS + Flexbox
- [Malachite UI](https://github.com/OGShawnLee/malachite-ui) - Component Library
- Svelte + Transition API
- Vite### What I learned
For this project I had to **build the Accordion component from the ground up for Malachite UI** so that I could just install, import, style and chill knowing that I won't worry about accessibility nor functionality since it is all handled for me, and on top of that animations are handled by Svelte... ahhh what a time to be alive.
```html
{#each questions as { question, answer }, index}
{question}
![]()
{answer}
{/each}```
```ts
export default class Accordion extends Component {
protected readonly Buttons: Ordered;
protected readonly Navigable: Navigable;readonly Open: Readable;
protected readonly Items = new Hashable();
readonly Finite: Store>;
readonly ShouldOrder: Store>;protected isInitialised = false;
protected previousOpenItem: ItemInstance | undefined;constructor({ Finite, ShouldOrder }: Expand) {
super({ component: 'accordion', index: Accordion.generateIndex() });this.Finite = Finite;
this.ShouldOrder = ShouldOrder;this.Buttons = new Ordered(ShouldOrder);
this.Navigable = new Navigable({ Ordered: this.Buttons, Finite, Vertical: true });this.Open = derived(this.Buttons.Members, (items) => {
return items.some(({ isOpen }) => isOpen);
});Context.setContext({
accordion: this.accordion,
initItem: this.initItem.bind(this),
});
}get accordion() {
return this.defineActionComponent({
onMount: this.name,
destroy: ({ element }) => [
this.Navigable.initNavigation(element, {
handler() {
return Navigable.initNavigationHandler(element, ({ event, code, ctrlKey }) => {
if (!this.isWithin(document.activeElement)) return;
switch (code) {
case 'ArrowDown':
event.preventDefault();
return this.handleNextKey(code, ctrlKey);
case 'ArrowUp':
event.preventDefault();
return this.handleBackKey(code, ctrlKey);
case 'End':
return this.goLast();
case 'Home':
return this.goFirst();
}
});
},
}),
],
});
}protected handleAriaLevel(header: HTMLElement, level: string | number | undefined) {
if (isNumber(level) || isString(level)) return (header.ariaLevel = level.toString());
const [h, number] = header.tagName;
if (header.tagName.length === 2 && h === 'H' && isNumber(Number(number))) {
header.ariaLevel = number;
} else header.ariaLevel = '2';
}protected handleButtonFocus(button: HTMLElement) {
return useListener(button, 'focus', async () => {
await tick();
const index = this.Navigable.indexOf(button);
if (index < 0 || this.Navigable.isSelected(index)) return;
this.Navigable.set(index);
});
}protected handleUnique(button: HTMLElement, Toggleable: Toggleable) {
return Toggleable.subscribe((isOpen) => {
if (!isOpen) return;
const item = this.Buttons.get(button);
if (button !== this.previousOpenItem?.button) this.previousOpenItem?.close();
this.previousOpenItem = item;
});
}initItem({ Toggleable, initialOpen }: { Toggleable: Toggleable; initialOpen: boolean }) {
const { destroy, index } = this.Items.push(Toggleable);
onDestroy(() => destroy());if (initialOpen && !this.isInitialised) {
this.isInitialised = true;
Toggleable.open();
}const [Button, Header, Panel] = generate(3, () => new Bridge());
const { nameChild } = useName({ parent: this.name, component: 'item', index });return ItemContext.setContext({
Open: makeReadable(Toggleable),
close: Toggleable.close.bind(Toggleable),
button: defineActionComponent({
Bridge: Button,
onMount: ({ element }) => {
setAttribute(element, ['tabIndex', '0'], {
overwrite: true,
predicate: () => element.tabIndex <= 0,
});
return nameChild('button');
},
destroy: ({ element }) => [
Toggleable.button(element, {
onChange: (isOpen) => {
element.ariaExpanded = String(isOpen);
},
}),
this.Navigable.initItem(element, {
Bridge: Button,
Value: {
Index: writable(this.Buttons.size),
button: element,
close: () => Toggleable.set(false),
isOpen: Toggleable.isOpen,
},
}),
this.handleUnique(element, Toggleable),
this.handleButtonFocus(element),
Panel.Name.subscribe((id) => {
if (id) element.setAttribute('aria-controls', id);
else element.removeAttribute('aria-controls');
}),
usePair(Button.Disabled, Panel).subscribe(([isDisabled, panel]) => {
if (isDisabled && panel) element.ariaDisabled = 'true';
else element.ariaDisabled = null;
}),
],
}),
header: defineActionComponentWithParams({
Bridge: Header,
onMount: ({ element, parameter: level }) => {
this.handleAriaLevel(element, level);
element.setAttribute('role', 'heading');
return nameChild('header');
},
onUpdate: ({ element, parameter: level }) => {
this.handleAriaLevel(element, level);
},
}),
panel: defineActionComponent({
Bridge: Panel,
onMount: () => {
return nameChild('panel');
},
destroy: ({ element }) => [
Toggleable.panel(element, {
plugins: [usePreventInternalFocus],
}),
Button.Name.subscribe((id) => {
if (id) element.setAttribute('aria-labelledby', id);
else element.removeAttribute('aria-labelledby');
}),
this.Items.Size.subscribe((size) => {
if (size <= 6) element.setAttribute('role', 'region');
else element.removeAttribute('role');
}),
],
}),
});
}private static generateIndex = this.initIndexGenerator();
}
```### Useful resources
- [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#accordion) - I followed this awesome pattern for the Accordion.
## Author
- Frontend Mentor - [@Shawn Lee](https://www.frontendmentor.io/profile/OGShawnLee)