panelui-native 0.20.1 → 0.22.3

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.
Files changed (44) hide show
  1. package/lib/module/components/bottom-sheet/index.js +35 -10
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/flow/flow-paths.js +18 -0
  4. package/lib/module/components/flow/flow-paths.js.map +1 -1
  5. package/lib/module/components/flow/index.js +156 -31
  6. package/lib/module/components/flow/index.js.map +1 -1
  7. package/lib/module/components/input/index.js +15 -5
  8. package/lib/module/components/input/index.js.map +1 -1
  9. package/lib/module/components/number-input/index.js +350 -0
  10. package/lib/module/components/number-input/index.js.map +1 -0
  11. package/lib/module/components/signature/index.js +7 -3
  12. package/lib/module/components/signature/index.js.map +1 -1
  13. package/lib/module/components/switch/index.js +7 -2
  14. package/lib/module/components/switch/index.js.map +1 -1
  15. package/lib/module/icons/index.js +39 -1
  16. package/lib/module/icons/index.js.map +1 -1
  17. package/lib/module/index.js +5 -0
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/flow/flow-paths.d.ts +8 -0
  21. package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -1
  22. package/lib/typescript/src/components/flow/index.d.ts +13 -1
  23. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  25. package/lib/typescript/src/components/number-input/index.d.ts +169 -0
  26. package/lib/typescript/src/components/number-input/index.d.ts.map +1 -0
  27. package/lib/typescript/src/components/signature/index.d.ts +4 -0
  28. package/lib/typescript/src/components/signature/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/switch/index.d.ts +7 -0
  30. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  31. package/lib/typescript/src/icons/index.d.ts +10 -1
  32. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  33. package/lib/typescript/src/index.d.ts +2 -0
  34. package/lib/typescript/src/index.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/bottom-sheet/index.tsx +52 -21
  37. package/src/components/flow/flow-paths.ts +14 -0
  38. package/src/components/flow/index.tsx +197 -31
  39. package/src/components/input/index.tsx +15 -5
  40. package/src/components/number-input/index.tsx +373 -0
  41. package/src/components/signature/index.tsx +13 -3
  42. package/src/components/switch/index.tsx +16 -2
  43. package/src/icons/index.tsx +32 -2
  44. package/src/index.ts +8 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * NumberInput — a value stepped by two buttons or typed by hand.
3
+ *
4
+ * A number is not a slider: past a handful of steps, dragging to 47 is worse
5
+ * than tapping + a few times or just typing it. So the control keeps both
6
+ * doors open — a − and a + at the ends for nudging, and an editable field in
7
+ * the middle for jumping straight to a value — and reconciles them through a
8
+ * single clamped, step-snapped number.
9
+ *
10
+ * The two ends are not just tap targets. Hold one down and it repeats, after a
11
+ * short delay and then faster, the way a native stepper does — so walking from
12
+ * 0 to 200 is a press, not two hundred of them. The repeat reads the live
13
+ * value from a ref rather than a closure, so it never strides off a stale
14
+ * number.
15
+ *
16
+ * Typing is allowed to be briefly invalid — an empty field, a lone `-`, a
17
+ * trailing `.` — because clamping every keystroke fights the person mid-number.
18
+ * The field commits (parses, snaps, clamps) on blur, and falls back to the
19
+ * last good value when what is left cannot be read as a number.
20
+ *
21
+ * ```tsx
22
+ * <NumberInput defaultValue={1} min={0} max={10} />
23
+ * <NumberInput label="Quantity" value={qty} onValueChange={setQty} />
24
+ * ```
25
+ *
26
+ * Runs controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
27
+ */
28
+ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
29
+ import { TextInput, View, type TextInputProps, type TextStyle } from 'react-native';
30
+ import { tv, type VariantProps } from 'tailwind-variants';
31
+ import { useCSSVariable } from 'uniwind';
32
+ import { MinusIcon, PlusIcon } from '../../icons';
33
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
34
+ import { Text } from '../../primitives/text';
35
+ import { selectionTick } from '../../utils/haptics';
36
+ import { Label } from '../label';
37
+
38
+ /** Wait before a held button starts repeating — a tap must not trip it. */
39
+ const REPEAT_DELAY = 400;
40
+ /** Interval between repeats once holding — fast enough to cover ground. */
41
+ const REPEAT_INTERVAL = 80;
42
+
43
+ const numberInputVariants = tv({
44
+ slots: {
45
+ container: 'w-full gap-1.5',
46
+ control: 'w-full flex-row items-center overflow-hidden rounded-lg border',
47
+ button: 'h-full items-center justify-center',
48
+ // The field takes the full height so the digits centre against the same
49
+ // box the two glyphs do, rather than against their own line's height.
50
+ field: 'h-full flex-1 text-center font-medium text-foreground',
51
+ description: 'text-sm text-muted-foreground',
52
+ error: 'text-sm text-destructive',
53
+ },
54
+ variants: {
55
+ variant: {
56
+ // Matches Input: outline draws its own edge on the page; filled sits
57
+ // inside a card, where a second border reads as a seam.
58
+ outline: { control: 'border-input bg-background' },
59
+ filled: { control: 'border-transparent bg-muted' },
60
+ },
61
+ size: {
62
+ sm: { control: 'h-10', button: 'w-10' },
63
+ md: { control: 'h-12', button: 'w-12' },
64
+ lg: { control: 'h-14', button: 'w-14' },
65
+ },
66
+ invalid: {
67
+ true: { control: 'border-destructive' },
68
+ },
69
+ disabled: {
70
+ true: { container: 'opacity-[0.64]' },
71
+ },
72
+ },
73
+ defaultVariants: {
74
+ variant: 'outline',
75
+ size: 'md',
76
+ },
77
+ });
78
+
79
+ /** Icon size per control size — the glyph scales with its target. */
80
+ const ICON_SIZE: Record<'sm' | 'md' | 'lg', number> = { sm: 16, md: 18, lg: 20 };
81
+
82
+ /**
83
+ * The field's job is not to be a box of its own: it has to put the number on
84
+ * exactly the axis the − and the + sit on, and in the exact middle between
85
+ * them. An editable field does not do that unhelped — it arrives with padding
86
+ * and, on Android, with font padding above and below the line, all of which
87
+ * shift the digits off centre. Zeroing both and centring the line in the full
88
+ * height leaves geometry as the only thing placing the number.
89
+ *
90
+ * Tabular figures on top of that, so the digits keep the same advance widths
91
+ * while the value counts — a 1 that is narrower than a 7 makes a centred
92
+ * number appear to shuffle sideways as it steps.
93
+ */
94
+ const FIELD_BASE: TextStyle = {
95
+ paddingVertical: 0,
96
+ paddingHorizontal: 0,
97
+ includeFontPadding: false,
98
+ textAlignVertical: 'center',
99
+ fontVariant: ['tabular-nums'],
100
+ };
101
+
102
+ /**
103
+ * Font size is set here rather than by a text class because those bring a line
104
+ * height with them: a 16px number in a 24px line box. The extra leading is
105
+ * added above the digits, so centring that box leaves the number sitting a
106
+ * couple of pixels below the − and the + it is meant to line up with — close
107
+ * enough to look like a mistake rather than a difference. Sized this way the
108
+ * line box is the font's own, and centring it centres the digits.
109
+ */
110
+ const FIELD_STYLE: Record<'sm' | 'md' | 'lg', TextStyle> = {
111
+ sm: { ...FIELD_BASE, fontSize: 14 },
112
+ md: { ...FIELD_BASE, fontSize: 16 },
113
+ lg: { ...FIELD_BASE, fontSize: 16 },
114
+ };
115
+
116
+ type NumberInputVariantProps = VariantProps<typeof numberInputVariants>;
117
+
118
+ export interface NumberInputProps
119
+ extends Omit<NumberInputVariantProps, 'invalid' | 'disabled'>,
120
+ Pick<TextInputProps, 'onFocus' | 'onBlur' | 'placeholder' | 'returnKeyType'> {
121
+ className?: string;
122
+ containerClassName?: string;
123
+ /** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */
124
+ value?: number;
125
+ /** Starting value when uncontrolled. Defaults to `min`, or 0. */
126
+ defaultValue?: number;
127
+ /** Lower bound. The decrement button disables here. */
128
+ min?: number;
129
+ /** Upper bound. The increment button disables here. */
130
+ max?: number;
131
+ /** Nudge per press, and the granularity the value snaps to. */
132
+ step?: number;
133
+ /** Fires whenever the committed value changes. */
134
+ onValueChange?: (value: number) => void;
135
+ /**
136
+ * Format the displayed number — units, currency, grouping. The field shows
137
+ * this string; typing still reads a bare number back. Defaults to the value
138
+ * rounded to `step`'s precision.
139
+ */
140
+ formatValue?: (value: number) => string;
141
+ /** Let the middle field be typed into. When false it is display-only. */
142
+ editable?: boolean;
143
+ disabled?: boolean;
144
+ /** A label above the control. Doubles as the accessibility label. */
145
+ label?: string;
146
+ /** Helper text below the control. Hidden while an error shows. */
147
+ description?: string;
148
+ /** Error message. When set, the control renders in its invalid state. */
149
+ errorMessage?: string;
150
+ /** Marks the field required — an asterisk on the label, and the a11y state. */
151
+ isRequired?: boolean;
152
+ /**
153
+ * Tick the haptic engine on each step. Needs the optional `expo-haptics`,
154
+ * and is silent without it.
155
+ */
156
+ haptics?: boolean;
157
+ }
158
+
159
+ /** Decimals implied by `step`, so display and math round to the same place. */
160
+ function precisionOf(step: number): number {
161
+ if (!isFinite(step)) return 0;
162
+ const s = String(step);
163
+ const dot = s.indexOf('.');
164
+ return dot === -1 ? 0 : s.length - dot - 1;
165
+ }
166
+
167
+ function clamp(value: number, min: number, max: number): number {
168
+ return Math.min(Math.max(value, min), max);
169
+ }
170
+
171
+ /**
172
+ * Snaps to the nearest step from a finite base (or 0 when `min` is open), then
173
+ * rounds to the step's precision so 0.1 + 0.2 does not drift to 0.30000004.
174
+ */
175
+ function normalize(value: number, min: number, max: number, step: number): number {
176
+ const base = isFinite(min) ? min : 0;
177
+ const snapped = isFinite(step) && step > 0
178
+ ? Math.round((value - base) / step) * step + base
179
+ : value;
180
+ const p = precisionOf(step);
181
+ const factor = 10 ** p;
182
+ return clamp(Math.round(snapped * factor) / factor, min, max);
183
+ }
184
+
185
+ /**
186
+ * A number stepped by buttons or typed by hand, reconciled to one clamped,
187
+ * step-snapped value. See the file header for why it keeps both affordances.
188
+ */
189
+ export const NumberInput = forwardRef<TextInput, NumberInputProps>(
190
+ (
191
+ {
192
+ className,
193
+ containerClassName,
194
+ value: controlledValue,
195
+ defaultValue,
196
+ min = -Infinity,
197
+ max = Infinity,
198
+ step = 1,
199
+ onValueChange,
200
+ formatValue,
201
+ editable = true,
202
+ variant = 'outline',
203
+ size = 'md',
204
+ disabled,
205
+ label,
206
+ description,
207
+ errorMessage,
208
+ isRequired,
209
+ haptics,
210
+ placeholder,
211
+ onFocus,
212
+ onBlur,
213
+ returnKeyType,
214
+ },
215
+ ref
216
+ ) => {
217
+ const isControlled = controlledValue !== undefined;
218
+ const initial = normalize(
219
+ controlledValue ?? defaultValue ?? (isFinite(min) ? min : 0),
220
+ min,
221
+ max,
222
+ step
223
+ );
224
+ const [uncontrolled, setUncontrolled] = useState(initial);
225
+ const value = isControlled ? normalize(controlledValue!, min, max, step) : uncontrolled;
226
+
227
+ // The repeat timers read the live value through a ref, so a held button
228
+ // keeps counting from where it is rather than from the value it closed over.
229
+ const valueRef = useRef(value);
230
+ valueRef.current = value;
231
+
232
+ const invalid = !!errorMessage;
233
+ const foreground = useCSSVariable('--color-foreground');
234
+ const iconColor = typeof foreground === 'string' ? foreground : undefined;
235
+ const slots = numberInputVariants({ variant, size, invalid, disabled: !!disabled });
236
+
237
+ const format = useCallback(
238
+ (v: number) => (formatValue ? formatValue(v) : String(v)),
239
+ [formatValue]
240
+ );
241
+
242
+ // `text` is what the field shows while focused; it is allowed to be
243
+ // half-typed. Away from focus it always mirrors the committed value.
244
+ const [text, setText] = useState(() => format(value));
245
+ const [focused, setFocused] = useState(false);
246
+ useEffect(() => {
247
+ if (!focused) setText(format(value));
248
+ }, [value, focused, format]);
249
+
250
+ const commit = useCallback(
251
+ (next: number, tick: boolean) => {
252
+ const clamped = normalize(next, min, max, step);
253
+ if (tick && haptics) selectionTick();
254
+ if (!isControlled) setUncontrolled(clamped);
255
+ if (clamped !== value) onValueChange?.(clamped);
256
+ },
257
+ [min, max, step, haptics, isControlled, value, onValueChange]
258
+ );
259
+
260
+ // --- hold-to-repeat -----------------------------------------------------
261
+ const delayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
262
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
263
+
264
+ const stopRepeat = useCallback(() => {
265
+ if (delayRef.current) clearTimeout(delayRef.current);
266
+ if (intervalRef.current) clearInterval(intervalRef.current);
267
+ delayRef.current = null;
268
+ intervalRef.current = null;
269
+ }, []);
270
+
271
+ useEffect(() => stopRepeat, [stopRepeat]);
272
+
273
+ const startRepeat = useCallback(
274
+ (direction: 1 | -1) => {
275
+ const nudge = () => commit(valueRef.current + direction * step, true);
276
+ nudge();
277
+ delayRef.current = setTimeout(() => {
278
+ intervalRef.current = setInterval(nudge, REPEAT_INTERVAL);
279
+ }, REPEAT_DELAY);
280
+ },
281
+ [commit, step]
282
+ );
283
+
284
+ const atMin = value <= min;
285
+ const atMax = value >= max;
286
+
287
+ const handleEndEditing = useCallback(() => {
288
+ const parsed = parseFloat(text);
289
+ // An empty or unreadable field falls back to the last good value rather
290
+ // than snapping to a surprising 0.
291
+ commit(Number.isNaN(parsed) ? value : parsed, false);
292
+ setFocused(false);
293
+ setText(format(Number.isNaN(parsed) ? value : normalize(parsed, min, max, step)));
294
+ }, [text, value, commit, format, min, max, step]);
295
+
296
+ return (
297
+ <View className={slots.container({ className: containerClassName })}>
298
+ {label ? (
299
+ <Label isRequired={isRequired} isInvalid={invalid} isDisabled={!!disabled}>
300
+ {label}
301
+ </Label>
302
+ ) : null}
303
+ <View className={slots.control({ className })}>
304
+ <AnimatedPressable
305
+ className={slots.button()}
306
+ disabled={disabled || atMin}
307
+ pressOpacity={0.6}
308
+ onPressIn={() => {
309
+ if (!disabled && !atMin) startRepeat(-1);
310
+ }}
311
+ onPressOut={stopRepeat}
312
+ accessibilityRole="button"
313
+ accessibilityLabel={`Decrease ${label ?? 'value'}`}
314
+ accessibilityState={{ disabled: disabled || atMin }}
315
+ style={atMin ? { opacity: 0.4 } : undefined}
316
+ >
317
+ {/* Same weight as the +, or the two ends read as different glyphs. */}
318
+ <MinusIcon size={ICON_SIZE[size]} color={iconColor} strokeWidth={2} />
319
+ </AnimatedPressable>
320
+
321
+ <TextInput
322
+ ref={ref}
323
+ editable={editable && !disabled}
324
+ value={text}
325
+ keyboardType={precisionOf(step) > 0 ? 'decimal-pad' : 'number-pad'}
326
+ placeholder={placeholder}
327
+ returnKeyType={returnKeyType}
328
+ selectTextOnFocus
329
+ onChangeText={setText}
330
+ onFocus={(e) => {
331
+ setFocused(true);
332
+ onFocus?.(e);
333
+ }}
334
+ onBlur={(e) => {
335
+ handleEndEditing();
336
+ onBlur?.(e);
337
+ }}
338
+ onSubmitEditing={handleEndEditing}
339
+ accessibilityLabel={label}
340
+ accessibilityState={{ disabled: !!disabled }}
341
+ aria-required={isRequired}
342
+ aria-invalid={invalid}
343
+ className={slots.field()}
344
+ style={FIELD_STYLE[size]}
345
+ />
346
+
347
+ <AnimatedPressable
348
+ className={slots.button()}
349
+ disabled={disabled || atMax}
350
+ pressOpacity={0.6}
351
+ onPressIn={() => {
352
+ if (!disabled && !atMax) startRepeat(1);
353
+ }}
354
+ onPressOut={stopRepeat}
355
+ accessibilityRole="button"
356
+ accessibilityLabel={`Increase ${label ?? 'value'}`}
357
+ accessibilityState={{ disabled: disabled || atMax }}
358
+ style={atMax ? { opacity: 0.4 } : undefined}
359
+ >
360
+ <PlusIcon size={ICON_SIZE[size]} color={iconColor} />
361
+ </AnimatedPressable>
362
+ </View>
363
+ {errorMessage ? (
364
+ <Text className={slots.error()}>{errorMessage}</Text>
365
+ ) : description ? (
366
+ <Text className={slots.description()}>{description}</Text>
367
+ ) : null}
368
+ </View>
369
+ );
370
+ }
371
+ );
372
+
373
+ NumberInput.displayName = 'NumberInput';
@@ -68,7 +68,13 @@ import Animated, {
68
68
  } from 'react-native-reanimated';
69
69
  import Svg, { Path } from 'react-native-svg';
70
70
  import { tv, type VariantProps } from 'tailwind-variants';
71
- import { PencilIcon, RotateCcwIcon, TrashIcon, type IconProps } from '../../icons';
71
+ import {
72
+ PencilIcon,
73
+ RotateCcwIcon,
74
+ RotateCwIcon,
75
+ TrashIcon,
76
+ type IconProps,
77
+ } from '../../icons';
72
78
  import {
73
79
  AnimatedPressable,
74
80
  type AnimatedPressableProps,
@@ -631,8 +637,8 @@ export interface SignatureButtonProps
631
637
  }
632
638
 
633
639
  /**
634
- * The two pad controls are the same round button with a different icon and a
635
- * different spoken name, so they are built rather than written twice.
640
+ * The pad controls are all the same round button with a different icon and a
641
+ * different spoken name, so they are built rather than written out each time.
636
642
  */
637
643
  function circleButton(
638
644
  Icon: ComponentType<IconProps>,
@@ -671,11 +677,15 @@ function circleButton(
671
677
  /** Removes the last stroke. Wire it to `ref.current?.undo()`. */
672
678
  const SignatureUndo = circleButton(RotateCcwIcon, 'Undo last stroke', 'Signature.Undo');
673
679
 
680
+ /** Puts back the last undone stroke. Wire it to `ref.current?.redo()`. */
681
+ const SignatureRedo = circleButton(RotateCwIcon, 'Redo last stroke', 'Signature.Redo');
682
+
674
683
  /** Drops every stroke. Wire it to `ref.current?.clear()`. */
675
684
  const SignatureClear = circleButton(TrashIcon, 'Clear signature', 'Signature.Clear');
676
685
 
677
686
  export const Signature = Object.assign(SignatureForwarded, {
678
687
  Toolbar: SignatureToolbar,
679
688
  Undo: SignatureUndo,
689
+ Redo: SignatureRedo,
680
690
  Clear: SignatureClear,
681
691
  });
@@ -8,6 +8,7 @@ import Animated, {
8
8
  } from 'react-native-reanimated';
9
9
  import { tv, type VariantProps } from 'tailwind-variants';
10
10
  import { getNativeUI } from '../../native';
11
+ import { selectionTick } from '../../utils/haptics';
11
12
 
12
13
  const SPRING = { damping: 18, stiffness: 250, mass: 0.5 } as const;
13
14
 
@@ -48,6 +49,13 @@ export interface SwitchProps extends VariantProps<typeof switchVariants> {
48
49
  native?: boolean;
49
50
  /** Text label drawn beside the control. Native mode only. */
50
51
  label?: string;
52
+ /**
53
+ * Tick the haptic engine each time the switch is flipped — a toggle you feel
54
+ * click rather than one that merely slides. Needs the optional
55
+ * `expo-haptics`, and is silent without it. Ignored in `native` mode, where
56
+ * the platform control owns its own feedback.
57
+ */
58
+ haptics?: boolean;
51
59
  }
52
60
 
53
61
  /**
@@ -55,7 +63,10 @@ export interface SwitchProps extends VariantProps<typeof switchVariants> {
55
63
  * UI thread; toggling never re-renders beyond the value change itself.
56
64
  */
57
65
  export const Switch = forwardRef<View, SwitchProps>(
58
- ({ className, value, onValueChange, disabled, size = 'md', native, label }, ref) => {
66
+ (
67
+ { className, value, onValueChange, disabled, size = 'md', native, label, haptics },
68
+ ref
69
+ ) => {
59
70
  const progress = useSharedValue(value ? 1 : 0);
60
71
  const nativeUI = native ? getNativeUI() : null;
61
72
  const slots = switchVariants({ size, disabled: !!disabled });
@@ -98,7 +109,10 @@ export const Switch = forwardRef<View, SwitchProps>(
98
109
  accessibilityRole="switch"
99
110
  accessibilityState={{ checked: value, disabled: !!disabled }}
100
111
  disabled={disabled}
101
- onPress={() => onValueChange?.(!value)}
112
+ onPress={() => {
113
+ if (haptics) selectionTick();
114
+ onValueChange?.(!value);
115
+ }}
102
116
  hitSlop={8}
103
117
  >
104
118
  <View className={slots.track({ className })}>
@@ -80,14 +80,21 @@ export function CheckIcon({ size = 14, color, ...props }: IconProps) {
80
80
  );
81
81
  }
82
82
 
83
- export function MinusIcon({ size = 14, color, ...props }: IconProps) {
83
+ /**
84
+ * A single bar. Its weight is a prop because it has two jobs: it is the
85
+ * indeterminate mark inside a checkbox, where it has to carry the same weight
86
+ * as the check it stands in for, and it is the decrement half of a stepper,
87
+ * where it has to match the plus beside it. Those are different weights, and a
88
+ * bar that is heavier than the + it pairs with reads as a different glyph.
89
+ */
90
+ export function MinusIcon({ size = 14, color, strokeWidth = 3, ...props }: IconProps) {
84
91
  const resolved = useResolvedColor(color, '#fff');
85
92
  return (
86
93
  <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
87
94
  <Path
88
95
  d="M5 12h14"
89
96
  stroke={resolved}
90
- strokeWidth={3}
97
+ strokeWidth={strokeWidth}
91
98
  strokeLinecap="round"
92
99
  strokeLinejoin="round"
93
100
  />
@@ -627,6 +634,29 @@ export function RotateCcwIcon({ size = 16, color, ...props }: IconProps) {
627
634
  );
628
635
  }
629
636
 
637
+ /** An arrow curling clockwise — redo, put back what was undone. */
638
+ export function RotateCwIcon({ size = 16, color, ...props }: IconProps) {
639
+ const resolved = useResolvedColor(color, '#737373');
640
+ return (
641
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
642
+ <Path
643
+ d="M21 12a9 9 0 1 1-2.64-6.36L21 8"
644
+ stroke={resolved}
645
+ strokeWidth={2}
646
+ strokeLinecap="round"
647
+ strokeLinejoin="round"
648
+ />
649
+ <Path
650
+ d="M21 3v5h-5"
651
+ stroke={resolved}
652
+ strokeWidth={2}
653
+ strokeLinecap="round"
654
+ strokeLinejoin="round"
655
+ />
656
+ </Svg>
657
+ );
658
+ }
659
+
630
660
  /** A bin — discard what is there. */
631
661
  export function TrashIcon({ size = 16, color, ...props }: IconProps) {
632
662
  const resolved = useResolvedColor(color, '#737373');
package/src/index.ts CHANGED
@@ -16,6 +16,10 @@ export {
16
16
 
17
17
  // Primitives
18
18
  export { Portal, PortalHost, PortalProvider } from './primitives/portal';
19
+ // The companion to Portal: the layer that makes the screen behind an overlay
20
+ // recede. Public so a custom overlay gets the same backdrop — and the same
21
+ // graceful fall back from blur to dim — as the built-in ones.
22
+ export { Scrim, hasBlur, type ScrimProps } from './primitives/scrim';
19
23
  export { Text, type TextProps } from './primitives/text';
20
24
  export {
21
25
  KeyboardAvoider,
@@ -225,6 +229,10 @@ export {
225
229
  type InputGroupInputProps,
226
230
  type InputGroupDecoratorProps,
227
231
  } from './components/input-group';
232
+ export {
233
+ NumberInput,
234
+ type NumberInputProps,
235
+ } from './components/number-input';
228
236
  export { OtpInput, type OtpInputProps } from './components/otp-input';
229
237
  export {
230
238
  Item,