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 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/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/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", 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;