@qijenchen/design-system 0.1.0-beta.79 → 0.1.0-beta.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -124,4 +124,6 @@ export declare const fieldMeta: {
124
124
  };
125
125
  };
126
126
  export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup };
127
+ export { useFormValidation } from './use-form-validation';
128
+ export type { UseFormValidationOptions, UseFormValidationReturn, FormFieldInputProps } from './use-form-validation';
127
129
  //# sourceMappingURL=field.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../../src/components/Field/field.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAMH,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,gBAAgB,EAAE,SAAS,EAAE,kBAAkB,EAAqB,MAAM,iBAAiB,CAAA;AAqDlI,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,IAAI,CAAC;IAClF,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,WAAW,CAAC,EAAE,gBAAgB,CAAA;IAC9B,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAA;CACnC;AAWD,QAAA,MAAM,KAAK,mFA2JV,CAAA;AAKD,MAAM,WAAW,eAAgB,SAAQ,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAGD,QAAA,MAAM,UAAU,0FA8Ff,CAAA;AAKD,QAAA,MAAM,gBAAgB,yHAsBpB,CAAA;AAKF,QAAA,MAAM,UAAU,yHAqBd,CAAA;AAOF,MAAM,WAAW,eAAgB,SAAQ,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC;IAC3E,uCAAuC;IACvC,GAAG,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAA;IACpC;;;;;;;;;;OAUG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,QAAA,MAAM,UAAU,wFAkBf,CAAA;AAKD,eAAO,MAAM,SAAS;;;;;;;;;;;CAeZ,CAAA;AAEV,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,UAAU,EAAE,UAAU,EAAE,CAAA"}
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../../../src/components/Field/field.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAMH,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,gBAAgB,EAAE,SAAS,EAAE,kBAAkB,EAAqB,MAAM,iBAAiB,CAAA;AAqDlI,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,IAAI,CAAC;IAClF,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,WAAW,CAAC,EAAE,gBAAgB,CAAA;IAC9B,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAA;CACnC;AAWD,QAAA,MAAM,KAAK,mFA2JV,CAAA;AAKD,MAAM,WAAW,eAAgB,SAAQ,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAGD,QAAA,MAAM,UAAU,0FA8Ff,CAAA;AAKD,QAAA,MAAM,gBAAgB,yHAsBpB,CAAA;AAKF,QAAA,MAAM,UAAU,yHAqBd,CAAA;AAOF,MAAM,WAAW,eAAgB,SAAQ,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC;IAC3E,uCAAuC;IACvC,GAAG,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAA;IACpC;;;;;;;;;;OAUG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,QAAA,MAAM,UAAU,wFAkBf,CAAA;AAKD,eAAO,MAAM,SAAS;;;;;;;;;;;CAeZ,CAAA;AAEV,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,UAAU,EAAE,UAAU,EAAE,CAAA;AAGtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AACzD,YAAY,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"field.js","sources":["../../../src/components/Field/field.tsx"],"sourcesContent":["// code-quality-allow: file-size — foundational composite(Field + FieldLabel + FieldDescription + FieldError + context + 8 layout variants),拆檔會讓 Field 家族互相 import 循環\nimport * as React from 'react'\nimport { Info as InfoIcon } from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport { Tooltip, TooltipTrigger, TooltipContent } from '@/design-system/components/Tooltip/tooltip'\n\n/**\n * Field — 表單欄位佈局容器(shadcn Field 風格)\n *\n * ── 定位 ────────────────────────────────────────────────────────────────\n * Field 只負責 **佈局 + 狀態 context**,不擁有任何資料型別邏輯。\n * 每個資料型別對應的 Control(Input、NumberInput、Checkbox、Switch 等)\n * 維持自己的 edit / readonly / disabled 三態,Field 透過 context 把\n * mode / disabled / required / invalid / id 傳給子元件,由子元件決定\n * 如何反映。\n *\n * ── 結構 ────────────────────────────────────────────────────────────────\n * <Field orientation=\"vertical | horizontal\" labelWidth=\"120px\">\n * <FieldLabel>姓名</FieldLabel>\n * <Input value={...} onChange={...} /> ← Control(任何非 label/desc/error 的 child)\n * <FieldDescription>...</FieldDescription>\n * <FieldError>{errors.name}</FieldError>\n * </Field>\n *\n * Control 會自動包在 control area slot(min-h-field-* + items-center),\n * 確保 Checkbox / Switch / Radio 等高度 < field-height 的 primitive\n * 垂直對齊 Input 中線;Input 等自身為 field-height 的 primitive 填滿。\n *\n * ── Horizontal mode 的 label 垂直對齊 ───────────────────────────────────\n * FieldLabel 在 horizontal 模式下使用公式:\n * padding-top: calc((var(--field-height-{size}) - 1lh) / 2)\n *\n * 單行 label → 文字第一行與 input 中線對齊(視覺置中)\n * 多行 label → 第一行仍與 input 中線對齊,其餘行往下流(label 高度超過\n * input 時視覺上仍保持與 input 內容同一基準線)\n *\n * 此公式 tracks field-height 和 line-height 的變動,size 切換或字體\n * 調整時自動連動,不需 JS 測量。\n *\n * ── Horizontal mode 的 label 寬度 ───────────────────────────────────────\n * 透過 labelWidth prop → --field-label-width CSS variable,可以是任何\n * CSS length(\"120px\"、\"10rem\"、\"30%\" 等)。預設 \"auto\" 由 label 內容撐開。\n *\n * ── Required 星號 ──────────────────────────────────────────────────────\n * Field 的 required prop 會透過 context 傳給 FieldLabel 自動渲染 *,\n * 星號為 neutral-7(fg-muted),貼齊 label 文字(無 gap),disabled\n * 時降為 fg-disabled。也可在個別 FieldLabel 覆寫。\n */\n\n// ── Types & Context ──\n// Context 定義在 field-context.ts(打斷 circular import)。\n// field.tsx 只 import 不 re-export——consumer 直接從 field-context.ts import useFieldContext。\n\nimport type { FieldMode, FieldVariant, FieldOrientation, FieldSize, FieldControlLayout, FieldContextValue } from './field-context'\nimport { FieldContext, useFieldContext } from './field-context'\n\n// ── Internal helpers ────────────────────────────────────────────────────────\n\nconst MIN_H_CLASS: Record<FieldSize, string> = {\n sm: 'min-h-field-sm',\n md: 'min-h-field-md',\n lg: 'min-h-field-lg',\n}\n\nconst FIELD_HEIGHT_VAR: Record<FieldSize, string> = {\n sm: 'var(--field-height-sm)',\n md: 'var(--field-height-md)',\n lg: 'var(--field-height-lg)',\n}\n\n// Label / Description / Error 的字體固定 text-body (14px),不隨 field size 變。\n// 世界級共識:field size 只影響 input 高度,不影響表單佈局元素的 typography。\nconst FIELD_TEXT_CLASS = 'text-body'\n\ntype SlotKind = 'label' | 'description' | 'error' | 'control'\n\nfunction resolveSlotKind(node: React.ReactNode): SlotKind {\n if (!React.isValidElement(node)) return 'control'\n const displayName = (node.type as { displayName?: string } | null | undefined)?.displayName\n if (displayName === 'FieldLabel') return 'label'\n if (displayName === 'FieldDescription') return 'description'\n if (displayName === 'FieldError') return 'error'\n return 'control'\n}\n\n/**\n * 偵測 control children 的 fieldLayout——任一 control 宣告為 'block' 即整個 area 切 block 模式。\n *\n * Convention:block primitive 在自己的元件檔案掛 static `fieldLayout = 'block'` 屬性,\n * Field 在 render 時讀 `child.type.fieldLayout`。預設 'inline'。\n *\n * 為什麼是「任一」而非「全部」:實務上 Field 一個 control area 通常只有一個 control,\n * 但若 consumer 同時放多個 child(例如 RadioGroup + 一段補充文字節點),只要其中有 block\n * primitive,整個 area 就應該以 block 模式佈局,確保第一行對齊正確。\n */\nfunction detectControlLayout(controlNodes: React.ReactNode[]): FieldControlLayout {\n for (const node of controlNodes) {\n if (!React.isValidElement(node)) continue\n const layout = (node.type as { fieldLayout?: FieldControlLayout } | null | undefined)?.fieldLayout\n if (layout === 'block') return 'block'\n }\n return 'inline'\n}\n\n// ── Field ───────────────────────────────────────────────────────────────────\n\nexport interface FieldProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'id'> {\n id?: string\n mode?: FieldMode\n /**\n * 視覺外殼(2026-05-05)。\n * - `default`(預設)— 含 border + bg(一般 form input)\n * - `bare` — 透明 variant,hover/focus reveal(cell-as-input substrate;VS Code/Figma toolbar idiom)\n *\n * 透傳機制:Field 一次宣告,所有 child Field control 自動繼承(per-control prop override 可覆寫)。\n */\n variant?: FieldVariant\n orientation?: FieldOrientation\n size?: FieldSize\n required?: boolean\n disabled?: boolean\n invalid?: boolean\n /**\n * Horizontal mode 的 label 欄寬度。支援任何 CSS length 值(\"120px\"、\"10rem\"、\"30%\"...)。\n * 預設 'auto' 由 label 內容撐開。\n */\n labelWidth?: string\n /**\n * Control area 佈局模型(逃生艙)。\n *\n * 預設由 Field 自動偵測——迭代全部 control child 的 `type.fieldLayout` static 屬性,\n * 任一宣告為 `'block'` 即整個 area 切 block(first-block-wins);全部缺宣告時視為 `'inline'`。\n *\n * 只有兩種情況需要手動指定:\n * 1. consumer 把自己手寫的 JSX(`<div>` / 函式元件)當 control,系統無法偵測——強制 `'block'`\n * 2. 想覆寫 primitive 的預設(如把 RadioGroup 強制 inline 呈現,罕見)\n */\n controlLayout?: FieldControlLayout\n}\n\n// ── FieldGroup Context(cascade horizontal labelWidth)──\n// 同一畫面多個 horizontal Field,label 寬度應統一對齊 → FieldGroup 提供 SSOT。\n// 下面 Field 組件自動 consume,consumer 可用 Field 的 labelWidth prop 覆寫單行。\ninterface FieldGroupContextValue {\n horizontalLabelWidth?: string\n}\n// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding\nconst FieldGroupContext = React.createContext<FieldGroupContextValue>({})\n\nconst Field = React.forwardRef<HTMLDivElement, FieldProps>(\n (\n {\n id: idProp,\n mode = 'edit',\n variant = 'default',\n orientation = 'vertical',\n size = 'md',\n required = false,\n disabled: disabledProp = false,\n invalid = false,\n labelWidth,\n controlLayout: controlLayoutProp,\n className,\n style,\n children,\n ...props\n },\n ref\n ) => {\n const generatedId = React.useId()\n const id = idProp ?? generatedId\n const labelId = `${id}-label`\n const descriptionId = `${id}-description`\n const errorId = `${id}-error`\n\n // FieldGroup cascade:group 的 horizontalLabelWidth 是 fallback,單行 labelWidth 覆寫\n const groupCtx = React.useContext(FieldGroupContext)\n const effectiveLabelWidth = labelWidth ?? groupCtx.horizontalLabelWidth\n\n // mode=disabled 與 disabled prop 任一為 true 即視為 disabled\n const disabled = disabledProp || mode === 'disabled'\n\n // 把 children 依 slot 類型分組\n const labelNodes: React.ReactNode[] = []\n const controlNodes: React.ReactNode[] = []\n const descriptionNodes: React.ReactNode[] = []\n const errorNodes: React.ReactNode[] = []\n\n React.Children.forEach(children, (child) => {\n const slot = resolveSlotKind(child)\n if (slot === 'label') labelNodes.push(child)\n else if (slot === 'description') descriptionNodes.push(child)\n else if (slot === 'error') errorNodes.push(child)\n else controlNodes.push(child)\n })\n\n // 解析 control layout:consumer 顯式指定 > primitive 自我宣告 > 預設 inline\n const controlLayout: FieldControlLayout =\n controlLayoutProp ?? detectControlLayout(controlNodes)\n\n const contextValue = React.useMemo<FieldContextValue>(\n () => ({\n id,\n labelId,\n descriptionId,\n errorId,\n mode,\n variant,\n disabled,\n required,\n invalid,\n size,\n orientation,\n controlLayout,\n hasFieldWrapper: true,\n }),\n [id, labelId, descriptionId, errorId, mode, variant, disabled, required, invalid, size, orientation, controlLayout]\n )\n\n // Control area:兩種佈局模型,「第一行內容中線」都錨在 field-height/2,\n // 跟 FieldLabel 在 horizontal 模式下的 padding-top 公式自然對齊。\n //\n // - inline: min-h-field-{size} + items-center\n // 單行 control(Input、Button 等)中線置中於 min-h box。\n //\n // - block: flex-col + items-start(不設 min-h、不加 padding-top,內容自己決定高度)\n // 多行 control(RadioGroup 等),第一行中線由 block primitive 自帶 py 推到\n // field-height/2,後續 item 自然往下流。\n // Block control area 不加額外 paddingTop——block primitive(RadioGroup 等)\n // 的子元件(SelectionItem)已自帶 py = calc((field-height - 1lh) / 2),\n // 第一個 item 的文字自然落在 field-height/2。額外加 paddingTop 會 double padding。\n const controlArea =\n controlLayout === 'block' ? (\n <div\n className=\"flex flex-col items-start min-w-0\"\n data-field-slot=\"control\"\n data-field-control-layout=\"block\"\n >\n {controlNodes}\n </div>\n ) : (\n <div\n className={cn('flex items-center min-w-0', MIN_H_CLASS[size])}\n data-field-slot=\"control\"\n data-field-control-layout=\"inline\"\n >\n {controlNodes}\n </div>\n )\n\n // Horizontal:grid 兩欄,label 在左、content 欄堆疊(control → description → error)\n if (orientation === 'horizontal') {\n return (\n <FieldContext.Provider value={contextValue}>\n <div\n ref={ref}\n className={cn('grid gap-x-3 items-start', className)}\n style={{\n gridTemplateColumns: 'var(--field-label-width, auto) minmax(0, 1fr)',\n ...(effectiveLabelWidth !== undefined\n ? ({ ['--field-label-width' as string]: effectiveLabelWidth } as React.CSSProperties)\n : undefined),\n ...style,\n }}\n data-field-orientation=\"horizontal\"\n data-field-mode={mode}\n data-field-size={size}\n data-field-disabled={disabled ? '' : undefined}\n data-field-invalid={invalid ? '' : undefined}\n {...props}\n >\n {labelNodes}\n <div className=\"flex flex-col gap-1 min-w-0\">\n {controlArea}\n {descriptionNodes}\n {errorNodes}\n </div>\n </div>\n </FieldContext.Provider>\n )\n }\n\n // Vertical(預設):單欄 flex-col\n return (\n <FieldContext.Provider value={contextValue}>\n <div\n ref={ref}\n className={cn('flex flex-col gap-1 min-w-0', className)}\n style={style}\n data-field-orientation=\"vertical\"\n data-field-mode={mode}\n data-field-size={size}\n data-field-disabled={disabled ? '' : undefined}\n data-field-invalid={invalid ? '' : undefined}\n {...props}\n >\n {labelNodes}\n {controlArea}\n {descriptionNodes}\n {errorNodes}\n </div>\n </FieldContext.Provider>\n )\n }\n)\nField.displayName = 'Field'\n\n// ── FieldLabel ──────────────────────────────────────────────────────────────\n\nexport interface FieldLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {\n /**\n * 強制渲染 required 星號(覆寫 Field context 的 required)。\n * 若未設定,預設讀 context。\n */\n required?: boolean\n /**\n * 在 label 文字後方顯示 info icon (ℹ),hover 出現 tooltip 說明。\n * 傳 string → tooltip 內容。\n *\n * Info icon 用 inline action pattern(補充工具,視覺退後),\n * 因為 label 的 primary interaction 是 input,info 是補充說明。\n */\n info?: string\n}\n\n// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding\nconst FieldLabel = React.forwardRef<HTMLLabelElement, FieldLabelProps>(\n ({ className, required: requiredProp, info, htmlFor: htmlForProp, style, children, ...props }, ref) => {\n const ctx = useFieldContext()\n const required = requiredProp ?? ctx?.required ?? false\n const disabled = ctx?.disabled ?? false\n const htmlFor = htmlForProp ?? ctx?.id\n const isHorizontal = ctx?.orientation === 'horizontal'\n const controlLayout = ctx?.controlLayout ?? 'inline'\n const size: FieldSize = ctx?.size ?? 'md'\n\n // Horizontal 模式對齊策略 — 依 controlLayout 分兩套 (CSS-only, 不需 JS 測量)\n //\n // ── Inline control (Input / Button / Switch / SegmentedControl) ──\n // Control 有固定單行高度 = field-height,可以對齊中線。\n // 策略: min-h-field-{size} + flex flex-col + justify-content: center\n //\n // 1) 短 label (總高 ≤ field-height):\n // min-h 生效 → 容器 = field-height → justify-center 把 label 垂直置中\n // 第一行 top = (field-height - 1lh)/2 → 第一行中線對齊 control 中線 ✓\n // 2) 長 label (總高 > field-height):\n // min-h 被內容撐大 → 容器 = label 總高 → justify-center 無作用(內容已填滿)\n // 第一行 top = 0 → label top 對齊 control top ✓\n //\n // ── Block control (RadioGroup / CheckboxGroup) ──\n // Control 是多行群組,沒有「整體中線」可以對齊;錨點是「第一個 item 的第一行\n // 中線永遠在 field-height/2」,由 SelectionItem 的 py 維持。\n // 策略: padding-top = (field-height - 1lh)/2 — 把 label 第一行推到同樣位置。\n //\n // 這個策略對任何 label 長度都正確:label 第一行永遠與第一個 item 第一行對齊,\n // label 超出 control 時往下流(因為 block control 通常本來就很高,不會有\n // inline 模式那種「label 比 control 高」的視覺問題)。\n //\n // 內層 <span>: 只有 inline 策略需要(flex-col 會把 * 星號和 label 文字縱向堆疊,\n // 必須包一層讓兩者 inline 同行)。block 策略可以不包,但為了 DOM 一致性一律包。\n const horizontalInlineClass =\n isHorizontal && controlLayout === 'inline'\n ? cn('flex flex-col justify-center', MIN_H_CLASS[size])\n : undefined\n\n const horizontalBlockStyle: React.CSSProperties | undefined =\n isHorizontal && controlLayout === 'block'\n ? { paddingTop: `calc((${FIELD_HEIGHT_VAR[size]} - 1lh) / 2)` }\n : undefined\n\n return (\n <label\n ref={ref}\n id={ctx?.labelId}\n htmlFor={htmlFor}\n className={cn(\n FIELD_TEXT_CLASS,\n 'font-normal select-none',\n disabled ? 'text-fg-disabled' : 'text-foreground',\n horizontalInlineClass,\n className\n )}\n style={{ ...horizontalBlockStyle, ...style }}\n data-field-slot=\"label\"\n data-field-disabled={disabled ? '' : undefined}\n // 2026-06-10 a11y:styled-disabled label 必明告 inactive(WCAG 1.4.3 inactive-UI 豁免需可機判;\n // axe 對無 aria-disabled 的 fg-disabled 文字誤報 color-contrast — deep-audit 抓 8 筆)\n aria-disabled={disabled || undefined}\n {...props}\n >\n <span className=\"inline-flex items-center gap-1\">\n <span>\n {required && (\n <span\n aria-hidden=\"true\"\n className={disabled ? 'text-fg-disabled' : 'text-fg-muted'}\n >\n *\n </span>\n )}\n {children}\n </span>\n {info && !disabled && (\n <Tooltip>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n aria-label={info}\n className=\"inline-flex items-center text-fg-muted hover:text-fg-secondary bg-transparent border-0 p-0 cursor-pointer\"\n >\n <InfoIcon size={16} aria-hidden />\n </button>\n </TooltipTrigger>\n <TooltipContent>{info}</TooltipContent>\n </Tooltip>\n )}\n </span>\n </label>\n )\n }\n)\nFieldLabel.displayName = 'FieldLabel'\n\n// ── FieldDescription ────────────────────────────────────────────────────────\n\nconst FieldDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, id: idProp, ...props }, ref) => {\n const ctx = useFieldContext()\n const disabled = ctx?.disabled ?? false\n\n return (\n <p\n ref={ref}\n id={idProp ?? ctx?.descriptionId}\n className={cn(\n FIELD_TEXT_CLASS,\n disabled ? 'text-fg-disabled' : 'text-fg-secondary',\n className\n )}\n data-field-slot=\"description\"\n {...props}\n >\n {children}\n </p>\n )\n})\nFieldDescription.displayName = 'FieldDescription'\n\n// ── FieldError ──────────────────────────────────────────────────────────────\n\nconst FieldError = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, id: idProp, ...props }, ref) => {\n const ctx = useFieldContext()\n\n // 無內容不渲染,避免空殼佔位\n if (children == null || children === false || children === '') return null\n\n return (\n <p\n ref={ref}\n id={idProp ?? ctx?.errorId}\n className={cn(FIELD_TEXT_CLASS, 'text-error-text', className)}\n data-field-slot=\"error\"\n role=\"alert\"\n {...props}\n >\n {children}\n </p>\n )\n})\nFieldError.displayName = 'FieldError'\n\n// ── FieldGroup ──────────────────────────────────────────────────────────────\n// 垂直堆疊多個 Field,共用 gap 節奏。\n// 用於表單中多個欄位排列。\n\nexport interface FieldGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Field 之間的垂直間距,預設 'normal'(gap-4) */\n gap?: 'compact' | 'normal' | 'loose'\n /**\n * 同一 group 內所有 horizontal Field 共用的 label 欄寬度。\n *\n * 支援任何 CSS length(`\"140px\"` / `\"10rem\"` / `\"30%\"` 等)。預設不指定——\n * 每個 Field 自動以 label 內容撐開(容易歪七扭八)。\n *\n * 世界級 idiom:macOS System Settings / iOS Settings / GitHub Settings 的\n * setting list 一律 label 固定寬、control 右對齊,列與列整齊對齊。\n *\n * 單一 Field 可以用自己的 `labelWidth` prop 覆寫 cascade 值。\n */\n horizontalLabelWidth?: string\n}\n\nconst FieldGroup = React.forwardRef<HTMLDivElement, FieldGroupProps>(\n ({ className, gap = 'normal', horizontalLabelWidth, ...props }, ref) => {\n const gapClass = gap === 'compact' ? 'gap-3' : gap === 'loose' ? 'gap-6' : 'gap-4'\n const groupCtxValue = React.useMemo(\n () => ({ horizontalLabelWidth }),\n [horizontalLabelWidth],\n )\n return (\n <FieldGroupContext.Provider value={groupCtxValue}>\n <div\n ref={ref}\n className={cn('flex flex-col min-w-0', gapClass, className)}\n data-field-group=\"\"\n {...props}\n />\n </FieldGroupContext.Provider>\n )\n }\n)\nFieldGroup.displayName = 'FieldGroup'\n\n// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)\n// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs\nexport const fieldMeta = {\n component: 'Field',\n family: null, // non-family composite / overlay / layout\n variants: {\n\n },\n sizes: {\n\n },\n states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],\n tokens: {\n bg: [],\n fg: ['text-error-text', 'text-fg-disabled', 'text-fg-muted', 'text-fg-secondary', 'text-foreground'],\n ring: [],\n },\n} as const\n\nexport { Field, FieldLabel, FieldDescription, FieldError, FieldGroup }\n"],"names":["InfoIcon"],"mappings":"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"}
1
+ {"version":3,"file":"field.js","sources":["../../../src/components/Field/field.tsx"],"sourcesContent":["// code-quality-allow: file-size — foundational composite(Field + FieldLabel + FieldDescription + FieldError + context + 8 layout variants),拆檔會讓 Field 家族互相 import 循環\nimport * as React from 'react'\nimport { Info as InfoIcon } from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport { Tooltip, TooltipTrigger, TooltipContent } from '@/design-system/components/Tooltip/tooltip'\n\n/**\n * Field — 表單欄位佈局容器(shadcn Field 風格)\n *\n * ── 定位 ────────────────────────────────────────────────────────────────\n * Field 只負責 **佈局 + 狀態 context**,不擁有任何資料型別邏輯。\n * 每個資料型別對應的 Control(Input、NumberInput、Checkbox、Switch 等)\n * 維持自己的 edit / readonly / disabled 三態,Field 透過 context 把\n * mode / disabled / required / invalid / id 傳給子元件,由子元件決定\n * 如何反映。\n *\n * ── 結構 ────────────────────────────────────────────────────────────────\n * <Field orientation=\"vertical | horizontal\" labelWidth=\"120px\">\n * <FieldLabel>姓名</FieldLabel>\n * <Input value={...} onChange={...} /> ← Control(任何非 label/desc/error 的 child)\n * <FieldDescription>...</FieldDescription>\n * <FieldError>{errors.name}</FieldError>\n * </Field>\n *\n * Control 會自動包在 control area slot(min-h-field-* + items-center),\n * 確保 Checkbox / Switch / Radio 等高度 < field-height 的 primitive\n * 垂直對齊 Input 中線;Input 等自身為 field-height 的 primitive 填滿。\n *\n * ── Horizontal mode 的 label 垂直對齊 ───────────────────────────────────\n * FieldLabel 在 horizontal 模式下使用公式:\n * padding-top: calc((var(--field-height-{size}) - 1lh) / 2)\n *\n * 單行 label → 文字第一行與 input 中線對齊(視覺置中)\n * 多行 label → 第一行仍與 input 中線對齊,其餘行往下流(label 高度超過\n * input 時視覺上仍保持與 input 內容同一基準線)\n *\n * 此公式 tracks field-height 和 line-height 的變動,size 切換或字體\n * 調整時自動連動,不需 JS 測量。\n *\n * ── Horizontal mode 的 label 寬度 ───────────────────────────────────────\n * 透過 labelWidth prop → --field-label-width CSS variable,可以是任何\n * CSS length(\"120px\"、\"10rem\"、\"30%\" 等)。預設 \"auto\" 由 label 內容撐開。\n *\n * ── Required 星號 ──────────────────────────────────────────────────────\n * Field 的 required prop 會透過 context 傳給 FieldLabel 自動渲染 *,\n * 星號為 neutral-7(fg-muted),貼齊 label 文字(無 gap),disabled\n * 時降為 fg-disabled。也可在個別 FieldLabel 覆寫。\n */\n\n// ── Types & Context ──\n// Context 定義在 field-context.ts(打斷 circular import)。\n// field.tsx 只 import 不 re-export——consumer 直接從 field-context.ts import useFieldContext。\n\nimport type { FieldMode, FieldVariant, FieldOrientation, FieldSize, FieldControlLayout, FieldContextValue } from './field-context'\nimport { FieldContext, useFieldContext } from './field-context'\n\n// ── Internal helpers ────────────────────────────────────────────────────────\n\nconst MIN_H_CLASS: Record<FieldSize, string> = {\n sm: 'min-h-field-sm',\n md: 'min-h-field-md',\n lg: 'min-h-field-lg',\n}\n\nconst FIELD_HEIGHT_VAR: Record<FieldSize, string> = {\n sm: 'var(--field-height-sm)',\n md: 'var(--field-height-md)',\n lg: 'var(--field-height-lg)',\n}\n\n// Label / Description / Error 的字體固定 text-body (14px),不隨 field size 變。\n// 世界級共識:field size 只影響 input 高度,不影響表單佈局元素的 typography。\nconst FIELD_TEXT_CLASS = 'text-body'\n\ntype SlotKind = 'label' | 'description' | 'error' | 'control'\n\nfunction resolveSlotKind(node: React.ReactNode): SlotKind {\n if (!React.isValidElement(node)) return 'control'\n const displayName = (node.type as { displayName?: string } | null | undefined)?.displayName\n if (displayName === 'FieldLabel') return 'label'\n if (displayName === 'FieldDescription') return 'description'\n if (displayName === 'FieldError') return 'error'\n return 'control'\n}\n\n/**\n * 偵測 control children 的 fieldLayout——任一 control 宣告為 'block' 即整個 area 切 block 模式。\n *\n * Convention:block primitive 在自己的元件檔案掛 static `fieldLayout = 'block'` 屬性,\n * Field 在 render 時讀 `child.type.fieldLayout`。預設 'inline'。\n *\n * 為什麼是「任一」而非「全部」:實務上 Field 一個 control area 通常只有一個 control,\n * 但若 consumer 同時放多個 child(例如 RadioGroup + 一段補充文字節點),只要其中有 block\n * primitive,整個 area 就應該以 block 模式佈局,確保第一行對齊正確。\n */\nfunction detectControlLayout(controlNodes: React.ReactNode[]): FieldControlLayout {\n for (const node of controlNodes) {\n if (!React.isValidElement(node)) continue\n const layout = (node.type as { fieldLayout?: FieldControlLayout } | null | undefined)?.fieldLayout\n if (layout === 'block') return 'block'\n }\n return 'inline'\n}\n\n// ── Field ───────────────────────────────────────────────────────────────────\n\nexport interface FieldProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'id'> {\n id?: string\n mode?: FieldMode\n /**\n * 視覺外殼(2026-05-05)。\n * - `default`(預設)— 含 border + bg(一般 form input)\n * - `bare` — 透明 variant,hover/focus reveal(cell-as-input substrate;VS Code/Figma toolbar idiom)\n *\n * 透傳機制:Field 一次宣告,所有 child Field control 自動繼承(per-control prop override 可覆寫)。\n */\n variant?: FieldVariant\n orientation?: FieldOrientation\n size?: FieldSize\n required?: boolean\n disabled?: boolean\n invalid?: boolean\n /**\n * Horizontal mode 的 label 欄寬度。支援任何 CSS length 值(\"120px\"、\"10rem\"、\"30%\"...)。\n * 預設 'auto' 由 label 內容撐開。\n */\n labelWidth?: string\n /**\n * Control area 佈局模型(逃生艙)。\n *\n * 預設由 Field 自動偵測——迭代全部 control child 的 `type.fieldLayout` static 屬性,\n * 任一宣告為 `'block'` 即整個 area 切 block(first-block-wins);全部缺宣告時視為 `'inline'`。\n *\n * 只有兩種情況需要手動指定:\n * 1. consumer 把自己手寫的 JSX(`<div>` / 函式元件)當 control,系統無法偵測——強制 `'block'`\n * 2. 想覆寫 primitive 的預設(如把 RadioGroup 強制 inline 呈現,罕見)\n */\n controlLayout?: FieldControlLayout\n}\n\n// ── FieldGroup Context(cascade horizontal labelWidth)──\n// 同一畫面多個 horizontal Field,label 寬度應統一對齊 → FieldGroup 提供 SSOT。\n// 下面 Field 組件自動 consume,consumer 可用 Field 的 labelWidth prop 覆寫單行。\ninterface FieldGroupContextValue {\n horizontalLabelWidth?: string\n}\n// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding\nconst FieldGroupContext = React.createContext<FieldGroupContextValue>({})\n\nconst Field = React.forwardRef<HTMLDivElement, FieldProps>(\n (\n {\n id: idProp,\n mode = 'edit',\n variant = 'default',\n orientation = 'vertical',\n size = 'md',\n required = false,\n disabled: disabledProp = false,\n invalid = false,\n labelWidth,\n controlLayout: controlLayoutProp,\n className,\n style,\n children,\n ...props\n },\n ref\n ) => {\n const generatedId = React.useId()\n const id = idProp ?? generatedId\n const labelId = `${id}-label`\n const descriptionId = `${id}-description`\n const errorId = `${id}-error`\n\n // FieldGroup cascade:group 的 horizontalLabelWidth 是 fallback,單行 labelWidth 覆寫\n const groupCtx = React.useContext(FieldGroupContext)\n const effectiveLabelWidth = labelWidth ?? groupCtx.horizontalLabelWidth\n\n // mode=disabled 與 disabled prop 任一為 true 即視為 disabled\n const disabled = disabledProp || mode === 'disabled'\n\n // 把 children 依 slot 類型分組\n const labelNodes: React.ReactNode[] = []\n const controlNodes: React.ReactNode[] = []\n const descriptionNodes: React.ReactNode[] = []\n const errorNodes: React.ReactNode[] = []\n\n React.Children.forEach(children, (child) => {\n const slot = resolveSlotKind(child)\n if (slot === 'label') labelNodes.push(child)\n else if (slot === 'description') descriptionNodes.push(child)\n else if (slot === 'error') errorNodes.push(child)\n else controlNodes.push(child)\n })\n\n // 解析 control layout:consumer 顯式指定 > primitive 自我宣告 > 預設 inline\n const controlLayout: FieldControlLayout =\n controlLayoutProp ?? detectControlLayout(controlNodes)\n\n const contextValue = React.useMemo<FieldContextValue>(\n () => ({\n id,\n labelId,\n descriptionId,\n errorId,\n mode,\n variant,\n disabled,\n required,\n invalid,\n size,\n orientation,\n controlLayout,\n hasFieldWrapper: true,\n }),\n [id, labelId, descriptionId, errorId, mode, variant, disabled, required, invalid, size, orientation, controlLayout]\n )\n\n // Control area:兩種佈局模型,「第一行內容中線」都錨在 field-height/2,\n // 跟 FieldLabel 在 horizontal 模式下的 padding-top 公式自然對齊。\n //\n // - inline: min-h-field-{size} + items-center\n // 單行 control(Input、Button 等)中線置中於 min-h box。\n //\n // - block: flex-col + items-start(不設 min-h、不加 padding-top,內容自己決定高度)\n // 多行 control(RadioGroup 等),第一行中線由 block primitive 自帶 py 推到\n // field-height/2,後續 item 自然往下流。\n // Block control area 不加額外 paddingTop——block primitive(RadioGroup 等)\n // 的子元件(SelectionItem)已自帶 py = calc((field-height - 1lh) / 2),\n // 第一個 item 的文字自然落在 field-height/2。額外加 paddingTop 會 double padding。\n const controlArea =\n controlLayout === 'block' ? (\n <div\n className=\"flex flex-col items-start min-w-0\"\n data-field-slot=\"control\"\n data-field-control-layout=\"block\"\n >\n {controlNodes}\n </div>\n ) : (\n <div\n className={cn('flex items-center min-w-0', MIN_H_CLASS[size])}\n data-field-slot=\"control\"\n data-field-control-layout=\"inline\"\n >\n {controlNodes}\n </div>\n )\n\n // Horizontal:grid 兩欄,label 在左、content 欄堆疊(control → description → error)\n if (orientation === 'horizontal') {\n return (\n <FieldContext.Provider value={contextValue}>\n <div\n ref={ref}\n className={cn('grid gap-x-3 items-start', className)}\n style={{\n gridTemplateColumns: 'var(--field-label-width, auto) minmax(0, 1fr)',\n ...(effectiveLabelWidth !== undefined\n ? ({ ['--field-label-width' as string]: effectiveLabelWidth } as React.CSSProperties)\n : undefined),\n ...style,\n }}\n data-field-orientation=\"horizontal\"\n data-field-mode={mode}\n data-field-size={size}\n data-field-disabled={disabled ? '' : undefined}\n data-field-invalid={invalid ? '' : undefined}\n {...props}\n >\n {labelNodes}\n <div className=\"flex flex-col gap-1 min-w-0\">\n {controlArea}\n {descriptionNodes}\n {errorNodes}\n </div>\n </div>\n </FieldContext.Provider>\n )\n }\n\n // Vertical(預設):單欄 flex-col\n return (\n <FieldContext.Provider value={contextValue}>\n <div\n ref={ref}\n className={cn('flex flex-col gap-1 min-w-0', className)}\n style={style}\n data-field-orientation=\"vertical\"\n data-field-mode={mode}\n data-field-size={size}\n data-field-disabled={disabled ? '' : undefined}\n data-field-invalid={invalid ? '' : undefined}\n {...props}\n >\n {labelNodes}\n {controlArea}\n {descriptionNodes}\n {errorNodes}\n </div>\n </FieldContext.Provider>\n )\n }\n)\nField.displayName = 'Field'\n\n// ── FieldLabel ──────────────────────────────────────────────────────────────\n\nexport interface FieldLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {\n /**\n * 強制渲染 required 星號(覆寫 Field context 的 required)。\n * 若未設定,預設讀 context。\n */\n required?: boolean\n /**\n * 在 label 文字後方顯示 info icon (ℹ),hover 出現 tooltip 說明。\n * 傳 string → tooltip 內容。\n *\n * Info icon 用 inline action pattern(補充工具,視覺退後),\n * 因為 label 的 primary interaction 是 input,info 是補充說明。\n */\n info?: string\n}\n\n// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding\nconst FieldLabel = React.forwardRef<HTMLLabelElement, FieldLabelProps>(\n ({ className, required: requiredProp, info, htmlFor: htmlForProp, style, children, ...props }, ref) => {\n const ctx = useFieldContext()\n const required = requiredProp ?? ctx?.required ?? false\n const disabled = ctx?.disabled ?? false\n const htmlFor = htmlForProp ?? ctx?.id\n const isHorizontal = ctx?.orientation === 'horizontal'\n const controlLayout = ctx?.controlLayout ?? 'inline'\n const size: FieldSize = ctx?.size ?? 'md'\n\n // Horizontal 模式對齊策略 — 依 controlLayout 分兩套 (CSS-only, 不需 JS 測量)\n //\n // ── Inline control (Input / Button / Switch / SegmentedControl) ──\n // Control 有固定單行高度 = field-height,可以對齊中線。\n // 策略: min-h-field-{size} + flex flex-col + justify-content: center\n //\n // 1) 短 label (總高 ≤ field-height):\n // min-h 生效 → 容器 = field-height → justify-center 把 label 垂直置中\n // 第一行 top = (field-height - 1lh)/2 → 第一行中線對齊 control 中線 ✓\n // 2) 長 label (總高 > field-height):\n // min-h 被內容撐大 → 容器 = label 總高 → justify-center 無作用(內容已填滿)\n // 第一行 top = 0 → label top 對齊 control top ✓\n //\n // ── Block control (RadioGroup / CheckboxGroup) ──\n // Control 是多行群組,沒有「整體中線」可以對齊;錨點是「第一個 item 的第一行\n // 中線永遠在 field-height/2」,由 SelectionItem 的 py 維持。\n // 策略: padding-top = (field-height - 1lh)/2 — 把 label 第一行推到同樣位置。\n //\n // 這個策略對任何 label 長度都正確:label 第一行永遠與第一個 item 第一行對齊,\n // label 超出 control 時往下流(因為 block control 通常本來就很高,不會有\n // inline 模式那種「label 比 control 高」的視覺問題)。\n //\n // 內層 <span>: 只有 inline 策略需要(flex-col 會把 * 星號和 label 文字縱向堆疊,\n // 必須包一層讓兩者 inline 同行)。block 策略可以不包,但為了 DOM 一致性一律包。\n const horizontalInlineClass =\n isHorizontal && controlLayout === 'inline'\n ? cn('flex flex-col justify-center', MIN_H_CLASS[size])\n : undefined\n\n const horizontalBlockStyle: React.CSSProperties | undefined =\n isHorizontal && controlLayout === 'block'\n ? { paddingTop: `calc((${FIELD_HEIGHT_VAR[size]} - 1lh) / 2)` }\n : undefined\n\n return (\n <label\n ref={ref}\n id={ctx?.labelId}\n htmlFor={htmlFor}\n className={cn(\n FIELD_TEXT_CLASS,\n 'font-normal select-none',\n disabled ? 'text-fg-disabled' : 'text-foreground',\n horizontalInlineClass,\n className\n )}\n style={{ ...horizontalBlockStyle, ...style }}\n data-field-slot=\"label\"\n data-field-disabled={disabled ? '' : undefined}\n // 2026-06-10 a11y:styled-disabled label 必明告 inactive(WCAG 1.4.3 inactive-UI 豁免需可機判;\n // axe 對無 aria-disabled 的 fg-disabled 文字誤報 color-contrast — deep-audit 抓 8 筆)\n aria-disabled={disabled || undefined}\n {...props}\n >\n <span className=\"inline-flex items-center gap-1\">\n <span>\n {required && (\n <span\n aria-hidden=\"true\"\n className={disabled ? 'text-fg-disabled' : 'text-fg-muted'}\n >\n *\n </span>\n )}\n {children}\n </span>\n {info && !disabled && (\n <Tooltip>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n aria-label={info}\n className=\"inline-flex items-center text-fg-muted hover:text-fg-secondary bg-transparent border-0 p-0 cursor-pointer\"\n >\n <InfoIcon size={16} aria-hidden />\n </button>\n </TooltipTrigger>\n <TooltipContent>{info}</TooltipContent>\n </Tooltip>\n )}\n </span>\n </label>\n )\n }\n)\nFieldLabel.displayName = 'FieldLabel'\n\n// ── FieldDescription ────────────────────────────────────────────────────────\n\nconst FieldDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, id: idProp, ...props }, ref) => {\n const ctx = useFieldContext()\n const disabled = ctx?.disabled ?? false\n\n return (\n <p\n ref={ref}\n id={idProp ?? ctx?.descriptionId}\n className={cn(\n FIELD_TEXT_CLASS,\n disabled ? 'text-fg-disabled' : 'text-fg-secondary',\n className\n )}\n data-field-slot=\"description\"\n {...props}\n >\n {children}\n </p>\n )\n})\nFieldDescription.displayName = 'FieldDescription'\n\n// ── FieldError ──────────────────────────────────────────────────────────────\n\nconst FieldError = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, id: idProp, ...props }, ref) => {\n const ctx = useFieldContext()\n\n // 無內容不渲染,避免空殼佔位\n if (children == null || children === false || children === '') return null\n\n return (\n <p\n ref={ref}\n id={idProp ?? ctx?.errorId}\n className={cn(FIELD_TEXT_CLASS, 'text-error-text', className)}\n data-field-slot=\"error\"\n role=\"alert\"\n {...props}\n >\n {children}\n </p>\n )\n})\nFieldError.displayName = 'FieldError'\n\n// ── FieldGroup ──────────────────────────────────────────────────────────────\n// 垂直堆疊多個 Field,共用 gap 節奏。\n// 用於表單中多個欄位排列。\n\nexport interface FieldGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Field 之間的垂直間距,預設 'normal'(gap-4) */\n gap?: 'compact' | 'normal' | 'loose'\n /**\n * 同一 group 內所有 horizontal Field 共用的 label 欄寬度。\n *\n * 支援任何 CSS length(`\"140px\"` / `\"10rem\"` / `\"30%\"` 等)。預設不指定——\n * 每個 Field 自動以 label 內容撐開(容易歪七扭八)。\n *\n * 世界級 idiom:macOS System Settings / iOS Settings / GitHub Settings 的\n * setting list 一律 label 固定寬、control 右對齊,列與列整齊對齊。\n *\n * 單一 Field 可以用自己的 `labelWidth` prop 覆寫 cascade 值。\n */\n horizontalLabelWidth?: string\n}\n\nconst FieldGroup = React.forwardRef<HTMLDivElement, FieldGroupProps>(\n ({ className, gap = 'normal', horizontalLabelWidth, ...props }, ref) => {\n const gapClass = gap === 'compact' ? 'gap-3' : gap === 'loose' ? 'gap-6' : 'gap-4'\n const groupCtxValue = React.useMemo(\n () => ({ horizontalLabelWidth }),\n [horizontalLabelWidth],\n )\n return (\n <FieldGroupContext.Provider value={groupCtxValue}>\n <div\n ref={ref}\n className={cn('flex flex-col min-w-0', gapClass, className)}\n data-field-group=\"\"\n {...props}\n />\n </FieldGroupContext.Provider>\n )\n }\n)\nFieldGroup.displayName = 'FieldGroup'\n\n// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)\n// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs\nexport const fieldMeta = {\n component: 'Field',\n family: null, // non-family composite / overlay / layout\n variants: {\n\n },\n sizes: {\n\n },\n states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],\n tokens: {\n bg: [],\n fg: ['text-error-text', 'text-fg-disabled', 'text-fg-muted', 'text-fg-secondary', 'text-foreground'],\n ring: [],\n },\n} as const\n\nexport { Field, FieldLabel, FieldDescription, FieldError, FieldGroup }\n\n// form-validation.spec.md 可執行層(per-component index gen 只 re-export 主檔,故經此公開)\nexport { useFormValidation } from './use-form-validation'\nexport type { UseFormValidationOptions, UseFormValidationReturn, FormFieldInputProps } from './use-form-validation'\n"],"names":["InfoIcon"],"mappings":";;;;;;AA0DA,MAAM,cAAyC;AAAA,EAC7C,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAEA,MAAM,mBAA8C;AAAA,EAClD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAIA,MAAM,mBAAmB;AAIzB,SAAS,gBAAgB,MAAiC;;AACxD,MAAI,CAAC,MAAM,eAAe,IAAI,EAAG,QAAO;AACxC,QAAM,eAAe,UAAK,SAAL,mBAA2D;AAChF,MAAI,gBAAgB,aAAc,QAAO;AACzC,MAAI,gBAAgB,mBAAoB,QAAO;AAC/C,MAAI,gBAAgB,aAAc,QAAO;AACzC,SAAO;AACT;AAYA,SAAS,oBAAoB,cAAqD;;AAChF,aAAW,QAAQ,cAAc;AAC/B,QAAI,CAAC,MAAM,eAAe,IAAI,EAAG;AACjC,UAAM,UAAU,UAAK,SAAL,mBAAuE;AACvF,QAAI,WAAW,QAAS,QAAO;AAAA,EACjC;AACA,SAAO;AACT;AA6CA,MAAM,oBAAoB,MAAM,cAAsC,EAAE;AAExE,MAAM,QAAQ,MAAM;AAAA,EAClB,CACE;AAAA,IACE,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,UAAU;AAAA,IACV,cAAc;AAAA,IACd,OAAO;AAAA,IACP,WAAW;AAAA,IACX,UAAU,eAAe;AAAA,IACzB,UAAU;AAAA,IACV;AAAA,IACA,eAAe;AAAA,IACf;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG;AAAA,EAAA,GAEL,QACG;AACH,UAAM,cAAc,MAAM,MAAA;AAC1B,UAAM,KAAK,UAAU;AACrB,UAAM,UAAU,GAAG,EAAE;AACrB,UAAM,gBAAgB,GAAG,EAAE;AAC3B,UAAM,UAAU,GAAG,EAAE;AAGrB,UAAM,WAAW,MAAM,WAAW,iBAAiB;AACnD,UAAM,sBAAsB,cAAc,SAAS;AAGnD,UAAM,WAAW,gBAAgB,SAAS;AAG1C,UAAM,aAAgC,CAAA;AACtC,UAAM,eAAkC,CAAA;AACxC,UAAM,mBAAsC,CAAA;AAC5C,UAAM,aAAgC,CAAA;AAEtC,UAAM,SAAS,QAAQ,UAAU,CAAC,UAAU;AAC1C,YAAM,OAAO,gBAAgB,KAAK;AAClC,UAAI,SAAS,QAAS,YAAW,KAAK,KAAK;AAAA,eAClC,SAAS,cAAe,kBAAiB,KAAK,KAAK;AAAA,eACnD,SAAS,QAAS,YAAW,KAAK,KAAK;AAAA,UAC3C,cAAa,KAAK,KAAK;AAAA,IAC9B,CAAC;AAGD,UAAM,gBACJ,qBAAqB,oBAAoB,YAAY;AAEvD,UAAM,eAAe,MAAM;AAAA,MACzB,OAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,iBAAiB;AAAA,MAAA;AAAA,MAEnB,CAAC,IAAI,SAAS,eAAe,SAAS,MAAM,SAAS,UAAU,UAAU,SAAS,MAAM,aAAa,aAAa;AAAA,IAAA;AAepH,UAAM,cACJ,kBAAkB,UAChB;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAU;AAAA,QACV,mBAAgB;AAAA,QAChB,6BAA0B;AAAA,QAEzB,UAAA;AAAA,MAAA;AAAA,IAAA,IAGH;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAW,GAAG,6BAA6B,YAAY,IAAI,CAAC;AAAA,QAC5D,mBAAgB;AAAA,QAChB,6BAA0B;AAAA,QAEzB,UAAA;AAAA,MAAA;AAAA,IAAA;AAKP,QAAI,gBAAgB,cAAc;AAChC,aACE,oBAAC,aAAa,UAAb,EAAsB,OAAO,cAC5B,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC;AAAA,UACA,WAAW,GAAG,4BAA4B,SAAS;AAAA,UACnD,OAAO;AAAA,YACL,qBAAqB;AAAA,YACrB,GAAI,wBAAwB,SACvB,EAAE,CAAC,qBAA+B,GAAG,wBACtC;AAAA,YACJ,GAAG;AAAA,UAAA;AAAA,UAEL,0BAAuB;AAAA,UACvB,mBAAiB;AAAA,UACjB,mBAAiB;AAAA,UACjB,uBAAqB,WAAW,KAAK;AAAA,UACrC,sBAAoB,UAAU,KAAK;AAAA,UAClC,GAAG;AAAA,UAEH,UAAA;AAAA,YAAA;AAAA,YACD,qBAAC,OAAA,EAAI,WAAU,+BACZ,UAAA;AAAA,cAAA;AAAA,cACA;AAAA,cACA;AAAA,YAAA,EAAA,CACH;AAAA,UAAA;AAAA,QAAA;AAAA,MAAA,GAEJ;AAAA,IAEJ;AAGA,WACE,oBAAC,aAAa,UAAb,EAAsB,OAAO,cAC5B,UAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC;AAAA,QACA,WAAW,GAAG,+BAA+B,SAAS;AAAA,QACtD;AAAA,QACA,0BAAuB;AAAA,QACvB,mBAAiB;AAAA,QACjB,mBAAiB;AAAA,QACjB,uBAAqB,WAAW,KAAK;AAAA,QACrC,sBAAoB,UAAU,KAAK;AAAA,QAClC,GAAG;AAAA,QAEH,UAAA;AAAA,UAAA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA,GAEL;AAAA,EAEJ;AACF;AACA,MAAM,cAAc;AAqBpB,MAAM,aAAa,MAAM;AAAA,EACvB,CAAC,EAAE,WAAW,UAAU,cAAc,MAAM,SAAS,aAAa,OAAO,UAAU,GAAG,MAAA,GAAS,QAAQ;AACrG,UAAM,MAAM,gBAAA;AACZ,UAAM,WAAW,iBAAgB,2BAAK,aAAY;AAClD,UAAM,YAAW,2BAAK,aAAY;AAClC,UAAM,UAAU,gBAAe,2BAAK;AACpC,UAAM,gBAAe,2BAAK,iBAAgB;AAC1C,UAAM,iBAAgB,2BAAK,kBAAiB;AAC5C,UAAM,QAAkB,2BAAK,SAAQ;AA0BrC,UAAM,wBACJ,gBAAgB,kBAAkB,WAC9B,GAAG,gCAAgC,YAAY,IAAI,CAAC,IACpD;AAEN,UAAM,uBACJ,gBAAgB,kBAAkB,UAC9B,EAAE,YAAY,SAAS,iBAAiB,IAAI,CAAC,eAAA,IAC7C;AAEN,WACE;AAAA,MAAC;AAAA,MAAA;AAAA,QACC;AAAA,QACA,IAAI,2BAAK;AAAA,QACT;AAAA,QACA,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA,WAAW,qBAAqB;AAAA,UAChC;AAAA,UACA;AAAA,QAAA;AAAA,QAEF,OAAO,EAAE,GAAG,sBAAsB,GAAG,MAAA;AAAA,QACrC,mBAAgB;AAAA,QAChB,uBAAqB,WAAW,KAAK;AAAA,QAGrC,iBAAe,YAAY;AAAA,QAC1B,GAAG;AAAA,QAEJ,UAAA,qBAAC,QAAA,EAAK,WAAU,kCACd,UAAA;AAAA,UAAA,qBAAC,QAAA,EACE,UAAA;AAAA,YAAA,YACC;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,eAAY;AAAA,gBACZ,WAAW,WAAW,qBAAqB;AAAA,gBAC5C,UAAA;AAAA,cAAA;AAAA,YAAA;AAAA,YAIF;AAAA,UAAA,GACH;AAAA,UACC,QAAQ,CAAC,YACR,qBAAC,SAAA,EACC,UAAA;AAAA,YAAA,oBAAC,gBAAA,EAAe,SAAO,MACrB,UAAA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY;AAAA,gBACZ,WAAU;AAAA,gBAEV,UAAA,oBAACA,MAAA,EAAS,MAAM,IAAI,eAAW,KAAA,CAAC;AAAA,cAAA;AAAA,YAAA,GAEpC;AAAA,YACA,oBAAC,kBAAgB,UAAA,KAAA,CAAK;AAAA,UAAA,EAAA,CACxB;AAAA,QAAA,EAAA,CAEJ;AAAA,MAAA;AAAA,IAAA;AAAA,EAGN;AACF;AACA,WAAW,cAAc;AAIzB,MAAM,mBAAmB,MAAM,WAG7B,CAAC,EAAE,WAAW,UAAU,IAAI,QAAQ,GAAG,MAAA,GAAS,QAAQ;AACxD,QAAM,MAAM,gBAAA;AACZ,QAAM,YAAW,2BAAK,aAAY;AAElC,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC;AAAA,MACA,IAAI,WAAU,2BAAK;AAAA,MACnB,WAAW;AAAA,QACT;AAAA,QACA,WAAW,qBAAqB;AAAA,QAChC;AAAA,MAAA;AAAA,MAEF,mBAAgB;AAAA,MACf,GAAG;AAAA,MAEH;AAAA,IAAA;AAAA,EAAA;AAGP,CAAC;AACD,iBAAiB,cAAc;AAI/B,MAAM,aAAa,MAAM,WAGvB,CAAC,EAAE,WAAW,UAAU,IAAI,QAAQ,GAAG,MAAA,GAAS,QAAQ;AACxD,QAAM,MAAM,gBAAA;AAGZ,MAAI,YAAY,QAAQ,aAAa,SAAS,aAAa,GAAI,QAAO;AAEtE,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC;AAAA,MACA,IAAI,WAAU,2BAAK;AAAA,MACnB,WAAW,GAAG,kBAAkB,mBAAmB,SAAS;AAAA,MAC5D,mBAAgB;AAAA,MAChB,MAAK;AAAA,MACJ,GAAG;AAAA,MAEH;AAAA,IAAA;AAAA,EAAA;AAGP,CAAC;AACD,WAAW,cAAc;AAuBzB,MAAM,aAAa,MAAM;AAAA,EACvB,CAAC,EAAE,WAAW,MAAM,UAAU,sBAAsB,GAAG,MAAA,GAAS,QAAQ;AACtE,UAAM,WAAW,QAAQ,YAAY,UAAU,QAAQ,UAAU,UAAU;AAC3E,UAAM,gBAAgB,MAAM;AAAA,MAC1B,OAAO,EAAE,qBAAA;AAAA,MACT,CAAC,oBAAoB;AAAA,IAAA;AAEvB,WACE,oBAAC,kBAAkB,UAAlB,EAA2B,OAAO,eACjC,UAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC;AAAA,QACA,WAAW,GAAG,yBAAyB,UAAU,SAAS;AAAA,QAC1D,oBAAiB;AAAA,QAChB,GAAG;AAAA,MAAA;AAAA,IAAA,GAER;AAAA,EAEJ;AACF;AACA,WAAW,cAAc;AAIlB,MAAM,YAAY;AAAA,EACvB,WAAW;AAAA,EACX,QAAQ;AAAA;AAAA,EACR,UAAU,CAAA;AAAA,EAGV,OAAO,CAAA;AAAA,EAGP,QAAQ,CAAC,WAAW,SAAS,UAAU,iBAAiB,UAAU;AAAA,EAClE,QAAQ;AAAA,IACN,IAAI,CAAA;AAAA,IACJ,IAAI,CAAC,mBAAmB,oBAAoB,iBAAiB,qBAAqB,iBAAiB;AAAA,IACnG,MAAM,CAAA;AAAA,EAAC;AAEX;"}
@@ -1,10 +1,12 @@
1
1
  import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, fieldMeta } from "./field.js";
2
+ import { useFormValidation } from "./use-form-validation.js";
2
3
  export {
3
4
  Field,
4
5
  FieldDescription,
5
6
  FieldError,
6
7
  FieldGroup,
7
8
  FieldLabel,
8
- fieldMeta
9
+ fieldMeta,
10
+ useFormValidation
9
11
  };
10
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;"}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * useFormValidation — form-validation.spec.md 方法論的可執行層(SSOT executable arm)
3
+ *
4
+ * ── 定位 ──
5
+ * 把 `form-validation.spec.md` 的 9 條驗證方法論編成**不可配置的預設**——consumer 拿到就是
6
+ * canonical 行為,沒有 API 可以違反(M17「SSOT 必可傳播」:方法論從 prose 變 executable)。
7
+ *
8
+ * ── 實作基礎 ──
9
+ * 基於 react-hook-form(direct dependency,完全 wrapped 不外露——對齊 DS「基於 X」引擎慣例:
10
+ * DataTable 基於 TanStack / DatePicker 基於 react-day-picker / Toast 基於 sonner)。
11
+ * Consumer 不 install、不 import、看不到 RHF API。RHF 提供 values state / dirty 深比對 /
12
+ * errors store / resetField;驗證「時機」由本 hook own(RHF 的 mode/reValidateMode 不外露)。
13
+ *
14
+ * ── 與 Field 家族的關係(engine-agnostic 分層,field.spec.md「定位」段)──
15
+ * Field 保持純 layout + context(MUI FormControl 派,可用於 cell / display / 無引擎場景);
16
+ * 本 hook 住 form 層,錯誤經 consumer 一行 `<Field invalid={!!form.errors.x}>` 接入——
17
+ * Field 層零耦合。這是「A 派的自由 + B 派的 DX」混合位置。
18
+ *
19
+ * ── 方法論對應(form-validation.spec.md 規則 1-9)──
20
+ * 1 Focus 中不顯示錯誤 → 驗證只在 blur / submit 跑(無 onChange 驗證路徑)
21
+ * 2 Blur 時驗證 → getInputProps().onBlur 跑 validate[name]
22
+ * 3 Enter 等同 blur → form 內 Enter 觸發 submit(全驗,超集);單行控件原生行為
23
+ * 4 Escape 取消回復原值 → getInputProps().onKeyDown Escape → resetField + 清 error
24
+ * 5 開始編輯立即清除 error → onChange 先清 errors[name](不論新值合法與否)
25
+ * 6 Blur 重新驗證 → 同 2(離開時重判)
26
+ * 7 Submit 驗證全部 → handleSubmit 對所有 validate keys 全跑(不依賴 blur 狀態)
27
+ * 8 Anchor 到第一個錯誤 → focus + scrollIntoView({block:'center'});每次 submit 重算
28
+ * 9 Async / 跨欄位 defer 到 submit → onSubmit 回傳 field-keyed errors → 同 8 anchor
29
+ * + Submit button:Create 永遠 enabled / Update disabled-until-dirty → `submitDisabled`
30
+ *
31
+ * ── v1 邊界(spec「可執行層」段 documented)──
32
+ * - getInputProps 支援 value/onChange 型控件(Input / Textarea / NumberInput / Select /
33
+ * Combobox / DatePicker / TimePicker;onChange 收 event 或裸值皆可)。Checkbox / Switch
34
+ * (onCheckedChange)consumer 自接 setFieldValue。
35
+ * - focus-first-error 以 DOM `name` 屬性定位(native input 生效;非 native 控件 fallback
36
+ * scroll 略過,errors 視覺仍由 Field 紅框 + FieldError 呈現)。
37
+ */
38
+ import * as React from 'react';
39
+ import type { FieldValues } from 'react-hook-form';
40
+ export interface UseFormValidationOptions<T extends FieldValues> {
41
+ /** 表單初始值(Update 場景 = 現有資料;dirty 比對基準) */
42
+ initialValues: T;
43
+ /**
44
+ * 表單意圖,驅動 submit button 狀態(form-validation.spec.md「Submit Button 狀態」):
45
+ * - 'create'(default):submitDisabled 永遠 false(不讓使用者猜「為什麼按不了」)
46
+ * - 'update':submitDisabled = !isDirty(沒改就不用存;變更還原回 pristine 即再 disabled)
47
+ */
48
+ intent?: 'create' | 'update';
49
+ /**
50
+ * 格式驗證(blur 層,規則 2):single-field 純 syntax(email 格式 / 必填 / URL)。
51
+ * 回傳 error 訊息字串 = 不合法;undefined = 合法。
52
+ * 業務 / async / 跨欄位驗證**不要**放這裡——放 onSubmit 回傳(規則 9)。
53
+ */
54
+ validate?: Partial<Record<keyof T, (value: T[keyof T], values: T) => string | undefined>>;
55
+ /**
56
+ * Submit handler(格式驗證全過後呼叫)。業務驗證(名稱重複 API / 跨欄位)在此判斷,
57
+ * 回傳 field-keyed error object(如 `{ name: '名稱已存在' }`)→ hook 自動 setError +
58
+ * anchor 到第一個錯誤(規則 9);回傳 undefined = 成功。
59
+ */
60
+ onSubmit: (values: T) => void | Partial<Record<keyof T, string>> | Promise<void | Partial<Record<keyof T, string>>>;
61
+ }
62
+ export interface FormFieldInputProps<V = unknown> {
63
+ name: string;
64
+ value: V;
65
+ onChange: (eventOrValue: unknown) => void;
66
+ onBlur: () => void;
67
+ onKeyDown: (e: React.KeyboardEvent) => void;
68
+ }
69
+ export interface UseFormValidationReturn<T extends FieldValues> {
70
+ /** 當前表單值(即時) */
71
+ values: T;
72
+ /** field-keyed 錯誤訊息(餵 `<Field invalid>` + `<FieldError>`) */
73
+ errors: Partial<Record<keyof T, string>>;
74
+ /** 任一欄位偏離 initialValues(深比對,還原回原值 = false) */
75
+ isDirty: boolean;
76
+ /** Submit button disabled 狀態(intent 驅動,見 options.intent) */
77
+ submitDisabled: boolean;
78
+ /** Spread 到 value/onChange 型控件:`<Input {...form.getInputProps('name')} />` */
79
+ getInputProps: <K extends keyof T & string>(name: K) => FormFieldInputProps<T[K]>;
80
+ /** 接 `<form onSubmit={form.handleSubmit}>`(規則 7/8/9) */
81
+ handleSubmit: (e?: React.FormEvent) => Promise<void>;
82
+ /** 整表重置回 initialValues(清 errors + dirty) */
83
+ reset: () => void;
84
+ /** Escape hatch:非 value/onChange 控件(Checkbox/Switch)手動寫值 */
85
+ setFieldValue: (name: keyof T & string, value: unknown) => void;
86
+ }
87
+ export declare function useFormValidation<T extends FieldValues>(options: UseFormValidationOptions<T>): UseFormValidationReturn<T>;
88
+ //# sourceMappingURL=use-form-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-form-validation.d.ts","sourceRoot":"","sources":["../../../src/components/Field/use-form-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAE,WAAW,EAAkC,MAAM,iBAAiB,CAAA;AAElF,MAAM,WAAW,wBAAwB,CAAC,CAAC,SAAS,WAAW;IAC7D,yCAAyC;IACzC,aAAa,EAAE,CAAC,CAAA;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IACzF;;;;OAIG;IACH,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAA;CACpH;AAED,MAAM,WAAW,mBAAmB,CAAC,CAAC,GAAG,OAAO;IAC9C,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,CAAC,CAAA;IACR,QAAQ,EAAE,CAAC,YAAY,EAAE,OAAO,KAAK,IAAI,CAAA;IACzC,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,SAAS,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAA;CAC5C;AAED,MAAM,WAAW,uBAAuB,CAAC,CAAC,SAAS,WAAW;IAC5D,gBAAgB;IAChB,MAAM,EAAE,CAAC,CAAA;IACT,6DAA6D;IAC7D,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAA;IACxC,8CAA8C;IAC9C,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,cAAc,EAAE,OAAO,CAAA;IACvB,8EAA8E;IAC9E,aAAa,EAAE,CAAC,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACjF,wDAAwD;IACxD,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACpD,4CAA4C;IAC5C,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,4DAA4D;IAC5D,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,GAAG,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CAChE;AA+BD,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,WAAW,EACrD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,uBAAuB,CAAC,CAAC,CAAC,CAuH5B"}
@@ -0,0 +1,127 @@
1
+ import * as React from "react";
2
+ import { useForm } from "../../node_modules/react-hook-form/dist/index.esm.js";
3
+ function extractValue(eventOrValue) {
4
+ if (eventOrValue && typeof eventOrValue === "object" && "target" in eventOrValue && eventOrValue.target && typeof eventOrValue.target === "object" && "value" in eventOrValue.target) {
5
+ return eventOrValue.target.value;
6
+ }
7
+ return eventOrValue;
8
+ }
9
+ function focusFirstError(errorNames) {
10
+ for (const name of errorNames) {
11
+ const el = document.getElementsByName(name)[0];
12
+ if (el) {
13
+ el.focus();
14
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
15
+ return;
16
+ }
17
+ }
18
+ }
19
+ function useFormValidation(options) {
20
+ const { initialValues, intent = "create", validate, onSubmit } = options;
21
+ const form = useForm({
22
+ defaultValues: initialValues,
23
+ mode: "onSubmit",
24
+ shouldFocusError: false
25
+ // 規則 8 自己 focus(RHF 依賴 register ref,本 hook 不走 register)
26
+ });
27
+ const values = form.watch();
28
+ const { errors: rhfErrors, isDirty } = form.formState;
29
+ const errors = React.useMemo(() => {
30
+ var _a;
31
+ const out = {};
32
+ for (const key of Object.keys(rhfErrors)) {
33
+ const msg = (_a = rhfErrors[key]) == null ? void 0 : _a.message;
34
+ if (msg) out[key] = msg;
35
+ }
36
+ return out;
37
+ }, [rhfErrors]);
38
+ const validateField = React.useCallback(
39
+ (name) => {
40
+ const fn = validate == null ? void 0 : validate[name];
41
+ if (!fn) return;
42
+ const current = form.getValues();
43
+ const message = fn(current[name], current);
44
+ if (message) form.setError(name, { type: "format", message });
45
+ else form.clearErrors(name);
46
+ },
47
+ [form, validate]
48
+ );
49
+ const getInputProps = React.useCallback(
50
+ (name) => {
51
+ const path = name;
52
+ return {
53
+ name,
54
+ value: form.watch(path),
55
+ onChange: (eventOrValue) => {
56
+ if (form.getFieldState(path).error) form.clearErrors(path);
57
+ form.setValue(path, extractValue(eventOrValue), {
58
+ shouldDirty: true
59
+ });
60
+ },
61
+ // 規則 2:blur 驗證(focus 中永不驗 = 規則 1 自然成立)
62
+ onBlur: () => validateField(name),
63
+ // 規則 4:Escape 回復原值,不觸發驗證
64
+ onKeyDown: (e) => {
65
+ if (e.key === "Escape") {
66
+ form.resetField(path);
67
+ form.clearErrors(path);
68
+ }
69
+ }
70
+ };
71
+ },
72
+ [form, validateField]
73
+ );
74
+ const handleSubmit = React.useCallback(
75
+ async (e) => {
76
+ e == null ? void 0 : e.preventDefault();
77
+ const current = form.getValues();
78
+ const formatErrors = [];
79
+ if (validate) {
80
+ for (const name of Object.keys(validate)) {
81
+ const fn = validate[name];
82
+ if (!fn) continue;
83
+ const message = fn(current[name], current);
84
+ if (message) {
85
+ form.setError(name, { type: "format", message });
86
+ formatErrors.push(name);
87
+ } else {
88
+ form.clearErrors(name);
89
+ }
90
+ }
91
+ }
92
+ if (formatErrors.length > 0) {
93
+ focusFirstError(formatErrors);
94
+ return;
95
+ }
96
+ const businessErrors = await onSubmit(current);
97
+ if (businessErrors && typeof businessErrors === "object") {
98
+ const names = Object.keys(businessErrors).filter(
99
+ (k) => businessErrors[k] != null
100
+ );
101
+ for (const name of names) {
102
+ form.setError(name, {
103
+ type: "business",
104
+ message: businessErrors[name]
105
+ });
106
+ }
107
+ if (names.length > 0) focusFirstError(names);
108
+ }
109
+ },
110
+ [form, validate, onSubmit]
111
+ );
112
+ return {
113
+ values,
114
+ errors,
115
+ isDirty,
116
+ // Submit Button 狀態 canonical:Create 永遠 enabled / Update disabled-until-dirty
117
+ submitDisabled: intent === "update" ? !isDirty : false,
118
+ getInputProps,
119
+ handleSubmit,
120
+ reset: () => form.reset(),
121
+ setFieldValue: (name, value) => form.setValue(name, value, { shouldDirty: true })
122
+ };
123
+ }
124
+ export {
125
+ useFormValidation
126
+ };
127
+ //# sourceMappingURL=use-form-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-form-validation.js","sources":["../../../src/components/Field/use-form-validation.ts"],"sourcesContent":["/**\n * useFormValidation — form-validation.spec.md 方法論的可執行層(SSOT executable arm)\n *\n * ── 定位 ──\n * 把 `form-validation.spec.md` 的 9 條驗證方法論編成**不可配置的預設**——consumer 拿到就是\n * canonical 行為,沒有 API 可以違反(M17「SSOT 必可傳播」:方法論從 prose 變 executable)。\n *\n * ── 實作基礎 ──\n * 基於 react-hook-form(direct dependency,完全 wrapped 不外露——對齊 DS「基於 X」引擎慣例:\n * DataTable 基於 TanStack / DatePicker 基於 react-day-picker / Toast 基於 sonner)。\n * Consumer 不 install、不 import、看不到 RHF API。RHF 提供 values state / dirty 深比對 /\n * errors store / resetField;驗證「時機」由本 hook own(RHF 的 mode/reValidateMode 不外露)。\n *\n * ── 與 Field 家族的關係(engine-agnostic 分層,field.spec.md「定位」段)──\n * Field 保持純 layout + context(MUI FormControl 派,可用於 cell / display / 無引擎場景);\n * 本 hook 住 form 層,錯誤經 consumer 一行 `<Field invalid={!!form.errors.x}>` 接入——\n * Field 層零耦合。這是「A 派的自由 + B 派的 DX」混合位置。\n *\n * ── 方法論對應(form-validation.spec.md 規則 1-9)──\n * 1 Focus 中不顯示錯誤 → 驗證只在 blur / submit 跑(無 onChange 驗證路徑)\n * 2 Blur 時驗證 → getInputProps().onBlur 跑 validate[name]\n * 3 Enter 等同 blur → form 內 Enter 觸發 submit(全驗,超集);單行控件原生行為\n * 4 Escape 取消回復原值 → getInputProps().onKeyDown Escape → resetField + 清 error\n * 5 開始編輯立即清除 error → onChange 先清 errors[name](不論新值合法與否)\n * 6 Blur 重新驗證 → 同 2(離開時重判)\n * 7 Submit 驗證全部 → handleSubmit 對所有 validate keys 全跑(不依賴 blur 狀態)\n * 8 Anchor 到第一個錯誤 → focus + scrollIntoView({block:'center'});每次 submit 重算\n * 9 Async / 跨欄位 defer 到 submit → onSubmit 回傳 field-keyed errors → 同 8 anchor\n * + Submit button:Create 永遠 enabled / Update disabled-until-dirty → `submitDisabled`\n *\n * ── v1 邊界(spec「可執行層」段 documented)──\n * - getInputProps 支援 value/onChange 型控件(Input / Textarea / NumberInput / Select /\n * Combobox / DatePicker / TimePicker;onChange 收 event 或裸值皆可)。Checkbox / Switch\n * (onCheckedChange)consumer 自接 setFieldValue。\n * - focus-first-error 以 DOM `name` 屬性定位(native input 生效;非 native 控件 fallback\n * scroll 略過,errors 視覺仍由 Field 紅框 + FieldError 呈現)。\n */\nimport * as React from 'react'\nimport { useForm } from 'react-hook-form'\nimport type { FieldValues, Path, PathValue, DefaultValues } from 'react-hook-form'\n\nexport interface UseFormValidationOptions<T extends FieldValues> {\n /** 表單初始值(Update 場景 = 現有資料;dirty 比對基準) */\n initialValues: T\n /**\n * 表單意圖,驅動 submit button 狀態(form-validation.spec.md「Submit Button 狀態」):\n * - 'create'(default):submitDisabled 永遠 false(不讓使用者猜「為什麼按不了」)\n * - 'update':submitDisabled = !isDirty(沒改就不用存;變更還原回 pristine 即再 disabled)\n */\n intent?: 'create' | 'update'\n /**\n * 格式驗證(blur 層,規則 2):single-field 純 syntax(email 格式 / 必填 / URL)。\n * 回傳 error 訊息字串 = 不合法;undefined = 合法。\n * 業務 / async / 跨欄位驗證**不要**放這裡——放 onSubmit 回傳(規則 9)。\n */\n validate?: Partial<Record<keyof T, (value: T[keyof T], values: T) => string | undefined>>\n /**\n * Submit handler(格式驗證全過後呼叫)。業務驗證(名稱重複 API / 跨欄位)在此判斷,\n * 回傳 field-keyed error object(如 `{ name: '名稱已存在' }`)→ hook 自動 setError +\n * anchor 到第一個錯誤(規則 9);回傳 undefined = 成功。\n */\n onSubmit: (values: T) => void | Partial<Record<keyof T, string>> | Promise<void | Partial<Record<keyof T, string>>>\n}\n\nexport interface FormFieldInputProps<V = unknown> {\n name: string\n value: V\n onChange: (eventOrValue: unknown) => void\n onBlur: () => void\n onKeyDown: (e: React.KeyboardEvent) => void\n}\n\nexport interface UseFormValidationReturn<T extends FieldValues> {\n /** 當前表單值(即時) */\n values: T\n /** field-keyed 錯誤訊息(餵 `<Field invalid>` + `<FieldError>`) */\n errors: Partial<Record<keyof T, string>>\n /** 任一欄位偏離 initialValues(深比對,還原回原值 = false) */\n isDirty: boolean\n /** Submit button disabled 狀態(intent 驅動,見 options.intent) */\n submitDisabled: boolean\n /** Spread 到 value/onChange 型控件:`<Input {...form.getInputProps('name')} />` */\n getInputProps: <K extends keyof T & string>(name: K) => FormFieldInputProps<T[K]>\n /** 接 `<form onSubmit={form.handleSubmit}>`(規則 7/8/9) */\n handleSubmit: (e?: React.FormEvent) => Promise<void>\n /** 整表重置回 initialValues(清 errors + dirty) */\n reset: () => void\n /** Escape hatch:非 value/onChange 控件(Checkbox/Switch)手動寫值 */\n setFieldValue: (name: keyof T & string, value: unknown) => void\n}\n\n/** onChange 收 event 或裸值皆可(對齊 Mantine getInputProps idiom):\n * native input event → e.target.value;自訂控件裸值(string / number / Date / array)→ 原樣。 */\nfunction extractValue(eventOrValue: unknown): unknown {\n if (\n eventOrValue &&\n typeof eventOrValue === 'object' &&\n 'target' in eventOrValue &&\n eventOrValue.target &&\n typeof eventOrValue.target === 'object' &&\n 'value' in (eventOrValue.target as object)\n ) {\n return (eventOrValue.target as HTMLInputElement).value\n }\n return eventOrValue\n}\n\n/** 規則 8:focus + scroll 到第一個錯誤欄位。以 DOM name 屬性定位(native input);\n * 找不到(非 native 控件)→ 靜默略過,error 視覺仍由 Field 紅框呈現。 */\nfunction focusFirstError(errorNames: string[]) {\n for (const name of errorNames) {\n const el = document.getElementsByName(name)[0] as HTMLElement | undefined\n if (el) {\n el.focus()\n el.scrollIntoView({ block: 'center', behavior: 'smooth' })\n return\n }\n }\n}\n\nexport function useFormValidation<T extends FieldValues>(\n options: UseFormValidationOptions<T>,\n): UseFormValidationReturn<T> {\n const { initialValues, intent = 'create', validate, onSubmit } = options\n\n // RHF 引擎(wrapped):驗證時機由本 hook own,故 RHF 自身 mode 鎖 onSubmit 且不掛 resolver\n // (所有 setError/clearErrors 走手動,RHF 只當 state + dirty + errors store)。\n const form = useForm<T>({\n defaultValues: initialValues as DefaultValues<T>,\n mode: 'onSubmit',\n shouldFocusError: false, // 規則 8 自己 focus(RHF 依賴 register ref,本 hook 不走 register)\n })\n\n // 訂閱全表(表單尺度 re-render 可接受;formState.isDirty 深比對 vs defaultValues)\n const values = form.watch()\n const { errors: rhfErrors, isDirty } = form.formState\n\n const errors = React.useMemo(() => {\n const out: Partial<Record<keyof T, string>> = {}\n for (const key of Object.keys(rhfErrors)) {\n const msg = (rhfErrors as Record<string, { message?: string } | undefined>)[key]?.message\n if (msg) out[key as keyof T] = msg\n }\n return out\n }, [rhfErrors])\n\n /** 規則 2/6:blur 驗證單一欄位 */\n const validateField = React.useCallback(\n (name: keyof T & string) => {\n const fn = validate?.[name]\n if (!fn) return\n const current = form.getValues()\n const message = fn(current[name], current)\n if (message) form.setError(name as Path<T>, { type: 'format', message })\n else form.clearErrors(name as Path<T>)\n },\n [form, validate],\n )\n\n const getInputProps = React.useCallback(\n <K extends keyof T & string>(name: K): FormFieldInputProps<T[K]> => {\n // 泛型 K 窄化到 Path<T> 需經 unknown(RHF Path 是 template-literal type,K 不直接 overlap)\n const path = name as unknown as Path<T>\n return {\n name,\n value: form.watch(path) as T[K],\n onChange: (eventOrValue: unknown) => {\n // 規則 5:開始編輯立即清除 error(不論新值合法與否,給修正空間)\n if (form.getFieldState(path).error) form.clearErrors(path)\n form.setValue(path, extractValue(eventOrValue) as PathValue<T, Path<T>>, {\n shouldDirty: true,\n })\n },\n // 規則 2:blur 驗證(focus 中永不驗 = 規則 1 自然成立)\n onBlur: () => validateField(name),\n // 規則 4:Escape 回復原值,不觸發驗證\n onKeyDown: (e: React.KeyboardEvent) => {\n if (e.key === 'Escape') {\n form.resetField(path)\n form.clearErrors(path)\n }\n },\n }\n },\n [form, validateField],\n )\n\n /** 規則 7/8/9:submit 全驗 + anchor 第一個錯誤 + 業務錯誤同軌 */\n const handleSubmit = React.useCallback(\n async (e?: React.FormEvent) => {\n e?.preventDefault()\n const current = form.getValues()\n // 規則 7:對所有 validate keys 全跑(不依賴個別 blur 狀態);每次 submit 重算(規則 8)\n const formatErrors: string[] = []\n if (validate) {\n for (const name of Object.keys(validate)) {\n const fn = validate[name as keyof T]\n if (!fn) continue\n const message = fn(current[name as keyof T], current)\n if (message) {\n form.setError(name as Path<T>, { type: 'format', message })\n formatErrors.push(name)\n } else {\n form.clearErrors(name as Path<T>)\n }\n }\n }\n if (formatErrors.length > 0) {\n focusFirstError(formatErrors)\n return\n }\n // 規則 9:業務 / async / 跨欄位驗證 defer 到 submit(onSubmit 回傳 field-keyed errors)\n const businessErrors = await onSubmit(current)\n if (businessErrors && typeof businessErrors === 'object') {\n const names = Object.keys(businessErrors).filter(\n (k) => businessErrors[k as keyof T] != null,\n )\n for (const name of names) {\n form.setError(name as Path<T>, {\n type: 'business',\n message: businessErrors[name as keyof T] as string,\n })\n }\n if (names.length > 0) focusFirstError(names)\n }\n },\n [form, validate, onSubmit],\n )\n\n return {\n values,\n errors,\n isDirty,\n // Submit Button 狀態 canonical:Create 永遠 enabled / Update disabled-until-dirty\n submitDisabled: intent === 'update' ? !isDirty : false,\n getInputProps,\n handleSubmit,\n reset: () => form.reset(),\n setFieldValue: (name, value) =>\n form.setValue(name as Path<T>, value as PathValue<T, Path<T>>, { shouldDirty: true }),\n }\n}\n"],"names":[],"mappings":";;AA6FA,SAAS,aAAa,cAAgC;AACpD,MACE,gBACA,OAAO,iBAAiB,YACxB,YAAY,gBACZ,aAAa,UACb,OAAO,aAAa,WAAW,YAC/B,WAAY,aAAa,QACzB;AACA,WAAQ,aAAa,OAA4B;AAAA,EACnD;AACA,SAAO;AACT;AAIA,SAAS,gBAAgB,YAAsB;AAC7C,aAAW,QAAQ,YAAY;AAC7B,UAAM,KAAK,SAAS,kBAAkB,IAAI,EAAE,CAAC;AAC7C,QAAI,IAAI;AACN,SAAG,MAAA;AACH,SAAG,eAAe,EAAE,OAAO,UAAU,UAAU,UAAU;AACzD;AAAA,IACF;AAAA,EACF;AACF;AAEO,SAAS,kBACd,SAC4B;AAC5B,QAAM,EAAE,eAAe,SAAS,UAAU,UAAU,aAAa;AAIjE,QAAM,OAAO,QAAW;AAAA,IACtB,eAAe;AAAA,IACf,MAAM;AAAA,IACN,kBAAkB;AAAA;AAAA,EAAA,CACnB;AAGD,QAAM,SAAS,KAAK,MAAA;AACpB,QAAM,EAAE,QAAQ,WAAW,QAAA,IAAY,KAAK;AAE5C,QAAM,SAAS,MAAM,QAAQ,MAAM;;AACjC,UAAM,MAAwC,CAAA;AAC9C,eAAW,OAAO,OAAO,KAAK,SAAS,GAAG;AACxC,YAAM,OAAO,eAA+D,GAAG,MAAlE,mBAAqE;AAClF,UAAI,IAAK,KAAI,GAAc,IAAI;AAAA,IACjC;AACA,WAAO;AAAA,EACT,GAAG,CAAC,SAAS,CAAC;AAGd,QAAM,gBAAgB,MAAM;AAAA,IAC1B,CAAC,SAA2B;AAC1B,YAAM,KAAK,qCAAW;AACtB,UAAI,CAAC,GAAI;AACT,YAAM,UAAU,KAAK,UAAA;AACrB,YAAM,UAAU,GAAG,QAAQ,IAAI,GAAG,OAAO;AACzC,UAAI,cAAc,SAAS,MAAiB,EAAE,MAAM,UAAU,SAAS;AAAA,UAClE,MAAK,YAAY,IAAe;AAAA,IACvC;AAAA,IACA,CAAC,MAAM,QAAQ;AAAA,EAAA;AAGjB,QAAM,gBAAgB,MAAM;AAAA,IAC1B,CAA6B,SAAuC;AAElE,YAAM,OAAO;AACb,aAAO;AAAA,QACL;AAAA,QACA,OAAO,KAAK,MAAM,IAAI;AAAA,QACtB,UAAU,CAAC,iBAA0B;AAEnC,cAAI,KAAK,cAAc,IAAI,EAAE,MAAO,MAAK,YAAY,IAAI;AACzD,eAAK,SAAS,MAAM,aAAa,YAAY,GAA4B;AAAA,YACvE,aAAa;AAAA,UAAA,CACd;AAAA,QACH;AAAA;AAAA,QAEA,QAAQ,MAAM,cAAc,IAAI;AAAA;AAAA,QAEhC,WAAW,CAAC,MAA2B;AACrC,cAAI,EAAE,QAAQ,UAAU;AACtB,iBAAK,WAAW,IAAI;AACpB,iBAAK,YAAY,IAAI;AAAA,UACvB;AAAA,QACF;AAAA,MAAA;AAAA,IAEJ;AAAA,IACA,CAAC,MAAM,aAAa;AAAA,EAAA;AAItB,QAAM,eAAe,MAAM;AAAA,IACzB,OAAO,MAAwB;AAC7B,6BAAG;AACH,YAAM,UAAU,KAAK,UAAA;AAErB,YAAM,eAAyB,CAAA;AAC/B,UAAI,UAAU;AACZ,mBAAW,QAAQ,OAAO,KAAK,QAAQ,GAAG;AACxC,gBAAM,KAAK,SAAS,IAAe;AACnC,cAAI,CAAC,GAAI;AACT,gBAAM,UAAU,GAAG,QAAQ,IAAe,GAAG,OAAO;AACpD,cAAI,SAAS;AACX,iBAAK,SAAS,MAAiB,EAAE,MAAM,UAAU,SAAS;AAC1D,yBAAa,KAAK,IAAI;AAAA,UACxB,OAAO;AACL,iBAAK,YAAY,IAAe;AAAA,UAClC;AAAA,QACF;AAAA,MACF;AACA,UAAI,aAAa,SAAS,GAAG;AAC3B,wBAAgB,YAAY;AAC5B;AAAA,MACF;AAEA,YAAM,iBAAiB,MAAM,SAAS,OAAO;AAC7C,UAAI,kBAAkB,OAAO,mBAAmB,UAAU;AACxD,cAAM,QAAQ,OAAO,KAAK,cAAc,EAAE;AAAA,UACxC,CAAC,MAAM,eAAe,CAAY,KAAK;AAAA,QAAA;AAEzC,mBAAW,QAAQ,OAAO;AACxB,eAAK,SAAS,MAAiB;AAAA,YAC7B,MAAM;AAAA,YACN,SAAS,eAAe,IAAe;AAAA,UAAA,CACxC;AAAA,QACH;AACA,YAAI,MAAM,SAAS,EAAG,iBAAgB,KAAK;AAAA,MAC7C;AAAA,IACF;AAAA,IACA,CAAC,MAAM,UAAU,QAAQ;AAAA,EAAA;AAG3B,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA;AAAA,IAEA,gBAAgB,WAAW,WAAW,CAAC,UAAU;AAAA,IACjD;AAAA,IACA;AAAA,IACA,OAAO,MAAM,KAAK,MAAA;AAAA,IAClB,eAAe,CAAC,MAAM,UACpB,KAAK,SAAS,MAAiB,OAAgC,EAAE,aAAa,KAAA,CAAM;AAAA,EAAA;AAE1F;"}
package/dist/index.js CHANGED
@@ -62,6 +62,7 @@ import { dragActiveCursor, dragHandleCursorClass, dragSourceClass, dragSourceSty
62
62
  import { applySelectAll, clearSelection } from "./lib/multi-select-ordering.js";
63
63
  import { cn } from "./lib/utils.js";
64
64
  import { PEOPLE_PICKER_LENGTH1_WRAPPER_CLASS, getPeoplePickerTagWrapperClass } from "./components/PeoplePicker/people-picker-helpers.js";
65
+ import { useFormValidation } from "./components/Field/use-form-validation.js";
65
66
  export {
66
67
  AVATAR_SIZE,
67
68
  Accordion,
@@ -329,6 +330,7 @@ export {
329
330
  treeViewMeta,
330
331
  useAppShell,
331
332
  useControllable,
333
+ useFormValidation,
332
334
  useIsNarrowViewport,
333
335
  useIsTouchDevice,
334
336
  useOverflowIndices,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}