Skip to content

yapplabs/ember-nav-stack

Repository files navigation

ember-nav-stack

iOS-style stack navigation for Ember. Routes push and pop horizontally across one or more visual layers, with spring-driven animations and an interactive back-swipe gesture. Inspired by ember-elsewhere.

Compatibility

  • ember-source: >= 4.12.0 (declared as a peer dependency)
  • Node: >= 20.19
  • ember-cli: any version compatible with the host app

Installation

ember install ember-nav-stack

Then import the addon's stylesheet from your app's entry point:

// app/app.js
import 'ember-nav-stack/styles/nav-stack.css';

(Or @import 'ember-nav-stack/styles/nav-stack.css'; from your top-level CSS.) Unlike the pre-v6 versions of this addon, v6+ ships as a v2 addon and does not auto-merge styles into your build.

Concepts

The addon revolves around three pieces:

  • NavStacksService (nav-stacks) — a shared store of stack contents keyed by layer index. Items are pushed and popped via <ToNavStack> (most consumers never call the service directly).
  • <NavStack> — renders one visual stack: the current item, the previous-item header, the spring animations between stack states, and the back-swipe gesture.
  • <ToNavStack> — declarative push. Mounting one adds an item to the stack at the given layer; unmounting removes it.

A typical app renders one <NavStack> per layer index (often just one for the base layer, with modal/overlay layers as needed) and uses <ToNavStack> from inside each route's template to declare the route's content as a stack item.

Two patterns: ad-hoc components vs. routed pages

1. Ad-hoc <NavStack> + <ToNavStack> directly

Use this when the stack content isn't tied to routes — e.g. a modal flow inside a single page, a multi-step form.

{{!-- app/templates/application.hbs --}}
<div style="width:320px;height:480px;position:relative">
  <NavStack @layer={{0}} @back={{this.handleBack}} />
</div>

<ToNavStack
  @layer={{0}}
  @item={{component "my-first-step" model=this.model}}
  @header={{component "my-first-step/header" model=this.model}}
/>

{{#if this.isAtSecondStep}}
  <ToNavStack
    @layer={{0}}
    @item={{component "my-second-step" model=this.model}}
    @header={{component "my-second-step/header" model=this.model}}
  />
{{/if}}

Each <ToNavStack> describes one stack item — its body (@item) and its header (@header) as curried components. Mounting more <ToNavStack> instances pushes them onto the stack in mount order; unmounting them pops.

2. StackableRoute for route-driven stacks

When your stack maps to routes (one stack item per route in a parent → child → grandchild URL), extend StackableRoute and rely on the convention:

// app/routes/page.js
import StackableRoute from 'ember-nav-stack/routes/stackable-route';

export default class PageRoute extends StackableRoute {
  // Optionally:
  //   templateName       — the route's template name; defaults to 'stackable'
  //                        which auto-renders <ToNavStack> for this route
  //   newLayer = true    — this route is on a new visual layer (e.g. a modal
  //                        stack above the base navigation)
  //   routableTemplateName — override the routing-component name (see below)
}

StackableRoute does three things for you:

  1. templateName = 'stackable' by default. The shipped stackable template renders <ToNavStack @layer={{this.layerIndex}} @item={{component this.routeComponent ...}} @header={{component this.headerComponent ...}} /> plus {{outlet}}, so you don't write that boilerplate per route.
  2. Computes layerIndex by walking up the route tree (via the public RouterService.currentRoute / activeTransition.to chain). Routes that opt into a new layer with newLayer = true get parent's layerIndex + 1.
  3. Provides a back action that transitions to the parent route. Wire it to your back button via {{on "click" (route-action "back")}} (or your preferred action-delivery mechanism).

The routable-components/... convention

StackableRoute derives the component names for @item and @header from the route's name:

Route name routeComponent (item) headerComponent
page routable-components/page routable-components/page/header
yapp.page.schedule-item routable-components/yapp/page/schedule-item routable-components/yapp/page/schedule-item/header

So a route called page resolves to component routable-components/page (i.e. app/components/routable-components/page.{js,hbs} in classic layout, or whatever your resolver does for that name). Headers live at <route-component-name>/header.

Override per-route via routableTemplateName = 'something-else' if a single component should back multiple routes.

<NavStack> arguments

Arg Required Description
@layer yes Integer ≥ 0. Identifies which layer this NavStack renders. Items pushed via <ToNavStack @layer={{n}}> appear here when n === @layer.
@back no Callback invoked when a back-swipe completes successfully. Typically wired via {{route-action "back"}} when using StackableRoute. Without this, the back-swipe gesture is disabled.
@footer no A curried component to render below the stack (e.g. a tab bar). Often only set on layer 0.
@birdsEyeDebugging no If truthy, applies an .is-birdsEyeDebugging class for a debug view that shows all layers at once.
@extractComponentKey no Override how NavStack derives a stable identity for a stack item's component (used to detect "did the root component change?" → cut vs. slide). The default reads the curried component's resolved name + bound model.id. Most apps don't need to override.
@onActiveItemChange no Callback invoked whenever the top of the stack changes identity (push, pop, root swap, initial render). Receives { isInitialRender, previousDepth, currentDepth, previousTopKey, currentTopKey }, where the *TopKey values are the top stack item's component name. Use this — not subclassing — to react to navigation (e.g. moving keyboard focus to the newly active heading, recording analytics page views).

<ToNavStack> arguments

Arg Required Description
@layer yes Integer ≥ 0. Which <NavStack> should receive this item.
@item yes A curried component ({{component 'my-page' model=this.model}}) rendered as the stack item's body.
@header no A curried component rendered in the stack item's header slot. Receives @model, @controller, and @back as arguments when the parent <NavStack> has them.

Animation behavior

NavStack chooses one of these transitions automatically based on observed stack state:

Situation Animation
First render of the stack cut (no animation, snap into place)
Layer > 0 appearing from empty slideUp (slides up from below)
Layer > 0 disappearing to empty slideDown (slides down off-screen)
Same root, deeper push slideForward (new page slides in from the right)
Same root, shallower pop slideBack (current page slides off to the right)
Root component changed cut (instant swap — used for tab switches)

The decision is made by decideTransition (src/utils/transition-decision.js), which you can unit-test in isolation if you're forking or debugging. See its source comment for the full truth table.

Disabling animation (e.g. for tests)

// config/environment.js
ENV['ember-nav-stack'] = { suppressAnimation: true };

When set, <NavStack> skips its springs and snaps directly to the post-transition position. Useful in test environments that don't need to wait on rAF-driven animations.

Test waiters

ember-nav-stack registers waiters for stack recomputes, transition animations, and the initial render so Ember's built-in test helpers (visit, click, settled, etc.) wait correctly. You should not need custom "wait for nav stack idle" helpers or manual waitFor calls when navigation is your only async source.

If you do need a programmatic hook:

import { getOwner } from '@ember/application';

let navStacks = getOwner(this).lookup('service:nav-stacks');
await navStacks.waitUntilTransitionIdle();

CSS classes you can target

The addon owns these class names. Consumers can style them (or query them in tests) but should not rely on internal structure:

  • .NavStack — the stack container element
  • .NavStack--layer0, .NavStack--layer1, … — added per layer index
  • .NavStack--withFooter — added when @footer is set
  • .NavStack-item, .NavStack-item-0, .NavStack-item-1, … — per-item containers
  • .NavStack-itemContainer — the horizontally-translated container that holds items
  • .NavStack-header — wrapper for the current + parent header
  • .NavStack-currentHeaderContainer, .NavStack-parentItemHeaderContainer — the two live header slots
  • .NavStack-footer — wrapper for @footer

.NavStack-gestureBackTargetHeader may briefly appear during a completed back-swipe; it's an overlay clone that gets cleaned up automatically.

Test support

import { isInViewport, getElementInViewportRatio } from 'ember-nav-stack/test-support';

Two helpers for asserting on the visible position of stack items in tests. See src/test-support/in-viewport.js for details.

Contributing

See CONTRIBUTING.md for setup and development notes.

License

This project is licensed under the MIT License.

About

mobile-style stack navigation blended with the Ember router

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors