Frontile

Breadcrumbs

An ordered trail of links ending in the current page, for showing where the current page sits in a hierarchy.

Like TabNav, it does not use roving focus: every crumb is a link, so each one stays individually reachable by Tab and the arrow keys are left to the browser.

Import

import { Breadcrumbs } from 'frontile';

Usage

The docs site has no routes for Breadcrumbs to link to, so every demo on this page uses @href rather than @route. In an app with routes, prefer @route (below). It derives the current crumb from the router for you.

A crumb with no link target at all is the current page: <b.Item> needs no arguments to say "you are here."

import { Breadcrumbs } from 'frontile';

<template>
  <Breadcrumbs as |b|>
    <b.Item @href='/'>Home</b.Item>
    <b.Item @href='/library'>Library</b.Item>
    <b.Item>Data</b.Item>
  </Breadcrumbs>
</template>

Breadcrumbs has two authoring forms. The block form above gives full control over each crumb; use it by default. The @items form, covered under Collapsing long trails, renders the trail from a plain array instead, which is worth it once the trail is long enough to collapse automatically.

Linking to routes

Pass @route (with @model, @models, or @query as needed) and b.Item renders an Ember LinkTo, deriving its current state from the router. No @isCurrent needed. The demo below isn't rendered, since the docs site has no matching routes; it is here as reference.

import { Breadcrumbs } from 'frontile';

<template>
  <Breadcrumbs as |b|>
    <b.Item @route='library.index'>Library</b.Item>
    <b.Item @route='library.item' @model={{@item.id}}>{{@item.title}}</b.Item>
  </Breadcrumbs>
</template>

@isCurrent always wins over the router, in both directions. Pass it to override a route crumb the router would otherwise mark current, or to force one current that isn't.

Collapsing long trails

Once a trail gets long, the middle can be collapsed behind an ellipsis marker. There are two ways to get one, matching the two authoring forms.

@items and @maxItems

Passing @items renders the trail from an array instead of from blocks, and is what makes @maxItems meaningful. Yielded blocks can't be counted before they render, so in the block form there's nothing for @maxItems to divide. Once @items.length exceeds @maxItems, the middle collapses into an ellipsis marker that announces how many crumbs it stands in for. It is the same marker b.Ellipsis renders when you place one by hand, below.

import { Breadcrumbs } from 'frontile';

const trail = [
  { label: 'Home', href: '/' },
  { label: 'Library', href: '/library' },
  { label: 'Data', href: '/library/data' },
  { label: 'Reports', href: '/library/data/reports' },
  { label: 'Q3' }
];

<template>
  <Breadcrumbs @items={{trail}} @maxItems={{3}} />
</template>

@itemsBeforeCollapse and @itemsAfterCollapse (each defaulting to 1) move where the split happens:

import { Breadcrumbs } from 'frontile';

const trail = [
  { label: 'Home', href: '/' },
  { label: 'Library', href: '/library' },
  { label: 'Data', href: '/library/data' },
  { label: 'Reports', href: '/library/data/reports' },
  { label: 'Q3' }
];

<template>
  <Breadcrumbs
    @items={{trail}}
    @maxItems={{4}}
    @itemsBeforeCollapse={{2}}
    @itemsAfterCollapse={{1}}
  />
</template>

Each entry in @items takes label (its text) plus the same arguments as b.Item: route/model/models/query or href, isCurrent, isDisabled. A crumb with neither route nor href is the current page, exactly as in the block form.

A manual <b.Ellipsis />

In the block form, you place the ellipsis yourself. That helps when the trail doesn't come from a flat array, or the collapse point isn't a simple count:

import { Breadcrumbs } from 'frontile';

<template>
  <Breadcrumbs as |b|>
    <b.Item @href='/'>Home</b.Item>
    <b.Ellipsis @hiddenCount={{2}} />
    <b.Item>Q3</b.Item>
  </Breadcrumbs>
</template>

@hiddenCount drives the visually-hidden announcement ("2 more levels"); pass it whenever you know how many crumbs the marker stands in for. Omit it and the announcement falls back to an uncounted "More levels" rather than making you count your own crumbs.

Passing a block replaces the glyph and takes over the announcement entirely. This is where a Dropdown listing the hidden crumbs goes. The block yields hiddenCount and hiddenItems (empty unless you pass @hiddenItems), so you can render the crumbs it stands in for:

import { Breadcrumbs } from 'frontile';

const hidden = [{ label: 'Library' }, { label: 'Data' }];

<template>
  <Breadcrumbs as |b|>
    <b.Item @href='/'>Home</b.Item>
    <b.Ellipsis @hiddenItems={{hidden}} as |e|>
      <button type='button'>
        {{e.hiddenItems.length}}
        hidden &hellip;
      </button>
    </b.Ellipsis>
    <b.Item>Q3</b.Item>
  </Breadcrumbs>
</template>

In the @items form, the same two blocks are available as named blocks: :item to render every crumb yourself, and :ellipsis to render the auto-placed marker. @maxItems still computes the split while you control the markup:

import { Breadcrumbs } from 'frontile';

<template>
  <Breadcrumbs @items={{@trail}} @maxItems={{3}}>
    <:item as |ctx|>
      <li class={{ctx.itemClass}}>
        <span class={{ctx.linkClass}}>{{ctx.item.label}}</span>
      </li>
    </:item>
    <:ellipsis as |e|>
      <button type='button'>{{e.hiddenCount}} hidden</button>
    </:ellipsis>
  </Breadcrumbs>
</template>

Custom separator

@separator replaces the chevron glyph between crumbs with any component:

import { Breadcrumbs } from 'frontile';

const Slash = <template><span>/</span></template>;

<template>
  <Breadcrumbs @separator={{Slash}} as |b|>
    <b.Item @href='/'>Home</b.Item>
    <b.Item @href='/library'>Library</b.Item>
    <b.Item>Data</b.Item>
  </Breadcrumbs>
</template>

Sizes, colors, and underline

@size (sm / md / lg, default md) scales the text and separator glyph. @underline (always / hover / none, default hover) controls when a crumb's link is underlined. The current crumb is never underlined in any mode, since it doesn't go anywhere.

@color picks the hover and current-page ink, and takes neutral (the default), primary or danger. That is three categories where most themed components offer seven. The other four are fill colours, designed to carry text-on-* text on top of them the way Pagination's active chip does. A breadcrumb has no fill, so its colour lands on the text itself, and as ink on a light surface those categories fall well below the contrast a reader needs: success reaches only 2.3

at its darkest level, against the 4.5
WCAG AA asks for body text.

import { Breadcrumbs } from 'frontile';

<template>
  <div class='demo-stack items-start'>
    <Breadcrumbs @size='sm' @color='primary' as |b|>
      <b.Item @href='/'>Home</b.Item>
      <b.Item>Small, primary</b.Item>
    </Breadcrumbs>

    <Breadcrumbs @size='lg' @color='danger' @underline='always' as |b|>
      <b.Item @href='/'>Home</b.Item>
      <b.Item>Large, danger, always underlined</b.Item>
    </Breadcrumbs>
  </div>
</template>

Disabled crumbs

@isDisabled drops the href as well as marking the crumb aria-disabled. An anchor can't be natively disabled, so removing the href is what actually stops navigation. It only affects a linked crumb; an unlinked crumb (no @route or @href) never receives aria-disabled, since it isn't a link to begin with.

import { Breadcrumbs } from 'frontile';

<template>
  <Breadcrumbs as |b|>
    <b.Item @href='/'>Home</b.Item>
    <b.Item @href='/library' @isDisabled={{true}}>Library</b.Item>
    <b.Item>Data</b.Item>
  </Breadcrumbs>
</template>

b also yields itemClass, linkClass, separatorClass, and setupItem directly, for a link component other than b.Item: ember-link, or a custom <AppLink>. Apply linkClass to the link's class, and {{b.setupItem isCurrent}} to its element, passing a boolean for whether it's the current crumb. It then gets the same theme classes and ARIA as b.Item. Wrap it in an <li> with itemClass, and add the separator yourself with separatorClass:

import { Breadcrumbs } from 'frontile';

<template>
  <Breadcrumbs as |b|>
    <li class={{b.itemClass}}>
      <a href='/library' class={{b.linkClass}} {{b.setupItem false}}>
        Library
      </a>
      <span class={{b.separatorClass}} aria-hidden='true'>/</span>
    </li>
    <li class={{b.itemClass}}>
      <span class={{b.linkClass}} {{b.setupItem true}}>Data</span>
    </li>
  </Breadcrumbs>
</template>

Accessibility

Breadcrumbs renders a <nav> landmark with an accessible name from @label (default 'Breadcrumb'), wrapping an <ol>, since the trail is an ordered list. Every crumb stays in the natural tab order; there is no roving tabindex and no keyboard handling beyond ordinary link navigation.

The current crumb carries aria-current="page" and is rendered as a <span>, not a link, since it doesn't go anywhere. Breadcrumbs.Item derives current from, in order: an explicit @isCurrent; the router, for a @route crumb; then the fallback that a crumb with no link target at all is the page you're on. Only the last statically-current crumb keeps aria-current, since two would be invalid. Breadcrumbs warns if @items produces more than one.

A separator follows every crumb, including the last, where it is hidden by CSS rather than omitted. It carries aria-hidden="true" either way, so it never reaches assistive technology.

An ellipsis marker with no block carries aria-hidden="true" on its glyph and a visually-hidden announcement ("N more levels", or "More levels" without a count). Supplying a block to b.Ellipsis suppresses that built-in announcement, since the block's own content (typically a button that opens a menu) carries its own accessible name.

API

Breadcrumbs

Element: HTMLElement

An ordered trail of links ending in the current page.

Like TabNav, it deliberately does not use roving focus: these are links, so every one of them stays individually reachable by Tab and the arrow keys are left to the browser.

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'item' | 'separator' | 'list' | 'link' | 'ellipsis'> - Class names for each slot of the component, merged with the theme's.
color enum 'neutral'

The colour of the hover and current-page ink.

Three categories rather than the usual seven: the others are fill colours, meant to carry text-on-* on top of them, and as ink on a light surface they fall below the contrast a reader needs. See breadcrumbs.ts in the theme for the measurements.

items Array - Renders the trail from an array instead of from blocks. Passing it is what makes @maxItems meaningful -- yielded blocks cannot be counted before they render, so in the block form there is nothing for it to divide.
itemsAfterCollapse number 1
itemsBeforeCollapse number 1
label string 'Breadcrumb' Accessible name for the navigation landmark.
maxItems number - Collapses the middle of the trail once there are more crumbs than this. @items form only.
separator ComponentLike<{ Element: SVGElement; }> ChevronRightIcon Replaces the separator glyph. A component argument rather than a named block because the separator is rendered inside each <li>, which Item owns -- a named block on this component cannot be handed down to a child.
size enum 'md'
underline enum 'hover'

Blocks

Name Type Default Description
default * Array -
item * Array -
ellipsis * Array -

BreadcrumbsItem

Element: HTMLElement

One crumb: its <li>, the crumb itself, and the separator that follows it.

The separator is rendered here rather than by the root, and hidden on the last crumb by CSS. That is what lets both authoring forms work without either one knowing an item's position.

Arguments

Name Type Default Description
itemClass * string - Supplied by Breadcrumbs. Not part of the public API.
linkClass * string -
separatorClass * string -
setupItem * ModifierLike<{ Element: HTMLElement; Args: { Positional: [boolean]; }; }> -
class string - Class names appended to this crumb's theme classes.
href string - Renders a plain anchor.
isCurrent boolean - Overrides the current-page state. Wins over every other rule.
isDisabled boolean false Marks the crumb as disabled. An anchor cannot be natively disabled, so the href is dropped as well -- aria-disabled alone still leaves it clickable. Only affects a linked crumb: an unlinked crumb (no @route or @href) renders as a <span> and never receives aria-disabled or data-disabled regardless of this arg.
model unknown - A single dynamic segment for @route.
models Array - Dynamic segments for @route.
query Record<string, unknown> - Query params for @route.
route string - Renders a LinkTo for this route and derives the current state from the router. Omit it (and pass @href) to stay entirely router-free.
separator ComponentLike<{ Element: SVGElement; }> -

Blocks

Name Type Default Description
default * Array -

BreadcrumbsEllipsis

Element: HTMLElement

The gap marker standing in for a run of crumbs.

It renders its own <li> including a separator, structurally identical to what Item renders -- a bare <span> would fall outside the group-last/item:hidden scheme that hides the trailing separator, and the trail would end in a dangling chevron whenever an ellipsis came last.

Arguments

Name Type Default Description
ellipsisClass * string -
itemClass * string -
separatorClass * string -
class string - Class names appended to this marker's theme classes.
hiddenCount number - Drives the visually-hidden announcement. The @items form passes it; in the block form an author may supply it, and without it the announcement falls back to an uncounted one rather than making the author count their own crumbs.
hiddenItems Array - The crumbs this marker stands in for. Yielded straight back to the block.
separator ComponentLike<{ Element: SVGElement; }> -

Blocks

Name Type Default Description
default * Array - Replaces the glyph. This is where a Dropdown goes.
Released under MIT License - Created by Josemar Luedke