ember-primitives 0.59.1 → 0.60.1

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.
@@ -0,0 +1,374 @@
1
+ import { tracked } from '@glimmer/tracking';
2
+ import { htmlSafe } from '@ember/template';
3
+
4
+ /**
5
+ * `style` attribute values need to be SafeStrings to avoid Ember's
6
+ * style-binding warning.
7
+ */
8
+ export type StyleString = ReturnType<typeof htmlSafe>;
9
+
10
+ export interface SliderStoreArgs {
11
+ value?: number | number[];
12
+ min?: number;
13
+ max?: number;
14
+ step?: number | number[];
15
+ orientation?: 'horizontal' | 'vertical';
16
+ disabled?: boolean;
17
+ onValueChange?: (value: number | number[]) => void;
18
+ onValueCommit?: (value: number | number[]) => void;
19
+ }
20
+
21
+ export interface SliderThumb {
22
+ index: number;
23
+ value: number;
24
+ /**
25
+ * The value to pass to `<input type="range">`.
26
+ *
27
+ * When using an array `step`, this is the internal index (0..n-1).
28
+ * Otherwise it's the same as `value`.
29
+ */
30
+ inputValue: number;
31
+ percent: number;
32
+ active: boolean;
33
+ /**
34
+ * Inline style positioning this thumb along the track
35
+ * (`left: x%` or `bottom: x%`, depending on orientation).
36
+ */
37
+ positionStyle: StyleString;
38
+ }
39
+
40
+ const DEFAULT_MIN = 0;
41
+ const DEFAULT_MAX = 100;
42
+ const DEFAULT_STEP = 1;
43
+
44
+ function clamp(value: number, min: number, max: number): number {
45
+ return Math.min(Math.max(value, min), max);
46
+ }
47
+
48
+ function roundToStep(value: number, step: number): number {
49
+ if (!Number.isFinite(step) || step <= 0) return value;
50
+
51
+ return Math.round(value / step) * step;
52
+ }
53
+
54
+ function getPercentage(value: number, min: number, max: number): number {
55
+ const range = max - min;
56
+
57
+ if (!Number.isFinite(range) || range === 0) return 0;
58
+
59
+ return ((value - min) / range) * 100;
60
+ }
61
+
62
+ function normalizeTickValues(values: number[]): number[] {
63
+ const uniques = new Set<number>();
64
+
65
+ for (const value of values) {
66
+ if (Number.isFinite(value)) uniques.add(value);
67
+ }
68
+
69
+ return Array.from(uniques).sort((a, b) => a - b);
70
+ }
71
+
72
+ function findNearestIndex(values: number[], target: number): number {
73
+ if (values.length === 0) return 0;
74
+
75
+ const first = values[0];
76
+
77
+ if (first === undefined) return 0;
78
+
79
+ let nearestIndex = 0;
80
+ let nearestDistance = Math.abs(first - target);
81
+
82
+ for (let index = 1; index < values.length; index++) {
83
+ const candidate = values[index];
84
+
85
+ if (candidate === undefined) continue;
86
+
87
+ const distance = Math.abs(candidate - target);
88
+
89
+ if (distance < nearestDistance) {
90
+ nearestIndex = index;
91
+ nearestDistance = distance;
92
+ }
93
+ }
94
+
95
+ return nearestIndex;
96
+ }
97
+
98
+ class ThumbState implements SliderThumb {
99
+ #slider: SliderStore;
100
+
101
+ constructor(
102
+ slider: SliderStore,
103
+ public index: number
104
+ ) {
105
+ this.#slider = slider;
106
+ }
107
+
108
+ get value(): number {
109
+ return this.#slider.values[this.index] ?? this.#slider.min;
110
+ }
111
+
112
+ get inputValue(): number {
113
+ const ticks = this.#slider.tickValues;
114
+
115
+ if (!ticks) return this.value;
116
+
117
+ return clamp(findNearestIndex(ticks, this.value), 0, Math.max(0, ticks.length - 1));
118
+ }
119
+
120
+ get percent(): number {
121
+ return this.#slider.thumbPercents[this.index] ?? 0;
122
+ }
123
+
124
+ get active(): boolean {
125
+ return this.#slider.activeThumbIndex === this.index;
126
+ }
127
+
128
+ get positionStyle(): StyleString {
129
+ return this.#slider.thumbPositionStyle(this.percent);
130
+ }
131
+ }
132
+
133
+ export class SliderStore {
134
+ #thumbStates: ThumbState[] = [];
135
+ #getArgs: () => SliderStoreArgs;
136
+
137
+ @tracked activeThumbIndex: number | null = null;
138
+
139
+ constructor(getArgs: () => SliderStoreArgs) {
140
+ this.#getArgs = getArgs;
141
+
142
+ const args = this.#getArgs();
143
+ const initialCount = Array.isArray(args.value) ? Math.max(1, args.value.length) : 1;
144
+
145
+ this.#thumbStates = Array.from(
146
+ { length: initialCount },
147
+ (_, index) => new ThumbState(this, index)
148
+ );
149
+ }
150
+
151
+ get #args(): SliderStoreArgs {
152
+ return this.#getArgs();
153
+ }
154
+
155
+ get min(): number {
156
+ return this.#args.min ?? DEFAULT_MIN;
157
+ }
158
+
159
+ get max(): number {
160
+ return this.#args.max ?? DEFAULT_MAX;
161
+ }
162
+
163
+ get step(): number {
164
+ return typeof this.#args.step === 'number' ? this.#args.step : DEFAULT_STEP;
165
+ }
166
+
167
+ get tickValues(): number[] | null {
168
+ const fromStep = Array.isArray(this.#args.step) ? this.#args.step : undefined;
169
+ const raw = fromStep;
170
+
171
+ if (!raw) return null;
172
+
173
+ const normalized = normalizeTickValues(raw);
174
+
175
+ return normalized.length === 0 ? null : normalized;
176
+ }
177
+
178
+ get orientation(): 'horizontal' | 'vertical' {
179
+ return this.#args.orientation ?? 'horizontal';
180
+ }
181
+
182
+ get disabled(): boolean {
183
+ return this.#args.disabled ?? false;
184
+ }
185
+
186
+ get internalMin(): number {
187
+ const ticks = this.tickValues;
188
+
189
+ if (ticks) return 0;
190
+
191
+ return this.min;
192
+ }
193
+
194
+ get internalMax(): number {
195
+ const ticks = this.tickValues;
196
+
197
+ if (ticks) return Math.max(0, ticks.length - 1);
198
+
199
+ return this.max;
200
+ }
201
+
202
+ get internalStep(): number {
203
+ const ticks = this.tickValues;
204
+
205
+ if (ticks) return 1;
206
+
207
+ return this.step;
208
+ }
209
+
210
+ get internalValues(): number[] {
211
+ const ticks = this.tickValues;
212
+ const normalized = Array.isArray(this.#args.value)
213
+ ? this.#args.value.length === 0
214
+ ? [ticks?.[0] ?? this.min]
215
+ : this.#args.value
216
+ : [this.#args.value ?? ticks?.[0] ?? this.min];
217
+
218
+ if (ticks) {
219
+ return normalized.map((v) => {
220
+ const index = findNearestIndex(ticks, v);
221
+
222
+ return clamp(index, this.internalMin, this.internalMax);
223
+ });
224
+ }
225
+
226
+ return normalized.map((v) => clamp(roundToStep(v, this.step), this.min, this.max));
227
+ }
228
+
229
+ outputValuesFromInternal(internalValues: number[]): number[] {
230
+ const ticks = this.tickValues;
231
+
232
+ if (!ticks) return internalValues;
233
+
234
+ return internalValues.map((internal) => {
235
+ const index = clamp(Math.round(internal), 0, ticks.length - 1);
236
+
237
+ return ticks[index] ?? ticks[0] ?? this.min;
238
+ });
239
+ }
240
+
241
+ get values(): number[] {
242
+ return this.outputValuesFromInternal(this.internalValues);
243
+ }
244
+
245
+ get thumbs(): SliderThumb[] {
246
+ this.#ensureThumbCount(this.internalValues.length);
247
+
248
+ return this.#thumbStates;
249
+ }
250
+
251
+ get thumbPercents(): number[] {
252
+ return this.internalValues.map((value) =>
253
+ getPercentage(value, this.internalMin, this.internalMax)
254
+ );
255
+ }
256
+
257
+ get isMulti(): boolean {
258
+ return this.internalValues.length > 1;
259
+ }
260
+
261
+ thumbPositionStyle = (percent: number): StyleString => {
262
+ const property = this.orientation === 'horizontal' ? 'left' : 'bottom';
263
+
264
+ return htmlSafe(`${property}: ${percent}%`);
265
+ };
266
+
267
+ get rangeStyle(): StyleString {
268
+ const internalValues = this.internalValues;
269
+
270
+ // For a single-thumb slider, the "range" should fill from the start to the
271
+ // thumb. For multi-thumb, it fills between the min/max thumbs.
272
+ const internalRangeMin =
273
+ internalValues.length <= 1 ? this.internalMin : Math.min(...internalValues);
274
+ const internalRangeMax =
275
+ internalValues[0] === undefined ? this.internalMin : Math.max(...internalValues);
276
+ const startPercent = getPercentage(internalRangeMin, this.internalMin, this.internalMax);
277
+ const endPercent = getPercentage(internalRangeMax, this.internalMin, this.internalMax);
278
+
279
+ if (this.orientation === 'horizontal') {
280
+ return htmlSafe(`left: ${startPercent}%; right: ${100 - endPercent}%`);
281
+ } else {
282
+ return htmlSafe(`bottom: ${startPercent}%; top: ${100 - endPercent}%`);
283
+ }
284
+ }
285
+
286
+ updateValue = (newValues: number[]) => {
287
+ if (this.#args.onValueChange) {
288
+ this.#args.onValueChange(this.coerceOutput(newValues));
289
+ }
290
+ };
291
+
292
+ commitValue = (newValues: number[]) => {
293
+ if (this.#args.onValueCommit) {
294
+ this.#args.onValueCommit(this.coerceOutput(newValues));
295
+ }
296
+ };
297
+
298
+ coerceOutput(values: number[]): number | number[] {
299
+ if (Array.isArray(this.#args.value)) return values;
300
+
301
+ return values[0] ?? this.min;
302
+ }
303
+
304
+ #ensureThumbCount(count: number) {
305
+ if (count === this.#thumbStates.length) return;
306
+
307
+ if (count < this.#thumbStates.length) {
308
+ this.#thumbStates = this.#thumbStates.slice(0, count);
309
+
310
+ return;
311
+ }
312
+
313
+ const startIndex = this.#thumbStates.length;
314
+
315
+ for (let index = startIndex; index < count; index++) {
316
+ this.#thumbStates.push(new ThumbState(this, index));
317
+ }
318
+ }
319
+
320
+ #applyThumbInternalValue(index: number, rawValue: number): number[] {
321
+ const nextValues = [...this.internalValues];
322
+ const stepped = clamp(
323
+ roundToStep(rawValue, this.internalStep),
324
+ this.internalMin,
325
+ this.internalMax
326
+ );
327
+
328
+ let constrained = stepped;
329
+
330
+ if (nextValues.length > 1) {
331
+ const prev = nextValues[index - 1];
332
+ const next = nextValues[index + 1];
333
+
334
+ if (prev !== undefined) constrained = Math.max(constrained, prev);
335
+ if (next !== undefined) constrained = Math.min(constrained, next);
336
+ }
337
+
338
+ nextValues[index] = constrained;
339
+
340
+ return nextValues;
341
+ }
342
+
343
+ handleThumbInput = (index: number, value: number) => {
344
+ if (this.disabled) return;
345
+
346
+ const internalValues = this.#applyThumbInternalValue(index, value);
347
+ const newValues = this.outputValuesFromInternal(internalValues);
348
+
349
+ this.updateValue(newValues);
350
+ };
351
+
352
+ handleThumbChange = (index: number, value: number) => {
353
+ if (this.disabled) return;
354
+
355
+ const internalValues = this.#applyThumbInternalValue(index, value);
356
+ const newValues = this.outputValuesFromInternal(internalValues);
357
+
358
+ this.updateValue(newValues);
359
+ this.commitValue(newValues);
360
+ };
361
+
362
+ handleThumbActivate = (index: number) => {
363
+ this.activeThumbIndex = index;
364
+ };
365
+
366
+ defaultThumbLabel = (index: number): string => {
367
+ const count = this.internalValues.length;
368
+
369
+ if (count <= 1) return 'Value';
370
+ if (count === 2) return index === 0 ? 'Minimum' : 'Maximum';
371
+
372
+ return `Value ${index + 1}`;
373
+ };
374
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Structural styles for <Slider>.
3
+ *
4
+ * These handle the annoying parts of building a custom slider on top of
5
+ * native <input type="range"> elements:
6
+ * - stretching an invisible native input over the track (so keyboard,
7
+ * pointer, and assistive-tech behavior all come from the platform)
8
+ * - letting multiple overlapping inputs coexist (multi-thumb / range
9
+ * sliders) by routing pointer events through the native thumb only
10
+ * - vertical orientation (via `writing-mode`, no rotation hacks)
11
+ * - positioning the visual thumb and keeping the active thumb on top
12
+ *
13
+ * Appearance (colors, exact sizes) is left to the consumer.
14
+ *
15
+ * Everything is wrapped in `@layer ember-primitives`, so *any* unlayered
16
+ * consumer rule overrides these -- regardless of specificity or order.
17
+ *
18
+ * This is a plain stylesheet (bundled like any other CSS), so styles are
19
+ * present at first layout -- no waiting on a render cycle. If you render
20
+ * the slider inside a shadow root, bring this stylesheet into that root
21
+ * yourself (e.g. adopt it, or @import it in the shadow tree).
22
+ *
23
+ * Knobs:
24
+ * --ember-primitives__slider__hit-area pointer target size (default 24px)
25
+ * --ember-primitives__slider__thumb-size visual thumb size (default 16px)
26
+ * --ember-primitives__slider__track-thickness rail thickness (default 4px)
27
+ * --ember-primitives__slider__vertical-size length of a vertical slider (default 10rem)
28
+ */
29
+ @layer ember-primitives {
30
+ .ember-primitives__slider {
31
+ position: relative;
32
+ display: flex;
33
+ align-items: center;
34
+ min-height: var(--ember-primitives__slider__hit-area, 24px);
35
+ }
36
+
37
+ .ember-primitives__slider[data-orientation="vertical"] {
38
+ flex-direction: column;
39
+ min-height: 0;
40
+ min-width: var(--ember-primitives__slider__hit-area, 24px);
41
+ height: var(--ember-primitives__slider__vertical-size, 10rem);
42
+ }
43
+
44
+ .ember-primitives__slider__track {
45
+ position: relative;
46
+ flex: 1;
47
+ height: var(--ember-primitives__slider__track-thickness, 4px);
48
+ }
49
+
50
+ .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__track {
51
+ height: auto;
52
+ width: var(--ember-primitives__slider__track-thickness, 4px);
53
+ }
54
+
55
+ .ember-primitives__slider__range {
56
+ position: absolute;
57
+ top: 0;
58
+ bottom: 0;
59
+ }
60
+
61
+ .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__range {
62
+ top: auto;
63
+ bottom: auto;
64
+ left: 0;
65
+ right: 0;
66
+ }
67
+
68
+ /*
69
+ The native input is stretched across the whole track, invisible, and only
70
+ used for interaction. The visual thumb (a sibling) is what users see.
71
+ */
72
+ .ember-primitives__slider__thumb-input {
73
+ position: absolute;
74
+ left: 0;
75
+ top: 50%;
76
+ translate: 0 -50%;
77
+ width: 100%;
78
+ height: var(--ember-primitives__slider__hit-area, 24px);
79
+ margin: 0;
80
+ opacity: 0;
81
+ appearance: none;
82
+ background: transparent;
83
+ cursor: pointer;
84
+ }
85
+
86
+ .ember-primitives__slider__thumb-input:disabled {
87
+ cursor: not-allowed;
88
+ }
89
+
90
+ .ember-primitives__slider__thumb-input[data-active] {
91
+ z-index: 2;
92
+ }
93
+
94
+ /*
95
+ Size the (invisible) native thumb to the hit area, so grabbing "the thumb"
96
+ feels right. These cannot be comma-combined: an unknown pseudo-element
97
+ invalidates the whole selector list in the other engine.
98
+ */
99
+ .ember-primitives__slider__thumb-input::-webkit-slider-thumb {
100
+ appearance: none;
101
+ width: var(--ember-primitives__slider__hit-area, 24px);
102
+ height: var(--ember-primitives__slider__hit-area, 24px);
103
+ }
104
+
105
+ .ember-primitives__slider__thumb-input::-moz-range-thumb {
106
+ border: none;
107
+ width: var(--ember-primitives__slider__hit-area, 24px);
108
+ height: var(--ember-primitives__slider__hit-area, 24px);
109
+ }
110
+
111
+ /*
112
+ Multi-thumb sliders overlap multiple full-width range inputs. If the
113
+ inputs themselves receive pointer events, the top-most input steals
114
+ clicks/drags from the other thumbs. Disable pointer events on the
115
+ track-sized input and re-enable them on the native thumb only.
116
+
117
+ Single-thumb sliders keep the whole input interactive, so clicking
118
+ anywhere on the track jumps to that value.
119
+ */
120
+ .ember-primitives__slider[data-multi] .ember-primitives__slider__thumb-input {
121
+ pointer-events: none;
122
+ }
123
+
124
+ .ember-primitives__slider[data-multi]
125
+ .ember-primitives__slider__thumb-input::-webkit-slider-thumb {
126
+ pointer-events: auto;
127
+ }
128
+
129
+ .ember-primitives__slider[data-multi] .ember-primitives__slider__thumb-input::-moz-range-thumb {
130
+ pointer-events: auto;
131
+ }
132
+
133
+ /*
134
+ Vertical orientation: modern engines render a native vertical range input
135
+ with `writing-mode`. `direction: rtl` puts the minimum at the bottom.
136
+ */
137
+ .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__thumb-input {
138
+ writing-mode: vertical-lr;
139
+ direction: rtl;
140
+ top: 0;
141
+ left: 50%;
142
+ translate: -50% 0;
143
+ width: var(--ember-primitives__slider__hit-area, 24px);
144
+ height: 100%;
145
+ }
146
+
147
+ /*
148
+ The visual thumb. The component positions it with an inline
149
+ `left`/`bottom` percentage; centering uses the `translate` property
150
+ (not `transform`) so consumer hover/active effects like
151
+ `transform: scale(1.4)` or `scale: 1.4` compose with it instead of
152
+ clobbering it.
153
+ */
154
+ .ember-primitives__slider__thumb {
155
+ position: absolute;
156
+ top: 50%;
157
+ translate: -50% -50%;
158
+ pointer-events: none;
159
+ z-index: 1;
160
+ }
161
+
162
+ .ember-primitives__slider__thumb[data-active] {
163
+ z-index: 3;
164
+ }
165
+
166
+ .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__thumb {
167
+ top: auto;
168
+ left: 50%;
169
+ translate: -50% 50%;
170
+ }
171
+
172
+ /*
173
+ Default appearance -- zero specificity via :where(), so any consumer rule
174
+ wins (even a layered one). Colors derive from currentColor so the slider
175
+ adapts to its context.
176
+ */
177
+ :where(.ember-primitives__slider__track) {
178
+ border-radius: calc(var(--ember-primitives__slider__track-thickness, 4px) / 2);
179
+ background: color-mix(in srgb, currentColor 20%, transparent);
180
+ }
181
+
182
+ :where(.ember-primitives__slider__range) {
183
+ border-radius: inherit;
184
+ background: currentColor;
185
+ }
186
+
187
+ :where(.ember-primitives__slider__thumb) {
188
+ width: var(--ember-primitives__slider__thumb-size, 16px);
189
+ height: var(--ember-primitives__slider__thumb-size, 16px);
190
+ border-radius: 50%;
191
+ background: currentColor;
192
+ }
193
+
194
+ :where(.ember-primitives__slider__thumb[data-disabled]) {
195
+ opacity: 0.5;
196
+ }
197
+
198
+ /* Keyboard focus is on the (invisible) input; reflect it on the visual thumb. */
199
+ :where(.ember-primitives__slider__thumb-input:focus-visible + .ember-primitives__slider__thumb) {
200
+ outline: 2px solid currentColor;
201
+ outline-offset: 2px;
202
+ }
203
+ }