@bearmenu/ui 0.8.15 → 0.8.16

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 (36) hide show
  1. package/README.md +1 -0
  2. package/dist/{chunk-5FCC5L5F.js → chunk-36KEHCET.js} +14 -2
  3. package/dist/{chunk-V4E333QS.js → chunk-5YNIQ2BL.js} +7 -29
  4. package/dist/chunk-RIH2IS7X.js +29 -0
  5. package/dist/components/alert-dialog.js +3 -2
  6. package/dist/components/button.d.ts +4 -0
  7. package/dist/components/button.js +3 -2
  8. package/dist/components/calendar.js +3 -2
  9. package/dist/components/carousel.js +3 -2
  10. package/dist/components/combobox.js +3 -2
  11. package/dist/components/date-input.d.ts +1 -1
  12. package/dist/components/empty-state.js +3 -2
  13. package/dist/components/event-detail-panel.js +3 -2
  14. package/dist/components/event-list-row-wide.js +3 -2
  15. package/dist/components/haptic-switch.d.ts +35 -0
  16. package/dist/components/haptic-switch.js +3 -0
  17. package/dist/components/kitchen-dossier.js +1 -1
  18. package/dist/components/link-button.js +3 -2
  19. package/dist/components/menu-family-block.js +3 -2
  20. package/dist/components/menu-index-list.js +3 -2
  21. package/dist/components/multi-select-combobox.js +3 -2
  22. package/dist/components/pagination.js +3 -2
  23. package/dist/components/place-card.js +1 -1
  24. package/dist/components/searchable-select.js +3 -2
  25. package/dist/components/sliding-panels.js +3 -2
  26. package/dist/components/switch.d.ts +7 -1
  27. package/dist/components/switch.js +33 -24
  28. package/dist/hooks/use-haptic.d.ts +9 -1
  29. package/dist/hooks/use-haptic.js +1 -1
  30. package/package.json +1 -1
  31. package/src/components/button.tsx +25 -1
  32. package/src/components/haptic-switch.test.tsx +134 -0
  33. package/src/components/haptic-switch.tsx +62 -0
  34. package/src/components/switch.tsx +29 -18
  35. package/src/hooks/use-haptic.test.tsx +54 -59
  36. package/src/hooks/use-haptic.ts +18 -44
package/README.md CHANGED
@@ -48,6 +48,7 @@ import { Input } from "@bearmenu/ui/components/input";
48
48
  | Dropdown Menu | `@bearmenu/ui/components/dropdown-menu` |
49
49
  | Empty State | `@bearmenu/ui/components/empty-state` |
50
50
  | Form | `@bearmenu/ui/components/form` |
51
+ | Haptic Switch | `@bearmenu/ui/components/haptic-switch` |
51
52
  | Input | `@bearmenu/ui/components/input` |
52
53
  | Input OTP | `@bearmenu/ui/components/input-otp` |
53
54
  | Label | `@bearmenu/ui/components/label` |
@@ -1,4 +1,5 @@
1
- import { useHaptic } from './chunk-V4E333QS.js';
1
+ import { HapticSwitch, isHapticSwitchTarget } from './chunk-RIH2IS7X.js';
2
+ import { useHaptic } from './chunk-5YNIQ2BL.js';
2
3
  import { cn } from './chunk-FEK5XGZW.js';
3
4
  import { __objRest, __spreadProps, __spreadValues } from './chunk-MMUBH76A.js';
4
5
  import * as React from 'react';
@@ -82,6 +83,7 @@ var Button = React.forwardRef(
82
83
  hapticStyle = "light",
83
84
  isLoading = false,
84
85
  disabled,
86
+ type,
85
87
  onClick,
86
88
  children
87
89
  } = _b, props = __objRest(_b, [
@@ -94,27 +96,34 @@ var Button = React.forwardRef(
94
96
  "hapticStyle",
95
97
  "isLoading",
96
98
  "disabled",
99
+ "type",
97
100
  "onClick",
98
101
  "children"
99
102
  ]);
100
103
  const Comp = asChild ? Slot : "button";
101
104
  const { trigger } = useHaptic();
102
105
  const [isMounted, setIsMounted] = React.useState(false);
106
+ const withSwitch = haptic && !asChild;
103
107
  React.useEffect(() => {
104
108
  setIsMounted(true);
105
109
  }, []);
106
110
  const handleClick = (event) => {
111
+ var _a2;
107
112
  if (haptic && isMounted) {
108
113
  trigger(hapticStyle);
109
114
  }
110
115
  onClick == null ? void 0 : onClick(event);
116
+ if (withSwitch && type === "submit" && !event.defaultPrevented && isHapticSwitchTarget(event.target)) {
117
+ (_a2 = event.currentTarget.form) == null ? void 0 : _a2.requestSubmit(event.currentTarget);
118
+ }
111
119
  };
112
120
  return /* @__PURE__ */ jsx(
113
121
  Comp,
114
122
  __spreadProps(__spreadValues({
115
123
  "data-slot": "button",
116
- className: cn(buttonVariants({ variant, size, fullWidth, className })),
124
+ className: cn(buttonVariants({ variant, size, fullWidth, className }), withSwitch && "relative"),
117
125
  ref,
126
+ type,
118
127
  onClick: handleClick,
119
128
  disabled: disabled || isLoading,
120
129
  "aria-busy": isLoading || void 0
@@ -122,6 +131,9 @@ var Button = React.forwardRef(
122
131
  children: isLoading ? /* @__PURE__ */ jsxs(Fragment, { children: [
123
132
  /* @__PURE__ */ jsx(Loader2, { className: "animate-spin", "aria-hidden": "true" }),
124
133
  !asChild && children
134
+ ] }) : withSwitch ? /* @__PURE__ */ jsxs(Fragment, { children: [
135
+ children,
136
+ /* @__PURE__ */ jsx(HapticSwitch, { disabled })
125
137
  ] }) : children
126
138
  })
127
139
  );
@@ -1,14 +1,17 @@
1
1
  import { useState, useEffect, useCallback } from 'react';
2
2
 
3
+ function isIos() {
4
+ if (typeof navigator === "undefined") return false;
5
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
6
+ }
3
7
  var useHaptic = () => {
4
8
  const [platform, setPlatform] = useState("unknown");
5
9
  const [isSupported, setIsSupported] = useState(false);
6
10
  useEffect(() => {
7
11
  var _a, _b;
8
12
  const userAgent = navigator.userAgent.toLowerCase();
9
- const isIOS = /iphone|ipad|ipod/.test(userAgent);
10
13
  const isAndroid = /android/.test(userAgent);
11
- if (isIOS) {
14
+ if (isIos()) {
12
15
  setPlatform("ios");
13
16
  } else if (isAndroid) {
14
17
  setPlatform("android");
@@ -17,7 +20,7 @@ var useHaptic = () => {
17
20
  }
18
21
  const hasVibration = "vibrate" in navigator;
19
22
  const hasTelegram = typeof window !== "undefined" && ((_b = (_a = window.Telegram) == null ? void 0 : _a.WebApp) == null ? void 0 : _b.HapticFeedback);
20
- setIsSupported(hasVibration || !!hasTelegram || supportsSwitchHaptic());
23
+ setIsSupported(hasVibration || !!hasTelegram || isIos());
21
24
  }, []);
22
25
  const trigger = useCallback((style) => {
23
26
  var _a, _b;
@@ -29,10 +32,6 @@ var useHaptic = () => {
29
32
  triggerVibrationAPI(style);
30
33
  return;
31
34
  }
32
- if (supportsSwitchHaptic()) {
33
- triggerSwitchHaptic();
34
- return;
35
- }
36
35
  console.debug("Haptic feedback not supported on this device");
37
36
  }, []);
38
37
  return { isSupported, platform, trigger };
@@ -78,26 +77,5 @@ function triggerVibrationAPI(style) {
78
77
  };
79
78
  navigator.vibrate(patterns[style]);
80
79
  }
81
- var hapticSwitch = null;
82
- function supportsSwitchHaptic() {
83
- return typeof HTMLInputElement !== "undefined" && "switch" in HTMLInputElement.prototype;
84
- }
85
- function getHapticSwitch() {
86
- if (hapticSwitch == null ? void 0 : hapticSwitch.isConnected) return hapticSwitch;
87
- const label = document.createElement("label");
88
- const input = document.createElement("input");
89
- input.type = "checkbox";
90
- input.setAttribute("switch", "");
91
- input.tabIndex = -1;
92
- label.setAttribute("aria-hidden", "true");
93
- label.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none";
94
- label.appendChild(input);
95
- document.body.appendChild(label);
96
- hapticSwitch = label;
97
- return label;
98
- }
99
- function triggerSwitchHaptic() {
100
- getHapticSwitch().click();
101
- }
102
80
 
103
- export { useHaptic };
81
+ export { isIos, useHaptic };
@@ -0,0 +1,29 @@
1
+ import { isIos } from './chunk-5YNIQ2BL.js';
2
+ import * as React from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ var HAPTIC_SWITCH_SLOT = "haptic-switch";
6
+ function isHapticSwitchTarget(target) {
7
+ return target instanceof HTMLElement && target.dataset.slot === HAPTIC_SWITCH_SLOT;
8
+ }
9
+ function HapticSwitch({ disabled = false }) {
10
+ const [enabled, setEnabled] = React.useState(false);
11
+ React.useEffect(() => {
12
+ setEnabled(isIos());
13
+ }, []);
14
+ if (!enabled) return null;
15
+ return /* @__PURE__ */ jsx(
16
+ "input",
17
+ {
18
+ type: "checkbox",
19
+ ref: (el) => el == null ? void 0 : el.setAttribute("switch", ""),
20
+ "data-slot": HAPTIC_SWITCH_SLOT,
21
+ "aria-hidden": "true",
22
+ tabIndex: -1,
23
+ disabled,
24
+ className: "absolute inset-0 m-0 h-full w-full cursor-[inherit] rounded-[inherit] opacity-0 touch-manipulation [-webkit-tap-highlight-color:transparent]"
25
+ }
26
+ );
27
+ }
28
+
29
+ export { HAPTIC_SWITCH_SLOT, HapticSwitch, isHapticSwitchTarget };
@@ -1,5 +1,6 @@
1
- import { buttonVariants } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { buttonVariants } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import { cn } from '../chunk-FEK5XGZW.js';
4
5
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
5
6
  import * as React from 'react';
@@ -12,6 +12,10 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, Var
12
12
  /**
13
13
  * Off by default. Haptics are for actions that change state — a save, a
14
14
  * toggle, a destructive confirm — not for every button, so each site opts in.
15
+ *
16
+ * Android vibrates through `useHaptic`; iOS ticks through a `HapticSwitch`
17
+ * laid over the button, which needs a real element, so `asChild` gets the
18
+ * vibration only.
15
19
  */
16
20
  haptic?: boolean;
17
21
  hapticStyle?: "light" | "medium" | "heavy";
@@ -1,4 +1,5 @@
1
- export { Button, buttonVariants } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ export { Button, buttonVariants } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import '../chunk-FEK5XGZW.js';
4
5
  import '../chunk-MMUBH76A.js';
@@ -1,5 +1,6 @@
1
- import { buttonVariants, Button } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { buttonVariants, Button } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import { cn } from '../chunk-FEK5XGZW.js';
4
5
  import { __objRest, __spreadValues, __spreadProps } from '../chunk-MMUBH76A.js';
5
6
  import * as React from 'react';
@@ -1,5 +1,6 @@
1
- import { Button } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { Button } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import { cn } from '../chunk-FEK5XGZW.js';
4
5
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
5
6
  import * as React from 'react';
@@ -1,8 +1,9 @@
1
1
  import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
2
2
  import { Command, CommandInput, CommandList, CommandLoading, CommandEmpty, CommandGroup, CommandItem } from '../chunk-MCOHLWRS.js';
3
3
  import '../chunk-PDTPO6NG.js';
4
- import { Button } from '../chunk-5FCC5L5F.js';
5
- import '../chunk-V4E333QS.js';
4
+ import { Button } from '../chunk-36KEHCET.js';
5
+ import '../chunk-RIH2IS7X.js';
6
+ import '../chunk-5YNIQ2BL.js';
6
7
  import { cn } from '../chunk-FEK5XGZW.js';
7
8
  import '../chunk-MMUBH76A.js';
8
9
  import * as React from 'react';
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
 
3
- declare const DateInput: React.ForwardRefExoticComponent<Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "type"> & {
3
+ declare const DateInput: React.ForwardRefExoticComponent<Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> & {
4
4
  /** Native input type. Defaults to "date". */
5
5
  type?: "date" | "datetime-local" | "time" | "month" | "week";
6
6
  size?: "sm" | "default" | "lg";
@@ -1,5 +1,6 @@
1
- import { Button } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { Button } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import { Text } from '../chunk-ZLMRVBD7.js';
4
5
  import { cn } from '../chunk-FEK5XGZW.js';
5
6
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
@@ -2,8 +2,9 @@ import { resolveEventEntryFee } from '../chunk-DNSXMHYX.js';
2
2
  import { Separator } from '../chunk-NWZGSLPU.js';
3
3
  import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
4
4
  import { Skeleton } from '../chunk-XBBEX4SF.js';
5
- import { Button } from '../chunk-5FCC5L5F.js';
6
- import '../chunk-V4E333QS.js';
5
+ import { Button } from '../chunk-36KEHCET.js';
6
+ import '../chunk-RIH2IS7X.js';
7
+ import '../chunk-5YNIQ2BL.js';
7
8
  import { cn } from '../chunk-FEK5XGZW.js';
8
9
  import { __spreadProps, __spreadValues, __objRest } from '../chunk-MMUBH76A.js';
9
10
  import * as React from 'react';
@@ -3,8 +3,9 @@ import { resolveEventEntryFee } from '../chunk-DNSXMHYX.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { EventPosterFallback } from '../chunk-FDITZ2VM.js';
5
5
  import { Skeleton } from '../chunk-XBBEX4SF.js';
6
- import { buttonVariants } from '../chunk-5FCC5L5F.js';
7
- import '../chunk-V4E333QS.js';
6
+ import { buttonVariants } from '../chunk-36KEHCET.js';
7
+ import '../chunk-RIH2IS7X.js';
8
+ import '../chunk-5YNIQ2BL.js';
8
9
  import { cn } from '../chunk-FEK5XGZW.js';
9
10
  import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
10
11
  import { memo, useState } from 'react';
@@ -0,0 +1,35 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ declare const HAPTIC_SWITCH_SLOT = "haptic-switch";
4
+ /** True when a click's target is the overlay switch rather than the host. */
5
+ declare function isHapticSwitchTarget(target: EventTarget | null): boolean;
6
+ type HapticSwitchProps = {
7
+ /** Mirror the host's disabled state; a disabled switch neither flips nor ticks. */
8
+ disabled?: boolean;
9
+ };
10
+ /**
11
+ * The only haptic iOS Safari has left.
12
+ *
13
+ * WebKit plays the system toggle haptic when an `<input type="checkbox" switch>`
14
+ * flips under the user's finger, and since iOS 26.5 under the finger is the
15
+ * only way — a programmatic click on the input or its label no longer counts
16
+ * (WebKit #65327). So the switch goes INSIDE the control, stretched over it and
17
+ * invisible, and the tap that lands on the control lands on the switch first.
18
+ * The click then bubbles to the host's own handler as usual.
19
+ *
20
+ * What the host must do:
21
+ * - be positioned (`relative` at least) so `inset-0` fills it;
22
+ * - not call `preventDefault()` on the click — that cancels the toggle, and
23
+ * the toggle is the haptic;
24
+ * - if it is `type="submit"`, submit explicitly: the click target is the
25
+ * switch, so the button's implicit submission never runs. `Button` does
26
+ * this through `form.requestSubmit()`.
27
+ *
28
+ * Renders nothing on the server and on non-iOS clients (so the first client
29
+ * render matches SSR). Keyboard and assistive-tech activation land on the host,
30
+ * not the switch, and stay silent. `opacity: 0`, never `display: none` — a
31
+ * switch that is not rendered does not tick.
32
+ */
33
+ declare function HapticSwitch({ disabled }: HapticSwitchProps): react_jsx_runtime.JSX.Element | null;
34
+
35
+ export { HAPTIC_SWITCH_SLOT, HapticSwitch, isHapticSwitchTarget };
@@ -0,0 +1,3 @@
1
+ export { HAPTIC_SWITCH_SLOT, HapticSwitch, isHapticSwitchTarget } from '../chunk-RIH2IS7X.js';
2
+ import '../chunk-5YNIQ2BL.js';
3
+ import '../chunk-MMUBH76A.js';
@@ -1,5 +1,5 @@
1
- import { VenueQuotes } from '../chunk-43RC7MHY.js';
2
1
  import { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-ZQ53AGML.js';
2
+ import { VenueQuotes } from '../chunk-43RC7MHY.js';
3
3
  import { StoryProvider, StorySegments, StoryPanel } from '../chunk-F6FMZJDG.js';
4
4
  import { GlassGround } from '../chunk-ICOU5QHT.js';
5
5
  import { Separator } from '../chunk-NWZGSLPU.js';
@@ -1,5 +1,6 @@
1
- import { Button } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { Button } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import '../chunk-FEK5XGZW.js';
4
5
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
5
6
  import * as React from 'react';
@@ -3,8 +3,9 @@ import { HueChip } from '../chunk-CLLY6XB3.js';
3
3
  import { Separator } from '../chunk-NWZGSLPU.js';
4
4
  import { PhotoGround, PHOTO_GROUND } from '../chunk-CNJQUHCU.js';
5
5
  import { Skeleton } from '../chunk-XBBEX4SF.js';
6
- import { buttonVariants } from '../chunk-5FCC5L5F.js';
7
- import '../chunk-V4E333QS.js';
6
+ import { buttonVariants } from '../chunk-36KEHCET.js';
7
+ import '../chunk-RIH2IS7X.js';
8
+ import '../chunk-5YNIQ2BL.js';
8
9
  import { cn } from '../chunk-FEK5XGZW.js';
9
10
  import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
10
11
  import { Wallet } from 'lucide-react';
@@ -1,7 +1,8 @@
1
1
  import { Separator } from '../chunk-NWZGSLPU.js';
2
2
  import { Skeleton } from '../chunk-XBBEX4SF.js';
3
- import { Button } from '../chunk-5FCC5L5F.js';
4
- import '../chunk-V4E333QS.js';
3
+ import { Button } from '../chunk-36KEHCET.js';
4
+ import '../chunk-RIH2IS7X.js';
5
+ import '../chunk-5YNIQ2BL.js';
5
6
  import { cn } from '../chunk-FEK5XGZW.js';
6
7
  import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
7
8
  import { useState } from 'react';
@@ -2,8 +2,9 @@ import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
2
2
  import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-MCOHLWRS.js';
3
3
  import '../chunk-PDTPO6NG.js';
4
4
  import { Checkbox } from '../chunk-NMEVJT5Q.js';
5
- import { Button } from '../chunk-5FCC5L5F.js';
6
- import '../chunk-V4E333QS.js';
5
+ import { Button } from '../chunk-36KEHCET.js';
6
+ import '../chunk-RIH2IS7X.js';
7
+ import '../chunk-5YNIQ2BL.js';
7
8
  import { Badge } from '../chunk-P43B3ONY.js';
8
9
  import { cn } from '../chunk-FEK5XGZW.js';
9
10
  import '../chunk-MMUBH76A.js';
@@ -1,5 +1,6 @@
1
- import { buttonVariants } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { buttonVariants } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import { cn } from '../chunk-FEK5XGZW.js';
4
5
  import { __objRest, __spreadValues, __spreadProps } from '../chunk-MMUBH76A.js';
5
6
  import { ChevronsLeftIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsRightIcon, MoreHorizontalIcon } from 'lucide-react';
@@ -1,6 +1,6 @@
1
+ import { OVERLAY_PILL } from '../chunk-EZG2NELX.js';
1
2
  import { HOVER_IMAGE_MOTION, HOVER_IMAGE_WRAPPER, HOVER_MOTION, HOVER_LIFT_GRID_GROUP } from '../chunk-XCB3F6SF.js';
2
3
  import { NEUTRAL_ICON_FILTER } from '../chunk-LQWGZIYA.js';
3
- import { OVERLAY_PILL } from '../chunk-EZG2NELX.js';
4
4
  import { GradeAtom } from '../chunk-DJD3EMPK.js';
5
5
  import { showsGradeAtom } from '../chunk-I5V446BX.js';
6
6
  import { Separator } from '../chunk-NWZGSLPU.js';
@@ -1,8 +1,9 @@
1
1
  import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
2
2
  import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-MCOHLWRS.js';
3
3
  import '../chunk-PDTPO6NG.js';
4
- import { Button } from '../chunk-5FCC5L5F.js';
5
- import '../chunk-V4E333QS.js';
4
+ import { Button } from '../chunk-36KEHCET.js';
5
+ import '../chunk-RIH2IS7X.js';
6
+ import '../chunk-5YNIQ2BL.js';
6
7
  import { cn } from '../chunk-FEK5XGZW.js';
7
8
  import '../chunk-MMUBH76A.js';
8
9
  import * as React from 'react';
@@ -1,5 +1,6 @@
1
- import { Button } from '../chunk-5FCC5L5F.js';
2
- import '../chunk-V4E333QS.js';
1
+ import { Button } from '../chunk-36KEHCET.js';
2
+ import '../chunk-RIH2IS7X.js';
3
+ import '../chunk-5YNIQ2BL.js';
3
4
  import { cn } from '../chunk-FEK5XGZW.js';
4
5
  import '../chunk-MMUBH76A.js';
5
6
  import * as React from 'react';
@@ -1,6 +1,12 @@
1
1
  import * as React from 'react';
2
2
  import * as SwitchPrimitive from '@radix-ui/react-switch';
3
3
 
4
- declare const Switch: React.ForwardRefExoticComponent<Omit<SwitchPrimitive.SwitchProps & React.RefAttributes<HTMLButtonElement>, "ref"> & React.RefAttributes<HTMLButtonElement>>;
4
+ declare const Switch: React.ForwardRefExoticComponent<Omit<SwitchPrimitive.SwitchProps & React.RefAttributes<HTMLButtonElement>, "ref"> & {
5
+ /**
6
+ * Tick on iOS through a `HapticSwitch` laid over the control. Off by default;
7
+ * a switch that writes a persisted setting opts in.
8
+ */
9
+ haptic?: boolean;
10
+ } & React.RefAttributes<HTMLButtonElement>>;
5
11
 
6
12
  export { Switch };
@@ -1,32 +1,41 @@
1
+ import { HapticSwitch } from '../chunk-RIH2IS7X.js';
2
+ import '../chunk-5YNIQ2BL.js';
1
3
  import { cn } from '../chunk-FEK5XGZW.js';
2
4
  import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
3
5
  import * as React from 'react';
4
6
  import * as SwitchPrimitive from '@radix-ui/react-switch';
5
- import { jsx } from 'react/jsx-runtime';
7
+ import { jsxs, jsx } from 'react/jsx-runtime';
6
8
 
7
- var Switch = React.forwardRef((_a, ref) => {
8
- var _b = _a, { className } = _b, props = __objRest(_b, ["className"]);
9
- return /* @__PURE__ */ jsx(
10
- SwitchPrimitive.Root,
11
- __spreadProps(__spreadValues({
12
- ref,
13
- "data-slot": "switch",
14
- className: cn(
15
- "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
16
- className
17
- )
18
- }, props), {
19
- children: /* @__PURE__ */ jsx(
20
- SwitchPrimitive.Thumb,
21
- {
22
- className: cn(
23
- "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
24
- )
25
- }
26
- )
27
- })
28
- );
29
- });
9
+ var Switch = React.forwardRef(
10
+ (_a, ref) => {
11
+ var _b = _a, { className, haptic = false, disabled } = _b, props = __objRest(_b, ["className", "haptic", "disabled"]);
12
+ return /* @__PURE__ */ jsxs(
13
+ SwitchPrimitive.Root,
14
+ __spreadProps(__spreadValues({
15
+ ref,
16
+ "data-slot": "switch",
17
+ disabled,
18
+ className: cn(
19
+ "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
20
+ haptic && "relative",
21
+ className
22
+ )
23
+ }, props), {
24
+ children: [
25
+ /* @__PURE__ */ jsx(
26
+ SwitchPrimitive.Thumb,
27
+ {
28
+ className: cn(
29
+ "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
30
+ )
31
+ }
32
+ ),
33
+ haptic && /* @__PURE__ */ jsx(HapticSwitch, { disabled })
34
+ ]
35
+ })
36
+ );
37
+ }
38
+ );
30
39
  Switch.displayName = SwitchPrimitive.Root.displayName;
31
40
 
32
41
  export { Switch };
@@ -4,6 +4,14 @@ interface HapticAPI {
4
4
  platform: "ios" | "android" | "desktop" | "unknown";
5
5
  trigger: (style: HapticStyle) => void;
6
6
  }
7
+ /**
8
+ * iOS Safari, including iPadOS masquerading as a Mac. Haptics there come from
9
+ * `HapticSwitch`, not from `trigger`: WebKit has no Vibration API, and since
10
+ * iOS 26.5 the only haptic it plays is a native switch flipping under the
11
+ * user's own finger — a programmatic click on the switch or its label no
12
+ * longer counts (WebKit #65327).
13
+ */
14
+ declare function isIos(): boolean;
7
15
  declare const useHaptic: () => HapticAPI;
8
16
  declare global {
9
17
  interface Window {
@@ -19,4 +27,4 @@ declare global {
19
27
  }
20
28
  }
21
29
 
22
- export { useHaptic };
30
+ export { isIos, useHaptic };
@@ -1,2 +1,2 @@
1
- export { useHaptic } from '../chunk-V4E333QS.js';
1
+ export { isIos, useHaptic } from '../chunk-5YNIQ2BL.js';
2
2
  import '../chunk-MMUBH76A.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.15",
3
+ "version": "0.8.16",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { cn } from "../lib/utils";
6
6
  import { useHaptic } from "../hooks/use-haptic";
7
+ import { HapticSwitch, isHapticSwitchTarget } from "./haptic-switch";
7
8
 
8
9
  const buttonVariants = cva(
9
10
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer [@media(pointer:coarse)]:active:scale-95",
@@ -82,6 +83,10 @@ export interface ButtonProps
82
83
  /**
83
84
  * Off by default. Haptics are for actions that change state — a save, a
84
85
  * toggle, a destructive confirm — not for every button, so each site opts in.
86
+ *
87
+ * Android vibrates through `useHaptic`; iOS ticks through a `HapticSwitch`
88
+ * laid over the button, which needs a real element, so `asChild` gets the
89
+ * vibration only.
85
90
  */
86
91
  haptic?: boolean;
87
92
  hapticStyle?: "light" | "medium" | "heavy";
@@ -100,6 +105,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
100
105
  hapticStyle = "light",
101
106
  isLoading = false,
102
107
  disabled,
108
+ type,
103
109
  onClick,
104
110
  children,
105
111
  ...props
@@ -109,6 +115,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
109
115
  const Comp = asChild ? Slot : "button";
110
116
  const { trigger } = useHaptic();
111
117
  const [isMounted, setIsMounted] = React.useState(false);
118
+ const withSwitch = haptic && !asChild;
112
119
 
113
120
  React.useEffect(() => {
114
121
  setIsMounted(true);
@@ -119,13 +126,25 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
119
126
  trigger(hapticStyle);
120
127
  }
121
128
  onClick?.(event);
129
+ // On iOS the tap lands on the overlay switch, so the button's own
130
+ // implicit submission never runs; forward it once the handler has had
131
+ // its say.
132
+ if (
133
+ withSwitch &&
134
+ type === "submit" &&
135
+ !event.defaultPrevented &&
136
+ isHapticSwitchTarget(event.target)
137
+ ) {
138
+ event.currentTarget.form?.requestSubmit(event.currentTarget);
139
+ }
122
140
  };
123
141
 
124
142
  return (
125
143
  <Comp
126
144
  data-slot="button"
127
- className={cn(buttonVariants({ variant, size, fullWidth, className }))}
145
+ className={cn(buttonVariants({ variant, size, fullWidth, className }), withSwitch && "relative")}
128
146
  ref={ref}
147
+ type={type}
129
148
  onClick={handleClick}
130
149
  disabled={disabled || isLoading}
131
150
  aria-busy={isLoading || undefined}
@@ -136,6 +155,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
136
155
  <Loader2 className="animate-spin" aria-hidden="true" />
137
156
  {!asChild && children}
138
157
  </>
158
+ ) : withSwitch ? (
159
+ <>
160
+ {children}
161
+ <HapticSwitch disabled={disabled} />
162
+ </>
139
163
  ) : (
140
164
  children
141
165
  )}
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { HapticSwitch, isHapticSwitchTarget } from "./haptic-switch";
5
+ import { Button } from "./button";
6
+ import { Switch } from "./switch";
7
+
8
+ const IOS_UA =
9
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1";
10
+
11
+ function pretendIos() {
12
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IOS_UA);
13
+ }
14
+
15
+ const overlay = () => document.querySelector<HTMLInputElement>('input[data-slot="haptic-switch"]');
16
+
17
+ describe("HapticSwitch", () => {
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ it("renders nothing off iOS", () => {
23
+ render(<HapticSwitch />);
24
+ expect(overlay()).toBeNull();
25
+ });
26
+
27
+ it("renders an invisible native switch on iOS, out of the tab order and the a11y tree", () => {
28
+ pretendIos();
29
+ render(<HapticSwitch />);
30
+ const input = overlay();
31
+ expect(input).not.toBeNull();
32
+ expect(input?.type).toBe("checkbox");
33
+ expect(input?.hasAttribute("switch")).toBe(true);
34
+ expect(input?.getAttribute("aria-hidden")).toBe("true");
35
+ expect(input?.tabIndex).toBe(-1);
36
+ expect(input?.className).toContain("opacity-0");
37
+ });
38
+
39
+ it("mirrors the host's disabled state", () => {
40
+ pretendIos();
41
+ render(<HapticSwitch disabled />);
42
+ expect(overlay()?.disabled).toBe(true);
43
+ });
44
+
45
+ it("identifies its own click target", () => {
46
+ pretendIos();
47
+ render(<HapticSwitch />);
48
+ expect(isHapticSwitchTarget(overlay())).toBe(true);
49
+ expect(isHapticSwitchTarget(document.body)).toBe(false);
50
+ expect(isHapticSwitchTarget(null)).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe("Button haptic on iOS", () => {
55
+ afterEach(() => {
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ it("lays the switch over the button and still runs onClick when the switch is tapped", async () => {
60
+ pretendIos();
61
+ const onClick = vi.fn();
62
+ render(
63
+ <Button haptic onClick={onClick}>
64
+ Save
65
+ </Button>
66
+ );
67
+ const button = screen.getByRole("button", { name: "Save" });
68
+ expect(button.className).toContain("relative");
69
+ const input = overlay();
70
+ expect(input?.parentElement).toBe(button);
71
+
72
+ await userEvent.click(input!);
73
+ expect(onClick).toHaveBeenCalledTimes(1);
74
+ expect(input?.checked).toBe(true);
75
+ });
76
+
77
+ it("does not render the switch without haptic, or with asChild", () => {
78
+ pretendIos();
79
+ const { rerender } = render(<Button>Plain</Button>);
80
+ expect(overlay()).toBeNull();
81
+ rerender(
82
+ <Button haptic asChild>
83
+ <a href="/x">Link</a>
84
+ </Button>
85
+ );
86
+ expect(overlay()).toBeNull();
87
+ });
88
+
89
+ it("forwards a submit tapped through the switch to the form", async () => {
90
+ pretendIos();
91
+ const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
92
+ render(
93
+ <form onSubmit={onSubmit}>
94
+ <Button type="submit" haptic>
95
+ Send
96
+ </Button>
97
+ </form>
98
+ );
99
+ await userEvent.click(overlay()!);
100
+ expect(onSubmit).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it("does not forward the submit when the click handler cancelled it", async () => {
104
+ pretendIos();
105
+ const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
106
+ render(
107
+ <form onSubmit={onSubmit}>
108
+ <Button type="submit" haptic onClick={(e) => e.preventDefault()}>
109
+ Send
110
+ </Button>
111
+ </form>
112
+ );
113
+ await userEvent.click(overlay()!);
114
+ expect(onSubmit).not.toHaveBeenCalled();
115
+ });
116
+ });
117
+
118
+ describe("Switch haptic on iOS", () => {
119
+ afterEach(() => {
120
+ vi.restoreAllMocks();
121
+ });
122
+
123
+ it("toggles through a tap on the overlay", async () => {
124
+ pretendIos();
125
+ const onCheckedChange = vi.fn();
126
+ render(<Switch haptic aria-label="Precise" onCheckedChange={onCheckedChange} />);
127
+ const root = screen.getByRole("switch", { name: "Precise" });
128
+ expect(root.className).toContain("relative");
129
+ expect(overlay()?.parentElement).toBe(root);
130
+
131
+ await userEvent.click(overlay()!);
132
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
133
+ });
134
+ });
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { isIos } from "../hooks/use-haptic";
5
+
6
+ export const HAPTIC_SWITCH_SLOT = "haptic-switch";
7
+
8
+ /** True when a click's target is the overlay switch rather than the host. */
9
+ export function isHapticSwitchTarget(target: EventTarget | null): boolean {
10
+ return target instanceof HTMLElement && target.dataset.slot === HAPTIC_SWITCH_SLOT;
11
+ }
12
+
13
+ type HapticSwitchProps = {
14
+ /** Mirror the host's disabled state; a disabled switch neither flips nor ticks. */
15
+ disabled?: boolean;
16
+ };
17
+
18
+ /**
19
+ * The only haptic iOS Safari has left.
20
+ *
21
+ * WebKit plays the system toggle haptic when an `<input type="checkbox" switch>`
22
+ * flips under the user's finger, and since iOS 26.5 under the finger is the
23
+ * only way — a programmatic click on the input or its label no longer counts
24
+ * (WebKit #65327). So the switch goes INSIDE the control, stretched over it and
25
+ * invisible, and the tap that lands on the control lands on the switch first.
26
+ * The click then bubbles to the host's own handler as usual.
27
+ *
28
+ * What the host must do:
29
+ * - be positioned (`relative` at least) so `inset-0` fills it;
30
+ * - not call `preventDefault()` on the click — that cancels the toggle, and
31
+ * the toggle is the haptic;
32
+ * - if it is `type="submit"`, submit explicitly: the click target is the
33
+ * switch, so the button's implicit submission never runs. `Button` does
34
+ * this through `form.requestSubmit()`.
35
+ *
36
+ * Renders nothing on the server and on non-iOS clients (so the first client
37
+ * render matches SSR). Keyboard and assistive-tech activation land on the host,
38
+ * not the switch, and stay silent. `opacity: 0`, never `display: none` — a
39
+ * switch that is not rendered does not tick.
40
+ */
41
+ export function HapticSwitch({ disabled = false }: HapticSwitchProps) {
42
+ const [enabled, setEnabled] = React.useState(false);
43
+
44
+ React.useEffect(() => {
45
+ setEnabled(isIos());
46
+ }, []);
47
+
48
+ if (!enabled) return null;
49
+
50
+ return (
51
+ <input
52
+ type="checkbox"
53
+ // WebKit's non-standard attribute; React's input types do not know it.
54
+ ref={(el) => el?.setAttribute("switch", "")}
55
+ data-slot={HAPTIC_SWITCH_SLOT}
56
+ aria-hidden="true"
57
+ tabIndex={-1}
58
+ disabled={disabled}
59
+ className="absolute inset-0 m-0 h-full w-full cursor-[inherit] rounded-[inherit] opacity-0 touch-manipulation [-webkit-tap-highlight-color:transparent]"
60
+ />
61
+ );
62
+ }
@@ -3,27 +3,38 @@
3
3
  import * as React from "react";
4
4
  import * as SwitchPrimitive from "@radix-ui/react-switch";
5
5
  import { cn } from "../lib/utils";
6
+ import { HapticSwitch } from "./haptic-switch";
6
7
 
7
- const Switch = React.forwardRef<
8
- React.ElementRef<typeof SwitchPrimitive.Root>,
9
- React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
10
- >(({ className, ...props }, ref) => (
11
- <SwitchPrimitive.Root
12
- ref={ref}
13
- data-slot="switch"
14
- className={cn(
15
- "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
16
- className
17
- )}
18
- {...props}
19
- >
20
- <SwitchPrimitive.Thumb
8
+ type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> & {
9
+ /**
10
+ * Tick on iOS through a `HapticSwitch` laid over the control. Off by default;
11
+ * a switch that writes a persisted setting opts in.
12
+ */
13
+ haptic?: boolean;
14
+ };
15
+
16
+ const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitive.Root>, SwitchProps>(
17
+ ({ className, haptic = false, disabled, ...props }, ref) => (
18
+ <SwitchPrimitive.Root
19
+ ref={ref}
20
+ data-slot="switch"
21
+ disabled={disabled}
21
22
  className={cn(
22
- "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
23
+ "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
24
+ haptic && "relative",
25
+ className
23
26
  )}
24
- />
25
- </SwitchPrimitive.Root>
26
- ));
27
+ {...props}
28
+ >
29
+ <SwitchPrimitive.Thumb
30
+ className={cn(
31
+ "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
32
+ )}
33
+ />
34
+ {haptic && <HapticSwitch disabled={disabled} />}
35
+ </SwitchPrimitive.Root>
36
+ )
37
+ );
27
38
  Switch.displayName = SwitchPrimitive.Root.displayName;
28
39
 
29
40
  export { Switch };
@@ -1,80 +1,75 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { act, renderHook } from "@testing-library/react";
3
- import { useHaptic } from "./use-haptic";
3
+ import { isIos, useHaptic } from "./use-haptic";
4
4
 
5
- const hapticSwitch = () => document.body.querySelector<HTMLInputElement>("input[type=checkbox][switch]");
5
+ const IOS_UA =
6
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1";
7
+ const IPAD_AS_MAC_UA =
8
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15";
6
9
 
7
- describe("useHaptic", () => {
10
+ describe("isIos", () => {
8
11
  afterEach(() => {
9
12
  vi.restoreAllMocks();
10
13
  });
11
14
 
12
- describe("on iOS Safari (no Vibration API, switch supported)", () => {
13
- beforeEach(() => {
14
- Object.defineProperty(HTMLInputElement.prototype, "switch", { value: false, configurable: true });
15
- });
16
-
17
- afterEach(() => {
18
- Reflect.deleteProperty(HTMLInputElement.prototype, "switch");
19
- hapticSwitch()?.parentElement?.remove();
20
- });
21
-
22
- it("reports support", () => {
23
- const { result } = renderHook(() => useHaptic());
24
- expect(result.current.isSupported).toBe(true);
25
- });
15
+ it("is false in jsdom", () => {
16
+ expect(isIos()).toBe(false);
17
+ });
26
18
 
27
- it("toggles one hidden switch through its label on every trigger", () => {
28
- const { result } = renderHook(() => useHaptic());
19
+ it("matches an iPhone user agent", () => {
20
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IOS_UA);
21
+ expect(isIos()).toBe(true);
22
+ });
29
23
 
30
- act(() => result.current.trigger("light"));
31
- const input = hapticSwitch();
32
- expect(input).not.toBeNull();
33
- expect(input?.checked).toBe(true);
34
- expect(input?.parentElement?.getAttribute("aria-hidden")).toBe("true");
24
+ it("matches an iPad presenting as a Mac by its touch points", () => {
25
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IPAD_AS_MAC_UA);
26
+ vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
27
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
28
+ expect(isIos()).toBe(true);
29
+ Reflect.deleteProperty(navigator, "maxTouchPoints");
30
+ });
31
+ });
35
32
 
36
- act(() => result.current.trigger("light"));
37
- expect(document.body.querySelectorAll("input[switch]")).toHaveLength(1);
38
- expect(input?.checked).toBe(false);
39
- });
33
+ describe("useHaptic", () => {
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ Reflect.deleteProperty(navigator, "vibrate");
37
+ });
40
38
 
41
- it("collapses every style to the one tick WebKit has", () => {
42
- const { result } = renderHook(() => useHaptic());
39
+ it("reports support on iOS without adding anything to the DOM the switch lives in the control", () => {
40
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IOS_UA);
41
+ const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
42
+ const { result } = renderHook(() => useHaptic());
43
43
 
44
- act(() => result.current.trigger("heavy"));
45
- expect(hapticSwitch()?.checked).toBe(true);
46
- act(() => result.current.trigger("success"));
47
- expect(hapticSwitch()?.checked).toBe(false);
48
- });
44
+ expect(result.current.isSupported).toBe(true);
45
+ expect(result.current.platform).toBe("ios");
46
+ act(() => result.current.trigger("light"));
47
+ expect(document.body.querySelector("input[switch]")).toBeNull();
48
+ expect(debug).toHaveBeenCalled();
49
49
  });
50
50
 
51
- describe("without any haptic API", () => {
52
- it("reports no support and renders no switch", () => {
53
- const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
54
- const { result } = renderHook(() => useHaptic());
51
+ it("reports no support without any haptic API", () => {
52
+ const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
53
+ const { result } = renderHook(() => useHaptic());
55
54
 
56
- expect(result.current.isSupported).toBe(false);
57
- act(() => result.current.trigger("light"));
58
- expect(hapticSwitch()).toBeNull();
59
- expect(debug).toHaveBeenCalled();
60
- });
55
+ expect(result.current.isSupported).toBe(false);
56
+ act(() => result.current.trigger("light"));
57
+ expect(debug).toHaveBeenCalled();
61
58
  });
62
59
 
63
- describe("with the Vibration API", () => {
64
- afterEach(() => {
65
- Reflect.deleteProperty(navigator, "vibrate");
66
- });
60
+ it("vibrates through navigator.vibrate where it exists", () => {
61
+ const vibrate = vi.fn();
62
+ Object.defineProperty(navigator, "vibrate", { value: vibrate, configurable: true });
63
+ const { result } = renderHook(() => useHaptic());
67
64
 
68
- it("prefers navigator.vibrate over the switch", () => {
69
- const vibrate = vi.fn();
70
- Object.defineProperty(navigator, "vibrate", { value: vibrate, configurable: true });
71
- Object.defineProperty(HTMLInputElement.prototype, "switch", { value: false, configurable: true });
72
- const { result } = renderHook(() => useHaptic());
65
+ act(() => result.current.trigger("medium"));
66
+ expect(vibrate).toHaveBeenCalledWith(20);
67
+ });
73
68
 
74
- act(() => result.current.trigger("medium"));
75
- expect(vibrate).toHaveBeenCalledWith(20);
76
- expect(hapticSwitch()).toBeNull();
77
- Reflect.deleteProperty(HTMLInputElement.prototype, "switch");
78
- });
69
+ it("keeps trigger referentially stable across renders", () => {
70
+ const { result, rerender } = renderHook(() => useHaptic());
71
+ const first = result.current.trigger;
72
+ rerender();
73
+ expect(result.current.trigger).toBe(first);
79
74
  });
80
75
  });
@@ -10,6 +10,21 @@ interface HapticAPI {
10
10
  trigger: (style: HapticStyle) => void;
11
11
  }
12
12
 
13
+ /**
14
+ * iOS Safari, including iPadOS masquerading as a Mac. Haptics there come from
15
+ * `HapticSwitch`, not from `trigger`: WebKit has no Vibration API, and since
16
+ * iOS 26.5 the only haptic it plays is a native switch flipping under the
17
+ * user's own finger — a programmatic click on the switch or its label no
18
+ * longer counts (WebKit #65327).
19
+ */
20
+ export function isIos(): boolean {
21
+ if (typeof navigator === "undefined") return false;
22
+ return (
23
+ /iPad|iPhone|iPod/.test(navigator.userAgent) ||
24
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
25
+ );
26
+ }
27
+
13
28
  export const useHaptic = (): HapticAPI => {
14
29
  const [platform, setPlatform] = useState<"ios" | "android" | "desktop" | "unknown">("unknown");
15
30
  const [isSupported, setIsSupported] = useState(false);
@@ -17,10 +32,9 @@ export const useHaptic = (): HapticAPI => {
17
32
  useEffect(() => {
18
33
  // Detect platform
19
34
  const userAgent = navigator.userAgent.toLowerCase();
20
- const isIOS = /iphone|ipad|ipod/.test(userAgent);
21
35
  const isAndroid = /android/.test(userAgent);
22
36
 
23
- if (isIOS) {
37
+ if (isIos()) {
24
38
  setPlatform("ios");
25
39
  } else if (isAndroid) {
26
40
  setPlatform("android");
@@ -32,7 +46,7 @@ export const useHaptic = (): HapticAPI => {
32
46
  const hasVibration = "vibrate" in navigator;
33
47
  const hasTelegram = typeof window !== "undefined" && window.Telegram?.WebApp?.HapticFeedback;
34
48
 
35
- setIsSupported(hasVibration || !!hasTelegram || supportsSwitchHaptic());
49
+ setIsSupported(hasVibration || !!hasTelegram || isIos());
36
50
  }, []);
37
51
 
38
52
  const trigger = useCallback((style: HapticStyle) => {
@@ -48,12 +62,7 @@ export const useHaptic = (): HapticAPI => {
48
62
  return;
49
63
  }
50
64
 
51
- // iOS Safari has no Vibration API; the switch toggle is the only haptic it exposes
52
- if (supportsSwitchHaptic()) {
53
- triggerSwitchHaptic();
54
- return;
55
- }
56
-
65
+ // iOS: nothing to call see HapticSwitch, which sits inside the control
57
66
  // No haptic support - silently fail
58
67
  console.debug("Haptic feedback not supported on this device");
59
68
  }, []);
@@ -108,41 +117,6 @@ function triggerVibrationAPI(style: HapticStyle) {
108
117
  navigator.vibrate(patterns[style]);
109
118
  }
110
119
 
111
- // Switch implementation (iOS 18+ Safari and home-screen PWAs)
112
- //
113
- // WebKit plays the system "toggle" haptic when an `<input type="checkbox" switch>`
114
- // flips through its label, and a programmatic `label.click()` counts. One hidden
115
- // switch appended to the body doubles as a haptic trigger. It is the only haptic
116
- // WebKit exposes, so every style collapses to that one tick here.
117
- // https://github.com/ionic-team/ionic-framework/issues/29942
118
- let hapticSwitch: HTMLLabelElement | null = null;
119
-
120
- function supportsSwitchHaptic(): boolean {
121
- return typeof HTMLInputElement !== "undefined" && "switch" in HTMLInputElement.prototype;
122
- }
123
-
124
- function getHapticSwitch(): HTMLLabelElement {
125
- if (hapticSwitch?.isConnected) return hapticSwitch;
126
-
127
- const label = document.createElement("label");
128
- const input = document.createElement("input");
129
- input.type = "checkbox";
130
- input.setAttribute("switch", "");
131
- input.tabIndex = -1;
132
- label.setAttribute("aria-hidden", "true");
133
- label.style.cssText =
134
- "position:fixed;top:0;left:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none";
135
- label.appendChild(input);
136
- document.body.appendChild(label);
137
-
138
- hapticSwitch = label;
139
- return label;
140
- }
141
-
142
- function triggerSwitchHaptic() {
143
- getHapticSwitch().click();
144
- }
145
-
146
120
  // Extend Window interface for TypeScript
147
121
  declare global {
148
122
  interface Window {