@symbiote-native/slider 0.1.0
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/LICENSE +21 -0
- package/README.md +90 -0
- package/build/angular/index.d.ts +3 -0
- package/build/angular/index.js +2 -0
- package/build/angular/slider/index.android.d.ts +9 -0
- package/build/angular/slider/index.android.js +71 -0
- package/build/angular/slider/index.d.ts +2 -0
- package/build/angular/slider/index.ios.d.ts +11 -0
- package/build/angular/slider/index.ios.js +72 -0
- package/build/angular/slider/index.js +3 -0
- package/build/angular/slider/shared.d.ts +84 -0
- package/build/angular/slider/shared.js +648 -0
- package/build/core/constants.d.ts +20 -0
- package/build/core/constants.js +40 -0
- package/build/core/index.d.ts +7 -0
- package/build/core/index.js +11 -0
- package/build/core/render-slider.d.ts +11 -0
- package/build/core/render-slider.js +66 -0
- package/build/core/render-steps-indicator.d.ts +28 -0
- package/build/core/render-steps-indicator.js +91 -0
- package/build/core/slider-props.d.ts +52 -0
- package/build/core/slider-props.js +6 -0
- package/build/core/slider-state.d.ts +15 -0
- package/build/core/slider-state.js +73 -0
- package/build/react/index.d.ts +4 -0
- package/build/react/index.js +7 -0
- package/build/react/slider/index.android.d.ts +1 -0
- package/build/react/slider/index.android.js +10 -0
- package/build/react/slider/index.d.ts +1 -0
- package/build/react/slider/index.ios.d.ts +1 -0
- package/build/react/slider/index.ios.js +11 -0
- package/build/react/slider/index.js +3 -0
- package/build/react/slider/shared.d.ts +10 -0
- package/build/react/slider/shared.js +175 -0
- package/build/register.d.ts +1 -0
- package/build/register.js +48 -0
- package/build/vue/index.d.ts +3 -0
- package/build/vue/index.js +8 -0
- package/build/vue/slider/index.android.d.ts +18 -0
- package/build/vue/slider/index.android.js +10 -0
- package/build/vue/slider/index.d.ts +2 -0
- package/build/vue/slider/index.ios.d.ts +18 -0
- package/build/vue/slider/index.ios.js +11 -0
- package/build/vue/slider/index.js +3 -0
- package/build/vue/slider/shared.d.ts +26 -0
- package/build/vue/slider/shared.js +254 -0
- package/build-ngc/angular/index.d.ts +3 -0
- package/build-ngc/angular/index.js +3 -0
- package/build-ngc/angular/index.js.map +1 -0
- package/build-ngc/angular/slider/index.android.d.ts +12 -0
- package/build-ngc/angular/slider/index.android.js +28 -0
- package/build-ngc/angular/slider/index.android.js.map +1 -0
- package/build-ngc/angular/slider/index.d.ts +2 -0
- package/build-ngc/angular/slider/index.ios.d.ts +14 -0
- package/build-ngc/angular/slider/index.ios.js +29 -0
- package/build-ngc/angular/slider/index.ios.js.map +1 -0
- package/build-ngc/angular/slider/index.js +4 -0
- package/build-ngc/angular/slider/index.js.map +1 -0
- package/build-ngc/angular/slider/shared.d.ts +87 -0
- package/build-ngc/angular/slider/shared.js +427 -0
- package/build-ngc/angular/slider/shared.js.map +1 -0
- package/build-ngc/core/constants.d.ts +20 -0
- package/build-ngc/core/constants.js +41 -0
- package/build-ngc/core/constants.js.map +1 -0
- package/build-ngc/core/index.d.ts +7 -0
- package/build-ngc/core/index.js +12 -0
- package/build-ngc/core/index.js.map +1 -0
- package/build-ngc/core/render-slider.d.ts +11 -0
- package/build-ngc/core/render-slider.js +67 -0
- package/build-ngc/core/render-slider.js.map +1 -0
- package/build-ngc/core/render-steps-indicator.d.ts +28 -0
- package/build-ngc/core/render-steps-indicator.js +92 -0
- package/build-ngc/core/render-steps-indicator.js.map +1 -0
- package/build-ngc/core/slider-props.d.ts +52 -0
- package/build-ngc/core/slider-props.js +7 -0
- package/build-ngc/core/slider-props.js.map +1 -0
- package/build-ngc/core/slider-state.d.ts +15 -0
- package/build-ngc/core/slider-state.js +74 -0
- package/build-ngc/core/slider-state.js.map +1 -0
- package/build-ngc/register.d.ts +1 -0
- package/build-ngc/register.js +49 -0
- package/build-ngc/register.js.map +1 -0
- package/package.json +116 -0
- package/src/angular/index.ts +4 -0
- package/src/angular/slider/index.android.ts +25 -0
- package/src/angular/slider/index.ios.ts +26 -0
- package/src/angular/slider/index.ts +5 -0
- package/src/angular/slider/shared.ts +364 -0
- package/src/angular/slider/slider.test.ts +260 -0
- package/src/core/constants.ts +47 -0
- package/src/core/index.ts +46 -0
- package/src/core/render-slider.ts +89 -0
- package/src/core/render-steps-indicator.ts +154 -0
- package/src/core/slider-props.ts +83 -0
- package/src/core/slider-state.ts +112 -0
- package/src/react/index.ts +11 -0
- package/src/react/slider/index.android.ts +13 -0
- package/src/react/slider/index.ios.ts +14 -0
- package/src/react/slider/index.ts +4 -0
- package/src/react/slider/shared.ts +332 -0
- package/src/react/slider/slider.test.tsx +203 -0
- package/src/register.test.tsx +89 -0
- package/src/register.ts +59 -0
- package/src/vue/index.ts +11 -0
- package/src/vue/slider/index.android.ts +13 -0
- package/src/vue/slider/index.ios.ts +14 -0
- package/src/vue/slider/index.ts +5 -0
- package/src/vue/slider/shared.ts +383 -0
- package/src/vue/slider/slider.test.ts +184 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Slider, the Vue lifecycle half. The logic (value/limit/disabled folds, the step-option layout)
|
|
2
|
+
// and the native render live in @symbiote-native/slider core, shared verbatim with the future Angular
|
|
3
|
+
// adapter; here Vue supplies the reactivity — a ref tracks the value native last reported (to mark
|
|
4
|
+
// the active step) and a ref the measured width (for the step indicator) — plus the descriptor
|
|
5
|
+
// bridge. The native RNCSlider view carries no symbiote metadata: the engine derives its events
|
|
6
|
+
// and color processors from the library's ViewConfig at runtime. That ViewConfig is registered by
|
|
7
|
+
// the side-effect import in ../register (pulled in by the package barrel, NOT here, so this module
|
|
8
|
+
// and its tests stay free of the third-party spec). See CLAUDE.md
|
|
9
|
+
// <third_party_rn_packages_are_react_only> and ADR 0027.
|
|
10
|
+
|
|
11
|
+
import { defineComponent, h, ref, type SetupContext, type VNode } from '@vue/runtime-core';
|
|
12
|
+
import {
|
|
13
|
+
descriptorToVue,
|
|
14
|
+
normalizeVueAttrs,
|
|
15
|
+
resolveModelValue,
|
|
16
|
+
emitModelUpdate,
|
|
17
|
+
Image,
|
|
18
|
+
type IImageSourceProp,
|
|
19
|
+
} from '@symbiote-native/vue';
|
|
20
|
+
import { dlog, type IClassNameValue, type ISymbioteEvent } from '@symbiote-native/engine';
|
|
21
|
+
import {
|
|
22
|
+
sanitizeSliderValue,
|
|
23
|
+
resolveSliderDisabled,
|
|
24
|
+
resolveSliderAccessibilityState,
|
|
25
|
+
resolveSliderLowerLimit,
|
|
26
|
+
resolveSliderUpperLimit,
|
|
27
|
+
valueFromSliderEvent,
|
|
28
|
+
shouldRenderStepsIndicator,
|
|
29
|
+
resolveThumbTintColor,
|
|
30
|
+
shouldPassNativeThumbImage,
|
|
31
|
+
isInvalidLimitConfig,
|
|
32
|
+
computeStepOptions,
|
|
33
|
+
orderStepOptions,
|
|
34
|
+
stepNumberFontSize,
|
|
35
|
+
renderSlider,
|
|
36
|
+
renderSliderNative,
|
|
37
|
+
resolveStepsContainerStyle,
|
|
38
|
+
renderStepsIndicator,
|
|
39
|
+
STEP_INDICATOR_ELEMENT_STYLE,
|
|
40
|
+
TRACK_MARK_CONTAINER_STYLE,
|
|
41
|
+
THUMB_IMAGE_CONTAINER_STYLE,
|
|
42
|
+
THUMB_IMAGE_STYLE,
|
|
43
|
+
STEP_NUMBER_CONTAINER_STYLE,
|
|
44
|
+
SLIDER_DEFAULT_MINIMUM_VALUE,
|
|
45
|
+
SLIDER_DEFAULT_MAXIMUM_VALUE,
|
|
46
|
+
SLIDER_DEFAULT_STEP,
|
|
47
|
+
SLIDER_ON_CHANGE,
|
|
48
|
+
SLIDER_ON_VALUE_CHANGE,
|
|
49
|
+
SLIDER_ON_SLIDING_START,
|
|
50
|
+
SLIDER_ON_SLIDING_COMPLETE,
|
|
51
|
+
SLIDER_ON_ACCESSIBILITY_ACTION,
|
|
52
|
+
type ISliderPlatform,
|
|
53
|
+
type ISliderProps as ISliderBaseProps,
|
|
54
|
+
type ISliderViewProps,
|
|
55
|
+
type ISliderAccessibilityState,
|
|
56
|
+
} from '../../core';
|
|
57
|
+
|
|
58
|
+
// ISliderProps lives framework-agnostic in packages/slider/src/core; `class` can't join it
|
|
59
|
+
// there, so it's added locally, exactly like Image's IImageProps. It targets the WRAPPER
|
|
60
|
+
// `symbiote-view` like `style`, not the inner native slider leaf `passthrough` reaches — see
|
|
61
|
+
// the class-routing note on HANDLED_ATTRS below.
|
|
62
|
+
export type ISliderProps = Omit<
|
|
63
|
+
ISliderBaseProps,
|
|
64
|
+
'onValueChange' | 'onSlidingStart' | 'onSlidingComplete' | 'onAccessibilityAction'
|
|
65
|
+
> & {
|
|
66
|
+
modelValue?: number;
|
|
67
|
+
class?: IClassNameValue;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type ISliderEmits = {
|
|
71
|
+
valueChange: (value: number) => boolean;
|
|
72
|
+
slidingStart: (value: number) => boolean;
|
|
73
|
+
slidingComplete: (value: number) => boolean;
|
|
74
|
+
accessibilityAction: (event: ISymbioteEvent) => boolean;
|
|
75
|
+
'update:modelValue': (value: number) => boolean;
|
|
76
|
+
'update:value': (value: number) => boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function asNumber(value: unknown): number | undefined {
|
|
84
|
+
return typeof value === 'number' ? value : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isNumber(value: unknown): value is number {
|
|
88
|
+
return typeof value === 'number';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
92
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isImageSourceProp(value: unknown): value is IImageSourceProp {
|
|
96
|
+
return typeof value === 'number' || (typeof value === 'object' && value !== null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// accessibilityState arrives untyped; narrow its disabled flag for the fold while preserving the
|
|
100
|
+
// other entries at runtime (the resolve spread carries them onto the native node).
|
|
101
|
+
function asAccessibilityState(value: unknown): ISliderAccessibilityState | undefined {
|
|
102
|
+
if (!isRecord(value)) return undefined;
|
|
103
|
+
return { ...value, disabled: typeof value.disabled === 'boolean' ? value.disabled : undefined };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The props/handlers the lifecycle consumes itself; everything else (the track tints + images,
|
|
107
|
+
// thumbSize, tapToSeek, vertical, accessibility*/testID/aria-*) forwards onto the native node.
|
|
108
|
+
// The four callbacks are JS-only and must NEVER reach Fabric, so they are stripped here and
|
|
109
|
+
// re-supplied as the native event handlers in `passthrough`. `class` is handled too: like
|
|
110
|
+
// `style`, it targets the WRAPPER `symbiote-view` (renderSlider's outer element), never the
|
|
111
|
+
// inner native slider leaf `passthrough` reaches — applied explicitly by each render path below.
|
|
112
|
+
const HANDLED_ATTRS = [
|
|
113
|
+
'value',
|
|
114
|
+
'modelValue',
|
|
115
|
+
'minimumValue',
|
|
116
|
+
'maximumValue',
|
|
117
|
+
'step',
|
|
118
|
+
'lowerLimit',
|
|
119
|
+
'upperLimit',
|
|
120
|
+
'disabled',
|
|
121
|
+
'inverted',
|
|
122
|
+
'thumbTintColor',
|
|
123
|
+
'thumbImage',
|
|
124
|
+
'accessibilityState',
|
|
125
|
+
'renderStepNumber',
|
|
126
|
+
'style',
|
|
127
|
+
'class',
|
|
128
|
+
'onValueChange',
|
|
129
|
+
'onSlidingStart',
|
|
130
|
+
'onSlidingComplete',
|
|
131
|
+
'onAccessibilityAction',
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function forwardAttrs(attrs: Record<string, unknown>): Record<string, unknown> {
|
|
135
|
+
const result: Record<string, unknown> = {};
|
|
136
|
+
for (const key of Object.keys(attrs)) {
|
|
137
|
+
if (!HANDLED_ATTRS.includes(key)) result[key] = attrs[key];
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// renderSlider builds the wrapper `symbiote-view` descriptor itself, with no `class` field of
|
|
143
|
+
// its own (packages/slider/src/core stays framework-agnostic). `class` is stamped onto that
|
|
144
|
+
// returned descriptor's props here, right before the Vue bridge, so it lands on the SAME
|
|
145
|
+
// element `style` targets rather than the inner native leaf `passthrough` would otherwise
|
|
146
|
+
// carry it to (the ImageBackground-style wrapper/inner split).
|
|
147
|
+
function applyWrapperClass<T extends { props: Record<string, unknown> }>(
|
|
148
|
+
descriptor: T,
|
|
149
|
+
classValue: unknown,
|
|
150
|
+
): T {
|
|
151
|
+
descriptor.props = { ...descriptor.props, class: classValue };
|
|
152
|
+
return descriptor;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createSlider(platform: ISliderPlatform) {
|
|
156
|
+
return defineComponent<ISliderProps, ISliderEmits>(
|
|
157
|
+
(_props, { attrs: rawAttrs, slots, emit }: SetupContext<ISliderEmits>) => {
|
|
158
|
+
// The value native last reported, kept only to mark the active step in the indicator. Not
|
|
159
|
+
// the controlled value — the slider is uncontrolled during a drag (no snap-back).
|
|
160
|
+
const reportedValue = ref<number | undefined>(undefined);
|
|
161
|
+
// The measured wrapper width the step indicator lays out against; 0 until the first layout.
|
|
162
|
+
const width = ref(0);
|
|
163
|
+
|
|
164
|
+
const handleValueChange = (event: ISymbioteEvent): void => {
|
|
165
|
+
const value = valueFromSliderEvent(event);
|
|
166
|
+
if (value === undefined) return;
|
|
167
|
+
reportedValue.value = value;
|
|
168
|
+
emit('valueChange', value);
|
|
169
|
+
emitModelUpdate<number>(emit, value);
|
|
170
|
+
};
|
|
171
|
+
const handleSlidingStart = (event: ISymbioteEvent): void => {
|
|
172
|
+
const value = valueFromSliderEvent(event);
|
|
173
|
+
if (value !== undefined) emit('slidingStart', value);
|
|
174
|
+
};
|
|
175
|
+
const handleSlidingComplete = (event: ISymbioteEvent): void => {
|
|
176
|
+
const value = valueFromSliderEvent(event);
|
|
177
|
+
if (value !== undefined) emit('slidingComplete', value);
|
|
178
|
+
};
|
|
179
|
+
const handleAccessibilityAction = (event: ISymbioteEvent): void => {
|
|
180
|
+
emit('accessibilityAction', event);
|
|
181
|
+
};
|
|
182
|
+
const handleLayout = (event: ISymbioteEvent): void => {
|
|
183
|
+
const layout = event.nativeEvent.layout;
|
|
184
|
+
if (isRecord(layout) && typeof layout.width === 'number') width.value = layout.width;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
const attrs = normalizeVueAttrs(rawAttrs);
|
|
189
|
+
|
|
190
|
+
const minimumValue = asNumber(attrs.minimumValue) ?? SLIDER_DEFAULT_MINIMUM_VALUE;
|
|
191
|
+
const maximumValue = asNumber(attrs.maximumValue) ?? SLIDER_DEFAULT_MAXIMUM_VALUE;
|
|
192
|
+
const step = asNumber(attrs.step) ?? SLIDER_DEFAULT_STEP;
|
|
193
|
+
const lowerLimit = resolveSliderLowerLimit(asNumber(attrs.lowerLimit));
|
|
194
|
+
const upperLimit = resolveSliderUpperLimit(asNumber(attrs.upperLimit));
|
|
195
|
+
if (isInvalidLimitConfig(lowerLimit, upperLimit)) {
|
|
196
|
+
dlog('Slider: lowerLimit must be smaller than upperLimit');
|
|
197
|
+
}
|
|
198
|
+
const disabledAttr = asBoolean(attrs.disabled);
|
|
199
|
+
const accessibilityState = asAccessibilityState(attrs.accessibilityState);
|
|
200
|
+
const inverted = asBoolean(attrs.inverted) ?? false;
|
|
201
|
+
|
|
202
|
+
const hasStepMarker = slots.stepMarker !== undefined;
|
|
203
|
+
const renderStepNumber = asBoolean(attrs.renderStepNumber);
|
|
204
|
+
const hasThumbImage = attrs.thumbImage !== undefined;
|
|
205
|
+
const showSteps = shouldRenderStepsIndicator(hasStepMarker, renderStepNumber);
|
|
206
|
+
|
|
207
|
+
// The native view gets a thumbImage only when there is one AND no custom marker (which
|
|
208
|
+
// draws its own thumb), matching the library. We pass the source raw — the engine runs the
|
|
209
|
+
// image processor derived from RNCSlider's ViewConfig (same path as the color tints), so
|
|
210
|
+
// unlike the plain-RN library wrapper we do NOT pre-call Image.resolveAssetSource here.
|
|
211
|
+
const nativeThumbImage = shouldPassNativeThumbImage(hasStepMarker, hasThumbImage)
|
|
212
|
+
? attrs.thumbImage
|
|
213
|
+
: undefined;
|
|
214
|
+
|
|
215
|
+
const view: ISliderViewProps = {
|
|
216
|
+
value: sanitizeSliderValue(resolveModelValue(attrs, isNumber)),
|
|
217
|
+
minimumValue,
|
|
218
|
+
maximumValue,
|
|
219
|
+
step,
|
|
220
|
+
lowerLimit,
|
|
221
|
+
upperLimit,
|
|
222
|
+
disabled: resolveSliderDisabled(disabledAttr, accessibilityState),
|
|
223
|
+
inverted,
|
|
224
|
+
thumbTintColor: resolveThumbTintColor(attrs.thumbTintColor, hasStepMarker, hasThumbImage),
|
|
225
|
+
thumbImage: nativeThumbImage,
|
|
226
|
+
accessibilityState: resolveSliderAccessibilityState(disabledAttr, accessibilityState),
|
|
227
|
+
width: width.value,
|
|
228
|
+
style: attrs.style,
|
|
229
|
+
passthrough: {
|
|
230
|
+
...forwardAttrs(attrs),
|
|
231
|
+
[SLIDER_ON_CHANGE]: handleValueChange,
|
|
232
|
+
[SLIDER_ON_VALUE_CHANGE]: handleValueChange,
|
|
233
|
+
[SLIDER_ON_SLIDING_START]: handleSlidingStart,
|
|
234
|
+
[SLIDER_ON_SLIDING_COMPLETE]: handleSlidingComplete,
|
|
235
|
+
[SLIDER_ON_ACCESSIBILITY_ACTION]: handleAccessibilityAction,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Common case: no step overlay. The full slider (wrapper + native leaf) renders agnostic.
|
|
240
|
+
if (!showSteps) {
|
|
241
|
+
return descriptorToVue(
|
|
242
|
+
applyWrapperClass(
|
|
243
|
+
renderSlider(view, platform, { onLayout: handleLayout }),
|
|
244
|
+
attrs.class,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const options = computeStepOptions(
|
|
250
|
+
minimumValue,
|
|
251
|
+
maximumValue,
|
|
252
|
+
step,
|
|
253
|
+
platform.stepResolution,
|
|
254
|
+
);
|
|
255
|
+
const currentValue = reportedValue.value ?? view.value ?? minimumValue;
|
|
256
|
+
|
|
257
|
+
// A custom StepMarker is a per-adapter element (a Vue slot), so its overlay is assembled
|
|
258
|
+
// here at the VNode level; the default overlay (numbers / thumbImage only) is the shared
|
|
259
|
+
// agnostic render. Either way the wrapper measures width via onLayout.
|
|
260
|
+
if (hasStepMarker) {
|
|
261
|
+
const overlay = renderCustomStepsOverlay({
|
|
262
|
+
options,
|
|
263
|
+
currentValue,
|
|
264
|
+
minimumValue,
|
|
265
|
+
maximumValue,
|
|
266
|
+
inverted,
|
|
267
|
+
renderStepNumber: renderStepNumber === true,
|
|
268
|
+
thumbImage: attrs.thumbImage,
|
|
269
|
+
width: width.value,
|
|
270
|
+
platform,
|
|
271
|
+
stepMarker: slots.stepMarker,
|
|
272
|
+
});
|
|
273
|
+
return h(
|
|
274
|
+
'symbiote-view',
|
|
275
|
+
{
|
|
276
|
+
style: resolveStepsWrapperStyle(view.style, platform),
|
|
277
|
+
class: attrs.class,
|
|
278
|
+
onLayout: handleLayout,
|
|
279
|
+
},
|
|
280
|
+
[overlay, descriptorToVue(renderSliderNative(view, platform))],
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const steps = renderStepsIndicator({
|
|
285
|
+
options,
|
|
286
|
+
currentValue,
|
|
287
|
+
width: width.value,
|
|
288
|
+
renderStepNumber: renderStepNumber === true,
|
|
289
|
+
thumbImage: attrs.thumbImage,
|
|
290
|
+
inverted,
|
|
291
|
+
platform,
|
|
292
|
+
});
|
|
293
|
+
return descriptorToVue(
|
|
294
|
+
applyWrapperClass(
|
|
295
|
+
renderSlider(view, platform, { steps, onLayout: handleLayout }),
|
|
296
|
+
attrs.class,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'Slider',
|
|
303
|
+
inheritAttrs: false,
|
|
304
|
+
emits: {
|
|
305
|
+
valueChange: (_value: number): boolean => true,
|
|
306
|
+
slidingStart: (_value: number): boolean => true,
|
|
307
|
+
slidingComplete: (_value: number): boolean => true,
|
|
308
|
+
accessibilityAction: (_event: ISymbioteEvent): boolean => true,
|
|
309
|
+
'update:modelValue': (_value: number): boolean => true,
|
|
310
|
+
'update:value': (_value: number): boolean => true,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The wrapper style for the steps path mirrors renderSlider's wrapper (kept here because the
|
|
317
|
+
// custom-marker path builds the wrapper itself rather than going through renderSlider).
|
|
318
|
+
function resolveStepsWrapperStyle(style: unknown, platform: ISliderPlatform): unknown {
|
|
319
|
+
return [platform.defaultStyle, style, { justifyContent: 'center' }];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
type ICustomStepsParams = {
|
|
323
|
+
options: readonly number[];
|
|
324
|
+
currentValue: number;
|
|
325
|
+
minimumValue: number;
|
|
326
|
+
maximumValue: number;
|
|
327
|
+
inverted: boolean;
|
|
328
|
+
renderStepNumber: boolean;
|
|
329
|
+
thumbImage: unknown;
|
|
330
|
+
width: number;
|
|
331
|
+
platform: ISliderPlatform;
|
|
332
|
+
stepMarker: SetupContext['slots'][string];
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// The custom-marker overlay: the same flex row as the agnostic default, but each mark hosts the
|
|
336
|
+
// user's `#stepMarker` scoped slot (plus the thumbImage on the current step and the optional step
|
|
337
|
+
// number), exactly as the library's SliderTrackMark composes a StepMarker + Image.
|
|
338
|
+
function renderCustomStepsOverlay(params: ICustomStepsParams): VNode {
|
|
339
|
+
const fontSize = stepNumberFontSize(params.options.length);
|
|
340
|
+
const ordered = orderStepOptions(params.options, params.inverted);
|
|
341
|
+
const min = params.options[0];
|
|
342
|
+
const max = params.options[params.options.length - 1];
|
|
343
|
+
const cells = ordered.map((value, index) => {
|
|
344
|
+
const trackChildren: VNode[] = [];
|
|
345
|
+
const marker = params.stepMarker?.({
|
|
346
|
+
stepMarked: value === params.currentValue,
|
|
347
|
+
currentValue: params.currentValue,
|
|
348
|
+
index,
|
|
349
|
+
min,
|
|
350
|
+
max,
|
|
351
|
+
});
|
|
352
|
+
if (marker !== undefined) trackChildren.push(h('symbiote-view', {}, marker));
|
|
353
|
+
if (isImageSourceProp(params.thumbImage) && value === params.currentValue) {
|
|
354
|
+
trackChildren.push(
|
|
355
|
+
h(
|
|
356
|
+
'symbiote-view',
|
|
357
|
+
{ style: THUMB_IMAGE_CONTAINER_STYLE, testID: 'sliderTrackMark-thumbImage' },
|
|
358
|
+
[h(Image, { source: params.thumbImage, style: THUMB_IMAGE_STYLE })],
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const cellChildren: VNode[] = [
|
|
363
|
+
h('symbiote-view', { style: TRACK_MARK_CONTAINER_STYLE }, trackChildren),
|
|
364
|
+
];
|
|
365
|
+
if (params.renderStepNumber) {
|
|
366
|
+
cellChildren.push(
|
|
367
|
+
h('symbiote-view', { style: STEP_NUMBER_CONTAINER_STYLE }, [
|
|
368
|
+
h('symbiote-text', { testID: `${index}th-step`, style: { fontSize } }, String(value)),
|
|
369
|
+
]),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return h('symbiote-view', { style: STEP_INDICATOR_ELEMENT_STYLE }, cellChildren);
|
|
373
|
+
});
|
|
374
|
+
return h(
|
|
375
|
+
'symbiote-view',
|
|
376
|
+
{
|
|
377
|
+
pointerEvents: 'none',
|
|
378
|
+
testID: 'StepsIndicator-Container',
|
|
379
|
+
style: resolveStepsContainerStyle(params.width, params.platform),
|
|
380
|
+
},
|
|
381
|
+
cells,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Co-located Vue-driven test (ADR 0025) for the @symbiote-native/slider Vue wrapper. The native RNCSlider
|
|
2
|
+
// carries no symbiote metadata — the engine DERIVES its events + tint processors from an injected
|
|
3
|
+
// codegen-shaped ViewConfig (the same shape the React adapter's slider.test injects, the same shape
|
|
4
|
+
// RN's ReactNativeViewConfigRegistry holds on a real host). We import the Slider component from '.'
|
|
5
|
+
// (NOT the package barrel) so the third-party native-spec side-effect (../register) never loads
|
|
6
|
+
// headless. Proves: the wrapper paints the raw RNCSlider leaf inside its centering View, folds
|
|
7
|
+
// value/disabled/limits faithfully to the library, forwards + processes the tints, and maps the
|
|
8
|
+
// native value/sliding events onto onValueChange / onSlidingStart / onSlidingComplete.
|
|
9
|
+
|
|
10
|
+
import { defineComponent, h } from '@vue/runtime-core';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { mount, unmount, setNativeViewConfigSource } from '@symbiote-native/vue';
|
|
13
|
+
import { installFabric, type IFakeNode } from '@symbiote-native/test-utils';
|
|
14
|
+
import { Slider } from '.';
|
|
15
|
+
|
|
16
|
+
const ROOT_TAG = 311;
|
|
17
|
+
const SLIDER_VIEW = 'RNCSlider';
|
|
18
|
+
|
|
19
|
+
const fakeColor = (value: unknown): string => `processed(${value})`;
|
|
20
|
+
|
|
21
|
+
// The codegen-shaped config the engine derives from: both value rails (bubbling), the two sliding
|
|
22
|
+
// rails + accessibility (direct), plain pass-through attributes, and the tint processors.
|
|
23
|
+
const RNC_SLIDER_VIEW_CONFIG = {
|
|
24
|
+
bubblingEventTypes: {
|
|
25
|
+
topChange: { phasedRegistrationNames: { bubbled: 'onChange', captured: 'onChangeCapture' } },
|
|
26
|
+
topRNCSliderValueChange: {
|
|
27
|
+
phasedRegistrationNames: {
|
|
28
|
+
bubbled: 'onRNCSliderValueChange',
|
|
29
|
+
captured: 'onRNCSliderValueChangeCapture',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
directEventTypes: {
|
|
34
|
+
topRNCSliderSlidingStart: { registrationName: 'onRNCSliderSlidingStart' },
|
|
35
|
+
topRNCSliderSlidingComplete: { registrationName: 'onRNCSliderSlidingComplete' },
|
|
36
|
+
topRNCSliderAccessibilityAction: { registrationName: 'onRNCSliderAccessibilityAction' },
|
|
37
|
+
},
|
|
38
|
+
validAttributes: {
|
|
39
|
+
value: true,
|
|
40
|
+
minimumValue: true,
|
|
41
|
+
maximumValue: true,
|
|
42
|
+
step: true,
|
|
43
|
+
lowerLimit: true,
|
|
44
|
+
upperLimit: true,
|
|
45
|
+
inverted: true,
|
|
46
|
+
disabled: true,
|
|
47
|
+
minimumTrackTintColor: { process: fakeColor },
|
|
48
|
+
maximumTrackTintColor: { process: fakeColor },
|
|
49
|
+
thumbTintColor: { process: fakeColor },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const fabric = installFabric();
|
|
54
|
+
setNativeViewConfigSource(name => (name === SLIDER_VIEW ? RNC_SLIDER_VIEW_CONFIG : undefined));
|
|
55
|
+
|
|
56
|
+
const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
|
|
57
|
+
|
|
58
|
+
beforeEach(() => fabric.reset());
|
|
59
|
+
afterEach(() => unmount(ROOT_TAG));
|
|
60
|
+
|
|
61
|
+
function sliderNode(): IFakeNode {
|
|
62
|
+
const node = fabric.find(n => n.viewName === SLIDER_VIEW);
|
|
63
|
+
if (!node) throw new Error(`no ${SLIDER_VIEW} was created`);
|
|
64
|
+
return node;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function mountSlider(props: Record<string, unknown>): Promise<void> {
|
|
68
|
+
mount(ROOT_TAG, defineComponent({ setup: () => () => h(Slider, props) }));
|
|
69
|
+
await tick();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('Vue Slider wrapper', () => {
|
|
73
|
+
it('paints the raw RNCSlider leaf inside a centering wrapper View', async () => {
|
|
74
|
+
await mountSlider({ value: 0.5, minimumValue: 0, maximumValue: 1, step: 0.1 });
|
|
75
|
+
const props = sliderNode().props;
|
|
76
|
+
expect(props.value).toBe(0.5);
|
|
77
|
+
expect(props.minimumValue).toBe(0);
|
|
78
|
+
expect(props.maximumValue).toBe(1);
|
|
79
|
+
expect(props.step).toBe(0.1);
|
|
80
|
+
// The native leaf lives under a symbiote-view wrapper (RCTView), not at the root.
|
|
81
|
+
expect(fabric.find(n => n.viewName === 'RCTView')).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('defaults range + limits the way the library wrapper does', async () => {
|
|
85
|
+
await mountSlider({ value: 0.3 });
|
|
86
|
+
const props = sliderNode().props;
|
|
87
|
+
expect(props.minimumValue).toBe(0);
|
|
88
|
+
expect(props.maximumValue).toBe(1);
|
|
89
|
+
expect(props.step).toBe(0);
|
|
90
|
+
expect(props.lowerLimit).toBe(Number.MIN_SAFE_INTEGER);
|
|
91
|
+
expect(props.upperLimit).toBe(Number.MAX_SAFE_INTEGER);
|
|
92
|
+
expect(props.inverted).toBe(false);
|
|
93
|
+
expect(props.disabled).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('sanitizes a falsy/NaN value to undefined (library passedValue quirk)', async () => {
|
|
97
|
+
await mountSlider({ value: 0 });
|
|
98
|
+
expect(sliderNode().props.value).toBeUndefined();
|
|
99
|
+
unmount(ROOT_TAG);
|
|
100
|
+
fabric.reset();
|
|
101
|
+
await mountSlider({ value: Number.NaN });
|
|
102
|
+
expect(sliderNode().props.value).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('forwards tint props and runs them through the derived processor', async () => {
|
|
106
|
+
await mountSlider({
|
|
107
|
+
value: 0.2,
|
|
108
|
+
minimumTrackTintColor: '#ff0000',
|
|
109
|
+
maximumTrackTintColor: '#00ff00',
|
|
110
|
+
thumbTintColor: '#0000ff',
|
|
111
|
+
});
|
|
112
|
+
const props = sliderNode().props;
|
|
113
|
+
expect(props.minimumTrackTintColor).toBe('processed(#ff0000)');
|
|
114
|
+
expect(props.maximumTrackTintColor).toBe('processed(#00ff00)');
|
|
115
|
+
expect(props.thumbTintColor).toBe('processed(#0000ff)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('maps both native value rails onto onValueChange(value)', async () => {
|
|
119
|
+
let changed: number | undefined;
|
|
120
|
+
await mountSlider({ value: 0.2, onValueChange: (value: number) => (changed = value) });
|
|
121
|
+
const node = sliderNode();
|
|
122
|
+
fabric.fireEvent(node.instanceHandle, 'topChange', { value: 0.7 });
|
|
123
|
+
expect(changed).toBe(0.7);
|
|
124
|
+
fabric.fireEvent(node.instanceHandle, 'topRNCSliderValueChange', { value: 0.42 });
|
|
125
|
+
expect(changed).toBe(0.42);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('maps the direct sliding events onto their callbacks', async () => {
|
|
129
|
+
let startedAt: number | undefined;
|
|
130
|
+
let completedAt: number | undefined;
|
|
131
|
+
await mountSlider({
|
|
132
|
+
value: 0.2,
|
|
133
|
+
onSlidingStart: (value: number) => (startedAt = value),
|
|
134
|
+
onSlidingComplete: (value: number) => (completedAt = value),
|
|
135
|
+
});
|
|
136
|
+
const node = sliderNode();
|
|
137
|
+
fabric.fireEvent(node.instanceHandle, 'topRNCSliderSlidingStart', { value: 0.1 });
|
|
138
|
+
fabric.fireEvent(node.instanceHandle, 'topRNCSliderSlidingComplete', { value: 0.9 });
|
|
139
|
+
expect(startedAt).toBe(0.1);
|
|
140
|
+
expect(completedAt).toBe(0.9);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('resolves disabled from accessibilityState when no explicit boolean', async () => {
|
|
144
|
+
await mountSlider({ value: 0.2, accessibilityState: { disabled: true } });
|
|
145
|
+
expect(sliderNode().props.disabled).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('renders the step indicator when renderStepNumber is set', async () => {
|
|
149
|
+
await mountSlider({
|
|
150
|
+
value: 0.5,
|
|
151
|
+
minimumValue: 0,
|
|
152
|
+
maximumValue: 1,
|
|
153
|
+
step: 0.5,
|
|
154
|
+
renderStepNumber: true,
|
|
155
|
+
});
|
|
156
|
+
const container = fabric.find(n => n.props.testID === 'StepsIndicator-Container');
|
|
157
|
+
expect(container, 'a StepsIndicator container is painted').toBeDefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does NOT leak the JS onValueChange callback to the native node as a prop', async () => {
|
|
161
|
+
await mountSlider({ value: 0.2, onValueChange: () => undefined });
|
|
162
|
+
expect(typeof sliderNode().props.onValueChange).not.toBe('function');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('accepts modelValue as an alias for value, never forwarding it to the native node', async () => {
|
|
166
|
+
await mountSlider({ modelValue: 0.6 });
|
|
167
|
+
const props = sliderNode().props;
|
|
168
|
+
expect(props.value).toBe(0.6);
|
|
169
|
+
expect('modelValue' in props, 'modelValue must not reach Fabric').toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('emits update:modelValue and update:value alongside valueChange', async () => {
|
|
173
|
+
let modelValueUpdate: number | undefined;
|
|
174
|
+
let valueUpdate: number | undefined;
|
|
175
|
+
await mountSlider({
|
|
176
|
+
modelValue: 0.2,
|
|
177
|
+
'onUpdate:modelValue': (value: number) => (modelValueUpdate = value),
|
|
178
|
+
'onUpdate:value': (value: number) => (valueUpdate = value),
|
|
179
|
+
});
|
|
180
|
+
fabric.fireEvent(sliderNode().instanceHandle, 'topChange', { value: 0.7 });
|
|
181
|
+
expect(modelValueUpdate).toBe(0.7);
|
|
182
|
+
expect(valueUpdate).toBe(0.7);
|
|
183
|
+
});
|
|
184
|
+
});
|