lupine.components 1.1.52 → 1.1.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lupine.components",
3
- "version": "1.1.52",
3
+ "version": "1.1.54",
4
4
  "license": "MIT",
5
5
  "author": "uuware.com",
6
6
  "homepage": "https://github.com/uuware/lupine.js",
@@ -18,8 +18,12 @@ export type RangeProps = {
18
18
  tickStep?: number;
19
19
  vertical?: boolean;
20
20
  range?: boolean;
21
+ snapThresholdPercent?: number; // threshold in % to snap to tick, <=0 disables snapping (default 5%)
21
22
  onChange?: (val: number | [number, number]) => void;
22
23
  hook?: RangeHookProps;
24
+ trackColor?: string;
25
+ fillColor?: string;
26
+ showButtons?: boolean;
23
27
  };
24
28
 
25
29
  // 1. Define Static CSS completely decoupled from Reactivity (isVertical)
@@ -35,7 +39,6 @@ const rangeCss: CssProps = {
35
39
  padding: 'var(--space-m, 8px) 0',
36
40
  width: '100%',
37
41
  height: 'auto',
38
- minWidth: '100%',
39
42
  minHeight: '40px',
40
43
  },
41
44
  '&.vertical': {
@@ -61,15 +64,17 @@ const rangeCss: CssProps = {
61
64
  '&.horizontal .&-wrapper': {
62
65
  width: '100%',
63
66
  height: '20px',
67
+ flex: 1,
64
68
  },
65
69
  '&.vertical .&-wrapper': {
66
70
  width: '20px',
67
71
  height: '100%',
72
+ flex: 1,
68
73
  },
69
74
 
70
75
  '.&-track': {
71
76
  position: 'relative',
72
- backgroundColor: 'var(--secondary-bg-color, #eee)',
77
+ backgroundColor: 'var(--range-track-color, var(--scrollbar-bg, #eee))',
73
78
  borderRadius: '999px',
74
79
  },
75
80
  '&.horizontal .&-track': {
@@ -83,7 +88,7 @@ const rangeCss: CssProps = {
83
88
 
84
89
  '.&-fill': {
85
90
  position: 'absolute',
86
- backgroundColor: 'var(--primary-accent-color, #0a74c9)',
91
+ backgroundColor: 'var(--range-fill-color, var(--primary-accent-color, #0a74c9))',
87
92
  borderRadius: '999px',
88
93
  },
89
94
  '&.horizontal .&-fill': {
@@ -132,7 +137,7 @@ const rangeCss: CssProps = {
132
137
  width: '20px',
133
138
  height: '20px',
134
139
  backgroundColor: '#fff',
135
- border: '2px solid var(--primary-accent-color, #0a74c9)',
140
+ border: '2px solid var(--range-fill-color, var(--primary-accent-color, #0a74c9))',
136
141
  borderRadius: '50%',
137
142
  cursor: 'pointer',
138
143
  boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
@@ -149,7 +154,7 @@ const rangeCss: CssProps = {
149
154
  width: '20px',
150
155
  height: '20px',
151
156
  backgroundColor: '#fff',
152
- border: '2px solid var(--primary-accent-color, #0a74c9)',
157
+ border: '2px solid var(--range-fill-color, var(--primary-accent-color, #0a74c9))',
153
158
  borderRadius: '50%',
154
159
  cursor: 'pointer',
155
160
  boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
@@ -158,30 +163,24 @@ const rangeCss: CssProps = {
158
163
 
159
164
  '.&-ticks': {
160
165
  position: 'absolute',
161
- display: 'flex',
162
- justifyContent: 'space-between',
163
166
  pointerEvents: 'none',
164
167
  fontSize: '12px',
165
168
  color: 'var(--secondary-color, #818181)',
166
169
  },
167
170
  '&.horizontal .&-ticks': {
168
- top: '50%',
171
+ top: 'calc(50% + 4px)',
169
172
  bottom: 'auto',
170
173
  left: 0,
171
174
  right: 0,
172
- marginTop: '4px',
173
- marginLeft: '4px',
174
- flexDirection: 'row',
175
+ marginTop: 0,
175
176
  height: 'auto',
176
177
  },
177
178
  '&.vertical .&-ticks': {
178
179
  top: 0,
179
180
  bottom: 0,
180
- left: '20%',
181
+ left: 'calc(50% + 4px)',
181
182
  right: 'auto',
182
- marginLeft: '8px',
183
- marginTop: 0,
184
- flexDirection: 'column-reverse',
183
+ marginLeft: 0,
185
184
  height: '100%',
186
185
  },
187
186
 
@@ -211,6 +210,64 @@ const rangeCss: CssProps = {
211
210
  marginBottom: 0,
212
211
  marginRight: '4px',
213
212
  },
213
+
214
+ '.&-inner-container': {
215
+ display: 'flex',
216
+ width: '100%',
217
+ height: '100%',
218
+ },
219
+ '&.horizontal .&-inner-container': {
220
+ flexDirection: 'row',
221
+ alignItems: 'center',
222
+ },
223
+ '&.vertical .&-inner-container': {
224
+ flexDirection: 'column',
225
+ alignItems: 'center',
226
+ },
227
+
228
+ '&.horizontal .&-buttons': {
229
+ display: 'flex',
230
+ flexDirection: 'row',
231
+ alignItems: 'center',
232
+ gap: '6px',
233
+ marginLeft: '10px',
234
+ },
235
+ '&.vertical .&-buttons': {
236
+ display: 'flex',
237
+ flexDirection: 'column',
238
+ alignItems: 'center',
239
+ gap: '6px',
240
+ marginTop: '10px',
241
+ },
242
+ '.&-btn': {
243
+ display: 'flex',
244
+ alignItems: 'center',
245
+ justifyContent: 'center',
246
+ width: '24px',
247
+ height: '24px',
248
+ borderRadius: '50%',
249
+ border: '2px solid var(--range-fill-color, var(--primary-accent-color, #0a74c9))',
250
+ backgroundColor: 'var(--secondary-bg-color, #1c1c1c)',
251
+ color: 'var(--primary-color, #fff)',
252
+ fontSize: '16px',
253
+ fontFamily: 'monospace',
254
+ fontWeight: 'bold',
255
+ cursor: 'pointer',
256
+ padding: 0,
257
+ margin: 0,
258
+ userSelect: 'none',
259
+ transition: 'background-color 0.2s, transform 0.1s',
260
+ lineHeight: '1',
261
+ pointerEvents: 'auto',
262
+ },
263
+ '.&-btn:hover': {
264
+ backgroundColor: 'var(--range-fill-color, var(--primary-accent-color, #0a74c9))',
265
+ color: '#fff',
266
+ transform: 'scale(1.1)',
267
+ },
268
+ '.&-btn:active': {
269
+ transform: 'scale(0.95)',
270
+ },
214
271
  };
215
272
 
216
273
  export const Range = (props: RangeProps) => {
@@ -220,6 +277,10 @@ export const Range = (props: RangeProps) => {
220
277
  const isRangeMode = !!props.range;
221
278
  const isVertical = !!props.vertical;
222
279
 
280
+ const customStyle: any = { ...props.style };
281
+ if (props.trackColor) customStyle['--range-track-color'] = props.trackColor;
282
+ if (props.fillColor) customStyle['--range-fill-color'] = props.fillColor;
283
+
223
284
  // Evaluate the immutable hash exact ONCE for all range implementations.
224
285
  const globalCssId = getGlobalStylesId(rangeCss);
225
286
  // Bind the global style ID ensuring it's in the CSS tree across instances
@@ -247,6 +308,25 @@ export const Range = (props: RangeProps) => {
247
308
  let currentVal1 = val1;
248
309
  let currentVal2 = val2;
249
310
 
311
+ const adjustByStep = (direction: -1 | 1) => {
312
+ if (props.disabled) return;
313
+ if (isRangeMode) {
314
+ let newVal = currentVal1 + direction * step;
315
+ newVal = Math.max(min, Math.min(currentVal2, newVal));
316
+ currentVal1 = newVal;
317
+ const inputMin = ref.$('.input-min') as HTMLInputElement;
318
+ if (inputMin) inputMin.value = currentVal1.toString();
319
+ } else {
320
+ let newVal = currentVal1 + direction * step;
321
+ newVal = Math.max(min, Math.min(max, newVal));
322
+ currentVal1 = newVal;
323
+ const inputSingle = ref.$('.input-single') as HTMLInputElement;
324
+ if (inputSingle) inputSingle.value = currentVal1.toString();
325
+ }
326
+ updateVisuals();
327
+ handleChange();
328
+ };
329
+
250
330
  const updateVisuals = () => {
251
331
  const fill = ref.$('.&-fill') as HTMLElement;
252
332
  if (!fill) return;
@@ -276,8 +356,124 @@ export const Range = (props: RangeProps) => {
276
356
  }
277
357
  };
278
358
 
359
+ let snapThresholdPercent = props.snapThresholdPercent;
360
+ if (snapThresholdPercent === undefined) {
361
+ const tStep = props.tickStep || max - min;
362
+ const tStepPercent = (tStep / (max - min)) * 100;
363
+ snapThresholdPercent = Math.min(5, tStepPercent * 0.1);
364
+ }
365
+
366
+ let animRef = 0;
367
+ const animateToValue = (targetVal: number) => {
368
+ cancelAnimationFrame(animRef);
369
+
370
+ let isThumb2 = false;
371
+ let startVal = currentVal1;
372
+ if (isRangeMode) {
373
+ if (Math.abs(targetVal - currentVal2) < Math.abs(targetVal - currentVal1)) {
374
+ isThumb2 = true;
375
+ startVal = currentVal2;
376
+ }
377
+ }
378
+
379
+ const startTime = performance.now();
380
+ const duration = 200; // 200ms animation
381
+
382
+ const stepAnim = (time: number) => {
383
+ const elapsed = time - startTime;
384
+ let progress = elapsed / duration;
385
+ if (progress > 1) progress = 1;
386
+
387
+ // easeOutQuad
388
+ const eased = progress * (2 - progress);
389
+ const current = startVal + (targetVal - startVal) * eased;
390
+
391
+ if (isRangeMode) {
392
+ if (isThumb2) {
393
+ currentVal2 = current;
394
+ const inputMax = ref.$('.input-max') as HTMLInputElement;
395
+ if (inputMax) inputMax.value = currentVal2.toString();
396
+ } else {
397
+ currentVal1 = current;
398
+ const inputMin = ref.$('.input-min') as HTMLInputElement;
399
+ if (inputMin) inputMin.value = currentVal1.toString();
400
+ }
401
+ } else {
402
+ currentVal1 = current;
403
+ const inputSingle = ref.$('.input-single') as HTMLInputElement;
404
+ if (inputSingle) inputSingle.value = currentVal1.toString();
405
+ }
406
+
407
+ updateVisuals();
408
+
409
+ if (progress < 1) {
410
+ animRef = requestAnimationFrame(stepAnim);
411
+ } else {
412
+ handleChange();
413
+ }
414
+ };
415
+
416
+ animRef = requestAnimationFrame(stepAnim);
417
+ };
418
+
419
+ const handleContainerClick = (e: any) => {
420
+ if (props.disabled) return;
421
+ if (e.target && e.target.tagName === 'INPUT') return;
422
+
423
+ const wrapper = ref.$('.&-wrapper') as HTMLElement;
424
+ if (!wrapper) return;
425
+ const rect = wrapper.getBoundingClientRect();
426
+
427
+ let clientX = e.clientX;
428
+ let clientY = e.clientY;
429
+ if (e.type === 'touchstart') {
430
+ const touch = (e as unknown as TouchEvent).touches[0];
431
+ if (touch) {
432
+ clientX = touch.clientX;
433
+ clientY = touch.clientY;
434
+ }
435
+ }
436
+
437
+ let percent = 0;
438
+ if (isVertical) {
439
+ const y = clientY - rect.top;
440
+ percent = 1 - y / rect.height;
441
+ } else {
442
+ const x = clientX - rect.left;
443
+ percent = x / rect.width;
444
+ }
445
+ percent = Math.max(0, Math.min(1, percent));
446
+
447
+ let targetVal = min + (max - min) * percent;
448
+ targetVal = Math.round((targetVal - min) / step) * step + min;
449
+
450
+ if (snapThresholdPercent > 0 && (props.showTicks || props.showTickLabels)) {
451
+ const tStep = props.tickStep || max - min;
452
+ const nearestTick = Math.round((targetVal - min) / tStep) * tStep + min;
453
+ const threshold = (max - min) * (snapThresholdPercent / 100);
454
+ if (Math.abs(targetVal - nearestTick) <= threshold) {
455
+ targetVal = nearestTick;
456
+ }
457
+ }
458
+
459
+ targetVal = Math.max(min, Math.min(max, targetVal));
460
+ animateToValue(targetVal);
461
+ };
462
+
279
463
  const handleInput = (e: Event, isThumb2: boolean) => {
280
- const val = parseFloat((e.target as HTMLInputElement).value);
464
+ let val = parseFloat((e.target as HTMLInputElement).value);
465
+
466
+ // Magnetic snapping
467
+ if (snapThresholdPercent > 0 && (props.showTicks || props.showTickLabels)) {
468
+ const tStep = props.tickStep || max - min;
469
+ const nearestTick = Math.round((val - min) / tStep) * tStep + min;
470
+ const threshold = (max - min) * (snapThresholdPercent / 100);
471
+ if (Math.abs(val - nearestTick) <= threshold) {
472
+ val = nearestTick;
473
+ (e.target as HTMLInputElement).value = val.toString();
474
+ }
475
+ }
476
+
281
477
  if (isRangeMode) {
282
478
  if (isThumb2) {
283
479
  currentVal2 = Math.max(val, currentVal1);
@@ -296,6 +492,7 @@ export const Range = (props: RangeProps) => {
296
492
  currentVal1 = val;
297
493
  }
298
494
  updateVisuals();
495
+ handleChange(); // Ensure parent receives continuous updates during drag
299
496
  };
300
497
 
301
498
  const handleChange = () => {
@@ -370,18 +567,45 @@ export const Range = (props: RangeProps) => {
370
567
  .join(' ')
371
568
  .trim()}
372
569
  ref={ref}
373
- css={props.style}
570
+ css={customStyle}
571
+ onPointerDown={handleContainerClick}
572
+ onTouchStart={handleContainerClick}
374
573
  >
375
- <div class='&-wrapper'>
376
- <div class='&-track'>
377
- <div class='&-fill'></div>
378
- </div>
574
+ <div class='&-inner-container'>
575
+ <div class='&-wrapper'>
576
+ <div class='&-track'>
577
+ <div class='&-fill'></div>
578
+ </div>
379
579
 
380
- {isRangeMode ? (
381
- <>
580
+ {isRangeMode ? (
581
+ <>
582
+ <input
583
+ type='range'
584
+ class='&-input input-min'
585
+ min={min}
586
+ max={max}
587
+ step={step}
588
+ value={val1}
589
+ onInput={(e) => handleInput(e, false)}
590
+ onChange={handleChange}
591
+ disabled={props.disabled}
592
+ />
593
+ <input
594
+ type='range'
595
+ class='&-input input-max'
596
+ min={min}
597
+ max={max}
598
+ step={step}
599
+ value={val2}
600
+ onInput={(e) => handleInput(e, true)}
601
+ onChange={handleChange}
602
+ disabled={props.disabled}
603
+ />
604
+ </>
605
+ ) : (
382
606
  <input
383
607
  type='range'
384
- class='&-input input-min'
608
+ class='&-input input-single'
385
609
  min={min}
386
610
  max={max}
387
611
  step={step}
@@ -390,43 +614,79 @@ export const Range = (props: RangeProps) => {
390
614
  onChange={handleChange}
391
615
  disabled={props.disabled}
392
616
  />
393
- <input
394
- type='range'
395
- class='&-input input-max'
396
- min={min}
397
- max={max}
398
- step={step}
399
- value={val2}
400
- onInput={(e) => handleInput(e, true)}
401
- onChange={handleChange}
402
- disabled={props.disabled}
403
- />
404
- </>
405
- ) : (
406
- <input
407
- type='range'
408
- class='&-input input-single'
409
- min={min}
410
- max={max}
411
- step={step}
412
- value={val1}
413
- onInput={(e) => handleInput(e, false)}
414
- onChange={handleChange}
415
- disabled={props.disabled}
416
- />
417
- )}
418
- </div>
617
+ )}
618
+
619
+ {(props.showTicks || props.showTickLabels) && (
620
+ <div class='&-ticks'>
621
+ {ticks.map((t) => {
622
+ const percent = ((t - min) / (max - min)) * 100;
623
+ const offsetPx = 10 - (percent / 100) * 20;
624
+ const style = isVertical
625
+ ? { position: 'absolute' as any, bottom: `calc(${percent}% + ${offsetPx}px)`, transform: 'translateY(50%)' }
626
+ : { position: 'absolute' as any, left: `calc(${percent}% + ${offsetPx}px)`, top: '0', transform: 'translateX(-50%)' };
419
627
 
420
- {(props.showTicks || props.showTickLabels) && (
421
- <div class='&-ticks'>
422
- {ticks.map((t) => (
423
- <div class='&-tick'>
424
- {props.showTicks && <div class='&-tick-mark'></div>}
425
- {props.showTickLabels && <span>{t}</span>}
628
+ return (
629
+ <div class='&-tick' style={style}>
630
+ {props.showTicks && <div class='&-tick-mark'></div>}
631
+ {props.showTickLabels && (
632
+ <span
633
+ style={{ pointerEvents: 'auto', cursor: 'pointer' }}
634
+ onPointerDown={(e: any) => {
635
+ e.stopPropagation();
636
+ animateToValue(t);
637
+ }}
638
+ onTouchStart={(e: any) => {
639
+ e.stopPropagation();
640
+ animateToValue(t);
641
+ }}
642
+ >
643
+ {t}
644
+ </span>
645
+ )}
646
+ </div>
647
+ );
648
+ })}
426
649
  </div>
427
- ))}
650
+ )}
428
651
  </div>
429
- )}
652
+
653
+ {props.showButtons && (
654
+ <div class='&-buttons'>
655
+ <button
656
+ class='&-btn &-btn-minus'
657
+ onPointerDown={(e: any) => {
658
+ e.stopPropagation();
659
+ adjustByStep(-1);
660
+ }}
661
+ onTouchStart={(e: any) => {
662
+ e.stopPropagation();
663
+ adjustByStep(-1);
664
+ }}
665
+ onClick={(e: any) => {
666
+ e.stopPropagation();
667
+ }}
668
+ >
669
+ -
670
+ </button>
671
+ <button
672
+ class='&-btn &-btn-plus'
673
+ onPointerDown={(e: any) => {
674
+ e.stopPropagation();
675
+ adjustByStep(1);
676
+ }}
677
+ onTouchStart={(e: any) => {
678
+ e.stopPropagation();
679
+ adjustByStep(1);
680
+ }}
681
+ onClick={(e: any) => {
682
+ e.stopPropagation();
683
+ }}
684
+ >
685
+ +
686
+ </button>
687
+ </div>
688
+ )}
689
+ </div>
430
690
  </div>
431
691
  );
432
692
  };
@@ -1,4 +1,4 @@
1
- import { RefProps } from 'lupine.web';
1
+ import { RefProps, refreshPage } from 'lupine.web';
2
2
 
3
3
  export type RedirectProps = {
4
4
  title?: string;
@@ -10,8 +10,8 @@ export const Redirect = ({ title = 'redirect...', url, delaySeconds = 0 }: Redir
10
10
  // MetaData({ httpEquiv: 'refresh', content: delaySeconds + ';URL=' + url });
11
11
  const ref: RefProps = {
12
12
  onLoad: async (el: Element) => {
13
- setTimeout(() => {
14
- window.location.href = url;
13
+ setTimeout(async () => {
14
+ await refreshPage(url);
15
15
  }, delaySeconds * 1000);
16
16
  },
17
17
  };
@@ -75,7 +75,7 @@ export const DemoRenderPage = async (props: PageProps) => {
75
75
  maxWidth: MediaQueryMaxWidth.DesktopMax,
76
76
  borderRight: '1px solid var(--primary-border-color)',
77
77
  borderLeft: '1px solid var(--primary-border-color)',
78
- '>fragment>div': {
78
+ '>div': {
79
79
  flex: 1,
80
80
  alignSelf: 'stretch', // overwrite parent's align-items: center
81
81
  width: '100%',
@@ -0,0 +1,43 @@
1
+ // async button lock (protected against double click during async processing)
2
+ /*
3
+ const onToggle = buttonAsyncLock(
4
+ async (checked: boolean) => {
5
+ ... async code ...
6
+ },
7
+ hook,
8
+ 500,
9
+ (checked) => hook.setChecked?.(!checked)
10
+ );
11
+ */
12
+ export function buttonAsyncLock<T extends (...args: any[]) => Promise<any>>(
13
+ fn: T,
14
+ hook?: { setEnabled?: (enabled: boolean) => void },
15
+ minDelayMs: number = 500,
16
+ onDrop?: (...args: Parameters<T>) => void
17
+ ): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
18
+ let isLocked = false;
19
+ return async (...args: Parameters<T>): Promise<ReturnType<T> | undefined> => {
20
+ if (isLocked) {
21
+ if (onDrop) onDrop(...args);
22
+ return undefined;
23
+ }
24
+ isLocked = true;
25
+ if (hook && hook.setEnabled) {
26
+ hook.setEnabled(false);
27
+ }
28
+ const startTime = Date.now();
29
+ try {
30
+ return await fn(...args);
31
+ } finally {
32
+ const elapsed = Date.now() - startTime;
33
+ const remainingDelay = minDelayMs - elapsed;
34
+ if (remainingDelay > 0) {
35
+ await new Promise((resolve) => setTimeout(resolve, remainingDelay));
36
+ }
37
+ isLocked = false;
38
+ if (hook && hook.setEnabled) {
39
+ hook.setEnabled(true);
40
+ }
41
+ }
42
+ };
43
+ }
package/src/lib/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './back-action-helper';
2
2
  export * from './base62';
3
3
  export * from './blob-utils';
4
+ export * from './button-async-lock';
4
5
  export * from './calculate-text-width';
5
6
  export * from './canvas';
6
7
  export * from './date-utils';
@@ -5,8 +5,8 @@ export const darkThemes: ThemeProps = {
5
5
  ...sharedThemes,
6
6
  '--theme-name': 'dark',
7
7
 
8
- '--scrollbar-bg': '#1c1c1c',
9
- '--scrollbar-thumb-bg': '#373636',
8
+ '--scrollbar-bg': '#373737',
9
+ '--scrollbar-thumb-bg': '#505050',
10
10
  '--scrollbar-active-thumb-bg': '#5b5b5b',
11
11
 
12
12
  '--primary-color': '#d8d8d8',