initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
snc777
2026-05-09 14:24:17 +09:00
co-authored by Cursor
commit f6a781c47e
196 changed files with 31290 additions and 0 deletions
@@ -0,0 +1,151 @@
"use client";
/**
* InlineComboboxField — Pane 4 編集 UI の「検索 + 選択 + 新規追加」プリミティブ。
*
* shadcn 公式 Combobox パターン:
* - `<Popover>` + `<Command>` の組み合わせ
* - 各オプションに `value`(ラベル)と `description`(説明)を持つ
* - CommandInput で検索(`shouldFilter` ON)
* - 検索クエリにマッチがない場合は「+ 「X」を追加」ボタンで新規オプション作成可
* - トリガーの chrome は他フィールドと統一(border-input + bg-card)
* - 選択中アイテムは `data-checked` で shadcn 標準の CheckIcon を表示
*
* 雛形では「応募経路」(Wantedly / LinkedIn / 社員リファラル / 直接応募 / etc.)で再利用。
* オプション集合は外部 state で管理し、`onCreate` を経由して追加する。
*/
import { useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
/** Combobox の選択肢。`value` は ID 兼ラベル、`description` は補助説明。 */
export type ComboOption = {
value: string;
description: string;
};
export type InlineComboboxFieldProps = {
/** 現在選択中の値(`options[].value` のいずれか、または空文字) */
value: string;
/** 選択肢リスト(順序が表示順) */
options: ComboOption[];
/** 選択時に呼ばれる */
onSave: (v: string) => void;
/** 「+ 追加」が押されたときに呼ばれる。新オプション追加 + 選択は呼び出し側の責務 */
onCreate: (newOpt: ComboOption) => void;
/** スクリーンリーダー向けラベル */
ariaLabel: string;
/** トリガーの空表示 placeholder。デフォルト "選択..." */
placeholder?: string;
/** Command 検索 input の placeholder。デフォルト "検索または追加..." */
searchPlaceholder?: string;
};
export function InlineComboboxField({
value,
options,
onSave,
onCreate,
ariaLabel,
placeholder = "選択...",
searchPlaceholder = "検索または追加...",
}: InlineComboboxFieldProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const trimmed = query.trim();
const exactMatch = options.find((o) => o.value === trimmed);
const showCreate = trimmed !== "" && !exactMatch;
const handleCreate = () => {
const newOpt: ComboOption = {
value: trimmed,
description: "(新規追加)",
};
onCreate(newOpt);
onSave(newOpt.value);
setOpen(false);
};
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) setQuery("");
}}
>
<PopoverTrigger
aria-label={ariaLabel}
className="flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-card px-2.5 py-1 text-left text-sm text-foreground transition-colors outline-none hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-popup-open:border-ring data-popup-open:ring-3 data-popup-open:ring-ring/50"
>
<span className="truncate">
{value || (
<span className="text-muted-foreground">{placeholder}</span>
)}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0">
<Command shouldFilter>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={searchPlaceholder}
/>
<CommandList>
<CommandEmpty className="p-1">
{showCreate ? (
<button
type="button"
onClick={handleCreate}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 text-left text-sm text-primary hover:bg-muted"
>
<Plus className="size-4" />「{trimmed}」を追加
</button>
) : (
<span className="block py-3 text-center text-sm text-muted-foreground">
候補なし
</span>
)}
</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.value}
data-checked={value === opt.value || undefined}
onSelect={() => {
onSave(opt.value);
setOpen(false);
}}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{opt.value}</span>
<span className="truncate text-xs text-muted-foreground group-data-selected/command-item:text-foreground/70">
{opt.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
/**
* InlineDateField — Pane 4 編集 UI の「日付 input」プリミティブ。
*
* shadcn 公式 Date Picker パターンに完全準拠:
* - `<Popover>` + `<Calendar>` の組み合わせ
* - 📅 アイコンを **左** に配置(shadcn 公式サンプルと同じ)
* - トリガーの chrome は `<Input>` フィールドに揃える(border-input + bg-card)
* ※ Button variant ではなく Input 風にする理由:
* Pane 4 内の他フィールドと「箱」の濃淡を揃え、視覚的に浮かないようにする
* - 値が空のときは「日付を選択」(shadcn "Pick a date" の翻訳)
* - 日付の保存形式は ANSI / ISO 8601(YYYY-MM-DD)
* - 月・年はドロップダウン(captionLayout="dropdown")。1925〜2050 の範囲で
* 生年月日(過去)から入社可能日(未来)まで網羅する
*
* ADR-0014 で chromeless inline edit から shadcn 標準フォームへ転換した結果として
* `components/primitives/` に切り出された再利用可能なプリミティブ。
*/
import { useState } from "react";
import { Calendar as CalendarIcon } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { formatISODate, parseISODate } from "@/lib/computed/profile";
export type InlineDateFieldProps = {
/** ISO 8601 (YYYY-MM-DD) 形式の文字列。空で「日付を選択」placeholder */
value: string;
/** 値が変わったとき呼ばれる(ISO 8601 文字列を渡す) */
onSave: (v: string) => void;
/** スクリーンリーダー向けラベル */
ariaLabel: string;
};
export function InlineDateField({
value,
onSave,
ariaLabel,
}: InlineDateFieldProps) {
const [open, setOpen] = useState(false);
const selected = parseISODate(value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
aria-label={ariaLabel}
className="flex h-8 w-full items-center justify-start gap-2 rounded-lg border border-input bg-card px-2.5 py-1 text-left text-sm text-foreground transition-colors outline-none hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-popup-open:border-ring data-popup-open:ring-3 data-popup-open:ring-ring/50"
>
<CalendarIcon className="size-4 shrink-0 text-muted-foreground" />
<span
className={
value
? "truncate text-foreground"
: "truncate text-muted-foreground"
}
>
{value || "日付を選択"}
</span>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={selected}
onSelect={(d) => {
const next = formatISODate(d);
if (next !== value) onSave(next);
setOpen(false);
}}
captionLayout="dropdown"
startMonth={new Date(1925, 0)}
endMonth={new Date(2050, 11)}
defaultMonth={selected ?? new Date()}
autoFocus
/>
</PopoverContent>
</Popover>
);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* InlineFieldRow — ラベル + 値の縦積み行プリミティブ。
*
* Pane 3 / Pane 4 のフォーム系セクションで「ラベル + Inline*Field」を
* 並べる用途で再利用する MUST。dl/dt/dd セマンティクスで a11y を確保。
*
* 利用例:
* <dl className="flex flex-col gap-2.5 text-sm">
* <InlineFieldRow label="名前">
* <InlineTextField value={name} onSave={...} ariaLabel="名前" />
* </InlineFieldRow>
* <InlineFieldRow label="生年月日">
* <InlineDateField value={birthday} onSave={...} ariaLabel="生年月日" />
* </InlineFieldRow>
* </dl>
*
* 雛形では Pane 3 ヘッダー帯トグル内の「応募情報」/「連絡先」、
* 採用条件カード、Pane 4 モード 2「基本情報」で使う。
*/
export type InlineFieldRowProps = {
/** ラベルテキスト */
label: string;
/** 値(Inline*Field など) */
children: React.ReactNode;
};
export function InlineFieldRow({ label, children }: InlineFieldRowProps) {
return (
<div className="flex flex-col gap-1.5">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
/**
* InlineSelectField — Pane 4 編集 UI の「固定リスト Select」プリミティブ。
*
* shadcn `<Select>` をラップし、ComboOption ベースの `<InlineComboboxField>` よりも
* 軽量な「固定 N 択」の場面で使う:
* - `string[]` だけ渡せば良い(`{ value, description }` 構造は不要)
* - 新規追加機能は持たない(候補者個別に増やす想定がない場合に使う)
* - SelectTrigger 素体(`border-input` + `h-8`)に `bg-card` + `w-full` +
* `hover:bg-accent/40` を加えて他の InlineXxxField と完全に揃える
*
* 雛形では「形式(書類 / オンライン / オフライン)」「判定(通過 / 保留 / 不合格)」
* のような選考ステージのメタ情報フィールドで再利用される。
*/
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type InlineSelectFieldProps = {
/** 現在の値(空文字で placeholder 表示) */
value: string;
/** 選択肢(順序が表示順、`readonly` 配列も受け取る) */
options: readonly string[];
/** 選択時に呼ばれる */
onSave: (v: string) => void;
/** スクリーンリーダー向けラベル */
ariaLabel: string;
/** 空のときの placeholder。デフォルト "選択..." */
placeholder?: string;
};
export function InlineSelectField({
value,
options,
onSave,
ariaLabel,
placeholder = "選択...",
}: InlineSelectFieldProps) {
return (
<Select
value={value === "" ? undefined : value}
onValueChange={(v) => onSave(v ?? "")}
>
<SelectTrigger
aria-label={ariaLabel}
className="h-8 w-full bg-card hover:bg-accent/40"
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent align="start">
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
/**
* InlineTextField — Pane 4 編集 UI の「1 行 input」プリミティブ。
*
* shadcn `<Input>` をラップし、Lab v3 で確定した規律で編集体験を統一する:
* - 常に `<Input>` 表示(Type-direct、ADR-0014)
* - `border-input` + `bg-card` で「手前」感を出し、編集可能と一目で分かる
* - 保存: blur / Enter(値が変わっていれば onSave)
* - キャンセル: Esc で defaultValue に戻して blur
*
* ADR-0014 で旧 ADR-0010 §6 D R5「shadcn Input MUST 禁止」を撤回。
* 雛形では候補者の「氏名・採用担当・連絡先・希望年収(min/max)」等で再利用。
*/
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export type InlineTextFieldProps = {
/** 現在の値(空文字で「未設定」placeholder 表示) */
value: string;
/** 値が変わって blur した時に呼ばれる */
onSave: (v: string) => void;
/** スクリーンリーダー向けラベル */
ariaLabel: string;
/** input の type 属性(`text` / `email` / `tel` / `number`) */
inputType?: "text" | "email" | "tel" | "number";
/** 空のときの placeholder。デフォルト "未設定" */
placeholder?: string;
/** className override(width 制限などに使う) */
className?: string;
};
export function InlineTextField({
value,
onSave,
ariaLabel,
inputType = "text",
placeholder,
className,
}: InlineTextFieldProps) {
return (
<Input
type={inputType}
defaultValue={value}
placeholder={placeholder ?? "未設定"}
aria-label={ariaLabel}
onBlur={(e) => {
if (e.target.value !== value) onSave(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
(e.target as HTMLInputElement).value = value;
(e.target as HTMLInputElement).blur();
}
}}
className={cn("h-8 bg-card", className)}
/>
);
}
@@ -0,0 +1,54 @@
"use client";
/**
* InlineTextareaField — Pane 4 編集 UI の「複数行 textarea」プリミティブ。
*
* shadcn `<Textarea>` をラップし、Lab v3 で確定した規律で編集体験を統一する:
* - 常に `<Textarea>` 表示(Type-direct、ADR-0014)
* - `bg-card` で周囲(bg-background)より明るく「手前」感を出す
* - `field-sizing: content`(Tailwind v4 / shadcn v4 の textarea デフォルト)で内容に応じて自動リサイズ
* - 保存: blur で onSave 発火(値が変わっていれば)。Cmd+Enter で blur
* - キャンセル: Esc で defaultValue に戻して blur
*
* 雛形では「職務経歴」「志望動機」のような長文項目で再利用。
*/
import { Textarea } from "@/components/ui/textarea";
export type InlineTextareaFieldProps = {
/** 現在の値(空文字で「未設定」placeholder 表示) */
value: string;
/** 値が変わって blur した時に呼ばれる */
onSave: (v: string) => void;
/** スクリーンリーダー向けラベル */
ariaLabel: string;
/** 空のときの placeholder。デフォルト "未設定" */
placeholder?: string;
};
export function InlineTextareaField({
value,
onSave,
ariaLabel,
placeholder,
}: InlineTextareaFieldProps) {
return (
<Textarea
defaultValue={value}
placeholder={placeholder ?? "未設定"}
aria-label={ariaLabel}
onBlur={(e) => {
if (e.target.value !== value) onSave(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
(e.target as HTMLTextAreaElement).blur();
} else if (e.key === "Escape") {
(e.target as HTMLTextAreaElement).value = value;
(e.target as HTMLTextAreaElement).blur();
}
}}
className="min-h-24 bg-card leading-relaxed whitespace-pre-line"
/>
);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* ScoreLabel — 候補者の代表平均スコアを「★ 4.3」形式で表示する読み取り専用ラベル。
*
* Pane 3 ヘッダー帯(閉時)で名前の隣にサブテキストとして配置し、
* 採用担当者が「この候補者は採る価値があるか」を 5 秒で判断する材料となる。
*
* 計算は `getCandidateAverageScore()` の派生値(最新 done scorecard の axisScores 平均)。
* null(未評価)のときは `★ —` を表示する。
*
* クリック動作は持たない(ADR-0015 §19 大決定 L: スコアは読み取り専用ラベル)。
* 4 軸の内訳が見たければ、選考フローカードから Pane 4 モード 2 へジャンプする。
*/
import { Star } from "lucide-react";
export type ScoreLabelProps = {
/** 平均スコア(null = 未評価) */
value: number | null;
};
export function ScoreLabel({ value }: ScoreLabelProps) {
const displayValue = value !== null ? value.toFixed(1) : "—";
return (
<span
className="inline-flex items-center gap-1 text-sm text-muted-foreground tabular-nums"
aria-label={
value !== null ? `評価平均 ${displayValue} 点` : "評価平均 未評価"
}
>
<Star className="size-3 fill-primary text-primary" aria-hidden="true" />
{displayValue}
</span>
);
}
+31
View File
@@ -0,0 +1,31 @@
import React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const sectionLabelVariants = cva(
"text-sm font-semibold uppercase tracking-wide text-muted-foreground",
{
variants: {
tone: {
default: "",
sidebar: "text-sidebar-foreground/70",
},
},
defaultVariants: {
tone: "default",
},
},
);
function SectionLabel({
className,
tone = "default",
...props
}: React.ComponentProps<"h2"> & VariantProps<typeof sectionLabelVariants>) {
return (
<h2 className={cn(sectionLabelVariants({ tone }), className)} {...props} />
);
}
export { SectionLabel, sectionLabelVariants };
+38
View File
@@ -0,0 +1,38 @@
/**
* Pane 4 編集 UI 用の inline 編集プリミティブ群(shadcn 標準フォームベース)。
*
* ADR-0014「shadcn 標準フォームによる Pane 4 編集 UI」で確定:
* - 旧 ADR-0010 §6 D R5「shadcn Input/Textarea MUST 禁止」を撤回
* - 鉛筆アイコン式・編集ボタン式は引き続き禁止(ADR-0014 で明文化)
* - 全フィールドが常に shadcn の Input / Textarea / Calendar / Combobox で表示される
*
* 利用例:
* import { InlineTextField } from "@/components/primitives";
* <InlineTextField value={...} onSave={...} ariaLabel="..." />
*/
export { InlineTextField } from "./InlineTextField";
export type { InlineTextFieldProps } from "./InlineTextField";
export { InlineDateField } from "./InlineDateField";
export type { InlineDateFieldProps } from "./InlineDateField";
export { InlineComboboxField } from "./InlineComboboxField";
export type {
ComboOption,
InlineComboboxFieldProps,
} from "./InlineComboboxField";
export { InlineSelectField } from "./InlineSelectField";
export type { InlineSelectFieldProps } from "./InlineSelectField";
export { InlineTextareaField } from "./InlineTextareaField";
export type { InlineTextareaFieldProps } from "./InlineTextareaField";
export { SectionLabel, sectionLabelVariants } from "./SectionLabel";
export { InlineFieldRow } from "./InlineFieldRow";
export type { InlineFieldRowProps } from "./InlineFieldRow";
export { ScoreLabel } from "./ScoreLabel";
export type { ScoreLabelProps } from "./ScoreLabel";