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.

lightbox

+975 -14
+37 -1
.oxlintrc.json
··· 1 - {"$schema":"./node_modules/oxlint/configuration_schema.json","extends":["config/oxlint/rules-base.json","config/oxlint/overrides.json"],"categories":{"correctness":"off"},"env":{"builtin":true,"es2026":true},"ignorePatterns":["**/node_modules","**/.DS_Store","**/dist","**/*.local","**/.env","**/.next","**/.nitro","**/.tanstack","**/.wrangler","**/.output","**/.vinxi","**/.content-collections","**/.vite","**/.nx/**","**/build/**","**/coverage/**","**/dist/**","**/snap/**","**/vite.config.*.timestamp-*.*","eslint.config.*","**/routeTree.gen.ts"],"jsPlugins":["@eslint-community/eslint-plugin-eslint-comments","eslint-plugin-perfectionist","@stylexjs/eslint-plugin"],"settings":{"import-x/resolver":{"typescript":{"alwaysTryTypes":true}}}} 1 + { 2 + "$schema": "./node_modules/oxlint/configuration_schema.json", 3 + "extends": ["config/oxlint/rules-base.json", "config/oxlint/overrides.json"], 4 + "categories": { "correctness": "off" }, 5 + "env": { "builtin": true, "es2026": true }, 6 + "ignorePatterns": [ 7 + "**/node_modules", 8 + "**/.DS_Store", 9 + "**/dist", 10 + "**/*.local", 11 + "**/.env", 12 + "**/.next", 13 + "**/.nitro", 14 + "**/.tanstack", 15 + "**/.wrangler", 16 + "**/.output", 17 + "**/.vinxi", 18 + "**/.content-collections", 19 + "**/.vite", 20 + "**/.nx/**", 21 + "**/build/**", 22 + "**/coverage/**", 23 + "**/dist/**", 24 + "**/snap/**", 25 + "**/vite.config.*.timestamp-*.*", 26 + "eslint.config.*", 27 + "**/routeTree.gen.ts" 28 + ], 29 + "jsPlugins": [ 30 + "@eslint-community/eslint-plugin-eslint-comments", 31 + "eslint-plugin-perfectionist", 32 + "@stylexjs/eslint-plugin" 33 + ], 34 + "settings": { 35 + "import-x/resolver": { "typescript": { "alwaysTryTypes": true } } 36 + } 37 + }
+414
apps/docs/src/components/lightbox/index.tsx
··· 1 + "use client"; 2 + 3 + import * as stylex from "@stylexjs/stylex"; 4 + import { ChevronLeft, ChevronRight, X } from "lucide-react"; 5 + import { 6 + useCallback, 7 + useEffect, 8 + useLayoutEffect, 9 + useRef, 10 + useState, 11 + } from "react"; 12 + import { 13 + Dialog as AriaDialog, 14 + DialogTrigger, 15 + Modal, 16 + ModalOverlay, 17 + } from "react-aria-components"; 18 + 19 + import type { StyleXComponentProps } from "../theme/types"; 20 + 21 + import { IconButton } from "../icon-button"; 22 + import { 23 + animationDuration, 24 + animationTimingFunction, 25 + animations, 26 + } from "../theme/animations.stylex"; 27 + import { ui } from "../theme/semantic-color.stylex"; 28 + import { spacing } from "../theme/spacing.stylex"; 29 + 30 + const SLIDE_DURATION_MS = 250; 31 + 32 + const styles = stylex.create({ 33 + overlay: { 34 + inset: 0, 35 + alignItems: "center", 36 + animationDuration: animationDuration.default, 37 + animationName: animations.fadeIn, 38 + animationTimingFunction: animationTimingFunction.easeIn, 39 + display: "flex", 40 + justifyContent: "center", 41 + opacity: { 42 + default: 1, 43 + ":is([data-exiting])": 0, 44 + }, 45 + position: "fixed", 46 + transitionDuration: { 47 + ":is([data-exiting])": animationDuration.fast, 48 + }, 49 + transitionProperty: "opacity", 50 + transitionTimingFunction: "ease-in-out", 51 + zIndex: 200, 52 + }, 53 + backdrop: { 54 + inset: 0, 55 + position: "absolute", 56 + }, 57 + modal: { 58 + outline: "none", 59 + alignItems: "center", 60 + display: "flex", 61 + flexDirection: "column", 62 + justifyContent: "center", 63 + position: "relative", 64 + zIndex: 1, 65 + height: "fit-content", 66 + maxHeight: "90vh", 67 + maxWidth: "90vw", 68 + // Shrink to fit content so backdrop remains clickable around the edges 69 + width: "fit-content", 70 + }, 71 + dialog: { 72 + outline: "none", 73 + alignItems: "center", 74 + display: "flex", 75 + flexDirection: "column", 76 + flexGrow: 1, 77 + justifyContent: "center", 78 + position: "relative", 79 + minHeight: 0, 80 + }, 81 + imageWrapper: { 82 + alignItems: "center", 83 + display: "flex", 84 + flexGrow: 1, 85 + justifyContent: "center", 86 + maxHeight: "100%", 87 + maxWidth: "100%", 88 + overflow: "hidden", 89 + minWidth: 0, 90 + position: "relative", 91 + }, 92 + image: { 93 + objectFit: "contain", 94 + height: "auto", 95 + maxHeight: "90vh", 96 + maxWidth: "100%", 97 + width: "auto", 98 + }, 99 + imageLayer: { 100 + inset: 0, 101 + alignItems: "center", 102 + display: "flex", 103 + justifyContent: "center", 104 + position: "absolute", 105 + height: "100%", 106 + width: "100%", 107 + }, 108 + imageLayerOutgoing: { 109 + zIndex: 2, 110 + }, 111 + imageLayerIncoming: { 112 + zIndex: 1, 113 + }, 114 + closeButton: { 115 + position: "fixed", 116 + zIndex: 210, 117 + right: spacing["4"], 118 + top: spacing["4"], 119 + }, 120 + hiddenTrigger: { 121 + display: "none", 122 + }, 123 + contentRow: { 124 + alignItems: "center", 125 + gap: spacing["4"], 126 + display: "flex", 127 + flexGrow: 1, 128 + justifyContent: "center", 129 + minHeight: 0, 130 + }, 131 + navButton: { 132 + flexShrink: 0, 133 + }, 134 + }); 135 + 136 + export interface LightboxProps extends StyleXComponentProps<object> { 137 + /** Whether the lightbox is open */ 138 + isOpen: boolean; 139 + /** Called when the lightbox should close */ 140 + onOpenChange: (isOpen: boolean) => void; 141 + /** Array of image URLs to display */ 142 + images: Array<string>; 143 + /** Initial index when opening (default 0) */ 144 + initialIndex?: number; 145 + /** Alt text for the current image */ 146 + alt?: string; 147 + } 148 + 149 + export function Lightbox({ 150 + isOpen, 151 + onOpenChange, 152 + images, 153 + initialIndex = 0, 154 + alt = "Image", 155 + style, 156 + }: LightboxProps) { 157 + const [currentIndex, setCurrentIndex] = useState(initialIndex); 158 + const [previousIndex, setPreviousIndex] = useState<number | null>(null); 159 + const [direction, setDirection] = useState<"next" | "prev">("next"); 160 + const [transitionSize, setTransitionSize] = useState<{ 161 + width: number; 162 + height: number; 163 + } | null>(null); 164 + const wrapperRef = useRef<HTMLDivElement>(null); 165 + const outgoingRef = useRef<HTMLDivElement>(null); 166 + const incomingRef = useRef<HTMLDivElement>(null); 167 + 168 + // Run slide animations via Web Animations API 169 + useLayoutEffect(() => { 170 + if (previousIndex === null) return; 171 + const wrapper = wrapperRef.current; 172 + const outgoing = outgoingRef.current; 173 + const incoming = incomingRef.current; 174 + if (!wrapper || !outgoing || !incoming) return; 175 + 176 + const runAnimation = () => { 177 + const viewportWidth = 178 + globalThis.visualViewport?.width ?? globalThis.innerWidth ?? 800; 179 + const wrapperWidth = transitionSize 180 + ? transitionSize.width 181 + : wrapper.offsetWidth || wrapper.getBoundingClientRect().width; 182 + const slideDistance = Math.max( 183 + viewportWidth, 184 + wrapperWidth ?? viewportWidth, 185 + ); 186 + 187 + const isNext = direction === "next"; 188 + const outKeyframes = isNext 189 + ? [ 190 + { transform: "translateX(0)" }, 191 + { transform: `translateX(-${slideDistance}px)` }, 192 + ] 193 + : [ 194 + { transform: "translateX(0)" }, 195 + { transform: `translateX(${slideDistance}px)` }, 196 + ]; 197 + const inKeyframes = isNext 198 + ? [ 199 + { transform: `translateX(${slideDistance}px)` }, 200 + { transform: "translateX(0)" }, 201 + ] 202 + : [ 203 + { transform: `translateX(-${slideDistance}px)` }, 204 + { transform: "translateX(0)" }, 205 + ]; 206 + 207 + outgoing.animate(outKeyframes, { 208 + duration: SLIDE_DURATION_MS, 209 + easing: "cubic-bezier(0.4, 0, 1, 1)", 210 + fill: "forwards", 211 + }); 212 + incoming 213 + .animate(inKeyframes, { 214 + duration: SLIDE_DURATION_MS, 215 + easing: "cubic-bezier(0, 0, 0.2, 1)", 216 + fill: "forwards", 217 + }) 218 + .finished.then(() => { 219 + setPreviousIndex(null); 220 + setTransitionSize(null); 221 + }); 222 + }; 223 + 224 + requestAnimationFrame(() => { 225 + requestAnimationFrame(runAnimation); 226 + }); 227 + }, [previousIndex, direction, transitionSize]); 228 + 229 + // Sync currentIndex when opening with a new initialIndex; reset transition state 230 + useEffect(() => { 231 + if (isOpen) { 232 + setCurrentIndex(Math.min(Math.max(0, initialIndex), images.length - 1)); 233 + setPreviousIndex(null); 234 + setTransitionSize(null); 235 + } 236 + }, [isOpen, initialIndex, images.length]); 237 + 238 + const captureAndGoPrev = useCallback(() => { 239 + if (previousIndex !== null) return; 240 + const wrapper = wrapperRef.current; 241 + if (wrapper) { 242 + setTransitionSize({ 243 + width: wrapper.offsetWidth, 244 + height: wrapper.offsetHeight, 245 + }); 246 + } 247 + setDirection("prev"); 248 + setPreviousIndex(currentIndex); 249 + setCurrentIndex((i) => (i <= 0 ? images.length - 1 : i - 1)); 250 + }, [images.length, currentIndex, previousIndex]); 251 + 252 + const captureAndGoNext = useCallback(() => { 253 + if (previousIndex !== null) return; 254 + const wrapper = wrapperRef.current; 255 + if (wrapper) { 256 + setTransitionSize({ 257 + width: wrapper.offsetWidth, 258 + height: wrapper.offsetHeight, 259 + }); 260 + } 261 + setDirection("next"); 262 + setPreviousIndex(currentIndex); 263 + setCurrentIndex((i) => (i >= images.length - 1 ? 0 : i + 1)); 264 + }, [images.length, currentIndex, previousIndex]); 265 + 266 + useEffect(() => { 267 + if (!isOpen || images.length <= 1) return; 268 + 269 + const handleKeyDown = (e: KeyboardEvent) => { 270 + if (e.key === "ArrowLeft") { 271 + e.preventDefault(); 272 + captureAndGoPrev(); 273 + } else if (e.key === "ArrowRight") { 274 + e.preventDefault(); 275 + captureAndGoNext(); 276 + } 277 + }; 278 + 279 + globalThis.addEventListener("keydown", handleKeyDown); 280 + return () => globalThis.removeEventListener("keydown", handleKeyDown); 281 + }, [isOpen, images.length, captureAndGoPrev, captureAndGoNext]); 282 + 283 + if (images.length === 0) return null; 284 + 285 + const currentImage = images[currentIndex]; 286 + const hasMultiple = images.length > 1; 287 + 288 + return ( 289 + <DialogTrigger isOpen={isOpen} onOpenChange={onOpenChange}> 290 + <span {...stylex.props(styles.hiddenTrigger)} /> 291 + <ModalOverlay 292 + isDismissable 293 + {...stylex.props(styles.overlay, ui.overlay, style)} 294 + > 295 + <div 296 + {...stylex.props(styles.backdrop)} 297 + onClick={() => onOpenChange(false)} 298 + aria-hidden 299 + /> 300 + {/* oxlint-disable-next-line jsx_a11y/click-events-have-key-events, jsx_a11y/no-static-element-interactions */} 301 + <div 302 + {...stylex.props(styles.closeButton)} 303 + onClick={(e) => e.stopPropagation()} 304 + > 305 + <IconButton variant="tertiary" size="lg" label="Close" slot="close"> 306 + <X size={24} /> 307 + </IconButton> 308 + </div> 309 + <Modal 310 + {...stylex.props(styles.modal)} 311 + onClick={(e) => e.stopPropagation()} 312 + > 313 + <AriaDialog {...stylex.props(styles.dialog)} aria-label={alt}> 314 + {hasMultiple ? ( 315 + <div {...stylex.props(styles.contentRow)}> 316 + <div {...stylex.props(styles.navButton, ui.textContrast)}> 317 + <IconButton 318 + variant="secondary" 319 + size="lg" 320 + label="Previous image" 321 + onPress={captureAndGoPrev} 322 + > 323 + <ChevronLeft size={32} /> 324 + </IconButton> 325 + </div> 326 + 327 + <div 328 + ref={wrapperRef} 329 + {...stylex.props(styles.imageWrapper)} 330 + style={ 331 + transitionSize 332 + ? { 333 + height: transitionSize.height, 334 + width: transitionSize.width, 335 + } 336 + : undefined 337 + } 338 + > 339 + {previousIndex === null ? ( 340 + <img 341 + src={currentImage} 342 + alt={`${alt} ${hasMultiple ? `${String(currentIndex + 1)} of ${String(images.length)}` : ""}`} 343 + {...stylex.props(styles.image)} 344 + /> 345 + ) : ( 346 + <> 347 + <div 348 + ref={outgoingRef} 349 + {...stylex.props( 350 + styles.imageLayer, 351 + styles.imageLayerOutgoing, 352 + )} 353 + > 354 + <img 355 + src={images[previousIndex]} 356 + alt="" 357 + role="presentation" 358 + {...stylex.props(styles.image)} 359 + /> 360 + </div> 361 + <div 362 + ref={incomingRef} 363 + {...stylex.props( 364 + styles.imageLayer, 365 + styles.imageLayerIncoming, 366 + )} 367 + > 368 + <img 369 + src={currentImage} 370 + alt={`${alt} ${hasMultiple ? `${String(currentIndex + 1)} of ${String(images.length)}` : ""}`} 371 + {...stylex.props(styles.image)} 372 + /> 373 + </div> 374 + </> 375 + )} 376 + </div> 377 + 378 + <div {...stylex.props(styles.navButton, ui.textContrast)}> 379 + <IconButton 380 + variant="secondary" 381 + size="lg" 382 + label="Next image" 383 + onPress={captureAndGoNext} 384 + > 385 + <ChevronRight size={32} /> 386 + </IconButton> 387 + </div> 388 + </div> 389 + ) : ( 390 + <div 391 + ref={wrapperRef} 392 + {...stylex.props(styles.imageWrapper)} 393 + style={ 394 + transitionSize 395 + ? { 396 + height: transitionSize.height, 397 + width: transitionSize.width, 398 + } 399 + : undefined 400 + } 401 + > 402 + <img 403 + src={currentImage} 404 + alt={`${alt}`} 405 + {...stylex.props(styles.image)} 406 + /> 407 + </div> 408 + )} 409 + </AriaDialog> 410 + </Modal> 411 + </ModalOverlay> 412 + </DialogTrigger> 413 + ); 414 + }
+1
apps/docs/src/components/theme/color.stylex.tsx
··· 7 7 import { yellow } from "./colors/yellow.stylex"; 8 8 9 9 export const uiColor = stylex.defineVars({ 10 + overlayBackdrop: "light-dark(rgba(4, 1, 1, 0.5), rgba(0, 0, 0, 0.75))", 10 11 bg: slate.bg, 11 12 bgSubtle: slate.bgSubtle, 12 13 component1: slate.component1,
+1
apps/docs/src/components/theme/semantic-color.stylex.tsx
··· 46 46 textDim: { color: uiColor.text1, fontFamily: fontFamily["sans"] }, 47 47 text: { color: uiColor.text2, fontFamily: fontFamily["sans"] }, 48 48 textContrast: { color: uiColor.textContrast }, 49 + overlay: { backgroundColor: uiColor.overlayBackdrop }, 49 50 50 51 bgGhost: { 51 52 backgroundColor: {
+42
apps/docs/src/docs/components/overlays/lightbox.mdx
··· 1 + --- 2 + title: Lightbox 3 + description: A full-screen overlay for viewing images with navigation between multiple images. 4 + --- 5 + 6 + import { PropDocs } from "../../../lib/PropDocs"; 7 + import { Example } from "../../../lib/Example"; 8 + import { Basic } from "../../../examples/lightbox/basic"; 9 + 10 + <Example src={Basic} /> 11 + 12 + ## Installation 13 + 14 + Run the following command to add the lightbox component to your project. 15 + 16 + ```bash 17 + pnpm hip install lightbox 18 + ``` 19 + 20 + ## Props 21 + 22 + <PropDocs components={["Lightbox"]} /> 23 + 24 + ## Features 25 + 26 + ### Controlled 27 + 28 + The lightbox is controlled via `isOpen` and `onOpenChange`. 29 + Use a button or other trigger to open it and set `onOpenChange(false)` to close. 30 + 31 + ### Keyboard navigation 32 + 33 + When multiple images are provided, use Arrow Left and Arrow Right to navigate. 34 + 35 + ### Click to dismiss 36 + 37 + Click the backdrop or press Escape to close the lightbox. 38 + 39 + ## Related Components 40 + 41 + - [Dialog](/docs/components/dialog) - For modal content overlays 42 + - [ImageCropper](/docs/components/image-cropper) - For cropping images
+26
apps/docs/src/examples/lightbox/basic.tsx
··· 1 + import { useState } from "react"; 2 + 3 + import { Button } from "@/components/button"; 4 + import { Lightbox } from "@/components/lightbox"; 5 + 6 + const SAMPLE_IMAGES = [ 7 + "https://picsum.photos/seed/1/800/600", 8 + "https://picsum.photos/seed/2/800/600", 9 + "https://picsum.photos/seed/3/800/600", 10 + ]; 11 + 12 + export function Basic() { 13 + const [isOpen, setIsOpen] = useState(false); 14 + 15 + return ( 16 + <> 17 + <Button onPress={() => setIsOpen(true)}>Open Lightbox</Button> 18 + <Lightbox 19 + isOpen={isOpen} 20 + onOpenChange={setIsOpen} 21 + images={SAMPLE_IMAGES} 22 + alt="Sample image" 23 + /> 24 + </> 25 + ); 26 + }
+1 -5
apps/docs/src/examples/sidebar-layout/with-header-layout-wrapper.tsx
··· 7 7 NavbarLogo, 8 8 NavbarNavigation, 9 9 } from "@/components/navbar"; 10 - import { 11 - Sidebar, 12 - SidebarItem, 13 - SidebarSection, 14 - } from "@/components/sidebar"; 10 + import { Sidebar, SidebarItem, SidebarSection } from "@/components/sidebar"; 15 11 import { SidebarLayout } from "@/components/sidebar-layout"; 16 12 import { Body, Heading1 } from "@/components/typography"; 17 13
+5 -6
apps/docs/src/examples/tree/drag-and-drop.tsx
··· 65 65 66 66 function renderTreeItem(item: { 67 67 value: { id: string; name: string }; 68 - children?: Array<{ value: { id: string; name: string }; children?: Array<unknown> }>; 68 + children?: Array<{ 69 + value: { id: string; name: string }; 70 + children?: Array<unknown>; 71 + }>; 69 72 }): React.ReactNode { 70 73 return ( 71 - <TreeItem 72 - key={item.value.id} 73 - id={item.value.id} 74 - title={item.value.name} 75 - > 74 + <TreeItem key={item.value.id} id={item.value.id} title={item.value.name}> 76 75 {item.children?.map((child) => renderTreeItem(child))} 77 76 </TreeItem> 78 77 );
+5 -1
apps/docs/src/examples/tree/virtualization.tsx
··· 15 15 }, 16 16 }); 17 17 18 - function renderTreeItem(item: { id: string; name: string; children?: Array<{ id: string; name: string }> }) { 18 + function renderTreeItem(item: { 19 + id: string; 20 + name: string; 21 + children?: Array<{ id: string; name: string }>; 22 + }) { 19 23 return ( 20 24 <TreeItem key={item.id} id={item.id} title={item.name}> 21 25 {item.children?.map((child) => (
+6 -1
apps/example/src/components/KitchenSink.tsx
··· 815 815 return ( 816 816 <Flex gap="4"> 817 817 {bageVariants.map((variant) => ( 818 - <Flex key={variant} gap="4" direction="column" style={styles.capitalize}> 818 + <Flex 819 + key={variant} 820 + gap="4" 821 + direction="column" 822 + style={styles.capitalize} 823 + > 819 824 <Badge variant={variant}>{variant}</Badge> 820 825 <Badge variant={variant} size="md"> 821 826 {variant}
+2
packages/hip-ui/src/cli/install.tsx
··· 56 56 import { imageCropperConfig } from "../components/image-cropper/image-cropper-config.js"; 57 57 import { kbdConfig } from "../components/kbd/kbd-config.js"; 58 58 import { labelConfig } from "../components/label/label-config.js"; 59 + import { lightboxConfig } from "../components/lightbox/lightbox-config.js"; 59 60 import { linkConfig } from "../components/link/link-config.js"; 60 61 import { listboxConfig } from "../components/listbox/listbox-config.js"; 61 62 import { menuConfig } from "../components/menu/menu-config.js"; ··· 111 112 cardConfig, 112 113 textFieldConfig, 113 114 labelConfig, 115 + lightboxConfig, 114 116 linkConfig, 115 117 checkboxConfig, 116 118 radioConfig,
+414
packages/hip-ui/src/components/lightbox/index.tsx
··· 1 + "use client"; 2 + 3 + import * as stylex from "@stylexjs/stylex"; 4 + import { ChevronLeft, ChevronRight, X } from "lucide-react"; 5 + import { 6 + useCallback, 7 + useEffect, 8 + useLayoutEffect, 9 + useRef, 10 + useState, 11 + } from "react"; 12 + import { 13 + Dialog as AriaDialog, 14 + DialogTrigger, 15 + Modal, 16 + ModalOverlay, 17 + } from "react-aria-components"; 18 + 19 + import type { StyleXComponentProps } from "../theme/types"; 20 + 21 + import { IconButton } from "../icon-button"; 22 + import { 23 + animationDuration, 24 + animationTimingFunction, 25 + animations, 26 + } from "../theme/animations.stylex"; 27 + import { ui } from "../theme/semantic-color.stylex"; 28 + import { spacing } from "../theme/spacing.stylex"; 29 + 30 + const SLIDE_DURATION_MS = 250; 31 + 32 + const styles = stylex.create({ 33 + overlay: { 34 + inset: 0, 35 + alignItems: "center", 36 + animationDuration: animationDuration.default, 37 + animationName: animations.fadeIn, 38 + animationTimingFunction: animationTimingFunction.easeIn, 39 + display: "flex", 40 + justifyContent: "center", 41 + opacity: { 42 + default: 1, 43 + ":is([data-exiting])": 0, 44 + }, 45 + position: "fixed", 46 + transitionDuration: { 47 + ":is([data-exiting])": animationDuration.fast, 48 + }, 49 + transitionProperty: "opacity", 50 + transitionTimingFunction: "ease-in-out", 51 + zIndex: 200, 52 + }, 53 + backdrop: { 54 + inset: 0, 55 + position: "absolute", 56 + }, 57 + modal: { 58 + outline: "none", 59 + alignItems: "center", 60 + display: "flex", 61 + flexDirection: "column", 62 + justifyContent: "center", 63 + position: "relative", 64 + zIndex: 1, 65 + height: "fit-content", 66 + maxHeight: "90vh", 67 + maxWidth: "90vw", 68 + // Shrink to fit content so backdrop remains clickable around the edges 69 + width: "fit-content", 70 + }, 71 + dialog: { 72 + outline: "none", 73 + alignItems: "center", 74 + display: "flex", 75 + flexDirection: "column", 76 + flexGrow: 1, 77 + justifyContent: "center", 78 + position: "relative", 79 + minHeight: 0, 80 + }, 81 + imageWrapper: { 82 + overflow: "hidden", 83 + alignItems: "center", 84 + display: "flex", 85 + flexGrow: 1, 86 + justifyContent: "center", 87 + position: "relative", 88 + maxHeight: "100%", 89 + maxWidth: "100%", 90 + minWidth: 0, 91 + }, 92 + image: { 93 + objectFit: "contain", 94 + height: "auto", 95 + maxHeight: "90vh", 96 + maxWidth: "100%", 97 + width: "auto", 98 + }, 99 + imageLayer: { 100 + inset: 0, 101 + alignItems: "center", 102 + display: "flex", 103 + justifyContent: "center", 104 + position: "absolute", 105 + height: "100%", 106 + width: "100%", 107 + }, 108 + imageLayerOutgoing: { 109 + zIndex: 2, 110 + }, 111 + imageLayerIncoming: { 112 + zIndex: 1, 113 + }, 114 + closeButton: { 115 + position: "fixed", 116 + zIndex: 210, 117 + right: spacing["4"], 118 + top: spacing["4"], 119 + }, 120 + hiddenTrigger: { 121 + display: "none", 122 + }, 123 + contentRow: { 124 + gap: spacing["4"], 125 + alignItems: "center", 126 + display: "flex", 127 + flexGrow: 1, 128 + justifyContent: "center", 129 + minHeight: 0, 130 + }, 131 + navButton: { 132 + flexShrink: 0, 133 + }, 134 + }); 135 + 136 + export interface LightboxProps extends StyleXComponentProps<object> { 137 + /** Whether the lightbox is open */ 138 + isOpen: boolean; 139 + /** Called when the lightbox should close */ 140 + onOpenChange: (isOpen: boolean) => void; 141 + /** Array of image URLs to display */ 142 + images: Array<string>; 143 + /** Initial index when opening (default 0) */ 144 + initialIndex?: number; 145 + /** Alt text for the current image */ 146 + alt?: string; 147 + } 148 + 149 + export function Lightbox({ 150 + isOpen, 151 + onOpenChange, 152 + images, 153 + initialIndex = 0, 154 + alt = "Image", 155 + style, 156 + }: LightboxProps) { 157 + const [currentIndex, setCurrentIndex] = useState(initialIndex); 158 + const [previousIndex, setPreviousIndex] = useState<number | null>(null); 159 + const [direction, setDirection] = useState<"next" | "prev">("next"); 160 + const [transitionSize, setTransitionSize] = useState<{ 161 + width: number; 162 + height: number; 163 + } | null>(null); 164 + const wrapperRef = useRef<HTMLDivElement>(null); 165 + const outgoingRef = useRef<HTMLDivElement>(null); 166 + const incomingRef = useRef<HTMLDivElement>(null); 167 + 168 + // Run slide animations via Web Animations API 169 + useLayoutEffect(() => { 170 + if (previousIndex === null) return; 171 + const wrapper = wrapperRef.current; 172 + const outgoing = outgoingRef.current; 173 + const incoming = incomingRef.current; 174 + if (!wrapper || !outgoing || !incoming) return; 175 + 176 + const runAnimation = () => { 177 + const viewportWidth = 178 + globalThis.visualViewport?.width ?? globalThis.innerWidth; 179 + const wrapperWidth = transitionSize 180 + ? transitionSize.width 181 + : wrapper.offsetWidth || wrapper.getBoundingClientRect().width; 182 + const slideDistance = Math.max(viewportWidth, wrapperWidth); 183 + 184 + const isNext = direction === "next"; 185 + const outKeyframes = isNext 186 + ? [ 187 + { transform: "translateX(0)" }, 188 + { transform: `translateX(-${String(slideDistance)}px)` }, 189 + ] 190 + : [ 191 + { transform: "translateX(0)" }, 192 + { transform: `translateX(${String(slideDistance)}px)` }, 193 + ]; 194 + const inKeyframes = isNext 195 + ? [ 196 + { transform: `translateX(${String(slideDistance)}px)` }, 197 + { transform: "translateX(0)" }, 198 + ] 199 + : [ 200 + { transform: `translateX(-${String(slideDistance)}px)` }, 201 + { transform: "translateX(0)" }, 202 + ]; 203 + 204 + outgoing.animate(outKeyframes, { 205 + duration: SLIDE_DURATION_MS, 206 + easing: "cubic-bezier(0.4, 0, 1, 1)", 207 + fill: "forwards", 208 + }); 209 + incoming 210 + .animate(inKeyframes, { 211 + duration: SLIDE_DURATION_MS, 212 + easing: "cubic-bezier(0, 0, 0.2, 1)", 213 + fill: "forwards", 214 + }) 215 + .finished.then(() => { 216 + setPreviousIndex(null); 217 + setTransitionSize(null); 218 + }) 219 + .catch((error: unknown) => { 220 + console.error(error); 221 + }); 222 + }; 223 + 224 + requestAnimationFrame(() => { 225 + requestAnimationFrame(runAnimation); 226 + }); 227 + }, [previousIndex, direction, transitionSize]); 228 + 229 + // Sync currentIndex when opening with a new initialIndex; reset transition state 230 + useEffect(() => { 231 + if (isOpen) { 232 + setCurrentIndex(Math.min(Math.max(0, initialIndex), images.length - 1)); 233 + setPreviousIndex(null); 234 + setTransitionSize(null); 235 + } 236 + }, [isOpen, initialIndex, images.length]); 237 + 238 + const captureAndGoPrev = useCallback(() => { 239 + if (previousIndex !== null) return; 240 + const wrapper = wrapperRef.current; 241 + if (wrapper) { 242 + setTransitionSize({ 243 + width: wrapper.offsetWidth, 244 + height: wrapper.offsetHeight, 245 + }); 246 + } 247 + setDirection("prev"); 248 + setPreviousIndex(currentIndex); 249 + setCurrentIndex((i) => (i <= 0 ? images.length - 1 : i - 1)); 250 + }, [images.length, currentIndex, previousIndex]); 251 + 252 + const captureAndGoNext = useCallback(() => { 253 + if (previousIndex !== null) return; 254 + const wrapper = wrapperRef.current; 255 + if (wrapper) { 256 + setTransitionSize({ 257 + width: wrapper.offsetWidth, 258 + height: wrapper.offsetHeight, 259 + }); 260 + } 261 + setDirection("next"); 262 + setPreviousIndex(currentIndex); 263 + setCurrentIndex((i) => (i >= images.length - 1 ? 0 : i + 1)); 264 + }, [images.length, currentIndex, previousIndex]); 265 + 266 + useEffect(() => { 267 + if (!isOpen || images.length <= 1) return; 268 + 269 + const handleKeyDown = (e: KeyboardEvent) => { 270 + if (e.key === "ArrowLeft") { 271 + e.preventDefault(); 272 + captureAndGoPrev(); 273 + } else if (e.key === "ArrowRight") { 274 + e.preventDefault(); 275 + captureAndGoNext(); 276 + } 277 + }; 278 + 279 + globalThis.addEventListener("keydown", handleKeyDown); 280 + return () => globalThis.removeEventListener("keydown", handleKeyDown); 281 + }, [isOpen, images.length, captureAndGoPrev, captureAndGoNext]); 282 + 283 + if (images.length === 0) return null; 284 + 285 + const currentImage = images[currentIndex]; 286 + const hasMultiple = images.length > 1; 287 + 288 + return ( 289 + <DialogTrigger isOpen={isOpen} onOpenChange={onOpenChange}> 290 + <span {...stylex.props(styles.hiddenTrigger)} /> 291 + <ModalOverlay 292 + isDismissable 293 + {...stylex.props(styles.overlay, ui.overlay, style)} 294 + > 295 + <div 296 + {...stylex.props(styles.backdrop)} 297 + onClick={() => onOpenChange(false)} 298 + aria-hidden 299 + /> 300 + {/* oxlint-disable-next-line jsx_a11y/click-events-have-key-events, jsx_a11y/no-static-element-interactions */} 301 + <div 302 + {...stylex.props(styles.closeButton)} 303 + onClick={(e) => e.stopPropagation()} 304 + > 305 + <IconButton variant="tertiary" size="lg" label="Close" slot="close"> 306 + <X size={24} /> 307 + </IconButton> 308 + </div> 309 + <Modal 310 + {...stylex.props(styles.modal)} 311 + onClick={(e) => e.stopPropagation()} 312 + > 313 + <AriaDialog {...stylex.props(styles.dialog)} aria-label={alt}> 314 + {hasMultiple ? ( 315 + <div {...stylex.props(styles.contentRow)}> 316 + <div {...stylex.props(styles.navButton, ui.textContrast)}> 317 + <IconButton 318 + variant="secondary" 319 + size="lg" 320 + label="Previous image" 321 + onPress={captureAndGoPrev} 322 + > 323 + <ChevronLeft size={32} /> 324 + </IconButton> 325 + </div> 326 + 327 + <div 328 + ref={wrapperRef} 329 + {...stylex.props(styles.imageWrapper)} 330 + style={ 331 + transitionSize 332 + ? { 333 + height: transitionSize.height, 334 + width: transitionSize.width, 335 + } 336 + : undefined 337 + } 338 + > 339 + {previousIndex === null ? ( 340 + <img 341 + src={currentImage} 342 + alt={`${alt} ${String(currentIndex + 1)}`} 343 + {...stylex.props(styles.image)} 344 + /> 345 + ) : ( 346 + <> 347 + <div 348 + ref={outgoingRef} 349 + {...stylex.props( 350 + styles.imageLayer, 351 + styles.imageLayerOutgoing, 352 + )} 353 + > 354 + <img 355 + src={images[previousIndex]} 356 + alt="" 357 + role="presentation" 358 + {...stylex.props(styles.image)} 359 + /> 360 + </div> 361 + <div 362 + ref={incomingRef} 363 + {...stylex.props( 364 + styles.imageLayer, 365 + styles.imageLayerIncoming, 366 + )} 367 + > 368 + <img 369 + src={currentImage} 370 + alt={`${alt} ${String(currentIndex + 1)}`} 371 + {...stylex.props(styles.image)} 372 + /> 373 + </div> 374 + </> 375 + )} 376 + </div> 377 + 378 + <div {...stylex.props(styles.navButton, ui.textContrast)}> 379 + <IconButton 380 + variant="secondary" 381 + size="lg" 382 + label="Next image" 383 + onPress={captureAndGoNext} 384 + > 385 + <ChevronRight size={32} /> 386 + </IconButton> 387 + </div> 388 + </div> 389 + ) : ( 390 + <div 391 + ref={wrapperRef} 392 + {...stylex.props(styles.imageWrapper)} 393 + style={ 394 + transitionSize 395 + ? { 396 + height: transitionSize.height, 397 + width: transitionSize.width, 398 + } 399 + : undefined 400 + } 401 + > 402 + <img 403 + src={currentImage} 404 + alt={alt} 405 + {...stylex.props(styles.image)} 406 + /> 407 + </div> 408 + )} 409 + </AriaDialog> 410 + </Modal> 411 + </ModalOverlay> 412 + </DialogTrigger> 413 + ); 414 + }
+18
packages/hip-ui/src/components/lightbox/lightbox-config.ts
··· 1 + import type { ComponentConfig } from "../../types"; 2 + 3 + export const lightboxConfig: ComponentConfig = { 4 + name: "lightbox", 5 + filepath: "./index.tsx", 6 + hipDependencies: [ 7 + "../icon-button/index.tsx", 8 + "../theme/animations.stylex.tsx", 9 + "../theme/color.stylex.tsx", 10 + "../theme/semantic-color.stylex.tsx", 11 + "../theme/spacing.stylex.tsx", 12 + "../theme/types.ts", 13 + ], 14 + dependencies: { 15 + "lucide-react": "^0.545.0", 16 + "react-aria-components": "^1.13.0", 17 + }, 18 + };
+1
packages/hip-ui/src/components/theme/color.stylex.tsx
··· 7 7 import { yellow } from "./colors/yellow.stylex"; 8 8 9 9 export const uiColor = stylex.defineVars({ 10 + overlayBackdrop: "light-dark(rgba(4, 1, 1, 0.5), rgba(0, 0, 0, 0.75))", 10 11 bg: slate.bg, 11 12 bgSubtle: slate.bgSubtle, 12 13 component1: slate.component1,
+1
packages/hip-ui/src/components/theme/semantic-color.stylex.tsx
··· 46 46 textDim: { color: uiColor.text1, fontFamily: fontFamily["sans"] }, 47 47 text: { color: uiColor.text2, fontFamily: fontFamily["sans"] }, 48 48 textContrast: { color: uiColor.textContrast }, 49 + overlay: { backgroundColor: uiColor.overlayBackdrop }, 49 50 50 51 bgGhost: { 51 52 backgroundColor: {
+1
packages/hip-ui/src/components/toast/toast-config.ts
··· 13 13 "../icon-button/index.tsx", 14 14 "./queue.ts", 15 15 "./Toast.tsx", 16 + "./index.tsx", 16 17 ], 17 18 dependencies: { 18 19 "react-aria-components": "^1.13.0",