diff --git a/README.md b/README.md index 12a55da..76751bf 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ a component already exists before building your own. | `List1View` | Simple label ↔ value row | [Lists & rows](docs/lists-and-rows.md) | | `List8View` | Label ↔ value row with an optional avatar | [Lists & rows](docs/lists-and-rows.md) | | `NavigationBar` | Custom three-slot nav bar + `NavigationBarElement` buttons | [Navigation & containers](docs/navigation-and-containers.md) | +| `NavigationBarCircleButton` / `NavigationBarCircleMenu` | Bar button / menu in the nav circle, for actions with no bundled element | [Navigation & containers](docs/navigation-and-containers.md) | +| `NavigationBarCircleIcon` | The nav bar's 34pt token circle around any icon | [Navigation & containers](docs/navigation-and-containers.md) | | `BottomSheet` | Sheet chrome (grabber + nav bar) with self-sizing support | [Navigation & containers](docs/navigation-and-containers.md) | | `MenuViewModifier` | Card chrome — rounded background + shadow | [Navigation & containers](docs/navigation-and-containers.md) | | `Toast` | Blurred toast — warning/info/error/success/copied/loading | [Feedback](docs/feedback.md) | diff --git a/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift index b824c91..d8a4346 100644 --- a/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift @@ -155,7 +155,6 @@ public struct BottomSheet: View { ) } .disabled(!isCloseButtonActive) - .opacity(isCloseButtonActive ? 1 : 0.35) } } ) diff --git a/Sources/DashUIKit/Components/NavigationBar.swift b/Sources/DashUIKit/Components/NavigationBar.swift index fe9c0e0..fb2c3a1 100644 --- a/Sources/DashUIKit/Components/NavigationBar.swift +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -99,37 +99,27 @@ public enum NavigationBarElement: String { case plus = "navigationbar-plus" case info = "navigationbar-info" - /// The element as drawn in the bar. `back`, `close` and `plus` are a glyph - /// centred in a stroked circle; `info` is a self-contained icon. + /// The element as drawn in the bar. `back`, `close` and `plus` are a + /// `NavigationBarCircleIcon`; `info` is a self-contained icon. @ViewBuilder public var icon: some View { switch self { case .back, .close, .plus: - glyph - .foregroundColor(.dash.toolbarRoundContent) - .frame(width: 34, height: 34) - .overlay( - Circle() - .strokeBorder(Color.dash.toolbarRoundBorder, lineWidth: 1.5) - ) + NavigationBarCircleIconContent(source: source) case .info: - glyph + Image(dash: source) + .fixedSize() } } - private var glyph: some View { - Image(dash: .custom(rawValue, bundle: .module)) - .fixedSize() + private var source: DashIconSource { + .custom(rawValue, bundle: .module) } public func button(action: @escaping () -> Void) -> some View { - Button(action: action) { + NavigationBarButton(accessibilityLabel: Text(accessibilityLabelText), action: action) { icon - .frame(height: 44) - .contentShape(Rectangle()) } - .buttonStyle(NavigationBarButtonStyle()) - .accessibilityLabel(Text(accessibilityLabelText)) } var accessibilityLabelText: String { @@ -146,22 +136,6 @@ public enum NavigationBarElement: String { } } -@available(iOS 14, macOS 11, *) -private struct NavigationBarButtonStyle: ButtonStyle { - @ViewBuilder - func makeBody(configuration: Configuration) -> some View { - let content = configuration.label - .scaleEffect(configuration.isPressed ? 0.88 : 1) - .opacity(configuration.isPressed ? 0.7 : 1) - - if #available(iOS 15, macOS 12, *) { - content.animation(.easeInOut(duration: 0.12), value: configuration.isPressed) - } else { - content.animation(.easeInOut(duration: 0.12)) - } - } -} - @available(iOS 17, macOS 14, *) #Preview("NavigationBar Back") { NavigationBar( @@ -216,6 +190,26 @@ private struct NavigationBarButtonStyle: ButtonStyle { leading: { NavigationBarElement.back.button { } }, trailing: { NavigationBarElement.plus.button { } } ) + + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { + HStack(spacing: 10) { + NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } + NavigationBarCircleMenu(.system("ellipsis"), accessibilityLabel: Text("More")) { + Button("Action") { } + } + } + } + ) + + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { + NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } + .disabled(true) + } + ) } } diff --git a/Sources/DashUIKit/Components/NavigationBarButton.swift b/Sources/DashUIKit/Components/NavigationBarButton.swift new file mode 100644 index 0000000..93b5ac5 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarButton.swift @@ -0,0 +1,105 @@ +// +// 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 + +/// The one implementation of a navigation bar button: touch area, press and +/// disabled styling, accessibility label. `NavigationBarElement.button(action:)` +/// and `NavigationBarCircleButton` both render through it. +@available(iOS 14, macOS 11, *) +nonisolated struct NavigationBarButton: View { + private let accessibilityLabel: Text + private let action: () -> Void + private let label: Label + + init( + accessibilityLabel: Text, + action: @escaping () -> Void, + @ViewBuilder label: () -> Label + ) { + self.accessibilityLabel = accessibilityLabel + self.action = action + self.label = label() + } + + var body: some View { + Button(action: action) { + label.navigationBarTouchArea() + } + .buttonStyle(NavigationBarButtonStyle()) + .accessibilityLabel(accessibilityLabel) + } +} + +@available(iOS 14, macOS 11, *) +extension View { + /// The bar's touch area: 44pt tall, and at least 44pt wide for hit + /// testing. The extra width is hit area only — the element keeps its + /// hug width in layout, so a 34pt circle stays aligned to the bar's + /// 20pt inset. + nonisolated func navigationBarTouchArea() -> some View { + frame(height: 44) + .contentShape(NavigationBarHitShape()) + } + + /// Dims a bar element while it is disabled — the one place the bar's + /// disabled look is defined. + nonisolated func navigationBarDisabledDimming(_ isEnabled: Bool) -> some View { + opacity(isEnabled ? 1 : navigationBarDisabledOpacity) + } +} + +/// Opacity of a disabled bar element (button, menu, `BottomSheet` close). +let navigationBarDisabledOpacity: Double = 0.35 + +/// The element's frame, widened symmetrically to at least 44pt. +@available(iOS 14, macOS 11, *) +private struct NavigationBarHitShape: Shape { + func path(in rect: CGRect) -> Path { + let outset = max(0, (44 - rect.width) / 2) + return Path(rect.insetBy(dx: -outset, dy: 0)) + } +} + +@available(iOS 14, macOS 11, *) +struct NavigationBarButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + NavigationBarButtonStyleBody(configuration: configuration) + } +} + +/// A view, not inline in `makeBody`, so it can read `isEnabled`: a custom +/// `ButtonStyle` gets no automatic disabled dimming. +@available(iOS 14, macOS 11, *) +private struct NavigationBarButtonStyleBody: View { + let configuration: ButtonStyleConfiguration + + @Environment(\.isEnabled) private var isEnabled + + @ViewBuilder + var body: some View { + let content = configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1) + .opacity(configuration.isPressed ? 0.7 : 1) + .navigationBarDisabledDimming(isEnabled) + + if #available(iOS 15, macOS 12, *) { + content.animation(.easeInOut(duration: 0.12), value: configuration.isPressed) + } else { + content.animation(.easeInOut(duration: 0.12)) + } + } +} diff --git a/Sources/DashUIKit/Components/NavigationBarCircleButton.swift b/Sources/DashUIKit/Components/NavigationBarCircleButton.swift new file mode 100644 index 0000000..b416091 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarCircleButton.swift @@ -0,0 +1,59 @@ +// +// 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 + +/// A `NavigationBarCircleIcon` as a bar button, for an action the bar has no +/// bundled element for (refresh, …). Same touch area, press animation and +/// disabled dimming as `NavigationBarElement.button(action:)`. +/// +/// `accessibilityLabel` is a `Text`, so `Text("Refresh")` is looked up in the +/// host's string table like a SwiftUI `Button` title; pass +/// `Text(verbatim:)` for an already-localized string. +@available(iOS 14, macOS 11, *) +public struct NavigationBarCircleButton: View { + private let icon: DashIconSource + private let accessibilityLabel: Text + private let action: () -> Void + + public init( + _ icon: DashIconSource, + accessibilityLabel: Text, + action: @escaping () -> Void + ) { + self.icon = icon + self.accessibilityLabel = accessibilityLabel + self.action = action + } + + public var body: some View { + NavigationBarButton(accessibilityLabel: accessibilityLabel, action: action) { + NavigationBarCircleIcon(icon) + } + } +} + +#if DEBUG +@available(iOS 17, macOS 14, *) +#Preview("NavigationBarCircleButton") { + HStack(spacing: 10) { + NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } + NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } + .disabled(true) + } + .padding() +} +#endif diff --git a/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift b/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift new file mode 100644 index 0000000..f2d86e5 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift @@ -0,0 +1,84 @@ +// +// 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 + +/// The navigation bar's round element: an icon centred in a 34pt circle with a +/// 1.5pt `toolbarRoundBorder` stroke, tinted `toolbarRoundContent`. The whole +/// circle is its hit-testing shape. +/// +/// `NavigationBarElement.back`, `.close` and `.plus` draw through it. Use it +/// directly only to build something the bar doesn't ship — for a button use +/// `NavigationBarCircleButton`, for a menu `NavigationBarCircleMenu`. +/// +/// The icon is sized by the component: an SF Symbol (`.system`) is set in +/// `Font.dash.subheadMedium`; a `.custom` / `.uiImage` asset renders at its own +/// size, so it should be drawn for the circle (the bundled nav glyphs are). +@available(iOS 14, macOS 11, *) +public struct NavigationBarCircleIcon: View { + private let source: DashIconSource + + public init(_ source: DashIconSource) { + self.source = source + } + + public var body: some View { + NavigationBarCircleIconContent(source: source) + } +} + +/// The drawing behind `NavigationBarCircleIcon`. `nonisolated` so the +/// nonisolated `NavigationBarElement.icon` can build it without the public +/// type committing to that isolation. +@available(iOS 14, macOS 11, *) +nonisolated struct NavigationBarCircleIconContent: View { + let source: DashIconSource + + var body: some View { + glyph + .foregroundColor(.dash.toolbarRoundContent) + .frame(width: 34, height: 34) + .overlay( + Circle() + .strokeBorder(Color.dash.toolbarRoundBorder, lineWidth: 1.5) + ) + .contentShape(Circle()) + } + + @ViewBuilder + private var glyph: some View { + switch source { + case .system: + Image(dash: source) + .font(Font.dash.subheadMedium) + case .custom, .uiImage: + Image(dash: source) + .fixedSize() + } + } +} + +#if DEBUG +@available(iOS 17, macOS 14, *) +#Preview("NavigationBarCircleIcon") { + HStack(spacing: 10) { + NavigationBarCircleIcon(.custom(NavigationBarElement.back.rawValue, bundle: .module)) + NavigationBarCircleIcon(.system("arrow.clockwise")) + NavigationBarCircleIcon(.system("ellipsis")) + } + .padding() +} +#endif diff --git a/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift b/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift new file mode 100644 index 0000000..376ee58 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift @@ -0,0 +1,73 @@ +// +// 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 + +/// A `Menu` whose label is a `NavigationBarCircleIcon` (e.g. the ⋯ overflow), +/// with the bar's touch area, press animation (iOS 16+) and disabled dimming. `accessibilityLabel` is a +/// `Text` — see `NavigationBarCircleButton`. +@available(iOS 14, macOS 11, *) +public struct NavigationBarCircleMenu: View { + private let icon: DashIconSource + private let accessibilityLabel: Text + private let content: Content + + @Environment(\.isEnabled) private var isEnabled + + public init( + _ icon: DashIconSource, + accessibilityLabel: Text, + @ViewBuilder content: () -> Content + ) { + self.icon = icon + self.accessibilityLabel = accessibilityLabel + self.content = content() + } + + @ViewBuilder + public var body: some View { + let menu = Menu { + content + } label: { + NavigationBarCircleIcon(icon) + .navigationBarTouchArea() + } + + if #available(iOS 16, macOS 13, *) { + // The bar's ButtonStyle draws the label, so the menu gets the + // same press animation and disabled dimming as the circle + // buttons beside it. + menu + .menuStyle(.button) + .buttonStyle(NavigationBarButtonStyle()) + .accessibilityLabel(accessibilityLabel) + } else { + menu + .navigationBarDisabledDimming(isEnabled) + .accessibilityLabel(accessibilityLabel) + } + } +} + +#if DEBUG +@available(iOS 17, macOS 14, *) +#Preview("NavigationBarCircleMenu") { + NavigationBarCircleMenu(.system("ellipsis"), accessibilityLabel: Text("More")) { + Button("Action") { } + } + .padding() +} +#endif diff --git a/docs/README.md b/docs/README.md index d8042f1..a734c7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,8 @@ and callbacks; they render and report intent. They require `import DashUIKit` an - **[Lists & rows](lists-and-rows.md)** — `CoinSelector`, `MenuItem`, `ConverterCard`, `TransactionView`, `RadioButtonRow`, `List1View`, `List8View`. - **[Navigation & containers](navigation-and-containers.md)** — `NavigationBar`, - `NavigationBarElement`, `TopIntroView`, `BottomSheet`, `MenuViewModifier`. + `NavigationBarElement`, `NavigationBarCircleButton`, `NavigationBarCircleMenu`, + `NavigationBarCircleIcon`, `TopIntroView`, `BottomSheet`, `MenuViewModifier`. - **[Feedback](feedback.md)** — `Toast`, `SystemMessageView`, `LoadingIllustration` / `LoadingSpinner`, `SuccessIllustration`, `ErrorIllustration`, `XmarkIcon`, `CheckmarkIcon`, `ChevronIcon`, `InfoRoundIcon`. @@ -58,6 +59,9 @@ and callbacks; they render and report intent. They require `import DashUIKit` an | `MenuItem` (`MenuItemAccessory`, `MenuItemInfo`) | [Lists & rows](lists-and-rows.md#menuitem) | | `MenuViewModifier` | [Navigation & containers](navigation-and-containers.md#menuviewmodifier) | | `NavigationBar` / `NavigationBarElement` | [Navigation & containers](navigation-and-containers.md#navigationbar) | +| `NavigationBarCircleButton` | [Navigation & containers](navigation-and-containers.md#custom-circle-actions) | +| `NavigationBarCircleIcon` | [Navigation & containers](navigation-and-containers.md#custom-circle-actions) | +| `NavigationBarCircleMenu` | [Navigation & containers](navigation-and-containers.md#custom-circle-actions) | | `NumericKeyboardView` | [Buttons & inputs](buttons-and-inputs.md#numerickeyboardview) | | `RadioButtonRow` | [Lists & rows](lists-and-rows.md#radiobuttonrow) | | `readingFrame` / `readingLocation` | [Utilities](utilities.md#frame--location-readers) | diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 8798542..3a25cd9 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -35,8 +35,46 @@ Convenience initializers let you omit any slot (e.g. only `leading`, or `central (`Color.dash.toolbarRoundContent`) centred in a 34×34 circle with a 1.5pt `Color.dash.toolbarRoundBorder` stroke, so both adapt to light/dark mode. `.info` is a self-contained 24×24 icon. -- `.button(action:)` — the icon in a tappable area 44pt tall and as wide as the icon, with a press animation - (scale 0.88 + opacity 0.7). +- `.button(action:)` — the icon as a button. The touch area is 44pt tall and at least 44pt wide + for hit testing, while the element keeps its hug width in layout (so a circle stays on the + bar's 20pt inset). Press animation: scale 0.88 + opacity 0.7; dimmed to 0.35 opacity while + `.disabled(true)`. + +### Custom circle actions + +For a bar action with no bundled element, use the same token circle with your own icon. All +three take a `DashIconSource`: an SF Symbol (`.system`) is set in `Font.dash.subheadMedium`; +a `.custom` / `.uiImage` asset renders at its own size, so draw it for a 34pt circle. +`accessibilityLabel` is a `Text`, so `Text("Refresh")` is looked up in the host's string table +like a SwiftUI `Button` title (`Text(verbatim:)` for an already-localized string). + +File `Components/NavigationBarCircleButton.swift` — **`NavigationBarCircleButton(_:accessibilityLabel:action:)`**, +a bar button with the same touch area, press animation and disabled dimming as +`NavigationBarElement.button(action:)`: + +```swift +NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh"), action: reload) + .disabled(isReloading) +``` + +Each element's hit area is widened to at least 44pt beyond its 34pt circle (5pt per side), so +put adjacent bar buttons **at least 10pt apart** (`HStack(spacing: 10)`) or the hit areas +overlap and a tap between them goes to the later one. + +File `Components/NavigationBarCircleMenu.swift` — **`NavigationBarCircleMenu(_:accessibilityLabel:content:)`**, +a `Menu` with the circle as its label (e.g. a ⋯ overflow). On iOS 16+ it uses the bar's button style, so its press +animation and disabled dimming match the circle buttons: + +```swift +NavigationBarCircleMenu(.system("ellipsis"), accessibilityLabel: Text("More")) { + Button("Select") { startSelecting() } +} +``` + +File `Components/NavigationBarCircleIcon.swift` — **`NavigationBarCircleIcon(_:)`**, the circle on its +own (34×34, 1.5pt `toolbarRoundBorder` stroke, `toolbarRoundContent` tint, the whole circle +hit-testable). `.back` / `.close` / `.plus` draw through it; reach for it only to build +something the two above don't cover. ---