Skip to content

feat(SliderArea): add SliderArea component#2460

Open
GrandMagus02 wants to merge 18 commits into
unovue:v2from
GrandMagus02:feat/slider-area
Open

feat(SliderArea): add SliderArea component#2460
GrandMagus02 wants to merge 18 commits into
unovue:v2from
GrandMagus02:feat/slider-area

Conversation

@GrandMagus02
Copy link
Copy Markdown

@GrandMagus02 GrandMagus02 commented Feb 16, 2026

🔗 Linked issue

#2455

❓ Type of change

  • 📖 Documentation (updates to the documentation, readme or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Implemented the new SliderArea component set, enabling a fully accessible 2D slider for selecting (x, y) values via one or multiple thumbs within a rectangular track.

This adds the core primitives (SliderAreaRoot, SliderAreaTrack, SliderAreaRegion, SliderAreaThumb, SliderAreaThumbX, SliderAreaThumbY) with controlled and uncontrolled value support, keyboard interactions, pointer/touch dragging, RTL handling, disabled state, stepping, and optional axis inversion—following the existing Slider patterns and API conventions.

Resolves #2455

📸 Screenshots (if appropriate)

image image

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added SliderArea component, a 2D slider with multi-thumb support, customizable axis ranges, keyboard navigation, and accessibility features for enhanced user interaction.
  • Documentation

    • Added comprehensive guides, API documentation, and interactive demos showcasing component usage patterns and configurations.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

📝 Walkthrough

Walkthrough

This PR introduces the SliderArea component—a 2D slider for selecting (x, y) coordinates via dragging one or more thumbs in a rectangular area. It includes four main Vue components (Root, Area, Region, Thumb), comprehensive accessibility support, keyboard and pointer interaction handling, form integration, documentation, story variants, and utility functions.

Changes

Cohort / File(s) Summary
Documentation Navigation & Demos
docs/.vitepress/config.ts, docs/components/Demos.vue
Added SliderArea to navigation menu and imported SliderAreaDemo component into the docs template.
Documentation Examples
docs/components/demo/SliderArea/.../*
New demo implementations with CSS and Tailwind variants, including configuration files and styling.
Component Documentation
docs/content/docs/components/slider-area.md
Comprehensive SliderArea documentation covering anatomy, props, examples, accessibility, and keyboard interactions.
Meta Documentation
docs/content/meta/SliderArea*.md
Auto-generated API reference documentation for SliderAreaRoot, SliderAreaRegion, SliderAreaThumb, and SliderAreaTrack components.
Core SliderArea Components
packages/core/src/SliderArea/SliderAreaRoot.vue, packages/core/src/SliderArea/SliderAreaArea.vue, packages/core/src/SliderArea/SliderAreaRegion.vue, packages/core/src/SliderArea/SliderAreaThumb.vue
Main component implementations with context management, pointer/keyboard event handling, multi-thumb support, and 2D value tracking.
SliderArea Utilities & Exports
packages/core/src/SliderArea/utils.ts, packages/core/src/SliderArea/index.ts, packages/core/src/index.ts
Utility functions for snapping values to steps and finding closest thumb; barrel exports for SliderArea components; public API re-export.
Component Registry
packages/core/constant/components.ts
Added sliderArea entry to the components registry with its four component names.
Comprehensive Test Suite
packages/core/src/SliderArea/SliderAreaTest.ts
Tests covering accessibility (ARIA, roving tabindex), keyboard navigation (arrows, Home/End/PageUp/PageDown, shift-modifiers), pointer interactions, enabled/disabled states, form integration, and axis-inversion variants.
Story Components
packages/core/src/SliderArea/story/.../*.story.vue
Multiple story files (Chromatic variants, Demo, Extremes, and base template) demonstrating uncontrolled/controlled modes, RTL, disabled states, custom steps, inverted axes, thumb alignment, multi-thumb scenarios, and form submission.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant SliderAreaThumb as Thumb
    participant SliderAreaRoot as Root
    participant Context as Root Context

    User->>SliderAreaThumb: Pointer Down on Thumb
    SliderAreaThumb->>Root: handlePointerDown
    Root->>Root: getClosestThumbIndex
    Root->>Root: getPointFromPointerEvent
    Root->>Context: Update valueIndexToChangeRef
    Root->>User: Focus Thumb

    User->>SliderAreaThumb: Pointer Move
    SliderAreaThumb->>Root: handlePointerMove
    Root->>Root: getPointFromPointerEvent
    Root->>Root: clampAxis + updateValues
    Root->>Context: Update modelValue (continuous)

    User->>SliderAreaThumb: Pointer Up
    SliderAreaThumb->>Root: handlePointerUp
    Root->>Root: updateValues
    Root->>Context: Update modelValue
    Root->>User: Emit valueCommit
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #2206: Modifies the same components registry (packages/core/constant/components.ts) by adding new component groups; demonstrates a pattern of extending the library surface.
  • PR #2451: Adds another new component family with registry and public API wiring similar to this SliderArea addition.

Suggested labels

v2

Suggested reviewers

  • zernonia

Poem

🐰 A slider hops in two dimensions now,
Dragging thumbs across an area's brow,
From games to pickers, axes multiply,
Keyboard and pointer both reply—
Accessibility's code, pristine and bright! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(SliderArea): add SliderArea component' directly and clearly summarizes the main change: adding a new SliderArea component to the codebase.
Linked Issues check ✅ Passed The PR implementation addresses all core objectives from issue #2455: SliderAreaRoot, SliderAreaArea, SliderAreaRegion, SliderAreaThumb components with controlled/uncontrolled support, axis-specific props, keyboard interactions, accessibility features, RTL support, disabled state, and comprehensive documentation.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the SliderArea component and its documentation. No unrelated modifications to other components, utilities, or systems were introduced outside the stated objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Tip

You can validate your CodeRabbit configuration file in your editor.

If your editor has YAML language server, you can enable auto-completion and validation by adding # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json at the top of your CodeRabbit configuration file.

Copy link
Copy Markdown
Contributor

@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: 4

🤖 Fix all issues with AI agents
In `@docs/components/demo/SliderArea/tailwind/index.vue`:
- Around line 20-26: The aria-label on the SliderAreaThumb component is
incorrectly set to "Volume" for a generic 2D slider demo; update the aria-label
value on the SliderAreaThumb element (the attribute in the SliderAreaThumb
component instance) to a neutral, descriptive label such as "2D Slider" or
"Position" to accurately reflect its purpose in the demo and improve
accessibility.

In `@docs/content/docs/components/slider-area.md`:
- Around line 348-354: Add a short clarifying note in the Keyboard Interactions
section of the SliderArea docs explaining that SliderArea uses screen-coordinate
convention by default (Y increases downward) for its 2D use case, which is why
ArrowDown increases Y and ArrowUp decreases Y, and mention that the invertedY
prop on SliderArea can be set to true to invert the vertical axis to match the
WAI-ARIA vertical slider semantics if users need that behavior; update the text
near the ArrowDown/ArrowUp rows and/or add a single-sentence callout referencing
SliderArea and invertedY to keep the Accessibility/WAI-ARIA statement accurate.

In `@docs/content/meta/SliderAreaRoot.md`:
- Around line 3-10: Change the default for the "as" prop in the SliderAreaRoot
component from 'span' to a block-level element 'div' to avoid invalid HTML
nesting; update the prop default in the source component SliderAreaRoot
(packages/core/src/SliderArea/SliderAreaRoot.vue) so the props declaration for
"as" sets default: 'div' (the prop name is "as" in SliderAreaRoot) to match
other container components like Calendar/NumberField/Editable.

In `@packages/core/src/SliderArea/SliderAreaThumb.vue`:
- Around line 55-75: The ARIA attributes aria-roledescription and aria-label are
currently placed on SliderAreaThumb's wrapper Primitive (which has no role),
causing incorrect screen reader announcements; remove aria-label and
aria-roledescription from the Primitive in SliderAreaThumb and instead add
aria-label and aria-roledescription (or at least aria-label) to each focusable
child component SliderAreaThumbX and SliderAreaThumbY (the elements that set
role="slider"), ensuring aria-orientation remains on those child sliders; update
the binding that currently uses ($attrs['aria-label'] as string) || label so the
children receive the resolved label (or fallback) and do not rely on the parent
Primitive for ARIA attributes.
🧹 Nitpick comments (11)
packages/core/src/SliderArea/SliderArea.test.ts (3)

9-18: Duplicated global mocks could be extracted to a shared setup.

The ResizeObserver and pointer capture method mocks are duplicated verbatim between the two top-level describe blocks (lines 10–18 and lines 377–385). Consider extracting them to a file-level beforeAll or a shared helper to reduce duplication.

♻️ Proposed refactor: hoist mocks to file level
+// File-level setup — shared across all describe blocks
+globalThis.ResizeObserver = class ResizeObserver {
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+window.HTMLElement.prototype.scrollIntoView = vi.fn()
+window.HTMLElement.prototype.hasPointerCapture = vi.fn().mockImplementation(id => id)
+window.HTMLElement.prototype.releasePointerCapture = vi.fn()
+window.HTMLElement.prototype.setPointerCapture = vi.fn()
+
 describe('given default SliderArea', () => {
-  globalThis.ResizeObserver = class ResizeObserver {
-    observe() {}
-    unobserve() {}
-    disconnect() {}
-  }
-  window.HTMLElement.prototype.scrollIntoView = vi.fn()
-  window.HTMLElement.prototype.hasPointerCapture = vi.fn().mockImplementation(id => id)
-  window.HTMLElement.prototype.releasePointerCapture = vi.fn()
-  window.HTMLElement.prototype.setPointerCapture = vi.fn()
   ...
 })
 
 describe('given slider area in a form', () => {
-  globalThis.ResizeObserver = class ResizeObserver {
-    observe() {}
-    unobserve() {}
-    disconnect() {}
-  }
-  window.HTMLElement.prototype.scrollIntoView = vi.fn()
-  window.HTMLElement.prototype.hasPointerCapture = vi.fn().mockImplementation(id => id)
-  window.HTMLElement.prototype.releasePointerCapture = vi.fn()
-  window.HTMLElement.prototype.setPointerCapture = vi.fn()
   ...
 })

Also applies to: 376-385


303-325: Consider asserting the emitted valueCommit payload, not just its presence.

The pointer interaction test verifies that valueCommit is emitted with one argument (line 321), but doesn't assert what value was committed. Adding a payload assertion would strengthen this test and guard against regressions in the committed value.


26-33: Note: axe test disables the label rule.

The accessibility test disables the label rule. This is acceptable for a story/demo component, but please ensure that the documentation or real-world usage examples demonstrate proper labeling for the SliderArea (e.g., via aria-label or associated <label> elements) so consumers get it right.

packages/core/src/SliderArea/SliderAreaRegion.vue (1)

53-67: Verify that the parent track element has position: relative.

The region uses position: absolute in its inline style, which requires a positioned ancestor. The demo CSS sets position: relative on .SliderAreaTrack, but consumers using custom styles or the as-child pattern on the track could lose this. Consider documenting this requirement or adding a note in the SliderAreaRegion docs.

docs/components/demo/SliderArea/css/index.vue (1)

17-23: Consider a more descriptive aria-label for the demo.

"Volume" suggests a 1D control. Since this demonstrates a 2D slider area, a label like "Color picker" or "Position" would better illustrate the component's purpose to users reading the docs.

docs/content/docs/components/slider-area.md (1)

403-403: Minor wording nit.

Per static analysis: "abstracts all of the" → "abstracts all the" for conciseness.

Proposed fix
-This example abstracts all of the `SliderArea` parts so it can be used as a self-closing element.
+This example abstracts all the `SliderArea` parts so it can be used as a self-closing element.
packages/core/src/SliderArea/story/_SliderArea.vue (1)

18-34: v-model overrides modelValue from forwarded props.

v-bind="forwarded" spreads all props (including modelValue if passed by parent), but v-model="sliderValue" immediately overrides it with local state. This means any externally passed modelValue is silently ignored. Since this is a story-internal component, it's likely intentional — just flagging for awareness.

packages/core/src/SliderArea/SliderAreaImpl.vue (1)

51-54: PAGE_KEYS in the concat is redundant.

PageUp and PageDown are already handled by the earlier else if branches (lines 43–50), so they can never reach this branch. The PAGE_KEYS.concat(ARROW_KEYS) effectively matches only arrow keys here.

Suggested simplification
-      else if (PAGE_KEYS.concat(ARROW_KEYS).includes(event.key)) {
+      else if (ARROW_KEYS.includes(event.key)) {
packages/core/src/SliderArea/SliderAreaRoot.vue (3)

132-136: isSlidingFromLeft logic is correct but hard to parse.

The double-negative style (!== 'rtl', !== 'ltr') makes the truth table non-obvious at a glance. Consider simplifying to an XOR-style expression.

Optional: clearer expression
 const isSlidingFromLeft = computed(() => {
-  return (dir.value !== 'rtl' && !props.invertedX) || (dir.value !== 'ltr' && props.invertedX)
+  return (dir.value === 'ltr') !== props.invertedX
 })

This reads as: "slide from left when LTR and not inverted, or RTL and inverted" — i.e., inversion flips the direction.


224-245: Deep-comparison via JSON.stringify is adequate here but worth noting.

JSON.stringify for change detection (line 236) works for small arrays of number pairs. For this use case it's fine, but if the component ever supports large numbers of thumbs, a manual comparison would be more efficient.


336-348: Verify valuesBeforeSlideStartRef captures a sufficiently deep snapshot.

Line 337: valuesBeforeSlideStartRef = currentModelValue assigns the shallow-copied array from the computed ([...modelValue.value]). The inner [x, y] arrays are shared references with modelValue. This is safe only because updateValues (line 232) replaces inner arrays with new [finalX, finalY] rather than mutating them in place. If that invariant is ever broken, handleSlideEnd's change detection (line 210) would silently fail.

Consider a defensive deep copy to decouple from this implicit invariant:

Optional: defensive deep copy
 `@pointerdown`="() => {
-  if (!disabled) valuesBeforeSlideStartRef = currentModelValue
+  if (!disabled) valuesBeforeSlideStartRef = currentModelValue.map(p => [...p])
 }"

Comment thread docs/components/demo/SliderArea/tailwind/index.vue Outdated
Comment thread docs/content/docs/components/slider-area.md
Comment thread docs/content/meta/SliderAreaRoot.md
Comment on lines +55 to +75
<template>
<CollectionItem>
<Primitive
v-bind="$attrs"
:ref="forwardRef"
:aria-label="($attrs['aria-label'] as string) || label"
:data-disabled="rootContext.disabled.value ? '' : undefined"
aria-roledescription="2D slider"
:as-child="asChild"
:as="as"
:style="{
transform: 'var(--reka-slider-area-thumb-transform)',
position: 'absolute',
[rootContext.isSlidingFromLeft.value ? 'left' : 'right']: `calc(${percentX}% + ${thumbInBoundsOffsetX}px)`,
[rootContext.isSlidingFromTop.value ? 'top' : 'bottom']: `calc(${percentY}% + ${thumbInBoundsOffsetY}px)`,
display: !isMounted && value === undefined ? 'none' : undefined,
}"
>
<slot />
</Primitive>
</CollectionItem>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd 'SliderAreaThumbX\.vue|SliderAreaThumbY\.vue'

Repository: unovue/reka-ui

Length of output: 157


🏁 Script executed:

cat packages/core/src/SliderArea/SliderAreaThumbX.vue
cat packages/core/src/SliderArea/SliderAreaThumbY.vue

Repository: unovue/reka-ui

Length of output: 3717


Move ARIA attributes to the axis-specific child components.

The aria-roledescription and aria-label are currently on SliderAreaThumb (the parent wrapper), which has no role attribute. However, the actual focusable slider elements are SliderAreaThumbX and SliderAreaThumbY, which have role="slider" but no labels or role descriptions.

ARIA attributes like aria-roledescription should be on elements with semantic roles. This structure causes screen readers to announce the label on an invisible wrapper, then announce two separate sliders without context.

Move aria-label to both child components, or provide a single label on the parent if these are meant as a unified control. Add aria-roledescription to describe the 2D behavior on the child components or consider removing it entirely since each axis is already described as "horizontal"/"vertical" via aria-orientation.

🤖 Prompt for AI Agents
In `@packages/core/src/SliderArea/SliderAreaThumb.vue` around lines 55 - 75, The
ARIA attributes aria-roledescription and aria-label are currently placed on
SliderAreaThumb's wrapper Primitive (which has no role), causing incorrect
screen reader announcements; remove aria-label and aria-roledescription from the
Primitive in SliderAreaThumb and instead add aria-label and aria-roledescription
(or at least aria-label) to each focusable child component SliderAreaThumbX and
SliderAreaThumbY (the elements that set role="slider"), ensuring
aria-orientation remains on those child sliders; update the binding that
currently uses ($attrs['aria-label'] as string) || label so the children receive
the resolved label (or fallback) and do not rely on the parent Primitive for
ARIA attributes.

@zernonia
Copy link
Copy Markdown
Member

@GrandMagus02 can you check the comment from coderabbit?

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Feb 24, 2026

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2460

commit: 3c3ee27

@GrandMagus02
Copy link
Copy Markdown
Author

@GrandMagus02 can you check the comment from coderabbit?

Yeah, I see color components being added, but I think SliderArea still has a place. My implementation design is a bit different so I will update code to use single thumb for both X & Y

Copy link
Copy Markdown
Contributor

@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

🧹 Nitpick comments (1)
packages/core/src/SliderArea/SliderArea.test.ts (1)

9-17: Extract and centralize global mock setup to reduce drift.

Line 9 and Line 337 duplicate the same global/prototype stubs. Consolidating this into a shared helper (and restoring in teardown) will make the suite less brittle and easier to maintain.

Also applies to: 337-345

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

In `@packages/core/src/SliderArea/SliderArea.test.ts` around lines 9 - 17, Extract
the duplicated global/prototype stubs (globalThis.ResizeObserver and
window.HTMLElement.prototype methods: scrollIntoView, hasPointerCapture,
releasePointerCapture, setPointerCapture) from SliderArea.test.ts into a
centralized test helper (e.g., a shared setup/teardown utility used by tests);
replace the duplicated blocks in SliderArea.test.ts (lines around the
ResizeObserver and the prototype stubs) with calls to that helper, and ensure
the helper registers the mocks in a beforeAll/beforeEach and restores the
originals in an afterAll/afterEach so tests don’t leak mocked globals.
🤖 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/core/src/SliderArea/SliderAreaRoot.vue`:
- Around line 314-325: The pointerdown handler currently checks exact equality
with thumbElements via thumbElements.value.includes(target), which misses clicks
on nested elements inside a thumb and incorrectly calls handleSlideStart; change
the check to detect descendant hits (e.g., use Element.closest or check
thumbElements.value.some(thumb => thumb.contains(target))) so that pointer
events on thumb children focus the correct thumb and avoid calling
handleSlideStart; keep the setPointerCapture(event.pointerId),
valuesBeforeSlideStartRef.value = currentModelValue.value, and the focus
behavior for the resolved thumb element.

In `@packages/core/src/SliderArea/SliderAreaThumb.vue`:
- Around line 49-57: The cleanup in onUnmounted can fail because
currentElement.value may be null by unmount time; capture the element reference
during onMounted into a local variable (e.g., mountedEl) and push that captured
value into rootContext.thumbElements.value, then in onUnmounted use that
captured mountedEl (not currentElement.value) to findIndex and splice it out of
rootContext.thumbElements.value so removal is deterministic; update references
to currentElement in the mount/unmount pair accordingly.

---

Nitpick comments:
In `@packages/core/src/SliderArea/SliderArea.test.ts`:
- Around line 9-17: Extract the duplicated global/prototype stubs
(globalThis.ResizeObserver and window.HTMLElement.prototype methods:
scrollIntoView, hasPointerCapture, releasePointerCapture, setPointerCapture)
from SliderArea.test.ts into a centralized test helper (e.g., a shared
setup/teardown utility used by tests); replace the duplicated blocks in
SliderArea.test.ts (lines around the ResizeObserver and the prototype stubs)
with calls to that helper, and ensure the helper registers the mocks in a
beforeAll/beforeEach and restores the originals in an afterAll/afterEach so
tests don’t leak mocked globals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88afc80e-3f40-4b2b-bc99-3d62bcdbeaf2

📥 Commits

Reviewing files that changed from the base of the PR and between 8a279a9 and f889f4d.

📒 Files selected for processing (15)
  • docs/components/demo/SliderArea/css/index.vue
  • docs/components/demo/SliderArea/css/styles.css
  • docs/components/demo/SliderArea/tailwind/index.vue
  • docs/content/docs/components/slider-area.md
  • packages/core/constant/components.ts
  • packages/core/src/SliderArea/SliderArea.test.ts
  • packages/core/src/SliderArea/SliderAreaArea.vue
  • packages/core/src/SliderArea/SliderAreaRoot.vue
  • packages/core/src/SliderArea/SliderAreaThumb.vue
  • packages/core/src/SliderArea/index.ts
  • packages/core/src/SliderArea/story/SliderAreaChromatic.story.vue
  • packages/core/src/SliderArea/story/SliderAreaDemo.story.vue
  • packages/core/src/SliderArea/story/SliderAreaExtremes.story.vue
  • packages/core/src/SliderArea/story/_SliderArea.vue
  • packages/core/src/SliderArea/utils.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • docs/components/demo/SliderArea/css/index.vue
  • packages/core/src/SliderArea/story/_SliderArea.vue
  • packages/core/src/SliderArea/story/SliderAreaExtremes.story.vue
  • docs/components/demo/SliderArea/css/styles.css
  • packages/core/src/SliderArea/story/SliderAreaChromatic.story.vue
  • packages/core/src/SliderArea/story/SliderAreaDemo.story.vue
  • packages/core/src/SliderArea/utils.ts
  • docs/components/demo/SliderArea/tailwind/index.vue

Comment on lines +314 to +325
const target = event.target as HTMLElement
target.setPointerCapture(event.pointerId)
event.preventDefault()

valuesBeforeSlideStartRef.value = currentModelValue.value

if (thumbElements.value.includes(target)) {
target.focus()
}
else {
handleSlideStart(event)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle pointerdown on thumb descendants, not only exact thumb elements.

At Line 320, using includes(target) misses cases where the pointer lands on nested content inside a thumb. That path falls into handleSlideStart, which can cause jumpy behavior instead of dragging the intended thumb.

Proposed fix
 function handlePointerDown(event: PointerEvent) {
   if (disabled.value)
     return

-  const target = event.target as HTMLElement
-  target.setPointerCapture(event.pointerId)
+  const target = event.target as HTMLElement
+  const captureEl = event.currentTarget as HTMLElement
+  captureEl.setPointerCapture(event.pointerId)
   event.preventDefault()

   valuesBeforeSlideStartRef.value = currentModelValue.value

-  if (thumbElements.value.includes(target)) {
-    target.focus()
+  const thumbIndex = thumbElements.value.findIndex(el => el === target || el.contains(target))
+  if (thumbIndex >= 0) {
+    valueIndexToChangeRef.value = thumbIndex
+    thumbElements.value[thumbIndex]?.focus()
   }
   else {
     handleSlideStart(event)
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const target = event.target as HTMLElement
target.setPointerCapture(event.pointerId)
event.preventDefault()
valuesBeforeSlideStartRef.value = currentModelValue.value
if (thumbElements.value.includes(target)) {
target.focus()
}
else {
handleSlideStart(event)
}
const target = event.target as HTMLElement
const captureEl = event.currentTarget as HTMLElement
captureEl.setPointerCapture(event.pointerId)
event.preventDefault()
valuesBeforeSlideStartRef.value = currentModelValue.value
const thumbIndex = thumbElements.value.findIndex(el => el === target || el.contains(target))
if (thumbIndex >= 0) {
valueIndexToChangeRef.value = thumbIndex
thumbElements.value[thumbIndex]?.focus()
}
else {
handleSlideStart(event)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/SliderArea/SliderAreaRoot.vue` around lines 314 - 325, The
pointerdown handler currently checks exact equality with thumbElements via
thumbElements.value.includes(target), which misses clicks on nested elements
inside a thumb and incorrectly calls handleSlideStart; change the check to
detect descendant hits (e.g., use Element.closest or check
thumbElements.value.some(thumb => thumb.contains(target))) so that pointer
events on thumb children focus the correct thumb and avoid calling
handleSlideStart; keep the setPointerCapture(event.pointerId),
valuesBeforeSlideStartRef.value = currentModelValue.value, and the focus
behavior for the resolved thumb element.

Comment on lines +49 to +57
onMounted(() => {
if (currentElement.value)
rootContext.thumbElements.value.push(currentElement.value)
})
onUnmounted(() => {
const i = rootContext.thumbElements.value.findIndex(el => el === currentElement.value)
if (i >= 0)
rootContext.thumbElements.value.splice(i, 1)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Vue 3, are template refs guaranteed to still reference the DOM node inside onUnmounted, or can they already be null by that hook?

💡 Result:

In Vue 3, template refs are not guaranteed to still point at a DOM node in onUnmounted(). By the time onUnmounted runs, the component is already unmounted, and template refs can already have been cleared to null (Vue explicitly notes refs can become null when the referenced element is unmounted, e.g. via v-if) [2]. The hook intended for “still fully functional” teardown work is onBeforeUnmount() [1].

Practical rule: if you must read the element (measurements, final DOM state, etc.), do it in onBeforeUnmount; in onUnmounted, assume the ref may be null [1][2].


🏁 Script executed:

cat -n packages/core/src/SliderArea/SliderAreaThumb.vue | sed -n '40,70p'

Repository: unovue/reka-ui

Length of output: 1262


🏁 Script executed:

cat -n packages/core/src/SliderArea/SliderAreaThumb.vue | sed -n '1,48p'

Repository: unovue/reka-ui

Length of output: 2443


🏁 Script executed:

rg -A 10 "export.*useForwardExpose" packages/core/src --type ts --type vue

Repository: unovue/reka-ui

Length of output: 85


🏁 Script executed:

rg -A 10 "export.*useForwardExpose" packages/core/src --type ts

Repository: unovue/reka-ui

Length of output: 2001


🏁 Script executed:

cat packages/core/src/shared/useForwardExpose.ts

Repository: unovue/reka-ui

Length of output: 3316


🏁 Script executed:

rg "thumbElements" packages/core/src/SliderArea --type ts -B 2 -A 2

Repository: unovue/reka-ui

Length of output: 40


🏁 Script executed:

rg "thumbElements" packages/core/src --type ts -B 2 -A 2

Repository: unovue/reka-ui

Length of output: 40


🏁 Script executed:

find packages/core/src -name "*.ts" -o -name "*.vue" | xargs grep -l "thumbElements"

Repository: unovue/reka-ui

Length of output: 369


🏁 Script executed:

cat packages/core/src/SliderArea/SliderAreaRoot.vue | grep -n "thumbElements" -B 5 -A 5

Repository: unovue/reka-ui

Length of output: 1999


🏁 Script executed:

rg "currentElement\.value\s*=" packages/core/src/SliderArea/SliderAreaThumb.vue

Repository: unovue/reka-ui

Length of output: 40


Capture the element reference during mount to ensure deterministic cleanup.

In Vue 3, template refs can become null by the time onUnmounted runs because the component is already unmounted. The current cleanup path at line 54 depends on currentElement.value still pointing to the mounted node, but if that ref has been cleared to null, the findIndex comparison fails and the element is never removed from rootContext.thumbElements. This causes stale entries to accumulate.

Proposed fix
+import { computed, onMounted, onUnmounted, ref } from 'vue'
 ...
+const mountedThumbEl = ref<HTMLElement | null>(null)
+
 onMounted(() => {
-  if (currentElement.value)
-    rootContext.thumbElements.value.push(currentElement.value)
+  mountedThumbEl.value = currentElement.value ?? null
+  if (mountedThumbEl.value)
+    rootContext.thumbElements.value.push(mountedThumbEl.value)
 })
 onUnmounted(() => {
-  const i = rootContext.thumbElements.value.findIndex(el => el === currentElement.value)
+  const i = rootContext.thumbElements.value.findIndex(el => el === mountedThumbEl.value)
   if (i >= 0)
     rootContext.thumbElements.value.splice(i, 1)
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onMounted(() => {
if (currentElement.value)
rootContext.thumbElements.value.push(currentElement.value)
})
onUnmounted(() => {
const i = rootContext.thumbElements.value.findIndex(el => el === currentElement.value)
if (i >= 0)
rootContext.thumbElements.value.splice(i, 1)
})
const mountedThumbEl = ref<HTMLElement | null>(null)
onMounted(() => {
mountedThumbEl.value = currentElement.value ?? null
if (mountedThumbEl.value)
rootContext.thumbElements.value.push(mountedThumbEl.value)
})
onUnmounted(() => {
const i = rootContext.thumbElements.value.findIndex(el => el === mountedThumbEl.value)
if (i >= 0)
rootContext.thumbElements.value.splice(i, 1)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/SliderArea/SliderAreaThumb.vue` around lines 49 - 57, The
cleanup in onUnmounted can fail because currentElement.value may be null by
unmount time; capture the element reference during onMounted into a local
variable (e.g., mountedEl) and push that captured value into
rootContext.thumbElements.value, then in onUnmounted use that captured mountedEl
(not currentElement.value) to findIndex and splice it out of
rootContext.thumbElements.value so removal is deterministic; update references
to currentElement in the mount/unmount pair accordingly.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add SliderArea component

2 participants