diff --git a/README.md b/README.md
index f4a87f4..04f585e 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://www.npmjs.com/package/@elik56a/react-native-pickers) [](https://www.npmjs.com/package/@elik56a/react-native-pickers) [](https://bundlephobia.com/package/@elik56a/react-native-pickers) [](https://github.com/elik56a/react-native-pickers/actions/workflows/ci.yml) [](LICENSE) [](https://scorecard.dev/viewer/?uri=github.com/elik56a/react-native-pickers)
-**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers, plus the bottom sheets and form fields to put them in. Native on iOS, Android and the web, and every detail is yours to customize.
+**Every picker a React Native app needs, in one package with zero native code.** Wheel, time, duration, date, month, week strip, date range, date & time, select, country, phone number, time zone, segmented control, emoji and color pickers and a slider, plus the bottom sheets and form fields to put them in. Native on iOS, Android and the web, and every detail is yours to customize.
@@ -105,6 +105,7 @@ Each field opens its picker in a bottom sheet on iOS and Android and in a dropdo
| [`TimeZonePicker`](https://react-native-pickers.dev/docs/components/time-zone-picker) | Every IANA time zone with its GMT offset, sorted by offset, the device's zone on top |
| [`HorizontalList`](https://react-native-pickers.dev/docs/components/horizontal-list) | Segmented control or chips that scroll sideways and reveal the next item |
| [`ColorPicker`](https://react-native-pickers.dev/docs/components/color-picker) | iOS's Grid / Spectrum / Sliders, Google's swatch circles, or the browser's spectrum; hex with opacity and a copy button |
+| [`Slider`](https://react-native-pickers.dev/docs/components/slider) | iOS's knob, Material 3's handle with a value bubble, or a range input; one thumb or a range, steps, icons |
| [`EmojiPicker`](https://react-native-pickers.dev/docs/components/emoji-picker) | 1,923 emojis: categories, search, skin tones (long-press for one emoji's), recents, version filtering |
| [`BottomSheet`](https://react-native-pickers.dev/docs/components/bottom-sheet) | Animated sheet with drag-to-dismiss, for your own content |
| [Picker sheets](https://react-native-pickers.dev/docs/components/picker-sheets) | `DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet`, `DurationPickerSheet`, `MonthPickerSheet`, `ColorPickerSheet`, `WheelPickerSheet`, `EmojiPickerSheet`: a picker in a sheet with Cancel / Done |
@@ -289,6 +290,20 @@ The [full API reference](https://react-native-pickers.dev/docs/api/reference) li
`ColorPickerField` shows a swatch and the hex code.
+### Slider
+
+| Prop | Type | Default |
+|---|---|---|
+| `value` | `number`, or `[number, number]` with `range` | `min` |
+| `onChange` | `(value) => void`: on release, or a key or screen-reader step | |
+| `onValueChanging` | `(value) => void`: every step while dragging | |
+| `min` / `max` / `step` | `number`; `step={0}` for any value | `0` / `100` / `1` |
+| `range` | `boolean`: two thumbs | `false` |
+| `showSteps` | `boolean`: a dot per step | `false` |
+| `marks` / `snapToMarks` | `(number \| { value, label? })[]`: dots with labels / only the marks can be picked | / `false` |
+| `valueLabel` | `'never' \| 'drag' \| 'always'`: the value bubble | `'drag'` in Material |
+| `minimumIcon` / `maximumIcon` | `ReactNode` beside the track | |
+
### DurationPicker
A length of time, such as a timer or a workout, rather than a time of day. The value is a number of seconds.
diff --git a/context7.json b/context7.json
index 54dc768..00f6c48 100644
--- a/context7.json
+++ b/context7.json
@@ -3,7 +3,7 @@
"url": "https://context7.com/elik56a/react-native-pickers",
"public_key": "pk_EfgECmIIv32CQlZpPe9GW",
"projectTitle": "React Native Pickers",
- "description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers plus bottom sheets and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).",
+ "description": "Wheel, time, duration, date, month, week strip, date-time, select, country, phone number, time zone, horizontal list, emoji and color pickers, a slider plus bottom sheets and form fields for React Native, Expo and React Native Web. Looks native on iOS, Android and the web, with zero native code (pure JavaScript).",
"folders": ["website/docs"],
"rules": [
"Install @elik56a/react-native-pickers. It has zero native code (pure JavaScript), so it works in Expo Go without a rebuild.",
diff --git a/package.json b/package.json
index b1dd47e..afd70ab 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@elik56a/react-native-pickers",
"version": "0.25.0",
- "description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, plus bottom sheets and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.",
+ "description": "Fully customizable React Native pickers with zero native code: wheel, time, duration, date (calendar, range, wheel), month, week strip, date-time, select (with server search), country (flags and dialing codes), phone number, time zone, segmented control, emoji and color, a slider (single or range), plus bottom sheets and form fields. Native iOS, Material and web designs. No native code, works with Expo and React Native Web.",
"license": "MIT",
"author": "Eliyahu Kriel ",
"homepage": "https://react-native-pickers.dev/",
@@ -72,6 +72,8 @@
"multi-select",
"emoji-keyboard",
"color-picker",
+ "slider",
+ "range-slider",
"colour-picker",
"hex-color-picker",
"duration-picker",
diff --git a/src/color/color.constants.ts b/src/color/color.constants.ts
index 307889f..6ff98aa 100644
--- a/src/color/color.constants.ts
+++ b/src/color/color.constants.ts
@@ -75,20 +75,13 @@ export const SLIDER_SEGMENTS = 36;
export const PAD_SEGMENTS = 32;
/** How far a screen reader's step moves a slider or pad: 1/36 of the hue circle is 10°. */
export const COLOR_STEP = 1 / 36;
-export const SLIDER_THUMB_SIZE = 28;
-/** The slider's touch area, 44pt as iOS asks, however thin its track. */
-export const SLIDER_HIT_HEIGHT = 44;
export const PAD_THUMB_SIZE = 22;
-export const COLOR_A11Y_ACTIONS = [{ name: 'increment' }, { name: 'decrement' }];
/** The pad's second axis, for screen readers, which step one axis at a time. */
export const PAD_ACTION = { up: 'up', down: 'down' } as const;
export const COPIED_MS = 1500;
export const CHECKER_SIZE = 6;
export const CHECKER_COLUMNS = 80;
-/** The thumb's ring and shadow, white on any colour as in the platforms' own colour sliders. */
-export const THUMB_RING = '#FFFFFF';
-export const THUMB_SHADOW = '0 1px 4px rgba(0, 0, 0, 0.3)';
export const PAD_WHITE = '#FFFFFF';
export const PAD_BLACK = '#000000';
export const CHECKER_LIGHT = '#FFFFFF';
diff --git a/src/color/color.styles.ts b/src/color/color.styles.ts
index 73697bd..223ba64 100644
--- a/src/color/color.styles.ts
+++ b/src/color/color.styles.ts
@@ -1,12 +1,14 @@
import { StyleSheet } from 'react-native';
-import { CHECKER_SIZE, PAD_THUMB_SIZE, SLIDER_HIT_HEIGHT, SLIDER_THUMB_SIZE, THUMB_RING, THUMB_SHADOW } from './color.constants';
+import { KNOB_COLOR, KNOB_SHADOW, KNOB_SIZE, TOUCH_TARGET } from '../core/core.constants';
+import { CHECKER_SIZE, PAD_THUMB_SIZE } from './color.constants';
+/** The platforms' colour sliders ring the colour in white, so it shows on any colour. */
const thumb = {
position: 'absolute',
borderWidth: 3,
- borderColor: THUMB_RING,
- boxShadow: THUMB_SHADOW,
+ borderColor: KNOB_COLOR,
+ boxShadow: KNOB_SHADOW,
} as const;
export const styles = StyleSheet.create({
@@ -18,16 +20,16 @@ export const styles = StyleSheet.create({
swatches: { flexDirection: 'row', flexWrap: 'wrap' },
swatchRow: { flexDirection: 'row' },
swatch: { alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth },
- slider: { height: SLIDER_HIT_HEIGHT, justifyContent: 'center' },
+ slider: { height: TOUCH_TARGET, justifyContent: 'center' },
sliderHeader: { flexDirection: 'row', justifyContent: 'space-between' },
rail: { justifyContent: 'center' },
track: { ...StyleSheet.absoluteFill, flexDirection: 'row', overflow: 'hidden' },
sliderThumb: {
...thumb,
- width: SLIDER_THUMB_SIZE,
- height: SLIDER_THUMB_SIZE,
- borderRadius: SLIDER_THUMB_SIZE / 2,
- marginLeft: -SLIDER_THUMB_SIZE / 2,
+ width: KNOB_SIZE,
+ height: KNOB_SIZE,
+ borderRadius: KNOB_SIZE / 2,
+ marginLeft: -KNOB_SIZE / 2,
},
pad: { overflow: 'hidden' },
layer: { ...StyleSheet.absoluteFill },
diff --git a/src/color/color.utils.test.ts b/src/color/color.utils.test.ts
index 050fd69..3833382 100644
--- a/src/color/color.utils.test.ts
+++ b/src/color/color.utils.test.ts
@@ -17,7 +17,6 @@ import {
resolveColorLabels,
spectrumToHsv,
splitAlpha,
- toFraction,
toSwatchCells,
withAlpha,
} from './color.utils';
@@ -116,15 +115,6 @@ describe('swatches', () => {
});
});
-describe('toFraction', () => {
- it('turns a touch into 0–1, clamped', () => {
- expect(toFraction(50, 200)).toBe(0.25);
- expect(toFraction(-10, 200)).toBe(0);
- expect(toFraction(300, 200)).toBe(1);
- expect(toFraction(10, 0)).toBe(0);
- });
-});
-
describe('platform features', () => {
it('has no clipboard or eyedropper of its own on native', () => {
expect(getDefaultCopy()).toBeNull();
diff --git a/src/color/color.utils.ts b/src/color/color.utils.ts
index c896a8a..172c846 100644
--- a/src/color/color.utils.ts
+++ b/src/color/color.utils.ts
@@ -1,4 +1,5 @@
import { IS_WEB } from '../core/core.constants';
+import { clamp01 } from '../core/utils/track.utils';
import {
CHECK_ON_DARK,
CHECK_ON_LIGHT,
@@ -24,7 +25,6 @@ export const normalizeHex = (input: string) => {
return `#${full.endsWith(OPAQUE) && full.length === 8 ? full.slice(0, 6) : full}`;
};
-export const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const toHexByte = (n: number) =>
Math.round(clamp01(n) * 255)
.toString(16)
@@ -151,9 +151,6 @@ export const toSwatchCells = (colors: ColorOption[]): SwatchCell[] =>
...cell,
}));
-/** A value between 0 and 1 from a touch `at` along a length. */
-export const toFraction = (at: number, length: number) => (length > 0 ? clamp01(at / length) : 0);
-
export const percent = (value: number) => `${Math.round(value * 100)}%`;
export const degrees = (value: number) => `${Math.round(value * 360)}°`;
diff --git a/src/color/components/ColorPad.tsx b/src/color/components/ColorPad.tsx
index b591b29..03ecafb 100644
--- a/src/color/components/ColorPad.tsx
+++ b/src/color/components/ColorPad.tsx
@@ -1,12 +1,12 @@
import { View } from 'react-native';
-import { KEY } from '../../core/core.constants';
+import { A11Y_ACTION, ADJUSTABLE_ACTIONS, KEY } from '../../core/core.constants';
+import { useTrackPan } from '../../core/hooks/useTrackPan';
import { keyboardProps } from '../../core/utils/keyboard.utils';
-import { COLOR_A11Y_ACTIONS, COLOR_STEP, PAD_ACTION } from '../color.constants';
+import { clamp01 } from '../../core/utils/track.utils';
+import { COLOR_STEP, PAD_ACTION } from '../color.constants';
import { styles } from '../color.styles';
import type { ColorPadProps } from '../color.types';
-import { clamp01 } from '../color.utils';
-import { useTrackPan } from '../hooks/useTrackPan';
/**
* A square of colour to tap or drag across. Screen readers step it across with increment and decrement, and up and down
@@ -27,7 +27,7 @@ export const ColorPad = ({
slots,
testID,
}: ColorPadProps) => {
- const pan = useTrackPan(onChange);
+ const pan = useTrackPan({ onMove: onChange });
const moveX = (by: number) => onChange(clamp01(x + by), y);
const moveY = (by: number) => onChange(x, clamp01(y + by));
return (
@@ -39,10 +39,11 @@ export const ColorPad = ({
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ text: valueText }}
- accessibilityActions={[...COLOR_A11Y_ACTIONS, { name: PAD_ACTION.up, label: upLabel }, { name: PAD_ACTION.down, label: downLabel }]}
+ accessibilityActions={[...ADJUSTABLE_ACTIONS, { name: PAD_ACTION.up, label: upLabel }, { name: PAD_ACTION.down, label: downLabel }]}
onAccessibilityAction={({ nativeEvent: { actionName } }) => {
- if (actionName === 'increment' || actionName === 'decrement') moveX(actionName === 'increment' ? COLOR_STEP : -COLOR_STEP);
- else moveY(actionName === PAD_ACTION.up ? -COLOR_STEP : COLOR_STEP);
+ if (actionName === A11Y_ACTION.increment || actionName === A11Y_ACTION.decrement) {
+ moveX(actionName === A11Y_ACTION.increment ? COLOR_STEP : -COLOR_STEP);
+ } else moveY(actionName === PAD_ACTION.up ? -COLOR_STEP : COLOR_STEP);
}}
{...keyboardProps({
[KEY.right]: () => moveX(COLOR_STEP),
diff --git a/src/color/components/ColorSlider.tsx b/src/color/components/ColorSlider.tsx
index 25616fc..777a0d9 100644
--- a/src/color/components/ColorSlider.tsx
+++ b/src/color/components/ColorSlider.tsx
@@ -1,16 +1,17 @@
import { Text, View } from 'react-native';
import { textStyle } from '../../core';
-import { KEY } from '../../core/core.constants';
-import { keyboardProps } from '../../core/utils/keyboard.utils';
-import { COLOR_A11Y_ACTIONS, COLOR_DESIGN, COLOR_STEP, SLIDER_THUMB_SIZE } from '../color.constants';
+import { KNOB_SIZE } from '../../core/core.constants';
+import { useTrackPan } from '../../core/hooks/useTrackPan';
+import { adjustableProps } from '../../core/utils/adjustable.utils';
+import { clamp01 } from '../../core/utils/track.utils';
+import { COLOR_DESIGN, COLOR_STEP } from '../color.constants';
import { styles } from '../color.styles';
import type { ColorSliderProps } from '../color.types';
import { toSlices } from '../color.utils';
-import { useTrackPan } from '../hooks/useTrackPan';
import { Checkerboard } from './Checkerboard';
-const INSET = SLIDER_THUMB_SIZE / 2;
+const INSET = KNOB_SIZE / 2;
/** A slider over its own gradient, drawn as slices of solid colour; 44pt to touch however thin the track. */
export const ColorSlider = ({
@@ -27,8 +28,7 @@ export const ColorSlider = ({
slots,
testID,
}: ColorSliderProps) => {
- const pan = useTrackPan((x) => onChange(x), INSET);
- const step = (by: number) => onChange(Math.min(1, Math.max(0, value + by)));
+ const pan = useTrackPan({ onMove: (x) => onChange(x), insetX: INSET });
const { trackHeight } = COLOR_DESIGN[theme.design];
const caption = [textStyle(theme, 'sm'), { color: theme.colors.textSecondary }, slots?.sliderLabel];
return (
@@ -43,19 +43,12 @@ export const ColorSlider = ({
style={[styles.slider, slots?.slider]}
onLayout={pan.onLayout}
{...pan.panHandlers}
- accessible
- accessibilityRole="adjustable"
- accessibilityLabel={label}
- accessibilityValue={{ text: valueText }}
- accessibilityActions={COLOR_A11Y_ACTIONS}
- onAccessibilityAction={(e) => step(e.nativeEvent.actionName === 'increment' ? COLOR_STEP : -COLOR_STEP)}
- {...keyboardProps({
- [KEY.right]: () => step(COLOR_STEP),
- [KEY.up]: () => step(COLOR_STEP),
- [KEY.left]: () => step(-COLOR_STEP),
- [KEY.down]: () => step(-COLOR_STEP),
- [KEY.home]: () => onChange(0),
- [KEY.end]: () => onChange(1),
+ {...adjustableProps({
+ label,
+ valueText,
+ onStep: (direction) => onChange(clamp01(value + direction * COLOR_STEP)),
+ onMin: () => onChange(0),
+ onMax: () => onChange(1),
})}
testID={testID}
>
@@ -69,7 +62,7 @@ export const ColorSlider = ({
diff --git a/src/color/components/IosGrid.tsx b/src/color/components/IosGrid.tsx
index d17c149..c0d8524 100644
--- a/src/color/components/IosGrid.tsx
+++ b/src/color/components/IosGrid.tsx
@@ -2,7 +2,8 @@ import { View } from 'react-native';
import type { PickerTheme } from '../../core';
import { Touchable } from '../../core/components/Touchable';
-import { IOS_GRID_CELL_HEIGHT, THUMB_RING } from '../color.constants';
+import { KNOB_COLOR } from '../../core/core.constants';
+import { IOS_GRID_CELL_HEIGHT } from '../color.constants';
import { styles } from '../color.styles';
import { getIosGridColors } from '../color.utils';
@@ -27,7 +28,7 @@ export const IosGrid = ({ selected, onSelect, theme }: { selected: string; onSel
style={[
styles.gridCell,
{ height: IOS_GRID_CELL_HEIGHT, backgroundColor: color },
- chosen && [styles.gridCellSelected, { borderColor: THUMB_RING }],
+ chosen && [styles.gridCellSelected, { borderColor: KNOB_COLOR }],
]}
/>
);
diff --git a/src/color/hooks/useTrackPan.ts b/src/color/hooks/useTrackPan.ts
deleted file mode 100644
index 7fdade5..0000000
--- a/src/color/hooks/useTrackPan.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useRef } from 'react';
-import type { GestureResponderHandlers, LayoutChangeEvent } from 'react-native';
-import { PanResponder } from 'react-native';
-
-import { toFraction } from '../color.utils';
-
-/**
- * Tap or drag on a slider's track or a pad: `onMove` gets where, 0–1 across and down. `insetX` keeps the ends half a
- * thumb in, where a slider's thumb centre stops.
- */
-export const useTrackPan = (onMove: (x: number, y: number) => void, insetX = 0) => {
- const size = useRef({ width: 0, height: 0 });
- const start = useRef({ x: 0, y: 0 });
- // The responder is created once, so it reads the latest onMove through a ref.
- const latest = useRef(onMove);
- latest.current = onMove;
- const at = (x: number, y: number) =>
- latest.current(toFraction(x - insetX, size.current.width - 2 * insetX), toFraction(y, size.current.height));
-
- const panHandlers: GestureResponderHandlers = useRef(
- PanResponder.create({
- onStartShouldSetPanResponder: () => true,
- onMoveShouldSetPanResponder: () => true,
- // A sheet's swipe to close mustn't take over a drag on the colour.
- onPanResponderTerminationRequest: () => false,
- onPanResponderGrant: (e) => {
- start.current = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY };
- at(start.current.x, start.current.y);
- },
- onPanResponderMove: (_, g) => at(start.current.x + g.dx, start.current.y + g.dy),
- })
- ).current.panHandlers;
-
- return {
- panHandlers,
- onLayout: (e: LayoutChangeEvent) => {
- size.current = {
- width: e.nativeEvent.layout.width,
- height: e.nativeEvent.layout.height,
- };
- },
- };
-};
diff --git a/src/core/core.constants.ts b/src/core/core.constants.ts
index 73056ed..c46ab66 100644
--- a/src/core/core.constants.ts
+++ b/src/core/core.constants.ts
@@ -98,8 +98,24 @@ export const KEY = {
right: 'ArrowRight',
home: 'Home',
end: 'End',
+ pageUp: 'PageUp',
+ pageDown: 'PageDown',
} as const;
+/** The screen-reader actions of an adjustable control, a slider or a wheel. */
+export const A11Y_ACTION = {
+ increment: 'increment',
+ decrement: 'decrement',
+} as const;
+export const ADJUSTABLE_ACTIONS = [{ name: A11Y_ACTION.increment }, { name: A11Y_ACTION.decrement }];
+
+/** A slider's touch area, 44pt as iOS asks, however thin its track. */
+export const TOUCH_TARGET = 44;
+/** iOS's slider knob: white on any colour, with a soft shadow. */
+export const KNOB_SIZE = 28;
+export const KNOB_COLOR = '#FFFFFF';
+export const KNOB_SHADOW = '0 1px 4px rgba(0, 0, 0, 0.3)';
+
export const DIRECTION = {
up: 'up',
down: 'down',
@@ -117,3 +133,7 @@ export const ARROW_DIRECTION: Record> = {
/** What arrow-key focus moves to: what Tab stops at, text fields included. */
export const FOCUS_TARGETS = '[tabindex="0"], input:not([disabled]), textarea:not([disabled])';
export const TEXT_FIELD_TAGS = ['INPUT', 'TEXTAREA'];
+/** How far (px) a box may overlap the one focus leaves and still count as lying that way. */
+export const EDGE_TOLERANCE = 1;
+/** A step across counts double a step along, so moving down keeps to the column and moving right to the row. */
+export const CROSS_WEIGHT = 2;
diff --git a/src/core/core.types.ts b/src/core/core.types.ts
index 9c4235b..984178f 100644
--- a/src/core/core.types.ts
+++ b/src/core/core.types.ts
@@ -147,6 +147,48 @@ export type Key = ValueOf;
export type Direction = ValueOf;
export type KeyHandlers = Partial void>>;
+export interface AdjustableOptions {
+ label?: string;
+ /** What a screen reader reads as the value */
+ valueText: string;
+ disabled?: boolean;
+ /** 1 to step up, -1 to step down */
+ onStep: (direction: 1 | -1) => void;
+ /** Page Up and Page Down: 1 or -1 */
+ onPage?: (direction: 1 | -1) => void;
+ onMin?: () => void;
+ onMax?: () => void;
+}
+
+export interface TrackPanOptions {
+ /** Where the drag is, 0–1 across and down */
+ onMove: (x: number, y: number) => void;
+ /** Where the touch lands. Default: `onMove` */
+ onStart?: (x: number, y: number) => void;
+ /** The finger lifts, or the gesture is taken away */
+ onEnd?: () => void;
+ /** Keeps the ends this far in, where a slider's thumb centre stops */
+ insetX?: number;
+}
+
+/** The few DOM members arrow-key focus needs; the library is typed without the DOM. */
+export interface WebElement {
+ getBoundingClientRect: () => Rect;
+ focus: () => void;
+ tagName: string;
+ contains: (node: WebElement) => boolean;
+ querySelectorAll: (selectors: string) => ArrayLike;
+ parentElement: WebElement | null;
+ dispatchEvent: (event: unknown) => boolean;
+ ownerDocument: {
+ activeElement: WebElement | null;
+ defaultView: {
+ getComputedStyle: (element: WebElement) => { direction: string };
+ KeyboardEvent: new (type: string, init: { key: string; bubbles: boolean; cancelable: boolean }) => unknown;
+ } | null;
+ };
+}
+
/** A box on screen, as `getBoundingClientRect` gives it. */
export interface Rect {
left: number;
diff --git a/src/color/hooks/useTrackPan.test.ts b/src/core/hooks/useTrackPan.test.ts
similarity index 54%
rename from src/color/hooks/useTrackPan.test.ts
rename to src/core/hooks/useTrackPan.test.ts
index 2b5729d..cacac55 100644
--- a/src/color/hooks/useTrackPan.test.ts
+++ b/src/core/hooks/useTrackPan.test.ts
@@ -12,15 +12,19 @@ const moved = (dx: number, dy: number) =>
dy,
}) as PanResponderGestureState;
+const setUp = async (options: Parameters[0]) => {
+ const spy = jest.spyOn(PanResponder, 'create');
+ const { result } = await renderHook(() => useTrackPan(options));
+ result.current.onLayout({ nativeEvent: { layout: { width: 220, height: 100 } } } as LayoutChangeEvent);
+ return spy.mock.calls[0]?.[0] as PanResponderCallbacks;
+};
+
describe('useTrackPan', () => {
afterEach(() => jest.restoreAllMocks());
it('reports where the track is touched and follows the drag, inset at the ends, and keeps the gesture', async () => {
- const spy = jest.spyOn(PanResponder, 'create');
const onMove = jest.fn();
- const { result } = await renderHook(() => useTrackPan(onMove, 10));
- result.current.onLayout({ nativeEvent: { layout: { width: 220, height: 100 } } } as LayoutChangeEvent);
- const responder = spy.mock.calls[0]?.[0] as PanResponderCallbacks;
+ const responder = await setUp({ onMove, insetX: 10 });
const start = touchAt(60, 25);
responder.onPanResponderGrant?.(start, moved(0, 0));
expect(onMove).toHaveBeenLastCalledWith(0.25, 0.25);
@@ -30,4 +34,18 @@ describe('useTrackPan', () => {
expect(onMove).toHaveBeenLastCalledWith(1, 0);
expect(responder.onPanResponderTerminationRequest?.(start, moved(0, 0))).toBe(false);
});
+
+ it('reports the first touch to onStart, and the release or a taken gesture to onEnd', async () => {
+ const onMove = jest.fn();
+ const onStart = jest.fn();
+ const onEnd = jest.fn();
+ const responder = await setUp({ onMove, onStart, onEnd });
+ const start = touchAt(110, 50);
+ responder.onPanResponderGrant?.(start, moved(0, 0));
+ expect(onStart).toHaveBeenCalledWith(0.5, 0.5);
+ expect(onMove).not.toHaveBeenCalled();
+ responder.onPanResponderRelease?.(start, moved(0, 0));
+ responder.onPanResponderTerminate?.(start, moved(0, 0));
+ expect(onEnd).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/src/core/hooks/useTrackPan.ts b/src/core/hooks/useTrackPan.ts
new file mode 100644
index 0000000..800792a
--- /dev/null
+++ b/src/core/hooks/useTrackPan.ts
@@ -0,0 +1,47 @@
+import { useRef } from 'react';
+import type { GestureResponderHandlers, LayoutChangeEvent } from 'react-native';
+import { PanResponder } from 'react-native';
+
+import type { TrackPanOptions } from '../core.types';
+import { toFraction } from '../utils/track.utils';
+
+/** Tap or drag on a slider's track or a pad: `onStart` and `onMove` get where, 0–1 across and down, and `onEnd` the release. */
+export const useTrackPan = (options: TrackPanOptions) => {
+ const size = useRef({ width: 0, height: 0 });
+ const start = useRef({ x: 0, y: 0 });
+ // The responder is created once, so it reads the latest callbacks through a ref.
+ const latest = useRef(options);
+ latest.current = options;
+ const at = (x: number, y: number): [number, number] => {
+ const inset = latest.current.insetX ?? 0;
+ return [toFraction(x - inset, size.current.width - 2 * inset), toFraction(y, size.current.height)];
+ };
+ const end = () => latest.current.onEnd?.();
+
+ const panHandlers: GestureResponderHandlers = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ // A sheet's swipe to close mustn't take over a drag on the track.
+ onPanResponderTerminationRequest: () => false,
+ onPanResponderGrant: (e) => {
+ start.current = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY };
+ const { onStart = latest.current.onMove } = latest.current;
+ onStart(...at(start.current.x, start.current.y));
+ },
+ onPanResponderMove: (_, g) => latest.current.onMove(...at(start.current.x + g.dx, start.current.y + g.dy)),
+ onPanResponderRelease: end,
+ onPanResponderTerminate: end,
+ })
+ ).current.panHandlers;
+
+ return {
+ panHandlers,
+ onLayout: (e: LayoutChangeEvent) => {
+ size.current = {
+ width: e.nativeEvent.layout.width,
+ height: e.nativeEvent.layout.height,
+ };
+ },
+ };
+};
diff --git a/src/core/utils/adjustable.utils.test.ts b/src/core/utils/adjustable.utils.test.ts
new file mode 100644
index 0000000..d0b7b89
--- /dev/null
+++ b/src/core/utils/adjustable.utils.test.ts
@@ -0,0 +1,42 @@
+import type { AccessibilityActionEvent, KeyDownEvent } from 'react-native';
+
+import { adjustableProps } from './adjustable.utils';
+
+const action = (actionName: string) => ({ nativeEvent: { actionName } }) as AccessibilityActionEvent;
+const key = (k: string) =>
+ ({
+ nativeEvent: { key: k },
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ }) as unknown as KeyDownEvent;
+
+describe('adjustableProps', () => {
+ it('steps on screen-reader actions and arrow keys, pages, and goes to the ends', () => {
+ const onStep = jest.fn();
+ const onPage = jest.fn();
+ const onMin = jest.fn();
+ const onMax = jest.fn();
+ const props = adjustableProps({ label: 'Volume', valueText: '40', onStep, onPage, onMin, onMax });
+ expect(props).toMatchObject({ accessibilityRole: 'adjustable', accessibilityLabel: 'Volume', accessibilityValue: { text: '40' } });
+ props.onAccessibilityAction(action('increment'));
+ props.onAccessibilityAction(action('decrement'));
+ props.onKeyDown?.(key('ArrowRight'));
+ props.onKeyDown?.(key('ArrowDown'));
+ expect(onStep.mock.calls).toEqual([[1], [-1], [1], [-1]]);
+ props.onKeyDown?.(key('PageUp'));
+ expect(onPage).toHaveBeenCalledWith(1);
+ props.onKeyDown?.(key('Home'));
+ props.onKeyDown?.(key('End'));
+ expect(onMin).toHaveBeenCalled();
+ expect(onMax).toHaveBeenCalled();
+ });
+
+ it('ignores steps and keys while disabled', () => {
+ const onStep = jest.fn();
+ const props = adjustableProps({ valueText: '1', disabled: true, onStep });
+ props.onAccessibilityAction(action('increment'));
+ expect(onStep).not.toHaveBeenCalled();
+ expect('onKeyDown' in props).toBe(false);
+ expect(props.accessibilityState).toEqual({ disabled: true });
+ });
+});
diff --git a/src/core/utils/adjustable.utils.ts b/src/core/utils/adjustable.utils.ts
new file mode 100644
index 0000000..3fcb10c
--- /dev/null
+++ b/src/core/utils/adjustable.utils.ts
@@ -0,0 +1,33 @@
+import type { AccessibilityActionEvent } from 'react-native';
+
+import { A11Y_ACTION, ADJUSTABLE_ACTIONS, KEY } from '../core.constants';
+import type { AdjustableOptions } from '../core.types';
+import { keyboardProps } from './keyboard.utils';
+
+/**
+ * Screen-reader adjust actions and keys for a control that moves in steps, a slider's thumb: `onStep(1)` on increment,
+ * right and up, `onStep(-1)` on decrement, left and down. Home and End go to the ends; Page Up and Down call `onPage`.
+ */
+export const adjustableProps = ({ label, valueText, disabled = false, onStep, onPage, onMin, onMax }: AdjustableOptions) => ({
+ accessible: true,
+ accessibilityRole: 'adjustable' as const,
+ accessibilityLabel: label,
+ accessibilityValue: { text: valueText },
+ accessibilityState: { disabled },
+ accessibilityActions: ADJUSTABLE_ACTIONS,
+ onAccessibilityAction: (e: AccessibilityActionEvent) => {
+ if (!disabled) onStep(e.nativeEvent.actionName === A11Y_ACTION.increment ? 1 : -1);
+ },
+ ...(disabled
+ ? {}
+ : keyboardProps({
+ [KEY.right]: () => onStep(1),
+ [KEY.up]: () => onStep(1),
+ [KEY.left]: () => onStep(-1),
+ [KEY.down]: () => onStep(-1),
+ [KEY.pageUp]: onPage && (() => onPage(1)),
+ [KEY.pageDown]: onPage && (() => onPage(-1)),
+ [KEY.home]: onMin,
+ [KEY.end]: onMax,
+ })),
+});
diff --git a/src/core/utils/keyboard.utils.ts b/src/core/utils/keyboard.utils.ts
index e93bf8f..eb95c2c 100644
--- a/src/core/utils/keyboard.utils.ts
+++ b/src/core/utils/keyboard.utils.ts
@@ -1,7 +1,7 @@
import type { KeyDownEvent, TextInputKeyPressEvent } from 'react-native';
-import { ARROW_DIRECTION, DIRECTION, FOCUS_TARGETS, IS_WEB, KEY, TEXT_FIELD_TAGS } from '../core.constants';
-import type { Direction, Key, KeyHandlers, Rect } from '../core.types';
+import { ARROW_DIRECTION, CROSS_WEIGHT, DIRECTION, EDGE_TOLERANCE, FOCUS_TARGETS, IS_WEB, KEY, TEXT_FIELD_TAGS } from '../core.constants';
+import type { Direction, Key, KeyHandlers, Rect, WebElement } from '../core.types';
/** Lets a control take focus and answer keys. The keys it answers don't scroll the page or reach the grid around it. */
export const keyboardProps = (handlers: KeyHandlers) => ({
@@ -15,10 +15,6 @@ export const keyboardProps = (handlers: KeyHandlers) => ({
},
});
-const EDGE_TOLERANCE = 1;
-/** A step across counts double a step along, so moving down keeps to the column and moving right to the row. */
-const CROSS_WEIGHT = 2;
-
/** How far `point` is outside the span from `start` to `end`. */
const outside = (point: number, start: number, end: number) => Math.max(0, start - point, point - end);
@@ -84,24 +80,6 @@ export const nextFocusIndex = (key: string, current: number, rects: readonly Rec
return wrapped >= 0 && wrapped < rects.length ? wrapped : -1;
};
-/** The few DOM members arrow-key focus needs; the library is typed without the DOM. */
-interface WebElement {
- getBoundingClientRect: () => Rect;
- focus: () => void;
- tagName: string;
- contains: (node: WebElement) => boolean;
- querySelectorAll: (selectors: string) => ArrayLike;
- parentElement: WebElement | null;
- dispatchEvent: (event: unknown) => boolean;
- ownerDocument: {
- activeElement: WebElement | null;
- defaultView: {
- getComputedStyle: (element: WebElement) => { direction: string };
- KeyboardEvent: new (type: string, init: { key: string; bubbles: boolean; cancelable: boolean }) => unknown;
- } | null;
- };
-}
-
const moveFocus = (e: KeyDownEvent) => {
const root = e.currentTarget as unknown as WebElement;
const active = root.ownerDocument.activeElement;
diff --git a/src/core/utils/track.utils.test.ts b/src/core/utils/track.utils.test.ts
new file mode 100644
index 0000000..67d8eac
--- /dev/null
+++ b/src/core/utils/track.utils.test.ts
@@ -0,0 +1,17 @@
+import { clamp, clamp01, toFraction } from './track.utils';
+
+describe('track utils', () => {
+ it('clamps into a range', () => {
+ expect(clamp(5, 0, 3)).toBe(3);
+ expect(clamp(-1, 0, 3)).toBe(0);
+ expect(clamp01(0.4)).toBe(0.4);
+ expect(clamp01(2)).toBe(1);
+ });
+
+ it('turns a touch along a length into a fraction, 0 before the length is known', () => {
+ expect(toFraction(50, 200)).toBe(0.25);
+ expect(toFraction(-10, 200)).toBe(0);
+ expect(toFraction(300, 200)).toBe(1);
+ expect(toFraction(50, 0)).toBe(0);
+ });
+});
diff --git a/src/core/utils/track.utils.ts b/src/core/utils/track.utils.ts
new file mode 100644
index 0000000..ba88fa1
--- /dev/null
+++ b/src/core/utils/track.utils.ts
@@ -0,0 +1,6 @@
+export const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
+
+export const clamp01 = (n: number) => clamp(n, 0, 1);
+
+/** A value between 0 and 1 from a touch `at` along a length. */
+export const toFraction = (at: number, length: number) => (length > 0 ? clamp01(at / length) : 0);
diff --git a/src/index.ts b/src/index.ts
index 2598150..e686acb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,6 +10,7 @@ export * from './list';
export * from './month';
export * from './select';
export * from './sheet';
+export * from './slider';
export * from './time';
export * from './timezone';
export * from './wheel';
diff --git a/src/slider/Slider.test.tsx b/src/slider/Slider.test.tsx
new file mode 100644
index 0000000..52e95a4
--- /dev/null
+++ b/src/slider/Slider.test.tsx
@@ -0,0 +1,133 @@
+import type { GestureResponderEvent, PanResponderCallbacks, PanResponderGestureState } from 'react-native';
+import { PanResponder } from 'react-native';
+
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+
+import { Slider } from './Slider';
+
+const touchAt = (x: number) => ({ nativeEvent: { locationX: x, locationY: 22 } }) as unknown as GestureResponderEvent;
+const moved = (dx: number) =>
+ ({
+ dx,
+ dy: 0,
+ }) as PanResponderGestureState;
+const key = (k: string) => ({
+ nativeEvent: { key: k },
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+});
+
+/** Renders with the track laid out 228 px wide: iOS's 28 px knob leaves 200 px of travel, 2 px per step of 0–100. */
+const renderSlider = async (ui: React.ReactElement) => {
+ const spy = jest.spyOn(PanResponder, 'create');
+ await render(ui);
+ await fireEvent(screen.getByTestId('slider-track'), 'layout', { nativeEvent: { layout: { x: 0, y: 0, width: 228, height: 44 } } });
+ return spy.mock.calls[0]?.[0] as PanResponderCallbacks;
+};
+
+describe('Slider', () => {
+ afterEach(() => jest.restoreAllMocks());
+
+ it('reports each step while dragging and commits once on release', async () => {
+ const onChange = jest.fn();
+ const onValueChanging = jest.fn();
+ const pan = await renderSlider();
+ const start = touchAt(114);
+ await act(() => pan.onPanResponderGrant?.(start, moved(0)));
+ expect(onValueChanging).toHaveBeenLastCalledWith(50);
+ await act(() => pan.onPanResponderMove?.(start, moved(40)));
+ expect(onValueChanging).toHaveBeenLastCalledWith(70);
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByTestId('slider-thumb-0').props.accessibilityValue).toEqual({ text: '70' });
+ await act(() => pan.onPanResponderRelease?.(start, moved(40)));
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(70);
+ });
+
+ it('does not report a tap on the current value', async () => {
+ const onChange = jest.fn();
+ const pan = await renderSlider();
+ await act(() => pan.onPanResponderGrant?.(touchAt(114), moved(0)));
+ await act(() => pan.onPanResponderRelease?.(touchAt(114), moved(0)));
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('steps with keys and screen-reader actions, in steps, within min and max', async () => {
+ const onChange = jest.fn();
+ await render();
+ const thumb = screen.getByTestId('slider-thumb-0');
+ await fireEvent(thumb, 'keyDown', key('ArrowRight'));
+ expect(onChange).toHaveBeenLastCalledWith(30);
+ await fireEvent(thumb, 'accessibilityAction', { nativeEvent: { actionName: 'decrement' } });
+ expect(onChange).toHaveBeenLastCalledWith(20);
+ await fireEvent(thumb, 'keyDown', key('End'));
+ expect(onChange).toHaveBeenLastCalledWith(50);
+ await fireEvent(thumb, 'keyDown', key('ArrowUp'));
+ expect(onChange).toHaveBeenCalledTimes(3);
+ });
+
+ it('moves the nearest of two thumbs and keeps the range in order', async () => {
+ const onChange = jest.fn();
+ const pan = await renderSlider();
+ const start = touchAt(164);
+ await act(() => pan.onPanResponderGrant?.(start, moved(0)));
+ await act(() => pan.onPanResponderMove?.(start, moved(-160)));
+ await act(() => pan.onPanResponderRelease?.(start, moved(-160)));
+ expect(onChange).toHaveBeenCalledWith([20, 20]);
+ expect(screen.getByTestId('slider-thumb-0').props.accessibilityLabel).toBe('Minimum');
+ });
+
+ it('follows a controlled value without reporting it', async () => {
+ const onChange = jest.fn();
+ await render( `${v}%`} testID="slider" />);
+ expect(screen.getByTestId('slider-thumb-0').props.accessibilityValue).toEqual({ text: '30%' });
+ await screen.rerender( `${v}%`} testID="slider" />);
+ expect(screen.getByTestId('slider-thumb-0').props.accessibilityValue).toEqual({ text: '60%' });
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('shows Material’s value bubble only while dragging', async () => {
+ const pan = await renderSlider();
+ expect(screen.queryByText('50')).toBeNull();
+ await act(() => pan.onPanResponderGrant?.(touchAt(114), moved(0)));
+ expect(screen.getByText('50')).toBeTruthy();
+ await act(() => pan.onPanResponderRelease?.(touchAt(114), moved(0)));
+ expect(screen.queryByText('50')).toBeNull();
+ });
+
+ it('ignores touches and keys while disabled', async () => {
+ const onChange = jest.fn();
+ await render();
+ const thumb = screen.getByTestId('slider-thumb-0');
+ await fireEvent(thumb, 'keyDown', key('ArrowRight'));
+ expect(onChange).not.toHaveBeenCalled();
+ expect(thumb.props.accessibilityState).toEqual({ disabled: true });
+ expect(screen.getByTestId('slider-track').props.onResponderGrant).toBeUndefined();
+ });
+
+ it('draws a halo behind each thumb in the web design only', async () => {
+ await render();
+ expect(screen.getByTestId('slider-halo-0')).toBeTruthy();
+ expect(screen.getByTestId('slider-halo-1')).toBeTruthy();
+ await screen.rerender();
+ expect(screen.queryByTestId('slider-halo-0')).toBeNull();
+ });
+
+ it('keeps to the marks with snapToMarks, by drag and by key, and labels them', async () => {
+ const onChange = jest.fn();
+ const marks = [0, { value: 20, label: '20°C' }, { value: 37, label: '37°C' }, { value: 100, label: '100°C' }];
+ const pan = await renderSlider();
+ expect(screen.getByText('37°C')).toBeTruthy();
+ await act(() => pan.onPanResponderGrant?.(touchAt(114), moved(0)));
+ await act(() => pan.onPanResponderRelease?.(touchAt(114), moved(0)));
+ expect(onChange).toHaveBeenLastCalledWith(37);
+ await fireEvent(screen.getByTestId('slider-thumb-0'), 'keyDown', key('ArrowRight'));
+ expect(onChange).toHaveBeenLastCalledWith(100);
+ });
+
+ it('shows the value bubble always, on every thumb, with valueLabel="always"', async () => {
+ await render();
+ expect(screen.getByText('20')).toBeTruthy();
+ expect(screen.getByText('80')).toBeTruthy();
+ });
+});
diff --git a/src/slider/Slider.tsx b/src/slider/Slider.tsx
new file mode 100644
index 0000000..843864a
--- /dev/null
+++ b/src/slider/Slider.tsx
@@ -0,0 +1,75 @@
+import { View } from 'react-native';
+
+import { usePickerTheme } from '../core';
+import { useDesignOverride } from '../core/theme/ThemeProvider';
+import { MarkLabels } from './components/MarkLabels';
+import { SliderHalo } from './components/SliderHalo';
+import { SliderThumb } from './components/SliderThumb';
+import { SliderTrack } from './components/SliderTrack';
+import { ValueLabel } from './components/ValueLabel';
+import { useSlider } from './hooks/useSlider';
+import { styles } from './slider.styles';
+import type { SliderProps } from './slider.types';
+
+/** A slider drawn the platform's way: iOS's white knob, Material 3's handle with its value bubble, or a browser's range input. One thumb, or two with `range`. */
+export const Slider = (props: SliderProps) => {
+ const { disabled, minimumIcon, maximumIcon, style, styles: slots, theme: themeProp, design, testID } = props;
+ const theme = usePickerTheme(useDesignOverride(themeProp, design));
+ const slider = useSlider(props, theme);
+
+ return (
+
+ {minimumIcon}
+
+
+
+ {slider.design.halo
+ ? slider.thumbs.map((thumb) => (
+
+ ))
+ : null}
+ {slider.thumbs.map((thumb) => (
+
+ ))}
+ {slider.thumbs.map((thumb) =>
+ thumb.showValueLabel ? (
+
+ ) : null
+ )}
+
+ {slider.markLabels.length > 0 ? : null}
+
+ {maximumIcon}
+
+ );
+};
diff --git a/src/slider/components/MarkLabels.tsx b/src/slider/components/MarkLabels.tsx
new file mode 100644
index 0000000..68ea8e2
--- /dev/null
+++ b/src/slider/components/MarkLabels.tsx
@@ -0,0 +1,22 @@
+import { Text, View } from 'react-native';
+
+import { textStyle } from '../../core';
+import { MARK_LABEL_BOX_WIDTH } from '../slider.constants';
+import { styles } from '../slider.styles';
+import type { MarkLabelsProps } from '../slider.types';
+
+/** The marks' labels under the track, each centred on its mark; the ones in the filled part read stronger. */
+export const MarkLabels = ({ labels, theme, slots }: MarkLabelsProps) => (
+
+ {labels.map((mark) => (
+
+
+ {mark.label}
+
+
+ ))}
+
+);
diff --git a/src/slider/components/SliderHalo.tsx b/src/slider/components/SliderHalo.tsx
new file mode 100644
index 0000000..1731626
--- /dev/null
+++ b/src/slider/components/SliderHalo.tsx
@@ -0,0 +1,29 @@
+import { Animated } from 'react-native';
+
+import { TOUCH_TARGET } from '../../core/core.constants';
+import { useHaloStyle } from '../hooks/useHaloStyle';
+import { styles } from '../slider.styles';
+import type { SliderHaloProps } from '../slider.types';
+
+/** The web design's ring of colour behind a thumb: faint on hover or focus, grown in and stronger while pressed. */
+export const SliderHalo = ({ x, level, size, theme, testID }: SliderHaloProps) => {
+ const animated = useHaloStyle(level, theme.animation.duration);
+ return (
+
+ );
+};
diff --git a/src/slider/components/SliderThumb.tsx b/src/slider/components/SliderThumb.tsx
new file mode 100644
index 0000000..f79595c
--- /dev/null
+++ b/src/slider/components/SliderThumb.tsx
@@ -0,0 +1,29 @@
+import { View } from 'react-native';
+
+import { TOUCH_TARGET } from '../../core/core.constants';
+import { SLIDER_THUMB } from '../slider.constants';
+import { styles } from '../slider.styles';
+import type { SliderThumbProps } from '../slider.types';
+
+/** A knob, bar or dot centred on its value. It takes keys and screen-reader steps; touches go to the track under it. */
+export const SliderThumb = ({ thumb, design, theme, slots, testID }: SliderThumbProps) => (
+
+);
diff --git a/src/slider/components/SliderTrack.tsx b/src/slider/components/SliderTrack.tsx
new file mode 100644
index 0000000..5c069df
--- /dev/null
+++ b/src/slider/components/SliderTrack.tsx
@@ -0,0 +1,50 @@
+import { View } from 'react-native';
+
+import { TOUCH_TARGET } from '../../core/core.constants';
+import { styles } from '../slider.styles';
+import type { SliderTrackProps } from '../slider.types';
+
+/** The track in pieces around the thumbs, active ones in the primary colour, with the step and stop marks on it. */
+export const SliderTrack = ({ segments, marks, design, theme, slots }: SliderTrackProps) => {
+ const outer = design.trackHeight / 2;
+ return (
+
+ {segments.map((segment) => (
+
+ ))}
+ {marks.map((mark) => (
+
+ ))}
+
+ );
+};
diff --git a/src/slider/components/ValueLabel.tsx b/src/slider/components/ValueLabel.tsx
new file mode 100644
index 0000000..d9aa84b
--- /dev/null
+++ b/src/slider/components/ValueLabel.tsx
@@ -0,0 +1,22 @@
+import { Text, View } from 'react-native';
+
+import { textStyle } from '../../core';
+import { TOUCH_TARGET } from '../../core/core.constants';
+import { VALUE_LABEL_BOX_WIDTH, VALUE_LABEL_GAP } from '../slider.constants';
+import { styles } from '../slider.styles';
+import type { ValueLabelProps } from '../slider.types';
+
+/** Material's value bubble over the dragged thumb. */
+export const ValueLabel = ({ x, text, design, theme, slots }: ValueLabelProps) => (
+
+
+ {text}
+
+
+);
diff --git a/src/slider/hooks/useHaloStyle.ts b/src/slider/hooks/useHaloStyle.ts
new file mode 100644
index 0000000..74a4a23
--- /dev/null
+++ b/src/slider/hooks/useHaloStyle.ts
@@ -0,0 +1,23 @@
+import { useEffect, useMemo } from 'react';
+
+import { useAnimatedValue } from '../../core/hooks/useAnimatedValue';
+import { timing } from '../../core/utils/animation.utils';
+import { HALO_LEVEL, HALO_OPACITY, HALO_SCALE } from '../slider.constants';
+import type { HaloLevel, HaloStyle } from '../slider.types';
+
+const LEVELS = Object.values(HALO_LEVEL);
+
+/** The halo's fade and size, animating between none, hover and pressed. */
+export const useHaloStyle = (level: HaloLevel, duration: number): HaloStyle => {
+ const progress = useAnimatedValue(level);
+ useEffect(() => {
+ timing(progress, level, { duration }).start();
+ }, [duration, level, progress]);
+ return useMemo(
+ () => ({
+ opacity: progress.interpolate({ inputRange: LEVELS, outputRange: HALO_OPACITY }),
+ transform: [{ scale: progress.interpolate({ inputRange: LEVELS, outputRange: HALO_SCALE }) }],
+ }),
+ [progress]
+ );
+};
diff --git a/src/slider/hooks/useSlider.ts b/src/slider/hooks/useSlider.ts
new file mode 100644
index 0000000..ac3a3e7
--- /dev/null
+++ b/src/slider/hooks/useSlider.ts
@@ -0,0 +1,178 @@
+import { useRef, useState } from 'react';
+import { I18nManager, type LayoutChangeEvent, type PointerEvent } from 'react-native';
+
+import { useControllableState } from '../../core/hooks/useControllableState';
+import { useTrackPan } from '../../core/hooks/useTrackPan';
+import type { PickerTheme } from '../../core/theme/theme.types';
+import { adjustableProps } from '../../core/utils/adjustable.utils';
+import { toFraction } from '../../core/utils/track.utils';
+import {
+ DEFAULT_SLIDER_MAX,
+ DEFAULT_SLIDER_MIN,
+ DEFAULT_SLIDER_STEP,
+ DEFAULT_THUMB_LABELS,
+ PAGE_FRACTION,
+ SLIDER_DESIGN,
+ SLIDER_VALUE_LABEL,
+} from '../slider.constants';
+import type { SliderProps, SliderRange, SliderThumbState } from '../slider.types';
+import {
+ formatSliderValue,
+ getHaloLevel,
+ getKeyStep,
+ getMarkLabels,
+ getMarks,
+ getThumbOffset,
+ getTrackSegments,
+ moveThumb,
+ nearestMark,
+ nearestThumb,
+ normalizeMarks,
+ sameValues,
+ snapValue,
+ stepToMark,
+ toThumbValues,
+ valueToFraction,
+} from '../slider.utils';
+
+/**
+ * The slider's value, held while dragging and committed on release, and where everything sits on the track.
+ * `onValueChanging` fires on each step of a drag; `onChange` once the finger lifts, or on a key or screen-reader step.
+ */
+export const useSlider = (props: SliderProps, theme: PickerTheme) => {
+ const { min = DEFAULT_SLIDER_MIN, max = DEFAULT_SLIDER_MAX, step = DEFAULT_SLIDER_STEP, disabled = false, showSteps = false } = props;
+ const design = SLIDER_DESIGN[theme.design];
+ const range = !!props.range;
+ const format = props.formatValue ?? ((v: number) => formatSliderValue(v, step));
+ const report = (values: number[]) => (range ? (values as SliderRange) : (values[0] ?? min));
+ const marks = normalizeMarks(props.marks ?? [], min, max);
+ const markValues = marks.map((mark) => mark.value);
+ const onMarks = !!props.snapToMarks && markValues.length > 0;
+ const snap = (value: number) => (onMarks ? nearestMark(value, markValues) : snapValue(value, min, max, step));
+
+ const [committed, commit] = useControllableState(
+ props.value === undefined ? undefined : toThumbValues(props.value, min, max),
+ () => toThumbValues(props.defaultValue ?? (range ? [min, max] : min), min, max),
+ (next) => (props.onChange as ((value: number | SliderRange) => void) | undefined)?.(report(next))
+ );
+ const [drag, setDrag] = useState(null);
+ const [dragging, setDragging] = useState(null);
+ const [width, setWidth] = useState(0);
+ const [hovered, setHovered] = useState(null);
+ const [focused, setFocused] = useState(null);
+ // The pan reads these between renders, so a fast drag never works from a stale value.
+ const live = useRef({ drag: null as number[] | null, thumb: 0 });
+ const values = drag ?? committed;
+
+ // Touches come in screen coordinates; the track runs from its start, which is the right edge in RTL.
+ const fromStart = (fraction: number) => (I18nManager.isRTL ? 1 - fraction : fraction);
+ const valueAt = (fraction: number) => snap(min + fromStart(fraction) * (max - min));
+ const moveTo = (fraction: number) => {
+ const from = live.current.drag ?? committed;
+ const next = moveThumb(from, live.current.thumb, valueAt(fraction));
+ if (sameValues(next, from)) return;
+ live.current.drag = next;
+ setDrag(next);
+ (props.onValueChanging as ((value: number | SliderRange) => void) | undefined)?.(report(next));
+ };
+ const pan = useTrackPan({
+ insetX: design.thumbWidth / 2,
+ onStart: (fraction) => {
+ const thumb = nearestThumb(
+ committed.map((v) => valueToFraction(v, min, max)),
+ fromStart(fraction)
+ );
+ live.current = { drag: null, thumb };
+ setDragging(thumb);
+ moveTo(fraction);
+ },
+ onMove: moveTo,
+ onEnd: () => {
+ const next = live.current.drag;
+ live.current.drag = null;
+ setDrag(null);
+ setDragging(null);
+ if (next && !sameValues(next, committed)) commit(next);
+ },
+ });
+
+ const setThumb = (index: number, value: number) => {
+ const next = moveThumb(committed, index, snap(value));
+ if (!sameValues(next, committed)) commit(next);
+ };
+ const keyStep = getKeyStep(min, max, step);
+ const pageStep = Math.max(keyStep, (max - min) * PAGE_FRACTION);
+ const stepFrom = (value: number, direction: 1 | -1, by: number) =>
+ onMarks ? stepToMark(value, markValues, direction) : value + direction * by;
+ const valueLabel = props.valueLabel ?? design.valueLabel;
+ const thumbLabels = props.range ? (props.thumbLabels ?? DEFAULT_THUMB_LABELS) : [];
+
+ const inset = design.thumbWidth / 2;
+ const offsets = values.map((v) => getThumbOffset(valueToFraction(v, min, max), width, inset));
+ const cut = design.gap > 0 ? design.gap + design.thumbWidth / 2 : 0;
+ const halo = {
+ enabled: design.halo > 0 && !disabled,
+ dragging,
+ hovered,
+ focused,
+ };
+ const thumbs: SliderThumbState[] = values.map((value, i) => ({
+ key: String(i),
+ x: offsets[i] ?? 0,
+ width: dragging === i ? design.pressedThumbWidth : design.thumbWidth,
+ dragging: dragging === i,
+ showValueLabel: valueLabel === SLIDER_VALUE_LABEL.always || (valueLabel === SLIDER_VALUE_LABEL.drag && dragging === i),
+ halo: getHaloLevel(i, halo),
+ valueText: format(value),
+ adjustable: adjustableProps({
+ label: thumbLabels[i] ?? props.accessibilityLabel,
+ valueText: format(value),
+ disabled,
+ onStep: (direction) => setThumb(i, stepFrom(value, direction, keyStep)),
+ onPage: (direction) => setThumb(i, stepFrom(value, direction, pageStep)),
+ onMin: () => setThumb(i, min),
+ onMax: () => setThumb(i, max),
+ }),
+ onFocus: () => setFocused(i),
+ onBlur: () => setFocused(null),
+ }));
+
+ return {
+ design,
+ thumbs,
+ segments: getTrackSegments(width, offsets, range, cut),
+ marks: getMarks({
+ min,
+ max,
+ step,
+ width,
+ inset,
+ thumbs: offsets,
+ range,
+ cut,
+ showSteps,
+ stopIndicators: design.stopIndicators,
+ custom: markValues.map((v) => valueToFraction(v, min, max)),
+ }),
+ markLabels: getMarkLabels(marks, { min, max, width, inset, thumbs: offsets, range }),
+ reserveValueLabel: valueLabel === SLIDER_VALUE_LABEL.always,
+ panHandlers: disabled ? undefined : pan.panHandlers,
+ // The thumb a click would grab: offsetX is on the track, as the thumbs let the pointer through.
+ hoverHandlers: design.halo
+ ? {
+ onPointerMove: (e: PointerEvent) =>
+ setHovered(
+ nearestThumb(
+ values.map((v) => valueToFraction(v, min, max)),
+ fromStart(toFraction(e.nativeEvent.offsetX - inset, width - 2 * inset))
+ )
+ ),
+ onPointerLeave: () => setHovered(null),
+ }
+ : undefined,
+ onLayout: (e: LayoutChangeEvent) => {
+ pan.onLayout(e);
+ setWidth(e.nativeEvent.layout.width);
+ },
+ };
+};
diff --git a/src/slider/index.ts b/src/slider/index.ts
new file mode 100644
index 0000000..8728dd0
--- /dev/null
+++ b/src/slider/index.ts
@@ -0,0 +1,10 @@
+export { Slider } from './Slider';
+export type {
+ RangeSliderProps,
+ SingleSliderProps,
+ SliderMarkInput,
+ SliderProps,
+ SliderRange,
+ SliderSlot,
+ SliderValueLabel,
+} from './slider.types';
diff --git a/src/slider/slider.constants.ts b/src/slider/slider.constants.ts
new file mode 100644
index 0000000..66a7b5c
--- /dev/null
+++ b/src/slider/slider.constants.ts
@@ -0,0 +1,98 @@
+import type { Design } from '../core/theme/theme.types';
+import type { SliderDesign } from './slider.types';
+
+export const SLIDER_THUMB = {
+ /** iOS: a white knob with a shadow */
+ knob: 'knob',
+ /** Material 3: a slim bar, with a gap to the track either side */
+ bar: 'bar',
+ /** The web: a solid dot in the primary colour */
+ dot: 'dot',
+} as const;
+
+/** When the value bubble shows over a thumb. */
+export const SLIDER_VALUE_LABEL = {
+ never: 'never',
+ /** While the thumb is dragged */
+ drag: 'drag',
+ always: 'always',
+} as const;
+
+/** iOS's UISlider, Material 3's slider and a browser's range input. */
+export const SLIDER_DESIGN: Record = {
+ ios: {
+ thumb: SLIDER_THUMB.knob,
+ thumbWidth: 28,
+ thumbHeight: 28,
+ pressedThumbWidth: 28,
+ trackHeight: 4,
+ innerRadius: 2,
+ gap: 0,
+ inactiveTrack: 'border',
+ markSize: 2,
+ stopIndicators: false,
+ valueLabel: SLIDER_VALUE_LABEL.never,
+ halo: 0,
+ },
+ material: {
+ thumb: SLIDER_THUMB.bar,
+ thumbWidth: 4,
+ thumbHeight: 44,
+ pressedThumbWidth: 2,
+ trackHeight: 16,
+ innerRadius: 2,
+ gap: 6,
+ inactiveTrack: 'primaryMuted',
+ markSize: 4,
+ stopIndicators: true,
+ valueLabel: SLIDER_VALUE_LABEL.drag,
+ halo: 0,
+ },
+ web: {
+ thumb: SLIDER_THUMB.dot,
+ thumbWidth: 18,
+ thumbHeight: 18,
+ pressedThumbWidth: 18,
+ trackHeight: 6,
+ innerRadius: 3,
+ gap: 0,
+ inactiveTrack: 'border',
+ markSize: 2,
+ stopIndicators: false,
+ valueLabel: SLIDER_VALUE_LABEL.never,
+ halo: 40,
+ },
+};
+
+/** How strongly a thumb's halo shows: on hover or keyboard focus, and stronger, grown in, while pressed. */
+export const HALO_LEVEL = {
+ none: 0,
+ hover: 1,
+ pressed: 2,
+} as const;
+/** The halo's opacity (of the primary colour) and size at each level. */
+export const HALO_OPACITY = [0, 0.12, 0.24];
+export const HALO_SCALE = [0.5, 0.8, 1];
+
+export const DEFAULT_SLIDER_MIN = 0;
+export const DEFAULT_SLIDER_MAX = 100;
+export const DEFAULT_SLIDER_STEP = 1;
+/** A continuous slider (`step` 0) moves this fraction of its range per arrow key or screen-reader step. */
+export const CONTINUOUS_STEP_FRACTION = 0.01;
+/** Page Up and Page Down move this fraction of the range. */
+export const PAGE_FRACTION = 0.1;
+/** A continuous value is shown with this many decimals. */
+export const CONTINUOUS_DECIMALS = 2;
+/** Step marks closer than this many px would run together, so they're left out. */
+export const MIN_MARK_SPACING = 8;
+export const DEFAULT_THUMB_LABELS: [string, string] = ['Minimum', 'Maximum'];
+export const SLIDER_DISABLED_OPACITY = 0.38;
+/** The value bubble's box, centred over the thumb; the bubble inside is as wide as its text. */
+export const VALUE_LABEL_BOX_WIDTH = 120;
+export const VALUE_LABEL_GAP = 4;
+/** Room kept above the track for a bubble that always shows. */
+export const VALUE_LABEL_SPACE = 32;
+/** A mark's label box, centred under its mark; the text inside is as wide as it needs. */
+export const MARK_LABEL_BOX_WIDTH = 80;
+/** Pulls the mark labels up into the touch area's empty space under the track. */
+export const MARK_LABEL_OFFSET = -10;
diff --git a/src/slider/slider.styles.ts b/src/slider/slider.styles.ts
new file mode 100644
index 0000000..3225a2f
--- /dev/null
+++ b/src/slider/slider.styles.ts
@@ -0,0 +1,29 @@
+import { StyleSheet } from 'react-native';
+
+import { KNOB_COLOR, KNOB_SHADOW, TOUCH_TARGET } from '../core/core.constants';
+import {
+ MARK_LABEL_BOX_WIDTH,
+ MARK_LABEL_OFFSET,
+ SLIDER_DISABLED_OPACITY,
+ VALUE_LABEL_BOX_WIDTH,
+ VALUE_LABEL_SPACE,
+} from './slider.constants';
+
+export const styles = StyleSheet.create({
+ row: { flexDirection: 'row', alignItems: 'center' },
+ disabled: { opacity: SLIDER_DISABLED_OPACITY },
+ noCursor: { cursor: 'auto' },
+ column: { flex: 1 },
+ reserveValueLabel: { paddingTop: VALUE_LABEL_SPACE },
+ track: { height: TOUCH_TARGET, cursor: 'pointer' },
+ markLabels: { marginTop: MARK_LABEL_OFFSET, minHeight: 20 },
+ markLabelBox: { position: 'absolute', width: MARK_LABEL_BOX_WIDTH, alignItems: 'center' },
+ rail: { position: 'absolute', start: 0, end: 0 },
+ segment: { position: 'absolute', top: 0, bottom: 0 },
+ mark: { position: 'absolute' },
+ thumb: { position: 'absolute' },
+ halo: { position: 'absolute' },
+ knob: { backgroundColor: KNOB_COLOR, boxShadow: KNOB_SHADOW },
+ valueLabelBox: { position: 'absolute', width: VALUE_LABEL_BOX_WIDTH, alignItems: 'center' },
+ valueLabel: { paddingHorizontal: 12, paddingVertical: 6 },
+});
diff --git a/src/slider/slider.types.ts b/src/slider/slider.types.ts
new file mode 100644
index 0000000..0e80b9c
--- /dev/null
+++ b/src/slider/slider.types.ts
@@ -0,0 +1,205 @@
+import type React from 'react';
+import type { Animated, StyleProp, ViewStyle } from 'react-native';
+
+import type { PickerTheme, SlotStyles, ThemableProps } from '../core';
+import type { ValueOf } from '../core/core.types';
+import type { adjustableProps } from '../core/utils/adjustable.utils';
+import type { HALO_LEVEL, SLIDER_THUMB, SLIDER_VALUE_LABEL } from './slider.constants';
+
+export type SliderThumbKind = ValueOf;
+
+/** When the value bubble shows: `never`, `drag` (while dragging) or `always`. */
+export type SliderValueLabel = ValueOf;
+
+/** A mark on the track, with an optional label under it. */
+export interface SliderMarkInput {
+ value: number;
+ label?: string;
+}
+
+/** How a design draws the slider. */
+export interface SliderDesign {
+ thumb: SliderThumbKind;
+ thumbWidth: number;
+ thumbHeight: number;
+ /** The thumb's width while dragged: Material's handle narrows */
+ pressedThumbWidth: number;
+ trackHeight: number;
+ /** The track's corners beside a thumb; its outer ends are fully round */
+ innerRadius: number;
+ /** Space between the thumb and the track on either side */
+ gap: number;
+ inactiveTrack: keyof PickerTheme['colors'];
+ markSize: number;
+ /** Dots at the track's inactive ends, as Material 3 draws */
+ stopIndicators: boolean;
+ valueLabel: SliderValueLabel;
+ /** A ring of colour around the thumb on hover, focus and press, as browsers draw; 0 for none */
+ halo: number;
+}
+
+export type HaloLevel = ValueOf;
+
+/** The halo's animated fade and size. Named so the build can emit the hook returning it. */
+export interface HaloStyle {
+ opacity: Animated.AnimatedInterpolation;
+ transform: [{ scale: Animated.AnimatedInterpolation }];
+}
+
+/** Which thumb is dragged, hovered and focused, by index. */
+export interface HaloState {
+ /** The design draws a halo and the slider isn't disabled */
+ enabled: boolean;
+ dragging: number | null;
+ hovered: number | null;
+ focused: number | null;
+}
+
+export type SliderSlot = 'container' | 'track' | 'activeTrack' | 'thumb' | 'mark' | 'markLabel' | 'valueLabel' | 'valueLabelText';
+
+/** A range slider's value: its start and end. */
+export type SliderRange = [number, number];
+
+interface SliderBaseProps extends ThemableProps {
+ /** Default: 0 */
+ min?: number;
+ /** Default: 100 */
+ max?: number;
+ /** The value moves in steps this big; 0 for any value. Default: 1 */
+ step?: number;
+ disabled?: boolean;
+ /** A dot at each step, when they're far enough apart to tell apart. Default: false */
+ showSteps?: boolean;
+ /** Marks on the track at these values; give one a `label` to write it under the track */
+ marks?: ReadonlyArray;
+ /** Only the `marks` can be picked: dragging and keys jump between them. Default: false */
+ snapToMarks?: boolean;
+ /** A bubble with the value over the thumb: 'never', 'drag' (while dragging) or 'always'. Default: 'drag' in the Material design, else 'never' */
+ valueLabel?: SliderValueLabel;
+ /** The value as text, for the bubble and screen readers. Default: the number, with the step's decimals */
+ formatValue?: (value: number) => string;
+ /** Before the track, like iOS's quiet speaker beside the volume slider */
+ minimumIcon?: React.ReactNode;
+ /** After the track */
+ maximumIcon?: React.ReactNode;
+ accessibilityLabel?: string;
+ style?: StyleProp;
+ styles?: SlotStyles;
+ testID?: string;
+}
+
+export interface SingleSliderProps extends SliderBaseProps {
+ range?: false;
+ value?: number;
+ /** Default: `min` */
+ defaultValue?: number;
+ /** Once the user lets go on a new value, or steps it with a key or a screen reader */
+ onChange?: (value: number) => void;
+ /** On every step while dragging, for haptics and live previews */
+ onValueChanging?: (value: number) => void;
+}
+
+export interface RangeSliderProps extends SliderBaseProps {
+ /** Two thumbs: the value is `[start, end]` */
+ range: true;
+ value?: SliderRange;
+ /** Default: `[min, max]` */
+ defaultValue?: SliderRange;
+ onChange?: (value: SliderRange) => void;
+ onValueChanging?: (value: SliderRange) => void;
+ /** What screen readers call the two thumbs. Default: ['Minimum', 'Maximum'] */
+ thumbLabels?: [string, string];
+}
+
+export type SliderProps = SingleSliderProps | RangeSliderProps;
+
+/** A piece of the track between its ends and the thumbs, in px from the start. */
+export interface TrackSegment {
+ key: string;
+ start: number;
+ end: number;
+ active: boolean;
+ /** Starts at the track's start rather than beside a thumb */
+ outerStart: boolean;
+ outerEnd: boolean;
+}
+
+export interface SliderMark {
+ key: string;
+ /** px from the start */
+ x: number;
+ active: boolean;
+}
+
+export interface SliderMarkLabel extends SliderMark {
+ label: string;
+}
+
+export interface MarkLabelsProps {
+ labels: SliderMarkLabel[];
+ theme: PickerTheme;
+ slots?: SlotStyles;
+}
+
+export interface MarkOptions {
+ min: number;
+ max: number;
+ step: number;
+ width: number;
+ inset: number;
+ /** The thumbs' centres, px from the start */
+ thumbs: number[];
+ range: boolean;
+ /** Marks this close to a thumb hide in its gap */
+ cut: number;
+ showSteps: boolean;
+ stopIndicators: boolean;
+ /** The `marks` prop's fractions; they replace the steps and stops */
+ custom: number[];
+}
+
+export interface SliderThumbState {
+ key: string;
+ /** px from the start */
+ x: number;
+ width: number;
+ dragging: boolean;
+ showValueLabel: boolean;
+ halo: HaloLevel;
+ valueText: string;
+ adjustable: ReturnType;
+ onFocus: () => void;
+ onBlur: () => void;
+}
+
+export interface SliderThumbProps {
+ thumb: SliderThumbState;
+ design: SliderDesign;
+ theme: PickerTheme;
+ slots?: SlotStyles;
+ testID?: string;
+}
+
+export interface SliderHaloProps {
+ x: number;
+ level: HaloLevel;
+ size: number;
+ theme: PickerTheme;
+ testID?: string;
+}
+
+export interface SliderTrackProps {
+ segments: TrackSegment[];
+ marks: SliderMark[];
+ design: SliderDesign;
+ theme: PickerTheme;
+ slots?: SlotStyles;
+}
+
+export interface ValueLabelProps {
+ x: number;
+ text: string;
+ design: SliderDesign;
+ theme: PickerTheme;
+ slots?: SlotStyles;
+}
diff --git a/src/slider/slider.utils.test.ts b/src/slider/slider.utils.test.ts
new file mode 100644
index 0000000..84567e1
--- /dev/null
+++ b/src/slider/slider.utils.test.ts
@@ -0,0 +1,193 @@
+import {
+ formatSliderValue,
+ fractionToValue,
+ getHaloLevel,
+ getKeyStep,
+ getMarkLabels,
+ getMarks,
+ getThumbOffset,
+ getTrackSegments,
+ moveThumb,
+ nearestMark,
+ nearestThumb,
+ normalizeMarks,
+ sameValues,
+ snapValue,
+ stepToMark,
+ toThumbValues,
+ valueToFraction,
+} from './slider.utils';
+
+describe('snapValue', () => {
+ it('snaps to the nearest step from min and stays in range', () => {
+ expect(snapValue(7, 0, 100, 5)).toBe(5);
+ expect(snapValue(8, 0, 100, 5)).toBe(10);
+ expect(snapValue(3, 1, 11, 2)).toBe(3);
+ expect(snapValue(120, 0, 100, 5)).toBe(100);
+ expect(snapValue(-3, 0, 100, 5)).toBe(0);
+ });
+
+ it('keeps decimal steps free of float noise, and continuous values as they are', () => {
+ expect(snapValue(0.30000000000000004, 0, 1, 0.1)).toBe(0.3);
+ expect(snapValue(0.123, 0, 1, 0)).toBe(0.12);
+ });
+});
+
+describe('fractions', () => {
+ it('maps values to fractions and back, snapped', () => {
+ expect(valueToFraction(25, 0, 100)).toBe(0.25);
+ expect(valueToFraction(5, 5, 5)).toBe(0);
+ expect(fractionToValue(0.26, 0, 100, 5)).toBe(25);
+ expect(fractionToValue(1, -50, 50, 1)).toBe(50);
+ });
+
+ it('places a thumb inset from the ends', () => {
+ expect(getThumbOffset(0, 200, 14)).toBe(14);
+ expect(getThumbOffset(1, 200, 14)).toBe(186);
+ expect(getThumbOffset(0.5, 200, 14)).toBe(100);
+ });
+
+ it('steps a key by one step, or a hundredth of a continuous range', () => {
+ expect(getKeyStep(0, 100, 5)).toBe(5);
+ expect(getKeyStep(0, 200, 0)).toBe(2);
+ });
+});
+
+describe('thumbs', () => {
+ it('grabs the nearest thumb, and of stacked ones the one on the touch side', () => {
+ expect(nearestThumb([0.2, 0.8], 0.3)).toBe(0);
+ expect(nearestThumb([0.2, 0.8], 0.7)).toBe(1);
+ expect(nearestThumb([0.5, 0.5], 0.4)).toBe(0);
+ expect(nearestThumb([0.5, 0.5], 0.6)).toBe(1);
+ expect(nearestThumb([1, 1], 1)).toBe(1);
+ });
+
+ it('keeps a range in order as a thumb moves', () => {
+ expect(moveThumb([20, 60], 0, 80)).toEqual([60, 60]);
+ expect(moveThumb([20, 60], 1, 10)).toEqual([20, 20]);
+ expect(moveThumb([20, 60], 1, 70)).toEqual([20, 70]);
+ expect(moveThumb([20], 0, 90)).toEqual([90]);
+ });
+
+ it('reads a value or range in order and in range', () => {
+ expect(toThumbValues(150, 0, 100)).toEqual([100]);
+ expect(toThumbValues([80, 10], 0, 100)).toEqual([10, 80]);
+ expect(sameValues([1, 2], [1, 2])).toBe(true);
+ expect(sameValues([1, 2], [1, 3])).toBe(false);
+ });
+
+ it('formats with the step’s decimals', () => {
+ expect(formatSliderValue(40, 1)).toBe('40');
+ expect(formatSliderValue(0.25, 0.05)).toBe('0.25');
+ expect(formatSliderValue(0.3333, 0)).toBe('0.33');
+ });
+});
+
+describe('getTrackSegments', () => {
+ it('is active from the start to a single thumb', () => {
+ expect(getTrackSegments(200, [50], false, 0)).toEqual([
+ { key: '0', start: 0, end: 50, active: true, outerStart: true, outerEnd: false },
+ { key: '1', start: 50, end: 200, active: false, outerStart: false, outerEnd: true },
+ ]);
+ });
+
+ it('is active between a range’s thumbs, cut back around each for a gap', () => {
+ const segments = getTrackSegments(200, [50, 150], true, 8);
+ expect(segments.map((s) => [s.start, s.end, s.active])).toEqual([
+ [0, 42, false],
+ [58, 142, true],
+ [158, 200, false],
+ ]);
+ });
+
+ it('drops a piece the gap swallows', () => {
+ expect(getTrackSegments(200, [2], false, 8).map((s) => s.key)).toEqual(['1']);
+ });
+});
+
+describe('getMarks', () => {
+ const base = { min: 0, max: 100, width: 220, inset: 10, range: false, cut: 0, stopIndicators: false, custom: [] };
+
+ it('puts a mark on every step, active up to the thumb', () => {
+ const marks = getMarks({ ...base, step: 25, thumbs: [110], showSteps: true });
+ expect(marks.map((m) => [m.x, m.active])).toEqual([
+ [10, true],
+ [60, true],
+ [160, false],
+ [210, false],
+ ]);
+ });
+
+ it('leaves out steps that would run together', () => {
+ expect(getMarks({ ...base, step: 1, thumbs: [110], showSteps: true })).toEqual([]);
+ });
+
+ it('draws the marks prop’s marks instead, active ones too', () => {
+ const marks = getMarks({ ...base, step: 1, thumbs: [110], showSteps: false, stopIndicators: true, custom: [0, 0.2, 0.37, 1] });
+ expect(marks.map((m) => [m.x, m.active])).toEqual([
+ [10, true],
+ [50, true],
+ [84, true],
+ [210, false],
+ ]);
+ });
+
+ it('draws Material’s stop indicators at the inactive ends only, none in a thumb’s gap', () => {
+ const stops = { ...base, step: 1, showSteps: false, stopIndicators: true, cut: 8 };
+ expect(getMarks({ ...stops, thumbs: [110] }).map((m) => m.x)).toEqual([210]);
+ expect(getMarks({ ...stops, thumbs: [60, 160], range: true }).map((m) => m.x)).toEqual([10, 210]);
+ expect(getMarks({ ...stops, thumbs: [205] })).toEqual([]);
+ });
+});
+
+describe('getHaloLevel', () => {
+ const idle = { enabled: true, dragging: null, hovered: null, focused: null };
+
+ it('is pressed while dragged, faint while hovered or focused, else none', () => {
+ expect(getHaloLevel(0, idle)).toBe(0);
+ expect(getHaloLevel(1, { ...idle, hovered: 1 })).toBe(1);
+ expect(getHaloLevel(0, { ...idle, hovered: 1 })).toBe(0);
+ expect(getHaloLevel(0, { ...idle, focused: 0 })).toBe(1);
+ expect(getHaloLevel(0, { ...idle, hovered: 0, dragging: 0 })).toBe(2);
+ });
+
+ it('shows none without a halo or while disabled', () => {
+ expect(getHaloLevel(0, { ...idle, enabled: false, dragging: 0 })).toBe(0);
+ });
+});
+
+describe('marks', () => {
+ it('reads numbers and objects, in range and in order', () => {
+ expect(normalizeMarks([100, { value: 20, label: '20°C' }, 0, 150], 0, 100)).toEqual([
+ { value: 0 },
+ { value: 20, label: '20°C' },
+ { value: 100 },
+ ]);
+ });
+
+ it('snaps to the nearest mark and steps from mark to mark', () => {
+ const marks = [0, 20, 37, 100];
+ expect(nearestMark(30, marks)).toBe(37);
+ expect(nearestMark(60, marks)).toBe(37);
+ expect(nearestMark(5, [])).toBe(5);
+ expect(stepToMark(20, marks, 1)).toBe(37);
+ expect(stepToMark(20, marks, -1)).toBe(0);
+ expect(stepToMark(25, marks, -1)).toBe(20);
+ expect(stepToMark(100, marks, 1)).toBe(100);
+ });
+
+ it('places the labelled marks, active inside the filled track', () => {
+ const labels = getMarkLabels([{ value: 0, label: '0°C' }, { value: 50 }, { value: 100, label: '100°C' }], {
+ min: 0,
+ max: 100,
+ width: 220,
+ inset: 10,
+ thumbs: [110],
+ range: false,
+ });
+ expect(labels).toEqual([
+ { key: '0', x: 10, label: '0°C', active: true },
+ { key: '100', x: 210, label: '100°C', active: false },
+ ]);
+ });
+});
diff --git a/src/slider/slider.utils.ts b/src/slider/slider.utils.ts
new file mode 100644
index 0000000..e337965
--- /dev/null
+++ b/src/slider/slider.utils.ts
@@ -0,0 +1,153 @@
+import { clamp } from '../core/utils/track.utils';
+import { CONTINUOUS_DECIMALS, CONTINUOUS_STEP_FRACTION, HALO_LEVEL, MIN_MARK_SPACING } from './slider.constants';
+import type {
+ HaloLevel,
+ HaloState,
+ MarkOptions,
+ SliderMark,
+ SliderMarkInput,
+ SliderMarkLabel,
+ SliderRange,
+ TrackSegment,
+} from './slider.types';
+
+const decimalsOf = (step: number) => (step > 0 ? (String(step).split('.')[1]?.length ?? 0) : CONTINUOUS_DECIMALS);
+
+/** Rounds away float noise, so 0.1 + 0.2 lands on 0.3. */
+const round = (n: number, decimals: number) => Number(n.toFixed(decimals));
+
+/** The value in range, on the nearest step counted from `min`. */
+export const snapValue = (value: number, min: number, max: number, step: number) => {
+ const snapped = step > 0 ? min + Math.round((value - min) / step) * step : value;
+ return clamp(round(snapped, decimalsOf(step)), min, max);
+};
+
+export const valueToFraction = (value: number, min: number, max: number) => (max > min ? clamp((value - min) / (max - min), 0, 1) : 0);
+
+export const fractionToValue = (fraction: number, min: number, max: number, step: number) =>
+ snapValue(min + fraction * (max - min), min, max, step);
+
+/** How far an arrow key or a screen reader's step moves: one step, or a hundredth of a continuous range. */
+export const getKeyStep = (min: number, max: number, step: number) => (step > 0 ? step : (max - min) * CONTINUOUS_STEP_FRACTION);
+
+/** A thumb's centre, px from the track's start: `inset` in from either end, where it stops. */
+export const getThumbOffset = (fraction: number, width: number, inset: number) => inset + fraction * Math.max(0, width - 2 * inset);
+
+/** The thumb a touch at `fraction` grabs: the nearest, or on a tie (thumbs stacked) the one on the touch's side. */
+export const nearestThumb = (fractions: readonly number[], fraction: number) => {
+ let best = 0;
+ fractions.forEach((f, i) => {
+ const d = Math.abs(f - fraction);
+ const bestD = Math.abs((fractions[best] ?? 0) - fraction);
+ if (d < bestD || (d === bestD && fraction >= f)) best = i;
+ });
+ return best;
+};
+
+/** Moves one thumb, kept between its neighbours so a range's start never passes its end. */
+export const moveThumb = (values: readonly number[], index: number, next: number) =>
+ values.map((v, i) => (i === index ? clamp(next, values[index - 1] ?? -Infinity, values[index + 1] ?? Infinity) : v));
+
+export const sameValues = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
+
+/** The thumbs' values from a single value or a range, kept in range and in order. */
+export const toThumbValues = (value: number | SliderRange, min: number, max: number) =>
+ (Array.isArray(value) ? [...value].sort((a, b) => a - b) : [value]).map((v) => clamp(v, min, max));
+
+export const formatSliderValue = (value: number, step: number) => String(round(value, decimalsOf(step)));
+
+/**
+ * The track cut at the thumbs, `cut` px either side of each (Material's gap). A single slider is active from its start
+ * to the thumb; a range between its thumbs.
+ */
+export const getTrackSegments = (width: number, thumbs: readonly number[], range: boolean, cut: number): TrackSegment[] => {
+ const edges = [0, ...thumbs, width];
+ const last = edges.length - 2;
+ return edges
+ .slice(1)
+ .map((end, i) => ({
+ key: String(i),
+ start: (edges[i] ?? 0) + (i > 0 ? cut : 0),
+ end: end - (i < last ? cut : 0),
+ active: range ? i === 1 : i === 0,
+ outerStart: i === 0,
+ outerEnd: i === last,
+ }))
+ .filter((segment) => segment.end > segment.start);
+};
+
+const isActiveAt = (x: number, thumbs: readonly number[], range: boolean) =>
+ range ? x >= (thumbs[0] ?? 0) && x <= (thumbs[1] ?? 0) : x <= (thumbs[0] ?? 0);
+
+/**
+ * Dots on the track: one per step with `showSteps` (unless they'd run together), else Material's stop indicators at
+ * the inactive ends. None in a thumb's gap.
+ */
+export const getMarks = ({
+ min,
+ max,
+ step,
+ width,
+ inset,
+ thumbs,
+ range,
+ cut,
+ showSteps,
+ stopIndicators,
+ custom,
+}: MarkOptions): SliderMark[] => {
+ const count = step > 0 ? Math.floor((max - min) / step) : 0;
+ const steps = custom.length > 0 || (showSteps && count > 0 && (width - 2 * inset) / count >= MIN_MARK_SPACING);
+ const stepFractions = () => Array.from({ length: count + 1 }, (_, i) => (i * step) / (max - min));
+ const fractions = custom.length > 0 ? custom : steps ? stepFractions() : stopIndicators ? [0, 1] : [];
+ return fractions
+ .map((f) => {
+ const x = getThumbOffset(f, width, inset);
+ return {
+ key: String(f),
+ x,
+ active: isActiveAt(x, thumbs, range),
+ };
+ })
+ .filter((mark) => (steps || !mark.active) && thumbs.every((t) => Math.abs(t - mark.x) > cut));
+};
+
+/** A thumb's halo: pressed while dragged, faint while hovered or focused, none without a halo or while disabled. */
+export const getHaloLevel = (index: number, { enabled, dragging, hovered, focused }: HaloState): HaloLevel => {
+ if (!enabled) return HALO_LEVEL.none;
+ if (dragging === index) return HALO_LEVEL.pressed;
+ return hovered === index || focused === index ? HALO_LEVEL.hover : HALO_LEVEL.none;
+};
+
+/** The `marks` prop as `{ value, label }`, in range and in order. */
+export const normalizeMarks = (marks: ReadonlyArray, min: number, max: number) =>
+ marks
+ .map((mark) => (typeof mark === 'number' ? { value: mark } : mark))
+ .filter((mark) => mark.value >= min && mark.value <= max)
+ .sort((a, b) => a.value - b.value);
+
+/** The mark nearest `value`, or `value` itself without marks. */
+export const nearestMark = (value: number, marks: readonly number[]) =>
+ marks.reduce((best, mark) => (Math.abs(mark - value) < Math.abs(best - value) ? mark : best), marks[0] ?? value);
+
+/** The next mark up (1) or down (-1) from `value`, or `value` at the last one. */
+export const stepToMark = (value: number, marks: readonly number[], direction: 1 | -1) =>
+ (direction > 0 ? marks.find((mark) => mark > value) : [...marks].reverse().find((mark) => mark < value)) ?? value;
+
+/** The marks that have a label, placed like the thumbs; a label is active inside the filled part of the track. */
+export const getMarkLabels = (
+ marks: readonly SliderMarkInput[],
+ { min, max, width, inset, thumbs, range }: Pick
+): SliderMarkLabel[] =>
+ marks.flatMap(({ value, label }) => {
+ if (!label) return [];
+ const x = getThumbOffset((value - min) / (max - min), width, inset);
+ return [
+ {
+ key: String(value),
+ x,
+ label,
+ active: isActiveAt(x, thumbs, range),
+ },
+ ];
+ });
diff --git a/website/docs/api/reference.mdx b/website/docs/api/reference.mdx
index ba39cd2..653bc37 100644
--- a/website/docs/api/reference.mdx
+++ b/website/docs/api/reference.mdx
@@ -29,6 +29,7 @@ Every export from `@elik56a/react-native-pickers`, grouped. Each name links to i
| [`HorizontalList`](../components/horizontal-list) | Segmented control or chips that scroll sideways |
| [`EmojiPicker`](../components/emoji-picker) | Emoji keyboard |
| [`ColorPicker`](../components/color-picker) | iOS's tabs, Google's swatch circles or the browser's spectrum; hex with opacity |
+| [`Slider`](../components/slider) | iOS's knob, Material 3's handle or a range input; one value or a range |
| [`BottomSheet`](../components/bottom-sheet) | Animated sheet |
## Sheets
@@ -174,6 +175,8 @@ interface ThemableProps { theme?: DeepPartial }
| `display` | `'wheel'` iOS · `'input'` Material · `'list'` web | DurationPicker |
| `display` | `'wheel'` iOS · `'grid'` Material, web | MonthPicker |
| `display` | `'tabs'` iOS · `'swatches'` Material · `'spectrum'` web | ColorPicker |
+| `min` / `max` / `step` | `0` / `100` / `1` | Slider |
+| `valueLabel` | `'drag'` Material · `'never'` iOS, web | Slider |
| `display` / `timeDisplay` | `'wheel'` / `'wheel'` iOS · `'calendar'` / `'input'` Material · `'calendar'` / `'list'` web | DateTimePicker |
| `timePosition` | `'beside'` web · `'below'` other | DateTimePicker |
| `dayHeight` | `44` | Calendar |
diff --git a/website/docs/components/slider.mdx b/website/docs/components/slider.mdx
new file mode 100644
index 0000000..3fd69b6
--- /dev/null
+++ b/website/docs/components/slider.mdx
@@ -0,0 +1,253 @@
+---
+title: React Native Slider
+description: "A React Native slider that looks native everywhere: iOS's knob, Material 3's handle with a value bubble, or a browser's range input. Range sliders with two thumbs, steps, labelled marks, snapping to marks, icons, keyboard and screen-reader support. Zero native code."
+---
+
+import PropPlayground from '@site/src/components/PropPlayground';
+import { PLAYGROUNDS } from '@site/src/catalog/playgrounds';
+
+# Slider
+
+`Slider` picks a number by dragging along a track, drawn the platform's way:
+
+- **iOS**: a thin track and a white knob with a soft shadow, like `UISlider`.
+- **Material**: Material 3's thick track with a slim handle, a gap either side of it, stop dots at the ends and a value bubble while dragging.
+- **Web**: a browser's range input, with a dot in the primary colour, a pointer cursor, and a halo around the thumb on hover and focus that grows while you press and drag.
+
+Tap anywhere on the track to jump there, or drag. Pass `range` for two thumbs.
+
+```tsx
+import { Slider } from '@elik56a/react-native-pickers';
+```
+
+```tsx live
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+import { Slider } from '@elik56a/react-native-pickers';
+
+export default function Example() {
+ const [volume, setVolume] = useState(40);
+ return (
+
+ 🔈}
+ maximumIcon={🔊}
+ accessibilityLabel="Volume"
+ />
+ Volume: {volume}
+
+ );
+}
+```
+
+## Try the props
+
+
+
+## `onChange` and `onValueChanging`
+
+Like the wheels, the slider has two callbacks:
+
+| Callback | When | Use it for |
+| --- | --- | --- |
+| `onChange` | Once, when the finger lifts on a new value, or on each arrow key or screen-reader step | Updating state, saving |
+| `onValueChanging` | On **every step** while dragging | Haptics, live previews |
+
+The thumb follows the finger on its own while dragging, so a controlled slider doesn't need `onValueChanging` to move. Use it to preview the value as it changes:
+
+```tsx live
+import { useState } from 'react';
+import { View } from 'react-native';
+import { Slider } from '@elik56a/react-native-pickers';
+
+export default function Example() {
+ const [size, setSize] = useState(24);
+ const [preview, setPreview] = useState(24);
+ return (
+
+
+
+
+ {
+ setSize(next);
+ setPreview(next);
+ }}
+ accessibilityLabel="Dot size"
+ />
+
+ );
+}
+```
+
+## Ranges
+
+`range` gives the slider two thumbs, and its value becomes `[start, end]`. A touch grabs the nearest thumb, and neither can pass the other. Screen readers call them **Minimum** and **Maximum**; translate them with `thumbLabels`.
+
+```tsx live
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+import { Slider } from '@elik56a/react-native-pickers';
+
+export default function Example() {
+ const [price, setPrice] = useState<[number, number]>([120, 480]);
+ return (
+
+
+ ${price[0]} – ${price[1]}
+
+ `$${v}`}
+ accessibilityLabel="Price"
+ />
+
+ );
+}
+```
+
+## Steps and marks
+
+`step` sets how far the value moves (default `1`); `0` lets it take any value. `showSteps` puts a dot on the track at each step, left out when they'd be too close to tell apart. The Material design marks the track's inactive ends with stop dots, as Material 3 does.
+
+```tsx live
+import { useState } from 'react';
+import { Slider } from '@elik56a/react-native-pickers';
+
+export default function Example() {
+ const [rating, setRating] = useState(3);
+ return ;
+}
+```
+
+## Marks with labels
+
+`marks` puts dots on the track at the values you pick; give one a `label` to write it under the track. Labels inside the filled part of the track read stronger.
+
+```tsx live
+import { useState } from 'react';
+import { Slider } from '@elik56a/react-native-pickers';
+
+const marks = [
+ { value: 0, label: '0°C' },
+ { value: 20, label: '20°C' },
+ { value: 37, label: '37°C' },
+ { value: 100, label: '100°C' },
+];
+
+export default function Example() {
+ const [temperature, setTemperature] = useState(20);
+ return ;
+}
+```
+
+### Only the marks
+
+`snapToMarks` restricts the value to the marks: a drag jumps to the nearest one, and arrow keys and screen readers step from mark to mark.
+
+```tsx live
+import { useState } from 'react';
+import { Slider } from '@elik56a/react-native-pickers';
+
+const marks = [
+ { value: 0, label: '0°C' },
+ { value: 20, label: '20°C' },
+ { value: 37, label: '37°C' },
+ { value: 100, label: '100°C' },
+];
+
+export default function Example() {
+ const [temperature, setTemperature] = useState(20);
+ return (
+ `${v}°C`}
+ accessibilityLabel="Temperature"
+ />
+ );
+}
+```
+
+## Value bubble
+
+`valueLabel` sets when a bubble shows the value over the thumb: `'drag'` while dragging (the Material design's default), `'always'`, or `'never'` (the default elsewhere). With `'always'` the slider keeps room above the track for it. `formatValue` sets the bubble's text, which screen readers read too.
+
+```tsx live
+import { useState } from 'react';
+import { Slider } from '@elik56a/react-native-pickers';
+
+export default function Example() {
+ const [opacity, setOpacity] = useState(0.8);
+ return (
+ `${Math.round(v * 100)}%`}
+ value={opacity}
+ onChange={setOpacity}
+ accessibilityLabel="Opacity"
+ />
+ );
+}
+```
+
+## Keyboard and screen readers
+
+Each thumb is an adjustable control: screen readers step it with swipe up and down (iOS) or the volume keys (Android), and read `formatValue`'s text. On the web, Tab focuses a thumb; arrow keys step it, Page Up and Page Down move a tenth of the range, and Home and End go to the ends.
+
+The track's touch area is 44 pt tall however thin it looks, as iOS asks, and a sheet's swipe to close doesn't take over a drag on it.
+
+## Right to left
+
+In an RTL layout the track fills from the right, and dragging follows.
+
+## Styling
+
+`styles` takes these slots: `container`, `track` (every piece of the track), `activeTrack` (the filled part), `thumb`, `mark`, `markLabel`, `valueLabel` and `valueLabelText`. The filled track and Material's handle use the theme's `primary` colour.
+
+```tsx
+
+```
+
+## Props
+
+| Prop | Type | Default | Description |
+| --- | --- | --- | --- |
+| `value` | `number`, or `[number, number]` with `range` | | Controlled value. |
+| `defaultValue` | `number`, or `[number, number]` with `range` | `min`, or `[min, max]` | Uncontrolled starting value. |
+| `onChange` | `(value) => void` | | When the finger lifts on a new value, or on a key or screen-reader step. |
+| `onValueChanging` | `(value) => void` | | On every step while dragging. |
+| `range` | `boolean` | `false` | Two thumbs; the value is `[start, end]`. |
+| `min` | `number` | `0` | |
+| `max` | `number` | `100` | |
+| `step` | `number` | `1` | The value moves in steps this big; `0` for any value. |
+| `showSteps` | `boolean` | `false` | A dot on the track at each step. |
+| `marks` | `(number \| { value, label? })[]` | | Dots at these values, with labels under the track. |
+| `snapToMarks` | `boolean` | `false` | Only the marks can be picked. |
+| `valueLabel` | `'never' \| 'drag' \| 'always'` | `'drag'` in Material, else `'never'` | When a bubble shows the value over the thumb. |
+| `formatValue` | `(value: number) => string` | the number, with the step's decimals | Text for the bubble and screen readers. |
+| `minimumIcon` | `ReactNode` | | Before the track, like a quiet speaker beside a volume slider. |
+| `maximumIcon` | `ReactNode` | | After the track. |
+| `disabled` | `boolean` | `false` | Dims the slider and ignores touches and keys. |
+| `accessibilityLabel` | `string` | | What screen readers call the slider. |
+| `thumbLabels` | `[string, string]` | `['Minimum', 'Maximum']` | With `range`, what screen readers call each thumb. |
+| `design` | `'auto' \| 'ios' \| 'material' \| 'web'` | `'auto'` | |
+| `theme` | `DeepPartial` | | |
+| `style` / `styles` | | | Root style, and the slots above. |
+| `testID` | `string` | | On the root; the track gets `${testID}-track`, the thumbs `${testID}-thumb-0` and `-1`. |
diff --git a/website/docs/guides/designs.mdx b/website/docs/guides/designs.mdx
index 0babab4..c4b46dc 100644
--- a/website/docs/guides/designs.mdx
+++ b/website/docs/guides/designs.mdx
@@ -70,6 +70,7 @@ export default function Example() {
| Month | month and year wheels | a grid of months under the year | a grid of months under the year |
| WeekStrip | chosen day tinted, SUN MON | chosen day filled, S M T | chosen day a filled square, Sun Mon |
| Colour | Grid, Spectrum and Sliders tabs, swatches in a row to scroll | swatch circles, then **Custom** for a saturation square and hue slider | a saturation square, eyedropper, hue slider and hex code, then swatches |
+| Slider | a white knob on a thin track | a slim handle with gaps on a thick track, stop dots, a value bubble while dragging | a solid dot on a medium track, a halo on hover and press |
| HorizontalList | a sliding raised thumb; filled chips | outlined segments and chips, the chosen one filled with a ✓ | a sliding raised thumb; filled chips |
| Search fields | a short gray search bar | a tall pill | an outlined input with a focus ring |
| EmojiPicker | categories at the bottom, the current one on a gray fill, UPPERCASE titles | categories at the bottom, the current one in a tinted pill | categories on top, underlined |
diff --git a/website/docs/guides/styling.mdx b/website/docs/guides/styling.mdx
index eb32376..e50d027 100644
--- a/website/docs/guides/styling.mdx
+++ b/website/docs/guides/styling.mdx
@@ -54,6 +54,7 @@ type SlotStyles = Partial>>;
| [MonthPicker](../components/month-picker#props) | the wheel slots, or the grid's `header` `headerTitle` `arrow` `arrowText` `gridCell` `gridCellText` |
| [WeekStrip](../components/week-strip#props) | the Calendar slots |
| [ColorPicker](../components/color-picker#props) | `container` `swatch` `pad` `slider` `sliderThumb` `sliderLabel` `hexInput` `preview` |
+| [Slider](../components/slider#styling) | `container` `track` `activeTrack` `thumb` `mark` `markLabel` `valueLabel` `valueLabelText` |
| [SelectList](../components/select-list#slots) | `container` `search` `searchInput` `list` `groupHeader` `groupHeaderText` `option` `selectedOption` `optionLabel` `selectedOptionLabel` `optionDescription` `check` `empty` `emptyText` `loading` `loadingText` |
| [SelectPicker](../components/select-picker#slots) | all SelectList slots + `trigger` `triggerText` `placeholder` `label` `chip` `chipText` `chevron` `clear` |
| [HorizontalList](../components/horizontal-list#slots) | `container` `content` `item` `selectedItem` `itemLabel` `selectedItemLabel` `thumb` `arrow` `arrowText` |
diff --git a/website/docs/guides/typescript.mdx b/website/docs/guides/typescript.mdx
index cacbcc8..e1dc0d3 100644
--- a/website/docs/guides/typescript.mdx
+++ b/website/docs/guides/typescript.mdx
@@ -61,6 +61,8 @@ import type {
WeekStripProps, DurationPickerProps, DurationLabels, MonthPickerProps, MonthPickerSlot, MonthDisplay,
// color
ColorPickerProps, ColorPickerSlot, ColorOption, ColorDisplay, ColorLabels,
+ // slider
+ SliderProps, SingleSliderProps, RangeSliderProps, SliderRange, SliderSlot, SliderMarkInput, SliderValueLabel,
// select
SelectPickerProps, SelectListProps, SelectListBaseProps, SelectValueProps,
SelectOption, SelectSlot, SelectTriggerSlot, SelectTriggerInfo, SelectRenderOptionInfo,
diff --git a/website/docs/intro.mdx b/website/docs/intro.mdx
index a7ec730..1b4cabd 100644
--- a/website/docs/intro.mdx
+++ b/website/docs/intro.mdx
@@ -2,7 +2,7 @@
title: React Native Pickers, zero native code
sidebar_label: Introduction
slug: /intro
-description: "React Native Pickers: wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable."
+description: "React Native Pickers: wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers and a slider, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable."
---
# React Native Pickers
@@ -91,6 +91,7 @@ Edit the code and the result updates instantly. Each example is a complete file
| [`HorizontalList`](./components/horizontal-list) | Segmented control or chips that scroll sideways and reveal the next item |
| [`EmojiPicker`](./components/emoji-picker) | 1,923 emojis, categories, search, skin tones, recents, version filtering |
| [`ColorPicker`](./components/color-picker) | iOS's Grid / Spectrum / Sliders, Google's swatch circles, or the browser's spectrum; hex with opacity and a copy button |
+| [`Slider`](./components/slider) | iOS's knob, Material 3's handle with a value bubble, or a range input; one thumb or a range, steps, icons |
| [`BottomSheet`](./components/bottom-sheet) | Animated sheet with drag-to-dismiss, for your own content |
| [`DatePickerSheet`, `TimePickerSheet`, `DateTimePickerSheet`, `DurationPickerSheet`, `MonthPickerSheet`, `ColorPickerSheet`, `WheelPickerSheet`, `EmojiPickerSheet`](./components/picker-sheets) | Ready-made pickers in a sheet with Cancel / Done and draft state |
| [`DatePickerField`, `TimePickerField`, `DateTimePickerField`, `DurationPickerField`, `MonthPickerField`, `ColorPickerField`, `WheelPickerField`, `EmojiPickerField`, `PickerField`](./components/picker-fields) | Form fields that open a picker sheet and show the picked value, styled like `SelectPicker` |
diff --git a/website/docs/resources/ai.mdx b/website/docs/resources/ai.mdx
index 8fb0c6f..cf69e02 100644
--- a/website/docs/resources/ai.mdx
+++ b/website/docs/resources/ai.mdx
@@ -83,6 +83,10 @@ EmojiPicker onSelect(emoji, data) columns=8 height=380 categories exclude
ColorPicker value: hex ('#E4572E', or '#E4572E80' with showAlpha) onChange('#RRGGBB'|'#RRGGBBAA', fires while dragging)
display 'tabs'(ios: Grid/Spectrum/Sliders) | 'swatches'(material: circles + Custom) | 'spectrum'(web: square, hue, hex, eyedropper); default from the design
colors (hex | {value,label})[]=DEFAULT_COLORS showCustom=true showAlpha=false onCopy(hex) (copy button; web clipboard by default, native needs it, e.g. expo-clipboard) labels normalizeHex hexToHsv hsvToHex withAlpha
+Slider value: number onChange(value) (on release, or a key/screen-reader step) onValueChanging(value) (every step while dragging) min=0 max=100 step=1 (0: continuous)
+ range → value [start,end], onChange([start,end]), thumbLabels=['Minimum','Maximum']; a touch grabs the nearest thumb, thumbs never cross
+ showSteps (dots per step) marks (number | {value,label})[] (dots + labels under the track) snapToMarks (only the marks) valueLabel 'never'|'drag'|'always' (default 'drag' Material, else 'never') formatValue(v) (bubble + screen readers) minimumIcon maximumIcon (nodes beside the track) disabled
+ looks: ios white knob on a 4pt track | material M3 bar handle, 16dp track, gaps, stop dots | web 18px dot on a 6px track; web: pointer cursor + a halo on hover/focus/press; slots container track activeTrack thumb mark markLabel valueLabel valueLabelText
BottomSheet visible onClose title headerLeft headerRight footer height(<=1 fraction | px) enableSwipeToClose draggable(header drags too; native:true web:false; handle always drags) expandable expandedHeight
Sheets (draft; commit on Done):
DatePickerSheet visible onClose value onConfirm + all calendar props + mode + display + confirmOnSelect
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index bec51da..5c668b9 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -38,7 +38,7 @@ const FOOTER_BRAND = `
${socialLink('LinkedIn', LINKEDIN, `${AUTHOR} on LinkedIn`)}
`;
const DESCRIPTION =
- 'Wheel, time, duration, date, calendar, month, week strip, range, date-time, select, country, phone, time zone, emoji and color pickers for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
+ 'Wheel, time, duration, date, calendar, month, week strip, range, date-time, select, country, phone, time zone, emoji and color pickers and a slider for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
const local = (pkg: string) => path.dirname(require.resolve(`${pkg}/package.json`));
const { version } = require('../package.json');
@@ -70,7 +70,7 @@ const structuredData = {
version,
author: { '@type': 'Person', name: AUTHOR, url: 'https://github.com/elik56a', sameAs: [LINKEDIN] },
keywords:
- 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible',
+ 'react native date picker, time picker, duration picker, month picker, week strip, date time picker, wheel picker, calendar, date range picker, select, dropdown, inline picker, multi-select, autocomplete, segmented control, country picker, phone code picker, phone number input, time zone picker, emoji picker, color picker, slider, range slider, bottom sheet, material design, ios picker, expo, react native web, keyboard accessible',
},
],
};
@@ -95,7 +95,7 @@ const config: Config = {
attributes: {
name: 'keywords',
content:
- 'react native picker, react native date picker, react native time picker, react native duration picker, countdown timer picker, react native month picker, month year picker, credit card expiry picker, react native week calendar strip, react native date time picker, react native wheel picker, react native calendar, date range picker, react native select, react native dropdown, multi-select, autocomplete, segmented control, chips, react native country picker, country code picker, emoji picker, react native color picker, hex color picker, bottom sheet, material design date picker, material time picker clock, ios style picker, expo, react native web',
+ 'react native picker, react native date picker, react native time picker, react native duration picker, countdown timer picker, react native month picker, month year picker, credit card expiry picker, react native week calendar strip, react native date time picker, react native wheel picker, react native calendar, date range picker, react native select, react native dropdown, multi-select, autocomplete, segmented control, chips, react native country picker, country code picker, emoji picker, react native color picker, hex color picker, react native slider, range slider, bottom sheet, material design date picker, material time picker clock, ios style picker, expo, react native web',
},
},
{ tagName: 'link', attributes: { rel: 'alternate', type: 'text/plain', title: 'LLM docs', href: `${BASE_URL}llms.txt` } },
diff --git a/website/src/catalog/catalog.constants.ts b/website/src/catalog/catalog.constants.ts
index 0ff403b..a222398 100644
--- a/website/src/catalog/catalog.constants.ts
+++ b/website/src/catalog/catalog.constants.ts
@@ -30,6 +30,7 @@ export const PLAYGROUND = {
emojiPickerField: 'emojiPickerField',
colorPicker: 'colorPicker',
colorPickerField: 'colorPickerField',
+ slider: 'slider',
pickerField: 'pickerField',
pickerSheet: 'pickerSheet',
bottomSheet: 'bottomSheet',
@@ -96,6 +97,7 @@ export const CATALOG: readonly CatalogEntry[] = [
},
{ doc: 'components/emoji-picker', label: 'EmojiPicker', playgrounds: [PLAYGROUND.emojiPicker, PLAYGROUND.emojiPickerField] },
{ doc: 'components/color-picker', label: 'ColorPicker', playgrounds: [PLAYGROUND.colorPicker, PLAYGROUND.colorPickerField] },
+ { doc: 'components/slider', label: 'Slider', playgrounds: [PLAYGROUND.slider] },
{
label: 'Fields & sheets',
pages: [
diff --git a/website/src/catalog/playgrounds.tsx b/website/src/catalog/playgrounds.tsx
index e3f8fe0..fb42c4d 100644
--- a/website/src/catalog/playgrounds.tsx
+++ b/website/src/catalog/playgrounds.tsx
@@ -22,6 +22,7 @@ import {
PhoneField,
SelectList,
SelectPicker,
+ Slider,
TimePicker,
TimePickerField,
TimeZonePicker,
@@ -438,6 +439,19 @@ export const PLAYGROUNDS: Record = {
{ prop: 'showCustom', options: [true, false] },
],
},
+ slider: {
+ name: 'Slider',
+ component: Slider,
+ props: { accessibilityLabel: 'Volume' },
+ value: 40,
+ code: 'value={volume} onChange={setVolume} accessibilityLabel="Volume"',
+ controls: [
+ { prop: 'step', options: [1, 10, 0], default: 1 },
+ { prop: 'showSteps', options: [false, true] },
+ { prop: 'valueLabel', options: ['auto', 'never', 'drag', 'always'], unset: 'auto' },
+ { prop: 'disabled', options: [false, true] },
+ ],
+ },
colorPickerField: {
name: 'ColorPickerField',
component: ColorPickerField,
diff --git a/website/src/home/components/Hero.tsx b/website/src/home/components/Hero.tsx
index 2c4c0da..dde832c 100644
--- a/website/src/home/components/Hero.tsx
+++ b/website/src/home/components/Hero.tsx
@@ -29,9 +29,9 @@ export const Hero = () => {
React Native app needs.
- Wheel, time, duration, date, month, range, select, country, phone, time zone, emoji and color pickers, plus the sheets and
- fields to put them in. Native on iOS, Android and the web, yours to customize, and 100% JavaScript with zero
- native code.
+ Wheel, time, duration, date, month, range, select, country, phone, time zone, emoji and color pickers, a slider, plus the sheets
+ and fields to put them in. Native on iOS, Android and the web, yours to customize, and 100% JavaScript with
+ zero native code.
diff --git a/website/src/home/home.constants.ts b/website/src/home/home.constants.ts
index 38f878c..79a23c9 100644
--- a/website/src/home/home.constants.ts
+++ b/website/src/home/home.constants.ts
@@ -13,7 +13,7 @@ export const NPM_DOWNLOADS_STATUS = {
export const PAGE_TITLE = 'Date, Time, Select & Emoji Pickers with Zero Native Code';
export const PAGE_DESCRIPTION =
- 'Wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
+ 'Wheel, time, duration, date, month, week, range, date-time, select, country, phone, time zone, emoji and color pickers and a slider for React Native, with bottom sheets and form fields. Looks native on iOS, Android and the web, with zero native code. Fully themeable; works in Expo Go, bare RN and React Native Web.';
export const COUNT_UP_MS = 1400;
export const COPIED_MS = 1500;
@@ -68,6 +68,7 @@ export const COMPONENTS = [
{ doc: 'components/horizontal-list', name: 'HorizontalList', body: 'Segmented control or scrolling chips, with icons' },
{ doc: 'components/emoji-picker', name: 'EmojiPicker', body: '1,923 emojis, skin tones, recents, search' },
{ doc: 'components/color-picker', name: 'ColorPicker', body: "iOS's grid, Google's circles, a web spectrum" },
+ { doc: 'components/slider', name: 'Slider', body: 'One value or a range, with steps and icons' },
{ doc: 'components/multi-wheel-picker', name: 'MultiWheelPicker', body: 'Units, compound values, dependent columns' },
{ doc: 'components/picker-sheets', name: 'Sheets', body: 'Every picker in a bottom sheet or modal' },
{ doc: 'components/picker-fields', name: 'Fields', body: 'Tap a field, pick in a sheet, a dropdown or inline' },
diff --git a/website/src/live/live.constants.ts b/website/src/live/live.constants.ts
index 6e27fd2..0435433 100644
--- a/website/src/live/live.constants.ts
+++ b/website/src/live/live.constants.ts
@@ -33,6 +33,7 @@ const DATE_TIME = ['DateTimePicker', 'DateTimePickerField', 'DateTimePickerSheet
const DURATION = ['DurationPicker', 'DurationPickerField', 'DurationPickerSheet'];
const EMOJI = ['EmojiPicker', 'EmojiPickerField', 'EmojiPickerSheet'];
const SELECT = ['SelectPicker', 'CountryPicker'];
+const SLIDER = ['Slider'];
/**
* The side panel's props, offered for every live example that renders one of their components.
@@ -74,7 +75,8 @@ export const LIVE_CONTROLS: readonly LiveControl[] = [
{ prop: 'showSkinTones', components: EMOJI, options: BOOLEAN, default: true },
{ prop: 'showDialCode', components: ['CountryPicker'], options: BOOLEAN, default: false },
{ prop: 'clearable', components: [...FIELDS, ...SELECT], options: BOOLEAN, default: false, requires: /\bonClear=/ },
- { prop: 'disabled', components: [...FIELDS, ...SELECT], options: BOOLEAN, default: false },
+ { prop: 'showSteps', components: SLIDER, options: BOOLEAN, default: false },
+ { prop: 'disabled', components: [...FIELDS, ...SELECT, ...SLIDER], options: BOOLEAN, default: false },
];
export const SNACK_URL = 'https://snack.expo.dev/';
diff --git a/website/src/showcase/ShowcaseApp.tsx b/website/src/showcase/ShowcaseApp.tsx
index 1a8d82e..a6448e0 100644
--- a/website/src/showcase/ShowcaseApp.tsx
+++ b/website/src/showcase/ShowcaseApp.tsx
@@ -6,6 +6,7 @@ import { BookingScreen } from '../home/demo/components/BookingScreen';
import { AlarmScreen } from './components/AlarmScreen';
import { ChatScreen } from './components/ChatScreen';
import { DeliveryScreen } from './components/DeliveryScreen';
+import { DisplayScreen } from './components/DisplayScreen';
import { FlightsScreen } from './components/FlightsScreen';
import { HabitScreen } from './components/HabitScreen';
import { MeetingScreen } from './components/MeetingScreen';
@@ -29,6 +30,7 @@ const SCREENS: Record
> = {
habit: HabitScreen,
trips: TripsScreen,
delivery: DeliveryScreen,
+ display: DisplayScreen,
};
interface ShowcaseAppProps extends ScreenProps {
diff --git a/website/src/showcase/components/DisplayScreen.tsx b/website/src/showcase/components/DisplayScreen.tsx
new file mode 100644
index 0000000..d0d3a3a
--- /dev/null
+++ b/website/src/showcase/components/DisplayScreen.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { Text, View } from 'react-native';
+
+import { Slider, usePickerTheme } from '@elik56a/react-native-pickers';
+import { AppScreen } from '../../home/demo/components/AppScreen';
+import { DISPLAY, FOCUS_MARKS, HOURS_IN_DAY, TEXT_SIZE_RANGE, TEXT_SIZES } from '../showcase.constants';
+import { styles } from '../showcase.styles';
+import type { ScreenProps } from '../showcase.types';
+import { formatHour, previewOpacity, textSizeLabel } from '../showcase.utils';
+import { SliderSetting } from './SliderSetting';
+
+export const DisplayScreen = ({ platform }: ScreenProps) => {
+ const { colors } = usePickerTheme();
+ const [brightness, setBrightness] = useState(DISPLAY.brightness);
+ const [textSize, setTextSize] = useState(DISPLAY.textSize);
+ const [focus, setFocus] = useState(DISPLAY.focus);
+ return (
+
+
+ Good morning ☀️
+
+ Three meetings today, the first at 10.
+
+
+
+ 🔅}
+ maximumIcon={🔆}
+ formatValue={(v) => `${v}%`}
+ accessibilityLabel="Brightness"
+ />
+
+
+ `${v} pt`}
+ accessibilityLabel="Text size"
+ />
+
+
+
+
+
+ );
+};
diff --git a/website/src/showcase/components/SliderSetting.tsx b/website/src/showcase/components/SliderSetting.tsx
new file mode 100644
index 0000000..1fb7d2a
--- /dev/null
+++ b/website/src/showcase/components/SliderSetting.tsx
@@ -0,0 +1,19 @@
+import { Text, View } from 'react-native';
+
+import { usePickerTheme } from '@elik56a/react-native-pickers';
+import { styles } from '../showcase.styles';
+import type { SliderSettingProps } from '../showcase.types';
+
+/** A settings row: its name and current value over the slider that sets it. */
+export const SliderSetting = ({ label, value, children }: SliderSettingProps) => {
+ const { colors } = usePickerTheme();
+ return (
+
+
+ {label}
+ {value}
+
+ {children}
+
+ );
+};
diff --git a/website/src/showcase/showcase.constants.ts b/website/src/showcase/showcase.constants.ts
index 612d534..17770a4 100644
--- a/website/src/showcase/showcase.constants.ts
+++ b/website/src/showcase/showcase.constants.ts
@@ -15,6 +15,7 @@ export const SHOWCASE_SCREEN = {
habit: 'habit',
trips: 'trips',
delivery: 'delivery',
+ display: 'display',
} as const;
export const SHOWCASE: ShowcaseItem[] = [
@@ -93,6 +94,12 @@ export const SHOWCASE: ShowcaseItem[] = [
blurb: 'Tap the days you ran on a multi-select calendar, set a daily reminder, and pick the colour that marks your runs.',
components: ['DatePicker', 'TimePickerField', 'ColorPickerField'],
},
+ {
+ key: SHOWCASE_SCREEN.display,
+ title: 'Display & text',
+ blurb: 'Brightness and text size preview live as you drag; the text size snaps to its marks, and focus hours are a range.',
+ components: ['Slider'],
+ },
{
key: SHOWCASE_SCREEN.trips,
title: 'Trip planner',
@@ -253,3 +260,32 @@ export const TRIPS = {
] as const,
cities: ['Lisbon', 'Paris'],
};
+
+export const DISPLAY = {
+ brightness: 70,
+ textSize: 17,
+ focus: [9, 17] as [number, number],
+};
+
+/** Text sizes in points, like iOS's Larger Text slider: only these can be picked. */
+export const TEXT_SIZES = [
+ { value: 13, label: 'XS' },
+ { value: 15, label: 'S' },
+ { value: 17, label: 'M' },
+ { value: 20, label: 'L' },
+ { value: 24, label: 'XL' },
+];
+export const TEXT_SIZE_RANGE = { min: 13, max: 24 };
+
+export const HOURS_IN_DAY = 24;
+/** Every six hours, labelled under the focus-hours track. */
+export const FOCUS_MARKS = [
+ { value: 0, label: '12a' },
+ { value: 6, label: '6a' },
+ { value: 12, label: 'Noon' },
+ { value: 18, label: '6p' },
+ { value: 24, label: '12a' },
+];
+
+/** The preview is never fully dark, so it stays readable at the lowest brightness. */
+export const MIN_PREVIEW_OPACITY = 0.3;
diff --git a/website/src/showcase/showcase.styles.ts b/website/src/showcase/showcase.styles.ts
index 90a59d3..cf7e773 100644
--- a/website/src/showcase/showcase.styles.ts
+++ b/website/src/showcase/showcase.styles.ts
@@ -35,4 +35,12 @@ export const styles = StyleSheet.create({
iconText: { fontSize: 22 },
send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
sendText: { fontSize: 18, fontWeight: '700' },
+ preview: { padding: 16, borderRadius: 16, gap: 4 },
+ previewTitle: { fontWeight: '600' },
+ previewBody: { flexShrink: 1 },
+ setting: { gap: 2 },
+ settingHeader: { flexDirection: 'row', justifyContent: 'space-between' },
+ settingLabel: { fontSize: 15, fontWeight: '500' },
+ settingValue: { fontSize: 15 },
+ icon: { fontSize: 16 },
});
diff --git a/website/src/showcase/showcase.types.ts b/website/src/showcase/showcase.types.ts
index 4194a08..f80ca9e 100644
--- a/website/src/showcase/showcase.types.ts
+++ b/website/src/showcase/showcase.types.ts
@@ -1,3 +1,5 @@
+import type { ReactNode } from 'react';
+
import type { BookingScreenProps } from '../home/demo/demo.types';
import type { ValueOf } from '../home/home.types';
import type { SHOWCASE_SCREEN, TRIP_TYPE } from './showcase.constants';
@@ -29,6 +31,12 @@ export interface TextSelection {
end: number;
}
+export interface SliderSettingProps {
+ label: string;
+ value: string;
+ children: ReactNode;
+}
+
export interface TimerPreset {
label: string;
/** Seconds */
diff --git a/website/src/showcase/showcase.utils.test.ts b/website/src/showcase/showcase.utils.test.ts
index 19cb937..9764a5a 100644
--- a/website/src/showcase/showcase.utils.test.ts
+++ b/website/src/showcase/showcase.utils.test.ts
@@ -1,5 +1,5 @@
import { CLOSED_WEEKDAY, TIMER_PRESETS } from './showcase.constants';
-import { findPreset, formatClock, insertAtSelection, isClosedDay } from './showcase.utils';
+import { findPreset, formatClock, formatHour, insertAtSelection, isClosedDay, previewOpacity, textSizeLabel } from './showcase.utils';
describe('formatClock', () => {
it('reads like a clock, with hours only once there are some', () => {
@@ -37,3 +37,28 @@ describe('insertAtSelection', () => {
});
});
});
+
+describe('formatHour', () => {
+ it('reads an hour of the day on a 12-hour clock', () => {
+ expect(formatHour(0)).toBe('12 AM');
+ expect(formatHour(9)).toBe('9 AM');
+ expect(formatHour(12)).toBe('12 PM');
+ expect(formatHour(17)).toBe('5 PM');
+ expect(formatHour(24)).toBe('12 AM');
+ });
+});
+
+describe('previewOpacity', () => {
+ it('dims with brightness but never fully', () => {
+ expect(previewOpacity(100)).toBe(1);
+ expect(previewOpacity(0)).toBe(0.3);
+ expect(previewOpacity(50)).toBeCloseTo(0.65);
+ });
+});
+
+describe('textSizeLabel', () => {
+ it('names a size by its mark, or in points off the marks', () => {
+ expect(textSizeLabel(17)).toBe('M');
+ expect(textSizeLabel(16)).toBe('16 pt');
+ });
+});
diff --git a/website/src/showcase/showcase.utils.ts b/website/src/showcase/showcase.utils.ts
index 7d63c27..d568536 100644
--- a/website/src/showcase/showcase.utils.ts
+++ b/website/src/showcase/showcase.utils.ts
@@ -1,4 +1,4 @@
-import { CLOSED_WEEKDAY, TIMER_PRESETS } from './showcase.constants';
+import { CLOSED_WEEKDAY, HOURS_IN_DAY, MIN_PREVIEW_OPACITY, TEXT_SIZES, TIMER_PRESETS } from './showcase.constants';
import type { TextSelection } from './showcase.types';
const pad = (n: number) => String(n).padStart(2, '0');
@@ -27,3 +27,15 @@ export const insertAtSelection = (text: string, { start, end }: TextSelection, i
},
};
};
+
+/** An hour of the day on a 12-hour clock: 0 → 12 AM, 13 → 1 PM, 24 → 12 AM. */
+export const formatHour = (hour: number) => {
+ const h = hour % HOURS_IN_DAY;
+ return `${h % 12 || 12} ${h < 12 ? 'AM' : 'PM'}`;
+};
+
+/** How bright the preview shows for a 0–100 brightness. */
+export const previewOpacity = (brightness: number) => MIN_PREVIEW_OPACITY + ((1 - MIN_PREVIEW_OPACITY) * brightness) / 100;
+
+/** A text size's name on its mark, such as M for 17 pt. */
+export const textSizeLabel = (size: number) => TEXT_SIZES.find((option) => option.value === size)?.label ?? `${size} pt`;