From 864f4adccce4d385b541297d962aa6e274cc653c Mon Sep 17 00:00:00 2001 From: Shangxin Date: Wed, 14 Oct 2026 10:59:20 +0000 Subject: [PATCH 1/3] feat(ui): upgrade input to authentic Splatoon 3 Lobby Terminal field-cut specification --- apps/docs/content/docs/en/input.mdx | 33 +- apps/docs/content/docs/ja/input.mdx | 33 +- apps/docs/content/docs/zh/input.mdx | 33 +- apps/docs/src/docs/examples/input.tsx | 118 +++++- apps/docs/src/docs/generated/api.json | 109 +++++- .../src/docs/generated/example-source.json | 2 +- .../ui/src/components/ui/input.module.css | 104 ++++++ packages/ui/src/components/ui/input.tsx | 350 +++++++++++++++++- packages/ui/src/lib/splatoon-color-tokens.ts | 4 + packages/ui/src/styles/globals.css | 2 + 10 files changed, 749 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/components/ui/input.module.css diff --git a/apps/docs/content/docs/en/input.mdx b/apps/docs/content/docs/en/input.mdx index 157bbb5..fbc8949 100644 --- a/apps/docs/content/docs/en/input.mdx +++ b/apps/docs/content/docs/en/input.mdx @@ -1,16 +1,37 @@ export const metadata = { title: 'Input', - description: 'Text field primitive for compact forms and control panels.', + description: 'Production-ready text field blending Splatoon street graffiti and in-game lobby terminal aesthetics.', category: 'forms', example: 'input', } ## Usage -Use Input with Label for named text fields. Keep validation and helper text near the field so repeated form workflows stay scannable. +`Input` is built for web forms and game UI interactions, faithfully capturing the authentic physical card styling of the *Splatoon 3* Lobby Terminal. It includes prefix/suffix icon slots, one-click clear, password reveal, and invalid shake feedback. -## Design Notes +```tsx +import { Input } from 'splatoon-ui/input' +import { Search } from 'lucide-react' -- Always pair inputs with labels. -- Reserve placeholder text for examples, not field names. -- Keep field width tied to the expected input length. +} + clearable + placeholder="Enter pool keyword..." +/> +``` + +## Design Style (Official Reference) + +- **`field-cut` (Default)**: 4-corner asymmetric organic cut (`7px 11px 8px 12px`), faithfully matching the *Splatoon 3* Lobby Terminal Pool Keyword input screen. Includes a 2.5px border, tactile controller focus displacement (`translate(-1px, -1px)`), and a solid black shadow. + +> Note: The 4-digit Private Battle passcode uses segmented physical slots and is tracked separately as a dedicated `PinInput` component. + +## Features + +- **Ink Focus Rings (`focusColor`)**: Supports `yellow`, `blue`, `green`, `purple`, `orange`, and `red` solid ink halos. +- **One-Click Clear (`clearable`)**: Blob clear button appears when text is entered, supporting both controlled and uncontrolled usage. +- **Password Toggle (`showPasswordToggle`)**: Automatically enables an eye toggle button for `type="password"`. +- **Validation Feedback (`invalid` & `errorMessage`)**: Triggers an authentic mechanical `splat-shake` tremor and an alert red error indicator. diff --git a/apps/docs/content/docs/ja/input.mdx b/apps/docs/content/docs/ja/input.mdx index de9e624..cbe6359 100644 --- a/apps/docs/content/docs/ja/input.mdx +++ b/apps/docs/content/docs/ja/input.mdx @@ -1,16 +1,37 @@ export const metadata = { title: 'Input', - description: 'コンパクトなフォームと操作パネルのためのテキストフィールド primitive です。', + description: 'スプラトゥーンのストリートグラフィティとゲーム内ロビーターミナルを融合させたプロダクション対応テキスト入力フィールド。', category: 'forms', example: 'input', } ## 使い方 -Input は Label と一緒に使います。検証や補助文はフィールドの近くに置き、反復するフォーム作業を読み取りやすくします。 +`Input` は Web フォームやゲーム UI 向けに設計され、『スプラトゥーン 3』のロビーターミナル(Lobby Terminal)の物理カードスタイルを忠実に再現しています。プレフィックス/サフィックスアイコン、ワンクリッククリア、パスワード表示切り替え、バリデーションエラー時のシェイク演出を備えています。 -## デザインメモ +```tsx +import { Input } from 'splatoon-ui/input' +import { Search } from 'lucide-react' -- 入力には必ずラベルを付けます。 -- placeholder は例に使い、フィールド名には使いません。 -- 幅は想定する入力長に合わせます。 +} + clearable + placeholder="合流キーワードを入力..." +/> +``` + +## デザインスタイル(公式実機考証) + +- **`field-cut`(デフォルト)**:4 角の非対称カッティング(`7px 11px 8px 12px`)を採用し、『スプラトゥーン 3』ロビーターミナルの合流キーワード(Pool Keyword)入力画面を忠実に再現。2.5px ボーダー、コントローラー選択時の立体押し込み動效(`translate(-1px, -1px)`)、ソリッドシャドウを搭載。 + +> 注意:プライベートマッチ(Private Battle)の 4 桁パスコード入力は独立したスロット構造のため、別コンポーネント `PinInput` として計画されています。 + +## 主な機能 + +- **インクフォーカスリング (`focusColor`)**:`yellow`、`blue`、`green`、`purple`、`orange`、`red` のソリッドインク光輪に対応。 +- **ワンクリッククリア (`clearable`)**:文字入力時にインク滴型の削除ボタンが出現し、制御・非制御フォームに対応。 +- **パスワード切り替え (`showPasswordToggle`)**:`type="password"` 時に表示切り替えボタンを自動配置。 +- **エラーフィードバック (`invalid` & `errorMessage`)**:エラー時にメカニカルな `splat-shake` 振動動效とアラート表示を実行。 diff --git a/apps/docs/content/docs/zh/input.mdx b/apps/docs/content/docs/zh/input.mdx index ba09688..3c0f7f3 100644 --- a/apps/docs/content/docs/zh/input.mdx +++ b/apps/docs/content/docs/zh/input.mdx @@ -1,16 +1,37 @@ export const metadata = { title: 'Input', - description: '用于紧凑表单和控制面板的文本字段 primitive。', + description: '融合 Splatoon 街头涂鸦与游戏内终端风格的生产级文本输入框。', category: 'forms', example: 'input', } ## 用法 -Input 应与 Label 搭配使用。校验和辅助说明应靠近字段,方便重复表单流程快速扫描。 +`Input` 针对前端表单与游戏交互场景进行了全面升级,严谨复刻《Splatoon 3》大厅终端(Lobby Terminal)的实体卡片风格,内置前后缀插槽、一键清空、密码显隐切换及错误状态提示。 -## 设计备注 +```tsx +import { Input } from 'splatoon-ui/input' +import { Search } from 'lucide-react' -- 输入框必须搭配标签。 -- placeholder 用于示例,不用于字段名。 -- 字段宽度应匹配预期输入长度。 +} + clearable + placeholder="输入组队关键词..." +/> +``` + +## 设计风格(官方实机考据) + +- **`field-cut`(默认形态)**:四角非对称有机切割(`7px 11px 8px 12px`),严格还原《Splatoon 3》大厅终端关键词输入(Pool Keyword)界面。包含 2.5px 墨黑边框、手柄聚焦弹跳动效(`translate(-1px, -1px)`)及实体黑色落影。 + +> 提示:私人对战(Private Match)的 4 位房间密码锁属于分体式插槽形态,已规划为独立的 `PinInput` 组件实现。 + +## 核心特性 + +- **聚焦高亮态 (`focusColor`)**:支持 `yellow`、`blue`、`green`、`purple`、`orange`、`red` 实体墨水光晕。 +- **一键清空 (`clearable`)**:输入框有内容时浮现墨水圆滴清除按钮,兼容受控与非受控表单。 +- **密码切换 (`showPasswordToggle`)**:在 `type="password"` 时自动挂载眨眼显隐开关。 +- **校验反馈 (`invalid` & `errorMessage`)**:错误时触发 `splat-shake` 机械震颤动效并展示高亮警报红标签。 diff --git a/apps/docs/src/docs/examples/input.tsx b/apps/docs/src/docs/examples/input.tsx index 4236676..43629ce 100644 --- a/apps/docs/src/docs/examples/input.tsx +++ b/apps/docs/src/docs/examples/input.tsx @@ -1,21 +1,63 @@ 'use client' +import * as React from 'react' +import { Search, Sparkles } from 'lucide-react' import type { DocsExampleDefinitionInput } from '@/docs/types' // docs-source-start -import { Input } from 'splatoon-ui/input' +import { Input, type InputColor, type InputSize, type InputVariant } from 'splatoon-ui/input' type InputExampleProps = { value: string + variant: string + size: string + focusColor: string + withPrefix: boolean + withSuffix: boolean + clearable: boolean + invalid: boolean + showPasswordToggle: boolean } -export function InputExample({ value }: InputExampleProps) { +export function InputExample({ + value: initialValue, + variant, + size, + focusColor, + withPrefix, + withSuffix, + clearable, + invalid, + showPasswordToggle, +}: InputExampleProps) { + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + return (
- + : undefined} + suffixIcon={withSuffix ? : undefined} + clearable={clearable} + showPasswordToggle={showPasswordToggle} + type={showPasswordToggle ? 'password' : 'text'} + invalid={invalid} + errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined} + helperText={!invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined} + value={value} + onChange={(e) => setValue(e.target.value)} + placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'} + />
) } @@ -24,8 +66,70 @@ export function InputExample({ value }: InputExampleProps) { export const inputExample: DocsExampleDefinitionInput = { id: 'input', title: 'Input', - description: 'Check sizing and text rendering inside form fields.', - controls: [{ type: 'text', prop: 'value', label: 'Value', defaultValue: 'Inkling' }], - initialProps: { value: 'Inkling' }, + description: 'Production-ready text field with authentic Splatoon 3 Lobby Terminal field-cut styling.', + controls: [ + { + type: 'select', + prop: 'variant', + label: 'Variant (形态)', + options: ['field-cut'], + defaultValue: 'field-cut', + }, + { + type: 'select', + prop: 'size', + label: 'Size (尺寸)', + options: ['default', 'sm', 'lg'], + defaultValue: 'default', + }, + { + type: 'select', + prop: 'focusColor', + label: 'Focus Color (聚焦色)', + options: ['yellow', 'blue', 'green', 'purple', 'orange', 'red'], + defaultValue: 'yellow', + }, + { + type: 'boolean', + prop: 'withPrefix', + label: 'Prefix Icon (左侧图标)', + defaultValue: true, + }, + { + type: 'boolean', + prop: 'withSuffix', + label: 'Suffix Icon (右侧图标)', + defaultValue: false, + }, + { + type: 'boolean', + prop: 'clearable', + label: 'Clearable (一键清空)', + defaultValue: true, + }, + { + type: 'boolean', + prop: 'showPasswordToggle', + label: 'Password Toggle (密码显隐)', + defaultValue: false, + }, + { + type: 'boolean', + prop: 'invalid', + label: 'Invalid / Error (错误态)', + defaultValue: false, + }, + ], + initialProps: { + value: 'Splatfest2026', + variant: 'field-cut', + size: 'default', + focusColor: 'yellow', + withPrefix: true, + withSuffix: false, + clearable: true, + showPasswordToggle: false, + invalid: false, + }, Component: InputExample, } diff --git a/apps/docs/src/docs/generated/api.json b/apps/docs/src/docs/generated/api.json index 074da45..6830ba1 100644 --- a/apps/docs/src/docs/generated/api.json +++ b/apps/docs/src/docs/generated/api.json @@ -2586,7 +2586,7 @@ "importPath": "splatoon-ui/input", "primaryExport": "Input", "sourcePath": "src/components/ui/input.tsx", - "boundary": "server", + "boundary": "client", "exports": [ { "name": "Input", @@ -2600,13 +2600,118 @@ "type": "React.Ref", "defaultValue": "", "description": "" + }, + { + "name": "variant", + "optional": true, + "type": "InputVariant", + "defaultValue": "", + "description": "Visual variant style. Defaults to 'field-cut' matching Splatoon 3 Lobby Terminal." + }, + { + "name": "size", + "optional": true, + "type": "InputSize", + "defaultValue": "", + "description": "Sizing tier. Defaults to 'default'." + }, + { + "name": "focusColor", + "optional": true, + "type": "InputColor", + "defaultValue": "", + "description": "Color theme for focus ring & accent highlights. Defaults to 'yellow'." + }, + { + "name": "prefixIcon", + "optional": true, + "type": "React.ReactNode", + "defaultValue": "", + "description": "Left slot for leading icon or label" + }, + { + "name": "suffixIcon", + "optional": true, + "type": "React.ReactNode", + "defaultValue": "", + "description": "Right slot for trailing icon" + }, + { + "name": "clearable", + "optional": true, + "type": "boolean", + "defaultValue": "", + "description": "Whether to show a clear button when input has value. Defaults to false." + }, + { + "name": "onClear", + "optional": true, + "type": "() => void", + "defaultValue": "", + "description": "Callback fired when clear button is clicked." + }, + { + "name": "showPasswordToggle", + "optional": true, + "type": "boolean", + "defaultValue": "", + "description": "For type=\"password\", automatically toggle password visibility with eye icon." + }, + { + "name": "invalid", + "optional": true, + "type": "boolean", + "defaultValue": "", + "description": "Whether the field is in an invalid/error state." + }, + { + "name": "errorMessage", + "optional": true, + "type": "React.ReactNode", + "defaultValue": "", + "description": "Error message displayed below input in an ink alert label." + }, + { + "name": "helperText", + "optional": true, + "type": "React.ReactNode", + "defaultValue": "", + "description": "Helper text displayed below input." + }, + { + "name": "containerClassName", + "optional": true, + "type": "string", + "defaultValue": "", + "description": "Container wrapper className if wrapper exists" } ] }, + { + "name": "InputColor", + "kind": "type", + "type": "export type InputColor = 'yellow' | 'blue' | 'green' | 'purple' | 'orange' | 'red'", + "description": "", + "props": [] + }, { "name": "InputProps", "kind": "interface", - "type": "export interface InputProps extends Omit, 'children' | 'ref'>", + "type": "export interface InputProps extends Omit, 'size' | 'ref'>", + "description": "", + "props": [] + }, + { + "name": "InputSize", + "kind": "type", + "type": "export type InputSize = 'sm' | 'default' | 'lg'", + "description": "", + "props": [] + }, + { + "name": "InputVariant", + "kind": "type", + "type": "export type InputVariant = 'field-cut'", "description": "", "props": [] } diff --git a/apps/docs/src/docs/generated/example-source.json b/apps/docs/src/docs/generated/example-source.json index 4a4c5a7..7c58fca 100644 --- a/apps/docs/src/docs/generated/example-source.json +++ b/apps/docs/src/docs/generated/example-source.json @@ -11,7 +11,7 @@ "dotted-divider": "import {\n DottedDivider,\n type DottedDividerProps,\n type SplatoonColorValue,\n} from 'splatoon-ui/dotted-divider'\n\ntype DottedDividerExampleProps = {\n orientation: DottedDividerProps['orientation']\n color: SplatoonColorValue\n}\n\nexport function DottedDividerExample({ orientation, color }: DottedDividerExampleProps) {\n return (\n \n \n \n )\n}", "heading-tape": "import { HeadingTape, type HeadingTapeProps } from 'splatoon-ui/heading-tape'\n\ntype HeadingTapeExampleProps = {\n size: HeadingTapeProps['size']\n decorationSet: NonNullable\n overlapTop: boolean\n label: string\n}\n\nexport function HeadingTapeExample({\n size,\n decorationSet,\n overlapTop,\n label,\n}: HeadingTapeExampleProps) {\n return (\n
\n \n {label}\n \n
\n )\n}", "icon-button": "import { Play } from 'lucide-react'\nimport {\n IconButton,\n type IconButtonAnimation,\n type IconButtonDirection,\n type IconButtonSize,\n type IconButtonVariant,\n} from 'splatoon-ui/icon-button'\n\ntype IconButtonExampleProps = {\n variant: IconButtonVariant\n size: IconButtonSize\n animation: IconButtonAnimation\n direction: IconButtonDirection\n}\n\nexport function IconButtonExample({ variant, size, animation, direction }: IconButtonExampleProps) {\n return (\n
\n \n }\n />\n
\n )\n}", - "input": "import { Input } from 'splatoon-ui/input'\n\ntype InputExampleProps = {\n value: string\n}\n\nexport function InputExample({ value }: InputExampleProps) {\n return (\n
\n \n \n
\n )\n}", + "input": "import { Input, type InputColor, type InputSize, type InputVariant } from 'splatoon-ui/input'\n\ntype InputExampleProps = {\n value: string\n variant: string\n size: string\n focusColor: string\n withPrefix: boolean\n withSuffix: boolean\n clearable: boolean\n invalid: boolean\n showPasswordToggle: boolean\n}\n\nexport function InputExample({\n value: initialValue,\n variant,\n size,\n focusColor,\n withPrefix,\n withSuffix,\n clearable,\n invalid,\n showPasswordToggle,\n}: InputExampleProps) {\n const [value, setValue] = React.useState(initialValue)\n\n React.useEffect(() => {\n setValue(initialValue)\n }, [initialValue])\n\n return (\n
\n \n : undefined}\n suffixIcon={withSuffix ? : undefined}\n clearable={clearable}\n showPasswordToggle={showPasswordToggle}\n type={showPasswordToggle ? 'password' : 'text'}\n invalid={invalid}\n errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined}\n helperText={!invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'}\n />\n
\n )\n}", "label": "import { Label } from 'splatoon-ui/label'\n\ntype LabelExampleProps = {\n label: string\n disabled: boolean\n}\n\nexport function LabelExample({ label, disabled }: LabelExampleProps) {\n return (\n
\n \n \n
\n )\n}", "list": "import { List, ListItem, type SplatoonColorValue } from 'splatoon-ui/list'\n\ntype ListExampleProps = {\n start: number\n markerColor: SplatoonColorValue\n}\n\nexport function ListExample({ start, markerColor }: ListExampleProps) {\n return (\n \n Pick a weapon role.\n Queue with the squad.\n Review the rotation.\n \n )\n}", "loader": "import { Loader, type LoaderAnimation, type LoaderProps } from 'splatoon-ui/loader'\n\ntype LoaderExampleProps = {\n variant: LoaderProps['variant']\n animation: LoaderAnimation\n size: string\n label: string\n}\n\nexport function LoaderExample({ variant, animation, size, label }: LoaderExampleProps) {\n return (\n
\n \n {label}\n
\n )\n}", diff --git a/packages/ui/src/components/ui/input.module.css b/packages/ui/src/components/ui/input.module.css new file mode 100644 index 0000000..d57532f --- /dev/null +++ b/packages/ui/src/components/ui/input.module.css @@ -0,0 +1,104 @@ +.inputRoot { + position: relative; + width: 100%; +} + +.inputField { + width: 100%; + min-width: 0; + transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), + border-color 0.15s ease, + background-color 0.15s ease; + outline: none; +} + +/* Authentic Splatoon 3 Lobby Terminal focus state with tactile press & solid shadow */ +.inputField:focus, +.inputField:focus-visible { + outline: none; + border-color: var(--color-chaos-black, #0D0D0D); + box-shadow: 0 0 0 3px var(--input-focus-color, var(--color-yellow, #EAFF3D)), 3px 3px 0px var(--color-chaos-black, #0D0D0D); + transform: translate(-1px, -1px); +} + +.invalid { + border-color: var(--color-red, #FF585E) !important; + animation: splat-shake 0.38s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; +} + +.invalid:focus, +.invalid:focus-visible { + box-shadow: 0 0 0 3px var(--color-red, #FF585E), 3px 3px 0px var(--color-chaos-black, #0D0D0D) !important; + transform: translate(-1px, -1px) !important; +} + +.clearBlobButton { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + background-color: var(--color-chaos-black, #0D0D0D); + color: #FFFFFF; + cursor: pointer; + transition: transform 0.15s ease, background-color 0.15s ease; + border: none; + padding: 0; +} + +.clearBlobButton:hover { + background-color: var(--color-red, #FF585E); + transform: scale(1.1) rotate(-8deg); +} + +.clearBlobButton:active { + transform: scale(0.92) rotate(4deg); +} + +.togglePasswordButton { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: inherit; + opacity: 0.65; + cursor: pointer; + transition: opacity 0.15s ease, transform 0.15s ease; + padding: 2px; +} + +.togglePasswordButton:hover { + opacity: 1; + transform: scale(1.1); +} + +.togglePasswordButton:active { + transform: scale(0.95); +} + +@keyframes splat-shake { + 10%, 90% { + transform: translate3d(-1px, 0, 0) rotate(-0.5deg); + } + 20%, 80% { + transform: translate3d(2px, 0, 0) rotate(0.5deg); + } + 30%, 50%, 70% { + transform: translate3d(-3px, 0, 0) rotate(-1deg); + } + 40%, 60% { + transform: translate3d(3px, 0, 0) rotate(1deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .invalid { + animation: none; + } + .inputField, + .clearBlobButton, + .togglePasswordButton { + transition: none; + } +} diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx index a820232..a0830db 100644 --- a/packages/ui/src/components/ui/input.tsx +++ b/packages/ui/src/components/ui/input.tsx @@ -1,23 +1,351 @@ +'use client' + import * as React from 'react' +import { Eye, EyeOff, X } from 'lucide-react' +import { composeRefs } from '@/lib/react-refs' +import { splatoonColorVars } from '@/lib/splatoon-color-tokens' import { cn } from '@/lib/utils' +import styles from './input.module.css' + +export type InputVariant = 'field-cut' +export type InputSize = 'sm' | 'default' | 'lg' +export type InputColor = 'yellow' | 'blue' | 'green' | 'purple' | 'orange' | 'red' -export interface InputProps extends Omit, 'children' | 'ref'> { +export interface InputProps extends Omit, 'size' | 'ref'> { ref?: React.Ref + /** Visual variant style. Defaults to 'field-cut' matching Splatoon 3 Lobby Terminal. */ + variant?: InputVariant + /** Sizing tier. Defaults to 'default'. */ + size?: InputSize + /** Color theme for focus ring & accent highlights. Defaults to 'yellow'. */ + focusColor?: InputColor + /** Left slot for leading icon or label */ + prefixIcon?: React.ReactNode + /** Right slot for trailing icon */ + suffixIcon?: React.ReactNode + /** Whether to show a clear button when input has value. Defaults to false. */ + clearable?: boolean + /** Callback fired when clear button is clicked. */ + onClear?: () => void + /** For type="password", automatically toggle password visibility with eye icon. */ + showPasswordToggle?: boolean + /** Whether the field is in an invalid/error state. */ + invalid?: boolean + /** Error message displayed below input in an ink alert label. */ + errorMessage?: React.ReactNode + /** Helper text displayed below input. */ + helperText?: React.ReactNode + /** Container wrapper className if wrapper exists */ + containerClassName?: string } -function Input({ ref, className, type, ...props }: InputProps) { +function Input({ + ref, + className, + containerClassName, + variant = 'field-cut', + size = 'default', + focusColor = 'yellow', + prefixIcon, + suffixIcon, + clearable = false, + onClear, + showPasswordToggle = false, + invalid = false, + errorMessage, + helperText, + type = 'text', + disabled, + readOnly, + id, + value, + defaultValue, + onChange, + style, + ...props +}: InputProps) { + const inputRef = React.useRef(null) + const combinedRef = React.useMemo(() => composeRefs(inputRef, ref), [ref]) + + // Track value for controlled / uncontrolled clearable button visibility + const isControlled = value !== undefined + const [internalValue, setInternalValue] = React.useState(() => + defaultValue !== undefined ? String(defaultValue) : '' + ) + const currentValue = isControlled ? String(value ?? '') : internalValue + const hasValue = currentValue.length > 0 + + // Password visibility toggle + const isPassword = type === 'password' + const [passwordVisible, setPasswordVisible] = React.useState(false) + const resolvedType = + isPassword && showPasswordToggle ? (passwordVisible ? 'text' : 'password') : type + + const handleClear = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (!inputRef.current) return + + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + + if (nativeSetter) { + nativeSetter.call(inputRef.current, '') + } else { + inputRef.current.value = '' + } + + const inputEvent = new Event('input', { bubbles: true }) + inputRef.current.dispatchEvent(inputEvent) + + const changeEvent = new Event('change', { bubbles: true }) + inputRef.current.dispatchEvent(changeEvent) + + if (!isControlled) { + setInternalValue('') + } + + onClear?.() + inputRef.current.focus() + }, + [isControlled, onClear] + ) + + const handleChange = (e: React.ChangeEvent) => { + if (!isControlled) { + setInternalValue(e.target.value) + } + onChange?.(e) + } + + // Accessible IDs + const generatedId = React.useId() + const inputId = id ?? generatedId + const errorId = `${inputId}-error` + const helperId = `${inputId}-helper` + const describedBy = + [ + invalid && errorMessage ? errorId : null, + helperText ? helperId : null, + props['aria-describedby'], + ] + .filter(Boolean) + .join(' ') || undefined + + // Variant Classes (Authentic Splatoon 3 Lobby Terminal Pool Keyword input) + const variantClasses: Record = { + 'field-cut': + 'field-cut border-[2.5px] border-chaos-black/40 bg-card text-foreground shadow-[2px_2px_0px_rgba(13,13,13,0.08)]', + } + + // Size Configuration & Strict Slot Bounds + const sizeClasses: Record< + InputSize, + { + input: string + prefix: string + suffix: string + iconBox: string + clearBtn: string + paddingLeft: string + plDefault: string + paddingRightSingle: string + paddingRightDouble: string + paddingRightTriple: string + prDefault: string + placeholderSize: string + } + > = { + sm: { + input: 'h-8 text-sm', + prefix: 'left-2.5', + suffix: 'right-2', + iconBox: 'size-4 [&_svg]:size-full [&_svg]:shrink-0', + clearBtn: 'size-4 [&_svg]:size-2.5', + paddingLeft: 'pl-9', + plDefault: 'pl-2.5', + paddingRightSingle: 'pr-8', + paddingRightDouble: 'pr-14', + paddingRightTriple: 'pr-20', + prDefault: 'pr-2.5', + placeholderSize: 'placeholder:text-xs', + }, + default: { + input: 'h-10 text-base md:text-sm', + prefix: 'left-3', + suffix: 'right-2.5', + iconBox: 'size-5 [&_svg]:size-full [&_svg]:shrink-0', + clearBtn: 'size-5 [&_svg]:size-3', + paddingLeft: 'pl-11', + plDefault: 'pl-3.5', + paddingRightSingle: 'pr-11', + paddingRightDouble: 'pr-18', + paddingRightTriple: 'pr-24', + prDefault: 'pr-3.5', + placeholderSize: 'placeholder:text-sm', + }, + lg: { + input: 'h-12 text-lg md:text-base', + prefix: 'left-3.5', + suffix: 'right-3', + iconBox: 'size-6 [&_svg]:size-full [&_svg]:shrink-0', + clearBtn: 'size-6 [&_svg]:size-3.5', + paddingLeft: 'pl-14', + plDefault: 'pl-4', + paddingRightSingle: 'pr-14', + paddingRightDouble: 'pr-22', + paddingRightTriple: 'pr-30', + prDefault: 'pr-4', + placeholderSize: 'placeholder:text-base', + }, + } + + const activeSize = sizeClasses[size] + + // Dynamic Horizontal Padding based on slots + const hasPrefix = Boolean(prefixIcon) + const showClear = clearable && hasValue && !disabled && !readOnly + const showToggle = isPassword && showPasswordToggle && !disabled + + let suffixItemCount = 0 + if (showClear) suffixItemCount++ + if (showToggle) suffixItemCount++ + if (suffixIcon) suffixItemCount++ + + // Independent left and right padding so tailwind-merge never collides + const paddingLeftClass = hasPrefix ? activeSize.paddingLeft : activeSize.plDefault + + const paddingRightClass = + suffixItemCount >= 3 + ? activeSize.paddingRightTriple + : suffixItemCount === 2 + ? activeSize.paddingRightDouble + : suffixItemCount === 1 + ? activeSize.paddingRightSingle + : activeSize.prDefault + + const focusColorValue = splatoonColorVars[focusColor] ?? 'var(--color-yellow)' + return ( - +
+ {hasPrefix && ( + + )} + + + + {suffixItemCount > 0 && ( +
+ {showClear && ( + + )} + + {showToggle && ( + + )} + + {suffixIcon && ( + + {suffixIcon} + + )} +
+ )} +
+ + {invalid && errorMessage && ( + + )} + + {!invalid && helperText && ( +

+ {helperText} +

)} - {...props} - /> + ) } diff --git a/packages/ui/src/lib/splatoon-color-tokens.ts b/packages/ui/src/lib/splatoon-color-tokens.ts index 6e02de1..9ace4d5 100644 --- a/packages/ui/src/lib/splatoon-color-tokens.ts +++ b/packages/ui/src/lib/splatoon-color-tokens.ts @@ -10,6 +10,8 @@ export type SplatoonColorToken = | 'black' | 'trueBlack' | 'salmonRunGreen' + | 'desertSand' + | 'tapeWhite' export const splatoonColorVars = { yellow: 'var(--color-yellow)', @@ -23,6 +25,8 @@ export const splatoonColorVars = { black: 'var(--color-black)', trueBlack: 'var(--color-true-black)', salmonRunGreen: 'var(--color-salmon-run-green)', + desertSand: 'var(--desert-sand)', + tapeWhite: 'var(--tape-white)', } as const satisfies Record export type SplatoonColorValue = SplatoonColorToken | (string & {}) diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 505269d..b1690fc 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -102,6 +102,8 @@ --color-chaos-black: #0d0d0d; --color-black: #0d0d0d; --color-white: #ffffff; + --color-desert-sand: var(--desert-sand); + --color-tape-white: var(--tape-white); --color-danger-surface-title: var(--danger-surface-title); --color-danger-surface-description: var(--danger-surface-description); --color-sidebar-ring: var(--sidebar-ring); From 1fd815f0c773c2f48da6e9c0b5b5dd0966cd8c2f Mon Sep 17 00:00:00 2001 From: Shangxin Date: Wed, 14 Oct 2026 11:17:14 +0000 Subject: [PATCH 2/3] chore(ci): fix prettier formatting and react compiler state update in docs --- apps/docs/content/docs/en/input.mdx | 10 ++--- apps/docs/content/docs/ja/input.mdx | 6 +-- apps/docs/content/docs/zh/input.mdx | 3 +- apps/docs/src/docs/examples/input.tsx | 15 ++++--- .../src/docs/generated/example-source.json | 2 +- .../ui/src/components/ui/input.module.css | 42 ++++++++++++------- packages/ui/src/components/ui/input.tsx | 20 ++++----- 7 files changed, 56 insertions(+), 42 deletions(-) diff --git a/apps/docs/content/docs/en/input.mdx b/apps/docs/content/docs/en/input.mdx index fbc8949..59d4e04 100644 --- a/apps/docs/content/docs/en/input.mdx +++ b/apps/docs/content/docs/en/input.mdx @@ -1,19 +1,19 @@ export const metadata = { title: 'Input', - description: 'Production-ready text field blending Splatoon street graffiti and in-game lobby terminal aesthetics.', + description: + 'Production-ready text field blending Splatoon street graffiti and in-game lobby terminal aesthetics.', category: 'forms', example: 'input', } ## Usage -`Input` is built for web forms and game UI interactions, faithfully capturing the authentic physical card styling of the *Splatoon 3* Lobby Terminal. It includes prefix/suffix icon slots, one-click clear, password reveal, and invalid shake feedback. +`Input` is built for web forms and game UI interactions, faithfully capturing the authentic physical card styling of the _Splatoon 3_ Lobby Terminal. It includes prefix/suffix icon slots, one-click clear, password reveal, and invalid shake feedback. ```tsx import { Input } from 'splatoon-ui/input' import { Search } from 'lucide-react' - - Note: The 4-digit Private Battle passcode uses segmented physical slots and is tracked separately as a dedicated `PinInput` component. diff --git a/apps/docs/content/docs/ja/input.mdx b/apps/docs/content/docs/ja/input.mdx index cbe6359..6c31936 100644 --- a/apps/docs/content/docs/ja/input.mdx +++ b/apps/docs/content/docs/ja/input.mdx @@ -1,6 +1,7 @@ export const metadata = { title: 'Input', - description: 'スプラトゥーンのストリートグラフィティとゲーム内ロビーターミナルを融合させたプロダクション対応テキスト入力フィールド。', + description: + 'スプラトゥーンのストリートグラフィティとゲーム内ロビーターミナルを融合させたプロダクション対応テキスト入力フィールド。', category: 'forms', example: 'input', } @@ -12,8 +13,7 @@ export const metadata = { ```tsx import { Input } from 'splatoon-ui/input' import { Search } from 'lucide-react' - - { + if (initialValue !== prevInitialValue) { + setPrevInitialValue(initialValue) setValue(initialValue) - }, [initialValue]) + } return (
@@ -47,13 +49,15 @@ export function InputExample({ size={size as InputSize} focusColor={focusColor as InputColor} prefixIcon={withPrefix ? : undefined} - suffixIcon={withSuffix ? : undefined} + suffixIcon={withSuffix ? : undefined} clearable={clearable} showPasswordToggle={showPasswordToggle} type={showPasswordToggle ? 'password' : 'text'} invalid={invalid} errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined} - helperText={!invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined} + helperText={ + !invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined + } value={value} onChange={(e) => setValue(e.target.value)} placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'} @@ -66,7 +70,8 @@ export function InputExample({ export const inputExample: DocsExampleDefinitionInput = { id: 'input', title: 'Input', - description: 'Production-ready text field with authentic Splatoon 3 Lobby Terminal field-cut styling.', + description: + 'Production-ready text field with authentic Splatoon 3 Lobby Terminal field-cut styling.', controls: [ { type: 'select', diff --git a/apps/docs/src/docs/generated/example-source.json b/apps/docs/src/docs/generated/example-source.json index 7c58fca..b542f05 100644 --- a/apps/docs/src/docs/generated/example-source.json +++ b/apps/docs/src/docs/generated/example-source.json @@ -11,7 +11,7 @@ "dotted-divider": "import {\n DottedDivider,\n type DottedDividerProps,\n type SplatoonColorValue,\n} from 'splatoon-ui/dotted-divider'\n\ntype DottedDividerExampleProps = {\n orientation: DottedDividerProps['orientation']\n color: SplatoonColorValue\n}\n\nexport function DottedDividerExample({ orientation, color }: DottedDividerExampleProps) {\n return (\n \n \n
\n )\n}", "heading-tape": "import { HeadingTape, type HeadingTapeProps } from 'splatoon-ui/heading-tape'\n\ntype HeadingTapeExampleProps = {\n size: HeadingTapeProps['size']\n decorationSet: NonNullable\n overlapTop: boolean\n label: string\n}\n\nexport function HeadingTapeExample({\n size,\n decorationSet,\n overlapTop,\n label,\n}: HeadingTapeExampleProps) {\n return (\n
\n \n {label}\n \n
\n )\n}", "icon-button": "import { Play } from 'lucide-react'\nimport {\n IconButton,\n type IconButtonAnimation,\n type IconButtonDirection,\n type IconButtonSize,\n type IconButtonVariant,\n} from 'splatoon-ui/icon-button'\n\ntype IconButtonExampleProps = {\n variant: IconButtonVariant\n size: IconButtonSize\n animation: IconButtonAnimation\n direction: IconButtonDirection\n}\n\nexport function IconButtonExample({ variant, size, animation, direction }: IconButtonExampleProps) {\n return (\n
\n \n }\n />\n
\n )\n}", - "input": "import { Input, type InputColor, type InputSize, type InputVariant } from 'splatoon-ui/input'\n\ntype InputExampleProps = {\n value: string\n variant: string\n size: string\n focusColor: string\n withPrefix: boolean\n withSuffix: boolean\n clearable: boolean\n invalid: boolean\n showPasswordToggle: boolean\n}\n\nexport function InputExample({\n value: initialValue,\n variant,\n size,\n focusColor,\n withPrefix,\n withSuffix,\n clearable,\n invalid,\n showPasswordToggle,\n}: InputExampleProps) {\n const [value, setValue] = React.useState(initialValue)\n\n React.useEffect(() => {\n setValue(initialValue)\n }, [initialValue])\n\n return (\n
\n \n : undefined}\n suffixIcon={withSuffix ? : undefined}\n clearable={clearable}\n showPasswordToggle={showPasswordToggle}\n type={showPasswordToggle ? 'password' : 'text'}\n invalid={invalid}\n errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined}\n helperText={!invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'}\n />\n
\n )\n}", + "input": "import { Input, type InputColor, type InputSize, type InputVariant } from 'splatoon-ui/input'\n\ntype InputExampleProps = {\n value: string\n variant: string\n size: string\n focusColor: string\n withPrefix: boolean\n withSuffix: boolean\n clearable: boolean\n invalid: boolean\n showPasswordToggle: boolean\n}\n\nexport function InputExample({\n value: initialValue,\n variant,\n size,\n focusColor,\n withPrefix,\n withSuffix,\n clearable,\n invalid,\n showPasswordToggle,\n}: InputExampleProps) {\n const [value, setValue] = React.useState(initialValue)\n const [prevInitialValue, setPrevInitialValue] = React.useState(initialValue)\n\n if (initialValue !== prevInitialValue) {\n setPrevInitialValue(initialValue)\n setValue(initialValue)\n }\n\n return (\n
\n \n : undefined}\n suffixIcon={withSuffix ? : undefined}\n clearable={clearable}\n showPasswordToggle={showPasswordToggle}\n type={showPasswordToggle ? 'password' : 'text'}\n invalid={invalid}\n errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined}\n helperText={\n !invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined\n }\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'}\n />\n
\n )\n}", "label": "import { Label } from 'splatoon-ui/label'\n\ntype LabelExampleProps = {\n label: string\n disabled: boolean\n}\n\nexport function LabelExample({ label, disabled }: LabelExampleProps) {\n return (\n
\n \n \n
\n )\n}", "list": "import { List, ListItem, type SplatoonColorValue } from 'splatoon-ui/list'\n\ntype ListExampleProps = {\n start: number\n markerColor: SplatoonColorValue\n}\n\nexport function ListExample({ start, markerColor }: ListExampleProps) {\n return (\n \n Pick a weapon role.\n Queue with the squad.\n Review the rotation.\n \n )\n}", "loader": "import { Loader, type LoaderAnimation, type LoaderProps } from 'splatoon-ui/loader'\n\ntype LoaderExampleProps = {\n variant: LoaderProps['variant']\n animation: LoaderAnimation\n size: string\n label: string\n}\n\nexport function LoaderExample({ variant, animation, size, label }: LoaderExampleProps) {\n return (\n
\n \n {label}\n
\n )\n}", diff --git a/packages/ui/src/components/ui/input.module.css b/packages/ui/src/components/ui/input.module.css index d57532f..6370ca0 100644 --- a/packages/ui/src/components/ui/input.module.css +++ b/packages/ui/src/components/ui/input.module.css @@ -6,7 +6,8 @@ .inputField { width: 100%; min-width: 0; - transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), + transition: + transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.15s ease, background-color 0.15s ease; @@ -17,19 +18,23 @@ .inputField:focus, .inputField:focus-visible { outline: none; - border-color: var(--color-chaos-black, #0D0D0D); - box-shadow: 0 0 0 3px var(--input-focus-color, var(--color-yellow, #EAFF3D)), 3px 3px 0px var(--color-chaos-black, #0D0D0D); + border-color: var(--color-chaos-black, #0d0d0d); + box-shadow: + 0 0 0 3px var(--input-focus-color, var(--color-yellow, #eaff3d)), + 3px 3px 0px var(--color-chaos-black, #0d0d0d); transform: translate(-1px, -1px); } .invalid { - border-color: var(--color-red, #FF585E) !important; + border-color: var(--color-red, #ff585e) !important; animation: splat-shake 0.38s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; } .invalid:focus, .invalid:focus-visible { - box-shadow: 0 0 0 3px var(--color-red, #FF585E), 3px 3px 0px var(--color-chaos-black, #0D0D0D) !important; + box-shadow: + 0 0 0 3px var(--color-red, #ff585e), + 3px 3px 0px var(--color-chaos-black, #0d0d0d) !important; transform: translate(-1px, -1px) !important; } @@ -38,16 +43,18 @@ align-items: center; justify-content: center; border-radius: 9999px; - background-color: var(--color-chaos-black, #0D0D0D); - color: #FFFFFF; + background-color: var(--color-chaos-black, #0d0d0d); + color: #ffffff; cursor: pointer; - transition: transform 0.15s ease, background-color 0.15s ease; + transition: + transform 0.15s ease, + background-color 0.15s ease; border: none; padding: 0; } .clearBlobButton:hover { - background-color: var(--color-red, #FF585E); + background-color: var(--color-red, #ff585e); transform: scale(1.1) rotate(-8deg); } @@ -64,7 +71,9 @@ color: inherit; opacity: 0.65; cursor: pointer; - transition: opacity 0.15s ease, transform 0.15s ease; + transition: + opacity 0.15s ease, + transform 0.15s ease; padding: 2px; } @@ -78,16 +87,21 @@ } @keyframes splat-shake { - 10%, 90% { + 10%, + 90% { transform: translate3d(-1px, 0, 0) rotate(-0.5deg); } - 20%, 80% { + 20%, + 80% { transform: translate3d(2px, 0, 0) rotate(0.5deg); } - 30%, 50%, 70% { + 30%, + 50%, + 70% { transform: translate3d(-3px, 0, 0) rotate(-1deg); } - 40%, 60% { + 40%, + 60% { transform: translate3d(3px, 0, 0) rotate(1deg); } } diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx index a0830db..ec452b2 100644 --- a/packages/ui/src/components/ui/input.tsx +++ b/packages/ui/src/components/ui/input.tsx @@ -237,7 +237,7 @@ function Input({ {hasPrefix && ( 0 && (
@@ -307,18 +307,14 @@ function Input({ aria-label={passwordVisible ? 'Hide password' : 'Show password'} className={cn(styles.togglePasswordButton, activeSize.iconBox)} > - {passwordVisible ? ( - - ) : ( - - )} + {passwordVisible ? : } )} {suffixIcon && ( @@ -333,15 +329,15 @@ function Input({ )} {!invalid && helperText && ( -

+

{helperText}

)} From c3db9904d2a74ca7fb160459b09567e81386e859 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Wed, 14 Oct 2026 11:20:13 +0000 Subject: [PATCH 3/3] fix(ui): preserve void primitive ref contract and server component compatibility for input --- apps/docs/src/docs/examples/input.tsx | 96 ++--- apps/docs/src/docs/generated/api.json | 91 +---- .../src/docs/generated/example-source.json | 2 +- .../ui/src/components/ui/input.module.css | 99 ++--- packages/ui/src/components/ui/input.tsx | 355 ++---------------- 5 files changed, 100 insertions(+), 543 deletions(-) diff --git a/apps/docs/src/docs/examples/input.tsx b/apps/docs/src/docs/examples/input.tsx index ce6b8ec..4aead1c 100644 --- a/apps/docs/src/docs/examples/input.tsx +++ b/apps/docs/src/docs/examples/input.tsx @@ -1,34 +1,27 @@ 'use client' import * as React from 'react' -import { Search, Sparkles } from 'lucide-react' import type { DocsExampleDefinitionInput } from '@/docs/types' // docs-source-start -import { Input, type InputColor, type InputSize, type InputVariant } from 'splatoon-ui/input' +import { Input, type InputFocusColor, type InputVariant } from 'splatoon-ui/input' type InputExampleProps = { value: string + placeholder: string variant: string - size: string focusColor: string - withPrefix: boolean - withSuffix: boolean - clearable: boolean + disabled: boolean invalid: boolean - showPasswordToggle: boolean } export function InputExample({ value: initialValue, + placeholder, variant, - size, focusColor, - withPrefix, - withSuffix, - clearable, + disabled, invalid, - showPasswordToggle, }: InputExampleProps) { const [value, setValue] = React.useState(initialValue) const [prevInitialValue, setPrevInitialValue] = React.useState(initialValue) @@ -41,26 +34,17 @@ export function InputExample({ return (
: undefined} - suffixIcon={withSuffix ? : undefined} - clearable={clearable} - showPasswordToggle={showPasswordToggle} - type={showPasswordToggle ? 'password' : 'text'} - invalid={invalid} - errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined} - helperText={ - !invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined - } + focusColor={focusColor as InputFocusColor} value={value} + placeholder={placeholder} + disabled={disabled} + aria-invalid={invalid} onChange={(e) => setValue(e.target.value)} - placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'} />
) @@ -71,69 +55,53 @@ export const inputExample: DocsExampleDefinitionInput = { id: 'input', title: 'Input', description: - 'Production-ready text field with authentic Splatoon 3 Lobby Terminal field-cut styling.', + 'Splatoon 3 Lobby Terminal keyword input with authentic 4-corner asymmetric cut, tactile displacement, and solid black shadow.', controls: [ + { + type: 'text', + prop: 'value', + label: 'Value', + defaultValue: 'SPLAT-2026', + }, + { + type: 'text', + prop: 'placeholder', + label: 'Placeholder', + defaultValue: 'Enter pool keyword...', + }, { type: 'select', prop: 'variant', - label: 'Variant (形态)', + label: 'Variant', options: ['field-cut'], defaultValue: 'field-cut', }, - { - type: 'select', - prop: 'size', - label: 'Size (尺寸)', - options: ['default', 'sm', 'lg'], - defaultValue: 'default', - }, { type: 'select', prop: 'focusColor', - label: 'Focus Color (聚焦色)', - options: ['yellow', 'blue', 'green', 'purple', 'orange', 'red'], + label: 'Focus Color', + options: ['yellow', 'blue', 'purple', 'pink', 'green', 'orange'], defaultValue: 'yellow', }, { type: 'boolean', - prop: 'withPrefix', - label: 'Prefix Icon (左侧图标)', - defaultValue: true, - }, - { - type: 'boolean', - prop: 'withSuffix', - label: 'Suffix Icon (右侧图标)', - defaultValue: false, - }, - { - type: 'boolean', - prop: 'clearable', - label: 'Clearable (一键清空)', - defaultValue: true, - }, - { - type: 'boolean', - prop: 'showPasswordToggle', - label: 'Password Toggle (密码显隐)', + prop: 'disabled', + label: 'Disabled', defaultValue: false, }, { type: 'boolean', prop: 'invalid', - label: 'Invalid / Error (错误态)', + label: 'Invalid', defaultValue: false, }, ], initialProps: { - value: 'Splatfest2026', + value: 'SPLAT-2026', + placeholder: 'Enter pool keyword...', variant: 'field-cut', - size: 'default', focusColor: 'yellow', - withPrefix: true, - withSuffix: false, - clearable: true, - showPasswordToggle: false, + disabled: false, invalid: false, }, Component: InputExample, diff --git a/apps/docs/src/docs/generated/api.json b/apps/docs/src/docs/generated/api.json index 6830ba1..bd2181c 100644 --- a/apps/docs/src/docs/generated/api.json +++ b/apps/docs/src/docs/generated/api.json @@ -2586,7 +2586,7 @@ "importPath": "splatoon-ui/input", "primaryExport": "Input", "sourcePath": "src/components/ui/input.tsx", - "boundary": "client", + "boundary": "server", "exports": [ { "name": "Input", @@ -2606,105 +2606,28 @@ "optional": true, "type": "InputVariant", "defaultValue": "", - "description": "Visual variant style. Defaults to 'field-cut' matching Splatoon 3 Lobby Terminal." - }, - { - "name": "size", - "optional": true, - "type": "InputSize", - "defaultValue": "", - "description": "Sizing tier. Defaults to 'default'." + "description": "" }, { "name": "focusColor", "optional": true, - "type": "InputColor", - "defaultValue": "", - "description": "Color theme for focus ring & accent highlights. Defaults to 'yellow'." - }, - { - "name": "prefixIcon", - "optional": true, - "type": "React.ReactNode", - "defaultValue": "", - "description": "Left slot for leading icon or label" - }, - { - "name": "suffixIcon", - "optional": true, - "type": "React.ReactNode", - "defaultValue": "", - "description": "Right slot for trailing icon" - }, - { - "name": "clearable", - "optional": true, - "type": "boolean", - "defaultValue": "", - "description": "Whether to show a clear button when input has value. Defaults to false." - }, - { - "name": "onClear", - "optional": true, - "type": "() => void", - "defaultValue": "", - "description": "Callback fired when clear button is clicked." - }, - { - "name": "showPasswordToggle", - "optional": true, - "type": "boolean", - "defaultValue": "", - "description": "For type=\"password\", automatically toggle password visibility with eye icon." - }, - { - "name": "invalid", - "optional": true, - "type": "boolean", + "type": "InputFocusColor", "defaultValue": "", - "description": "Whether the field is in an invalid/error state." - }, - { - "name": "errorMessage", - "optional": true, - "type": "React.ReactNode", - "defaultValue": "", - "description": "Error message displayed below input in an ink alert label." - }, - { - "name": "helperText", - "optional": true, - "type": "React.ReactNode", - "defaultValue": "", - "description": "Helper text displayed below input." - }, - { - "name": "containerClassName", - "optional": true, - "type": "string", - "defaultValue": "", - "description": "Container wrapper className if wrapper exists" + "description": "" } ] }, { - "name": "InputColor", + "name": "InputFocusColor", "kind": "type", - "type": "export type InputColor = 'yellow' | 'blue' | 'green' | 'purple' | 'orange' | 'red'", + "type": "export type InputFocusColor = 'yellow' | 'blue' | 'purple' | 'pink' | 'green' | 'orange'", "description": "", "props": [] }, { "name": "InputProps", "kind": "interface", - "type": "export interface InputProps extends Omit, 'size' | 'ref'>", - "description": "", - "props": [] - }, - { - "name": "InputSize", - "kind": "type", - "type": "export type InputSize = 'sm' | 'default' | 'lg'", + "type": "export interface InputProps extends Omit, 'children' | 'ref'>", "description": "", "props": [] }, diff --git a/apps/docs/src/docs/generated/example-source.json b/apps/docs/src/docs/generated/example-source.json index b542f05..6334d3e 100644 --- a/apps/docs/src/docs/generated/example-source.json +++ b/apps/docs/src/docs/generated/example-source.json @@ -11,7 +11,7 @@ "dotted-divider": "import {\n DottedDivider,\n type DottedDividerProps,\n type SplatoonColorValue,\n} from 'splatoon-ui/dotted-divider'\n\ntype DottedDividerExampleProps = {\n orientation: DottedDividerProps['orientation']\n color: SplatoonColorValue\n}\n\nexport function DottedDividerExample({ orientation, color }: DottedDividerExampleProps) {\n return (\n \n \n
\n )\n}", "heading-tape": "import { HeadingTape, type HeadingTapeProps } from 'splatoon-ui/heading-tape'\n\ntype HeadingTapeExampleProps = {\n size: HeadingTapeProps['size']\n decorationSet: NonNullable\n overlapTop: boolean\n label: string\n}\n\nexport function HeadingTapeExample({\n size,\n decorationSet,\n overlapTop,\n label,\n}: HeadingTapeExampleProps) {\n return (\n
\n \n {label}\n \n
\n )\n}", "icon-button": "import { Play } from 'lucide-react'\nimport {\n IconButton,\n type IconButtonAnimation,\n type IconButtonDirection,\n type IconButtonSize,\n type IconButtonVariant,\n} from 'splatoon-ui/icon-button'\n\ntype IconButtonExampleProps = {\n variant: IconButtonVariant\n size: IconButtonSize\n animation: IconButtonAnimation\n direction: IconButtonDirection\n}\n\nexport function IconButtonExample({ variant, size, animation, direction }: IconButtonExampleProps) {\n return (\n
\n \n }\n />\n
\n )\n}", - "input": "import { Input, type InputColor, type InputSize, type InputVariant } from 'splatoon-ui/input'\n\ntype InputExampleProps = {\n value: string\n variant: string\n size: string\n focusColor: string\n withPrefix: boolean\n withSuffix: boolean\n clearable: boolean\n invalid: boolean\n showPasswordToggle: boolean\n}\n\nexport function InputExample({\n value: initialValue,\n variant,\n size,\n focusColor,\n withPrefix,\n withSuffix,\n clearable,\n invalid,\n showPasswordToggle,\n}: InputExampleProps) {\n const [value, setValue] = React.useState(initialValue)\n const [prevInitialValue, setPrevInitialValue] = React.useState(initialValue)\n\n if (initialValue !== prevInitialValue) {\n setPrevInitialValue(initialValue)\n setValue(initialValue)\n }\n\n return (\n
\n \n : undefined}\n suffixIcon={withSuffix ? : undefined}\n clearable={clearable}\n showPasswordToggle={showPasswordToggle}\n type={showPasswordToggle ? 'password' : 'text'}\n invalid={invalid}\n errorMessage={invalid ? 'Invalid keyword: special characters are not allowed!' : undefined}\n helperText={\n !invalid ? 'Supports Japanese, English, and alphanumeric characters.' : undefined\n }\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={showPasswordToggle ? 'Enter 4-digit code' : 'Enter search term...'}\n />\n
\n )\n}", + "input": "import { Input, type InputFocusColor, type InputVariant } from 'splatoon-ui/input'\n\ntype InputExampleProps = {\n value: string\n placeholder: string\n variant: string\n focusColor: string\n disabled: boolean\n invalid: boolean\n}\n\nexport function InputExample({\n value: initialValue,\n placeholder,\n variant,\n focusColor,\n disabled,\n invalid,\n}: InputExampleProps) {\n const [value, setValue] = React.useState(initialValue)\n const [prevInitialValue, setPrevInitialValue] = React.useState(initialValue)\n\n if (initialValue !== prevInitialValue) {\n setPrevInitialValue(initialValue)\n setValue(initialValue)\n }\n\n return (\n
\n \n setValue(e.target.value)}\n />\n
\n )\n}", "label": "import { Label } from 'splatoon-ui/label'\n\ntype LabelExampleProps = {\n label: string\n disabled: boolean\n}\n\nexport function LabelExample({ label, disabled }: LabelExampleProps) {\n return (\n
\n \n \n
\n )\n}", "list": "import { List, ListItem, type SplatoonColorValue } from 'splatoon-ui/list'\n\ntype ListExampleProps = {\n start: number\n markerColor: SplatoonColorValue\n}\n\nexport function ListExample({ start, markerColor }: ListExampleProps) {\n return (\n \n Pick a weapon role.\n Queue with the squad.\n Review the rotation.\n \n )\n}", "loader": "import { Loader, type LoaderAnimation, type LoaderProps } from 'splatoon-ui/loader'\n\ntype LoaderExampleProps = {\n variant: LoaderProps['variant']\n animation: LoaderAnimation\n size: string\n label: string\n}\n\nexport function LoaderExample({ variant, animation, size, label }: LoaderExampleProps) {\n return (\n
\n \n {label}\n
\n )\n}", diff --git a/packages/ui/src/components/ui/input.module.css b/packages/ui/src/components/ui/input.module.css index 6370ca0..5154e4f 100644 --- a/packages/ui/src/components/ui/input.module.css +++ b/packages/ui/src/components/ui/input.module.css @@ -1,20 +1,37 @@ -.inputRoot { - position: relative; - width: 100%; -} - .inputField { + appearance: none; width: 100%; min-width: 0; + height: 2.75rem; + border-radius: 7px 11px 8px 12px; + background-color: var(--desert-sand, #f4eee0); + color: var(--color-chaos-black, #0d0d0d); + border: 2.5px solid var(--color-chaos-black, #0d0d0d); + box-shadow: 2.5px 2.5px 0px var(--color-chaos-black, #0d0d0d); + padding: 0.5rem 0.875rem; + font-family: inherit; + font-size: 0.95rem; + font-weight: 700; + outline: none; transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.15s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.15s ease, background-color 0.15s ease; - outline: none; } -/* Authentic Splatoon 3 Lobby Terminal focus state with tactile press & solid shadow */ +.inputField::placeholder { + color: #8c857b; + font-weight: 500; +} + +.inputField:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Authentic Splatoon 3 Lobby Terminal focus state with tactile displacement & solid shadow */ .inputField:focus, .inputField:focus-visible { outline: none; @@ -25,65 +42,18 @@ transform: translate(-1px, -1px); } -.invalid { - border-color: var(--color-red, #ff585e) !important; +.inputField[aria-invalid='true'], +.inputField:invalid { + border-color: var(--color-red, #ff585e); animation: splat-shake 0.38s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; } -.invalid:focus, -.invalid:focus-visible { +.inputField[aria-invalid='true']:focus, +.inputField:invalid:focus { box-shadow: 0 0 0 3px var(--color-red, #ff585e), - 3px 3px 0px var(--color-chaos-black, #0d0d0d) !important; - transform: translate(-1px, -1px) !important; -} - -.clearBlobButton { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 9999px; - background-color: var(--color-chaos-black, #0d0d0d); - color: #ffffff; - cursor: pointer; - transition: - transform 0.15s ease, - background-color 0.15s ease; - border: none; - padding: 0; -} - -.clearBlobButton:hover { - background-color: var(--color-red, #ff585e); - transform: scale(1.1) rotate(-8deg); -} - -.clearBlobButton:active { - transform: scale(0.92) rotate(4deg); -} - -.togglePasswordButton { - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: inherit; - opacity: 0.65; - cursor: pointer; - transition: - opacity 0.15s ease, - transform 0.15s ease; - padding: 2px; -} - -.togglePasswordButton:hover { - opacity: 1; - transform: scale(1.1); -} - -.togglePasswordButton:active { - transform: scale(0.95); + 3px 3px 0px var(--color-chaos-black, #0d0d0d); + transform: translate(-1px, -1px); } @keyframes splat-shake { @@ -107,12 +77,11 @@ } @media (prefers-reduced-motion: reduce) { - .invalid { + .inputField[aria-invalid='true'], + .inputField:invalid { animation: none; } - .inputField, - .clearBlobButton, - .togglePasswordButton { + .inputField { transition: none; } } diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx index ec452b2..7e851c1 100644 --- a/packages/ui/src/components/ui/input.tsx +++ b/packages/ui/src/components/ui/input.tsx @@ -1,347 +1,44 @@ -'use client' - import * as React from 'react' -import { Eye, EyeOff, X } from 'lucide-react' -import { composeRefs } from '@/lib/react-refs' -import { splatoonColorVars } from '@/lib/splatoon-color-tokens' import { cn } from '@/lib/utils' import styles from './input.module.css' export type InputVariant = 'field-cut' -export type InputSize = 'sm' | 'default' | 'lg' -export type InputColor = 'yellow' | 'blue' | 'green' | 'purple' | 'orange' | 'red' +export type InputFocusColor = 'yellow' | 'blue' | 'purple' | 'pink' | 'green' | 'orange' -export interface InputProps extends Omit, 'size' | 'ref'> { +export interface InputProps extends Omit, 'children' | 'ref'> { ref?: React.Ref - /** Visual variant style. Defaults to 'field-cut' matching Splatoon 3 Lobby Terminal. */ variant?: InputVariant - /** Sizing tier. Defaults to 'default'. */ - size?: InputSize - /** Color theme for focus ring & accent highlights. Defaults to 'yellow'. */ - focusColor?: InputColor - /** Left slot for leading icon or label */ - prefixIcon?: React.ReactNode - /** Right slot for trailing icon */ - suffixIcon?: React.ReactNode - /** Whether to show a clear button when input has value. Defaults to false. */ - clearable?: boolean - /** Callback fired when clear button is clicked. */ - onClear?: () => void - /** For type="password", automatically toggle password visibility with eye icon. */ - showPasswordToggle?: boolean - /** Whether the field is in an invalid/error state. */ - invalid?: boolean - /** Error message displayed below input in an ink alert label. */ - errorMessage?: React.ReactNode - /** Helper text displayed below input. */ - helperText?: React.ReactNode - /** Container wrapper className if wrapper exists */ - containerClassName?: string + focusColor?: InputFocusColor } -function Input({ - ref, - className, - containerClassName, - variant = 'field-cut', - size = 'default', - focusColor = 'yellow', - prefixIcon, - suffixIcon, - clearable = false, - onClear, - showPasswordToggle = false, - invalid = false, - errorMessage, - helperText, - type = 'text', - disabled, - readOnly, - id, - value, - defaultValue, - onChange, - style, - ...props -}: InputProps) { - const inputRef = React.useRef(null) - const combinedRef = React.useMemo(() => composeRefs(inputRef, ref), [ref]) - - // Track value for controlled / uncontrolled clearable button visibility - const isControlled = value !== undefined - const [internalValue, setInternalValue] = React.useState(() => - defaultValue !== undefined ? String(defaultValue) : '' - ) - const currentValue = isControlled ? String(value ?? '') : internalValue - const hasValue = currentValue.length > 0 - - // Password visibility toggle - const isPassword = type === 'password' - const [passwordVisible, setPasswordVisible] = React.useState(false) - const resolvedType = - isPassword && showPasswordToggle ? (passwordVisible ? 'text' : 'password') : type - - const handleClear = React.useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - if (!inputRef.current) return - - const nativeSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - - if (nativeSetter) { - nativeSetter.call(inputRef.current, '') - } else { - inputRef.current.value = '' - } - - const inputEvent = new Event('input', { bubbles: true }) - inputRef.current.dispatchEvent(inputEvent) - - const changeEvent = new Event('change', { bubbles: true }) - inputRef.current.dispatchEvent(changeEvent) - - if (!isControlled) { - setInternalValue('') - } - - onClear?.() - inputRef.current.focus() - }, - [isControlled, onClear] - ) - - const handleChange = (e: React.ChangeEvent) => { - if (!isControlled) { - setInternalValue(e.target.value) - } - onChange?.(e) - } - - // Accessible IDs - const generatedId = React.useId() - const inputId = id ?? generatedId - const errorId = `${inputId}-error` - const helperId = `${inputId}-helper` - const describedBy = - [ - invalid && errorMessage ? errorId : null, - helperText ? helperId : null, - props['aria-describedby'], - ] - .filter(Boolean) - .join(' ') || undefined - - // Variant Classes (Authentic Splatoon 3 Lobby Terminal Pool Keyword input) - const variantClasses: Record = { - 'field-cut': - 'field-cut border-[2.5px] border-chaos-black/40 bg-card text-foreground shadow-[2px_2px_0px_rgba(13,13,13,0.08)]', - } - - // Size Configuration & Strict Slot Bounds - const sizeClasses: Record< - InputSize, - { - input: string - prefix: string - suffix: string - iconBox: string - clearBtn: string - paddingLeft: string - plDefault: string - paddingRightSingle: string - paddingRightDouble: string - paddingRightTriple: string - prDefault: string - placeholderSize: string - } - > = { - sm: { - input: 'h-8 text-sm', - prefix: 'left-2.5', - suffix: 'right-2', - iconBox: 'size-4 [&_svg]:size-full [&_svg]:shrink-0', - clearBtn: 'size-4 [&_svg]:size-2.5', - paddingLeft: 'pl-9', - plDefault: 'pl-2.5', - paddingRightSingle: 'pr-8', - paddingRightDouble: 'pr-14', - paddingRightTriple: 'pr-20', - prDefault: 'pr-2.5', - placeholderSize: 'placeholder:text-xs', - }, - default: { - input: 'h-10 text-base md:text-sm', - prefix: 'left-3', - suffix: 'right-2.5', - iconBox: 'size-5 [&_svg]:size-full [&_svg]:shrink-0', - clearBtn: 'size-5 [&_svg]:size-3', - paddingLeft: 'pl-11', - plDefault: 'pl-3.5', - paddingRightSingle: 'pr-11', - paddingRightDouble: 'pr-18', - paddingRightTriple: 'pr-24', - prDefault: 'pr-3.5', - placeholderSize: 'placeholder:text-sm', - }, - lg: { - input: 'h-12 text-lg md:text-base', - prefix: 'left-3.5', - suffix: 'right-3', - iconBox: 'size-6 [&_svg]:size-full [&_svg]:shrink-0', - clearBtn: 'size-6 [&_svg]:size-3.5', - paddingLeft: 'pl-14', - plDefault: 'pl-4', - paddingRightSingle: 'pr-14', - paddingRightDouble: 'pr-22', - paddingRightTriple: 'pr-30', - prDefault: 'pr-4', - placeholderSize: 'placeholder:text-base', - }, - } - - const activeSize = sizeClasses[size] - - // Dynamic Horizontal Padding based on slots - const hasPrefix = Boolean(prefixIcon) - const showClear = clearable && hasValue && !disabled && !readOnly - const showToggle = isPassword && showPasswordToggle && !disabled - - let suffixItemCount = 0 - if (showClear) suffixItemCount++ - if (showToggle) suffixItemCount++ - if (suffixIcon) suffixItemCount++ - - // Independent left and right padding so tailwind-merge never collides - const paddingLeftClass = hasPrefix ? activeSize.paddingLeft : activeSize.plDefault +const focusColorMap: Record = { + yellow: 'var(--color-yellow, #eaff3d)', + blue: 'var(--color-blue, #2b3bf6)', + purple: 'var(--color-purple, #6a0dad)', + pink: 'var(--color-pink, #ff007f)', + green: 'var(--color-green, #00e676)', + orange: 'var(--color-orange, #ff6600)', +} - const paddingRightClass = - suffixItemCount >= 3 - ? activeSize.paddingRightTriple - : suffixItemCount === 2 - ? activeSize.paddingRightDouble - : suffixItemCount === 1 - ? activeSize.paddingRightSingle - : activeSize.prDefault +function Input({ ref, className, type, ...props }: InputProps) { + const { variant = 'field-cut', focusColor = 'yellow', style, ...rest } = props - const focusColorValue = splatoonColorVars[focusColor] ?? 'var(--color-yellow)' + const inlineStyle: React.CSSProperties = { + '--input-focus-color': focusColorMap[focusColor] ?? focusColor, + ...style, + } as React.CSSProperties return ( -
-
- {hasPrefix && ( - - )} - - - - {suffixItemCount > 0 && ( -
- {showClear && ( - - )} - - {showToggle && ( - - )} - - {suffixIcon && ( - - {suffixIcon} - - )} -
- )} -
- - {invalid && errorMessage && ( - - )} - - {!invalid && helperText && ( -

- {helperText} -

- )} -
+ ) }