compote-ui 0.86.1 → 0.87.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.
@@ -5,6 +5,11 @@
5
5
  import { PhCaretDown, PhCaretUp } from '../../icons';
6
6
  import { useLocaleContext } from '@ark-ui/svelte/locale';
7
7
  import { toNumberInputValue } from '../../utils/number-input-value';
8
+ import {
9
+ getWheelDirection,
10
+ getWheelNextValue,
11
+ getWheelStep
12
+ } from '../../utils/number-input-wheel';
8
13
 
9
14
  let {
10
15
  value = $bindable(),
@@ -21,6 +26,39 @@
21
26
  ? 'flex items-center justify-between gap-1.5 w-full data-disabled:opacity-50 data-disabled:grayscale'
22
27
  : 'flex flex-col gap-1.5 w-full max-w-48 data-disabled:opacity-50 data-disabled:grayscale'
23
28
  );
29
+
30
+ type NumberInputApi = { setValue: (n: number) => void; valueAsNumber: number };
31
+
32
+ // Shift+wheel steps by `largeStep`, Alt+wheel by `smallStep` (same modifiers as the arrow
33
+ // keys). Capture phase + stopImmediatePropagation keeps Zag's plain-step wheel handler from also
34
+ // firing. Zag only listens while the input is focused, so we do the same.
35
+ function modifierWheel(api: () => NumberInputApi) {
36
+ return (node: HTMLElement) => {
37
+ function onWheel(event: WheelEvent) {
38
+ if (restProps.disabled || readonly) return;
39
+ const input = node.querySelector('input');
40
+ if (!input || document.activeElement !== input) return;
41
+ const stepSize = getWheelStep(event, restProps);
42
+ if (stepSize === null) return;
43
+ event.preventDefault();
44
+ event.stopImmediatePropagation();
45
+ const direction = getWheelDirection(event);
46
+ if (direction === 0) return;
47
+ api().setValue(
48
+ getWheelNextValue({
49
+ current: api().valueAsNumber,
50
+ direction,
51
+ stepSize,
52
+ min: restProps.min,
53
+ max: restProps.max,
54
+ allowOverflow: restProps.allowOverflow
55
+ })
56
+ );
57
+ }
58
+ node.addEventListener('wheel', onWheel, { capture: true, passive: false });
59
+ return () => node.removeEventListener('wheel', onWheel, { capture: true });
60
+ };
61
+ }
24
62
  </script>
25
63
 
26
64
  <input type="hidden" {name} value={Number.isNaN(value) ? undefined : value?.toString()} />
@@ -47,23 +85,27 @@
47
85
  <Field.RequiredIndicator />
48
86
  </NumberInput.Label>
49
87
  {/if}
50
- <NumberInput.Control class="relative isolate">
51
- <NumberInput.Input
52
- class="h-9 w-full max-w-48 rounded-md border bg-surface-1 px-3 pr-8 text-right text-sm font-medium tabular-nums shadow-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none data-invalid:border-danger data-invalid:focus-visible:ring-danger"
53
- />
54
- <div
55
- class="absolute top-px right-px bottom-px z-10 flex w-6 flex-col overflow-hidden rounded-r border-l"
56
- >
57
- <NumberInput.IncrementTrigger
58
- class="inline-flex flex-1 cursor-pointer items-center justify-center border-none text-ink-dim hover:bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary active:bg-surface-2 disabled:cursor-not-allowed disabled:text-ink-dim"
59
- >
60
- <PhCaretUp class="h-4 w-4 shrink-0" />
61
- </NumberInput.IncrementTrigger>
62
- <NumberInput.DecrementTrigger
63
- class="inline-flex flex-1 cursor-pointer items-center justify-center border-t text-ink-dim hover:bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary active:bg-surface-2 disabled:cursor-not-allowed disabled:text-ink-dim"
64
- >
65
- <PhCaretDown class="h-4 w-4 shrink-0" />
66
- </NumberInput.DecrementTrigger>
67
- </div>
68
- </NumberInput.Control>
88
+ <NumberInput.Context>
89
+ {#snippet render(api)}
90
+ <NumberInput.Control class="relative isolate" {@attach modifierWheel(api)}>
91
+ <NumberInput.Input
92
+ class="h-9 w-full max-w-48 rounded-md border bg-surface-1 px-3 pr-8 text-right text-sm font-medium tabular-nums shadow-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none data-invalid:border-danger data-invalid:focus-visible:ring-danger"
93
+ />
94
+ <div
95
+ class="absolute top-px right-px bottom-px z-10 flex w-6 flex-col overflow-hidden rounded-r border-l"
96
+ >
97
+ <NumberInput.IncrementTrigger
98
+ class="inline-flex flex-1 cursor-pointer items-center justify-center border-none text-ink-dim hover:bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary active:bg-surface-2 disabled:cursor-not-allowed disabled:text-ink-dim"
99
+ >
100
+ <PhCaretUp class="h-4 w-4 shrink-0" />
101
+ </NumberInput.IncrementTrigger>
102
+ <NumberInput.DecrementTrigger
103
+ class="inline-flex flex-1 cursor-pointer items-center justify-center border-t text-ink-dim hover:bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary active:bg-surface-2 disabled:cursor-not-allowed disabled:text-ink-dim"
104
+ >
105
+ <PhCaretDown class="h-4 w-4 shrink-0" />
106
+ </NumberInput.DecrementTrigger>
107
+ </div>
108
+ </NumberInput.Control>
109
+ {/snippet}
110
+ </NumberInput.Context>
69
111
  </NumberInput.Root>
@@ -0,0 +1,32 @@
1
+ export interface WheelStepOptions {
2
+ step?: number;
3
+ formatOptions?: Intl.NumberFormatOptions;
4
+ largeStep?: number;
5
+ smallStep?: number;
6
+ }
7
+ /** Zag's default step: 1, or 0.01 for percent formatting without an explicit `step`. */
8
+ export declare function getDefaultStep(step?: number, formatOptions?: Intl.NumberFormatOptions): number;
9
+ /**
10
+ * Step size for a wheel event, mirroring Zag's keyboard modifiers: Shift → `largeStep`
11
+ * (default `10 × step`), Alt → `smallStep` (default `step / 10`). Returns `null` for a plain
12
+ * wheel so Zag's own handler keeps handling it.
13
+ */
14
+ export declare function getWheelStep(event: Pick<WheelEvent, 'shiftKey' | 'altKey'>, { step: stepProp, formatOptions, largeStep, smallStep }: WheelStepOptions): number | null;
15
+ /**
16
+ * Wheel direction: +1 for scrolling up (increment), -1 for down, 0 for none. Browsers report
17
+ * Shift+wheel on `deltaX` instead of `deltaY` (Chrome/Edge), hence the fallback.
18
+ */
19
+ export declare function getWheelDirection(event: Pick<WheelEvent, 'deltaX' | 'deltaY'>): -1 | 0 | 1;
20
+ /**
21
+ * `current + direction × stepSize`, rounded to the precision of the operands (no
22
+ * `0.30000000000000004`) and clamped to `[min, max]` unless `allowOverflow`. An empty
23
+ * (`NaN`) current value starts from 0.
24
+ */
25
+ export declare function getWheelNextValue(options: {
26
+ current: number;
27
+ direction: -1 | 1;
28
+ stepSize: number;
29
+ min?: number;
30
+ max?: number;
31
+ allowOverflow?: boolean;
32
+ }): number;
@@ -0,0 +1,46 @@
1
+ /** Zag's default step: 1, or 0.01 for percent formatting without an explicit `step`. */
2
+ export function getDefaultStep(step, formatOptions) {
3
+ if (step !== undefined && !Number.isNaN(step))
4
+ return step;
5
+ return formatOptions?.style === 'percent' ? 0.01 : 1;
6
+ }
7
+ /**
8
+ * Step size for a wheel event, mirroring Zag's keyboard modifiers: Shift → `largeStep`
9
+ * (default `10 × step`), Alt → `smallStep` (default `step / 10`). Returns `null` for a plain
10
+ * wheel so Zag's own handler keeps handling it.
11
+ */
12
+ export function getWheelStep(event, { step: stepProp, formatOptions, largeStep, smallStep }) {
13
+ const step = getDefaultStep(stepProp, formatOptions);
14
+ if (event.shiftKey)
15
+ return largeStep ?? step * 10;
16
+ if (event.altKey)
17
+ return smallStep ?? step / 10;
18
+ return null;
19
+ }
20
+ /**
21
+ * Wheel direction: +1 for scrolling up (increment), -1 for down, 0 for none. Browsers report
22
+ * Shift+wheel on `deltaX` instead of `deltaY` (Chrome/Edge), hence the fallback.
23
+ */
24
+ export function getWheelDirection(event) {
25
+ const delta = event.deltaY || event.deltaX;
26
+ return (Math.sign(delta) * -1 || 0);
27
+ }
28
+ function decimals(n) {
29
+ if (!Number.isFinite(n))
30
+ return 0;
31
+ const [mantissa, exponent] = n.toString().split('e');
32
+ const fraction = mantissa.split('.')[1]?.length ?? 0;
33
+ return Math.max(0, fraction - Number(exponent ?? 0));
34
+ }
35
+ /**
36
+ * `current + direction × stepSize`, rounded to the precision of the operands (no
37
+ * `0.30000000000000004`) and clamped to `[min, max]` unless `allowOverflow`. An empty
38
+ * (`NaN`) current value starts from 0.
39
+ */
40
+ export function getWheelNextValue(options) {
41
+ const { direction, stepSize, min = -Infinity, max = Infinity, allowOverflow } = options;
42
+ const current = Number.isNaN(options.current) ? 0 : options.current;
43
+ const precision = Math.max(decimals(current), decimals(stepSize));
44
+ const next = Number((current + direction * stepSize).toFixed(Math.min(precision, 20)));
45
+ return allowOverflow ? next : Math.min(max, Math.max(min, next));
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compote-ui",
3
- "version": "0.86.1",
3
+ "version": "0.87.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -170,7 +170,12 @@ If selection must be restricted to a fixed set (pick-only, no free text), use `C
170
170
  ```
171
171
 
172
172
  Props: `value?: number | null` (bindable), `label?`, `readonly?`, `layout?: 'vertical' | 'horizontal'`,
173
- plus Ark UI NumberInput props (`min`, `max`, `step`, `formatOptions`, `locale`).
173
+ plus Ark UI NumberInput props (`min`, `max`, `step`, `largeStep`, `smallStep`, `formatOptions`,
174
+ `locale`).
175
+
176
+ Step modifiers (focused input): arrow keys and mouse wheel use `step`; **Shift** uses `largeStep`
177
+ (default `10 × step`), **Alt** uses `smallStep` (default `step / 10`). Shift/Alt+wheel are handled by
178
+ Compote (values are clamped to `min`/`max` unless `allowOverflow`); Ark only provides them for keys.
174
179
 
175
180
  `value` is controlled: setting it to `null` from outside clears the input. Use `defaultValue`
176
181
  (a string) only for uncontrolled use, without passing `value`.