Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Grid List

A list of rows that each hold their own buttons, links or fields, such as files with Share and Delete, people with a menu, or cards in a gallery.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { GridList } from '@hamur/react/grid-list'

interface File {
  value: string
  name: string
  details: string
}

const initial: File[] = [
  { value: 'report', name: 'Report.pdf', details: '2.4 MB, edited today' },
  { value: 'invoice', name: 'Invoice.pdf', details: '180 KB, edited yesterday' },
  { value: 'notes', name: 'Notes.md', details: '12 KB, edited Monday' },
  { value: 'photo', name: 'Photo.jpg', details: '3.1 MB, edited last week' },
]

export default function GridListExample() {
  const [files, setFiles] = React.useState(initial)
  const [last, setLast] = React.useState('none')
  return (
    <div className="stack">
      <GridList.Root
        className="grid-list"
        aria-label="Files"
        selectable
        defaultValue="report"
      >
        {files.map((file) => (
          <GridList.Item
            key={file.value}
            value={file.value}
            className="grid-list-item"
            onAction={() => setLast(`Opened ${file.name}`)}
          >
            <GridList.ItemContent className="grid-list-cell">
              <span className="grid-list-body">
                <GridList.ItemText className="grid-list-text">
                  {file.name}
                </GridList.ItemText>
                <GridList.ItemDescription
                  className="grid-list-description"
                >
                  {file.details}
                </GridList.ItemDescription>
              </span>
              <button
                type="button"
                className="grid-list-action"
                onClick={() => setLast(`Shared ${file.name}`)}
              >
                Share
              </button>
              <button
                type="button"
                className="grid-list-action"
                aria-label={`Delete ${file.name}`}
                onClick={() => {
                  setFiles((previous) => previous.filter((f) => f.value !== file.value))
                  setLast(`Deleted ${file.name}`)
                }}
              >
                Delete
              </button>
            </GridList.ItemContent>
          </GridList.Item>
        ))}
        <GridList.Empty className="grid-list-empty">
          No files
        </GridList.Empty>
      </GridList.Root>
      <p className="muted">Last action: {last}</p>
    </div>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<GridList.Root>
  <GridList.Item>
    <GridList.ItemContent>
      <GridList.ItemText />
      <GridList.ItemDescription />
    </GridList.ItemContent>
  </GridList.Item>
  <GridList.Empty />
</GridList.Root>

// Not in this example: GridList.Group, GridList.GroupLabel, GridList.ItemHandle, GridList.ItemIndicator

Examples

Multiple

multiple with a checkbox per row: Alan Turing is disabled, so Control+A and ranges skip him; the selected values are listed below.

GridListMultiple.tsx
import * as React from 'react'
import { GridList } from '@hamur/react/grid-list'

const people = [
  { value: 'ada', name: 'Ada Lovelace', email: 'ada@example.com' },
  { value: 'grace', name: 'Grace Hopper', email: 'grace@example.com' },
  { value: 'alan', name: 'Alan Turing', email: 'alan@example.com', away: true },
  { value: 'katherine', name: 'Katherine Johnson', email: 'katherine@example.com' },
  { value: 'edsger', name: 'Edsger Dijkstra', email: 'edsger@example.com' },
]

export default function GridListMultipleExample() {
  const [value, setValue] = React.useState<string[]>(['ada'])
  return (
    <div className="stack">
      <GridList.Root
        className="grid-list"
        aria-label="Recipients"
        selectable
        multiple
        value={value}
        onValueChange={setValue}
      >
        {people.map((person) => (
          <GridList.Item
            key={person.value}
            value={person.value}
            disabled={person.away}
            className="grid-list-item"
          >
            <GridList.ItemContent className="grid-list-cell">
              <GridList.ItemIndicator
                keepMounted
                className="grid-list-check"
              >
                <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
                  <path d="M3 8.5l3.5 3.5 6.5-8" />
                </svg>
              </GridList.ItemIndicator>
              <span className="grid-list-body">
                <GridList.ItemText className="grid-list-text">
                  {person.name}
                </GridList.ItemText>
                <GridList.ItemDescription
                  className="grid-list-description"
                >
                  {person.away ? 'Away until Monday' : person.email}
                </GridList.ItemDescription>
              </span>
            </GridList.ItemContent>
          </GridList.Item>
        ))}
      </GridList.Root>
      <p className="muted">Selected: {value.join(', ') || 'none'}</p>
    </div>
  )
}

Layout

layout="grid": eight album cards in as many columns as fit, ArrowDown keeping the column and Home and End the line.

GridListLayout.tsx
import { GridList } from '@hamur/react/grid-list'

const albums = [
  { value: 'dawn', name: 'Dawn', count: '24 photos' },
  { value: 'harbor', name: 'Harbor', count: '18 photos' },
  { value: 'forest', name: 'Forest', count: '41 photos' },
  { value: 'desert', name: 'Desert', count: '12 photos' },
  { value: 'city', name: 'City', count: '57 photos' },
  { value: 'snow', name: 'Snow', count: '9 photos' },
  { value: 'garden', name: 'Garden', count: '33 photos' },
  { value: 'coast', name: 'Coast', count: '27 photos' },
]

export default function GridListLayoutExample() {
  return (
    <GridList.Root
      className="grid-list grid-list-cards"
      aria-label="Albums"
      layout="grid"
      selectable
      defaultValue="forest"
    >
      {albums.map((album) => (
        <GridList.Item
          key={album.value}
          value={album.value}
          className="grid-list-item"
        >
          <GridList.ItemContent className="grid-list-cell grid-list-card">
            <span className="grid-list-cover" aria-hidden="true" />
            <GridList.ItemText className="grid-list-text">
              {album.name}
            </GridList.ItemText>
            <GridList.ItemDescription
              className="grid-list-description"
            >
              {album.count}
            </GridList.ItemDescription>
          </GridList.ItemContent>
        </GridList.Item>
      ))}
    </GridList.Root>
  )
}

Move

allowMove with an ItemHandle per row: drag by the handle, or Control+X and Control+V; onItemMove reorders the playlist.

GridListMove.tsx
import * as React from 'react'
import { GridList } from '@hamur/react/grid-list'

const labels: Record<string, string> = {
  intro: 'Intro',
  river: 'River Song',
  night: 'Night Drive',
  morning: 'Morning Light',
  outro: 'Outro',
}

interface Move {
  items: string[]
  target: string
  position: 'before' | 'after'
}

/** The app applies a move to its own data: the moved rows out, then in before or after the target. */
function applyMove(order: string[], { items, target, position }: Move) {
  const rest = order.filter((value) => !items.includes(value))
  const index = rest.indexOf(target) + (position === 'after' ? 1 : 0)
  return [...rest.slice(0, index), ...items, ...rest.slice(index)]
}

export default function GridListMoveExample() {
  const [order, setOrder] = React.useState(Object.keys(labels))
  return (
    <div className="stack">
      <GridList.Root
        className="grid-list"
        aria-label="Playlist"
        allowMove
        onItemMove={(move: Move) => setOrder((previous) => applyMove(previous, move))}
      >
        {order.map((value, index) => (
          <GridList.Item key={value} value={value} className="grid-list-item">
            <GridList.ItemContent className="grid-list-cell">
              <GridList.ItemHandle className="grid-list-handle">
                <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
                  <path d="M6 3.5h.01M10 3.5h.01M6 8h.01M10 8h.01M6 12.5h.01M10 12.5h.01" />
                </svg>
              </GridList.ItemHandle>
              <span className="grid-list-number">{index + 1}</span>
              <GridList.ItemText className="grid-list-text">
                {labels[value]}
              </GridList.ItemText>
            </GridList.ItemContent>
          </GridList.Item>
        ))}
      </GridList.Root>
      <p className="muted">
        Drag a row by its handle, or press Ctrl+X on it, move and press Ctrl+V.
      </p>
    </div>
  )
}

Virtualized

1,000 rows as items, virtualized, with fewer than 30 rows mounted; Home, End and the page keys reach rows that are not mounted.

GridListVirtualized.tsx
import * as React from 'react'
import { GridList } from '@hamur/react/grid-list'

// Rows of a fixed height, windowed by hand: only the rows near the scroll position render.
const ROW = 40
const VIEWPORT = 280
const OVERSCAN = 10
const COUNT = 1000

// Every item, mounted or not: Home, End, the page keys and typeahead work on it.
const items = Array.from({ length: COUNT }, (_, index) => index + 1)
const getLabel = (item: number) => `Message ${String(item).padStart(4, '0')}`

export default function GridListVirtualizedExample() {
  const scrollerRef = React.useRef<HTMLDivElement | null>(null)
  const [scrollTop, setScrollTop] = React.useState(0)
  const first = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN)
  const last = Math.min(COUNT, Math.ceil((scrollTop + VIEWPORT) / ROW) + OVERSCAN)
  // The list asks for the row at `index` to be scrolled into view, then focuses it once it mounts.
  function scrollToIndex(index: number) {
    const scroller = scrollerRef.current
    if (!scroller) {
      return
    }
    const top = index * ROW
    if (top < scroller.scrollTop) {
      scroller.scrollTop = top
    } else if (top + ROW > scroller.scrollTop + scroller.clientHeight) {
      scroller.scrollTop = top + ROW - scroller.clientHeight
    }
    setScrollTop(scroller.scrollTop)
  }

  return (
    <div className="field">
      <div id="grid-list-virtual-label" className="grid-list-label">
        1,000 messages
      </div>
      <div
        ref={scrollerRef}
        className="grid-list-scroller"
        onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
      >
        <GridList.Root
          className="grid-list grid-list-virtual"
          aria-labelledby="grid-list-virtual-label"
          items={items}
          virtualized
          itemToStringLabel={getLabel}
          onItemHighlighted={(_item: number, { index }: { index: number }) => scrollToIndex(index)}
          style={{ height: COUNT * ROW }}
        >
          {items.slice(first, last).map((item, offset) => (
            <GridList.Item
              key={item}
              value={item}
              index={first + offset}
              className="grid-list-item"
              style={{ transform: `translateY(${(first + offset) * ROW}px)` }}
            >
              <GridList.ItemContent className="grid-list-cell">
                <GridList.ItemText className="grid-list-text">
                  {getLabel(item)}
                </GridList.ItemText>
              </GridList.ItemContent>
            </GridList.Item>
          ))}
        </GridList.Root>
      </div>
    </div>
  )
}

API reference

35 props across 10 parts, in React syntax. className, style, render are listed once, under Shared props.

EmptyGridList.Empty

Content shown while the list has no rows. Renders nothing otherwise. It has no ARIA role. Renders a <div>.

GroupGridList.Group

Groups related rows with the corresponding label. Renders a <div>.

GroupLabelGridList.GroupLabel

A label that names its group. Hidden from assistive technology, which reads it as the group's name. Renders a <div>.

ItemGridList.Item

A row of the grid list, with its one cell (GridList.ItemContent). Renders a <div>.

Item props
PropTypeDefault
childrenReact.ReactNode—
valuerequiredA unique value that identifies this row.any—
labelOverrides the text that typeahead matches and the announcements read.string—
disabledWhether the component should ignore user interaction.booleanfalse
selectableWhether the row can be selected, in a list that is selectable.booleantrue
onActionCalled when the row is activated: Enter, or a press or Space on a row that does not select.((eventDetails: GridListActionDetails) => void)—
indexVirtualized only: the position of the row in items.number—
Item data attributes
Data attributeDescription
data-selectedPresent when the row is selected.
data-highlightedPresent when focus is on the row or inside it.
data-disabledPresent when the row is disabled.
data-movingPresent while the row is being moved.
data-drop-targetWhere a dragged row would land relative to this one.
data-draggingPresent while the row is being dragged.

ItemContentGridList.ItemContent

The one cell of a row: its text, description, indicator and controls. Renders a <div>.

ItemDescriptionGridList.ItemDescription

A description of a row, read after its name. Renders a <span>.

ItemHandleGridList.ItemHandle

The part of a row a drag starts from. While it is mounted, the rest of the row does not start a drag. Renders a <span>.

ItemHandle props
PropTypeDefault
childrenReact.ReactNode—

ItemIndicatorGridList.ItemIndicator

Shows whether the row is selected. A press toggles the row's selection. Renders a <span>.

ItemIndicator props
PropTypeDefault
childrenReact.ReactNode—
keepMountedWhether to keep the element in the DOM when the row is not selected.booleanfalse

ItemTextGridList.ItemText

The text of a row: it names the row, and typeahead matches it. Renders a <span>.

RootGridList.Root

Groups all parts of the grid list: a list of rows that hold their own buttons, links and inputs., followed by the status region. Renders a <div>.

Root props
PropTypeDefault
valueThe selected value.GridListInputValue<Value, Multiple> | null—
defaultValueThe uncontrolled selected value when the list is initially rendered.GridListInputValue<Value, Multiple> | null—
onValueChangeEvent handler called when the selection changes, once per gesture.((value: GridListOutputValue<Value, Multiple>, eventDetails: GridListRootChangeEventDetails) => void)—
selectableWhether rows can be selected at all.booleanfalse
multipleWhether several rows can be selected.Multiplefalse
disallowEmptySelectionWhether the last selected row of a multiple list cannot be deselected.booleanfalse
selectOnPressWhether a press on a selectable row selects it.booleantrue
isItemEqualToValueCustom comparison logic used to determine if an item value matches the current value.((itemValue: Value, value: Value) => boolean)—
layoutHow the rows are laid out: one row per line along orientation ('list'), or wrappedGridListLayout'list'
orientationThe orientation of layout="list".GridListOrientation'vertical'
columnsThe column count of layout="grid".number—
loopFocusWhether the arrow keys wrap from the last row to the first and back.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
highlightedValueThe value of the focused row.Value | null—
onHighlightChangeEvent handler called when the current row changes (focus on a row or inside it).((value: Value | null) => void)—
itemsEvery item of the list in order, mounted or not.readonly Value[]—
virtualizedWhether the app renders only some rows (a virtualizer), each with its index.booleanfalse
itemToStringLabelConverts an item of items to its typeahead text.((item: Value) => string)—
isItemDisabledWhether an item of items is disabled, for rows that are not mounted.((item: Value) => boolean)—
onItemHighlightedVirtualized only: called when keyboard navigation (or highlightedValue) targets a row that((item: Value, eventDetails: GridListHighlightDetails) => void)—
allowMoveWhether rows can be moved by dragging, with Ctrl/Cmd+X and Ctrl/Cmd+V, and throughbooleanfalse
canMoveWhether a move may happen.((details: GridListMoveDetails<Value>) => boolean)—
onItemMoveCalled with a move.((details: GridListMoveDetails<Value>, eventDetails: GridListRootItemMoveEventDetails) => void)—
actionsRefA ref to start, complete and cancel a move from the app (a context menu's "Move").React.RefObject<GridListRootActions | null>—
labelsThe texts of the status region.Partial<GridListLabels>—
Root data attributes
Data attributeDescription
data-layoutIndicates how the rows are laid out.
data-orientationIndicates the orientation of the list layout.
data-multiplePresent when several rows can be selected.
data-selectablePresent when rows can be selected.
data-disabledPresent when the list is disabled.
data-emptyPresent when the list has no rows.
data-movingPresent while a move is pending.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
ArrowDownArrowUpMoves focus to the next or previous row, disabled rows includedRoot
ArrowRightArrowLeftMoves into the row's controls and through them; ArrowLeft from the first control returns to the rowItem
HomeEndMoves focus to the first or last row; in the wrapped layout, the first or last card of the lineRoot
PageDownPageUpMoves focus ten rows and stops at the endsRoot
TabShiftTabMoves through the focused row's controls, then out of the listItem
F2Moves from the row to its first control and backItem
EscapeFrom a control, returns to its row; on a row, cancels a move in progressItem
Moves focus to the next row whose text starts with what was typedRoot
EnterRuns the row's onAction; without one, selects the rowItem
SpaceSelects the focused row; with multiple, toggles itItem
ShiftArrowDownShiftArrowUpWith multiple, extends the selection from the anchorRoot
ControlAMetaAWith multiple, selects every enabled row, or clears them when all are selectedRoot
ControlXControlVWith allowMove, takes the row and puts it before the focused row going up, after it going downItem

Accessibility

What the parts set
PartSetsSo that
Rootrole="grid", aria-multiselectable with multiple, aria-disabled, aria-rowcount when virtualized, never aria-orientation; named by aria-label or aria-labelledbythe list is read as one widget with its name; a development build warns without one
Itemrole="row", named by its ItemText (aria-labelledby) and described by its ItemDescription (aria-describedby); aria-rowindex when virtualizeda row is read as "Report.pdf", not as the text of its buttons, and says where it is when its neighbours are not mounted
Itemaria-selected (true or false) on a selectable row only, aria-disabled on a disabled rowa list without selection says nothing about it; a disabled row keeps focus and is read as unavailable
ItemContentrole="gridcell", the row's only element childthe row's controls keep their own roles inside one cell
ItemIndicatoraria-hidden, never focusablethe check is for sight; the row's state says it
ItemHandlearia-hidden, never focusablethe drag handle is for the pointer; Control+X and Control+V move from the keyboard
Grouprole="rowgroup", labelled by its GroupLabel, which is aria-hiddenthe rows are read under their heading once
Rootone polite role="status" region next to the list, always mountedselecting, ranges, Control+A and moves are announced, since readers do not announce aria-selected changes on grid rows

Focus order

  1. The list is one Tab stop: the last focused row, or the first selected one, or the first row. Nothing takes focus on mount; Shift+Tab into the list lands on the row, not on the last row's button.
  2. Inside, the arrow keys, Home, End, the page keys and typeahead move between rows; Tab from a row walks its controls, then leaves the list.
  3. Escape or F2 from a control returns to its row; removing the focused row moves focus to the next row, else the previous one.