Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/native/Sources/VisionNativeUI/AccountDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ struct AccountDetailContent: View {
// MARK: Header menu

private var actionsMenu: some View {
Menu {
VisionMenu {
ForEach(Array(model.menuItems(account).enumerated()), id: \.offset) { _, item in
menuRow(item)
}
Expand Down Expand Up @@ -179,8 +179,13 @@ struct AccountDetailContent: View {
case .deleteDisabled:
Button {
} label: {
Text(strings.t("common.delete"))
Text(strings.t("accounts.delete.hasTransactions"))
Label {
Text(strings.t("common.delete"))
Text(strings.t("accounts.delete.hasTransactions"))
.font(.footnote).foregroundStyle(theme.secondaryLabel)
} icon: {
Image(systemName: "trash")
}
}
.disabled(true)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/native/Sources/VisionNativeUI/AccountsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ struct AccountHubRow: View {
}

private var rowMenu: some View {
Menu {
VisionMenu {
Button {
model.openDetail(account)
} label: {
Expand Down
4 changes: 3 additions & 1 deletion apps/native/Sources/VisionNativeUI/CategoriesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ struct CategoryTreeRowView: View {

/// The ••• menu: Edit, Merge category, Mark inactive / active, then Delete.
private var rowMenu: some View {
Menu {
VisionMenu {
Button {
model.openEdit(node)
} label: {
Expand All @@ -228,7 +228,9 @@ struct CategoryTreeRowView: View {
Label {
Text(strings.t("common.delete"))
if row.isBranch {
// The web helper line: `type-footnote text-label-secondary`.
Text(strings.t("categoriesPage.deleteChildrenFirst"))
.font(.footnote).foregroundStyle(theme.secondaryLabel)
}
} icon: {
Image(systemName: "trash")
Expand Down
111 changes: 111 additions & 0 deletions apps/native/Sources/VisionNativeUI/Components/ChartHoverTooltip.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import SwiftUI
import VisionNativeCore

/// One line of a chart tooltip: the series swatch, its label and its formatted value.
struct ChartTooltipItem: Identifiable, Equatable {
let id: String
let label: String
let value: String
var color: Color?
}

/// `ChartTooltip` (components/charts/ChartTooltip.tsx): the floating readout of the hovered
/// category. It sits centred above its anchor with a 12 pt gap, moves beside the anchor when it
/// would leave the chart sideways and below it when there is no room above. `--popover` with the
/// elevation shadow; `glass-thick` under Full effects.
///
/// Place it in a chart overlay that spans `bounds`, with `anchor` in the overlay's coordinates.
struct ChartHoverTooltip: View {
let title: String?
let items: [ChartTooltipItem]
let anchor: CGPoint
/// The chart box the tooltip stays within sideways.
let bounds: CGSize
@State private var size: CGSize = .zero
@Environment(\.visionTheme) private var theme
@Environment(\.visionFullEffects) private var fullEffects

private static let gap: CGFloat = 12
private static let pad: CGFloat = 8

/// `computePosition`: where the tooltip's top-left corner goes for its measured size.
static func origin(anchor: CGPoint, size: CGSize, bounds: CGSize, roomAbove: CGFloat) -> CGPoint {
var x = anchor.x - size.width / 2
var y = anchor.y - size.height - gap
var side = false
if x + size.width > bounds.width - pad {
side = true
x = anchor.x - size.width - gap
y = anchor.y - size.height / 2
} else if x < pad {
side = true
x = anchor.x + gap
y = anchor.y - size.height / 2
}
// No room above the chart: drop below the anchor.
if !side, y < -roomAbove { y = anchor.y + gap }
if x + size.width > bounds.width - pad { x = max(pad, bounds.width - size.width - pad) }
if x < pad { x = pad }
return CGPoint(x: x, y: y)
}

var body: some View {
let origin = Self.origin(anchor: anchor, size: size, bounds: bounds, roomAbove: 160)
VStack(alignment: .leading, spacing: 6) {
if let title, !title.isEmpty {
Text(title)
.font(.caption2.weight(.medium))
.tracking(1.3)
.textCase(.uppercase)
.foregroundStyle(Color(token: theme.palette.mutedForeground).opacity(0.8))
}
VStack(alignment: .leading, spacing: 4) {
ForEach(items) { item in
HStack(spacing: 12) {
HStack(spacing: 8) {
if let color = item.color {
Circle().fill(color).frame(width: 8, height: 8).accessibilityHidden(true)
}
Text(item.label).foregroundStyle(theme.foreground.opacity(0.8))
}
Spacer(minLength: 0)
Text(item.value).fontWeight(.medium).monospacedDigit().foregroundStyle(theme.foreground)
}
.lineLimit(1)
}
}
}
.font(.footnote)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minWidth: 140, maxWidth: 260, alignment: .leading)
.fixedSize()
.background(surface)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.offset(x: origin.x, y: origin.y)
// Unmeasured, it would flash at the wrong place for a frame.
.opacity(size == .zero ? 0 : 1)
.frame(width: bounds.width, height: bounds.height, alignment: .topLeading)
.allowsHitTesting(false)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.updatesFrequently)
}

@ViewBuilder
private var surface: some View {
let radius = DesignTokens.Radius.card
let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
if fullEffects {
VisionGlassSurface(material: .thick, cornerRadius: radius)
} else {
// `bg-popover shadow-elevation-3`: `0 4px 8px shadow/0.06` (dark 0.35) and the wide
// ambient layer, whose negative spread SwiftUI cannot draw, at a third of its alpha.
shape.fill(theme.popover)
.overlay(shape.strokeBorder(theme.border.opacity(0.6), lineWidth: 1))
.shadow(
color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.35 : 0.06), radius: 4, y: 4)
.shadow(
color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.25 : 0.1), radius: 14, y: 12)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,13 @@ struct LocalDataTable<Row: Identifiable, Empty: View>: View {
// MARK: Rows

private func rowView(_ row: Row) -> some View {
// `TableRow`'s `hover:bg-foreground/[0.03]`; the hover state stays in the row.
HoverTracking { hovered in
rowContent(row).background(theme.foreground.opacity(hovered ? 0.03 : 0))
}
}

private func rowContent(_ row: Row) -> some View {
HStack(spacing: 0) {
ForEach(columns) { column in
column.cell(row)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,6 @@ struct NativeSidebarRail: View {
.font(.system(size: 15))
.foregroundStyle(Color(token: theme.glass.icon))
.frame(width: Self.buttonSize, height: Self.buttonSize)
.background(
RoundedRectangle(cornerRadius: DesignTokens.Radius.chip)
.fill(
isActive
? Color(token: theme.glass.selection, opacity: theme.glass.selectionOpacity)
: Color.clear)
)
.overlay(alignment: .topTrailing) { countPill(badge) }
// The update-ready dot, collapsed: `h-[7px] w-[7px]` primary at `right-1 top-1`.
.overlay(alignment: .topTrailing) {
Expand All @@ -177,7 +170,7 @@ struct NativeSidebarRail: View {
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(SidebarRowButtonStyle(isActive: isActive))
.help(tooltip)
.accessibilityLabel(label)
.accessibilityValue(badgeLabel)
Expand Down
136 changes: 136 additions & 0 deletions apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import SwiftUI
import VisionNativeCore

/// The web `DropdownMenu` (components/ui/dropdown-menu.tsx) in place of an AppKit menu: an in-page
/// `glass-thick rounded-card p-1.5` popover whose items show their icon (`mr-2 h-4 w-4
/// text-label-secondary`), a `bg-primary/10` hover row, and destructive items in the destructive
/// colour (`variant="destructive"`, icon included). A `Label` whose title holds a second `Text`
/// shows it as the item's helper line (`type-footnote text-label-secondary`).
///
/// Use it like `Menu` for menus of `Button`s and `Divider`s; a menu that holds `Toggle`s or
/// `Picker`s stays a `Menu`. Buttons keep their role and `.disabled`; choosing an item closes the
/// popover first, then runs its action. Escape or a click outside closes it.
///
/// VisionMenu {
/// Button { edit() } label: { Label("Edit", systemImage: "pencil") }
/// Divider()
/// Button(role: .destructive) { delete() } label: { Label("Delete", systemImage: "trash") }
/// } label: {
/// Image(systemName: "ellipsis")
/// }
struct VisionMenu<Content: View, MenuLabel: View>: View {
private let content: () -> Content
private let label: () -> MenuLabel
@State private var isOpen = false

init(@ViewBuilder content: @escaping () -> Content, @ViewBuilder label: @escaping () -> MenuLabel) {
self.content = content
self.label = label
}

var body: some View {
Button {
isOpen.toggle()
} label: {
label()
}
.popover(isPresented: $isOpen, arrowEdge: .bottom) {
VisionMenuPanel(dismiss: { isOpen = false }, content: content)
}
}
}

/// `DropdownMenuContent`: `min-w-[8rem] p-1.5` on `glass-thick`.
private struct VisionMenuPanel<Content: View>: View {
let dismiss: () -> Void
let content: () -> Content

var body: some View {
VStack(alignment: .leading, spacing: 0) {
content()
}
.buttonStyle(VisionMenuItemStyle(dismiss: dismiss))
.labelStyle(VisionMenuItemLabelStyle())
.padding(6)
.frame(minWidth: 128, alignment: .leading)
.fixedSize()
.background(VisionGlassSurface(material: .thick, cornerRadius: CGFloat(DesignTokens.Radius.card)))
.presentationBackground(.clear)
}
}

/// `DropdownMenuItem`: `rounded-[card-6px] px-2 py-1.5 type-body`, `focus:bg-primary/10`
/// (destructive: `text-destructive focus:bg-destructive/10`), `data-[disabled]:opacity-50`.
private struct VisionMenuItemStyle: PrimitiveButtonStyle {
let dismiss: () -> Void

func makeBody(configuration: Configuration) -> some View {
VisionMenuItem(configuration: configuration, dismiss: dismiss)
}
}

private struct VisionMenuItem: View {
let configuration: PrimitiveButtonStyleConfiguration
let dismiss: () -> Void
@State private var hovering = false
@Environment(\.isEnabled) private var isEnabled
@Environment(\.visionTheme) private var theme

var body: some View {
let destructive = configuration.role == .destructive
let shape = RoundedRectangle(cornerRadius: CGFloat(DesignTokens.Radius.card) - 6, style: .continuous)
let highlight = destructive ? theme.destructive : theme.primary
Button {
dismiss()
configuration.trigger()
} label: {
VStack(alignment: .leading, spacing: 1) { configuration.label }
.font(VisionControlMetrics.fieldFont)
.foregroundStyle(destructive ? theme.destructive : theme.foreground)
.environment(\.visionMenuItemDestructive, destructive)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(shape.fill(highlight.opacity(hovering && isEnabled ? 0.1 : 0)))
.contentShape(shape)
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1 : VisionControlMetrics.disabledOpacity)
.onHover { inside in
if hovering != inside { hovering = inside }
}
}
}

/// The item's icon (`mr-2 h-4 w-4`, secondary unless the item is destructive), then its title;
/// a second `Text` in the title is the helper line.
private struct VisionMenuItemLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
VisionMenuItemLabel(configuration: configuration)
}
}

private struct VisionMenuItemLabel: View {
let configuration: LabelStyleConfiguration
@Environment(\.visionMenuItemDestructive) private var destructive
@Environment(\.visionTheme) private var theme

var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
configuration.icon
.font(.system(size: 13))
.foregroundStyle(destructive ? theme.destructive : theme.secondaryLabel)
.frame(width: 16)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
configuration.title
}
.lineLimit(1)
}
}
}

extension EnvironmentValues {
/// Set by a `VisionMenu` item for its label: the icon takes the destructive colour.
@Entry var visionMenuItemDestructive = false
}
Loading
Loading