Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,33 @@ name: Ember CI

on:
push:
branches: [ main ]
branches:
- main
- 'release/v*'
- 'dev-v*'
tags:
- 'v*'
pull_request:
branches: [ main ]
branches:
- main
- 'release/v*'
- 'dev-v*'

env:
NODE_VERSION: 22.x

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

Expand All @@ -41,10 +50,10 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

Expand All @@ -67,10 +76,10 @@ jobs:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

Expand All @@ -93,13 +102,16 @@ jobs:

github_publish:
needs: build
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:

jobs:
create:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
9 changes: 3 additions & 6 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
> v0.3.41 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title"
> v0.4.0 ~ "Tab navigation overflow fix"

---
## Highlights

RELEASE_NOTES_PLACEHOLDER

Describe what changed in this release. The first line above must name the version
being released, and both placeholder markers must be gone, or the release workflow
refuses to tag.
- Route-backed tabs without explicit IDs remain accessible through the TabNavigation More menu when the tab bar overflows (#171).
- Tab identity falls back from `id` to `route` to `key` for active state, overflow calculations, and keyboard navigation.

---
## Need help?
Expand Down
26 changes: 19 additions & 7 deletions addon/components/tab-navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { next, scheduleOnce } from '@ember/runloop';

/**
* Tabs are keyed by `id`, but route-backed tabs are commonly declared with only a
* `route` (and yielded/menu tabs with a `key`). Overflow bookkeeping compares these
* identities, so a missing `id` must fall back to the next stable identifier rather
* than collapsing every tab onto `undefined`.
*/
function tabIdentity(tab) {
return tab?.id ?? tab?.route ?? tab?.key ?? null;
}

export default class TabNavigationComponent extends Component {
@service universe;
@tracked _activeTabId = null;
Expand All @@ -18,7 +28,7 @@ export default class TabNavigationComponent extends Component {

constructor(owner, args) {
super(owner, args);
this._activeTabId = args.activeTabId || (args.tabs?.[0]?.id ?? null);
this._activeTabId = args.activeTabId || tabIdentity(args.tabs?.[0]);
next(() => {
if (typeof this.args.contextApi === 'function') {
this.args.contextApi(this.context);
Expand All @@ -37,7 +47,7 @@ export default class TabNavigationComponent extends Component {

get activeTab() {
if (!this.args.tabs) return null;
return this.args.tabs.find((tab) => tab.id === this._activeTabId) || null;
return this.args.tabs.find((tab) => tabIdentity(tab) === this._activeTabId) || null;
}

get style() {
Expand All @@ -53,9 +63,11 @@ export default class TabNavigationComponent extends Component {
if (!this.args.tabs) return [];

return this.args.tabs.map((tab) => {
const id = tabIdentity(tab);
const enhanced = {
...tab,
isActive: tab.id === this._activeTabId,
id,
isActive: id === this._activeTabId,
isDisabled: !!tab.disabled,
hasIcon: !!tab.icon,
hasBadge: !!(tab.badge && tab.badge > 0),
Expand Down Expand Up @@ -141,8 +153,8 @@ export default class TabNavigationComponent extends Component {
@action argsDidChange() {
const tabs = this.args.tabs ?? [];
const hasControlledActiveTab = this.args.activeTabId !== undefined;
const currentActiveTabExists = tabs.some((tab) => tab.id === this._activeTabId);
const nextActiveTabId = hasControlledActiveTab ? this.args.activeTabId : currentActiveTabExists ? this._activeTabId : (tabs[0]?.id ?? null);
const currentActiveTabExists = tabs.some((tab) => tabIdentity(tab) === this._activeTabId);
const nextActiveTabId = hasControlledActiveTab ? this.args.activeTabId : currentActiveTabExists ? this._activeTabId : tabIdentity(tabs[0]);

if (nextActiveTabId !== this._activeTabId) {
this._activeTabId = nextActiveTabId;
Expand Down Expand Up @@ -271,7 +283,7 @@ export default class TabNavigationComponent extends Component {

@action handleKeyDown(tab, event) {
const { key } = event;
const currentIndex = this.args.tabs.findIndex((t) => t.id === tab.id);
const currentIndex = this.args.tabs.findIndex((t) => tabIdentity(t) === tabIdentity(tab));

let targetIndex = currentIndex;

Expand Down Expand Up @@ -304,7 +316,7 @@ export default class TabNavigationComponent extends Component {
const targetTab = this.args.tabs[targetIndex];
if (targetTab && !targetTab.disabled) {
// Focus the target tab
const targetElement = document.querySelector(`[data-tab-id="${targetTab.id}"]`);
const targetElement = document.querySelector(`[data-tab-id="${tabIdentity(targetTab)}"]`);
if (targetElement) {
targetElement.focus();
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/ember-ui",
"version": "0.3.41",
"version": "0.4.0",
"description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.",
"keywords": [
"fleetbase-ui",
Expand Down
7 changes: 6 additions & 1 deletion tests/dummy/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ export default class Router extends EmberRouter {
rootURL = config.rootURL;
}

Router.map(function () {});
Router.map(function () {
// Route targets for TabNavigation's route-backed tab tests.
this.route('positions');
this.route('devices');
this.route('schedules');
});
23 changes: 23 additions & 0 deletions tests/integration/components/tab-navigation-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,29 @@ module('Integration | Component | tab-navigation', function (hooks) {
assert.dom('a[role="menuitem"][data-tab-id="index"]').hasAttribute('href', /view=details/);
});

test('route-backed tabs declared without ids still overflow into the More menu', async function (assert) {
availableWidth = 142;
this.set('tabs', [
{ route: 'index', label: 'Overview' },
{ route: 'positions', label: 'Positions' },
{ route: 'devices', label: 'Devices' },
{ route: 'schedules', label: 'Schedules' },
]);

await render(hbs`<TabNavigation @tabs={{this.tabs}} />`);

assert.dom('[role="tab"][data-tab-id="index"]').exists();
assert.dom('[role="tab"][data-tab-id="positions"]').exists();
assert.dom('[role="tab"][data-tab-id="devices"]').doesNotExist('tabs past the available width are not rendered inline');
assert.dom('[role="tab"][data-tab-id="schedules"]').doesNotExist();
assert.dom('[data-tab-navigation-more]').exists();

await click('[data-tab-navigation-more]');

assert.dom('a[role="menuitem"][data-tab-id="devices"]').exists();
assert.dom('a[role="menuitem"][data-tab-id="schedules"]').exists();
});

test('yielded custom tab blocks are not overflow-managed', async function (assert) {
availableWidth = 40;

Expand Down
Loading