{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "job-listings",
  "title": "Job Listings",
  "description": "A four-state careers board: roles grouped by department, three filters that really combine (department, location, remote-only), a live result count, and salary ranges whose absence is spelled out instead of left blank.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/job-listings.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight, ChevronDown, MapPin, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  JobEmployment,\n  JobListingsData,\n  JobListingsItem,\n  JobLocation,\n  JobSalary,\n} from \"./job-listings.contract\"\n\n/** the \"all\" sentinel. not the empty string: inside a <select> that reads as \"nothing selected\" and makes a poor option value. */\nconst ALL = \"__all__\"\n\nconst MODE_LABEL: Record<JobLocation[\"mode\"], string> = {\n  hybrid: \"Hybrid\",\n  onsite: \"On-site\",\n  remote: \"Remote\",\n}\n\nconst EMPLOYMENT_LABEL: Record<JobEmployment, string> = {\n  contract: \"Contract\",\n  \"full-time\": \"Full-time\",\n  internship: \"Internship\",\n  \"part-time\": \"Part-time\",\n}\n\nconst PERIOD_SUFFIX: Record<JobSalary[\"period\"], string> = {\n  hour: \"/ hr\",\n  month: \"/ mo\",\n  year: \"/ yr\",\n}\n\nconst pulse = \"animate-pulse rounded bg-muted motion-reduce:animate-none\"\n\n/**\n * the location label joins two dimensions: the mode word plus the place.\n * a fully location-free remote role renders just \"Remote\", never a dangling \"· \" stub.\n */\nfunction locationLabel(location: JobLocation) {\n  const mode = MODE_LABEL[location.mode]\n  return location.place ? `${mode} · ${location.place}` : mode\n}\n\n/**\n * salary range. swapped min/max never renders as \"160k – 120k\" — the smaller value is the floor;\n * equal bounds render as a single number instead of \"$120,000 – $120,000\".\n */\nfunction salaryLabel(salary: JobSalary, formatter: Intl.NumberFormat) {\n  const lo = Math.min(salary.min, salary.max)\n  const hi = Math.max(salary.min, salary.max)\n  const range =\n    lo === hi\n      ? `${salary.currency}${formatter.format(lo)}`\n      : `${salary.currency}${formatter.format(lo)} – ${salary.currency}${formatter.format(hi)}`\n  return `${range} ${PERIOD_SUFFIX[salary.period]}`\n}\n\n/** order-preserving dedupe: filters and groups follow first appearance in the data, so ordering the data is ordering the UI */\nfunction uniqueInOrder(values: string[]) {\n  const seen = new Set<string>()\n  const out: string[] = []\n  for (const value of values) {\n    if (seen.has(value)) continue\n    seen.add(value)\n    out.push(value)\n  }\n  return out\n}\n\nfunction plural(count: number, noun: string) {\n  return `${count} ${noun}${count === 1 ? \"\" : \"s\"}`\n}\n\nfunction FilterSelect({\n  allLabel,\n  id,\n  label,\n  onChange,\n  options,\n  value,\n}: {\n  allLabel: string\n  id: string\n  label: string\n  onChange: (value: string) => void\n  options: string[]\n  value: string\n}) {\n  return (\n    <div className=\"relative min-w-0 flex-1 sm:max-w-56\">\n      <label className=\"sr-only\" htmlFor={id}>\n        {label}\n      </label>\n      <select\n        className={cn(\n          \"w-full cursor-pointer appearance-none rounded-lg border bg-background py-2 pr-9 pl-3 text-sm\",\n          // the native dropdown panel is drawn by the OS and follows color-scheme; without this the dark theme pops open a sheet of white\n          \"scheme-light dark:scheme-dark\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n        )}\n        id={id}\n        onChange={event => onChange(event.target.value)}\n        value={value}\n      >\n        <option value={ALL}>{allLabel}</option>\n        {options.map(option => (\n          <option key={option} value={option}>\n            {option}\n          </option>\n        ))}\n      </select>\n      {/* decorative arrow only: pointer-events-none, so clicking it hits the select underneath */}\n      <ChevronDown\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground\"\n      />\n    </div>\n  )\n}\n\nfunction JobRow({\n  formatter,\n  headingTag: Heading,\n  item,\n  undisclosedSalaryLabel,\n}: {\n  formatter: Intl.NumberFormat\n  headingTag: \"h3\" | \"h4\"\n  item: JobListingsItem\n  undisclosedSalaryLabel: string\n}) {\n  return (\n    // relative: the row-wide click pseudo-element positions against this row\n    <li className=\"group relative transition-colors hover:bg-muted/50 motion-reduce:transition-none\">\n      <div className=\"flex flex-col gap-3 px-1 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6\">\n        {/* min-w-0: a long title wraps on this side instead of pushing the salary / Apply out of view */}\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <Heading className=\"text-base font-medium tracking-tight\">\n            {/*\n              the legal way to make a whole row clickable: one link per row, its ::after covering the row.\n              not an <a> wrapping the row (that nests clickable areas); the title has no height cap and wraps.\n            */}\n            <a\n              className={cn(\n                \"rounded-sm break-words after:absolute after:inset-0 after:content-['']\",\n                \"hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n              )}\n              href={item.href}\n            >\n              {item.title}\n            </a>\n          </Heading>\n\n          <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground\">\n            <span className=\"inline-flex items-center gap-1.5\">\n              <MapPin aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n              {locationLabel(item.location)}\n            </span>\n            <span aria-hidden=\"true\">·</span>\n            <span className=\"rounded-full border px-2 py-0.5 text-xs font-medium\">\n              {EMPLOYMENT_LABEL[item.employment]}\n            </span>\n          </div>\n        </div>\n\n        <div className=\"flex shrink-0 items-center justify-between gap-4 sm:justify-end\">\n          {/*\n            a missing salary is not blank space: render an explicit, configurable line. some\n            jurisdictions (many US states, EU pay transparency) require the range by law, so\n            saying nothing is both non-compliant and unhelpful.\n          */}\n          <span\n            className={cn(\n              \"text-sm\",\n              item.salary ? \"text-foreground tabular-nums\" : \"text-muted-foreground italic\",\n            )}\n          >\n            {item.salary ? salaryLabel(item.salary, formatter) : undisclosedSalaryLabel}\n          </span>\n\n          {/*\n            aria-hidden: the row has exactly one link and its accessible name is already the role;\n            \"Apply\" is a visual part of that same link (covered by the ::after, so clicking it\n            clicks the link) and announcing it would just add a duplicate second \"link\".\n          */}\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"inline-flex shrink-0 items-center gap-1 rounded-lg border px-3 py-1.5 text-sm font-medium\",\n              \"transition-colors group-hover:bg-primary group-hover:text-primary-foreground\",\n              \"group-focus-within:bg-primary group-focus-within:text-primary-foreground\",\n              \"motion-reduce:transition-none\",\n            )}\n          >\n            Apply\n            <ArrowUpRight\n              className={cn(\n                // Tailwind v4's translate-* writes the CSS `translate` property, so the transition list must name it\n                \"size-3.5 transition-[translate] group-hover:translate-x-0.5\",\n                \"motion-reduce:transition-none motion-reduce:group-hover:translate-x-0\",\n              )}\n            />\n          </span>\n        </div>\n      </div>\n    </li>\n  )\n}\n\nexport interface JobListingsProps extends JobListingsData {\n  /** what to render when a role has no salary data — the gap is stated, never left blank */\n  undisclosedSalaryLabel?: string\n  /** BCP-47 locale for Intl; passed explicitly so server and client don't format numbers off different defaults */\n  locale?: string\n  onRetry?: () => void\n  className?: string\n}\n\nexport function JobListings({\n  className,\n  heading,\n  items,\n  locale = \"en-US\",\n  onRetry,\n  status,\n  subheading,\n  undisclosedSalaryLabel = \"Salary not disclosed\",\n}: JobListingsProps) {\n  const baseId = React.useId()\n  const [departmentValue, setDepartmentValue] = React.useState(ALL)\n  const [placeValue, setPlaceValue] = React.useState(ALL)\n  const [remoteOnly, setRemoteOnly] = React.useState(false)\n\n  const formatter = React.useMemo(\n    () => new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }),\n    [locale],\n  )\n\n  // every filter option is derived from the data: swap the roles and the dropdowns follow — no hardcoded list to go stale\n  const departments = React.useMemo(() => uniqueInOrder(items.map(item => item.department)), [items])\n  const places = React.useMemo(\n    () => uniqueInOrder(items.flatMap(item => (item.location.place ? [item.location.place] : []))),\n    [items],\n  )\n\n  /**\n   * a filter that can't change the result is a fake control: with one department the dropdown\n   * always yields the same set, and with no places at all it holds only \"All locations\".\n   * in both cases the control simply isn't rendered.\n   */\n  const departmentEnabled = departments.length > 1\n  const placeEnabled = places.length > 0\n\n  /**\n   * stale-selection fallback: after new data arrives the previously selected department / place\n   * may no longer exist (or its control may no longer render). without this the component keeps\n   * filtering on a value the user can neither see nor clear.\n   */\n  const department =\n    departmentEnabled && departments.includes(departmentValue) ? departmentValue : ALL\n  const place = placeEnabled && places.includes(placeValue) ? placeValue : ALL\n  const filtersActive = department !== ALL || place !== ALL || remoteOnly\n\n  // the three dimensions are ANDed: department ∩ location ∩ remote; an unset one doesn't narrow anything\n  const visible = React.useMemo(\n    () =>\n      items.filter(\n        item =>\n          (department === ALL || item.department === department) &&\n          (place === ALL || item.location.place === place) &&\n          (!remoteOnly || item.location.mode === \"remote\"),\n      ),\n    [department, items, place, remoteOnly],\n  )\n\n  // group order = first appearance of each department in the data; groups with no matches aren't rendered\n  const groups = React.useMemo(() => {\n    const buckets = new Map<string, JobListingsItem[]>()\n    for (const item of visible) {\n      const bucket = buckets.get(item.department)\n      if (bucket) bucket.push(item)\n      else buckets.set(item.department, [item])\n    }\n    return [...buckets].map(([name, jobs]) => ({ jobs, name }))\n  }, [visible])\n\n  const clearFilters = React.useCallback(() => {\n    setDepartmentValue(ALL)\n    setPlaceValue(ALL)\n    setRemoteOnly(false)\n  }, [])\n\n  // with no section heading the department headings are this block's top level — don't skip a level\n  const GroupHeading = heading ? \"h3\" : \"h2\"\n  const titleTag = heading ? \"h4\" : \"h3\"\n\n  return (\n    <section\n      // the heading only renders in the ready branch, so other states must not carry aria-labelledby —\n      // pointing at a missing id leaves the section both unnamed and not a landmark\n      aria-labelledby={status === \"ready\" && heading ? `${baseId}-heading` : undefined}\n      className={cn(\"flex w-full flex-col gap-6\", className)}\n    >\n      {status === \"ready\" && (heading || subheading) && (\n        <div className=\"flex max-w-2xl flex-col gap-2\">\n          {heading && (\n            <h2 className=\"text-2xl font-semibold tracking-tight\" id={`${baseId}-heading`}>\n              {heading}\n            </h2>\n          )}\n          {subheading && <p className=\"text-sm text-muted-foreground\">{subheading}</p>}\n        </div>\n      )}\n\n      {status === \"loading\" && (\n        <>\n          <span className=\"sr-only\" role=\"status\">\n            Loading open roles\n          </span>\n          <div aria-hidden=\"true\" className=\"flex flex-col gap-6\">\n            <div className=\"flex flex-col gap-2 sm:flex-row sm:gap-3\">\n              <div className={cn(\"h-9 flex-1 sm:max-w-56\", pulse)} />\n              <div className={cn(\"h-9 flex-1 sm:max-w-56\", pulse)} />\n              <div className={cn(\"h-9 w-32\", pulse)} />\n            </div>\n            {[3, 2].map((rows, group) => (\n              <div className=\"flex flex-col gap-3\" key={group}>\n                <div className={cn(\"h-5 w-40\", pulse)} />\n                <div className=\"flex flex-col divide-y border-y\">\n                  {Array.from({ length: rows }, (_, row) => (\n                    <div className=\"flex items-center justify-between gap-6 px-1 py-4\" key={row}>\n                      <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n                        <div className={cn(\"h-4 w-2/3 max-w-64\", pulse)} />\n                        <div className={cn(\"h-3 w-40\", pulse)} />\n                      </div>\n                      <div className={cn(\"h-8 w-24 shrink-0\", pulse)} />\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n        </>\n      )}\n\n      {status === \"empty\" && (\n        <div className=\"flex flex-col items-center gap-2 rounded-xl border bg-card py-14 text-center\">\n          <p className=\"text-sm font-medium\">No open roles right now</p>\n          <p className=\"text-sm text-muted-foreground\">\n            This page updates the moment a new position opens.\n          </p>\n        </div>\n      )}\n\n      {status === \"error\" && (\n        <div className=\"flex flex-col items-center gap-3 rounded-xl border bg-card py-14 text-center\">\n          <p className=\"text-sm font-medium\">Couldn&apos;t load the open roles</p>\n          <p className=\"text-sm text-muted-foreground\">The job board didn&apos;t respond.</p>\n          {onRetry && (\n            <button\n              className={cn(\n                \"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n              )}\n              onClick={onRetry}\n              type=\"button\"\n            >\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {status === \"ready\" && (\n        <>\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3\">\n              {departmentEnabled && (\n                <FilterSelect\n                  allLabel=\"All departments\"\n                  id={`${baseId}-department`}\n                  label=\"Filter by department\"\n                  onChange={setDepartmentValue}\n                  options={departments}\n                  value={department}\n                />\n              )}\n              {placeEnabled && (\n                <FilterSelect\n                  allLabel=\"All locations\"\n                  id={`${baseId}-location`}\n                  label=\"Filter by location\"\n                  onChange={setPlaceValue}\n                  options={places}\n                  value={place}\n                />\n              )}\n              <button\n                aria-pressed={remoteOnly}\n                className={cn(\n                  \"inline-flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors\",\n                  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                  remoteOnly ? \"border-primary bg-primary/10 font-medium\" : \"hover:bg-muted\",\n                )}\n                onClick={() => setRemoteOnly(value => !value)}\n                type=\"button\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"size-2 rounded-full\",\n                    remoteOnly ? \"bg-primary\" : \"bg-muted-foreground/40\",\n                  )}\n                />\n                Remote only\n              </button>\n\n              {filtersActive && (\n                <button\n                  className={cn(\n                    \"inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-2 py-2 text-sm\",\n                    \"text-muted-foreground transition-colors hover:text-foreground\",\n                    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                  )}\n                  onClick={clearFilters}\n                  type=\"button\"\n                >\n                  <X aria-hidden=\"true\" className=\"size-3.5\" />\n                  Clear filters\n                </button>\n              )}\n            </div>\n\n            {/* the count reads the post-filter array length and doubles as the live region announcing the result */}\n            <p className=\"text-sm text-muted-foreground\" role=\"status\">\n              {filtersActive\n                ? `Showing ${visible.length} of ${plural(items.length, \"open role\")}`\n                : plural(items.length, \"open role\")}\n            </p>\n          </div>\n\n          {groups.length === 0 ? (\n            /*\n              \"filtered to nothing\" and \"no openings\" are different things: this says the roles are\n              still there and the search is just too narrow, and offers a real way out\n              (the same clearFilters as the filter bar).\n            */\n            <div className=\"flex flex-col items-center gap-3 rounded-xl border border-dashed bg-card py-14 text-center\">\n              <p className=\"text-sm font-medium\">No roles match these filters</p>\n              <p className=\"text-sm text-muted-foreground\">\n                All {plural(items.length, \"open role\")} are still listed — widen the search to see them.\n              </p>\n              <button\n                className={cn(\n                  \"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted\",\n                  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                )}\n                onClick={clearFilters}\n                type=\"button\"\n              >\n                Clear filters\n              </button>\n            </div>\n          ) : (\n            <div className=\"flex flex-col gap-8\">\n              {groups.map((group, index) => (\n                <section\n                  aria-labelledby={`${baseId}-group-${index}`}\n                  className=\"flex flex-col gap-3\"\n                  key={group.name}\n                >\n                  <div className=\"flex items-baseline gap-2\">\n                    <GroupHeading\n                      className=\"text-base font-semibold tracking-tight\"\n                      id={`${baseId}-group-${index}`}\n                    >\n                      {group.name}\n                    </GroupHeading>\n                    <span className=\"text-xs tabular-nums text-muted-foreground\">\n                      {plural(group.jobs.length, \"role\")}\n                    </span>\n                  </div>\n                  <ul className=\"flex flex-col divide-y border-y\">\n                    {group.jobs.map(item => (\n                      <JobRow\n                        formatter={formatter}\n                        headingTag={titleTag}\n                        item={item}\n                        key={item.id}\n                        undisclosedSalaryLabel={undisclosedSalaryLabel}\n                      />\n                    ))}\n                  </ul>\n                </section>\n              ))}\n            </div>\n          )}\n        </>\n      )}\n    </section>\n  )\n}\n\nexport default JobListings\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/job-listings.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * interaction-honesty red line: an apply link must point at a real destination (ATS posting / mailto).\n * the contract rejects dead anchors outright, so no \"render an Apply button for now\" fake link ships.\n */\nconst hrefSchema = z\n  .string()\n  .min(1)\n  .refine(href => href.trim() !== \"\" && !href.trim().startsWith(\"#\"), {\n    message: 'href must point at a real application destination — dead anchors like \"#\" are rejected',\n  })\n\n/**\n * \"remote\" and \"city\" are two dimensions — Remote must not be stuffed into the city field:\n * `mode` is the role's relationship to a place, `place` is which place.\n * so on-site and hybrid roles in the same city land under one location filter entry,\n * while remote is its own dimension and the two combine freely.\n */\nexport const jobLocationSchema = z.object({\n  mode: z.enum([\"remote\", \"hybrid\", \"onsite\"]),\n  /**\n   * a city or region (\"Berlin\" / \"EU time zones\").\n   * a remote role can be location-free — leave this empty and it only shows under \"All locations\",\n   * found via the Remote filter; an empty hybrid / on-site place renders the mode word alone, never a stub.\n   */\n  place: z.string().min(1).optional(),\n})\n\n/**\n * salary range. **optional**: plenty of companies don't publish one, and when it's missing the\n * component renders explicit copy instead of blank space.\n * some jurisdictions (many US states, EU pay transparency) require it, which is why this is a\n * first-class field rather than free text smuggled into the title.\n */\nexport const jobSalarySchema = z.object({\n  /** lower bound */\n  min: z.number().nonnegative(),\n  /** upper bound; when equal to min the component renders a single number rather than \"X – X\" */\n  max: z.number().nonnegative(),\n  /** currency symbol prefix, e.g. \"$\" / \"€\" — per role, so one page can mix currencies */\n  currency: z.string().min(1),\n  /** pay period; picks the suffix / yr · / mo · / hr */\n  period: z.enum([\"year\", \"month\", \"hour\"]),\n})\n\nexport const jobEmploymentSchema = z.enum([\"full-time\", \"part-time\", \"contract\", \"internship\"])\n\nexport const jobListingsItemSchema = z.object({\n  id: z.string(),\n  /** role name; long titles wrap and are never truncated */\n  title: z.string().min(1),\n  /** grouping key + department filter option; groups follow first appearance, so the host sets priority */\n  department: z.string().min(1),\n  location: jobLocationSchema,\n  employment: jobEmploymentSchema,\n  /** absent = not disclosed; the component shows undisclosedSalaryLabel */\n  salary: jobSalarySchema.optional(),\n  /** application URL (ATS detail page / mailto); dead anchors are rejected at parse time */\n  href: hrefSchema,\n})\n\nexport const jobListingsSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  heading: z.string().optional(),\n  subheading: z.string().optional(),\n  items: z.array(jobListingsItemSchema),\n})\n\nexport type JobEmployment = z.infer<typeof jobEmploymentSchema>\nexport type JobLocation = z.infer<typeof jobLocationSchema>\nexport type JobSalary = z.infer<typeof jobSalarySchema>\nexport type JobListingsItem = z.infer<typeof jobListingsItemSchema>\nexport type JobListingsData = z.infer<typeof jobListingsSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}