From 16af35fd8fd6f1663972b397c139c8625e9a2602 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:13:09 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[#42]=20feat:=20DVChip=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20DVChipPreviewView=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SampleApp/Sources/ContentView.swift | 3 + .../SampleApp/Sources/DVChipPreviewView.swift | 68 +++++++++++++++++ .../DVDesign/Sources/Components/DVChip.swift | 75 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 Projects/DVDesign/SampleApp/Sources/DVChipPreviewView.swift create mode 100644 Projects/DVDesign/Sources/Components/DVChip.swift diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index 513ee437..e2922f88 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -60,6 +60,8 @@ struct ContentView: View { TextFieldPreviewView() case "DVTextContainer": TextContainerPreviewView() + case "DVChip": + DVChipPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -103,6 +105,7 @@ private struct Component: Hashable { Component(name: "DVRadioButtonGroup"), Component(name: "DVTextContainer"), Component(name: "DVTextField"), + Component(name: "DVChip"), // 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 00000000..e54dd608 --- /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/Sources/Components/DVChip.swift b/Projects/DVDesign/Sources/Components/DVChip.swift new file mode 100644 index 00000000..0f092a81 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVChip.swift @@ -0,0 +1,75 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// Devault 디자인 시스템의 태그/토큰 표시용 chip. +/// +/// `DVChip`는 `Services` 같은 태그 필드에서 개별 태그를 표시하는 pill 형태 +/// 컴포넌트입니다. Figma의 "Push Button" (node 396:13245)에 대응합니다. +/// +/// ## 시각 스펙 +/// +/// - 높이: 24pt 고정 +/// - 배경: ``DVColor/vaultGreenTint`` +/// - 라디우스: 6pt +/// - 좌우 padding: 16pt +/// - 텍스트: ``DVFont/bodyMD``, ``DVColor/gray900`` +/// +/// ## 인터랙션 +/// +/// 클릭 시 ``action`` 클로저가 호출됩니다. 호출자가 이 액션을 "제거"로 +/// 해석하는 것이 가장 흔한 패턴이지만, "선택 토글" 등 다른 의미로도 +/// 사용할 수 있습니다. Chip 자체에는 상태가 없으며 관리 책임은 호출자에게 있습니다. +/// +/// ## 사용 +/// +/// ```swift +/// DVChip("GitHub") { services.removeAll { $0 == "GitHub" } } +/// ``` +public struct DVChip: View { + + // MARK: - Properties + + public let text: String + public 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: action) { + Text(text) + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.gray900)) + .lineLimit(1) + .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() +} From e7682a00efd4aaf50ae0f10a44f19ea71caf9955 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:39:24 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[#42]=20feat:=20DVMultilineTextField=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 여러 줄 입력용 TextEditor 래퍼 (PEM 블록·JSON·env content 등) - 고정 height + 내용 초과 시 내부 세로 스크롤 --- .../SampleApp/Sources/ContentView.swift | 3 + .../DVMultilineTextFieldPreviewView.swift | 96 ++++++++++ .../Components/DVMultilineTextField.swift | 168 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 Projects/DVDesign/SampleApp/Sources/DVMultilineTextFieldPreviewView.swift create mode 100644 Projects/DVDesign/Sources/Components/DVMultilineTextField.swift diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index e2922f88..3bac2f57 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -62,6 +62,8 @@ struct ContentView: View { TextContainerPreviewView() case "DVChip": DVChipPreviewView() + case "DVMultilineTextField": + DVMultilineTextFieldPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -106,6 +108,7 @@ private struct Component: Hashable { Component(name: "DVTextContainer"), Component(name: "DVTextField"), Component(name: "DVChip"), + Component(name: "DVMultilineTextField"), // Component(name: "DVInputField"), // Component(name: "DVDropDown"), ] diff --git a/Projects/DVDesign/SampleApp/Sources/DVMultilineTextFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVMultilineTextFieldPreviewView.swift new file mode 100644 index 00000000..f37dfc0c --- /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/Sources/Components/DVMultilineTextField.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift new file mode 100644 index 00000000..7dbc888c --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift @@ -0,0 +1,168 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// Devault 디자인 시스템의 여러 줄 입력 필드. +/// +/// `DVMultilineTextField`는 ``DVTextField``의 세로 확장 버전입니다. +/// Service Account credentialJSON, SSH privateKey, Env Var Set content 등 +/// 여러 줄에 걸친 텍스트 입력이 필요한 곳에서 사용합니다. +/// +/// SwiftUI 시스템 `TextEditor`를 그대로 사용하면서 디자인 토큰 +/// (``DVColor``, ``DVComponentSize``)으로 시각 속성만 덧입힙니다. +/// +/// ## 시각 상태 +/// +/// | 상태 | 표현 | +/// |------|------| +/// | Empty | `text == ""` — 좌측 상단에 placeholder(``DVColor/gray400``) 오버레이 | +/// | Active | `!text.isEmpty` — 입력 텍스트가 ``DVColor/gray900``로 표시 | +/// | Focus | 시스템 커서가 ``DVColor/vaultGreen``으로 점멸 | +/// +/// 외곽선은 항상 1pt ``DVColor/gray300``으로 유지 (``DVTextField``와 동일). +/// +/// ## 사이즈 +/// +/// 너비는 ``DVComponentSize``의 ``DVComponentSize/width``를 그대로 따릅니다. +/// 높이는 ``minHeight`` 이상으로 확장 — 내용이 늘어나면 세로로 커지고, +/// 부모 `ScrollView`가 전체 스크롤을 담당합니다. +/// +/// ## 사용 +/// +/// ```swift +/// @State private var content = "" +/// +/// DVMultilineTextField( +/// "e.g DATABASE_URL=postgres://...", +/// text: $content, +/// size: .lg, +/// minHeight: 120 +/// ) +/// ``` +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 { + + // TextEditor는 NSTextView 기반이라 자체 내부 inset이 있습니다. + // 시각 정렬을 위해 커서 시작 위치(약 leading 5pt, top 8pt)에 + // placeholder를 정확히 겹치도록 padding을 맞춥니다. + private static let contentInsetLeading: CGFloat = 6 + private static let contentInsetTop: CGFloat = 0 + + 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.contentInsetLeading) + .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) + } + } +} From 71d2c19d2b6dfb28a3ded421ca8c78f363319243 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:54:31 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[#42]=20feat:=20DVDropdown=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SampleApp/Sources/ContentView.swift | 4 +- .../Sources/DVDropdownPreviewView.swift | 104 +++++++++++ .../Sources/Components/DVDropdown.swift | 175 ++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 Projects/DVDesign/SampleApp/Sources/DVDropdownPreviewView.swift create mode 100644 Projects/DVDesign/Sources/Components/DVDropdown.swift diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index 3bac2f57..dfd85f48 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -64,6 +64,8 @@ struct ContentView: View { DVChipPreviewView() case "DVMultilineTextField": DVMultilineTextFieldPreviewView() + case "DVDropdown": + DVDropdownPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -109,7 +111,7 @@ private struct Component: Hashable { Component(name: "DVTextField"), Component(name: "DVChip"), Component(name: "DVMultilineTextField"), + Component(name: "DVDropdown"), // Component(name: "DVInputField"), -// Component(name: "DVDropDown"), ] } diff --git a/Projects/DVDesign/SampleApp/Sources/DVDropdownPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVDropdownPreviewView.swift new file mode 100644 index 00000000..e6faf18c --- /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/Sources/Components/DVDropdown.swift b/Projects/DVDesign/Sources/Components/DVDropdown.swift new file mode 100644 index 00000000..1e513952 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVDropdown.swift @@ -0,0 +1,175 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// Devault 디자인 시스템의 드롭다운 (읽기 전용 필드 + 시스템 Menu). +/// +/// `DVDropdown`은 Figma의 "Filled (ReadOnly)" 스타일 트리거 필드와 +/// macOS 시스템 Menu 팝오버를 결합한 컴포넌트입니다. +/// +/// 트리거만 디자인 토큰으로 커스텀 스타일링하고, 팝오버 렌더링과 hover/선택 +/// 하이라이트, separator, 키보드 네비게이션은 시스템 `Menu`에 위임합니다. +/// +/// ## 시각 스펙 (트리거) +/// +/// - 높이: 28pt 고정 +/// - 배경: ``DVColor/gray300`` (filled) +/// - 라디우스: 6pt +/// - 좌 padding: 8pt, 우 padding: 4pt +/// - 표시 텍스트: ``DVFont/bodyLG``, ``DVColor/gray900`` +/// - 우측 chevron: `chevron.down`, ``DVFont/captionMDSemibold``, 24×24 슬롯 +/// +/// ## 사이즈 +/// +/// 너비는 ``DVComponentSize``의 ``DVComponentSize/width``를 그대로 따릅니다. +/// +/// ## Menu content +/// +/// 팝오버 내용은 호출자가 `@ViewBuilder`로 제공합니다. SwiftUI의 `Menu`가 +/// 기대하는 어떤 뷰든 넣을 수 있으며, 시스템이 정렬·하이라이트·구분선을 +/// 자동으로 렌더링합니다. +/// +/// ```swift +/// enum Project: Hashable { case cheerLot, drinkiG } +/// @State private var selection: Project? +/// +/// DVDropdown(selection?.name ?? "Select Project", size: .sm) { +/// Button("CheerLot") { selection = .cheerLot } +/// Button("DrinkiG") { selection = .drinkiG } +/// Divider() +/// Button { +/// // add new project +/// } label: { +/// Label("Add New Project", systemImage: "plus") +/// } +/// } +/// ``` +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") {} } + } + } +} From 5a72494d0201acdc107d6d3aae091f9f2016e45f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:13:34 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[#42]=20feat:=20DVColor.required=20(#C835?= =?UTF-8?q?35)=20=EC=BB=AC=EB=9F=AC=20=ED=86=A0=ED=81=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assets에 required.colorset 등록 - DVColor에 case required 추가 — 필수 표시(*)·validation warning 텍스트용 --- .../color/required.colorset/Contents.json | 38 +++++++++++++++++++ .../Sources/Foundations/Color/DVColor.swift | 2 + 2 files changed, 40 insertions(+) create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/color/required.colorset/Contents.json 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 00000000..418b727a --- /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/Sources/Foundations/Color/DVColor.swift b/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift index bdf755fc..2ec0f65a 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) } } } From 33e1d571f654b20b643a7c874ee8ab7499ad23f4 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:22:47 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[#42]=20feat:=20DVTextField=EC=97=90=20is?= =?UTF-8?q?Secure=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecureField 기반 마스킹 + 우측 eye/eye.slash 토글 (11pt, 24pt 슬롯) - 마스킹 상태는 내부 @State로 관리 (caller는 text 바인딩만 신경) - 기존 API 호환 유지 (isSecure 기본값 false) - SampleApp preview에 Secure 섹션 추가 Known limitation: SecureField는 macOS 시스템 특성상 IME(한글 조합) 입력 불가. 시크릿 값은 대부분 ASCII라 실사용 문제 미미. --- .../Sources/TextFieldPreviewView.swift | 15 +++ .../Sources/Components/DVTextField.swift | 101 ++++++++++++++++-- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/Projects/DVDesign/SampleApp/Sources/TextFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/TextFieldPreviewView.swift index ae0d620f..f7b65adf 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/DVTextField.swift b/Projects/DVDesign/Sources/Components/DVTextField.swift index ac820c28..022d75a3 100644 --- a/Projects/DVDesign/Sources/Components/DVTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVTextField.swift @@ -28,6 +28,23 @@ import SwiftUI /// 너비는 ``DVComponentSize``의 `width`를 그대로 따릅니다. 높이는 28pt /// 고정. 폼 안에서 다른 사이즈를 섞어 쓰지 않는 것을 권장. /// +/// ## Secure 모드 (민감 값 마스킹) +/// +/// `isSecure: true`로 생성하면 입력값이 `SecureField`로 마스킹되고, +/// 필드 우측에 눈 아이콘 토글이 자동으로 표시됩니다. 사용자가 눈을 클릭하면 +/// 마스킹이 잠시 해제되어 값 확인이 가능합니다. +/// +/// 마스킹 여부는 내부 `@State`로 관리되므로 caller는 `text` 바인딩만 신경 쓰면 됩니다. +/// +/// ```swift +/// @State private var apiKey = "" +/// +/// DVTextField("secret value", text: $apiKey, size: .lg, isSecure: true) +/// ``` +/// +/// > SwiftUI 특성상 `SecureField ↔ TextField` 토글 시 포커스가 잠깐 초기화되는데, +/// > 이는 시스템 컨트롤의 알려진 동작이며 정상적인 사용에서 큰 영향을 주지 않습니다. +/// /// ## 사용 /// /// ```swift @@ -36,9 +53,17 @@ import SwiftUI /// DVTextField("e.g DeVault", text: $name, size: .md) /// ``` 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 + + // MARK: - Init /// 텍스트 필드를 생성합니다. /// @@ -48,26 +73,29 @@ public struct DVTextField: View { /// - text: 입력 값에 대한 양방향 바인딩. 시스템 `TextField` 에 그대로 /// 전달되므로 IME(한글/일본어/중국어 등) 입력도 정상 동작합니다. /// - size: 너비 변형. 기본값은 ``DVComponentSize/md``. + /// - isSecure: 민감 정보 마스킹 여부. `true`이면 `SecureField` 사용 + + /// 우측 눈 아이콘 토글이 자동으로 추가됩니다. 기본값 `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 + if isSecure { + revealToggle + } + } .padding(.leading, 8) .padding(.trailing, 4) .frame(width: size.width, height: 28) @@ -78,6 +106,42 @@ 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 { + isRevealed.toggle() + } label: { + Image(systemName: isRevealed ? "eye.slash" : "eye") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.dv(.gray900)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } +} + // MARK: - Previews #Preview("Placeholder (Empty)") { @@ -95,6 +159,11 @@ public struct DVTextField: View { .padding() } +#Preview("Secure") { + DVTextFieldSecurePreview() + .padding() +} + private struct DVTextFieldPlaceholderPreview: View { @State private var text = "" var body: some View { @@ -124,3 +193,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) + } + } +} From 271edbb8651ddb41d53a484178358148f78c6acf Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:23:27 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[#42]=20feat:=20DVLabeledField=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Label + (*) 필수 표시 + trailing hint slot(감지/경고) 3파트 래퍼 - 힌트 슬롯: .detected(초록, vaultGreen) / .warning(빨강, required) 지원 - 내부 컨텐츠는 @ViewBuilder — 어떤 입력 뷰든 감싸기 가능 --- .../SampleApp/Sources/ContentView.swift | 3 + .../Sources/DVLabeledFieldPreviewView.swift | 138 +++++++++++ .../Sources/Components/DVLabeledField.swift | 225 ++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 Projects/DVDesign/SampleApp/Sources/DVLabeledFieldPreviewView.swift create mode 100644 Projects/DVDesign/Sources/Components/DVLabeledField.swift diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index dfd85f48..33ea14e4 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -66,6 +66,8 @@ struct ContentView: View { DVMultilineTextFieldPreviewView() case "DVDropdown": DVDropdownPreviewView() + case "DVLabeledField": + DVLabeledFieldPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -112,6 +114,7 @@ private struct Component: Hashable { Component(name: "DVChip"), Component(name: "DVMultilineTextField"), Component(name: "DVDropdown"), + Component(name: "DVLabeledField"), // Component(name: "DVInputField"), ] } diff --git a/Projects/DVDesign/SampleApp/Sources/DVLabeledFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVLabeledFieldPreviewView.swift new file mode 100644 index 00000000..71886ba5 --- /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/Sources/Components/DVLabeledField.swift b/Projects/DVDesign/Sources/Components/DVLabeledField.swift new file mode 100644 index 00000000..3a4970dc --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVLabeledField.swift @@ -0,0 +1,225 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// Devault 디자인 시스템의 폼 필드 래퍼. +/// +/// `DVLabeledField`는 폼 안에서 반복되는 "Label + (*) 필수 표시 + 입력 슬롯 + 우측 힌트" +/// 3파트 조합을 하나의 컴포넌트로 묶은 wrapper입니다. Figma의 Field 컴포넌트 +/// (node 467:16059)에 대응합니다. +/// +/// 실제 입력 컨트롤은 caller가 `@ViewBuilder` content로 자유롭게 지정 — +/// ``DVTextField``, ``DVDropdown``, ``DVMultilineTextField``, ``DVChipsField`` +/// 등 어떤 뷰든 넣을 수 있습니다. +/// +/// ## 시각 스펙 +/// +/// - Label 로우와 입력 슬롯 사이 vertical spacing: 10pt +/// - Label: ``DVFont/bodyMD`` (13pt medium), ``DVColor/gray700`` +/// - 필수 표시 `(*)`: ``DVColor/warning`` (Figma `#c83535`) +/// - Trailing hint: 라벨 로우 우측 정렬, 1줄 tail truncation +/// - ``TrailingHint/detected(_:)``: ``DVColor/vaultGreen`` — 감지 엔진이 서비스 인식했을 때 +/// - ``TrailingHint/warning(_:)``: ``DVColor/warning`` — validation 실패 메시지 +/// +/// ## 사이즈 +/// +/// 컴포넌트 너비는 ``DVComponentSize`` 를 따르며, 내부 입력 뷰의 너비와 +/// 일치시켜야 라벨 로우가 정렬됩니다. +/// +/// ## 사용 +/// +/// ```swift +/// // 감지 엔진 결과 표시 +/// DVLabeledField("Name", isRequired: true, trailingHint: .detected("GitHub"), size: .lg) { +/// DVTextField("e.g DeVault", text: $name, size: .lg) +/// } +/// +/// // Validation 실패 메시지 +/// DVLabeledField("Value", isRequired: true, trailingHint: .warning("필수 항목입니다"), size: .lg) { +/// DVTextField("value", text: $value, size: .lg) +/// } +/// +/// // 힌트 없음 +/// DVLabeledField("Memo", size: .lg) { +/// DVTextField("optional", text: $memo, size: .lg) +/// } +/// ``` +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) + } + } + } +} From 5476a69d422f0725491e78b763a8d0438657c7da Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:59:11 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[#42]=20feat:=20DVChipsField=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chip 기반 multi-tag 표시 필드 (DVTextField + DVChip 배열 composite) - 반응형 Layout — 컨테이너 너비 초과 시 세로 wrap, chip은 tail truncate - Chip 클릭 시 input에 텍스트 세팅 + input과 일치하는 chip만 시각 숨김 - 다른 chip 클릭하면 이전 chip 자동 복귀 - Chip 배열 관리는 외부 담당 (감지 엔진 등) --- .../SampleApp/Sources/ContentView.swift | 3 + .../Sources/DVChipsFieldPreviewView.swift | 104 +++++++++ .../Sources/Components/DVChipsField.swift | 215 ++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 Projects/DVDesign/SampleApp/Sources/DVChipsFieldPreviewView.swift create mode 100644 Projects/DVDesign/Sources/Components/DVChipsField.swift diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index 33ea14e4..9371f620 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -68,6 +68,8 @@ struct ContentView: View { DVDropdownPreviewView() case "DVLabeledField": DVLabeledFieldPreviewView() + case "DVChipsField": + DVChipsFieldPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -115,6 +117,7 @@ private struct Component: Hashable { Component(name: "DVMultilineTextField"), Component(name: "DVDropdown"), Component(name: "DVLabeledField"), + Component(name: "DVChipsField"), // Component(name: "DVInputField"), ] } diff --git a/Projects/DVDesign/SampleApp/Sources/DVChipsFieldPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVChipsFieldPreviewView.swift new file mode 100644 index 00000000..d5d68432 --- /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/Sources/Components/DVChipsField.swift b/Projects/DVDesign/Sources/Components/DVChipsField.swift new file mode 100644 index 00000000..fcab6170 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVChipsField.swift @@ -0,0 +1,215 @@ +// 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) } + } + } + .frame(width: size.width, alignment: .leading) + } + } + } + + /// 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. +/// +/// SwiftUI `Layout` protocol을 사용해 컨테이너 너비에 맞춰 자동으로 세로 확장. +/// macOS 13+ 필요. +private struct DVChipFlow: Layout { + + let hSpacing: CGFloat + let vSpacing: CGFloat + + init(hSpacing: CGFloat = 10, vSpacing: CGFloat = 10) { + self.hSpacing = hSpacing + self.vSpacing = vSpacing + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + guard !subviews.isEmpty else { return .zero } + let maxWidth = proposal.width ?? .infinity + + // 각 subview에 컨테이너 너비를 propose — chip이 자연 너비가 + // 초과할 경우 tail truncate 되어 항상 한 줄에 최소 하나는 들어감. + let chipProposal = ProposedViewSize(width: maxWidth, height: nil) + + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for (index, subview) in subviews.enumerated() { + let size = subview.sizeThatFits(chipProposal) + if index > 0 && currentX + size.width > maxWidth { + currentY += lineHeight + vSpacing + currentX = 0 + lineHeight = 0 + } + currentX += size.width + lineHeight = max(lineHeight, size.height) + totalWidth = max(totalWidth, currentX) + if index < subviews.count - 1 { + currentX += hSpacing + } + } + return CGSize(width: totalWidth, height: currentY + lineHeight) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let chipProposal = ProposedViewSize(width: bounds.width, height: nil) + var currentX: CGFloat = bounds.minX + var currentY: CGFloat = bounds.minY + var lineHeight: CGFloat = 0 + + for (index, subview) in subviews.enumerated() { + let size = subview.sizeThatFits(chipProposal) + if index > 0 && currentX + size.width > bounds.maxX { + currentY += lineHeight + vSpacing + currentX = bounds.minX + lineHeight = 0 + } + subview.place( + at: CGPoint(x: currentX, y: currentY), + proposal: chipProposal + ) + currentX += size.width + hSpacing + lineHeight = max(lineHeight, size.height) + } + } +} + +// 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) + } + } +} From dbb46b2420f894be8f7e5bbc2529c6c54b6f5ac0 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:59:32 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[#42]=20feat:=20DVTextContainer=20textCol?= =?UTF-8?q?or=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5개 init 모두에 textColor: DVColor = .gray900 파라미터 추가 - 비활성 상태 등 상황별 텍스트 색상 지정 가능 - 기본값 유지로 기존 caller 무영향 --- .../Sources/Components/DVTextContainer.swift | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVTextContainer.swift b/Projects/DVDesign/Sources/Components/DVTextContainer.swift index 3607bc51..aa5cda71 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() From f66e9c88899a93d777942fba7615e4cc2d86e40a Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:00:02 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[#42]=20docs:=205=EA=B0=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DVChip, DVMultilineTextField, DVDropdown, DVLabeledField, DVTextField - init Parameters 블록과 필수 요약만 유지 --- .../DVDesign/Sources/Components/DVChip.swift | 31 +++---------- .../Sources/Components/DVDropdown.swift | 45 ++----------------- .../Sources/Components/DVLabeledField.swift | 45 +++---------------- .../Components/DVMultilineTextField.swift | 39 ++-------------- .../Sources/Components/DVTextField.swift | 26 ++--------- 5 files changed, 22 insertions(+), 164 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVChip.swift b/Projects/DVDesign/Sources/Components/DVChip.swift index 0f092a81..cff0bab0 100644 --- a/Projects/DVDesign/Sources/Components/DVChip.swift +++ b/Projects/DVDesign/Sources/Components/DVChip.swift @@ -2,30 +2,10 @@ import SwiftUI -/// Devault 디자인 시스템의 태그/토큰 표시용 chip. +/// 태그/토큰 표시용 pill 컴포넌트. /// -/// `DVChip`는 `Services` 같은 태그 필드에서 개별 태그를 표시하는 pill 형태 -/// 컴포넌트입니다. Figma의 "Push Button" (node 396:13245)에 대응합니다. -/// -/// ## 시각 스펙 -/// -/// - 높이: 24pt 고정 -/// - 배경: ``DVColor/vaultGreenTint`` -/// - 라디우스: 6pt -/// - 좌우 padding: 16pt -/// - 텍스트: ``DVFont/bodyMD``, ``DVColor/gray900`` -/// -/// ## 인터랙션 -/// -/// 클릭 시 ``action`` 클로저가 호출됩니다. 호출자가 이 액션을 "제거"로 -/// 해석하는 것이 가장 흔한 패턴이지만, "선택 토글" 등 다른 의미로도 -/// 사용할 수 있습니다. Chip 자체에는 상태가 없으며 관리 책임은 호출자에게 있습니다. -/// -/// ## 사용 -/// -/// ```swift -/// DVChip("GitHub") { services.removeAll { $0 == "GitHub" } } -/// ``` +/// 높이 24pt, ``DVColor/vaultGreenTint`` 배경. 클릭 시 ``action`` 클로저가 +/// 호출되며 chip 자체는 상태를 갖지 않습니다. public struct DVChip: View { // MARK: - Properties @@ -46,11 +26,14 @@ public struct DVChip: View { // MARK: - Body public var body: some View { - Button(action: action) { + Button { + action() + } label: { Text(text) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.gray900)) .lineLimit(1) + .truncationMode(.tail) .padding(.horizontal, 16) .frame(height: 24) .background( diff --git a/Projects/DVDesign/Sources/Components/DVDropdown.swift b/Projects/DVDesign/Sources/Components/DVDropdown.swift index 1e513952..81ea6cc7 100644 --- a/Projects/DVDesign/Sources/Components/DVDropdown.swift +++ b/Projects/DVDesign/Sources/Components/DVDropdown.swift @@ -2,48 +2,11 @@ import SwiftUI -/// Devault 디자인 시스템의 드롭다운 (읽기 전용 필드 + 시스템 Menu). +/// 읽기 전용 트리거 필드 + macOS 시스템 Menu 팝오버. /// -/// `DVDropdown`은 Figma의 "Filled (ReadOnly)" 스타일 트리거 필드와 -/// macOS 시스템 Menu 팝오버를 결합한 컴포넌트입니다. -/// -/// 트리거만 디자인 토큰으로 커스텀 스타일링하고, 팝오버 렌더링과 hover/선택 -/// 하이라이트, separator, 키보드 네비게이션은 시스템 `Menu`에 위임합니다. -/// -/// ## 시각 스펙 (트리거) -/// -/// - 높이: 28pt 고정 -/// - 배경: ``DVColor/gray300`` (filled) -/// - 라디우스: 6pt -/// - 좌 padding: 8pt, 우 padding: 4pt -/// - 표시 텍스트: ``DVFont/bodyLG``, ``DVColor/gray900`` -/// - 우측 chevron: `chevron.down`, ``DVFont/captionMDSemibold``, 24×24 슬롯 -/// -/// ## 사이즈 -/// -/// 너비는 ``DVComponentSize``의 ``DVComponentSize/width``를 그대로 따릅니다. -/// -/// ## Menu content -/// -/// 팝오버 내용은 호출자가 `@ViewBuilder`로 제공합니다. SwiftUI의 `Menu`가 -/// 기대하는 어떤 뷰든 넣을 수 있으며, 시스템이 정렬·하이라이트·구분선을 -/// 자동으로 렌더링합니다. -/// -/// ```swift -/// enum Project: Hashable { case cheerLot, drinkiG } -/// @State private var selection: Project? -/// -/// DVDropdown(selection?.name ?? "Select Project", size: .sm) { -/// Button("CheerLot") { selection = .cheerLot } -/// Button("DrinkiG") { selection = .drinkiG } -/// Divider() -/// Button { -/// // add new project -/// } label: { -/// Label("Add New Project", systemImage: "plus") -/// } -/// } -/// ``` +/// 트리거만 디자인 토큰으로 스타일링하고, 팝오버 렌더링·정렬·하이라이트· +/// 키보드 네비게이션은 시스템 `Menu`에 위임합니다. 팝오버 내용은 caller가 +/// `@ViewBuilder`로 자유롭게 제공. public struct DVDropdown: View { // MARK: - Properties diff --git a/Projects/DVDesign/Sources/Components/DVLabeledField.swift b/Projects/DVDesign/Sources/Components/DVLabeledField.swift index 3a4970dc..233146a9 100644 --- a/Projects/DVDesign/Sources/Components/DVLabeledField.swift +++ b/Projects/DVDesign/Sources/Components/DVLabeledField.swift @@ -2,48 +2,13 @@ import SwiftUI -/// Devault 디자인 시스템의 폼 필드 래퍼. +/// 폼 필드 래퍼 — Label + `(*)` 필수 표시 + 입력 슬롯 + 우측 hint. /// -/// `DVLabeledField`는 폼 안에서 반복되는 "Label + (*) 필수 표시 + 입력 슬롯 + 우측 힌트" -/// 3파트 조합을 하나의 컴포넌트로 묶은 wrapper입니다. Figma의 Field 컴포넌트 -/// (node 467:16059)에 대응합니다. +/// 입력 컨트롤은 caller가 `@ViewBuilder`로 자유롭게 지정. 우측 hint는 +/// 감지 결과(초록, ``TrailingHint/detected(_:)``) 또는 validation 경고(빨강, +/// ``TrailingHint/warning(_:)``)를 표시합니다. /// -/// 실제 입력 컨트롤은 caller가 `@ViewBuilder` content로 자유롭게 지정 — -/// ``DVTextField``, ``DVDropdown``, ``DVMultilineTextField``, ``DVChipsField`` -/// 등 어떤 뷰든 넣을 수 있습니다. -/// -/// ## 시각 스펙 -/// -/// - Label 로우와 입력 슬롯 사이 vertical spacing: 10pt -/// - Label: ``DVFont/bodyMD`` (13pt medium), ``DVColor/gray700`` -/// - 필수 표시 `(*)`: ``DVColor/warning`` (Figma `#c83535`) -/// - Trailing hint: 라벨 로우 우측 정렬, 1줄 tail truncation -/// - ``TrailingHint/detected(_:)``: ``DVColor/vaultGreen`` — 감지 엔진이 서비스 인식했을 때 -/// - ``TrailingHint/warning(_:)``: ``DVColor/warning`` — validation 실패 메시지 -/// -/// ## 사이즈 -/// -/// 컴포넌트 너비는 ``DVComponentSize`` 를 따르며, 내부 입력 뷰의 너비와 -/// 일치시켜야 라벨 로우가 정렬됩니다. -/// -/// ## 사용 -/// -/// ```swift -/// // 감지 엔진 결과 표시 -/// DVLabeledField("Name", isRequired: true, trailingHint: .detected("GitHub"), size: .lg) { -/// DVTextField("e.g DeVault", text: $name, size: .lg) -/// } -/// -/// // Validation 실패 메시지 -/// DVLabeledField("Value", isRequired: true, trailingHint: .warning("필수 항목입니다"), size: .lg) { -/// DVTextField("value", text: $value, size: .lg) -/// } -/// -/// // 힌트 없음 -/// DVLabeledField("Memo", size: .lg) { -/// DVTextField("optional", text: $memo, size: .lg) -/// } -/// ``` +/// 내부 입력 뷰와 ``DVComponentSize``를 일치시켜야 라벨 로우가 정렬됩니다. public struct DVLabeledField: View { // MARK: - Types diff --git a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift index 7dbc888c..2b71d3b6 100644 --- a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift @@ -2,43 +2,10 @@ import SwiftUI -/// Devault 디자인 시스템의 여러 줄 입력 필드. +/// 여러 줄 입력 필드. ``DVTextField``의 세로 확장 버전. /// -/// `DVMultilineTextField`는 ``DVTextField``의 세로 확장 버전입니다. -/// Service Account credentialJSON, SSH privateKey, Env Var Set content 등 -/// 여러 줄에 걸친 텍스트 입력이 필요한 곳에서 사용합니다. -/// -/// SwiftUI 시스템 `TextEditor`를 그대로 사용하면서 디자인 토큰 -/// (``DVColor``, ``DVComponentSize``)으로 시각 속성만 덧입힙니다. -/// -/// ## 시각 상태 -/// -/// | 상태 | 표현 | -/// |------|------| -/// | Empty | `text == ""` — 좌측 상단에 placeholder(``DVColor/gray400``) 오버레이 | -/// | Active | `!text.isEmpty` — 입력 텍스트가 ``DVColor/gray900``로 표시 | -/// | Focus | 시스템 커서가 ``DVColor/vaultGreen``으로 점멸 | -/// -/// 외곽선은 항상 1pt ``DVColor/gray300``으로 유지 (``DVTextField``와 동일). -/// -/// ## 사이즈 -/// -/// 너비는 ``DVComponentSize``의 ``DVComponentSize/width``를 그대로 따릅니다. -/// 높이는 ``minHeight`` 이상으로 확장 — 내용이 늘어나면 세로로 커지고, -/// 부모 `ScrollView`가 전체 스크롤을 담당합니다. -/// -/// ## 사용 -/// -/// ```swift -/// @State private var content = "" -/// -/// DVMultilineTextField( -/// "e.g DATABASE_URL=postgres://...", -/// text: $content, -/// size: .lg, -/// minHeight: 120 -/// ) -/// ``` +/// 너비는 ``DVComponentSize``, 높이는 `height` 파라미터로 고정. +/// 내용이 초과하면 내부에서 세로 스크롤됩니다. public struct DVMultilineTextField: View { // MARK: - Properties diff --git a/Projects/DVDesign/Sources/Components/DVTextField.swift b/Projects/DVDesign/Sources/Components/DVTextField.swift index 022d75a3..dd0771e0 100644 --- a/Projects/DVDesign/Sources/Components/DVTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVTextField.swift @@ -28,30 +28,10 @@ import SwiftUI /// 너비는 ``DVComponentSize``의 `width`를 그대로 따릅니다. 높이는 28pt /// 고정. 폼 안에서 다른 사이즈를 섞어 쓰지 않는 것을 권장. /// -/// ## Secure 모드 (민감 값 마스킹) +/// ## Secure 모드 /// -/// `isSecure: true`로 생성하면 입력값이 `SecureField`로 마스킹되고, -/// 필드 우측에 눈 아이콘 토글이 자동으로 표시됩니다. 사용자가 눈을 클릭하면 -/// 마스킹이 잠시 해제되어 값 확인이 가능합니다. -/// -/// 마스킹 여부는 내부 `@State`로 관리되므로 caller는 `text` 바인딩만 신경 쓰면 됩니다. -/// -/// ```swift -/// @State private var apiKey = "" -/// -/// DVTextField("secret value", text: $apiKey, size: .lg, isSecure: true) -/// ``` -/// -/// > SwiftUI 특성상 `SecureField ↔ TextField` 토글 시 포커스가 잠깐 초기화되는데, -/// > 이는 시스템 컨트롤의 알려진 동작이며 정상적인 사용에서 큰 영향을 주지 않습니다. -/// -/// ## 사용 -/// -/// ```swift -/// @State private var name = "" -/// -/// DVTextField("e.g DeVault", text: $name, size: .md) -/// ``` +/// `isSecure: true`이면 값이 `SecureField`로 마스킹되고 우측에 눈 아이콘 토글이 +/// 자동 표시됩니다. 마스킹 상태는 내부 `@State`로 관리됩니다. public struct DVTextField: View { // MARK: - Properties From 5a37710b6bc22f6a37ddbc78a8b256fa1b72447d Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:02:28 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[#42]=20refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1=C2=B7=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DVChip: text/action 접근 제어를 다른 컴포넌트와 맞춰 private으로 좁힘 - DVMultilineTextField: 사용되지 않는 contentInsetTop 상수와 잘못된 정렬 주석 삭제, 상수 이름을 실제 용도(placeholderLeadingInset)로 리네이밍 - DVLabeledField: size 파라미터 자동 전달을 위한 environment 도입 여지 TODO 노트 추가 --- Projects/DVDesign/Sources/Components/DVChip.swift | 4 ++-- Projects/DVDesign/Sources/Components/DVLabeledField.swift | 4 ++++ .../Sources/Components/DVMultilineTextField.swift | 8 ++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVChip.swift b/Projects/DVDesign/Sources/Components/DVChip.swift index cff0bab0..91287fb4 100644 --- a/Projects/DVDesign/Sources/Components/DVChip.swift +++ b/Projects/DVDesign/Sources/Components/DVChip.swift @@ -10,8 +10,8 @@ public struct DVChip: View { // MARK: - Properties - public let text: String - public let action: () -> Void + private let text: String + private let action: () -> Void // MARK: - Init diff --git a/Projects/DVDesign/Sources/Components/DVLabeledField.swift b/Projects/DVDesign/Sources/Components/DVLabeledField.swift index 233146a9..d29911cc 100644 --- a/Projects/DVDesign/Sources/Components/DVLabeledField.swift +++ b/Projects/DVDesign/Sources/Components/DVLabeledField.swift @@ -9,6 +9,10 @@ import SwiftUI /// ``TrailingHint/warning(_:)``)를 표시합니다. /// /// 내부 입력 뷰와 ``DVComponentSize``를 일치시켜야 라벨 로우가 정렬됩니다. +/// +/// > TODO(size-env): 현재는 caller가 wrapper와 내부 input에 동일한 `size`를 +/// > 각각 전달해야 함. 향후 `EnvironmentValues.dvComponentSize`를 도입해 +/// > wrapper가 자식에게 자동 전파하는 방식으로 개선 여지 있음. public struct DVLabeledField: View { // MARK: - Types diff --git a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift index 2b71d3b6..bf83acfc 100644 --- a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift @@ -59,11 +59,7 @@ public struct DVMultilineTextField: View { extension DVMultilineTextField { - // TextEditor는 NSTextView 기반이라 자체 내부 inset이 있습니다. - // 시각 정렬을 위해 커서 시작 위치(약 leading 5pt, top 8pt)에 - // placeholder를 정확히 겹치도록 padding을 맞춥니다. - private static let contentInsetLeading: CGFloat = 6 - private static let contentInsetTop: CGFloat = 0 + private static let placeholderLeadingInset: CGFloat = 6 private var editor: some View { TextEditor(text: $text) @@ -78,7 +74,7 @@ extension DVMultilineTextField { Text(placeholder) .font(DVFont.bodyLG.font) .foregroundStyle(Color.dv(.gray400)) - .padding(.leading, Self.contentInsetLeading) + .padding(.leading, Self.placeholderLeadingInset) .allowsHitTesting(false) } } From 22af650bbe704ff15775c0d87562d9f8859657e5 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:34:53 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[#42]=20fix:=20DVTextField=20Secure=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=8B=9C=20=ED=8F=AC=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=C2=B7=EC=BB=A4=EC=84=9C=20=EC=9C=84=EC=B9=98=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @FocusState 추가 — SecureField ↔ TextField view type 스왑 시 포커스 리셋 방지 - 토글 후 AppKit NSText.selectedRange로 커서를 문자열 끝으로 이동 → macOS 기본 '전체 선택' 동작 대신 커서만 위치 --- .../Sources/Components/DVTextField.swift | 66 ++++++++----------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVTextField.swift b/Projects/DVDesign/Sources/Components/DVTextField.swift index dd0771e0..ed81369f 100644 --- a/Projects/DVDesign/Sources/Components/DVTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVTextField.swift @@ -1,37 +1,13 @@ // 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 -/// 고정. 폼 안에서 다른 사이즈를 섞어 쓰지 않는 것을 권장. -/// -/// ## Secure 모드 -/// -/// `isSecure: true`이면 값이 `SecureField`로 마스킹되고 우측에 눈 아이콘 토글이 -/// 자동 표시됩니다. 마스킹 상태는 내부 `@State`로 관리됩니다. +/// `isSecure: true`이면 `SecureField` + 우측 눈 아이콘 토글이 자동 표시되고, +/// 토글 시 포커스와 커서 위치가 유지됩니다 (마스킹 상태는 내부 `@State`). public struct DVTextField: View { // MARK: - Properties @@ -42,19 +18,15 @@ public struct DVTextField: View { 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``. - /// - isSecure: 민감 정보 마스킹 여부. `true`이면 `SecureField` 사용 + - /// 우측 눈 아이콘 토글이 자동으로 추가됩니다. 기본값 `false`. + /// - placeholder: 빈 값일 때 표시될 안내 문구. + /// - text: 입력 값 양방향 바인딩. + /// - size: 너비 변형. 기본 ``DVComponentSize/md``. + /// - isSecure: 민감 값 마스킹 여부. 기본 `false`. public init( _ placeholder: String, text: Binding, @@ -72,6 +44,7 @@ public struct DVTextField: View { public var body: some View { HStack(spacing: 0) { inputField + .focused($isFocused) if isSecure { revealToggle } @@ -111,7 +84,17 @@ extension DVTextField { 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)) @@ -120,6 +103,13 @@ extension DVTextField { } .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 From 3caf73350585a85a05f69cafb22a332d4e3c9ee8 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:36:05 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[#42]=20refactor:=20DVChipsField=20Layout?= =?UTF-8?q?=20=EC=84=B1=EB=8A=A5=C2=B7=EB=B6=80=EB=93=9C=EB=9F=AC=EC=9A=B4?= =?UTF-8?q?=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layout.Cache 도입 — sizeThatFits/placeSubviews 간 chip 사이즈 재측정 제거 - arrangement(...) 헬퍼로 두 함수의 배치 계산 통합 - visibleChips 변화에 spring 애니메이션 (response 0.5, damping 0.85) - chip 등장/퇴장 asymmetric transition (insertion: opacity + scale 0.85, removal: opacity) --- .../Sources/Components/DVChipsField.swift | 102 +++++++++++------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVChipsField.swift b/Projects/DVDesign/Sources/Components/DVChipsField.swift index fcab6170..639d647e 100644 --- a/Projects/DVDesign/Sources/Components/DVChipsField.swift +++ b/Projects/DVDesign/Sources/Components/DVChipsField.swift @@ -55,11 +55,18 @@ public struct DVChipsField: View { 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은 편집 대상으로 간주하여 목록에서 숨김. @@ -85,10 +92,15 @@ extension DVChipsField { /// Chips wrapping flow layout — 가로로 배치하다가 너비를 넘으면 다음 줄로 wrap. /// -/// SwiftUI `Layout` protocol을 사용해 컨테이너 너비에 맞춰 자동으로 세로 확장. -/// macOS 13+ 필요. +/// 각 chip 크기를 캐시해 `sizeThatFits`/`placeSubviews` 간 중복 측정을 피하고, +/// 두 함수 모두 동일한 `arrangement(...)` 헬퍼로 배치 계산을 위임한다. private struct DVChipFlow: Layout { + struct Cache { + var proposedWidth: CGFloat? + var sizes: [CGSize] = [] + } + let hSpacing: CGFloat let vSpacing: CGFloat @@ -97,65 +109,81 @@ private struct DVChipFlow: Layout { self.vSpacing = vSpacing } + func makeCache(subviews: Subviews) -> Cache { Cache() } + func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, - cache: inout () + 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 — chip이 자연 너비가 - // 초과할 경우 tail truncate 되어 항상 한 줄에 최소 하나는 들어감. - let chipProposal = ProposedViewSize(width: maxWidth, height: nil) + /// 각 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, subview) in subviews.enumerated() { - let size = subview.sizeThatFits(chipProposal) + 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 < subviews.count - 1 { + if index < sizes.count - 1 { currentX += hSpacing } } - return CGSize(width: totalWidth, height: currentY + lineHeight) - } - - func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) { - let chipProposal = ProposedViewSize(width: bounds.width, height: nil) - var currentX: CGFloat = bounds.minX - var currentY: CGFloat = bounds.minY - var lineHeight: CGFloat = 0 - - for (index, subview) in subviews.enumerated() { - let size = subview.sizeThatFits(chipProposal) - if index > 0 && currentX + size.width > bounds.maxX { - currentY += lineHeight + vSpacing - currentX = bounds.minX - lineHeight = 0 - } - subview.place( - at: CGPoint(x: currentX, y: currentY), - proposal: chipProposal - ) - currentX += size.width + hSpacing - lineHeight = max(lineHeight, size.height) - } + return (positions, CGSize(width: totalWidth, height: currentY + lineHeight)) } }