@forthtilliath/react-native-kit 0.17.0 → 0.18.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.
package/README.md CHANGED
@@ -277,6 +277,21 @@ useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
277
277
  });
278
278
  ```
279
279
 
280
+ ### `usePressRepeat(timings?)`
281
+
282
+ Repeats an action while a button is held, faster and faster: `start(action)` runs it at once, then every 180 ms after 450 ms, every 60 ms after 1.2 s of repeating (all four timings overridable); `stop()` ends it, and so does unmounting.
283
+
284
+ ```tsx
285
+ import { usePressRepeat } from "@forthtilliath/react-native-kit/hooks/usePressRepeat";
286
+
287
+ const repeat = usePressRepeat();
288
+
289
+ <Pressable
290
+ onPressIn={() => repeat.start(() => setCount((n) => n + 1))}
291
+ onPressOut={repeat.stop}
292
+ />;
293
+ ```
294
+
280
295
  ### `confirmDestructive(title, onConfirm, options?)`
281
296
 
282
297
  Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults, or English ones with `{ locale: "en" }` (`message`, `cancelLabel`, `confirmLabel` all overridable).
@@ -343,20 +358,20 @@ const colors = Colors[useResolvedScheme()];
343
358
 
344
359
  Every component below has neutral default colors and a `styles` prop whose slots are each merged **after** their default (a partial override keeps the rest of the slot) — pass the app's palette there.
345
360
 
346
- | Component | Import path | What it is |
347
- | ----------------------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
348
- | `<BottomSheet visible onClose bottomInset?>` | `components/overlay/BottomSheet` | Modal sliding up from the bottom, content scrollable past 85 % of the screen. Pass `useSafeAreaInsets().bottom` as `bottomInset` with Android edge-to-edge. |
349
- | `<OptionsModal visible onClose options onSelect>` | `components/picker/OptionsModal` | Centered popup listing `{ value, label }` options; a tap selects and closes. |
350
- | `<SelectField label valueLabel options onSelect>` | `components/picker/SelectField` | Native-looking "select": captioned button + `OptionsModal` (styled through `styles.optionsModal`). |
351
- | `<SegmentedToggle options value onChange label?>` | `components/picker/SegmentedToggle` | Row of equal-width buttons, one active (Calendar / List…), announced as a radio group; each option may carry a smaller `hint` line under its label. |
352
- | `<QuantityStepper value onChange label min? max?>` | `components/picker/QuantityStepper` | Whole-number counter `min…max` (`0…999`): −/+ buttons (disabled at the bounds, `longPressStep` to jump on long press) and a numeric field; screen-reader labels in French or English (`a11yLabels` to override). |
353
- | `<SwitchRow label description? value onChange>` | `components/settings/SwitchRow` | Settings line "label + description + switch", the whole row pressable and read as one switch; `colors` for the track and thumb. |
354
- | `<SettingsCard title icon?>` + `SettingsCardStatus`, `SettingsCardHint` | `components/settings/SettingsCard` | Settings card: icon + title header, then free content — status lines (small icon + text, `iconColor` per state) and a quieter hint. |
355
- | `<CheckboxGroup options values onChange label?>` | `components/picker/CheckboxGroup` | Wrapping chips that each check and uncheck independently (extensions, tags…), each announced as a checkbox. |
356
- | `<MonthDayGrid days selected onToggle>` | `components/picker/MonthDayGrid` | Monday-first month grid whose days toggle on and off (holidays, days off…); `YYYY-MM-DD` days. |
357
- | `<AddButton label onPress>` | `components/list/AddButton` | Dashed "+ Add …" button closing a list. |
358
- | `<DragHandle onPressIn accessibilityLabel>` | `components/list/DragHandle` | "⠿" grip for `react-native-draggable-flatlist` rows (gesture-handler's `Pressable`). |
359
- | `<ErrorFallback error retry>` | `components/feedback/ErrorFallback` | Readable crash screen with a retry button, for Expo Router's `ErrorBoundary`; texts in French or English (`labels` to override). |
361
+ | Component | Import path | What it is |
362
+ | ----------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
363
+ | `<BottomSheet visible onClose bottomInset?>` | `components/overlay/BottomSheet` | Modal sliding up from the bottom, content scrollable past 85 % of the screen. Pass `useSafeAreaInsets().bottom` as `bottomInset` with Android edge-to-edge. |
364
+ | `<OptionsModal visible onClose options onSelect>` | `components/picker/OptionsModal` | Centered popup listing `{ value, label }` options; a tap selects and closes. |
365
+ | `<SelectField label valueLabel options onSelect>` | `components/picker/SelectField` | Native-looking "select": captioned button + `OptionsModal` (styled through `styles.optionsModal`). |
366
+ | `<SegmentedToggle options value onChange label?>` | `components/picker/SegmentedToggle` | Row of equal-width buttons, one active (Calendar / List…), announced as a radio group; each option may carry a smaller `hint` line under its label. |
367
+ | `<QuantityStepper value onChange label min? max?>` | `components/picker/QuantityStepper` | Whole-number counter `min…max` (`0…999`, negatives allowed with a negative `min`): −/+ buttons (disabled at the bounds, `longPressStep` to jump on long press, or `repeatOnHold` to repeat faster and faster while held) and a numeric field; screen-reader labels in French or English (`a11yLabels` to override). |
368
+ | `<SwitchRow label description? value onChange>` | `components/settings/SwitchRow` | Settings line "label + description + switch", the whole row pressable and read as one switch; `colors` for the track and thumb. |
369
+ | `<SettingsCard title icon?>` + `SettingsCardStatus`, `SettingsCardHint` | `components/settings/SettingsCard` | Settings card: icon + title header, then free content — status lines (small icon + text, `iconColor` per state) and a quieter hint. |
370
+ | `<CheckboxGroup options values onChange label?>` | `components/picker/CheckboxGroup` | Wrapping chips that each check and uncheck independently (extensions, tags…), each announced as a checkbox. |
371
+ | `<MonthDayGrid days selected onToggle>` | `components/picker/MonthDayGrid` | Monday-first month grid whose days toggle on and off (holidays, days off…); `YYYY-MM-DD` days. |
372
+ | `<AddButton label onPress>` | `components/list/AddButton` | Dashed "+ Add …" button closing a list. |
373
+ | `<DragHandle onPressIn accessibilityLabel>` | `components/list/DragHandle` | "⠿" grip for `react-native-draggable-flatlist` rows (gesture-handler's `Pressable`). |
374
+ | `<ErrorFallback error retry>` | `components/feedback/ErrorFallback` | Readable crash screen with a retry button, for Expo Router's `ErrorBoundary`; texts in French or English (`labels` to override). |
360
375
 
361
376
  `SwipeableRow` also takes `fullSwipeToDelete`: swiping past half the row deletes right away (the row slides out over a full-width delete band) instead of revealing a button — pair it with an `UndoToast`.
362
377
 
@@ -25,15 +25,26 @@ export interface QuantityStepperProps {
25
25
  onChange: (value: number) => void;
26
26
  /** What is being counted — used in the screen-reader labels. */
27
27
  label: string;
28
- /** @defaultValue 0 */
28
+ /**
29
+ * Below 0, negative values are allowed: the field then accepts a leading
30
+ * "-" (and opens the keyboard that has one).
31
+ * @defaultValue 0
32
+ */
29
33
  min?: number;
30
34
  /** @defaultValue 999 */
31
35
  max?: number;
32
36
  /**
33
37
  * Step applied by a long press on −/+, to reach large values quickly.
34
- * Unset, a long press does nothing more than a tap.
38
+ * Unset, a long press does nothing more than a tap. Ignored with
39
+ * `repeatOnHold`.
35
40
  */
36
41
  longPressStep?: number;
42
+ /**
43
+ * Holding −/+ repeats the step, faster and faster (see `usePressRepeat`),
44
+ * until released or a bound is reached.
45
+ * @defaultValue false
46
+ */
47
+ repeatOnHold?: boolean;
37
48
  /**
38
49
  * Language of the default screen-reader labels — French by default, or the
39
50
  * nearest `KitLocaleProvider`'s.
@@ -46,10 +57,12 @@ export interface QuantityStepperProps {
46
57
  }
47
58
  /**
48
59
  * A whole-number counter from `min` (0) to `max`: −/+ buttons to adjust
49
- * (optionally faster on long press), and a numeric field in the middle to
50
- * type a large number in one go (non-digits are dropped, the result is
51
- * clamped). A typed value below `min` (an empty field, the "2" of "25" with
52
- * `min={10}`) stays as typed until the field loses focus.
60
+ * (optionally faster on long press, or repeating while held), and a numeric
61
+ * field in the middle to type a large number in one go (other characters are
62
+ * dropped, the result is clamped). A typed value that is not a number yet or
63
+ * below `min` (an empty field, a lone "-", the "2" of "25" with `min={10}`)
64
+ * stays as typed until the field loses focus. A negative `min` allows
65
+ * negative values (penalties, defeat tokens…).
53
66
  *
54
67
  * @example
55
68
  * ```tsx
@@ -57,7 +70,8 @@ export interface QuantityStepperProps {
57
70
  *
58
71
  * <QuantityStepper value={count} onChange={setCount} label="Croissants" />
59
72
  * <QuantityStepper value={bans} onChange={setBans} label="Bannissements" min={1} max={40} longPressStep={5} />
73
+ * <QuantityStepper value={score} onChange={setScore} label="Points" min={-999} repeatOnHold />
60
74
  * ```
61
75
  */
62
- export declare function QuantityStepper({ value, onChange, label, min, max, longPressStep, locale, a11yLabels, styles, }: QuantityStepperProps): import("react").JSX.Element;
76
+ export declare function QuantityStepper({ value, onChange, label, min, max, longPressStep, repeatOnHold, locale, a11yLabels, styles, }: QuantityStepperProps): import("react").JSX.Element;
63
77
  //# sourceMappingURL=QuantityStepper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"QuantityStepper.d.ts","sourceRoot":"","sources":["../../../src/components/picker/QuantityStepper.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAGpE,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAGpE,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3B,0BAA0B;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,kEAAkE;IAClE,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,4BAA4B;IAC5B,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,iEAAiE;IACjE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,oFAAoF;AACpF,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,sBAAsB;IACtB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,yDAAyD;IACzD,UAAU,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChD,oEAAoE;IACpE,MAAM,CAAC,EAAE,qBAAqB,CAAC;CAChC;AAkDD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,GAAO,EACP,GAAS,EACT,aAAa,EACb,MAAM,EACN,UAAU,EACV,MAAM,GACP,EAAE,oBAAoB,+BAwEtB"}
1
+ {"version":3,"file":"QuantityStepper.d.ts","sourceRoot":"","sources":["../../../src/components/picker/QuantityStepper.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAIpE,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAGpE,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3B,0BAA0B;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,kEAAkE;IAClE,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,4BAA4B;IAC5B,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,iEAAiE;IACjE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,oFAAoF;AACpF,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,yDAAyD;IACzD,UAAU,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChD,oEAAoE;IACpE,MAAM,CAAC,EAAE,qBAAqB,CAAC;CAChC;AA0DD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,GAAO,EACP,GAAS,EACT,aAAa,EACb,YAAoB,EACpB,MAAM,EACN,UAAU,EACV,MAAM,GACP,EAAE,oBAAoB,+BAqItB"}
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import { Pressable, Text, TextInput, View } from "react-native";
4
+ import { usePressRepeat } from "../../hooks/usePressRepeat.js";
4
5
  import { useKitLocale } from "../../i18n/locale.js";
5
6
  import { mergeSlotStyles } from "../../utils/helpers/mergeSlotStyles.js";
6
7
  const defaultA11yLabels = {
@@ -49,12 +50,21 @@ const defaultStyles = {
49
50
  padding: 0,
50
51
  },
51
52
  };
53
+ /** Keeps the digits, and a leading "-" when negatives are allowed. */
54
+ function sanitize(text, allowsNegative) {
55
+ const digits = text.replace(/\D/g, "");
56
+ return allowsNegative && text.trimStart().startsWith("-")
57
+ ? `-${digits}`
58
+ : digits;
59
+ }
52
60
  /**
53
61
  * A whole-number counter from `min` (0) to `max`: −/+ buttons to adjust
54
- * (optionally faster on long press), and a numeric field in the middle to
55
- * type a large number in one go (non-digits are dropped, the result is
56
- * clamped). A typed value below `min` (an empty field, the "2" of "25" with
57
- * `min={10}`) stays as typed until the field loses focus.
62
+ * (optionally faster on long press, or repeating while held), and a numeric
63
+ * field in the middle to type a large number in one go (other characters are
64
+ * dropped, the result is clamped). A typed value that is not a number yet or
65
+ * below `min` (an empty field, a lone "-", the "2" of "25" with `min={10}`)
66
+ * stays as typed until the field loses focus. A negative `min` allows
67
+ * negative values (penalties, defeat tokens…).
58
68
  *
59
69
  * @example
60
70
  * ```tsx
@@ -62,40 +72,87 @@ const defaultStyles = {
62
72
  *
63
73
  * <QuantityStepper value={count} onChange={setCount} label="Croissants" />
64
74
  * <QuantityStepper value={bans} onChange={setBans} label="Bannissements" min={1} max={40} longPressStep={5} />
75
+ * <QuantityStepper value={score} onChange={setScore} label="Points" min={-999} repeatOnHold />
65
76
  * ```
66
77
  */
67
- export function QuantityStepper({ value, onChange, label, min = 0, max = 999, longPressStep, locale, a11yLabels, styles, }) {
78
+ export function QuantityStepper({ value, onChange, label, min = 0, max = 999, longPressStep, repeatOnHold = false, locale, a11yLabels, styles, }) {
68
79
  const labels = { ...defaultA11yLabels[useKitLocale(locale)], ...a11yLabels };
69
80
  const merged = mergeSlotStyles(defaultStyles, styles);
70
81
  const atMin = value <= min;
71
82
  const atMax = value >= max;
83
+ const allowsNegative = min < 0;
72
84
  // Field text not sent yet: clamping it to `min` on every keystroke would
73
85
  // make any number starting below `min` impossible to type.
74
86
  const [draft, setDraft] = useState(null);
87
+ const repeat = usePressRepeat();
88
+ // Value during a repeat, ahead of the `value` prop until the parent
89
+ // re-renders with each onChange.
90
+ const valueRef = useRef(value);
91
+ useEffect(() => {
92
+ valueRef.current = value;
93
+ }, [value]);
94
+ const clamp = (n) => Math.min(max, Math.max(min, n));
75
95
  function set(next) {
76
96
  setDraft(null);
77
- onChange(Math.min(max, Math.max(min, next)));
97
+ onChange(clamp(next));
98
+ }
99
+ function stepBy(delta) {
100
+ const next = clamp(valueRef.current + delta);
101
+ // A button disabled at its bound may never report the release.
102
+ if (next === valueRef.current) {
103
+ repeat.stop();
104
+ return;
105
+ }
106
+ valueRef.current = next;
107
+ set(next);
108
+ }
109
+ function startRepeat(delta) {
110
+ repeat.start(() => {
111
+ stepBy(delta);
112
+ });
78
113
  }
79
- return (_jsxs(View, { style: merged.row, children: [_jsx(Pressable, { style: [merged.button, atMin && merged.buttonDisabled], onPress: () => {
80
- set(value - 1);
81
- }, onLongPress: longPressStep === undefined
82
- ? undefined
83
- : () => {
114
+ // Held: repeat from press-in to press-out. Otherwise: one step per tap,
115
+ // and `longPressStep` on long press.
116
+ const tapsOnly = !repeatOnHold;
117
+ const jumps = tapsOnly && longPressStep !== undefined;
118
+ return (_jsxs(View, { style: merged.row, children: [_jsx(Pressable, { style: [merged.button, atMin && merged.buttonDisabled], onPress: tapsOnly
119
+ ? () => {
120
+ set(value - 1);
121
+ }
122
+ : undefined, onLongPress: jumps
123
+ ? () => {
84
124
  set(value - longPressStep);
85
- }, disabled: atMin, hitSlop: 6, accessibilityRole: "button", accessibilityLabel: labels.decrease(label), children: _jsx(Text, { style: merged.buttonText, children: "\u2212" }) }), _jsx(TextInput, { style: merged.input, value: draft ?? String(value), onChangeText: (text) => {
86
- const digits = text.replace(/\D/g, "");
87
- if (digits === "" || Number(digits) < min)
88
- setDraft(digits);
125
+ }
126
+ : undefined, onPressIn: repeatOnHold
127
+ ? () => {
128
+ startRepeat(-1);
129
+ }
130
+ : undefined, onPressOut: repeatOnHold ? repeat.stop : undefined, disabled: atMin, hitSlop: 6, accessibilityRole: "button", accessibilityLabel: labels.decrease(label), children: _jsx(Text, { style: merged.buttonText, children: "\u2212" }) }), _jsx(TextInput, { style: merged.input, value: draft ?? String(value), onChangeText: (text) => {
131
+ const cleaned = sanitize(text, allowsNegative);
132
+ const typed = Number(cleaned);
133
+ if (cleaned === "" || cleaned === "-" || typed < min) {
134
+ setDraft(cleaned);
135
+ }
89
136
  else
90
- set(Number(digits));
137
+ set(typed);
91
138
  }, onBlur: () => {
92
- if (draft !== null)
93
- set(draft === "" ? min : Number(draft));
94
- }, keyboardType: "number-pad", selectTextOnFocus: true, maxLength: String(max).length, accessibilityLabel: labels.input(label) }), _jsx(Pressable, { style: [merged.button, atMax && merged.buttonDisabled], onPress: () => {
95
- set(value + 1);
96
- }, onLongPress: longPressStep === undefined
97
- ? undefined
98
- : () => {
139
+ if (draft === null)
140
+ return;
141
+ // Nothing typed yet: back to 0, or to the nearest bound.
142
+ set(draft === "" || draft === "-" ? 0 : Number(draft));
143
+ },
144
+ // "number-pad" has no "-" key on Android.
145
+ keyboardType: allowsNegative ? "numeric" : "number-pad", selectTextOnFocus: true, maxLength: Math.max(String(min).length, String(max).length), accessibilityLabel: labels.input(label) }), _jsx(Pressable, { style: [merged.button, atMax && merged.buttonDisabled], onPress: tapsOnly
146
+ ? () => {
147
+ set(value + 1);
148
+ }
149
+ : undefined, onLongPress: jumps
150
+ ? () => {
99
151
  set(value + longPressStep);
100
- }, disabled: atMax, hitSlop: 6, accessibilityRole: "button", accessibilityLabel: labels.increase(label), children: _jsx(Text, { style: merged.buttonText, children: "+" }) })] }));
152
+ }
153
+ : undefined, onPressIn: repeatOnHold
154
+ ? () => {
155
+ startRepeat(1);
156
+ }
157
+ : undefined, onPressOut: repeatOnHold ? repeat.stop : undefined, disabled: atMax, hitSlop: 6, accessibilityRole: "button", accessibilityLabel: labels.increase(label), children: _jsx(Text, { style: merged.buttonText, children: "+" }) })] }));
101
158
  }
@@ -1,4 +1,5 @@
1
1
  export * from "./useDebouncedChange.js";
2
2
  export * from "./useEffectiveColorScheme.js";
3
+ export * from "./usePressRepeat.js";
3
4
  export * from "./useSubmitGuard.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC"}
@@ -1,3 +1,4 @@
1
1
  export * from "./useDebouncedChange.js";
2
2
  export * from "./useEffectiveColorScheme.js";
3
+ export * from "./usePressRepeat.js";
3
4
  export * from "./useSubmitGuard.js";
@@ -0,0 +1,31 @@
1
+ /** Timings of {@link usePressRepeat}, in milliseconds. */
2
+ export interface PressRepeatTimings {
3
+ /** Hold time before the first repeat. @defaultValue 450 */
4
+ initialDelay?: number;
5
+ /** Interval between repeats at first. @defaultValue 180 */
6
+ interval?: number;
7
+ /** Repeat time after which repeats speed up. @defaultValue 1200 */
8
+ accelerateAfter?: number;
9
+ /** Interval between repeats once sped up. @defaultValue 60 */
10
+ fastInterval?: number;
11
+ }
12
+ /**
13
+ * Repeats an action while a button is held, faster and faster — the way a
14
+ * counter's −/+ reach large values quickly. `start` runs the action at once,
15
+ * then again every `interval` after `initialDelay`, every `fastInterval`
16
+ * after `accelerateAfter`; `stop` ends it (also done on unmount).
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const repeat = usePressRepeat();
21
+ * <Pressable
22
+ * onPressIn={() => repeat.start(() => setCount((n) => n + 1))}
23
+ * onPressOut={repeat.stop}
24
+ * />
25
+ * ```
26
+ */
27
+ export declare function usePressRepeat({ initialDelay, interval, accelerateAfter, fastInterval, }?: PressRepeatTimings): {
28
+ start: (action: () => void) => void;
29
+ stop: () => void;
30
+ };
31
+ //# sourceMappingURL=usePressRepeat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usePressRepeat.d.ts","sourceRoot":"","sources":["../../src/hooks/usePressRepeat.ts"],"names":[],"mappings":"AAEA,0DAA0D;AAC1D,MAAM,WAAW,kBAAkB;IACjC,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAcD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,EAC7B,YAAkB,EAClB,QAAc,EACd,eAAsB,EACtB,YAAiB,GAClB,GAAE,kBAAuB;oBAeD,MAAM,IAAI;;EAclC"}
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef } from "react";
2
+ function clearTimers(timers) {
3
+ clearTimeout(timers.delay);
4
+ clearInterval(timers.repeat);
5
+ clearTimeout(timers.accelerate);
6
+ }
7
+ /**
8
+ * Repeats an action while a button is held, faster and faster — the way a
9
+ * counter's −/+ reach large values quickly. `start` runs the action at once,
10
+ * then again every `interval` after `initialDelay`, every `fastInterval`
11
+ * after `accelerateAfter`; `stop` ends it (also done on unmount).
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * const repeat = usePressRepeat();
16
+ * <Pressable
17
+ * onPressIn={() => repeat.start(() => setCount((n) => n + 1))}
18
+ * onPressOut={repeat.stop}
19
+ * />
20
+ * ```
21
+ */
22
+ export function usePressRepeat({ initialDelay = 450, interval = 180, accelerateAfter = 1200, fastInterval = 60, } = {}) {
23
+ const timersRef = useRef({});
24
+ useEffect(() => {
25
+ const timers = timersRef;
26
+ return () => {
27
+ clearTimers(timers.current);
28
+ };
29
+ }, []);
30
+ function stop() {
31
+ clearTimers(timersRef.current);
32
+ timersRef.current = {};
33
+ }
34
+ function start(action) {
35
+ stop();
36
+ action();
37
+ const timers = timersRef.current;
38
+ timers.delay = setTimeout(() => {
39
+ timers.repeat = setInterval(action, interval);
40
+ timers.accelerate = setTimeout(() => {
41
+ clearInterval(timers.repeat);
42
+ timers.repeat = setInterval(action, fastInterval);
43
+ }, accelerateAfter);
44
+ }, initialDelay);
45
+ }
46
+ return { start, stop };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/react-native-kit",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  "./*": "./dist/*.js"
23
23
  },
24
24
  "dependencies": {
25
- "@forthtilliath/ts-kit": "^0.12.0"
25
+ "@forthtilliath/ts-kit": "^0.13.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@expo/vector-icons": ">=15.0.0",
@@ -103,8 +103,8 @@
103
103
  "typescript": "^5.9.3",
104
104
  "vitest": "^5.0.2",
105
105
  "@forthtilliath/eslint-config": "0.9.0",
106
- "@forthtilliath/typescript-config": "0.1.1",
107
- "@forthtilliath/expo-release-updates-ui": "^0.4.1"
106
+ "@forthtilliath/expo-release-updates-ui": "^0.4.1",
107
+ "@forthtilliath/typescript-config": "0.1.1"
108
108
  },
109
109
  "size-limit": [
110
110
  {