Skip to content

Commit

Permalink
feat(challenge): initial 02-progress-steps
Browse files Browse the repository at this point in the history
  • Loading branch information
dorayx committed Feb 21, 2024
1 parent ce271e7 commit c7a6f07
Show file tree
Hide file tree
Showing 15 changed files with 470 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ A series of front-end challenges to experiment and demonstrate my ideas and thou
| # | Title | Experiment | Tech Stack | Source | Live Demo |
| --- | --------------- | --------------------------- | ------------ | ---------------------------------------------------------------- | --------- |
| 01 | Expanding Cards | CSS Variables as Parameters | 🍦Vanilla TS | [challenges/01-expanding-cards](./challenges/01-expanding-cards) | 🚧 |
| 02 | Progress Steps | A11y for Progress Steps | 🍦Vanilla TS | [challenges/02-progress-steps](./challenges/02-progress-steps) | 🚧 |
23 changes: 23 additions & 0 deletions challenges/02-progress-steps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 02 Progress Steps

<center><img src="docs/snapshot.png"></center>

## Requirements

- [x] Create a progress bar with 4 steps.
- [x] When a prev or next button is clicked, the progress bar should move to the next or previous step.
- [x] Accessibility Support (Keyboard Navigation).

## Considerations

- Use `role="group"` to group the steps.
- Use `aria-labelledby` to describe the button text for screen readers.
- Use `aria-current` to indicate the current step.
- Use `aria-live` to announce the current step when the user navigates through the buttons.

## Reference

- https://www.udemy.com/course/50-projects-50-days/learn/lecture/23595222#overview
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/group_role
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current
Empty file.
Binary file added challenges/02-progress-steps/docs/snapshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions challenges/02-progress-steps/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>#02 Progress Steps</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions challenges/02-progress-steps/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "02-progress-steps",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}
1 change: 1 addition & 0 deletions challenges/02-progress-steps/public/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions challenges/02-progress-steps/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import './style.css';
import { ProgressStepsModule } from '@/progress-steps.module.ts';

document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div tabindex="0" role="group" aria-label="Progress Steps"
class="progress js-progress">
<ol class="progress__indicator" tabindex="-1">
<li class="progress__indicator__circle"><span class="sr-only">Step</span>1</li>
<li class="progress__indicator__circle"><span class="sr-only">Step</span>2</li>
<li class="progress__indicator__circle"><span class="sr-only">Step</span>3</li>
<li class="progress__indicator__circle"><span class="sr-only">Step</span>4</li>
</ol>
<div class="progress__operators">
<button class="progress__button" data-btn-prev aria-labelledby="prev-btn-label">
<span id="prev-btn-label" class="sr-only">Prev to <span data-slot></span></span>
Prev
</button>
<button class="progress__button" data-btn-next aria-labelledby="next-btn-label">
<span id="next-btn-label" class="sr-only">Next to <span data-slot></span></span>
Next
</button>
</div>
</div>
`;

document.addEventListener('DOMContentLoaded', () => {
const root = document.querySelector('.js-progress') as HTMLElement;
if (!root) {
throw new Error('Root element not found');
}

ProgressStepsModule.init(root);
});
136 changes: 136 additions & 0 deletions challenges/02-progress-steps/src/progress-steps.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
export class ProgressStepsModule {
static init(root: HTMLElement) {
return new ProgressStepsModule(root);
}

private readonly elSteps: NodeListOf<HTMLElement>;
private readonly elButtons: [HTMLButtonElement, HTMLButtonElement];
private readonly elButtonLabelSlots: [HTMLElement, HTMLElement];
private destroyHandlers: (() => void)[] = [];

private currentStep = 0;

private constructor(private root: HTMLElement) {
this.elSteps = this.root.querySelectorAll('.progress__indicator__circle');

this.elButtons = [
this.root.querySelector<HTMLButtonElement>('[data-btn-prev]')!,
this.root.querySelector<HTMLButtonElement>('[data-btn-next]')!,
];

this.elButtonLabelSlots = this.elButtons.map((el) => el.querySelector<HTMLElement>('[data-slot]')!) as [
HTMLElement,
HTMLElement,
];

this.root.style.setProperty('--steps-count', String(this.elSteps.length));

this.setDefaultStep();
this.handleButtonsClick();
this.handleButtonsKeydown();
}

public destroy() {
this.destroyHandlers.forEach((handler) => handler());
}

private setDefaultStep() {
this.setActiveStep(this.currentStep);
}

private handleButtonsClick = () => {
const handler = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.dataset.btnPrev !== undefined) {
this.movePrev();
} else if (target.dataset.btnNext !== undefined) {
this.moveNext();
}
};

this.root.addEventListener('click', handler);
this.destroyHandlers.push(() => this.root.removeEventListener('click', handler));
};

private handleButtonsKeydown = () => {
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Enter') {
return;
}

const target = event.target as HTMLElement;
if (target.dataset.btnPrev !== undefined) {
this.movePrev();
} else if (target.dataset.btnNext !== undefined) {
this.moveNext();
}
};

this.root.addEventListener('keydown', handler);
this.destroyHandlers.push(() => this.root.removeEventListener('keydown', handler));
};

private moveNext() {
if (this.currentStep < this.elSteps.length) {
this.currentStep++;
this.setActiveStep(this.currentStep);
}
}

private movePrev() {
if (this.currentStep > 0) {
this.currentStep--;
this.setActiveStep(this.currentStep);
}
}

private setActiveStep(step: number) {
this.root.style.setProperty('--current-step', String(step));

this.elSteps.forEach((el, index) => {
el.removeAttribute('aria-current');
el.removeAttribute('aria-live');
el.removeAttribute('aria-atomic');

if (index <= step) {
el.setAttribute('data-active', 'true');
} else {
el.removeAttribute('data-active');
}
});

const targetStep = this.elSteps.item(step);
targetStep?.setAttribute('aria-current', 'step');
targetStep?.setAttribute('aria-live', 'polite');
targetStep?.setAttribute('aria-atomic', 'true');

this.setButtonsState(step);
this.setButtonLabels(step);
}

private setButtonsState(step: number) {
const [prev, next] = this.elButtons;
if (step === 0) {
prev.setAttribute('disabled', 'true');
next.removeAttribute('disabled');
next.focus();
} else if (step === this.elSteps.length - 1) {
prev.removeAttribute('disabled');
next.setAttribute('disabled', 'true');
prev.focus();
} else {
prev.removeAttribute('disabled');
next.removeAttribute('disabled');
}
}

private setButtonLabels(step: number) {
const [prevSlot, nextSlot] = this.elButtonLabelSlots;

const prevStep = step > 0 ? this.elSteps.item(step - 1) : null;
const nextStep = step < this.elSteps.length - 1 ? this.elSteps.item(step + 1) : null;

prevSlot.textContent = prevStep?.textContent ?? '';
nextSlot.textContent = nextStep?.textContent ?? '';
}
}
Loading

0 comments on commit c7a6f07

Please sign in to comment.