diff --git a/Sources/DashUIKit/Components/NumericKeyboardView.swift b/Sources/DashUIKit/Components/NumericKeyboardView.swift index 1950fa4..aa9c141 100644 --- a/Sources/DashUIKit/Components/NumericKeyboardView.swift +++ b/Sources/DashUIKit/Components/NumericKeyboardView.swift @@ -18,11 +18,25 @@ import Foundation import SwiftUI -enum NumericKeyboardLocaleSupport { - static func decimalSeparator(for locale: Locale) -> String { +/// The keypad's input rules, shared by the on-screen buttons and by hosts that +/// route a physical keyboard into the same value. +/// +/// `NumericKeyboardView` renders buttons and installs no text responder, so a +/// host that wants hardware-keyboard support has to supply its own responder. +/// It must not reimplement the rules while doing so: `applyKeyPress` owns the +/// locale-sensitive parts (which separator is decimal, which is grouping and +/// therefore dropped, single decimal separator, delete), and +/// `key(forTyped:locale:)` translates a typed character into the key the +/// on-screen keypad would have sent. +public enum NumericKeyboardLocaleSupport { + public static func decimalSeparator(for locale: Locale) -> String { locale.decimalSeparator ?? "." } + /// The key the delete button sends; a host's Backspace handling passes this + /// to `applyKeyPress`. + public static var deleteKey: String { Layout.deleteKey } + static func rows(showDecimalSeparator: Bool, locale: Locale) -> [[String]] { let lastRow: [String] if showDecimalSeparator { @@ -39,7 +53,33 @@ enum NumericKeyboardLocaleSupport { ] } - static func applyKeyPress( + /// Translates a character typed on a physical keyboard into the key the + /// on-screen keypad would have sent, or `nil` when the keypad has no such + /// key. Feed the result to `applyKeyPress`. + /// + /// A hardware decimal key stands for whatever this locale's decimal + /// separator is — a German layout emits "," and a US layout ".". The one + /// character deliberately passed through unchanged is the locale's + /// grouping separator, because dropping it is `applyKeyPress`'s rule to + /// apply, not this method's to duplicate. + public static func key(forTyped character: Character, locale: Locale) -> String? { + if let digit = character.wholeNumberValue, (0 ... 9).contains(digit) { + return String(digit) + } + + guard character == "." || character == "," else { return nil } + + let typed = String(character) + let groupingSeparator = locale.groupingSeparator ?? "," + let decimalSeparator = decimalSeparator(for: locale) + + if typed == groupingSeparator, groupingSeparator != decimalSeparator { + return typed + } + return decimalSeparator + } + + public static func applyKeyPress( value: String, key: String, showDecimalSeparator: Bool, diff --git a/Tests/DashUIKitTests/NumericKeyboardLocaleSupportTests.swift b/Tests/DashUIKitTests/NumericKeyboardLocaleSupportTests.swift index 1b566c8..c845c8f 100644 --- a/Tests/DashUIKitTests/NumericKeyboardLocaleSupportTests.swift +++ b/Tests/DashUIKitTests/NumericKeyboardLocaleSupportTests.swift @@ -101,4 +101,60 @@ final class NumericKeyboardLocaleSupportTests: XCTestCase { XCTAssertEqual(afterGroupingSeparator, "1234") XCTAssertEqual(afterDelete, "123") } + + func testTypedCharacterMapsToTheKeyTheKeypadWouldSend() { + let enUS = locale("en_US") + XCTAssertEqual(NumericKeyboardLocaleSupport.key(forTyped: "0", locale: enUS), "0") + XCTAssertEqual(NumericKeyboardLocaleSupport.key(forTyped: "9", locale: enUS), "9") + XCTAssertNil(NumericKeyboardLocaleSupport.key(forTyped: "a", locale: enUS)) + XCTAssertNil(NumericKeyboardLocaleSupport.key(forTyped: "-", locale: enUS)) + XCTAssertNil(NumericKeyboardLocaleSupport.key(forTyped: " ", locale: enUS)) + } + + func testTypedDecimalKeyFollowsTheLocale() { + XCTAssertEqual(NumericKeyboardLocaleSupport.key(forTyped: ".", locale: locale("en_US")), ".") + XCTAssertEqual(NumericKeyboardLocaleSupport.key(forTyped: ",", locale: locale("de_DE")), ",") + + // Neither separator in this locale (`de_CH` groups with "’"), so the + // key still means "decimal" rather than being dropped. + let deCH = locale("de_CH") + XCTAssertNotEqual(deCH.groupingSeparator, ",") + XCTAssertEqual(NumericKeyboardLocaleSupport.key(forTyped: ",", locale: deCH), ".") + } + + /// The grouping separator is handed through unchanged so `applyKeyPress` + /// can drop it — typing "1,000" in `en_US` must not become "1.000". + func testTypingAGroupedAmountKeepsItsMagnitude() { + let locale = locale("en_US") + var value = "" + + for character in "1,000.5" { + guard let key = NumericKeyboardLocaleSupport.key(forTyped: character, locale: locale) else { continue } + value = NumericKeyboardLocaleSupport.applyKeyPress( + value: value, + key: key, + showDecimalSeparator: true, + locale: locale + ) + } + + XCTAssertEqual(value, "1000.5") + } + + func testTypingAGroupedAmountKeepsItsMagnitudeInAGermanLocale() { + let locale = locale("de_DE") + var value = "" + + for character in "1.000,5" { + guard let key = NumericKeyboardLocaleSupport.key(forTyped: character, locale: locale) else { continue } + value = NumericKeyboardLocaleSupport.applyKeyPress( + value: value, + key: key, + showDecimalSeparator: true, + locale: locale + ) + } + + XCTAssertEqual(value, "1000,5") + } } diff --git a/docs/buttons-and-inputs.md b/docs/buttons-and-inputs.md index 8fd8e2a..8e8715d 100644 --- a/docs/buttons-and-inputs.md +++ b/docs/buttons-and-inputs.md @@ -134,7 +134,36 @@ NumericKeyboardView( ) ``` -Key-press logic lives in `NumericKeyboardLocaleSupport` (internal, unit-testable): +Key-press logic lives in `NumericKeyboardLocaleSupport` (public, unit-testable): appends digits, inserts at most one decimal separator (only when `showDecimalSeparator`), ignores the grouping separator, and `⌫` removes the last character. The action button is enabled only when `value` is non-empty **and** `actionEnabled` is true. + +### Hardware keyboards + +The pad is made of buttons and installs no text responder, so a physical keyboard reaches +it only if the host adds one. Route those keystrokes through the same rules rather than +reimplementing them — a second copy is how `1,000` typed in `en_US` turns into `1.000`: + +```swift +// In the host's `UIKeyInput` responder. +override func insertText(_ text: String) { + for character in text { + guard let key = NumericKeyboardLocaleSupport.key(forTyped: character, locale: locale) else { continue } + value = NumericKeyboardLocaleSupport.applyKeyPress( + value: value, key: key, showDecimalSeparator: true, locale: locale + ) + } +} + +override func deleteBackward() { + value = NumericKeyboardLocaleSupport.applyKeyPress( + value: value, key: NumericKeyboardLocaleSupport.deleteKey, showDecimalSeparator: true, locale: locale + ) +} +``` + +`key(forTyped:locale:)` returns `nil` for characters the pad has no key for. A typed `.` or +`,` maps to the locale's decimal separator, except when it *is* the locale's grouping +separator — that one is passed through so `applyKeyPress` drops it, exactly as a tapped key +would be.