noph-ui 0.35.0 → 0.36.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.
package/README.md CHANGED
@@ -52,30 +52,30 @@ body {
52
52
 
53
53
  ## Roadmap
54
54
 
55
- Beta (No breaking changes expected)
55
+ Beta
56
56
 
57
+ - Auto complete
58
+ - Badges
57
59
  - Buttons
58
60
  - Cards
59
61
  - Checkbox
62
+ - Chips
63
+ - Dialogs
60
64
  - Divider
61
65
  - Icon buttons
62
- - Lists (Docs missing)
66
+ - Lists
67
+ - Loading indicator
68
+ - Menus
69
+ - Navigation Drawer
70
+ - Navigation Rail
63
71
  - Progress indicators
64
72
  - Radio
65
73
  - Ripple
66
74
  - Segmented buttons
67
75
  - Select
76
+ - Slider
68
77
  - Snackbar
69
78
  - Switch
70
- - Text fields
71
79
  - Tabs
72
-
73
- In progress (Breaking changes expected)
74
-
75
- - Auto complete
76
- - Chips (Docs missing)
77
- - Dialogs (Fullscreen + Docs missing)
78
- - Menus (Positioning missing + Docs missing)
79
- - Navigation Drawer (Docs missing)
80
- - Navigation Rail (Badge is missing + Docs missing)
81
- - Tooltips (Positioning missing)
80
+ - Text fields
81
+ - Tooltips
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './chip/index.js';
7
7
  export * from './dialog/index.js';
8
8
  export * from './divider/index.js';
9
9
  export * from './list/index.js';
10
+ export * from './loading-indicator/index.js';
10
11
  export * from './menu/index.js';
11
12
  export * from './navigation-drawer/index.js';
12
13
  export * from './navigation-rail/index.js';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './chip/index.js';
7
7
  export * from './dialog/index.js';
8
8
  export * from './divider/index.js';
9
9
  export * from './list/index.js';
10
+ export * from './loading-indicator/index.js';
10
11
  export * from './menu/index.js';
11
12
  export * from './navigation-drawer/index.js';
12
13
  export * from './navigation-rail/index.js';
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ import {
3
+ DETERMINATE_SCALE,
4
+ DETERMINATE_SHAPES,
5
+ INDETERMINATE_SCALE,
6
+ INDETERMINATE_SHAPES,
7
+ VIEWBOX,
8
+ outlinePath,
9
+ } from './shapes.ts'
10
+ import type { LoadingIndicatorProps } from './types.ts'
11
+
12
+ let {
13
+ value = 0,
14
+ max = 1,
15
+ indeterminate = false,
16
+ contained = false,
17
+ ...attributes
18
+ }: LoadingIndicatorProps = $props()
19
+
20
+ const MORPH_DURATION = 650
21
+ const ROTATION_DURATION = 4666
22
+ const QUARTER_TURN = 90
23
+ const CENTER = VIEWBOX / 2
24
+ const DAMPING_RATIO = 0.6
25
+ const NATURAL_FREQUENCY = Math.sqrt(200)
26
+ const DAMPED_FREQUENCY = NATURAL_FREQUENCY * Math.sqrt(1 - DAMPING_RATIO * DAMPING_RATIO)
27
+ const springValue = (seconds: number) =>
28
+ 1 -
29
+ Math.exp(-DAMPING_RATIO * NATURAL_FREQUENCY * seconds) *
30
+ (Math.cos(DAMPED_FREQUENCY * seconds) +
31
+ ((DAMPING_RATIO * NATURAL_FREQUENCY) / DAMPED_FREQUENCY) *
32
+ Math.sin(DAMPED_FREQUENCY * seconds))
33
+
34
+ const clamp = (n: number) => (n > 0 ? Math.min(n, 1) : 0)
35
+ const rotate = (degrees: number) => `rotate(${(degrees % 360).toFixed(2)} ${CENTER} ${CENTER})`
36
+
37
+ const initialPath = outlinePath(
38
+ INDETERMINATE_SHAPES[0],
39
+ INDETERMINATE_SHAPES[1],
40
+ 0,
41
+ INDETERMINATE_SCALE,
42
+ )
43
+
44
+ let progress = $derived(clamp(value / max))
45
+ let determinatePath = $derived(
46
+ outlinePath(DETERMINATE_SHAPES[0], DETERMINATE_SHAPES[1], progress, DETERMINATE_SCALE),
47
+ )
48
+
49
+ let indicator = $state<SVGPathElement>()
50
+
51
+ $effect(() => {
52
+ if (!indicator) return
53
+ const path = indicator
54
+ const start = performance.now()
55
+ let raf = 0
56
+ const frame = (now: number) => {
57
+ raf = requestAnimationFrame(frame)
58
+ const elapsed = Math.max(0, now - start)
59
+ const index = Math.floor(elapsed / MORPH_DURATION)
60
+ const morph = springValue((elapsed % MORPH_DURATION) / 1000)
61
+ path.setAttribute(
62
+ 'd',
63
+ outlinePath(
64
+ INDETERMINATE_SHAPES[index % INDETERMINATE_SHAPES.length],
65
+ INDETERMINATE_SHAPES[(index + 1) % INDETERMINATE_SHAPES.length],
66
+ clamp(morph),
67
+ INDETERMINATE_SCALE,
68
+ ),
69
+ )
70
+ path.setAttribute(
71
+ 'transform',
72
+ rotate((elapsed / ROTATION_DURATION) * 360 + (index + morph) * QUARTER_TURN),
73
+ )
74
+ }
75
+ raf = requestAnimationFrame(frame)
76
+ return () => cancelAnimationFrame(raf)
77
+ })
78
+ </script>
79
+
80
+ <div
81
+ {...attributes}
82
+ class={['np-loading-indicator', contained && 'contained']}
83
+ role="progressbar"
84
+ aria-valuemin="0"
85
+ aria-valuemax={max}
86
+ aria-valuenow={indeterminate ? undefined : value}
87
+ >
88
+ <svg viewBox="0 0 {VIEWBOX} {VIEWBOX}" aria-hidden="true">
89
+ {#if indeterminate}
90
+ <path bind:this={indicator} class="indicator" d={initialPath}></path>
91
+ {:else}
92
+ <path class="indicator" d={determinatePath} transform={rotate(-progress * 180)}></path>
93
+ {/if}
94
+ </svg>
95
+ </div>
96
+
97
+ <style>
98
+ .np-loading-indicator {
99
+ all: unset;
100
+ --_color: var(--np-color-primary);
101
+ display: inline-flex;
102
+ vertical-align: middle;
103
+ align-items: center;
104
+ justify-content: center;
105
+ inline-size: var(--np-loading-indicator-size, 3rem);
106
+ block-size: var(--np-loading-indicator-size, 3rem);
107
+ border-radius: var(--np-shape-corner-full);
108
+ contain: strict;
109
+ content-visibility: auto;
110
+ }
111
+
112
+ .contained {
113
+ --_color: var(--np-color-on-primary-container);
114
+ background-color: var(
115
+ --np-loading-indicator-container-color,
116
+ var(--np-color-primary-container)
117
+ );
118
+ }
119
+
120
+ svg {
121
+ display: block;
122
+ inline-size: 100%;
123
+ block-size: 100%;
124
+ }
125
+
126
+ .indicator {
127
+ fill: var(--np-loading-indicator-color, var(--_color));
128
+ }
129
+
130
+ @media (forced-colors: active) {
131
+ .indicator {
132
+ fill: CanvasText;
133
+ }
134
+ }
135
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { LoadingIndicatorProps } from './types.ts';
2
+ declare const LoadingIndicator: import("svelte").Component<LoadingIndicatorProps, {}, "">;
3
+ type LoadingIndicator = ReturnType<typeof LoadingIndicator>;
4
+ export default LoadingIndicator;
@@ -0,0 +1 @@
1
+ export { default as LoadingIndicator } from './LoadingIndicator.svelte';
@@ -0,0 +1 @@
1
+ export { default as LoadingIndicator } from './LoadingIndicator.svelte';
@@ -0,0 +1,6 @@
1
+ export declare const INDETERMINATE_SHAPES: number[][];
2
+ export declare const DETERMINATE_SHAPES: number[][];
3
+ export declare const INDETERMINATE_SCALE = 0.6853;
4
+ export declare const DETERMINATE_SCALE = 0.7917;
5
+ export declare const VIEWBOX = 48;
6
+ export declare const outlinePath: (from: number[], to: number[], progress: number, scale: number) => string;
@@ -0,0 +1,49 @@
1
+ const SAMPLE_COUNT = 128;
2
+ const decode = (encoded) => {
3
+ const radii = [];
4
+ for (let i = 0; i < SAMPLE_COUNT; i++) {
5
+ radii.push(parseInt(encoded.slice(i * 3, i * 3 + 3), 36) / 10000);
6
+ }
7
+ return radii;
8
+ };
9
+ export const INDETERMINATE_SHAPES = [
10
+ // MaterialShapes.SoftBurst
11
+ '2zl30r3533bb3ik3pq3se3s33oh3gs39u33v30a2zn31b3693co3k53qk3sk3ro3n53fb38k32t2zz2zr32037h3e23ls3r83sm3r43lj3dv37c31y2zr30032w38p3fi3ne3rq3sj3qf3jw3ch3653192zm30b33y39z3h03oo3s53sd3pk3ib3b534z30q2zl30r3533bb3ik3pq3se3s33oh3gs39u33v30a2zn31b3693co3k53qk3sk3ro3n53fb38k32t2zz2zr32037h3e23ls3r83sm3r43lj3dv37c31y2zr30032w38p3fi3ne3rq3sj3qf3jw3ch3653192zm30b33y39z3h03oo3s53sd3pk3ib3b534z30q',
12
+ // MaterialShapes.Cookie9Sided
13
+ '3ne3kp3hr3g53fr3gl3iq3m33ot3qq3rw3sa3rz3qx3p33mg3jc3hi3gz3hn3jn3my3pp3rn3st3t73sv3rs3pw3n73jz3i23hg3i23jz3n73pw3rs3sv3t73st3rn3pp3my3jn3hn3gz3hi3jc3mg3p33qx3rz3sa3rw3qq3ot3m33iq3gl3fr3g53hr3kp3ne3pb3qg3qw3qm3pm3nv3lc3i13fk3eg3ei3fr3ie3lc3nh3ov3pk3pk3ou3ne3l83i93fb3dr3df3e83gb3jh3lz3nr3os3p53os3nr3lz3jh3gb3e83df3dr3fb3i93l83ne3ou3pk3pk3ov3nh3lc3ie3fr3ei3eg3fk3i13lc3nv3pm3qm3qw3qg3pb',
14
+ // MaterialShapes.Pentagon
15
+ '3ng3lh3jw3im3ho3h23gq3gq3h03hl3ih3jp3l93n63ph3s73vc3y23zz41241e40z3zr3xr3uz3se3q73od3mx3lt3l13kk3kf3kk3l13lt3mx3od3q73se3uz3xr3zr40z41e4123zz3y23vc3s73ph3n63l93jp3ih3hl3h03gq3gq3h23ho3im3jw3lh3ng3pp3rf3se3sp3sb3r93pg3mx3jm3gc3dh3b138z37935u34r33x33e33533533f33y34s35w37b3913b43df3f93gk3hb3hl3hb3gk3f93df3b439137b35w34s33y33f33533533e33x34r35u37938z3b13dh3gc3jm3mx3pg3r93sb3sp3se3rf3pp',
16
+ // MaterialShapes.Pill
17
+ '3t53rz3qr3pj3o93mz3lo3kd3j13hp3gd3f13dq3cp3by3bj3be3bj3by3cp3dq3f13gd3hp3j13kd3lo3mz3o93pj3qr3rz3t53ua3vd3wc3wu3xm3yg3z83zx40k41341j41x42842f42k42l42k42f42841x41j41340k3zx3z83yg3xm3wu3wc3vd3ua3t53rz3qr3pj3o93mz3lo3kd3j13hp3gd3f13dq3cp3by3bj3be3bj3by3cp3dq3f13gd3hp3j13kd3lo3mz3o93pj3qr3rz3t53ua3vd3wc3wu3xm3yg3z83zx40k41341j41x42842f42k42l42k42f42841x41j41340k3zx3z83yg3xm3wu3wc3vd3ua',
18
+ // MaterialShapes.Sunny
19
+ '3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa3t63sa3pe3l73hi3ea3bj3973893973bj3ea3hi3l73pe3sa',
20
+ // MaterialShapes.Cookie4Sided
21
+ '2yr2z530a32b35g39l3ea3iv3mz3qk3tm3w53y73zr40v41j41r41i40u3zr3y63w43tk3qi3mw3it3e739j35f32a3092z42yr2z530a32b35g39l3ea3iv3mz3qk3tm3w53y73zr40v41j41r41i40u3zr3y63w43tk3qi3mw3it3e739j35f32a3092z42yr2z530a32b35g39l3ea3iv3mz3qk3tm3w53y73zr40v41j41r41i40u3zr3y63w43tk3qi3mw3it3e739j35f32a3092z42yr2z530a32b35g39l3ea3iv3mz3qk3tm3w53y73zr40v41j41r41i40u3zr3y63w43tk3qi3mw3it3e739j35f32a3092z4',
22
+ // MaterialShapes.Oval
23
+ '3ec3bw39m37i35k33s32630q2zg2yb2xc2wj2vv2vd2uz2ur2up2ur2uz2vd2vv2wj2xc2yb2zg30q32633s35k37i39m3bw3ec3gx3jn3mi3ph3sj3vn3yr41t44r47j4a14c84e14fd4g64gg4g64fd4e14c84a147j44r41t3yr3vn3sj3ph3mi3jn3gx3ec3bw39m37i35k33s32630q2zg2yb2xc2wj2vv2vd2uz2ur2up2ur2uz2vd2vv2wj2xc2yb2zg30q32633s35k37i39m3bw3ec3gx3jn3mi3ph3sj3vn3yr41t44r47j4a14c84e14fd4g64gg4g64fd4e14c84a147j44r41t3yr3vn3sj3ph3mi3jn3gx',
24
+ ].map(decode);
25
+ export const DETERMINATE_SHAPES = [
26
+ // MaterialShapes.Circle
27
+ '3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so3so',
28
+ // MaterialShapes.SoftBurst
29
+ '2zl30r3533bb3ik3pq3se3s33oh3gs39u33v30a2zn31b3693co3k53qk3sk3ro3n53fb38k32t2zz2zr32037h3e23ls3r83sm3r43lj3dv37c31y2zr30032w38p3fi3ne3rq3sj3qf3jw3ch3653192zm30b33y39z3h03oo3s53sd3pk3ib3b534z30q2zl30r3533bb3ik3pq3se3s33oh3gs39u33v30a2zn31b3693co3k53qk3sk3ro3n53fb38k32t2zz2zr32037h3e23ls3r83sm3r43lj3dv37c31y2zr30032w38p3fi3ne3rq3sj3qf3jw3ch3653192zm30b33y39z3h03oo3s53sd3pk3ib3b534z30q',
30
+ ].map(decode);
31
+ export const INDETERMINATE_SCALE = 0.6853;
32
+ export const DETERMINATE_SCALE = 0.7917;
33
+ export const VIEWBOX = 48;
34
+ const CENTER = VIEWBOX / 2;
35
+ const COS = Array.from({ length: SAMPLE_COUNT }, (_, i) => Math.cos((2 * Math.PI * i) / SAMPLE_COUNT));
36
+ const SIN = Array.from({ length: SAMPLE_COUNT }, (_, i) => Math.sin((2 * Math.PI * i) / SAMPLE_COUNT));
37
+ export const outlinePath = (from, to, progress, scale) => {
38
+ const size = VIEWBOX * scale;
39
+ let d = '';
40
+ for (let i = 0; i < SAMPLE_COUNT; i++) {
41
+ const radius = (from[i] + (to[i] - from[i]) * progress) * size;
42
+ d +=
43
+ (i === 0 ? 'M' : 'L') +
44
+ (CENTER + radius * COS[i]).toFixed(2) +
45
+ ',' +
46
+ (CENTER + radius * SIN[i]).toFixed(2);
47
+ }
48
+ return d + 'Z';
49
+ };
@@ -0,0 +1,7 @@
1
+ export interface LoadingIndicatorProps {
2
+ value?: number;
3
+ max?: number;
4
+ indeterminate?: boolean;
5
+ contained?: boolean;
6
+ 'aria-label'?: string | undefined | null;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -86,7 +86,7 @@
86
86
  transition-property: transform, opacity;
87
87
  transition-timing-function: linear;
88
88
  background-color: var(--np-color-secondary-container);
89
- border-radius: var(--np-shape-corner-full);
89
+ border-radius: var(--np-navigation-drawer-item-container-shape, var(--np-shape-corner-full));
90
90
  z-index: -1;
91
91
  }
92
92
 
@@ -22,9 +22,8 @@
22
22
  let wave = $derived(wavy && !reducedMotion.current)
23
23
 
24
24
  let wavelength = $derived(indeterminate ? INDETERMINATE_WAVELENGTH : DETERMINATE_WAVELENGTH)
25
- let amplitude = $derived(
26
- !wave ? 0 : indeterminate || (value / max > 0.1 && value / max < 0.95) ? 1 : 0,
27
- )
25
+ const amplitudeFor = (fraction: number) =>
26
+ !wave ? 0 : indeterminate || (fraction > 0.1 && fraction < 0.95) ? 1 : 0
28
27
 
29
28
  const cubicBezier = (x1: number, y1: number, x2: number, y2: number) => {
30
29
  const A = (a: number, b: number) => 1 - 3 * b + 3 * a
@@ -112,6 +111,10 @@
112
111
  const len = wavelength
113
112
  const width = containerWidth
114
113
  const host = wavePathA?.closest('.progress') as HTMLElement | null
114
+ // Live declaration: --np-lp-percent is a registered custom property that carries
115
+ // the same transition as the track, so reading it each frame gives the wave the
116
+ // eased position instead of jumping straight to the new value.
117
+ const hostStyles = host && getComputedStyle(host)
115
118
  let raf = 0
116
119
  let last = 0
117
120
  const frame = (now: number) => {
@@ -119,7 +122,12 @@
119
122
  const dt = last ? Math.min(64, now - last) : 16
120
123
  last = now
121
124
  const phase = ((now / 1000) % 1) * len
122
- renderedAmplitude += (amplitude - renderedAmplitude) * Math.min(1, dt / 500)
125
+ const eased =
126
+ hostStyles && !indeterminate
127
+ ? parseFloat(hostStyles.getPropertyValue('--np-lp-percent'))
128
+ : percent
129
+ const shown = Number.isFinite(eased) ? eased : percent
130
+ renderedAmplitude += (amplitudeFor(shown / 100) - renderedAmplitude) * Math.min(1, dt / 500)
123
131
  const wave = buildWave(width, len, renderedAmplitude, phase)
124
132
  if (indeterminate) {
125
133
  const t = now % CYCLE
@@ -131,7 +139,7 @@
131
139
  trim(wavePathA, wave, t1 * width, h1 * width)
132
140
  trim(wavePathB, wave, t2 * width, h2 * width)
133
141
  } else {
134
- trim(wavePathA, wave, 0, (percent / 100) * width)
142
+ trim(wavePathA, wave, 0, (shown / 100) * width)
135
143
  }
136
144
  }
137
145
  raf = requestAnimationFrame(frame)
@@ -148,7 +156,7 @@
148
156
  let gapOn = $derived(percent > 0 ? 1 : 0)
149
157
 
150
158
  let trackStyles = $derived(
151
- `--_percent:${percent}%;--_buffer-percent:${bufferPercent}%;--_gap-on:${gapOn}`,
159
+ `--_percent:${percent}%;--np-lp-percent:${percent}%;--_buffer-percent:${bufferPercent}%;--_gap-on:${gapOn}`,
152
160
  )
153
161
 
154
162
  let hideDots = $derived(indeterminate || !hasBuffer || bufferValue >= max || value >= max)
@@ -328,6 +336,18 @@
328
336
  inherits: true;
329
337
  initial-value: 0%;
330
338
  }
339
+ @property --np-lp-percent {
340
+ syntax: '<percentage>';
341
+ inherits: true;
342
+ initial-value: 0%;
343
+ }
344
+
345
+ /* The wave is trimmed in script, so it cannot transition the way the track does.
346
+ Transitioning the value it is trimmed from on the same token keeps the two in
347
+ lockstep: same start, same duration, same curve. */
348
+ .progress.wavy:not(.indeterminate) {
349
+ transition: --np-lp-percent var(--np-motion-expressive-default-effects);
350
+ }
331
351
 
332
352
  .progress.indeterminate {
333
353
  animation:
@@ -30,7 +30,6 @@
30
30
  padding: 0.5rem 1rem;
31
31
  gap: 0.75rem;
32
32
  height: 2rem;
33
- background: var(--np-surface);
34
33
  align-items: center;
35
34
 
36
35
  &:checked {
@@ -99,8 +99,32 @@
99
99
  const isActive = (t: number) => t >= activeFrom && t <= activeTo
100
100
 
101
101
  let dragging = $state<'start' | 'end' | null>(null)
102
+ let tracking = $state(false)
102
103
  let trackElement = $state<HTMLDivElement>()
104
+ let activeLaneElement = $state<HTMLDivElement>()
105
+ let iconElement = $state<HTMLSpanElement>()
106
+ let iconFits = $state(true)
103
107
  let pointerFocused = $state(false)
108
+ let pointerOrigin: { x: number; y: number } | null = null
109
+
110
+ const DRAG_THRESHOLD = 3
111
+
112
+ $effect(() => {
113
+ if (!icon || !activeLaneElement) return
114
+ const lane = activeLaneElement
115
+ const inlineOf = (r: DOMRect) => (orientation === 'vertical' ? r.height : r.width)
116
+ const measure = () => {
117
+ if (!iconElement) return
118
+ const padding = parseFloat(getComputedStyle(iconElement).insetInlineStart) || 0
119
+ iconFits =
120
+ inlineOf(lane.getBoundingClientRect()) >=
121
+ inlineOf(iconElement.getBoundingClientRect()) + 2 * padding
122
+ }
123
+ const observer = new ResizeObserver(measure)
124
+ observer.observe(lane)
125
+ measure()
126
+ return () => observer.disconnect()
127
+ })
104
128
 
105
129
  const fractionFromEvent = (e: PointerEvent) => {
106
130
  if (!trackElement) return 0
@@ -131,9 +155,23 @@
131
155
  }
132
156
  }
133
157
 
158
+ const onpointermove = (e: PointerEvent) => {
159
+ if (!dragging) return
160
+ if (!tracking) {
161
+ const d = pointerOrigin
162
+ ? Math.hypot(e.clientX - pointerOrigin.x, e.clientY - pointerOrigin.y)
163
+ : Infinity
164
+ if (d <= DRAG_THRESHOLD) return
165
+ tracking = true
166
+ }
167
+ moveTo(e)
168
+ }
169
+
134
170
  const onpointerdown = (e: PointerEvent) => {
135
171
  if (disabled || e.button !== 0) return
136
172
  const v = min + clamp(fractionFromEvent(e), 0, 1) * span
173
+ pointerOrigin = { x: e.clientX, y: e.clientY }
174
+ tracking = false
137
175
  dragging = !range
138
176
  ? 'start'
139
177
  : lo === hi
@@ -154,6 +192,8 @@
154
192
  if (!dragging) return
155
193
  const input = dragging === 'start' ? inputElement : endInputElement
156
194
  dragging = null
195
+ tracking = false
196
+ pointerOrigin = null
157
197
  element?.releasePointerCapture(e.pointerId)
158
198
  input?.dispatchEvent(new Event('change', { bubbles: true }))
159
199
  }
@@ -173,12 +213,13 @@
173
213
  labeled && 'np-labeled',
174
214
  disabled && 'np-disabled',
175
215
  dragging && 'np-dragging',
216
+ tracking && 'np-tracking',
176
217
  pointerFocused && 'np-pointer-focused',
177
218
  attributes.class,
178
219
  ]}
179
220
  role="presentation"
180
221
  {onpointerdown}
181
- onpointermove={moveTo}
222
+ {onpointermove}
182
223
  onpointerup={endDrag}
183
224
  onpointercancel={endDrag}
184
225
  onkeydown={() => (pointerFocused = false)}
@@ -190,12 +231,15 @@
190
231
  <span class="np-slider-stop np-slider-stop-start"></span>
191
232
  </div>
192
233
  {/if}
193
- <div class="np-slider-lane np-slider-active">
194
- {#if icon}
195
- <span class="np-slider-icon">{@render icon()}</span>
234
+ <div class="np-slider-lane np-slider-active" bind:this={activeLaneElement}>
235
+ {#if icon && iconFits}
236
+ <span class="np-slider-icon" bind:this={iconElement}>{@render icon()}</span>
196
237
  {/if}
197
238
  </div>
198
239
  <div class="np-slider-lane np-slider-inactive-end">
240
+ {#if icon && !iconFits}
241
+ <span class="np-slider-icon np-on-inactive" bind:this={iconElement}>{@render icon()}</span>
242
+ {/if}
199
243
  <span class="np-slider-stop np-slider-stop-end"></span>
200
244
  </div>
201
245
 
@@ -266,6 +310,8 @@
266
310
  --_inside: var(--np-slider-track-inside-shape, 0.125rem);
267
311
  --_stop: var(--np-slider-stop-indicator-size, 0.25rem);
268
312
  --_gap: calc(var(--_hw) / 2 + 0.375rem);
313
+ --_ring: 3px;
314
+ --_ring-gap: 0.4rem;
269
315
 
270
316
  position: relative;
271
317
  display: block;
@@ -320,8 +366,7 @@
320
366
  inset-inline-end var(--np-motion-expressive-fast-effects);
321
367
  }
322
368
 
323
- .np-dragging .np-slider-lane,
324
- .np-dragging .np-slider-handle {
369
+ .np-tracking .np-slider-lane {
325
370
  transition: none;
326
371
  }
327
372
 
@@ -372,6 +417,10 @@
372
417
  writing-mode: horizontal-tb;
373
418
  }
374
419
 
420
+ .np-slider-icon.np-on-inactive {
421
+ color: var(--np-slider-icon-inactive-color, var(--np-color-on-secondary-container));
422
+ }
423
+
375
424
  .np-slider-icon :global(svg) {
376
425
  inline-size: var(--_icon-size);
377
426
  block-size: var(--_icon-size);
@@ -387,6 +436,7 @@
387
436
  border-radius: var(--np-shape-corner-full);
388
437
  background: var(--np-slider-inactive-stop-color, var(--np-color-on-secondary-container));
389
438
  pointer-events: none;
439
+ transition: background-color var(--np-motion-expressive-fast-effects);
390
440
  }
391
441
 
392
442
  .np-slider-stop-end {
@@ -408,15 +458,21 @@
408
458
  }
409
459
 
410
460
  .np-slider-handle {
461
+ /* The current handle box drives both size and offset, so a handle that
462
+ narrows or shortens stays centred on the value it points at. */
463
+ --_cw: var(--_hw);
464
+ --_ch: var(--_handle-height);
465
+
411
466
  position: absolute;
412
- inset-inline-start: calc(var(--_p) - var(--_hw) / 2);
413
- inset-block-start: calc((var(--_track-height) - var(--_handle-height)) / 2);
414
- inline-size: var(--_hw);
415
- block-size: var(--_handle-height);
467
+ inset-inline-start: calc(var(--_p) - var(--_cw) / 2);
468
+ inset-block-start: calc((var(--_track-height) - var(--_ch)) / 2);
469
+ inline-size: var(--_cw);
470
+ block-size: var(--_ch);
416
471
  border-radius: var(--np-slider-handle-shape, var(--np-shape-corner-full));
417
472
  background: var(--np-slider-handle-color, var(--np-color-primary));
418
473
  transition:
419
474
  inset-inline-start var(--np-motion-expressive-fast-effects),
475
+ inset-block-start var(--np-motion-expressive-fast-effects),
420
476
  inline-size var(--np-motion-expressive-fast-effects),
421
477
  block-size var(--np-motion-expressive-fast-effects);
422
478
  }
@@ -429,18 +485,25 @@
429
485
  --_p: var(--_p2);
430
486
  }
431
487
 
488
+ /* Focus narrows the handle the same way a press does and keeps its full height;
489
+ the ring stands clear of it instead of hugging it. */
432
490
  .np-slider:not(.np-disabled, .np-pointer-focused):has(.np-slider-input-start:focus-visible)
433
491
  .np-slider-handle-start,
434
492
  .np-slider:not(.np-disabled, .np-pointer-focused):has(.np-slider-input-end:focus-visible)
435
493
  .np-slider-handle-end {
436
- inline-size: var(--np-slider-handle-width-focus, 0.125rem);
437
- block-size: calc(var(--_handle-height) - 0.375rem);
438
- outline: 3px solid var(--np-color-secondary);
439
- outline-offset: 0.125rem;
494
+ --_cw: var(--np-slider-handle-width-focus, 0.125rem);
495
+ outline: var(--_ring) solid var(--np-color-secondary);
496
+ outline-offset: var(--_ring-gap);
440
497
  }
441
498
 
442
499
  .np-dragging .np-slider-handle {
443
- inline-size: var(--np-slider-handle-width-focus, 0.125rem);
500
+ --_cw: var(--np-slider-handle-width-focus, 0.125rem);
501
+ }
502
+
503
+ .np-tracking .np-slider-handle {
504
+ transition:
505
+ inline-size var(--np-motion-expressive-fast-effects),
506
+ block-size var(--np-motion-expressive-fast-effects);
444
507
  }
445
508
 
446
509
  .np-slider-label-anchor {
@@ -530,10 +593,15 @@
530
593
  color: var(--np-color-surface);
531
594
  }
532
595
 
596
+ .np-slider.np-disabled .np-slider-icon.np-on-inactive {
597
+ color: color-mix(in srgb, var(--np-color-on-surface) 38%, transparent);
598
+ }
599
+
533
600
  @media (prefers-reduced-motion: reduce) {
534
- .np-slider-lane,
535
- .np-slider-handle,
536
- .np-slider-label {
601
+ .np-slider .np-slider-lane,
602
+ .np-slider .np-slider-handle,
603
+ .np-slider .np-slider-tick,
604
+ .np-slider .np-slider-label {
537
605
  transition: none;
538
606
  }
539
607
  }
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './chip/types.ts';
6
6
  export * from './dialog/types.ts';
7
7
  export * from './divider/types.ts';
8
8
  export * from './list/types.ts';
9
+ export * from './loading-indicator/types.ts';
9
10
  export * from './menu/types.ts';
10
11
  export * from './navigation-drawer/types.ts';
11
12
  export * from './navigation-rail/types.ts';
package/dist/types.js CHANGED
@@ -6,6 +6,7 @@ export * from './chip/types.ts';
6
6
  export * from './dialog/types.ts';
7
7
  export * from './divider/types.ts';
8
8
  export * from './list/types.ts';
9
+ export * from './loading-indicator/types.ts';
9
10
  export * from './menu/types.ts';
10
11
  export * from './navigation-drawer/types.ts';
11
12
  export * from './navigation-rail/types.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {