diff --git a/Projects/DVDesign/Resources/Assets.xcassets/color/required.colorset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/color/required.colorset/Contents.json new file mode 100644 index 0000000..418b727 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/color/required.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x35", + "green" : "0x35", + "red" : "0xC8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x35", + "green" : "0x35", + "red" : "0xC8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index 513ee43..9371f62 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -60,6 +60,16 @@ struct ContentView: View { TextFieldPreviewView() case "DVTextContainer": TextContainerPreviewView() + case "DVChip": + DVChipPreviewView() + case "DVMultilineTextField": + DVMultilineTextFieldPreviewView() + case "DVDropdown": + DVDropdownPreviewView() + case "DVLabeledField": + DVLabeledFieldPreviewView() + case "DVChipsField": + DVChipsFieldPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -103,7 +113,11 @@ private struct Component: Hashable { Component(name: "DVRadioButtonGroup"), Component(name: "DVTextContainer"), Component(name: "DVTextField"), + Component(name: "DVChip"), + Component(name: "DVMultilineTextField"), + Component(name: "DVDropdown"), + Component(name: "DVLabeledField"), + Component(name: "DVChipsField"), // Component(name: "DVInputField"), -// Component(name: "DVDropDown"), ] } diff --git a/Projects/DVDesign/SampleApp/Sources/DVChipPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVChipPreviewView.swift new file mode 100644 index 0000000..e54dd60 --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/DVChipPreviewView.swift @@ -0,0 +1,68 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI +import DVDesign + +struct DVChipPreviewView: View { + @State private var chips: [String] = ["GitHub", "NameNameName", "OpenAI"] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + section(title: "기본") { + labeled("단일 chip") { + DVChip("GitHub") + } + labeled("여러 chip (수평 배치)") { + HStack(spacing: 10) { + DVChip("GitHub") + DVChip("OpenAI") + DVChip("Anthropic") + DVChip("LongLongName") + } + } + } + + section(title: "인터랙션 — 클릭 시 제거") { + labeled("각 chip 클릭 시 목록에서 제거됨") { + HStack(spacing: 10) { + ForEach(chips, id: \.self) { chip in + DVChip(chip) { + chips.removeAll { $0 == chip } + } + } + } + } + Button("초기화") { + chips = ["GitHub", "NameNameName", "OpenAI"] + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("DVChip") + } + + @ViewBuilder + private func section( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2).fontWeight(.bold) + content() + } + } + + @ViewBuilder + private func labeled( + _ caption: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(caption).font(.caption).foregroundStyle(.secondary) + content() + } + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/DVChipsFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVChipsFieldPreviewView.swift new file mode 100644 index 0000000..d5d6843 --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/DVChipsFieldPreviewView.swift @@ -0,0 +1,104 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI +import DVDesign + +struct DVChipsFieldPreviewView: View { + @State private var empty: [String] = [] + @State private var emptyInput = "" + + @State private var few: [String] = ["GitHub"] + @State private var fewInput = "" + + @State private var many: [String] = [ + "GitHub", "OpenAI", "Anthropic", "AWS", "Slack", "Notion", "Linear", + "Stripe", "Vercel", "Supabase", "LongLongLongService" + ] + @State private var manyInput = "" + + @State private var detected: [String] = [] + @State private var detectedInput = "" + @State private var lastEvent = "이벤트 없음" + + @State private var xs: [String] = ["A", "B"] + @State private var sm: [String] = ["GitHub", "OpenAI"] + @State private var md: [String] = ["Stripe", "Vercel", "Supabase"] + @State private var lg: [String] = ["GitHub", "OpenAI", "Anthropic", "AWS", "Slack", "Notion", "Linear", "Stripe"] + @State private var xsInput = "" + @State private var smInput = "" + @State private var mdInput = "" + @State private var lgInput = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + section(title: "기본") { + labeled("Empty — chip 없음") { + DVChipsField("e.g GitHub", chips: empty, input: $emptyInput, size: .sm) + } + labeled("Chip 1개") { + DVChipsField("추가 입력", chips: few, input: $fewInput, size: .sm) + } + labeled("여러 개 — 세로 wrap 확인") { + DVChipsField("추가 입력", chips: many, input: $manyInput, size: .sm) + } + } + + section(title: "인터랙션") { + labeled("Chip 클릭 → 텍스트 입력창에 세팅 + 원본은 시각적으로만 숨김. 다른 chip 클릭 시 이전 것 복귀") { + DVChipsField( + "chip 클릭해보기", + chips: ["GitHub", "OpenAI", "Anthropic"], + input: $detectedInput, + size: .sm, + onTap: { chip in lastEvent = "tap — \(chip)" } + ) + } + Text("마지막 이벤트: \(lastEvent)") + .font(.caption) + .foregroundStyle(.secondary) + } + + section(title: "사이즈 (DVComponentSize 전 케이스)") { + labeled("XS (180pt)") { + DVChipsField("XS", chips: xs, input: $xsInput, size: .xs) + } + labeled("SM (330pt)") { + DVChipsField("SM", chips: sm, input: $smInput, size: .sm) + } + labeled("MD (380pt)") { + DVChipsField("MD", chips: md, input: $mdInput, size: .md) + } + labeled("LG (700pt)") { + DVChipsField("LG", chips: lg, input: $lgInput, size: .lg) + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("DVChipsField") + } + + @ViewBuilder + private func section( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2).fontWeight(.bold) + content() + } + } + + @ViewBuilder + private func labeled( + _ caption: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(caption).font(.caption).foregroundStyle(.secondary) + content() + } + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/DVDropdownPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVDropdownPreviewView.swift new file mode 100644 index 0000000..e6faf18 --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/DVDropdownPreviewView.swift @@ -0,0 +1,104 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI +import DVDesign + +struct DVDropdownPreviewView: View { + @State private var project: String? = nil + @State private var env: String? = "Staging" + @State private var xs: String? = nil + @State private var md: String? = nil + @State private var lg: String? = nil + + private let projects = ["CheerLot", "DrinkiG", "LongLongNameExample"] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + section(title: "기본") { + labeled("Placeholder — 아직 선택 안 함") { + DVDropdown(project ?? "Select Project", size: .sm) { + ForEach(projects, id: \.self) { name in + Button(name) { project = name } + } + Divider() + Button { + // add new project + } label: { + Label("Add New Project", systemImage: "plus") + } + } + } + labeled("Selected — 값 있음") { + DVDropdown(env ?? "Select Env", size: .sm) { + Button("Dev") { env = "Dev" } + Button("Staging") { env = "Staging" } + Button("Prod") { env = "Prod" } + } + } + } + + section(title: "사이즈 (DVComponentSize 전 케이스)") { + labeled("XS (180pt)") { + DVDropdown(xs ?? "XS", size: .xs) { + Button("Option 1") { xs = "Option 1" } + Button("Option 2") { xs = "Option 2" } + } + } + labeled("SM (330pt)") { + DVDropdown(project ?? "Select Project", size: .sm) { + ForEach(projects, id: \.self) { name in + Button(name) { project = name } + } + } + } + labeled("MD (380pt)") { + DVDropdown(md ?? "Select Region", size: .md) { + Button("us-east-1") { md = "us-east-1" } + Button("ap-northeast-2") { md = "ap-northeast-2" } + } + } + labeled("LG (700pt)") { + DVDropdown(lg ?? "Select Very Long Value", size: .lg) { + Button("A") { lg = "Option A" } + Button("B") { lg = "Option B" } + } + } + } + + section(title: "긴 텍스트 truncation") { + labeled("SM 사이즈 + 긴 프로젝트명 → 뒤가 잘림 (tail truncation)") { + DVDropdown("SuperLongProjectNameThatExceedsWidth", size: .sm) { + Button("A") {} + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("DVDropdown") + } + + @ViewBuilder + private func section( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2).fontWeight(.bold) + content() + } + } + + @ViewBuilder + private func labeled( + _ caption: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(caption).font(.caption).foregroundStyle(.secondary) + content() + } + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/DVLabeledFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVLabeledFieldPreviewView.swift new file mode 100644 index 0000000..71886ba --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/DVLabeledFieldPreviewView.swift @@ -0,0 +1,138 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI +import DVDesign + +struct DVLabeledFieldPreviewView: View { + @State private var name = "" + @State private var value = "ghp_1234567890abcdef" + @State private var memo = "" + @State private var project: String? = nil + @State private var envContent = "" + @State private var xs = "" + @State private var sm = "" + @State private var md = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + section(title: "상태") { + labeled("힌트 없음") { + DVLabeledField("Memo", size: .lg) { + DVTextField("optional", text: $memo, size: .lg) + } + } + labeled("필수 (*)") { + DVLabeledField("Name", isRequired: true, size: .lg) { + DVTextField("e.g DeVault", text: $name, size: .lg) + } + } + labeled("Detected 힌트 (초록)") { + DVLabeledField( + "Value", + isRequired: true, + trailingHint: .detected("Auto-detected: GitHub"), + size: .lg + ) { + DVTextField("secret value", text: $value, size: .lg) + } + } + labeled("Warning 힌트 (빨강)") { + DVLabeledField( + "Name", + isRequired: true, + trailingHint: .warning("필수 항목입니다"), + size: .lg + ) { + DVTextField("e.g DeVault", text: $name, size: .lg) + } + } + } + + section(title: "다양한 input 조합") { + labeled("DVDropdown 감싸기") { + DVLabeledField("Project", size: .sm) { + DVDropdown(project ?? "Select Project", size: .sm) { + Button("CheerLot") { project = "CheerLot" } + Button("DrinkiG") { project = "DrinkiG" } + } + } + } + labeled("DVMultilineTextField 감싸기") { + DVLabeledField( + "Env Content", + isRequired: true, + trailingHint: .detected("Auto-detected: .env format"), + size: .lg + ) { + DVMultilineTextField( + "e.g DATABASE_URL=postgres://...", + text: $envContent, + size: .lg, + height: 120 + ) + } + } + } + + section(title: "사이즈 (DVComponentSize 전 케이스)") { + DVLabeledField("XS", isRequired: true, size: .xs) { + DVTextField("XS", text: $xs, size: .xs) + } + DVLabeledField("SM", isRequired: true, size: .sm) { + DVTextField("SM", text: $sm, size: .sm) + } + DVLabeledField("MD", isRequired: true, trailingHint: .detected("hint"), size: .md) { + DVTextField("MD", text: $md, size: .md) + } + DVLabeledField( + "LG", + isRequired: true, + trailingHint: .detected("Auto-detected: GitHub"), + size: .lg + ) { + DVTextField("LG", text: $value, size: .lg) + } + } + + section(title: "긴 hint truncation") { + labeled("SM 사이즈 + 긴 warning → tail truncation") { + DVLabeledField( + "Value", + isRequired: true, + trailingHint: .warning("This is a really really really long warning message that should truncate"), + size: .sm + ) { + DVTextField("SM", text: $sm, size: .sm) + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("DVLabeledField") + } + + @ViewBuilder + private func section( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2).fontWeight(.bold) + content() + } + } + + @ViewBuilder + private func labeled( + _ caption: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(caption).font(.caption).foregroundStyle(.secondary) + content() + } + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/DVMultilineTextFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVMultilineTextFieldPreviewView.swift new file mode 100644 index 0000000..f37dfc0 --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/DVMultilineTextFieldPreviewView.swift @@ -0,0 +1,96 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI +import DVDesign + +struct DVMultilineTextFieldPreviewView: View { + @State private var emptyValue = "" + @State private var envSet = """ + DATABASE_URL=postgres://user:pass@localhost:5432/mydb + OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + SECRET_KEY=abc123 + """ + @State private var jsonValue = "" + @State private var xsValue = "" + @State private var smValue = "" + @State private var mdValue = "" + @State private var lgValue = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + section(title: "상태") { + labeled("Empty — placeholder 오버레이") { + DVMultilineTextField( + "e.g DATABASE_URL=postgres://...", + text: $emptyValue, + size: .lg + ) + } + labeled("Active — 여러 줄 입력값") { + DVMultilineTextField( + "e.g DATABASE_URL=...", + text: $envSet, + size: .lg + ) + } + } + + section(title: "사이즈 (DVComponentSize 전 케이스)") { + labeled("XS (180pt)") { + DVMultilineTextField("XS", text: $xsValue, size: .xs, height: 80) + } + labeled("SM (330pt)") { + DVMultilineTextField("SM", text: $smValue, size: .sm, height: 80) + } + labeled("MD (380pt)") { + DVMultilineTextField("MD", text: $mdValue, size: .md, height: 80) + } + labeled("LG (700pt) — 기본") { + DVMultilineTextField( + "e.g Service Account JSON", + text: $lgValue, + size: .lg + ) + } + } + + section(title: "min height 확장") { + labeled("minHeight 200 — 여유 공간이 필요할 때") { + DVMultilineTextField( + "긴 SSH private key 붙여넣기", + text: $jsonValue, + size: .lg, + height: 200 + ) + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("DVMultilineTextField") + } + + @ViewBuilder + private func section( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2).fontWeight(.bold) + content() + } + } + + @ViewBuilder + private func labeled( + _ caption: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(caption).font(.caption).foregroundStyle(.secondary) + content() + } + } +} diff --git a/Projects/DVDesign/SampleApp/Sources/TextFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/TextFieldPreviewView.swift index ae0d620..f7b65ad 100644 --- a/Projects/DVDesign/SampleApp/Sources/TextFieldPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/TextFieldPreviewView.swift @@ -10,6 +10,9 @@ struct TextFieldPreviewView: View { @State private var sm = "" @State private var md = "DeVault" @State private var lg = "" + @State private var secureEmpty = "" + @State private var secureFilled = "ghp_1234567890abcdef" + @State private var secureSm = "sk_test_abc123" var body: some View { ScrollView { @@ -32,6 +35,18 @@ struct TextFieldPreviewView: View { labeled("MD (380pt)") { DVTextField("MD", text: $md, size: .md) } labeled("LG (700pt)") { DVTextField("LG", text: $lg, size: .lg) } } + + section(title: "Secure (민감 값 마스킹)") { + labeled("빈 상태") { + DVTextField("secret value", text: $secureEmpty, size: .lg, isSecure: true) + } + labeled("값 있음 — 눈 아이콘 클릭 시 토글") { + DVTextField("secret value", text: $secureFilled, size: .lg, isSecure: true) + } + labeled("SM 사이즈") { + DVTextField("token", text: $secureSm, size: .sm, isSecure: true) + } + } } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Projects/DVDesign/Sources/Components/DVChip.swift b/Projects/DVDesign/Sources/Components/DVChip.swift new file mode 100644 index 0000000..91287fb --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVChip.swift @@ -0,0 +1,58 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// 태그/토큰 표시용 pill 컴포넌트. +/// +/// 높이 24pt, ``DVColor/vaultGreenTint`` 배경. 클릭 시 ``action`` 클로저가 +/// 호출되며 chip 자체는 상태를 갖지 않습니다. +public struct DVChip: View { + + // MARK: - Properties + + private let text: String + private let action: () -> Void + + // MARK: - Init + + public init( + _ text: String, + action: @escaping () -> Void = {} + ) { + self.text = text + self.action = action + } + + // MARK: - Body + + public var body: some View { + Button { + action() + } label: { + Text(text) + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.gray900)) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 16) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.dv(.vaultGreenTint)) + ) + .contentShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Previews + +#Preview("Chip") { + HStack(spacing: 10) { + DVChip("GitHub") + DVChip("NameNameName") + DVChip("OpenAI") + } + .padding() +} diff --git a/Projects/DVDesign/Sources/Components/DVChipsField.swift b/Projects/DVDesign/Sources/Components/DVChipsField.swift new file mode 100644 index 0000000..639d647 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVChipsField.swift @@ -0,0 +1,243 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// Chip 기반 multi-tag 표시 필드. ``DVTextField``와 ``DVChip`` 배열을 +/// 묶은 composite 컴포넌트로, 컨테이너 너비를 넘으면 chip이 자동으로 wrap. +/// +/// Chip 클릭 시 텍스트가 input에 세팅되며 input과 정확히 일치하는 chip만 +/// 시각적으로 숨겨집니다. 다른 chip을 클릭하거나 input을 수정하면 원래 chip이 +/// 다시 나타납니다. +/// +/// Chip 추가/삭제는 이 컴포넌트가 하지 않고 외부에서 `chips` 배열을 관리하는 +/// 방식을 전제합니다. +public struct DVChipsField: View { + + // MARK: - Properties + + private let placeholder: String + private let chips: [String] + @Binding private var input: String + private let size: DVComponentSize + private let onTap: (String) -> Void + + // MARK: - Init + + /// Chips 필드를 생성합니다. + /// + /// - Parameters: + /// - placeholder: 입력 필드가 비어 있을 때 표시할 안내 문구. + /// - chips: 표시할 chip 배열. 외부(예: 감지 엔진)가 관리. + /// - input: 현재 입력 중인 텍스트 바인딩. chip 클릭 시 이 값이 chip 텍스트로 세팅됨. + /// - size: 너비 변형. 기본값 ``DVComponentSize/sm`` (330pt). + /// - onTap: chip 클릭 시 호출되는 side effect 콜백. 기본 no-op. + public init( + _ placeholder: String, + chips: [String], + input: Binding, + size: DVComponentSize = .sm, + onTap: @escaping (String) -> Void = { _ in } + ) { + self.placeholder = placeholder + self.chips = chips + self._input = input + self.size = size + self.onTap = onTap + } + + // MARK: - Body + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + DVTextField(placeholder, text: $input, size: size) + + if !visibleChips.isEmpty { + DVChipFlow(hSpacing: 10, vSpacing: 10) { + ForEach(visibleChips, id: \.self) { chip in + DVChip(chip) { handleTapChip(chip) } + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.85)), + removal: .opacity + ) + ) + } + } + .frame(width: size.width, alignment: .leading) + } + } + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: visibleChips) + } + + /// input과 정확히 일치하는 chip은 편집 대상으로 간주하여 목록에서 숨김. + /// 다른 chip을 클릭하거나 input이 바뀌면 자동으로 다시 나타남. + private var visibleChips: [String] { + chips.filter { $0 != input } + } +} + +// MARK: - Behavior + +extension DVChipsField { + + /// Chip 클릭 시: input에 chip 텍스트를 넣음. + /// 원본 배열은 건드리지 않고, input과 일치하는 동안 시각적으로만 숨겨짐. + private func handleTapChip(_ chip: String) { + input = chip + onTap(chip) + } +} + +// MARK: - Layout + +/// Chips wrapping flow layout — 가로로 배치하다가 너비를 넘으면 다음 줄로 wrap. +/// +/// 각 chip 크기를 캐시해 `sizeThatFits`/`placeSubviews` 간 중복 측정을 피하고, +/// 두 함수 모두 동일한 `arrangement(...)` 헬퍼로 배치 계산을 위임한다. +private struct DVChipFlow: Layout { + + struct Cache { + var proposedWidth: CGFloat? + var sizes: [CGSize] = [] + } + + let hSpacing: CGFloat + let vSpacing: CGFloat + + init(hSpacing: CGFloat = 10, vSpacing: CGFloat = 10) { + self.hSpacing = hSpacing + self.vSpacing = vSpacing + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + guard !subviews.isEmpty else { return .zero } + let maxWidth = proposal.width ?? .infinity + let sizes = ensureSizes(for: subviews, width: maxWidth, cache: &cache) + return arrangement(sizes: sizes, maxWidth: maxWidth).bounds + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let sizes = ensureSizes(for: subviews, width: bounds.width, cache: &cache) + let positions = arrangement(sizes: sizes, maxWidth: bounds.width).positions + let chipProposal = ProposedViewSize(width: bounds.width, height: nil) + + for (index, subview) in subviews.enumerated() { + let position = positions[index] + subview.place( + at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), + proposal: chipProposal + ) + } + } + + /// 각 subview에 컨테이너 너비를 propose 한 결과를 캐시. 동일한 width에 대한 + /// 반복 호출 시 재계산 생략. + private func ensureSizes( + for subviews: Subviews, + width: CGFloat, + cache: inout Cache + ) -> [CGSize] { + if cache.proposedWidth != width || cache.sizes.count != subviews.count { + let proposal = ProposedViewSize(width: width, height: nil) + cache.sizes = subviews.map { $0.sizeThatFits(proposal) } + cache.proposedWidth = width + } + return cache.sizes + } + + /// 한 번의 라인 브레이크 계산으로 각 chip의 상대 위치와 전체 바운딩 사이즈를 반환. + /// `sizeThatFits`와 `placeSubviews`가 동일한 결과를 참조하도록 단일 소스로 둔다. + private func arrangement( + sizes: [CGSize], + maxWidth: CGFloat + ) -> (positions: [CGPoint], bounds: CGSize) { + var positions: [CGPoint] = [] + positions.reserveCapacity(sizes.count) + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for (index, size) in sizes.enumerated() { + if index > 0 && currentX + size.width > maxWidth { + currentY += lineHeight + vSpacing + currentX = 0 + lineHeight = 0 + } + positions.append(CGPoint(x: currentX, y: currentY)) + currentX += size.width + lineHeight = max(lineHeight, size.height) + totalWidth = max(totalWidth, currentX) + if index < sizes.count - 1 { + currentX += hSpacing + } + } + return (positions, CGSize(width: totalWidth, height: currentY + lineHeight)) + } +} + +// MARK: - Previews + +#Preview("Empty") { + DVChipsFieldEmptyPreview() + .padding() +} + +#Preview("With chips (wrapping)") { + DVChipsFieldWithChipsPreview() + .padding() +} + +#Preview("Sizes") { + DVChipsFieldSizesPreview() + .padding() +} + +private struct DVChipsFieldEmptyPreview: View { + @State private var chips: [String] = [] + @State private var input = "" + var body: some View { + DVChipsField("e.g GitHub", chips: chips, input: $input, size: .sm) + } +} + +private struct DVChipsFieldWithChipsPreview: View { + @State private var chips: [String] = [ + "GitHub", "OpenAI", "Anthropic", "AWS", "Slack", "Notion", "Linear", "LongLongName" + ] + @State private var input = "" + var body: some View { + DVChipsField("추가 서비스 입력", chips: chips, input: $input, size: .sm) + } +} + +private struct DVChipsFieldSizesPreview: View { + @State private var xs: [String] = ["A", "B"] + @State private var sm: [String] = ["GitHub", "OpenAI"] + @State private var md: [String] = ["Stripe", "Vercel", "Supabase"] + @State private var lg: [String] = ["GitHub", "OpenAI", "Anthropic", "AWS", "Slack"] + @State private var xsInput = "" + @State private var smInput = "" + @State private var mdInput = "" + @State private var lgInput = "" + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + DVChipsField("XS", chips: xs, input: $xsInput, size: .xs) + DVChipsField("SM", chips: sm, input: $smInput, size: .sm) + DVChipsField("MD", chips: md, input: $mdInput, size: .md) + DVChipsField("LG", chips: lg, input: $lgInput, size: .lg) + } + } +} diff --git a/Projects/DVDesign/Sources/Components/DVDropdown.swift b/Projects/DVDesign/Sources/Components/DVDropdown.swift new file mode 100644 index 0000000..81ea6cc --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVDropdown.swift @@ -0,0 +1,138 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// 읽기 전용 트리거 필드 + macOS 시스템 Menu 팝오버. +/// +/// 트리거만 디자인 토큰으로 스타일링하고, 팝오버 렌더링·정렬·하이라이트· +/// 키보드 네비게이션은 시스템 `Menu`에 위임합니다. 팝오버 내용은 caller가 +/// `@ViewBuilder`로 자유롭게 제공. +public struct DVDropdown: View { + + // MARK: - Properties + + private let text: String + private let size: DVComponentSize + private let content: () -> Content + + // MARK: - Init + + /// 드롭다운을 생성합니다. + /// + /// - Parameters: + /// - text: 트리거에 표시할 현재 값 또는 placeholder (예: "Select Project"). + /// - size: 너비 변형. 기본값 ``DVComponentSize/sm`` (330pt). + /// - content: 시스템 `Menu`가 표시할 항목들 (Button, Divider 등). + public init( + _ text: String, + size: DVComponentSize = .sm, + @ViewBuilder content: @escaping () -> Content + ) { + self.text = text + self.size = size + self.content = content + } + + // MARK: - Body + + public var body: some View { + // .menuIndicator(.hidden): 시스템 기본 chevron 숨김 + // .menuOrder(.fixed): 팝오버가 위/아래 어느 방향으로 열려도 항목 순서 유지 + // → "Add New Project" 같은 항목이 항상 마지막에 오도록 보장 + // .buttonStyle(.plain): 시스템 기본 버튼 chrome 제거 (커스텀 label 그대로 렌더링) + Menu { + content() + } label: { + triggerLabel + } + .menuIndicator(.hidden) + .menuOrder(.fixed) + .buttonStyle(.plain) + .fixedSize() + } +} + +// MARK: - Subviews + +extension DVDropdown { + + private var triggerLabel: some View { + HStack(spacing: 0) { + Text(text) + .dvFont(.bodyLG) + .foregroundStyle(Color.dv(.gray900)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + chevron + } + .padding(.leading, 8) + .padding(.trailing, 4) + .frame(width: size.width, height: 28) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.dv(.gray300)) + ) + } + + private var chevron: some View { + Image(systemName: "chevron.down") + .font(DVFont.captionMDSemibold.font) + .foregroundStyle(Color.black.opacity(0.85)) + .frame(width: 24, height: 24) + } +} + +// MARK: - Previews + +#Preview("Placeholder") { + DVDropdownPlaceholderPreview() + .padding() +} + +#Preview("Selected") { + DVDropdownSelectedPreview() + .padding() +} + +#Preview("Sizes") { + DVDropdownSizesPreview() + .padding() +} + +private struct DVDropdownPlaceholderPreview: View { + var body: some View { + DVDropdown("Select Project", size: .sm) { + Button("CheerLot") {} + Button("DrinkiG") {} + Divider() + Button { + } label: { + Label("Add New Project", systemImage: "plus") + } + } + } +} + +private struct DVDropdownSelectedPreview: View { + @State private var selected: String? = "DrinkiG" + + var body: some View { + DVDropdown(selected ?? "Select Project", size: .sm) { + Button("CheerLot") { selected = "CheerLot" } + Button("DrinkiG") { selected = "DrinkiG" } + Button("Example") { selected = "Example" } + } + } +} + +private struct DVDropdownSizesPreview: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + DVDropdown("XS", size: .xs) { Button("A") {} } + DVDropdown("SM", size: .sm) { Button("A") {} } + DVDropdown("MD", size: .md) { Button("A") {} } + DVDropdown("LG", size: .lg) { Button("A") {} } + } + } +} diff --git a/Projects/DVDesign/Sources/Components/DVLabeledField.swift b/Projects/DVDesign/Sources/Components/DVLabeledField.swift new file mode 100644 index 0000000..d29911c --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVLabeledField.swift @@ -0,0 +1,194 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// 폼 필드 래퍼 — Label + `(*)` 필수 표시 + 입력 슬롯 + 우측 hint. +/// +/// 입력 컨트롤은 caller가 `@ViewBuilder`로 자유롭게 지정. 우측 hint는 +/// 감지 결과(초록, ``TrailingHint/detected(_:)``) 또는 validation 경고(빨강, +/// ``TrailingHint/warning(_:)``)를 표시합니다. +/// +/// 내부 입력 뷰와 ``DVComponentSize``를 일치시켜야 라벨 로우가 정렬됩니다. +/// +/// > TODO(size-env): 현재는 caller가 wrapper와 내부 input에 동일한 `size`를 +/// > 각각 전달해야 함. 향후 `EnvironmentValues.dvComponentSize`를 도입해 +/// > wrapper가 자식에게 자동 전파하는 방식으로 개선 여지 있음. +public struct DVLabeledField: View { + + // MARK: - Types + + /// 라벨 로우 우측에 표시되는 힌트. + public enum TrailingHint: Equatable { + /// 자동 감지 결과 표시 (초록). 예: `"Auto-detected: GitHub"` + case detected(String) + + /// Validation 실패 등 경고 (빨강). 예: `"필수 항목입니다"` + case warning(String) + } + + // MARK: - Properties + + private let label: String + private let isRequired: Bool + private let trailingHint: TrailingHint? + private let size: DVComponentSize + private let content: () -> Content + + // MARK: - Init + + /// 라벨 래퍼를 생성합니다. + /// + /// - Parameters: + /// - label: 표시할 라벨 문자열 (예: "Name"). + /// - isRequired: 라벨 뒤에 `(*)` 필수 표시를 붙일지 여부. 기본 `false`. + /// - trailingHint: 라벨 로우 우측에 표시할 힌트. 기본 `nil`. + /// - size: 너비 변형. 내부 입력 뷰와 동일 사이즈로 넘겨야 정렬이 맞습니다. + /// 기본값 ``DVComponentSize/md``. + /// - content: 라벨 아래 배치할 입력 컨트롤. + public init( + _ label: String, + isRequired: Bool = false, + trailingHint: TrailingHint? = nil, + size: DVComponentSize = .md, + @ViewBuilder content: @escaping () -> Content + ) { + self.label = label + self.isRequired = isRequired + self.trailingHint = trailingHint + self.size = size + self.content = content + } + + // MARK: - Body + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + labelRow + content() + } + .frame(width: size.width, alignment: .leading) + } +} + +// MARK: - Subviews + +extension DVLabeledField { + + private var labelRow: some View { + HStack(spacing: 0) { + Text(label) + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.gray700)) + if isRequired { + Text("(*)") + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.required)) + } + Spacer(minLength: 8) + hintView + } + } + + @ViewBuilder + private var hintView: some View { + switch trailingHint { + case .none: + EmptyView() + case .detected(let text): + Text(text) + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.vaultGreen)) + .lineLimit(1) + .truncationMode(.tail) + case .warning(let text): + Text(text) + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.required)) + .lineLimit(1) + .truncationMode(.tail) + } + } +} + +// MARK: - Previews + +#Preview("Basic (no hint)") { + DVLabeledFieldBasicPreview() + .padding() +} + +#Preview("Detected hint") { + DVLabeledFieldDetectedPreview() + .padding() +} + +#Preview("Warning hint") { + DVLabeledFieldWarningPreview() + .padding() +} + +#Preview("Sizes") { + DVLabeledFieldSizesPreview() + .padding() +} + +private struct DVLabeledFieldBasicPreview: View { + @State private var text = "" + var body: some View { + DVLabeledField("Memo", size: .lg) { + DVTextField("optional", text: $text, size: .lg) + } + } +} + +private struct DVLabeledFieldDetectedPreview: View { + @State private var text = "ghp_xxxxxxxx" + var body: some View { + DVLabeledField( + "Value", + isRequired: true, + trailingHint: .detected("Auto-detected: GitHub"), + size: .lg + ) { + DVTextField("secret value", text: $text, size: .lg) + } + } +} + +private struct DVLabeledFieldWarningPreview: View { + @State private var text = "" + var body: some View { + DVLabeledField( + "Name", + isRequired: true, + trailingHint: .warning("필수 항목입니다"), + size: .lg + ) { + DVTextField("e.g DeVault", text: $text, size: .lg) + } + } +} + +private struct DVLabeledFieldSizesPreview: View { + @State private var xs = "" + @State private var sm = "" + @State private var md = "" + @State private var lg = "" + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + DVLabeledField("XS", isRequired: true, size: .xs) { + DVTextField("XS", text: $xs, size: .xs) + } + DVLabeledField("SM", isRequired: true, size: .sm) { + DVTextField("SM", text: $sm, size: .sm) + } + DVLabeledField("MD", isRequired: true, trailingHint: .detected("hint"), size: .md) { + DVTextField("MD", text: $md, size: .md) + } + DVLabeledField("LG", isRequired: true, trailingHint: .detected("Auto-detected: GitHub"), size: .lg) { + DVTextField("LG", text: $lg, size: .lg) + } + } + } +} diff --git a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift new file mode 100644 index 0000000..bf83acf --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift @@ -0,0 +1,131 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// 여러 줄 입력 필드. ``DVTextField``의 세로 확장 버전. +/// +/// 너비는 ``DVComponentSize``, 높이는 `height` 파라미터로 고정. +/// 내용이 초과하면 내부에서 세로 스크롤됩니다. +public struct DVMultilineTextField: View { + + // MARK: - Properties + + private let placeholder: String + @Binding private var text: String + private let size: DVComponentSize + private let height: CGFloat + + // MARK: - Init + + /// 여러 줄 텍스트 필드를 생성합니다. + /// + /// - Parameters: + /// - placeholder: 입력값이 비어 있을 때 좌측 상단에 표시될 안내 문구. + /// - text: 입력 값에 대한 양방향 바인딩. + /// - size: 너비 변형. 기본값 ``DVComponentSize/lg`` (700pt). + /// - height: 고정 높이. 기본값 100pt. 내용이 이 높이를 초과하면 내부에서 세로 스크롤. + public init( + _ placeholder: String, + text: Binding, + size: DVComponentSize = .lg, + height: CGFloat = 100 + ) { + self.placeholder = placeholder + self._text = text + self.size = size + self.height = height + } + + // MARK: - Body + + public var body: some View { + ZStack(alignment: .topLeading) { + editor + if text.isEmpty { + placeholderText + } + } + .padding(.horizontal, 4) + .padding(.vertical, 4) + .frame(width: size.width, height: height, alignment: .topLeading) + .background { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.dv(.gray300), lineWidth: 1) + } + } +} + +// MARK: - Subviews + +extension DVMultilineTextField { + + private static let placeholderLeadingInset: CGFloat = 6 + + private var editor: some View { + TextEditor(text: $text) + .textEditorStyle(.plain) + .font(DVFont.bodyLG.font) + .foregroundStyle(Color.dv(.gray900)) + .tint(Color.dv(.vaultGreen)) + .scrollContentBackground(.hidden) + } + + private var placeholderText: some View { + Text(placeholder) + .font(DVFont.bodyLG.font) + .foregroundStyle(Color.dv(.gray400)) + .padding(.leading, Self.placeholderLeadingInset) + .allowsHitTesting(false) + } +} + +// MARK: - Previews + +#Preview("Empty (Placeholder)") { + DVMultilineTextFieldEmptyPreview() + .padding() +} + +#Preview("Filled") { + DVMultilineTextFieldFilledPreview() + .padding() +} + +#Preview("Sizes") { + DVMultilineTextFieldSizesPreview() + .padding() +} + +private struct DVMultilineTextFieldEmptyPreview: View { + @State private var text = "" + var body: some View { + DVMultilineTextField("e.g DATABASE_URL=...", text: $text, size: .lg) + } +} + +private struct DVMultilineTextFieldFilledPreview: View { + @State private var text = """ + DATABASE_URL=postgres://user:pass@localhost:5432/mydb + OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + SECRET_KEY=abc123 + """ + var body: some View { + DVMultilineTextField("e.g DATABASE_URL=...", text: $text, size: .lg) + } +} + +private struct DVMultilineTextFieldSizesPreview: View { + @State private var xs = "" + @State private var sm = "" + @State private var md = "" + @State private var lg = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + DVMultilineTextField("XS", text: $xs, size: .xs, height: 80) + DVMultilineTextField("SM", text: $sm, size: .sm, height: 80) + DVMultilineTextField("MD", text: $md, size: .md, height: 80) + DVMultilineTextField("LG", text: $lg, size: .lg, height: 100) + } + } +} diff --git a/Projects/DVDesign/Sources/Components/DVTextContainer.swift b/Projects/DVDesign/Sources/Components/DVTextContainer.swift index 3607bc5..aa5cda7 100644 --- a/Projects/DVDesign/Sources/Components/DVTextContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVTextContainer.swift @@ -93,6 +93,7 @@ import SwiftUI public struct DVTextContainer: View { private let text: String private let size: DVComponentSize + private let textColor: DVColor private let accessories: () -> Accessories /// 우측 padding (포인트). 액세서리가 있을 땐 4pt(아이콘이 약간 안쪽으로 들어오는 디자인), 액세서리 없는 단순 텍스트 컨테이너는 좌우 대칭의 8pt를 적용한다. private let trailingPadding: CGFloat @@ -102,15 +103,19 @@ public struct DVTextContainer: View { /// - Parameters: /// - text: 박스 좌측에 표시될 본문 텍스트. 빈 문자열을 전달하면 Empty 상태가 됩니다. /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - textColor: 본문 텍스트 색상 토큰. 비활성 상태 등에서 다른 색을 쓰고 싶을 때 지정. + /// 기본값 ``DVColor/gray900``. /// - accessories: 박스 우측에 배치될 임의의 뷰를 만드는 빌더. /// 아이콘 모음, 토글 버튼 등. 버튼이 외부 `@State`를 갱신하면 자연스럽게 `text` 인자가 다음 렌더에서 바뀌어 텍스트 표시도 함께 갱신됩니다. public init( _ text: String, size: DVComponentSize = .md, + textColor: DVColor = .gray900, @ViewBuilder accessories: @escaping () -> Accessories ) { self.text = text self.size = size + self.textColor = textColor self.accessories = accessories self.trailingPadding = 4 } @@ -122,7 +127,7 @@ public struct DVTextContainer: View { ScrollView(.horizontal, showsIndicators: false) { Text(text) .font(DVFont.bodyLG.font) - .foregroundStyle(Color.dv(.gray900)) + .foregroundStyle(Color.dv(textColor)) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .textSelection(.enabled) @@ -148,12 +153,15 @@ extension DVTextContainer where Accessories == EmptyView { /// - Parameters: /// - text: 박스에 표시될 본문 텍스트. /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - textColor: 본문 텍스트 색상 토큰. 기본값 ``DVColor/gray900``. public init( _ text: String, - size: DVComponentSize = .md + size: DVComponentSize = .md, + textColor: DVColor = .gray900 ) { self.text = text self.size = size + self.textColor = textColor self.accessories = { EmptyView() } self.trailingPadding = 8 } @@ -184,14 +192,16 @@ extension DVTextContainer where Accessories == AnyView { /// - text: 마스킹 대상 원본 텍스트. /// - isRevealed: 마스킹 해제 여부에 대한 양방향 바인딩. 토글 버튼이 이 값을 갱신하며, 호출자도 자유롭게 외부에서 수정 가능합니다. /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - textColor: 본문 텍스트 색상 토큰. 기본값 ``DVColor/gray900``. public init( secured text: String, isRevealed: Binding, - size: DVComponentSize = .md + size: DVComponentSize = .md, + textColor: DVColor = .gray900 ) { let revealed = isRevealed.wrappedValue let displayed = revealed ? text : String(repeating: "•", count: text.count) - self.init(displayed, size: size) { + self.init(displayed, size: size, textColor: textColor) { AnyView( Button { isRevealed.wrappedValue.toggle() @@ -220,13 +230,15 @@ extension DVTextContainer where Accessories == AnyView { /// - Parameters: /// - text: 박스에 표시될 본문 텍스트. (호출자가 실제 클립보드에 쓸 문자열은 `onCopy` 내부에서 결정합니다 — text 인자와 다를 수 있음.) /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - textColor: 본문 텍스트 색상 토큰. 기본값 ``DVColor/gray900``. /// - onCopy: 복사 버튼 탭 시 실행될 클로저. public init( copyable text: String, size: DVComponentSize = .md, + textColor: DVColor = .gray900, onCopy: @escaping () -> Void ) { - self.init(text, size: size) { + self.init(text, size: size, textColor: textColor) { AnyView( Button(action: onCopy) { Image(systemName: "doc.on.doc") @@ -257,15 +269,17 @@ extension DVTextContainer where Accessories == AnyView { /// - Parameters: /// - text: 박스에 표시될 본문 텍스트. /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - textColor: 본문 텍스트 색상 토큰. 기본값 ``DVColor/gray900``. /// - onTap: 액세서리 버튼 탭 시 실행될 클로저. /// - icon: 액세서리 자리에 표시될 아이콘 뷰를 만드는 빌더. public init( _ text: String, size: DVComponentSize = .md, + textColor: DVColor = .gray900, onTap: @escaping () -> Void, @ViewBuilder icon: @escaping () -> Icon ) { - self.init(text, size: size) { + self.init(text, size: size, textColor: textColor) { AnyView( Button(action: onTap) { icon() diff --git a/Projects/DVDesign/Sources/Components/DVTextField.swift b/Projects/DVDesign/Sources/Components/DVTextField.swift index ac820c2..ed81369 100644 --- a/Projects/DVDesign/Sources/Components/DVTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVTextField.swift @@ -1,73 +1,54 @@ // Copyright © 2026 Devault. All rights reserved +import AppKit import SwiftUI -/// Devault 디자인 시스템 스타일이 입혀진 텍스트 입력 필드. +/// 시스템 `TextField` 위에 디자인 토큰만 덧입힌 텍스트 입력 필드. +/// 너비는 ``DVComponentSize``, 높이 28pt 고정. /// -/// `DVTextField`는 SwiftUI의 시스템 `TextField`를 그대로 사용하면서 디자인 -/// 토큰(`DVColor`, ``DVComponentSize``)으로 시각 속성만 덧입힙니다. -/// 입력 동작·접근성·국제화·키보드 동작은 모두 시스템 컨트롤이 제공하는 것을 그대로 따릅니다. -/// -/// ## 시각 상태 -/// -/// Figma의 4가지 variant는 다음과 같이 자연스럽게 표현됩니다: -/// -/// | Figma variant | 표현 방식 | -/// |---------------|----------| -/// | Empty | `text == ""` 이고 포커스 없음 | -/// | Placeholder | `text == ""` 이고 포커스 없음 — `prompt`가 ``DVColor/gray400``으로 표시 | -/// | Active | `!text.isEmpty` — 입력된 텍스트가 ``DVColor/gray900``로 표시 | -/// | Focus | 포커스 진입 — 시스템 커서가 ``DVColor/vaultGreen``으로 점멸 (`.tint`) | -/// -/// 외곽선은 모든 상태에서 1pt ``DVColor/gray300`` 으로 유지됩니다 — 포커스 -/// 시에도 외곽선 색은 변하지 않으며, 활성 상태는 오직 커서 색으로만 -/// 표현하는 것이 디자인 의도입니다. -/// -/// ## 사이즈 -/// -/// 너비는 ``DVComponentSize``의 `width`를 그대로 따릅니다. 높이는 28pt -/// 고정. 폼 안에서 다른 사이즈를 섞어 쓰지 않는 것을 권장. -/// -/// ## 사용 -/// -/// ```swift -/// @State private var name = "" -/// -/// DVTextField("e.g DeVault", text: $name, size: .md) -/// ``` +/// `isSecure: true`이면 `SecureField` + 우측 눈 아이콘 토글이 자동 표시되고, +/// 토글 시 포커스와 커서 위치가 유지됩니다 (마스킹 상태는 내부 `@State`). public struct DVTextField: View { + + // MARK: - Properties + private let placeholder: String @Binding private var text: String private let size: DVComponentSize + private let isSecure: Bool + + @State private var isRevealed = false + @FocusState private var isFocused: Bool + + // MARK: - Init - /// 텍스트 필드를 생성합니다. - /// /// - Parameters: - /// - placeholder: 입력값이 비어 있을 때 ``DVColor/gray400`` 으로 표시될 - /// 안내 문구. - /// - text: 입력 값에 대한 양방향 바인딩. 시스템 `TextField` 에 그대로 - /// 전달되므로 IME(한글/일본어/중국어 등) 입력도 정상 동작합니다. - /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - placeholder: 빈 값일 때 표시될 안내 문구. + /// - text: 입력 값 양방향 바인딩. + /// - size: 너비 변형. 기본 ``DVComponentSize/md``. + /// - isSecure: 민감 값 마스킹 여부. 기본 `false`. public init( _ placeholder: String, text: Binding, - size: DVComponentSize = .md + size: DVComponentSize = .md, + isSecure: Bool = false ) { self.placeholder = placeholder self._text = text self.size = size + self.isSecure = isSecure } + // MARK: - Body + public var body: some View { - TextField( - "", - text: $text, - prompt: Text(placeholder).foregroundStyle(Color.dv(.gray400)) - ) - .textFieldStyle(.plain) - .font(DVFont.bodyLG.font) - .foregroundStyle(Color.dv(.gray900)) - .tint(Color.dv(.vaultGreen)) + HStack(spacing: 0) { + inputField + .focused($isFocused) + if isSecure { + revealToggle + } + } .padding(.leading, 8) .padding(.trailing, 4) .frame(width: size.width, height: 28) @@ -78,6 +59,59 @@ public struct DVTextField: View { } } +// MARK: - Subviews + +extension DVTextField { + + private var promptText: Text { + Text(placeholder).foregroundStyle(Color.dv(.gray400)) + } + + @ViewBuilder + private var inputField: some View { + Group { + if isSecure && !isRevealed { + SecureField("", text: $text, prompt: promptText) + } else { + TextField("", text: $text, prompt: promptText) + } + } + .textFieldStyle(.plain) + .font(DVFont.bodyLG.font) + .foregroundStyle(Color.dv(.gray900)) + .tint(Color.dv(.vaultGreen)) + } + + private var revealToggle: some View { + Button { + // 스왑 후 포커스 복원 + 커서를 끝으로 (기본 "전체 선택" 회피). + let wasFocused = isFocused + isRevealed.toggle() + if wasFocused { + Task { @MainActor in + isFocused = true + DispatchQueue.main.async { + Self.moveCursorToEnd() + } + } + } + } label: { + Image(systemName: isRevealed ? "eye.slash" : "eye") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.dv(.gray900)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + + /// Field editor의 커서를 문자열 끝으로 이동 (`@FocusState = true` 시 macOS 기본 "전체 선택" 회피). + private static func moveCursorToEnd() { + guard let editor = NSApp.keyWindow?.firstResponder as? NSText else { return } + let end = (editor.string as NSString).length + editor.selectedRange = NSRange(location: end, length: 0) + } +} + // MARK: - Previews #Preview("Placeholder (Empty)") { @@ -95,6 +129,11 @@ public struct DVTextField: View { .padding() } +#Preview("Secure") { + DVTextFieldSecurePreview() + .padding() +} + private struct DVTextFieldPlaceholderPreview: View { @State private var text = "" var body: some View { @@ -124,3 +163,15 @@ private struct DVTextFieldSizesPreview: View { } } } + +private struct DVTextFieldSecurePreview: View { + @State private var apiKey = "ghp_1234567890abcdef" + @State private var empty = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + DVTextField("secret value", text: $apiKey, size: .lg, isSecure: true) + DVTextField("empty secure", text: $empty, size: .lg, isSecure: true) + } + } +} diff --git a/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift b/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift index bdf755f..2ec0f65 100644 --- a/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift +++ b/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift @@ -20,6 +20,7 @@ public enum DVColor: CaseIterable { case black case danger case warning + case required public var color: Color { switch self { @@ -40,6 +41,7 @@ public enum DVColor: CaseIterable { case .black: return Color("black", bundle: .module) case .danger: return Color("danger", bundle: .module) case .warning: return Color("warning", bundle: .module) + case .required: return Color("required", bundle: .module) } } }