diff --git a/.claude/skills/add-sparkle-component/references/sparkle-design-features.md b/.claude/skills/add-sparkle-component/references/sparkle-design-features.md index 3ed7949..02148ca 100644 --- a/.claude/skills/add-sparkle-component/references/sparkle-design-features.md +++ b/.claude/skills/add-sparkle-component/references/sparkle-design-features.md @@ -184,6 +184,19 @@ Components include appropriate ARIA attributes: - Logical tab order - Skip links for navigation +#### Focus ring color (form components) + +Form components (`input`, `textarea`, `select`, `checkbox`, `radio`, …) drive the +focus ring color from their error state — do **not** hardcode the blue ring on the +error path: + +- Normal state → `ring-[var(--color-ring-normal)]` (blue) +- Error state (`isInvalid`) → `ring-negative-500`, matching the negative border + +Define the ring color in the `isInvalid` variant (`false` → ring-normal, `true` → +ring-negative-500) rather than in the base class, so only one ring color ever +applies. `switch` has no error state, so it keeps the normal ring only. + ### Color Contrast All color combinations meet WCAG 2.1 Level AA standards: diff --git a/src/components/ui/checkbox/index.tsx b/src/components/ui/checkbox/index.tsx index e2a125c..a95d387 100644 --- a/src/components/ui/checkbox/index.tsx +++ b/src/components/ui/checkbox/index.tsx @@ -37,7 +37,9 @@ const checkboxItemVariants = cva( const checkboxRootVariants = cva( [ "rounded-xs border-2 transition-colors", - "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-[var(--color-ring-normal)] [.group:focus-visible_&]:ring-offset-2", + // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。 + // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error). + "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-offset-2", ].join(" "), { variants: { @@ -49,11 +51,13 @@ const checkboxRootVariants = cva( isInvalid: { true: [ "border-negative-500", + "[.group:focus-visible_&]:ring-negative-500", "[.group[data-state=checked]_&]:bg-negative-500 [.group[data-state=checked]_&]:border-none", "[.group[data-state=indeterminate]_&]:bg-negative-500 [.group[data-state=indeterminate]_&]:border-none", ].join(" "), false: [ "border-neutral-500", + "[.group:focus-visible_&]:ring-[var(--color-ring-normal)]", "[.group[data-state=checked]_&]:bg-primary-500 [.group[data-state=checked]_&]:border-none", "[.group[data-state=indeterminate]_&]:bg-primary-500 [.group[data-state=indeterminate]_&]:border-none", ].join(" "), diff --git a/src/components/ui/input/index.test.tsx b/src/components/ui/input/index.test.tsx index 4f4923c..944d842 100644 --- a/src/components/ui/input/index.test.tsx +++ b/src/components/ui/input/index.test.tsx @@ -266,6 +266,28 @@ describe("Input", () => { // Then: invalid状態のクラスが保持される(実際のCVAクラス名) expect(container?.className).toContain("border-negative-500"); }); + + it("uses the negative focus ring on error (isInvalid + isFocused)", () => { + // Given: エラー状態でフォーカスされた Input + testContainer.render(); + const container = testContainer.getContainer().firstElementChild; + + // Then: フォーカスリングは negative 色になり、通常の青リングは付かない + expect(container?.className).toContain("ring-negative-500"); + expect(container?.className).not.toContain( + "ring-[var(--color-ring-normal)]" + ); + }); + + it("uses the normal focus ring when valid (isFocused only)", () => { + // Given: 通常状態でフォーカスされた Input + testContainer.render(); + const container = testContainer.getContainer().firstElementChild; + + // Then: フォーカスリングは通常の青色になり、negative リングは付かない + expect(container?.className).toContain("ring-[var(--color-ring-normal)]"); + expect(container?.className).not.toContain("ring-negative-500"); + }); }); describe("Accessibility", () => { diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx index 4f250f7..76ff2cc 100644 --- a/src/components/ui/input/index.tsx +++ b/src/components/ui/input/index.tsx @@ -29,11 +29,23 @@ const inputVariants = cva( false: "", }, isFocused: { - true: "ring-2 ring-[var(--color-ring-normal)] ring-offset-2 outline-hidden", + true: "ring-2 ring-offset-2 outline-hidden", false: "", }, }, compoundVariants: [ + // フォーカスリング色。通常は青、エラー時は枠線と同じ negative に揃える。 + // en: Focus ring color. Blue normally; on error it matches the negative border. + { + isFocused: true, + isInvalid: false, + className: "ring-[var(--color-ring-normal)]", + }, + { + isFocused: true, + isInvalid: true, + className: "ring-negative-500", + }, // 通常状態 { isInvalid: false, diff --git a/src/components/ui/radio/index.tsx b/src/components/ui/radio/index.tsx index f4408b9..1884023 100644 --- a/src/components/ui/radio/index.tsx +++ b/src/components/ui/radio/index.tsx @@ -54,7 +54,9 @@ const radioItemVariants = cva( const radioIndicatorVariants = cva( [ "flex items-center justify-center rounded-full border border-2 transition-colors", - "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-[var(--color-ring-normal)] [.group:focus-visible_&]:ring-offset-2", + // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。 + // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error). + "[.group:focus_&]:outline-hidden [.group:focus-visible_&]:ring-2 [.group:focus-visible_&]:ring-offset-2", ].join(" "), { variants: { @@ -64,8 +66,8 @@ const radioIndicatorVariants = cva( lg: "h-6 w-6", }, isInvalid: { - true: "border-negative-500 [.group[data-state=checked]_&]:border-negative-500", - false: "border-neutral-500", + true: "border-negative-500 [.group:focus-visible_&]:ring-negative-500 [.group[data-state=checked]_&]:border-negative-500", + false: "border-neutral-500 [.group:focus-visible_&]:ring-[var(--color-ring-normal)]", }, isDisabled: { true: "", diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx index b8251d8..a433843 100644 --- a/src/components/ui/select/index.tsx +++ b/src/components/ui/select/index.tsx @@ -14,7 +14,9 @@ import { cn } from "@/lib/utils"; const selectTriggerVariants = cva( [ "flex items-center justify-between w-full rounded-action border bg-white text-text-high transition-colors", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring-normal)] focus-visible:ring-offset-2", + // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。 + // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error). + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "overflow-hidden whitespace-nowrap", ].join(" "), { @@ -25,9 +27,9 @@ const selectTriggerVariants = cva( lg: "h-12 py-1 pl-4 pr-2 gap-2 character-4-regular-pro", }, isInvalid: { - true: "bg-white border-negative-500 hover:border-negative-600 data-[state=open]:border-negative-600", + true: "bg-white border-negative-500 hover:border-negative-600 data-[state=open]:border-negative-600 focus-visible:ring-negative-500", false: - "border-neutral-500 hover:border-neutral-600 data-[state=open]:border-neutral-600", + "border-neutral-500 hover:border-neutral-600 data-[state=open]:border-neutral-600 focus-visible:ring-[var(--color-ring-normal)]", }, isDisabled: { true: "cursor-not-allowed bg-neutral-50 border-neutral-200 hover:border-neutral-200 text-text-disabled", diff --git a/src/components/ui/textarea/index.tsx b/src/components/ui/textarea/index.tsx index ea29bd8..0091d8f 100644 --- a/src/components/ui/textarea/index.tsx +++ b/src/components/ui/textarea/index.tsx @@ -15,7 +15,9 @@ import { cn } from "@/lib/utils"; */ const textareaVariants = cva( // ベーススタイル - "flex w-full rounded-action border bg-white px-3 py-1 ring-offset-background placeholder:text-base-400 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--color-ring-normal)] focus-visible:ring-offset-2 resize", + // フォーカスリング色は isInvalid バリアントで指定(通常=青/エラー=negative)。 + // en: Focus ring color is set in the isInvalid variant (blue normally / negative on error). + "flex w-full rounded-action border bg-white px-3 py-1 ring-offset-background placeholder:text-base-400 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 resize", { variants: { // サイズバリアント(sm, md, lg) @@ -26,9 +28,9 @@ const textareaVariants = cva( }, // エラー状態のバリアント isInvalid: { - true: "border-negative-500 hover:border-negative-600 focus-visible:border-negative-600", + true: "border-negative-500 hover:border-negative-600 focus-visible:border-negative-600 focus-visible:ring-negative-500", false: - "border-neutral-500 hover:border-neutral-600 focus-visible:border-neutral-600", + "border-neutral-500 hover:border-neutral-600 focus-visible:border-neutral-600 focus-visible:ring-[var(--color-ring-normal)]", }, // 無効状態のバリアント isDisabled: {