Blocks

Job Listings

A four-state careers board — roles grouped by department, three filters that genuinely combine, a live result count, and salary ranges whose absence is spelled out instead of left blank.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUpRight, ChevronDown, MapPin, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  JobEmployment,
  JobListingsData,
  JobListingsItem,
  JobLocation,
  JobSalary,
} from "./job-listings.contract"

/** the "all" sentinel. not the empty string: inside a <select> that reads as "nothing selected" and makes a poor option value. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/job-listings.json

Prompt

Build a React + TypeScript + Tailwind "JobListings" careers block
(lucide-react glyphs) with zod.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; heading?; subheading?;
    items: { id, title, department,
             location: { mode: "remote" | "hybrid" | "onsite"; place? },
             employment: "full-time" | "part-time" | "contract" | "internship",
             salary?: { min, max, currency, period: "year"|"month"|"hour" },
             href }[] }.
- Location is TWO fields, never one string: `mode` says how the role relates
  to a place, `place` says which place. That is what lets "On-site · Berlin"
  and "Hybrid · Berlin" land under the same location filter option while
  remote stays an independent axis. A remote role with no `place` is
  location-independent: it renders as plain "Remote" and is reached through
  the remote filter or "All locations".
- `salary` is optional on purpose — many employers do not publish pay, while
  several US states and the EU pay-transparency directive require a range.
  Absence is a rendered state, not a blank cell (see Behavior).
- `href` is refined at parse time to reject dead anchors ("#" and friends),
  so a decorative fake Apply link cannot enter the data in the first place.
- Component props = z.infer of the schema plus undisclosedSalaryLabel?:
  string (default "Salary not disclosed"), locale?: string (default "en-US"),
  onRetry?: () => void, className. Filter selection is internal state — the
  host ships data, the component owns the filtering.

Behavior
- Four first-class branches: loading (filter bar + two group skeletons
  mirroring the real row anatomy, plus an sr-only role=status "Loading open
  roles"), empty ("No open roles right now" — the board itself is empty, no
  filter bar), error (message + "Try again" only when onRetry exists), ready.
- Filter options are DERIVED from the items: department options are the
  distinct departments, location options the distinct `place` values, both in
  first-appearance order so the host controls priority by ordering the data.
  A hardcoded option list eventually lies about the data. A control that
  cannot change the result is not rendered at all: one department means no
  department dropdown, zero `place` values mean no location dropdown, and the
  matching dimension is forced back to "all" so nothing filters invisibly.
- The three filters INTERSECT: department AND location AND remote-only. An
  unset dimension does not narrow. The remote toggle is an aria-pressed
  button that keeps mode === "remote" (hybrid is a distinct answer to "where
  do I work", so it is not silently folded in).
- Stale-selection guard: if the items prop changes and the selected
  department/location no longer exists, that dimension falls back to "all"
  during render — otherwise the block filters to zero forever on a value the
  user can no longer see in the dropdown, with no way back.
- A result line reads the filtered array length: "N open roles" unfiltered,
  "Showing M of N open roles" once any filter is on. It carries role=status
  so the new count is announced.
- Two visually and verbally distinct zero states:
    * status === "empty"  -> "No open roles right now" (nothing to filter).
    * ready + 0 matches   -> "No roles match these filters", restating the
      total that is still listed, plus a Clear filters button. The same
      Clear filters control also sits in the filter bar whenever any filter
      is active, so the user is never trapped in an empty result.
- Grouping: one <section aria-labelledby> per department (heading + role
  count) wrapping a <ul> of rows; groups with no matches are not rendered.
  The department shows once as the group heading instead of repeating on
  every row.
- Rows: the job title is the only link in the row, and its ::after covers the
  whole row so the entire row is clickable while the accessible name stays
  exactly the job title. The trailing "Apply" pill is aria-hidden decoration
  belonging to that same link, not a second link and not a dead button.
- Salary rendering: min/max are ordered defensively (a reversed pair still
  reads low to high), min === max collapses to a single figure, the period
  becomes / yr · / mo · / hr, and the currency symbol comes from the data so
  one board can mix currencies. No salary -> undisclosedSalaryLabel in muted
  italic, never an empty cell.
- No caps anywhere: every item in `items` renders, titles wrap (break-words,
  min-w-0 on the text column) instead of being clipped by a fixed height.
- Heading levels shift with the data: with a section heading the groups are
  h3 and job titles h4; without one the groups become h2 and titles h3, so
  the outline never skips a level.

Rendering & styling
- Semantic tokens only: bg-card panels, border / divide-y separators,
  text-muted-foreground for meta and the undisclosed-salary fallback, primary
  reserved for the active remote toggle (border-primary bg-primary/10) and
  the Apply pill on row hover/focus-within. Salary figures tabular-nums.
- Native <select> for the two dropdowns (appearance-none + a positioned
  chevron with pointer-events-none) and sr-only <label>s; scheme-light /
  dark:scheme-dark so the system-drawn option list follows the theme.
- Row hover uses bg-muted/50; transitions are transition-colors and
  transition-[translate] (Tailwind v4 translate-* writes the CSS `translate`
  property, so it must be named in the transition list), all disabled under
  motion-reduce without losing any function.
- focus-visible:ring-2 ring-ring on every control and on the title link;
  cn() merges className.

Customization levers
- Add a fourth dimension (employment type, seniority, team) by copying the
  derive-options -> intersect-predicate -> reset-in-clearFilters trio; the
  result count and both zero states pick it up with no further changes.
- Grouping key: swap `department` for location or employment in the bucket
  builder to regroup the same data; render one flat <ul> by dropping the
  bucketing and keeping the row component.
- Row density: the row is a flex column that becomes a row at sm; drop the
  meta line, or move salary under the title, without touching filter logic.
- Copy: undisclosedSalaryLabel is a prop; the empty / no-match / error strings
  are single literals meant to be swapped for your voice or an i18n lookup.
- Formatting: locale feeds Intl.NumberFormat — switch to style:"currency" with
  an ISO code if you would rather pass "USD" than "$".
- Emphasis: the Apply pill inherits primary on hover; make it always-solid for
  a louder board, or reduce it to the arrow glyph alone for a denser one.

Concepts

  • Intersecting filters — the three controls are ANDed into one predicate, so department + location + remote narrows the list the way a visitor expects, and a dimension left unset simply does not participate. Filter chrome that does not change the result is a dead affordance.
  • Derived filter options — the dropdowns are built from the items themselves in first-appearance order, so a new department appears the moment it is in the data and a removed one disappears; a hardcoded option list eventually offers a filter that matches nothing.
  • Remote as its own axismode (remote / hybrid / on-site) is separate from place, which keeps "Hybrid · Berlin" and "On-site · Berlin" under one Berlin option and stops "Remote" from squatting in the city field.
  • No-match is not no-openings — an empty board and an over-narrow filter look identical if you reuse one panel; they get different copy, and only the filter case offers the Clear filters escape hatch.
  • Undisclosed is a rendered state — a missing salary prints a configurable label rather than a blank column, which keeps the row scannable and makes pay-transparency gaps visible instead of invisible.
  • Stretched link, single accessible name — one anchor per row with its ::after covering the row: the whole row is clickable while a screen reader still hears exactly one link, named after the job.

On This Page