Skip to content

feat(tabs, segmented-control) DLT-3292 DLT-3293 DLT-3294 traveling indicator#1192

Open
Francis Rupert (francisrupert) wants to merge 15 commits intonextfrom
traveling-active
Open

feat(tabs, segmented-control) DLT-3292 DLT-3293 DLT-3294 traveling indicator#1192
Francis Rupert (francisrupert) wants to merge 15 commits intonextfrom
traveling-active

Conversation

@francisrupert
Copy link
Copy Markdown
Contributor

@francisrupert Francis Rupert (francisrupert) commented Apr 12, 2026

seinfeld-kramer

🛠️ Type Of Change

  • Feature

📖 Jira Ticket

  • DLT-3292 — Tabs: traveling indicator
  • DLT-3293 — Segmented Control: traveling indicator
  • DLT-3294 — Shared indicator animation composable
  • DLT-3291 — Add --ttf-ease token

📖 Description

Adds a "traveling indicator" animation to DtTabGroup and DtSegmentedControl. When clicking a new tab or segmented control item, the selection indicator (underline, border, or background) visually slides from the old selection to the new one instead of instantly switching.

What it does:

  • FLIP technique + Web Animations API (Element.animate on pseudo-elements) — no markup changes
  • DtTabGroup: all four variants (default underline, outlined border, muted+outlined, muted active background)
  • DtSegmentedControl: active background variant
  • Shared useIndicatorAnimation composable + indicatorAnimation.js utility
  • showIndicatorTransition prop on both components (default true) for consumer opt-out
  • Duration/easing driven by CSS custom properties (--tab-indicator-duration, --segmented-indicator-duration, etc.)
  • Respects prefers-reduced-motion
  • Keyboard auto-activation (activation-mode="auto") snaps instantly — no animation

What it does not do:

  • No template/markup changes to existing components
  • No changes to selection logic or event contracts
  • No new dependencies

Other changes in this PR:

  • New --ttf-ease token (cubic-bezier(0.25, 0.1, 0.25, 1)) + .d-ttf utility class
  • Fix: .ttf-out-quint.d-ttf-out-quint (missing d- prefix)

🔍 For Reviewers

Test on the deploy preview:

  1. Tabs — click between tabs in each variant demo (Default, Borderless, Outlined, Muted, Muted+Outlined). Indicator should slide smoothly.
  2. Segmented Control — click between items. Background should slide.
  3. Keyboard — tab into a group, use arrow keys. In manual mode (default), arrows move focus only; Enter/Space selects with animation. In auto mode, arrows snap instantly (no animation).
  4. Rapid clicks — click tabs fast. Previous animation should cancel cleanly, no stuck transparent states.
  5. Stress tests — scroll to "Traveling Indicator Stress Test" section for edge cases: all sizes, spread modes, vertical, RTL, disabled, showIndicatorTransition=false, extreme width variance, many tabs, multiple instances.

📦 Cross-Package Impact

Package Changes
dialtone-css Animation CSS vars, ::before/::after overlay rules, new --ttf-ease token
dialtone-vue tab_group.vue, segmented_control.vue, new composable + utility
dialtone-documentation Transition utility docs updated, scratch.md stress tests

📝 Checklist

For all PRs:

  • I have ensured no private Dialpad links or info are in the code or pull request description.
  • I have reviewed my changes.
  • I have added all relevant documentation.
  • I have considered the performance impact of my change.

For all Vue changes:

  • I have added / updated unit tests.
  • I have validated components with a screen reader.
  • I have validated components keyboard navigation.

For all CSS changes:

  • I have used design tokens whenever possible.
  • I have considered how this change will behave on different screen sizes.
  • I have visually validated my change in light and dark mode.
  • I have used gap or flexbox properties for layout instead of margin whenever possible.

📷 Screenshots / GIFs / Screencasts

Screen.Recording.2026-04-11.at.9.19.39.PM.mov
Screen.Recording.2026-04-11.at.10.05.10.PM.mov

Adds a "traveling indicator" animation for DtTabGroup and DtSegmentedControl using FLIP + Web Animations API: new CSS vars and pseudo-element overlays, shared indicatorAnimation util and useIndicatorAnimation composable, showIndicatorTransition prop (default true), respects prefers-reduced-motion and keyboard auto-activation.

@francisrupert Francis Rupert (francisrupert) added the no-visual-test Add this tag when the PR does not need visual testing label Apr 12, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 12, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 25cc43fa-48a3-4fb1-a07e-f4f04bd3ef61

📥 Commits

Reviewing files that changed from the base of the PR and between 752fb02 and 6b132de.

📒 Files selected for processing (1)
  • packages/dialtone-vue/components/segmented_control/segmented_control.vue

Walkthrough

Adds a traveling-selection indicator: new transition token and utility classes, CSS hooks/pseudo-elements for FLIP-style animations, a Web Animations API helper and Vue composable, component integrations for tabs/segmented controls (prop + flow changes), and documentation/demo updates (including demo HTML adjustments and an extensive stress-test page).

Changes

Cohort / File(s) Summary
CSS Variables & Utilities
packages/dialtone-css/lib/build/less/variables/visual-styles.less, packages/dialtone-css/lib/build/less/utilities/effects.less
Added ttf-ease token; added .d-ttf utility; renamed selector to .d-ttf-out-quint.
Component Animation Styles
packages/dialtone-css/lib/build/less/components/tabs.less, packages/dialtone-css/lib/build/less/components/segmented-control.less
Added --*-indicator-duration/--*-indicator-easing; disabled per-item transitions; introduced ::before/::after pseudo-elements for animatable overlays used by indicator animations.
Shared Animation Utilities
packages/dialtone-vue/common/utils/indicatorAnimation.js, packages/dialtone-vue/common/utils/index.js
New module exporting cacheIndicatorConfig, cancelIndicatorAnimations, animateIndicator; re-exported these from utils index.
Vue Indicator Composable
packages/dialtone-vue/common/composables/useIndicatorAnimation.js
New composable useIndicatorAnimation(containerRef, durationVar, easingVar) exposing { animate, cancel, canAnimate }, lifecycle handling, cached config, and reduced-motion checks.
Component Integration
packages/dialtone-vue/components/tab/tab_group.vue, packages/dialtone-vue/components/segmented_control/segmented_control.vue
Added showIndicatorTransition prop; integrated useIndicatorAnimation; changed selection flow to capture previous geometry, cancel in-flight animations, and conditionally trigger indicator animations; keyboard auto-activation cancels/avoids animation.
Documentation & Examples
apps/dialtone-documentation/docs/components/tabs.md, apps/dialtone-documentation/docs/utilities/effects/transition.md, apps/dialtone-documentation/docs/scratch.md
Adjusted Borderless demo HTML placement; added .d-ttf demo and updated easing variants; expanded scratch page with a large "Traveling Indicator Stress Test" and removed placeholder div.

Sequence Diagram

sequenceDiagram
    participant User
    participant TabGroup
    participant Composable as useIndicatorAnimation
    participant Utils as indicatorAnimation.js
    participant WebAPI as Web Animations API

    User->>TabGroup: select tab (click/keyboard)
    TabGroup->>TabGroup: capture previous selection rect/styles
    TabGroup->>Composable: animate({ oldRect, newEl, orientation, ... })
    Composable->>Utils: animateIndicator(state, options)
    Utils->>Utils: compute config, check reduced-motion, cancel prior animations
    Utils->>WebAPI: start indicator animation (pseudoElement, duration, easing)
    Utils->>WebAPI: start hide-native animation (hideProps)
    WebAPI-->>Utils: animations finish
    Utils->>Utils: cleanup (cancel hide animation, clear handlers)
    Utils-->>Composable: resolved
    Composable-->>TabGroup: animation complete
    TabGroup-->>User: visual update rendered
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch traveling-active

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/dialtone-vue/components/tab/tab_group.vue (1)

407-486: ⚠️ Potential issue | 🟠 Major

Add tests for the new indicator-selection flow before merging.

This adds several non-trivial branches (showIndicatorTransition, $nextTick handoff, auto-mode cancellation, and outlined/muted animation paths), but there’s no coverage in the diff for them. At minimum I’d cover the no-animation fallback, auto activation, and the ::before/::after branches. As per coding guidelines, "Flag missing tests for non-trivial logic changes — untested behavior is unverified behavior."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dialtone-vue/components/tab/tab_group.vue` around lines 407 - 486,
The PR adds non-trivial indicator-selection logic but lacks tests; add unit
tests that exercise transitionIndicator and selection flow: cover the
no-animation fallback path when showIndicatorTransition is false or
indicator.canAnimate() is false (assert provideObj.selected changes and animate
is not called), the $nextTick animation handoff (spy on indicator.animate and
ensure it's called with oldRect/newEl when oldRect exists), the outlined and
muted branches (assert pseudoElement is '::before' and indicatorExtra includes
boxShadow or backgroundColor respectively), and the auto activation cancellation
via selectFocusOnTab/selectTab/selectTabByIndex (when activationMode === 'auto'
ensure indicator.cancel is called, provideObj.selected set, and onChange
invoked). Use spies/mocks for indicator.animate and indicator.cancel and
simulate tab DOM elements to produce oldRect/newContext conditions.
🧹 Nitpick comments (1)
packages/dialtone-vue/common/composables/useIndicatorAnimation.js (1)

17-50: The animation gate doesn't verify WAAPI support, but likely moot given browser targets.

cacheIndicatorConfig() checks window.matchMedia (very widely supported) and CSS vars, but doesn't detect Web Animations API capability, the pseudoElement option, or translate/scale animation support. If a caller runs on a browser lacking these features, animate() will fail without graceful fallback.

However, since Dialtone targets Vue ≥3.2 (which drops IE11 and very old browsers), and pseudoElement + translate/scale have been standard across all modern browsers since ~2020 (Safari 13.1+), this is a theoretical concern rather than a practical blocker. Consider adding explicit feature detection in cacheIndicatorConfig() only if supporting older Safari versions or non-evergreen browsers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dialtone-vue/common/composables/useIndicatorAnimation.js` around
lines 17 - 50, The current animation gate in useIndicatorAnimation (animate,
canAnimate) relies on config from cacheIndicatorConfig but does not detect Web
Animations API or specific feature support (pseudo-element animation and
translate/scale transforms), so animateIndicator may throw on non-evergreen
browsers; update cacheIndicatorConfig to perform explicit feature detection for
WAAPI (e.g., typeof Element.prototype.animate === 'function' or Animation in
window), detect pseudo-element animation capability and transform
(translate/scale) support, store the result as a flag on the returned config
(e.g., config.supportsWAAPI or config.canAnimateTransforms), and then have
useIndicatorAnimation’s animate and canAnimate check that flag in addition to
prefersReducedMotion before calling animateIndicator or returning true so
callers gracefully skip animations when features are unavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/dialtone-documentation/docs/utilities/effects/transition.md`:
- Around line 80-82: The v-for array for t when i === 'ttf' incorrectly uses
'quint', producing a non-existent class like .d-ttf-quint; change the array
entry 'quint' to 'out-quint' so the template that renders .d-{{ i }}{{ t ?
`-${t}` : '' }} and the timing variable var(--ttf-{{ t || 'ease' }}) will
produce the correct .d-ttf-out-quint and corresponding --ttf-out-quint variable.

In `@packages/dialtone-css/lib/build/less/utilities/effects.less`:
- Around line 192-195: Remove the old selector .ttf-out-quint and keep the new
.d-ttf-out-quint in effects.less (ensure the rule .d-ttf-out-quint {
transition-timing-function: var(--ttf-out-quint) !important; } remains), run a
repository-wide search for any remaining references to .ttf-out-quint or
.ttf-out and update them to .d-ttf-out-quint where appropriate, and add a note
to the release notes/migration guide documenting the breaking rename so external
consumers can update their class usage.

---

Outside diff comments:
In `@packages/dialtone-vue/components/tab/tab_group.vue`:
- Around line 407-486: The PR adds non-trivial indicator-selection logic but
lacks tests; add unit tests that exercise transitionIndicator and selection
flow: cover the no-animation fallback path when showIndicatorTransition is false
or indicator.canAnimate() is false (assert provideObj.selected changes and
animate is not called), the $nextTick animation handoff (spy on
indicator.animate and ensure it's called with oldRect/newEl when oldRect
exists), the outlined and muted branches (assert pseudoElement is '::before' and
indicatorExtra includes boxShadow or backgroundColor respectively), and the auto
activation cancellation via selectFocusOnTab/selectTab/selectTabByIndex (when
activationMode === 'auto' ensure indicator.cancel is called, provideObj.selected
set, and onChange invoked). Use spies/mocks for indicator.animate and
indicator.cancel and simulate tab DOM elements to produce oldRect/newContext
conditions.

---

Nitpick comments:
In `@packages/dialtone-vue/common/composables/useIndicatorAnimation.js`:
- Around line 17-50: The current animation gate in useIndicatorAnimation
(animate, canAnimate) relies on config from cacheIndicatorConfig but does not
detect Web Animations API or specific feature support (pseudo-element animation
and translate/scale transforms), so animateIndicator may throw on non-evergreen
browsers; update cacheIndicatorConfig to perform explicit feature detection for
WAAPI (e.g., typeof Element.prototype.animate === 'function' or Animation in
window), detect pseudo-element animation capability and transform
(translate/scale) support, store the result as a flag on the returned config
(e.g., config.supportsWAAPI or config.canAnimateTransforms), and then have
useIndicatorAnimation’s animate and canAnimate check that flag in addition to
prefersReducedMotion before calling animateIndicator or returning true so
callers gracefully skip animations when features are unavailable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 62318f2b-2a6d-4cf1-9cbd-edcaee296f09

📥 Commits

Reviewing files that changed from the base of the PR and between e1ea074 and a9ca1e1.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • apps/dialtone-documentation/docs/components/tabs.md
  • apps/dialtone-documentation/docs/scratch.md
  • apps/dialtone-documentation/docs/utilities/effects/transition.md
  • packages/dialtone-css/lib/build/less/components/segmented-control.less
  • packages/dialtone-css/lib/build/less/components/tabs.less
  • packages/dialtone-css/lib/build/less/utilities/effects.less
  • packages/dialtone-css/lib/build/less/variables/visual-styles.less
  • packages/dialtone-vue/common/composables/useIndicatorAnimation.js
  • packages/dialtone-vue/common/utils/index.js
  • packages/dialtone-vue/common/utils/indicatorAnimation.js
  • packages/dialtone-vue/components/segmented_control/segmented_control.vue
  • packages/dialtone-vue/components/tab/tab_group.vue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed both CodeRabbit findings:

  1. 'quint''out-quint' in transition.md — Fixed. The table now correctly renders .d-ttf-out-quint and var(--ttf-out-quint).

  2. .ttf-out-quint rename — Acknowledged. No internal usages of the old unprefixed class. Will note in release notes.

Thanks CodeRabbit!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/dialpad/dialtone/blob/c82b2e449504d8f5206fa8e6805cd332d2b2df6d/packages/dialtone-vue/common/utils/indicatorAnimation.js#L122-L124
P2 Badge Compute indicator translation across both axes

The delta is calculated on only one axis based on orientation, but horizontal tablists can wrap onto multiple rows. When moving selection between wrapped rows, vertical displacement is ignored, so the indicator animates from the wrong origin or skips entirely when the x-centers match (delta === 0). This breaks the traveling-indicator behavior for multi-row tab groups.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

@francisrupert
Copy link
Copy Markdown
Contributor Author

Addressing the CodeRabbit review summary:

Inline comments (both resolved):

  1. 'quint''out-quint' in transition.md — fixed in c82b2e4.
  2. .ttf-out-quint.d-ttf-out-quint rename — already in the diff. No internal usages of the old class found.

Outside diff comment (unit tests for transitionIndicator):

Not adding. The animation code gracefully degrades in jsdom (Element.animate doesn't exist), and all 65 tab + 30 segmented control tests pass. The animation is purely visual — it doesn't change observable state (selection, events, focus). Testing spy calls on animate would be testing implementation details, not behavior.

Nitpick (WAAPI feature detection):

Already handled. Both components guard with typeof newContext?.animate !== 'function' / typeof newEl.animate !== 'function' before calling the API. That IS the WAAPI feature detection. Every browser that supports Element.animate() also supports pseudo-element animation and translate/scale transforms. Additional capability sniffing is unnecessary.

@francisrupert
Copy link
Copy Markdown
Contributor Author

Re: Codex review — multi-row tab wrapping

Good catch. Fixed — we now detect when old and new elements are on different rows (cross-axis delta > 1px) and skip the animation entirely (instant switch). We don't want a diagonal transition, and wrapped tabs are an extreme edge case that ideally wouldn't occur in practice.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/dialtone-vue/common/utils/indicatorAnimation.js`:
- Around line 53-88: Add unit tests covering animateIndicator's decision and
cleanup paths: mock Element.prototype.animate and verify behavior for (1)
cross-axis skip (when crossAxisDelta > 1) returns without starting animations,
(2) center-align skip (when delta === 0) returns without starting animations,
(3) cancel/restart: when state has existing animations ensure
cancelIndicatorAnimations is invoked and new animations are created on
state.hideNative and state.indicator, and (4) onfinish cleanup: simulate
finishing state.indicator and assert state.hideNative.cancel was called; test
both orientations (vertical/horizontal) and include cases using pseudoElement
and indicatorExtra to ensure those options are passed through. Use
animateIndicator and cancelIndicatorAnimations identifiers to locate the code
and assert expected calls on state.hideNative, state.indicator, and the mocked
Element.animate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 4b82775e-cddd-4ca6-8b5c-e1911ee9a90a

📥 Commits

Reviewing files that changed from the base of the PR and between c82b2e4 and 752fb02.

📒 Files selected for processing (1)
  • packages/dialtone-vue/common/utils/indicatorAnimation.js

@github-actions
Copy link
Copy Markdown
Contributor

✔️ Deploy previews ready!
😎 Dialtone documentation preview: https://dialtone.dialpad.com/deploy-previews/pr-1192/
😎 Dialtone-vue preview: https://dialtone.dialpad.com/vue/deploy-previews/pr-1192/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-visual-test Add this tag when the PR does not need visual testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant