diff --git a/.changeset/twelve-ends-find.md b/.changeset/twelve-ends-find.md new file mode 100644 index 0000000000..272a3f4e3c --- /dev/null +++ b/.changeset/twelve-ends-find.md @@ -0,0 +1,7 @@ +--- +'@alfalab/core-components-haptics': minor +'@alfalab/core-components-config': minor +'@alfalab/core-components': minor +--- + +- Добавлена базовая инфраструктура `haptic feedback`. diff --git a/.vscode/settings.json b/.vscode/settings.json index c987a1bd2a..86a7993589 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,6 +57,7 @@ "packages/generic-wrapper", "packages/global-store", "packages/grid", + "packages/haptics", "packages/hatching-progress-bar", "packages/icon-button", "packages/icon-view", diff --git a/packages/button/package.json b/packages/button/package.json index 82d31b3524..24bb6562c1 100644 --- a/packages/button/package.json +++ b/packages/button/package.json @@ -13,6 +13,7 @@ "build": "rollup -c ../../tools/rollup/rollup.config.mjs --silent" }, "dependencies": { + "@alfalab/core-components-haptics": "^0.0.1", "@alfalab/core-components-mq": "^6.0.5", "@alfalab/core-components-shared": "^2.2.1", "@alfalab/core-components-spinner": "^6.0.5", diff --git a/packages/button/src/components/base-button/Component.tsx b/packages/button/src/components/base-button/Component.tsx index 65d68be616..54a8c77dce 100644 --- a/packages/button/src/components/base-button/Component.tsx +++ b/packages/button/src/components/base-button/Component.tsx @@ -10,6 +10,7 @@ import React, { import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; +import { HapticA, HapticButton } from '@alfalab/core-components-haptics'; import { getDataTestId } from '@alfalab/core-components-shared'; import { Spinner } from '@alfalab/core-components-spinner'; import { useFocus } from '@alfalab/hooks'; @@ -50,6 +51,7 @@ export const BaseButton = forwardRef< nowrap = false, colors = 'default', Component = href ? 'a' : 'button', + 'data-haptic-preset': dataHapticPreset, onClick, styles = {}, colorStylesMap = { default: {}, inverted: {} }, @@ -73,6 +75,9 @@ export const BaseButton = forwardRef< const iconOnly = !children; + const isNativeButton = Component === 'button'; + const isNativeAnchor = Component === 'a'; + const sizeStyle = `size-${size}`; const componentProps = { @@ -186,12 +191,16 @@ export const BaseButton = forwardRef< if (href) { const { target } = restProps as AnchorHTMLAttributes; + const LinkComponent = isNativeAnchor ? HapticA : Component; // Для совместимости с react-router-dom, меняем href на to const hrefProps = { [typeof Component === 'string' ? 'href' : 'to']: href }; return ( - )} @@ -201,12 +210,17 @@ export const BaseButton = forwardRef< ref={mergeRefs([buttonRef, ref])} > {buttonChildren} - + ); } + const ButtonComponent = isNativeButton ? HapticButton : Component; + return ( - {buttonChildren} - + ); }, ); diff --git a/packages/button/src/typings.ts b/packages/button/src/typings.ts index 30ba826cdc..d30fb19f09 100644 --- a/packages/button/src/typings.ts +++ b/packages/button/src/typings.ts @@ -5,6 +5,8 @@ import { type ReactNode, } from 'react'; +import { type HapticConfig } from '@alfalab/core-components-haptics'; + export type StyleColors = { default: { [key: string]: string; @@ -114,6 +116,12 @@ type ComponentProps = { */ children?: ReactNode; + /** + * Haptic-пресет или кастомный vibration-конфиг для клика по кнопке. + * @default selection + */ + 'data-haptic-preset'?: HapticConfig['data-haptic-preset']; + /** * Дополнительный класс для label */ diff --git a/packages/button/tsconfig.build.json b/packages/button/tsconfig.build.json index 0d03a57426..16cd041e1d 100644 --- a/packages/button/tsconfig.build.json +++ b/packages/button/tsconfig.build.json @@ -9,6 +9,8 @@ "paths": { "@alfalab/core-components-button": ["./src"], "@alfalab/core-components-button/*": ["./src/*"], + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-mq": ["../mq/src"], "@alfalab/core-components-mq/*": ["../mq/src/*"], "@alfalab/core-components-shared": ["../shared/src"], @@ -18,6 +20,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }, { "path": "../shared/tsconfig.build.json" }, { "path": "../spinner/tsconfig.build.json" } diff --git a/packages/button/tsconfig.json b/packages/button/tsconfig.json index c5b8c87ada..5ea1c204cf 100644 --- a/packages/button/tsconfig.json +++ b/packages/button/tsconfig.json @@ -8,6 +8,8 @@ "paths": { "@alfalab/core-components-button": ["./src"], "@alfalab/core-components-button/*": ["./src/*"], + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-mq": ["../mq/src"], "@alfalab/core-components-mq/*": ["../mq/src/*"], "@alfalab/core-components-screenshot-utils": ["../screenshot-utils/src"], @@ -21,6 +23,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }, { "path": "../screenshot-utils/tsconfig.build.json" }, { "path": "../shared/tsconfig.build.json" }, diff --git a/packages/checkbox/package.json b/packages/checkbox/package.json index 7db8d3abc1..fe86b49fa7 100644 --- a/packages/checkbox/package.json +++ b/packages/checkbox/package.json @@ -13,6 +13,7 @@ "build": "rollup -c ../../tools/rollup/rollup.config.mjs --silent" }, "dependencies": { + "@alfalab/core-components-haptics": "^0.0.1", "@alfalab/core-components-shared": "^2.2.1", "@alfalab/hooks": "^1.17.0", "classnames": "^2.5.1", diff --git a/packages/checkbox/src/Component.tsx b/packages/checkbox/src/Component.tsx index 5422c03026..df12740da2 100644 --- a/packages/checkbox/src/Component.tsx +++ b/packages/checkbox/src/Component.tsx @@ -12,6 +12,7 @@ import React, { import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; +import { type HapticConfig, HapticInput } from '@alfalab/core-components-haptics'; import { dom, getDataTestId } from '@alfalab/core-components-shared'; import { useFocus } from '@alfalab/hooks'; @@ -154,6 +155,12 @@ export type CheckboxProps = Omit( @@ -184,6 +191,7 @@ export const Checkbox = forwardRef( error, inputRef, colors = 'default', + 'data-haptic-preset': dataHapticPreset, ...restProps }, ref, @@ -237,7 +245,8 @@ export const Checkbox = forwardRef( ref={mergeRefs([labelRef, ref, labelProps?.ref as Ref])} > {!hiddenInput && ( - Element | null | undefined; + haptics?: { + enabled?: boolean; + }; }; export const CoreConfigContext = createContext({ diff --git a/packages/haptics/CHANGELOG.md b/packages/haptics/CHANGELOG.md new file mode 100644 index 0000000000..dd442a776b --- /dev/null +++ b/packages/haptics/CHANGELOG.md @@ -0,0 +1,9 @@ +# @alfalab/core-components-haptics + +## 0.0.1 + +### Patch Changes + + + +- Инициализация пакета diff --git a/packages/haptics/package.json b/packages/haptics/package.json new file mode 100644 index 0000000000..78ae51d90a --- /dev/null +++ b/packages/haptics/package.json @@ -0,0 +1,30 @@ +{ + "name": "@alfalab/core-components-haptics", + "version": "0.0.1", + "description": "", + "keywords": [], + "license": "MIT", + "sideEffects": [ + "**/*.css" + ], + "main": "index.js", + "module": "./esm/index.js", + "scripts": { + "build": "rollup -c ../../tools/rollup/rollup.config.mjs --silent" + }, + "dependencies": { + "@alfalab/core-components-config": "^1.1.0", + "tslib": "^2.4.0", + "web-haptics": "^0.0.6" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "themesVersion": "15.0.4", + "varsVersion": "11.0.2" +} diff --git a/packages/haptics/src/components/haptic-a/Component.tsx b/packages/haptics/src/components/haptic-a/Component.tsx new file mode 100644 index 0000000000..89ffa1debc --- /dev/null +++ b/packages/haptics/src/components/haptic-a/Component.tsx @@ -0,0 +1,27 @@ +import React, { type AnchorHTMLAttributes, forwardRef, type MouseEvent } from 'react'; + +import { useHaptic } from '../../hooks/use-haptic'; +import { type HapticBaseProps } from '../../types'; + +type HapticAProps = AnchorHTMLAttributes & HapticBaseProps; + +export const HapticA = forwardRef( + ({ 'data-haptic-preset': dataHapticPreset, onClick, ...restProps }, ref) => { + const { trigger } = useHaptic({ dataHapticPreset }); + + const handleClick = (event: MouseEvent) => { + onClick?.(event); + + if (event.defaultPrevented) return; + + trigger(); + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/anchor-has-content, jsx-a11y/click-events-have-key-events + + ); + }, +); + +HapticA.displayName = 'HapticA'; diff --git a/packages/haptics/src/components/haptic-a/index.ts b/packages/haptics/src/components/haptic-a/index.ts new file mode 100644 index 0000000000..8803d33fa9 --- /dev/null +++ b/packages/haptics/src/components/haptic-a/index.ts @@ -0,0 +1 @@ +export { HapticA } from './Component'; diff --git a/packages/haptics/src/components/haptic-button/Component.tsx b/packages/haptics/src/components/haptic-button/Component.tsx new file mode 100644 index 0000000000..9414eb5cca --- /dev/null +++ b/packages/haptics/src/components/haptic-button/Component.tsx @@ -0,0 +1,33 @@ +import React, { type ButtonHTMLAttributes, forwardRef, type MouseEvent } from 'react'; + +import { useHaptic } from '../../hooks/use-haptic'; +import { type HapticBaseProps } from '../../types'; + +type HapticButtonProps = Omit, 'type'> & { + type?: 'button' | 'submit'; +} & HapticBaseProps; + +export const HapticButton = forwardRef( + ({ 'data-haptic-preset': dataHapticPreset, onClick, type = 'button', ...restProps }, ref) => { + const { trigger } = useHaptic({ dataHapticPreset }); + + const handleClick = (e: MouseEvent) => { + onClick?.(e); + + if (e.defaultPrevented) return; + + trigger(); + }; + + return ( + + + + {description} + + + ))} + + + ))} + + ); +}); +``` + +Взаимодействие. Это вибрации, которые дополняют визуальный опыт и делают его более ощутимым. + +```jsx live mobileOnly mobileHeight={750} +render(() => { + const presets = [ + { + items: [ + { + label: 'Light', + description: 'Лёгкий, едва заметный щелчёк', + preset: 'light', + }, + { + label: 'Medium', + description: 'Более ощутимый щелчок, как мягкое нажатие кнопки', + preset: 'medium', + }, + { + label: 'Heavy', + description: 'Плотный, уверенный щелчёк будто нажал тугую физическую кнопку', + preset: 'heavy', + }, + { + label: 'Rigid', + description: 'Короткий, резкий щелчёк', + preset: 'rigid', + }, + { + label: 'Soft', + description: 'Мягкий импульс', + preset: 'soft', + }, + ], + }, + ]; + + return ( + <> + {presets.map((group) => ( +
+
+ {group.items.map(({ preset, label, description }) => ( +
+ + + + {description} + +
+ ))} +
+
+ ))} + + ); +}); +``` + +Изменение. Сигнал о том, что значение UI-элемента меняется. + +```jsx live mobileOnly mobileHeight={96} +render(() => { + const presets = [ + { + items: [ + { + label: 'Selection', + preset: 'selection', + }, + ], + }, + ]; + + return ( + <> + {presets.map((group) => ( +
+
+ {group.items.map(({ preset, label }) => ( +
+ +
+ ))} +
+
+ ))} + + ); +}); +``` + +## Кастом + +Управляя параметрами [delay], [intensity], [duration] — можно получить нужный вид вибрации, если ни один из пресетов не подходит. + +```jsx live mobileHeight={96} mobileOnly + + + +``` + +## Дефолтные вибрации + +В некоторые компоненты зашиты аналогичные iOS паттерны вибрации. + +```jsx live mobileHeight={658} mobileOnly +render(() => { + const [checkboxFirst, setCheckboxFirst] = React.useState(false); + const [checkboxSecond, setCheckboxSecond] = React.useState(false); + const [radio, setRadio] = React.useState('first'); + const [switchChecked, setSwitchChecked] = React.useState(true); + const [sliderValue, setSliderValue] = React.useState(2.5); + const [numberValue, setNumberValue] = React.useState(1); + const [date, setDate] = React.useState(null); + + return ( +
+
+ + Чекбоксы + + setCheckboxFirst(checked)} + /> + setCheckboxSecond(checked)} + /> +
+ +
+ + Радио + + setRadio('first')} + /> + setRadio('second')} + /> +
+ + setSwitchChecked(checked)} + /> + + setSliderValue(value)} + /> + + setNumberValue(value)} + /> + + +
+ ); +}); +``` + +## Ограничения + +В данной реализации не поддерживается нативный параметр [sharpness] и длительный тип вибрации [Continuous]. diff --git a/packages/haptics/src/docs/development.mdx b/packages/haptics/src/docs/development.mdx new file mode 100644 index 0000000000..169302f141 --- /dev/null +++ b/packages/haptics/src/docs/development.mdx @@ -0,0 +1,29 @@ +import { ArgTypes } from '@storybook/addon-docs'; +import { HapticButton } from '../index'; + +## Подключение + +```jsx +import { HapticButton, useHaptic } from '@alfalab/core-components/haptics'; +``` + +## Ручной запуск + +```jsx +const { trigger } = useHaptic(); + +trigger('success'); +trigger( + [ + { duration: 12, intensity: 0.4 }, + { delay: 32, duration: 20, intensity: 1 }, + ], + { + repeat: 2, + }, +); +``` + +## Свойства + + diff --git a/packages/haptics/src/hooks/use-haptic.ts b/packages/haptics/src/hooks/use-haptic.ts new file mode 100644 index 0000000000..44ac5afd44 --- /dev/null +++ b/packages/haptics/src/hooks/use-haptic.ts @@ -0,0 +1,96 @@ +import { useCallback } from 'react'; +import { useWebHaptics } from 'web-haptics/react'; + +import { useCoreConfig } from '@alfalab/core-components-config'; + +import { type HapticInput, type HapticPresetValue, type TriggerOptions } from '../types'; +import { resolveHapticConfig } from '../utils'; + +type WebHapticsOptions = NonNullable[0]>; + +interface UseHapticParams extends WebHapticsOptions { + dataHapticPreset?: HapticPresetValue; +} + +interface UseHapticResponse { + /** + * Можно ли запускать haptic с учётом итогового конфига и поддержки окружения. + */ + enabled: boolean; + + /** + * Поддерживает ли текущее окружение запуск haptic feedback через `web-haptics`. + */ + isSupported: boolean; + + /** + * Запускает haptic feedback. + * + * Если передать `input`, он будет отправлен напрямую в `web-haptics.trigger`. + * Если `input` не передан, используется payload из локального `haptic` или `CoreConfig.haptics`. + */ + trigger: (input?: HapticInput, options?: TriggerOptions) => void; + + /** + * Отменяет текущий haptic feedback, если `web-haptics` уже начал проигрывать паттерн. + */ + cancel: () => void; +} + +/** + * Хук для ручного запуска haptic feedback. + * + * Оборачивает `useWebHaptics` и добавляет поддержку конфигурации из `CoreConfig.haptics` + * и локального `data-haptic-preset`-пропса компонента. + * + * Приоритет источников: + * 1. `trigger(input, options)` — прямой вызов с payload для `web-haptics`, самый высокий приоритет. + * 2. `useHaptic({ dataHapticPreset })` — локальный preset или кастомный vibration-конфиг. + * 3. `CoreConfig.haptics` — глобальная конфигурация из провайдера. + * + * Если `input` передан в `trigger`, локальный и глобальный конфиг используются только для + * проверки доступности hook-а, но не меняют payload. Если `input` не передан, hook запускает + * заранее разрешённый payload из локального `data-haptic-preset` или глобального `CoreConfig.haptics`. + * + * `enabled` возвращает `true` только когда итоговый конфиг есть и `web-haptics` поддерживается + * текущим окружением. + */ +export const useHaptic = ({ + dataHapticPreset, + debug, + showSwitch, +}: UseHapticParams = {}): UseHapticResponse => { + const { haptics } = useCoreConfig(); + const { cancel, isSupported, trigger: triggerHaptics } = useWebHaptics({ debug, showSwitch }); + + const config = resolveHapticConfig({ + dataHapticPreset, + global: haptics, + }); + + const trigger = useCallback( + (input?: HapticInput, options?: TriggerOptions) => { + if (!isSupported) return; + + // Direct trigger has the highest priority and bypasses local/global config. + if (input !== undefined) { + triggerHaptics(input, options)?.catch(() => {}); + + return; + } + + if (!config) return; + + // No direct input: use resolved local `data-haptic-preset` prop or global CoreConfig.haptics. + triggerHaptics(config.input, options ?? config.options)?.catch(() => {}); + }, + [config, isSupported, triggerHaptics], + ); + + return { + enabled: Boolean(config && isSupported), + isSupported, + trigger, + cancel, + }; +}; diff --git a/packages/haptics/src/index.ts b/packages/haptics/src/index.ts new file mode 100644 index 0000000000..4ae6869c8d --- /dev/null +++ b/packages/haptics/src/index.ts @@ -0,0 +1,3 @@ +export { HapticA, HapticButton, HapticInput } from './components'; +export { useHaptic } from './hooks/use-haptic'; +export type { HapticConfig } from './types'; diff --git a/packages/haptics/src/types.ts b/packages/haptics/src/types.ts new file mode 100644 index 0000000000..e3ceb35b63 --- /dev/null +++ b/packages/haptics/src/types.ts @@ -0,0 +1,63 @@ +import { type useWebHaptics } from 'web-haptics/react'; + +export type HapticPreset = + | 'success' + | 'warning' + | 'error' + | 'light' + | 'medium' + | 'heavy' + | 'soft' + | 'rigid' + | 'selection' + | 'nudge' + | 'buzz'; + +type WebHaptics = ReturnType; +type WebHapticsTrigger = WebHaptics['trigger']; + +export type HapticInput = Parameters[0]; +export type TriggerOptions = Parameters[1]; + +type HapticInputPattern = Extract, Array<{ duration: number }>>; + +type Vibration = HapticInputPattern[number]; +export type HapticPattern = Vibration[]; +export type HapticPresetValue = HapticPreset | (Partial & { repeat?: number }); + +export type HapticBaseProps = Pick + +export interface HapticTriggerConfig { + enabled?: boolean; + + /** + * Payload, который будет передан напрямую в `web-haptics.trigger`. + */ + input?: HapticInput; + + /** + * Паттерн в формате `web-haptics` preset object. + */ + pattern?: HapticPattern; + + /** + * Опции, которые будут переданы напрямую в `web-haptics.trigger`. + */ + options?: TriggerOptions; +} + +export interface HapticConfig extends HapticTriggerConfig, Partial { + enabled?: boolean; + + /** + * Haptic-пресет или кастомный vibration-конфиг + * @default selection + */ + 'data-haptic-preset'?: HapticPresetValue; + + /** + * Повтор всего паттерна + * @default 1 + */ + repeat?: number; +} diff --git a/packages/haptics/src/utils.ts b/packages/haptics/src/utils.ts new file mode 100644 index 0000000000..ff490a6172 --- /dev/null +++ b/packages/haptics/src/utils.ts @@ -0,0 +1,100 @@ +import { defaultPatterns } from 'web-haptics'; + +import { + type HapticConfig, + type HapticPattern, + type HapticPreset, + type HapticPresetValue, + type HapticTriggerConfig, +} from './types'; + +const DEFAULT_REPEAT = 1; +const DEFAULT_PRESET: HapticPreset = 'selection'; + +const normalizeRepeat = (repeat = DEFAULT_REPEAT) => Math.max(1, Math.floor(repeat)); + +/** + * Повторяет весь haptic-паттерн целиком. + * + * `repeat=2` для `[A, B]` вернёт `[A, B, A, B]`. + */ +export const repeatHapticPattern = (pattern: HapticPattern, repeat = DEFAULT_REPEAT) => + Array.from({ length: normalizeRepeat(repeat) }).flatMap(() => pattern); + +const resolvePresetValue = ( + presetValue: HapticPresetValue, +): Omit => { + if (typeof presetValue === 'string') { + return { + input: defaultPatterns[presetValue].pattern as HapticPattern, + enabled: true, + }; + } + + const { repeat = DEFAULT_REPEAT, ...vibration } = presetValue; + + return { + input: repeatHapticPattern([vibration] as HapticPattern, repeat), + enabled: true, + }; +}; + +export type ResolveHapticConfigParams = { + /** + * Локальный haptic-пресет, переданный в компонент. + */ + dataHapticPreset?: HapticPresetValue; + + /** + * Глобальный haptic-конфиг из `CoreConfig`. + */ + global?: HapticConfig; +}; + +/** + * Собирает итоговый haptic-конфиг из локального `haptic` и глобального `CoreConfig.haptics`. + * + * Правила приоритета: + * - если `data-haptic-preset` не передан, используется только `global.enabled=true` + * - если `data-haptic-preset` передан, он включает haptic даже при `global.enabled=false` + * - строковый `data-haptic-preset` запускает только выбранный preset + * - объектный `data-haptic-preset` считается кастомным vibration-конфигом + */ +export const resolveHapticConfig = ({ + dataHapticPreset, + global, +}: ResolveHapticConfigParams): Omit | null => { + const hasLocalPreset = dataHapticPreset !== undefined; + + if (!hasLocalPreset && global?.enabled !== true) return null; + + if (dataHapticPreset !== undefined) { + return resolvePresetValue(dataHapticPreset); + } + + if (global?.enabled === false) return null; + + if (global?.['data-haptic-preset'] !== undefined) { + return resolvePresetValue(global['data-haptic-preset']); + } + + const { input, pattern, options, intensity, repeat = DEFAULT_REPEAT } = global ?? {}; + + const inputFromGlobal = + input ?? + pattern ?? + repeatHapticPattern(defaultPatterns[DEFAULT_PRESET].pattern as HapticPattern, repeat); + const optionsFromGlobal = + options || intensity !== undefined + ? { + ...(intensity !== undefined && { intensity }), + ...options, + } + : undefined; + + return { + input: inputFromGlobal, + options: optionsFromGlobal, + enabled: global?.enabled === true, + }; +}; diff --git a/packages/haptics/tsconfig.build.json b/packages/haptics/tsconfig.build.json new file mode 100644 index 0000000000..6bfbfbf51e --- /dev/null +++ b/packages/haptics/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@alfalab/core-components-env/tsconfig.base.json", + "include": ["src", "src/**/*.json"], + "exclude": ["**/*.stories.*", "**/*.test.*"], + "compilerOptions": { + "rootDir": "src", + "outDir": "ts-dist", + "paths": { + "@alfalab/core-components-config": ["../config/src"], + "@alfalab/core-components-config/*": ["../config/src/*"], + "@alfalab/core-components-haptics": ["./src"], + "@alfalab/core-components-haptics/*": ["./src/*"] + } + }, + "references": [{ "path": "../config/tsconfig.build.json" }] +} diff --git a/packages/haptics/tsconfig.json b/packages/haptics/tsconfig.json new file mode 100644 index 0000000000..01be67eaa4 --- /dev/null +++ b/packages/haptics/tsconfig.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@alfalab/core-components-env/tsconfig.base.json", + "include": ["src", "src/**/*.json", ".eslintrc.cjs"], + "compilerOptions": { + "rootDir": "src", + "outDir": "no-dist", + "paths": { + "@alfalab/core-components-config": ["../config/src"], + "@alfalab/core-components-config/*": ["../config/src/*"], + "@alfalab/core-components-haptics": ["./src"], + "@alfalab/core-components-haptics/*": ["./src/*"], + "@alfalab/core-components-screenshot-utils": ["../screenshot-utils/src"], + "@alfalab/core-components-screenshot-utils/*": ["../screenshot-utils/src/*"], + "@alfalab/core-components-test-utils": ["../test-utils/src"], + "@alfalab/core-components-test-utils/*": ["../test-utils/src/*"] + } + }, + "references": [ + { "path": "../config/tsconfig.build.json" }, + { "path": "../screenshot-utils/tsconfig.build.json" }, + { "path": "../test-utils/tsconfig.build.json" } + ] +} diff --git a/packages/icon-button/src/types/icon-button-props.ts b/packages/icon-button/src/types/icon-button-props.ts index b9a4b21b06..84e244e76b 100644 --- a/packages/icon-button/src/types/icon-button-props.ts +++ b/packages/icon-button/src/types/icon-button-props.ts @@ -9,7 +9,7 @@ import { type ButtonProps } from '@alfalab/core-components-button'; export interface IconButtonProps extends Omit, 'size'>, - Pick, + Pick, Pick, 'target' | 'download'> { /** * Компонент иконки diff --git a/packages/number-input/package.json b/packages/number-input/package.json index 0c60dbfaa6..55634c91cb 100644 --- a/packages/number-input/package.json +++ b/packages/number-input/package.json @@ -13,6 +13,7 @@ "build": "rollup -c ../../tools/rollup/rollup.config.mjs --silent" }, "dependencies": { + "@alfalab/core-components-haptics": "^0.0.1", "@alfalab/core-components-icon-button": "^8.0.8", "@alfalab/core-components-input": "^17.1.8", "@alfalab/core-components-mq": "^6.0.5", diff --git a/packages/number-input/src/components/number-input/Component.tsx b/packages/number-input/src/components/number-input/Component.tsx index 68c0d98e57..8aa99f60e5 100644 --- a/packages/number-input/src/components/number-input/Component.tsx +++ b/packages/number-input/src/components/number-input/Component.tsx @@ -13,6 +13,7 @@ import mergeRefs from 'react-merge-refs'; import { type MaskitoOptions, maskitoTransform } from '@maskito/core'; import { useMaskito } from '@maskito/react'; +import { type HapticConfig } from '@alfalab/core-components-haptics'; import { type InputProps } from '@alfalab/core-components-input'; import { fnUtils, isIOS } from '@alfalab/core-components-shared'; @@ -86,6 +87,12 @@ export interface NumberInputProps * Для кнопки инкремента используется модификатор -increment-button, декремента -decrement-button */ dataTestId?: string; + + /** + * Haptic-пресет или кастомный vibration-конфиг для кнопок increment/decrement. + * @default selection + */ + 'data-haptic-preset'?: HapticConfig['data-haptic-preset']; } export const NumberInput = forwardRef( @@ -110,6 +117,7 @@ export const NumberInput = forwardRef( disableUserInput, clear: clearProp, colors = 'default', + 'data-haptic-preset': dataHapticPreset, ...restProps }, ref, @@ -242,6 +250,7 @@ export const NumberInput = forwardRef( onIncrement={handleIncrement} onDecrement={handleDecrement} size={size} + data-haptic-preset={dataHapticPreset} /> )} diff --git a/packages/number-input/src/components/steppers/Component.tsx b/packages/number-input/src/components/steppers/Component.tsx index 1630f770d1..0f116aebc1 100644 --- a/packages/number-input/src/components/steppers/Component.tsx +++ b/packages/number-input/src/components/steppers/Component.tsx @@ -1,6 +1,7 @@ import React, { type FC } from 'react'; import cn from 'classnames'; +import { type HapticConfig } from '@alfalab/core-components-haptics'; import { IconButton } from '@alfalab/core-components-icon-button'; import { type InputProps } from '@alfalab/core-components-input'; import { getDataTestId } from '@alfalab/core-components-shared'; @@ -25,6 +26,7 @@ export type SteppersProps = { dataTestId?: string; colors: 'default' | 'inverted'; size: InputProps['size']; + 'data-haptic-preset'?: HapticConfig['data-haptic-preset']; }; const colorStyles = { @@ -60,6 +62,7 @@ export const Steppers: FC = ({ dataTestId, colors, size = 48, + 'data-haptic-preset': dataHapticPreset, }) => { const decButtonDisabled = disabled || value <= min; const incButtonDisabled = disabled || value >= max; @@ -89,6 +92,7 @@ export const Steppers: FC = ({ onMouseDown={preventDefault} onClick={onDecrement} dataTestId={getDataTestId(dataTestId, 'decrement-button')} + data-haptic-preset={dataHapticPreset} view='secondary' />
@@ -101,6 +105,7 @@ export const Steppers: FC = ({ onMouseDown={preventDefault} onClick={onIncrement} dataTestId={getDataTestId(dataTestId, 'increment-button')} + data-haptic-preset={dataHapticPreset} view='secondary' />
diff --git a/packages/number-input/tsconfig.build.json b/packages/number-input/tsconfig.build.json index 38c43c6b63..4e7f047257 100644 --- a/packages/number-input/tsconfig.build.json +++ b/packages/number-input/tsconfig.build.json @@ -7,6 +7,8 @@ "rootDir": "src", "outDir": "ts-dist", "paths": { + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-icon-button": ["../icon-button/src"], "@alfalab/core-components-icon-button/*": ["../icon-button/src/*"], "@alfalab/core-components-input": ["../input/src"], @@ -20,6 +22,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../icon-button/tsconfig.build.json" }, { "path": "../input/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }, diff --git a/packages/number-input/tsconfig.json b/packages/number-input/tsconfig.json index d5149860d4..1e269b47b6 100644 --- a/packages/number-input/tsconfig.json +++ b/packages/number-input/tsconfig.json @@ -6,6 +6,8 @@ "rootDir": "src", "outDir": "no-dist", "paths": { + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-icon-button": ["../icon-button/src"], "@alfalab/core-components-icon-button/*": ["../icon-button/src/*"], "@alfalab/core-components-input": ["../input/src"], @@ -23,6 +25,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../icon-button/tsconfig.build.json" }, { "path": "../input/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }, diff --git a/packages/radio/package.json b/packages/radio/package.json index 712831cb70..fe593b306d 100644 --- a/packages/radio/package.json +++ b/packages/radio/package.json @@ -13,6 +13,7 @@ "build": "rollup -c ../../tools/rollup/rollup.config.mjs --silent" }, "dependencies": { + "@alfalab/core-components-haptics": "^0.0.1", "@alfalab/core-components-shared": "^2.2.1", "@alfalab/hooks": "^1.17.0", "classnames": "^2.5.1", diff --git a/packages/radio/src/Component.tsx b/packages/radio/src/Component.tsx index 0d115f93f3..80721bbe94 100644 --- a/packages/radio/src/Component.tsx +++ b/packages/radio/src/Component.tsx @@ -11,6 +11,7 @@ import React, { import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; +import { type HapticConfig, HapticInput } from '@alfalab/core-components-haptics'; import { dom } from '@alfalab/core-components-shared'; import { useFocus } from '@alfalab/hooks'; @@ -117,6 +118,12 @@ export type RadioProps = Omit< * @default default */ colors?: 'default' | 'inverted'; + + /** + * Haptic-пресет или кастомный vibration-конфиг для выбора radio. + * @default selection + */ + 'data-haptic-preset'?: HapticConfig['data-haptic-preset']; }; export const Radio = forwardRef( @@ -138,6 +145,7 @@ export const Radio = forwardRef( block, labelProps, colors = 'default', + 'data-haptic-preset': dataHapticPreset, ...restProps }, ref, @@ -175,13 +183,14 @@ export const Radio = forwardRef( )} ref={mergeRefs([labelRef, ref, labelProps?.ref as Ref])} > - diff --git a/packages/radio/tsconfig.build.json b/packages/radio/tsconfig.build.json index d6012fb8e9..9eeaa73cb6 100644 --- a/packages/radio/tsconfig.build.json +++ b/packages/radio/tsconfig.build.json @@ -7,11 +7,16 @@ "rootDir": "src", "outDir": "ts-dist", "paths": { + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-radio": ["./src"], "@alfalab/core-components-radio/*": ["./src/*"], "@alfalab/core-components-shared": ["../shared/src"], "@alfalab/core-components-shared/*": ["../shared/src/*"] } }, - "references": [{ "path": "../shared/tsconfig.build.json" }] + "references": [ + { "path": "../haptics/tsconfig.build.json" }, + { "path": "../shared/tsconfig.build.json" } + ] } diff --git a/packages/radio/tsconfig.json b/packages/radio/tsconfig.json index 23e0309ebb..84c3e44f2f 100644 --- a/packages/radio/tsconfig.json +++ b/packages/radio/tsconfig.json @@ -6,6 +6,8 @@ "rootDir": "src", "outDir": "no-dist", "paths": { + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-radio": ["./src"], "@alfalab/core-components-radio/*": ["./src/*"], "@alfalab/core-components-screenshot-utils": ["../screenshot-utils/src"], @@ -17,6 +19,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../screenshot-utils/tsconfig.build.json" }, { "path": "../shared/tsconfig.build.json" }, { "path": "../test-utils/tsconfig.build.json" } diff --git a/packages/root/package.json b/packages/root/package.json index c8c9ee2a80..87558b0c69 100644 --- a/packages/root/package.json +++ b/packages/root/package.json @@ -58,6 +58,7 @@ "@alfalab/core-components-generic-wrapper": "^3.0.2", "@alfalab/core-components-global-store": "^4.0.1", "@alfalab/core-components-grid": "^5.0.2", + "@alfalab/core-components-haptics": "^0.0.1", "@alfalab/core-components-hatching-progress-bar": "^4.0.1", "@alfalab/core-components-icon-button": "^8.0.8", "@alfalab/core-components-icon-view": "^5.0.4", diff --git a/packages/root/tsconfig.build.json b/packages/root/tsconfig.build.json index 7b8e8079d6..c70d2e089b 100644 --- a/packages/root/tsconfig.build.json +++ b/packages/root/tsconfig.build.json @@ -101,6 +101,8 @@ "@alfalab/core-components-global-store/*": ["../global-store/src/*"], "@alfalab/core-components-grid": ["../grid/src"], "@alfalab/core-components-grid/*": ["../grid/src/*"], + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-hatching-progress-bar": ["../hatching-progress-bar/src"], "@alfalab/core-components-hatching-progress-bar/*": ["../hatching-progress-bar/src/*"], "@alfalab/core-components-icon-button": ["../icon-button/src"], @@ -312,6 +314,7 @@ { "path": "../generic-wrapper/tsconfig.build.json" }, { "path": "../global-store/tsconfig.build.json" }, { "path": "../grid/tsconfig.build.json" }, + { "path": "../haptics/tsconfig.build.json" }, { "path": "../hatching-progress-bar/tsconfig.build.json" }, { "path": "../icon-button/tsconfig.build.json" }, { "path": "../icon-view/tsconfig.build.json" }, diff --git a/packages/switch/package.json b/packages/switch/package.json index 83c9974219..e84a1e9c24 100644 --- a/packages/switch/package.json +++ b/packages/switch/package.json @@ -13,6 +13,7 @@ "build": "rollup -c ../../tools/rollup/rollup.config.mjs --silent" }, "dependencies": { + "@alfalab/core-components-haptics": "^0.0.1", "@alfalab/core-components-shared": "^2.2.1", "@alfalab/core-components-skeleton": "^7.0.4", "@alfalab/hooks": "^1.17.0", diff --git a/packages/switch/src/Component.tsx b/packages/switch/src/Component.tsx index ccad3c3230..5dd5bef604 100644 --- a/packages/switch/src/Component.tsx +++ b/packages/switch/src/Component.tsx @@ -8,6 +8,7 @@ import React, { import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; +import { type HapticConfig, HapticInput } from '@alfalab/core-components-haptics'; import { dom } from '@alfalab/core-components-shared'; import { Skeleton } from '@alfalab/core-components-skeleton'; import { useFocus } from '@alfalab/hooks'; @@ -101,6 +102,12 @@ export type SwitchProps = Omit< * @default false */ showSkeleton?: boolean; + + /** + * Haptic-пресет или кастомный vibration-конфиг для переключения switch. + * @default selection + */ + 'data-haptic-preset'?: HapticConfig['data-haptic-preset']; }; export const Switch = forwardRef( @@ -122,6 +129,7 @@ export const Switch = forwardRef( dataTestId, colors = 'default', showSkeleton = false, + 'data-haptic-preset': dataHapticPreset, ...restProps }, ref, @@ -153,7 +161,7 @@ export const Switch = forwardRef( })} ref={mergeRefs([labelRef, ref])} > - ( name={name} value={value} data-test-id={dataTestId} + data-haptic-preset={dataHapticPreset} {...restProps} /> diff --git a/packages/switch/tsconfig.build.json b/packages/switch/tsconfig.build.json index a61f335b4d..c37e30a1c4 100644 --- a/packages/switch/tsconfig.build.json +++ b/packages/switch/tsconfig.build.json @@ -7,6 +7,8 @@ "rootDir": "src", "outDir": "ts-dist", "paths": { + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-shared": ["../shared/src"], "@alfalab/core-components-shared/*": ["../shared/src/*"], "@alfalab/core-components-skeleton": ["../skeleton/src"], @@ -16,6 +18,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../shared/tsconfig.build.json" }, { "path": "../skeleton/tsconfig.build.json" } ] diff --git a/packages/switch/tsconfig.json b/packages/switch/tsconfig.json index 2ee9775a99..3da6e15d7c 100644 --- a/packages/switch/tsconfig.json +++ b/packages/switch/tsconfig.json @@ -6,6 +6,8 @@ "rootDir": "src", "outDir": "no-dist", "paths": { + "@alfalab/core-components-haptics": ["../haptics/src"], + "@alfalab/core-components-haptics/*": ["../haptics/src/*"], "@alfalab/core-components-screenshot-utils": ["../screenshot-utils/src"], "@alfalab/core-components-screenshot-utils/*": ["../screenshot-utils/src/*"], "@alfalab/core-components-shared": ["../shared/src"], @@ -19,6 +21,7 @@ } }, "references": [ + { "path": "../haptics/tsconfig.build.json" }, { "path": "../screenshot-utils/tsconfig.build.json" }, { "path": "../shared/tsconfig.build.json" }, { "path": "../skeleton/tsconfig.build.json" }, diff --git a/tsconfig.react-docgen-typescript.json b/tsconfig.react-docgen-typescript.json index 4f8fefd7e0..2feeb20b45 100644 --- a/tsconfig.react-docgen-typescript.json +++ b/tsconfig.react-docgen-typescript.json @@ -110,6 +110,8 @@ "@alfalab/core-components-global-store/*": ["./packages/global-store/src/*"], "@alfalab/core-components-grid": ["./packages/grid/src"], "@alfalab/core-components-grid/*": ["./packages/grid/src/*"], + "@alfalab/core-components-haptics": ["./packages/haptics/src"], + "@alfalab/core-components-haptics/*": ["./packages/haptics/src/*"], "@alfalab/core-components-hatching-progress-bar": [ "./packages/hatching-progress-bar/src" ], diff --git a/tsconfig.test.json b/tsconfig.test.json index 18ef111f34..a9d9571f56 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -109,6 +109,8 @@ "@alfalab/core-components-global-store/*": ["./packages/global-store/src/*"], "@alfalab/core-components-grid": ["./packages/grid/src"], "@alfalab/core-components-grid/*": ["./packages/grid/src/*"], + "@alfalab/core-components-haptics": ["./packages/haptics/src"], + "@alfalab/core-components-haptics/*": ["./packages/haptics/src/*"], "@alfalab/core-components-hatching-progress-bar": [ "./packages/hatching-progress-bar/src" ], diff --git a/yarn.lock b/yarn.lock index c5db5e8c5e..304e34b4f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -241,6 +241,7 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/core-components-button@workspace:packages/button" dependencies: + "@alfalab/core-components-haptics": "npm:^0.0.1" "@alfalab/core-components-mq": "npm:^6.0.5" "@alfalab/core-components-shared": "npm:^2.2.1" "@alfalab/core-components-spinner": "npm:^6.0.5" @@ -409,6 +410,7 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/core-components-checkbox@workspace:packages/checkbox" dependencies: + "@alfalab/core-components-haptics": "npm:^0.0.1" "@alfalab/core-components-shared": "npm:^2.2.1" "@alfalab/hooks": "npm:^1.17.0" classnames: "npm:^2.5.1" @@ -500,7 +502,7 @@ __metadata: languageName: unknown linkType: soft -"@alfalab/core-components-config@workspace:packages/config": +"@alfalab/core-components-config@npm:^1.1.0, @alfalab/core-components-config@workspace:packages/config": version: 0.0.0-use.local resolution: "@alfalab/core-components-config@workspace:packages/config" dependencies: @@ -816,6 +818,19 @@ __metadata: languageName: unknown linkType: soft +"@alfalab/core-components-haptics@npm:^0.0.1, @alfalab/core-components-haptics@workspace:packages/haptics": + version: 0.0.0-use.local + resolution: "@alfalab/core-components-haptics@workspace:packages/haptics" + dependencies: + "@alfalab/core-components-config": "npm:^1.1.0" + tslib: "npm:^2.4.0" + web-haptics: "npm:^0.0.6" + peerDependencies: + react: ^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 + react-dom: ^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 + languageName: unknown + linkType: soft + "@alfalab/core-components-hatching-progress-bar@npm:^4.0.1, @alfalab/core-components-hatching-progress-bar@workspace:packages/hatching-progress-bar": version: 0.0.0-use.local resolution: "@alfalab/core-components-hatching-progress-bar@workspace:packages/hatching-progress-bar" @@ -1173,6 +1188,7 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/core-components-number-input@workspace:packages/number-input" dependencies: + "@alfalab/core-components-haptics": "npm:^0.0.1" "@alfalab/core-components-icon-button": "npm:^8.0.8" "@alfalab/core-components-input": "npm:^17.1.8" "@alfalab/core-components-mq": "npm:^6.0.5" @@ -1478,6 +1494,7 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/core-components-radio@workspace:packages/radio" dependencies: + "@alfalab/core-components-haptics": "npm:^0.0.1" "@alfalab/core-components-shared": "npm:^2.2.1" "@alfalab/hooks": "npm:^1.17.0" classnames: "npm:^2.5.1" @@ -1897,6 +1914,7 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/core-components-switch@workspace:packages/switch" dependencies: + "@alfalab/core-components-haptics": "npm:^0.0.1" "@alfalab/core-components-shared": "npm:^2.2.1" "@alfalab/core-components-skeleton": "npm:^7.0.4" "@alfalab/hooks": "npm:^1.17.0" @@ -2263,6 +2281,7 @@ __metadata: "@alfalab/core-components-generic-wrapper": "npm:^3.0.2" "@alfalab/core-components-global-store": "npm:^4.0.1" "@alfalab/core-components-grid": "npm:^5.0.2" + "@alfalab/core-components-haptics": "npm:^0.0.1" "@alfalab/core-components-hatching-progress-bar": "npm:^4.0.1" "@alfalab/core-components-icon-button": "npm:^8.0.8" "@alfalab/core-components-icon-view": "npm:^5.0.4" @@ -33933,6 +33952,27 @@ __metadata: languageName: node linkType: hard +"web-haptics@npm:^0.0.6": + version: 0.0.6 + resolution: "web-haptics@npm:0.0.6" + peerDependencies: + react: ">=18" + react-dom: ">=18" + svelte: ">=4" + vue: ">=3" + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + checksum: 10c0/71d3bfb0299469362e23ef177337f2cf665ed66aa49b6d5e7afc9d97fc05af9a2056263c17766eb929eb86ec1f526fba21502e926ea3cc39175f251c5c9374db + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"