@hzblj/kara-components 0.4.1 → 0.6.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/lib/module/atoms/chip/chip.js +1 -1
  3. package/lib/module/atoms/chip/chip.js.map +1 -1
  4. package/lib/module/atoms/pressable/pressable.js +11 -5
  5. package/lib/module/atoms/pressable/pressable.js.map +1 -1
  6. package/lib/module/atoms/segmented-control/segmented-control.js +14 -2
  7. package/lib/module/atoms/segmented-control/segmented-control.js.map +1 -1
  8. package/lib/module/atoms/slider/slider.android.js +26 -2
  9. package/lib/module/atoms/slider/slider.android.js.map +1 -1
  10. package/lib/module/atoms/switch/switch.android.js +11 -4
  11. package/lib/module/atoms/switch/switch.android.js.map +1 -1
  12. package/lib/module/haptics/haptics.js +56 -0
  13. package/lib/module/haptics/haptics.js.map +1 -0
  14. package/lib/module/haptics/index.js +5 -0
  15. package/lib/module/haptics/index.js.map +1 -0
  16. package/lib/module/haptics/use-haptic-feedback.js +33 -0
  17. package/lib/module/haptics/use-haptic-feedback.js.map +1 -0
  18. package/lib/module/index.js +1 -0
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/organisms/context-menu/context-menu.android.js +4 -3
  21. package/lib/module/organisms/context-menu/context-menu.android.js.map +1 -1
  22. package/lib/module/organisms/context-menu/context-menu.ios.js +2 -0
  23. package/lib/module/organisms/context-menu/context-menu.ios.js.map +1 -1
  24. package/lib/typescript/atoms/pressable/pressable.types.d.ts +3 -1
  25. package/lib/typescript/atoms/segmented-control/segmented-control.d.ts +4 -0
  26. package/lib/typescript/haptics/haptics.d.ts +61 -0
  27. package/lib/typescript/haptics/index.d.ts +2 -0
  28. package/lib/typescript/haptics/use-haptic-feedback.d.ts +15 -0
  29. package/lib/typescript/index.d.ts +1 -0
  30. package/lib/typescript/organisms/context-menu/context-menu.types.d.ts +8 -0
  31. package/package.json +4 -6
  32. package/src/atoms/chip/chip.tsx +1 -1
  33. package/src/atoms/pressable/pressable.tsx +12 -5
  34. package/src/atoms/pressable/pressable.types.ts +4 -1
  35. package/src/atoms/segmented-control/segmented-control.tsx +15 -2
  36. package/src/atoms/slider/slider.android.tsx +33 -2
  37. package/src/atoms/switch/switch.android.tsx +15 -5
  38. package/src/haptics/haptics.ts +68 -0
  39. package/src/haptics/index.ts +2 -0
  40. package/src/haptics/use-haptic-feedback.ts +36 -0
  41. package/src/index.ts +1 -0
  42. package/src/organisms/context-menu/context-menu.android.tsx +4 -4
  43. package/src/organisms/context-menu/context-menu.ios.tsx +10 -1
  44. package/src/organisms/context-menu/context-menu.types.ts +8 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @hzblj/kara-components
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#10](https://github.com/hzblj/kara-ui/pull/10) [`62b7131`](https://github.com/hzblj/kara-ui/commit/62b713143bd03872ecdd89ca8de42d98fe589617) Thanks [@hzblj](https://github.com/hzblj)! - Haptics: kara's own, native on both platforms, named for what happened.
8
+
9
+ - `haptics.success()`, `haptics.selection()`, … — one function per haptic — and `haptics.play(name)` for a name that arrives as data. `selection`, `tick`, `toggleOn`/`toggleOff`, `success`/`warning`/`error`, `longPress`, `dragStart`, `thresholdActivate`/`thresholdDeactivate`, `keyPress`, and the impacts `soft`, `light`, `medium`, `rigid`, `heavy` (`intensity` scales them on iOS). Synchronous, nothing to await.
10
+ - iOS: UIKit's feedback generators, kept alive and warmed up with `haptics.prepare` — `Pressable` prepares its haptic on press-in. Android: the system's own haptic feedback (`View.performHapticFeedback`), with Android 14's constants falling back to older ones — never a raw vibration. Both follow the user's system setting.
11
+ - `useHapticFeedback(trigger, haptic)` plays on every change of a value, never on mount; a function of the previous and next value picks a haptic per change.
12
+ - `haptics.setEnabled(false)` silences every kara haptic, the components' own included.
13
+ - `Pressable`'s `haptic` takes a name as well as `true` (a `light` impact); `Chip` plays `selection`.
14
+ - On Android, where Compose's Material controls play nothing: `Switch` plays `toggleOn`/`toggleOff`, a stepped `Slider` ticks at every stop, `SegmentedControl` plays `selection`. iOS keeps its native controls' own feedback.
15
+ - `Slider`: `step` is the increment on Android too — it was passed to Compose as the number of stops.
16
+ - `expo-haptics` is no longer a peer dependency.
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies [[`62b7131`](https://github.com/hzblj/kara-ui/commit/62b713143bd03872ecdd89ca8de42d98fe589617)]:
21
+ - @hzblj/kara-native@0.6.0
22
+ - @hzblj/kara-theme@0.6.0
23
+ - @hzblj/kara-sx@0.6.0
24
+
25
+ ## 0.5.0
26
+
27
+ ### Minor Changes
28
+
29
+ - [#8](https://github.com/hzblj/kara-ui/pull/8) [`3b0a6fb`](https://github.com/hzblj/kara-ui/commit/3b0a6fbc49559720023fece2b5c6189ad4d88bc8) Thanks [@hzblj](https://github.com/hzblj)! - `ZoomTransition`: a push that grows out of the photo it was opened from and shrinks back into it.
30
+
31
+ - iOS 18: UIKit's zoom transition, with the interactive dismissal (swipe down, edge swipe, pinch) gated on the scroll views under the finger. Below iOS 18 the push stays a slide. Android: Material's container transform, both ways.
32
+ - Three pieces from `@hzblj/kara-ui/router`, tied by one identifier: `ZoomTransition.Source` on the list, `ZoomTransition.Enabler` and `ZoomTransition.Target` on the pushed screen; `useZoomTransitionSourceId`, `ZOOM_TRANSITION_PARAM`, `useZoomTransitionParam` and `withZoomTransition` (an Android fade under the transform) around them.
33
+ - Settings both platforms honour sit at the top (`scrim`); the rest is typed apart under `ios` (`dismissGesture`, `blur`, the target's `fit`) and `android` (`duration`, `easing`, `fade`, `path`, corner radii, the target's `fit` and `flex`).
34
+ - `ContextMenu`: with a `ZoomTransition.Source` in the trigger, a tap on the iOS preview zooms the preview's photo straight into the pushed screen. `previewTransition` (`zoom`, `pop`, `dismiss`) picks how the preview leaves.
35
+ - `DurationSize`, the keys of `duration`.
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies [[`3b0a6fb`](https://github.com/hzblj/kara-ui/commit/3b0a6fbc49559720023fece2b5c6189ad4d88bc8)]:
40
+ - @hzblj/kara-native@0.5.0
41
+ - @hzblj/kara-theme@0.5.0
42
+ - @hzblj/kara-sx@0.5.0
43
+
3
44
  ## 0.4.1
4
45
 
5
46
  ### Patch Changes
@@ -14,7 +14,7 @@ export const Chip = ({
14
14
  borderColor: selected ? 'border.accent' : 'border.tertiary',
15
15
  borderRadius: "full",
16
16
  borderWidth: 1,
17
- haptic: true,
17
+ haptic: "selection",
18
18
  justify: "center",
19
19
  onPress: onPress,
20
20
  px: "s12",
@@ -1 +1 @@
1
- {"version":3,"names":["Pressable","RnText","jsx","_jsx","Chip","label","onPress","selected","testID","align","bg","borderColor","borderRadius","borderWidth","haptic","justify","px","py","children","color","variant"],"sourceRoot":"../../../../src","sources":["atoms/chip/chip.tsx"],"mappings":";;AAAA,SAAQA,SAAS,QAAO,uBAAc;AACtC,SAAQC,MAAM,QAAO,qBAAY;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAGjC,OAAO,MAAMC,IAAmB,GAAGA,CAAC;EAACC,KAAK;EAAEC,OAAO;EAAEC,QAAQ,GAAG,KAAK;EAAEC;AAAiB,CAAC,kBACvFL,IAAA,CAACH,SAAS;EACRS,KAAK,EAAC,QAAQ;EACdC,EAAE,EAAEH,QAAQ,GAAG,kBAAkB,GAAG,eAAgB;EACpDI,WAAW,EAAEJ,QAAQ,GAAG,eAAe,GAAG,iBAAkB;EAC5DK,YAAY,EAAC,MAAM;EACnBC,WAAW,EAAE,CAAE;EACfC,MAAM;EACNC,OAAO,EAAC,QAAQ;EAChBT,OAAO,EAAEA,OAAQ;EACjBU,EAAE,EAAC,KAAK;EACRC,EAAE,EAAC,IAAI;EACPT,MAAM,EAAEA,MAAO;EAAAU,QAAA,eAEff,IAAA,CAACF,MAAM;IAACkB,KAAK,EAAEZ,QAAQ,GAAG,gBAAgB,GAAG,mBAAoB;IAACa,OAAO,EAAC,aAAa;IAAAF,QAAA,EACpFb;EAAK,CACA;AAAC,CACA,CACZ","ignoreList":[]}
1
+ {"version":3,"names":["Pressable","RnText","jsx","_jsx","Chip","label","onPress","selected","testID","align","bg","borderColor","borderRadius","borderWidth","haptic","justify","px","py","children","color","variant"],"sourceRoot":"../../../../src","sources":["atoms/chip/chip.tsx"],"mappings":";;AAAA,SAAQA,SAAS,QAAO,uBAAc;AACtC,SAAQC,MAAM,QAAO,qBAAY;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAGjC,OAAO,MAAMC,IAAmB,GAAGA,CAAC;EAACC,KAAK;EAAEC,OAAO;EAAEC,QAAQ,GAAG,KAAK;EAAEC;AAAiB,CAAC,kBACvFL,IAAA,CAACH,SAAS;EACRS,KAAK,EAAC,QAAQ;EACdC,EAAE,EAAEH,QAAQ,GAAG,kBAAkB,GAAG,eAAgB;EACpDI,WAAW,EAAEJ,QAAQ,GAAG,eAAe,GAAG,iBAAkB;EAC5DK,YAAY,EAAC,MAAM;EACnBC,WAAW,EAAE,CAAE;EACfC,MAAM,EAAC,WAAW;EAClBC,OAAO,EAAC,QAAQ;EAChBT,OAAO,EAAEA,OAAQ;EACjBU,EAAE,EAAC,KAAK;EACRC,EAAE,EAAC,IAAI;EACPT,MAAM,EAAEA,MAAO;EAAAU,QAAA,eAEff,IAAA,CAACF,MAAM;IAACkB,KAAK,EAAEZ,QAAQ,GAAG,gBAAgB,GAAG,mBAAoB;IAACa,OAAO,EAAC,aAAa;IAAAF,QAAA,EACpFb;EAAK,CACA;AAAC,CACA,CACZ","ignoreList":[]}
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
 
3
3
  import { useSplitSx } from '@hzblj/kara-sx';
4
- import * as Haptics from 'expo-haptics';
5
4
  import { useCallback } from 'react';
6
5
  import { Pressable as RNPressable } from 'react-native';
7
6
  import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
7
+ import { haptics } from "../../haptics/index.js";
8
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
9
  const SPRING = {
10
10
  damping: 20,
@@ -21,23 +21,29 @@ export const Pressable = ({
21
21
  }) => {
22
22
  const [resolved, rest] = useSplitSx(props);
23
23
  const scale = useSharedValue(1);
24
+ const feedback = haptic === true ? 'light' : haptic || undefined;
24
25
  const rStyle = useAnimatedStyle(() => ({
25
26
  transform: [{
26
27
  scale: scale.value
27
28
  }]
28
29
  }), []);
30
+
31
+ // The finger is down: the Taptic Engine has until the release to wake, so the haptic lands with the press.
29
32
  const onPressIn = useCallback(() => {
30
33
  scale.value = withSpring(0.96, SPRING);
31
- }, [scale]);
34
+ if (feedback) {
35
+ haptics.prepare(feedback);
36
+ }
37
+ }, [feedback, scale]);
32
38
  const onPressOut = useCallback(() => {
33
39
  scale.value = withSpring(1, SPRING);
34
40
  }, [scale]);
35
41
  const onPressWithHaptic = useCallback(() => {
36
- if (haptic) {
37
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
42
+ if (feedback) {
43
+ haptics.play(feedback);
38
44
  }
39
45
  onPress?.();
40
- }, [haptic, onPress]);
46
+ }, [feedback, onPress]);
41
47
  return /*#__PURE__*/_jsx(AnimatedPressable, {
42
48
  disabled: disabled,
43
49
  hitSlop: 8,
@@ -1 +1 @@
1
- {"version":3,"names":["useSplitSx","Haptics","useCallback","Pressable","RNPressable","Animated","useAnimatedStyle","useSharedValue","withSpring","jsx","_jsx","SPRING","damping","stiffness","AnimatedPressable","createAnimatedComponent","children","disabled","haptic","onPress","testID","props","resolved","rest","scale","rStyle","transform","value","onPressIn","onPressOut","onPressWithHaptic","impactAsync","ImpactFeedbackStyle","Light","catch","undefined","hitSlop","style"],"sourceRoot":"../../../../src","sources":["atoms/pressable/pressable.tsx"],"mappings":";;AAAA,SAAQA,UAAU,QAAO,gBAAgB;AACzC,OAAO,KAAKC,OAAO,MAAM,cAAc;AACvC,SAAQC,WAAW,QAAO,OAAO;AACjC,SAAQC,SAAS,IAAIC,WAAW,QAAO,cAAc;AACrD,OAAOC,QAAQ,IAAGC,gBAAgB,EAAEC,cAAc,EAAEC,UAAU,QAAO,yBAAyB;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAG9F,MAAMC,MAAM,GAAG;EAACC,OAAO,EAAE,EAAE;EAAEC,SAAS,EAAE;AAAG,CAAC;AAE5C,MAAMC,iBAAiB,GAAGT,QAAQ,CAACU,uBAAuB,CAACX,WAAW,CAAC;AAEvE,OAAO,MAAMD,SAA6B,GAAGA,CAAC;EAC5Ca,QAAQ;EACRC,QAAQ,GAAG,KAAK;EAChBC,MAAM,GAAG,KAAK;EACdC,OAAO;EACPC,MAAM;EACN,GAAGC;AACW,CAAC,KAAK;EACpB,MAAM,CAACC,QAAQ,EAAEC,IAAI,CAAC,GAAGvB,UAAU,CAACqB,KAAK,CAAC;EAC1C,MAAMG,KAAK,GAAGjB,cAAc,CAAC,CAAC,CAAC;EAE/B,MAAMkB,MAAM,GAAGnB,gBAAgB,CAAC,OAAO;IAACoB,SAAS,EAAE,CAAC;MAACF,KAAK,EAAEA,KAAK,CAACG;IAAK,CAAC;EAAC,CAAC,CAAC,EAAE,EAAE,CAAC;EAEhF,MAAMC,SAAS,GAAG1B,WAAW,CAAC,MAAM;IAClCsB,KAAK,CAACG,KAAK,GAAGnB,UAAU,CAAC,IAAI,EAAEG,MAAM,CAAC;EACxC,CAAC,EAAE,CAACa,KAAK,CAAC,CAAC;EAEX,MAAMK,UAAU,GAAG3B,WAAW,CAAC,MAAM;IACnCsB,KAAK,CAACG,KAAK,GAAGnB,UAAU,CAAC,CAAC,EAAEG,MAAM,CAAC;EACrC,CAAC,EAAE,CAACa,KAAK,CAAC,CAAC;EAEX,MAAMM,iBAAiB,GAAG5B,WAAW,CAAC,MAAM;IAC1C,IAAIgB,MAAM,EAAE;MACVjB,OAAO,CAAC8B,WAAW,CAAC9B,OAAO,CAAC+B,mBAAmB,CAACC,KAAK,CAAC,CAACC,KAAK,CAAC,MAAMC,SAAS,CAAC;IAC/E;IAEAhB,OAAO,GAAG,CAAC;EACb,CAAC,EAAE,CAACD,MAAM,EAAEC,OAAO,CAAC,CAAC;EAErB,oBACET,IAAA,CAACI,iBAAiB;IAChBG,QAAQ,EAAEA,QAAS;IACnBmB,OAAO,EAAE,CAAE;IACXjB,OAAO,EAAEW,iBAAkB;IAC3BF,SAAS,EAAEA,SAAU;IACrBC,UAAU,EAAEA,UAAW;IACvBQ,KAAK,EAAE,CAACf,QAAQ,EAAEG,MAAM,CAAE;IAC1BL,MAAM,EAAEA,MAAO;IAAA,GACXG,IAAI;IAAAP,QAAA,EAEPA;EAAQ,CACQ,CAAC;AAExB,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["useSplitSx","useCallback","Pressable","RNPressable","Animated","useAnimatedStyle","useSharedValue","withSpring","haptics","jsx","_jsx","SPRING","damping","stiffness","AnimatedPressable","createAnimatedComponent","children","disabled","haptic","onPress","testID","props","resolved","rest","scale","feedback","undefined","rStyle","transform","value","onPressIn","prepare","onPressOut","onPressWithHaptic","play","hitSlop","style"],"sourceRoot":"../../../../src","sources":["atoms/pressable/pressable.tsx"],"mappings":";;AAAA,SAAQA,UAAU,QAAO,gBAAgB;AACzC,SAAQC,WAAW,QAAO,OAAO;AACjC,SAAQC,SAAS,IAAIC,WAAW,QAAO,cAAc;AACrD,OAAOC,QAAQ,IAAGC,gBAAgB,EAAEC,cAAc,EAAEC,UAAU,QAAO,yBAAyB;AAE9F,SAAQC,OAAO,QAAO,wBAAe;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAGrC,MAAMC,MAAM,GAAG;EAACC,OAAO,EAAE,EAAE;EAAEC,SAAS,EAAE;AAAG,CAAC;AAE5C,MAAMC,iBAAiB,GAAGV,QAAQ,CAACW,uBAAuB,CAACZ,WAAW,CAAC;AAEvE,OAAO,MAAMD,SAA6B,GAAGA,CAAC;EAC5Cc,QAAQ;EACRC,QAAQ,GAAG,KAAK;EAChBC,MAAM,GAAG,KAAK;EACdC,OAAO;EACPC,MAAM;EACN,GAAGC;AACW,CAAC,KAAK;EACpB,MAAM,CAACC,QAAQ,EAAEC,IAAI,CAAC,GAAGvB,UAAU,CAACqB,KAAK,CAAC;EAC1C,MAAMG,KAAK,GAAGlB,cAAc,CAAC,CAAC,CAAC;EAC/B,MAAMmB,QAAQ,GAAGP,MAAM,KAAK,IAAI,GAAG,OAAO,GAAGA,MAAM,IAAIQ,SAAS;EAEhE,MAAMC,MAAM,GAAGtB,gBAAgB,CAAC,OAAO;IAACuB,SAAS,EAAE,CAAC;MAACJ,KAAK,EAAEA,KAAK,CAACK;IAAK,CAAC;EAAC,CAAC,CAAC,EAAE,EAAE,CAAC;;EAEhF;EACA,MAAMC,SAAS,GAAG7B,WAAW,CAAC,MAAM;IAClCuB,KAAK,CAACK,KAAK,GAAGtB,UAAU,CAAC,IAAI,EAAEI,MAAM,CAAC;IAEtC,IAAIc,QAAQ,EAAE;MACZjB,OAAO,CAACuB,OAAO,CAACN,QAAQ,CAAC;IAC3B;EACF,CAAC,EAAE,CAACA,QAAQ,EAAED,KAAK,CAAC,CAAC;EAErB,MAAMQ,UAAU,GAAG/B,WAAW,CAAC,MAAM;IACnCuB,KAAK,CAACK,KAAK,GAAGtB,UAAU,CAAC,CAAC,EAAEI,MAAM,CAAC;EACrC,CAAC,EAAE,CAACa,KAAK,CAAC,CAAC;EAEX,MAAMS,iBAAiB,GAAGhC,WAAW,CAAC,MAAM;IAC1C,IAAIwB,QAAQ,EAAE;MACZjB,OAAO,CAAC0B,IAAI,CAACT,QAAQ,CAAC;IACxB;IAEAN,OAAO,GAAG,CAAC;EACb,CAAC,EAAE,CAACM,QAAQ,EAAEN,OAAO,CAAC,CAAC;EAEvB,oBACET,IAAA,CAACI,iBAAiB;IAChBG,QAAQ,EAAEA,QAAS;IACnBkB,OAAO,EAAE,CAAE;IACXhB,OAAO,EAAEc,iBAAkB;IAC3BH,SAAS,EAAEA,SAAU;IACrBE,UAAU,EAAEA,UAAW;IACvBI,KAAK,EAAE,CAACd,QAAQ,EAAEK,MAAM,CAAE;IAC1BP,MAAM,EAAEA,MAAO;IAAA,GACXG,IAAI;IAAAP,QAAA,EAEPA;EAAQ,CACQ,CAAC;AAExB,CAAC","ignoreList":[]}
@@ -3,7 +3,13 @@
3
3
  import { SegmentedControl as NativeSegmentedControl } from '@expo/ui/community/segmented-control';
4
4
  import { useAppTheme } from '@hzblj/kara-theme';
5
5
  import { useCallback } from 'react';
6
+ import { Platform } from 'react-native';
7
+ import { haptics } from "../../haptics/index.js";
6
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
+ /**
10
+ * `UISegmentedControl` on iOS, Material's single-choice segmented buttons on Android. A new segment plays `selection`
11
+ * on Android, whose buttons play nothing of their own; iOS 26's control already gives its own feedback.
12
+ */
7
13
  export const SegmentedControl = ({
8
14
  disabled = false,
9
15
  onChange,
@@ -13,8 +19,14 @@ export const SegmentedControl = ({
13
19
  }) => {
14
20
  const theme = useAppTheme();
15
21
  const onNativeChange = useCallback(event => {
16
- onChange?.(event.nativeEvent.selectedSegmentIndex, event.nativeEvent.value);
17
- }, [onChange]);
22
+ const index = event.nativeEvent.selectedSegmentIndex;
23
+
24
+ // Material reports a tap on the segment that is already selected too.
25
+ if (Platform.OS === 'android' && index !== selectedIndex) {
26
+ haptics.selection();
27
+ }
28
+ onChange?.(index, event.nativeEvent.value);
29
+ }, [onChange, selectedIndex]);
18
30
  return /*#__PURE__*/_jsx(NativeSegmentedControl, {
19
31
  appearance: theme.dark ? 'dark' : 'light',
20
32
  enabled: !disabled,
@@ -1 +1 @@
1
- {"version":3,"names":["SegmentedControl","NativeSegmentedControl","useAppTheme","useCallback","jsx","_jsx","disabled","onChange","selectedIndex","testID","values","theme","onNativeChange","event","nativeEvent","selectedSegmentIndex","value","appearance","dark","enabled","tintColor","colorString"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segmented-control.tsx"],"mappings":";;AAAA,SAAQA,gBAAgB,IAAIC,sBAAsB,QAAO,sCAAsC;AAC/F,SAAQC,WAAW,QAAO,mBAAmB;AAC7C,SAAQC,WAAW,QAAO,OAAO;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAIjC,OAAO,MAAML,gBAA2C,GAAGA,CAAC;EAC1DM,QAAQ,GAAG,KAAK;EAChBC,QAAQ;EACRC,aAAa;EACbC,MAAM;EACNC;AACqB,CAAC,KAAK;EAC3B,MAAMC,KAAK,GAAGT,WAAW,CAAC,CAAC;EAE3B,MAAMU,cAAc,GAAGT,WAAW,CAC/BU,KAAmE,IAAK;IACvEN,QAAQ,GAAGM,KAAK,CAACC,WAAW,CAACC,oBAAoB,EAAEF,KAAK,CAACC,WAAW,CAACE,KAAK,CAAC;EAC7E,CAAC,EACD,CAACT,QAAQ,CACX,CAAC;EAED,oBACEF,IAAA,CAACJ,sBAAsB;IACrBgB,UAAU,EAAEN,KAAK,CAACO,IAAI,GAAG,MAAM,GAAG,OAAQ;IAC1CC,OAAO,EAAE,CAACb,QAAS;IACnBC,QAAQ,EAAEK,cAAe;IACzBJ,aAAa,EAAEA,aAAc;IAC7BC,MAAM,EAAEA,MAAO;IACfW,SAAS,EAAET,KAAK,CAACU,WAAW,CAAC,iBAAiB,CAAE;IAChDX,MAAM,EAAEA;EAAO,CAChB,CAAC;AAEN,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["SegmentedControl","NativeSegmentedControl","useAppTheme","useCallback","Platform","haptics","jsx","_jsx","disabled","onChange","selectedIndex","testID","values","theme","onNativeChange","event","index","nativeEvent","selectedSegmentIndex","OS","selection","value","appearance","dark","enabled","tintColor","colorString"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segmented-control.tsx"],"mappings":";;AAAA,SAAQA,gBAAgB,IAAIC,sBAAsB,QAAO,sCAAsC;AAC/F,SAAQC,WAAW,QAAO,mBAAmB;AAC7C,SAAQC,WAAW,QAAO,OAAO;AACjC,SAAQC,QAAQ,QAAO,cAAc;AAErC,SAAQC,OAAO,QAAO,wBAAe;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAGrC;AACA;AACA;AACA;AACA,OAAO,MAAMP,gBAA2C,GAAGA,CAAC;EAC1DQ,QAAQ,GAAG,KAAK;EAChBC,QAAQ;EACRC,aAAa;EACbC,MAAM;EACNC;AACqB,CAAC,KAAK;EAC3B,MAAMC,KAAK,GAAGX,WAAW,CAAC,CAAC;EAE3B,MAAMY,cAAc,GAAGX,WAAW,CAC/BY,KAAmE,IAAK;IACvE,MAAMC,KAAK,GAAGD,KAAK,CAACE,WAAW,CAACC,oBAAoB;;IAEpD;IACA,IAAId,QAAQ,CAACe,EAAE,KAAK,SAAS,IAAIH,KAAK,KAAKN,aAAa,EAAE;MACxDL,OAAO,CAACe,SAAS,CAAC,CAAC;IACrB;IAEAX,QAAQ,GAAGO,KAAK,EAAED,KAAK,CAACE,WAAW,CAACI,KAAK,CAAC;EAC5C,CAAC,EACD,CAACZ,QAAQ,EAAEC,aAAa,CAC1B,CAAC;EAED,oBACEH,IAAA,CAACN,sBAAsB;IACrBqB,UAAU,EAAET,KAAK,CAACU,IAAI,GAAG,MAAM,GAAG,OAAQ;IAC1CC,OAAO,EAAE,CAAChB,QAAS;IACnBC,QAAQ,EAAEK,cAAe;IACzBJ,aAAa,EAAEA,aAAc;IAC7BC,MAAM,EAAEA,MAAO;IACfc,SAAS,EAAEZ,KAAK,CAACa,WAAW,CAAC,iBAAiB,CAAE;IAChDd,MAAM,EAAEA;EAAO,CAChB,CAAC;AAEN,CAAC","ignoreList":[]}
@@ -2,8 +2,15 @@
2
2
 
3
3
  import { Slider as ComposeSlider } from '@expo/ui/jetpack-compose';
4
4
  import { useAppTheme } from '@hzblj/kara-theme';
5
+ import { useCallback, useEffect, useRef } from 'react';
6
+ import { haptics } from "../../haptics/index.js";
5
7
  import { useHostGuard } from "../../host-context.js";
6
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
+ /**
10
+ * Material's slider. `step` is the increment, as SwiftUI takes it; Compose counts the stops between the two ends
11
+ * instead, so it is converted. A stepped slider ticks at every stop the thumb reaches — Compose's Material slider
12
+ * plays nothing of its own, where iOS 26's slider already does.
13
+ */
7
14
  export const Slider = ({
8
15
  disabled = false,
9
16
  max = 1,
@@ -14,6 +21,23 @@ export const Slider = ({
14
21
  }) => {
15
22
  useHostGuard('Slider');
16
23
  const theme = useAppTheme();
24
+ const steps = step ? Math.max(0, Math.round((max - min) / step) - 1) : 0;
25
+
26
+ // The stop is derived from the value rather than trusting it to arrive snapped, and remembered between events:
27
+ // Compose reports faster than React re-renders the controlled value.
28
+ const stopOf = useCallback(at => step ? Math.round((at - min) / step) : 0, [min, step]);
29
+ const stop = useRef(stopOf(value));
30
+ useEffect(() => {
31
+ stop.current = stopOf(value);
32
+ }, [stopOf, value]);
33
+ const onChange = useCallback(next => {
34
+ const reached = stopOf(next);
35
+ if (steps > 0 && reached !== stop.current) {
36
+ haptics.tick();
37
+ }
38
+ stop.current = reached;
39
+ onValueChange?.(next);
40
+ }, [onValueChange, stopOf, steps]);
17
41
  return /*#__PURE__*/_jsx(ComposeSlider, {
18
42
  colors: {
19
43
  activeTrackColor: theme.color('fill.accent.primary'),
@@ -22,8 +46,8 @@ export const Slider = ({
22
46
  enabled: !disabled,
23
47
  max: max,
24
48
  min: min,
25
- onValueChange: onValueChange,
26
- steps: step,
49
+ onValueChange: onChange,
50
+ steps: steps,
27
51
  value: value
28
52
  });
29
53
  };
@@ -1 +1 @@
1
- {"version":3,"names":["Slider","ComposeSlider","useAppTheme","useHostGuard","jsx","_jsx","disabled","max","min","onValueChange","step","value","theme","colors","activeTrackColor","color","thumbColor","enabled","steps"],"sourceRoot":"../../../../src","sources":["atoms/slider/slider.android.tsx"],"mappings":";;AAAA,SAAQA,MAAM,IAAIC,aAAa,QAAO,0BAA0B;AAChE,SAAQC,WAAW,QAAO,mBAAmB;AAE7C,SAAQC,YAAY,QAAO,uBAAoB;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAG/C,OAAO,MAAML,MAAuB,GAAGA,CAAC;EACtCM,QAAQ,GAAG,KAAK;EAChBC,GAAG,GAAG,CAAC;EACPC,GAAG,GAAG,CAAC;EACPC,aAAa;EACbC,IAAI;EACJC;AACW,CAAC,KAAK;EACjBR,YAAY,CAAC,QAAQ,CAAC;EACtB,MAAMS,KAAK,GAAGV,WAAW,CAAC,CAAC;EAE3B,oBACEG,IAAA,CAACJ,aAAa;IACZY,MAAM,EAAE;MACNC,gBAAgB,EAAEF,KAAK,CAACG,KAAK,CAAC,qBAAqB,CAAC;MACpDC,UAAU,EAAEJ,KAAK,CAACG,KAAK,CAAC,qBAAqB;IAC/C,CAAE;IACFE,OAAO,EAAE,CAACX,QAAS;IACnBC,GAAG,EAAEA,GAAI;IACTC,GAAG,EAAEA,GAAI;IACTC,aAAa,EAAEA,aAAc;IAC7BS,KAAK,EAAER,IAAK;IACZC,KAAK,EAAEA;EAAM,CACd,CAAC;AAEN,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["Slider","ComposeSlider","useAppTheme","useCallback","useEffect","useRef","haptics","useHostGuard","jsx","_jsx","disabled","max","min","onValueChange","step","value","theme","steps","Math","round","stopOf","at","stop","current","onChange","next","reached","tick","colors","activeTrackColor","color","thumbColor","enabled"],"sourceRoot":"../../../../src","sources":["atoms/slider/slider.android.tsx"],"mappings":";;AAAA,SAAQA,MAAM,IAAIC,aAAa,QAAO,0BAA0B;AAChE,SAAQC,WAAW,QAAO,mBAAmB;AAC7C,SAAQC,WAAW,EAAEC,SAAS,EAAEC,MAAM,QAAO,OAAO;AAEpD,SAAQC,OAAO,QAAO,wBAAe;AACrC,SAAQC,YAAY,QAAO,uBAAoB;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAG/C;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMT,MAAuB,GAAGA,CAAC;EACtCU,QAAQ,GAAG,KAAK;EAChBC,GAAG,GAAG,CAAC;EACPC,GAAG,GAAG,CAAC;EACPC,aAAa;EACbC,IAAI;EACJC;AACW,CAAC,KAAK;EACjBR,YAAY,CAAC,QAAQ,CAAC;EACtB,MAAMS,KAAK,GAAGd,WAAW,CAAC,CAAC;EAC3B,MAAMe,KAAK,GAAGH,IAAI,GAAGI,IAAI,CAACP,GAAG,CAAC,CAAC,EAAEO,IAAI,CAACC,KAAK,CAAC,CAACR,GAAG,GAAGC,GAAG,IAAIE,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC;;EAExE;EACA;EACA,MAAMM,MAAM,GAAGjB,WAAW,CAAEkB,EAAU,IAAMP,IAAI,GAAGI,IAAI,CAACC,KAAK,CAAC,CAACE,EAAE,GAAGT,GAAG,IAAIE,IAAI,CAAC,GAAG,CAAE,EAAE,CAACF,GAAG,EAAEE,IAAI,CAAC,CAAC;EACnG,MAAMQ,IAAI,GAAGjB,MAAM,CAACe,MAAM,CAACL,KAAK,CAAC,CAAC;EAElCX,SAAS,CAAC,MAAM;IACdkB,IAAI,CAACC,OAAO,GAAGH,MAAM,CAACL,KAAK,CAAC;EAC9B,CAAC,EAAE,CAACK,MAAM,EAAEL,KAAK,CAAC,CAAC;EAEnB,MAAMS,QAAQ,GAAGrB,WAAW,CACzBsB,IAAY,IAAK;IAChB,MAAMC,OAAO,GAAGN,MAAM,CAACK,IAAI,CAAC;IAE5B,IAAIR,KAAK,GAAG,CAAC,IAAIS,OAAO,KAAKJ,IAAI,CAACC,OAAO,EAAE;MACzCjB,OAAO,CAACqB,IAAI,CAAC,CAAC;IAChB;IAEAL,IAAI,CAACC,OAAO,GAAGG,OAAO;IACtBb,aAAa,GAAGY,IAAI,CAAC;EACvB,CAAC,EACD,CAACZ,aAAa,EAAEO,MAAM,EAAEH,KAAK,CAC/B,CAAC;EAED,oBACER,IAAA,CAACR,aAAa;IACZ2B,MAAM,EAAE;MACNC,gBAAgB,EAAEb,KAAK,CAACc,KAAK,CAAC,qBAAqB,CAAC;MACpDC,UAAU,EAAEf,KAAK,CAACc,KAAK,CAAC,qBAAqB;IAC/C,CAAE;IACFE,OAAO,EAAE,CAACtB,QAAS;IACnBC,GAAG,EAAEA,GAAI;IACTC,GAAG,EAAEA,GAAI;IACTC,aAAa,EAAEW,QAAS;IACxBP,KAAK,EAAEA,KAAM;IACbF,KAAK,EAAEA;EAAM,CACd,CAAC;AAEN,CAAC","ignoreList":[]}
@@ -3,12 +3,15 @@
3
3
  import { Switch as ComposeSwitch, Row, Text } from '@expo/ui/jetpack-compose';
4
4
  import { clickable, fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers';
5
5
  import { useAppTheme } from '@hzblj/kara-theme';
6
- import { useMemo } from 'react';
6
+ import { useCallback, useMemo } from 'react';
7
+ import { haptics } from "../../haptics/index.js";
7
8
  import { useHostGuard } from "../../host-context.js";
8
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
10
  /**
10
11
  * Material's switch, tinted with the accent like SwiftUI's toggle. With a label it is a full-width row — label at the
11
- * start, switch at the end — and the whole row toggles, the way a Material settings row does.
12
+ * start, switch at the end — and the whole row toggles, the way a Material settings row does. A flip plays
13
+ * `toggleOn`/`toggleOff` — Android 14's constants made for switches — since Compose's Material switch plays nothing
14
+ * of its own. iOS stays silent, as `UISwitch` does.
12
15
  */
13
16
  export const Switch = ({
14
17
  disabled = false,
@@ -18,7 +21,11 @@ export const Switch = ({
18
21
  }) => {
19
22
  useHostGuard('Switch');
20
23
  const theme = useAppTheme();
21
- const rowModifiers = useMemo(() => disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => onValueChange(!value))], [disabled, onValueChange, value]);
24
+ const toggle = useCallback(next => {
25
+ haptics.play(next ? 'toggleOn' : 'toggleOff');
26
+ onValueChange?.(next);
27
+ }, [onValueChange]);
28
+ const rowModifiers = useMemo(() => disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => toggle(!value))], [disabled, onValueChange, toggle, value]);
22
29
  const control = /*#__PURE__*/_jsx(ComposeSwitch, {
23
30
  colors: {
24
31
  checkedBorderColor: theme.color('fill.accent.primary'),
@@ -26,7 +33,7 @@ export const Switch = ({
26
33
  checkedTrackColor: theme.color('fill.accent.primary')
27
34
  },
28
35
  enabled: !disabled,
29
- onCheckedChange: onValueChange,
36
+ onCheckedChange: onValueChange ? toggle : undefined,
30
37
  value: value
31
38
  });
32
39
  if (!label) {
@@ -1 +1 @@
1
- {"version":3,"names":["Switch","ComposeSwitch","Row","Text","clickable","fillMaxWidth","useAppTheme","useMemo","useHostGuard","jsx","_jsx","jsxs","_jsxs","disabled","label","onValueChange","value","theme","rowModifiers","control","colors","checkedBorderColor","color","checkedThumbColor","checkedTrackColor","enabled","onCheckedChange","horizontalArrangement","modifiers","verticalAlignment","children","colorString","style","typography","body"],"sourceRoot":"../../../../src","sources":["atoms/switch/switch.android.tsx"],"mappings":";;AAAA,SAAQA,MAAM,IAAIC,aAAa,EAAEC,GAAG,EAAEC,IAAI,QAAO,0BAA0B;AAC3E,SAAQC,SAAS,EAAEC,YAAY,QAAO,oCAAoC;AAC1E,SAAQC,WAAW,QAAO,mBAAmB;AAC7C,SAAQC,OAAO,QAAO,OAAO;AAE7B,SAAQC,YAAY,QAAO,uBAAoB;AAAA,SAAAC,GAAA,IAAAC,IAAA,EAAAC,IAAA,IAAAC,KAAA;AAG/C;AACA;AACA;AACA;AACA,OAAO,MAAMZ,MAAuB,GAAGA,CAAC;EAACa,QAAQ,GAAG,KAAK;EAAEC,KAAK;EAAEC,aAAa;EAAEC;AAAkB,CAAC,KAAK;EACvGR,YAAY,CAAC,QAAQ,CAAC;EACtB,MAAMS,KAAK,GAAGX,WAAW,CAAC,CAAC;EAC3B,MAAMY,YAAY,GAAGX,OAAO,CAC1B,MAAOM,QAAQ,IAAI,CAACE,aAAa,GAAG,CAACV,YAAY,CAAC,CAAC,CAAC,GAAG,CAACA,YAAY,CAAC,CAAC,EAAED,SAAS,CAAC,MAAMW,aAAa,CAAC,CAACC,KAAK,CAAC,CAAC,CAAE,EAChH,CAACH,QAAQ,EAAEE,aAAa,EAAEC,KAAK,CACjC,CAAC;EACD,MAAMG,OAAO,gBACXT,IAAA,CAACT,aAAa;IACZmB,MAAM,EAAE;MACNC,kBAAkB,EAAEJ,KAAK,CAACK,KAAK,CAAC,qBAAqB,CAAC;MACtDC,iBAAiB,EAAEN,KAAK,CAACK,KAAK,CAAC,eAAe,CAAC;MAC/CE,iBAAiB,EAAEP,KAAK,CAACK,KAAK,CAAC,qBAAqB;IACtD,CAAE;IACFG,OAAO,EAAE,CAACZ,QAAS;IACnBa,eAAe,EAAEX,aAAc;IAC/BC,KAAK,EAAEA;EAAM,CACd,CACF;EAED,IAAI,CAACF,KAAK,EAAE;IACV,OAAOK,OAAO;EAChB;EAEA,oBACEP,KAAA,CAACV,GAAG;IAACyB,qBAAqB,EAAC,cAAc;IAACC,SAAS,EAAEV,YAAa;IAACW,iBAAiB,EAAC,QAAQ;IAAAC,QAAA,gBAC3FpB,IAAA,CAACP,IAAI;MAACmB,KAAK,EAAEL,KAAK,CAACc,WAAW,CAAClB,QAAQ,GAAG,kBAAkB,GAAG,iBAAiB,CAAE;MAACmB,KAAK,EAAEf,KAAK,CAACgB,UAAU,CAACC,IAAK;MAAAJ,QAAA,EAC7GhB;IAAK,CACF,CAAC,EACNK,OAAO;EAAA,CACL,CAAC;AAEV,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["Switch","ComposeSwitch","Row","Text","clickable","fillMaxWidth","useAppTheme","useCallback","useMemo","haptics","useHostGuard","jsx","_jsx","jsxs","_jsxs","disabled","label","onValueChange","value","theme","toggle","next","play","rowModifiers","control","colors","checkedBorderColor","color","checkedThumbColor","checkedTrackColor","enabled","onCheckedChange","undefined","horizontalArrangement","modifiers","verticalAlignment","children","colorString","style","typography","body"],"sourceRoot":"../../../../src","sources":["atoms/switch/switch.android.tsx"],"mappings":";;AAAA,SAAQA,MAAM,IAAIC,aAAa,EAAEC,GAAG,EAAEC,IAAI,QAAO,0BAA0B;AAC3E,SAAQC,SAAS,EAAEC,YAAY,QAAO,oCAAoC;AAC1E,SAAQC,WAAW,QAAO,mBAAmB;AAC7C,SAAQC,WAAW,EAAEC,OAAO,QAAO,OAAO;AAE1C,SAAQC,OAAO,QAAO,wBAAe;AACrC,SAAQC,YAAY,QAAO,uBAAoB;AAAA,SAAAC,GAAA,IAAAC,IAAA,EAAAC,IAAA,IAAAC,KAAA;AAG/C;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMd,MAAuB,GAAGA,CAAC;EAACe,QAAQ,GAAG,KAAK;EAAEC,KAAK;EAAEC,aAAa;EAAEC;AAAkB,CAAC,KAAK;EACvGR,YAAY,CAAC,QAAQ,CAAC;EACtB,MAAMS,KAAK,GAAGb,WAAW,CAAC,CAAC;EAC3B,MAAMc,MAAM,GAAGb,WAAW,CACvBc,IAAa,IAAK;IACjBZ,OAAO,CAACa,IAAI,CAACD,IAAI,GAAG,UAAU,GAAG,WAAW,CAAC;IAC7CJ,aAAa,GAAGI,IAAI,CAAC;EACvB,CAAC,EACD,CAACJ,aAAa,CAChB,CAAC;EACD,MAAMM,YAAY,GAAGf,OAAO,CAC1B,MAAOO,QAAQ,IAAI,CAACE,aAAa,GAAG,CAACZ,YAAY,CAAC,CAAC,CAAC,GAAG,CAACA,YAAY,CAAC,CAAC,EAAED,SAAS,CAAC,MAAMgB,MAAM,CAAC,CAACF,KAAK,CAAC,CAAC,CAAE,EACzG,CAACH,QAAQ,EAAEE,aAAa,EAAEG,MAAM,EAAEF,KAAK,CACzC,CAAC;EACD,MAAMM,OAAO,gBACXZ,IAAA,CAACX,aAAa;IACZwB,MAAM,EAAE;MACNC,kBAAkB,EAAEP,KAAK,CAACQ,KAAK,CAAC,qBAAqB,CAAC;MACtDC,iBAAiB,EAAET,KAAK,CAACQ,KAAK,CAAC,eAAe,CAAC;MAC/CE,iBAAiB,EAAEV,KAAK,CAACQ,KAAK,CAAC,qBAAqB;IACtD,CAAE;IACFG,OAAO,EAAE,CAACf,QAAS;IACnBgB,eAAe,EAAEd,aAAa,GAAGG,MAAM,GAAGY,SAAU;IACpDd,KAAK,EAAEA;EAAM,CACd,CACF;EAED,IAAI,CAACF,KAAK,EAAE;IACV,OAAOQ,OAAO;EAChB;EAEA,oBACEV,KAAA,CAACZ,GAAG;IAAC+B,qBAAqB,EAAC,cAAc;IAACC,SAAS,EAAEX,YAAa;IAACY,iBAAiB,EAAC,QAAQ;IAAAC,QAAA,gBAC3FxB,IAAA,CAACT,IAAI;MAACwB,KAAK,EAAER,KAAK,CAACkB,WAAW,CAACtB,QAAQ,GAAG,kBAAkB,GAAG,iBAAiB,CAAE;MAACuB,KAAK,EAAEnB,KAAK,CAACoB,UAAU,CAACC,IAAK;MAAAJ,QAAA,EAC7GpB;IAAK,CACF,CAAC,EACNQ,OAAO;EAAA,CACL,CAAC;AAEV,CAAC","ignoreList":[]}
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+
3
+ import { HAPTICS, playHaptic, prepareHaptic } from '@hzblj/kara-native';
4
+ let enabled = true;
5
+ const play = (haptic, options) => {
6
+ if (enabled) {
7
+ playHaptic(haptic, options?.intensity);
8
+ }
9
+ };
10
+ const prepare = haptic => {
11
+ if (enabled) {
12
+ prepareHaptic(haptic);
13
+ }
14
+ };
15
+ const shorthands = Object.fromEntries(HAPTICS.map(haptic => [haptic, options => play(haptic, options)]));
16
+
17
+ /**
18
+ * The platform's own haptic feedback, named for what happened: UIKit's feedback generators on iOS, the system's
19
+ * `HapticFeedbackConstants` on Android — never a raw vibration. Both follow the user's system haptics setting, and
20
+ * every call is synchronous, with nothing to await.
21
+ *
22
+ * ```ts
23
+ * haptics.success() // a name you know: one function per haptic
24
+ * haptics.play(haptic) // a name that arrives as data
25
+ * haptics.light({intensity: 0.6}) // impacts scale on iOS
26
+ * haptics.prepare('selection') // iOS: warm the Taptic Engine as a gesture begins
27
+ * haptics.setEnabled(userPreference) // silences every kara haptic, the components' own too
28
+ * ```
29
+ *
30
+ * - `selection` — a choice changed: a segment, a chip, a picker row.
31
+ * - `tick` — one step of a continuous gesture: a slider detent, a scrubbed value.
32
+ * - `toggleOn` / `toggleOff` — a switch flipped.
33
+ * - `success` / `warning` / `error` — how a task ended. Android has no warning of its own; it plays a rejection.
34
+ * - `longPress` / `dragStart` — a long press was recognised; an item lifted to be dragged.
35
+ * - `thresholdActivate` / `thresholdDeactivate` — a gesture crossed its commit point, or went back over it.
36
+ * - `keyPress` — a key of the app's own keypad.
37
+ * - `soft`, `light`, `medium`, `rigid`, `heavy` — a collision none of the names above describes.
38
+ *
39
+ * On Android the names that came with Android 14's constants fall back to the nearest older feedback, so every name
40
+ * plays on every version. Haptics are silent in a build without kara's native module.
41
+ */
42
+ export const haptics = {
43
+ ...shorthands,
44
+ /** Whether kara plays haptics — `true` until `setEnabled(false)`. */
45
+ isEnabled: () => enabled,
46
+ play,
47
+ prepare,
48
+ /**
49
+ * Switches every kara haptic on or off, the ones its components play included — for an app's own "Haptics" setting.
50
+ * Not persisted: call it at start-up with the stored preference. The system setting applies either way.
51
+ */
52
+ setEnabled: value => {
53
+ enabled = value;
54
+ }
55
+ };
56
+ //# sourceMappingURL=haptics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["HAPTICS","playHaptic","prepareHaptic","enabled","play","haptic","options","intensity","prepare","shorthands","Object","fromEntries","map","haptics","isEnabled","setEnabled","value"],"sourceRoot":"../../../src","sources":["haptics/haptics.ts"],"mappings":";;AAAA,SAAQA,OAAO,EAAeC,UAAU,EAAEC,aAAa,QAAO,oBAAoB;AAWlF,IAAIC,OAAO,GAAG,IAAI;AAElB,MAAMC,IAAI,GAAGA,CAACC,MAAc,EAAEC,OAAuB,KAAW;EAC9D,IAAIH,OAAO,EAAE;IACXF,UAAU,CAACI,MAAM,EAAEC,OAAO,EAAEC,SAAS,CAAC;EACxC;AACF,CAAC;AAED,MAAMC,OAAO,GAAIH,MAAc,IAAW;EACxC,IAAIF,OAAO,EAAE;IACXD,aAAa,CAACG,MAAM,CAAC;EACvB;AACF,CAAC;AAED,MAAMI,UAAU,GAAGC,MAAM,CAACC,WAAW,CACnCX,OAAO,CAACY,GAAG,CAACP,MAAM,IAAI,CAACA,MAAM,EAAGC,OAAuB,IAAKF,IAAI,CAACC,MAAM,EAAEC,OAAO,CAAC,CAAC,CACpF,CAAqB;;AAErB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMO,OAAO,GAAG;EACrB,GAAGJ,UAAU;EACb;EACAK,SAAS,EAAEA,CAAA,KAAeX,OAAO;EACjCC,IAAI;EACJI,OAAO;EACP;AACF;AACA;AACA;EACEO,UAAU,EAAGC,KAAc,IAAW;IACpCb,OAAO,GAAGa,KAAK;EACjB;AACF,CAAC","ignoreList":[]}
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ export { haptics } from "./haptics.js";
4
+ export { useHapticFeedback } from "./use-haptic-feedback.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["haptics","useHapticFeedback"],"sourceRoot":"../../../src","sources":["haptics/index.ts"],"mappings":";;AAAA,SAAyCA,OAAO,QAAO,cAAW;AAClE,SAA6BC,iBAAiB,QAAO,0BAAuB","ignoreList":[]}
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { haptics } from "./haptics.js";
5
+
6
+ /** Picks the haptic for one change of a trigger — or none, to stay silent for that change. */
7
+
8
+ /**
9
+ * Plays a haptic whenever `trigger` changes — never on mount — like SwiftUI's `sensoryFeedback(_:trigger:)`. Give it
10
+ * a haptic, or a function of the previous and next value that picks one per change. Changes are compared with
11
+ * `Object.is`, so a trigger is a primitive or a stable reference.
12
+ *
13
+ * ```ts
14
+ * useHapticFeedback(tab, 'selection')
15
+ * useHapticFeedback(quantity, (before, after) => (after > before ? 'tick' : 'soft'))
16
+ * useHapticFeedback(payment.status, (_, status) => status === 'paid' ? 'success' : status === 'failed' && 'error')
17
+ * ```
18
+ */
19
+ export const useHapticFeedback = (trigger, feedback) => {
20
+ const previous = useRef(trigger);
21
+ useEffect(() => {
22
+ const before = previous.current;
23
+ if (Object.is(before, trigger)) {
24
+ return;
25
+ }
26
+ previous.current = trigger;
27
+ const haptic = typeof feedback === 'function' ? feedback(before, trigger) : feedback;
28
+ if (haptic) {
29
+ haptics.play(haptic);
30
+ }
31
+ }, [feedback, trigger]);
32
+ };
33
+ //# sourceMappingURL=use-haptic-feedback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["useEffect","useRef","haptics","useHapticFeedback","trigger","feedback","previous","before","current","Object","is","haptic","play"],"sourceRoot":"../../../src","sources":["haptics/use-haptic-feedback.ts"],"mappings":";;AAAA,SAAQA,SAAS,EAAEC,MAAM,QAAO,OAAO;AAEvC,SAAqBC,OAAO,QAAO,cAAW;;AAE9C;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,iBAAiB,GAAGA,CAAIC,OAAU,EAAEC,QAAoC,KAAW;EAC9F,MAAMC,QAAQ,GAAGL,MAAM,CAACG,OAAO,CAAC;EAEhCJ,SAAS,CAAC,MAAM;IACd,MAAMO,MAAM,GAAGD,QAAQ,CAACE,OAAO;IAE/B,IAAIC,MAAM,CAACC,EAAE,CAACH,MAAM,EAAEH,OAAO,CAAC,EAAE;MAC9B;IACF;IAEAE,QAAQ,CAACE,OAAO,GAAGJ,OAAO;IAC1B,MAAMO,MAAM,GAAG,OAAON,QAAQ,KAAK,UAAU,GAAGA,QAAQ,CAACE,MAAM,EAAEH,OAAO,CAAC,GAAGC,QAAQ;IAEpF,IAAIM,MAAM,EAAE;MACVT,OAAO,CAACU,IAAI,CAACD,MAAM,CAAC;IACtB;EACF,CAAC,EAAE,CAACN,QAAQ,EAAED,OAAO,CAAC,CAAC;AACzB,CAAC","ignoreList":[]}
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  export * from "./atoms/index.js";
4
+ export * from "./haptics/index.js";
4
5
  export * from "./molecules/index.js";
5
6
  export * from "./organisms/index.js";
6
7
  export * from "./templates/index.js";
@@ -1 +1 @@
1
- {"version":3,"names":[],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA,cAAc,kBAAS;AACvB,cAAc,sBAAa;AAC3B,cAAc,sBAAa;AAC3B,cAAc,sBAAa","ignoreList":[]}
1
+ {"version":3,"names":[],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA,cAAc,kBAAS;AACvB,cAAc,oBAAW;AACzB,cAAc,sBAAa;AAC3B,cAAc,sBAAa;AAC3B,cAAc,sBAAa","ignoreList":[]}
@@ -3,10 +3,10 @@
3
3
  import { Text as ComposeText, DropdownMenu, DropdownMenuItem, Host } from '@expo/ui/jetpack-compose';
4
4
  import { useSx } from '@hzblj/kara-sx';
5
5
  import { useAppTheme } from '@hzblj/kara-theme';
6
- import * as Haptics from 'expo-haptics';
7
6
  import { useCallback, useMemo, useRef, useState } from 'react';
8
7
  import { Pressable, StyleSheet, View } from 'react-native';
9
8
  import { Icon } from "../../atoms/icon/index.js";
9
+ import { haptics } from "../../haptics/index.js";
10
10
  import { HostBoundary } from "../../host-context.js";
11
11
  import { splitForRipple } from "../../ripple-clip.js";
12
12
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -51,7 +51,7 @@ const MenuRow = ({
51
51
  * Android's own take on a context menu, since Material has no lifted preview: a long press ripples the trigger, ticks
52
52
  * the long-press haptic, and opens a Material dropdown where the finger is — the trigger stays highlighted while the
53
53
  * menu is up. The host behind the menu mounts on the first long press and stays, so a list of triggers that are only
54
- * scrolled past never builds one. `preview` and `onPreviewPress` are iOS-only.
54
+ * scrolled past never builds one. `preview`, `onPreviewPress` and `previewTransition` are iOS-only.
55
55
  */
56
56
  const ContextMenuRoot = ({
57
57
  actions,
@@ -62,6 +62,7 @@ const ContextMenuRoot = ({
62
62
  const {
63
63
  onPreviewPress: _onPreviewPress,
64
64
  preview: _preview,
65
+ previewTransition: _previewTransition,
65
66
  ...sx
66
67
  } = props;
67
68
  const theme = useAppTheme();
@@ -86,7 +87,7 @@ const ContextMenuRoot = ({
86
87
  pageX,
87
88
  pageY
88
89
  } = event.nativeEvent;
89
- Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => undefined);
90
+ haptics.longPress();
90
91
  // The touch in the trigger's own coordinates: `locationX/Y` are relative to whichever child was touched.
91
92
  trigger.current?.measure((_x, _y, _width, _height, left, top) => {
92
93
  setAnchor({
@@ -1 +1 @@
1
- {"version":3,"names":["Text","ComposeText","DropdownMenu","DropdownMenuItem","Host","useSx","useAppTheme","Haptics","useCallback","useMemo","useRef","useState","Pressable","StyleSheet","View","Icon","HostBoundary","splitForRipple","jsx","_jsx","jsxs","_jsxs","styles","create","anchor","height","position","width","MenuRow","action","onDismiss","theme","color","destructive","onClick","onPress","children","colorString","style","typography","body","label","icon","LeadingIcon","name","size","ContextMenuRoot","actions","testID","props","onPreviewPress","_onPreviewPress","preview","_preview","sx","resolved","trigger","setAnchor","expanded","setExpanded","inner","outer","ripple","highlight","backgroundColor","open","event","pageX","pageY","nativeEvent","performAndroidHapticsAsync","AndroidHaptics","Long_Press","catch","undefined","current","measure","_x","_y","_width","_height","left","top","x","y","dismiss","android_ripple","onLongPress","ref","pointerEvents","absoluteFill","colorScheme","dark","seedColor","materialSeed","onDismissRequest","Items","map","Exclusion","ContextMenu","Object","assign"],"sourceRoot":"../../../../src","sources":["organisms/context-menu/context-menu.android.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["Text","ComposeText","DropdownMenu","DropdownMenuItem","Host","useSx","useAppTheme","useCallback","useMemo","useRef","useState","Pressable","StyleSheet","View","Icon","haptics","HostBoundary","splitForRipple","jsx","_jsx","jsxs","_jsxs","styles","create","anchor","height","position","width","MenuRow","action","onDismiss","theme","color","destructive","onClick","onPress","children","colorString","style","typography","body","label","icon","LeadingIcon","name","size","ContextMenuRoot","actions","testID","props","onPreviewPress","_onPreviewPress","preview","_preview","previewTransition","_previewTransition","sx","resolved","trigger","setAnchor","expanded","setExpanded","inner","outer","ripple","highlight","backgroundColor","open","event","pageX","pageY","nativeEvent","longPress","current","measure","_x","_y","_width","_height","left","top","x","y","dismiss","android_ripple","onLongPress","ref","pointerEvents","absoluteFill","colorScheme","dark","seedColor","materialSeed","onDismissRequest","Items","map","Exclusion","ContextMenu","Object","assign"],"sourceRoot":"../../../../src","sources":["organisms/context-menu/context-menu.android.tsx"],"mappings":"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","ignoreList":[]}
@@ -10,6 +10,7 @@ const ContextMenuRoot = ({
10
10
  children,
11
11
  onPreviewPress,
12
12
  preview,
13
+ previewTransition,
13
14
  testID,
14
15
  ...sx
15
16
  }) => {
@@ -28,6 +29,7 @@ const ContextMenuRoot = ({
28
29
  onPreviewPress: onPreviewPress,
29
30
  onSelectItem: onSelectItem,
30
31
  preview: preview,
32
+ previewTransition: previewTransition,
31
33
  style: style,
32
34
  testID: testID,
33
35
  children: children
@@ -1 +1 @@
1
- {"version":3,"names":["iconResourceName","ContextMenu","NativeContextMenu","ContextMenuExclusion","NativeContextMenuExclusion","useSx","useCallback","useMemo","jsx","_jsx","ContextMenuRoot","actions","children","onPreviewPress","preview","testID","sx","style","items","map","action","index","destructive","id","String","image","icon","undefined","title","label","onSelectItem","Number","onPress","Exclusion","Object","assign"],"sourceRoot":"../../../../src","sources":["organisms/context-menu/context-menu.ios.tsx"],"mappings":";;AAAA,SAAQA,gBAAgB,QAAO,oBAAoB;AACnD,SAEEC,WAAW,IAAIC,iBAAiB,EAChCC,oBAAoB,IAAIC,0BAA0B,QAC7C,6BAA6B;AACpC,SAAQC,KAAK,QAAO,gBAAgB;AACpC,SAAQC,WAAW,EAAEC,OAAO,QAAO,OAAO;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAG1C,MAAMC,eAAe,GAAGA,CAAC;EAACC,OAAO;EAAEC,QAAQ;EAAEC,cAAc;EAAEC,OAAO;EAAEC,MAAM;EAAE,GAAGC;AAAoB,CAAC,KAAK;EACzG,MAAMC,KAAK,GAAGZ,KAAK,CAACW,EAAE,CAAC;;EAEvB;EACA,MAAME,KAAK,GAAGX,OAAO,CACnB,MACEI,OAAO,CAACQ,GAAG,CAAC,CAACC,MAAM,EAAEC,KAAK,MAAM;IAC9BC,WAAW,EAAEF,MAAM,CAACE,WAAW;IAC/BC,EAAE,EAAEC,MAAM,CAACH,KAAK,CAAC;IACjBI,KAAK,EAAEL,MAAM,CAACM,IAAI,GAAG1B,gBAAgB,CAACoB,MAAM,CAACM,IAAI,CAAC,GAAGC,SAAS;IAC9DC,KAAK,EAAER,MAAM,CAACS;EAChB,CAAC,CAAC,CAAC,EACL,CAAClB,OAAO,CACV,CAAC;EAED,MAAMmB,YAAY,GAAGxB,WAAW,CAAEiB,EAAU,IAAKZ,OAAO,CAACoB,MAAM,CAACR,EAAE,CAAC,CAAC,EAAES,OAAO,CAAC,CAAC,EAAE,CAACrB,OAAO,CAAC,CAAC;EAE3F,oBACEF,IAAA,CAACP,iBAAiB;IAChBgB,KAAK,EAAEA,KAAM;IACbL,cAAc,EAAEA,cAAe;IAC/BiB,YAAY,EAAEA,YAAa;IAC3BhB,OAAO,EAAEA,OAAQ;IACjBG,KAAK,EAAEA,KAAM;IACbF,MAAM,EAAEA,MAAO;IAAAH,QAAA,EAEdA;EAAQ,CACQ,CAAC;AAExB,CAAC;AAED,MAAMqB,SAAS,GAAGA,CAAC;EAACrB;AAAmC,CAAC,kBACtDH,IAAA,CAACL,0BAA0B;EAAAQ,QAAA,EAAEA;AAAQ,CAA6B,CACnE;AAED,OAAO,MAAMX,WAAiC,GAAGiC,MAAM,CAACC,MAAM,CAACzB,eAAe,EAAE;EAACuB;AAAS,CAAC,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["iconResourceName","ContextMenu","NativeContextMenu","ContextMenuExclusion","NativeContextMenuExclusion","useSx","useCallback","useMemo","jsx","_jsx","ContextMenuRoot","actions","children","onPreviewPress","preview","previewTransition","testID","sx","style","items","map","action","index","destructive","id","String","image","icon","undefined","title","label","onSelectItem","Number","onPress","Exclusion","Object","assign"],"sourceRoot":"../../../../src","sources":["organisms/context-menu/context-menu.ios.tsx"],"mappings":";;AAAA,SAAQA,gBAAgB,QAAO,oBAAoB;AACnD,SAEEC,WAAW,IAAIC,iBAAiB,EAChCC,oBAAoB,IAAIC,0BAA0B,QAC7C,6BAA6B;AACpC,SAAQC,KAAK,QAAO,gBAAgB;AACpC,SAAQC,WAAW,EAAEC,OAAO,QAAO,OAAO;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAG1C,MAAMC,eAAe,GAAGA,CAAC;EACvBC,OAAO;EACPC,QAAQ;EACRC,cAAc;EACdC,OAAO;EACPC,iBAAiB;EACjBC,MAAM;EACN,GAAGC;AACa,CAAC,KAAK;EACtB,MAAMC,KAAK,GAAGb,KAAK,CAACY,EAAE,CAAC;;EAEvB;EACA,MAAME,KAAK,GAAGZ,OAAO,CACnB,MACEI,OAAO,CAACS,GAAG,CAAC,CAACC,MAAM,EAAEC,KAAK,MAAM;IAC9BC,WAAW,EAAEF,MAAM,CAACE,WAAW;IAC/BC,EAAE,EAAEC,MAAM,CAACH,KAAK,CAAC;IACjBI,KAAK,EAAEL,MAAM,CAACM,IAAI,GAAG3B,gBAAgB,CAACqB,MAAM,CAACM,IAAI,CAAC,GAAGC,SAAS;IAC9DC,KAAK,EAAER,MAAM,CAACS;EAChB,CAAC,CAAC,CAAC,EACL,CAACnB,OAAO,CACV,CAAC;EAED,MAAMoB,YAAY,GAAGzB,WAAW,CAAEkB,EAAU,IAAKb,OAAO,CAACqB,MAAM,CAACR,EAAE,CAAC,CAAC,EAAES,OAAO,CAAC,CAAC,EAAE,CAACtB,OAAO,CAAC,CAAC;EAE3F,oBACEF,IAAA,CAACP,iBAAiB;IAChBiB,KAAK,EAAEA,KAAM;IACbN,cAAc,EAAEA,cAAe;IAC/BkB,YAAY,EAAEA,YAAa;IAC3BjB,OAAO,EAAEA,OAAQ;IACjBC,iBAAiB,EAAEA,iBAAkB;IACrCG,KAAK,EAAEA,KAAM;IACbF,MAAM,EAAEA,MAAO;IAAAJ,QAAA,EAEdA;EAAQ,CACQ,CAAC;AAExB,CAAC;AAED,MAAMsB,SAAS,GAAGA,CAAC;EAACtB;AAAmC,CAAC,kBACtDH,IAAA,CAACL,0BAA0B;EAAAQ,QAAA,EAAEA;AAAQ,CAA6B,CACnE;AAED,OAAO,MAAMX,WAAiC,GAAGkC,MAAM,CAACC,MAAM,CAAC1B,eAAe,EAAE;EAACwB;AAAS,CAAC,CAAC","ignoreList":[]}
@@ -1,9 +1,11 @@
1
1
  import type { ResponsiveViewSx } from '@hzblj/kara-sx';
2
2
  import type { ReactNode } from 'react';
3
+ import type { Haptic } from '../../haptics';
3
4
  export type PressableProps = ResponsiveViewSx & {
4
5
  children?: ReactNode;
5
6
  disabled?: boolean;
6
- haptic?: boolean;
7
+ /** Played on press. `true` is a `light` impact; a name picks the haptic, e.g. `selection` for a choice. */
8
+ haptic?: boolean | Haptic;
7
9
  onPress?: () => void;
8
10
  testID?: string;
9
11
  };
@@ -1,2 +1,6 @@
1
1
  import type { SegmentedControlComponent } from './segmented-control.types';
2
+ /**
3
+ * `UISegmentedControl` on iOS, Material's single-choice segmented buttons on Android. A new segment plays `selection`
4
+ * on Android, whose buttons play nothing of their own; iOS 26's control already gives its own feedback.
5
+ */
2
6
  export declare const SegmentedControl: SegmentedControlComponent;
@@ -0,0 +1,61 @@
1
+ import { type Haptic } from '@hzblj/kara-native';
2
+ export type { Haptic };
3
+ export type HapticOptions = {
4
+ /** iOS: scales an impact — `soft`, `light`, `medium`, `rigid`, `heavy` — from 0 to 1. Everything else ignores it. */
5
+ intensity?: number;
6
+ };
7
+ declare const play: (haptic: Haptic, options?: HapticOptions) => void;
8
+ declare const prepare: (haptic: Haptic) => void;
9
+ /**
10
+ * The platform's own haptic feedback, named for what happened: UIKit's feedback generators on iOS, the system's
11
+ * `HapticFeedbackConstants` on Android — never a raw vibration. Both follow the user's system haptics setting, and
12
+ * every call is synchronous, with nothing to await.
13
+ *
14
+ * ```ts
15
+ * haptics.success() // a name you know: one function per haptic
16
+ * haptics.play(haptic) // a name that arrives as data
17
+ * haptics.light({intensity: 0.6}) // impacts scale on iOS
18
+ * haptics.prepare('selection') // iOS: warm the Taptic Engine as a gesture begins
19
+ * haptics.setEnabled(userPreference) // silences every kara haptic, the components' own too
20
+ * ```
21
+ *
22
+ * - `selection` — a choice changed: a segment, a chip, a picker row.
23
+ * - `tick` — one step of a continuous gesture: a slider detent, a scrubbed value.
24
+ * - `toggleOn` / `toggleOff` — a switch flipped.
25
+ * - `success` / `warning` / `error` — how a task ended. Android has no warning of its own; it plays a rejection.
26
+ * - `longPress` / `dragStart` — a long press was recognised; an item lifted to be dragged.
27
+ * - `thresholdActivate` / `thresholdDeactivate` — a gesture crossed its commit point, or went back over it.
28
+ * - `keyPress` — a key of the app's own keypad.
29
+ * - `soft`, `light`, `medium`, `rigid`, `heavy` — a collision none of the names above describes.
30
+ *
31
+ * On Android the names that came with Android 14's constants fall back to the nearest older feedback, so every name
32
+ * plays on every version. Haptics are silent in a build without kara's native module.
33
+ */
34
+ export declare const haptics: {
35
+ /** Whether kara plays haptics — `true` until `setEnabled(false)`. */
36
+ isEnabled: () => boolean;
37
+ play: typeof play;
38
+ prepare: typeof prepare;
39
+ /**
40
+ * Switches every kara haptic on or off, the ones its components play included — for an app's own "Haptics" setting.
41
+ * Not persisted: call it at start-up with the stored preference. The system setting applies either way.
42
+ */
43
+ setEnabled: (value: boolean) => void;
44
+ dragStart: (options?: HapticOptions) => void;
45
+ error: (options?: HapticOptions) => void;
46
+ heavy: (options?: HapticOptions) => void;
47
+ keyPress: (options?: HapticOptions) => void;
48
+ light: (options?: HapticOptions) => void;
49
+ longPress: (options?: HapticOptions) => void;
50
+ medium: (options?: HapticOptions) => void;
51
+ rigid: (options?: HapticOptions) => void;
52
+ selection: (options?: HapticOptions) => void;
53
+ soft: (options?: HapticOptions) => void;
54
+ success: (options?: HapticOptions) => void;
55
+ thresholdActivate: (options?: HapticOptions) => void;
56
+ thresholdDeactivate: (options?: HapticOptions) => void;
57
+ tick: (options?: HapticOptions) => void;
58
+ toggleOff: (options?: HapticOptions) => void;
59
+ toggleOn: (options?: HapticOptions) => void;
60
+ warning: (options?: HapticOptions) => void;
61
+ };
@@ -0,0 +1,2 @@
1
+ export { type Haptic, type HapticOptions, haptics } from './haptics';
2
+ export { type HapticResolver, useHapticFeedback } from './use-haptic-feedback';
@@ -0,0 +1,15 @@
1
+ import { type Haptic } from './haptics';
2
+ /** Picks the haptic for one change of a trigger — or none, to stay silent for that change. */
3
+ export type HapticResolver<T> = (previous: T, next: T) => Haptic | false | null | undefined;
4
+ /**
5
+ * Plays a haptic whenever `trigger` changes — never on mount — like SwiftUI's `sensoryFeedback(_:trigger:)`. Give it
6
+ * a haptic, or a function of the previous and next value that picks one per change. Changes are compared with
7
+ * `Object.is`, so a trigger is a primitive or a stable reference.
8
+ *
9
+ * ```ts
10
+ * useHapticFeedback(tab, 'selection')
11
+ * useHapticFeedback(quantity, (before, after) => (after > before ? 'tick' : 'soft'))
12
+ * useHapticFeedback(payment.status, (_, status) => status === 'paid' ? 'success' : status === 'failed' && 'error')
13
+ * ```
14
+ */
15
+ export declare const useHapticFeedback: <T>(trigger: T, feedback: Haptic | HapticResolver<T>) => void;
@@ -1,4 +1,5 @@
1
1
  export * from './atoms';
2
+ export * from './haptics';
2
3
  export * from './molecules';
3
4
  export * from './organisms';
4
5
  export * from './templates';
@@ -20,6 +20,14 @@ export type ContextMenuProps = ResponsiveViewSx & {
20
20
  * every preview up front.
21
21
  */
22
22
  preview?: ReactNode;
23
+ /**
24
+ * iOS: how the preview leaves once tapped, before `onPreviewPress`. `zoom`: its photo grows straight into the pushed
25
+ * screen, the way the App Store expands a preview — needs a `ZoomTransition.Source` (`@hzblj/kara-ui/router`) in the
26
+ * trigger, a `preview` and iOS 18, and shrinks back like `dismiss` otherwise. `pop`: UIKit's default, the preview
27
+ * flies out. `dismiss`: it shrinks back onto the trigger first. Default: `zoom` when the trigger holds a
28
+ * `ZoomTransition.Source`, `pop` otherwise.
29
+ */
30
+ previewTransition?: 'dismiss' | 'pop' | 'zoom';
23
31
  testID?: string;
24
32
  };
25
33
  export type ContextMenuExclusionProps = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hzblj/kara-components",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "The atoms, molecules, organisms and templates of @hzblj/kara-ui — real SwiftUI and Jetpack Compose views through @expo/ui.",
5
5
  "author": "Jan Blazej",
6
6
  "license": "MIT",
@@ -59,9 +59,9 @@
59
59
  ]
60
60
  },
61
61
  "dependencies": {
62
- "@hzblj/kara-native": "^0.4.1",
63
- "@hzblj/kara-sx": "^0.4.1",
64
- "@hzblj/kara-theme": "^0.4.1",
62
+ "@hzblj/kara-native": "^0.6.0",
63
+ "@hzblj/kara-sx": "^0.6.0",
64
+ "@hzblj/kara-theme": "^0.6.0",
65
65
  "sf-symbols-typescript": "^2.2.0"
66
66
  },
67
67
  "peerDependencies": {
@@ -69,7 +69,6 @@
69
69
  "@lodev09/react-native-true-sheet": ">=3.11.0",
70
70
  "@shopify/flash-list": ">=2.0.0",
71
71
  "expo": "~57.0.0",
72
- "expo-haptics": "*",
73
72
  "expo-image": "*",
74
73
  "react": "*",
75
74
  "react-native": "*",
@@ -82,7 +81,6 @@
82
81
  "@storybook/react-native": "^10.6.0",
83
82
  "@types/react": "~19.2.18",
84
83
  "expo": "~57.0.22",
85
- "expo-haptics": "~57.0.3",
86
84
  "expo-image": "~57.0.5",
87
85
  "react": "19.2.3",
88
86
  "react-native": "0.86.3",
@@ -9,7 +9,7 @@ export const Chip: ChipComponent = ({label, onPress, selected = false, testID}:
9
9
  borderColor={selected ? 'border.accent' : 'border.tertiary'}
10
10
  borderRadius="full"
11
11
  borderWidth={1}
12
- haptic
12
+ haptic="selection"
13
13
  justify="center"
14
14
  onPress={onPress}
15
15
  px="s12"
@@ -1,8 +1,9 @@
1
1
  import {useSplitSx} from '@hzblj/kara-sx'
2
- import * as Haptics from 'expo-haptics'
3
2
  import {useCallback} from 'react'
4
3
  import {Pressable as RNPressable} from 'react-native'
5
4
  import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'
5
+
6
+ import {haptics} from '../../haptics'
6
7
  import type {PressableComponent, PressableProps} from './pressable.types'
7
8
 
8
9
  const SPRING = {damping: 20, stiffness: 400}
@@ -19,24 +20,30 @@ export const Pressable: PressableComponent = ({
19
20
  }: PressableProps) => {
20
21
  const [resolved, rest] = useSplitSx(props)
21
22
  const scale = useSharedValue(1)
23
+ const feedback = haptic === true ? 'light' : haptic || undefined
22
24
 
23
25
  const rStyle = useAnimatedStyle(() => ({transform: [{scale: scale.value}]}), [])
24
26
 
27
+ // The finger is down: the Taptic Engine has until the release to wake, so the haptic lands with the press.
25
28
  const onPressIn = useCallback(() => {
26
29
  scale.value = withSpring(0.96, SPRING)
27
- }, [scale])
30
+
31
+ if (feedback) {
32
+ haptics.prepare(feedback)
33
+ }
34
+ }, [feedback, scale])
28
35
 
29
36
  const onPressOut = useCallback(() => {
30
37
  scale.value = withSpring(1, SPRING)
31
38
  }, [scale])
32
39
 
33
40
  const onPressWithHaptic = useCallback(() => {
34
- if (haptic) {
35
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined)
41
+ if (feedback) {
42
+ haptics.play(feedback)
36
43
  }
37
44
 
38
45
  onPress?.()
39
- }, [haptic, onPress])
46
+ }, [feedback, onPress])
40
47
 
41
48
  return (
42
49
  <AnimatedPressable
@@ -1,10 +1,13 @@
1
1
  import type {ResponsiveViewSx} from '@hzblj/kara-sx'
2
2
  import type {ReactNode} from 'react'
3
3
 
4
+ import type {Haptic} from '../../haptics'
5
+
4
6
  export type PressableProps = ResponsiveViewSx & {
5
7
  children?: ReactNode
6
8
  disabled?: boolean
7
- haptic?: boolean
9
+ /** Played on press. `true` is a `light` impact; a name picks the haptic, e.g. `selection` for a choice. */
10
+ haptic?: boolean | Haptic
8
11
  onPress?: () => void
9
12
  testID?: string
10
13
  }
@@ -1,9 +1,15 @@
1
1
  import {SegmentedControl as NativeSegmentedControl} from '@expo/ui/community/segmented-control'
2
2
  import {useAppTheme} from '@hzblj/kara-theme'
3
3
  import {useCallback} from 'react'
4
+ import {Platform} from 'react-native'
4
5
 
6
+ import {haptics} from '../../haptics'
5
7
  import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
6
8
 
9
+ /**
10
+ * `UISegmentedControl` on iOS, Material's single-choice segmented buttons on Android. A new segment plays `selection`
11
+ * on Android, whose buttons play nothing of their own; iOS 26's control already gives its own feedback.
12
+ */
7
13
  export const SegmentedControl: SegmentedControlComponent = ({
8
14
  disabled = false,
9
15
  onChange,
@@ -15,9 +21,16 @@ export const SegmentedControl: SegmentedControlComponent = ({
15
21
 
16
22
  const onNativeChange = useCallback(
17
23
  (event: {nativeEvent: {selectedSegmentIndex: number; value: string}}) => {
18
- onChange?.(event.nativeEvent.selectedSegmentIndex, event.nativeEvent.value)
24
+ const index = event.nativeEvent.selectedSegmentIndex
25
+
26
+ // Material reports a tap on the segment that is already selected too.
27
+ if (Platform.OS === 'android' && index !== selectedIndex) {
28
+ haptics.selection()
29
+ }
30
+
31
+ onChange?.(index, event.nativeEvent.value)
19
32
  },
20
- [onChange]
33
+ [onChange, selectedIndex]
21
34
  )
22
35
 
23
36
  return (
@@ -1,9 +1,16 @@
1
1
  import {Slider as ComposeSlider} from '@expo/ui/jetpack-compose'
2
2
  import {useAppTheme} from '@hzblj/kara-theme'
3
+ import {useCallback, useEffect, useRef} from 'react'
3
4
 
5
+ import {haptics} from '../../haptics'
4
6
  import {useHostGuard} from '../../host-context'
5
7
  import type {SliderComponent, SliderProps} from './slider.types'
6
8
 
9
+ /**
10
+ * Material's slider. `step` is the increment, as SwiftUI takes it; Compose counts the stops between the two ends
11
+ * instead, so it is converted. A stepped slider ticks at every stop the thumb reaches — Compose's Material slider
12
+ * plays nothing of its own, where iOS 26's slider already does.
13
+ */
7
14
  export const Slider: SliderComponent = ({
8
15
  disabled = false,
9
16
  max = 1,
@@ -14,6 +21,30 @@ export const Slider: SliderComponent = ({
14
21
  }: SliderProps) => {
15
22
  useHostGuard('Slider')
16
23
  const theme = useAppTheme()
24
+ const steps = step ? Math.max(0, Math.round((max - min) / step) - 1) : 0
25
+
26
+ // The stop is derived from the value rather than trusting it to arrive snapped, and remembered between events:
27
+ // Compose reports faster than React re-renders the controlled value.
28
+ const stopOf = useCallback((at: number) => (step ? Math.round((at - min) / step) : 0), [min, step])
29
+ const stop = useRef(stopOf(value))
30
+
31
+ useEffect(() => {
32
+ stop.current = stopOf(value)
33
+ }, [stopOf, value])
34
+
35
+ const onChange = useCallback(
36
+ (next: number) => {
37
+ const reached = stopOf(next)
38
+
39
+ if (steps > 0 && reached !== stop.current) {
40
+ haptics.tick()
41
+ }
42
+
43
+ stop.current = reached
44
+ onValueChange?.(next)
45
+ },
46
+ [onValueChange, stopOf, steps]
47
+ )
17
48
 
18
49
  return (
19
50
  <ComposeSlider
@@ -24,8 +55,8 @@ export const Slider: SliderComponent = ({
24
55
  enabled={!disabled}
25
56
  max={max}
26
57
  min={min}
27
- onValueChange={onValueChange}
28
- steps={step}
58
+ onValueChange={onChange}
59
+ steps={steps}
29
60
  value={value}
30
61
  />
31
62
  )
@@ -1,21 +1,31 @@
1
1
  import {Switch as ComposeSwitch, Row, Text} from '@expo/ui/jetpack-compose'
2
2
  import {clickable, fillMaxWidth} from '@expo/ui/jetpack-compose/modifiers'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
- import {useMemo} from 'react'
4
+ import {useCallback, useMemo} from 'react'
5
5
 
6
+ import {haptics} from '../../haptics'
6
7
  import {useHostGuard} from '../../host-context'
7
8
  import type {SwitchComponent, SwitchProps} from './switch.types'
8
9
 
9
10
  /**
10
11
  * Material's switch, tinted with the accent like SwiftUI's toggle. With a label it is a full-width row — label at the
11
- * start, switch at the end — and the whole row toggles, the way a Material settings row does.
12
+ * start, switch at the end — and the whole row toggles, the way a Material settings row does. A flip plays
13
+ * `toggleOn`/`toggleOff` — Android 14's constants made for switches — since Compose's Material switch plays nothing
14
+ * of its own. iOS stays silent, as `UISwitch` does.
12
15
  */
13
16
  export const Switch: SwitchComponent = ({disabled = false, label, onValueChange, value}: SwitchProps) => {
14
17
  useHostGuard('Switch')
15
18
  const theme = useAppTheme()
19
+ const toggle = useCallback(
20
+ (next: boolean) => {
21
+ haptics.play(next ? 'toggleOn' : 'toggleOff')
22
+ onValueChange?.(next)
23
+ },
24
+ [onValueChange]
25
+ )
16
26
  const rowModifiers = useMemo(
17
- () => (disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => onValueChange(!value))]),
18
- [disabled, onValueChange, value]
27
+ () => (disabled || !onValueChange ? [fillMaxWidth()] : [fillMaxWidth(), clickable(() => toggle(!value))]),
28
+ [disabled, onValueChange, toggle, value]
19
29
  )
20
30
  const control = (
21
31
  <ComposeSwitch
@@ -25,7 +35,7 @@ export const Switch: SwitchComponent = ({disabled = false, label, onValueChange,
25
35
  checkedTrackColor: theme.color('fill.accent.primary'),
26
36
  }}
27
37
  enabled={!disabled}
28
- onCheckedChange={onValueChange}
38
+ onCheckedChange={onValueChange ? toggle : undefined}
29
39
  value={value}
30
40
  />
31
41
  )
@@ -0,0 +1,68 @@
1
+ import {HAPTICS, type Haptic, playHaptic, prepareHaptic} from '@hzblj/kara-native'
2
+
3
+ export type {Haptic}
4
+
5
+ export type HapticOptions = {
6
+ /** iOS: scales an impact — `soft`, `light`, `medium`, `rigid`, `heavy` — from 0 to 1. Everything else ignores it. */
7
+ intensity?: number
8
+ }
9
+
10
+ type HapticShorthands = {readonly [K in Haptic]: (options?: HapticOptions) => void}
11
+
12
+ let enabled = true
13
+
14
+ const play = (haptic: Haptic, options?: HapticOptions): void => {
15
+ if (enabled) {
16
+ playHaptic(haptic, options?.intensity)
17
+ }
18
+ }
19
+
20
+ const prepare = (haptic: Haptic): void => {
21
+ if (enabled) {
22
+ prepareHaptic(haptic)
23
+ }
24
+ }
25
+
26
+ const shorthands = Object.fromEntries(
27
+ HAPTICS.map(haptic => [haptic, (options?: HapticOptions) => play(haptic, options)])
28
+ ) as HapticShorthands
29
+
30
+ /**
31
+ * The platform's own haptic feedback, named for what happened: UIKit's feedback generators on iOS, the system's
32
+ * `HapticFeedbackConstants` on Android — never a raw vibration. Both follow the user's system haptics setting, and
33
+ * every call is synchronous, with nothing to await.
34
+ *
35
+ * ```ts
36
+ * haptics.success() // a name you know: one function per haptic
37
+ * haptics.play(haptic) // a name that arrives as data
38
+ * haptics.light({intensity: 0.6}) // impacts scale on iOS
39
+ * haptics.prepare('selection') // iOS: warm the Taptic Engine as a gesture begins
40
+ * haptics.setEnabled(userPreference) // silences every kara haptic, the components' own too
41
+ * ```
42
+ *
43
+ * - `selection` — a choice changed: a segment, a chip, a picker row.
44
+ * - `tick` — one step of a continuous gesture: a slider detent, a scrubbed value.
45
+ * - `toggleOn` / `toggleOff` — a switch flipped.
46
+ * - `success` / `warning` / `error` — how a task ended. Android has no warning of its own; it plays a rejection.
47
+ * - `longPress` / `dragStart` — a long press was recognised; an item lifted to be dragged.
48
+ * - `thresholdActivate` / `thresholdDeactivate` — a gesture crossed its commit point, or went back over it.
49
+ * - `keyPress` — a key of the app's own keypad.
50
+ * - `soft`, `light`, `medium`, `rigid`, `heavy` — a collision none of the names above describes.
51
+ *
52
+ * On Android the names that came with Android 14's constants fall back to the nearest older feedback, so every name
53
+ * plays on every version. Haptics are silent in a build without kara's native module.
54
+ */
55
+ export const haptics = {
56
+ ...shorthands,
57
+ /** Whether kara plays haptics — `true` until `setEnabled(false)`. */
58
+ isEnabled: (): boolean => enabled,
59
+ play,
60
+ prepare,
61
+ /**
62
+ * Switches every kara haptic on or off, the ones its components play included — for an app's own "Haptics" setting.
63
+ * Not persisted: call it at start-up with the stored preference. The system setting applies either way.
64
+ */
65
+ setEnabled: (value: boolean): void => {
66
+ enabled = value
67
+ },
68
+ }
@@ -0,0 +1,2 @@
1
+ export {type Haptic, type HapticOptions, haptics} from './haptics'
2
+ export {type HapticResolver, useHapticFeedback} from './use-haptic-feedback'
@@ -0,0 +1,36 @@
1
+ import {useEffect, useRef} from 'react'
2
+
3
+ import {type Haptic, haptics} from './haptics'
4
+
5
+ /** Picks the haptic for one change of a trigger — or none, to stay silent for that change. */
6
+ export type HapticResolver<T> = (previous: T, next: T) => Haptic | false | null | undefined
7
+
8
+ /**
9
+ * Plays a haptic whenever `trigger` changes — never on mount — like SwiftUI's `sensoryFeedback(_:trigger:)`. Give it
10
+ * a haptic, or a function of the previous and next value that picks one per change. Changes are compared with
11
+ * `Object.is`, so a trigger is a primitive or a stable reference.
12
+ *
13
+ * ```ts
14
+ * useHapticFeedback(tab, 'selection')
15
+ * useHapticFeedback(quantity, (before, after) => (after > before ? 'tick' : 'soft'))
16
+ * useHapticFeedback(payment.status, (_, status) => status === 'paid' ? 'success' : status === 'failed' && 'error')
17
+ * ```
18
+ */
19
+ export const useHapticFeedback = <T>(trigger: T, feedback: Haptic | HapticResolver<T>): void => {
20
+ const previous = useRef(trigger)
21
+
22
+ useEffect(() => {
23
+ const before = previous.current
24
+
25
+ if (Object.is(before, trigger)) {
26
+ return
27
+ }
28
+
29
+ previous.current = trigger
30
+ const haptic = typeof feedback === 'function' ? feedback(before, trigger) : feedback
31
+
32
+ if (haptic) {
33
+ haptics.play(haptic)
34
+ }
35
+ }, [feedback, trigger])
36
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './atoms'
2
+ export * from './haptics'
2
3
  export * from './molecules'
3
4
  export * from './organisms'
4
5
  export * from './templates'
@@ -1,10 +1,10 @@
1
1
  import {Text as ComposeText, DropdownMenu, DropdownMenuItem, Host} from '@expo/ui/jetpack-compose'
2
2
  import {useSx} from '@hzblj/kara-sx'
3
3
  import {useAppTheme} from '@hzblj/kara-theme'
4
- import * as Haptics from 'expo-haptics'
5
4
  import {useCallback, useMemo, useRef, useState} from 'react'
6
5
  import {type GestureResponderEvent, Pressable, StyleSheet, View} from 'react-native'
7
6
  import {Icon} from '../../atoms/icon'
7
+ import {haptics} from '../../haptics'
8
8
  import {HostBoundary} from '../../host-context'
9
9
  import {splitForRipple} from '../../ripple-clip'
10
10
  import type {MenuAction} from '../menu'
@@ -52,10 +52,10 @@ const MenuRow = ({action, onDismiss}: MenuRowProps) => {
52
52
  * Android's own take on a context menu, since Material has no lifted preview: a long press ripples the trigger, ticks
53
53
  * the long-press haptic, and opens a Material dropdown where the finger is — the trigger stays highlighted while the
54
54
  * menu is up. The host behind the menu mounts on the first long press and stays, so a list of triggers that are only
55
- * scrolled past never builds one. `preview` and `onPreviewPress` are iOS-only.
55
+ * scrolled past never builds one. `preview`, `onPreviewPress` and `previewTransition` are iOS-only.
56
56
  */
57
57
  const ContextMenuRoot = ({actions, children, testID, ...props}: ContextMenuProps) => {
58
- const {onPreviewPress: _onPreviewPress, preview: _preview, ...sx} = props
58
+ const {onPreviewPress: _onPreviewPress, preview: _preview, previewTransition: _previewTransition, ...sx} = props
59
59
  const theme = useAppTheme()
60
60
  const resolved = useSx(sx)
61
61
  const trigger = useRef<View>(null)
@@ -70,7 +70,7 @@ const ContextMenuRoot = ({actions, children, testID, ...props}: ContextMenuProps
70
70
  const open = useCallback((event: GestureResponderEvent) => {
71
71
  const {pageX, pageY} = event.nativeEvent
72
72
 
73
- Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Long_Press).catch(() => undefined)
73
+ haptics.longPress()
74
74
  // The touch in the trigger's own coordinates: `locationX/Y` are relative to whichever child was touched.
75
75
  trigger.current?.measure((_x, _y, _width, _height, left, top) => {
76
76
  setAnchor({x: pageX - left, y: pageY - top})
@@ -8,7 +8,15 @@ import {useSx} from '@hzblj/kara-sx'
8
8
  import {useCallback, useMemo} from 'react'
9
9
  import type {ContextMenuComponent, ContextMenuExclusionProps, ContextMenuProps} from './context-menu.types'
10
10
 
11
- const ContextMenuRoot = ({actions, children, onPreviewPress, preview, testID, ...sx}: ContextMenuProps) => {
11
+ const ContextMenuRoot = ({
12
+ actions,
13
+ children,
14
+ onPreviewPress,
15
+ preview,
16
+ previewTransition,
17
+ testID,
18
+ ...sx
19
+ }: ContextMenuProps) => {
12
20
  const style = useSx(sx)
13
21
 
14
22
  /** The native side hands back an id; the index is the one identity every action has. */
@@ -31,6 +39,7 @@ const ContextMenuRoot = ({actions, children, onPreviewPress, preview, testID, ..
31
39
  onPreviewPress={onPreviewPress}
32
40
  onSelectItem={onSelectItem}
33
41
  preview={preview}
42
+ previewTransition={previewTransition}
34
43
  style={style}
35
44
  testID={testID}
36
45
  >
@@ -21,6 +21,14 @@ export type ContextMenuProps = ResponsiveViewSx & {
21
21
  * every preview up front.
22
22
  */
23
23
  preview?: ReactNode
24
+ /**
25
+ * iOS: how the preview leaves once tapped, before `onPreviewPress`. `zoom`: its photo grows straight into the pushed
26
+ * screen, the way the App Store expands a preview — needs a `ZoomTransition.Source` (`@hzblj/kara-ui/router`) in the
27
+ * trigger, a `preview` and iOS 18, and shrinks back like `dismiss` otherwise. `pop`: UIKit's default, the preview
28
+ * flies out. `dismiss`: it shrinks back onto the trigger first. Default: `zoom` when the trigger holds a
29
+ * `ZoomTransition.Source`, `pop` otherwise.
30
+ */
31
+ previewTransition?: 'dismiss' | 'pop' | 'zoom'
24
32
  testID?: string
25
33
  }
26
34