Property Section

Compact key/value grid for inspector panels — labels, sublabels, optional collapse.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/property-section.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
} from "react";

import { cn } from "../../lib/utils";

/**
 * One row in the property grid.
 *
 * @public
 */
export type PropertyEntry = {
  /** Stable id (used as React key + analytics hook). */
  id: string;
  /** Property label (left column). */
  label: ReactNode;
  /** Optional sublabel rendered beneath the label. */
  sublabel?: ReactNode;
  /** Property value (right column). */
  value: ReactNode;
};

/**
 * Localizable strings.
 *
 * @public
 */
export type PropertySectionLabels = {
  /** Aria-label for the section. Defaults to `"Properties"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  region: "Properties",
} as const satisfies Required<PropertySectionLabels>;

/**
 * Props for {@link PropertySection}.
 *
 * @public
 */
export type PropertySectionProps = {
  /** Optional collapsible affordance. Pass `false` to render the body inline (default). */
  collapsible?: boolean;
  /** Property entries in render order. */
  entries: PropertyEntry[];
  /** Localizable strings. */
  labels?: PropertySectionLabels;
  /** Optional title rendered as a small uppercase heading above the grid. */
  title?: ReactNode;
} & ComponentPropsWithoutRef<"section">;

const Grid = (props: { entries: PropertyEntry[] }): React.ReactElement => (
  <dl
    className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 px-3 pb-3 pt-1 text-xs"
    data-property-grid
  >
    {props.entries.map((entry) => (
      <div className="contents" key={entry.id}>
        <dt
          className="flex flex-col text-muted-foreground"
          data-property-id={entry.id}
        >
          <span>{entry.label}</span>
          {entry.sublabel ? (
            <span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
              {entry.sublabel}
            </span>
          ) : null}
        </dt>
        <dd className="text-right text-foreground">{entry.value}</dd>
      </div>
    ))}
  </dl>
);

const Body = (props: {
  collapsible: boolean;
  entries: PropertyEntry[];
  title?: ReactNode;
}): React.ReactElement => {
  const grid = <Grid entries={props.entries} />;
  if (!props.title) {
    return grid;
  }
  if (props.collapsible) {
    return (
      <details className="group" open>
        <summary
          className="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
          data-property-summary
        >
          <span>{props.title}</span>
          <span
            aria-hidden="true"
            className="text-muted-foreground/70 group-open:rotate-90"
          >
          </span>
        </summary>
        {grid}
      </details>
    );
  }
  return (
    <>
      <header
        className="flex items-center justify-between gap-2 border-b border-border px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
        data-property-header
      >
        {props.title}
      </header>
      {grid}
    </>
  );
};

/**
 * Compact key / value grid for an inspector panel. Use one
 * `PropertySection` per logical group (Identity, Layout, State, etc.)
 * so the right dock stays scannable. Pure presentation — the host
 * computes the entries from the current selection.
 *
 * @example
 * ```tsx
 * <PropertySection
 *   title="Layout"
 *   entries={[
 *     { id: "x", label: "X", value: "120" },
 *     { id: "y", label: "Y", value: "80" },
 *     { id: "size", label: "Size", value: "240 × 120" },
 *   ]}
 * />
 * ```
 *
 * @public
 */
export const PropertySection = forwardRef<HTMLElement, PropertySectionProps>(
  (props, ref) => {
    const {
      className,
      collapsible = false,
      entries,
      labels,
      title,
      ...rest
    } = props;
    const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
    return (
      <section
        aria-label={resolvedLabels.region}
        className={cn(
          "rounded-lg border bg-background text-foreground",
          className,
        )}
        data-property-section
        ref={ref}
        {...rest}
      >
        <Body collapsible={collapsible} entries={entries} title={title} />
      </section>
    );
  },
);
PropertySection.displayName = "PropertySection";
typescript

Dependencies

  • @vllnt/ui@^0.2.1