@delacour/react-native-ui 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { declarationCount } from "../../styles/theme-tokens.test";
|
|
3
|
+
import { TEXT_SIZES } from "../text/text.variants";
|
|
4
|
+
import {
|
|
5
|
+
clampThumb,
|
|
6
|
+
fillBounds,
|
|
7
|
+
fillExtent,
|
|
8
|
+
formatSliderValue,
|
|
9
|
+
fromValueArray,
|
|
10
|
+
nearestThumbIndex,
|
|
11
|
+
progressOf,
|
|
12
|
+
resolveSliderAxes,
|
|
13
|
+
SLIDER_COLORS,
|
|
14
|
+
SLIDER_DEFAULT_COLOR,
|
|
15
|
+
SLIDER_DEFAULT_ORIENTATION,
|
|
16
|
+
SLIDER_DEFAULT_SIZE,
|
|
17
|
+
SLIDER_HAPTIC_MIN_TRAVEL,
|
|
18
|
+
SLIDER_MAX_VALUE,
|
|
19
|
+
SLIDER_MIN_VALUE,
|
|
20
|
+
SLIDER_ORIENTATIONS,
|
|
21
|
+
SLIDER_OUTPUT_TEXT_SIZE,
|
|
22
|
+
SLIDER_RANGE_SEPARATOR,
|
|
23
|
+
SLIDER_SIZES,
|
|
24
|
+
SLIDER_STEP,
|
|
25
|
+
SLIDER_THUMB_ANIMATION,
|
|
26
|
+
SLIDER_THUMB_SPRING,
|
|
27
|
+
shouldTickHaptic,
|
|
28
|
+
sliderVariants,
|
|
29
|
+
snapToStep,
|
|
30
|
+
toValueArray,
|
|
31
|
+
valueFromOffset,
|
|
32
|
+
} from "./slider.variants";
|
|
33
|
+
|
|
34
|
+
/** The bare colour a `bg-*` class names — `bg-success` yields `success`. */
|
|
35
|
+
function backgroundToken(value: string): string | undefined {
|
|
36
|
+
return value.match(/\bbg-([\w-]+)\b/)?.[1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A slot's class string, with `tv`'s empty-slot `undefined` flattened.
|
|
41
|
+
*
|
|
42
|
+
* A slot that emits nothing returns `undefined` rather than `""` — correct, but
|
|
43
|
+
* it makes a bare `not.toMatch()` throw rather than pass.
|
|
44
|
+
*/
|
|
45
|
+
function cls(value: string | undefined): string {
|
|
46
|
+
return value ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `h-*` step a class string sets — `h-1.5` yields 1.5. */
|
|
50
|
+
function heightStep(value: string): number {
|
|
51
|
+
return Number(value.match(/\bh-(\d+(?:\.\d+)?)\b/)?.[1]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `w-*` step a class string sets — `w-1.5` yields 1.5. */
|
|
55
|
+
function widthStep(value: string): number {
|
|
56
|
+
return Number(value.match(/\bw-(\d+(?:\.\d+)?)\b/)?.[1]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `py-*` / `px-*` step a class string sets — `py-3.5` yields 3.5. */
|
|
60
|
+
function paddingStep(value: string): number {
|
|
61
|
+
return Number(value.match(/\bp[xy]-(\d+(?:\.\d+)?)\b/)?.[1]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Tailwind's spacing scale is quarter-rem steps, so a step is four points. */
|
|
65
|
+
const POINTS_PER_STEP = 4;
|
|
66
|
+
|
|
67
|
+
/** The tap target every control in this library has to clear. */
|
|
68
|
+
const MINIMUM_TARGET_PT = 44;
|
|
69
|
+
|
|
70
|
+
/** Every combination of the axes that paint a slider, as `tv` props. */
|
|
71
|
+
function everyCell(): {
|
|
72
|
+
color: (typeof SLIDER_COLORS)[number];
|
|
73
|
+
size: (typeof SLIDER_SIZES)[number];
|
|
74
|
+
orientation: (typeof SLIDER_ORIENTATIONS)[number];
|
|
75
|
+
isDisabled: boolean;
|
|
76
|
+
isInvalid: boolean;
|
|
77
|
+
}[] {
|
|
78
|
+
const cells = [];
|
|
79
|
+
for (const color of SLIDER_COLORS) {
|
|
80
|
+
for (const size of SLIDER_SIZES) {
|
|
81
|
+
for (const orientation of SLIDER_ORIENTATIONS) {
|
|
82
|
+
for (const isDisabled of [false, true]) {
|
|
83
|
+
for (const isInvalid of [false, true]) {
|
|
84
|
+
cells.push({ color, size, orientation, isDisabled, isInvalid });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return cells;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("the theme reader", () => {
|
|
94
|
+
// Guard: every assertion below that reads tokens.css is worthless if the
|
|
95
|
+
// parse silently found nothing. The suite must not be able to go green empty.
|
|
96
|
+
test("finds both variants of the theme", () => {
|
|
97
|
+
expect(declarationCount("primary")).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("the axes", () => {
|
|
102
|
+
test("name the six semantic colours Badge and Checkbox share", () => {
|
|
103
|
+
expect([...SLIDER_COLORS]).toEqual(["default", "primary", "success", "warning", "destructive", "info"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("name three sizes and two orientations", () => {
|
|
107
|
+
expect([...SLIDER_SIZES]).toEqual(["sm", "md", "lg"]);
|
|
108
|
+
expect([...SLIDER_ORIENTATIONS]).toEqual(["horizontal", "vertical"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// The defaults are read twice — by `defaultVariants` and by
|
|
112
|
+
// `resolveSliderAxes`, which runs before `tv` is ever called. A drift between
|
|
113
|
+
// them is a slider that renders at one size and reports another.
|
|
114
|
+
test("default to values the axes actually hold", () => {
|
|
115
|
+
expect(SLIDER_COLORS).toContain(SLIDER_DEFAULT_COLOR);
|
|
116
|
+
expect(SLIDER_SIZES).toContain(SLIDER_DEFAULT_SIZE);
|
|
117
|
+
expect(SLIDER_ORIENTATIONS).toContain(SLIDER_DEFAULT_ORIENTATION);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("default the range to 0–100 in whole steps", () => {
|
|
121
|
+
expect(SLIDER_MIN_VALUE).toBe(0);
|
|
122
|
+
expect(SLIDER_MAX_VALUE).toBe(100);
|
|
123
|
+
expect(SLIDER_STEP).toBe(1);
|
|
124
|
+
expect(SLIDER_MAX_VALUE).toBeGreaterThan(SLIDER_MIN_VALUE);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("sliderVariants", () => {
|
|
129
|
+
test("paints the fill differently for every colour", () => {
|
|
130
|
+
const painted = new Set(SLIDER_COLORS.map((color) => cls(sliderVariants({ color }).fill())));
|
|
131
|
+
expect(painted.size).toBe(SLIDER_COLORS.length);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The empty groove is chrome at every colour, the way an unticked checkbox is
|
|
135
|
+
// `border-input bg-card` however it is coloured. The capsule is not: it takes
|
|
136
|
+
// the fill's own colour so the two meet with no seam.
|
|
137
|
+
test("leaves the groove alone at every colour, and paints the capsule with the fill", () => {
|
|
138
|
+
const tracks = new Set(SLIDER_COLORS.map((color) => cls(sliderVariants({ color }).track())));
|
|
139
|
+
expect(tracks.size).toBe(1);
|
|
140
|
+
|
|
141
|
+
for (const color of SLIDER_COLORS) {
|
|
142
|
+
const slots = sliderVariants({ color });
|
|
143
|
+
expect(backgroundToken(cls(slots.thumb()))).toBe(backgroundToken(cls(slots.fill())));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Rule 11: `X-foreground` is content drawn on an `X` surface. The knob sits on
|
|
148
|
+
// the capsule, so its colour is that capsule's foreground and nothing else —
|
|
149
|
+
// two maps that can drift is how a handle ends up invisible on one colour.
|
|
150
|
+
test("gives the knob the capsule's own foreground, at every colour", () => {
|
|
151
|
+
for (const color of [...SLIDER_COLORS, "destructive"] as const) {
|
|
152
|
+
for (const isInvalid of [false, true]) {
|
|
153
|
+
const slots = sliderVariants({ color, isInvalid });
|
|
154
|
+
const capsule = backgroundToken(cls(slots.thumb()));
|
|
155
|
+
const knob = backgroundToken(cls(slots.knob()));
|
|
156
|
+
// `foreground` is the page's ink, and what is drawn on ink is the page.
|
|
157
|
+
expect(knob).toBe(capsule === "foreground" ? "background" : `${capsule}-foreground`);
|
|
158
|
+
expect(declarationCount(knob as string)).toBe(2);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("reddens the fill when invalid, whatever the colour", () => {
|
|
164
|
+
for (const color of SLIDER_COLORS) {
|
|
165
|
+
expect(cls(sliderVariants({ color, isInvalid: true }).fill())).toMatch(/\bbg-destructive\b/);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Nothing in this package draws a shadow, and RN's shadow props diverge
|
|
170
|
+
// between platforms in a way a one-pixel border does not.
|
|
171
|
+
test("draws no shadow anywhere", () => {
|
|
172
|
+
for (const cell of everyCell()) {
|
|
173
|
+
const slots = sliderVariants(cell);
|
|
174
|
+
for (const slot of [
|
|
175
|
+
slots.root(),
|
|
176
|
+
slots.touchArea(),
|
|
177
|
+
slots.track(),
|
|
178
|
+
slots.fill(),
|
|
179
|
+
slots.thumb(),
|
|
180
|
+
slots.knob(),
|
|
181
|
+
slots.output(),
|
|
182
|
+
]) {
|
|
183
|
+
expect(cls(slot)).not.toMatch(/shadow/);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Rule 1: a View cannot cascade colour to a Text descendant, and the output
|
|
189
|
+
// renders a `Text` preset that owns its own scale, weight and colour.
|
|
190
|
+
test("puts no type treatment on any slot", () => {
|
|
191
|
+
for (const cell of everyCell()) {
|
|
192
|
+
const slots = sliderVariants(cell);
|
|
193
|
+
for (const slot of [
|
|
194
|
+
slots.root(),
|
|
195
|
+
slots.touchArea(),
|
|
196
|
+
slots.track(),
|
|
197
|
+
slots.fill(),
|
|
198
|
+
slots.thumb(),
|
|
199
|
+
slots.knob(),
|
|
200
|
+
slots.output(),
|
|
201
|
+
]) {
|
|
202
|
+
expect(cls(slot)).not.toMatch(/\btext-(?!ellipsis|clip)/);
|
|
203
|
+
expect(cls(slot)).not.toMatch(/\bfont-/);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("swaps the track's axis with the orientation rather than restating it", () => {
|
|
209
|
+
for (const size of SLIDER_SIZES) {
|
|
210
|
+
const horizontal = cls(sliderVariants({ orientation: "horizontal", size }).track());
|
|
211
|
+
const vertical = cls(sliderVariants({ orientation: "vertical", size }).track());
|
|
212
|
+
// Both extractors yield NaN for anything that is not a numeric step, and
|
|
213
|
+
// `expect(NaN).toBe(NaN)` *passes* — so without this the test would go
|
|
214
|
+
// falsely green the moment the thickness stopped being a plain class.
|
|
215
|
+
expect(Number.isFinite(heightStep(horizontal))).toBe(true);
|
|
216
|
+
expect(Number.isFinite(widthStep(vertical))).toBe(true);
|
|
217
|
+
expect(heightStep(horizontal)).toBe(widthStep(vertical));
|
|
218
|
+
expect(horizontal).toMatch(/\bw-full\b/);
|
|
219
|
+
expect(vertical).toMatch(/\bh-full\b/);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Padding the main axis would offset every value by the gutter — silently, and
|
|
224
|
+
// visibly only at the two ends. A test is the cheapest place to catch it.
|
|
225
|
+
test("pads the touch area on the cross axis only", () => {
|
|
226
|
+
const horizontal = cls(sliderVariants({ orientation: "horizontal" }).touchArea());
|
|
227
|
+
const vertical = cls(sliderVariants({ orientation: "vertical" }).touchArea());
|
|
228
|
+
expect(horizontal).toMatch(/\bpy-\d/);
|
|
229
|
+
expect(horizontal).not.toMatch(/\b(px|pl|pr|p)-\d/);
|
|
230
|
+
expect(vertical).toMatch(/\bpx-\d/);
|
|
231
|
+
expect(vertical).not.toMatch(/\b(py|pt|pb|p)-\d/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// The thickness and its padding live in one compound cell because they are one
|
|
235
|
+
// number. Asserting the sum rather than the parts is what lets the ladder be
|
|
236
|
+
// retuned without the test becoming a transcript of it.
|
|
237
|
+
test("brings every size up to a 44pt target, track plus padding", () => {
|
|
238
|
+
for (const size of SLIDER_SIZES) {
|
|
239
|
+
for (const orientation of SLIDER_ORIENTATIONS) {
|
|
240
|
+
const slots = sliderVariants({ orientation, size });
|
|
241
|
+
const track = cls(slots.track());
|
|
242
|
+
const thickness = orientation === "horizontal" ? heightStep(track) : widthStep(track);
|
|
243
|
+
const padding = paddingStep(cls(slots.touchArea()));
|
|
244
|
+
expect(Number.isFinite(thickness)).toBe(true);
|
|
245
|
+
expect(Number.isFinite(padding)).toBe(true);
|
|
246
|
+
expect((thickness + padding * 2) * POINTS_PER_STEP).toBe(MINIMUM_TARGET_PT);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("thickens the track as the size steps up", () => {
|
|
252
|
+
const steps = SLIDER_SIZES.map((size) =>
|
|
253
|
+
heightStep(cls(sliderVariants({ orientation: "horizontal", size }).track()))
|
|
254
|
+
);
|
|
255
|
+
expect(steps).toEqual([...steps].sort((a, b) => a - b));
|
|
256
|
+
expect(new Set(steps).size).toBe(SLIDER_SIZES.length);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// The invariant the whole geometry rests on. `fillExtent` lands exactly on both
|
|
260
|
+
// extremes only while these two are the same number: inset the thumb inside the
|
|
261
|
+
// track and there is stray colour at the minimum and empty groove at the
|
|
262
|
+
// maximum, at every size.
|
|
263
|
+
test("draws the capsule flush across the track and longer along it", () => {
|
|
264
|
+
for (const size of SLIDER_SIZES) {
|
|
265
|
+
for (const orientation of SLIDER_ORIENTATIONS) {
|
|
266
|
+
const isVertical = orientation === "vertical";
|
|
267
|
+
const track = cls(sliderVariants({ orientation, size }).track());
|
|
268
|
+
const thumb = cls(sliderVariants({ orientation, size }).thumb());
|
|
269
|
+
const across = isVertical ? widthStep : heightStep;
|
|
270
|
+
const along = isVertical ? heightStep : widthStep;
|
|
271
|
+
|
|
272
|
+
expect(Number.isFinite(across(thumb))).toBe(true);
|
|
273
|
+
expect(Number.isFinite(along(thumb))).toBe(true);
|
|
274
|
+
// Flush on the cross axis: this is what makes `fillExtent` exact.
|
|
275
|
+
expect(across(thumb)).toBe(across(track));
|
|
276
|
+
// A capsule, not a disc.
|
|
277
|
+
expect(along(thumb)).toBeGreaterThan(across(thumb));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("steps the capsule up with the size, in step with the track", () => {
|
|
283
|
+
const steps = SLIDER_SIZES.map((size) =>
|
|
284
|
+
heightStep(cls(sliderVariants({ orientation: "horizontal", size }).thumb()))
|
|
285
|
+
);
|
|
286
|
+
expect(steps).toEqual([...steps].sort((a, b) => a - b));
|
|
287
|
+
expect(new Set(steps).size).toBe(SLIDER_SIZES.length);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// The padding is what leaves the knob room to shrink into without the capsule
|
|
291
|
+
// moving, and it is the only thing separating the knob from the capsule's edge.
|
|
292
|
+
test("insets the knob inside the capsule", () => {
|
|
293
|
+
expect(cls(sliderVariants({}).thumb())).toMatch(/\bp-\d/);
|
|
294
|
+
expect(cls(sliderVariants({}).knob())).toMatch(/\bflex-1\b/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// The centring is a no-op while the thumb and the track are the same size, and
|
|
298
|
+
// stays because it is load-bearing again the moment they are allowed to differ.
|
|
299
|
+
test("centres the thumb from the track, at every size", () => {
|
|
300
|
+
for (const size of SLIDER_SIZES) {
|
|
301
|
+
for (const orientation of SLIDER_ORIENTATIONS) {
|
|
302
|
+
const track = cls(sliderVariants({ orientation, size }).track());
|
|
303
|
+
expect(track).toMatch(/\bitems-center\b/);
|
|
304
|
+
expect(track).toMatch(/\brelative\b/);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("fades the track and the thumb when disabled, and only then", () => {
|
|
310
|
+
expect(cls(sliderVariants({ isDisabled: true }).root())).toMatch(/\bopacity-50\b/);
|
|
311
|
+
expect(cls(sliderVariants({ isDisabled: false }).root())).not.toMatch(/\bopacity-/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("hands a caller's className to the slot it names", () => {
|
|
315
|
+
expect(sliderVariants({}).track({ className: "h-8" })).toMatch(/\bh-8\b/);
|
|
316
|
+
expect(sliderVariants({}).fill({ className: "bg-accent" })).toMatch(/\bbg-accent\b/);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("SLIDER_OUTPUT_TEXT_SIZE", () => {
|
|
321
|
+
test("names a size Text actually has, for every slider size", () => {
|
|
322
|
+
for (const size of SLIDER_SIZES) {
|
|
323
|
+
expect(TEXT_SIZES).toContain(SLIDER_OUTPUT_TEXT_SIZE[size]);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("steps up with the slider's own size", () => {
|
|
328
|
+
const steps = SLIDER_SIZES.map((size) => TEXT_SIZES.indexOf(SLIDER_OUTPUT_TEXT_SIZE[size]));
|
|
329
|
+
expect(steps).toEqual([...steps].sort((a, b) => a - b));
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("the thumb's animation", () => {
|
|
334
|
+
test("springs rather than easing, and settles", () => {
|
|
335
|
+
expect(SLIDER_THUMB_SPRING.damping).toBeGreaterThan(0);
|
|
336
|
+
expect(SLIDER_THUMB_SPRING.mass).toBeGreaterThan(0);
|
|
337
|
+
expect(SLIDER_THUMB_SPRING.stiffness).toBeGreaterThan(0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// The knob shrinks, the way every pressable in this library does. Growing would
|
|
341
|
+
// push the capsule past the track it sits flush inside.
|
|
342
|
+
test("squeezes the grabbed knob, and rests at its drawn size", () => {
|
|
343
|
+
expect(SLIDER_THUMB_ANIMATION.restScale).toBe(1);
|
|
344
|
+
expect(SLIDER_THUMB_ANIMATION.grabbedScale).toBeLessThan(SLIDER_THUMB_ANIMATION.restScale);
|
|
345
|
+
expect(SLIDER_THUMB_ANIMATION.grabbedScale).toBeGreaterThanOrEqual(0.75);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("toValueArray / fromValueArray", () => {
|
|
350
|
+
test("normalise both shapes to an array", () => {
|
|
351
|
+
expect(toValueArray(30)).toEqual([30]);
|
|
352
|
+
expect(toValueArray([200, 800])).toEqual([200, 800]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("round-trip a caller's own shape", () => {
|
|
356
|
+
expect(fromValueArray(toValueArray(30), false)).toBe(30);
|
|
357
|
+
expect(fromValueArray(toValueArray([200, 800]), true)).toEqual([200, 800]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("copy rather than alias, so a write cannot reach the caller's array", () => {
|
|
361
|
+
const source = [200, 800];
|
|
362
|
+
const copied = toValueArray(source);
|
|
363
|
+
copied[0] = 0;
|
|
364
|
+
expect(source[0]).toBe(200);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("survive an empty array without yielding undefined", () => {
|
|
368
|
+
expect(fromValueArray([], false)).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("progressOf", () => {
|
|
373
|
+
test("maps the ends to 0 and 1", () => {
|
|
374
|
+
expect(progressOf(0, 0, 100)).toBe(0);
|
|
375
|
+
expect(progressOf(100, 0, 100)).toBe(1);
|
|
376
|
+
expect(progressOf(50, 0, 100)).toBe(0.5);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("clamps rather than extrapolating", () => {
|
|
380
|
+
expect(progressOf(-20, 0, 100)).toBe(0);
|
|
381
|
+
expect(progressOf(120, 0, 100)).toBe(1);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("returns 0 for a degenerate range rather than dividing by zero", () => {
|
|
385
|
+
expect(progressOf(5, 5, 5)).toBe(0);
|
|
386
|
+
expect(progressOf(5, 10, 0)).toBe(0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("handles a range that does not start at zero", () => {
|
|
390
|
+
expect(progressOf(-50, -100, 0)).toBe(0.5);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("snapToStep", () => {
|
|
395
|
+
test("lands on multiples measured from the minimum, not from zero", () => {
|
|
396
|
+
expect(snapToStep(23, 10, 0, 100)).toBe(20);
|
|
397
|
+
expect(snapToStep(26, 10, 0, 100)).toBe(30);
|
|
398
|
+
// Offset from the minimum: the reachable values are 5, 15, 25 — not 10, 20.
|
|
399
|
+
expect(snapToStep(13, 10, 5, 100)).toBe(15);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("passes the value through when the step is zero or negative", () => {
|
|
403
|
+
expect(snapToStep(23.7, 0, 0, 100)).toBe(23.7);
|
|
404
|
+
expect(snapToStep(23.7, -1, 0, 100)).toBe(23.7);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("never returns a value outside the range, at any step", () => {
|
|
408
|
+
for (const step of [0, 0.1, 0.25, 1, 7, 10]) {
|
|
409
|
+
for (const value of [-500, -1, 0, 33.3, 99.9, 100, 500]) {
|
|
410
|
+
const snapped = snapToStep(value, step, 0, 100);
|
|
411
|
+
expect(snapped).toBeGreaterThanOrEqual(0);
|
|
412
|
+
expect(snapped).toBeLessThanOrEqual(100);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// A step the range does not divide evenly must not push the last stop past the
|
|
418
|
+
// maximum — 0,7,14…98 then 100, never 105.
|
|
419
|
+
test("clamps a step the range does not divide evenly", () => {
|
|
420
|
+
expect(snapToStep(100, 7, 0, 100)).toBe(100);
|
|
421
|
+
expect(snapToStep(99, 7, 0, 100)).toBe(98);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("does not leak binary floating-point noise", () => {
|
|
425
|
+
expect(snapToStep(0.3, 0.1, 0, 1)).toBe(0.3);
|
|
426
|
+
expect(snapToStep(0.7, 0.1, 0, 1)).toBe(0.7);
|
|
427
|
+
expect(snapToStep(0.26, 0.25, 0, 1)).toBe(0.25);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("clampThumb", () => {
|
|
432
|
+
test("holds a lone thumb inside the range", () => {
|
|
433
|
+
expect(clampThumb(120, [50], 0, 0, 100)).toBe(100);
|
|
434
|
+
expect(clampThumb(-20, [50], 0, 0, 100)).toBe(0);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("stops a thumb at its neighbour rather than letting it cross", () => {
|
|
438
|
+
expect(clampThumb(900, [200, 800], 0, 0, 1000)).toBe(800);
|
|
439
|
+
expect(clampThumb(100, [200, 800], 1, 0, 1000)).toBe(200);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("bounds the outer thumbs by the range and the inner ones by both sides", () => {
|
|
443
|
+
expect(clampThumb(1200, [200, 500, 800], 2, 0, 1000)).toBe(1000);
|
|
444
|
+
expect(clampThumb(-5, [200, 500, 800], 0, 0, 1000)).toBe(0);
|
|
445
|
+
expect(clampThumb(900, [200, 500, 800], 1, 0, 1000)).toBe(800);
|
|
446
|
+
expect(clampThumb(0, [200, 500, 800], 1, 0, 1000)).toBe(200);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// The property that matters: whatever a drag asks for, the order survives.
|
|
450
|
+
test("never lets a thumb pass one of its neighbours, for any request", () => {
|
|
451
|
+
const values = [200, 500, 800];
|
|
452
|
+
for (const index of [0, 1, 2]) {
|
|
453
|
+
for (const request of [-1000, 0, 199, 201, 499, 501, 799, 801, 1000, 5000]) {
|
|
454
|
+
const next = clampThumb(request, values, index, 0, 1000);
|
|
455
|
+
if (index > 0) expect(next).toBeGreaterThanOrEqual(values[index - 1] as number);
|
|
456
|
+
if (index < values.length - 1) expect(next).toBeLessThanOrEqual(values[index + 1] as number);
|
|
457
|
+
expect(next).toBeGreaterThanOrEqual(0);
|
|
458
|
+
expect(next).toBeLessThanOrEqual(1000);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("valueFromOffset", () => {
|
|
465
|
+
test("maps a horizontal position across the travel", () => {
|
|
466
|
+
expect(valueFromOffset({ position: 0, travel: 200, minValue: 0, maxValue: 100, isVertical: false })).toBe(0);
|
|
467
|
+
expect(valueFromOffset({ position: 200, travel: 200, minValue: 0, maxValue: 100, isVertical: false })).toBe(100);
|
|
468
|
+
expect(valueFromOffset({ position: 100, travel: 200, minValue: 0, maxValue: 100, isVertical: false })).toBe(50);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Min at the bottom: a vertical track measures its offset from the top, so the
|
|
472
|
+
// axis is inverted here and in exactly one other place, the translate's sign.
|
|
473
|
+
test("inverts a vertical position, so the bottom is the minimum", () => {
|
|
474
|
+
expect(valueFromOffset({ position: 0, travel: 200, minValue: 0, maxValue: 100, isVertical: true })).toBe(100);
|
|
475
|
+
expect(valueFromOffset({ position: 200, travel: 200, minValue: 0, maxValue: 100, isVertical: true })).toBe(0);
|
|
476
|
+
expect(valueFromOffset({ position: 150, travel: 200, minValue: 0, maxValue: 100, isVertical: true })).toBe(25);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("clamps a position past either end", () => {
|
|
480
|
+
expect(valueFromOffset({ position: -80, travel: 200, minValue: 0, maxValue: 100, isVertical: false })).toBe(0);
|
|
481
|
+
expect(valueFromOffset({ position: 900, travel: 200, minValue: 0, maxValue: 100, isVertical: false })).toBe(100);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Before the first layout the track has no size, and a division would be NaN —
|
|
485
|
+
// which would poison the shared value and freeze the thumb for good.
|
|
486
|
+
test("returns the minimum rather than NaN before the track has been measured", () => {
|
|
487
|
+
expect(valueFromOffset({ position: 40, travel: 0, minValue: 0, maxValue: 100, isVertical: false })).toBe(0);
|
|
488
|
+
expect(valueFromOffset({ position: 40, travel: -10, minValue: 5, maxValue: 100, isVertical: false })).toBe(5);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("carries a range that does not start at zero", () => {
|
|
492
|
+
expect(valueFromOffset({ position: 100, travel: 200, minValue: -100, maxValue: 100, isVertical: false })).toBe(0);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe("nearestThumbIndex", () => {
|
|
497
|
+
test("picks the only thumb there is", () => {
|
|
498
|
+
expect(nearestThumbIndex([50], 90)).toBe(0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("picks the closer of two", () => {
|
|
502
|
+
expect(nearestThumbIndex([200, 800], 300)).toBe(0);
|
|
503
|
+
expect(nearestThumbIndex([200, 800], 700)).toBe(1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// A tie has to resolve the same way every time, or the thumb a press grabs at
|
|
507
|
+
// the exact midpoint depends on nothing the caller can see.
|
|
508
|
+
test("resolves a tie to the lower index, deterministically", () => {
|
|
509
|
+
expect(nearestThumbIndex([200, 800], 500)).toBe(0);
|
|
510
|
+
expect(nearestThumbIndex([0, 100], 50)).toBe(0);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("never returns an index the array does not hold", () => {
|
|
514
|
+
expect(nearestThumbIndex([], 50)).toBe(0);
|
|
515
|
+
for (const target of [-100, 0, 33, 500, 1000]) {
|
|
516
|
+
const index = nearestThumbIndex([200, 500, 800], target);
|
|
517
|
+
expect(index).toBeGreaterThanOrEqual(0);
|
|
518
|
+
expect(index).toBeLessThan(3);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("fillBounds", () => {
|
|
524
|
+
// A lone thumb fills from the minimum; a range fills between its own thumbs.
|
|
525
|
+
test("fills from the start for a lone thumb", () => {
|
|
526
|
+
expect(fillBounds([25], 0, 100)).toEqual({ start: 0, end: 0.25 });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("fills between the thumbs for a range", () => {
|
|
530
|
+
expect(fillBounds([200, 800], 0, 1000)).toEqual({ start: 0.2, end: 0.8 });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// A caller who hands over a descending array gets a fill that still has a
|
|
534
|
+
// positive extent, rather than one drawn backwards.
|
|
535
|
+
test("spans the outermost thumbs however they are ordered", () => {
|
|
536
|
+
expect(fillBounds([800, 200], 0, 1000)).toEqual({ start: 0.2, end: 0.8 });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("never returns a negative extent", () => {
|
|
540
|
+
for (const values of [[0], [100], [0, 0], [100, 0], [50, 50], []]) {
|
|
541
|
+
const { start, end } = fillBounds(values, 0, 100);
|
|
542
|
+
expect(end).toBeGreaterThanOrEqual(start);
|
|
543
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
544
|
+
expect(end).toBeLessThanOrEqual(1);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe("fillExtent", () => {
|
|
550
|
+
// The thumb's diameter *is* the track's thickness, and these two properties are
|
|
551
|
+
// the whole reason for that. Neither holds if the thumb is inset inside the
|
|
552
|
+
// track: an inset leaves stray colour at the minimum and empty groove at the
|
|
553
|
+
// maximum, at every size.
|
|
554
|
+
test("draws exactly one thumb at the minimum, so no colour escapes it", () => {
|
|
555
|
+
for (const thumbSize of [16, 20, 24]) {
|
|
556
|
+
const { offset, extent } = fillExtent({ start: 0, end: 0, travel: 300, thumbSize, isRange: false });
|
|
557
|
+
expect(offset).toBe(0);
|
|
558
|
+
expect(extent).toBe(thumbSize);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("reaches the far end of the track at the maximum", () => {
|
|
563
|
+
for (const thumbSize of [16, 20, 24]) {
|
|
564
|
+
const travel = 300;
|
|
565
|
+
const { offset, extent } = fillExtent({ start: 0, end: 1, travel, thumbSize, isRange: false });
|
|
566
|
+
expect(offset + extent).toBe(travel + thumbSize);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// A range starts at its own first thumb rather than at the track's edge, which
|
|
571
|
+
// is what makes it read as a span rather than as a fill with a hole in it.
|
|
572
|
+
test("starts a range at its first thumb, not at the track's edge", () => {
|
|
573
|
+
const { offset, extent } = fillExtent({ start: 0.2, end: 0.8, travel: 300, thumbSize: 20, isRange: true });
|
|
574
|
+
expect(offset).toBe(60);
|
|
575
|
+
expect(extent).toBe(0.6 * 300 + 20);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("fills the whole track for a range spanning it end to end", () => {
|
|
579
|
+
const { offset, extent } = fillExtent({ start: 0, end: 1, travel: 300, thumbSize: 20, isRange: true });
|
|
580
|
+
expect(offset).toBe(0);
|
|
581
|
+
expect(offset + extent).toBe(320);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// Two thumbs dragged onto one another are still two thumbs. Collapsing to zero
|
|
585
|
+
// would blink the fill out from under them.
|
|
586
|
+
test("collapses a range to one thumb's width, never to nothing", () => {
|
|
587
|
+
for (const at of [0, 0.5, 1]) {
|
|
588
|
+
const { extent } = fillExtent({ start: at, end: at, travel: 300, thumbSize: 20, isRange: true });
|
|
589
|
+
expect(extent).toBe(20);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("never returns a negative extent or an offset past the travel", () => {
|
|
594
|
+
for (const start of [-0.5, 0, 0.3, 0.9, 1, 1.5]) {
|
|
595
|
+
for (const end of [-0.5, 0, 0.3, 0.9, 1, 1.5]) {
|
|
596
|
+
for (const isRange of [false, true]) {
|
|
597
|
+
const { offset, extent } = fillExtent({ start, end, travel: 300, thumbSize: 20, isRange });
|
|
598
|
+
expect(extent).toBeGreaterThanOrEqual(0);
|
|
599
|
+
expect(offset).toBeGreaterThanOrEqual(0);
|
|
600
|
+
expect(offset).toBeLessThanOrEqual(300);
|
|
601
|
+
expect(offset + extent).toBeLessThanOrEqual(320);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// A measured 0 means "not measured yet". Drawing anything then puts a garbage
|
|
608
|
+
// bar on screen for a frame; dividing into it puts NaN on the UI thread, which
|
|
609
|
+
// no later frame recovers from.
|
|
610
|
+
test("draws nothing before the track has been measured", () => {
|
|
611
|
+
for (const travel of [0, -20]) {
|
|
612
|
+
expect(fillExtent({ start: 0, end: 0.5, travel, thumbSize: 20, isRange: false })).toEqual({
|
|
613
|
+
offset: 0,
|
|
614
|
+
extent: 0,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe("formatSliderValue", () => {
|
|
621
|
+
test("formats a lone value", () => {
|
|
622
|
+
expect(formatSliderValue([30])).toBe("30");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("joins a range with the shared separator", () => {
|
|
626
|
+
expect(formatSliderValue([200, 800])).toBe(`200${SLIDER_RANGE_SEPARATOR}800`);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("carries the caller's format options through", () => {
|
|
630
|
+
expect(formatSliderValue([1234.5], { maximumFractionDigits: 0 })).toBe("1,235");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// An en dash, not a hyphen: the separator is a range, and a hyphen beside a
|
|
634
|
+
// negative number is unreadable.
|
|
635
|
+
test("separates with an en dash", () => {
|
|
636
|
+
expect(SLIDER_RANGE_SEPARATOR).toContain("–");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("returns an empty string rather than throwing on no values", () => {
|
|
640
|
+
expect(formatSliderValue([])).toBe("");
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe("resolveSliderAxes", () => {
|
|
645
|
+
test("falls back to the defaults when nothing names an axis", () => {
|
|
646
|
+
expect(resolveSliderAxes({})).toEqual({
|
|
647
|
+
color: SLIDER_DEFAULT_COLOR,
|
|
648
|
+
size: SLIDER_DEFAULT_SIZE,
|
|
649
|
+
orientation: SLIDER_DEFAULT_ORIENTATION,
|
|
650
|
+
isDisabled: false,
|
|
651
|
+
isInvalid: false,
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("takes the slider's own props over everything", () => {
|
|
656
|
+
const axes = resolveSliderAxes({
|
|
657
|
+
own: { color: "success", size: "lg", orientation: "vertical", isDisabled: true, isInvalid: true },
|
|
658
|
+
field: { isDisabled: false, isInvalid: false },
|
|
659
|
+
});
|
|
660
|
+
expect(axes).toEqual({
|
|
661
|
+
color: "success",
|
|
662
|
+
size: "lg",
|
|
663
|
+
orientation: "vertical",
|
|
664
|
+
isDisabled: true,
|
|
665
|
+
isInvalid: true,
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("inherits the two state axes from an enclosing Field", () => {
|
|
670
|
+
const axes = resolveSliderAxes({ field: { isDisabled: true, isInvalid: true } });
|
|
671
|
+
expect(axes.isDisabled).toBe(true);
|
|
672
|
+
expect(axes.isInvalid).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// `??` and never `||`, so an explicit `false` is a value rather than an
|
|
676
|
+
// absence — the rule `pressedScale` already follows.
|
|
677
|
+
test("lets an explicit false opt out of a disabled or invalid Field", () => {
|
|
678
|
+
const axes = resolveSliderAxes({
|
|
679
|
+
own: { isDisabled: false, isInvalid: false },
|
|
680
|
+
field: { isDisabled: true, isInvalid: true },
|
|
681
|
+
});
|
|
682
|
+
expect(axes.isDisabled).toBe(false);
|
|
683
|
+
expect(axes.isInvalid).toBe(false);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// A Field carries no colour, size or orientation, so it must not be able to
|
|
687
|
+
// reach them even if one were added to its context later.
|
|
688
|
+
test("never takes a paint axis from a Field", () => {
|
|
689
|
+
const axes = resolveSliderAxes({ field: { isDisabled: false, isInvalid: false } });
|
|
690
|
+
expect(axes.color).toBe(SLIDER_DEFAULT_COLOR);
|
|
691
|
+
expect(axes.size).toBe(SLIDER_DEFAULT_SIZE);
|
|
692
|
+
expect(axes.orientation).toBe(SLIDER_DEFAULT_ORIENTATION);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
describe("the fill's colours", () => {
|
|
697
|
+
// A slot pointing at a token no theme emits resolves to nothing and draws an
|
|
698
|
+
// invisible fill — silently, since an unresolved variable is not an error.
|
|
699
|
+
test("name a token both variants of the theme declare", () => {
|
|
700
|
+
for (const color of SLIDER_COLORS) {
|
|
701
|
+
const token = backgroundToken(cls(sliderVariants({ color }).fill()));
|
|
702
|
+
expect(token).toBeDefined();
|
|
703
|
+
expect(declarationCount(token as string)).toBe(2);
|
|
704
|
+
}
|
|
705
|
+
expect(declarationCount(backgroundToken(cls(sliderVariants({ isInvalid: true }).fill())) as string)).toBe(2);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// `default` and `primary` name different tokens this theme happens to tune to
|
|
709
|
+
// the same value — `foreground` is the page's ink, `primary` is the brand's
|
|
710
|
+
// action colour, and an app that re-themes one wants the other left alone. The
|
|
711
|
+
// four semantic colours have no such excuse and must stay mutually distinct.
|
|
712
|
+
test("keep the four semantic colours distinct from each other and from the neutrals", () => {
|
|
713
|
+
const semantic = ["success", "warning", "destructive", "info"] as const;
|
|
714
|
+
const tokens = semantic.map((color) => backgroundToken(cls(sliderVariants({ color }).fill())));
|
|
715
|
+
expect(new Set(tokens).size).toBe(semantic.length);
|
|
716
|
+
expect(tokens).not.toContain(backgroundToken(cls(sliderVariants({ color: "default" }).fill())));
|
|
717
|
+
expect(tokens).not.toContain(backgroundToken(cls(sliderVariants({ color: "primary" }).fill())));
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// The groove has to read as empty at every colour, so it must not accidentally
|
|
721
|
+
// be painted in whatever the fill is wearing.
|
|
722
|
+
test("never paint the fill in the track's own colour", () => {
|
|
723
|
+
const track = backgroundToken(cls(sliderVariants({}).track()));
|
|
724
|
+
for (const color of SLIDER_COLORS) {
|
|
725
|
+
expect(backgroundToken(cls(sliderVariants({ color }).fill()))).not.toBe(track);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
describe("both ends of the range", () => {
|
|
731
|
+
// Dragging all the way to the end must reach the maximum the caller wrote,
|
|
732
|
+
// even when the step does not divide the range — see snapToStep.
|
|
733
|
+
test("are reachable at any step", () => {
|
|
734
|
+
for (const step of [0, 0.1, 1, 3, 7, 33]) {
|
|
735
|
+
expect(snapToStep(1000, step, 0, 100)).toBe(100);
|
|
736
|
+
expect(snapToStep(-1000, step, 0, 100)).toBe(0);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("do not add an extra stop anywhere but at the end", () => {
|
|
741
|
+
// 0, 7, 14 … 98, then 100. Nothing between 90 and 98 becomes 100.
|
|
742
|
+
expect(snapToStep(92, 7, 0, 100)).toBe(91);
|
|
743
|
+
expect(snapToStep(95, 7, 0, 100)).toBe(98);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
describe("the two orientations", () => {
|
|
748
|
+
// The inversion lives in exactly one place. This is the test that keeps the
|
|
749
|
+
// vertical branch from drifting into a second, hand-written axis: reading a
|
|
750
|
+
// vertical track from the far end must give the same value as reading a
|
|
751
|
+
// horizontal one from the near end.
|
|
752
|
+
test("are mirror images of one another", () => {
|
|
753
|
+
const travel = 240;
|
|
754
|
+
for (const position of [0, 1, 60, 120, 180, 239, 240]) {
|
|
755
|
+
const horizontal = valueFromOffset({ position, travel, minValue: 0, maxValue: 100, isVertical: false });
|
|
756
|
+
const vertical = valueFromOffset({
|
|
757
|
+
position: travel - position,
|
|
758
|
+
travel,
|
|
759
|
+
minValue: 0,
|
|
760
|
+
maxValue: 100,
|
|
761
|
+
isVertical: true,
|
|
762
|
+
});
|
|
763
|
+
expect(vertical).toBeCloseTo(horizontal, 10);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe("shouldTickHaptic", () => {
|
|
769
|
+
const base = { step: 1, snapped: 50, lastSnapped: 49, position: 100, lastPosition: 0, minValue: 0, maxValue: 100 };
|
|
770
|
+
|
|
771
|
+
test("ticks when the value crosses a step", () => {
|
|
772
|
+
expect(shouldTickHaptic(base)).toBe(true);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// A continuous slider has no stop to land on, so a tick would be reporting the
|
|
776
|
+
// refresh rate rather than the value.
|
|
777
|
+
test("never ticks a continuous slider", () => {
|
|
778
|
+
expect(shouldTickHaptic({ ...base, step: 0 })).toBe(false);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("stays quiet while the value has not changed", () => {
|
|
782
|
+
expect(shouldTickHaptic({ ...base, snapped: 49 })).toBe(false);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// The rate limit: a flick across a fine step scale would otherwise fire a
|
|
786
|
+
// hundred times in a fifth of a second, which is a buzz rather than ticks.
|
|
787
|
+
test("thins out a fine step scale by distance travelled", () => {
|
|
788
|
+
expect(shouldTickHaptic({ ...base, position: SLIDER_HAPTIC_MIN_TRAVEL - 1 })).toBe(false);
|
|
789
|
+
expect(shouldTickHaptic({ ...base, position: SLIDER_HAPTIC_MIN_TRAVEL })).toBe(true);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("always ticks at either end, however fast the drag arrived", () => {
|
|
793
|
+
expect(shouldTickHaptic({ ...base, snapped: 0, position: 1 })).toBe(true);
|
|
794
|
+
expect(shouldTickHaptic({ ...base, snapped: 100, position: 1 })).toBe(true);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("does not tick at an end the slider was already resting on", () => {
|
|
798
|
+
expect(shouldTickHaptic({ ...base, snapped: 100, lastSnapped: 100, position: 1 })).toBe(false);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("keeps the travel gate positive whichever way the drag is going", () => {
|
|
802
|
+
expect(shouldTickHaptic({ ...base, position: 0, lastPosition: SLIDER_HAPTIC_MIN_TRAVEL })).toBe(true);
|
|
803
|
+
});
|
|
804
|
+
});
|