Skip to content

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

LayoutReact

Composite

A group of items that is one tab stop, where the arrow keys move focus between the items (a roving tab stop).

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { Composite } from '@hamur/react/composite'

export default function CompositeExample() {
  const [last, setLast] = React.useState('nothing yet')
  return (
    <div className="stack">
      <Composite.Root
        className="toolbar"
        role="toolbar"
        aria-label="Text formatting"
        orientation="horizontal"
        enableHomeAndEndKeys
      >
        <Composite.Item
          className="toolbar-button"
          render={<button type="button" />}
          onClick={() => setLast('Bold')}
        >
          Bold
        </Composite.Item>
        <Composite.Item
          className="toolbar-button"
          render={<button type="button" />}
          onClick={() => setLast('Italic')}
        >
          Italic
        </Composite.Item>
        <Composite.Item
          className="toolbar-button"
          render={<button type="button" />}
          onClick={() => setLast('Underline')}
        >
          Underline
        </Composite.Item>
        <Composite.Item
          className="toolbar-button"
          render={<button type="button" />}
          disabled
        >
          Strike
        </Composite.Item>
        <Composite.Item
          className="input toolbar-input"
          render={<input />}
          aria-label="Font size"
          defaultValue="14"
        />
      </Composite.Root>
      <p className="muted">Last pressed: {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:

<Composite.Root>
  <Composite.Item />
</Composite.Root>

Examples

Listbox

A vertical listbox that loops, with typeahead; an option is selected on a keyboard move or a click.

CompositeListbox.tsx
import * as React from 'react'
import { Composite } from '@hamur/react/composite'

const fruits = ['Apple', 'Apricot', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango']

export default function CompositeListboxExample() {
  const [selected, setSelected] = React.useState(fruits[0])
  return (
    <div className="stack">
      <Composite.Root
        className="composite-list"
        role="listbox"
        aria-label="Fruit"
        orientation="vertical"
        loopFocus
        typeahead
        enableHomeAndEndKeys
        onHighlightedIndexChange={(index, eventDetails) => {
          if (eventDetails.reason === 'keyboard') setSelected(fruits[index])
        }}
      >
        {fruits.map((fruit) => (
          <Composite.Item
            key={fruit}
            className="composite-option"
            role="option"
            aria-selected={selected === fruit}
            onClick={() => setSelected(fruit)}
          >
            {fruit}
          </Composite.Item>
        ))}
      </Composite.Root>
      <p className="muted">Selected: {selected}</p>
    </div>
  )
}

Grid

A 3 by 3 alignment picker with cols set to 3 and loopFocus off; the tab stop starts in the centre and is the chosen cell.

CompositeGrid.tsx
import * as React from 'react'
import { Composite } from '@hamur/react/composite'

const rows = [
  ['Top left', 'Top', 'Top right'],
  ['Left', 'Center', 'Right'],
  ['Bottom left', 'Bottom', 'Bottom right'],
]
const cells = rows.flat()

export default function CompositeGridExample() {
  const [align, setAlign] = React.useState(4)
  return (
    <div className="stack">
      <Composite.Root
        className="composite-grid"
        role="grid"
        aria-label="Alignment"
        cols={3}
        loopFocus={false}
        enableHomeAndEndKeys
        defaultHighlightedIndex={4}
        onHighlightedIndexChange={(index) => setAlign(index)}
      >
        {rows.map((row, r) => (
          <div key={r} className="composite-row" role="row">
            {row.map((cell, c) => (
              <Composite.Item
                key={cell}
                className="composite-cell"
                role="gridcell"
                aria-label={cell}
                aria-selected={align === r * 3 + c}
              />
            ))}
          </div>
        ))}
      </Composite.Root>
      <p className="muted">Alignment: {cells[align]}</p>
    </div>
  )
}

Controlled

A radio group whose highlightedIndex is the app's state, showing the active index and the reason; a keyboard move checks.

CompositeControlled.tsx
import * as React from 'react'
import { Composite } from '@hamur/react/composite'

const plans = ['Free', 'Pro', 'Team', 'Enterprise']

export default function CompositeControlledExample() {
  const [index, setIndex] = React.useState(0)
  const [reason, setReason] = React.useState('none yet')
  const [plan, setPlan] = React.useState(plans[0])
  return (
    <div className="stack">
      <Composite.Root
        className="composite-radios"
        role="radiogroup"
        aria-label="Plan"
        highlightedIndex={index}
        onHighlightedIndexChange={(next, eventDetails) => {
          setIndex(next)
          setReason(eventDetails.reason)
          if (eventDetails.reason === 'keyboard') setPlan(plans[next])
        }}
      >
        {plans.map((name) => (
          <Composite.Item
            key={name}
            className="composite-radio"
            role="radio"
            aria-checked={plan === name}
            onClick={() => setPlan(name)}
          >
            {name}
          </Composite.Item>
        ))}
      </Composite.Root>
      <p className="muted">
        Active index: {index} · reason: {reason}
      </p>
      <p className="muted">Plan: {plan}</p>
    </div>
  )
}

API reference

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

ItemComposite.Item

An item of the composite: the one that holds the tab stop has tabindex="0", the others -1. Renders a <div>.

Item props
PropTypeDefault
disabledWhether the item is disabled: it gets aria-disabled and stays in the tab order unlessbooleanfalse
focusableWhenDisabledWhether the item remains focusable when disabled.booleantrue
indexThe item's position among the items, when it differs from the DOM order.number—
labelThe text typeahead matches.string—
metadataAny value, published with the item through the Root's onMapChange.unknown—
Item data attributes
Data attributeDescription
data-disabledPresent when the item is disabled.
data-focusablePresent when the item remains focusable when disabled.
data-orientationIndicates the orientation of the composite.

RootComposite.Root

Groups items into one tab stop and moves focus between them with the arrow keys. Renders a <div>.

Root props
PropTypeDefault
orientationThe arrow keys that move focus: Left and Right for 'horizontal', Up and Down forCompositeOrientation'both'
loopFocusWhether focus wraps to the other end of the items once an end is reached; in a grid, withinbooleantrue
onLoopCalled when focus wraps, with the index it leaves and the index it would land on.((event: KeyboardEvent, prevIndex: number, nextIndex: number) => number)—
enableHomeAndEndKeysWhether the Home and End keys move focus to the first and last item.booleanfalse
colsThe number of columns: the items are a grid of this many columns in DOM order.number—
itemSizesThe size of each item in grid cells, by item index.CompositeGridItemSize[]—
denseWhether the grid packs itemSizes densely, as CSS grid-auto-flow: dense.booleanfalse
highlightedIndexThe index of the item that holds the tab stop, in DOM order.number—
defaultHighlightedIndexThe index of the item that initially holds the tab stop, in DOM order.number0
onHighlightedIndexChangeCalled before the tab stop moves to another item.((index: number, eventDetails: CompositeRootChangeEventDetails) => void)—
disabledIndicesThe indices of the items the keys skip.number[]—
typeaheadWhether typing a printable character moves focus to the next item whose label starts withbooleanfalse
stopEventPropagationWhether the keys the composite handles stop propagating.booleantrue
modifierKeysThe modifier keys that may be held while an arrow, Home or End key moves focus.("Shift" | "Control" | "Alt" | "Meta")[][]
onMapChangeCalled with the registered items, in DOM order, whenever they change.((map: Map<Element, CompositeRoot.MapEntry<Metadata>>) => void)—
Root data attributes
Data attributeDescription
data-orientationIndicates the orientation of the composite.

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
ArrowRightArrowLeftMoves to the next or previous item with orientation="horizontal"Root
ArrowDownArrowUpMoves to the next or previous item with orientation="vertical"Root
ArrowRightArrowDownBoth move forward with orientation="both", the defaultRoot
ArrowLeftArrowRightSwap in a right-to-left direction; ArrowUp and ArrowDown never doRoot
ArrowRightWraps to the first item at the end, with loopFocus on by defaultRoot
ArrowRightStops at the last item and lets the key through while loopFocus is offRoot
HomeEndMoves to the first or last item that is not skipped, with enableHomeAndEndKeysRoot
HomeEndAre left to the page without enableHomeAndEndKeysRoot
Moves to the next item whose text starts with it, with typeaheadRoot
ArrowDownArrowUpMoves by row and keeps the column, with colsRoot
ArrowRightArrowLeftMoves within the row, with cols and orientation="both"Root
ArrowRightReaches a disabled Item, which stays focusable by defaultItem
ArrowRightSkips a disabled Item with focusableWhenDisabled offItem
ArrowLeftArrowRightIn a text input Item, move the caret and leave the field only at the end of its textItem
EnterSpaceTabAre left to the item and the pageRoot

Accessibility

What the parts set
PartSetsSo that
Rootno role, no tabindex and no aria-orientation; the app's role and ARIA attributes pass throughthe group is announced as the pattern the app gives it, a toolbar, listbox, grid or radio group
Itemtabindex="0" on the item that holds the tab stop and tabindex="-1" on the others, over the app's own tabindexthe group is one Tab stop, and Tab back into it lands where the reader left
Itemaria-disabled="true" and data-disabled while disabled, never the native disabled attributea disabled item stays in the arrow-key order and is announced as unavailable

Focus order

  1. The group is one Tab stop: the Item that holds the tab stop, the first enabled one until something moves it.
  2. The arrow keys, Home, End and typeahead move focus between the Items; Tab and Shift+Tab leave the group.
  3. A click or a script's focus on another Item makes it the tab stop (focus-in), so Shift+Tab back into the group returns to it.