@momo-kits/native-kits 0.162.36 → 0.162.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ public struct Information: View {
18
18
  private let action: String?
19
19
  private let showIconClose: Bool
20
20
  private let onPressClose: (() -> Void)?
21
+ private let highlightText: [String]
21
22
 
22
23
  public init(
23
24
  title: String? = nil,
@@ -28,7 +29,8 @@ public struct Information: View {
28
29
  showIcon: Bool = true,
29
30
  action: String? = nil,
30
31
  showIconClose: Bool = true,
31
- onPressClose: (() -> Void)? = nil
32
+ onPressClose: (() -> Void)? = nil,
33
+ highlightText: [String] = []
32
34
  ) {
33
35
  self.title = title
34
36
  self.description = description
@@ -39,6 +41,7 @@ public struct Information: View {
39
41
  self.action = action
40
42
  self.showIconClose = showIconClose
41
43
  self.onPressClose = onPressClose
44
+ self.highlightText = highlightText
42
45
  }
43
46
 
44
47
  private var borderColor: Color {
@@ -90,7 +93,7 @@ public struct Information: View {
90
93
  if let title {
91
94
  MomoText(title, typography: .labelDefaultMedium)
92
95
  }
93
- MomoText(description, typography: .descriptionDefaultRegular)
96
+ MomoText(description, typography: .descriptionDefaultRegular, highlightText: highlightText)
94
97
  }
95
98
  .frame(maxWidth: .infinity, alignment: .leading)
96
99
 
@@ -46,6 +46,42 @@ public func formatNumberToMoney(_ digits: String, currency: String = "đ") -> St
46
46
  return String(chars.reversed()) + currency
47
47
  }
48
48
 
49
+ private func isMoneyDigit(_ char: Character) -> Bool { char.isASCII && char.isNumber }
50
+
51
+ /// Applies an edit of `display[start..<end]` → `inserted` to the digits of `display`.
52
+ /// Separators and currency are never deleted: removing a separator deletes the
53
+ /// digit before it instead, removing only the currency leaves the digits untouched. Returns the new digits and the caret as a digit index.
54
+ private func applyMoneyEdit(_ display: String, start: Int, end: Int, inserted: String) -> (digits: String, caretDigit: Int) {
55
+ let chars = Array(display)
56
+ let start = min(start, chars.count)
57
+ let end = min(max(end, start), chars.count)
58
+ let digits = chars.filter(isMoneyDigit)
59
+ var left = chars[..<start].filter(isMoneyDigit).count
60
+ var removed = chars[start..<end].filter(isMoneyDigit).count
61
+ let insertedDigits = inserted.filter(isMoneyDigit)
62
+ if removed == 0 && insertedDigits.isEmpty && left > 0 && chars[start..<end].contains(".") {
63
+ left -= 1
64
+ removed = 1
65
+ }
66
+ if removed == 0 && insertedDigits.isEmpty {
67
+ return (String(digits), left)
68
+ }
69
+ let next = String(digits[..<left]) + insertedDigits + String(digits[(left + removed)...])
70
+ var trimmed = String(next.drop(while: { $0 == "0" }))
71
+ if trimmed.isEmpty { trimmed = String(next.prefix(1)) }
72
+ return (trimmed, max(0, left + insertedDigits.count - (next.count - trimmed.count)))
73
+ }
74
+
75
+ /// Caret offset right after the `digitIndex`-th digit of `display`.
76
+ private func moneyCaret(_ display: String, digitIndex: Int) -> Int {
77
+ var count = 0
78
+ for (index, char) in display.enumerated() where isMoneyDigit(char) {
79
+ count += 1
80
+ if count == digitIndex { return index + 1 }
81
+ }
82
+ return 0
83
+ }
84
+
49
85
  // MARK: - InputMoney
50
86
 
51
87
  public struct InputMoney: View {
@@ -261,8 +297,8 @@ public struct InputMoney: View {
261
297
  // MARK: - MoneyTextField (UIViewRepresentable)
262
298
 
263
299
  /// A UITextField that shows live thousands-separated currency while the bound
264
- /// `digits` string holds only the raw numeric value. Caret is pinned before
265
- /// the trailing currency symbol.
300
+ /// `digits` string holds only the raw numeric value. The caret can move freely
301
+ /// but never past the trailing currency symbol; edits apply at the caret.
266
302
  private struct MoneyTextField: UIViewRepresentable {
267
303
  @Binding var digits: String
268
304
  var keyboardType: UIKeyboardType
@@ -286,11 +322,6 @@ private struct MoneyTextField: UIViewRepresentable {
286
322
  field.setContentHuggingPriority(.defaultLow, for: .horizontal)
287
323
  field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
288
324
  field.text = displayValue(digits)
289
- field.addTarget(
290
- context.coordinator,
291
- action: #selector(Coordinator.editingChanged(_:)),
292
- for: .editingChanged
293
- )
294
325
  return field
295
326
  }
296
327
 
@@ -302,12 +333,13 @@ private struct MoneyTextField: UIViewRepresentable {
302
333
  field.textColor = textColor
303
334
  field.tintColor = cursorColor
304
335
  let expected = displayValue(digits)
305
- if !field.isFirstResponder && field.text != expected {
336
+ if field.text != expected {
306
337
  field.text = expected
338
+ context.coordinator.setCaret(field, offset: moneyCaret(expected, digitIndex: expected.filter(isMoneyDigit).count))
307
339
  }
308
340
  }
309
341
 
310
- private func displayValue(_ raw: String) -> String {
342
+ fileprivate func displayValue(_ raw: String) -> String {
311
343
  let numeric = raw.filter { $0.isNumber }
312
344
  return numeric.isEmpty ? "" : formatNumberToMoney(numeric)
313
345
  }
@@ -330,21 +362,44 @@ private struct MoneyTextField: UIViewRepresentable {
330
362
  return true
331
363
  }
332
364
 
333
- @objc func editingChanged(_ textField: UITextField) {
334
- // Extract digits from whatever the user typed/deleted.
335
- let numeric = (textField.text ?? "").filter { $0.isNumber }
336
- let formatted = numeric.isEmpty ? "" : formatNumberToMoney(numeric)
337
- if textField.text != formatted {
338
- textField.text = formatted
339
- // Keep caret before the currency suffix ("đ").
340
- if let end = textField.position(from: textField.endOfDocument, offset: -1) {
341
- textField.selectedTextRange = textField.textRange(from: end, to: end)
342
- }
343
- }
365
+ func textField(
366
+ _ textField: UITextField,
367
+ shouldChangeCharactersIn range: NSRange,
368
+ replacementString string: String
369
+ ) -> Bool {
370
+ let (numeric, caretDigit) = applyMoneyEdit(
371
+ textField.text ?? "",
372
+ start: range.location,
373
+ end: range.location + range.length,
374
+ inserted: string
375
+ )
376
+ let formatted = parent.displayValue(numeric)
377
+ textField.text = formatted
378
+ setCaret(textField, offset: moneyCaret(formatted, digitIndex: caretDigit))
344
379
  if parent.digits != numeric {
345
380
  parent.digits = numeric
381
+ parent.onChangeText?(numeric)
382
+ }
383
+ return false
384
+ }
385
+
386
+ // Keeps the caret out of the currency suffix.
387
+ func textFieldDidChangeSelection(_ textField: UITextField) {
388
+ guard let text = textField.text, !text.isEmpty,
389
+ let range = textField.selectedTextRange,
390
+ let limit = textField.position(from: textField.endOfDocument, offset: -1)
391
+ else { return }
392
+ let start = textField.compare(range.start, to: limit) == .orderedDescending ? limit : range.start
393
+ let end = textField.compare(range.end, to: limit) == .orderedDescending ? limit : range.end
394
+ if start != range.start || end != range.end {
395
+ textField.selectedTextRange = textField.textRange(from: start, to: end)
396
+ }
397
+ }
398
+
399
+ func setCaret(_ textField: UITextField, offset: Int) {
400
+ if let position = textField.position(from: textField.beginningOfDocument, offset: offset) {
401
+ textField.selectedTextRange = textField.textRange(from: position, to: position)
346
402
  }
347
- parent.onChangeText?(numeric)
348
403
  }
349
404
  }
350
405
  }
@@ -139,6 +139,7 @@ public struct MomoText: View {
139
139
  private let letterSpacing: CGFloat?
140
140
  private let softWrap: Bool
141
141
  private let accessibilityId: String?
142
+ private var highlightText: [String] = []
142
143
 
143
144
  public init(
144
145
  _ content: String,
@@ -176,13 +177,52 @@ public struct MomoText: View {
176
177
  self.accessibilityId = accessibilityId
177
178
  }
178
179
 
180
+ init(_ content: String, typography: TypographyStyle, highlightText: [String]) {
181
+ self.init(content, typography: typography)
182
+ self.highlightText = highlightText
183
+ }
184
+
185
+ // Whole-word matches of `highlightText` render semibold; the longest keyword wins (RN parity).
186
+ private var baseText: SwiftUI.Text {
187
+ let keywords = highlightText.filter { !$0.isEmpty }.sorted { $0.count > $1.count }
188
+ guard !keywords.isEmpty else { return SwiftUI.Text(content) }
189
+ let highlightFont = momoFont(size: fontScale(typography.fontSize), weight: TypographyStyle.headerXsSemibold.fontWeight, family: theme.font)
190
+ func isWordChar(_ character: Character?) -> Bool {
191
+ guard let character else { return false }
192
+ return character.isLetter || character.isNumber || character == "_"
193
+ }
194
+
195
+ var result = SwiftUI.Text(verbatim: "")
196
+ var plainStart = content.startIndex
197
+ var index = content.startIndex
198
+ while index < content.endIndex {
199
+ let previous = index > content.startIndex ? content[content.index(before: index)] : nil
200
+ let keyword = isWordChar(previous) ? nil : keywords.first { keyword in
201
+ guard content[index...].hasPrefix(keyword) else { return false }
202
+ let end = content.index(index, offsetBy: keyword.count)
203
+ return !isWordChar(end < content.endIndex ? content[end] : nil)
204
+ }
205
+ guard let keyword else {
206
+ index = content.index(after: index)
207
+ continue
208
+ }
209
+ let end = content.index(index, offsetBy: keyword.count)
210
+ result = result
211
+ + SwiftUI.Text(verbatim: String(content[plainStart..<index]))
212
+ + SwiftUI.Text(verbatim: String(content[index..<end])).font(highlightFont)
213
+ plainStart = end
214
+ index = end
215
+ }
216
+ return result + SwiftUI.Text(verbatim: String(content[plainStart...]))
217
+ }
218
+
179
219
  public var body: some View {
180
220
  let textColor = color ?? theme.colors.text.default
181
221
  // Strikethrough is driven either by the typography variant (mirrors Compose
182
222
  // *Strikethrough styles) or the explicit `strikethrough` flag.
183
223
  let isStrikethrough = strikethrough || typography.isStrikethrough
184
224
 
185
- var text = SwiftUI.Text(content)
225
+ var text = baseText
186
226
  .font(momoFont(size: fontScale(typography.fontSize), weight: typography.fontWeight, family: theme.font))
187
227
  .foregroundColor(textColor)
188
228
  .strikethrough(isStrikethrough, color: textColor)
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.164.1-swipeback.1'
3
+ s.version = '0.167.1-beta.2'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.164.1-swipeback.1'
3
+ s.version = '0.167.1-beta.2'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -44,6 +44,10 @@ private struct InformationPreview: View {
44
44
  Information(title: "Payment failed", description: "We couldn't process your payment.",
45
45
  state: .error, onPressAction: {}, action: "Retry")
46
46
  }
47
+ BasicColumnBox("Highlight text", alignment: .leading) {
48
+ Information(description: "Bạn được hoàn 50.000đ, áp dụng đến hết 31/12/2026",
49
+ highlightText: ["50.000đ", "31/12/2026"])
50
+ }
47
51
  BasicColumnBox("Without icon", alignment: .leading) {
48
52
  Information(title: "No icon", description: "showIcon is set to false.", showIcon: false)
49
53
  }
@@ -60,6 +64,7 @@ private struct InformationPlayground: View {
60
64
  @State private var showIcon = true
61
65
  @State private var showIconClose = false
62
66
  @State private var action = ""
67
+ @State private var highlightText = "description"
63
68
 
64
69
  var body: some View {
65
70
  PlaygroundTab(
@@ -72,12 +77,15 @@ private struct InformationPlayground: View {
72
77
  showIcon: showIcon,
73
78
  action: action.isEmpty ? nil : action,
74
79
  showIconClose: showIconClose,
75
- onPressClose: {}
80
+ onPressClose: {},
81
+ highlightText: highlightText.components(separatedBy: ",")
82
+ .map { $0.trimmingCharacters(in: .whitespaces) }
76
83
  )
77
84
  },
78
85
  params: [
79
86
  .string("Title", $title),
80
87
  .string("Description", $description),
88
+ .string("HighlightText: [String] (comma-separated)", $highlightText),
81
89
  .radio("State", informationStateList, $state),
82
90
  .checkbox("ShowIcon", $showIcon),
83
91
  .checkbox("ShowIconClose", $showIconClose),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.162.36",
3
+ "version": "0.162.37",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},