From 45758f774a71cebad55018bc04d7b4122b683b7c Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Sat, 6 Jun 2026 16:50:47 +0300 Subject: [PATCH] feat(slider): rewrite over Base UI architecture --- .../references/signal-forms-readiness.md | 41 +- .../content/primitives/components/slider.mdx | 103 +++-- .../demos/primitives/slider/slider-demo.css | 27 +- .../demos/primitives/slider/slider-demo.ts | 16 +- .../src/app/components/slider/page.ts | 43 +- .../slider/__tests__/slider.spec.ts | 175 ++++++++ packages/primitives/slider/index.ts | 38 +- .../primitives/slider/src/slider-context.ts | 11 + .../primitives/slider/src/slider-control.ts | 256 +++++++++++ .../slider/src/slider-horizontal.component.ts | 98 ----- .../slider/src/slider-impl.directive.ts | 76 ---- .../primitives/slider/src/slider-indicator.ts | 53 +++ .../src/slider-orientation-context.service.ts | 29 -- .../slider/src/slider-range.component.ts | 36 -- .../slider/src/slider-root.component.ts | 279 ------------ packages/primitives/slider/src/slider-root.ts | 309 ++++++++++++++ .../slider/src/slider-thumb-impl.directive.ts | 119 ------ .../slider/src/slider-thumb-input.ts | 147 +++++++ .../slider/src/slider-thumb.component.ts | 11 - .../primitives/slider/src/slider-thumb.ts | 125 ++++++ .../slider/src/slider-track.component.ts | 16 - .../primitives/slider/src/slider-track.ts | 21 + .../primitives/slider/src/slider-value.ts | 30 ++ .../slider/src/slider-vertical.component.ts | 98 ----- .../primitives/slider/src/slider.utils.ts | 401 ++++++++++++++++++ packages/primitives/slider/src/utils.ts | 114 ----- .../slider/stories/slider-default.ts | 30 ++ .../slider/stories/slider-disabled.ts | 27 ++ .../primitives/slider/stories/slider-forms.ts | 45 ++ .../primitives/slider/stories/slider-range.ts | 38 ++ .../primitives/slider/stories/slider-value.ts | 45 ++ .../slider/stories/slider-vertical.ts | 30 ++ .../primitives/slider/stories/slider.docs.mdx | 174 +++++++- .../slider/stories/slider.stories.ts | 148 +++---- .../tooltip/stories/tooltip-slider.ts | 76 ++-- 35 files changed, 2167 insertions(+), 1118 deletions(-) create mode 100644 packages/primitives/slider/__tests__/slider.spec.ts create mode 100644 packages/primitives/slider/src/slider-context.ts create mode 100644 packages/primitives/slider/src/slider-control.ts delete mode 100644 packages/primitives/slider/src/slider-horizontal.component.ts delete mode 100644 packages/primitives/slider/src/slider-impl.directive.ts create mode 100644 packages/primitives/slider/src/slider-indicator.ts delete mode 100644 packages/primitives/slider/src/slider-orientation-context.service.ts delete mode 100644 packages/primitives/slider/src/slider-range.component.ts delete mode 100644 packages/primitives/slider/src/slider-root.component.ts create mode 100644 packages/primitives/slider/src/slider-root.ts delete mode 100644 packages/primitives/slider/src/slider-thumb-impl.directive.ts create mode 100644 packages/primitives/slider/src/slider-thumb-input.ts delete mode 100644 packages/primitives/slider/src/slider-thumb.component.ts create mode 100644 packages/primitives/slider/src/slider-thumb.ts delete mode 100644 packages/primitives/slider/src/slider-track.component.ts create mode 100644 packages/primitives/slider/src/slider-track.ts create mode 100644 packages/primitives/slider/src/slider-value.ts delete mode 100644 packages/primitives/slider/src/slider-vertical.component.ts create mode 100644 packages/primitives/slider/src/slider.utils.ts delete mode 100644 packages/primitives/slider/src/utils.ts create mode 100644 packages/primitives/slider/stories/slider-default.ts create mode 100644 packages/primitives/slider/stories/slider-disabled.ts create mode 100644 packages/primitives/slider/stories/slider-forms.ts create mode 100644 packages/primitives/slider/stories/slider-range.ts create mode 100644 packages/primitives/slider/stories/slider-value.ts create mode 100644 packages/primitives/slider/stories/slider-vertical.ts diff --git a/.claude/skills/project-knowledge/references/signal-forms-readiness.md b/.claude/skills/project-knowledge/references/signal-forms-readiness.md index 6d57b944..8c380c7c 100644 --- a/.claude/skills/project-knowledge/references/signal-forms-readiness.md +++ b/.claude/skills/project-knowledge/references/signal-forms-readiness.md @@ -39,20 +39,20 @@ is version-independent. ## Conformance matrix -| Control (file) | Target interface | Required signal | Optional already present | Missing | Collisions / risk | -| ------------------------------------------------------------------------- | ------------------------------------ | ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **checkbox-root** (`checkbox/src/checkbox-root.ts`) | `FormCheckboxControl` | `checked = model` βœ… | `disabled`, `readonly`, `required`, `name` | `invalid`, `errors`, `touched`, `dirty` | πŸ”΄ has `value = input('on')` β€” interface **forbids** `value`; `checked` is `CheckedState` (`boolean \| 'indeterminate'`), interface wants `boolean` | -| **checkbox-group** (`checkbox/src/checkbox-group.ts`) | `FormValueControl` | `value = model` βœ… | `disabled` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | β€” | -| **switch-root** (`switch/src/switch-root.ts`) | `FormCheckboxControl` | `checked = model` βœ… | `disabled`, `required`, `readonly`, `name` | `invalid`, `errors`, `touched`, `dirty` | 🟒 clean β€” Base UI rewrite added `readonly`/`name` and dropped CDK `_IdGenerator` | -| **radio-root** (`radio/src/radio-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly`, `required`, `name` | `invalid`, `errors`, `touched`, `dirty` | 🟒 most ready | -| **select-root** (`select/src/select-root.ts`) | `FormValueControl` | `value = model` βœ… | `disabled` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | β€” | -| **toggle-group** (`toggle-group/src/toggle-group-base.ts`) | `FormValueControl` | `value = model` βœ… | `disabled` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | β€” | -| **slider-root** (`slider/src/slider-root.component.ts`) | `FormValueControl` | ❌ named `modelValue` | `disabled`, `min`, `max` | add/rename `value`; `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | πŸ”΄ value signal is `modelValue`, not `value` | -| **number-field-root** (`number-field/src/number-field-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly`, `required`, `min`, `max` | `invalid`, `errors`, `touched`, `dirty`, `name` | 🟒 `min/max` already align | -| **input** (`input/src/input.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `required`, **`invalid`** βœ… | `readonly`, `errors`, `touched`, `dirty`, `minLength/maxLength/pattern`, `name` | 🟒 only control that already has `invalid` | -| **date-field-root** (`date-field/src/date-field-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly` | `required`, `invalid`, `errors`, `touched`, `dirty`, `name`, `min/max` | β€” | -| **time-field-root** (`time-field/src/time-field-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly` | `required`, `invalid`, `errors`, `touched`, `dirty`, `name`, `min/max` | β€” | -| **editable-root** (`editable/src/editable-root.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly`, `required`, `maxLength` | `invalid`, `errors`, `touched`, `dirty`, `name`, `minLength`, `pattern` | β€” | +| Control (file) | Target interface | Required signal | Optional already present | Missing | Collisions / risk | +| ------------------------------------------------------------------------- | -------------------------------------- | ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **checkbox-root** (`checkbox/src/checkbox-root.ts`) | `FormCheckboxControl` | `checked = model` βœ… | `disabled`, `readonly`, `required`, `name` | `invalid`, `errors`, `touched`, `dirty` | πŸ”΄ has `value = input('on')` β€” interface **forbids** `value`; `checked` is `CheckedState` (`boolean \| 'indeterminate'`), interface wants `boolean` | +| **checkbox-group** (`checkbox/src/checkbox-group.ts`) | `FormValueControl` | `value = model` βœ… | `disabled` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | β€” | +| **switch-root** (`switch/src/switch-root.ts`) | `FormCheckboxControl` | `checked = model` βœ… | `disabled`, `required`, `readonly`, `name` | `invalid`, `errors`, `touched`, `dirty` | 🟒 clean β€” Base UI rewrite added `readonly`/`name` and dropped CDK `_IdGenerator` | +| **radio-root** (`radio/src/radio-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly`, `required`, `name` | `invalid`, `errors`, `touched`, `dirty` | 🟒 most ready | +| **select-root** (`select/src/select-root.ts`) | `FormValueControl` | `value = model` βœ… | `disabled` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | β€” | +| **toggle-group** (`toggle-group/src/toggle-group-base.ts`) | `FormValueControl` | `value = model` βœ… | `disabled` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty`, `name` | β€” | +| **slider-root** (`slider/src/slider-root.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `min`, `max`, `name`, `form` | `required`, `readonly`, `invalid`, `errors`, `touched`, `dirty` | 🟒 Base UI rewrite renamed `modelValue`β†’`value`, added `name`/`form`, uses `core` `_IdGenerator`; value is a `number \| number[]` union (single/range) | +| **number-field-root** (`number-field/src/number-field-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly`, `required`, `min`, `max` | `invalid`, `errors`, `touched`, `dirty`, `name` | 🟒 `min/max` already align | +| **input** (`input/src/input.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `required`, **`invalid`** βœ… | `readonly`, `errors`, `touched`, `dirty`, `minLength/maxLength/pattern`, `name` | 🟒 only control that already has `invalid` | +| **date-field-root** (`date-field/src/date-field-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly` | `required`, `invalid`, `errors`, `touched`, `dirty`, `name`, `min/max` | β€” | +| **time-field-root** (`time-field/src/time-field-root.directive.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly` | `required`, `invalid`, `errors`, `touched`, `dirty`, `name`, `min/max` | β€” | +| **editable-root** (`editable/src/editable-root.ts`) | `FormValueControl` | `value = model` βœ… | `disabled`, `readonly`, `required`, `maxLength` | `invalid`, `errors`, `touched`, `dirty`, `name`, `minLength`, `pattern` | β€” | ## Collisions to resolve by design (Angular 21) @@ -64,16 +64,21 @@ is version-independent. - `checked: CheckedState` (`boolean | 'indeterminate'`) is incompatible with `checked: model()`. Indeterminate is a third state to reconcile. - Hardest case β€” prototype first. -2. πŸ”΄ **slider β€” `modelValue` instead of `value`.** Either rename (breaking for - templates and `valueChange`) or add a parallel `value` model. Needs a decision. +2. βœ… **slider β€” resolved by the Base UI rewrite.** `modelValue` was renamed to + `value = model()` (with `name`/`form` added and the CDK + `_IdGenerator` swapped for the `core` one), so the control now satisfies + `FormValueControl`'s required signal. Remaining gap is only the shared + `invalid`/`errors`/`touched`/`dirty` batch (#3). Nuance: the value is a + `number | number[]` union (single thumb vs range), so `FormValueControl` is + parameterised on the union rather than a fixed shape. 3. 🟑 **Most controls lack `invalid`/`errors`/`touched`/`dirty`.** Homogeneous batch β€” close it with one shared pattern (see prep #3). ## Readiness ranking - 🟒 **radio, switch, input, number-field** β€” clean, minimal edits β†’ best first-pilot candidates. -- 🟑 **select, toggle-group, checkbox-group, date/time-field, editable** β€” need `invalid/errors/touched/dirty` + `required/name`. -- πŸ”΄ **checkbox, slider** β€” require name/type collision resolution. +- 🟑 **select, toggle-group, checkbox-group, date/time-field, editable, slider** β€” need `invalid/errors/touched/dirty` (+ `required/name` where missing; slider may also add optional `required`/`readonly`). +- πŸ”΄ **checkbox** β€” requires name/type collision resolution (forbidden `value` member + `indeterminate` third state). ## Open question (not answered by the matrix) diff --git a/apps/radix-docs/src/content/primitives/components/slider.mdx b/apps/radix-docs/src/content/primitives/components/slider.mdx index 02583f40..6b33a3a5 100644 --- a/apps/radix-docs/src/content/primitives/components/slider.mdx +++ b/apps/radix-docs/src/content/primitives/components/slider.mdx @@ -7,59 +7,77 @@ description: . # Slider -An input where the user selects a value from within a given range. +An input where the user selects a value, or a range of values, from within a given range. ## Anatomy ```html - - - - - - +
+ +
+
+
+
+ +
+
+
+
``` ## API Reference ### Root -`RdxSliderRootComponent` +`RdxSliderRoot` - + - + -### Track -`RdxSliderTrackComponent` +### Control +`RdxSliderControl` -The track that contains the `SliderRange`. +The interactive area. Handles pointer presses and drags. -### Range -`RdxSliderRangeComponent` +### Track +`RdxSliderTrack` -The range part. Must live inside `SliderTrack`. +The track that contains the `Indicator` and `Thumb`s. + + +### Indicator +`RdxSliderIndicator` + +Visualises the filled portion of the track. Must live inside `Track`. ### Thumb -`RdxSliderThumbComponent` +`RdxSliderThumb` -A draggable thumb. You can render multiple thumbs. +A draggable thumb. Render one per value and wrap an `input[rdxSliderThumbInput]`. + +### Thumb Input +`RdxSliderThumbInput` + +The nested native `input[type=range]`. Visually hidden but drives keyboard, +accessibility and form submission. + + + +### Value +`RdxSliderValue` + +Displays the formatted value(s). + + + ## Accessibility Adheres to the [Slider WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb). @@ -84,7 +119,7 @@ Adheres to the [Slider WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/ diff --git a/apps/radix-docs/src/demos/primitives/slider/slider-demo.css b/apps/radix-docs/src/demos/primitives/slider/slider-demo.css index 7185aa8e..c614e8b7 100644 --- a/apps/radix-docs/src/demos/primitives/slider/slider-demo.css +++ b/apps/radix-docs/src/demos/primitives/slider/slider-demo.css @@ -1,23 +1,26 @@ .SliderRoot { position: relative; - display: flex; - align-items: center; user-select: none; touch-action: none; width: 200px; +} + +.SliderControl { + display: flex; + align-items: center; + width: 100%; height: 20px; } .SliderTrack { - background-color: var(--black-a10); position: relative; - flex-grow: 1; + background-color: var(--black-a10); + width: 100%; border-radius: 9999px; height: 3px; } -.SliderRange { - position: absolute; +.SliderIndicator { background-color: white; border-radius: 9999px; height: 100%; @@ -32,11 +35,21 @@ border-radius: 10px; } +.SliderThumb input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: inherit; +} + .SliderThumb:hover { background-color: var(--violet-3); } -.SliderThumb:focus { +.SliderThumb:focus-within { outline: none; box-shadow: 0 0 0 5px var(--black-a8); } diff --git a/apps/radix-docs/src/demos/primitives/slider/slider-demo.ts b/apps/radix-docs/src/demos/primitives/slider/slider-demo.ts index 10ef2191..75242b85 100644 --- a/apps/radix-docs/src/demos/primitives/slider/slider-demo.ts +++ b/apps/radix-docs/src/demos/primitives/slider/slider-demo.ts @@ -8,12 +8,16 @@ import { RdxSliderModule } from '@radix-ng/primitives/slider'; imports: [RdxSliderModule], styleUrl: 'slider-demo.css', template: ` - - - - - - +
+
+
+
+
+ +
+
+
+
` }) export class SliderDemoComponent {} diff --git a/apps/radix-ssr-testing/src/app/components/slider/page.ts b/apps/radix-ssr-testing/src/app/components/slider/page.ts index 54e8bb07..7cf0b64a 100644 --- a/apps/radix-ssr-testing/src/app/components/slider/page.ts +++ b/apps/radix-ssr-testing/src/app/components/slider/page.ts @@ -7,22 +7,25 @@ import { RdxSliderModule } from '@radix-ng/primitives/slider'; styles: ` .SliderRoot { position: relative; + width: 200px; + } + + .SliderControl { display: flex; align-items: center; - width: 200px; + width: 100%; height: 20px; } .SliderTrack { - background-color: gray; position: relative; - flex-grow: 1; + background-color: gray; + width: 100%; height: 3px; } - .SliderRange { + .SliderIndicator { background-color: blue; - position: absolute; height: 100%; } @@ -31,17 +34,33 @@ import { RdxSliderModule } from '@radix-ng/primitives/slider'; width: 20px; height: 20px; background-color: blue; + border-radius: 50%; + } + + .SliderThumb input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; } `, encapsulation: ViewEncapsulation.None, template: ` - - - - - - - +
+
+
+
+
+ +
+
+ +
+
+
+
` }) export default class Page {} diff --git a/packages/primitives/slider/__tests__/slider.spec.ts b/packages/primitives/slider/__tests__/slider.spec.ts new file mode 100644 index 00000000..e5730dcb --- /dev/null +++ b/packages/primitives/slider/__tests__/slider.spec.ts @@ -0,0 +1,175 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { vi } from 'vitest'; +import { RdxSliderControl } from '../src/slider-control'; +import { RdxSliderIndicator } from '../src/slider-indicator'; +import { RdxSliderRoot, SliderValue } from '../src/slider-root'; +import { RdxSliderThumb } from '../src/slider-thumb'; +import { RdxSliderThumbInput } from '../src/slider-thumb-input'; +import { RdxSliderTrack } from '../src/slider-track'; +import { + getPushedThumbValues, + getSliderValue, + resolveThumbCollision, + roundValueToStep, + valueToPercent +} from '../src/slider.utils'; + +describe('slider utils', () => { + it('valueToPercent maps the range to 0–100', () => { + expect(valueToPercent(0, 0, 100)).toBe(0); + expect(valueToPercent(50, 0, 100)).toBe(50); + expect(valueToPercent(5, 0, 10)).toBe(50); + }); + + it('roundValueToStep snaps to the step grid using min as origin', () => { + expect(roundValueToStep(43, 5, 0)).toBe(45); + expect(roundValueToStep(42, 5, 0)).toBe(40); + expect(roundValueToStep(0.27, 0.1, 0)).toBe(0.3); + }); + + it('getSliderValue clamps a range thumb to its neighbours and re-sorts', () => { + expect(getSliderValue(90, 0, 0, 100, true, [20, 80])).toEqual([80, 80]); + expect(getSliderValue(50, 0, 0, 100, true, [20, 80])).toEqual([50, 80]); + expect(getSliderValue(120, 0, 0, 100, false, [50])).toBe(100); + }); + + it('push behavior moves neighbours to keep the minimum distance', () => { + const next = getPushedThumbValues({ + values: [20, 40], + index: 0, + nextValue: 50, + min: 0, + max: 100, + step: 1, + minStepsBetweenValues: 10 + }); + expect(next).toEqual([50, 60]); + }); + + it('resolveThumbCollision with "none" clamps between neighbours', () => { + const result = resolveThumbCollision({ + behavior: 'none', + values: [20, 80], + pressedIndex: 0, + nextValue: 90, + min: 0, + max: 100, + step: 1, + minStepsBetweenValues: 0 + }); + expect(result.value).toEqual([80, 80]); + expect(result.didSwap).toBe(false); + }); +}); + +@Component({ + imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], + template: ` +
+
+
+
+ @for (v of thumbs(); track $index) { +
+ +
+ } +
+
+
+ ` +}) +class TestComponent { + readonly value = signal(40); + readonly thumbs = signal([0]); + readonly step = signal(5); + readonly disabled = signal(false); + + onChange = vi.fn(); +} + +function press(el: HTMLElement, key: string, shiftKey = false): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true })); +} + +describe('RdxSlider', () => { + let fixture: ComponentFixture; + let component: TestComponent; + let root: HTMLElement; + let inputs: HTMLInputElement[]; + + function refresh(): void { + inputs = fixture.debugElement.queryAll(By.css('[rdxSliderThumbInput]')).map((d) => d.nativeElement); + } + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [TestComponent] }); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + root = fixture.debugElement.query(By.css('[rdxSliderRoot]')).nativeElement; + refresh(); + }); + + it('renders the group role and orientation', () => { + expect(root.getAttribute('role')).toBe('group'); + expect(root.getAttribute('data-orientation')).toBe('horizontal'); + }); + + it('exposes the value through the native range input', () => { + expect(inputs[0].value).toBe('40'); + expect(inputs[0].getAttribute('aria-valuenow')).toBe('40'); + expect(inputs[0].getAttribute('max')).toBe('100'); + }); + + it('increments by the step on ArrowRight', () => { + inputs[0].focus(); + press(inputs[0], 'ArrowRight'); + fixture.detectChanges(); + expect(component.value()).toBe(45); + expect(component.onChange).toHaveBeenCalledWith(45); + }); + + it('uses the large step with Shift + Arrow', () => { + press(inputs[0], 'ArrowUp', true); + fixture.detectChanges(); + expect(component.value()).toBe(50); + }); + + it('jumps to min and max on Home / End', () => { + press(inputs[0], 'End'); + fixture.detectChanges(); + expect(component.value()).toBe(100); + press(inputs[0], 'Home'); + fixture.detectChanges(); + expect(component.value()).toBe(0); + }); + + it('does not change when disabled', () => { + component.disabled.set(true); + fixture.detectChanges(); + press(inputs[0], 'ArrowRight'); + fixture.detectChanges(); + expect(component.value()).toBe(40); + }); + + it('positions the indicator to the current percentage', () => { + const indicator = fixture.debugElement.query(By.css('[rdxSliderIndicator]')).nativeElement as HTMLElement; + expect(indicator.style.width).toBe('40%'); + }); + + it('supports multiple thumbs and keeps them sorted', () => { + component.value.set([20, 60]); + component.thumbs.set([0, 1]); + fixture.detectChanges(); + refresh(); + expect(inputs.map((i) => i.value)).toEqual(['20', '60']); + + press(inputs[1], 'Home'); + fixture.detectChanges(); + // second thumb cannot move below the first + expect(component.value()).toEqual([20, 20]); + }); +}); diff --git a/packages/primitives/slider/index.ts b/packages/primitives/slider/index.ts index 6a40e189..8e0b5f9b 100644 --- a/packages/primitives/slider/index.ts +++ b/packages/primitives/slider/index.ts @@ -1,19 +1,31 @@ import { NgModule } from '@angular/core'; -import { RdxSliderRangeComponent } from './src/slider-range.component'; -import { RdxSliderRootComponent } from './src/slider-root.component'; -import { RdxSliderThumbComponent } from './src/slider-thumb.component'; -import { RdxSliderTrackComponent } from './src/slider-track.component'; +import { RdxSliderControl } from './src/slider-control'; +import { RdxSliderIndicator } from './src/slider-indicator'; +import { RdxSliderRoot } from './src/slider-root'; +import { RdxSliderThumb } from './src/slider-thumb'; +import { RdxSliderThumbInput } from './src/slider-thumb-input'; +import { RdxSliderTrack } from './src/slider-track'; +import { RdxSliderValue } from './src/slider-value'; -export * from './src/slider-horizontal.component'; -export * from './src/slider-impl.directive'; -export * from './src/slider-range.component'; -export * from './src/slider-root.component'; -export * from './src/slider-thumb-impl.directive'; -export * from './src/slider-thumb.component'; -export * from './src/slider-track.component'; -export * from './src/slider-vertical.component'; +export * from './src/slider-context'; +export * from './src/slider-control'; +export * from './src/slider-indicator'; +export * from './src/slider-root'; +export * from './src/slider-thumb'; +export * from './src/slider-thumb-input'; +export * from './src/slider-track'; +export * from './src/slider-value'; +export * from './src/slider.utils'; -const _imports = [RdxSliderRootComponent, RdxSliderTrackComponent, RdxSliderRangeComponent, RdxSliderThumbComponent]; +const _imports = [ + RdxSliderRoot, + RdxSliderControl, + RdxSliderTrack, + RdxSliderIndicator, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderValue +]; @NgModule({ imports: [..._imports], diff --git a/packages/primitives/slider/src/slider-context.ts b/packages/primitives/slider/src/slider-context.ts new file mode 100644 index 00000000..9c8e79d4 --- /dev/null +++ b/packages/primitives/slider/src/slider-context.ts @@ -0,0 +1,11 @@ +import { createContext } from '@radix-ng/primitives/core'; +import type { RdxSliderRoot } from './slider-root'; + +/** + * The Slider context exposes the root directive instance to every child part. + * The root owns all state, value-change logic and thumb registration; parts read + * signals and call methods off it. + * + * @see https://base-ui.com/react/components/slider + */ +export const [injectSliderRootContext, provideSliderRootContext] = createContext('RdxSliderRootContext'); diff --git a/packages/primitives/slider/src/slider-control.ts b/packages/primitives/slider/src/slider-control.ts new file mode 100644 index 00000000..0454dd7b --- /dev/null +++ b/packages/primitives/slider/src/slider-control.ts @@ -0,0 +1,256 @@ +import { DOCUMENT } from '@angular/common'; +import { Directive, ElementRef, inject, OnDestroy } from '@angular/core'; +import { injectSliderRootContext } from './slider-context'; +import { + clamp, + getControlOffset, + getMidpoint, + resolveThumbCollision, + ResolveThumbCollisionResult, + roundValueToStep, + validateMinimumDistance +} from './slider.utils'; + +const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; + +/** + * The interactive area of the slider. Handles pointer presses and drags on the + * track, mapping pointer position to a value and moving the closest thumb. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'div[rdxSliderControl]', + exportAs: 'rdxSliderControl', + host: { + '[attr.data-orientation]': 'root.orientation()', + '[attr.data-disabled]': 'root.isDisabled() ? "" : undefined', + '[attr.data-dragging]': 'root.dragging() ? "" : undefined', + '(pointerdown)': 'onPointerDown($event)' + } +}) +export class RdxSliderControl implements OnDestroy { + protected readonly root = injectSliderRootContext()!; + private readonly elementRef = inject>(ElementRef); + private readonly document = inject(DOCUMENT); + + private styles: CSSStyleDeclaration | null = null; + private moveCount = 0; + private currentInteractionValue: number | number[] | null = null; + + private readonly onMove = (event: PointerEvent) => this.handleMove(event); + private readonly onUp = (event: PointerEvent) => this.handleUp(event); + private readonly onCancel = (event: PointerEvent) => this.handleUp(event); + + constructor() { + this.root.controlRef.set(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + this.stopListening(); + } + + protected onPointerDown(event: PointerEvent): void { + const control = this.elementRef.nativeElement; + if (this.root.isDisabled() || event.defaultPrevented || event.button !== 0) { + return; + } + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + // Suppress the nested range input's native click-to-set and drag so the + // control fully owns pointer interaction (otherwise releasing on a thumb + // fires a native change that snaps the value to the press point inside + // the thumb-sized input). Focus is restored manually via focusThumb. + event.preventDefault(); + + this.styles = this.document.defaultView?.getComputedStyle(control) ?? null; + this.startPressing({ x: event.clientX, y: event.clientY }); + const finger = this.getFingerState({ x: event.clientX, y: event.clientY }); + if (finger == null) { + return; + } + + this.root.setDragging(true); + + // Pressing directly on a thumb sets a center offset; only a rail press changes value on down. + if (this.root.pressedThumbCenterOffset == null) { + this.setValueFromPointer(finger, 'track-press'); + } + + this.root.focusThumb(finger.thumbIndex); + + control.setPointerCapture(event.pointerId); + this.moveCount = 0; + this.document.addEventListener('pointermove', this.onMove); + this.document.addEventListener('pointerup', this.onUp, { once: true }); + this.document.addEventListener('pointercancel', this.onCancel, { once: true }); + } + + private handleMove(event: PointerEvent): void { + this.moveCount += 1; + if (event.buttons === 0) { + this.handleUp(event); + return; + } + const finger = this.getFingerState({ x: event.clientX, y: event.clientY }); + if (finger == null) { + return; + } + if (validateMinimumDistance(finger.value, this.root.step(), this.root.minStepsBetweenValues())) { + if (!this.root.dragging() && this.moveCount > INTENTIONAL_DRAG_COUNT_THRESHOLD) { + this.root.setDragging(true); + } + const applied = this.setValueFromPointer(finger, 'drag'); + if (applied && finger.didSwap) { + this.root.focusThumb(finger.thumbIndex); + } + } + } + + private handleUp(event: PointerEvent): void { + this.root.setActive(-1); + this.root.setDragging(false); + this.root.pressedThumbCenterOffset = null; + this.root.pressedInput = null; + + if (this.currentInteractionValue != null) { + this.root.commitValue(); + } + + const control = this.elementRef.nativeElement; + if (control.hasPointerCapture?.(event.pointerId)) { + control.releasePointerCapture(event.pointerId); + } + + this.root.resetPressedThumb(); + this.root.pressedValues = null; + this.currentInteractionValue = null; + this.stopListening(); + } + + private stopListening(): void { + this.document.removeEventListener('pointermove', this.onMove); + this.document.removeEventListener('pointerup', this.onUp); + this.document.removeEventListener('pointercancel', this.onCancel); + } + + private startPressing(finger: { x: number; y: number }): void { + const values = this.root.values(); + const range = this.root.range(); + this.root.pressedValues = range ? values.slice() : null; + this.currentInteractionValue = null; + + const pressedThumbIndex = this.root.pressedThumbIndex; + let closestThumbIndex = pressedThumbIndex; + + if (pressedThumbIndex > -1 && pressedThumbIndex < values.length) { + // Pressed directly on a thumb sitting on max β€” walk left over stacked max thumbs. + if (values[pressedThumbIndex] === this.root.max()) { + let c = pressedThumbIndex; + while (c > 0 && values[c - 1] === this.root.max()) { + c -= 1; + } + closestThumbIndex = c; + } + } else { + // Pressed on the rail β€” find the nearest enabled thumb by midpoint distance. + const axis = this.root.orientation() === 'horizontal' ? 'x' : 'y'; + const thumbs = this.root.thumbList(); + let minDistance: number | undefined; + closestThumbIndex = -1; + for (let i = 0; i < thumbs.length; i += 1) { + const thumb = thumbs[i]; + if (thumb.disabled()) { + continue; + } + const midpoint = getMidpoint(thumb.element); + const distance = Math.abs(finger[axis] - midpoint[axis]); + if (minDistance === undefined || distance <= minDistance) { + closestThumbIndex = i; + minDistance = distance; + } + } + } + + if (closestThumbIndex > -1 && closestThumbIndex !== pressedThumbIndex) { + this.root.pressedThumbIndex = closestThumbIndex; + this.root.pressedInput = this.root.thumbList()[closestThumbIndex]?.inputElement ?? null; + } + } + + private setValueFromPointer(finger: ResolveThumbCollisionResult, reason: string): boolean { + const nextValues = Array.isArray(finger.value) ? finger.value : [finger.value]; + const applied = this.root.setValue(nextValues, reason); + if (applied) { + this.currentInteractionValue = finger.value; + if (finger.didSwap) { + this.root.pressedThumbIndex = finger.thumbIndex; + this.root.pressedInput = this.root.thumbList()[finger.thumbIndex]?.inputElement ?? null; + } + } + return applied; + } + + /** Projects a pointer position onto the track and resolves it to a value (+ collision). */ + private getFingerState(finger: { x: number; y: number }): ResolveThumbCollisionResult | null { + const control = this.root.controlRef(); + const values = this.root.values(); + const range = this.root.range(); + const thumbIndex = this.root.pressedThumbIndex; + const vertical = this.root.orientation() === 'vertical'; + const rtl = this.root.dir() === 'rtl'; + const min = this.root.min(); + const max = this.root.max(); + const step = this.root.step(); + + if (!control || (!range && (thumbIndex < 0 || thumbIndex >= values.length))) { + return null; + } + + const { width, height, bottom, left, right } = control.getBoundingClientRect(); + const controlOffset = getControlOffset(this.styles, vertical, rtl); + const controlSize = (vertical ? height : width) - controlOffset.start - controlOffset.end; + + // A collapsed/unmeasurable track would divide by zero and yield NaN values. + if (!(controlSize > 0)) { + return null; + } + + const thumbCenterOffset = this.root.pressedThumbCenterOffset ?? 0; + const fingerX = finger.x - thumbCenterOffset; + const fingerY = finger.y - thumbCenterOffset; + + const valueSize = vertical + ? bottom - fingerY - controlOffset.end + : (rtl ? right - fingerX : fingerX - left) - controlOffset.start; + + const valueRescaled = clamp(valueSize / controlSize, 0, 1); + + let newValue = (max - min) * valueRescaled + min; + newValue = roundValueToStep(newValue, step, min); + newValue = clamp(newValue, min, max); + + if (!range) { + return { value: newValue, thumbIndex, didSwap: false }; + } + if (thumbIndex < 0) { + return null; + } + + return resolveThumbCollision({ + behavior: this.root.thumbCollisionBehavior(), + values, + currentValues: values, + initialValues: this.root.pressedValues, + pressedIndex: thumbIndex, + nextValue: newValue, + min, + max, + step, + minStepsBetweenValues: this.root.minStepsBetweenValues() + }); + } +} diff --git a/packages/primitives/slider/src/slider-horizontal.component.ts b/packages/primitives/slider/src/slider-horizontal.component.ts deleted file mode 100644 index 922a93dc..00000000 --- a/packages/primitives/slider/src/slider-horizontal.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { BooleanInput } from '@angular/cdk/coercion'; -import { - booleanAttribute, - Component, - ElementRef, - inject, - input, - Input, - output, - signal, - viewChild -} from '@angular/core'; -import { RdxSliderImplDirective } from './slider-impl.directive'; -import { RdxSliderRootComponent } from './slider-root.component'; -import { BACK_KEYS, linearScale } from './utils'; - -@Component({ - selector: 'rdx-slider-horizontal', - imports: [RdxSliderImplDirective], - template: ` - - - - ` -}) -export class RdxSliderHorizontalComponent { - private readonly rootContext = inject(RdxSliderRootComponent); - - @Input() dir: 'ltr' | 'rtl' = 'ltr'; - - readonly inverted = input(false, { transform: booleanAttribute }); - - @Input() min = 0; - @Input() max = 100; - - @Input() className = ''; - - readonly slideStart = output(); - readonly slideMove = output(); - readonly slideEnd = output(); - readonly stepKeyDown = output<{ event: KeyboardEvent; direction: number }>(); - readonly endKeyDown = output(); - readonly homeKeyDown = output(); - - private readonly sliderElement = viewChild('sliderElement'); - - private readonly rect = signal(undefined); - - onSlideStart(event: PointerEvent) { - const value = this.getValueFromPointer(event.clientX); - this.slideStart.emit(value); - } - - onSlideMove(event: PointerEvent) { - const value = this.getValueFromPointer(event.clientX); - this.slideMove.emit(value); - } - - onSlideEnd() { - this.rect.set(undefined); - this.slideEnd.emit(); - } - - onStepKeyDown(event: KeyboardEvent) { - const slideDirection = this.rootContext.isSlidingFromLeft() ? 'from-left' : 'from-right'; - const isBackKey = BACK_KEYS[slideDirection].includes(event.key); - - this.stepKeyDown.emit({ event, direction: isBackKey ? -1 : 1 }); - } - - private getValueFromPointer(pointerPosition: number): number { - this.rect.set(this.sliderElement()?.nativeElement.getBoundingClientRect()); - const rect = this.rect(); - if (!rect) return 0; - - const input: [number, number] = [0, rect.width]; - const output: [number, number] = this.rootContext.isSlidingFromLeft() - ? [this.min, this.max] - : [this.max, this.min]; - - const value = linearScale(input, output); - this.rect.set(rect); - - return value(pointerPosition - rect.left); - } -} diff --git a/packages/primitives/slider/src/slider-impl.directive.ts b/packages/primitives/slider/src/slider-impl.directive.ts deleted file mode 100644 index 0dd32349..00000000 --- a/packages/primitives/slider/src/slider-impl.directive.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Directive, inject, output } from '@angular/core'; -import { RdxSliderRootComponent } from './slider-root.component'; -import { ARROW_KEYS, PAGE_KEYS } from './utils'; - -@Directive({ - selector: '[rdxSliderImpl]', - host: { - role: 'slider', - tabindex: '0', - '(keydown)': 'onKeyDown($event)', - '(pointerdown)': 'onPointerDown($event)', - '(pointermove)': 'onPointerMove($event)', - '(pointerup)': 'onPointerUp($event)' - } -}) -export class RdxSliderImplDirective { - protected readonly rootContext = inject(RdxSliderRootComponent); - - readonly slideStart = output(); - readonly slideMove = output(); - readonly slideEnd = output(); - readonly homeKeyDown = output(); - readonly endKeyDown = output(); - readonly stepKeyDown = output(); - - onKeyDown(event: Event) { - const keyEvent = event as KeyboardEvent; - if (keyEvent.key === 'Home') { - this.homeKeyDown.emit(keyEvent); - // Prevent scrolling to page start - event.preventDefault(); - } else if (keyEvent.key === 'End') { - this.endKeyDown.emit(keyEvent); - // Prevent scrolling to page end - event.preventDefault(); - } else if (PAGE_KEYS.concat(ARROW_KEYS).includes(keyEvent.key)) { - this.stepKeyDown.emit(keyEvent); - // Prevent scrolling for directional key presses - event.preventDefault(); - } - } - - onPointerDown(event: Event) { - const pointerEvent = event as PointerEvent; - const target = event.target as HTMLElement; - target.setPointerCapture(pointerEvent.pointerId); - - // Prevent browser focus behaviour because we focus a thumb manually when values change. - event.preventDefault(); - - // Touch devices have a delay before focusing so won't focus if touch immediately moves - // away from target (sliding). We want thumb to focus regardless. - if (this.rootContext.thumbElements.includes(target)) { - target.focus(); - } else { - this.slideStart.emit(pointerEvent); - } - } - - onPointerMove(event: Event) { - const pointerEvent = event as PointerEvent; - const target = event.target as HTMLElement; - if (target.hasPointerCapture(pointerEvent.pointerId)) { - this.slideMove.emit(pointerEvent); - } - } - - onPointerUp(event: Event) { - const pointerEvent = event as PointerEvent; - const target = event.target as HTMLElement; - if (target.hasPointerCapture(pointerEvent.pointerId)) { - target.releasePointerCapture(pointerEvent.pointerId); - this.slideEnd.emit(pointerEvent); - } - } -} diff --git a/packages/primitives/slider/src/slider-indicator.ts b/packages/primitives/slider/src/slider-indicator.ts new file mode 100644 index 00000000..b4fff528 --- /dev/null +++ b/packages/primitives/slider/src/slider-indicator.ts @@ -0,0 +1,53 @@ +import { computed, Directive } from '@angular/core'; +import { injectSliderRootContext } from './slider-context'; +import { valueToPercent } from './slider.utils'; + +/** + * Visualises the portion of the track between the slider's minimum (or the first + * thumb in a range) and the active value. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'div[rdxSliderIndicator]', + exportAs: 'rdxSliderIndicator', + host: { + '[style]': 'indicatorStyle()', + '[attr.data-orientation]': 'root.orientation()', + '[attr.data-disabled]': 'root.isDisabled() ? "" : undefined', + '[attr.data-dragging]': 'root.dragging() ? "" : undefined' + } +}) +export class RdxSliderIndicator { + protected readonly root = injectSliderRootContext()!; + + protected readonly indicatorStyle = computed>(() => { + const vertical = this.root.orientation() === 'vertical'; + const range = this.root.range(); + const values = this.root.values(); + const min = this.root.min(); + const max = this.root.max(); + + const startEdge = vertical ? 'bottom' : 'inset-inline-start'; + const mainSide = vertical ? 'height' : 'width'; + const crossSide = vertical ? 'width' : 'height'; + + const start = valueToPercent(values[0], min, max); + const end = valueToPercent(values[values.length - 1], min, max); + + const styles: Record = { + position: vertical ? 'absolute' : 'relative', + [crossSide]: 'inherit' + }; + + if (!range) { + styles[startEdge] = 0; + styles[mainSide] = `${start}%`; + return styles; + } + + styles[startEdge] = `${start}%`; + styles[mainSide] = `${end - start}%`; + return styles; + }); +} diff --git a/packages/primitives/slider/src/slider-orientation-context.service.ts b/packages/primitives/slider/src/slider-orientation-context.service.ts deleted file mode 100644 index b877e979..00000000 --- a/packages/primitives/slider/src/slider-orientation-context.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable, signal } from '@angular/core'; - -export interface OrientationContext { - startEdge: string; - endEdge: string; - direction: number; - size: string; -} - -@Injectable() -export class RdxSliderOrientationContextService { - private contextSignal = signal({ - startEdge: 'left', - endEdge: 'right', - direction: 1, - size: 'width' - }); - - get context() { - return this.contextSignal(); - } - - updateContext(context: Partial) { - this.contextSignal.update((current) => ({ - ...current, - ...context - })); - } -} diff --git a/packages/primitives/slider/src/slider-range.component.ts b/packages/primitives/slider/src/slider-range.component.ts deleted file mode 100644 index 5baf1a66..00000000 --- a/packages/primitives/slider/src/slider-range.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Component, computed, inject } from '@angular/core'; -import { RdxSliderRootComponent } from './slider-root.component'; -import { convertValueToPercentage } from './utils'; - -@Component({ - selector: 'rdx-slider-range', - host: { - '[attr.data-disabled]': 'rootContext.disabled() ? "" : undefined', - '[attr.data-orientation]': 'rootContext.orientation()', - '[style]': 'rangeStyles()' - }, - template: ` - - ` -}) -export class RdxSliderRangeComponent { - protected readonly rootContext = inject(RdxSliderRootComponent); - - percentages = computed(() => - this.rootContext - .modelValue() - ?.map((value) => convertValueToPercentage(value, this.rootContext.min(), this.rootContext.max())) - ); - - offsetStart = computed(() => (this.rootContext.modelValue()!.length > 1 ? Math.min(...this.percentages()!) : 0)); - offsetEnd = computed(() => 100 - Math.max(...this.percentages()!)); - - rangeStyles = computed(() => { - const context = this.rootContext.orientationContext.context; - - return { - [context.startEdge]: `${this.offsetStart()}%`, - [context.endEdge]: `${this.offsetEnd()}%` - }; - }); -} diff --git a/packages/primitives/slider/src/slider-root.component.ts b/packages/primitives/slider/src/slider-root.component.ts deleted file mode 100644 index c398f57d..00000000 --- a/packages/primitives/slider/src/slider-root.component.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { BooleanInput, NumberInput } from '@angular/cdk/coercion'; -import { NgTemplateOutlet } from '@angular/common'; -import { - booleanAttribute, - Component, - computed, - inject, - input, - Input, - model, - numberAttribute, - OnInit, - output -} from '@angular/core'; -import { RdxSliderHorizontalComponent } from './slider-horizontal.component'; -import { RdxSliderOrientationContextService } from './slider-orientation-context.service'; -import { RdxSliderVerticalComponent } from './slider-vertical.component'; -import { - clamp, - getClosestValueIndex, - getDecimalCount, - getNextSortedValues, - hasMinStepsBetweenValues, - roundValue -} from './utils'; - -/** - * @group Components - */ -@Component({ - selector: 'rdx-slider', - imports: [RdxSliderHorizontalComponent, RdxSliderVerticalComponent, NgTemplateOutlet], - providers: [RdxSliderOrientationContextService], - template: ` - - - @if (orientation() === 'horizontal') { - - - - } - - @if (orientation() === 'vertical') { - - - - } - ` -}) -export class RdxSliderRootComponent implements OnInit { - /** @ignore */ - readonly orientationContext = inject(RdxSliderOrientationContextService); - - /** - * The minimum value for the range. - * - * @group Props - * @defaultValue 0 - */ - readonly min = input(0, { transform: numberAttribute }); - - /** - * The maximum value for the range. - * - * @group Props - * @defaultValue 100 - */ - readonly max = input(100, { transform: numberAttribute }); - - /** - * The stepping interval. - * - * @group Props - * @defaultValue 1 - */ - readonly step = input(1, { transform: numberAttribute }); - - /** - * The minimum permitted steps between multiple thumbs. - * - * @group Props - * @defaultValue 0 - */ - readonly minStepsBetweenThumbs = input(0, { transform: numberAttribute }); - - /** - * The orientation of the slider. - * - * @group Props - * @defaultValue 'horizontal' - */ - readonly orientation = input<'horizontal' | 'vertical'>('horizontal'); - - /** - * When true, prevents the user from interacting with the slider. - * - * @group Props - * @defaultValue false - */ - readonly disabled = input(false, { transform: booleanAttribute }); - - /** - * Whether the slider is visually inverted. - * - * @group Props - * @defaultValue false - */ - readonly inverted = input(false, { transform: booleanAttribute }); - - /** - * The reading direction of the combobox when applicable. - * - * @group Props - * @defaultValue 'ltr' - */ - readonly dir = input<'ltr' | 'rtl'>('ltr'); - - @Input() className: string = ''; - - /** - * Style class of the component. - * - * @group Props - */ - readonly styleClass = input(); - - /** - * The controlled value of the slider. - * - * @group Props - */ - readonly modelValue = model([0]); - - /** - * Event handler called when the slider value changes. - * - * @group Emits - */ - readonly valueChange = output(); - - /** - * Event handler called when the value changes at the end of an interaction. - * - * Useful when you only need to capture a final value e.g. to update a backend service. - * - * @group Emits - */ - readonly valueCommit = output(); - - /** @ignore */ - readonly valueIndexToChange = model(0); - - /** @ignore */ - readonly valuesBeforeSlideStart = model([]); - - /** @ignore */ - readonly isSlidingFromLeft = computed( - () => (this.dir() === 'ltr' && !this.inverted()) || (this.dir() !== 'ltr' && this.inverted()) - ); - - /** @ignore */ - readonly isSlidingFromBottom = computed(() => !this.inverted()); - - /** @ignore */ - thumbElements: HTMLElement[] = []; - - /** @ignore */ - ngOnInit() { - const isHorizontal = this.orientation() === 'horizontal'; - - if (isHorizontal) { - this.orientationContext.updateContext({ - direction: this.isSlidingFromLeft() ? 1 : -1, - size: 'width', - startEdge: this.isSlidingFromLeft() ? 'left' : 'right', - endEdge: this.isSlidingFromLeft() ? 'right' : 'left' - }); - } else { - this.orientationContext.updateContext({ - direction: this.isSlidingFromBottom() ? -1 : 1, - size: 'height', - startEdge: this.isSlidingFromBottom() ? 'bottom' : 'top', - endEdge: this.isSlidingFromBottom() ? 'top' : 'bottom' - }); - } - } - - /** @ignore */ - onPointerDown() { - this.valuesBeforeSlideStart.set([...this.modelValue()]); - } - - /** @ignore */ - handleSlideStart(value: number): void { - const closestIndex = getClosestValueIndex(this.modelValue(), value); - this.updateValues(value, closestIndex); - } - - /** @ignore */ - handleSlideMove(value: number): void { - this.updateValues(value, this.valueIndexToChange()); - } - - /** @ignore */ - handleSlideEnd(): void { - const prevValue = this.valuesBeforeSlideStart()[this.valueIndexToChange()]; - const nextValue = this.modelValue()[this.valueIndexToChange()]; - const hasChanged = nextValue !== prevValue; - - if (hasChanged) { - this.valueCommit.emit([...this.modelValue()]); - } - } - - /** @ignore */ - handleStepKeyDown(event: { event: KeyboardEvent; direction: number }): void { - const stepInDirection = this.step() * event.direction; - const atIndex = this.valueIndexToChange(); - const currentValue = this.modelValue()[atIndex]; - this.updateValues(currentValue + stepInDirection, atIndex, true); - } - - /** @ignore */ - updateValues(value: number, atIndex: number, commit = false): void { - const decimalCount = getDecimalCount(this.step()); - const snapToStep = roundValue( - Math.round((value - this.min()) / this.step()) * this.step() + this.min(), - decimalCount - ); - const nextValue = clamp(snapToStep, this.min(), this.max()); - - const nextValues = getNextSortedValues(this.modelValue(), nextValue, atIndex); - - if (hasMinStepsBetweenValues(nextValues, this.minStepsBetweenThumbs() * this.step())) { - this.valueIndexToChange.set(nextValues.indexOf(nextValue)); - const hasChanged = String(nextValues) !== String(this.modelValue()); - - if (hasChanged) { - this.modelValue.set(nextValues); - this.valueChange.emit([...this.modelValue()]); - this.thumbElements[this.valueIndexToChange()]?.focus(); - - if (commit) { - this.valueCommit.emit([...this.modelValue()]); - } - } - } - } -} diff --git a/packages/primitives/slider/src/slider-root.ts b/packages/primitives/slider/src/slider-root.ts new file mode 100644 index 00000000..939d0bf1 --- /dev/null +++ b/packages/primitives/slider/src/slider-root.ts @@ -0,0 +1,309 @@ +import { BooleanInput, NumberInput } from '@angular/cdk/coercion'; +import { + booleanAttribute, + computed, + Directive, + inject, + input, + model, + numberAttribute, + output, + signal, + Signal +} from '@angular/core'; +import { _IdGenerator, injectControlValueAccessor, RdxControlValueAccessor } from '@radix-ng/primitives/core'; +import { provideSliderRootContext } from './slider-context'; +import { + areValuesEqual, + asc, + clamp, + formatNumber, + getSliderValue, + SliderOrientation, + ThumbCollisionBehavior, + validateMinimumDistance +} from './slider.utils'; + +export type SliderValue = number | number[]; + +/** Minimal shape a thumb registers with the root, used for hit-testing and focus. */ +export interface RdxSliderThumbRef { + /** The thumb wrapper element. */ + readonly element: HTMLElement; + /** The nested `input[type=range]`, registered once it is initialised. */ + inputElement: HTMLInputElement | null; + /** Whether this thumb is disabled (own state OR root disabled). */ + readonly disabled: Signal; +} + +function sortByDomOrder(list: readonly RdxSliderThumbRef[]): RdxSliderThumbRef[] { + return list.slice().sort((a, b) => { + const position = a.element.compareDocumentPosition(b.element); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); +} + +/** + * Groups all parts of the slider and owns its state, value-change logic and + * thumb registration. A single directive drives both orientations β€” there are no + * separate horizontal/vertical components. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'div[rdxSliderRoot]', + exportAs: 'rdxSliderRoot', + providers: [provideSliderRootContext(() => inject(RdxSliderRoot))], + hostDirectives: [ + { + directive: RdxControlValueAccessor, + inputs: ['value: value', 'disabled'] + } + ], + host: { + role: 'group', + '[id]': 'id()', + '[attr.aria-labelledby]': 'ariaLabelledBy()', + '[attr.dir]': 'dir()', + '[attr.data-orientation]': 'orientation()', + '[attr.data-disabled]': 'isDisabled() ? "" : undefined', + '[attr.data-dragging]': 'dragging() ? "" : undefined' + } +}) +export class RdxSliderRoot { + /** @ignore */ + protected readonly cva = injectControlValueAccessor(); + + readonly id = input(inject(_IdGenerator).getId('rdx-slider-')); + + /** + * The minimum value of the slider. + * @default 0 + */ + readonly min = input(0, { transform: numberAttribute }); + + /** + * The maximum value of the slider. + * @default 100 + */ + readonly max = input(100, { transform: numberAttribute }); + + /** + * The granularity with which the value can change through user interaction. + * @default 1 + */ + readonly step = input(1, { transform: numberAttribute }); + + /** + * The granularity with which the value changes on Page Up / Page Down keys and Shift + Arrow keys. + * @default 10 + */ + readonly largeStep = input(10, { transform: numberAttribute }); + + /** + * The minimum number of steps that must separate two thumbs in a range slider. + * @default 0 + */ + readonly minStepsBetweenValues = input(0, { transform: numberAttribute }); + + /** + * The orientation of the slider. + * @default 'horizontal' + */ + readonly orientation = input('horizontal'); + + /** + * The reading direction. Mirrors the horizontal axis when set to `'rtl'`. + * @default 'ltr' + */ + readonly dir = input<'ltr' | 'rtl'>('ltr'); + + /** + * How thumbs behave when they meet in a range slider. + * @default 'push' + */ + readonly thumbCollisionBehavior = input('push'); + + /** Options forwarded to `Intl.NumberFormat` when displaying and announcing values. */ + readonly format = input(); + + /** Locale used for value formatting. */ + readonly locale = input(); + + /** Name of the hidden inputs rendered by each thumb, for form submission. */ + readonly name = input(); + + /** Id of the form the slider belongs to. */ + readonly form = input(); + + /** + * When `true`, the user cannot interact with the slider. + * @default false + */ + readonly disabled = input(false, { transform: booleanAttribute }); + + /** The uncontrolled value of the slider when it is initially rendered. */ + readonly defaultValue = input(); + + /** The controlled value of the slider. Use with `(onValueChange)` or two-way `[(value)]`. */ + readonly value = model(); + + readonly ariaLabelledBy = input(undefined, { alias: 'aria-labelledby' }); + + /** Emitted when the value changes (during interaction). */ + readonly onValueChange = output(); + + /** Emitted when interaction ends, with the final value β€” useful for committing to a backend. */ + readonly onValueCommitted = output(); + + /** @ignore */ + readonly controlRef = signal(null); + + /** @ignore Active thumb index (-1 when none). */ + readonly active = signal(-1); + /** @ignore Last thumb index that was focused/used, drives z-index stacking. */ + readonly lastUsedThumbIndex = signal(-1); + /** @ignore Whether a pointer drag is in progress. */ + readonly dragging = signal(false); + + /** @ignore Pointer-drag scratch state (not reactive). */ + pressedThumbIndex = -1; + /** @ignore */ + pressedThumbCenterOffset: number | null = null; + /** @ignore */ + pressedInput: HTMLInputElement | null = null; + /** @ignore Snapshot of values at drag start, the baseline for push/swap. */ + pressedValues: number[] | null = null; + /** @ignore */ + lastChangeReason = 'none'; + + /** @ignore */ + readonly isDisabled = computed(() => !!this.cva.disabled()); + + /** @ignore The current value source (controlled value, else default, else min). */ + private readonly currentRaw = computed(() => this.cva.value() ?? this.defaultValue() ?? this.min()); + + /** Whether the slider has multiple thumbs (the value is an array). */ + readonly range = computed(() => Array.isArray(this.cva.value() ?? this.defaultValue())); + + /** The clamped values rendered to the user, sorted ascending for range sliders. */ + readonly values = computed(() => { + const raw = this.currentRaw(); + const min = this.min(); + const max = this.max(); + const arr = (Array.isArray(raw) ? raw.slice() : [raw]).map((v) => clamp(v, min, max)); + return this.range() ? arr.sort(asc) : arr; + }); + + private readonly thumbs = signal([]); + /** Registered thumbs in DOM order. */ + readonly thumbList = this.thumbs.asReadonly(); + + /** @ignore */ + registerThumb(thumb: RdxSliderThumbRef): void { + this.thumbs.update((list) => sortByDomOrder([...list, thumb])); + } + + /** @ignore */ + unregisterThumb(thumb: RdxSliderThumbRef): void { + this.thumbs.update((list) => list.filter((t) => t !== thumb)); + } + + /** @ignore */ + thumbIndexOf(thumb: RdxSliderThumbRef): number { + return this.thumbList().indexOf(thumb); + } + + /** @ignore */ + setActive(index: number): void { + this.active.set(index); + if (index !== -1) { + this.lastUsedThumbIndex.set(index); + } + } + + /** @ignore */ + focusThumb(index: number): void { + this.thumbList()[index]?.inputElement?.focus({ preventScroll: true }); + } + + /** @ignore */ + formatValue(value: number): string { + return formatNumber(value, this.locale(), this.format()); + } + + /** @ignore Output value matching the original value shape (number vs array). */ + private outputValue(): SliderValue { + const raw = this.cva.value(); + if (raw !== undefined) { + return raw; + } + return this.range() ? this.values() : this.values()[0]; + } + + /** + * @ignore + * Applies a new full set of values, preserving the single/range value shape. + * Returns `false` when the value did not change. + */ + setValue(nextValues: number[], reason: string): boolean { + const next: SliderValue = this.range() ? nextValues : nextValues[0]; + const current = this.outputValue(); + const hasNaN = Array.isArray(next) ? next.some((v) => Number.isNaN(v)) : Number.isNaN(next); + if (hasNaN || areValuesEqual(next, current)) { + return false; + } + this.lastChangeReason = reason; + this.value.set(next); + this.cva.setValue(next); + this.onValueChange.emit(next); + return true; + } + + /** @ignore Keyboard / native input path: clamps to neighbours, commits immediately. */ + handleInputChange(valueInput: number, index: number, reason = 'keyboard'): void { + if (this.isDisabled()) { + return; + } + const result = getSliderValue(valueInput, index, this.min(), this.max(), this.range(), this.values()); + if (!validateMinimumDistance(result, this.step(), this.minStepsBetweenValues())) { + return; + } + const arr = Array.isArray(result) ? result : [result]; + const applied = this.setValue(arr, reason); + this.cva.markAsTouched(); + if (applied) { + this.onValueCommitted.emit(this.outputValue()); + } + } + + /** @ignore Emits the committed value at the end of a pointer drag. */ + commitValue(): void { + this.onValueCommitted.emit(this.outputValue()); + } + + /** @ignore */ + markAsTouched(): void { + this.cva.markAsTouched(); + } + + /** @ignore */ + setDragging(dragging: boolean): void { + this.dragging.set(dragging); + } + + /** @ignore */ + resetPressedThumb(): void { + this.pressedThumbIndex = -1; + this.pressedThumbCenterOffset = null; + this.pressedInput = null; + } +} diff --git a/packages/primitives/slider/src/slider-thumb-impl.directive.ts b/packages/primitives/slider/src/slider-thumb-impl.directive.ts deleted file mode 100644 index b988fe72..00000000 --- a/packages/primitives/slider/src/slider-thumb-impl.directive.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { isPlatformBrowser } from '@angular/common'; -import { computed, Directive, ElementRef, inject, OnDestroy, OnInit, PLATFORM_ID, signal } from '@angular/core'; -import { RdxSliderRootComponent } from './slider-root.component'; -import { convertValueToPercentage, getThumbInBoundsOffset } from './utils'; - -@Directive({ - selector: '[rdxSliderThumbImpl]', - host: { - role: 'slider', - '[tabindex]': 'rootContext.disabled() ? undefined : 0', - - '[attr.aria-valuenow]': 'rootContext.modelValue()', - '[attr.aria-valuemin]': 'rootContext.min()', - '[attr.aria-valuemax]': 'rootContext.max()', - '[attr.aria-orientation]': 'rootContext.orientation()', - - '[attr.data-orientation]': 'rootContext.orientation()', - '[attr.data-disabled]': 'rootContext.disabled() ? "" : undefined', - - '[style]': 'combinedStyles()', - - '(focus)': 'onFocus()' - } -}) -export class RdxSliderThumbImplDirective implements OnInit, OnDestroy { - protected readonly rootContext = inject(RdxSliderRootComponent); - private readonly elementRef = inject(ElementRef); - private readonly platformId = inject(PLATFORM_ID); - private resizeObserver!: ResizeObserver; - - isMounted = signal(false); - - thumbIndex = computed(() => { - const thumbElement = this.elementRef.nativeElement; - const index = this.rootContext.thumbElements.indexOf(thumbElement); - return index >= 0 ? index : null; - }); - - value = computed(() => { - const index = this.thumbIndex(); - if (index === null) return undefined; - return this.rootContext.modelValue()?.[index]; - }); - - percent = computed(() => { - const val = this.value(); - if (val === undefined) return 0; - return convertValueToPercentage(val, this.rootContext.min(), this.rootContext.max()); - }); - - transform = computed(() => { - const percent = this.percent(); - const offset = this.thumbInBoundsOffset(); - return `calc(${percent}% + ${offset}px)`; - }); - - orientationSize = signal(0); - - thumbInBoundsOffset = computed(() => { - const context = this.rootContext.orientationContext.context; - - const size = this.orientationSize(); - const percent = this.percent(); - const direction = context.direction; - - return size ? getThumbInBoundsOffset(size, percent, direction) : 0; - }); - - combinedStyles = computed(() => { - const context = this.rootContext.orientationContext.context; - - const startEdge = context.startEdge; - const percent = this.percent(); - const offset = this.thumbInBoundsOffset(); - - return { - position: 'absolute', - transform: 'var(--rdx-slider-thumb-transform)', - display: (this.isMounted() && this.value()) === false ? 'none' : undefined, - [startEdge]: `calc(${percent}% + ${offset}px)` - }; - }); - - onFocus() { - if (this.thumbIndex() !== null) { - this.rootContext.valueIndexToChange.set(this.thumbIndex()!); - } - } - - ngOnInit() { - if (isPlatformBrowser(this.platformId)) { - const thumbElement = this.elementRef.nativeElement; - this.rootContext.thumbElements.push(thumbElement); - - this.resizeObserver = new ResizeObserver(() => { - const rect = thumbElement.getBoundingClientRect(); - const context = this.rootContext.orientationContext.context; - const size = context.size === 'width' ? rect.width : rect.height; - this.orientationSize.set(size); - }); - - this.resizeObserver.observe(thumbElement); - - this.isMounted.set(true); - } - } - - ngOnDestroy() { - const thumbElement = this.elementRef.nativeElement; - const index = this.rootContext.thumbElements.indexOf(thumbElement); - if (index >= 0) this.rootContext.thumbElements.splice(index, 1); - - if (this.resizeObserver) { - this.resizeObserver.unobserve(thumbElement); - } - - this.isMounted.set(false); - } -} diff --git a/packages/primitives/slider/src/slider-thumb-input.ts b/packages/primitives/slider/src/slider-thumb-input.ts new file mode 100644 index 00000000..8456dfc4 --- /dev/null +++ b/packages/primitives/slider/src/slider-thumb-input.ts @@ -0,0 +1,147 @@ +import { computed, Directive, ElementRef, inject, input, OnDestroy, OnInit } from '@angular/core'; +import { injectSliderRootContext } from './slider-context'; +import { RdxSliderThumb } from './slider-thumb'; +import { ALL_KEYS, COMPOSITE_KEYS, getDefaultAriaValueText, getNewValue, roundValueToStep } from './slider.utils'; + +/** + * The native `input[type=range]` nested inside a thumb. It is visually hidden but + * remains the focusable element that drives keyboard interaction, accessibility + * and form submission. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'input[rdxSliderThumbInput]', + exportAs: 'rdxSliderThumbInput', + host: { + type: 'range', + style: 'position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; opacity: 0; cursor: inherit;', + '[style.writing-mode]': 'writingMode()', + '[attr.min]': 'root.min()', + '[attr.max]': 'root.max()', + '[attr.step]': 'root.step()', + '[value]': 'thumb.value() ?? ""', + '[disabled]': 'thumb.disabled()', + '[attr.name]': 'root.name()', + '[attr.form]': 'root.form()', + '[attr.aria-orientation]': 'root.orientation()', + '[attr.aria-valuenow]': 'thumb.value()', + '[attr.aria-valuetext]': 'valueText()', + '[attr.aria-label]': 'ariaLabel()', + '[attr.aria-labelledby]': 'ariaLabelledBy()', + '[attr.data-index]': 'thumb.index()', + '(keydown)': 'onKeyDown($event)', + '(change)': 'onChange($event)', + '(focus)': 'onFocus()', + '(blur)': 'onBlur()' + } +}) +export class RdxSliderThumbInput implements OnInit, OnDestroy { + protected readonly root = injectSliderRootContext()!; + protected readonly thumb = inject(RdxSliderThumb); + private readonly element = inject>(ElementRef).nativeElement; + + readonly ariaLabel = input(undefined, { alias: 'aria-label' }); + readonly ariaValueTextInput = input(undefined, { alias: 'aria-valuetext' }); + + protected readonly writingMode = computed(() => + this.root.orientation() === 'vertical' ? (this.root.dir() === 'rtl' ? 'vertical-rl' : 'vertical-lr') : undefined + ); + + protected readonly ariaLabelledBy = computed(() => + this.ariaLabel() == null ? this.root.ariaLabelledBy() : undefined + ); + + protected readonly valueText = computed( + () => + this.ariaValueTextInput() ?? + getDefaultAriaValueText(this.root.values(), this.thumb.index(), this.root.format(), this.root.locale()) + ); + + ngOnInit(): void { + this.thumb.inputElement = this.element; + } + + ngOnDestroy(): void { + if (this.thumb.inputElement === this.element) { + this.thumb.inputElement = null; + } + } + + protected onChange(event: Event): void { + const value = (event.target as HTMLInputElement).valueAsNumber; + if (!Number.isNaN(value)) { + this.root.handleInputChange(value, this.thumb.index(), 'input'); + } + } + + protected onFocus(): void { + this.root.setActive(this.thumb.index()); + } + + protected onBlur(): void { + this.root.setActive(-1); + this.root.markAsTouched(); + } + + protected onKeyDown(event: KeyboardEvent): void { + if (event.defaultPrevented || !ALL_KEYS.has(event.key)) { + return; + } + if (COMPOSITE_KEYS.has(event.key)) { + event.stopPropagation(); + } + + const min = this.root.min(); + const max = this.root.max(); + const step = this.root.step(); + const largeStep = this.root.largeStep(); + const rtl = this.root.dir() === 'rtl'; + const range = this.root.range(); + const values = this.root.values(); + const index = this.thumb.index(); + const thumbValue = values[index]; + const rounded = roundValueToStep(thumbValue, step, min); + + let newValue: number | null = null; + switch (event.key) { + case 'ArrowUp': + newValue = getNewValue(rounded, event.shiftKey ? largeStep : step, 1, min, max); + break; + case 'ArrowRight': + newValue = getNewValue(rounded, event.shiftKey ? largeStep : step, rtl ? -1 : 1, min, max); + break; + case 'ArrowDown': + newValue = getNewValue(rounded, event.shiftKey ? largeStep : step, -1, min, max); + break; + case 'ArrowLeft': + newValue = getNewValue(rounded, event.shiftKey ? largeStep : step, rtl ? 1 : -1, min, max); + break; + case 'PageUp': + newValue = getNewValue(rounded, largeStep, 1, min, max); + break; + case 'PageDown': + newValue = getNewValue(rounded, largeStep, -1, min, max); + break; + case 'End': + newValue = + range && Number.isFinite(values[index + 1]) + ? values[index + 1] - step * this.root.minStepsBetweenValues() + : max; + break; + case 'Home': + newValue = + range && Number.isFinite(values[index - 1]) + ? values[index - 1] + step * this.root.minStepsBetweenValues() + : min; + break; + default: + break; + } + + if (newValue !== null) { + this.root.handleInputChange(newValue, index, 'keyboard'); + event.preventDefault(); + } + } +} diff --git a/packages/primitives/slider/src/slider-thumb.component.ts b/packages/primitives/slider/src/slider-thumb.component.ts deleted file mode 100644 index c1fae6a0..00000000 --- a/packages/primitives/slider/src/slider-thumb.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { RdxSliderThumbImplDirective } from './slider-thumb-impl.directive'; - -@Component({ - selector: 'rdx-slider-thumb', - hostDirectives: [RdxSliderThumbImplDirective], - template: ` - - ` -}) -export class RdxSliderThumbComponent {} diff --git a/packages/primitives/slider/src/slider-thumb.ts b/packages/primitives/slider/src/slider-thumb.ts new file mode 100644 index 00000000..0a644cdd --- /dev/null +++ b/packages/primitives/slider/src/slider-thumb.ts @@ -0,0 +1,125 @@ +import { BooleanInput, NumberInput } from '@angular/cdk/coercion'; +import { + booleanAttribute, + computed, + Directive, + ElementRef, + inject, + input, + numberAttribute, + OnDestroy, + OnInit +} from '@angular/core'; +import { injectSliderRootContext } from './slider-context'; +import { RdxSliderThumbRef } from './slider-root'; +import { valueToPercent } from './slider.utils'; + +/** + * A draggable handle. Render one per value; place an `input[rdxSliderThumbInput]` + * inside it for keyboard, accessibility and form submission. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'div[rdxSliderThumb]', + exportAs: 'rdxSliderThumb', + host: { + '[style]': 'thumbStyle()', + '[attr.data-index]': 'index()', + '[attr.data-orientation]': 'root.orientation()', + '[attr.data-disabled]': 'disabled() ? "" : undefined', + '[attr.data-dragging]': 'root.dragging() ? "" : undefined', + '(pointerdown)': 'onPointerDown($event)' + } +}) +export class RdxSliderThumb implements RdxSliderThumbRef, OnInit, OnDestroy { + protected readonly root = injectSliderRootContext()!; + readonly element = inject>(ElementRef).nativeElement; + + /** The nested range input, set by `[rdxSliderThumbInput]`. */ + inputElement: HTMLInputElement | null = null; + + /** Explicit index for this thumb (required for SSR range sliders). */ + readonly indexInput = input(undefined, { + alias: 'index', + transform: (v) => (v == null ? undefined : numberAttribute(v)) + }); + + /** Disables this individual thumb. */ + readonly thumbDisabled = input(false, { alias: 'disabled', transform: booleanAttribute }); + + /** The position of this thumb among its siblings. */ + readonly index = computed(() => this.indexInput() ?? this.root.thumbIndexOf(this)); + + /** Whether this thumb is disabled (own state OR root disabled). */ + readonly disabled = computed(() => this.thumbDisabled() || this.root.isDisabled()); + + /** The value represented by this thumb. */ + readonly value = computed(() => this.root.values()[this.index()]); + + private readonly percent = computed(() => { + const value = this.value(); + return value === undefined ? NaN : valueToPercent(value, this.root.min(), this.root.max()); + }); + + protected readonly thumbStyle = computed>(() => { + const vertical = this.root.orientation() === 'vertical'; + const rtl = this.root.dir() === 'rtl'; + const startEdge = vertical ? 'bottom' : 'inset-inline-start'; + const crossOffset = vertical ? 'left' : 'top'; + const percent = this.percent(); + + if (!Number.isFinite(percent)) { + return { position: 'absolute', visibility: 'hidden' }; + } + + const index = this.index(); + let zIndex: number | undefined; + if (this.root.range()) { + if (this.root.active() === index) { + zIndex = 2; + } else if (this.root.lastUsedThumbIndex() === index) { + zIndex = 1; + } + } else if (this.root.active() === index) { + zIndex = 1; + } + + const style: Record = { + position: 'absolute', + [startEdge]: `${percent}%`, + [crossOffset]: '50%', + translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%` + }; + if (zIndex !== undefined) { + style['z-index'] = zIndex; + } + return style; + }); + + ngOnInit(): void { + this.root.registerThumb(this); + } + + ngOnDestroy(): void { + this.root.unregisterThumb(this); + } + + protected onPointerDown(event: PointerEvent): void { + if (this.disabled()) { + return; + } + const index = this.index(); + this.root.pressedThumbIndex = index; + + const axis = this.root.orientation() === 'horizontal' ? 'x' : 'y'; + const rect = this.element.getBoundingClientRect(); + const midpoint = axis === 'x' ? (rect.left + rect.right) / 2 : (rect.top + rect.bottom) / 2; + const pointer = axis === 'x' ? event.clientX : event.clientY; + this.root.pressedThumbCenterOffset = pointer - midpoint; + + if (this.inputElement) { + this.root.pressedInput = this.inputElement; + } + } +} diff --git a/packages/primitives/slider/src/slider-track.component.ts b/packages/primitives/slider/src/slider-track.component.ts deleted file mode 100644 index ca8284fe..00000000 --- a/packages/primitives/slider/src/slider-track.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { RdxSliderRootComponent } from './slider-root.component'; - -@Component({ - selector: 'rdx-slider-track', - host: { - '[attr.data-disabled]': "rootContext.disabled() ? '' : undefined", - '[attr.data-orientation]': 'rootContext.orientation()' - }, - template: ` - - ` -}) -export class RdxSliderTrackComponent { - protected readonly rootContext = inject(RdxSliderRootComponent); -} diff --git a/packages/primitives/slider/src/slider-track.ts b/packages/primitives/slider/src/slider-track.ts new file mode 100644 index 00000000..2c7f5f29 --- /dev/null +++ b/packages/primitives/slider/src/slider-track.ts @@ -0,0 +1,21 @@ +import { Directive } from '@angular/core'; +import { injectSliderRootContext } from './slider-context'; + +/** + * The track of the slider β€” the positioning context for the indicator and thumbs. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'div[rdxSliderTrack]', + exportAs: 'rdxSliderTrack', + host: { + style: 'position: relative;', + '[attr.data-orientation]': 'root.orientation()', + '[attr.data-disabled]': 'root.isDisabled() ? "" : undefined', + '[attr.data-dragging]': 'root.dragging() ? "" : undefined' + } +}) +export class RdxSliderTrack { + protected readonly root = injectSliderRootContext()!; +} diff --git a/packages/primitives/slider/src/slider-value.ts b/packages/primitives/slider/src/slider-value.ts new file mode 100644 index 00000000..96dc0794 --- /dev/null +++ b/packages/primitives/slider/src/slider-value.ts @@ -0,0 +1,30 @@ +import { computed, Directive, input } from '@angular/core'; +import { injectSliderRootContext } from './slider-context'; + +/** + * Displays the slider's current value(s) as formatted text. Renders into an + * `output` element; the displayed value honours the root `format` and `locale`. + * + * @see https://base-ui.com/react/components/slider + */ +@Directive({ + selector: 'output[rdxSliderValue]', + exportAs: 'rdxSliderValue', + host: { + 'aria-live': 'off', + '[textContent]': 'display()' + } +}) +export class RdxSliderValue { + protected readonly root = injectSliderRootContext()!; + + /** The separator placed between values of a range slider. */ + readonly separator = input(' – '); + + protected readonly display = computed(() => + this.root + .values() + .map((value) => this.root.formatValue(value) || `${value}`) + .join(this.separator()) + ); +} diff --git a/packages/primitives/slider/src/slider-vertical.component.ts b/packages/primitives/slider/src/slider-vertical.component.ts deleted file mode 100644 index a8a0bce1..00000000 --- a/packages/primitives/slider/src/slider-vertical.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { BooleanInput } from '@angular/cdk/coercion'; -import { - booleanAttribute, - Component, - ElementRef, - inject, - input, - Input, - output, - signal, - viewChild -} from '@angular/core'; -import { RdxSliderImplDirective } from './slider-impl.directive'; -import { RdxSliderRootComponent } from './slider-root.component'; -import { BACK_KEYS, linearScale } from './utils'; - -@Component({ - selector: 'rdx-slider-vertical', - imports: [RdxSliderImplDirective], - template: ` - - - - ` -}) -export class RdxSliderVerticalComponent { - private readonly rootContext = inject(RdxSliderRootComponent); - - @Input() dir: 'ltr' | 'rtl' = 'ltr'; - - readonly inverted = input(false, { transform: booleanAttribute }); - - @Input() min = 0; - @Input() max = 100; - - @Input() className = ''; - - readonly slideStart = output(); - readonly slideMove = output(); - readonly slideEnd = output(); - readonly stepKeyDown = output<{ event: KeyboardEvent; direction: number }>(); - readonly endKeyDown = output(); - readonly homeKeyDown = output(); - - private readonly sliderElement = viewChild('sliderElement'); - - private readonly rect = signal(undefined); - - onSlideStart(event: PointerEvent) { - const value = this.getValueFromPointer(event.clientY); - this.slideStart.emit(value); - } - - onSlideMove(event: PointerEvent) { - const value = this.getValueFromPointer(event.clientY); - this.slideMove.emit(value); - } - - onSlideEnd() { - this.rect.set(undefined); - this.slideEnd.emit(); - } - - onStepKeyDown(event: KeyboardEvent) { - const slideDirection = this.rootContext.isSlidingFromBottom() ? 'from-bottom' : 'from-top'; - const isBackKey = BACK_KEYS[slideDirection].includes(event.key); - - this.stepKeyDown.emit({ event, direction: isBackKey ? -1 : 1 }); - } - - private getValueFromPointer(pointerPosition: number): number { - this.rect.set(this.sliderElement()?.nativeElement.getBoundingClientRect()); - const rect = this.rect(); - if (!rect) return 0; - - const input: [number, number] = [0, rect.height]; - const output: [number, number] = this.rootContext.isSlidingFromBottom() - ? [this.max, this.min] - : [this.min, this.max]; - - const value = linearScale(input, output); - this.rect.set(rect); - - return value(pointerPosition - rect.top); - } -} diff --git a/packages/primitives/slider/src/slider.utils.ts b/packages/primitives/slider/src/slider.utils.ts new file mode 100644 index 00000000..3c78e9c0 --- /dev/null +++ b/packages/primitives/slider/src/slider.utils.ts @@ -0,0 +1,401 @@ +import { clamp } from '@radix-ng/primitives/core'; + +export { clamp }; + +export type SliderOrientation = 'horizontal' | 'vertical'; +export type ThumbCollisionBehavior = 'push' | 'swap' | 'none'; + +/** Ascending comparator. */ +export function asc(a: number, b: number): number { + return a - b; +} + +/** Maps a value within `[min, max]` to a 0–100 percentage. */ +export function valueToPercent(value: number, min: number, max: number): number { + return ((value - min) * 100) / (max - min); +} + +/** Replaces the item at `index` then re-sorts the array ascending. */ +export function replaceArrayItemAtIndex(array: readonly number[], index: number, newValue: number): number[] { + const output = array.slice(); + output[index] = newValue; + return output.sort(asc); +} + +/** The center point of an element in client coordinates. */ +export function getMidpoint(element: Element): { x: number; y: number } { + const rect = element.getBoundingClientRect(); + return { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 }; +} + +/** Converts an array of values into clamped 0–100 percentages. */ +export function valueArrayToPercentages(values: readonly number[], min: number, max: number): number[] { + return values.map((value) => clamp(valueToPercent(value, min, max), 0, 100)); +} + +/** Number of decimal places in `num`, handling exponential notation for tiny values. */ +export function getDecimalPrecision(num: number): number { + if (num === 0) { + return 0; + } + if (Math.abs(num) < 1) { + const parts = num.toExponential().split('e-'); + const mantissaDecimalPart = parts[0].split('.')[1]; + return (mantissaDecimalPart ? mantissaDecimalPart.length : 0) + parseInt(parts[1], 10); + } + const decimalPart = num.toString().split('.')[1]; + return decimalPart ? decimalPart.length : 0; +} + +/** Snaps `value` to the nearest step, using `min` as the origin of the step grid. */ +export function roundValueToStep(value: number, step: number, min: number): number { + const nearest = Math.round((value - min) / step) * step + min; + return Number(nearest.toFixed(Math.max(getDecimalPrecision(step), getDecimalPrecision(min)))); +} + +/** + * Resolves the value(s) for the keyboard / hidden-input path: clamps to the + * bounds, and for range sliders also clamps to neighbouring thumbs and re-sorts. + * Returns a `number` for single sliders and a sorted `number[]` for range sliders. + */ +export function getSliderValue( + valueInput: number, + index: number, + min: number, + max: number, + range: boolean, + values: readonly number[] +): number | number[] { + let newValue = clamp(valueInput, min, max); + if (range) { + newValue = clamp( + newValue, + values[index - 1] ?? Number.NEGATIVE_INFINITY, + values[index + 1] ?? Number.POSITIVE_INFINITY + ); + return replaceArrayItemAtIndex(values, index, newValue); + } + return newValue; +} + +/** Returns `false` if any adjacent pair of values is closer than the minimum distance. */ +export function validateMinimumDistance( + values: number | number[], + step: number, + minStepsBetweenValues: number +): boolean { + if (!Array.isArray(values)) { + return true; + } + const distances = values.reduce((acc, val, index, vals) => { + if (index === vals.length - 1) { + return acc; + } + acc.push(Math.abs(val - vals[index + 1])); + return acc; + }, []); + return Math.min(...distances) >= step * minStepsBetweenValues; +} + +/** Keyboard step helper: increments/decrements `thumbValue` and clamps to bounds. */ +export function getNewValue( + thumbValue: number, + increment: number, + direction: 1 | -1, + min: number, + max: number +): number { + const value = direction === 1 ? thumbValue + increment : thumbValue - increment; + const roundedValue = Number( + value.toFixed( + Math.max(getDecimalPrecision(thumbValue), getDecimalPrecision(increment), getDecimalPrecision(min)) + ) + ); + return clamp(roundedValue, min, max); +} + +interface GetPushedThumbValuesParams { + values: readonly number[]; + index: number; + nextValue: number; + min: number; + max: number; + step: number; + minStepsBetweenValues: number; + initialValues?: readonly number[]; +} + +/** + * The "push" collision algorithm: moves the pressed thumb and pushes its + * neighbours only as far as needed, letting them spring back toward their + * initial positions as the pressed thumb retreats. + */ +export function getPushedThumbValues(params: GetPushedThumbValuesParams): number[] { + const { values, index, nextValue, min, max, step, minStepsBetweenValues, initialValues } = params; + if (values.length === 0) { + return []; + } + const nextValues = values.slice(); + const minValueDifference = step * minStepsBetweenValues; + const lastIndex = nextValues.length - 1; + const baseInitialValues = initialValues ?? values; + + const indexMin = min + index * minValueDifference; + const indexMax = max - (lastIndex - index) * minValueDifference; + nextValues[index] = clamp(nextValue, indexMin, indexMax); + + // push thumbs to the right + for (let i = index + 1; i <= lastIndex; i += 1) { + const minAllowed = nextValues[i - 1] + minValueDifference; + const maxAllowed = max - (lastIndex - i) * minValueDifference; + const initialValue = baseInitialValues[i] ?? nextValues[i]; + let candidate = Math.max(nextValues[i], minAllowed); + if (initialValue < candidate) { + candidate = Math.max(initialValue, minAllowed); + } + nextValues[i] = clamp(candidate, minAllowed, maxAllowed); + } + + // push thumbs to the left + for (let i = index - 1; i >= 0; i -= 1) { + const maxAllowed = nextValues[i + 1] - minValueDifference; + const minAllowed = min + i * minValueDifference; + const initialValue = baseInitialValues[i] ?? nextValues[i]; + let candidate = Math.min(nextValues[i], maxAllowed); + if (initialValue > candidate) { + candidate = Math.min(initialValue, maxAllowed); + } + nextValues[i] = clamp(candidate, minAllowed, maxAllowed); + } + + for (let i = 0; i <= lastIndex; i += 1) { + nextValues[i] = Number(nextValues[i].toFixed(12)); + } + return nextValues; +} + +export interface ResolveThumbCollisionParams { + behavior: ThumbCollisionBehavior; + values: readonly number[]; + currentValues?: readonly number[]; + initialValues?: readonly number[] | null; + pressedIndex: number; + nextValue: number; + min: number; + max: number; + step: number; + minStepsBetweenValues: number; +} + +export interface ResolveThumbCollisionResult { + value: number | number[]; + thumbIndex: number; + didSwap: boolean; +} + +/** Dispatches the pressed thumb's new value through the configured collision behavior. */ +export function resolveThumbCollision(params: ResolveThumbCollisionParams): ResolveThumbCollisionResult { + const { + behavior, + values, + currentValues, + initialValues, + pressedIndex, + nextValue, + min, + max, + step, + minStepsBetweenValues + } = params; + + const activeValues = (currentValues ?? values).slice(); + const baselineValues = initialValues ?? values; + const range = activeValues.length > 1; + const minValueDifference = step * minStepsBetweenValues; + + if (!range) { + return { value: nextValue, thumbIndex: 0, didSwap: false }; + } + + if (behavior === 'push') { + const value = getPushedThumbValues({ + values: activeValues, + index: pressedIndex, + nextValue, + min, + max, + step, + minStepsBetweenValues + }); + return { value, thumbIndex: pressedIndex, didSwap: false }; + } + + if (behavior === 'swap') { + const pressedInitialValue = activeValues[pressedIndex]; + const epsilon = 1e-7; + const candidateValues = activeValues.slice(); + const previousNeighbor = candidateValues[pressedIndex - 1]; + const nextNeighbor = candidateValues[pressedIndex + 1]; + + const lowerBound = previousNeighbor != null ? previousNeighbor + minValueDifference : min; + const upperBound = nextNeighbor != null ? nextNeighbor - minValueDifference : max; + + const constrainedValue = clamp(nextValue, lowerBound, upperBound); + const pressedValueAfterClamp = Number(constrainedValue.toFixed(12)); + candidateValues[pressedIndex] = pressedValueAfterClamp; + + const movingForward = nextValue > pressedInitialValue; + const movingBackward = nextValue < pressedInitialValue; + const shouldSwapForward = movingForward && nextNeighbor != null && nextValue >= nextNeighbor - epsilon; + const shouldSwapBackward = + movingBackward && previousNeighbor != null && nextValue <= previousNeighbor + epsilon; + + if (!shouldSwapForward && !shouldSwapBackward) { + return { value: candidateValues, thumbIndex: pressedIndex, didSwap: false }; + } + + const targetIndex = shouldSwapForward ? pressedIndex + 1 : pressedIndex - 1; + + const initialValuesForPush = candidateValues.map((_, idx) => { + if (idx === pressedIndex) { + return pressedValueAfterClamp; + } + const baseline = baselineValues[idx]; + return baseline != null ? baseline : activeValues[idx]; + }); + + const nextValueForTarget = shouldSwapForward + ? Math.max(nextValue, candidateValues[targetIndex]) + : Math.min(nextValue, candidateValues[targetIndex]); + + const adjustedValues = getPushedThumbValues({ + values: candidateValues, + index: targetIndex, + nextValue: nextValueForTarget, + min, + max, + step, + minStepsBetweenValues, + initialValues: initialValuesForPush + }); + + const neighborIndex = shouldSwapForward ? targetIndex - 1 : targetIndex + 1; + if (neighborIndex >= 0 && neighborIndex < adjustedValues.length) { + const previousValue = adjustedValues[neighborIndex - 1]; + const nextValueAfter = adjustedValues[neighborIndex + 1]; + let neighborLowerBound = previousValue != null ? previousValue + minValueDifference : min; + neighborLowerBound = Math.max(neighborLowerBound, min + neighborIndex * minValueDifference); + let neighborUpperBound = nextValueAfter != null ? nextValueAfter - minValueDifference : max; + neighborUpperBound = Math.min( + neighborUpperBound, + max - (adjustedValues.length - 1 - neighborIndex) * minValueDifference + ); + const restoredValue = clamp(pressedValueAfterClamp, neighborLowerBound, neighborUpperBound); + adjustedValues[neighborIndex] = Number(restoredValue.toFixed(12)); + } + + return { value: adjustedValues, thumbIndex: targetIndex, didSwap: true }; + } + + // behavior === 'none' β€” clamp the pressed thumb between its neighbours; thumbs never cross. + const previousNeighbor = activeValues[pressedIndex - 1]; + const nextNeighbor = activeValues[pressedIndex + 1]; + const lowerBound = previousNeighbor != null ? previousNeighbor + minValueDifference : min; + const upperBound = nextNeighbor != null ? nextNeighbor - minValueDifference : max; + const constrained = Number(clamp(nextValue, lowerBound, upperBound).toFixed(12)); + const value = activeValues.slice(); + value[pressedIndex] = constrained; + return { value, thumbIndex: pressedIndex, didSwap: false }; +} + +/** + * Border + padding on the leading/trailing edge of the control along the active + * axis. Uses physical longhands (`left`/`right`/`top`/`bottom`) rather than + * logical ones (`inline-start`/…) because `getComputedStyle` resolves the + * physical properties in every browser, whereas logical longhands return an + * empty string in some engines. + */ +export function getControlOffset( + styles: CSSStyleDeclaration | null, + vertical: boolean, + rtl: boolean +): { start: number; end: number } { + if (!styles) { + return { start: 0, end: 0 }; + } + const parseSize = (v: string): number => { + const p = parseFloat(v); + return Number.isNaN(p) ? 0 : p; + }; + const sideOffset = (side: 'left' | 'right' | 'top' | 'bottom'): number => + parseSize(styles.getPropertyValue(`border-${side}-width`)) + + parseSize(styles.getPropertyValue(`padding-${side}`)); + + let startSide: 'left' | 'right' | 'top'; + let endSide: 'right' | 'left' | 'bottom'; + if (vertical) { + startSide = 'top'; + endSide = 'bottom'; + } else if (rtl) { + startSide = 'right'; + endSide = 'left'; + } else { + startSide = 'left'; + endSide = 'right'; + } + + return { start: sideOffset(startSide), end: sideOffset(endSide) }; +} + +const formatterCache = new Map(); + +function getFormatter(locale: string | undefined, options: Intl.NumberFormatOptions | undefined): Intl.NumberFormat { + const key = JSON.stringify({ locale: locale ?? null, options: options ?? null }); + let formatter = formatterCache.get(key); + if (!formatter) { + formatter = new Intl.NumberFormat(locale, options); + formatterCache.set(key, formatter); + } + return formatter; +} + +/** Formats a number with a cached `Intl.NumberFormat` instance. */ +export function formatNumber( + value: number | null | undefined, + locale: string | undefined, + options: Intl.NumberFormatOptions | undefined +): string { + if (value == null) { + return ''; + } + return getFormatter(locale, options).format(value); +} + +/** Default `aria-valuetext` for a two-thumb range, or a formatted value when `format` is set. */ +export function getDefaultAriaValueText( + values: readonly number[], + index: number, + format: Intl.NumberFormatOptions | undefined, + locale: string | undefined +): string | undefined { + if (index < 0) { + return undefined; + } + if (values.length === 2) { + return index === 0 + ? `${formatNumber(values[index], locale, format)} start range` + : `${formatNumber(values[index], locale, format)} end range`; + } + return format ? formatNumber(values[index], locale, format) : undefined; +} + +export const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; +export const COMPOSITE_KEYS = new Set([...ARROW_KEYS, 'Home', 'End']); +export const ALL_KEYS = new Set([...COMPOSITE_KEYS, 'PageUp', 'PageDown']); + +export function areValuesEqual(a: number | number[], b: number | number[]): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => v === b[i]); + } + return a === b; +} diff --git a/packages/primitives/slider/src/utils.ts b/packages/primitives/slider/src/utils.ts deleted file mode 100644 index ca27f6ff..00000000 --- a/packages/primitives/slider/src/utils.ts +++ /dev/null @@ -1,114 +0,0 @@ -// https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js -export function linearScale(input: readonly [number, number], output: readonly [number, number]) { - return (value: number) => { - if (input[0] === input[1] || output[0] === output[1]) return output[0]; - const ratio = (output[1] - output[0]) / (input[1] - input[0]); - return output[0] + ratio * (value - input[0]); - }; -} - -/** - * Verifies the minimum steps between all values is greater than or equal - * to the expected minimum steps. - * - * @example - * // returns false - * hasMinStepsBetweenValues([1,2,3], 2); - * - * @example - * // returns true - * hasMinStepsBetweenValues([1,2,3], 1); - */ -export function hasMinStepsBetweenValues(values: number[], minStepsBetweenValues: number) { - if (minStepsBetweenValues > 0) { - const stepsBetweenValues = getStepsBetweenValues(values); - const actualMinStepsBetweenValues = Math.min(...stepsBetweenValues); - return actualMinStepsBetweenValues >= minStepsBetweenValues; - } - return true; -} - -/** - * Given a `values` array and a `nextValue`, determine which value in - * the array is closest to `nextValue` and return its index. - * - * @example - * // returns 1 - * getClosestValueIndex([10, 30], 25); - */ -export function getClosestValueIndex(values: number[], nextValue: number) { - if (values.length === 1) return 0; - const distances = values.map((value) => Math.abs(value - nextValue)); - const closestDistance = Math.min(...distances); - return distances.indexOf(closestDistance); -} - -/** - * Gets an array of steps between each value. - * - * @example - * // returns [1, 9] - * getStepsBetweenValues([10, 11, 20]); - */ -export function getStepsBetweenValues(values: number[]) { - return values.slice(0, -1).map((value, index) => values[index + 1] - value); -} - -/** - * Offsets the thumb centre point while sliding to ensure it remains - * within the bounds of the slider when reaching the edges - */ -export function getThumbInBoundsOffset(width: number, left: number, direction: number) { - const halfWidth = width / 2; - const halfPercent = 50; - const offset = linearScale([0, halfPercent], [0, halfWidth]); - return (halfWidth - offset(left) * direction) * direction; -} - -export function convertValueToPercentage(value: number, min: number, max: number) { - const maxSteps = max - min; - const percentPerStep = 100 / maxSteps; - const percentage = percentPerStep * (value - min); - return clamp(percentage, 0, 100); -} - -export function getDecimalCount(value: number) { - return (String(value).split('.')[1] || '').length; -} - -export function roundValue(value: number, decimalCount: number) { - const rounder = 10 ** decimalCount; - return Math.round(value * rounder) / rounder; -} - -export function getNextSortedValues(prevValues: number[] = [], nextValue: number, atIndex: number) { - const nextValues = [...prevValues]; - nextValues[atIndex] = nextValue; - return nextValues.sort((a, b) => a - b); -} - -export const PAGE_KEYS = ['PageUp', 'PageDown']; -export const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; - -type SlideDirection = 'from-left' | 'from-right' | 'from-bottom' | 'from-top'; -export const BACK_KEYS: Record = { - 'from-left': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'], - 'from-right': ['Home', 'PageDown', 'ArrowDown', 'ArrowRight'], - 'from-bottom': ['Home', 'PageDown', 'ArrowDown', 'ArrowLeft'], - 'from-top': ['Home', 'PageUp', 'ArrowUp', 'ArrowLeft'] -}; - -export interface OrientationContext { - direction: number; - size: 'width' | 'height'; - startEdge: 'left' | 'top'; - endEdge: 'right' | 'bottom'; -} - -export function clamp( - value: number, - min: number = Number.NEGATIVE_INFINITY, - max: number = Number.POSITIVE_INFINITY -): number { - return Math.min(Math.max(value, min), max); -} diff --git a/packages/primitives/slider/stories/slider-default.ts b/packages/primitives/slider/stories/slider-default.ts new file mode 100644 index 00000000..5f4e3464 --- /dev/null +++ b/packages/primitives/slider/stories/slider-default.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack +} from '@radix-ng/primitives/slider'; + +@Component({ + selector: 'slider-default-example', + imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], + template: ` +
+
+
+
+
+ +
+
+
+
+ ` +}) +export class SliderDefaultExample {} diff --git a/packages/primitives/slider/stories/slider-disabled.ts b/packages/primitives/slider/stories/slider-disabled.ts new file mode 100644 index 00000000..00346be0 --- /dev/null +++ b/packages/primitives/slider/stories/slider-disabled.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack +} from '@radix-ng/primitives/slider'; + +@Component({ + selector: 'slider-disabled-example', + imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], + template: ` +
+
+
+
+
+ +
+
+
+
+ ` +}) +export class SliderDisabledExample {} diff --git a/packages/primitives/slider/stories/slider-forms.ts b/packages/primitives/slider/stories/slider-forms.ts new file mode 100644 index 00000000..05a919df --- /dev/null +++ b/packages/primitives/slider/stories/slider-forms.ts @@ -0,0 +1,45 @@ +import { Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack +} from '@radix-ng/primitives/slider'; + +@Component({ + selector: 'slider-forms-example', + imports: [ + ReactiveFormsModule, + RdxSliderRoot, + RdxSliderControl, + RdxSliderTrack, + RdxSliderIndicator, + RdxSliderThumb, + RdxSliderThumbInput + ], + template: ` +
+ +
+
+
+
+
+ +
+
+
+
+

Value: {{ volume.value }}

+
+ ` +}) +export class SliderFormsExample { + readonly volume = new FormControl(30); +} diff --git a/packages/primitives/slider/stories/slider-range.ts b/packages/primitives/slider/stories/slider-range.ts new file mode 100644 index 00000000..a08fa83a --- /dev/null +++ b/packages/primitives/slider/stories/slider-range.ts @@ -0,0 +1,38 @@ +import { Component } from '@angular/core'; +import { + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack +} from '@radix-ng/primitives/slider'; + +@Component({ + selector: 'slider-range-example', + imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], + template: ` +
+
+
+
+
+ +
+
+ +
+
+
+
+ ` +}) +export class SliderRangeExample {} diff --git a/packages/primitives/slider/stories/slider-value.ts b/packages/primitives/slider/stories/slider-value.ts new file mode 100644 index 00000000..cc586cbe --- /dev/null +++ b/packages/primitives/slider/stories/slider-value.ts @@ -0,0 +1,45 @@ +import { Component } from '@angular/core'; +import { + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack, + RdxSliderValue +} from '@radix-ng/primitives/slider'; + +@Component({ + selector: 'slider-value-example', + imports: [ + RdxSliderRoot, + RdxSliderControl, + RdxSliderTrack, + RdxSliderIndicator, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderValue + ], + template: ` +
+
+ Budget + +
+
+
+
+
+ +
+
+
+
+ ` +}) +export class SliderValueExample { + readonly format: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }; +} diff --git a/packages/primitives/slider/stories/slider-vertical.ts b/packages/primitives/slider/stories/slider-vertical.ts new file mode 100644 index 00000000..cdbfe530 --- /dev/null +++ b/packages/primitives/slider/stories/slider-vertical.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack +} from '@radix-ng/primitives/slider'; + +@Component({ + selector: 'slider-vertical-example', + imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], + template: ` +
+
+
+
+
+ +
+
+
+
+ ` +}) +export class SliderVerticalExample {} diff --git a/packages/primitives/slider/stories/slider.docs.mdx b/packages/primitives/slider/stories/slider.docs.mdx index 2417f24f..f9e0cc09 100644 --- a/packages/primitives/slider/stories/slider.docs.mdx +++ b/packages/primitives/slider/stories/slider.docs.mdx @@ -1,42 +1,182 @@ import { ArgTypes, Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as SliderStories from './slider.stories'; -import { RdxSliderRootComponent } from '../src/slider-root.component'; +import { RdxSliderRoot } from '../src/slider-root'; +import { RdxSliderThumb } from '../src/slider-thumb'; +import { RdxSliderThumbInput } from '../src/slider-thumb-input'; +import { RdxSliderValue } from '../src/slider-value'; # Slider -#### An input where the user selects a value from within a given range. +#### An input where the user selects a value, or a range of values, from within a given range. ## Features - βœ… Can be controlled or uncontrolled. -- βœ… Supports multiple thumbs. -- βœ… Supports a minimum value between thumbs. -- βœ… Supports touch or click on track to update value. -- βœ… Supports Right to Left direction. -- βœ… Full keyboard navigation. +- βœ… Single value or multiple thumbs for a range. +- βœ… Configurable thumb collision behavior (`push`, `swap`, `none`). +- βœ… Minimum distance between thumbs. +- βœ… Press or drag anywhere on the control to update the value. +- βœ… Horizontal and vertical orientation, with RTL support. +- βœ… Value formatting with `Intl.NumberFormat`. +- βœ… Full keyboard navigation, including large steps. +- βœ… Works with Angular forms via a hidden native range input per thumb. -## Anatomy +## Import + +```ts +import { + RdxSliderRoot, + RdxSliderControl, + RdxSliderTrack, + RdxSliderIndicator, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderValue +} from '@radix-ng/primitives/slider'; +``` + +Or import the whole module: + +```ts +import { RdxSliderModule } from '@radix-ng/primitives/slider'; +``` -Import all parts and piece them together. +## Anatomy +The slider is assembled from directive-based parts β€” there are no separate +horizontal/vertical components. The same parts drive both orientations, switched +through the `orientation` input on the root. ```html - - - - - - +
+ +
+
+
+
+ +
+
+
+
``` +Each thumb owns a nested `input[rdxSliderThumbInput]`. It is visually hidden but +remains the focusable element that powers keyboard interaction, accessibility and +form submission. + +## Examples + +### Range + +Pass an array value and render one `rdxSliderThumb` per value, each with an explicit +`index`. Use `minStepsBetweenValues` to keep the thumbs apart, and +`thumbCollisionBehavior` to control what happens when they meet. + + + +### Vertical + +Set `orientation="vertical"` on the root. The control and track lay out along the +vertical axis; no other changes are required. + + + +### Value + +Display the formatted value with `rdxSliderValue`. Formatting honours the root's +`format` (`Intl.NumberFormatOptions`) and `locale`. + + + +### Disabled + + + +### Reactive forms + +The root composes a `ControlValueAccessor`, so it binds directly to +`formControl` / `formControlName` and `[(ngModel)]`. + + + ## API Reference - +### Root + +`RdxSliderRoot` β€” groups the parts and owns the value, state and thumb registration. + + + +| Data attribute | Value | +| -------------------- | ------------------------------ | +| `[data-orientation]` | `"horizontal" \| "vertical"` | +| `[data-disabled]` | Present when disabled. | +| `[data-dragging]` | Present while a thumb is dragged. | + +### Control + +`RdxSliderControl` β€” the interactive area; reads everything from context, no inputs. + +| Data attribute | Value | +| -------------------- | ------------------------------ | +| `[data-orientation]` | `"horizontal" \| "vertical"` | +| `[data-disabled]` | Present when disabled. | +| `[data-dragging]` | Present while a thumb is dragged. | + +### Track + +`RdxSliderTrack` β€” the rail; reads everything from context, no inputs. + +| Data attribute | Value | +| -------------------- | ------------------------------ | +| `[data-orientation]` | `"horizontal" \| "vertical"` | +| `[data-disabled]` | Present when disabled. | +| `[data-dragging]` | Present while a thumb is dragged. | + +### Indicator + +`RdxSliderIndicator` β€” the filled range; reads everything from context, no inputs. + +| Data attribute | Value | +| -------------------- | ------------------------------ | +| `[data-orientation]` | `"horizontal" \| "vertical"` | +| `[data-disabled]` | Present when disabled. | +| `[data-dragging]` | Present while a thumb is dragged. | + +### Thumb + +`RdxSliderThumb` β€” a draggable handle; wrap an `input[rdxSliderThumbInput]` inside it. + + + +| Data attribute | Value | +| -------------------- | ------------------------------ | +| `[data-index]` | Numeric index of the thumb. | +| `[data-orientation]` | `"horizontal" \| "vertical"` | +| `[data-disabled]` | Present when the thumb is disabled. | +| `[data-dragging]` | Present while a thumb is dragged. | + +### Thumb Input + +`RdxSliderThumbInput` β€” the nested native `input[type=range]` that drives keyboard, a11y and forms. + + + +| Data attribute | Value | +| -------------- | --------------------------- | +| `[data-index]` | Numeric index of the thumb. | + +### Value + +`RdxSliderValue` β€” displays the formatted value(s). + + ## Accessibility -Adheres to the [Slider WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb). +Adheres to the [Slider WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb). diff --git a/packages/primitives/slider/stories/slider.stories.ts b/packages/primitives/slider/stories/slider.stories.ts index 591b2458..f2c4a48a 100644 --- a/packages/primitives/slider/stories/slider.stories.ts +++ b/packages/primitives/slider/stories/slider.stories.ts @@ -1,17 +1,34 @@ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { tailwindDemoDecorator } from '../../storybook/tailwind-demo'; -import { RdxSliderRangeComponent } from '../src/slider-range.component'; -import { RdxSliderRootComponent } from '../src/slider-root.component'; -import { RdxSliderThumbComponent } from '../src/slider-thumb.component'; -import { RdxSliderTrackComponent } from '../src/slider-track.component'; +import { SliderDefaultExample } from './slider-default'; +import defaultSource from './slider-default?raw'; +import { SliderDisabledExample } from './slider-disabled'; +import disabledSource from './slider-disabled?raw'; +import { SliderFormsExample } from './slider-forms'; +import formsSource from './slider-forms?raw'; +import { SliderRangeExample } from './slider-range'; +import rangeSource from './slider-range?raw'; +import { SliderValueExample } from './slider-value'; +import valueSource from './slider-value?raw'; +import { SliderVerticalExample } from './slider-vertical'; +import verticalSource from './slider-vertical?raw'; const html = String.raw; +const source = (code: string) => ({ docs: { source: { code, language: 'typescript' } } }); + export default { title: 'Primitives/Slider', decorators: [ moduleMetadata({ - imports: [RdxSliderRootComponent, RdxSliderTrackComponent, RdxSliderRangeComponent, RdxSliderThumbComponent] + imports: [ + SliderDefaultExample, + SliderRangeExample, + SliderVerticalExample, + SliderValueExample, + SliderDisabledExample, + SliderFormsExample + ] }), tailwindDemoDecorator() ] @@ -20,122 +37,55 @@ export default { type Story = StoryObj; export const Default: Story = { - render: (args) => ({ - props: args, + parameters: source(defaultSource), + render: () => ({ template: html` - - - - - - + ` }) }; -export const Inverted: Story = { - render: (args) => ({ - props: args, +export const Range: Story = { + parameters: source(rangeSource), + render: () => ({ template: html` - - - - - - + ` }) }; -export const Thumbs: Story = { - render: (args) => ({ - props: args, +export const Vertical: Story = { + parameters: source(verticalSource), + render: () => ({ template: html` - - - - - - - + ` }) }; -export const Vertical: Story = { - render: (args) => ({ - props: args, +export const Value: Story = { + parameters: source(valueSource), + render: () => ({ template: html` - - - - - - + + ` + }) +}; - - - - - - +export const Disabled: Story = { + parameters: source(disabledSource), + render: () => ({ + template: html` + ` }) }; -export const VerticalInverted: Story = { - render: (args) => ({ - props: args, +export const ReactiveForms: Story = { + parameters: source(formsSource), + render: () => ({ template: html` - - - - - - + ` }) }; diff --git a/packages/primitives/tooltip/stories/tooltip-slider.ts b/packages/primitives/tooltip/stories/tooltip-slider.ts index 03bcb049..b4022473 100644 --- a/packages/primitives/tooltip/stories/tooltip-slider.ts +++ b/packages/primitives/tooltip/stories/tooltip-slider.ts @@ -1,9 +1,11 @@ import { Component, ElementRef, signal, viewChild } from '@angular/core'; import { - RdxSliderRangeComponent, - RdxSliderRootComponent, - RdxSliderThumbComponent, - RdxSliderTrackComponent + RdxSliderControl, + RdxSliderIndicator, + RdxSliderRoot, + RdxSliderThumb, + RdxSliderThumbInput, + RdxSliderTrack } from '@radix-ng/primitives/slider'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { demoTooltip } from '../../storybook/styles'; @@ -12,44 +14,46 @@ import { demoTooltip } from '../../storybook/styles'; selector: 'rdx-tooltip-slider', imports: [ ...tooltipImports, - RdxSliderRootComponent, - RdxSliderTrackComponent, - RdxSliderRangeComponent, - RdxSliderThumbComponent + RdxSliderRoot, + RdxSliderControl, + RdxSliderTrack, + RdxSliderIndicator, + RdxSliderThumb, + RdxSliderThumbInput ], template: ` - - - - +
+
+
+
- - - - - +
-
Volume
+
-
-
-
- + + + +
+
Volume
+
+
+
+ +
+
+
` }) export class RdxTooltipSliderComponent {