feat(SliderArea): add SliderArea component#2460
feat(SliderArea): add SliderArea component#2460GrandMagus02 wants to merge 16 commits intounovue:v2from
Conversation
… call expectations
…s for 2D slider functionality
📝 WalkthroughWalkthroughA new SliderArea component—a 2D slider for selecting (x, y) coordinates—is introduced with six sub-components (Root, Track, Region, Thumb, ThumbX, ThumbY), keyboard and pointer interaction support, comprehensive documentation, test coverage, story variants, and API exports. Changes
Sequence DiagramsequenceDiagram
actor User
participant SliderAreaRoot
participant SliderAreaImpl
participant SliderAreaThumbX
participant SliderAreaThumbY
participant Context as Thumb Context
User->>SliderAreaRoot: Pointer Down on Track
SliderAreaRoot->>SliderAreaRoot: getPointFromPointerEvent()
SliderAreaRoot->>SliderAreaRoot: getClosestThumbIndex()
SliderAreaRoot->>SliderAreaImpl: slideStart event
SliderAreaImpl->>SliderAreaThumbX: focus() + set activeDirection='x'
SliderAreaImpl->>SliderAreaThumbY: focus() + set activeDirection='y'
User->>SliderAreaRoot: Pointer Move
SliderAreaRoot->>SliderAreaRoot: getPointFromPointerEvent()
SliderAreaRoot->>SliderAreaRoot: clampAxis() + updateValues()
SliderAreaImpl->>SliderAreaImpl: slideMove event
SliderAreaThumbX->>Context: Update X value
SliderAreaThumbY->>Context: Update Y value
SliderAreaRoot->>SliderAreaRoot: emit update:modelValue
User->>SliderAreaRoot: Pointer Up
SliderAreaImpl->>SliderAreaImpl: slideEnd event
SliderAreaRoot->>SliderAreaRoot: handleSlideEnd()
SliderAreaRoot->>SliderAreaRoot: emit valueCommit
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 6✅ Passed checks (6 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. 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. Comment |
There was a problem hiding this comment.
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
ResizeObserverand pointer capture method mocks are duplicated verbatim between the two top-leveldescribeblocks (lines 10–18 and lines 377–385). Consider extracting them to a file-levelbeforeAllor 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 emittedvalueCommitpayload, not just its presence.The pointer interaction test verifies that
valueCommitis 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 thelabelrule.The accessibility test disables the
labelrule. 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., viaaria-labelor associated<label>elements) so consumers get it right.packages/core/src/SliderArea/SliderAreaRegion.vue (1)
53-67: Verify that the parent track element hasposition: relative.The region uses
position: absolutein its inline style, which requires a positioned ancestor. The demo CSS setsposition: relativeon.SliderAreaTrack, but consumers using custom styles or theas-childpattern 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 descriptivearia-labelfor 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-modeloverridesmodelValuefrom forwarded props.
v-bind="forwarded"spreads all props (includingmodelValueif passed by parent), butv-model="sliderValue"immediately overrides it with local state. This means any externally passedmodelValueis 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_KEYSin the concat is redundant.
PageUpandPageDownare already handled by the earlierelse ifbranches (lines 43–50), so they can never reach this branch. ThePAGE_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:isSlidingFromLeftlogic 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 viaJSON.stringifyis adequate here but worth noting.
JSON.stringifyfor 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: VerifyvaluesBeforeSlideStartRefcaptures a sufficiently deep snapshot.Line 337:
valuesBeforeSlideStartRef = currentModelValueassigns the shallow-copied array from the computed ([...modelValue.value]). The inner[x, y]arrays are shared references withmodelValue. This is safe only becauseupdateValues(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]) }"
| <SliderAreaThumb | ||
| class="[&>*]:outline-none block w-6 h-6 bg-white rounded-full hover:bg-stone-50 shadow-sm focus-within:outline-none focus-within:shadow-[0_0_0_2px] focus-within:shadow-grass9" | ||
| aria-label="Volume" | ||
| > | ||
| <SliderAreaThumbX /> | ||
| <SliderAreaThumbY /> | ||
| </SliderAreaThumb> |
There was a problem hiding this comment.
aria-label="Volume" is misleading for a generic 2D slider demo.
This is a general-purpose SliderArea demo, not a volume control. A label like "2D Slider" or "Position" would be more appropriate and less confusing for users reading the docs.
Proposed fix
<SliderAreaThumb
class="[&>*]:outline-none block w-6 h-6 bg-white rounded-full hover:bg-stone-50 shadow-sm focus-within:outline-none focus-within:shadow-[0_0_0_2px] focus-within:shadow-grass9"
- aria-label="Volume"
+ aria-label="2D Slider"
>📝 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.
| <SliderAreaThumb | |
| class="[&>*]:outline-none block w-6 h-6 bg-white rounded-full hover:bg-stone-50 shadow-sm focus-within:outline-none focus-within:shadow-[0_0_0_2px] focus-within:shadow-grass9" | |
| aria-label="Volume" | |
| > | |
| <SliderAreaThumbX /> | |
| <SliderAreaThumbY /> | |
| </SliderAreaThumb> | |
| <SliderAreaThumb | |
| class="[&>*]:outline-none block w-6 h-6 bg-white rounded-full hover:bg-stone-50 shadow-sm focus-within:outline-none focus-within:shadow-[0_0_0_2px] focus-within:shadow-grass9" | |
| aria-label="2D Slider" | |
| > | |
| <SliderAreaThumbX /> | |
| <SliderAreaThumbY /> | |
| </SliderAreaThumb> |
🤖 Prompt for AI Agents
In `@docs/components/demo/SliderArea/tailwind/index.vue` around lines 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.
| keys: ['ArrowDown'], | ||
| description: 'Increases the Y value by the <Code>stepY</Code> amount.', | ||
| }, | ||
| { | ||
| keys: ['ArrowUp'], | ||
| description: 'Decreases the Y value by the <Code>stepY</Code> amount.', | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, find the SliderArea component files
find . -type f -name "*slider*area*" -o -name "*SliderArea*" | head -20Repository: unovue/reka-ui
Length of output: 1007
🏁 Script executed:
#!/bin/bash
# Check for keyboard handler implementation
rg -n 'ArrowDown|ArrowUp' --type=vue --type=ts -C3 2>/dev/null | head -100Repository: unovue/reka-ui
Length of output: 40
🏁 Script executed:
#!/bin/bash
# Look for the SliderArea component source more broadly
fd -i sliderarea packages/ || fd -i slider packages/ | grep -i area | head -10Repository: unovue/reka-ui
Length of output: 719
🏁 Script executed:
#!/bin/bash
# Check the main implementation files for keyboard handling
wc -l packages/core/src/SliderArea/SliderAreaImpl.vue packages/core/src/SliderArea/SliderAreaRoot.vueRepository: unovue/reka-ui
Length of output: 177
🏁 Script executed:
#!/bin/bash
# Search for keyboard event handling in SliderArea implementation
cat -n packages/core/src/SliderArea/SliderAreaImpl.vue | head -150Repository: unovue/reka-ui
Length of output: 3076
🏁 Script executed:
#!/bin/bash
# Search for key handling logic
rg -n "key|Arrow|keyboard" packages/core/src/SliderArea/SliderAreaImpl.vue -A 3Repository: unovue/reka-ui
Length of output: 770
🏁 Script executed:
#!/bin/bash
# Check SliderAreaRoot.vue for stepKeyDown handler and Y-value logic
rg -n "stepKeyDown|ArrowDown|ArrowUp|decreaseY|increaseY" packages/core/src/SliderArea/SliderAreaRoot.vue -B 2 -A 5Repository: unovue/reka-ui
Length of output: 353
🏁 Script executed:
#!/bin/bash
# Get more context from SliderAreaRoot.vue
sed -n '1,100p' packages/core/src/SliderArea/SliderAreaRoot.vue | cat -nRepository: unovue/reka-ui
Length of output: 4317
🏁 Script executed:
#!/bin/bash
# Check the full keyboard interactions section and WAI-ARIA reference
sed -n '320,360p' docs/content/docs/components/slider-area.md | cat -nRepository: unovue/reka-ui
Length of output: 1361
🏁 Script executed:
#!/bin/bash
# Check if invertedY is documented in the keyboard section or props
sed -n '340,400p' docs/content/docs/components/slider-area.md | cat -nRepository: unovue/reka-ui
Length of output: 2276
🏁 Script executed:
#!/bin/bash
# Search for invertedY prop documentation
rg -n "invertedY" docs/content/docs/components/slider-area.md -B 2 -A 3Repository: unovue/reka-ui
Length of output: 528
Add a clarifying note about SliderArea's screen-coordinate convention.
The keyboard interactions table accurately describes the component's behavior (ArrowDown increases Y, ArrowUp decreases Y), but the documentation doesn't explain why this deviates from the WAI-ARIA slider pattern referenced in the Accessibility section. While the invertedY prop is documented as a way to invert axes, users may be confused seeing "Adheres to the [Slider WAI-ARIA design pattern]" immediately before interactions that don't match that pattern by default.
Consider adding a brief note in the Keyboard Interactions section clarifying that SliderArea uses screen-coordinate convention by default (where Y increases downward) for the 2D use case, and that invertedY can be used to match WAI-ARIA vertical slider semantics if needed.
🤖 Prompt for AI Agents
In `@docs/content/docs/components/slider-area.md` around lines 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.
| <PropsTable :data="[ | ||
| { | ||
| 'name': 'as', | ||
| 'description': '<p>The element or component this component should render as. Can be overwritten by <code>asChild</code>.</p>\n', | ||
| 'type': 'AsTag | Component', | ||
| 'required': false, | ||
| 'default': '\'span\'' | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check what `as` defaults to in SliderAreaRoot.vue
echo "=== SliderAreaRoot.vue ==="
find . -name "SliderAreaRoot.vue" -type f | head -5Repository: unovue/reka-ui
Length of output: 134
🏁 Script executed:
#!/bin/bash
# Find and examine SliderAreaRoot.vue
fd "SliderAreaRoot.vue" -x cat -n {}
# Also check the component structure
echo ""
echo "=== Other Root components for comparison ==="
fd "Root.vue" packages/core/src -x sh -c 'echo "File: {}"; head -50 {}'Repository: unovue/reka-ui
Length of output: 50370
🏁 Script executed:
#!/bin/bash
# Search for all Root.vue files and their `as:` defaults
echo "=== Searching all Root components for 'as' defaults ==="
fd "Root.vue" packages/core/src -x sh -c '
file="{}"
echo "File: $file"
grep -A 15 "withDefaults(defineProps" "$file" | grep "as:" | head -1
'Repository: unovue/reka-ui
Length of output: 2540
Root component defaults to span — should be div or block-level element
SliderAreaRoot defaults the as prop to 'span', but this is a container holding block-level children (track, thumbs, input). A span is an inline element; nesting block content inside it violates HTML structure semantics. Other similar container-first components in the library (Calendar, NumberField, Editable) default to 'div'.
Since this file is auto-generated, apply the fix in packages/core/src/SliderArea/SliderAreaRoot.vue line 103.
🤖 Prompt for AI Agents
In `@docs/content/meta/SliderAreaRoot.md` around lines 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.
| <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> |
There was a problem hiding this comment.
🧩 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.vueRepository: 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.
|
@GrandMagus02 can you check the comment from coderabbit? |
commit: |
🔗 Linked issue
#2455
❓ Type of 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)
📝 Checklist
Summary by CodeRabbit
Release Notes