{"version":3,"file":"field-base.cjs","names":[],"sources":["../../src/core/field-base.ts"],"sourcesContent":["import { createStableId } from '@vielzeug/ore';\nimport { computed, type Readable } from '@vielzeug/ripple';\n\nimport { warn } from '../_dev';\n\n// ── Validation / context types ────────────────────────────────────────────────\n\nexport type ValidationTrigger = 'blur' | 'change' | 'input' | 'submit';\nexport type ControlValidationMode = ValidationTrigger | undefined;\n\n// ── Error/helper assistive state ──────────────────────────────────────────────\n\nexport type ErrorHelperState = {\n  errorText: string;\n  helperText: string;\n};\n\nexport type ErrorHelperOptions = {\n  error?: Readable<string | undefined>;\n  helper?: Readable<string | undefined>;\n};\n\nexport const createErrorHelperState = (options: ErrorHelperOptions): Readable<ErrorHelperState> =>\n  computed(() => ({\n    errorText: options.error?.value ?? '',\n    helperText: options.helper?.value ?? '',\n  }));\n\n// ── Counter state (opt-in) ─────────────────────────────────────────────────────\n\n/** Counter state for text fields with `maxLength`. */\nexport type CounterState = {\n  counterAtLimit: boolean;\n  counterNearLimit: boolean;\n  counterText: string;\n};\n\nexport type CounterOptions = {\n  maxLength?: Readable<number | undefined>;\n  value: Readable<string | undefined>;\n};\n\n/**\n * Creates a reactive counter state signal for text fields with `maxLength`.\n * Only call this when `maxLength` may be set — the state is genuinely opt-in.\n *\n * @example\n * ```ts\n * const counter = createCounterState({ value: tf.value, maxLength: props.maxlength });\n * ```\n */\nexport const createCounterState = (options: CounterOptions): Readable<CounterState> =>\n  computed<CounterState>(() => {\n    const value = options.value?.value ?? '';\n    const maxLength = options.maxLength?.value;\n    const parsedMaxLength = Number(maxLength);\n    const validMaxLength = Number.isFinite(parsedMaxLength) && parsedMaxLength > 0 ? parsedMaxLength : null;\n    const hasCounter = validMaxLength !== null;\n    const counterText = hasCounter ? `${value.length} / ${validMaxLength}` : '';\n    const ratio = hasCounter ? value.length / validMaxLength : 0;\n\n    return {\n      counterAtLimit: hasCounter ? ratio >= 1 : false,\n      counterNearLimit: hasCounter ? ratio >= 0.9 && ratio < 1 : false,\n      counterText,\n    };\n  });\n\n/**\n * The single implementation of \"which modifier class does this counter state map to\" —\n * every field with a character counter (`ore-input`, `ore-textarea`, `ore-message-composer`)\n * renders the identical near-limit/at-limit styling, so this is the one place that decides it.\n *\n * @example\n * ```ts\n * html`<span class=\"${() => counterClassName(counter?.value)}\">...</span>`\n * ```\n */\nexport const counterClassName = (counter: CounterState | undefined, base = 'counter'): string => {\n  if (!counter) return base;\n\n  if (counter.counterAtLimit) return `${base} at-limit`;\n\n  if (counter.counterNearLimit) return `${base} near-limit`;\n\n  return base;\n};\n\n// ── Dirty tracking (two-state reset) ───────────────────────────────────────────\n\n/**\n * Tracks whether a control's value has ever been changed by user interaction.\n *\n * Backs the \"two-state reset()\" pattern used by `createCheckable` (`checked`/\n * `indeterminate`) and `createChoiceField` (`selectedValues`): before the first\n * interaction, the live prop is still a reliable \"current default\" to resync from\n * (e.g. an async-loaded value arriving after mount); after it, the prop is\n * contaminated by interaction-driven attribute reflection, so only a snapshot taken\n * at creation still represents \"the default\" in the native sense. `reset()` callers\n * check `isDirty` to decide which source to revert to, then call `clear()`.\n */\nexport type DirtyTracker = {\n  clear: () => void;\n  readonly isDirty: boolean;\n  markDirty: () => void;\n};\n\nexport function createDirtyTracker(): DirtyTracker {\n  let dirty = false;\n\n  return {\n    clear: () => {\n      dirty = false;\n    },\n    get isDirty() {\n      return dirty;\n    },\n    markDirty: () => {\n      dirty = true;\n    },\n  };\n}\n\n// ── Label placement ───────────────────────────────────────────────────────────\n\nexport type LabelPlacement = 'inset' | 'outside' | undefined;\n\n// ── Assistive state (error/helper text, describedby/errormessage/invalid) ─────\n\n/**\n * A field's error/helper/disabled/validation-trigger state — everything an input\n * needs *except* a visible `<label>`. Split out from label state (below) because\n * not every field has one: `ore-message-composer` names itself via `aria-label`\n * (a chat composer's accessible name conventionally comes from context/placeholder,\n * not a floating `<label>`), so it composes this directly instead of pulling in\n * label plumbing it can't use. `createField()` composes both for fields that do\n * render a `<label>`.\n */\nexport type AssistiveStateHandle = {\n  /** `aria-describedby` value. Non-null when helper or error text is present. */\n  ariaDescribedBy: Readable<string | null>;\n  /** `aria-errormessage` value. Non-null when error text is present. */\n  ariaErrorMessage: Readable<string | null>;\n  /** `aria-invalid` value. `'true'` when error text is present, otherwise `null`. */\n  ariaInvalid: Readable<'true' | null>;\n  /**\n   * The stable `id` used for `aria-describedby` on the input. Points at the\n   * assistive-text region (covers both helper text and error text per WAI-ARIA).\n   */\n  assistiveId: string;\n  /**\n   * Registers the real form field handle (the return value of `useField()`) so that\n   * `triggerValidation()` can call its `reportValidity()`. See `TextFieldHandle.attachFormField`\n   * for why this is a post-hoc call instead of a constructor option — the same forward\n   * reference applies here for choice fields.\n   */\n  attachFormField: (formField: { reportValidity(): void }) => void;\n  disabled: Readable<boolean>;\n  /** Stable `id` for the inline error message element (`aria-errormessage`). */\n  errorId: string;\n  /** Reactive error text. Empty string when no error is set. */\n  errorText: Readable<string>;\n  /** Reactive helper text. Empty string when no helper is set. */\n  helperText: Readable<string>;\n  triggerValidation: (on: Extract<ValidationTrigger, 'blur' | 'change'>) => void;\n};\n\nexport type AssistiveStateOptions = {\n  disabled?: Readable<boolean | undefined>;\n  error?: Readable<string | undefined>;\n  helper?: Readable<string | undefined>;\n  validateOn?: Readable<ControlValidationMode>;\n};\n\nexport const createAssistiveState = (options: AssistiveStateOptions): AssistiveStateHandle => {\n  const disabled = computed(() => Boolean(options.disabled?.value));\n  const assistiveId = createStableId('helper');\n  const errorId = createStableId('error');\n  const resolvedAssistive = createErrorHelperState({ error: options.error, helper: options.helper });\n\n  const ariaDescribedBy = computed(() =>\n    resolvedAssistive.value.errorText || resolvedAssistive.value.helperText ? assistiveId : null,\n  );\n  const ariaErrorMessage = computed(() => (resolvedAssistive.value.errorText ? errorId : null));\n  const ariaInvalid = computed<'true' | null>(() => (resolvedAssistive.value.errorText ? 'true' : null));\n\n  let formField: { reportValidity(): void } | null = null;\n  const attachFormField = (nextFormField: { reportValidity(): void }): void => {\n    formField = nextFormField;\n  };\n\n  const triggerValidation = (on: Extract<ValidationTrigger, 'blur' | 'change'>): void => {\n    if (options.validateOn?.value !== on) return;\n\n    if (!formField) {\n      // Not user-facing — this only fires when a component author wired `validateOn` but\n      // forgot the matching `attachFormField(useField(...))` call, so validation silently\n      // never runs instead of erroring where the mistake actually is.\n      warn(\n        \"triggerValidation() called before attachFormField() — validation will not run. See createTextField()/createChoiceField()/createCheckable()'s attachFormField doc comment.\",\n      );\n\n      return;\n    }\n\n    formField.reportValidity();\n  };\n\n  return {\n    ariaDescribedBy,\n    ariaErrorMessage,\n    ariaInvalid,\n    assistiveId,\n    attachFormField,\n    disabled,\n    errorId,\n    errorText: computed(() => resolvedAssistive.value.errorText),\n    helperText: computed(() => resolvedAssistive.value.helperText),\n    triggerValidation,\n  };\n};\n\n// ── Label state (visible `<label>` + `aria-labelledby`) ────────────────────────\n\nexport type LabelStateHandle = {\n  /** `aria-labelledby` value. Non-null when a label is visible. */\n  ariaLabelledBy: Readable<string | null>;\n  /** Stable `id` for the underlying form control — the `<label for=\"...\">` target. */\n  fieldId: string;\n  /** Stable `id` for the label element. Stamp this on your `<label id=\"...\">`. */\n  labelId: string;\n  /** Whether the label should be visible. Use to toggle `hidden` on the `<label>`. */\n  labelVisible: Readable<boolean>;\n};\n\nexport type LabelStateOptions = {\n  /**\n   * Override for label visibility that takes precedence over deriving it from\n   * the `label` text signal. Pass a computed signal that checks both prop and\n   * slot presence to enable slot-first composition.\n   *\n   * @example\n   * ```ts\n   * const hasLabel = computed(() => !!props.label.value || slots.has('label').value);\n   * const label = createLabelState({ ...options, hasLabel });\n   * ```\n   */\n  hasLabel?: Readable<boolean>;\n  /**\n   * When provided, used directly as `fieldId` instead of generating one via\n   * `createStableId`. Useful in tests for deterministic ID assertions.\n   */\n  id?: string;\n  /**\n   * Label text signal. When provided, `labelVisible` and `ariaLabelledBy` are\n   * computed reactively from this value (see `hasLabel` for the slot-first override).\n   */\n  label?: Readable<string | undefined>;\n  /**\n   * Label placement signal. Defaults to `'inset'`.\n   *\n   * Not consumed by `createLabelState()` itself — visibility is driven solely by\n   * `hasLabel`/`label`. Threaded through so component authors can apply\n   * placement-specific styling (`label-placement` attribute) without a second\n   * option; if placement-dependent visibility is ever needed, wire it into\n   * `labelVisible` here instead of adding a new option.\n   */\n  labelPlacement?: Readable<LabelPlacement>;\n  prefix?: string;\n};\n\nexport const createLabelState = (options: LabelStateOptions): LabelStateHandle => {\n  const fieldId = options.id ?? createStableId(options.prefix ?? 'field');\n  const labelId = createStableId('label');\n  const label$ = options.label ?? computed(() => undefined);\n\n  // Slot-first composition: if the caller provides `hasLabel`, use it instead\n  // of deriving visibility purely from the label text signal. This allows\n  // components with a `<slot name=\"label\">` to stay visible even when the\n  // `label` prop is empty but a slotted label element is present.\n  const labelVisible = options.hasLabel ?? computed(() => Boolean(label$.value));\n  const ariaLabelledBy = computed(() => (labelVisible.value ? labelId : null));\n\n  return { ariaLabelledBy, fieldId, labelId, labelVisible };\n};\n\n// ── Field handle (assistive + label, composed) ──────────────────────────────\n\n/**\n * Common handle returned by `createField` — the base for both `TextFieldHandle`\n * and `ChoiceFieldHandle`. Composes `createAssistiveState` + `createLabelState`;\n * call those directly instead if a field has no visible `<label>` (see\n * `createAssistiveState`'s doc comment).\n *\n * ARIA signals and label state are flat on the handle — no nested `aria` or\n * `label` sub-objects. `labelId`/`labelVisible`/`ariaLabelledBy` are always\n * present but only meaningful when `label`/`hasLabel` was passed — omit both\n * and they resolve to an always-hidden, unreferenced label.\n *\n * @example\n * ```html\n * <label id=\"${labelId}\" ?hidden=\"${() => !labelVisible.value}\">...</label>\n * <input\n *   aria-labelledby=\"${ariaLabelledBy}\"\n *   aria-describedby=\"${ariaDescribedBy}\"\n *   aria-invalid=\"${ariaInvalid}\" />\n * ```\n */\nexport type FieldHandle = AssistiveStateHandle & LabelStateHandle;\n\n/** Options shared by both `createTextField` and `createChoiceField`. */\nexport type FieldOptions = AssistiveStateOptions & LabelStateOptions;\n\nexport const createField = (options: FieldOptions): FieldHandle => ({\n  ...createAssistiveState(options),\n  ...createLabelState(options),\n});\n"],"mappings":"oFAsBA,IAAa,EAA0B,IAAA,EACrC,EAAA,SAAA,MAAgB,CACd,UAAW,EAAQ,OAAO,OAAS,GACnC,WAAY,EAAQ,QAAQ,OAAS,EACvC,EAAE,EAyBS,EAAsB,IAAA,EACjC,EAAA,SAAA,KAA6B,CAC3B,IAAM,EAAQ,EAAQ,OAAO,OAAS,GAChC,EAAY,EAAQ,WAAW,MAC/B,EAAkB,OAAO,CAAS,EAClC,EAAiB,OAAO,SAAS,CAAe,GAAK,EAAkB,EAAI,EAAkB,KAC7F,EAAa,IAAmB,KAChC,EAAc,EAAa,GAAG,EAAM,OAAO,KAAK,IAAmB,GACnE,EAAQ,EAAa,EAAM,OAAS,EAAiB,EAE3D,MAAO,CACL,eAAgB,EAAa,GAAS,EAAI,GAC1C,iBAAkB,EAAa,GAAS,IAAO,EAAQ,EAAI,GAC3D,aACF,CACF,CAAC,EAYU,GAAoB,EAAmC,EAAO,YACpE,EAED,EAAQ,eAAuB,GAAG,EAAK,WAEvC,EAAQ,iBAAyB,GAAG,EAAK,aAEtC,EANc,EA4BvB,SAAgB,GAAmC,CACjD,IAAI,EAAQ,GAEZ,MAAO,CACL,UAAa,CACX,EAAQ,EACV,EACA,IAAI,SAAU,CACZ,OAAO,CACT,EACA,cAAiB,CACf,EAAQ,EACV,CACF,CACF,CAqDA,IAAa,EAAwB,GAAyD,CAC5F,IAAM,GAAA,EAAW,EAAA,SAAA,KAAe,EAAQ,EAAQ,UAAU,KAAM,EAC1D,GAAA,EAAc,EAAA,eAAA,CAAe,QAAQ,EACrC,GAAA,EAAU,EAAA,eAAA,CAAe,OAAO,EAChC,EAAoB,EAAuB,CAAE,MAAO,EAAQ,MAAO,OAAQ,EAAQ,MAAO,CAAC,EAE3F,GAAA,EAAkB,EAAA,SAAA,KACtB,EAAkB,MAAM,WAAa,EAAkB,MAAM,WAAa,EAAc,IAC1F,EACM,GAAA,EAAmB,EAAA,SAAA,KAAgB,EAAkB,MAAM,UAAY,EAAU,IAAK,EACtF,GAAA,EAAc,EAAA,SAAA,KAA+B,EAAkB,MAAM,UAAY,OAAS,IAAK,EAEjG,EAA+C,KAsBnD,MAAO,CACL,kBACA,mBACA,cACA,cACA,gBA1BuB,GAAoD,CAC3E,EAAY,CACd,EAyBE,WACA,UACA,WAAA,EAAW,EAAA,SAAA,KAAe,EAAkB,MAAM,SAAS,EAC3D,YAAA,EAAY,EAAA,SAAA,KAAe,EAAkB,MAAM,UAAU,EAC7D,kBA3ByB,GAA4D,CACjF,EAAQ,YAAY,QAAU,GAE7B,GAWL,EAAU,eAAe,CAC3B,CAaA,CACF,EAmDa,EAAoB,GAAiD,CAChF,IAAM,EAAU,EAAQ,KAAA,EAAM,EAAA,eAAA,CAAe,EAAQ,QAAU,OAAO,EAChE,GAAA,EAAU,EAAA,eAAA,CAAe,OAAO,EAChC,EAAS,EAAQ,QAAA,EAAS,EAAA,SAAA,KAAe,IAAA,EAAS,EAMlD,EAAe,EAAQ,WAAA,EAAY,EAAA,SAAA,KAAe,EAAQ,EAAO,KAAM,EAG7E,MAAO,CAAE,gBAAA,EAFc,EAAA,SAAA,KAAgB,EAAa,MAAQ,EAAU,IAE7D,EAAgB,UAAS,UAAS,cAAa,CAC1D,EA6Ba,EAAe,IAAwC,CAClE,GAAG,EAAqB,CAAO,EAC/B,GAAG,EAAiB,CAAO,CAC7B"}