diff --git a/README.md b/README.md index 7f558dd..b378873 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ a component already exists before building your own. | `DashButton` | Themed button — 4 sizes × 11 styles, icons, loading, full-width | [Buttons & inputs](docs/buttons-and-inputs.md) | | `DashSwitch` | DS-tinted toggle | [Buttons & inputs](docs/buttons-and-inputs.md) | | `SearchBar` | Search field with animated cancel (iOS 15+) and iOS 14 fallback | [Buttons & inputs](docs/buttons-and-inputs.md) | -| `AddressFieldView` | Crypto-address text field with QR / clear and error state | [Buttons & inputs](docs/buttons-and-inputs.md) | +| `AddressFieldView` | Labeled text field (label / field / help text) with optional QR, clear and error state | [Buttons & inputs](docs/buttons-and-inputs.md) | +| `Criteria` | Requirement check-list — `Criterion` rows in six states | [Buttons & inputs](docs/buttons-and-inputs.md) | | `NumericKeyboardView` | Locale-aware on-screen number pad with action button | [Buttons & inputs](docs/buttons-and-inputs.md) | | `EnterAmountView` | Amount-entry screen control — single & dual-swap modes | [Amount & currency](docs/amount-and-currency.md) | | `SwapAmountView` | Animated primary/secondary amount display | [Amount & currency](docs/amount-and-currency.md) | diff --git a/Sources/DashUIKit/Components/AddressFieldView.swift b/Sources/DashUIKit/Components/AddressFieldView.swift index 44aecc2..f87467f 100644 --- a/Sources/DashUIKit/Components/AddressFieldView.swift +++ b/Sources/DashUIKit/Components/AddressFieldView.swift @@ -26,6 +26,10 @@ private enum Layout { static let iconSize: CGFloat = 17 static let cornerRadius: CGFloat = 16 static let actionTapArea: CGFloat = 40 + /// Width of the focus / error ring, drawn hard-edged outside the field — + /// Figma's `shadow: 0 0 0 3px`, which is a 3pt spread with no blur and no + /// offset. + static let ringWidth: CGFloat = 3 } @available(iOS 15, macOS 12, *) @@ -44,6 +48,20 @@ public struct AddressFieldView: View { /// something that describes the field rather than acts on it; the /// controls that act live inside the field itself. private let accessory: Accessory + /// Lets the host raise and dismiss the keyboard — focus on appear, blur + /// before presenting a sheet. The field still owns its `@FocusState`; this + /// mirrors it in both directions so the host does not need one. `nil` means + /// the field is the only one deciding, which is what every existing caller + /// does. + private var isFocused: Binding? + /// Whether the trailing ✕ is offered once there is text. A long address + /// needs it; a short field the user can clear by hand does not, and there + /// it is only one more thing to hit by accident. + private let showsClearButton: Bool + /// The content has passed whatever the host validates it against, so the + /// field stops asking for attention: the focus ring is dropped. An error + /// still overrides it — a name can be well-formed and still refused. + private let isAccepted: Bool @FocusState private var isTextFieldFocused: Bool @@ -56,6 +74,9 @@ public struct AddressFieldView: View { isDisabled: Bool = false, onScanQR: (() -> Void)? = nil, onPaste: (() -> Void)? = nil, + isFocused: Binding? = nil, + showsClearButton: Bool = true, + isAccepted: Bool = false, @ViewBuilder accessory: () -> Accessory ) { self._text = text @@ -66,6 +87,9 @@ public struct AddressFieldView: View { self.isDisabled = isDisabled self.onScanQR = onScanQR self.onPaste = onPaste + self.isFocused = isFocused + self.showsClearButton = showsClearButton + self.isAccepted = isAccepted self.accessory = accessory() } @@ -107,6 +131,16 @@ public struct AddressFieldView: View { RoundedRectangle(cornerRadius: Layout.cornerRadius) .stroke(borderColor, lineWidth: borderWidth) ) + // Not `.shadow`: with no blur and no offset a shadow lands exactly + // under the field and is never seen, and in the focused state the + // background is clear, so what little showed would be cast by the + // text rather than by the field's outline. A stroke on the outward + // inset is the ring the design draws. + .overlay( + RoundedRectangle(cornerRadius: Layout.cornerRadius + Layout.ringWidth / 2, style: .continuous) + .inset(by: -Layout.ringWidth / 2) + .stroke(ringColor, lineWidth: Layout.ringWidth) + ) if let errorText { Text(errorText) @@ -114,6 +148,18 @@ public struct AddressFieldView: View { .foregroundStyle(Color.dash.errorText) } } + .onAppear { + // A host that wants the keyboard up on entry sets its flag before + // this field exists, so the mirror below never fires for it. + if isFocused?.wrappedValue == true { isTextFieldFocused = true } + } + .onChange(of: isTextFieldFocused) { focused in + if isFocused?.wrappedValue != focused { isFocused?.wrappedValue = focused } + } + .onChange(of: isFocused?.wrappedValue) { requested in + guard let requested, requested != isTextFieldFocused else { return } + isTextFieldFocused = requested + } } } @@ -130,7 +176,10 @@ public extension AddressFieldView where Accessory == EmptyView { errorText: String? = nil, isDisabled: Bool = false, onScanQR: (() -> Void)? = nil, - onPaste: (() -> Void)? = nil + onPaste: (() -> Void)? = nil, + isFocused: Binding? = nil, + showsClearButton: Bool = true, + isAccepted: Bool = false ) { self.init( text: text, @@ -141,6 +190,9 @@ public extension AddressFieldView where Accessory == EmptyView { isDisabled: isDisabled, onScanQR: onScanQR, onPaste: onPaste, + isFocused: isFocused, + showsClearButton: showsClearButton, + isAccepted: isAccepted, accessory: { EmptyView() }) } } @@ -197,14 +249,20 @@ extension AddressFieldView { private var actionButton: some View { Group { if text.isEmpty { - Button(action: { onScanQR?() }) { - DashIcon.Other.textFieldQR.image - .resizable() - .scaledToFit() - .frame(width: Layout.iconSize, height: Layout.iconSize) + // Only when there is somewhere for the tap to go. A field for + // something that has no QR form — a username, say — passes no + // handler, and drawing the glyph anyway offered a control that + // did nothing. + if let onScanQR { + Button(action: onScanQR) { + DashIcon.Other.textFieldQR.image + .resizable() + .scaledToFit() + .frame(width: Layout.iconSize, height: Layout.iconSize) + } + .accessibilityLabel(NSLocalizedString("Scan QR code", bundle: .module, comment: "DashUIKit")) } - .accessibilityLabel(NSLocalizedString("Scan QR code", bundle: .module, comment: "DashUIKit")) - } else { + } else if showsClearButton { Button(action: { text = "" }) { DashIcon.Other.textFieldClear.image .renderingMode(.template) @@ -216,10 +274,20 @@ extension AddressFieldView { .accessibilityLabel(NSLocalizedString("Clear address", bundle: .module, comment: "DashUIKit")) } } - .frame(width: Layout.actionTapArea, height: Layout.actionTapArea) + // The tap area is reserved rather than sized to whichever control is + // showing, so the field's width does not shift as the user types. A + // field that can never show either control reserves nothing. + .frame( + width: hasTrailingControl ? Layout.actionTapArea : 0, + height: Layout.actionTapArea) .contentShape(Rectangle()) } + /// Whether any trailing control can appear in this field's configuration. + private var hasTrailingControl: Bool { + onScanQR != nil || showsClearButton + } + // MARK: - Styling private var backgroundColor: Color { @@ -236,6 +304,17 @@ extension AddressFieldView { isFocusedState ? 1 : 0 } + /// The ring around the field, as the design system draws it: red while + /// something is wrong, blue while the user is working in the field, and + /// nothing once the content has been accepted — at that point the field + /// has no more to say and a glow would keep drawing the eye to a question + /// already answered. + private var ringColor: Color { + if hasError { return Color.dash.redAlpha20 } + if isFocusedState, !isAccepted { return Color.dash.blueAlpha20 } + return .clear + } + private var isFocusedState: Bool { isTextFieldFocused && !isDisabled } @@ -265,6 +344,32 @@ extension AddressFieldView { .padding() } +@available(iOS 17, macOS 14, *) +#Preview("Empty, no scan handler") { + // Nothing to scan — a username, a label, a note. No trailing glyph is + // drawn, where before an inert QR button was. + AddressFieldView( + text: .constant(""), + label: "Username", + placeholder: "Enter a username", + hasError: false, errorText: nil + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Accepted — no ring, no clear button") { + AddressFieldView( + text: .constant("satoshi"), + label: "Username", + placeholder: "Enter a username", + hasError: false, errorText: nil, + showsClearButton: false, + isAccepted: true + ) + .padding() +} + @available(iOS 17, macOS 14, *) #Preview("Empty with Paste button") { AddressFieldView( diff --git a/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift index 77dfdcd..06edb3f 100644 --- a/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift @@ -67,7 +67,9 @@ public struct BottomSheet: View { grabber .frame(maxWidth: .infinity, minHeight: 18, maxHeight: 18, alignment: .center) - header + if hasHeaderContent { + header + } contentSection } @@ -122,6 +124,15 @@ public struct BottomSheet: View { hasCustomCloseAction: onClose != nil) } + /// A header with no title, no back button and no close button is 64pt of + /// nothing: it pushes the content down, and on a self-sizing sheet that + /// blank strip becomes part of the sheet's own measured height. Collapse + /// it rather than reserve it. Any one of the three brings it back, and the + /// binding means a sheet can gain the header mid-flight. + private var hasHeaderContent: Bool { + !title.isEmpty || showBackButton || showsCloseButton + } + private var header: some View { NavigationBar( leading: { diff --git a/Sources/DashUIKit/Components/Criteria.swift b/Sources/DashUIKit/Components/Criteria.swift new file mode 100644 index 0000000..0107b2e --- /dev/null +++ b/Sources/DashUIKit/Components/Criteria.swift @@ -0,0 +1,413 @@ +// +// Created by Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// Outside the views so the rows and the block measure from one place. +private enum Layout { + /// Gap between the mark and the text. + static let gap: CGFloat = 6 + /// The box the mark is centred in. It, not the text, sets the row height. + static let markBox: CGFloat = 26 + /// The mark itself — every icon the design system draws for this row is + /// this size. + static let mark: CGFloat = 19 + /// Padding that lifts the 18pt text line box to the mark box's height. + static let textVerticalPadding: CGFloat = 4 + /// Stroke of the marks drawn here, in the 19pt box. + static let strokeWidth: CGFloat = 2 + /// The unfilled `pending` ring. + static let ringWidth: CGFloat = 1.5 +} + +// MARK: - CriterionState + +/// How one requirement currently stands. +@available(iOS 14, macOS 11, *) +public enum CriterionState: Equatable { + /// Stated but not evaluated yet — an empty ring. + case pending + /// Being evaluated — a spinner in the mark's place. + case checking + /// Satisfied — green disc, white tick. + case met + /// Not satisfied — red disc, white cross. + case failed + /// Not satisfied, and it is the reason the user cannot continue — the same + /// red disc, with the text in the error colour. + case blocking + /// Satisfied, but worth reading — yellow disc, white exclamation. + case warning +} + +// MARK: - Criterion + +/// One requirement in a ``Criteria`` block. +@available(iOS 14, macOS 11, *) +public struct Criterion: Identifiable { + /// What the row's mark slot holds — one of the standard discs, or the + /// caller's own icon. + enum Mark { + case state(CriterionState) + case icon(DashIconSource) + } + + public let id: String + public let text: String + let mark: Mark + + /// `id` defaults to `text`, which is what distinguishes the rows in + /// practice; pass one explicitly when a row's text changes while the row + /// stays the same (an availability line, say), so it animates in place + /// instead of being replaced. Two rows that share an `id` still render + /// as two rows — ``Criteria`` tells repeats apart itself. + public init(id: String? = nil, text: String, state: CriterionState) { + self.id = id ?? text + self.text = text + self.mark = .state(state) + } + + /// The design system's icon slot, for a row whose mark is none of the + /// standard discs. Rendered in the same 19pt box, untinted — the artwork + /// carries its own colour, exactly as the Figma slot does. + public init(id: String? = nil, text: String, icon: DashIconSource) { + self.id = id ?? text + self.text = text + self.mark = .icon(icon) + } +} + +// MARK: - Criteria + +/// A block of requirements with their status, as under a username or password +/// field: one mark per row saying whether that rule is met. +/// +/// Stateless — the host decides which rules exist, what they say and how each +/// one currently stands. A rule that should not be shown is simply not passed, +/// which is also how the design system models it: there is no "hidden" mark. +/// +/// ```swift +/// Criteria([ +/// Criterion(text: "Between 3 and 23 characters", state: .met), +/// Criterion(text: "Letters, numbers and hyphens only", state: .failed), +/// Criterion(text: "Username available", state: .checking), +/// ]) +/// ``` +@available(iOS 14, macOS 11, *) +public struct Criteria: View { + private let items: [Criterion] + private let spacing: CGFloat + + /// - Parameters: + /// - items: the requirements, in the order they should read. + /// - spacing: gap between rows. The default is the design's 10pt. + public init(_ items: [Criterion], spacing: CGFloat = 10) { + self.items = items + self.spacing = spacing + } + + public var body: some View { + VStack(alignment: .leading, spacing: spacing) { + ForEach(renderIdentities, id: \.key) { entry in + CriterionRow(criterion: entry.item) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Each item's `id`, made unique. A repeated `id` — two rules with the + /// same text and no explicit id — would give SwiftUI two rows with one + /// identity, and updates could land on the wrong one. The first + /// occurrence keeps its bare `id`, so a row with a stable explicit id + /// still animates in place; a later repeat takes the lowest `#n` suffix + /// that is neither another row's own id nor already handed out, so no + /// generated key can collide with an explicit one (`["A", "A", "A#1"]` + /// renders as `A`, `A#2`, `A#1`). + private var renderIdentities: [(key: String, item: Criterion)] { + var used = Set(items.map(\.id)) + var firstSeen = Set() + return items.map { item in + if firstSeen.insert(item.id).inserted { + return (item.id, item) + } + var n = 1 + while used.contains("\(item.id)#\(n)") { n += 1 } + let key = "\(item.id)#\(n)" + used.insert(key) + return (key, item) + } + } +} + +// MARK: - Row + +@available(iOS 14, macOS 11, *) +private struct CriterionRow: View { + let criterion: Criterion + + var body: some View { + HStack(alignment: .top, spacing: Layout.gap) { + mark + .frame(width: Layout.markBox, height: Layout.markBox) + + Text(criterion.text) + .dashFont(.footnote) + .foregroundColor(textColor) + // Without this a two-line requirement is truncated to one: + // the row offers the text its ideal single-line height. + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, Layout.textVerticalPadding) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(criterion.text)) + .accessibilityValue(Text(accessibilityValue)) + } + + // MARK: Mark + + @ViewBuilder private var mark: some View { + switch criterion.mark { + case .icon(let source): + Image(dash: source) + .resizable() + .scaledToFit() + .frame(width: Layout.mark, height: Layout.mark) + case .state(let state): + switch state { + case .pending: + // `strokeBorder` insets by half the line width, so a 19pt + // circle strokes at r 8.75 and still reaches 19pt across — + // the design system's default icon to the point. + Circle() + .strokeBorder(Color.dash.gray300Alpha50, lineWidth: Layout.ringWidth) + .frame(width: Layout.mark, height: Layout.mark) + case .checking: + // In the mark's own box, so the rows do not shift while a + // check is running. + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .frame(width: Layout.mark, height: Layout.mark) + case .met: + disc(Color.dash.green) { + CheckShape() + .stroke( + Color.dash.white, + style: StrokeStyle( + lineWidth: Layout.strokeWidth, + lineCap: .round, + lineJoin: .round)) + } + case .failed, .blocking: + disc(Color.dash.red) { + // The design's cross spans 5pt of the 19pt disc, and + // `XmarkIcon` insets its stroke to 7/9 of `size` — the + // same symmetric shape, so 5 ÷ (7/9) reproduces it. + XmarkIcon( + size: Layout.mark * 5 / 19 / (7.0 / 9.0), + color: Color.dash.white, + lineWidth: Layout.strokeWidth) + } + case .warning: + disc(Color.dash.yellow) { + ExclamationShape() + .stroke( + Color.dash.white, + style: StrokeStyle( + lineWidth: Layout.strokeWidth, + lineCap: .round, + lineJoin: .round)) + } + } + } + } + + private func disc( + _ color: Color, + @ViewBuilder glyph: () -> Glyph + ) -> some View { + ZStack { + Circle().fill(color) + glyph() + } + .frame(width: Layout.mark, height: Layout.mark) + } + + // MARK: Styling + + private var textColor: Color { + switch criterion.mark { + case .icon: + return Color.dash.primaryText + case .state(let state): + // Only `blocking` colours the text: it is the one state that says + // "this is why you cannot continue" rather than "this rule is not + // met yet". + return state == .blocking ? Color.dash.errorText : Color.dash.primaryText + } + } + + /// What VoiceOver reads after the requirement itself. The row combines its + /// children, so without this the mark is silent and every state sounds + /// identical. + private var accessibilityValue: String { + guard case .state(let state) = criterion.mark else { return "" } + switch state { + case .pending: + return NSLocalizedString("Not checked yet", bundle: .module, comment: "DashUIKit") + case .checking: + return NSLocalizedString("Checking", bundle: .module, comment: "DashUIKit") + case .met: + return NSLocalizedString("Met", bundle: .module, comment: "DashUIKit") + case .failed: + return NSLocalizedString("Not met", bundle: .module, comment: "DashUIKit") + case .blocking: + // Not just unmet: this is the rule that stops the user going on, + // which the red text says to a sighted user. + return NSLocalizedString("Not met, required to continue", bundle: .module, comment: "DashUIKit") + case .warning: + return NSLocalizedString("Met, with a warning", bundle: .module, comment: "DashUIKit") + } + } +} + +// MARK: - Marks drawn here + +/// The tick inside the `met` disc, from the design system's own 19-unit +/// artwork (`M6.78571 9.94668L8.65314 12.2143L12.2143 7.69048`). +/// +/// `CheckmarkIcon` is deliberately not reused: it comes from a different +/// source SVG (15×12, polyline aspect 0.746) while this tick is 5.43 × 4.52 — +/// aspect 0.832. Scaled to the right width it would draw about a tenth +/// flatter than the design. +@available(iOS 14, macOS 11, *) +private struct CheckShape: Shape { + private static let viewBox: CGFloat = 19 + private static let points = [ + CGPoint(x: 6.78571, y: 9.94668), + CGPoint(x: 8.65314, y: 12.2143), + CGPoint(x: 12.2143, y: 7.69048), + ] + + func path(in rect: CGRect) -> Path { + var path = Path() + let scaled = Self.points.map { point in + CGPoint( + x: rect.minX + rect.width * point.x / Self.viewBox, + y: rect.minY + rect.height * point.y / Self.viewBox) + } + path.move(to: scaled[0]) + path.addLine(to: scaled[1]) + path.addLine(to: scaled[2]) + return path + } +} + +/// The exclamation inside the `warning` disc. +/// +/// TODO(criteria-warning): the design system draws `Default`, `Xmark` and +/// `Checkmark` only — there is no warning icon to mirror yet, so this one is +/// drawn to match their weight and proportions in the same 19-unit box. Swap +/// it for the artwork once the design system has it. +@available(iOS 14, macOS 11, *) +private struct ExclamationShape: Shape { + private static let viewBox: CGFloat = 19 + /// The stem, top to bottom, and then the dot as a zero-length segment the + /// round cap turns into a circle — the same trick the source SVGs use. + private static let stem = (top: CGPoint(x: 9.5, y: 5), bottom: CGPoint(x: 9.5, y: 10.5)) + private static let dot = CGPoint(x: 9.5, y: 13.75) + + func path(in rect: CGRect) -> Path { + func place(_ point: CGPoint) -> CGPoint { + CGPoint( + x: rect.minX + rect.width * point.x / Self.viewBox, + y: rect.minY + rect.height * point.y / Self.viewBox) + } + var path = Path() + path.move(to: place(Self.stem.top)) + path.addLine(to: place(Self.stem.bottom)) + path.move(to: place(Self.dot)) + path.addLine(to: place(Self.dot)) + return path + } +} + +// MARK: - Previews + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview("Every state") { + Criteria([ + Criterion(text: "Pending — not evaluated yet", state: .pending), + Criterion(text: "Checking — a query is in flight", state: .checking), + Criterion(text: "Met — the rule is satisfied", state: .met), + Criterion(text: "Failed — the rule is not satisfied", state: .failed), + Criterion(text: "Blocking — this is why Continue is disabled", state: .blocking), + Criterion(text: "Warning — satisfied, but read this", state: .warning), + ]) + .padding(20) + .background(Color.dash.secondaryBackground) +} + +@available(iOS 17, macOS 14, *) +#Preview("Username rules") { + Criteria([ + Criterion(text: "Between 3 and 23 characters", state: .met), + Criterion(text: "Letters, numbers and hyphens only", state: .failed), + Criterion(text: "You need to have more 0.25 Dash to create this username", state: .met), + Criterion(text: "Username available", state: .checking), + ]) + .padding(20) + .background(Color.dash.secondaryBackground) +} + +@available(iOS 17, macOS 14, *) +#Preview("Wrapping") { + Criteria([ + Criterion( + text: "This username would also require voting — try adding a digit between 2 and 9", + state: .blocking), + Criterion(text: "Username available", state: .met), + ]) + .frame(width: 260) + .padding(20) + .background(Color.dash.secondaryBackground) +} + +@available(iOS 17, macOS 14, *) +#Preview("Icon slot") { + Criteria([ + Criterion(text: "Standard disc", state: .met), + Criterion(text: "The design system's icon slot", icon: .system("bolt.circle.fill")), + ]) + .padding(20) + .background(Color.dash.secondaryBackground) +} + +@available(iOS 17, macOS 14, *) +#Preview("Dark") { + Criteria([ + Criterion(text: "Between 3 and 23 characters", state: .met), + Criterion(text: "Letters, numbers and hyphens only", state: .blocking), + Criterion(text: "Username available", state: .pending), + ]) + .padding(20) + .background(Color.dash.secondaryBackground) + .preferredColorScheme(.dark) +} + +#endif diff --git a/Sources/DashUIKit/Components/SimpleSelect.swift b/Sources/DashUIKit/Components/SimpleSelect.swift new file mode 100644 index 0000000..2e05834 --- /dev/null +++ b/Sources/DashUIKit/Components/SimpleSelect.swift @@ -0,0 +1,122 @@ +// +// Created by Roman Chornyi +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +// MARK: - SimpleSelect + +/// One option in a short list the user picks from: a name, a line saying what +/// picking it means, and a card that carries the selection itself. +/// +/// The card *is* the control — there is no trailing radio or checkmark. The +/// description is part of what is being chosen, so it would be wrong for it to +/// sit outside the hit target; selection is shown by the card's own border and +/// fill instead. +/// +/// For a row with an icon, a trailing value or an explicit radio/checkbox mark, +/// use ``RadioButtonRow``. +@available(iOS 14, macOS 11, *) +public struct SimpleSelect: View { + private let title: String + private let description: String? + private let isSelected: Bool + private let action: () -> Void + + public init( + title: String, + description: String? = nil, + isSelected: Bool, + action: @escaping () -> Void + ) { + self.title = title + self.description = description + self.isSelected = isSelected + self.action = action + } + + public var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.primaryText) + + if let description { + Text(description) + .dashFont(.footnote) + .foregroundColor(Color.dash.secondaryText) + } + } + .multilineTextAlignment(.leading) + // Options stand in a column and are compared with each other, so + // they take the width they are offered rather than each shrinking + // to its own text. + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isSelected ? Color.dash.blueAlpha5 : Color.clear)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke( + isSelected ? Color.dash.blue : Color.dash.gray300Alpha30, + lineWidth: 1.5)) + // An unselected card has no fill, and an unfilled shape takes taps + // only where it is drawn — without this only the text is tappable. + .contentShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview("SimpleSelect") { + struct Harness: View { + @State private var selected = 0 + + var body: some View { + VStack(spacing: 10) { + SimpleSelect( + title: "Shielded balance", + description: "Keeps your username private", + isSelected: selected == 0, + action: { selected = 0 }) + + SimpleSelect( + title: "Dash balance", + description: "Funds will be traceable to your username", + isSelected: selected == 1, + action: { selected = 1 }) + + SimpleSelect( + title: "No description", + isSelected: selected == 2, + action: { selected = 2 }) + } + .padding(20) + .background(Color.dash.primaryBackground) + } + } + + return Harness() +} + +#endif diff --git a/Sources/DashUIKit/Components/SystemMessageView.swift b/Sources/DashUIKit/Components/SystemMessageView.swift index 67752c8..666f786 100644 --- a/Sources/DashUIKit/Components/SystemMessageView.swift +++ b/Sources/DashUIKit/Components/SystemMessageView.swift @@ -29,6 +29,10 @@ public struct SystemMessageView: View { public let secondaryButtonName: String? public let onSecondaryAction: (() -> Void)? public let onClose: (() -> Void)? + /// Look of the primary action. A filled pill is right when the message + /// asks for something; a message that merely points somewhere else reads + /// better with `.plainBlue`, which is a link rather than a call to action. + public let buttonStyle: DashButtonStyle public init( title: String, subtitle: String? = nil, @@ -38,8 +42,10 @@ public struct SystemMessageView: View { onAction: (() -> Void)? = nil, secondaryButtonName: String? = nil, onSecondaryAction: (() -> Void)? = nil, - onClose: (() -> Void)? = nil + onClose: (() -> Void)? = nil, + buttonStyle: DashButtonStyle = .filledBlue ) { + self.buttonStyle = buttonStyle self.title = title self.subtitle = subtitle self.icon = icon @@ -58,7 +64,7 @@ public struct SystemMessageView: View { .scaledToFit() .frame(width: 30, height: 30) - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: rendersPrimaryLink ? 6 : 20) { VStack(alignment: .leading, spacing: 0) { Text(title) .dashFont(.subhead) @@ -78,7 +84,7 @@ public struct SystemMessageView: View { DashUIKit.DashButton( text: buttonName, size: .small, - style: .filledBlue, + style: buttonStyle, action: onAction ) } @@ -91,7 +97,12 @@ public struct SystemMessageView: View { ) } } - .padding(.bottom, 14) + // A link belongs against the text it follows: `DashButton` + // pads every style for a tappable pill, which leaves a + // plain label visibly indented from the sentence above it, + // and the 20pt stack gap reads as a gap between sections. + .padding(.horizontal, rendersPrimaryLink ? -DashButtonSize.small.hPadding : 0) + .padding(.bottom, rendersPrimaryLink ? 4 : 14) } } .padding(.vertical, 5) @@ -110,6 +121,24 @@ public struct SystemMessageView: View { .background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) } + + /// The primary action is a link rather than a pill — it points somewhere + /// instead of asking to be pressed, and is spaced accordingly. + private var isLinkAction: Bool { + switch buttonStyle { + case .plainBlue, .plainBlack, .plainRed, .plainWhite: + return true + default: + return false + } + } + + /// The link layout only when the link is actually on screen. With a link + /// style but no primary button, the row holds just the secondary pill, + /// and pulling it out by the link's padding would misalign it. + private var rendersPrimaryLink: Bool { + isLinkAction && buttonName != nil && onAction != nil + } } // MARK: - Preview diff --git a/Sources/DashUIKit/Foundation/Color+DashUI.swift b/Sources/DashUIKit/Foundation/Color+DashUI.swift index b81bc06..8b25187 100644 --- a/Sources/DashUIKit/Foundation/Color+DashUI.swift +++ b/Sources/DashUIKit/Foundation/Color+DashUI.swift @@ -260,6 +260,7 @@ public enum DashColors { public static var red: Color { dashAsset("Red") } public static var redAlpha5: Color { dashAsset("RedAlpha5") } public static var redAlpha10: Color { dashAsset("RedAlpha10") } + public static var redAlpha20: Color { dashAsset("RedAlpha20") } // MARK: Tokens / White diff --git a/Sources/DashUIKit/Foundation/Icon_DashUI.swift b/Sources/DashUIKit/Foundation/Icon_DashUI.swift index a97588f..88e0398 100644 --- a/Sources/DashUIKit/Foundation/Icon_DashUI.swift +++ b/Sources/DashUIKit/Foundation/Icon_DashUI.swift @@ -80,14 +80,20 @@ public enum DashIcon { /// come in a plain and a `-purple` variant — the plain one is tinted by /// the caller, the purple one carries its own colour. public enum Features: String, CaseIterable, DashIconAsset { + case friends = "feature-friends" case identity = "feature-identity" case instant = "feature-instant" case platform = "feature-platform" case platformPurple = "feature-platform-purple" + case profile = "feature-profile" + case recovery = "feature-recovery" + case selectedList = "feature-selected-list" case shield = "feature-shield" case shieldPurple = "feature-shield-purple" case timer = "feature-timer" case timerPurple = "feature-timer-purple" + case username = "feature-username" + case usernameBlock = "feature-username-block" } // MARK: - Checkbox diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Colors/Tokens/Red/RedAlpha20.colorset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Colors/Tokens/Red/RedAlpha20.colorset/Contents.json new file mode 100644 index 0000000..fa9afbc --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Colors/Tokens/Red/RedAlpha20.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "0x42", + "green" : "0x38", + "red" : "0xEB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/Contents.json new file mode 100644 index 0000000..75308eb --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "feature-friends.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "feature-friends@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "feature-friends@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends.png new file mode 100644 index 0000000..cb7fcd9 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends@2x.png new file mode 100644 index 0000000..4412419 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends@3x.png new file mode 100644 index 0000000..efd0db0 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-friends.imageset/feature-friends@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/Contents.json new file mode 100644 index 0000000..f07b527 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "feature-profile.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "feature-profile@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "feature-profile@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile.png new file mode 100644 index 0000000..33a0363 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile@2x.png new file mode 100644 index 0000000..a8758e5 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile@3x.png new file mode 100644 index 0000000..03c6c60 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-profile.imageset/feature-profile@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/Contents.json new file mode 100644 index 0000000..20a8ce3 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "feature-recovery.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "feature-recovery@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "feature-recovery@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery.png new file mode 100644 index 0000000..6ba2033 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery@2x.png new file mode 100644 index 0000000..1ea35e2 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery@3x.png new file mode 100644 index 0000000..41a1489 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-recovery.imageset/feature-recovery@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/Contents.json new file mode 100644 index 0000000..eb1c600 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "feature-selected-list.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "feature-selected-list@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "feature-selected-list@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list.png new file mode 100644 index 0000000..cf1832c Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list@2x.png new file mode 100644 index 0000000..544de72 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list@3x.png new file mode 100644 index 0000000..9a1898c Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-selected-list.imageset/feature-selected-list@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/Contents.json new file mode 100644 index 0000000..513f3c9 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "feature-username-block.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "feature-username-block@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "feature-username-block@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block.png new file mode 100644 index 0000000..710bbad Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block@2x.png new file mode 100644 index 0000000..a5c3d9a Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block@3x.png new file mode 100644 index 0000000..302396e Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username-block.imageset/feature-username-block@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/Contents.json new file mode 100644 index 0000000..08fc154 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "feature-username.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "feature-username@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "feature-username@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username.png new file mode 100644 index 0000000..1d6a103 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username@2x.png new file mode 100644 index 0000000..c03c4b4 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username@3x.png new file mode 100644 index 0000000..2ae35ed Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Features/feature-username.imageset/feature-username@3x.png differ diff --git a/docs/README.md b/docs/README.md index 8ff032f..dd05369 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ and callbacks; they render and report intent. They require `import DashUIKit` an (`Font.dash.*`, `.dashFont`, `DashTextStyle`), icons (`DashIconSource`, `Image(dash:)`), bundle access. - **[Buttons & inputs](buttons-and-inputs.md)** — `DashButton`, `DashSwitch`, `SearchBar`, - `AddressFieldView`, `NumericKeyboardView`. + `AddressFieldView`, `Criteria`, `SimpleSelect`, `NumericKeyboardView`. - **[Amount & currency](amount-and-currency.md)** — `EnterAmountView`, `SwapAmountView`, `ReceiveEstimateView`, `DashAmount`, `DashBalanceView`, `DashPickerView`, `CurrencyOption`. @@ -41,6 +41,7 @@ and callbacks; they render and report intent. They require `import DashUIKit` an | `ConverterCard` | [Lists & rows](lists-and-rows.md#convertercard) | | `ConverterCardItem` | [Lists & rows](lists-and-rows.md#convertercarditem) | | `ConverterCardRow` | [Lists & rows](lists-and-rows.md#convertercardrow) | +| `Criteria` (`Criterion`, `CriterionState`) | [Buttons & inputs](buttons-and-inputs.md#criteria) | | `CurrencyOption` | [Amount & currency](amount-and-currency.md#currencyoption) | | `DashAmount` | [Amount & currency](amount-and-currency.md#dashamount) | | `DashBalanceView` | [Amount & currency](amount-and-currency.md#dashbalanceview) | @@ -63,6 +64,7 @@ and callbacks; they render and report intent. They require `import DashUIKit` an | `ScrollViewWithOnScrollChanged` | [Utilities](utilities.md#scrollviewwithonscrollchanged) | | `scaleToFitWidth` | [Utilities](utilities.md#scaletofitwidth) | | `SearchBar` | [Buttons & inputs](buttons-and-inputs.md#searchbar) | +| `SimpleSelect` | [Buttons & inputs](buttons-and-inputs.md#simpleselect) | | `SuccessIllustration` | [Feedback](feedback.md#successillustration--errorillustration) | | `SwapAmountView` | [Amount & currency](amount-and-currency.md#swapamountview) | | `SystemMessageView` | [Feedback](feedback.md#systemmessageview) | diff --git a/docs/buttons-and-inputs.md b/docs/buttons-and-inputs.md index 8e8715d..276f0bb 100644 --- a/docs/buttons-and-inputs.md +++ b/docs/buttons-and-inputs.md @@ -88,6 +88,12 @@ A labeled crypto-address input with a trailing action button (scan-QR when empty when filled), error styling, and a multi-line read-out state. iOS 17+ uses a vertical axis field (1–3 lines); older iOS uses a single-line field. +Also the general-purpose text input: its three parts — label, field, help text — are the +design system's `Input` component, so it is what any labeled field is built from, not +only an address one. The scan-QR button appears **only when `onScanQR` is given**; a +field for something with no QR form (a username, a note) passes no handler and gets no +trailing glyph while empty. + ```swift @State private var address = "" AddressFieldView( @@ -103,6 +109,10 @@ AddressFieldView( State-driven styling: +- **Ring** (3pt, hard-edged, outside the field — Figma's `0 0 0 3px`): + `redAlpha20` while `hasError`, `blueAlpha20` while + focused, and none once `isAccepted` — the field stops asking for attention when the + content has passed whatever the host validates it against. An error still overrides it. - **Focused:** clear background + gray border. - **Error:** red-tinted background, `errorText` shown beneath. - **Blurred + filled (no error):** clean read-out — the trailing action button is hidden @@ -110,6 +120,111 @@ State-driven styling: shift between focused/unfocused. - **Disabled:** field non-editable, action button omitted. +`showsClearButton: false` drops the trailing ✕ for a field short enough to clear by hand; +with no `onScanQR` either, no trailing space is reserved at all. + +--- + +## Criteria + +File `Components/Criteria.swift` · `@available(iOS 14, macOS 11, *)` + +A block of requirements with their status — the check-list under a username or password +field. One mark per row says whether that rule is met. + +```swift +Criteria([ + Criterion(text: "Between 3 and 23 characters", state: .met), + Criterion(text: "Letters, numbers and hyphens only", state: .failed), + Criterion(text: "You need to have more 0.25 Dash to create this username", state: .blocking), + Criterion(text: "Username available", state: .checking), +]) +``` + +`init(_ items: [Criterion], spacing: CGFloat = 10)` — `spacing` is the gap between rows. + +### States + +| `CriterionState` | Mark | Text | +|---|---|---| +| `.pending` | empty ring, `gray300Alpha50` | primary | +| `.checking` | circular spinner in the mark's box | primary | +| `.met` | green disc, white tick | primary | +| `.failed` | red disc, white cross | primary | +| `.blocking` | red disc, white cross | `errorText` | +| `.warning` | yellow disc, white exclamation | primary | + +`.failed` and `.blocking` draw the same mark: the difference is whether the rule is the +reason the user cannot continue, which is what colours the text. + +A rule that should not be shown is simply left out of the array — there is no "hidden" +state, matching the design system, which has no mark for one. + +### Icon slot + +The design system models this row as an icon slot plus text, so a row can carry artwork +of its own instead of a standard disc. It is rendered in the same 19pt box, untinted: + +```swift +Criterion(text: "Listed in the marketplace", icon: .custom("tag.badge", bundle: .main)) +``` + +### Geometry + +Fixed by the design system, not by the caller: 6pt between mark and text; the mark is +19pt centred in a 26pt box, which sets the row height; the text is `.dashFont(.footnote)` +with 4pt of vertical padding, and wraps rather than truncates. + +The `met` tick and the `warning` exclamation are drawn in this file rather than reusing +`CheckmarkIcon`: that icon comes from a different source SVG whose polyline is about a +tenth flatter than this one. The `failed` cross does reuse `XmarkIcon`, whose shape is +identical once scaled. + +> The design system draws three icons for this row — default, cross and tick. `.warning` +> has no artwork yet and is drawn here to match the others' weight; swap it for the real +> asset when it lands. + +--- + +## SimpleSelect + +File `Components/SimpleSelect.swift` · `@available(iOS 14, macOS 11, *)` + +One option in a short list the user picks from: a title, an optional line saying what picking +it means, and a card that carries the selection itself. The card *is* the control — there is no +trailing radio or checkmark — so the description sits inside the hit target. + +```swift +VStack(spacing: 20) { + SimpleSelect( + title: "Shielded balance", + description: "Keeps your username private", + isSelected: selection == .shielded, + action: { selection = .shielded }) + SimpleSelect( + title: "Dash balance", + description: "Funds will be traceable to your username", + isSelected: selection == .core, + action: { selection = .core }) +} +``` + +`init(title: String, description: String? = nil, isSelected: Bool, action: @escaping () -> Void)` +— stateless: the host owns the selection and passes `isSelected` for each option. + +| State | Border | Fill | +|---|---|---| +| selected | `blue`, 1.5pt | `blueAlpha5` | +| unselected | `gray300Alpha30`, 1.5pt | none | + +The card takes the full width it is offered (options are compared in a column), has a 16pt +corner radius and 20 × 12pt padding; the title is `.subheadMedium`, the description `.footnote` +in `secondaryText`. The whole card is tappable, including an unselected card with no fill. +VoiceOver reads it as a button, with the selected trait on the chosen option. + +For a row with an icon, a trailing value or an explicit radio/checkbox mark, use +[`RadioButtonRow`](lists-and-rows.md#radiobuttonrow). + --- ## NumericKeyboardView