Theme
Design your own
On this page (17)
Component Skill — components/property-grid/component-skill.md

Native basis

A <table> - <th scope="row"> keys, <td> values - generated from a JSON object; the editors are the library's own .input, .select, .switch and .textarea, or native color / date inputs.

Web Platform APIs

structuredClone()CustomEventvalueAsNumberposition: sticky

Classes

.property-grid.property-grid-source.property-grid-config.property-grid-table.property-grid-row.property-grid-group.property-grid-key.property-grid-value.property-grid-editor

Data attributes

data-variant="comfortable", data-sort="asc|desc", data-readonly, data-headless, data-frame="none", data-key-label, data-value-label, data-empty-text

§A service's settings

Click a value (or Tab to it and press Enter / F2): text, numbers, a switch for booleans, a select for the region (sourceConfig options), a color, a date, a textarea for multi-line text. Enter commits, Escape cancels, Tab commits and moves on. limits and tags fold.

§Render functions

keyRenderFn(key, value, ctx) and valueRenderFn(value, key, ctx) are called for every row; each returns text or a node for its cell - or null for the default. ctx carries the path, depth, type, the key's config, the whole source and the grid.

§Badges, units, icons

Keys get a type glyph, booleans a badge, latencies a unit, dates a relative label - the values stay plain JSON underneath (edit one to see).

§Custom editors

getEditorFn(key, value, ctx) picks the editor per key: return an element (an input or a select - its value is read back, a range or number input as a number), an object { el, getValue(), validate?(), focus?(), immediate? } for anything composite, null for the built-in editor or false to make the key read-only. Every committed change fires property-grid-beforechange (cancelable) and property-grid-change.

§A slider, a tag combobox, a guarded value

opacity edits with a slider; tags with the Combobox tag input (type to filter, Enter or a comma adds, × removes, new tags allowed) - cloned from a template, read back as an array; budget refuses values over 5000 through property-grid-beforechange. Ctrl / Cmd + Enter or a click outside commits the tags; the log lists every change.

§Validation

A refused value keeps its editor open, marks it aria-invalid and says why under it (announced). Rules live in sourceConfig - required, min / max, integer, minLength / maxLength, pattern, minItems / maxItems, with an optional message - and validateFn(key, value, ctx) checks anything else, across keys too (ctx.source).

§Rules and a cross-field check

Try port 80 (below 1024), replicas 2.5 (not whole), an empty name, an e-mail without @, or a budget above the limit (validateFn reads ctx.source.limit). The message shows under the editor until the value is valid or you press Escape.

§Data in, data out

The object is the grid's state, so it can be read and written from outside at any time: getSource() / getProperty() read copies, setSource() / setProperty() write (a write fires property-grid-change like an edit), and el.store.subscribe follows every change - here into a Code mockup that marks the lines that just changed.

§Read, write, follow

Edit a value or press the buttons: the JSON on the right follows the store (changed lines marked). Apply JSON replaces the whole object - invalid JSON is refused with the parser's message.

§Inspector in a closable panel

The Studio pattern: select something, inspect it, edit it. Click a box or an arrow in the diagram - the Panel on the right shows its properties; edit the name, the meta, the tone or the shape and the diagram follows. The panel closes with its × tool (the canvas takes the room); the Inspector button toggles it (data-panel-toggle - its aria-expanded says whether the panel is open).

§Select, inspect, edit

diagram-activate feeds setSource(); property-grid-change writes back into the diagram and redraws it. The panel is closable (.panel-close) and the Inspector button toggles it ([data-panel-toggle]).

§Variants

§Comfortable, sorted, read-only

data-variant=comfortable gives the rows room; data-sort=asc orders the keys (arrays keep their order); data-readonly shows without editing.

§States

Named states via the shared State API, bound on each grid:

  • default - the table: every key and value of the source
  • editing - one value open in its editor (config.editing is its path, data-editing on the grid)

The config is { source, editing, collapsed } - the JSON object is part of the state, so el.store.subscribe sees every committed edit. The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/property-grid-{state}.png.

Machine contract - verified against property-grid.schema.json by bun run verify:

StateTypeValuesDefaultDescription
editingstring——The path of the value open in its editor - setState('editing', { editing: 'replicas' }); mirrored as data-editing on the grid.

§API

Generated from property-grid.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type PropertyGridState = 'default' | 'editing' - setState(name, config) takes the config of the state it names.

StateDescription
default
The table - every key and value of the source.
Config fieldTypeDescription
source?PropertyGridSourcethe object shown (a copy is kept); replacing it rebuilds the table
editing?string | nullthe open editor's path - null closes it
collapsed?string[]the paths of the collapsed groups
editing
One value open in its editor (data-editing on the grid).
Config fieldTypeDescription
editing?string | nullthe path of the property to edit ("owner.team")
source?PropertyGridSourcethe object shown
collapsed?string[]the paths of the collapsed groups

Every element

MemberDescription
el.api.setState<S extends PropertyGridState>(name: S, config?: PropertyGridStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?PropertyGridStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.propertyGridApi.setState<S extends PropertyGridState>(el: HTMLElement, name: S, config?: PropertyGridStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?PropertyGridStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.propertyGridApi.getState(el: HTMLElement): { name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.propertyGridApi.render(state: { name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.propertyGridApi.store(el: HTMLElement): Store<{ name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: PropertyGridState; config: PropertyGridStateConfigs[PropertyGridState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.propertyGridApi.commit<S extends PropertyGridState>(el: HTMLElement, name: S, config?: PropertyGridStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?PropertyGridStateConfigs[S]its config
df$.shadcn.propertyGridStates: PropertyGridState[]The declared states, 'default' first: default, editing.

df$.shadcn.propertyGrid

MemberDescription
configure(target: string | HTMLElement, options: PropertyGridOptions = {}): HTMLElement | null
Configure a grid. source: the JSON object to show and edit. sourceConfig: per key or path ("owner.team") - { displayName, type ('string' | 'number' | 'boolean' | 'enum' | 'color' | 'date' | 'text' | 'json'), options (enum values or { value, label }), readOnly, hidden, description, and the rules required, min, max, integer, step, minLength, maxLength, pattern, minItems, maxItems, message (replaces the rule's own message). validateFn(key, value, ctx) returns a message (or false) to refuse a value. keyRenderFn(key, value, ctx) / valueRenderFn(value, key, ctx) return the cell's content (text or a node; null = the default rendering). getEditorFn(key, value, ctx) returns an editor - an input / select element, or { el, getValue(), validate?(), focus?(), immediate?, ownsEnter? } - null for the built-in one, false for read-only. ctx = { path, depth, type, config, source, grid }.
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
optionsPropertyGridOptions = {}the source and the hooks to set (merged into the current options)

Returns HTMLElement | null - the grid, null when the target matches none

setSource(target: string | HTMLElement, source: PropertyGridSource): void
Show another object (a copy is kept - the grid never mutates what you pass).
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
sourcePropertyGridSourcethe object to show
getSource(target: string | HTMLElement): PropertyGridSource
A copy of the object the grid holds now - every committed edit included.
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector

Returns PropertyGridSource - the object, with every committed edit

setProperty(target: string | HTMLElement, path: string | string[], value: PropertyGridValue): void
Write one property ("a.b" or ['a', 'b']) - fires property-grid-change like an edit.
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
pathstring | string[]the property: "a.b" or ['a', 'b']
valuePropertyGridValuethe new value (a copy is written)
getProperty(target: string | HTMLElement, path: string | string[]): PropertyGridValue
One property's value ("a.b" or ['a', 'b']).
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
pathstring | string[]the property: "a.b" or ['a', 'b']

Returns PropertyGridValue - a copy of its value (undefined when there is no such property)

edit(target: string | HTMLElement, path: string | string[]): void
Open a property's editor (state 'editing').
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
pathstring | string[]the property: "a.b" or ['a', 'b']
commit(target: string | HTMLElement): boolean
Commit the open editor.
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector

Returns boolean - false when its value is invalid (the editor stays open with the message); true otherwise

cancel(target: string | HTMLElement): void
Close the open editor without writing.
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
expand(target: string | HTMLElement, path: string | string[]): void
Expand a group (an object / array property).
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
pathstring | string[]the property: "a.b" or ['a', 'b']
collapse(target: string | HTMLElement, path: string | string[]): void
Collapse a group.
ArgumentTypeDescription
targetstring | HTMLElementthe .property-grid element or its selector
pathstring | string[]the property: "a.b" or ['a', 'b']

Events

EventDescription
property-grid-beforechange
Fires before a committed value is written - cancelable: preventDefault() keeps the old value. detail.path is the property's path ("owner.team").

detail: PropertyGridBeforeChangeDetail

FieldTypeDescription
pathstringthe property's path, dotted ("owner.team")
keystringits own key
valuePropertyGridValuethe value about to be written
oldValuePropertyGridValuethe value it holds now
property-grid-change
Fires after a value was written - its path, key, the new and the old value and the whole new source object.

detail: PropertyGridChangeDetail

FieldTypeDescription
pathstringthe property's path, dotted ("owner.team")
keystringits own key
valuePropertyGridValuethe value written
oldValuePropertyGridValuethe value it held
sourcePropertyGridSourcea copy of the whole new object

Types

TypeDescription
PropertyGridBeforeChangeDetail
What property-grid-beforechange carries.
FieldTypeDescription
pathstringthe property's path, dotted ("owner.team")
keystringits own key
valuePropertyGridValuethe value about to be written
oldValuePropertyGridValuethe value it holds now
PropertyGridChangeDetail
What property-grid-change carries.
FieldTypeDescription
pathstringthe property's path, dotted ("owner.team")
keystringits own key
valuePropertyGridValuethe value written
oldValuePropertyGridValuethe value it held
sourcePropertyGridSourcea copy of the whole new object
PropertyGridContext
Where a hook is called for: what keyRenderFn / valueRenderFn / getEditorFn / validateFn receive.
FieldTypeDescription
pathstring[]the property's path
depth?numberhow deep it is nested (render hooks)
typestringthe type it edits as
configPropertyGridKeyConfig | undefinedits sourceConfig settings, undefined when it has none
sourcePropertyGridSourcethe whole object the grid holds
gridHTMLElementthe grid element
PropertyGridEditor
A custom editor getEditorFn may return (an input / select element works too).
FieldTypeDescription
elHTMLElementthe editor element, placed in the value cell
getValue() => PropertyGridValuethe value it holds now
validate?() => stringa message when the value is not acceptable, '' when it is
focus?() => voidfocus the editor (default: el.focus())
immediate?booleancommit on every change (a checkbox, a select) instead of on Enter / blur
ownsEnter?booleanEnter belongs to the editor (Ctrl / Cmd + Enter commits)
PropertyGridKeyConfig
One key's settings in sourceConfig - by its path ("owner.team"), else by its own key.
FieldTypeDescription
displayName?stringshown instead of the key
type?PropertyGridTypehow it edits
options?Array<string | number | { value: string | number; label: string }>the choices of an enum: values, or { value, label }
readOnly?booleanshown, not editable
hidden?booleannot shown
description?stringthe key's tooltip
required?booleana value is needed
min?numbernumbers: the smallest allowed
max?numbernumbers: the largest allowed
integer?booleannumbers: whole numbers only
step?numbernumbers: the editor's step
minLength?numbertext: the fewest characters
maxLength?numbertext: the most characters
pattern?stringtext: a regular expression (source) the whole value must match
minItems?numberarrays: the fewest items
maxItems?numberarrays: the most items
message?stringreplaces the message of whichever rule fails
PropertyGridOptions
What configure() takes - every key optional, merged into the grid's options.
FieldTypeDescription
source?PropertyGridSourcethe object to show and edit (a copy is kept)
sourceConfig?Record<string, PropertyGridKeyConfig>per key or path: label, type, choices, rules
validateFn?(key: string, value: PropertyGridValue, ctx: PropertyGridContext) => string | false | null | undefinedrefuse a value: return a message, or false for a generic one; anything else accepts it
keyRenderFn?(key: string, value: PropertyGridValue, ctx: PropertyGridContext) => string | Node | nullthe key cell's content: text or a node; null keeps the default
valueRenderFn?(value: PropertyGridValue, key: string, ctx: PropertyGridContext) => string | Node | nullthe value cell's content: text or a node; null keeps the default
getEditorFn?(key: string, value: PropertyGridValue, ctx: PropertyGridContext) => HTMLElement | PropertyGridEditor | null | falsean editor for a property: an element or a PropertyGridEditor; null: the built-in one; false: read-only
PropertyGridSource
The object a grid shows and edits.
PropertyGridType
How a property edits; without one it follows its value (a #rrggbb string: color, YYYY-MM-DD: date, multi-line: text).

= 'string' | 'number' | 'boolean' | 'enum' | 'color' | 'date' | 'text' | 'json'

PropertyGridValue
A JSON value - what a property holds.

= string | number | boolean | null | PropertyGridValue[] | { [key: string]: PropertyGridValue }

§CSS view file

/* -- Property Grid -------------------------------------------------------------
   Two columns over one JSON object - key, value - dense by default. Groups
   (nested objects / arrays) fold; a value edits in place. Every color, radius
   and font is the theme's. */
@layer components {
  :where(.property-grid, .property-grid *, .property-grid *::before, .property-grid *::after) {
    box-sizing: border-box;
  }
  .property-grid {
    --property-grid-key-width: 42%;
    --property-grid-row: 1.875rem;
    --property-grid-pad-x: 0.5rem;
    --property-grid-indent: 0.875rem;
    --property-grid-font: 0.8125rem;
    min-width: 0;
    overflow: auto;
    overscroll-behavior: contain;
    background: var(--card);
    color: var(--card-foreground);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    font-size: var(--property-grid-font);
    line-height: 1.35;
    &[data-variant="comfortable"] {
      --property-grid-row: 2.375rem;
      --property-grid-pad-x: 0.75rem;
      --property-grid-indent: 1.125rem;
      --property-grid-font: 0.875rem;
    }
    /* inside a panel body (data-flush) the panel is the frame */
    &[data-frame="none"] {
      border: 0;
      border-radius: 0;
      background: transparent;
    }
  }
  .property-grid-table {
    inline-size: 100%;
    border-collapse: collapse;
    table-layout: fixed;
    & thead th {
      position: sticky;
      top: 0;
      z-index: 1;
      block-size: var(--property-grid-row);
      padding: 0 var(--property-grid-pad-x);
      background: var(--muted);
      color: var(--muted-foreground);
      border-block-end: 1px solid var(--border);
      font-size: 0.75em;
      font-weight: 600;
      letter-spacing: 0.04em;
      text-align: start;
      text-transform: uppercase;
    }
    & thead th.property-grid-key {
      inline-size: var(--property-grid-key-width);
      border-inline-end: 1px solid var(--border);
    }
    & tbody tr {
      block-size: var(--property-grid-row);
    }
    & tbody tr + tr > * {
      border-block-start: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
    }
  }
  .property-grid-key {
    padding: 0 var(--property-grid-pad-x);
    padding-inline-start: calc(var(--property-grid-pad-x) + var(--depth, 0) * var(--property-grid-indent));
    border-inline-end: 1px solid var(--border);
    font-weight: 500;
    text-align: start;
    vertical-align: middle;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .property-grid-label {
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .property-grid-value {
    position: relative;
    padding: 0 var(--property-grid-pad-x);
    vertical-align: middle;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    cursor: text;
    outline: none;
    &:focus-visible {
      box-shadow: inset 0 0 0 2px var(--ring);
    }
    &[data-editing] {
      padding: 0.125rem;
      overflow: visible;
      white-space: normal;
      cursor: default;
    }
  }
  .property-grid-row:not([data-readonly]) > .property-grid-value:hover {
    background: color-mix(in oklab, var(--accent) 70%, transparent);
  }
  .property-grid-row[data-readonly] > .property-grid-value {
    color: var(--muted-foreground);
    cursor: default;
  }
  .property-grid-row[data-type="number"] > .property-grid-value {
    font-variant-numeric: tabular-nums;
  }
  /* groups: a nested object / array, folded with its toggle */
  .property-grid-group {
    background: color-mix(in oklab, var(--muted) 55%, transparent);
    & > .property-grid-key {
      font-weight: 600;
    }
    & > .property-grid-value {
      cursor: default;
    }
  }
  .property-grid-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    max-inline-size: 100%;
    margin: 0 0 0 -0.25rem;
    padding: 0.125rem 0.25rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: inherit;
    font: inherit;
    cursor: pointer;
    &::before {
      content: "";
      flex: none;
      inline-size: 0.4rem;
      block-size: 0.4rem;
      border-inline-end: 1.5px solid currentColor;
      border-block-end: 1.5px solid currentColor;
      rotate: -45deg;
      transition: rotate 0.15s ease;
    }
    &[aria-expanded="true"]::before {
      rotate: 45deg;
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 1px;
    }
  }
  /* default value renderings */
  .property-grid-summary,
  .property-grid-null {
    color: var(--muted-foreground);
    font-family: var(--font-mono);
    font-size: 0.92em;
  }
  .property-grid-null {
    font-style: italic;
  }
  .property-grid-bool {
    font-family: var(--font-mono);
    font-size: 0.92em;
    &[data-value="true"] { color: var(--chart-2); }
    &[data-value="false"] { color: var(--muted-foreground); }
  }
  .property-grid-color {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    font-family: var(--font-mono);
    font-size: 0.92em;
  }
  .property-grid-swatch {
    flex: none;
    inline-size: 0.875rem;
    block-size: 0.875rem;
    border: 1px solid color-mix(in oklab, var(--foreground) 25%, transparent);
    border-radius: var(--radius-sm);
  }
  .property-grid-empty > td {
    padding: 0.75rem var(--property-grid-pad-x);
    color: var(--muted-foreground);
    text-align: center;
  }
  /* the editor fills its cell */
  .property-grid-editor {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    min-block-size: calc(var(--property-grid-row) - 0.25rem);
    & > :is(.input, .select, .textarea) {
      inline-size: 100%;
      min-inline-size: 0;
      font-size: inherit;
    }
    & > .textarea {
      min-block-size: 4.5rem;
      font-family: var(--font-mono);
      font-size: 0.92em;
      resize: vertical;
    }
    & > .switch {
      margin-inline-start: calc(var(--property-grid-pad-x) - 0.125rem);
    }
    & [aria-invalid="true"] {
      border-color: var(--destructive);
      box-shadow: 0 0 0 2px color-mix(in oklab, var(--destructive) 25%, transparent);
    }
  }
  /* a refused value: the reason under the editor */
  .property-grid-error {
    flex-basis: 100%;
    padding: 0.125rem 0.25rem 0.25rem;
    color: var(--destructive);
    font-size: 0.75rem;
    line-height: 1.3;
    white-space: normal;
  }
  .property-grid-color-input {
    inline-size: 2.5rem;
    block-size: calc(var(--property-grid-row) - 0.5rem);
    margin-inline-start: calc(var(--property-grid-pad-x) - 0.125rem);
    padding: 0.125rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-sm);
    background: var(--background);
    cursor: pointer;
  }
  @media (prefers-reduced-motion: reduce) {
    .property-grid-toggle::before { transition: none; }
  }
  @media (prefers-contrast: more) {
    .property-grid-key,
    .property-grid-table tbody tr + tr > * { border-color: var(--foreground); }
  }
  @media (forced-colors: active) {
    .property-grid,
    .property-grid-key,
    .property-grid-table thead th { border-color: CanvasText; }
    .property-grid-value:focus-visible { outline: 2px solid Highlight; box-shadow: none; }
    .property-grid-swatch { forced-color-adjust: none; }
  }
}

§JS view file

// -- Property Grid ----------------------------------------------------------
// The ExtJS property grid for this library: a two-column table - key, value -
// over ONE JSON object, dense by default. Nested objects and arrays become
// collapsible groups with their properties indented below. A click on a value
// (or Enter / F2) edits it in place with an editor that fits its type - text,
// number, a switch, a select of options, a color, a date, a textarea - or the
// one getEditorFn hands back; keyRenderFn / valueRenderFn draw the cells. The
// JSON object IS the state: el.store.value.config.source, replaced (never
// mutated) on every committed edit, so subscribers see each change.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** A JSON value - what a property holds. */
type PropertyGridValue = string | number | boolean | null | PropertyGridValue[] | { [key: string]: PropertyGridValue };
/** The object a grid shows and edits. */
type PropertyGridSource = { [key: string]: PropertyGridValue };
/** How a property edits; without one it follows its value (a #rrggbb string: color, YYYY-MM-DD: date, multi-line: text). */
type PropertyGridType = 'string' | 'number' | 'boolean' | 'enum' | 'color' | 'date' | 'text' | 'json';
/** One key's settings in sourceConfig - by its path ("owner.team"), else by its own key. */
interface PropertyGridKeyConfig {
  /** shown instead of the key */
  displayName?: string;
  /** how it edits */
  type?: PropertyGridType;
  /** the choices of an enum: values, or { value, label } */
  options?: Array<string | number | { value: string | number; label: string }>;
  /** shown, not editable */
  readOnly?: boolean;
  /** not shown */
  hidden?: boolean;
  /** the key's tooltip */
  description?: string;
  /** a value is needed */
  required?: boolean;
  /** numbers: the smallest allowed */
  min?: number;
  /** numbers: the largest allowed */
  max?: number;
  /** numbers: whole numbers only */
  integer?: boolean;
  /** numbers: the editor's step */
  step?: number;
  /** text: the fewest characters */
  minLength?: number;
  /** text: the most characters */
  maxLength?: number;
  /** text: a regular expression (source) the whole value must match */
  pattern?: string;
  /** arrays: the fewest items */
  minItems?: number;
  /** arrays: the most items */
  maxItems?: number;
  /** replaces the message of whichever rule fails */
  message?: string;
}
/** Where a hook is called for: what keyRenderFn / valueRenderFn / getEditorFn / validateFn receive. */
interface PropertyGridContext {
  /** the property's path */
  path: string[];
  /** how deep it is nested (render hooks) */
  depth?: number;
  /** the type it edits as */
  type: string;
  /** its sourceConfig settings, undefined when it has none */
  config: PropertyGridKeyConfig | undefined;
  /** the whole object the grid holds */
  source: PropertyGridSource;
  /** the grid element */
  grid: HTMLElement;
}
/** A custom editor getEditorFn may return (an input / select element works too). */
interface PropertyGridEditor {
  /** the editor element, placed in the value cell */
  el: HTMLElement;
  /** the value it holds now */
  getValue(): PropertyGridValue;
  /** a message when the value is not acceptable, '' when it is */
  validate?(): string;
  /** focus the editor (default: el.focus()) */
  focus?(): void;
  /** commit on every change (a checkbox, a select) instead of on Enter / blur */
  immediate?: boolean;
  /** Enter belongs to the editor (Ctrl / Cmd + Enter commits) */
  ownsEnter?: boolean;
}
/** What configure() takes - every key optional, merged into the grid's options. */
interface PropertyGridOptions {
  /** the object to show and edit (a copy is kept) */
  source?: PropertyGridSource;
  /** per key or path: label, type, choices, rules */
  sourceConfig?: Record<string, PropertyGridKeyConfig>;
  /** refuse a value: return a message, or false for a generic one; anything else accepts it */
  validateFn?: (key: string, value: PropertyGridValue, ctx: PropertyGridContext) => string | false | null | undefined;
  /** the key cell's content: text or a node; null keeps the default */
  keyRenderFn?: (key: string, value: PropertyGridValue, ctx: PropertyGridContext) => string | Node | null;
  /** the value cell's content: text or a node; null keeps the default */
  valueRenderFn?: (value: PropertyGridValue, key: string, ctx: PropertyGridContext) => string | Node | null;
  /** an editor for a property: an element or a PropertyGridEditor; null: the built-in one; false: read-only */
  getEditorFn?: (key: string, value: PropertyGridValue, ctx: PropertyGridContext) => HTMLElement | PropertyGridEditor | null | false;
}
/** What property-grid-beforechange carries. */
interface PropertyGridBeforeChangeDetail {
  /** the property's path, dotted ("owner.team") */
  path: string;
  /** its own key */
  key: string;
  /** the value about to be written */
  value: PropertyGridValue;
  /** the value it holds now */
  oldValue: PropertyGridValue;
}
/** What property-grid-change carries. */
interface PropertyGridChangeDetail {
  /** the property's path, dotted ("owner.team") */
  path: string;
  /** its own key */
  key: string;
  /** the value written */
  value: PropertyGridValue;
  /** the value it held */
  oldValue: PropertyGridValue;
  /** a copy of the whole new object */
  source: PropertyGridSource;
}
/** default = the table; editing = one value open in its editor (config.editing is its path). */
const propertyGridStates = ['default', 'editing'];
/** setState() configs per state - the config IS the grid's data, merged into the stored one. */
export interface PropertyGridStateConfigs {
  /** The table - every key and value of the source. */
  default: {
    /** the object shown (a copy is kept); replacing it rebuilds the table */
    source?: PropertyGridSource;
    /** the open editor's path - null closes it */
    editing?: string | null;
    /** the paths of the collapsed groups */
    collapsed?: string[];
  };
  /** One value open in its editor (data-editing on the grid). */
  editing: {
    /** the path of the property to edit ("owner.team") */
    editing?: string | null;
    /** the object shown */
    source?: PropertyGridSource;
    /** the paths of the collapsed groups */
    collapsed?: string[];
  };
}
const isGroup = (v) => v !== null && typeof v === 'object';
const pathKey = (path) => path.join('.');
const toPath = (path) => (Array.isArray(path) ? path.map(String) : String(path).split('.').filter((s) => s !== ''));
const clone = (v) => (v === undefined ? undefined : structuredClone(v));
const configOf = (root) => root._config ?? root.store?.value.config ?? {};
const optionsOf = (root) => root._options ?? {};
function getAt(obj, path) {
  let at = obj;
  for (const k of path) {
    if (!isGroup(at)) return undefined;
    at = at[k];
  }
  return at;
}
/** a copy of obj with the value at path replaced - the source is never mutated */
function setAt(obj, path, value) {
  if (!path.length) return value;
  const [head, ...rest] = path;
  const copy = Array.isArray(obj) ? [...obj] : { ...obj };
  copy[head] = setAt(isGroup(obj) ? obj[head] : undefined, rest, value);
  return copy;
}
/** the type a property edits as: its sourceConfig type, else what its value looks like */
function typeOf(value, conf) {
  if (conf?.type) return conf.type;
  if (conf?.options) return 'enum';
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'array';
  if (typeof value === 'object') return 'object';
  if (typeof value === 'boolean') return 'boolean';
  if (typeof value === 'number') return 'number';
  if (typeof value === 'string') {
    if (/^#[0-9a-f]{6}$/i.test(value)) return 'color';
    if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return 'date';
    if (value.includes('\n')) return 'text';
  }
  return 'string';
}
/** a key's sourceConfig - by its full path ("owner.team"), else by its own key */
function confOf(root, path) {
  const sc = optionsOf(root).sourceConfig ?? {};
  return sc[pathKey(path)] ?? sc[path[path.length - 1]] ?? null;
}
// -- the table ---------------------------------------------------------------------------------
/** a function's answer as cell content: a node, or text (null → the default rendering) */
function fill(cell, content) {
  if (content == null || content === false) return false;
  dfDollar(cell).empty();
  if (content instanceof Node) dfDollar(cell).append(content);
  else dfDollar(cell).text(String(content));
  return true;
}
function defaultValue(cell, value, type) {
  dfDollar(cell).empty();
  const span = document.createElement('span');
  if (type === 'object' || type === 'array') {
    span.className = 'property-grid-summary';
    const n = Object.keys(value).length;
    dfDollar(span).text(type === 'array' ? `[${n} ${n === 1 ? 'item' : 'items'}]` : `{${n} ${n === 1 ? 'property' : 'properties'}}`);
  } else if (type === 'boolean') {
    span.className = 'property-grid-bool';
    span.dataset.value = String(value);
    dfDollar(span).text(String(value));
  } else if (type === 'color') {
    span.className = 'property-grid-color';
    const swatch = document.createElement('i');
    swatch.className = 'property-grid-swatch';
    swatch.style.background = value;
    swatch.setAttribute('aria-hidden', 'true');
    dfDollar(span).append(swatch).append(document.createTextNode(value));
  } else if (type === 'json') {
    span.className = 'property-grid-summary';
    dfDollar(span).text(JSON.stringify(value));
  } else if (type === 'null') {
    span.className = 'property-grid-null';
    dfDollar(span).text('null');
  } else {
    dfDollar(span).text(String(value ?? ''));
  }
  dfDollar(cell).append(span);
}
/** the rows of a source: one per property, a group row (+ its children unless collapsed) per object / array */
function rowsOf(root, source) {
  const out = [];
  const collapsed = new Set(configOf(root).collapsed ?? []);
  const sort = root.dataset.sort;
  const walk = (obj, path, depth) => {
    let keys = Object.keys(obj);
    if (!Array.isArray(obj) && (sort === 'asc' || sort === 'desc')) {
      keys = keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }) * (sort === 'desc' ? -1 : 1));
    }
    for (const key of keys) {
      const p = [...path, key];
      const value = obj[key];
      const conf = confOf(root, p);
      if (conf?.hidden) continue;
      out.push({ path: p, key, value, depth, conf, type: typeOf(value, conf), group: isGroup(value) && !conf?.type });
      if (isGroup(value) && !conf?.type && !collapsed.has(pathKey(p))) walk(value, p, depth + 1);
    }
  };
  if (isGroup(source)) walk(source, [], 0);
  return out;
}
/** the table, (re)built from the current source - keeps the focused row */
function renderRows(root) {
  const { source = {} } = configOf(root);
  const opts = optionsOf(root);
  let table = dfDollar(root).children('.property-grid-table').get(0);
  const focused = table?.contains(document.activeElement) ? document.activeElement.closest('tr')?.dataset.path : null;
  if (!table) {
    table = document.createElement('table');
    table.className = 'property-grid-table';
    const head = document.createElement('thead');
    const tr = document.createElement('tr');
    for (const [label, cls] of [[root.dataset.keyLabel || 'Property', 'property-grid-key'], [root.dataset.valueLabel || 'Value', 'property-grid-value']]) {
      const th = document.createElement('th');
      th.scope = 'col';
      th.className = cls;
      dfDollar(th).text(label);
      tr.append(th);
    }
    head.append(tr);
    if (root.hasAttribute('data-headless')) head.hidden = true;
    table.append(head, document.createElement('tbody'));
    dfDollar(root).append(table);
  }
  const body = dfDollar(table).children('tbody').get(0);
  dfDollar(body).empty();
  const readonlyAll = root.hasAttribute('data-readonly');
  const rows = rowsOf(root, source);
  for (const row of rows) {
    const tr = document.createElement('tr');
    tr._path = row.path;
    tr.className = row.group ? 'property-grid-group' : 'property-grid-row';
    tr.dataset.path = pathKey(row.path);
    tr.dataset.type = row.type;
    if (row.depth) tr.style.setProperty('--depth', String(row.depth));
    const keyCell = document.createElement('th');
    keyCell.scope = 'row';
    keyCell.className = 'property-grid-key';
    const valueCell = document.createElement('td');
    valueCell.className = 'property-grid-value';
    const ctx = { path: [...row.path], depth: row.depth, type: row.type, config: row.conf, source, grid: root };
    if (row.conf?.description) keyCell.title = row.conf.description;
    // the key: keyRenderFn, else the displayName, else the key
    const keyText = document.createElement('span');
    keyText.className = 'property-grid-label';
    if (!fill(keyText, opts.keyRenderFn?.(row.key, row.value, ctx))) dfDollar(keyText).text(row.conf?.displayName ?? row.key);
    if (row.group) {
      const toggle = document.createElement('button');
      toggle.type = 'button';
      toggle.className = 'property-grid-toggle';
      const open = !(configOf(root).collapsed ?? []).includes(tr.dataset.path);
      toggle.setAttribute('aria-expanded', String(open));
      toggle.tabIndex = -1;
      dfDollar(toggle).append(keyText);
      dfDollar(keyCell).append(toggle);
    } else {
      dfDollar(keyCell).append(keyText);
    }
    // the value: valueRenderFn, else by type
    if (!fill(valueCell, opts.valueRenderFn?.(row.value, row.key, ctx))) defaultValue(valueCell, row.value, row.type);
    const readonly = readonlyAll || row.conf?.readOnly || (row.group && !opts.getEditorFn);
    if (readonly) tr.dataset.readonly = '';
    else valueCell.setAttribute('aria-label', `${row.conf?.displayName ?? row.key}: edit`);
    valueCell.tabIndex = -1;
    tr.append(keyCell, valueCell);
    body.append(tr);
  }
  if (!rows.length) {
    const tr = document.createElement('tr');
    tr.className = 'property-grid-empty';
    const td = document.createElement('td');
    td.colSpan = 2;
    dfDollar(td).text(root.dataset.emptyText || 'No properties.');
    tr.append(td);
    body.append(tr);
  }
  // one row in the tab order (roving)
  const target = focusTargets(root);
  const keep = target.find((t) => t.closest('tr')?.dataset.path === focused) ?? target.find((t) => t.closest('tr')?.dataset.path === root._current) ?? target[0];
  if (keep) {
    keep.tabIndex = 0;
    root._current = keep.closest('tr').dataset.path;
    if (focused) keep.focus();
  }
}
/** each row's keyboard stop: a group's toggle, a property's value cell */
const focusTargets = (root) =>
  dfDollar(root)
    .find('.property-grid-table > tbody > tr')
    .toArray()
    .map((tr) => (tr.classList.contains('property-grid-group') ? dfDollar(tr).find('.property-grid-toggle').get(0) : dfDollar(tr).children('.property-grid-value').get(0)))
    .filter(Boolean);
function moveFocus(root, from, by) {
  const targets = focusTargets(root);
  const at = targets.indexOf(from);
  const next = targets[by === Infinity ? targets.length - 1 : by === -Infinity ? 0 : Math.max(0, Math.min(targets.length - 1, at + by))];
  if (!next) return;
  for (const t of targets) t.tabIndex = -1;
  next.tabIndex = 0;
  root._current = next.closest('tr').dataset.path;
  next.focus();
}
// -- editing ---------------------------------------------------------------------------------
/** the built-in editor for a type */
function defaultEditor(type, value, conf) {
  let el;
  if (type === 'boolean') {
    el = document.createElement('input');
    el.type = 'checkbox';
    el.className = 'switch';
    el.setAttribute('role', 'switch');
    el.checked = !!value;
    return { el, getValue: () => el.checked, immediate: true };
  }
  if (type === 'enum') {
    el = document.createElement('select');
    el.className = 'select';
    el.dataset.size = 'xs';
    for (const opt of conf?.options ?? []) {
      const o = document.createElement('option');
      const [v, label] = isGroup(opt) ? [opt.value, opt.label ?? opt.value] : [opt, opt];
      o.value = String(v);
      dfDollar(o).text(String(label));
      if (v === value) o.selected = true;
      el.append(o);
    }
    const values = (conf?.options ?? []).map((o) => (isGroup(o) ? o.value : o));
    return { el, getValue: () => values.find((v) => String(v) === el.value) ?? el.value, immediate: true };
  }
  if (type === 'text' || type === 'object' || type === 'array' || type === 'json') {
    el = document.createElement('textarea');
    el.className = 'textarea';
    el.rows = type === 'text' ? 3 : 5;
    const json = type !== 'text';
    el.value = json ? JSON.stringify(value, null, 2) : String(value ?? '');
    return {
      el,
      multiline: true,
      getValue: () => (json ? JSON.parse(el.value) : el.value),
      validate: () => {
        if (!json) return '';
        try {
          JSON.parse(el.value);
          return '';
        } catch (error) {
          return String(error.message ?? error);
        }
      },
    };
  }
  el = document.createElement('input');
  el.className = type === 'color' ? 'property-grid-color-input' : 'input';
  if (type !== 'color') el.dataset.size = 'xs';
  el.type = type === 'number' ? 'number' : type === 'color' ? 'color' : type === 'date' ? 'date' : 'text';
  if (type === 'number') {
    el.step = conf?.step ?? 'any';
    if (conf?.min != null) el.min = conf.min;
    if (conf?.max != null) el.max = conf.max;
    el.inputMode = 'decimal';
  }
  el.value = value == null ? '' : String(value);
  return {
    el,
    immediate: type === 'color' || type === 'date',
    getValue: () => (type === 'number' ? (el.value === '' ? null : el.valueAsNumber) : type === 'null' && el.value === '' ? null : el.value),
    validate: () => (type === 'number' && el.value !== '' && Number.isNaN(el.valueAsNumber) ? 'not a number' : el.validity && !el.validity.valid ? el.validationMessage : ''),
  };
}
/** getEditorFn's answer as an editor - an element, an { el, getValue … } object, null (the default) or false (read-only) */
function editorFor(root, row, value) {
  const conf = confOf(root, row._path);
  const type = row.dataset.type;
  const ctx = { path: [...row._path], type, config: conf, source: configOf(root).source, grid: root };
  const custom = optionsOf(root).getEditorFn?.(row._path[row._path.length - 1], value, ctx);
  if (custom === false) return null;
  if (custom instanceof HTMLElement) {
    const el = custom as HTMLInputElement;
    return { el, immediate: el.type === 'checkbox' || el.tagName === 'SELECT', getValue: () => (el.type === 'checkbox' ? el.checked : el.type === 'number' || el.type === 'range' ? el.valueAsNumber : el.value) };
  }
  if (custom && custom.el) return custom;
  return defaultEditor(type, value, conf);
}
/** open a value's editor in its cell */
function openEditor(root, path) {
  closeEditor(root);
  const row = dfDollar(root).find('.property-grid-table > tbody > tr').toArray().find((tr) => tr.dataset.path === path);
  if (!row || row.hasAttribute('data-readonly')) return false;
  const cell = dfDollar(row).children('.property-grid-value').get(0);
  const value = getAt(configOf(root).source, row._path);
  const editor = editorFor(root, row, value);
  if (!editor) return false;
  const wrap = document.createElement('div');
  wrap.className = 'property-grid-editor';
  dfDollar(wrap).append(editor.el);
  cell.dataset.editing = '';
  dfDollar(cell).empty().append(wrap);
  root._editor = { path, row, cell, editor, value };
  const target = editor.el.matches?.('input, select, textarea, button, [tabindex]') ? editor.el : dfDollar(editor.el).find('input, select, textarea, button, [tabindex]').get(0) ?? editor.el;
  (editor.focus ?? (() => target.focus?.()))();
  if (target.select && target.type !== 'checkbox' && target.type !== 'color' && target.type !== 'date') target.select();
  return true;
}
/**
 * The declarative rules of a key (sourceConfig): required, min / max (numbers),
 * minLength / maxLength / pattern (text), integer. Returns the first broken
 * rule as a message ('' = valid); config.message replaces it.
 */
function ruleProblem(conf, value) {
  if (!conf) return '';
  const say = (fallback) => conf.message || fallback;
  const empty = value === null || value === undefined || value === '' || (Array.isArray(value) && !value.length);
  if (conf.required && empty) return say('Required');
  if (empty) return '';
  if (typeof value === 'number') {
    if (Number.isNaN(value)) return say('Not a number');
    if (conf.integer && !Number.isInteger(value)) return say('Must be a whole number');
    if (conf.min != null && value < conf.min) return say(`Must be at least ${conf.min}`);
    if (conf.max != null && value > conf.max) return say(`Must be at most ${conf.max}`);
  }
  if (typeof value === 'string') {
    if (conf.minLength != null && value.length < conf.minLength) return say(`At least ${conf.minLength} characters`);
    if (conf.maxLength != null && value.length > conf.maxLength) return say(`At most ${conf.maxLength} characters`);
    if (conf.pattern && !new RegExp(`^(?:${conf.pattern})$`).test(value)) return say('Does not match the expected format');
  }
  if (Array.isArray(value)) {
    if (conf.minItems != null && value.length < conf.minItems) return say(`At least ${conf.minItems}`);
    if (conf.maxItems != null && value.length > conf.maxItems) return say(`At most ${conf.maxItems}`);
  }
  return '';
}
/** an invalid value: the control is marked, the message shows under it (and is announced) */
function showProblem(ed, control, problem) {
  control.setAttribute?.('aria-invalid', 'true');
  const wrap = dfDollar(ed.cell).children('.property-grid-editor').get(0) ?? ed.cell;
  let note = dfDollar(wrap).children('.property-grid-error').get(0);
  if (!note) {
    note = document.createElement('div');
    note.className = 'property-grid-error';
    note.setAttribute('role', 'alert');
    note.id = `pg-error-${Math.random().toString(36).slice(2, 8)}`;
    dfDollar(wrap).append(note);
    control.setAttribute?.('aria-describedby', note.id);
  }
  dfDollar(note).text(problem);
}
/** remove the editor without writing - the table shows the values again */
function closeEditor(root) {
  if (!root._editor) return;
  root._editor = null;
  renderRows(root);
}
/**
 * Commit the open editor: validate, ask property-grid-beforechange (cancelable),
 * write a NEW source with the value replaced, then property-grid-change.
 * Returns false when the value is invalid (the editor stays open).
 */
function commit(root, then = 'stay') {
  const ed = root._editor;
  if (!ed) return true;
  const control = dfDollar(ed.editor.el).find('input, select, textarea').get(0) ?? ed.editor.el;
  const { path, row } = ed;
  const key = row._path[row._path.length - 1];
  let problem = ed.editor.validate?.() ?? '';
  let value;
  if (!problem) {
    try {
      value = ed.editor.getValue();
    } catch (error) {
      problem = String(error.message ?? error);
    }
  }
  // the key's rules (sourceConfig), then the grid's validateFn
  if (!problem) problem = ruleProblem(confOf(root, row._path), value);
  if (!problem) {
    const answer = optionsOf(root).validateFn?.(key, value, { path: [...row._path], type: row.dataset.type, config: confOf(root, row._path), source: configOf(root).source, grid: root });
    if (typeof answer === 'string' && answer) problem = answer;
    else if (answer === false) problem = 'Invalid value';
  }
  if (problem) {
    showProblem(ed, control, problem);
    return false;
  }
  const oldValue = ed.value;
  closeEditor(root);
  const same = JSON.stringify(value) === JSON.stringify(oldValue);
  // Fires before a committed value is written - cancelable: preventDefault() keeps the old value. detail.path is the property's path ("owner.team").
  const before = new CustomEvent<PropertyGridBeforeChangeDetail>('property-grid-beforechange', { bubbles: true, cancelable: true, detail: { path, key, value, oldValue } });
  if (same || !root.dispatchEvent(before)) {
    root.api.setState('default', { editing: null });
    restoreFocus(root, path, then);
    return true;
  }
  const source = setAt(configOf(root).source, row._path, value);
  root.api.setState('default', { source, editing: null });
  // Fires after a value was written - its path, key, the new and the old value and the whole new source object.
  root.dispatchEvent(new CustomEvent<PropertyGridChangeDetail>('property-grid-change', { bubbles: true, detail: { path, key, value, oldValue, source: clone(source) } }));
  restoreFocus(root, path, then);
  return true;
}
function restoreFocus(root, path, then) {
  const target = focusTargets(root).find((t) => t.closest('tr').dataset.path === path);
  if (!target) return;
  if (then === 'next' || then === 'prev') moveFocus(root, target, then === 'next' ? 1 : -1);
  else if (then === 'stay') moveFocus(root, target, 0);
}
// -- State API -------------------------------------------------------------------------------
/** The markup of a state, for render() AND the live element: the path being edited. */
function applyMarkup(el, state) {
  dfDollar(el).attr('data-editing', state.name === 'editing' && state.config?.editing ? String(state.config.editing) : null);
}
/** UI side of setState: rebuild the table when the source or the groups changed, open / close the editor */
function triggerStateChange(root, state, incoming) {
  root._config = state.config ?? {};
  applyMarkup(root, state);
  const rebuild = !dfDollar(root).children('.property-grid-table').get(0) || 'source' in (incoming ?? {}) || 'collapsed' in (incoming ?? {});
  if (rebuild) {
    root._editor = null;
    renderRows(root);
  }
  if (state.name === 'editing' && state.config?.editing) {
    if (root._editor?.path !== state.config.editing) {
      if (!openEditor(root, state.config.editing)) queueMicrotask(() => root.api.setState('default', { editing: null }));
    }
  } else if (root._editor) {
    // leaving 'editing' without a commit: the cell shows its value again
    closeEditor(root);
  }
}
/** Registry-level API; pass the grid explicitly. Unknown names throw. */
export const propertyGridApi = componentState({
  component: 'property-grid',
  states: propertyGridStates,
  // the config IS the grid's data: { source, editing, collapsed } - each setState merges into it
  mergeConfig: true,
  apply: (root, state, _previous, incoming) => triggerStateChange(root, state, incoming),
  markup: (el, state) => applyMarkup(el, state),
});
df$.propertyGridApi = propertyGridApi;
df$.propertyGridStates = propertyGridStates;
// -- df$.shadcn.propertyGrid: the imperative surface -----------------------------------------
const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);
df$.propertyGrid = {
  /**
   * Configure a grid. source: the JSON object to show and edit. sourceConfig:
   * per key or path ("owner.team") - { displayName, type ('string' | 'number' |
   * 'boolean' | 'enum' | 'color' | 'date' | 'text' | 'json'), options (enum
   * values or { value, label }), readOnly, hidden, description, and the rules
   * required, min, max, integer, step, minLength, maxLength, pattern,
   * minItems, maxItems, message (replaces the rule's own message).
   * validateFn(key, value, ctx) returns a message (or false) to refuse a value.
   * keyRenderFn(key, value, ctx) / valueRenderFn(value, key, ctx) return the
   * cell's content (text or a node; null = the default rendering).
   * getEditorFn(key, value, ctx) returns an editor - an input / select element,
   * or { el, getValue(), validate?(), focus?(), immediate?, ownsEnter? } - null for the
   * built-in one, false for read-only. ctx = { path, depth, type, config, source, grid }.
   * @param target - the .property-grid element or its selector
   * @param options - the source and the hooks to set (merged into the current options)
   * @returns the grid, null when the target matches none
   */
  configure(target: string | HTMLElement, options: PropertyGridOptions = {}): HTMLElement | null {
    const root = resolve(target);
    if (!root) return null;
    const { source, ...rest } = options;
    root._options = { ...root._options, ...rest };
    if (!root.api) {
      // not initialized yet (a script before the component): init picks it up
      if (source !== undefined) root._pendingSource = clone(source);
      return root;
    }
    if (source !== undefined) root.api.setState('default', { source: clone(source), editing: null });
    else root.api.setState(root.store.value.name, { collapsed: configOf(root).collapsed ?? [] });
    return root;
  },
  /**
   * Show another object (a copy is kept - the grid never mutates what you pass).
   * @param target - the .property-grid element or its selector
   * @param source - the object to show
   */
  setSource(target: string | HTMLElement, source: PropertyGridSource): void {
    const root = resolve(target);
    if (!root) return;
    if (!root.api) root._pendingSource = clone(source ?? {});
    else root.api.setState('default', { source: clone(source ?? {}), editing: null, collapsed: [] });
  },
  /**
   * A copy of the object the grid holds now - every committed edit included.
   * @param target - the .property-grid element or its selector
   * @returns the object, with every committed edit
   */
  getSource: (target: string | HTMLElement): PropertyGridSource => clone(configOf(resolve(target)).source ?? {}),
  /**
   * Write one property ("a.b" or ['a', 'b']) - fires property-grid-change like an edit.
   * @param target - the .property-grid element or its selector
   * @param path - the property: "a.b" or ['a', 'b']
   * @param value - the new value (a copy is written)
   */
  setProperty(target: string | HTMLElement, path: string | string[], value: PropertyGridValue): void {
    const root = resolve(target);
    if (!root) return;
    const p = toPath(path);
    const oldValue = getAt(configOf(root).source, p);
    const source = setAt(configOf(root).source, p, clone(value));
    root.api.setState(root.store.value.name === 'editing' ? 'default' : root.store.value.name, { source, editing: null });
    root.dispatchEvent(new CustomEvent<PropertyGridChangeDetail>('property-grid-change', { bubbles: true, detail: { path: pathKey(p), key: p[p.length - 1], value: clone(value), oldValue, source: clone(source) } }));
  },
  /**
   * One property's value ("a.b" or ['a', 'b']).
   * @param target - the .property-grid element or its selector
   * @param path - the property: "a.b" or ['a', 'b']
   * @returns a copy of its value (undefined when there is no such property)
   */
  getProperty: (target: string | HTMLElement, path: string | string[]): PropertyGridValue => clone(getAt(configOf(resolve(target)).source, toPath(path))),
  /**
   * Open a property's editor (state 'editing').
   * @param target - the .property-grid element or its selector
   * @param path - the property: "a.b" or ['a', 'b']
   */
  edit: (target: string | HTMLElement, path: string | string[]): void => { resolve(target)?.api.setState('editing', { editing: pathKey(toPath(path)) }); },
  /**
   * Commit the open editor.
   * @param target - the .property-grid element or its selector
   * @returns false when its value is invalid (the editor stays open with the message); true otherwise
   */
  commit: (target: string | HTMLElement): boolean => commit(resolve(target)),
  /**
   * Close the open editor without writing.
   * @param target - the .property-grid element or its selector
   */
  cancel: (target: string | HTMLElement): void => { resolve(target)?.api.setState('default', { editing: null }); },
  /**
   * Expand a group (an object / array property).
   * @param target - the .property-grid element or its selector
   * @param path - the property: "a.b" or ['a', 'b']
   */
  expand(target: string | HTMLElement, path: string | string[]): void {
    const root = resolve(target);
    const key = pathKey(toPath(path));
    root?.api.setState(root.store.value.name, { collapsed: (configOf(root).collapsed ?? []).filter((p) => p !== key) });
  },
  /**
   * Collapse a group.
   * @param target - the .property-grid element or its selector
   * @param path - the property: "a.b" or ['a', 'b']
   */
  collapse(target: string | HTMLElement, path: string | string[]): void {
    const root = resolve(target);
    const key = pathKey(toPath(path));
    const collapsed = new Set(configOf(root)?.collapsed ?? []);
    collapsed.add(key);
    root?.api.setState(root.store.value.name, { collapsed: [...collapsed] });
  },
};
// -- init ------------------------------------------------------------------------------------
function toggleGroup(root, path) {
  const collapsed = new Set(configOf(root).collapsed ?? []);
  if (collapsed.has(path)) collapsed.delete(path);
  else collapsed.add(path);
  root.api.setState('default', { collapsed: [...collapsed], editing: null });
}
function init() {
  dfDollar('.property-grid:not([data-init])').toArray().forEach((root) => {
    root.dataset.init = '';
    let source = root._pendingSource ?? {};
    const script = dfDollar(root).children('script.property-grid-source').get(0);
    try {
      if (root._pendingSource) delete root._pendingSource;
      else if (script) source = JSON.parse(script.textContent || '{}');
      else if (root.dataset.source) source = JSON.parse(root.dataset.source);
    } catch (error) {
      console.error('[property-grid] invalid source JSON', error);
    }
    // per-key settings without script: <script type="application/json" class="property-grid-config">
    const conf = dfDollar(root).children('script.property-grid-config').get(0);
    if (conf) {
      try {
        root._options = { ...root._options, sourceConfig: JSON.parse(conf.textContent || '{}') };
      } catch (error) {
        console.error('[property-grid] invalid config JSON', error);
      }
    }
    if (!root.getAttribute('role')) root.setAttribute('role', 'group');
    dfDollar(root).on('click', (e) => {
      const t = e.target;
      if (!(t as HTMLElement | null)?.closest || (t as HTMLElement).closest('.property-grid-editor')) return;
      const toggle = (t as HTMLElement).closest<HTMLElement>('.property-grid-toggle');
      if (toggle) {
        toggleGroup(root, toggle.closest('tr').dataset.path);
        return;
      }
      const cell = (t as HTMLElement).closest<HTMLElement>('.property-grid-value');
      const row = cell?.closest('tr');
      if (!row || row.closest('thead') || row.hasAttribute('data-readonly')) return;
      root.api.setState('editing', { editing: row.dataset.path });
    });
    dfDollar(root).on('keydown', (e) => {
      const t = e.target;
      if (root._editor && root._editor.cell.contains(t)) {
        if (e.key === 'Escape') {
          e.preventDefault();
          e.stopPropagation();
          const { path } = root._editor;
          root.api.setState('default', { editing: null });
          restoreFocus(root, path, 'stay');
        } else if (e.key === 'Enter' && (!(root._editor.editor.multiline || root._editor.editor.ownsEnter) || e.metaKey || e.ctrlKey)) {
          e.preventDefault();
          commit(root);
        } else if (e.key === 'Tab') {
          const targets = focusTargets(root);
          const at = targets.findIndex((x) => x.closest('tr') === root._editor.row);
          const edge = e.shiftKey ? at <= 0 : at >= targets.length - 1;
          if (!edge) e.preventDefault();
          commit(root, edge ? 'none' : e.shiftKey ? 'prev' : 'next');
        }
        return;
      }
      const row = (t as HTMLElement).closest?.('tr');
      if (!row || !root.contains(row) || row.closest('thead')) return;
      const keys = { ArrowDown: 1, ArrowUp: -1, Home: -Infinity, End: Infinity, PageDown: 10, PageUp: -10 };
      if (e.key in keys) {
        e.preventDefault();
        moveFocus(root, t, keys[e.key]);
      } else if (row.classList.contains('property-grid-group') && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ')) {
        const open = !(configOf(root).collapsed ?? []).includes(row.dataset.path);
        if ((e.key === 'ArrowLeft' && open) || (e.key === 'ArrowRight' && !open) || e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          toggleGroup(root, row.dataset.path);
        }
      } else if ((e.key === 'Enter' || e.key === 'F2') && !row.hasAttribute('data-readonly')) {
        e.preventDefault();
        root.api.setState('editing', { editing: row.dataset.path });
      }
    });
    // an editor that commits on change (a switch, a select, a color, a date) or when focus leaves it
    dfDollar(root).on('change', (e) => {
      if (root._editor?.editor.immediate && root._editor.cell.contains(e.target)) commit(root, 'stay');
    });
    dfDollar(root).on('focusout', (e) => {
      const ed = root._editor;
      if (!ed || !ed.cell.contains(e.target) || ed.cell.contains(e.relatedTarget)) return;
      // after the click that moved focus has been handled (it may open another editor)
      setTimeout(() => {
        if (root._editor === ed && !ed.cell.contains(document.activeElement)) commit(root, 'none');
      }, 0);
    });
    // el.store + el.api (AGENTS.md "State through stores") - the source is the state
    bindComponent(root, propertyGridApi, { name: 'default', config: { source, editing: null, collapsed: [] } });
    triggerStateChange(root, { name: 'default', config: { source, editing: null, collapsed: [] } }, { source });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub