Qure UI
Components

Worklist

The scrolling patient list from qTrack — a column of rows with one of them current.

Rohan Sharma

MACC-482913

Anika Desai

FACC-482914

3 unread messages

Vikram Iyer

MACC-482915

Meera Krishnan

FACC-482916

1 unread message
'use client'import { useState } from 'react'import {  Worklist, WorklistItem, WorklistItemAvatar, WorklistItemContent,  WorklistItemMeta, WorklistItemName, WorklistItemSeparator,} from '@/registry/qure/ui/worklist'const PATIENTS = [  { uid: 'p1', name: 'Rohan Sharma', initials: 'RS', sex: 'M', id: 'ACC-482913', unread: 0 },  { uid: 'p2', name: 'Anika Desai', initials: 'AD', sex: 'F', id: 'ACC-482914', unread: 3 },  { uid: 'p3', name: 'Vikram Iyer', initials: 'VI', sex: 'M', id: 'ACC-482915', unread: 0 },  { uid: 'p4', name: 'Meera Krishnan', initials: 'MK', sex: 'F', id: 'ACC-482916', unread: 1 },]export default function WorklistDemo() {  const [selected, setSelected] = useState('p2')  return (    <Worklist      aria-label="Patients"      value={selected}      onValueChange={setSelected}      className="w-full max-w-xs"    >      {PATIENTS.map((p) => (        <WorklistItem key={p.uid} value={p.uid} unread={p.unread || undefined}>          <WorklistItemAvatar>{p.initials}</WorklistItemAvatar>          <WorklistItemContent>            <WorklistItemName>{p.name}</WorklistItemName>            <WorklistItemMeta>              <span className="shrink-0">{p.sex}</span>              <WorklistItemSeparator />              <span className="truncate">{p.id}</span>            </WorklistItemMeta>          </WorklistItemContent>        </WorklistItem>      ))}    </Worklist>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/worklist.json

Usage

import {
  Worklist, WorklistItem, WorklistItemAvatar, WorklistItemContent,
  WorklistItemMeta, WorklistItemName, WorklistItemSeparator,
} from '@/components/ui/worklist'
<Worklist aria-label="Patients" value={selected} onValueChange={setSelected}>
  <WorklistItem value={patient.uid} unread={patient.unreadCount}>
    <WorklistItemAvatar>{initials}</WorklistItemAvatar>
    <WorklistItemContent>
      <WorklistItemName>{patient.name}</WorklistItemName>
      <WorklistItemMeta>
        <span className="shrink-0">M</span>
        <WorklistItemSeparator />
        <span className="truncate">{patient.accession}</span>
      </WorklistItemMeta>
    </WorklistItemContent>
  </WorklistItem>
</Worklist>

This one comes from the app rather than from Figma. It is src/components/patient-worklist/ in qtrack-lc with the data layer left behind — the store, the debounce and the analytics are the app's; the row is the design system's.

It is a listbox, not a nav

Choosing a row swaps what the panel beside it shows, which is a selection, so this is a listbox and each row an option. If yours changes the URL instead, you want NavListrole="option" on a router link tells a screen reader there is a list to arrow through and then navigates out from under them.

role="option" makes a promise the app does not currently keep: its rows are click handlers with no tabIndex, so nothing in the list is reachable from the keyboard at all. The role says "arrow through this"; there is nothing to arrow.

Fixed here. The selected row holds the only tab stop, the arrow keys move focus, Home and End jump to the ends, Enter and Space select — the roving-tabindex pattern the APG asks for. Worth carrying back into qTrack.

Keyboard

KeyDoes
TabEnters at the selected row, or the first one.
Move focus. Selection follows on Enter, not on focus.
Home EndFirst and last row.
Enter SpaceSelect the focused row.

Focus and selection are deliberately separate. In a follow-focus listbox, arrowing through 40 patients loads 40 patient records; here you can look before you commit.

Loading

'use client'import { Worklist, WorklistItemSkeleton } from '@/registry/qure/ui/worklist'export default function WorklistLoading() {  return (    <Worklist aria-label="Patients" loading className="w-full max-w-xs">      {Array.from({ length: 5 }).map((_, i) => (        <WorklistItemSkeleton key={i} />      ))}    </Worklist>  )}

loading puts aria-busy on the list, and the skeletons are aria-hidden. Both matter: a listbox may only contain options and groups, so eight placeholder divs inside one are invalid markup that reads aloud as nothing at all — an empty list, which is the wrong answer. aria-busy is what says something is on its way.

The app's skeleton draws a 36px disc where its own row draws 28px, so the placeholder is wider than the thing it stands in for and the list nudges sideways when the data arrives. This one is 28px.

In a panel

Rohan Sharma

MACC-482913

Anika Desai

FACC-482914

Vikram Iyer

MACC-482915

'use client'import { useState } from 'react'import { SearchIcon } from 'lucide-react'import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from '@/registry/qure/ui/empty'import { Input } from '@/registry/qure/ui/input'import {  Worklist, WorklistItem, WorklistItemAvatar, WorklistItemContent,  WorklistItemMeta, WorklistItemName, WorklistItemSeparator,} from '@/registry/qure/ui/worklist'const PATIENTS = [  { uid: 'p1', name: 'Rohan Sharma', initials: 'RS', sex: 'M', id: 'ACC-482913' },  { uid: 'p2', name: 'Anika Desai', initials: 'AD', sex: 'F', id: 'ACC-482914' },  { uid: 'p3', name: 'Vikram Iyer', initials: 'VI', sex: 'M', id: 'ACC-482915' },]export default function WorklistPanel() {  const [query, setQuery] = useState('')  const [selected, setSelected] = useState('p1')  const matches = PATIENTS.filter((p) =>    `${p.name} ${p.id}`.toLowerCase().includes(query.trim().toLowerCase())  )  return (    <div className="bg-card flex h-80 w-full max-w-xs flex-col gap-3 rounded-lg border p-3">      <Input        type="search"        value={query}        onChange={(e) => setQuery(e.target.value)}        aria-label="Search patients"        placeholder="Search patients"      />      {matches.length > 0 ? (        <Worklist          aria-label="Patients"          value={selected}          onValueChange={setSelected}          className="-mx-1 flex-1 px-1"        >          {matches.map((p) => (            <WorklistItem key={p.uid} value={p.uid}>              <WorklistItemAvatar>{p.initials}</WorklistItemAvatar>              <WorklistItemContent>                <WorklistItemName>{p.name}</WorklistItemName>                <WorklistItemMeta>                  <span className="shrink-0">{p.sex}</span>                  <WorklistItemSeparator />                  <span className="truncate">{p.id}</span>                </WorklistItemMeta>              </WorklistItemContent>            </WorklistItem>          ))}        </Worklist>      ) : (        <Empty className="flex-1">          <EmptyMedia>            <SearchIcon />          </EmptyMedia>          <EmptyTitle>No patients found</EmptyTitle>          <EmptyDescription>Nothing matches “{query}”.</EmptyDescription>        </Empty>      )}    </div>  )}

The whole shape, roughly as qTrack assembles it: a search field, the list, and Empty when nothing matches.

The search field is a plain Input — there is no WorklistSearch here, because the app's version is a 500ms debounce around a store write, and none of that is a component. Debounce in your own code and pass the result down.

The meta line

Separate the parts with WorklistItemSeparator rather than typing a |. A literal pipe between two spans is a character in the accessible name, and "M vertical line ACC 482913" is how some screen readers will say it.

Unread

unread takes a count or true. Pass the count where you have one — a dot on its own says something happened, a number says how much, and the difference decides whether someone opens the row now or later.

API Reference

Worklist

Renders the role="listbox" container.

PropTypeDefaultDescription
valuestringThe selected row. Pair with onValueChange.
defaultValuestringSelected on first render, when uncontrolled.
onValueChange(value: string) => voidFires on click, Enter and Space.
loadingbooleanfalseMarks the list busy while placeholders are up.

Give it an aria-label. An unlabelled listbox is announced as "listbox" and nothing else.

WorklistItem

PropTypeDefaultDescription
valuestringRequired. Identifies the row.
unreadboolean | numberShows the dot. A number is announced.
disabledbooleanfalseSets aria-disabled and drops it out of the arrow order.

A selected row scrolls itself into view with block: "nearest", which matters when the selection arrived from a deep link. Not center — centring scrolls the list even when the row is already visible, and that reads as the page moving for no reason.

The rest

WorklistItemAvatar is the initials disc, WorklistItemContent stacks the two lines, WorklistItemName and WorklistItemMeta are the lines themselves, WorklistItemSeparator is the divider, and WorklistItemSkeleton is a placeholder row at the same height.

On this page