From 1815bf0b543f094296fc44529aa0794755b3ca1f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 8 Sep 2026 10:42:18 +0800 Subject: [PATCH 1/3] fix(tab-navigation): key overflow bookkeeping by route when tabs have no id Route-backed tabs are usually declared with only a route and label. The overflow calculation compared tab ids, so every tab collapsed onto undefined: the visible and overflow sets both matched all tabs and the More menu never worked while the tab list clipped the trailing tabs. Derive the identity from id, route or key and cover the route-only case in the integration test. --- addon/components/tab-navigation.js | 26 ++++++++++++++----- tests/dummy/app/router.js | 7 ++++- .../components/tab-navigation-test.js | 23 ++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/addon/components/tab-navigation.js b/addon/components/tab-navigation.js index b51ed36f..1c3879e4 100644 --- a/addon/components/tab-navigation.js +++ b/addon/components/tab-navigation.js @@ -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; @@ -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); @@ -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() { @@ -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), @@ -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; @@ -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; @@ -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(); } diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index 0b05bb95..f94cb3b3 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -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'); +}); diff --git a/tests/integration/components/tab-navigation-test.js b/tests/integration/components/tab-navigation-test.js index db07ec13..e08b1681 100644 --- a/tests/integration/components/tab-navigation-test.js +++ b/tests/integration/components/tab-navigation-test.js @@ -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``); + + 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; From 39c2e501d3035cc20e6bd1df27fb9b2d7d40b94f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 9 Sep 2026 12:43:23 +0800 Subject: [PATCH 2/3] chore: prepare v0.4.0 release --- RELEASE.md | 9 +++------ package.json | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index ccda9aca..bcde344d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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? diff --git a/package.json b/package.json index 2355cbba..f990bb14 100644 --- a/package.json +++ b/package.json @@ -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", From de99d37d34c5cda614ac43afd0220b776878a35c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 9 Sep 2026 12:47:45 +0800 Subject: [PATCH 3/3] fix: complete release branch CI and publishing permissions --- .github/workflows/ci.yml | 32 +++++++++++++++++++--------- .github/workflows/create-release.yml | 2 ++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77579571..6658dc1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} @@ -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 }} @@ -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 }} @@ -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 }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 40e80db2..1e5580d4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -6,6 +6,8 @@ on: jobs: create: + permissions: + contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4