` gives you the ARIA without the keyboard.
With the default `click` trigger type, the trigger handles:
| Key | Behavior |
| ----------------------- | -------------------------------------------------------------- |
| `Enter` / `Space` | Toggles, via the element's native click |
| `ArrowDown` / `ArrowUp` | Opens when closed |
| any letter key | Opens when closed, for type-ahead into the content |
| `Escape` | Closes when open |
| `Tab` | Closes and moves on, without pulling focus back to the trigger |
A letter pressed with
Cmd ,
Ctrl or
Alt held is left alone —
those combinations belong to the browser or the OS, so
Cmd +
R reloads
without the popover opening over the page.
Shift +letter still opens, since a
capital letter is legitimate type-ahead.
Focus moves into the content when it opens and returns to the trigger when it closes.
`@didClose` fires once the content has finished leaving, exit transition included — not at
the moment the popover is asked to close. That makes it the right place to unmount or reset
whatever the content was showing, and it will not fire at all for a `close()` on a popover
that was not open.
**`{{p.trigger "hover"}}`** opens on pointer hover, and also on keyboard `focus-visible` —
so it is reachable without a mouse. `Escape` closes it while it's open. The pointer, and
keyboard focus, may both move off the trigger and onto the content without the popover
closing (see [Hover Trigger](#hover-trigger)). Unlike the click trigger, a hover popover
never moves focus into its content — focus stays wherever it already was, avoiding the page
jump that comes from a portaled overlay being scrolled into view. Consequently there is
nothing to restore on close either.
The `aria=` option, passed alongside the trigger type, chooses which relationship the
trigger element carries:
| Value | Effect |
| --- | --- |
| `'menu'` (default) | Sets `aria-haspopup`, `aria-controls`, and `aria-expanded`, as described above |
| `'describedby'` | Sets `aria-describedby` instead, present only while open — appropriate when the content describes the trigger rather than acting as a menu or panel |
| `'none'` | Sets none of the above, for a fully custom ARIA setup |
```gts preview
import { Button } from 'frontile';
import { Popover } from 'frontile';
Hover me
Announced as this button's description.
```
## API
### Popover
**Element:** `HTMLUListElement`
**Arguments**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `closeDelay` | `number` | `100` | Milliseconds to wait before closing after the pointer leaves the trigger or the content. Only applies to a trigger installed in hover mode. This is also the window the pointer has to cross the gap between the trigger and the content, so setting it to 0 makes content hover unreachable in practice. |
| `didClose` | `() => void` | - | Callback when closing has finished, including any exit transition. |
| `flipOptions` | `{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean \| 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' \| 'initialPlacement'; fallbackAxisSideDirection?: 'start' \| ... 1 more ... \| 'none'; ... 4 more ...; boundary?: Boundary; }` | - | Options for the floating-ui flip middleware, which moves the content to the opposite side when it would overflow the viewport. |
| `isOpen` | `boolean` | - | Whether the popover is open. Pair with onOpenChange to control it; leave it unset to let the popover manage its own state. |
| `middleware` | `{ name: string; options?: any; fn: (state: { placement: Placement; strategy: Strategy; x: number; y: number; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => Promisable<...>; }[]` | - | Additional floating-ui middleware, for positioning behavior beyond what placement, offsetOptions, flipOptions, and shiftOptions cover. |
| `offsetOptions` | `OffsetOptions` | `5` | |
| `onOpenChange` | `(isOpen: boolean) => void` | - | Callback when the popover opens or closes, receiving the new state. |
| `openDelay` | `number` | `100` | Milliseconds to wait before opening on hover or keyboard focus. Only applies to a trigger installed in hover mode. |
| `placement` | `'bottom' \| 'left' \| 'right' \| 'top' \| 'top-start' \| 'top-end' \| 'right-start' \| 'right-end' \| 'bottom-start' \| 'bottom-end' \| 'left-start' \| 'left-end'` | `'bottom-start'` | Placement of the menu when open |
| `shiftOptions` | `{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; limiter?: { ...; }; boundary?: Boundary; }` | - | Options for the floating-ui shift middleware, which nudges the content along its axis to keep it in view. |
| `strategy` | `Strategy` | `'absolute'` | |
**Blocks**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `default *` | `[{ anchor: ModifierLike<{ Element: HTMLElement; }>; measureWidth: ModifierLike<{ Element: HTMLElement; }>; isOpen: boolean; toggle: () => void; open: () => void; close: () => void; trigger: ModifierLike<{ Element: HTMLElement; Args: { Positional: [eventType?: 'click' \| 'hover']; Named: { aria?: 'menu' \| 'describedby' \| 'none'; }; }; }>; data: MiddlewareArguments; Content: Content (loop, isOpen, id, toggle, internalDidClose, blockScroll, backdrop, triggerWidth, preventAutoFocus, isHoverTrigger, onContentHoverStart, onContentHoverEnd, registerArrow, velcroData bound); }]` | - | |
### Content
Component yielded from Popover
**Element:** `HTMLDivElement`
**Arguments**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id *` | `string` | - | _(internal)_ |
| `isOpen *` | `boolean` | - | _(internal)_ |
| `loop *` | `ModifierLike<{ Element: HTMLElement; }>` | - | _(internal)_ |
| `toggle *` | `() => void` | - | _(internal)_ |
| `arrow` | `boolean` | `false` | Renders an arrow pointing at the anchor. |
| `arrowClass` | `string` | - | _(internal)_ |
| `backdrop` | `'faded' \| 'none' \| 'blur' \| 'transparent'` | - | How the area behind the overlay is rendered: none omits the backdrop entirely, transparent keeps it clickable but invisible, faded dims the page, and blur blurs it. |
| `backdropTransition` | `Object` | - | Transition classes for the backdrop, overriding the defaults used when it fades in and out. |
| `blockScroll` | `boolean` | `true` | |
| `class` | `string` | - | Custom class name for the content element, merged with the default ones using Tailwind Merge. |
| `closeOnEscapeKey` | `boolean` | `true` | Whether to close when the escape key is pressed |
| `closeOnOutsideClick` | `boolean` | `true` | Whether to close when the area outside (the backdrop) is clicked |
| `didClose` | `() => void` | - | A function that will be called when closing is finished executing, this includes waiting for animations/transitions to finish. |
| `disableFocusTrap` | `boolean` | `true` | |
| `disableInteractive` | `boolean` | `false` | Closes as soon as the pointer leaves the trigger, instead of letting it move into the content. Only meaningful for a hover trigger. |
| `disableTransitions` | `boolean` | `false` | Disable css transitions |
| `focusTrapOptions` | `any` | `{ clickOutsideDeactivates: true, allowOutsideClick: true }` | Focus trap options |
| `isHoverTrigger` | `boolean` | - | _(internal)_ |
| `onContentHoverEnd` | `() => void` | - | _(internal)_ |
| `onContentHoverStart` | `() => void` | - | _(internal)_ |
| `onOpen` | `() => void` | - | A function that will be called when opened |
| `preventAutoFocus` | `boolean` | `false` | When focusTrap is disabled, by default Oberlay will be auto focused. This option prevents that. |
| `preventFocusRestore` | `boolean` | - | _(internal)_ |
| `registerArrow` | `ModifierLike<{ Element: HTMLElement; }>` | - | _(internal)_ |
| `renderInPlace` | `boolean` | `false` | Whether to render in place or in the specified/default destination |
| `size` | `'sm' \| 'md' \| 'lg' \| 'trigger' \| 'xl' \| 'auto'` | `'md'` | The size of the content. |
| `target` | `string \| Element` | - | The target where to render the portal. There are 3 options: 1) Element object, 2) element id, 3) portal target name. For element id, string must be prefixed with #. If no value is passed in, we will render to the closest unnamed portal target, parent portal or document.body. |
| `transition` | `Object` | `{name: 'overlay-transition--scale'}` | The transition to be used in the Modal. |
| `transitionDuration` | `number` | `200` | Duration of the animation |
| `triggerWidth` | `number` | - | _(internal)_ |
| `velcroData` | `{ placement: Placement; strategy: Strategy; x: number; y: number; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }` | - | _(internal)_ |
**Blocks**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `default *` | `[]` | - | |
---
# Portal & PortalTarget
Source: /docs/components/overlays/portal.md
# Portal & PortalTarget
`Portal` renders its content somewhere else in the DOM, and `PortalTarget` marks the places it can render to. Reach for them when content has to escape a parent's `overflow: hidden` or stacking context — modals, drawers, popovers, tooltips, toasts — or when it belongs in a specific region of the page for reading order or z-index reasons.
## Import
```js
import { Portal, PortalTarget } from 'frontile';
```
## Usage
With no arguments, `Portal` renders into the closest destination it can find, falling back to `document.body`.
```gts preview
import { Portal } from 'frontile';
This content is rendered in a portal.
```
## PortalTarget
`PortalTarget` renders a `div` marked with `data-portal-target="true"` and, when `@for` is
given, `data-portal-for="
"`. It takes attributes and a block, so it can be positioned
and styled like any other element and can hold content of its own.
A target with no `@for` is **unnamed**: it is the default destination for any `Portal`
rendered below it that has no `@target` of its own. A target with `@for` is only used by
portals that ask for it by name, so it never captures unrelated content.
```gts preview
import { Portal, PortalTarget } from 'frontile';
```
### Named targets as slots
Because named targets are opt-in, several can coexist and each `Portal` picks one by name.
The target's own block content stays put; portal content is appended after it.
```gts preview
import { Button, Portal, PortalTarget } from 'frontile';
Toolbar
Footer
Save changes
Cancel
```
### An application-level target
Without a target, overlays land in `document.body`, outside the element your app styles and
outside its stacking context. Rendering one unnamed `PortalTarget` near the end of the
application template gives every overlay a predictable home you control — this documentation
site does exactly that:
```gts
import { PortalTarget } from 'frontile';
{{outlet}}
```
### How a destination is chosen
`Portal` resolves its destination in this order:
1. `@renderInPlace={{true}}` — no portal at all, the content stays where it is written.
2. `@target` as an `Element` — that element.
3. `@target` as a string beginning with `#` — the element with that id. Any id HTML allows
works, including ones that are not valid CSS identifiers (`#1foo`, `#my.target`).
4. `@target` as any other string — the nearest `PortalTarget` with a matching `@for`. The
name is compared literally, so it too may contain characters a selector would choke on.
5. Otherwise, the nearest parent portal, unless `@appendToParentPortal={{false}}`.
6. Otherwise, the nearest **unnamed** `PortalTarget`.
7. Otherwise, `document.body`.
When the destination ends up being plain `document.body`, `Portal` wraps its content in a
`PortalTarget` for you, so portals nested inside it still have somewhere to go.
## Nesting Portals
Portals nest: an inner `Portal` renders into the destination of the outer one.
```gts preview
import { Portal, PortalTarget } from 'frontile';
First portal (Outer)
Second portal (Inner)
Last portal (Inner 2)
```
## Rendering Inline
`@renderInPlace={{true}}` skips the portal entirely and renders the content where it is
written — useful for turning portalling off conditionally.
```gts preview
import { Portal } from 'frontile';
This content is rendered inline instead of a portal.
```
## Rendering Inside a Specific DOM Element
`@target` also accepts an element id prefixed with `#`, or an `Element` reference, for
destinations that are not `PortalTarget`s — a third-party container, for instance.
```gts preview
import { Portal } from 'frontile';
Target Element
Rendered inside target element.
```
## Append to Parent Portal
By default a `Portal` appends to the parent portal when there is one. Set
`@appendToParentPortal={{false}}` to make it resolve a destination on its own instead, which
sends it to the nearest unnamed `PortalTarget`.
```gts preview
import { Portal, PortalTarget } from 'frontile';
Outer portal content
This content is rendered separately, not appended to the parent portal.
```
## Server-Side Rendering
Both components work under FastBoot. `Portal` resolves its destination from the owner's
document rather than the global `document`, so portalled content is present in the
server-rendered HTML.
## Accessibility
Neither component adds a role or any ARIA attribute — `PortalTarget` is a plain `div`, so
the semantics belong to whatever you render inside it. What they do change is DOM position,
and that has consequences:
- **Reading and tab order follow the destination, not the source.** Content written next to a
button but portalled to the end of the page is announced and reached there. Place your
application-level `PortalTarget` after the main content so overlay content comes last.
- **Focus management is not handled here.** `Portal` does not move, trap, or restore focus.
For dialogs use `Modal`, `Drawer`, or `Overlay`, which handle focus, focus trapping, and
the Escape key on top of `Portal`.
- **Keep the relationship explicit.** When portalled content describes a control that stays
behind — a popover, a tooltip, an error message — wire it up with `aria-controls`,
`aria-describedby`, or `aria-labelledby`, since proximity in the DOM no longer implies it.
- **Give a named target only one owner.** Two portals targeting the same name append in render
order; for a slot that should hold one thing at a time, render one `Portal` at a time.
## API
### PortalTarget
**Element:** `HTMLElement`
**Arguments**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `for` | `string` | - | Name of this target, matched against a Portal's @target argument. When omitted, the target is unnamed: any Portal rendered below it that has no @target and no parent portal will render here. A named target is only used by portals that ask for it by name. |
**Blocks**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `default *` | `[]` | - | |
### Portal
**Element:** `HTMLDivElement`
**Arguments**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `appendToParentPortal` | `boolean` | `true` | |
| `renderInPlace` | `boolean` | `false` | Whether to render in place or in the specified/default destination |
| `target` | `string \| Element` | - | The target where to render the portal. There are 3 options: 1) Element object, 2) element id, 3) portal target name. For element id, string must be prefixed with #. If no value is passed in, we will render to the closest unnamed portal target, parent portal or document.body. |
**Blocks**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `default *` | `[]` | - | |
---
# Tooltip
Source: /docs/components/overlays/tooltip.md
# Tooltip
A Tooltip shows a short, non-interactive hint next to whatever a user hovers or
keyboard-focuses. It is built on [Popover](./popover.md): reach for `Popover`
instead whenever the overlay needs to hold links, buttons, or anything else a
user must be able to interact with, because a tooltip's content is announced
through `aria-describedby` as a description of the trigger, not a region of its
own — interactive elements inside it are unreachable to assistive technology.
## Import
```js
import { Tooltip } from 'frontile';
```
## Usage
Pass `@content` for the common case of a plain-text tooltip, and put the
`trigger` modifier on the element the tooltip describes.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
Add
```
## Placement
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
const placements = ['top', 'right', 'bottom', 'left'];
{{#each placements as |placement|}}
{{placement}}
{{/each}}
```
## Arrow
`@arrow={{true}}` renders an arrow pointing at the trigger.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
Hover me
```
## Rich content
Use the yielded `Content` block instead of `@content` when the tooltip needs
more than a single string. Passing both `@content` and a `Content` block
asserts in development — pick one.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
import { Kbd } from 'frontile';
Keyboard shortcuts
Save
saves the current document.
```
## Color
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
const intents = [
'default',
'primary',
'secondary',
'tertiary',
'success',
'warning',
'danger'
];
{{#each intents as |intent|}}
{{intent}}
{{/each}}
```
## Size
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
const sizes = ['sm', 'md', 'lg'];
{{#each sizes as |size|}}
{{size}}
{{/each}}
```
## Delays
`@openDelay` (default `200`ms) and `@closeDelay` (default `150`ms) control how
long the tooltip waits before showing and hiding. `@closeDelay` is also the
window the pointer has to cross the gap between the trigger and the tooltip
content, so setting it very low makes the tooltip effectively non-interactive.
These defaults are longer than the underlying `Popover`'s hover defaults
(`100`ms/`100`ms): a tooltip fires on every incidental mouse pass over its
trigger, so it waits a little longer before appearing to avoid flashing at a
user who was only moving the cursor across.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
Hover and wait
```
## Interactive content
By default, moving the pointer or keyboard focus off the trigger and onto the
tooltip's own content keeps it open — useful when the content is long enough
that a reader's cursor has to cross a gap to reach it. `@disableInteractive`
closes the tooltip as soon as the pointer leaves the trigger instead, without
waiting to see whether it lands on the content.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
Interactive (default)
Not interactive
```
## Controlled
Pair `@isOpen` with `@onOpenChange` to drive the tooltip from your own state.
`@isOpen` only takes effect together with `@onOpenChange` — passing `@isOpen`
alone falls back to uncontrolled behavior.
Open the tooltip in response to an interaction or after the component has
mounted, not on the very first render: the trigger installs its floating-ui
anchor through a modifier, which only runs once the element exists, so forcing
`@isOpen={{true}}` before that happens throws.
```gts preview
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
export default class ControlledTooltipExample extends Component {
@tracked isOpen = false;
onOpenChange = (isOpen: boolean) => {
this.isOpen = isOpen;
};
show = () => {
this.isOpen = true;
};
Show tooltip
Trigger
}
```
## Imperative Controls
Besides `@isOpen`/`@onOpenChange`, the default block also yields `isOpen`, `open`, and
`close` directly, with no external state required. `open` and `close` drive the tooltip from
anywhere on the page — a button doesn't have to be the trigger itself — and `isOpen` reports
whether the tooltip is currently open.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
Trigger
Close tooltip
{{if t.isOpen 'Open' 'Closed'}}
```
## Disabled
`@isDisabled={{true}}` still installs the trigger element, but skips wiring up the hover and
focus listeners that open the tooltip — so the tooltip never opens, and the trigger itself
renders exactly as you wrote it, whether or not it also carries a plain `disabled` attribute.
Toggling `@isDisabled` back to `false` at runtime re-installs the listeners immediately.
The common case is disabling the tooltip alongside a disabled trigger, so a hint about an
unavailable action doesn't pop up on hover.
```gts preview
import { Tooltip } from 'frontile';
import { Button } from 'frontile';
Save (disabled)
Save (enabled)
```
## Accessibility
The `trigger` modifier marks up the element it's applied to as the tooltip's
description, and keeps it in sync as the tooltip opens and closes:
```
aria-describedby=""
```
The tooltip opens on mouse hover, and on keyboard `focus-visible` after
`@openDelay`. `Escape` closes it while it's open. The content itself carries
`role="tooltip"` and is never a tab stop (`tabindex="-1"`) — focus is never
moved into it, only the description relationship changes.
Touch has no equivalent: there is no long-press emulation, so anything said
only in a tooltip is invisible to a touch-only user. Make sure the same
information is available another way — in the trigger's own label, or
elsewhere on the page — rather than relying on the tooltip alone.
## API
### Tooltip
**Element:** `HTMLDivElement`
**Arguments**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `arrow` | `boolean` | `false` | Renders an arrow pointing at the trigger. |
| `class` | `string` | - | Custom class for the tooltip, merged with the theme's using Tailwind Merge. |
| `classes` | `SlotsToClasses<'base' \| 'arrow'>` | - | Class names for each slot, merged with the theme's. |
| `closeDelay` | `number` | `150` | Milliseconds before closing. Also the window the pointer has to cross the gap into the tooltip, so a very small value makes the tooltip effectively non-interactive. |
| `color` | `'neutral' \| 'primary' \| 'secondary' \| 'tertiary' \| 'success' \| 'warning' \| 'danger'` | `'neutral'` | The tooltip's semantic color role. The arrow inherits the body's background, so it follows the chosen intent automatically. |
| `content` | `string` | - | The tooltip's text. A shorthand for the common case; pass a Content block instead when the tooltip needs markup. Passing both asserts. |
| `didClose` | `() => void` | - | Callback when closing has finished, including any exit transition. |
| `disableInteractive` | `boolean` | `false` | Closes as soon as the pointer leaves the trigger, rather than letting it move onto the tooltip. |
| `flipOptions` | `{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean \| 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' \| 'initialPlacement'; fallbackAxisSideDirection?: 'start' \| ... 1 more ... \| 'none'; ... 4 more ...; boundary?: Boundary; }` | - | Options for the floating-ui flip middleware, which moves the tooltip to the opposite side when it would overflow the viewport. Forwarded to the underlying Popover. |
| `isDisabled` | `boolean` | `false` | Installs the trigger but never opens. For a tooltip whose text is conditionally irrelevant. |
| `isOpen` | `boolean` | - | Whether the tooltip is open. Pair with onOpenChange to control it; leave it unset to let the tooltip manage its own state. Passing isOpen alone, without onOpenChange, falls back to uncontrolled behavior. |
| `middleware` | `{ name: string; options?: any; fn: (state: { placement: Placement; strategy: Strategy; x: number; y: number; initialPlacement: Placement; middlewareData: MiddlewareData; rects: ElementRects; platform: Platform; elements: Elements; }) => Promisable<...>; }[]` | - | Additional floating-ui middleware, for positioning behavior beyond what placement, offsetOptions, flipOptions, and shiftOptions cover. Forwarded to the underlying Popover. |
| `offsetOptions` | `OffsetOptions` | `8` | The gap, in pixels, between the trigger and the tooltip. The default leaves room for the arrow when @arrow is enabled. |
| `onOpenChange` | `(isOpen: boolean) => void` | - | Callback when the tooltip opens or closes, receiving the new state. |
| `openDelay` | `number` | `200` | Milliseconds before opening on hover or keyboard focus. |
| `placement` | `'bottom' \| 'left' \| 'right' \| 'top' \| 'top-start' \| 'top-end' \| 'right-start' \| 'right-end' \| 'bottom-start' \| 'bottom-end' \| 'left-start' \| 'left-end'` | `'top'` | The requested placement of the tooltip relative to its trigger. This is a preference, not a guarantee: the flip middleware may resolve it to the opposite side when there is no room for it in the requested spot. |
| `shiftOptions` | `{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; limiter?: { ...; }; boundary?: Boundary; }` | - | Options for the floating-ui shift middleware, which nudges the tooltip along its axis to keep it in view. Forwarded to the underlying Popover. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The tooltip's size, which scales its padding and label text. |
| `strategy` | `Strategy` | `'absolute'` | The CSS positioning strategy, forwarded to the underlying Popover/floating-ui. |
**Blocks**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `default *` | `[{ trigger: ModifierLike<{ Element: HTMLElement; }>; Content: TooltipContent (PopoverContent, classNames, arrow, disableInteractive, hasContentArg bound); isOpen: boolean; open: () => void; close: () => void; }]` | - | |
---
# Breadcrumbs
Source: /docs/components/navigation/breadcrumbs.md
# Breadcrumbs
An ordered trail of links ending in the current page, for showing where the
current page sits in a hierarchy.
Like [TabNav](./tab-nav), 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
```js
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: `` needs no
arguments to say "you are here."
```gts preview
import { Breadcrumbs } from 'frontile';
Home
Library
Data
```
`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](#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.
```gts
import { Breadcrumbs } from 'frontile';
Library
{{@item.title}}
```
`@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.
```gts preview
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' }
];
```
`@itemsBeforeCollapse` and `@itemsAfterCollapse` (each defaulting to `1`)
move where the split happens:
```gts preview
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' }
];
```
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 ` `
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:
```gts preview
import { Breadcrumbs } from 'frontile';
Home
Q3
```
`@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:
```gts preview
import { Breadcrumbs } from 'frontile';
const hidden = [{ label: 'Library' }, { label: 'Data' }];
Home
{{e.hiddenItems.length}}
hidden …
Q3
```
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:
```gts
import { Breadcrumbs } from 'frontile';
<:item as |ctx|>
{{ctx.item.label}}
<:ellipsis as |e|>
{{e.hiddenCount}} hidden
```
## Custom separator
`@separator` replaces the chevron glyph between crumbs with any component:
```gts preview
import { Breadcrumbs } from 'frontile';
const Slash = / ;
Home
Library
Data
```
## 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:1 at its darkest level, against the 4.5:1 WCAG AA
asks for body text.
```gts preview
import { Breadcrumbs } from 'frontile';
Home
Small, primary
Home
Large, danger, always underlined
```
## 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.
```gts preview
import { Breadcrumbs } from 'frontile';
Home
Library
Data
```
## Bring your own link component
`b` also yields `itemClass`, `linkClass`, `separatorClass`, and `setupItem`
directly, for a link component other than `b.Item`: `ember-link`, or a custom
``. 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
`` with `itemClass`, and add the separator yourself with `separatorClass`:
```gts
import { Breadcrumbs } from 'frontile';
Library
/
Data
```
## Accessibility
`Breadcrumbs` renders a `` landmark with an accessible name from
`@label` (default `'Breadcrumb'`), wrapping an ``, 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
``, 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
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.
**Element:** `HTMLElement`
**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` | `'neutral' \| 'primary' \| 'danger'` | `'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` | `BreadcrumbsItemData[]` | - | 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 , which Item owns -- a named block on this component cannot be handed down to a child. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | |
| `underline` | `'none' \| 'always' \| 'hover'` | `'hover'` | |
**Blocks**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `default *` | `[{ Item: BreadcrumbsItem (itemClass, linkClass, separatorClass, separator, setupItem bound); Ellipsis: BreadcrumbsEllipsis (itemClass, ellipsisClass, separatorClass, separator bound); itemClass: string; linkClass: string; separatorClass: string; setupItem: Breadcrumbs['setupItem']; }]` | - | |
| `item *` | `[BreadcrumbsItemContext]` | - | |
| `ellipsis *` | `[{ hiddenCount?: number; hiddenItems: BreadcrumbsItemData[]; }]` | - | |
### BreadcrumbsItem
One crumb: its , 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.
**Element:** `HTMLElement`
**Arguments**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `itemClass *` | `string` | - | Supplied by Breadcrumbs. Not part of the public API. _(internal)_ |
| `linkClass *` | `string` | - | _(internal)_ |
| `separatorClass *` | `string` | - | _(internal)_ |
| `setupItem *` | `ModifierLike<{ Element: HTMLElement; Args: { Positional: [boolean]; }; }>` | - | _(internal)_ |
| `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 and never receives aria-disabled or data-disabled regardless of this arg. |
| `model` | `unknown` | - | A single dynamic segment for @route. |
| `models` | `unknown[]` | - | Dynamic segments for @route. |
| `query` | `Record