@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.
- package/ios/Information/Information.swift +5 -2
- package/ios/InputMoney/InputMoney.swift +76 -21
- package/ios/Typography/Text.swift +41 -1
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +9 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 =
|
|
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)
|
package/ios/native-kits.podspec
CHANGED
|
@@ -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),
|