@bearmenu/ui 0.8.14 → 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.
- package/README.md +1 -1
- package/dist/{chunk-F76NCVVR.js → chunk-36KEHCET.js} +15 -3
- package/dist/{chunk-R47NEOSI.js → chunk-5YNIQ2BL.js} +10 -7
- package/dist/chunk-RIH2IS7X.js +29 -0
- package/dist/components/alert-dialog.js +3 -2
- package/dist/components/button.d.ts +8 -0
- package/dist/components/button.js +3 -2
- package/dist/components/calendar.js +3 -2
- package/dist/components/carousel.js +3 -2
- package/dist/components/combobox.js +3 -2
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/empty-state.js +3 -2
- package/dist/components/event-detail-panel.js +3 -2
- package/dist/components/event-list-row-wide.js +3 -2
- package/dist/components/haptic-switch.d.ts +35 -0
- package/dist/components/haptic-switch.js +3 -0
- package/dist/components/link-button.js +3 -2
- package/dist/components/menu-family-block.js +3 -2
- package/dist/components/menu-index-list.js +3 -2
- package/dist/components/multi-select-combobox.js +3 -2
- package/dist/components/pagination.js +3 -2
- package/dist/components/searchable-select.js +3 -2
- package/dist/components/sliding-panels.js +3 -2
- package/dist/components/switch.d.ts +7 -1
- package/dist/components/switch.js +33 -24
- package/dist/hooks/use-haptic.d.ts +9 -1
- package/dist/hooks/use-haptic.js +1 -1
- package/package.json +1 -1
- package/src/components/button.tsx +30 -2
- package/src/components/haptic-switch.test.tsx +134 -0
- package/src/components/haptic-switch.tsx +62 -0
- package/src/components/switch.tsx +29 -18
- package/src/hooks/use-haptic.test.tsx +75 -0
- package/src/hooks/use-haptic.ts +21 -6
- package/dist/components/haptic-button.d.ts +0 -12
- package/dist/components/haptic-button.js +0 -17
- package/src/components/haptic-button.stories.tsx +0 -41
- package/src/components/haptic-button.tsx +0 -19
package/README.md
CHANGED
|
@@ -48,7 +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
|
|
51
|
+
| Haptic Switch | `@bearmenu/ui/components/haptic-switch` |
|
|
52
52
|
| Input | `@bearmenu/ui/components/input` |
|
|
53
53
|
| Input OTP | `@bearmenu/ui/components/input-otp` |
|
|
54
54
|
| Label | `@bearmenu/ui/components/label` |
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
@@ -78,10 +79,11 @@ var Button = React.forwardRef(
|
|
|
78
79
|
size,
|
|
79
80
|
fullWidth,
|
|
80
81
|
asChild = false,
|
|
81
|
-
haptic =
|
|
82
|
+
haptic = false,
|
|
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
|
-
import { useState, useEffect } from 'react';
|
|
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 (
|
|
14
|
+
if (isIos()) {
|
|
12
15
|
setPlatform("ios");
|
|
13
16
|
} else if (isAndroid) {
|
|
14
17
|
setPlatform("android");
|
|
@@ -17,9 +20,9 @@ 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);
|
|
23
|
+
setIsSupported(hasVibration || !!hasTelegram || isIos());
|
|
21
24
|
}, []);
|
|
22
|
-
const trigger = (style) => {
|
|
25
|
+
const trigger = useCallback((style) => {
|
|
23
26
|
var _a, _b;
|
|
24
27
|
if (typeof window !== "undefined" && ((_b = (_a = window.Telegram) == null ? void 0 : _a.WebApp) == null ? void 0 : _b.HapticFeedback)) {
|
|
25
28
|
triggerTelegramHaptic(style);
|
|
@@ -30,7 +33,7 @@ var useHaptic = () => {
|
|
|
30
33
|
return;
|
|
31
34
|
}
|
|
32
35
|
console.debug("Haptic feedback not supported on this device");
|
|
33
|
-
};
|
|
36
|
+
}, []);
|
|
34
37
|
return { isSupported, platform, trigger };
|
|
35
38
|
};
|
|
36
39
|
function triggerTelegramHaptic(style) {
|
|
@@ -75,4 +78,4 @@ function triggerVibrationAPI(style) {
|
|
|
75
78
|
navigator.vibrate(patterns[style]);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
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-
|
|
2
|
-
import '../chunk-
|
|
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';
|
|
@@ -9,6 +9,14 @@ declare const buttonVariants: (props?: ({
|
|
|
9
9
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
10
10
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
11
11
|
asChild?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Off by default. Haptics are for actions that change state — a save, a
|
|
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.
|
|
19
|
+
*/
|
|
12
20
|
haptic?: boolean;
|
|
13
21
|
hapticStyle?: "light" | "medium" | "heavy";
|
|
14
22
|
isLoading?: boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { Button, buttonVariants } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
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-
|
|
2
|
-
import '../chunk-
|
|
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-
|
|
2
|
-
import '../chunk-
|
|
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-
|
|
5
|
-
import '../chunk-
|
|
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>, "
|
|
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-
|
|
2
|
-
import '../chunk-
|
|
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-
|
|
6
|
-
import '../chunk-
|
|
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-
|
|
7
|
-
import '../chunk-
|
|
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 };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Button } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
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-
|
|
7
|
-
import '../chunk-
|
|
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-
|
|
4
|
-
import '../chunk-
|
|
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-
|
|
6
|
-
import '../chunk-
|
|
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-
|
|
2
|
-
import '../chunk-
|
|
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,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-
|
|
5
|
-
import '../chunk-
|
|
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-
|
|
2
|
-
import '../chunk-
|
|
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"> &
|
|
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(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
className
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 };
|
package/dist/hooks/use-haptic.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { useHaptic } from '../chunk-
|
|
1
|
+
export { isIos, useHaptic } from '../chunk-5YNIQ2BL.js';
|
|
2
2
|
import '../chunk-MMUBH76A.js';
|
package/package.json
CHANGED
|
@@ -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",
|
|
@@ -79,6 +80,14 @@ const buttonVariants = cva(
|
|
|
79
80
|
export interface ButtonProps
|
|
80
81
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
81
82
|
asChild?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Off by default. Haptics are for actions that change state — a save, a
|
|
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.
|
|
90
|
+
*/
|
|
82
91
|
haptic?: boolean;
|
|
83
92
|
hapticStyle?: "light" | "medium" | "heavy";
|
|
84
93
|
isLoading?: boolean;
|
|
@@ -92,10 +101,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
92
101
|
size,
|
|
93
102
|
fullWidth,
|
|
94
103
|
asChild = false,
|
|
95
|
-
haptic =
|
|
104
|
+
haptic = false,
|
|
96
105
|
hapticStyle = "light",
|
|
97
106
|
isLoading = false,
|
|
98
107
|
disabled,
|
|
108
|
+
type,
|
|
99
109
|
onClick,
|
|
100
110
|
children,
|
|
101
111
|
...props
|
|
@@ -105,6 +115,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
105
115
|
const Comp = asChild ? Slot : "button";
|
|
106
116
|
const { trigger } = useHaptic();
|
|
107
117
|
const [isMounted, setIsMounted] = React.useState(false);
|
|
118
|
+
const withSwitch = haptic && !asChild;
|
|
108
119
|
|
|
109
120
|
React.useEffect(() => {
|
|
110
121
|
setIsMounted(true);
|
|
@@ -115,13 +126,25 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
115
126
|
trigger(hapticStyle);
|
|
116
127
|
}
|
|
117
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
|
+
}
|
|
118
140
|
};
|
|
119
141
|
|
|
120
142
|
return (
|
|
121
143
|
<Comp
|
|
122
144
|
data-slot="button"
|
|
123
|
-
className={cn(buttonVariants({ variant, size, fullWidth, className }))}
|
|
145
|
+
className={cn(buttonVariants({ variant, size, fullWidth, className }), withSwitch && "relative")}
|
|
124
146
|
ref={ref}
|
|
147
|
+
type={type}
|
|
125
148
|
onClick={handleClick}
|
|
126
149
|
disabled={disabled || isLoading}
|
|
127
150
|
aria-busy={isLoading || undefined}
|
|
@@ -132,6 +155,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
132
155
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
133
156
|
{!asChild && children}
|
|
134
157
|
</>
|
|
158
|
+
) : withSwitch ? (
|
|
159
|
+
<>
|
|
160
|
+
{children}
|
|
161
|
+
<HapticSwitch disabled={disabled} />
|
|
162
|
+
</>
|
|
135
163
|
) : (
|
|
136
164
|
children
|
|
137
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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 };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { isIos, useHaptic } from "./use-haptic";
|
|
4
|
+
|
|
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";
|
|
9
|
+
|
|
10
|
+
describe("isIos", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is false in jsdom", () => {
|
|
16
|
+
expect(isIos()).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches an iPhone user agent", () => {
|
|
20
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IOS_UA);
|
|
21
|
+
expect(isIos()).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
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
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("useHaptic", () => {
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
Reflect.deleteProperty(navigator, "vibrate");
|
|
37
|
+
});
|
|
38
|
+
|
|
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
|
+
|
|
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
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reports no support without any haptic API", () => {
|
|
52
|
+
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
53
|
+
const { result } = renderHook(() => useHaptic());
|
|
54
|
+
|
|
55
|
+
expect(result.current.isSupported).toBe(false);
|
|
56
|
+
act(() => result.current.trigger("light"));
|
|
57
|
+
expect(debug).toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
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());
|
|
64
|
+
|
|
65
|
+
act(() => result.current.trigger("medium"));
|
|
66
|
+
expect(vibrate).toHaveBeenCalledWith(20);
|
|
67
|
+
});
|
|
68
|
+
|
|
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);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/hooks/use-haptic.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
4
|
|
|
5
5
|
type HapticStyle = "light" | "medium" | "heavy" | "success" | "warning" | "error" | "selection";
|
|
6
6
|
|
|
@@ -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 (
|
|
37
|
+
if (isIos()) {
|
|
24
38
|
setPlatform("ios");
|
|
25
39
|
} else if (isAndroid) {
|
|
26
40
|
setPlatform("android");
|
|
@@ -32,10 +46,10 @@ 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);
|
|
49
|
+
setIsSupported(hasVibration || !!hasTelegram || isIos());
|
|
36
50
|
}, []);
|
|
37
51
|
|
|
38
|
-
const trigger = (style: HapticStyle) => {
|
|
52
|
+
const trigger = useCallback((style: HapticStyle) => {
|
|
39
53
|
// Try Telegram API first (works on both iOS and Android in Telegram)
|
|
40
54
|
if (typeof window !== "undefined" && window.Telegram?.WebApp?.HapticFeedback) {
|
|
41
55
|
triggerTelegramHaptic(style);
|
|
@@ -48,9 +62,10 @@ export const useHaptic = (): HapticAPI => {
|
|
|
48
62
|
return;
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
// iOS: nothing to call — see HapticSwitch, which sits inside the control
|
|
51
66
|
// No haptic support - silently fail
|
|
52
67
|
console.debug("Haptic feedback not supported on this device");
|
|
53
|
-
};
|
|
68
|
+
}, []);
|
|
54
69
|
|
|
55
70
|
return { isSupported, platform, trigger };
|
|
56
71
|
};
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
-
import { ButtonProps } from './button.js';
|
|
3
|
-
import 'class-variance-authority/types';
|
|
4
|
-
import 'react';
|
|
5
|
-
import 'class-variance-authority';
|
|
6
|
-
|
|
7
|
-
interface HapticButtonProps extends ButtonProps {
|
|
8
|
-
hapticStyle?: "light" | "medium" | "heavy";
|
|
9
|
-
}
|
|
10
|
-
declare function HapticButton({ hapticStyle, onClick, ...props }: HapticButtonProps): react_jsx_runtime.JSX.Element;
|
|
11
|
-
|
|
12
|
-
export { HapticButton };
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { Button } from '../chunk-F76NCVVR.js';
|
|
2
|
-
import { useHaptic } from '../chunk-R47NEOSI.js';
|
|
3
|
-
import '../chunk-FEK5XGZW.js';
|
|
4
|
-
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
5
|
-
import { jsx } from 'react/jsx-runtime';
|
|
6
|
-
|
|
7
|
-
function HapticButton(_a) {
|
|
8
|
-
var _b = _a, { hapticStyle = "light", onClick } = _b, props = __objRest(_b, ["hapticStyle", "onClick"]);
|
|
9
|
-
const { trigger } = useHaptic();
|
|
10
|
-
const handleClick = (e) => {
|
|
11
|
-
trigger(hapticStyle);
|
|
12
|
-
onClick == null ? void 0 : onClick(e);
|
|
13
|
-
};
|
|
14
|
-
return /* @__PURE__ */ jsx(Button, __spreadProps(__spreadValues({}, props), { onClick: handleClick, haptic: false }));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export { HapticButton };
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { HapticButton } from "./haptic-button";
|
|
3
|
-
|
|
4
|
-
const meta = {
|
|
5
|
-
title: "Components/HapticButton",
|
|
6
|
-
component: HapticButton,
|
|
7
|
-
tags: ["autodocs"],
|
|
8
|
-
} satisfies Meta<typeof HapticButton>;
|
|
9
|
-
|
|
10
|
-
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof meta>;
|
|
12
|
-
|
|
13
|
-
export const Light: Story = {
|
|
14
|
-
render: () => <HapticButton hapticStyle="light">Light Haptic</HapticButton>,
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export const Medium: Story = {
|
|
18
|
-
render: () => (
|
|
19
|
-
<HapticButton hapticStyle="medium" variant="secondary">
|
|
20
|
-
Medium Haptic
|
|
21
|
-
</HapticButton>
|
|
22
|
-
),
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const Heavy: Story = {
|
|
26
|
-
render: () => (
|
|
27
|
-
<HapticButton hapticStyle="heavy" variant="destructive">
|
|
28
|
-
Heavy Haptic
|
|
29
|
-
</HapticButton>
|
|
30
|
-
),
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const AllStyles: Story = {
|
|
34
|
-
render: () => (
|
|
35
|
-
<div className="flex gap-3">
|
|
36
|
-
<HapticButton hapticStyle="light">Light</HapticButton>
|
|
37
|
-
<HapticButton hapticStyle="medium" variant="secondary">Medium</HapticButton>
|
|
38
|
-
<HapticButton hapticStyle="heavy" variant="outline">Heavy</HapticButton>
|
|
39
|
-
</div>
|
|
40
|
-
),
|
|
41
|
-
};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { Button, type ButtonProps } from "./button";
|
|
4
|
-
import { useHaptic } from "../hooks/use-haptic";
|
|
5
|
-
|
|
6
|
-
interface HapticButtonProps extends ButtonProps {
|
|
7
|
-
hapticStyle?: "light" | "medium" | "heavy";
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function HapticButton({ hapticStyle = "light", onClick, ...props }: HapticButtonProps) {
|
|
11
|
-
const { trigger } = useHaptic();
|
|
12
|
-
|
|
13
|
-
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
14
|
-
trigger(hapticStyle);
|
|
15
|
-
onClick?.(e);
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
return <Button {...props} onClick={handleClick} haptic={false} />;
|
|
19
|
-
}
|