컴포넌트

Layout

컴포넌트 사이의 간격, 정렬과 줄 전환을 관리합니다.

설치

pnpm dlx ply-ui add layout card button input badge field select icon

구성

컴포넌트역할기본값
Stack위에서 아래로 내용 배치16px 간격
Inline버튼·배지를 가로로 배치하고 항목 단위로 줄 전환8px 간격
Grid부모 너비에 따라 열 수 변경최소 열 너비 16rem, 16px 간격
ControlGroup입력 영역이 남은 너비를 사용하고 액션 버튼의 너비 보존8px 간격, 공간이 부족하면 줄 전환

gap은 0, 4, 8, 12, 16, 24, 32, 48 중 선택합니다. className, style, HTML 속성과 ref는 배치 요소에 전달합니다. 직접 CSS Grid나 Flex를 사용해도 됩니다.

입력과 버튼

import { ControlGroup } from "ply-ui/layout";
import { Input } from "ply-ui/input";
import { Button } from "ply-ui/button";

<ControlGroup>
  <Input aria-label="검색어" placeholder="프로젝트 검색" />
  <Button>검색</Button>
</ControlGroup>;

입력 영역은 최소 12rem을 기준으로 늘어나며, 공간이 부족하면 버튼이 다음 줄로 이동합니다. 버튼 자체를 좁혀 글자를 여러 줄로 쪼개지 않습니다. 긴 버튼 이름 하나가 부모보다 크면 그 버튼 안에서 줄바꿈합니다.

실제 화면 구성

Grid minItemWidth="14rem"으로 필드 두 개를 배치합니다. 넓으면 두 열, 좁으면 한 열이 됩니다. 부모 패널의 너비가 기준이므로 별도의 화면 크기 조건은 필요하지 않습니다.

크기 규칙

입력·선택·표는 가용 너비를 사용합니다. Button·Badge는 내용 너비, IconButton은 지정한 정사각형 크기를 유지합니다. 부모를 채우는 버튼이 필요하면 className="w-full" 또는 style={{ width: "100%" }}을 지정합니다.

구현 코드

components/ui/layout.tsx
layout.tsx
import type { ComponentProps, CSSProperties } from "react";
import { cx } from "../../lib/cx";
import "./layout.css";

type Spacing = 0 | 4 | 8 | 12 | 16 | 24 | 32 | 48;
type LayoutProps = ComponentProps<"div"> & {
  /** 4px 간격 체계를 사용합니다. */
  gap?: Spacing;
};
type Alignment = "start" | "center" | "end" | "stretch";
function spacing(gap: Spacing, style?: CSSProperties): CSSProperties {
  return { "--rbx-layout-gap": `${gap}px`, ...style } as CSSProperties;
}
/** 세로 흐름과 간격을 관리합니다. 버튼·배지는 고유 너비, 입력은 가용 너비를 사용합니다. */
export function Stack({
  gap = 16,
  align = "stretch",
  className,
  style,
  ...props
}: LayoutProps & { align?: Alignment }) {
  return (
    <div
      {...props}
      data-align={align}
      className={cx("rbx-stack", className)}
      style={spacing(gap, style)}
    />
  );
}
/** 액션·배지를 가로로 배치하고 공간이 부족하면 항목 단위로 다음 줄로 넘깁니다. */
export function Inline({
  gap = 8,
  align = "center",
  justify = "start",
  className,
  style,
  ...props
}: LayoutProps & {
  align?: Alignment;
  justify?: "start" | "center" | "end" | "space-between";
}) {
  return (
    <div
      {...props}
      data-align={align}
      data-justify={justify}
      className={cx("rbx-inline", className)}
      style={spacing(gap, style)}
    />
  );
}
/** 부모 너비에 맞춰 열을 나눕니다. 좁은 패널에서는 한 열로 전환됩니다. */
export function Grid({
  gap = 16,
  minItemWidth = "16rem",
  className,
  style,
  ...props
}: LayoutProps & { minItemWidth?: CSSProperties["minWidth"] }) {
  return (
    <div
      {...props}
      className={cx("rbx-grid", className)}
      style={
        {
          "--rbx-grid-min":
            typeof minItemWidth === "number"
              ? `${minItemWidth}px`
              : minItemWidth,
          ...spacing(gap, style),
        } as CSSProperties
      }
    />
  );
}
/** 입력·선택 영역이 남은 공간을 사용하고, 액션 버튼은 읽을 수 있는 너비를 유지합니다. */
export function ControlGroup({
  gap = 8,
  className,
  style,
  ...props
}: LayoutProps) {
  return (
    <div
      {...props}
      className={cx("rbx-control-group", className)}
      style={spacing(gap, style)}
    />
  );
}
components/ui/layout.css
layout.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@layer components.ply {
  :where(.rbx-stack, .rbx-inline, .rbx-grid, .rbx-control-group) {
    min-inline-size: 0;
    max-inline-size: 100%;
    gap: var(--rbx-layout-gap, 16px);
  }
  /* 간격은 gap이 맡습니다. 제목·문단의 브라우저 기본 바깥 여백이 gap에 더해지지 않게 합니다. */
  :where(.rbx-stack, .rbx-inline, .rbx-grid, .rbx-control-group) > :where(*) {
    margin-block: 0;
  }
  .rbx-stack {
    display: flex;
    flex-direction: column;
  }
  :where(.rbx-stack, .rbx-inline)[data-align="start"] {
    align-items: flex-start;
  }
  :where(.rbx-stack, .rbx-inline)[data-align="center"] {
    align-items: center;
  }
  :where(.rbx-stack, .rbx-inline)[data-align="end"] {
    align-items: flex-end;
  }
  :where(.rbx-stack, .rbx-inline)[data-align="stretch"] {
    align-items: stretch;
  }
  .rbx-inline {
    display: flex;
    flex-wrap: wrap;
  }
  .rbx-inline[data-justify="center"] {
    justify-content: center;
  }
  .rbx-inline[data-justify="end"] {
    justify-content: flex-end;
  }
  .rbx-inline[data-justify="space-between"] {
    justify-content: space-between;
  }
  .rbx-grid {
    display: grid;
    grid-template-columns: repeat(
      auto-fit,
      minmax(min(100%, var(--rbx-grid-min, 16rem)), 1fr)
    );
  }
  .rbx-control-group {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
  }
  .rbx-control-group
    > :where(:not(button, [role="button"], .rbx-button, .rbx-icon-button)) {
    flex: 1 1 12rem;
    min-inline-size: 0;
  }
}

목차