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.

page component

+1441 -3
+3 -1
apps/docs/src/components/icon-button/index.tsx
··· 45 45 onTooltipOpenChange?: never; 46 46 } 47 47 48 - type IconButtonProps = IconButtonWithLabelProps | IconButtonWithAriaLabelProps; 48 + export type IconButtonProps = 49 + | IconButtonWithLabelProps 50 + | IconButtonWithAriaLabelProps; 49 51 50 52 export const IconButton = ({ 51 53 children,
+615
apps/docs/src/components/page/Page.tsx
··· 1 + "use client"; 2 + 3 + import type { LinkProps as AriaLinkProps } from "react-aria-components"; 4 + 5 + import * as stylex from "@stylexjs/stylex"; 6 + import { ArrowLeft } from "lucide-react"; 7 + import { useEffect, useRef, useState } from "react"; 8 + 9 + import type { IconButtonProps } from "../icon-button"; 10 + import type { StyleXComponentProps } from "../theme/types"; 11 + 12 + import { Flex } from "../flex"; 13 + import { IconButton } from "../icon-button"; 14 + import { primaryColor, uiColor } from "../theme/color.stylex"; 15 + import { 16 + breakpoints, 17 + containerBreakpoints, 18 + mediaQueries, 19 + } from "../theme/media-queries.stylex"; 20 + import { radius } from "../theme/radius.stylex"; 21 + import { shadow } from "../theme/shadow.stylex"; 22 + import { spacing } from "../theme/spacing.stylex"; 23 + import { Text } from "../typography/text"; 24 + import { PageContext, usePageContext } from "./context"; 25 + 26 + const smallRootStyles = stylex.create({ 27 + root: { 28 + boxSizing: "border-box", 29 + flexGrow: 1, 30 + marginLeft: "auto", 31 + marginRight: "auto", 32 + maxWidth: "880px", 33 + paddingTop: { 34 + default: spacing["4"], 35 + [breakpoints.sm]: spacing["4"], 36 + }, 37 + width: "100%", 38 + }, 39 + }); 40 + 41 + const largeRootStyles = stylex.create({ 42 + root: { 43 + boxSizing: "border-box", 44 + display: "flex", 45 + flexDirection: "column", 46 + flexGrow: 1, 47 + marginLeft: "auto", 48 + marginRight: "auto", 49 + maxWidth: "var(--page-content-max-width)", 50 + paddingBottom: spacing["16"], 51 + paddingTop: spacing["3.5"], 52 + width: "100%", 53 + }, 54 + }); 55 + 56 + const smallHeaderStyles = stylex.create({ 57 + header: { 58 + marginBottom: { 59 + default: spacing["6"], 60 + ":is([data-sticky-header=true] *)": 0, 61 + }, 62 + minHeight: spacing["8"], 63 + }, 64 + }); 65 + 66 + const largeHeaderStyles = stylex.create({ 67 + header: { 68 + gridTemplateAreas: { 69 + default: ` 70 + 'title actions' 71 + 'description actions' 72 + `, 73 + ":has([data-page-icon])": ` 74 + 'icon title actions' 75 + 'icon description actions' 76 + `, 77 + }, 78 + alignItems: "center", 79 + columnGap: spacing["4"], 80 + display: "grid", 81 + gridTemplateColumns: { 82 + default: "1fr auto", 83 + ":has([data-page-icon])": "auto 1fr auto", 84 + }, 85 + rowGap: spacing["2"], 86 + marginBottom: { 87 + default: spacing["8"], 88 + ":is([data-sticky-header=true] *)": 0, 89 + }, 90 + minHeight: spacing["8"], 91 + paddingBottom: spacing["4"], 92 + paddingTop: spacing["4"], 93 + }, 94 + }); 95 + 96 + const sharedStyles = stylex.create({ 97 + smallTitle: { 98 + flexGrow: 1, 99 + minWidth: 0, 100 + }, 101 + largeTitle: { 102 + gridArea: "title", 103 + flexGrow: 1, 104 + minWidth: 0, 105 + }, 106 + description: { 107 + gridArea: "description", 108 + }, 109 + smallActions: { 110 + flexShrink: 0, 111 + }, 112 + largeActions: { 113 + gridArea: "actions", 114 + minHeight: spacing["8"], 115 + }, 116 + icon: { 117 + gridArea: "icon", 118 + borderRadius: { 119 + default: radius["md"], 120 + [mediaQueries.supportsSquircle]: radius["3xl"], 121 + }, 122 + // eslint-disable-next-line @stylexjs/valid-styles, @stylexjs/sort-keys 123 + cornerShape: "squircle", 124 + alignItems: "center", 125 + backgroundColor: primaryColor.solid1, 126 + boxShadow: shadow["lg"], 127 + color: primaryColor.textContrast, 128 + display: "flex", 129 + justifyContent: "center", 130 + height: spacing["12"], 131 + width: spacing["12"], 132 + }, 133 + }); 134 + 135 + const stickyBaseStyles = stylex.create({ 136 + sentinel: { 137 + pointerEvents: "none", 138 + position: "relative", 139 + height: 1, 140 + width: "100%", 141 + }, 142 + largeSentinel: { 143 + height: 0, 144 + }, 145 + stickyWrapper: { 146 + position: "sticky", 147 + zIndex: 10, 148 + left: 0, 149 + marginBottom: spacing["2"], 150 + marginLeft: `calc(-50vw + 50%)`, 151 + marginRight: `calc(-50vw + 50%)`, 152 + right: 0, 153 + top: 0, 154 + width: "100vw", 155 + }, 156 + largeStickyWrapper: { 157 + zIndex: 100, 158 + marginBottom: spacing["8"], 159 + }, 160 + stickyWrapperStuck: { 161 + backgroundColor: { 162 + default: "light-dark(rgba(252, 252, 253, 0.8), rgba(17, 17, 19, 0.8))", 163 + }, 164 + boxShadow: `${shadow.sm}, 0 0 32px 4px ${uiColor.bgSubtle}`, 165 + borderBottomColor: uiColor.border1, 166 + borderBottomStyle: "solid", 167 + borderBottomWidth: 1, 168 + }, 169 + blurContainer: { 170 + inset: 0, 171 + overflow: "hidden", 172 + position: "absolute", 173 + zIndex: 0, 174 + }, 175 + blur: { 176 + backdropFilter: "blur(32px) saturate(500%)", 177 + position: "absolute", 178 + bottom: -48, 179 + left: -48, 180 + right: -48, 181 + top: -48, 182 + }, 183 + smallStickyContent: { 184 + position: "relative", 185 + zIndex: 1, 186 + marginBottom: 0, 187 + marginLeft: "auto", 188 + marginRight: "auto", 189 + maxWidth: "880px", 190 + paddingBottom: spacing["4"], 191 + paddingLeft: spacing["4"], 192 + paddingRight: spacing["4"], 193 + paddingTop: spacing["4"], 194 + }, 195 + largeStickyContent: { 196 + boxSizing: "border-box", 197 + position: "relative", 198 + zIndex: 1, 199 + marginBottom: 0, 200 + marginLeft: "auto", 201 + marginRight: "auto", 202 + maxWidth: "var(--page-content-max-width)", 203 + paddingLeft: { 204 + default: spacing["4"], 205 + [containerBreakpoints.sm]: spacing["8"], 206 + ":has(> [data-sidebar-layout=true])": "0 !important", 207 + }, 208 + paddingRight: { 209 + default: spacing["4"], 210 + [containerBreakpoints.sm]: spacing["8"], 211 + ":has(> [data-sidebar-layout=true])": "0 !important", 212 + }, 213 + }, 214 + }); 215 + 216 + const stickyFooterBaseStyles = stylex.create({ 217 + sentinel: { 218 + pointerEvents: "none", 219 + position: "relative", 220 + height: 1, 221 + width: "100%", 222 + }, 223 + stickyWrapper: { 224 + position: "sticky", 225 + zIndex: 10, 226 + bottom: 0, 227 + left: 0, 228 + marginLeft: `calc(-50vw + 50%)`, 229 + marginRight: `calc(-50vw + 50%)`, 230 + marginTop: spacing["2"], 231 + right: 0, 232 + width: "100vw", 233 + }, 234 + stickyWrapperStuck: { 235 + backgroundColor: { 236 + default: "light-dark(rgba(252, 252, 253, 0.8), rgba(17, 17, 19, 0.8))", 237 + }, 238 + boxShadow: `${shadow.sm}, 0 0 32px 4px ${uiColor.bgSubtle}`, 239 + borderTopColor: uiColor.border1, 240 + borderTopStyle: "solid", 241 + borderTopWidth: 1, 242 + }, 243 + blurContainer: { 244 + inset: 0, 245 + overflow: "hidden", 246 + position: "absolute", 247 + zIndex: 0, 248 + }, 249 + blur: { 250 + backdropFilter: "blur(32px) saturate(500%)", 251 + position: "absolute", 252 + bottom: -48, 253 + left: -48, 254 + right: -48, 255 + top: -48, 256 + }, 257 + smallStickyContent: { 258 + position: "relative", 259 + zIndex: 1, 260 + marginLeft: "auto", 261 + marginRight: "auto", 262 + maxWidth: "880px", 263 + paddingBottom: spacing["4"], 264 + paddingLeft: spacing["4"], 265 + paddingRight: spacing["4"], 266 + paddingTop: spacing["4"], 267 + }, 268 + largeStickyContent: { 269 + boxSizing: "border-box", 270 + position: "relative", 271 + zIndex: 1, 272 + marginLeft: "auto", 273 + marginRight: "auto", 274 + maxWidth: "var(--page-content-max-width)", 275 + paddingBottom: spacing["4"], 276 + paddingLeft: { 277 + default: spacing["4"], 278 + [containerBreakpoints.sm]: spacing["8"], 279 + ":has(> [data-sidebar-layout=true])": "0 !important", 280 + }, 281 + paddingRight: { 282 + default: spacing["4"], 283 + [containerBreakpoints.sm]: spacing["8"], 284 + ":has(> [data-sidebar-layout=true])": "0 !important", 285 + }, 286 + paddingTop: spacing["4"], 287 + }, 288 + }); 289 + 290 + export interface PageRootProps extends StyleXComponentProps< 291 + React.ComponentProps<"div"> 292 + > { 293 + /** 294 + * Layout variant. "small" uses a narrow max-width (880px), "large" uses full content width. 295 + */ 296 + variant?: "small" | "large"; 297 + } 298 + 299 + /** 300 + * Root container for a page layout. 301 + */ 302 + export const PageRoot = ({ 303 + style, 304 + variant = "large", 305 + ...props 306 + }: PageRootProps) => { 307 + const rootStyles = 308 + variant === "small" ? smallRootStyles.root : largeRootStyles.root; 309 + 310 + return ( 311 + <PageContext value={variant}> 312 + <div 313 + {...props} 314 + data-page-variant={variant} 315 + {...stylex.props(rootStyles, style)} 316 + /> 317 + </PageContext> 318 + ); 319 + }; 320 + 321 + export interface PageHeaderProps extends StyleXComponentProps< 322 + React.ComponentProps<"div"> 323 + > {} 324 + 325 + /** 326 + * Header section for a page. 327 + */ 328 + export const PageHeader = ({ style, ...props }: PageHeaderProps) => { 329 + const variant = usePageContext(); 330 + const isSmall = variant === "small"; 331 + 332 + if (isSmall) { 333 + return ( 334 + <Flex 335 + align="center" 336 + gap="3" 337 + {...props} 338 + style={[smallHeaderStyles.header, style]} 339 + /> 340 + ); 341 + } 342 + 343 + return <div {...props} {...stylex.props(largeHeaderStyles.header, style)} />; 344 + }; 345 + 346 + export interface PageIconProps extends StyleXComponentProps< 347 + React.ComponentProps<"div"> 348 + > {} 349 + 350 + /** 351 + * Icon component for a large page header. 352 + * Only used with variant="large". 353 + */ 354 + export const PageIcon = ({ style, ...props }: PageIconProps) => { 355 + return ( 356 + <div 357 + data-page-icon 358 + {...props} 359 + {...stylex.props(sharedStyles.icon, style)} 360 + /> 361 + ); 362 + }; 363 + 364 + export interface PageBackLinkProps extends StyleXComponentProps< 365 + Omit<AriaLinkProps, "children" | "href"> 366 + > { 367 + /** The route path to navigate back to. When omitted with variant="small", uses router.back(). */ 368 + href?: string; 369 + /** Link content. Defaults to back arrow icon. */ 370 + children?: React.ReactNode; 371 + /** Enable view transition when navigating (for shared element transitions). Only applies when href is provided. */ 372 + viewTransition?: boolean; 373 + } 374 + 375 + export interface PageTitleProps extends StyleXComponentProps< 376 + React.ComponentProps<"h1"> 377 + > { 378 + /** Page title content. */ 379 + children: React.ReactNode; 380 + } 381 + 382 + /** 383 + * Title component for a page. 384 + */ 385 + export const PageTitle = ({ style, children, ...props }: PageTitleProps) => { 386 + const variant = usePageContext(); 387 + const isSmall = variant === "small"; 388 + const titleStyles = isSmall 389 + ? sharedStyles.smallTitle 390 + : sharedStyles.largeTitle; 391 + 392 + return ( 393 + <Text 394 + size={ 395 + isSmall ? { default: "xl", sm: "2xl" } : { default: "xl", sm: "3xl" } 396 + } 397 + weight="semibold" 398 + {...props} 399 + style={[titleStyles, style]} 400 + > 401 + {children} 402 + </Text> 403 + ); 404 + }; 405 + 406 + export interface PageDescriptionProps extends StyleXComponentProps< 407 + React.ComponentProps<"p"> 408 + > { 409 + /** Description content. */ 410 + children: React.ReactNode; 411 + } 412 + 413 + /** 414 + * Description component for a large page header. 415 + * Only used with variant="large". 416 + */ 417 + export const PageDescription = ({ 418 + style, 419 + children, 420 + ...props 421 + }: PageDescriptionProps) => { 422 + return ( 423 + <Text 424 + size="sm" 425 + variant="secondary" 426 + weight="light" 427 + {...props} 428 + style={[sharedStyles.description, style]} 429 + > 430 + {children} 431 + </Text> 432 + ); 433 + }; 434 + 435 + export interface PageActionsProps extends StyleXComponentProps< 436 + React.ComponentProps<"div"> 437 + > {} 438 + 439 + /** 440 + * Actions container for header buttons and controls. 441 + */ 442 + export const PageActions = ({ style, ...props }: PageActionsProps) => { 443 + const variant = usePageContext(); 444 + const isSmall = variant === "small"; 445 + const actionsStyles = isSmall 446 + ? sharedStyles.smallActions 447 + : sharedStyles.largeActions; 448 + 449 + return ( 450 + <Flex align="center" gap="2" {...props} style={[actionsStyles, style]} /> 451 + ); 452 + }; 453 + 454 + export interface PageStickyHeaderProps { 455 + /** Content to display in the sticky header. */ 456 + children: React.ReactNode; 457 + /** Optional style overrides. */ 458 + style?: stylex.StyleXStyles; 459 + } 460 + 461 + /** 462 + * Sticky header component that becomes opaque when scrolled past. 463 + * Includes a sentinel element for intersection observer detection. 464 + */ 465 + export const PageStickyHeader = ({ 466 + children, 467 + style, 468 + }: PageStickyHeaderProps) => { 469 + const variant = usePageContext(); 470 + const isSmall = variant === "small"; 471 + const [isStuck, setIsStuck] = useState(false); 472 + const sentinelRef = useRef<HTMLDivElement>(null); 473 + const headerRef = useRef<HTMLDivElement>(null); 474 + 475 + useEffect(() => { 476 + const sentinel = sentinelRef.current; 477 + const header = headerRef.current; 478 + if (!sentinel || !header) return; 479 + 480 + const observer = new IntersectionObserver( 481 + ([entry]) => { 482 + if (entry) setIsStuck(!entry.isIntersecting); 483 + }, 484 + { 485 + threshold: [0, 1], 486 + rootMargin: "0px 0px 0px 0px", 487 + }, 488 + ); 489 + 490 + observer.observe(sentinel); 491 + 492 + return () => { 493 + observer.disconnect(); 494 + }; 495 + }, []); 496 + 497 + const sentinelStyles = isSmall 498 + ? stickyBaseStyles.sentinel 499 + : [stickyBaseStyles.sentinel, stickyBaseStyles.largeSentinel]; 500 + const wrapperStyles = isSmall 501 + ? stickyBaseStyles.stickyWrapper 502 + : [stickyBaseStyles.stickyWrapper, stickyBaseStyles.largeStickyWrapper]; 503 + const contentStyles = isSmall 504 + ? stickyBaseStyles.smallStickyContent 505 + : stickyBaseStyles.largeStickyContent; 506 + 507 + return ( 508 + <div style={{ display: "contents" }}> 509 + <div ref={sentinelRef} {...stylex.props(sentinelStyles)} /> 510 + <div 511 + ref={headerRef} 512 + data-sticky-header 513 + {...stylex.props( 514 + wrapperStyles, 515 + isStuck && stickyBaseStyles.stickyWrapperStuck, 516 + style, 517 + )} 518 + > 519 + {isStuck && ( 520 + <div {...stylex.props(stickyBaseStyles.blurContainer)}> 521 + <div {...stylex.props(stickyBaseStyles.blur)} /> 522 + </div> 523 + )} 524 + <div {...stylex.props(contentStyles)}>{children}</div> 525 + </div> 526 + </div> 527 + ); 528 + }; 529 + 530 + export interface PageStickyFooterProps { 531 + /** Content to display in the sticky footer. */ 532 + children: React.ReactNode; 533 + } 534 + 535 + /** 536 + * Sticky footer component that becomes opaque when scrolled past. 537 + * Includes a sentinel element for intersection observer detection. 538 + */ 539 + export const PageStickyFooter = ({ children }: PageStickyFooterProps) => { 540 + const variant = usePageContext(); 541 + const isSmall = variant === "small"; 542 + const [isStuck, setIsStuck] = useState(false); 543 + const sentinelRef = useRef<HTMLDivElement>(null); 544 + const footerRef = useRef<HTMLDivElement>(null); 545 + 546 + useEffect(() => { 547 + const sentinel = sentinelRef.current; 548 + const footer = footerRef.current; 549 + if (!sentinel || !footer) return; 550 + 551 + const observer = new IntersectionObserver( 552 + ([entry]) => { 553 + if (entry) setIsStuck(!entry.isIntersecting); 554 + }, 555 + { 556 + threshold: [0, 1], 557 + rootMargin: "0px 0px 0px 0px", 558 + }, 559 + ); 560 + 561 + observer.observe(sentinel); 562 + 563 + return () => { 564 + observer.disconnect(); 565 + }; 566 + }, []); 567 + 568 + const contentStyles = isSmall 569 + ? stickyFooterBaseStyles.smallStickyContent 570 + : stickyFooterBaseStyles.largeStickyContent; 571 + 572 + return ( 573 + <> 574 + <div 575 + ref={sentinelRef} 576 + {...stylex.props(stickyFooterBaseStyles.sentinel)} 577 + /> 578 + <div 579 + ref={footerRef} 580 + data-sticky-footer 581 + {...stylex.props( 582 + stickyFooterBaseStyles.stickyWrapper, 583 + isStuck && stickyFooterBaseStyles.stickyWrapperStuck, 584 + )} 585 + > 586 + {isStuck && ( 587 + <div {...stylex.props(stickyFooterBaseStyles.blurContainer)}> 588 + <div {...stylex.props(stickyFooterBaseStyles.blur)} /> 589 + </div> 590 + )} 591 + <div {...stylex.props(contentStyles)}>{children}</div> 592 + </div> 593 + </> 594 + ); 595 + }; 596 + 597 + /** 598 + * Back link component for navigating to the previous page. 599 + */ 600 + export const PageBackLink = ({ 601 + style, 602 + children, 603 + ...props 604 + }: IconButtonProps) => { 605 + return ( 606 + <IconButton 607 + {...(props as React.ComponentProps<typeof IconButton>)} 608 + style={style} 609 + variant="tertiary" 610 + size="lg" 611 + > 612 + {children ?? <ArrowLeft size={20} />} 613 + </IconButton> 614 + ); 615 + };
+9
apps/docs/src/components/page/context.ts
··· 1 + import { createContext, useContext } from "react"; 2 + 3 + export type PageVariant = "small" | "large"; 4 + 5 + export const PageContext = createContext<PageVariant>("large"); 6 + 7 + export function usePageContext() { 8 + return useContext(PageContext); 9 + }
+23
apps/docs/src/components/page/index.tsx
··· 1 + import { 2 + PageActions, 3 + PageBackLink, 4 + PageDescription, 5 + PageHeader, 6 + PageIcon, 7 + PageRoot, 8 + PageStickyFooter, 9 + PageStickyHeader, 10 + PageTitle, 11 + } from "./Page"; 12 + 13 + export const Page = { 14 + Root: PageRoot, 15 + Header: PageHeader, 16 + BackLink: PageBackLink, 17 + Title: PageTitle, 18 + Description: PageDescription, 19 + Actions: PageActions, 20 + Icon: PageIcon, 21 + StickyHeader: PageStickyHeader, 22 + StickyFooter: PageStickyFooter, 23 + };
+62
apps/docs/src/docs/components/layout/page.mdx
··· 1 + --- 2 + title: Page 3 + description: A flexible page layout component with small and large variants for headers, titles, and sticky headers/footers. 4 + --- 5 + 6 + import { PropDocs } from "../../../lib/PropDocs"; 7 + import { Example } from "../../../lib/Example"; 8 + import { Basic } from "../../../examples/page/basic"; 9 + import { LargeVariant } from "../../../examples/page/large-variant"; 10 + import { WithIcon } from "../../../examples/page/with-icon"; 11 + 12 + <Example src={Basic} /> 13 + 14 + ## Installation 15 + 16 + Run the following command to add the page component to your project. 17 + 18 + ```bash 19 + pnpm hip install page 20 + ``` 21 + 22 + ## Props 23 + 24 + <PropDocs 25 + components={[ 26 + "PageRoot", 27 + "PageHeader", 28 + "PageBackLink", 29 + "PageTitle", 30 + "PageDescription", 31 + "PageActions", 32 + "PageIcon", 33 + "PageStickyHeader", 34 + "PageStickyFooter", 35 + ]} 36 + /> 37 + 38 + ## Features 39 + 40 + ### Small Variant 41 + 42 + A narrow page layout (max-width 880px) for focused content like forms or detail views. 43 + 44 + <Example src={Basic} /> 45 + 46 + ### Large Variant 47 + 48 + A full-width page layout with support for icons, descriptions, and action buttons in the header. 49 + 50 + <Example src={LargeVariant} /> 51 + 52 + ### With Icon 53 + 54 + Add an icon to the large page header for visual hierarchy. 55 + 56 + <Example src={WithIcon} /> 57 + 58 + ## Related Components 59 + 60 + - [Flex](/docs/components/layout/flex) - For flexible layouts within page content 61 + - [Footer](/docs/components/navigation/footer) - For page footers 62 + - [Header Layout](/docs/components/navigation/header-layout) - For app-level headers
+2 -1
apps/docs/src/examples/autocomplete/basic.tsx
··· 1 1 "use client"; 2 2 3 + import { useFilter } from "react-aria-components"; 4 + 3 5 import { AutocompleteInput } from "@/components/autocomplete"; 4 6 import { ListBoxItem } from "@/components/listbox"; 5 - import { useFilter } from "react-aria-components"; 6 7 7 8 const options = [ 8 9 { id: "apple", handle: "Apple" },
+13
apps/docs/src/examples/page/basic.tsx
··· 1 + import { Page } from "@/components/page"; 2 + 3 + export function Basic() { 4 + return ( 5 + <Page.Root variant="small"> 6 + <Page.Header> 7 + <Page.BackLink /> 8 + <Page.Title>Page Title</Page.Title> 9 + </Page.Header> 10 + <p>Page content goes here.</p> 11 + </Page.Root> 12 + ); 13 + }
+25
apps/docs/src/examples/page/large-variant.tsx
··· 1 + import { Button } from "@/components/button"; 2 + import { Flex } from "@/components/flex"; 3 + import { Page } from "@/components/page"; 4 + 5 + export function LargeVariant() { 6 + return ( 7 + <Page.Root variant="large"> 8 + <Page.Header> 9 + <Flex align="center" gap="3"> 10 + <Page.BackLink /> 11 + <Flex direction="column" gap="2"> 12 + <Page.Title>Large Page Title</Page.Title> 13 + <Page.Description> 14 + This is a description for the large page layout. 15 + </Page.Description> 16 + </Flex> 17 + </Flex> 18 + <Page.Actions> 19 + <Button size="lg">Action</Button> 20 + </Page.Actions> 21 + </Page.Header> 22 + <p>Full-width page content goes here.</p> 23 + </Page.Root> 24 + ); 25 + }
+20
apps/docs/src/examples/page/with-icon.tsx
··· 1 + import { LayoutDashboard } from "lucide-react"; 2 + 3 + import { Page } from "@/components/page"; 4 + 5 + export function WithIcon() { 6 + return ( 7 + <Page.Root variant="large"> 8 + <Page.Header> 9 + <Page.Icon> 10 + <LayoutDashboard size={24} /> 11 + </Page.Icon> 12 + <Page.Title>Dashboard</Page.Title> 13 + <Page.Description> 14 + Overview of your application metrics and activity. 15 + </Page.Description> 16 + </Page.Header> 17 + <p>Page content with icon in header.</p> 18 + </Page.Root> 19 + ); 20 + }
+1
packages/hip-ui/package.json
··· 26 26 "typescript": "catalog:" 27 27 }, 28 28 "dependencies": { 29 + "@tanstack/react-router": "^1.133.0", 29 30 "@inkjs/ui": "^2.0.0", 30 31 "@origin-space/image-cropper": "^0.1.9", 31 32 "@radix-ui/colors": "^3.0.0",
+2
packages/hip-ui/src/cli/install.tsx
··· 66 66 import { meterConfig } from "../components/meter/meter-config.js"; 67 67 import { navbarConfig } from "../components/navbar/navbar-config.js"; 68 68 import { numberFieldConfig } from "../components/number-field/number-field-config.js"; 69 + import { pageConfig } from "../components/page/page-config.js"; 69 70 import { paginationConfig } from "../components/pagination/pagination-config.js"; 70 71 import { popoverConfig } from "../components/popover/popover-config.js"; 71 72 import { progressBarConfig } from "../components/progress-bar/progress-bar-config.js"; ··· 169 170 meterConfig, 170 171 hoverCardConfig, 171 172 segmentedControlConfig, 173 + pageConfig, 172 174 paginationConfig, 173 175 kbdConfig, 174 176 sidebarConfig,
+3 -1
packages/hip-ui/src/components/icon-button/index.tsx
··· 45 45 onTooltipOpenChange?: never; 46 46 } 47 47 48 - type IconButtonProps = IconButtonWithLabelProps | IconButtonWithAriaLabelProps; 48 + export type IconButtonProps = 49 + | IconButtonWithLabelProps 50 + | IconButtonWithAriaLabelProps; 49 51 50 52 export const IconButton = ({ 51 53 children,
+604
packages/hip-ui/src/components/page/Page.tsx
··· 1 + "use client"; 2 + 3 + import type { LinkProps as AriaLinkProps } from "react-aria-components"; 4 + 5 + import * as stylex from "@stylexjs/stylex"; 6 + import { ArrowLeft } from "lucide-react"; 7 + import { useEffect, useRef, useState } from "react"; 8 + 9 + import type { IconButtonProps } from "../icon-button"; 10 + import type { StyleXComponentProps } from "../theme/types"; 11 + 12 + import { Flex } from "../flex"; 13 + import { IconButton } from "../icon-button"; 14 + import { primaryColor, uiColor } from "../theme/color.stylex"; 15 + import { 16 + breakpoints, 17 + containerBreakpoints, 18 + mediaQueries, 19 + } from "../theme/media-queries.stylex"; 20 + import { radius } from "../theme/radius.stylex"; 21 + import { shadow } from "../theme/shadow.stylex"; 22 + import { spacing } from "../theme/spacing.stylex"; 23 + import { Text } from "../typography/text"; 24 + import { PageContext, usePageContext } from "./context"; 25 + 26 + const smallRootStyles = stylex.create({ 27 + root: { 28 + boxSizing: "border-box", 29 + flexGrow: 1, 30 + marginLeft: "auto", 31 + marginRight: "auto", 32 + maxWidth: "880px", 33 + paddingTop: { 34 + default: spacing["4"], 35 + [breakpoints.sm]: spacing["4"], 36 + }, 37 + width: "100%", 38 + }, 39 + }); 40 + 41 + const largeRootStyles = stylex.create({ 42 + root: { 43 + boxSizing: "border-box", 44 + display: "flex", 45 + flexDirection: "column", 46 + flexGrow: 1, 47 + marginLeft: "auto", 48 + marginRight: "auto", 49 + maxWidth: "var(--page-content-max-width)", 50 + paddingBottom: spacing["16"], 51 + paddingTop: spacing["3.5"], 52 + width: "100%", 53 + }, 54 + }); 55 + 56 + const smallHeaderStyles = stylex.create({ 57 + header: { 58 + marginBottom: { 59 + default: spacing["6"], 60 + ":is([data-sticky-header=true] *)": 0, 61 + }, 62 + minHeight: spacing["8"], 63 + }, 64 + }); 65 + 66 + const largeHeaderStyles = stylex.create({ 67 + header: { 68 + gridTemplateAreas: { 69 + default: ` 70 + 'title actions' 71 + 'description actions' 72 + `, 73 + ":has([data-page-icon])": ` 74 + 'icon title actions' 75 + 'icon description actions' 76 + `, 77 + }, 78 + alignItems: "center", 79 + columnGap: spacing["4"], 80 + display: "grid", 81 + gridTemplateColumns: { 82 + default: "1fr auto", 83 + ":has([data-page-icon])": "auto 1fr auto", 84 + }, 85 + rowGap: spacing["2"], 86 + marginBottom: { 87 + default: spacing["8"], 88 + ":is([data-sticky-header=true] *)": 0, 89 + }, 90 + minHeight: spacing["8"], 91 + paddingBottom: spacing["4"], 92 + paddingTop: spacing["4"], 93 + }, 94 + }); 95 + 96 + const sharedStyles = stylex.create({ 97 + smallTitle: { 98 + flexGrow: 1, 99 + minWidth: 0, 100 + }, 101 + largeTitle: { 102 + gridArea: "title", 103 + flexGrow: 1, 104 + minWidth: 0, 105 + }, 106 + description: { 107 + gridArea: "description", 108 + }, 109 + smallActions: { 110 + flexShrink: 0, 111 + }, 112 + largeActions: { 113 + gridArea: "actions", 114 + minHeight: spacing["8"], 115 + }, 116 + icon: { 117 + gridArea: "icon", 118 + borderRadius: { 119 + default: radius["md"], 120 + [mediaQueries.supportsSquircle]: radius["3xl"], 121 + }, 122 + // eslint-disable-next-line @stylexjs/valid-styles, @stylexjs/sort-keys 123 + cornerShape: "squircle", 124 + alignItems: "center", 125 + backgroundColor: primaryColor.solid1, 126 + boxShadow: shadow["lg"], 127 + color: primaryColor.textContrast, 128 + display: "flex", 129 + justifyContent: "center", 130 + height: spacing["12"], 131 + width: spacing["12"], 132 + }, 133 + }); 134 + 135 + const stickyBaseStyles = stylex.create({ 136 + sentinel: { 137 + pointerEvents: "none", 138 + position: "relative", 139 + height: 1, 140 + width: "100%", 141 + }, 142 + largeSentinel: { 143 + height: 0, 144 + }, 145 + stickyWrapper: { 146 + position: "sticky", 147 + zIndex: 10, 148 + left: 0, 149 + marginBottom: spacing["2"], 150 + marginLeft: `calc(-50vw + 50%)`, 151 + marginRight: `calc(-50vw + 50%)`, 152 + right: 0, 153 + top: 0, 154 + width: "100vw", 155 + }, 156 + largeStickyWrapper: { 157 + zIndex: 100, 158 + marginBottom: spacing["8"], 159 + }, 160 + stickyWrapperStuck: { 161 + backgroundColor: { 162 + default: "light-dark(rgba(252, 252, 253, 0.8), rgba(17, 17, 19, 0.8))", 163 + }, 164 + boxShadow: `${shadow.sm}, 0 0 32px 4px ${uiColor.bgSubtle}`, 165 + borderBottomColor: uiColor.border1, 166 + borderBottomStyle: "solid", 167 + borderBottomWidth: 1, 168 + }, 169 + blurContainer: { 170 + inset: 0, 171 + overflow: "hidden", 172 + position: "absolute", 173 + zIndex: 0, 174 + }, 175 + blur: { 176 + backdropFilter: "blur(32px) saturate(500%)", 177 + position: "absolute", 178 + bottom: -48, 179 + left: -48, 180 + right: -48, 181 + top: -48, 182 + }, 183 + smallStickyContent: { 184 + position: "relative", 185 + zIndex: 1, 186 + marginBottom: 0, 187 + marginLeft: "auto", 188 + marginRight: "auto", 189 + maxWidth: "880px", 190 + paddingBottom: spacing["4"], 191 + paddingLeft: spacing["4"], 192 + paddingRight: spacing["4"], 193 + paddingTop: spacing["4"], 194 + }, 195 + largeStickyContent: { 196 + boxSizing: "border-box", 197 + position: "relative", 198 + zIndex: 1, 199 + marginBottom: 0, 200 + marginLeft: "auto", 201 + marginRight: "auto", 202 + maxWidth: "var(--page-content-max-width)", 203 + paddingLeft: { 204 + default: spacing["4"], 205 + [containerBreakpoints.sm]: spacing["8"], 206 + ":has(> [data-sidebar-layout=true])": "0 !important", 207 + }, 208 + paddingRight: { 209 + default: spacing["4"], 210 + [containerBreakpoints.sm]: spacing["8"], 211 + ":has(> [data-sidebar-layout=true])": "0 !important", 212 + }, 213 + }, 214 + }); 215 + 216 + const stickyFooterBaseStyles = stylex.create({ 217 + sentinel: { 218 + pointerEvents: "none", 219 + position: "relative", 220 + height: 1, 221 + width: "100%", 222 + }, 223 + stickyWrapper: { 224 + position: "sticky", 225 + zIndex: 10, 226 + bottom: 0, 227 + left: 0, 228 + marginLeft: `calc(-50vw + 50%)`, 229 + marginRight: `calc(-50vw + 50%)`, 230 + marginTop: spacing["2"], 231 + right: 0, 232 + width: "100vw", 233 + }, 234 + stickyWrapperStuck: { 235 + backgroundColor: { 236 + default: "light-dark(rgba(252, 252, 253, 0.8), rgba(17, 17, 19, 0.8))", 237 + }, 238 + boxShadow: `${shadow.sm}, 0 0 32px 4px ${uiColor.bgSubtle}`, 239 + borderTopColor: uiColor.border1, 240 + borderTopStyle: "solid", 241 + borderTopWidth: 1, 242 + }, 243 + blurContainer: { 244 + inset: 0, 245 + overflow: "hidden", 246 + position: "absolute", 247 + zIndex: 0, 248 + }, 249 + blur: { 250 + backdropFilter: "blur(32px) saturate(500%)", 251 + position: "absolute", 252 + bottom: -48, 253 + left: -48, 254 + right: -48, 255 + top: -48, 256 + }, 257 + smallStickyContent: { 258 + position: "relative", 259 + zIndex: 1, 260 + marginLeft: "auto", 261 + marginRight: "auto", 262 + maxWidth: "880px", 263 + paddingBottom: spacing["4"], 264 + paddingLeft: spacing["4"], 265 + paddingRight: spacing["4"], 266 + paddingTop: spacing["4"], 267 + }, 268 + largeStickyContent: { 269 + boxSizing: "border-box", 270 + position: "relative", 271 + zIndex: 1, 272 + marginLeft: "auto", 273 + marginRight: "auto", 274 + maxWidth: "var(--page-content-max-width)", 275 + paddingBottom: spacing["4"], 276 + paddingLeft: { 277 + default: spacing["4"], 278 + [containerBreakpoints.sm]: spacing["8"], 279 + ":has(> [data-sidebar-layout=true])": "0 !important", 280 + }, 281 + paddingRight: { 282 + default: spacing["4"], 283 + [containerBreakpoints.sm]: spacing["8"], 284 + ":has(> [data-sidebar-layout=true])": "0 !important", 285 + }, 286 + paddingTop: spacing["4"], 287 + }, 288 + }); 289 + 290 + export interface PageRootProps extends StyleXComponentProps< 291 + React.ComponentProps<"div"> 292 + > { 293 + /** 294 + * Layout variant. "small" uses a narrow max-width (880px), "large" uses full content width. 295 + */ 296 + variant?: "small" | "large"; 297 + } 298 + 299 + /** 300 + * Root container for a page layout. 301 + */ 302 + export const PageRoot = ({ 303 + style, 304 + variant = "large", 305 + ...props 306 + }: PageRootProps) => { 307 + const rootStyles = 308 + variant === "small" ? smallRootStyles.root : largeRootStyles.root; 309 + 310 + return ( 311 + <PageContext value={variant}> 312 + <div 313 + {...props} 314 + data-page-variant={variant} 315 + {...stylex.props(rootStyles, style)} 316 + /> 317 + </PageContext> 318 + ); 319 + }; 320 + 321 + export interface PageHeaderProps extends StyleXComponentProps< 322 + React.ComponentProps<"div"> 323 + > {} 324 + 325 + /** 326 + * Header section for a page. 327 + */ 328 + export const PageHeader = ({ style, ...props }: PageHeaderProps) => { 329 + const variant = usePageContext(); 330 + const isSmall = variant === "small"; 331 + 332 + if (isSmall) { 333 + return ( 334 + <Flex 335 + align="center" 336 + gap="3" 337 + {...props} 338 + style={[smallHeaderStyles.header, style]} 339 + /> 340 + ); 341 + } 342 + 343 + return <div {...props} {...stylex.props(largeHeaderStyles.header, style)} />; 344 + }; 345 + 346 + export interface PageIconProps extends StyleXComponentProps< 347 + React.ComponentProps<"div"> 348 + > {} 349 + 350 + /** 351 + * Icon component for a large page header. 352 + * Only used with variant="large". 353 + */ 354 + export const PageIcon = ({ style, ...props }: PageIconProps) => { 355 + return ( 356 + <div 357 + data-page-icon 358 + {...props} 359 + {...stylex.props(sharedStyles.icon, style)} 360 + /> 361 + ); 362 + }; 363 + 364 + export interface PageTitleProps extends StyleXComponentProps< 365 + React.ComponentProps<"h1"> 366 + > { 367 + /** Page title content. */ 368 + children: React.ReactNode; 369 + } 370 + 371 + /** 372 + * Title component for a page. 373 + */ 374 + export const PageTitle = ({ style, children, ...props }: PageTitleProps) => { 375 + const variant = usePageContext(); 376 + const isSmall = variant === "small"; 377 + const titleStyles = isSmall 378 + ? sharedStyles.smallTitle 379 + : sharedStyles.largeTitle; 380 + 381 + return ( 382 + <Text 383 + size={ 384 + isSmall ? { default: "xl", sm: "2xl" } : { default: "xl", sm: "3xl" } 385 + } 386 + weight="semibold" 387 + {...props} 388 + style={[titleStyles, style]} 389 + > 390 + {children} 391 + </Text> 392 + ); 393 + }; 394 + 395 + export interface PageDescriptionProps extends StyleXComponentProps< 396 + React.ComponentProps<"p"> 397 + > { 398 + /** Description content. */ 399 + children: React.ReactNode; 400 + } 401 + 402 + /** 403 + * Description component for a large page header. 404 + * Only used with variant="large". 405 + */ 406 + export const PageDescription = ({ 407 + style, 408 + children, 409 + ...props 410 + }: PageDescriptionProps) => { 411 + return ( 412 + <Text 413 + size="sm" 414 + variant="secondary" 415 + weight="light" 416 + {...props} 417 + style={[sharedStyles.description, style]} 418 + > 419 + {children} 420 + </Text> 421 + ); 422 + }; 423 + 424 + export interface PageActionsProps extends StyleXComponentProps< 425 + React.ComponentProps<"div"> 426 + > {} 427 + 428 + /** 429 + * Actions container for header buttons and controls. 430 + */ 431 + export const PageActions = ({ style, ...props }: PageActionsProps) => { 432 + const variant = usePageContext(); 433 + const isSmall = variant === "small"; 434 + const actionsStyles = isSmall 435 + ? sharedStyles.smallActions 436 + : sharedStyles.largeActions; 437 + 438 + return ( 439 + <Flex align="center" gap="2" {...props} style={[actionsStyles, style]} /> 440 + ); 441 + }; 442 + 443 + export interface PageStickyHeaderProps { 444 + /** Content to display in the sticky header. */ 445 + children: React.ReactNode; 446 + /** Optional style overrides. */ 447 + style?: stylex.StyleXStyles; 448 + } 449 + 450 + /** 451 + * Sticky header component that becomes opaque when scrolled past. 452 + * Includes a sentinel element for intersection observer detection. 453 + */ 454 + export const PageStickyHeader = ({ 455 + children, 456 + style, 457 + }: PageStickyHeaderProps) => { 458 + const variant = usePageContext(); 459 + const isSmall = variant === "small"; 460 + const [isStuck, setIsStuck] = useState(false); 461 + const sentinelRef = useRef<HTMLDivElement>(null); 462 + const headerRef = useRef<HTMLDivElement>(null); 463 + 464 + useEffect(() => { 465 + const sentinel = sentinelRef.current; 466 + const header = headerRef.current; 467 + if (!sentinel || !header) return; 468 + 469 + const observer = new IntersectionObserver( 470 + ([entry]) => { 471 + if (entry) setIsStuck(!entry.isIntersecting); 472 + }, 473 + { 474 + threshold: [0, 1], 475 + rootMargin: "0px 0px 0px 0px", 476 + }, 477 + ); 478 + 479 + observer.observe(sentinel); 480 + 481 + return () => { 482 + observer.disconnect(); 483 + }; 484 + }, []); 485 + 486 + const sentinelStyles = isSmall 487 + ? stickyBaseStyles.sentinel 488 + : [stickyBaseStyles.sentinel, stickyBaseStyles.largeSentinel]; 489 + const wrapperStyles = isSmall 490 + ? stickyBaseStyles.stickyWrapper 491 + : [stickyBaseStyles.stickyWrapper, stickyBaseStyles.largeStickyWrapper]; 492 + const contentStyles = isSmall 493 + ? stickyBaseStyles.smallStickyContent 494 + : stickyBaseStyles.largeStickyContent; 495 + 496 + return ( 497 + <div style={{ display: "contents" }}> 498 + <div ref={sentinelRef} {...stylex.props(sentinelStyles)} /> 499 + <div 500 + ref={headerRef} 501 + data-sticky-header 502 + {...stylex.props( 503 + wrapperStyles, 504 + isStuck && stickyBaseStyles.stickyWrapperStuck, 505 + style, 506 + )} 507 + > 508 + {isStuck && ( 509 + <div {...stylex.props(stickyBaseStyles.blurContainer)}> 510 + <div {...stylex.props(stickyBaseStyles.blur)} /> 511 + </div> 512 + )} 513 + <div {...stylex.props(contentStyles)}>{children}</div> 514 + </div> 515 + </div> 516 + ); 517 + }; 518 + 519 + export interface PageStickyFooterProps { 520 + /** Content to display in the sticky footer. */ 521 + children: React.ReactNode; 522 + } 523 + 524 + /** 525 + * Sticky footer component that becomes opaque when scrolled past. 526 + * Includes a sentinel element for intersection observer detection. 527 + */ 528 + export const PageStickyFooter = ({ children }: PageStickyFooterProps) => { 529 + const variant = usePageContext(); 530 + const isSmall = variant === "small"; 531 + const [isStuck, setIsStuck] = useState(false); 532 + const sentinelRef = useRef<HTMLDivElement>(null); 533 + const footerRef = useRef<HTMLDivElement>(null); 534 + 535 + useEffect(() => { 536 + const sentinel = sentinelRef.current; 537 + const footer = footerRef.current; 538 + if (!sentinel || !footer) return; 539 + 540 + const observer = new IntersectionObserver( 541 + ([entry]) => { 542 + if (entry) setIsStuck(!entry.isIntersecting); 543 + }, 544 + { 545 + threshold: [0, 1], 546 + rootMargin: "0px 0px 0px 0px", 547 + }, 548 + ); 549 + 550 + observer.observe(sentinel); 551 + 552 + return () => { 553 + observer.disconnect(); 554 + }; 555 + }, []); 556 + 557 + const contentStyles = isSmall 558 + ? stickyFooterBaseStyles.smallStickyContent 559 + : stickyFooterBaseStyles.largeStickyContent; 560 + 561 + return ( 562 + <> 563 + <div 564 + ref={sentinelRef} 565 + {...stylex.props(stickyFooterBaseStyles.sentinel)} 566 + /> 567 + <div 568 + ref={footerRef} 569 + data-sticky-footer 570 + {...stylex.props( 571 + stickyFooterBaseStyles.stickyWrapper, 572 + isStuck && stickyFooterBaseStyles.stickyWrapperStuck, 573 + )} 574 + > 575 + {isStuck && ( 576 + <div {...stylex.props(stickyFooterBaseStyles.blurContainer)}> 577 + <div {...stylex.props(stickyFooterBaseStyles.blur)} /> 578 + </div> 579 + )} 580 + <div {...stylex.props(contentStyles)}>{children}</div> 581 + </div> 582 + </> 583 + ); 584 + }; 585 + 586 + /** 587 + * Back link component for navigating to the previous page. 588 + */ 589 + export const PageBackLink = ({ 590 + style, 591 + children, 592 + ...props 593 + }: IconButtonProps) => { 594 + return ( 595 + <IconButton 596 + {...(props as React.ComponentProps<typeof IconButton>)} 597 + style={style} 598 + variant="tertiary" 599 + size="lg" 600 + > 601 + {children ?? <ArrowLeft size={20} />} 602 + </IconButton> 603 + ); 604 + };
+9
packages/hip-ui/src/components/page/context.ts
··· 1 + import { createContext, useContext } from "react"; 2 + 3 + export type PageVariant = "small" | "large"; 4 + 5 + export const PageContext = createContext<PageVariant>("large"); 6 + 7 + export function usePageContext() { 8 + return useContext(PageContext); 9 + }
+23
packages/hip-ui/src/components/page/index.tsx
··· 1 + import { 2 + PageActions, 3 + PageBackLink, 4 + PageDescription, 5 + PageHeader, 6 + PageIcon, 7 + PageRoot, 8 + PageStickyFooter, 9 + PageStickyHeader, 10 + PageTitle, 11 + } from "./Page"; 12 + 13 + export const Page = { 14 + Root: PageRoot, 15 + Header: PageHeader, 16 + BackLink: PageBackLink, 17 + Title: PageTitle, 18 + Description: PageDescription, 19 + Actions: PageActions, 20 + Icon: PageIcon, 21 + StickyHeader: PageStickyHeader, 22 + StickyFooter: PageStickyFooter, 23 + };
+24
packages/hip-ui/src/components/page/page-config.ts
··· 1 + import type { ComponentConfig } from "../../types"; 2 + 3 + export const pageConfig: ComponentConfig = { 4 + name: "page", 5 + filepath: "./index.ts", 6 + hipDependencies: [ 7 + "context.ts", 8 + "../theme/color.stylex.tsx", 9 + "../theme/media-queries.stylex.tsx", 10 + "../theme/radius.stylex.tsx", 11 + "../theme/shadow.stylex.tsx", 12 + "../theme/spacing.stylex.tsx", 13 + "../theme/types.ts", 14 + "../flex/index.tsx", 15 + "../icon-button/index.tsx", 16 + "../link/index.tsx", 17 + "../typography/text.tsx", 18 + "./Page.tsx", 19 + ], 20 + dependencies: { 21 + "@tanstack/react-router": "^1.0.0", 22 + "react-aria-components": "^1.13.0", 23 + }, 24 + };
+3
pnpm-lock.yaml
··· 461 461 '@stylexjs/stylex': 462 462 specifier: 'catalog:' 463 463 version: 0.17.0 464 + '@tanstack/react-router': 465 + specifier: ^1.133.0 466 + version: 1.133.27(react-dom@19.2.0(react@19.2.0))(react@19.2.0) 464 467 '@window-splitter/react': 465 468 specifier: ^1.0.0 466 469 version: 1.1.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)