Student Registration UI
Before you start
You need: all of Articles 01–11, and an API to call — Track 10, or a mock.
Time: 8–12 hours.
Goal
Demonstrate that you can build a browser interface that works on any screen, is usable without a mouse, calls an API handling every outcome, and cannot be broken by the data it displays.
Assignment
Build the student registration and records interface for NexCoding Academy against the Web API from the ASP.NET Core track (or a mock API). Deliver the site plus one document.
| Deliverable | Contents |
|---|---|
index.html, students/*.html | Semantic markup |
css/ | Token-based stylesheet, mobile-first |
src/*.ts | TypeScript, strict: true |
README.md | How to run it |
DECISIONS.md | Choices, with reasons |
Required screens
| Screen | Behaviour |
|---|---|
| Login | Token stored, redirect on success, error shown on failure |
| Student list | Search, class filter, paging, all four states |
| Student detail | Details, fee summary, recent results |
| Add student | Full validated form |
| Edit student | Pre-filled, must not lose unsent fields |
| Delete | Confirmation, then soft delete |
Non-negotiable requirements
- Works at 320px and at 200% zoom
- Fully operable by keyboard, with visible focus
- Every form control has a real
<label> - Every list has loading, success, empty and error states
- All rendered data uses
textContent— noinnerHTMLwith API data response.okchecked on every call; 401 redirects to login- Search is debounced and cancellable
- No horizontal page scroll at any width
- Colour contrast passes 4.5:1
prefers-reduced-motionrespected- TypeScript
strict: true, noany
Worked example: the list screen
This screen is where most of the track's requirements meet.
type ListState =
| { status: 'loading' }
| { status: 'success'; result: PagedResult<StudentListItem> }
| { status: 'empty'; term: string }
| { status: 'error'; message: string };
let controller: AbortController | null = null;
async function loadStudents(term: string, className: string, page: number): Promise<void> {
controller?.abort();
controller = new AbortController();
render({ status: 'loading' });
try {
const params = new URLSearchParams({
page: String(page),
pageSize: '20'
});
if (term) { params.set('term', term); }
if (className) { params.set('className', className); }
const result = await apiRequest<PagedResult<StudentListItem>>(
`/api/students?${params}`,
{ signal: controller.signal });
render(result.items.length === 0
? { status: 'empty', term }
: { status: 'success', result });
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return; // superseded, not a failure
}
render({
status: 'error',
message: error instanceof ApiError && error.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.'
});
}
}
function render(state: ListState): void {
const tbody = requireElement<HTMLTableSectionElement>('studentRows');
const status = requireElement<HTMLElement>('listStatus');
switch (state.status) {
case 'loading':
status.textContent = 'Loading students…';
status.hidden = false;
tbody.replaceChildren();
return;
case 'empty':
status.textContent = state.term
? `No students match “${state.term}”.`
: 'No students have been added yet.';
status.hidden = false;
tbody.replaceChildren();
return;
case 'error':
status.textContent = state.message;
status.hidden = false;
tbody.replaceChildren();
return;
case 'success':
status.hidden = true;
renderRows(tbody, state.result.items);
renderPager(state.result);
return;
default: {
const exhaustive: never = state;
return exhaustive;
}
}
}
The discriminated union plus the never check means adding a fifth state produces a compile error rather than a silently unhandled case.
<p id="listStatus" role="status" aria-live="polite" hidden></p>
role="status" and aria-live="polite" mean a screen-reader user hears "Loading students", "No students match Ravi", or the error — without focus moving. A visual-only spinner tells them nothing.
function renderRows(tbody: HTMLTableSectionElement, students: StudentListItem[]): void {
const fragment = document.createDocumentFragment();
for (const student of students) {
const row = document.createElement('tr');
row.dataset.publicId = student.publicId;
row.appendChild(cell(student.rollNumber));
row.appendChild(cell(student.name));
row.appendChild(cell(`${student.className} - ${student.section}`));
row.appendChild(actionsCell(student));
fragment.appendChild(row);
}
tbody.replaceChildren(fragment);
}
function cell(text: string): HTMLTableCellElement {
const td = document.createElement('td');
td.textContent = text;
return td;
}
textContent throughout, so a student named <img src=x onerror=alert(1)> renders as text. One insertion via a fragment rather than a reflow per row.
// One delegated listener — new rows work with no re-binding
requireElement<HTMLElement>('studentRows').addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const button = target.closest<HTMLButtonElement>('button[data-action]');
if (button === null) { return; }
const publicId = button.closest('tr')?.dataset.publicId;
if (publicId === undefined) { return; }
if (button.dataset.action === 'edit') {
window.location.href = `/students/edit.html?publicId=${publicId}`;
} else if (button.dataset.action === 'delete') {
confirmDelete(publicId);
}
});
closest() rather than event.target directly — a click on an icon inside the button has the icon as target.
searchInput.addEventListener('input', debounce(() => {
loadStudents(searchInput.value, classSelect.value, 1);
}, 300));
Without the debounce and the AbortController, typing "Ravi" fires four requests whose responses can arrive out of order, leaving the list showing results for "Rav".
Worked example: the edit screen
async function saveStudent(publicId: string): Promise<void> {
if (!validateForm()) {
return;
}
const form = requireElement<HTMLFormElement>('studentForm');
const formData = new FormData(form);
const input: StudentUpdateRequest = {
name: String(formData.get('name') ?? ''),
rollNumber: String(formData.get('rollNumber') ?? ''),
className: String(formData.get('className') ?? ''),
section: String(formData.get('section') ?? ''),
parentName: String(formData.get('parentName') ?? ''),
parentPhone: String(formData.get('parentPhone') ?? ''),
address: (formData.get('address') as string) || null
};
setSaving(true);
try {
await apiRequest<void>(`/api/students/${publicId}`, {
method: 'PUT',
body: JSON.stringify(input)
});
window.location.href = '/students/index.html?saved=1';
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
showFieldErrors(error.body);
return;
}
if (error instanceof ApiError && error.status === 409) {
showFormError('This student was changed by someone else. Reload and try again.');
return;
}
showFormError('Could not save. Please try again.');
} finally {
setSaving(false);
}
}
function showFieldErrors(body: unknown): void {
if (typeof body !== 'object' || body === null || !('errors' in body)) {
showFormError('The details could not be saved.');
return;
}
const errors = (body as { errors: Record<string, string[]> }).errors;
for (const [field, messages] of Object.entries(errors)) {
const input = document.querySelector<HTMLInputElement>(`[name="${field}"]`);
const errorElement = document.getElementById(`${field}Error`);
if (input !== null && errorElement !== null && messages[0] !== undefined) {
input.setAttribute('aria-invalid', 'true');
errorElement.textContent = messages[0];
errorElement.hidden = false;
}
}
const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
}
Four things a reviewer will check here:
| Detail | What breaks without it |
|---|---|
ASP.NET Core errors object mapped to fields | The user sees "something went wrong" and no idea which field |
aria-invalid plus a role="alert" message | A screen-reader user cannot perceive the failure |
| Focus moved to the first invalid field | A keyboard user must hunt for it |
| 409 handled separately | A concurrency conflict reads as a generic failure |
<p id="parentPhoneError" class="field-error" role="alert" hidden></p>
setSaving(true) must disable the submit button. Without it a double-click sends two POST requests and creates two students.
Worked example: layout and CSS
:root {
--colour-bg: #ffffff;
--colour-text: #1f2937;
--colour-muted: #6b7280; /* 4.6:1 on white — passes AA */
--colour-primary: #2563eb;
--colour-danger: #b91c1c;
--colour-border: #d1d5db;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--radius: 0.5rem;
--font-size-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
}
@media (prefers-color-scheme: dark) {
:root {
--colour-bg: #111827;
--colour-text: #f3f4f6;
--colour-muted: #9ca3af;
--colour-border: #374151;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--colour-bg);
color: var(--colour-text);
font-size: var(--font-size-base);
line-height: 1.6;
}
:focus-visible {
outline: 3px solid var(--colour-primary);
outline-offset: 2px;
}
.layout {
display: grid;
grid-template-columns: 1fr;
grid-template-areas: "header" "main" "footer";
min-height: 100vh;
}
.layout > main { grid-area: main; min-width: 0; }
@media (min-width: 768px) {
.layout {
grid-template-columns: 240px 1fr;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
}
}
.table-scroll { overflow-x: auto; }
.button { min-height: 44px; padding: var(--space-2) var(--space-4); }
Every colour is a token, so dark mode is one block. min-width: 0 on the grid item stops the wide table forcing horizontal page scroll. min-height: 44px on buttons meets the touch-target guideline.
Submission template
DECISIONS.md
Markup:
Landmarks used and why:
Where <table> was used and where it was not:
Form labelling approach:
CSS:
Token list and what each is for:
Breakpoints chosen, and the content reason for each:
Where fluid sizing replaced a media query:
Contrast ratios for text on background:
JavaScript / TypeScript:
Module structure:
Types defined, and which were derived with Pick/Omit:
Where a discriminated union models state:
Where runtime validation guards API data, and where it does not:
Any `any` remaining, and why:
API integration:
How response.ok, 401, 400, 409 and 204 are each handled:
Debounce and cancellation approach:
What each of the four list states shows:
Accessibility:
Keyboard walkthrough result:
Screen-reader test result:
Live regions used:
Focus management on validation failure:
Security:
Where textContent is used and why:
Token storage choice and its trade-off:
What is validated client-side and why the server still must:
Deliberately not done, and why:
Verification
Run all of these and record the result.
320px. Every screen usable, no horizontal page scroll. Check with * { outline: 1px solid red }.
200% zoom. Content still usable, nothing clipped or overlapping.
Keyboard only. Put the mouse away. Every control reachable in a sensible order, focus always visible, the skip link works, no keyboard trap. This finds more than any automated tool.
Screen reader. NVDA or VoiceOver. Confirm: form labels announced, table headers announced with cells, the live region announces loading and error states, and validation errors are heard.
Lighthouse. Accessibility and SEO. Fix every finding, then note what the keyboard test caught that Lighthouse did not.
XSS. Create a student named <img src=x onerror="alert(1)">. It must render as text everywhere it appears. Then deliberately render it with innerHTML once, confirm it executes, and revert.
All four states. Force each: a valid search, a search matching nothing, the API stopped, and a slow response throttled to Slow 3G.
Request races. Type a search quickly with the debounce and abort removed. Confirm out-of-order results. Restore them and confirm the race is gone.
Double submit. Click Save twice quickly with the button-disabling removed. Confirm two students are created, then restore it.
Partial update. PUT with only name and className. Confirm dateOfBirth, parentPhone and the rest are unchanged in the database.
401. Clear the token mid-session and perform any action. Confirm a redirect to login, not a blank page.
Contrast. Check every text colour in DevTools. All must pass 4.5:1.
TypeScript. tsc --noEmit clean with strict: true and no any.
AI practice
Three AI exercises from this track's syllabus. Do each after the project works, and apply Track 18's discipline — every answer is a hypothesis until you have run it.
- Ask AI for accessibility feedback. Paste your student list markup and ask what a screen-reader user would struggle with. Then verify each point — check the table has real
<th>elements, that every input has a bound<label>, and that the page works from the keyboard alone. Some suggestions will be genuine; some will not apply to your markup. - Review generated JavaScript for understanding. Ask for a fee payment form handler, then explain every line out loud before running it. Any line you cannot explain does not go into the project — that rule alone prevents most of the ways this goes wrong.
- Ask for a debugging checklist instead of a rewrite. When a fetch call returns
undefined, ask for a list of things to check rather than corrected code. Work the list yourself. You will usually find it is a camelCase field name — the API returnsrollNumber, notRollNumber.
Exercise 3 is the habit to keep. A rewrite fixes today's bug and teaches nothing; a checklist teaches the class of bug.
Track 18 — Reviewing AI-generated code — has the full checklist.
Self-assessment
Your submission is complete when someone can clone it, follow the README, use every screen with a keyboard alone, and read DECISIONS.md to see which choices were deliberate.
Four specific tests of quality:
- Does the XSS test render as text? This is the one security requirement in the whole track, and it is one method call.
- Do all four states appear, including the empty state? Most student projects have one, and a blank screen for "no results" is indistinguishable from a bug.
- Does the keyboard walkthrough find nothing? If you have not done it, it will find something.
- Does
DECISIONS.mdsay what you did not do? A stated limitation is a stronger signal than a silent gap.
Track completion criteria
You can build responsive semantic pages, create interactive browser features, call REST APIs, and write foundational TypeScript.
Specifically, you can:
- Write a valid HTML document and choose elements by meaning
- Build a table and a form a screen reader can navigate
- Structure a page with landmarks and operate it entirely by keyboard
- Predict which CSS rule wins and explain a rendered size from the box model
- Build a layout with Grid and Flexbox without magic numbers
- Make a page work from 320px to a wide monitor, and at 200% zoom
- Write JavaScript knowing how coercion, scope and
thisbehave - Transform collections without loops, and copy objects knowing what is shared
- Render data safely with
textContentand handle events by delegation - Call an API handling
response.ok, 401, 400, 409, 204, races and cancellation - Read jQuery in an existing project and write typed TypeScript in a new one
- Say why client-side validation is never the control
The syllabus recommends Track 11 — Angular UI Development or Track 12 — React UI Development next — choose one. If you need the API those frontends call, take Track 10 — ASP.NET Core Development first.