{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"color-swatch-picker","title":"Color Swatch Picker","description":"Single-selection palette of named color swatches.","dependencies":["react-aria-components@1.20.0"],"registryDependencies":["@siteplane/base","@siteplane/input"],"files":[{"path":"packages/ui/src/components/color-swatch-picker/color-swatch-picker.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { useFormReset } from \"../../system/form/use-form-reset\";\nimport {\n  ListBoxContext,\n  type ListBoxRenderProps,\n  ColorSwatchPicker as ColorSwatchPickerPrimitive,\n  ColorSwatchPickerItem as ColorSwatchPickerItemPrimitive,\n} from \"react-aria-components\";\n\nimport {\n  type SiteplaneControlSize,\n  type SiteplaneRadius,\n  useResolvedControlSize,\n  useResolvedRadius,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport { cn } from \"../../system/utils/cn\";\nimport {\n  noOwnerValidation,\n  useFieldControlBridge,\n  useFieldControlProxy,\n} from \"../field/field-control-bridge\";\nimport { ExternalInputOwner, Input } from \"../input/input\";\nimport {\n  ColorSwatchFace,\n  type ColorSwatchIndicator,\n} from \"./color-swatch-face\";\n\nimport \"./color-swatch-picker.css\";\n\nexport type { ColorSwatchIndicator } from \"./color-swatch-face\";\n\n/**\n * One choice. It is a `color`, or an `image` that fills the swatch (the\n * optional `color` then shows until the image has loaded). `imageSize` and\n * `imagePosition` take CSS `background-size` and `background-position`\n * values to show part of the image; by default it covers the swatch, centred.\n */\nexport type ColorSwatchPickerOption = {\n  disabled?: boolean;\n  label: string;\n  value: string;\n} & (\n  | {\n      color: string;\n      image?: undefined;\n      imagePosition?: undefined;\n      imageSize?: undefined;\n    }\n  | {\n      color?: string | undefined;\n      image: string;\n      imagePosition?: string | undefined;\n      imageSize?: string | undefined;\n    }\n);\n\nexport interface ColorSwatchPickerProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> {\n  defaultValue?: string | undefined;\n  disabled?: boolean | undefined;\n  form?: string | undefined;\n  /**\n   * How the selected swatch shows its selection: `check` (the default) puts a\n   * check inside it, `ring` draws an accent ring around it, like a row of image\n   * thumbnails.\n   */\n  indicator?: ColorSwatchIndicator | undefined;\n  inputRef?: React.Ref<HTMLInputElement> | undefined;\n  name?: string | undefined;\n  onValueChange?: ((value: string) => void) | undefined;\n  options: readonly ColorSwatchPickerOption[];\n  readOnly?: boolean | undefined;\n  required?: boolean | undefined;\n  size?: SiteplaneControlSize | undefined;\n  value?: string | undefined;\n}\n\nexport function ColorSwatchPicker({\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  defaultValue,\n  disabled,\n  form,\n  indicator = \"check\",\n  inputRef,\n  name,\n  onValueChange,\n  options,\n  readOnly,\n  required,\n  size,\n  value,\n  ...props\n}: ColorSwatchPickerProps): React.ReactElement {\n  // Inside a Field, FieldLabel names the listbox, FieldDescription and\n  // FieldError describe it, and the Field supplies name and disabled state.\n  const field = useFieldControlBridge({\n    \"aria-describedby\": ariaDescribedBy,\n    \"aria-labelledby\": ariaLabelledBy,\n    disabled,\n    name,\n  });\n  const fieldDisabled = field.disabled;\n  const fieldName = field.name;\n  const inField = field.inField;\n  const invalid = field.valid === false;\n  const resolvedSize = useResolvedControlSize(size);\n  const radius = useResolvedRadius<SiteplaneRadius>(undefined);\n  const controlled = value !== undefined;\n  const [resetVersion, setResetVersion] = React.useState(0);\n  const markerRef = useFormReset<HTMLInputElement>(form, () => {\n    if (!controlled) setResetVersion((current) => current + 1);\n  });\n  const optionEntries = React.useMemo(\n    () =>\n      options.map((option, index) => ({\n        identity: getOptionIdentity(index),\n        option,\n      })),\n    [options],\n  );\n  const selectedIdentity = optionEntries.find(\n    ({ option }) => option.value === value,\n  )?.identity;\n  const defaultIdentity = optionEntries.find(\n    ({ option }) => option.value === defaultValue,\n  )?.identity;\n  // React Aria's ColorSwatchPicker drops aria-invalid, aria-readonly and\n  // aria-required from its props; its listbox receives these states through\n  // the ListBox render. The form inputs follow the listbox: an option must not\n  // contain another interactive element. With every option disabled, React\n  // Aria makes the listbox itself a tab stop; a disabled picker leaves the\n  // tab order instead and is announced as disabled.\n  const listboxContext = React.useMemo(\n    () => ({\n      render: (\n        listboxProps: React.JSX.IntrinsicElements[\"div\"],\n        { state }: ListBoxRenderProps,\n      ) => (\n        <>\n          <div\n            {...listboxProps}\n            aria-disabled={fieldDisabled || undefined}\n            aria-invalid={invalid || undefined}\n            aria-readonly={readOnly || undefined}\n            aria-required={required || undefined}\n            tabIndex={fieldDisabled ? undefined : listboxProps.tabIndex}\n          />\n          {fieldName || required || inputRef ? (\n            <SwatchFormValue\n              disabled={fieldDisabled}\n              form={form}\n              inputRef={inputRef}\n              listboxId={listboxProps.id}\n              name={fieldName}\n              optionEntries={optionEntries}\n              readOnly={readOnly}\n              required={required}\n              selectedKeys={state.selectionManager.selectedKeys}\n            />\n          ) : null}\n          {inField ? (\n            <SwatchFieldProxy\n              disabled={fieldDisabled}\n              form={form}\n              listboxId={listboxProps.id}\n              name={fieldName}\n              optionEntries={optionEntries}\n              readOnly={readOnly}\n              required={required}\n              selectedKeys={state.selectionManager.selectedKeys}\n            />\n          ) : null}\n        </>\n      ),\n    }),\n    [\n      fieldDisabled,\n      fieldName,\n      form,\n      inField,\n      inputRef,\n      invalid,\n      optionEntries,\n      readOnly,\n      required,\n    ],\n  );\n\n  return (\n    <>\n      <ListBoxContext.Provider value={listboxContext}>\n        <ColorSwatchPickerPrimitive\n          {...(props as React.ComponentProps<\n            typeof ColorSwatchPickerPrimitive\n          >)}\n          {...(field.describedBy === undefined\n            ? {}\n            : { \"aria-describedby\": field.describedBy })}\n          {...(field.labelledBy || ariaLabel === undefined\n            ? {}\n            : { \"aria-label\": ariaLabel })}\n          {...(field.labelledBy === undefined\n            ? {}\n            : { \"aria-labelledby\": field.labelledBy })}\n          className={cn(\n            \"sp-color-swatch-picker sp-color-swatch-scope\",\n            className,\n          )}\n          data-disabled={fieldDisabled || undefined}\n          data-indicator={indicator}\n          data-radius={radius}\n          data-size={resolvedSize}\n          data-slot=\"color-swatch-picker\"\n          {...(controlled || readOnly\n            ? { value: selectedIdentity ?? defaultIdentity ?? \"#ffffff\" }\n            : defaultIdentity === undefined\n              ? {}\n              : { defaultValue: defaultIdentity })}\n          key={controlled ? undefined : resetVersion}\n          onChange={(color) => {\n            if (readOnly) return;\n            const identity = color.toString(\"hex\").toLowerCase();\n            const match = optionEntries.find(\n              (entry) => entry.identity === identity,\n            );\n            if (match) onValueChange?.(match.option.value);\n          }}\n        >\n          {optionEntries.map(({ identity, option }) => (\n            <ColorSwatchPickerItemPrimitive\n              key={option.value}\n              aria-label={option.label}\n              className=\"sp-color-swatch-picker-item sp-color-swatch-item\"\n              color={identity}\n              data-slot=\"color-swatch-picker-item\"\n              {...(fieldDisabled\n                ? { isDisabled: true }\n                : option.disabled === undefined\n                  ? {}\n                  : { isDisabled: option.disabled })}\n            >\n              {({ isSelected }) => (\n                <ColorSwatchFace\n                  color={option.color}\n                  image={option.image}\n                  imagePosition={option.imagePosition}\n                  imageSize={option.imageSize}\n                  indicator={indicator}\n                  label={option.label}\n                  selected={isSelected}\n                />\n              )}\n            </ColorSwatchPickerItemPrimitive>\n          ))}\n        </ColorSwatchPickerPrimitive>\n      </ListBoxContext.Provider>\n      <Input\n        aria-hidden=\"true\"\n        disabled\n        form={form}\n        ref={markerRef}\n        type=\"hidden\"\n      />\n    </>\n  );\n}\n\n// A form adapter reads the collection's selection; it owns no second selection\n// state. A visually hidden text input also participates in native validation.\nfunction SwatchFormValue({\n  disabled,\n  form,\n  inputRef,\n  listboxId,\n  name,\n  optionEntries,\n  readOnly,\n  required,\n  selectedKeys,\n}: Pick<\n  ColorSwatchPickerProps,\n  \"disabled\" | \"form\" | \"inputRef\" | \"name\" | \"readOnly\" | \"required\"\n> & {\n  listboxId: string | undefined;\n  optionEntries: SwatchOptionEntry[];\n  selectedKeys: Iterable<React.Key>;\n}) {\n  const selected = getSelectedOption(optionEntries, selectedKeys);\n  // The listbox owns the field semantics; this adapter stays out of a Field.\n  return (\n    <ExternalInputOwner>\n      <Input\n        aria-hidden=\"true\"\n        className=\"sr-only\"\n        disabled={disabled}\n        form={form}\n        name={name}\n        nativeInput\n        onChange={() => {}}\n        onInvalid={(event) => {\n          event.preventDefault();\n          if (!listboxId) return;\n          event.currentTarget.ownerDocument\n            .getElementById(listboxId)\n            ?.querySelector<HTMLElement>(\n              '[role=\"option\"]:not([aria-disabled=\"true\"])',\n            )\n            ?.focus();\n        }}\n        readOnly={readOnly}\n        ref={inputRef}\n        required={required}\n        tabIndex={-1}\n        type=\"text\"\n        unstyled\n        value={selected?.option.value ?? \"\"}\n      />\n    </ExternalInputOwner>\n  );\n}\n\n// Inside a Field, a visually hidden proxy registers the picker with Field and\n// Form like the date fields: FieldLabel focuses the selected or first\n// available swatch, and Form validation checks `required`, so FieldError\n// explains a missing choice.\nfunction SwatchFieldProxy({\n  disabled,\n  form,\n  listboxId,\n  name,\n  optionEntries,\n  readOnly,\n  required,\n  selectedKeys,\n}: Pick<\n  ColorSwatchPickerProps,\n  \"disabled\" | \"form\" | \"name\" | \"readOnly\" | \"required\"\n> & {\n  listboxId: string | undefined;\n  optionEntries: SwatchOptionEntry[];\n  selectedKeys: Iterable<React.Key>;\n}): React.ReactElement | null {\n  const proxyProps = useFieldControlProxy({\n    controlled: false,\n    disabled: Boolean(disabled),\n    displayValidation: noOwnerValidation,\n    focusTarget: () => getFocusableSwatch(listboxId),\n    form,\n    name,\n    onRevalidate: () => {},\n    realtimeValidation: noOwnerValidation,\n    required,\n    value: getSelectedOption(optionEntries, selectedKeys)?.option.value ?? \"\",\n  });\n\n  return proxyProps ? (\n    <ExternalInputOwner>\n      <Input nativeInput readOnly={readOnly} unstyled {...proxyProps} />\n    </ExternalInputOwner>\n  ) : null;\n}\n\ntype SwatchOptionEntry = { identity: string; option: ColorSwatchPickerOption };\n\nfunction getSelectedOption(\n  optionEntries: SwatchOptionEntry[],\n  selectedKeys: Iterable<React.Key>,\n): SwatchOptionEntry | undefined {\n  const selectedKey = [...selectedKeys][0];\n  return optionEntries.find(\n    ({ identity }) => `${identity}ff` === String(selectedKey).toLowerCase(),\n  );\n}\n\nfunction getFocusableSwatch(listboxId: string | undefined): HTMLElement | null {\n  const listbox = listboxId ? document.getElementById(listboxId) : null;\n  return (\n    listbox?.querySelector<HTMLElement>(\n      '[role=\"option\"][aria-selected=\"true\"]:not([aria-disabled=\"true\"])',\n    ) ??\n    listbox?.querySelector<HTMLElement>(\n      '[role=\"option\"]:not([aria-disabled=\"true\"])',\n    ) ??\n    null\n  );\n}\n\nfunction getOptionIdentity(index: number): string {\n  return `#${(index + 1).toString(16).padStart(6, \"0\")}`;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/color-swatch-picker/color-swatch-picker.tsx"},{"path":"packages/ui/src/components/color-swatch-picker/color-swatch-face.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ColorSwatch as ColorSwatchPrimitive } from \"react-aria-components\";\n\nimport \"./color-swatch-picker.css\";\n\n/**\n * How a selected swatch shows its selection: a check inside the swatch (the\n * default) or a ring around it, as the swatch rows of image and avatar\n * choosers do.\n */\nexport type ColorSwatchIndicator = \"check\" | \"ring\";\n\n/**\n * The picture of one swatch: the color or image, and the check of the `check`\n * indicator. ColorSwatchPicker items and the swatch trigger of ColorPicker\n * both draw it, so a swatch keeps one appearance wherever it appears. The\n * selection ring, hover, focus and disabled states belong to the swatch box\n * around it (`sp-color-swatch-item`) and are styled in\n * `color-swatch-picker.css`.\n */\nexport function ColorSwatchFace({\n  color,\n  image,\n  imagePosition,\n  imageSize,\n  indicator = \"check\",\n  label,\n  selected,\n}: {\n  /** The swatch color; behind an image it shows until the image has loaded. */\n  color?: string | undefined;\n  image?: string | undefined;\n  /** CSS `background-position` of the image; centred by default. */\n  imagePosition?: string | undefined;\n  /** CSS `background-size` of the image; it covers the swatch by default. */\n  imageSize?: string | undefined;\n  indicator?: ColorSwatchIndicator | undefined;\n  label?: string | undefined;\n  selected?: boolean | undefined;\n}): React.ReactElement {\n  return (\n    <>\n      {/* An image option is a swatch like any other: the swatch element names\n          the option, and the picture lies over its (transparent) color. */}\n      <ColorSwatchPrimitive\n        aria-hidden=\"true\"\n        className=\"sp-color-swatch-picker-swatch\"\n        {...(label === undefined ? {} : { colorName: label })}\n        data-image={image === undefined ? undefined : \"\"}\n        data-slot=\"color-swatch-picker-swatch\"\n        style={{\n          backgroundColor: color ?? \"transparent\",\n          ...(image === undefined\n            ? {}\n            : {\n                backgroundImage: `url(${JSON.stringify(image)})`,\n                ...(imagePosition === undefined\n                  ? {}\n                  : { backgroundPosition: imagePosition }),\n                ...(imageSize === undefined\n                  ? {}\n                  : { backgroundSize: imageSize }),\n              }),\n        }}\n      />\n      {indicator === \"check\" ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"sp-checkbox-indicator sp-color-swatch-picker-indicator\"\n          data-selected={selected || undefined}\n          data-slot=\"checkbox-indicator\"\n          style={{\n            color: `contrast-color(color-mix(in oklch, ${color ?? \"black\"} 90%, black))`,\n          }}\n        >\n          <svg\n            className=\"sp-check-glyph-motion\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"3\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path\n              className=\"sp-check-path-motion\"\n              d=\"M5.252 12.7 10.2 18.63 18.748 5.37\"\n            />\n          </svg>\n        </span>\n      ) : null}\n    </>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/color-swatch-picker/color-swatch-face.tsx"},{"path":"packages/ui/src/components/color-swatch-picker/color-swatch-picker.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  .sp-color-swatch-picker {\n    display: flex;\n    flex-flow: row wrap;\n    gap: var(--sp-rw-space-4);\n    outline: none;\n  }\n\n  .sp-color-swatch-picker[data-disabled] {\n    opacity: var(--sp-semantic-state-disabled-opacity);\n  }\n\n  /* One size, shape and ring per swatch scope. A scope is a ColorSwatchPicker or\n   * the wrapper of a ColorPicker whose trigger is a swatch, so both draw the same\n   * swatch box (`sp-color-swatch-item`) from the same rules. */\n  .sp-color-swatch-scope {\n    --sp-swatch-size: 2rem;\n    --sp-swatch-check: 0.875rem;\n    --sp-swatch-radius: 999px;\n  }\n\n  .sp-color-swatch-scope[data-size=\"sm\"] {\n    --sp-swatch-size: 1.75rem;\n    --sp-swatch-check: 0.75rem;\n  }\n\n  .sp-color-swatch-scope[data-size=\"lg\"] {\n    --sp-swatch-size: 2.25rem;\n    --sp-swatch-check: 1rem;\n  }\n\n  /* A ColorPicker whose trigger is a swatch takes the same 40 px at xl as the\n   * palette it ends, so a row of swatches and its custom color match at every\n   * size. */\n  .sp-color-swatch-scope[data-size=\"xl\"] {\n    --sp-swatch-size: 2.5rem;\n    --sp-swatch-check: 1.125rem;\n  }\n\n  .sp-color-swatch-scope[data-radius=\"none\"] {\n    --sp-swatch-radius: 0;\n  }\n\n  .sp-color-swatch-scope[data-radius=\"sm\"] {\n    --sp-swatch-radius: var(--sp-primitive-dimension-radius-sm);\n  }\n\n  /* Compact swatches sit closer together, so a palette and its neighbours fit one\n   * row. A ring stands 4px outside its swatch and keeps a wider gap. */\n  .sp-color-swatch-picker[data-size=\"sm\"] {\n    gap: var(--sp-rw-space-2);\n  }\n\n  .sp-color-swatch-picker[data-size=\"sm\"][data-indicator=\"ring\"] {\n    gap: var(--sp-rw-space-3);\n  }\n\n  .sp-color-swatch-item {\n    position: relative;\n    display: grid;\n    width: var(--sp-swatch-size);\n    height: var(--sp-swatch-size);\n    flex: none;\n    cursor: pointer;\n    place-items: center;\n    border-radius: var(--sp-swatch-radius);\n    outline: none;\n    transition:\n      opacity var(--motion-duration-fast) var(--motion-ease-standard),\n      box-shadow var(--motion-duration-fast) var(--motion-ease-standard);\n  }\n\n  .sp-color-swatch-item:hover:not([data-disabled]) {\n    opacity: 0.8;\n  }\n\n  .sp-color-swatch-item:active:not([data-disabled]) {\n    opacity: 0.7;\n  }\n\n  /* The gap ring takes the colour of the surface the picker sits on (published by\n   * Card, Dialog, Popover, Sheet), falling back to the page background. */\n  .sp-color-swatch-item[data-selected] {\n    box-shadow:\n      0 0 0 2px var(--sp-rw-surface-color, var(--sp-rw-bg-base)),\n      0 0 0 3px var(--sp-semantic-color-border);\n  }\n\n  /* The ring indicator: the same gap, then a 2px accent ring. The accent ink\n   * keeps its contrast on the surface in light and dark. */\n  .sp-color-swatch-scope[data-indicator=\"ring\"]\n    .sp-color-swatch-item[data-selected] {\n    box-shadow:\n      0 0 0 2px var(--sp-rw-surface-color, var(--sp-rw-bg-base)),\n      0 0 0 4px var(--sp-rw-fg-accent);\n  }\n\n  /* The focus outline is the one of every kit control. */\n  .sp-color-swatch-item[data-focus-visible] {\n    outline: var(--sp-semantic-state-focus-width) solid\n      var(--sp-rw-border-focus);\n    outline-offset: var(--sp-semantic-state-focus-offset);\n  }\n\n  /* A selected ring is not covered by the outline: the outline moves out beyond\n   * it, so the selection and the focus both stay visible. */\n  .sp-color-swatch-scope[data-indicator=\"ring\"]\n    .sp-color-swatch-item[data-selected][data-focus-visible] {\n    outline-offset: calc(4px + var(--sp-semantic-state-focus-offset));\n  }\n\n  .sp-color-swatch-item[data-disabled] {\n    cursor: not-allowed;\n    opacity: var(--sp-semantic-state-disabled-opacity);\n  }\n\n  /* A disabled picker fades once; its swatches, disabled with it, do not fade a\n   * second time. */\n  .sp-color-swatch-picker[data-disabled] .sp-color-swatch-item[data-disabled] {\n    opacity: 1;\n  }\n\n  .sp-color-swatch-picker-swatch {\n    width: 100%;\n    height: 100%;\n    border: var(--sp-rw-border-default) solid var(--sp-rw-border-translucent);\n    border-radius: inherit;\n  }\n\n  /* The picture fills the border box, so the translucent border is drawn over it\n   * and the two never part at a fractional pixel position. */\n  .sp-color-swatch-picker-swatch[data-image] {\n    background-origin: border-box;\n    background-position: center;\n    background-repeat: no-repeat;\n    background-size: cover;\n  }\n\n  /* The check draws and pops through the shared check motion in motion.css: the\n   * glyph carries its classes and this indicator is the element that holds the\n   * selected state around it. Only the box and the glyph size live here. */\n  .sp-color-swatch-picker-indicator {\n    position: absolute;\n    inset: 0;\n    display: grid;\n    color: white;\n    place-items: center;\n    pointer-events: none;\n  }\n\n  .sp-color-swatch-picker-indicator svg {\n    width: var(--sp-swatch-check);\n    height: var(--sp-swatch-check);\n    filter: drop-shadow(0 1px 1px rgb(0 0 0 / 32%));\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/color-swatch-picker/color-swatch-picker.css"},{"path":"packages/ui/src/components/color-swatch-picker/index.ts","content":"export * from \"./color-swatch-picker\";\n","type":"registry:ui","target":"~/src/siteplane/components/color-swatch-picker/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/color-swatch-picker.md","categories":["Date & color"],"meta":{"avoidWhen":["Avoid when people need to enter an arbitrary color; use Color Picker."],"description":"Single-selection palette of named color swatches.","knownLimitations":["The palette is owned by the application; arbitrary children and a native onChange are not part of the wrapper."],"providerNotes":[],"releaseStatus":"stable","searchTerms":["color-swatch-picker","color"],"useWhen":["Use when users choose one color from a predefined palette."]}}