|
| 1 | +// |
| 2 | +// ColumnValueFilterPopover.swift |
| 3 | +// TablePro |
| 4 | +// |
| 5 | + |
| 6 | +import SwiftUI |
| 7 | + |
| 8 | +struct ColumnValueFilterPopover: View { |
| 9 | + let columnName: String |
| 10 | + let values: [ColumnDistinctValue] |
| 11 | + let loadedRowCount: Int |
| 12 | + let onApply: (ColumnValueFilter?) -> Void |
| 13 | + let onCancel: () -> Void |
| 14 | + |
| 15 | + @State private var checkedValues: Set<String> |
| 16 | + @State private var nullChecked: Bool |
| 17 | + @State private var searchText: String = "" |
| 18 | + |
| 19 | + private static let nullLabel = String(localized: "(NULL)") |
| 20 | + private static let emptyLabel = String(localized: "(Empty)") |
| 21 | + |
| 22 | + init( |
| 23 | + columnName: String, |
| 24 | + values: [ColumnDistinctValue], |
| 25 | + loadedRowCount: Int, |
| 26 | + initialFilter: ColumnValueFilter?, |
| 27 | + onApply: @escaping (ColumnValueFilter?) -> Void, |
| 28 | + onCancel: @escaping () -> Void |
| 29 | + ) { |
| 30 | + self.columnName = columnName |
| 31 | + self.values = values |
| 32 | + self.loadedRowCount = loadedRowCount |
| 33 | + self.onApply = onApply |
| 34 | + self.onCancel = onCancel |
| 35 | + if let initialFilter { |
| 36 | + _checkedValues = State(initialValue: initialFilter.selectedValues) |
| 37 | + _nullChecked = State(initialValue: initialFilter.includesNull) |
| 38 | + } else { |
| 39 | + _checkedValues = State(initialValue: Set(values.filter { !$0.isNull }.map(\.display))) |
| 40 | + _nullChecked = State(initialValue: values.contains { $0.isNull }) |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + var body: some View { |
| 45 | + VStack(alignment: .leading, spacing: 0) { |
| 46 | + header |
| 47 | + Divider() |
| 48 | + controls |
| 49 | + Divider() |
| 50 | + valueList |
| 51 | + Divider() |
| 52 | + footer |
| 53 | + } |
| 54 | + .frame(width: 260) |
| 55 | + } |
| 56 | + |
| 57 | + private var header: some View { |
| 58 | + VStack(alignment: .leading, spacing: 1) { |
| 59 | + Text(columnName) |
| 60 | + .font(.headline) |
| 61 | + .lineLimit(1) |
| 62 | + .truncationMode(.middle) |
| 63 | + Text(loadedRowsCaption) |
| 64 | + .font(.caption) |
| 65 | + .foregroundStyle(.secondary) |
| 66 | + } |
| 67 | + .frame(maxWidth: .infinity, alignment: .leading) |
| 68 | + .padding(.horizontal, 14) |
| 69 | + .padding(.top, 12) |
| 70 | + .padding(.bottom, 8) |
| 71 | + } |
| 72 | + |
| 73 | + private var loadedRowsCaption: String { |
| 74 | + String(format: String(localized: "Values from %d loaded rows"), loadedRowCount) |
| 75 | + } |
| 76 | + |
| 77 | + private var controls: some View { |
| 78 | + VStack(spacing: 8) { |
| 79 | + NativeSearchField( |
| 80 | + text: $searchText, |
| 81 | + placeholder: String(localized: "Search values"), |
| 82 | + onSubmit: { apply() }, |
| 83 | + focusOnAppear: true, |
| 84 | + accessibilityIdentifier: "value-filter-search" |
| 85 | + ) |
| 86 | + HStack(spacing: 6) { |
| 87 | + TristateCheckbox(state: selectAllState) { toggleSelectAll() } |
| 88 | + Text("Select All") |
| 89 | + Spacer() |
| 90 | + } |
| 91 | + } |
| 92 | + .padding(.horizontal, 14) |
| 93 | + .padding(.vertical, 8) |
| 94 | + } |
| 95 | + |
| 96 | + private var valueList: some View { |
| 97 | + List { |
| 98 | + ForEach(filteredValues) { value in |
| 99 | + Toggle(isOn: binding(for: value)) { |
| 100 | + HStack(spacing: 8) { |
| 101 | + Text(label(for: value)) |
| 102 | + .lineLimit(1) |
| 103 | + .truncationMode(.tail) |
| 104 | + .foregroundStyle(value.isNull ? Color.secondary : Color.primary) |
| 105 | + Spacer(minLength: 8) |
| 106 | + Text("\(value.count)") |
| 107 | + .font(.callout.monospacedDigit()) |
| 108 | + .foregroundStyle(.secondary) |
| 109 | + } |
| 110 | + } |
| 111 | + .toggleStyle(.checkbox) |
| 112 | + } |
| 113 | + } |
| 114 | + .listStyle(.plain) |
| 115 | + .scrollContentBackground(.hidden) |
| 116 | + .frame(height: 200) |
| 117 | + } |
| 118 | + |
| 119 | + private var footer: some View { |
| 120 | + HStack { |
| 121 | + Spacer() |
| 122 | + Button(role: .cancel) { |
| 123 | + onCancel() |
| 124 | + } label: { |
| 125 | + Text("Cancel") |
| 126 | + } |
| 127 | + .keyboardShortcut(.cancelAction) |
| 128 | + Button { |
| 129 | + apply() |
| 130 | + } label: { |
| 131 | + Text("Apply") |
| 132 | + } |
| 133 | + .keyboardShortcut(.defaultAction) |
| 134 | + .disabled(nothingSelected) |
| 135 | + } |
| 136 | + .padding(14) |
| 137 | + } |
| 138 | + |
| 139 | + private var filteredValues: [ColumnDistinctValue] { |
| 140 | + guard !searchText.isEmpty else { return values } |
| 141 | + return values.filter { label(for: $0).localizedCaseInsensitiveContains(searchText) } |
| 142 | + } |
| 143 | + |
| 144 | + private var selectAllState: TristateCheckbox.State { |
| 145 | + let selected = values.filter { $0.isNull ? nullChecked : checkedValues.contains($0.display) }.count |
| 146 | + if selected == 0 { return .unchecked } |
| 147 | + if selected == values.count { return .checked } |
| 148 | + return .mixed |
| 149 | + } |
| 150 | + |
| 151 | + private var nothingSelected: Bool { |
| 152 | + checkedValues.isEmpty && !nullChecked |
| 153 | + } |
| 154 | + |
| 155 | + private func label(for value: ColumnDistinctValue) -> String { |
| 156 | + if value.isNull { return Self.nullLabel } |
| 157 | + return value.display.isEmpty ? Self.emptyLabel : value.display |
| 158 | + } |
| 159 | + |
| 160 | + private func binding(for value: ColumnDistinctValue) -> Binding<Bool> { |
| 161 | + if value.isNull { |
| 162 | + return Binding(get: { nullChecked }, set: { nullChecked = $0 }) |
| 163 | + } |
| 164 | + return Binding( |
| 165 | + get: { checkedValues.contains(value.display) }, |
| 166 | + set: { isOn in |
| 167 | + if isOn { |
| 168 | + checkedValues.insert(value.display) |
| 169 | + } else { |
| 170 | + checkedValues.remove(value.display) |
| 171 | + } |
| 172 | + } |
| 173 | + ) |
| 174 | + } |
| 175 | + |
| 176 | + private func toggleSelectAll() { |
| 177 | + setAll(selectAllState != .checked) |
| 178 | + } |
| 179 | + |
| 180 | + private func setAll(_ selected: Bool) { |
| 181 | + if selected { |
| 182 | + checkedValues = Set(values.filter { !$0.isNull }.map(\.display)) |
| 183 | + nullChecked = values.contains { $0.isNull } |
| 184 | + } else { |
| 185 | + checkedValues = [] |
| 186 | + nullChecked = false |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + private func apply() { |
| 191 | + if selectAllState == .checked { |
| 192 | + onApply(nil) |
| 193 | + } else { |
| 194 | + onApply(ColumnValueFilter(selectedValues: checkedValues, includesNull: nullChecked)) |
| 195 | + } |
| 196 | + } |
| 197 | +} |
0 commit comments