A design system in a box. hip-ui.tngl.io/docs/introduction
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

draggable input

+460 -20
-1
README.md
··· 26 26 27 27 #### OTher Wrappers 28 28 29 - - [ ] Canvas (tldraw) 30 29 - [ ] RichTextEditor (lexical) 31 30 - [ ] Window Splitter 32 31
+1
apps/docs/package.json
··· 12 12 "dependencies": { 13 13 "@mdx-js/react": "^3.1.1", 14 14 "@mdx-js/rollup": "^3.1.1", 15 + "@react-aria/overlays": "^3.30.0", 15 16 "@react-aria/utils": "^3.31.0", 16 17 "@react-stately/utils": "^3.10.8", 17 18 "@react-types/overlays": "^3.9.2",
+63 -6
apps/docs/src/components/number-field/index.tsx
··· 1 + import { useUNSAFE_PortalContext } from "@react-aria/overlays"; 1 2 import * as stylex from "@stylexjs/stylex"; 2 - import { Minus, Plus } from "lucide-react"; 3 + import { Minus, MoveHorizontal, Plus } from "lucide-react"; 3 4 import { use, useRef } from "react"; 5 + import { mergeProps } from "react-aria"; 4 6 import { 5 7 NumberFieldProps as AriaNumberFieldProps, 6 8 Input, ··· 9 11 NumberField as AriaNumberField, 10 12 Group, 11 13 Button, 14 + NumberFieldStateContext, 12 15 } from "react-aria-components"; 16 + import { createPortal } from "react-dom"; 13 17 14 18 import { SizeContext } from "../context"; 15 19 import { Description, FieldErrorMessage, Label } from "../label"; ··· 17 21 import { spacing } from "../theme/spacing.stylex"; 18 22 import { InputVariant, Size, StyleXComponentProps } from "../theme/types"; 19 23 import { useInputStyles } from "../theme/useInputStyles"; 24 + import { usePointerLock } from "./usePointerLock"; 25 + 26 + interface NumberInputWrapperProps { 27 + style: stylex.StyleXStyles; 28 + onClick: () => void; 29 + children: React.ReactNode; 30 + } 31 + 32 + function clamp(value: number, min: number, max: number) { 33 + return Math.min(Math.max(value, min), max); 34 + } 35 + 36 + function NumberInputWrapper({ 37 + style, 38 + onClick, 39 + children, 40 + }: NumberInputWrapperProps) { 41 + const state = use(NumberFieldStateContext); 42 + const { lockProps, isLocked, cursorProps } = usePointerLock({ 43 + onMove(e) { 44 + if (!state) return; 45 + state.setNumberValue( 46 + clamp( 47 + state.numberValue + e.deltaX, 48 + state.minValue ?? -Infinity, 49 + state.maxValue ?? Infinity, 50 + ), 51 + ); 52 + }, 53 + }); 54 + const { getContainer } = useUNSAFE_PortalContext(); 55 + 56 + return ( 57 + <div 58 + {...stylex.props(styles.wrapper, style)} 59 + {...mergeProps(lockProps, { onClick })} 60 + > 61 + {children} 62 + {isLocked && 63 + createPortal( 64 + <div {...cursorProps}> 65 + <MoveHorizontal size={16} /> 66 + </div>, 67 + getContainer?.() ?? document.body, 68 + )} 69 + </div> 70 + ); 71 + } 20 72 21 73 const styles = stylex.create({ 74 + wrapper: { 75 + cursor: "ew-resize", 76 + }, 77 + input: { 78 + cursor: "text", 79 + }, 22 80 buttons: { 23 81 display: "flex", 24 82 }, ··· 93 151 This onClick is specifically for mouse users not clicking directly on the input. 94 152 A keyboard user would not encounter the same issue. 95 153 */} 96 - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} 97 - <div 98 - {...stylex.props(inputStyles.wrapper)} 154 + <NumberInputWrapper 155 + style={inputStyles.wrapper} 99 156 onClick={() => inputRef.current?.focus()} 100 157 > 101 158 {prefix != null && ( ··· 104 161 <Input 105 162 placeholder={placeholder} 106 163 ref={inputRef} 107 - {...stylex.props(inputStyles.input)} 164 + {...stylex.props(styles.input, inputStyles.input)} 108 165 /> 109 166 {suffix != null && ( 110 167 <div {...stylex.props(inputStyles.addon)}>{suffix}</div> ··· 119 176 </Button> 120 177 </Group> 121 178 )} 122 - </div> 179 + </NumberInputWrapper> 123 180 <Description>{description}</Description> 124 181 <FieldErrorMessage>{errorMessage}</FieldErrorMessage> 125 182 </AriaNumberField>
+158
apps/docs/src/components/number-field/usePointerLock.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + 3 + const noop = () => {}; 4 + 5 + export function usePointerLock({ 6 + onMove: onMoveProp, 7 + }: { 8 + onMove?: (e: { deltaX: number; deltaY: number }) => void; 9 + }) { 10 + const onMove = useRef(onMoveProp ?? noop); 11 + useEffect(() => { 12 + onMove.current = onMoveProp ?? noop; 13 + }, [onMoveProp]); 14 + const elementRef = useRef<HTMLElement | null>(null); 15 + const [isLocked, setIsLocked] = useState(false); 16 + const [pointerPosition, setPointerPosition] = useState<{ 17 + x: number; 18 + y: number; 19 + } | null>(null); 20 + 21 + // Handle pointer lock state changes 22 + useEffect(() => { 23 + const handlePointerLockChange = () => { 24 + const isCurrentlyLocked = 25 + document.pointerLockElement === elementRef.current; 26 + setIsLocked(isCurrentlyLocked); 27 + 28 + if (!isCurrentlyLocked) { 29 + setPointerPosition(null); 30 + } 31 + }; 32 + 33 + const handlePointerLockError = () => { 34 + setIsLocked(false); 35 + setPointerPosition(null); 36 + }; 37 + 38 + document.addEventListener("pointerlockchange", handlePointerLockChange); 39 + document.addEventListener("pointerlockerror", handlePointerLockError); 40 + 41 + return () => { 42 + document.removeEventListener( 43 + "pointerlockchange", 44 + handlePointerLockChange, 45 + ); 46 + document.removeEventListener("pointerlockerror", handlePointerLockError); 47 + }; 48 + }, []); 49 + 50 + // Track mouse movement while locked 51 + useEffect(() => { 52 + if (!isLocked) return; 53 + 54 + const handleMouseMove = (e: MouseEvent) => { 55 + if (!elementRef.current) return; 56 + 57 + // Get current cursor position or initialize it 58 + setPointerPosition((prev) => { 59 + const currentX = prev?.x ?? window.innerWidth / 2; 60 + const currentY = prev?.y ?? window.innerHeight / 2; 61 + 62 + // Calculate new position with movement deltas 63 + let newX = currentX + e.movementX; 64 + let newY = currentY + e.movementY; 65 + 66 + // Wrap around screen edges 67 + if (newX < 0) { 68 + newX = window.innerWidth; 69 + } else if (newX > window.innerWidth) { 70 + newX = 0; 71 + } 72 + 73 + if (newY < 0) { 74 + newY = window.innerHeight; 75 + } else if (newY > window.innerHeight) { 76 + newY = 0; 77 + } 78 + 79 + onMove.current({ 80 + deltaX: e.movementX, 81 + deltaY: e.movementY, 82 + }); 83 + 84 + return { x: newX, y: newY }; 85 + }); 86 + }; 87 + 88 + document.addEventListener("mousemove", handleMouseMove); 89 + 90 + return () => { 91 + document.removeEventListener("mousemove", handleMouseMove); 92 + }; 93 + }, [isLocked]); 94 + 95 + // Request pointer lock on pointer down 96 + const onPointerDown = useCallback((e: React.PointerEvent) => { 97 + if (e.button !== 0) return; // Only handle primary button 98 + if (e.target instanceof HTMLInputElement) return; 99 + 100 + const target = e.currentTarget as HTMLElement; 101 + elementRef.current = target; 102 + 103 + // Initialize cursor position 104 + setPointerPosition({ x: e.clientX, y: e.clientY }); 105 + 106 + // Request pointer lock 107 + void target.requestPointerLock(); 108 + }, []); 109 + 110 + // Handle pointer up to exit pointer lock 111 + useEffect(() => { 112 + if (!isLocked) return; 113 + 114 + const handlePointerUp = () => { 115 + if (document.pointerLockElement) { 116 + document.exitPointerLock(); 117 + } 118 + }; 119 + 120 + document.addEventListener("pointerup", handlePointerUp); 121 + return () => { 122 + document.removeEventListener("pointerup", handlePointerUp); 123 + }; 124 + }, [isLocked]); 125 + 126 + // Handle escape key or other unlock scenarios 127 + useEffect(() => { 128 + if (!isLocked) return; 129 + 130 + const handleKeyDown = (e: KeyboardEvent) => { 131 + if (e.key === "Escape" && document.pointerLockElement) { 132 + document.exitPointerLock(); 133 + } 134 + }; 135 + 136 + document.addEventListener("keydown", handleKeyDown); 137 + return () => { 138 + document.removeEventListener("keydown", handleKeyDown); 139 + }; 140 + }, [isLocked]); 141 + 142 + return { 143 + isLocked, 144 + cursorProps: { 145 + style: { 146 + cursor: "ew-resize", 147 + position: "fixed", 148 + top: pointerPosition?.y ?? 0, 149 + left: pointerPosition?.x ?? 0, 150 + transform: "translate(-50%, -50%)", 151 + pointerEvents: "none", 152 + }, 153 + } as React.ComponentProps<"div">, 154 + lockProps: { 155 + onPointerDown, 156 + }, 157 + }; 158 + }
+3 -1
apps/docs/src/showcases/canvas-editor.tsx
··· 970 970 hideStepper 971 971 style={styles.grow} 972 972 value={shape.props.w} 973 + minValue={1} 973 974 prefix={ 974 975 <Text size="xs" weight="semibold" variant="secondary"> 975 976 W ··· 988 989 hideStepper 989 990 style={styles.grow} 990 991 value={shape.props.h} 992 + minValue={1} 991 993 prefix={ 992 994 <Text size="xs" weight="semibold" variant="secondary"> 993 995 H ··· 1249 1251 components={{ 1250 1252 Toolbar: null, 1251 1253 NavigationPanel: null, 1252 - // MenuPanel: null, 1254 + MenuPanel: null, 1253 1255 StylePanel: null, 1254 1256 }} 1255 1257 >
+1
packages/hip-ui/package.json
··· 28 28 "dependencies": { 29 29 "@inkjs/ui": "^2.0.0", 30 30 "@radix-ui/colors": "^3.0.0", 31 + "@react-aria/overlays": "^3.30.0", 31 32 "@react-aria/utils": "^3.31.0", 32 33 "@react-stately/utils": "catalog:", 33 34 "@react-types/overlays": "catalog:",
+63 -6
packages/hip-ui/src/components/number-field/index.tsx
··· 1 + import { useUNSAFE_PortalContext } from "@react-aria/overlays"; 1 2 import * as stylex from "@stylexjs/stylex"; 2 - import { Minus, Plus } from "lucide-react"; 3 + import { Minus, MoveHorizontal, Plus } from "lucide-react"; 3 4 import { use, useRef } from "react"; 5 + import { mergeProps } from "react-aria"; 4 6 import { 5 7 NumberFieldProps as AriaNumberFieldProps, 6 8 Input, ··· 9 11 NumberField as AriaNumberField, 10 12 Group, 11 13 Button, 14 + NumberFieldStateContext, 12 15 } from "react-aria-components"; 16 + import { createPortal } from "react-dom"; 13 17 14 18 import { SizeContext } from "../context"; 15 19 import { Description, FieldErrorMessage, Label } from "../label"; ··· 17 21 import { spacing } from "../theme/spacing.stylex"; 18 22 import { InputVariant, Size, StyleXComponentProps } from "../theme/types"; 19 23 import { useInputStyles } from "../theme/useInputStyles"; 24 + import { usePointerLock } from "./usePointerLock"; 25 + 26 + interface NumberInputWrapperProps { 27 + style: stylex.StyleXStyles; 28 + onClick: () => void; 29 + children: React.ReactNode; 30 + } 31 + 32 + function clamp(value: number, min: number, max: number) { 33 + return Math.min(Math.max(value, min), max); 34 + } 35 + 36 + function NumberInputWrapper({ 37 + style, 38 + onClick, 39 + children, 40 + }: NumberInputWrapperProps) { 41 + const state = use(NumberFieldStateContext); 42 + const { lockProps, isLocked, cursorProps } = usePointerLock({ 43 + onMove(e) { 44 + if (!state) return; 45 + state.setNumberValue( 46 + clamp( 47 + state.numberValue + e.deltaX, 48 + state.minValue ?? -Infinity, 49 + state.maxValue ?? Infinity, 50 + ), 51 + ); 52 + }, 53 + }); 54 + const { getContainer } = useUNSAFE_PortalContext(); 55 + 56 + return ( 57 + <div 58 + {...stylex.props(styles.wrapper, style)} 59 + {...mergeProps(lockProps, { onClick })} 60 + > 61 + {children} 62 + {isLocked && 63 + createPortal( 64 + <div {...cursorProps}> 65 + <MoveHorizontal size={16} /> 66 + </div>, 67 + getContainer?.() ?? document.body, 68 + )} 69 + </div> 70 + ); 71 + } 20 72 21 73 const styles = stylex.create({ 74 + wrapper: { 75 + cursor: "ew-resize", 76 + }, 77 + input: { 78 + cursor: "text", 79 + }, 22 80 buttons: { 23 81 display: "flex", 24 82 }, ··· 93 151 This onClick is specifically for mouse users not clicking directly on the input. 94 152 A keyboard user would not encounter the same issue. 95 153 */} 96 - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} 97 - <div 98 - {...stylex.props(inputStyles.wrapper)} 154 + <NumberInputWrapper 155 + style={inputStyles.wrapper} 99 156 onClick={() => inputRef.current?.focus()} 100 157 > 101 158 {prefix != null && ( ··· 104 161 <Input 105 162 placeholder={placeholder} 106 163 ref={inputRef} 107 - {...stylex.props(inputStyles.input)} 164 + {...stylex.props(styles.input, inputStyles.input)} 108 165 /> 109 166 {suffix != null && ( 110 167 <div {...stylex.props(inputStyles.addon)}>{suffix}</div> ··· 119 176 </Button> 120 177 </Group> 121 178 )} 122 - </div> 179 + </NumberInputWrapper> 123 180 <Description>{description}</Description> 124 181 <FieldErrorMessage>{errorMessage}</FieldErrorMessage> 125 182 </AriaNumberField>
+1
packages/hip-ui/src/components/number-field/number-field-config.ts
··· 13 13 "../theme/useInputStyles.ts", 14 14 ], 15 15 dependencies: { 16 + "@react-aria/overlays": "^3.30.0", 16 17 "lucide-react": "^0.545.0", 17 18 }, 18 19 };
+158
packages/hip-ui/src/components/number-field/usePointerLock.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + 3 + const noop = () => {}; 4 + 5 + export function usePointerLock({ 6 + onMove: onMoveProp, 7 + }: { 8 + onMove?: (e: { deltaX: number; deltaY: number }) => void; 9 + }) { 10 + const onMove = useRef(onMoveProp ?? noop); 11 + useEffect(() => { 12 + onMove.current = onMoveProp ?? noop; 13 + }, [onMoveProp]); 14 + const elementRef = useRef<HTMLElement | null>(null); 15 + const [isLocked, setIsLocked] = useState(false); 16 + const [pointerPosition, setPointerPosition] = useState<{ 17 + x: number; 18 + y: number; 19 + } | null>(null); 20 + 21 + // Handle pointer lock state changes 22 + useEffect(() => { 23 + const handlePointerLockChange = () => { 24 + const isCurrentlyLocked = 25 + document.pointerLockElement === elementRef.current; 26 + setIsLocked(isCurrentlyLocked); 27 + 28 + if (!isCurrentlyLocked) { 29 + setPointerPosition(null); 30 + } 31 + }; 32 + 33 + const handlePointerLockError = () => { 34 + setIsLocked(false); 35 + setPointerPosition(null); 36 + }; 37 + 38 + document.addEventListener("pointerlockchange", handlePointerLockChange); 39 + document.addEventListener("pointerlockerror", handlePointerLockError); 40 + 41 + return () => { 42 + document.removeEventListener( 43 + "pointerlockchange", 44 + handlePointerLockChange, 45 + ); 46 + document.removeEventListener("pointerlockerror", handlePointerLockError); 47 + }; 48 + }, []); 49 + 50 + // Track mouse movement while locked 51 + useEffect(() => { 52 + if (!isLocked) return; 53 + 54 + const handleMouseMove = (e: MouseEvent) => { 55 + if (!elementRef.current) return; 56 + 57 + // Get current cursor position or initialize it 58 + setPointerPosition((prev) => { 59 + const currentX = prev?.x ?? window.innerWidth / 2; 60 + const currentY = prev?.y ?? window.innerHeight / 2; 61 + 62 + // Calculate new position with movement deltas 63 + let newX = currentX + e.movementX; 64 + let newY = currentY + e.movementY; 65 + 66 + // Wrap around screen edges 67 + if (newX < 0) { 68 + newX = window.innerWidth; 69 + } else if (newX > window.innerWidth) { 70 + newX = 0; 71 + } 72 + 73 + if (newY < 0) { 74 + newY = window.innerHeight; 75 + } else if (newY > window.innerHeight) { 76 + newY = 0; 77 + } 78 + 79 + onMove.current({ 80 + deltaX: e.movementX, 81 + deltaY: e.movementY, 82 + }); 83 + 84 + return { x: newX, y: newY }; 85 + }); 86 + }; 87 + 88 + document.addEventListener("mousemove", handleMouseMove); 89 + 90 + return () => { 91 + document.removeEventListener("mousemove", handleMouseMove); 92 + }; 93 + }, [isLocked]); 94 + 95 + // Request pointer lock on pointer down 96 + const onPointerDown = useCallback((e: React.PointerEvent) => { 97 + if (e.button !== 0) return; // Only handle primary button 98 + if (e.target instanceof HTMLInputElement) return; 99 + 100 + const target = e.currentTarget as HTMLElement; 101 + elementRef.current = target; 102 + 103 + // Initialize cursor position 104 + setPointerPosition({ x: e.clientX, y: e.clientY }); 105 + 106 + // Request pointer lock 107 + void target.requestPointerLock(); 108 + }, []); 109 + 110 + // Handle pointer up to exit pointer lock 111 + useEffect(() => { 112 + if (!isLocked) return; 113 + 114 + const handlePointerUp = () => { 115 + if (document.pointerLockElement) { 116 + document.exitPointerLock(); 117 + } 118 + }; 119 + 120 + document.addEventListener("pointerup", handlePointerUp); 121 + return () => { 122 + document.removeEventListener("pointerup", handlePointerUp); 123 + }; 124 + }, [isLocked]); 125 + 126 + // Handle escape key or other unlock scenarios 127 + useEffect(() => { 128 + if (!isLocked) return; 129 + 130 + const handleKeyDown = (e: KeyboardEvent) => { 131 + if (e.key === "Escape" && document.pointerLockElement) { 132 + document.exitPointerLock(); 133 + } 134 + }; 135 + 136 + document.addEventListener("keydown", handleKeyDown); 137 + return () => { 138 + document.removeEventListener("keydown", handleKeyDown); 139 + }; 140 + }, [isLocked]); 141 + 142 + return { 143 + isLocked, 144 + cursorProps: { 145 + style: { 146 + cursor: "ew-resize", 147 + position: "fixed", 148 + top: pointerPosition?.y ?? 0, 149 + left: pointerPosition?.x ?? 0, 150 + transform: "translate(-50%, -50%)", 151 + pointerEvents: "none", 152 + }, 153 + } as React.ComponentProps<"div">, 154 + lockProps: { 155 + onPointerDown, 156 + }, 157 + }; 158 + }
+6
pnpm-lock.yaml
··· 83 83 '@mdx-js/rollup': 84 84 specifier: ^3.1.1 85 85 version: 3.1.1(rollup@4.52.5) 86 + '@react-aria/overlays': 87 + specifier: ^3.30.0 88 + version: 3.30.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) 86 89 '@react-aria/utils': 87 90 specifier: ^3.31.0 88 91 version: 3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) ··· 374 377 '@radix-ui/colors': 375 378 specifier: ^3.0.0 376 379 version: 3.0.0 380 + '@react-aria/overlays': 381 + specifier: ^3.30.0 382 + version: 3.30.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) 377 383 '@react-aria/utils': 378 384 specifier: ^3.31.0 379 385 version: 3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+6 -6
pnpm-workspace.yaml
··· 3 3 - packages/* 4 4 5 5 catalog: 6 - '@react-stately/utils': 3.10.8 7 - '@react-types/overlays': 3.9.2 8 - '@stylexjs/stylex': 0.16.2 9 - '@types/node': 24.9.1 10 - '@types/react': 19.2.0 11 - '@types/react-dom': 19.2.0 6 + "@react-stately/utils": 3.10.8 7 + "@react-types/overlays": 3.9.2 8 + "@stylexjs/stylex": 0.16.2 9 + "@types/node": 24.9.1 10 + "@types/react": 19.2.0 11 + "@types/react-dom": 19.2.0 12 12 change-case: 5.4.4 13 13 dedent: 1.7.0 14 14 eslint: 9.38.0