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
46 changes: 43 additions & 3 deletions Sources/DashUIKit/Components/NumericKeyboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions Tests/DashUIKitTests/NumericKeyboardLocaleSupportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
31 changes: 30 additions & 1 deletion docs/buttons-and-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading