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
22 changes: 14 additions & 8 deletions app/contributors/ContributorsSearch.empty-fallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { describe, expect, it } from 'vitest';

import ContributorsSearch from './ContributorsSearch';

function expectContributorsCount(container: HTMLElement, filtered: number, total: number) {
expect(container.textContent?.replace(/\s+/g, '').trim()).toContain(
`${filtered}/${total}contributors`
);
}

describe('ContributorsSearch empty fallback', () => {
it('renders the fallback when the contributor collection is missing', () => {
render(<ContributorsSearch />);
const { container } = render(<ContributorsSearch />);

expect(screen.getByText('No architects found')).toBeTruthy();
expect(screen.getByText('0 of 0 contributors')).toBeTruthy();
expectContributorsCount(container, 0, 0);
});

it('renders no contributor profile links for an empty collection', () => {
Expand All @@ -21,23 +27,23 @@ describe('ContributorsSearch empty fallback', () => {

it('keeps the empty collection stable while searching and clearing', async () => {
const user = userEvent.setup();
render(<ContributorsSearch contributors={[]} />);
const { container } = render(<ContributorsSearch contributors={[]} />);

const input = screen.getByRole('textbox', { name: 'Search contributors by name' });
await user.type(input, 'missing contributor');

expect(screen.getByText('No architects found')).toBeTruthy();
expect(screen.getByText('0 of 0 contributors')).toBeTruthy();
expectContributorsCount(container, 0, 0);

await user.click(screen.getByRole('button', { name: 'Clear' }));
await user.click(screen.getByRole('button', { name: 'Clear search' }));

expect(input).toHaveValue('');
expect(screen.getByText('No architects found')).toBeTruthy();
});

it('moves from populated results to the fallback and back', async () => {
const user = userEvent.setup();
render(
const { container } = render(
<ContributorsSearch
contributors={[
{
Expand All @@ -58,8 +64,8 @@ describe('ContributorsSearch empty fallback', () => {
expect(screen.getByText('No architects found')).toBeTruthy();
expect(screen.queryByRole('link')).toBeNull();

await user.click(screen.getByRole('button', { name: 'Clear' }));
await user.click(screen.getByRole('button', { name: 'Clear search' }));
expect(screen.getByRole('link', { name: /alice/i })).toBeTruthy();
expect(screen.getByText('1 of 1 contributors')).toBeTruthy();
expectContributorsCount(container, 1, 1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('ContributorsSearch Theme Contrast Tests', () => {

expect(input.className).toContain('dark:text-white');

expect(input.className).toContain('dark:placeholder:text-zinc-600');
expect(input.className).toContain('dark:placeholder:text-zinc-500');
});

it('applies dark and light border styling on contributor cards', () => {
Expand Down
30 changes: 19 additions & 11 deletions app/contributors/ContributorsSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useRef } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { GitFork, Search } from 'lucide-react';
import { GitFork, Search, X } from 'lucide-react';
import { motion, AnimatePresence, Variants } from 'framer-motion';

interface Contributor {
Expand Down Expand Up @@ -89,32 +89,40 @@ export default function ContributorsSearch({
return (
<>
{/* SEARCH BAR */}
<div className="mx-auto mb-16 max-w-2xl" id="contributors">
<div className="mx-auto mb-16 max-w-xl" id="contributors">
<div className="relative group">
{/* Animated gradient border */}
<div className="absolute -inset-[1px] rounded-2xl bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 opacity-30 blur-sm group-focus-within:opacity-70 transition-opacity duration-500" />
<div className="relative flex items-center rounded-2xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-white/10">
<Search className="ml-5 h-5 w-5 text-zinc-500" />
{/* Hover/focus glow ring */}
<div className="absolute -inset-[2px] rounded-2xl bg-gradient-to-r from-cyan-500/40 via-blue-500/40 to-purple-500/40 opacity-0 group-focus-within:opacity-100 transition-all duration-500 blur-md" />
<div className="relative flex items-center rounded-2xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-white/10 shadow-sm dark:shadow-none shadow-black/5 transition-all duration-300 group-focus-within:border-cyan-500/40 dark:group-focus-within:border-cyan-400/40 group-focus-within:shadow-md group-focus-within:shadow-cyan-500/10 dark:group-focus-within:shadow-cyan-400/5">
<div className="ml-5 flex h-9 w-9 items-center justify-center rounded-lg bg-black/5 dark:bg-white/5">
<Search className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
</div>
<input
type="text"
placeholder="Search the collective..."
aria-label="Search contributors by name"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent px-4 py-5 text-lg text-black dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-600 outline-none font-light"
className="w-full bg-transparent px-4 py-3.5 text-base text-black dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none focus-visible:outline-none font-medium tracking-wide"
/>
{search && (
<button
onClick={() => setSearch('')}
className="mr-4 text-zinc-500 hover:text-black dark:hover:text-white transition-colors text-sm"
className="mr-3 flex h-7 w-7 items-center justify-center rounded-full text-zinc-400 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10 transition-all"
aria-label="Clear search"
>
Clear
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="mt-3 text-center text-sm text-zinc-600">
{filtered.length} of {contributors.length} contributors
<div className="mt-4 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full bg-black/5 dark:bg-white/5 px-3.5 py-1.5 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
<span className="text-cyan-500 dark:text-cyan-400">{filtered.length}</span>
<span>/</span>
<span>{contributors.length}</span>
<span className="text-zinc-400 dark:text-zinc-500">contributors</span>
</span>
</div>
</div>

Expand Down
12 changes: 6 additions & 6 deletions app/contributors/page.empty-fallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ describe('ContributorsPage empty fallback', () => {

it('renders fallback UI when contributors are empty', async () => {
const element = await ContributorsPage();
render(element);
const { container } = render(element);

expect(screen.getByText(/No architects found/i)).toBeTruthy();
expect(screen.getByText(/0 of 0 contributors/i)).toBeTruthy();
expect(container.textContent?.replace(/\s+/g, '')).toContain('0/0contributors');
expect(screen.getByRole('heading', { name: /THE COLLECTIVE/i })).toBeTruthy();
expect(screen.getByText(/READY TO BUILD\?/i)).toBeTruthy();
});
Expand All @@ -109,10 +109,10 @@ describe('ContributorsPage empty fallback', () => {
it('handles fetch failures gracefully and still renders fallback state', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('Network failure'))) as any;
const element = await ContributorsPage();
render(element);
const { container } = render(element);

expect(screen.getByText(/No architects found/i)).toBeTruthy();
expect(screen.getByText(/0 of 0 contributors/i)).toBeTruthy();
expect(container.textContent?.replace(/\s+/g, '')).toContain('0/0contributors');
});

it('handles non-ok API responses without breaking the page', async () => {
Expand All @@ -126,10 +126,10 @@ describe('ContributorsPage empty fallback', () => {
) as any;

const element = await ContributorsPage();
render(element);
const { container } = render(element);

expect(screen.getByText(/No architects found/i)).toBeTruthy();
expect(screen.getByText(/0 of 0 contributors/i)).toBeTruthy();
expect(container.textContent?.replace(/\s+/g, '')).toContain('0/0contributors');
});

it('does not emit console errors when the fallback page renders', async () => {
Expand Down
16 changes: 9 additions & 7 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -379,14 +379,16 @@ body {
}

/* ── Accessibility: visible focus ring ── */
:focus-visible {
outline: 2px solid #58a6ff;
outline-offset: 2px;
border-radius: 4px;
}
@layer base {
:focus-visible {
outline: 2px solid #58a6ff;
outline-offset: 2px;
border-radius: 4px;
}

:focus:not(:focus-visible) {
outline: none;
:focus:not(:focus-visible) {
outline: none;
}
}

/* ── Accessibility: reduced motion ── */
Expand Down
Loading