diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index dc61b73..bcb9e0b 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -17,7 +17,7 @@ struct ContentView: View { Section(section.title) { ForEach(section.components, id: \.self) { component in NavigationLink(component.name) { - ComponentPlaceholderView(name: component.name, owner: component.owner) + detailView(for: component) } } } @@ -30,6 +30,16 @@ struct ContentView: View { } .frame(minWidth: 700, minHeight: 500) } + + @ViewBuilder + private func detailView(for component: Component) -> some View { + switch component.name { + case "DVRadioButton", "DVRadioButtonGroup": + RadioButtonPreviewView() + default: + ComponentPlaceholderView(name: component.name, owner: component.owner) + } + } } // MARK: - Placeholder Detail diff --git a/Projects/DVDesign/SampleApp/Sources/RadioButtonPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/RadioButtonPreviewView.swift new file mode 100644 index 0000000..8e6e44b --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/RadioButtonPreviewView.swift @@ -0,0 +1,103 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI +import DVDesign + +struct RadioButtonPreviewView: View { + @State private var singleSelected: Bool = false + @State private var xsSelection: Environment = .dev + @State private var smSelection: Environment = .staging + @State private var mdSelection: Environment = .prod + + private enum Environment: Hashable { + case dev, staging, prod + } + + private var items: [DVRadioButtonGroup.Item] { + [ + .init(.dev, title: "Dev"), + .init(.staging, title: "Staging"), + .init(.prod, title: "Prod"), + ] + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + section(title: "DVRadioButton") { + HStack(spacing: 40) { + labeled("Default") { + DVRadioButton("Dev", isSelected: false, action: {}) + } + labeled("Selected") { + DVRadioButton("Dev", isSelected: true, action: {}) + } + labeled("Interactive") { + DVRadioButton( + "Tap me", + isSelected: singleSelected + ) { + singleSelected.toggle() + } + } + } + } + + section(title: "DVRadioButtonGroup") { + VStack(alignment: .leading, spacing: 20) { + labeled("XS (spacing 8)") { + DVRadioButtonGroup( + items: items, + selection: $xsSelection, + size: .xs + ) + } + labeled("SM (spacing 20)") { + DVRadioButtonGroup( + items: items, + selection: $smSelection, + size: .sm + ) + } + labeled("MD (spacing 56)") { + DVRadioButtonGroup( + items: items, + selection: $mdSelection, + size: .md + ) + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("RadioButton") + } + + @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/RadioButton/DVRadioButton.swift b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButton.swift new file mode 100644 index 0000000..40b27d4 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButton.swift @@ -0,0 +1,179 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// Devault 디자인 시스템 스타일의 단일 라디오 버튼. +/// +/// `DVRadioButton`은 상호배타적 선택지(mutually-exclusive set) 중 하나의 +/// 옵션을 표현합니다. 16×16 원형 인디케이터와 라벨을 가로로 배치하고, +/// 사용자 활성화 이벤트는 `action` 클로저로 전달합니다. 컴포넌트 자체는 +/// **상태를 보유하지 않으며(stateless)**, `isSelected` 값과 그에 따른 +/// 갱신 책임은 호출 측에 있습니다. +/// +/// ## 시각 상태 +/// +/// | 상태 | 인디케이터 | +/// |------|-----------| +/// | 기본 (`isSelected == false`) | ``DVColor/gray300`` 으로 채워진 16pt 원 | +/// | 선택 (`isSelected == true`) | ``DVColor/vaultGreen`` 으로 채워진 16pt 원 + 가운데 5pt 흰색 점 | +/// +/// 모든 색상은 ``DVColor`` 토큰을 통해 해석되므로, 에셋 카탈로그에 +/// 라이트/다크 변형이 정의되면 자동으로 적응합니다. +/// +/// ## 인터랙션 (macOS) +/// +/// - **마우스**: 패딩된 HStack 영역 — 인디케이터, 3pt 갭, 라벨 텍스트, +/// 2/4pt 외곽 패딩 — 어디를 클릭해도 `action`이 호출됩니다. 시각 인디케이터는 +/// 16pt 원을 유지하면서 hit target은 세로 24pt로 확대되어 macOS 권장 +/// 클릭 영역을 충족합니다. +/// - **키보드**: focusable 이므로 Tab으로 포커스할 수 있고, Space로 +/// 활성화됩니다. 시스템 기본 포커스 링은 `focusEffectDisabled()`로 +/// 숨기며, 선택 여부는 인디케이터의 색 변화로만 표현하는 것이 의도된 +/// 디자인입니다. +/// - **호버**: 호버 효과를 적용하지 않습니다 — native `NSButton` 라디오 +/// 컨트롤의 외관과 일치합니다. +/// - **VoiceOver**: `.isButton` 트레이트를 가지며, `isSelected == true`일 +/// 때 `.isSelected` 트레이트가 추가됩니다. +/// +/// ## 그룹으로 묶기 +/// +/// 여러 옵션 간 상호배타 선택이 필요할 때는 개별 `DVRadioButton`을 손수 +/// 연결하기보다 ``DVRadioButtonGroup``을 사용하세요. 그룹은 화살표 키 +/// 네비게이션과 디자인 토큰 기반 간격을 함께 제공합니다. +/// +/// ```swift +/// DVRadioButton("Dev", isSelected: env == .dev) { +/// env = .dev +/// } +/// ``` +/// +/// ## 커스텀 라벨 +/// +/// 단순 텍스트 이상의 라벨(아이콘, 다중 줄, 다른 타이포그래피 등)이 +/// 필요할 때는 후행 클로저 이니셜라이저를 사용합니다. +/// +/// ```swift +/// DVRadioButton(isSelected: agreed, action: { agreed.toggle() }) { +/// HStack(spacing: 4) { +/// Image(systemName: "checkmark.seal") +/// Text("동의합니다") +/// } +/// } +/// ``` +public struct DVRadioButton: View { + private let isSelected: Bool + private let action: () -> Void + private let label: () -> Label + + /// 임의의 라벨 뷰를 갖는 라디오 버튼을 생성합니다. + /// + /// 라벨이 단순 텍스트 `String`이라면 ``init(_:isSelected:action:)``을 + /// 사용하는 편이 좋습니다 — 디자인 시스템 타이포그래피가 자동으로 + /// 적용됩니다. 아이콘, 서식 있는 텍스트, 혼합 콘텐츠 등 커스텀 라벨이 + /// 필요한 경우에 이 이니셜라이저를 사용하세요. + /// + /// - Parameters: + /// - isSelected: 현재 선택 여부. 인디케이터의 색과 `.isSelected` + /// 접근성 트레이트를 결정합니다. + /// - action: 클릭/탭 또는 포커스 상태에서 **Space** 키를 누를 때 + /// 호출되는 클로저. 호출자가 `isSelected`의 원천 값을 직접 + /// 갱신해야 합니다. + /// - label: 인디케이터 우측에 3pt 간격으로 렌더링될 라벨을 만드는 + /// 뷰 빌더. + public init( + isSelected: Bool, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.isSelected = isSelected + self.action = action + self.label = label + } + + public var body: some View { + HStack(spacing: 3) { + indicator + label() + } + .padding(.vertical, 4) + .padding(.horizontal, 2) + .contentShape(Rectangle()) + .onTapGesture(perform: action) + .focusable(true) + .focusEffectDisabled() + .onKeyPress(.space) { + action() + return .handled + } + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + + private var indicator: some View { + ZStack { + Circle() + .fill(isSelected ? Color.dv(.vaultGreen) : Color.dv(.gray300)) + .frame(width: 16, height: 16) + if isSelected { + Circle() + .fill(Color.dv(.white)) + .frame(width: 5, height: 5) + } + } + } +} + +extension DVRadioButton where Label == Text { + /// 디자인 시스템 타이포그래피가 적용된 단순 텍스트 라벨 라디오 버튼을 + /// 생성합니다. + /// + /// 라벨은 ``DVFont/bodyMD`` (SF Pro 13pt medium)와 ``DVColor/gray900``으로 + /// 렌더링됩니다. 일반 제품 UI에서 가장 흔하게 쓰이는 형태이며, 텍스트가 + /// 아닌 콘텐츠가 필요할 때만 ``init(isSelected:action:label:)``을 사용하세요. + /// + /// ```swift + /// DVRadioButton("Staging", isSelected: env == .staging) { + /// env = .staging + /// } + /// ``` + /// + /// - Parameters: + /// - title: 인디케이터 옆에 표시될 텍스트. + /// - isSelected: 선택 상태 — ``init(isSelected:action:label:)`` 참고. + /// - action: 활성화 핸들러 — ``init(isSelected:action:label:)`` 참고. + public init( + _ title: String, + isSelected: Bool, + action: @escaping () -> Void + ) { + self.init(isSelected: isSelected, action: action) { + Text(title) + .font(DVFont.bodyMD.font) + .foregroundStyle(Color.dv(.gray900)) + } + } +} + +#Preview("Default") { + DVRadioButton("Dev", isSelected: false, action: {}) + .padding() +} + +#Preview("Selected") { + DVRadioButton("Dev", isSelected: true, action: {}) + .padding() +} + +#Preview("Interactive") { + DVRadioButtonInteractivePreview() + .padding() +} + +private struct DVRadioButtonInteractivePreview: View { + @State private var isSelected = false + + var body: some View { + DVRadioButton("Tap me", isSelected: isSelected) { + isSelected.toggle() + } + } +} diff --git a/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift new file mode 100644 index 0000000..d00f147 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift @@ -0,0 +1,218 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// 상호배타적으로 선택되는 ``DVRadioButton`` 옵션들을 가로로 배열한 그룹. +/// +/// `DVRadioButtonGroup`은 하나의 선택 값을 `Binding`으로 받아 ``Item``마다 +/// 한 개의 ``DVRadioButton``을 렌더링하고, 선택된 ``Size`` 변형에 따라 +/// 간격과 최소 너비를 적용합니다. 사용자가 클릭, 탭, 또는 키보드로 어떤 +/// 라디오를 활성화하면 그 값이 바인딩에 기록됩니다. +/// +/// ## 선택 바인딩 +/// +/// `Value`는 `Hashable`을 만족하는 어떤 타입이든 될 수 있습니다 — 문자열 +/// 식별자, enum 케이스, 숫자 id 등. 그룹은 `id`가 `selection`과 일치하는 +/// 라디오를 선택된 상태로 표시합니다. 호출자는 `@State`, `@AppStorage`, +/// 또는 뷰모델 `Binding`을 통해 원천 데이터를 소유합니다. +/// +/// ```swift +/// enum Env: Hashable { case dev, staging, prod } +/// +/// struct EnvironmentPicker: View { +/// @State private var env: Env = .dev +/// +/// var body: some View { +/// DVRadioButtonGroup( +/// items: [ +/// .init(.dev, title: "Dev"), +/// .init(.staging, title: "Staging"), +/// .init(.prod, title: "Prod"), +/// ], +/// selection: $env, +/// size: .sm +/// ) +/// } +/// } +/// ``` +/// +/// `selection`이 `items`에 없는 값으로 설정되면 어떤 라디오도 선택된 +/// 상태로 보이지 않습니다 — 의도적인 중간 상태로 쓸 수도 있지만 보통은 +/// 바인딩 구성이 잘못된 경우입니다. +/// +/// ## 사이즈 +/// +/// 주변 레이아웃의 시각적 리듬에 맞춰 변형을 선택하세요. +/// +/// - ``Size/xs`` — 좁은 밀도 (간격 8pt, 최소 너비 180pt) +/// - ``Size/sm`` — 기본 (간격 20pt, 최소 너비 330pt) +/// - ``Size/md`` — 넓은 밀도 (간격 56pt, 최소 너비 380pt) +/// +/// `minWidth`는 윈도우 크기가 변할 때 그룹이 디자인 footprint 아래로 +/// 줄어들지 않도록 보장하는 하한선입니다. +/// +/// ## 키보드 네비게이션 (macOS) +/// +/// 그룹은 `NSMatrix` 라디오 컨트롤의 동작 규약을 따릅니다. +/// +/// - **Tab**: 그룹의 첫 라디오로 포커스가 진입합니다. 다음 Tab은 그룹을 +/// 벗어나 다음 컨트롤로 이동합니다. +/// - **← / →**: 인접 라디오로 포커스와 **선택**을 함께 이동합니다. +/// 양 끝에서 한 번 더 누르면 wrap되지 않고 그대로 머뭅니다. +/// - **Space**: 포커스된 라디오를 활성화합니다 (``DVRadioButton``과 동일). +/// +/// 마우스 클릭은 그룹의 내부 포커스 anchor를 함께 갱신하므로, 다음에 +/// 누르는 화살표 키는 클릭한 위치를 기준으로 동작합니다. +public struct DVRadioButtonGroup: View { + private let items: [Item] + @Binding private var selection: Value + private let size: Size + @FocusState private var focusedValue: Value? + + /// 라디오 그룹을 생성합니다. + /// + /// - Parameters: + /// - items: 가로 순서로 렌더링될 옵션 배열. 각 ``Item``은 `selection`과 + /// 비교될 `Value`와 화면에 표시될 `title`을 갖습니다. 같은 그룹 안의 + /// `Value`는 중복되어선 안 됩니다. + /// - selection: 현재 선택된 값에 대한 양방향 바인딩. 사용자가 어떤 + /// 라디오를 활성화하면 그룹이 이 바인딩에 쓰기를 수행합니다. + /// - size: 간격과 최소 너비 변형. 기본값은 ``Size/sm``. + public init( + items: [Item], + selection: Binding, + size: Size = .sm + ) { + self.items = items + self._selection = selection + self.size = size + } + + public var body: some View { + HStack(spacing: size.spacing) { + ForEach(items) { item in + DVRadioButton(item.title, isSelected: selection == item.id) { + selection = item.id + focusedValue = item.id + } + .focused($focusedValue, equals: item.id) + } + } + .frame(minWidth: size.minWidth, alignment: .leading) + .onKeyPress(.leftArrow) { moveSelection(by: -1) } + .onKeyPress(.rightArrow) { moveSelection(by: +1) } + } + + private func moveSelection(by offset: Int) -> KeyPress.Result { + let anchor = focusedValue ?? selection + guard let currentIndex = items.firstIndex(where: { $0.id == anchor }) else { + return .ignored + } + let nextIndex = currentIndex + offset + guard items.indices.contains(nextIndex) else { return .handled } + let nextId = items[nextIndex].id + selection = nextId + focusedValue = nextId + return .handled + } +} + +extension DVRadioButtonGroup { + /// ``DVRadioButtonGroup`` 안에 렌더링되는 단일 옵션. + /// + /// `Item`은 그룹의 `selection` 바인딩과 비교되는 `Value`와 사용자가 + /// 읽는 `title`을 묶은 값 타입입니다. `Value`가 그대로 `id`로 + /// 사용되어 `ForEach` 순회의 키가 되므로, 같은 그룹 안에서 값이 + /// 중복되지 않아야 합니다. + /// + /// ```swift + /// DVRadioButtonGroup.Item("staging", title: "Staging") + /// ``` + public struct Item: Identifiable { + /// 이 옵션을 식별하는 고유 값. 그룹의 `selection` 바인딩과 + /// 비교되어 선택 상태를 결정합니다. + public let id: Value + + /// 라디오 인디케이터 우측에 표시되는 텍스트 라벨. + public let title: String + + /// 옵션을 생성합니다. + /// + /// - Parameters: + /// - value: 이 옵션의 고유 식별값. 같은 그룹의 다른 옵션과 중복 + /// 되어선 안 됩니다. + /// - title: 인디케이터 옆에 ``DVFont/bodyMD`` 타이포그래피로 + /// 렌더링될 텍스트. + public init(_ value: Value, title: String) { + self.id = value + self.title = title + } + } + + /// ``DVRadioButtonGroup``의 레이아웃 사이즈 변형. + /// + /// 각 케이스는 라디오 사이의 가로 간격(``spacing``)과 그룹 HStack의 + /// 최소 너비 하한선(``minWidth``)을 Devault Figma 스펙에 맞춰 함께 + /// 고정합니다. + public enum Size { + /// 좁은 밀도: 간격 8pt, 최소 너비 180pt. + case xs + + /// 기본 밀도: 간격 20pt, 최소 너비 330pt. + case sm + + /// 넓은 밀도: 간격 56pt, 최소 너비 380pt. + case md + + /// 인접한 라디오 버튼 사이에 삽입되는 가로 간격 (포인트 단위). + public var spacing: CGFloat { + switch self { + case .xs: return 8 + case .sm: return 20 + case .md: return 56 + } + } + + /// 그룹 HStack에 적용되는 최소 너비 (포인트 단위). 윈도우 크기가 + /// 줄어들 때 그룹이 디자인 footprint 아래로 압축되지 않도록 합니다. + public var minWidth: CGFloat { + switch self { + case .xs: return 180 + case .sm: return 330 + case .md: return 380 + } + } + } +} + +#Preview("XS (spacing 8)") { + DVRadioButtonGroupPreview(size: .xs) + .padding(24) +} + +#Preview("SM (spacing 20)") { + DVRadioButtonGroupPreview(size: .sm) + .padding(24) +} + +#Preview("MD (spacing 56)") { + DVRadioButtonGroupPreview(size: .md) + .padding(24) +} + +private struct DVRadioButtonGroupPreview: View { + let size: DVRadioButtonGroup.Size + @State private var selection: String = "staging" + + var body: some View { + DVRadioButtonGroup( + items: [ + .init("dev", title: "Dev"), + .init("staging", title: "Staging"), + .init("prod", title: "Prod"), + ], + selection: $selection, + size: size + ) + } +}