From 94080bc86212278729d6b15c6902a43d42541314 Mon Sep 17 00:00:00 2001 From: Rostislav Gorbachenko Date: Fri, 16 Oct 2026 19:34:08 +0200 Subject: [PATCH 1/4] feat(navigation-bar): public circle icon and button for custom bar actions NavigationBarCircleIcon draws any glyph in the bar's 34pt token-stroked circle; NavigationBarCircleButton wraps it with the 44pt-tall touch area and press style of NavigationBarElement.button. Lets apps draw actions with no bundled element (refresh, a Menu label) to match back/close/plus. Co-Authored-By: Claude Opus 5.5 --- .../DashUIKit/Components/NavigationBar.swift | 86 +++++++++++++++++-- docs/navigation-and-containers.md | 10 +++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/Sources/DashUIKit/Components/NavigationBar.swift b/Sources/DashUIKit/Components/NavigationBar.swift index fe9c0e0..819b46b 100644 --- a/Sources/DashUIKit/Components/NavigationBar.swift +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -101,27 +101,23 @@ public enum NavigationBarElement: String { /// 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. - @ViewBuilder + @MainActor @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) - ) + NavigationBarCircleIcon { glyph } case .info: glyph } } + @MainActor private var glyph: some View { Image(dash: .custom(rawValue, bundle: .module)) .fixedSize() } + @MainActor public func button(action: @escaping () -> Void) -> some View { Button(action: action) { icon @@ -146,6 +142,58 @@ public enum NavigationBarElement: String { } } +/// The bar's round element: any glyph centred in a 34pt circle with a 1.5pt +/// `toolbarRoundBorder` stroke, tinted `toolbarRoundContent`. `back`, `close` +/// and `plus` are drawn with it; use it directly for a bar action that has no +/// bundled element (refresh, a `Menu` label). +@available(iOS 14, macOS 11, *) +public struct NavigationBarCircleIcon: View { + private let glyph: Glyph + + public init(@ViewBuilder glyph: () -> Glyph) { + self.glyph = glyph() + } + + public var body: some View { + glyph + .foregroundColor(.dash.toolbarRoundContent) + .frame(width: 34, height: 34) + .overlay( + Circle() + .strokeBorder(Color.dash.toolbarRoundBorder, lineWidth: 1.5) + ) + } +} + +/// A `NavigationBarCircleIcon` as a bar button: the same 44pt-tall touch area +/// and press animation as `NavigationBarElement.button(action:)`. +@available(iOS 14, macOS 11, *) +public struct NavigationBarCircleButton: View { + private let accessibilityLabel: String + private let action: () -> Void + private let glyph: Glyph + + public init( + accessibilityLabel: String, + action: @escaping () -> Void, + @ViewBuilder glyph: () -> Glyph + ) { + self.accessibilityLabel = accessibilityLabel + self.action = action + self.glyph = glyph() + } + + public var body: some View { + Button(action: action) { + NavigationBarCircleIcon { glyph } + .frame(height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(NavigationBarButtonStyle()) + .accessibilityLabel(Text(accessibilityLabel)) + } +} + @available(iOS 14, macOS 11, *) private struct NavigationBarButtonStyle: ButtonStyle { @ViewBuilder @@ -216,6 +264,28 @@ private struct NavigationBarButtonStyle: ButtonStyle { leading: { NavigationBarElement.back.button { } }, trailing: { NavigationBarElement.plus.button { } } ) + + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { + HStack(spacing: 8) { + NavigationBarCircleButton(accessibilityLabel: "Refresh", action: { }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + } + Menu { + Button("Action") { } + } label: { + NavigationBarCircleIcon { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .semibold)) + } + .frame(height: 44) + } + .accessibilityLabel(Text("More")) + } + } + ) } } diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 8798542..eb27f24 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -38,6 +38,16 @@ Convenience initializers let you omit any slot (e.g. only `leading`, or `central - `.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). +**`NavigationBarCircleIcon { glyph }`** — the same 34pt token circle around any glyph, for a bar +action with no bundled element (e.g. a `Menu` label). **`NavigationBarCircleButton(accessibilityLabel:action:glyph:)`** +is that circle as a bar button, with the same touch area and press animation: + +```swift +NavigationBarCircleButton(accessibilityLabel: "Refresh", action: reload) { + Image(systemName: "arrow.clockwise").font(.system(size: 14, weight: .semibold)) +} +``` + --- ## TopIntroView From 74d9d65d7f31d5b2f4c4c857e8f765ff431a5a73 Mon Sep 17 00:00:00 2001 From: Rostislav Gorbachenko Date: Tue, 20 Oct 2026 14:48:28 +0200 Subject: [PATCH 2/4] fix(navigation-bar): address review on the circle icon/button API - NavigationBarCircleIcon is hit-testable over its whole circle, and a new NavigationBarCircleMenu gives a Menu the bar's touch area. - Bar buttons dim to 0.4 opacity while disabled (bundled elements too). - accessibilityLabel takes Text, so it is localized like a Button title. - One internal NavigationBarButton backs both NavigationBarElement.button and NavigationBarCircleButton. - One public type per file; icons are a DashIconSource sized by the component (SF Symbols in Font.dash.subheadMedium); docs index and README list the new types. - The new types are nonisolated, so NavigationBarElement.icon/button keep their pre-existing (non-MainActor) isolation. - Hit area is at least 44pt wide without changing the element's layout width, so circles stay on the bar's 20pt inset. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + .../DashUIKit/Components/NavigationBar.swift | 114 +++--------------- .../Components/NavigationBarButton.swift | 101 ++++++++++++++++ .../NavigationBarCircleButton.swift | 59 +++++++++ .../Components/NavigationBarCircleIcon.swift | 72 +++++++++++ .../Components/NavigationBarCircleMenu.swift | 60 +++++++++ docs/README.md | 6 +- docs/navigation-and-containers.md | 37 ++++-- 8 files changed, 348 insertions(+), 103 deletions(-) create mode 100644 Sources/DashUIKit/Components/NavigationBarButton.swift create mode 100644 Sources/DashUIKit/Components/NavigationBarCircleButton.swift create mode 100644 Sources/DashUIKit/Components/NavigationBarCircleIcon.swift create mode 100644 Sources/DashUIKit/Components/NavigationBarCircleMenu.swift 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/NavigationBar.swift b/Sources/DashUIKit/Components/NavigationBar.swift index 819b46b..0bdf86f 100644 --- a/Sources/DashUIKit/Components/NavigationBar.swift +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -99,33 +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. - @MainActor @ViewBuilder + /// 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: - NavigationBarCircleIcon { glyph } + NavigationBarCircleIcon(source) case .info: - glyph + Image(dash: source) + .fixedSize() } } - @MainActor - private var glyph: some View { - Image(dash: .custom(rawValue, bundle: .module)) - .fixedSize() + private var source: DashIconSource { + .custom(rawValue, bundle: .module) } - @MainActor 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 { @@ -142,74 +136,6 @@ public enum NavigationBarElement: String { } } -/// The bar's round element: any glyph centred in a 34pt circle with a 1.5pt -/// `toolbarRoundBorder` stroke, tinted `toolbarRoundContent`. `back`, `close` -/// and `plus` are drawn with it; use it directly for a bar action that has no -/// bundled element (refresh, a `Menu` label). -@available(iOS 14, macOS 11, *) -public struct NavigationBarCircleIcon: View { - private let glyph: Glyph - - public init(@ViewBuilder glyph: () -> Glyph) { - self.glyph = glyph() - } - - public var body: some View { - glyph - .foregroundColor(.dash.toolbarRoundContent) - .frame(width: 34, height: 34) - .overlay( - Circle() - .strokeBorder(Color.dash.toolbarRoundBorder, lineWidth: 1.5) - ) - } -} - -/// A `NavigationBarCircleIcon` as a bar button: the same 44pt-tall touch area -/// and press animation as `NavigationBarElement.button(action:)`. -@available(iOS 14, macOS 11, *) -public struct NavigationBarCircleButton: View { - private let accessibilityLabel: String - private let action: () -> Void - private let glyph: Glyph - - public init( - accessibilityLabel: String, - action: @escaping () -> Void, - @ViewBuilder glyph: () -> Glyph - ) { - self.accessibilityLabel = accessibilityLabel - self.action = action - self.glyph = glyph() - } - - public var body: some View { - Button(action: action) { - NavigationBarCircleIcon { glyph } - .frame(height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(NavigationBarButtonStyle()) - .accessibilityLabel(Text(accessibilityLabel)) - } -} - -@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( @@ -269,23 +195,21 @@ private struct NavigationBarButtonStyle: ButtonStyle { leading: { NavigationBarElement.back.button { } }, trailing: { HStack(spacing: 8) { - NavigationBarCircleButton(accessibilityLabel: "Refresh", action: { }) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 14, weight: .semibold)) - } - Menu { + NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } + NavigationBarCircleMenu(.system("ellipsis"), accessibilityLabel: Text("More")) { Button("Action") { } - } label: { - NavigationBarCircleIcon { - Image(systemName: "ellipsis") - .font(.system(size: 14, weight: .semibold)) - } - .frame(height: 44) } - .accessibilityLabel(Text("More")) } } ) + + 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..1204a4c --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarButton.swift @@ -0,0 +1,101 @@ +// +// 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. + nonisolated func navigationBarDisabledDimming(_ isEnabled: Bool) -> some View { + opacity(isEnabled ? 1 : 0.4) + } +} + +/// 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, *) +private 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..c70b4cb --- /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, *) +nonisolated 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: 8) { + 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..74febcb --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift @@ -0,0 +1,72 @@ +// +// 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, *) +nonisolated public struct NavigationBarCircleIcon: View { + private let source: DashIconSource + + public init(_ source: DashIconSource) { + self.source = source + } + + public 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: 8) { + 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..2c42b34 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift @@ -0,0 +1,60 @@ +// +// 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 and disabled dimming. `accessibilityLabel` is a +/// `Text` — see `NavigationBarCircleButton`. +@available(iOS 14, macOS 11, *) +nonisolated 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() + } + + public var body: some View { + Menu { + content + } label: { + NavigationBarCircleIcon(icon) + .navigationBarTouchArea() + } + .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 eb27f24..17ae30e 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -35,19 +35,42 @@ 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.4 opacity while + `.disabled(true)`. -**`NavigationBarCircleIcon { glyph }`** — the same 34pt token circle around any glyph, for a bar -action with no bundled element (e.g. a `Menu` label). **`NavigationBarCircleButton(accessibilityLabel:action:glyph:)`** -is that circle as a bar button, with the same touch area and press animation: +### 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(accessibilityLabel: "Refresh", action: reload) { - Image(systemName: "arrow.clockwise").font(.system(size: 14, weight: .semibold)) +NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh"), action: reload) + .disabled(isReloading) +``` + +File `Components/NavigationBarCircleMenu.swift` — **`NavigationBarCircleMenu(_:accessibilityLabel:content:)`**, +a `Menu` with the circle as its label (e.g. a ⋯ overflow): + +```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. + --- ## TopIntroView From 91b4f6fe2a93361d57332e4ecedc067aca382e6e Mon Sep 17 00:00:00 2001 From: Rostislav Gorbachenko Date: Tue, 20 Oct 2026 17:09:57 +0200 Subject: [PATCH 3/4] fix(navigation-bar): single disabled look, menu press style, isolation - BottomSheet no longer dims its close button by hand: the bar's button style is the only source of the disabled look, so an inactive close renders at 0.35 again instead of 0.4 x 0.35. The opacity is one constant, navigationBarDisabledOpacity (0.35, BottomSheet's value). - NavigationBarCircleMenu uses .menuStyle(.button) with the bar's ButtonStyle on iOS 16+, so its press animation and disabled dimming match NavigationBarCircleButton (no stacked system dimming); iOS 14-15 keep the explicit dimming. - The public circle types are back on regular View isolation; the nonisolated drawing lives in the internal NavigationBarCircleIconContent that NavigationBarElement.icon builds. - Docs: adjacent bar buttons need at least 10pt spacing so the widened hit areas don't overlap; previews use 10. Co-Authored-By: Claude Opus 5.5 --- .../Components/BottomSheet/BottomSheet.swift | 1 - .../DashUIKit/Components/NavigationBar.swift | 4 ++-- .../Components/NavigationBarButton.swift | 10 +++++--- .../NavigationBarCircleButton.swift | 4 ++-- .../Components/NavigationBarCircleIcon.swift | 16 +++++++++++-- .../Components/NavigationBarCircleMenu.swift | 23 +++++++++++++++---- docs/navigation-and-containers.md | 9 ++++++-- 7 files changed, 50 insertions(+), 17 deletions(-) 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 0bdf86f..fb2c3a1 100644 --- a/Sources/DashUIKit/Components/NavigationBar.swift +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -105,7 +105,7 @@ public enum NavigationBarElement: String { public var icon: some View { switch self { case .back, .close, .plus: - NavigationBarCircleIcon(source) + NavigationBarCircleIconContent(source: source) case .info: Image(dash: source) .fixedSize() @@ -194,7 +194,7 @@ public enum NavigationBarElement: String { NavigationBar( leading: { NavigationBarElement.back.button { } }, trailing: { - HStack(spacing: 8) { + HStack(spacing: 10) { NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } NavigationBarCircleMenu(.system("ellipsis"), accessibilityLabel: Text("More")) { Button("Action") { } diff --git a/Sources/DashUIKit/Components/NavigationBarButton.swift b/Sources/DashUIKit/Components/NavigationBarButton.swift index 1204a4c..93b5ac5 100644 --- a/Sources/DashUIKit/Components/NavigationBarButton.swift +++ b/Sources/DashUIKit/Components/NavigationBarButton.swift @@ -55,12 +55,16 @@ extension View { .contentShape(NavigationBarHitShape()) } - /// Dims a bar element while it is disabled. + /// 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 : 0.4) + 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 { @@ -71,7 +75,7 @@ private struct NavigationBarHitShape: Shape { } @available(iOS 14, macOS 11, *) -private struct NavigationBarButtonStyle: ButtonStyle { +struct NavigationBarButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { NavigationBarButtonStyleBody(configuration: configuration) } diff --git a/Sources/DashUIKit/Components/NavigationBarCircleButton.swift b/Sources/DashUIKit/Components/NavigationBarCircleButton.swift index c70b4cb..b416091 100644 --- a/Sources/DashUIKit/Components/NavigationBarCircleButton.swift +++ b/Sources/DashUIKit/Components/NavigationBarCircleButton.swift @@ -24,7 +24,7 @@ import SwiftUI /// host's string table like a SwiftUI `Button` title; pass /// `Text(verbatim:)` for an already-localized string. @available(iOS 14, macOS 11, *) -nonisolated public struct NavigationBarCircleButton: View { +public struct NavigationBarCircleButton: View { private let icon: DashIconSource private let accessibilityLabel: Text private let action: () -> Void @@ -49,7 +49,7 @@ nonisolated public struct NavigationBarCircleButton: View { #if DEBUG @available(iOS 17, macOS 14, *) #Preview("NavigationBarCircleButton") { - HStack(spacing: 8) { + HStack(spacing: 10) { NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh")) { } .disabled(true) diff --git a/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift b/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift index 74febcb..f2d86e5 100644 --- a/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift +++ b/Sources/DashUIKit/Components/NavigationBarCircleIcon.swift @@ -28,7 +28,7 @@ import SwiftUI /// `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, *) -nonisolated public struct NavigationBarCircleIcon: View { +public struct NavigationBarCircleIcon: View { private let source: DashIconSource public init(_ source: DashIconSource) { @@ -36,6 +36,18 @@ nonisolated public struct NavigationBarCircleIcon: View { } 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) @@ -62,7 +74,7 @@ nonisolated public struct NavigationBarCircleIcon: View { #if DEBUG @available(iOS 17, macOS 14, *) #Preview("NavigationBarCircleIcon") { - HStack(spacing: 8) { + HStack(spacing: 10) { NavigationBarCircleIcon(.custom(NavigationBarElement.back.rawValue, bundle: .module)) NavigationBarCircleIcon(.system("arrow.clockwise")) NavigationBarCircleIcon(.system("ellipsis")) diff --git a/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift b/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift index 2c42b34..376ee58 100644 --- a/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift +++ b/Sources/DashUIKit/Components/NavigationBarCircleMenu.swift @@ -17,10 +17,10 @@ import SwiftUI /// A `Menu` whose label is a `NavigationBarCircleIcon` (e.g. the ⋯ overflow), -/// with the bar's touch area and disabled dimming. `accessibilityLabel` is a +/// with the bar's touch area, press animation (iOS 16+) and disabled dimming. `accessibilityLabel` is a /// `Text` — see `NavigationBarCircleButton`. @available(iOS 14, macOS 11, *) -nonisolated public struct NavigationBarCircleMenu: View { +public struct NavigationBarCircleMenu: View { private let icon: DashIconSource private let accessibilityLabel: Text private let content: Content @@ -37,15 +37,28 @@ nonisolated public struct NavigationBarCircleMenu: View { self.content = content() } + @ViewBuilder public var body: some View { - Menu { + let menu = Menu { content } label: { NavigationBarCircleIcon(icon) .navigationBarTouchArea() } - .navigationBarDisabledDimming(isEnabled) - .accessibilityLabel(accessibilityLabel) + + 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) + } } } diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 17ae30e..8611cd2 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -37,7 +37,7 @@ Convenience initializers let you omit any slot (e.g. only `leading`, or `central `.info` is a self-contained 24×24 icon. - `.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.4 opacity while + bar's 20pt inset). Press animation: scale 0.88 + opacity 0.7; dimmed to 0.35 opacity while `.disabled(true)`. ### Custom circle actions @@ -52,13 +52,18 @@ File `Components/NavigationBarCircleButton.swift` — **`NavigationBarCircleButt a bar button with the same touch area, press animation and disabled dimming as `NavigationBarElement.button(action:)`: +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. + ```swift NavigationBarCircleButton(.system("arrow.clockwise"), accessibilityLabel: Text("Refresh"), action: reload) .disabled(isReloading) ``` File `Components/NavigationBarCircleMenu.swift` — **`NavigationBarCircleMenu(_:accessibilityLabel:content:)`**, -a `Menu` with the circle as its label (e.g. a ⋯ overflow): +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")) { From bb7bb2099e2b5682e2c30287ad7413cd1e8f9bc8 Mon Sep 17 00:00:00 2001 From: Roman Chornyi Date: Tue, 20 Oct 2026 19:44:52 +0300 Subject: [PATCH 4/4] docs(navigation-bar): move the spacing note below the circle button example Co-Authored-By: Claude Opus 5.5 --- docs/navigation-and-containers.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 8611cd2..3a25cd9 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -52,15 +52,15 @@ File `Components/NavigationBarCircleButton.swift` — **`NavigationBarCircleButt a bar button with the same touch area, press animation and disabled dimming as `NavigationBarElement.button(action:)`: -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. - ```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: