@bearmenu/ui 0.8.14 → 0.8.15
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 +0 -1
- package/dist/{chunk-F76NCVVR.js → chunk-5FCC5L5F.js} +2 -2
- package/dist/{chunk-R47NEOSI.js → chunk-V4E333QS.js} +29 -4
- package/dist/components/alert-dialog.js +2 -2
- package/dist/components/button.d.ts +4 -0
- package/dist/components/button.js +2 -2
- package/dist/components/calendar.js +2 -2
- package/dist/components/carousel.js +2 -2
- package/dist/components/combobox.js +2 -2
- package/dist/components/empty-state.js +2 -2
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/event-list-row-wide.js +2 -2
- package/dist/components/kitchen-dossier.js +1 -1
- package/dist/components/link-button.js +2 -2
- package/dist/components/menu-family-block.js +2 -2
- package/dist/components/menu-index-list.js +2 -2
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +2 -2
- package/dist/components/place-card.js +1 -1
- package/dist/components/searchable-select.js +2 -2
- package/dist/components/sliding-panels.js +2 -2
- package/dist/hooks/use-haptic.js +1 -1
- package/package.json +1 -1
- package/src/components/button.tsx +5 -1
- package/src/hooks/use-haptic.test.tsx +80 -0
- package/src/hooks/use-haptic.ts +45 -4
- 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,6 @@ 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 Button | `@bearmenu/ui/components/haptic-button` |
|
|
52
51
|
| Input | `@bearmenu/ui/components/input` |
|
|
53
52
|
| Input OTP | `@bearmenu/ui/components/input-otp` |
|
|
54
53
|
| Label | `@bearmenu/ui/components/label` |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useHaptic } from './chunk-
|
|
1
|
+
import { useHaptic } from './chunk-V4E333QS.js';
|
|
2
2
|
import { cn } from './chunk-FEK5XGZW.js';
|
|
3
3
|
import { __objRest, __spreadProps, __spreadValues } from './chunk-MMUBH76A.js';
|
|
4
4
|
import * as React from 'react';
|
|
@@ -78,7 +78,7 @@ var Button = React.forwardRef(
|
|
|
78
78
|
size,
|
|
79
79
|
fullWidth,
|
|
80
80
|
asChild = false,
|
|
81
|
-
haptic =
|
|
81
|
+
haptic = false,
|
|
82
82
|
hapticStyle = "light",
|
|
83
83
|
isLoading = false,
|
|
84
84
|
disabled,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
|
|
3
3
|
var useHaptic = () => {
|
|
4
4
|
const [platform, setPlatform] = useState("unknown");
|
|
@@ -17,9 +17,9 @@ var useHaptic = () => {
|
|
|
17
17
|
}
|
|
18
18
|
const hasVibration = "vibrate" in navigator;
|
|
19
19
|
const hasTelegram = typeof window !== "undefined" && ((_b = (_a = window.Telegram) == null ? void 0 : _a.WebApp) == null ? void 0 : _b.HapticFeedback);
|
|
20
|
-
setIsSupported(hasVibration || !!hasTelegram);
|
|
20
|
+
setIsSupported(hasVibration || !!hasTelegram || supportsSwitchHaptic());
|
|
21
21
|
}, []);
|
|
22
|
-
const trigger = (style) => {
|
|
22
|
+
const trigger = useCallback((style) => {
|
|
23
23
|
var _a, _b;
|
|
24
24
|
if (typeof window !== "undefined" && ((_b = (_a = window.Telegram) == null ? void 0 : _a.WebApp) == null ? void 0 : _b.HapticFeedback)) {
|
|
25
25
|
triggerTelegramHaptic(style);
|
|
@@ -29,8 +29,12 @@ var useHaptic = () => {
|
|
|
29
29
|
triggerVibrationAPI(style);
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
+
if (supportsSwitchHaptic()) {
|
|
33
|
+
triggerSwitchHaptic();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
32
36
|
console.debug("Haptic feedback not supported on this device");
|
|
33
|
-
};
|
|
37
|
+
}, []);
|
|
34
38
|
return { isSupported, platform, trigger };
|
|
35
39
|
};
|
|
36
40
|
function triggerTelegramHaptic(style) {
|
|
@@ -74,5 +78,26 @@ function triggerVibrationAPI(style) {
|
|
|
74
78
|
};
|
|
75
79
|
navigator.vibrate(patterns[style]);
|
|
76
80
|
}
|
|
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
|
+
}
|
|
77
102
|
|
|
78
103
|
export { useHaptic };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { buttonVariants } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { buttonVariants } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
5
5
|
import * as React from 'react';
|
|
@@ -9,6 +9,10 @@ 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
|
+
*/
|
|
12
16
|
haptic?: boolean;
|
|
13
17
|
hapticStyle?: "light" | "medium" | "heavy";
|
|
14
18
|
isLoading?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { buttonVariants, Button } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { buttonVariants, Button } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadValues, __spreadProps } from '../chunk-MMUBH76A.js';
|
|
5
5
|
import * as React from 'react';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Button } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { Button } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
5
5
|
import * as React from 'react';
|
|
@@ -1,8 +1,8 @@
|
|
|
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-5FCC5L5F.js';
|
|
5
|
+
import '../chunk-V4E333QS.js';
|
|
6
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
7
7
|
import '../chunk-MMUBH76A.js';
|
|
8
8
|
import * as React from 'react';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Button } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { Button } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import { Text } from '../chunk-ZLMRVBD7.js';
|
|
4
4
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
5
5
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
@@ -2,8 +2,8 @@ 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-5FCC5L5F.js';
|
|
6
|
+
import '../chunk-V4E333QS.js';
|
|
7
7
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
8
8
|
import { __spreadProps, __spreadValues, __objRest } from '../chunk-MMUBH76A.js';
|
|
9
9
|
import * as React from 'react';
|
|
@@ -3,8 +3,8 @@ 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-5FCC5L5F.js';
|
|
7
|
+
import '../chunk-V4E333QS.js';
|
|
8
8
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
9
9
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
10
10
|
import { memo, useState } from 'react';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-ZQ53AGML.js';
|
|
2
1
|
import { VenueQuotes } from '../chunk-43RC7MHY.js';
|
|
2
|
+
import { DIMENSION_ORDER, VenueGradeDimensions } from '../chunk-ZQ53AGML.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,5 @@
|
|
|
1
|
-
import { Button } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { Button } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
5
5
|
import * as React from 'react';
|
|
@@ -3,8 +3,8 @@ 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-5FCC5L5F.js';
|
|
7
|
+
import '../chunk-V4E333QS.js';
|
|
8
8
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
9
9
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
10
10
|
import { Wallet } from 'lucide-react';
|
|
@@ -1,7 +1,7 @@
|
|
|
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-5FCC5L5F.js';
|
|
4
|
+
import '../chunk-V4E333QS.js';
|
|
5
5
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
6
6
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
7
7
|
import { useState } from 'react';
|
|
@@ -2,8 +2,8 @@ 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-5FCC5L5F.js';
|
|
6
|
+
import '../chunk-V4E333QS.js';
|
|
7
7
|
import { Badge } from '../chunk-P43B3ONY.js';
|
|
8
8
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
9
9
|
import '../chunk-MMUBH76A.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { buttonVariants } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { buttonVariants } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import { __objRest, __spreadValues, __spreadProps } from '../chunk-MMUBH76A.js';
|
|
5
5
|
import { ChevronsLeftIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { OVERLAY_PILL } from '../chunk-EZG2NELX.js';
|
|
2
1
|
import { HOVER_IMAGE_MOTION, HOVER_IMAGE_WRAPPER, HOVER_MOTION, HOVER_LIFT_GRID_GROUP } from '../chunk-XCB3F6SF.js';
|
|
3
2
|
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,8 @@
|
|
|
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-5FCC5L5F.js';
|
|
5
|
+
import '../chunk-V4E333QS.js';
|
|
6
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
7
7
|
import '../chunk-MMUBH76A.js';
|
|
8
8
|
import * as React from 'react';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Button } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { Button } from '../chunk-5FCC5L5F.js';
|
|
2
|
+
import '../chunk-V4E333QS.js';
|
|
3
3
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
4
4
|
import '../chunk-MMUBH76A.js';
|
|
5
5
|
import * as React from 'react';
|
package/dist/hooks/use-haptic.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { useHaptic } from '../chunk-
|
|
1
|
+
export { useHaptic } from '../chunk-V4E333QS.js';
|
|
2
2
|
import '../chunk-MMUBH76A.js';
|
package/package.json
CHANGED
|
@@ -79,6 +79,10 @@ const buttonVariants = cva(
|
|
|
79
79
|
export interface ButtonProps
|
|
80
80
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
81
81
|
asChild?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Off by default. Haptics are for actions that change state — a save, a
|
|
84
|
+
* toggle, a destructive confirm — not for every button, so each site opts in.
|
|
85
|
+
*/
|
|
82
86
|
haptic?: boolean;
|
|
83
87
|
hapticStyle?: "light" | "medium" | "heavy";
|
|
84
88
|
isLoading?: boolean;
|
|
@@ -92,7 +96,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
92
96
|
size,
|
|
93
97
|
fullWidth,
|
|
94
98
|
asChild = false,
|
|
95
|
-
haptic =
|
|
99
|
+
haptic = false,
|
|
96
100
|
hapticStyle = "light",
|
|
97
101
|
isLoading = false,
|
|
98
102
|
disabled,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { useHaptic } from "./use-haptic";
|
|
4
|
+
|
|
5
|
+
const hapticSwitch = () => document.body.querySelector<HTMLInputElement>("input[type=checkbox][switch]");
|
|
6
|
+
|
|
7
|
+
describe("useHaptic", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
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
|
+
});
|
|
26
|
+
|
|
27
|
+
it("toggles one hidden switch through its label on every trigger", () => {
|
|
28
|
+
const { result } = renderHook(() => useHaptic());
|
|
29
|
+
|
|
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");
|
|
35
|
+
|
|
36
|
+
act(() => result.current.trigger("light"));
|
|
37
|
+
expect(document.body.querySelectorAll("input[switch]")).toHaveLength(1);
|
|
38
|
+
expect(input?.checked).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("collapses every style to the one tick WebKit has", () => {
|
|
42
|
+
const { result } = renderHook(() => useHaptic());
|
|
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
|
+
});
|
|
49
|
+
});
|
|
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());
|
|
55
|
+
|
|
56
|
+
expect(result.current.isSupported).toBe(false);
|
|
57
|
+
act(() => result.current.trigger("light"));
|
|
58
|
+
expect(hapticSwitch()).toBeNull();
|
|
59
|
+
expect(debug).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("with the Vibration API", () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
Reflect.deleteProperty(navigator, "vibrate");
|
|
66
|
+
});
|
|
67
|
+
|
|
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());
|
|
73
|
+
|
|
74
|
+
act(() => result.current.trigger("medium"));
|
|
75
|
+
expect(vibrate).toHaveBeenCalledWith(20);
|
|
76
|
+
expect(hapticSwitch()).toBeNull();
|
|
77
|
+
Reflect.deleteProperty(HTMLInputElement.prototype, "switch");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
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
|
|
|
@@ -32,10 +32,10 @@ export const useHaptic = (): HapticAPI => {
|
|
|
32
32
|
const hasVibration = "vibrate" in navigator;
|
|
33
33
|
const hasTelegram = typeof window !== "undefined" && window.Telegram?.WebApp?.HapticFeedback;
|
|
34
34
|
|
|
35
|
-
setIsSupported(hasVibration || !!hasTelegram);
|
|
35
|
+
setIsSupported(hasVibration || !!hasTelegram || supportsSwitchHaptic());
|
|
36
36
|
}, []);
|
|
37
37
|
|
|
38
|
-
const trigger = (style: HapticStyle) => {
|
|
38
|
+
const trigger = useCallback((style: HapticStyle) => {
|
|
39
39
|
// Try Telegram API first (works on both iOS and Android in Telegram)
|
|
40
40
|
if (typeof window !== "undefined" && window.Telegram?.WebApp?.HapticFeedback) {
|
|
41
41
|
triggerTelegramHaptic(style);
|
|
@@ -48,9 +48,15 @@ export const useHaptic = (): HapticAPI => {
|
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
50
|
|
|
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
|
+
|
|
51
57
|
// No haptic support - silently fail
|
|
52
58
|
console.debug("Haptic feedback not supported on this device");
|
|
53
|
-
};
|
|
59
|
+
}, []);
|
|
54
60
|
|
|
55
61
|
return { isSupported, platform, trigger };
|
|
56
62
|
};
|
|
@@ -102,6 +108,41 @@ function triggerVibrationAPI(style: HapticStyle) {
|
|
|
102
108
|
navigator.vibrate(patterns[style]);
|
|
103
109
|
}
|
|
104
110
|
|
|
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
|
+
|
|
105
146
|
// Extend Window interface for TypeScript
|
|
106
147
|
declare global {
|
|
107
148
|
interface Window {
|
|
@@ -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
|
-
}
|