craft-native 0.0.484 → 0.0.487

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.
@@ -150,8 +150,8 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
150
150
  private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
151
151
 
152
152
  private fun craftNativePickerValue(value: Any?): String? = when (value) {
153
- is String -> value.takeIf { it.isNotEmpty() }
154
- is Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
153
+ is String -> value
154
+ is Number -> value.toDouble().takeIf { it.isFinite() }?.let { value.toString() }
155
155
  else -> null
156
156
  }
157
157
 
@@ -389,13 +389,172 @@ private object CraftNativeSkewTransforms {
389
389
  }
390
390
 
391
391
  /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
392
+ /** Local native chart selection: no JS events are needed while scrubbing. */
393
+ private class CraftNativeChartInspection(private val view: View) {
394
+ private var fingerprint = ""
395
+ private var title = ""
396
+ private var points = emptyList<JSONObject>()
397
+ private var selected = -1
398
+ private var startX = 0f
399
+ private var startY = 0f
400
+ private var dragging = false
401
+ private var abandoned = false
402
+ private val slop = android.view.ViewConfiguration.get(view.context).scaledTouchSlop
403
+ private val hold = Runnable { if (!abandoned) selectAt(startX) }
404
+ val enabled: Boolean get() = points.isNotEmpty()
405
+ private val density get() = view.resources.displayMetrics.density
406
+ private fun dp(value: Float) = value * density
407
+ fun configure(raw: JSONObject?) {
408
+ val next = raw?.toString().orEmpty()
409
+ if (next == fingerprint) return
410
+ fingerprint = next
411
+ title = raw?.optString("title").orEmpty()
412
+ val list = raw?.optJSONArray("points") ?: JSONArray()
413
+ points = (0 until list.length()).mapNotNull { list.optJSONObject(it) }.filter {
414
+ val x = it.optDouble("x", Double.NaN)
415
+ x.isFinite() && x in 0.0..1.0 && it.opt("label") is String
416
+ }
417
+ selected = -1
418
+ view.isFocusable = enabled
419
+ view.invalidate()
420
+ }
421
+ private fun announcement(point: JSONObject): String {
422
+ val rows = point.optJSONArray("rows") ?: JSONArray()
423
+ return (listOf(point.optString("label")) + (0 until rows.length()).mapNotNull { rows.optJSONObject(it)?.let { row -> row.optString("label") + ": " + row.optString("value") } }).joinToString(". ")
424
+ }
425
+ private fun select(index: Int) {
426
+ if (!enabled) return
427
+ val next = index.coerceIn(0, points.lastIndex)
428
+ if (next == selected) return
429
+ selected = next
430
+ view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
431
+ view.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED)
432
+ view.invalidate()
433
+ }
434
+ private fun selectAt(x: Float) {
435
+ if (view.width <= 0) return
436
+ val fraction = (x / view.width).coerceIn(0f, 1f)
437
+ points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }?.let { select(it) }
438
+ }
439
+ fun touch(event: MotionEvent): Boolean {
440
+ if (!enabled) return false
441
+ when (event.actionMasked) {
442
+ MotionEvent.ACTION_DOWN -> {
443
+ startX = event.x; startY = event.y; dragging = false; abandoned = false
444
+ view.postDelayed(hold, 250)
445
+ }
446
+ MotionEvent.ACTION_MOVE -> {
447
+ val dx = kotlin.math.abs(event.x - startX); val dy = kotlin.math.abs(event.y - startY)
448
+ if (!dragging && maxOf(dx, dy) > slop) {
449
+ view.removeCallbacks(hold)
450
+ if (dy > dx) { abandoned = true; return false }
451
+ dragging = true
452
+ view.parent?.requestDisallowInterceptTouchEvent(true)
453
+ }
454
+ if (dragging && !abandoned) selectAt(event.x)
455
+ }
456
+ MotionEvent.ACTION_UP -> {
457
+ view.removeCallbacks(hold)
458
+ if (!abandoned) { view.performClick(); selectAt(event.x) }
459
+ view.parent?.requestDisallowInterceptTouchEvent(false)
460
+ }
461
+ MotionEvent.ACTION_CANCEL -> {
462
+ view.removeCallbacks(hold); abandoned = true; selected = -1
463
+ view.parent?.requestDisallowInterceptTouchEvent(false); view.invalidate()
464
+ }
465
+ }
466
+ return !abandoned
467
+ }
468
+ fun detach() { view.removeCallbacks(hold); selected = -1 }
469
+ fun accessibility(info: android.view.accessibility.AccessibilityNodeInfo) {
470
+ if (!enabled) return
471
+ info.className = "android.widget.SeekBar"
472
+ info.isScrollable = true
473
+ info.contentDescription = listOf(title, announcement(points[if (selected >= 0) selected else points.lastIndex])).filter { it.isNotBlank() }.joinToString(". ")
474
+ info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD)
475
+ info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD)
476
+ if (selected >= 0) info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS)
477
+ }
478
+ fun action(action: Int): Boolean {
479
+ if (!enabled) return false
480
+ when (action) {
481
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> select(if (selected < 0) 0 else selected + 1)
482
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> select(if (selected < 0) points.lastIndex else selected - 1)
483
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS -> { selected = -1; view.invalidate() }
484
+ else -> return false
485
+ }
486
+ return true
487
+ }
488
+ fun draw(canvas: Canvas) {
489
+ val point = points.getOrNull(selected) ?: return
490
+ val dark = view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
491
+ val ink = if (dark) Color.rgb(249, 250, 251) else Color.rgb(17, 24, 39)
492
+ val secondary = if (dark) Color.rgb(209, 213, 219) else Color.rgb(75, 85, 99)
493
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
494
+ val x = (point.optDouble("x") * view.width).toFloat()
495
+ paint.color = secondary; paint.alpha = 160; paint.strokeWidth = dp(1f); paint.style = Paint.Style.STROKE
496
+ paint.pathEffect = DashPathEffect(floatArrayOf(dp(2f), dp(3f)), 0f)
497
+ canvas.drawLine(x, 0f, x, view.height.toFloat(), paint)
498
+ paint.pathEffect = null; paint.alpha = 255; paint.style = Paint.Style.FILL
499
+ val y = point.optDouble("y", Double.NaN)
500
+ if (y.isFinite() && y in 0.0..1.0) {
501
+ paint.color = runCatching { craftNativeColor(point.optString("color", "#3b82f6")) }.getOrDefault(Color.BLUE)
502
+ canvas.drawCircle(x, (y * view.height).toFloat(), dp(5f), paint)
503
+ paint.color = if (dark) Color.BLACK else Color.WHITE; paint.style = Paint.Style.STROKE; paint.strokeWidth = dp(2f)
504
+ canvas.drawCircle(x, (y * view.height).toFloat(), dp(5f), paint)
505
+ }
506
+ val rows = point.optJSONArray("rows") ?: JSONArray()
507
+ val textSize = minOf(dp(20f), 12f * view.resources.displayMetrics.scaledDensity)
508
+ val rowHeight = textSize * 1.25f + dp(7f)
509
+ val width = minOf(dp(264f), view.width - dp(16f))
510
+ if (width <= 0 || view.height < dp(24f)) return
511
+ val height = minOf(view.height - dp(16f), dp(24f) + rowHeight * (rows.length() + if (title.isBlank()) 1 else 2))
512
+ val left = (if (x > view.width / 2f) x - width - dp(12f) else x + dp(12f)).coerceIn(dp(8f), view.width - width - dp(8f))
513
+ val panel = RectF(left, dp(8f), left + width, dp(8f) + height)
514
+ paint.style = Paint.Style.FILL; paint.color = if (dark) Color.rgb(36, 36, 38) else Color.WHITE
515
+ canvas.drawRoundRect(panel, dp(12f), dp(12f), paint)
516
+ paint.style = Paint.Style.STROKE; paint.strokeWidth = dp(1f); paint.color = if (dark) Color.rgb(70, 70, 74) else Color.rgb(226, 232, 240)
517
+ canvas.drawRoundRect(panel, dp(12f), dp(12f), paint)
518
+ canvas.save(); canvas.clipRect(panel.left + dp(10f), panel.top + dp(8f), panel.right - dp(10f), panel.bottom - dp(8f))
519
+ paint.style = Paint.Style.FILL; paint.textSize = textSize
520
+ fun text(value: String, left: Float, top: Float, width: Float, bold: Boolean = false, right: Boolean = false, muted: Boolean = false) {
521
+ paint.color = if (muted) secondary else ink
522
+ paint.typeface = if (bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
523
+ val shown = TextUtils.ellipsize(value, android.text.TextPaint(paint), maxOf(0f, width), TextUtils.TruncateAt.END).toString()
524
+ canvas.drawText(shown, if (right) left + width - paint.measureText(shown) else left, top - paint.fontMetrics.ascent, paint)
525
+ }
526
+ var top = panel.top + dp(12f)
527
+ if (title.isNotBlank()) { text(title, left + dp(12f), top, width - dp(24f), bold = true); top += rowHeight }
528
+ text(point.optString("label"), left + dp(12f), top, width - dp(24f), muted = true); top += rowHeight
529
+ for (i in 0 until rows.length()) {
530
+ val row = rows.optJSONObject(i) ?: continue
531
+ var labelLeft = left + dp(12f)
532
+ if (row.opt("color") is String) {
533
+ paint.color = runCatching { craftNativeColor(row.optString("color")) }.getOrDefault(ink)
534
+ canvas.drawRoundRect(RectF(labelLeft, top + dp(4f), labelLeft + dp(8f), top + dp(12f)), dp(1f), dp(1f), paint); labelLeft += dp(14f)
535
+ }
536
+ text(row.optString("label"), labelLeft, top, width * 0.47f - (labelLeft - left), muted = true)
537
+ text(row.optString("value"), left + width * 0.48f, top, width * 0.52f - dp(12f), bold = true, right = true)
538
+ top += rowHeight
539
+ }
540
+ canvas.restore()
541
+ }
542
+ }
543
+
392
544
  private class CraftNativeSvgView(context: Context) : View(context) {
545
+ private val inspection = CraftNativeChartInspection(this)
546
+ override fun onTouchEvent(event: MotionEvent): Boolean = inspection.touch(event) || super.onTouchEvent(event)
547
+ override fun performClick(): Boolean = super.performClick()
548
+ override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) { super.onInitializeAccessibilityNodeInfo(info); inspection.accessibility(info) }
549
+ override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean = inspection.action(action) || super.performAccessibilityAction(action, arguments)
550
+
393
551
  override fun dispatchTouchEvent(event: MotionEvent): Boolean =
394
552
  CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
395
553
 
396
554
  var drawing: JSONObject = JSONObject()
397
555
  set(value) {
398
556
  field = value
557
+ inspection.configure(value.optJSONObject("props")?.optJSONObject("inspection"))
399
558
  animates = hasAnimation(value.optJSONArray("children"))
400
559
  invalidate()
401
560
  requestLayout()
@@ -419,6 +578,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
419
578
  }
420
579
 
421
580
  override fun onDetachedFromWindow() {
581
+ inspection.detach()
422
582
  super.onDetachedFromWindow()
423
583
  updateFrames()
424
584
  }
@@ -633,6 +793,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
633
793
  canvas.concat(viewTransform)
634
794
  drawNodes(canvas, drawing.optJSONArray("children") ?: JSONArray(), SvgPaint().merge(drawing))
635
795
  canvas.restore()
796
+ inspection.draw(canvas)
636
797
  }
637
798
 
638
799
  private fun viewBox(): Quadruple<Float, Float, Float, Float>? {
package/dist/cli.js CHANGED
@@ -3243,7 +3243,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3243
3243
  `);
3244
3244
  }
3245
3245
  // package.json
3246
- var version = "0.0.484";
3246
+ var version = "0.0.487";
3247
3247
 
3248
3248
  // bin/cli.ts
3249
3249
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.484";
379
+ var version = "0.0.487";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.484";
120
+ var version = "0.0.487";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -168,8 +168,8 @@ private func craftNativeFontWeight(_ value: Any?) -> String? {
168
168
  }
169
169
 
170
170
  private func craftNativePickerValue(_ value: Any?) -> String? {
171
- if let value = value as? String, !value.isEmpty { return value }
172
- if let value = craftNativeNumber(value), value.doubleValue.isFinite { return String(value.doubleValue) }
171
+ if let value = value as? String { return value }
172
+ if let value = craftNativeNumber(value), value.doubleValue.isFinite { return value.stringValue }
173
173
  return nil
174
174
  }
175
175
 
@@ -5192,7 +5192,164 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5192
5192
  /// 3 quadratic, 4 close, in viewBox units - and scaled to the view. Paint is
5193
5193
  /// inherited down the tree as SVG does; `dark:` classes arrive already
5194
5194
  /// resolved for the appearance, so a redraw is all a theme change needs.
5195
+ /// Native inspection of an SVG plot. Coordinates are fractions of the plot's
5196
+ /// bounds; labels and values are already localized by the application. No JS
5197
+ /// callback or bridge round trip is needed while a finger moves.
5198
+ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5199
+ struct Reading {
5200
+ let key: String
5201
+ let x: CGFloat
5202
+ let y: CGFloat?
5203
+ let color: UIColor
5204
+ let label: String
5205
+ let rows: [(String, String, UIColor?)]
5206
+ var announcement: String { ([label] + rows.map { $0.0 + ": " + $0.1 }).joined(separator: ". ") }
5207
+ }
5208
+ private weak var view: UIView?
5209
+ private var configuration: NSDictionary = [:]
5210
+ private(set) var readings: [Reading] = []
5211
+ private(set) var selectedIndex = -1
5212
+ private var title = ""
5213
+ private let feedback = UISelectionFeedbackGenerator()
5214
+ private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
5215
+ private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
5216
+ private lazy var hold = UILongPressGestureRecognizer(target: self, action: #selector(held(_:)))
5217
+
5218
+ init(view: UIView) {
5219
+ self.view = view
5220
+ super.init()
5221
+ pan.delegate = self
5222
+ hold.minimumPressDuration = 0.25
5223
+ [tap, pan, hold].forEach { view.addGestureRecognizer($0); $0.isEnabled = false }
5224
+ }
5225
+
5226
+ func configure(_ raw: Any?) {
5227
+ let next = raw as? [String: Any] ?? [:]
5228
+ guard !configuration.isEqual(to: next) else { return }
5229
+ configuration = next as NSDictionary
5230
+ title = next["title"] as? String ?? ""
5231
+ readings = (next["points"] as? [[String: Any]] ?? []).compactMap { point in
5232
+ guard let x = craftNativeNumber(point["x"])?.doubleValue, x.isFinite, x >= 0, x <= 1,
5233
+ let label = point["label"] as? String else { return nil }
5234
+ let rawY = craftNativeNumber(point["y"])?.doubleValue
5235
+ let y = rawY.flatMap { $0.isFinite && $0 >= 0 && $0 <= 1 ? CGFloat($0) : nil }
5236
+ let rows = (point["rows"] as? [[String: Any]] ?? []).compactMap { row -> (String, String, UIColor?)? in
5237
+ guard let label = row["label"] as? String, let value = row["value"] as? String else { return nil }
5238
+ return (label, value, (row["color"] as? String).flatMap { UIColor(css: $0) })
5239
+ }
5240
+ return Reading(key: point["key"] as? String ?? label, x: CGFloat(x), y: y,
5241
+ color: (point["color"] as? String).flatMap { UIColor(css: $0) } ?? .systemBlue, label: label, rows: rows)
5242
+ }
5243
+ selectedIndex = -1
5244
+ view?.isUserInteractionEnabled = !readings.isEmpty
5245
+ if readings.isEmpty { view?.accessibilityTraits.remove(.adjustable); view?.accessibilityValue = nil }
5246
+ [tap, pan, hold].forEach { $0.isEnabled = !readings.isEmpty }
5247
+ configureAccessibility()
5248
+ view?.setNeedsDisplay()
5249
+ }
5250
+
5251
+ func configureAccessibility() {
5252
+ guard let view, !readings.isEmpty else { return }
5253
+ view.isAccessibilityElement = true
5254
+ view.accessibilityTraits.insert(.adjustable)
5255
+ view.accessibilityValue = readings[selectedIndex >= 0 ? selectedIndex : readings.count - 1].announcement
5256
+ }
5257
+
5258
+ func select(_ index: Int, haptic: Bool = true) {
5259
+ guard !readings.isEmpty else { return }
5260
+ let next = max(0, min(readings.count - 1, index))
5261
+ guard next != selectedIndex else { return }
5262
+ selectedIndex = next
5263
+ if haptic { feedback.selectionChanged() }
5264
+ configureAccessibility()
5265
+ view?.setNeedsDisplay()
5266
+ }
5267
+
5268
+ func adjust(_ direction: Int) {
5269
+ select(selectedIndex < 0 ? (direction < 0 ? readings.count - 1 : 0) : selectedIndex + direction)
5270
+ }
5271
+
5272
+ @discardableResult func dismiss() -> Bool {
5273
+ guard selectedIndex >= 0 else { return false }
5274
+ selectedIndex = -1
5275
+ configureAccessibility()
5276
+ view?.setNeedsDisplay()
5277
+ return true
5278
+ }
5279
+
5280
+ private func select(at point: CGPoint) {
5281
+ guard let view, view.bounds.width > 0 else { return }
5282
+ let fraction = max(0, min(1, point.x / view.bounds.width))
5283
+ if let nearest = readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) }) { select(nearest) }
5284
+ }
5285
+ @objc private func tapped(_ gesture: UITapGestureRecognizer) { select(at: gesture.location(in: view)) }
5286
+ @objc private func panned(_ gesture: UIPanGestureRecognizer) {
5287
+ if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5288
+ if gesture.state == .cancelled { dismiss() }
5289
+ }
5290
+ @objc private func held(_ gesture: UILongPressGestureRecognizer) {
5291
+ if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5292
+ if gesture.state == .cancelled { dismiss() }
5293
+ }
5294
+ func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool {
5295
+ guard gesture === pan else { return true }
5296
+ let velocity = pan.velocity(in: view)
5297
+ return abs(velocity.x) > abs(velocity.y)
5298
+ }
5299
+ func gestureRecognizer(_ gesture: UIGestureRecognizer, shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool {
5300
+ gesture === pan && other.view is UIScrollView
5301
+ }
5302
+
5303
+ func draw(in context: CGContext, bounds: CGRect) {
5304
+ guard readings.indices.contains(selectedIndex), bounds.width > 24, bounds.height > 24 else { return }
5305
+ let reading = readings[selectedIndex], x = bounds.width * reading.x
5306
+ context.saveGState()
5307
+ defer { context.restoreGState() }
5308
+ context.setStrokeColor(UIColor.secondaryLabel.withAlphaComponent(0.65).cgColor)
5309
+ context.setLineWidth(1)
5310
+ context.setLineDash(phase: 0, lengths: [2, 3])
5311
+ context.move(to: CGPoint(x: x, y: 0)); context.addLine(to: CGPoint(x: x, y: bounds.height)); context.strokePath()
5312
+ context.setLineDash(phase: 0, lengths: [])
5313
+ if let y = reading.y {
5314
+ let dot = CGRect(x: x - 5, y: bounds.height * y - 5, width: 10, height: 10)
5315
+ context.setFillColor(reading.color.cgColor); context.fillEllipse(in: dot)
5316
+ context.setStrokeColor(UIColor.systemBackground.cgColor); context.setLineWidth(2); context.strokeEllipse(in: dot)
5317
+ }
5318
+ let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12), maximumPointSize: 20)
5319
+ let bold = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 12, weight: .semibold), maximumPointSize: 20)
5320
+ let rowHeight = ceil(font.lineHeight) + 7
5321
+ let width = min(264, bounds.width - 16)
5322
+ let height = min(bounds.height - 16, 24 + rowHeight * CGFloat(reading.rows.count + (title.isEmpty ? 1 : 2)))
5323
+ let left = max(8, min(bounds.width - width - 8, x > bounds.midX ? x - width - 12 : x + 12))
5324
+ let panel = CGRect(x: left, y: 8, width: width, height: height)
5325
+ context.setShadow(offset: CGSize(width: 0, height: 4), blur: 12, color: UIColor.black.withAlphaComponent(0.16).cgColor)
5326
+ UIColor.secondarySystemGroupedBackground.setFill(); UIBezierPath(roundedRect: panel, cornerRadius: 12).fill()
5327
+ context.setShadow(offset: .zero, blur: 0, color: nil)
5328
+ UIColor.separator.withAlphaComponent(0.5).setStroke(); let border = UIBezierPath(roundedRect: panel.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 12); border.lineWidth = 1; border.stroke()
5329
+ context.clip(to: panel.insetBy(dx: 10, dy: 8))
5330
+ let paragraph = NSMutableParagraphStyle(); paragraph.lineBreakMode = .byTruncatingTail
5331
+ func text(_ value: String, rect: CGRect, strong: Bool = false, right: Bool = false, secondary: Bool = false) {
5332
+ paragraph.alignment = right ? .right : .left
5333
+ (value as NSString).draw(in: rect, withAttributes: [.font: strong ? bold : font, .foregroundColor: secondary ? UIColor.label.withAlphaComponent(0.72) : UIColor.label, .paragraphStyle: paragraph])
5334
+ }
5335
+ var top = panel.minY + 12
5336
+ if !title.isEmpty { text(title, rect: CGRect(x: left + 12, y: top, width: width - 24, height: rowHeight), strong: true); top += rowHeight }
5337
+ text(reading.label, rect: CGRect(x: left + 12, y: top, width: width - 24, height: rowHeight), secondary: true); top += rowHeight
5338
+ for row in reading.rows {
5339
+ var labelLeft = left + 12
5340
+ if let color = row.2 { color.setFill(); UIBezierPath(roundedRect: CGRect(x: labelLeft, y: top + 4, width: 8, height: 8), cornerRadius: 1).fill(); labelLeft += 14 }
5341
+ text(row.0, rect: CGRect(x: labelLeft, y: top, width: width * 0.47 - (labelLeft - left), height: rowHeight), secondary: true)
5342
+ text(row.1, rect: CGRect(x: left + width * 0.48, y: top, width: width * 0.52 - 12, height: rowHeight), strong: true, right: true)
5343
+ top += rowHeight
5344
+ }
5345
+ }
5346
+ }
5347
+
5195
5348
  final class CraftNativeSvgView: UIView {
5349
+ lazy var inspection = CraftChartInspection(view: self)
5350
+ override func accessibilityIncrement() { inspection.adjust(1) }
5351
+ override func accessibilityDecrement() { inspection.adjust(-1) }
5352
+ override func accessibilityPerformEscape() -> Bool { inspection.dismiss() }
5196
5353
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5197
5354
  craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
5198
5355
  }
@@ -5200,6 +5357,7 @@ final class CraftNativeSvgView: UIView {
5200
5357
  var drawing: [String: Any] = [:] {
5201
5358
  didSet {
5202
5359
  animates = Self.hasAnimation(drawing["children"] as? [Any] ?? [])
5360
+ inspection.configure((drawing["props"] as? [String: Any])?["inspection"])
5203
5361
  setNeedsDisplay()
5204
5362
  updateFrames()
5205
5363
  }
@@ -5426,14 +5584,17 @@ final class CraftNativeSvgView: UIView {
5426
5584
 
5427
5585
  override func draw(_ rect: CGRect) {
5428
5586
  guard let context = UIGraphicsGetCurrentContext() else { return }
5587
+ context.saveGState()
5429
5588
  viewTransform = .identity
5430
5589
  if let box = viewBox {
5431
5590
  let props = drawing["props"] as? [String: Any] ?? [:]
5432
- guard let transform = CraftNativeSvgView.transform(box: box, bounds: bounds.size, preserveAspectRatio: props["preserveAspectRatio"] as? String) else { return }
5591
+ guard let transform = CraftNativeSvgView.transform(box: box, bounds: bounds.size, preserveAspectRatio: props["preserveAspectRatio"] as? String) else { context.restoreGState(); return }
5433
5592
  viewTransform = transform
5434
5593
  context.concatenate(transform)
5435
5594
  }
5436
5595
  draw(children: drawing["children"] as? [Any] ?? [], paint: Paint().merged(drawing), in: context)
5596
+ context.restoreGState()
5597
+ inspection.draw(in: context, bounds: bounds)
5437
5598
  }
5438
5599
 
5439
5600
  private func draw(children: [Any], paint: Paint, in context: CGContext) {
@@ -6434,11 +6595,108 @@ enum CraftNativeBundle {
6434
6595
  }
6435
6596
  }
6436
6597
 
6437
- private final class CraftNativePickerView: UIPickerView {
6598
+ /// One shared single-choice control, compact like a form field. UIKit owns
6599
+ /// presentation, dismissal, checkmarks, keyboard navigation and VoiceOver.
6600
+ final class CraftNativePickerView: UIButton {
6601
+ struct Option: Equatable {
6602
+ let value: String
6603
+ let label: String
6604
+ let disabled: Bool
6605
+ }
6606
+ private var options: [Option] = []
6607
+ private var selectedValue: String?
6608
+ private var handlerIdentity: String?
6609
+ private var dispatch: ((String, Int) -> Void)?
6610
+ private var revision = 0
6611
+ private(set) var selectedLabel = ""
6612
+
6613
+ override init(frame: CGRect) {
6614
+ super.init(frame: frame)
6615
+ showsMenuAsPrimaryAction = true
6616
+ contentHorizontalAlignment = .fill
6617
+ titleLabel?.adjustsFontForContentSizeCategory = true
6618
+ layer.cornerRadius = 10
6619
+ }
6620
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
6621
+
6622
+ func update(options: [Option], selected: String?, enabled: Bool, handler: String?,
6623
+ font: UIFont, foreground: UIColor, dispatch: @escaping (String, Int) -> Void) {
6624
+ let candidate = selected ?? self.selectedValue
6625
+ let resolved = options.contains(where: { $0.value == candidate }) ? candidate : options.first(where: { !$0.disabled })?.value
6626
+ if self.options != options || self.selectedValue != resolved || self.isEnabled != enabled || self.handlerIdentity != handler {
6627
+ revision += 1
6628
+ interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
6629
+ }
6630
+ self.options = options
6631
+ self.selectedValue = resolved
6632
+ self.handlerIdentity = handler
6633
+ self.dispatch = dispatch
6634
+ isEnabled = enabled && options.contains(where: { !$0.disabled })
6635
+ selectedLabel = options.first(where: { $0.value == resolved })?.label ?? ""
6636
+ var configuration = UIButton.Configuration.plain()
6637
+ configuration.title = selectedLabel
6638
+ configuration.image = UIImage(systemName: "chevron.down")
6639
+ configuration.imagePlacement = .trailing
6640
+ configuration.imagePadding = 12
6641
+ configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)
6642
+ configuration.titleAlignment = .leading
6643
+ configuration.baseForegroundColor = foreground
6644
+ configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
6645
+ var result = attributes
6646
+ result.font = font
6647
+ return result
6648
+ }
6649
+ configuration.imageColorTransformer = UIConfigurationColorTransformer { _ in .secondaryLabel }
6650
+ self.configuration = configuration
6651
+ refreshMenu()
6652
+ invalidateIntrinsicContentSize()
6653
+ }
6654
+
6655
+ private func refreshMenu() {
6656
+ menu = UIMenu(children: options.enumerated().map { index, option in
6657
+ let choose = selection(index)
6658
+ return UIAction(title: option.label, attributes: option.disabled ? [.disabled] : [],
6659
+ state: option.value == selectedValue ? .on : .off) { _ in choose() }
6660
+ })
6661
+ }
6662
+
6663
+ /// An open menu must not apply an old option to a rebound or disabled field.
6664
+ func selection(_ index: Int) -> () -> Void {
6665
+ let expected = revision
6666
+ return { [weak self] in
6667
+ guard let self, self.revision == expected, self.isEnabled,
6668
+ self.options.indices.contains(index), !self.options[index].disabled else { return }
6669
+ let value = self.options[index].value
6670
+ guard value != self.selectedValue else { return }
6671
+ self.selectedValue = value
6672
+ self.selectedLabel = self.options[index].label
6673
+ self.configuration?.title = self.selectedLabel
6674
+ self.accessibilityValue = self.selectedLabel
6675
+ self.revision += 1
6676
+ self.refreshMenu()
6677
+ self.invalidateIntrinsicContentSize()
6678
+ self.dispatch?(value, index)
6679
+ }
6680
+ }
6681
+
6682
+ func detach() {
6683
+ revision += 1
6684
+ dispatch = nil
6685
+ interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
6686
+ menu = nil
6687
+ }
6688
+
6689
+ override var intrinsicContentSize: CGSize {
6690
+ let size = super.intrinsicContentSize
6691
+ return CGSize(width: max(44, size.width), height: max(44, size.height))
6692
+ }
6693
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
6694
+ let fitted = super.sizeThatFits(size)
6695
+ return CGSize(width: fitted.width, height: max(44, fitted.height))
6696
+ }
6438
6697
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6439
6698
  craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6440
6699
  }
6441
-
6442
6700
  }
6443
6701
 
6444
6702
  private final class CraftNativeDatePickerView: UIDatePicker {
@@ -6489,7 +6747,7 @@ private final class CraftNativeScreenRootView: UIView {
6489
6747
  }
6490
6748
  }
6491
6749
 
6492
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
6750
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
6493
6751
  private final class RenderedNode {
6494
6752
  /// A recycled list row's root takes the identity of the row it shows next.
6495
6753
  var identity: String
@@ -6553,7 +6811,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6553
6811
  /// the user's tap. Remember that native value briefly so the echo does not
6554
6812
  /// interrupt the animation and leave the thumb painted at its old edge.
6555
6813
  private var switchUserValues: [ObjectIdentifier: Bool] = [:]
6556
- private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
6557
6814
  private var modalVisibility: [ObjectIdentifier: Bool] = [:]
6558
6815
  private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
6559
6816
  private var layoutHandlers: [ObjectIdentifier: String] = [:]
@@ -9433,31 +9690,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9433
9690
  control.configureAccessibility(items: items)
9434
9691
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
9435
9692
  case "Picker":
9436
- let picker = result as! UIPickerView
9437
- let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
9438
- guard let label = item["label"] as? String, !label.isEmpty else { return nil }
9439
- return (value: craftNativePickerValue(item["value"]) ?? label, label: label)
9440
- } ?? []
9441
- let options = itemOptions.isEmpty
9442
- ? children.compactMap { child -> (value: String, label: String)? in
9443
- guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
9444
- let optionProps = node["props"] as? [String: Any] ?? [:]
9445
- let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
9446
- guard !label.isEmpty else { return nil }
9447
- return (value: craftNativePickerValue(optionProps["value"]) ?? label, label: label)
9693
+ let picker = result as! CraftNativePickerView
9694
+ let rawItems = props["items"] as? [[String: Any]]
9695
+ let options: [CraftNativePickerView.Option] = rawItems.map { items in
9696
+ items.compactMap { item in
9697
+ guard let label = item["label"] as? String, !label.isEmpty else { return nil }
9698
+ return CraftNativePickerView.Option(value: craftNativePickerValue(item["value"]) ?? label,
9699
+ label: label, disabled: item["disabled"] as? Bool == true)
9448
9700
  }
9449
- : itemOptions
9450
- let pickerID = ObjectIdentifier(picker)
9451
- pickerOptions[pickerID] = options
9452
- picker.reloadAllComponents()
9453
- if let selected = craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
9454
- let row = options.firstIndex(where: { $0.value == selected }) {
9455
- picker.selectRow(row, inComponent: 0, animated: false)
9456
- } else if !options.isEmpty {
9457
- picker.selectRow(0, inComponent: 0, animated: false)
9458
- }
9459
- picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
9460
- updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
9701
+ } ?? children.compactMap { child in
9702
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
9703
+ let optionProps = node["props"] as? [String: Any] ?? [:]
9704
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
9705
+ guard !label.isEmpty else { return nil }
9706
+ return CraftNativePickerView.Option(value: craftNativePickerValue(optionProps["value"]) ?? label,
9707
+ label: label, disabled: optionProps["disabled"] as? Bool == true)
9708
+ }
9709
+ let handler = nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"])
9710
+ updateHandler(handler, for: picker)
9711
+ picker.update(options: options,
9712
+ selected: craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
9713
+ enabled: props["disabled"] as? Bool != true,
9714
+ handler: handler, font: textFont(style, default: .preferredFont(forTextStyle: .body)),
9715
+ foreground: color(style["color"]) ?? .label) { [weak self, weak picker] value, index in
9716
+ guard let self, let picker, let handler = self.handlers[ObjectIdentifier(picker)] else { return }
9717
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value, "index": index]])
9718
+ }
9719
+ if style["backgroundColor"] == nil { picker.backgroundColor = .tertiarySystemFill }
9461
9720
  case "DatePicker":
9462
9721
  let picker = result as! UIDatePicker
9463
9722
  let mode = Self.datePickerMode(props["mode"])
@@ -9832,6 +10091,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9832
10091
  }
9833
10092
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9834
10093
  applyAccessibility(props, type: type, to: result)
10094
+ (result as? CraftNativeSvgView)?.inspection.configureAccessibility()
10095
+ if let picker = result as? CraftNativePickerView, props["accessibilityValue"] == nil {
10096
+ picker.accessibilityValue = picker.selectedLabel
10097
+ }
9835
10098
  // After applyAccessibility: it settles whether the view is an element
9836
10099
  // at all, and an action on a non-element is unreachable.
9837
10100
  updateAccessibilityActions(props["accessibilityActions"], handler: menuDisabled ? nil : events["onAccessibilityAction"], for: result)
@@ -9891,10 +10154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9891
10154
  control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
9892
10155
  return control
9893
10156
  case "Picker":
9894
- let picker = CraftNativePickerView()
9895
- picker.dataSource = self
9896
- picker.delegate = self
9897
- return picker
10157
+ return CraftNativePickerView(frame: .zero)
9898
10158
  case "DatePicker":
9899
10159
  let picker = CraftNativeDatePickerView()
9900
10160
  picker.preferredDatePickerStyle = .compact
@@ -10282,10 +10542,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10282
10542
  // its subtree is gone.
10283
10543
  if !preservingInputDrafts {
10284
10544
  if let textView = node.view as? UITextView { textView.delegate = nil }
10285
- if let picker = node.view as? UIPickerView {
10286
- picker.delegate = nil
10287
- picker.dataSource = nil
10288
- }
10545
+ (node.view as? CraftNativePickerView)?.detach()
10289
10546
  if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
10290
10547
  (node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
10291
10548
  }
@@ -10340,7 +10597,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10340
10597
  scrollMomentumEndHandlers.removeValue(forKey: id)
10341
10598
  sliderCompleteHandlers.removeValue(forKey: id)
10342
10599
  sliderSteps.removeValue(forKey: id)
10343
- pickerOptions.removeValue(forKey: id)
10344
10600
  modalVisibility.removeValue(forKey: id)
10345
10601
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
10346
10602
  node.view.removeGestureRecognizer(recognizer)
@@ -10474,12 +10730,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10474
10730
  /// Views that take their own touches whatever handlers the screen gives
10475
10731
  /// them: native controls, a scroll view, a modal, and a video's controls (with
10476
10732
  /// interaction off, its play, scrubber and full screen never answered).
10477
- /// A map and a wheel picker too: neither is a UIControl, so a map with no
10478
- /// long-press handler - every map - was made non-interactive, and could
10479
- /// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
10733
+ /// Maps too: a map without a long-press handler must still pan, zoom,
10734
+ /// and take taps on its pins.
10480
10735
  private func ownsTouches(_ view: UIView) -> Bool {
10481
10736
  view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
10482
- || view is MKMapView || view is UIPickerView
10737
+ || view is MKMapView || ((view as? CraftNativeSvgView)?.inspection.readings.isEmpty == false)
10483
10738
  }
10484
10739
 
10485
10740
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -11468,9 +11723,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11468
11723
  // A video with its system controls is a group too: VoiceOver reaches
11469
11724
  // play, the scrubber, skipping and full screen inside it.
11470
11725
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
11471
- // UIPickerView exposes adjustable component wheels. Making the
11472
- // picker itself a leaf hides those wheels and their selected values.
11473
- let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl", "Modal", "Drawer"].contains(type)
11726
+ // Compact single-choice pickers are accessible menu buttons. Controls
11727
+ // containing several independent elements remain semantic groups.
11728
+ let container = ["ScrollView", "FlatList", "SectionList", "SegmentedControl", "Modal", "Drawer"].contains(type)
11474
11729
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
11475
11730
  || controlledVideo
11476
11731
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -11482,6 +11737,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11482
11737
  var traits: UIAccessibilityTraits = []
11483
11738
  switch type {
11484
11739
  case "Slider", "DatePicker": traits.insert(.adjustable)
11740
+ case "Picker": traits.insert(.button)
11485
11741
  default: break
11486
11742
  }
11487
11743
  switch role ?? type.lowercased() {
@@ -11894,27 +12150,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11894
12150
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
11895
12151
  }
11896
12152
 
11897
- func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
11898
-
11899
- func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
11900
- pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
11901
- }
11902
-
11903
- func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
11904
- guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
11905
- return options[row].label
11906
- }
11907
-
11908
- func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
11909
- guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
11910
- let handler = handlers[ObjectIdentifier(pickerView)] else { return }
11911
- let option = options[row]
11912
- send(type: "EVENT", payload: [
11913
- "handlerName": handler,
11914
- "nativeEvent": ["value": option.value, "index": row],
11915
- ])
11916
- }
11917
-
11918
12153
  @objc private func textFocused(_ sender: UITextField) {
11919
12154
  lastFocusedInput = sender
11920
12155
  // Moving between fields does not move the keyboard, so nothing else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.484",
3
+ "version": "0.0.487",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",