From 15c35f2f2200d88216181f1947051ee33d2f8182 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 4 Jul 2026 14:14:29 -0400 Subject: [PATCH 1/6] feat(a11y): comprehensive accessibility pass across PHP, iOS, and Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP API: - New HasA11y trait (a11yLabel/a11yHint via Element::setProp) adopted by every element — kills the copy-pasted per-element methods and gives the 9 elements that had no a11y support (Icon, ListItem, Radio, Carousel, ListSection, NativeList, NativeVirtualList, NativeDrawer, Tab) the full API. Tab gains the fluent methods it was missing. - ListItem: trailing_a11y_label prop labels the trailing icon_button. - Boost guidelines rewritten with real accessibility guidance; README Accessibility section; Pest coverage for a11y serialization. iOS renderers: - Dynamic Type everywhere: @ScaledMetric-backed nuiScaledFont() replaces every fixed .font(.system(size:)) — text now tracks the user's preferred content size. - 44pt minimum touch targets (nuiMinTapTarget) on checkbox, radio, chip, tabs, modal close, list icon buttons, top-bar/back/hamburger buttons, clickable icons. - VoiceOver: checkbox .isToggle (iOS 17) + Checked/Unchecked value, chip selected value, select current-option value, button loading announced, Back/Open menu/bottom-nav labels + .isSelected, list rows combined into one focus stop, skeleton rows hidden, modal .isModal trait, text-input label/error exposure, carousel + gesture-area label plumbing. - Icons decorative-by-default (hidden without a11y_label; labeled and .isButton when clickable). Image renderer honors new alt prop. - Reduce Motion respected in drawer animations and screen transitions. Android renderers: - stateDescription misuse fixed: a11y_hint no longer clobbers the state channel; shared Modifier.nuiA11y merges label+hint into contentDescription. Only remaining stateDescription use is the legitimate one (button loading). - Toggle/Checkbox/Radio rows use toggleable/selectable with proper Roles: one merged TalkBack focus stop, label taps activate the control, radio double tap-target removed. - Roles + labels on hand-rolled clickables (icons, top-bar back/actions, bottom-nav tabs with selected state), 48dp targets on the same. - Icons stop announcing machine names; image renderer honors alt; activity indicator announces Loading politely. - Dark-mode-hostile hardcoded grays/white replaced with theme tokens. Theme: default secondary/destructive/accent tokens (config + Swift fallbacks) now meet WCAG AA 4.5:1 against their white on-colors — visible change: accent #FB923C -> #C2410C, destructive #DC2626 -> #B91C1C, secondary #64748B -> #475569. Wire contract additions: alt (image), trailing_a11y_label (list_item). Pairs with mobile-air 57079e9 (Image element alt prop). Co-Authored-By: Claude Fable 5 --- README.md | 29 +++ config/native-ui.php | 9 +- .../android/ActivityIndicatorRenderer.kt | 9 +- resources/android/ButtonRenderer.kt | 9 +- resources/android/CarouselRenderer.kt | 6 +- resources/android/CheckboxRenderer.kt | 35 ++-- resources/android/ChipRenderer.kt | 7 +- resources/android/FilledTextInputRenderer.kt | 2 +- resources/android/GestureAreaRenderer.kt | 4 +- resources/android/IconRenderer.kt | 18 +- resources/android/ImageRenderer.kt | 5 +- resources/android/ListItemRenderer.kt | 31 +++- resources/android/ListRenderer.kt | 8 +- resources/android/NavRenderers.kt | 53 ++++-- .../android/OutlinedTextInputRenderer.kt | 2 +- resources/android/RadioGroupRenderer.kt | 7 +- resources/android/RadioRenderer.kt | 15 +- resources/android/SelectRenderer.kt | 7 +- resources/android/SliderRenderer.kt | 7 +- resources/android/TabRowRenderer.kt | 4 +- resources/android/TextInputShared.kt | 17 +- resources/android/ToggleRenderer.kt | 24 ++- resources/android/VirtualListRenderer.kt | 4 +- resources/boost/guidelines/core.blade.php | 106 ++++++----- resources/ios/NativeUIBadgeRenderer.swift | 2 +- .../ios/NativeUIButtonGroupRenderer.swift | 2 +- resources/ios/NativeUIButtonRenderer.swift | 19 +- resources/ios/NativeUICarouselRenderer.swift | 12 ++ resources/ios/NativeUICheckboxRenderer.swift | 18 +- resources/ios/NativeUIChipRenderer.swift | 10 +- resources/ios/NativeUIDrawerHost.swift | 22 ++- .../ios/NativeUIFilledTextInputRenderer.swift | 34 +++- .../ios/NativeUIGestureAreaRenderer.swift | 22 +++ resources/ios/NativeUIIconRenderer.swift | 46 +++++ resources/ios/NativeUIListItemRenderer.swift | 39 +++- resources/ios/NativeUIModalRenderer.swift | 3 + resources/ios/NativeUINavRenderers.swift | 37 +++- .../NativeUIOutlinedTextInputRenderer.swift | 34 +++- .../ios/NativeUIRadioGroupRenderer.swift | 5 +- resources/ios/NativeUISelectRenderer.swift | 5 +- resources/ios/NativeUISimpleRenderers.swift | 19 ++ resources/ios/NativeUITabRowRenderer.swift | 18 +- resources/ios/NativeUITextInputCore.swift | 2 +- resources/ios/NativeUITextRenderer.swift | 8 +- resources/ios/NativeUITheme.swift | 43 ++++- .../ios/NativeUITransitionFunctions.swift | 12 +- .../ios/NativeUIVirtualListRenderer.swift | 5 +- src/Concerns/HasA11y.php | 64 +++++++ src/Elements/ActivityIndicator.php | 14 +- src/Elements/Badge.php | 14 +- src/Elements/BaseTextInput.php | 26 +-- src/Elements/BottomSheet.php | 14 +- src/Elements/Button.php | 24 +-- src/Elements/ButtonGroup.php | 14 +- src/Elements/Carousel.php | 5 + src/Elements/Checkbox.php | 24 +-- src/Elements/Chip.php | 23 +-- src/Elements/Icon.php | 6 + src/Elements/ListItem.php | 22 +++ src/Elements/ListSection.php | 5 + src/Elements/Modal.php | 14 +- src/Elements/NativeDrawer.php | 5 + src/Elements/NativeList.php | 5 + src/Elements/NativeVirtualList.php | 5 + src/Elements/ProgressBar.php | 14 +- src/Elements/Radio.php | 5 + src/Elements/RadioGroup.php | 24 +-- src/Elements/Select.php | 24 +-- src/Elements/Slider.php | 24 +-- src/Elements/Tab.php | 6 +- src/Elements/TabRow.php | 14 +- src/Elements/Toggle.php | 24 +-- tests/A11yTest.php | 171 ++++++++++++++++++ 73 files changed, 984 insertions(+), 441 deletions(-) create mode 100644 src/Concerns/HasA11y.php create mode 100644 tests/A11yTest.php diff --git a/README.md b/README.md index ee275b4..9990de2 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,35 @@ public function handleNativeUICompleted($result, $id = null) } ``` +## Accessibility + +Every element accepts a screen-reader label and an optional hint, via Blade +attributes (`a11y-label` / `a11y-hint`, or the camelCase spellings +`a11yLabel` / `a11yHint`) or the fluent API (`->a11yLabel()` / `->a11yHint()`). +The label maps to `accessibilityLabel` on iOS and `contentDescription` on +Android; the hint maps to `accessibilityHint` on iOS and is appended to the +content description on Android. + +```blade + +``` + +```php +use Nativephp\NativeUi\Elements\Button; + +Button::make() + ->icon('plus') + ->a11yLabel('Add item') + ->a11yHint('Adds a new item to the list') + ->onPress('addItem'); +``` + +Always set `a11y-label` on icon-only buttons, chips, and tabs — without +visible text there is nothing for VoiceOver / TalkBack to announce. Icons are +decorative (silent to screen readers) unless given an `a11y-label`. List items +with a trailing icon button take `trailing-a11y-label` (fluent: +`->trailingA11yLabel()`) to label that button separately from the row. + ## License MIT \ No newline at end of file diff --git a/config/native-ui.php b/config/native-ui.php index 9521883..975fbc1 100644 --- a/config/native-ui.php +++ b/config/native-ui.php @@ -27,6 +27,9 @@ | Dark mode is auto-derived from `light` when `dark` is not set. To opt | into explicit dark tokens, fill out the `dark` block. | + | The default pairs meet WCAG AA (4.5:1) — if you customize, keep each + | `on-*` color at 4.5:1 contrast against its background token. + | */ 'theme' => [ @@ -37,7 +40,7 @@ 'on-primary' => '#FFFFFF', // Secondary / muted action color. - 'secondary' => '#64748B', + 'secondary' => '#475569', 'on-secondary' => '#FFFFFF', // Surface = cards, sheets, dialogs. Background = page root. @@ -55,11 +58,11 @@ 'outline' => '#CBD5E1', // Destructive actions — maps to `variant="destructive"` on components. - 'destructive' => '#DC2626', + 'destructive' => '#B91C1C', 'on-destructive' => '#FFFFFF', // Tertiary accent — for highlights, badges, emphasis not covered by primary. - 'accent' => '#FB923C', + 'accent' => '#C2410C', 'on-accent' => '#FFFFFF', ], diff --git a/resources/android/ActivityIndicatorRenderer.kt b/resources/android/ActivityIndicatorRenderer.kt index 2e0d7b5..19a19bc 100644 --- a/resources/android/ActivityIndicatorRenderer.kt +++ b/resources/android/ActivityIndicatorRenderer.kt @@ -6,7 +6,9 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -35,9 +37,14 @@ object ActivityIndicatorRenderer { else -> 32.dp } + // Always announce something ("Loading" fallback) and mark the node a + // polite live region so TalkBack reports the spinner appearing. val indicatorModifier = modifier .size(sizeDp) - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } + .semantics { + contentDescription = a11yLabel.ifEmpty { "Loading" } + liveRegion = LiveRegionMode.Polite + } CircularProgressIndicator( modifier = indicatorModifier, diff --git a/resources/android/ButtonRenderer.kt b/resources/android/ButtonRenderer.kt index 0fac469..cfa3597 100644 --- a/resources/android/ButtonRenderer.kt +++ b/resources/android/ButtonRenderer.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.Dp @@ -80,11 +79,11 @@ object ButtonRenderer { val buttonModifier = modifier .defaultMinSize(minHeight = metrics.minHeight) + .nuiA11y(a11yLabel, a11yHint) .let { m -> - if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m - } - .let { m -> - if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m + // Correct stateDescription use — actual widget state (the + // spinner is visual-only), not an a11y hint. + if (loading) m.semantics { stateDescription = "Loading" } else m } val content: @Composable () -> Unit = { diff --git a/resources/android/CarouselRenderer.kt b/resources/android/CarouselRenderer.kt index 538cc63..dd0cbaf 100644 --- a/resources/android/CarouselRenderer.kt +++ b/resources/android/CarouselRenderer.kt @@ -22,6 +22,8 @@ object CarouselRenderer { val itemWidth = p.getFloat("item_width").let { if (it > 0f) it else 200f } val itemSpacing = p.getFloat("item_spacing").let { if (it > 0f) it else 8f } + val carouselModifier = modifier.nuiA11y(p.getString("a11y_label"), p.getString("a11y_hint")) + val state = rememberCarouselState { node.children.size } when (variant) { @@ -30,7 +32,7 @@ object CarouselRenderer { state = state, itemWidth = itemWidth.dp, itemSpacing = itemSpacing.dp, - modifier = modifier + modifier = carouselModifier ) { index -> val child = node.children[index] RenderNode(child, Modifier.clip(MaterialTheme.shapes.extraLarge)) @@ -41,7 +43,7 @@ object CarouselRenderer { state = state, preferredItemWidth = itemWidth.dp, itemSpacing = itemSpacing.dp, - modifier = modifier + modifier = carouselModifier ) { index -> val child = node.children[index] RenderNode(child, Modifier.clip(MaterialTheme.shapes.extraLarge)) diff --git a/resources/android/CheckboxRenderer.kt b/resources/android/CheckboxRenderer.kt index 8a350f4..097d911 100644 --- a/resources/android/CheckboxRenderer.kt +++ b/resources/android/CheckboxRenderer.kt @@ -3,6 +3,7 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.Text @@ -14,9 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -59,24 +58,32 @@ object CheckboxRenderer { disabledUncheckedColor = theme.outline.copy(alpha = 0.38f), ) - val rowModifier = modifier - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } - .let { m -> if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m } + val onChanged = { new: Boolean -> + checked = new + lastSentValue = new + if (onChangeCb != 0) { + NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, node.id, new) + } + } + // toggleable on the row merges descendants into ONE TalkBack focus + // stop and makes the label itself a tap target; the inner Checkbox + // gets onCheckedChange = null so there's no nested second target. Row( - modifier = rowModifier, + modifier = modifier + .nuiA11y(a11yLabel, a11yHint) + .toggleable( + value = checked, + enabled = !disabled, + role = Role.Checkbox, + onValueChange = onChanged, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Checkbox( checked = checked, - onCheckedChange = { new -> - checked = new - lastSentValue = new - if (onChangeCb != 0) { - NativeUIBridge.sendCheckboxChangeEvent(onChangeCb, node.id, new) - } - }, + onCheckedChange = null, enabled = !disabled, colors = colors, ) diff --git a/resources/android/ChipRenderer.kt b/resources/android/ChipRenderer.kt index e84b9eb..e277e0f 100644 --- a/resources/android/ChipRenderer.kt +++ b/resources/android/ChipRenderer.kt @@ -12,9 +12,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.MaterialIcon import com.nativephp.mobile.ui.nativerender.NativeUIBridge @@ -66,9 +63,7 @@ object ChipRenderer { selectedBorderColor = theme.primary, ) - val chipModifier = modifier - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } - .let { m -> if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m } + val chipModifier = modifier.nuiA11y(a11yLabel, a11yHint) FilterChip( selected = isSelected, diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 83fde9d..4be6c0a 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -88,7 +88,7 @@ object FilledTextInputRenderer { text = filtered dispatcher.onTextChanged(filtered) }, - modifier = modifier.withA11y(props.a11yLabel, props.a11yHint), + modifier = modifier.nuiA11y(props.a11yLabel, props.a11yHint), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, diff --git a/resources/android/GestureAreaRenderer.kt b/resources/android/GestureAreaRenderer.kt index 0a9781a..de6acbd 100644 --- a/resources/android/GestureAreaRenderer.kt +++ b/resources/android/GestureAreaRenderer.kt @@ -35,7 +35,9 @@ object GestureAreaRenderer { } Box( - modifier = modifier.pointerInput(panYId) { + modifier = modifier + .nuiA11y(node.props.getString("a11y_label"), node.props.getString("a11y_hint")) + .pointerInput(panYId) { if (panYId == 0) return@pointerInput detectVerticalDragGestures( onDragStart = { /* no-op — store already holds the running value */ }, diff --git a/resources/android/IconRenderer.kt b/resources/android/IconRenderer.kt index e44a680..d668014 100644 --- a/resources/android/IconRenderer.kt +++ b/resources/android/IconRenderer.kt @@ -2,9 +2,11 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -14,6 +16,7 @@ object IconRenderer { fun Render(node: NativeUINode, modifier: Modifier) { val p = node.props val name = p.getString("name") + val a11yLabel = p.getString("a11y_label") val lightArgb = p.getColor("color", 0xFF000000.toInt()) val darkArgb = p.getColor("dark_color", 0) val isDark = isSystemInDarkTheme() @@ -21,7 +24,10 @@ object IconRenderer { com.nativephp.mobile.ui.MaterialIcon( name = name, - contentDescription = name, + // Only announce an explicit a11y label — the raw machine icon + // name ("chevron_right") is noise, so decorative icons stay + // silent for TalkBack. + contentDescription = a11yLabel.ifEmpty { null }, modifier = modifier.then(applyClickModifier(node)), size = p.getFloat("size", 24f).dp, tint = Color(effectiveArgb), @@ -30,9 +36,13 @@ object IconRenderer { private fun applyClickModifier(node: NativeUINode): Modifier { return if (node.onPress != 0) { - Modifier.clickable { - NativeUIBridge.sendPressEvent(node.onPress, node.id) - } + // Button role + a >=48dp touch target for tappable icons. The + // glyph keeps its visual size; only the hit area grows. + Modifier + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + NativeUIBridge.sendPressEvent(node.onPress, node.id) + } } else { Modifier } diff --git a/resources/android/ImageRenderer.kt b/resources/android/ImageRenderer.kt index 14c0ef2..df312a2 100644 --- a/resources/android/ImageRenderer.kt +++ b/resources/android/ImageRenderer.kt @@ -17,6 +17,7 @@ object ImageRenderer { val p = node.props val src = p.getString("src") val fit = p.getInt("fit") + val alt = p.getString("alt") val tintArgb = p.getColor("tint_color", 0) val radius = node.style?.borderRadius ?: 0f @@ -28,7 +29,9 @@ object ImageRenderer { if (src.isNotEmpty()) { AsyncImage( model = src, - contentDescription = null, + // `alt` marks the image as meaningful; without it the image + // stays decorative (silent for TalkBack). + contentDescription = alt.ifEmpty { null }, modifier = imgModifier, contentScale = resolveContentScale(fit), colorFilter = if (tintArgb != 0) { diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index 8d6f224..b2a5e12 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -96,7 +97,7 @@ object ListItemRenderer { ) } } else if (pressCbId != 0) { - modifier.clickable(enabled = !disabled) { + modifier.clickable(enabled = !disabled, role = Role.Button) { NativeUIBridge.sendPressEvent(pressCbId, node.id) } } else { @@ -166,6 +167,7 @@ object ListItemRenderer { disabled = disabled, hasMenu = p.getBool("has_trailing_menu"), menuItems = node.children.filter { it.type == "top_bar_action" }, + a11yLabel = p.getString("trailing_a11y_label"), ) } }, @@ -197,6 +199,7 @@ object ListItemRenderer { return { when (effectiveType) { "icon" -> { + // Decorative — announcing the machine icon name is noise. if (iconBgColor != 0) { Box( modifier = Modifier @@ -206,7 +209,7 @@ object ListItemRenderer { ) { com.nativephp.mobile.ui.MaterialIcon( name = effectiveValue, - contentDescription = effectiveValue, + contentDescription = null, size = 22.dp, tint = Color.White ) @@ -214,7 +217,7 @@ object ListItemRenderer { } else { com.nativephp.mobile.ui.MaterialIcon( name = effectiveValue, - contentDescription = effectiveValue, + contentDescription = null, size = 24.dp, tint = if (iconColor != 0) Color(iconColor) else Color.Unspecified ) @@ -232,14 +235,14 @@ object ListItemRenderer { Box( modifier = Modifier .size(40.dp) - .background(Color(0xFFE0E0E0), CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape) ) }, error = { Box( modifier = Modifier .size(40.dp) - .background(Color(0xFFE0E0E0), CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape) ) } ) @@ -276,14 +279,14 @@ object ListItemRenderer { Box( modifier = Modifier .size(56.dp) - .background(Color(0xFFE0E0E0), RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp)) ) }, error = { Box( modifier = Modifier .size(56.dp) - .background(Color(0xFFE0E0E0), RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp)) ) } ) @@ -368,18 +371,26 @@ object ListItemRenderer { disabled: Boolean, hasMenu: Boolean = false, menuItems: List = emptyList(), + a11yLabel: String = "", ): (@Composable () -> Unit)? { val effectiveType = type.ifEmpty { if (fallbackIcon.isNotEmpty()) "icon" else return null } val effectiveValue = value.ifEmpty { fallbackIcon } + // Interactive icon_button needs an accessible name: explicit + // `trailing_a11y_label` wins, else humanize the machine icon name + // ("more_vert" -> "more vert") so it's never unlabeled. + val iconButtonDescription = a11yLabel.ifEmpty { + effectiveValue.replace('_', ' ').replace('-', ' ') + } return { when (effectiveType) { "icon" -> { + // Decorative — announcing the machine icon name is noise. com.nativephp.mobile.ui.MaterialIcon( name = effectiveValue, - contentDescription = effectiveValue, + contentDescription = null, size = 24.dp, tint = if (iconColor != 0) Color(iconColor) else Color.Unspecified ) @@ -429,7 +440,7 @@ object ListItemRenderer { ) { com.nativephp.mobile.ui.MaterialIcon( name = effectiveValue, - contentDescription = effectiveValue, + contentDescription = iconButtonDescription, size = 24.dp, tint = if (iconColor != 0) Color(iconColor) else Color.Unspecified, ) @@ -454,7 +465,7 @@ object ListItemRenderer { ) { com.nativephp.mobile.ui.MaterialIcon( name = effectiveValue, - contentDescription = effectiveValue, + contentDescription = iconButtonDescription, size = 24.dp, tint = if (iconColor != 0) Color(iconColor) else Color.Unspecified ) diff --git a/resources/android/ListRenderer.kt b/resources/android/ListRenderer.kt index 3a860e0..90a464b 100644 --- a/resources/android/ListRenderer.kt +++ b/resources/android/ListRenderer.kt @@ -117,7 +117,7 @@ object ListRenderer { } else { ListRow(row) if (separator && i < child.children.size - 1) { - HorizontalDivider(color = Color(0xFFE0E0E0)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -129,7 +129,7 @@ object ListRenderer { item(key = child.id) { ListRow(child) if (separator && index < node.children.size - 1) { - HorizontalDivider(color = Color(0xFFE0E0E0)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -388,7 +388,9 @@ private fun SwipeActionsRow( Modifier .fillMaxWidth() .offset(x = offsetDp) - .background(Color.White) + // Opaque theme surface (not hardcoded white) so the action + // drawers stay hidden behind the row in dark mode too. + .background(MaterialTheme.colorScheme.surface) .pointerInput(nodeKey) { detectHorizontalDragGestures( onDragEnd = { diff --git a/resources/android/NavRenderers.kt b/resources/android/NavRenderers.kt index eef8790..c9b3ee5 100644 --- a/resources/android/NavRenderers.kt +++ b/resources/android/NavRenderers.kt @@ -18,12 +18,17 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -79,14 +84,21 @@ object TopBarRenderer { // queue. The runloop catches type 8 (EventType.systemBack) and // calls onBackPressed → back(), popping the navigation stack. // Same path the device hardware back button uses. + // minimumInteractiveComponentSize grows the tap target to + // 48dp (glyph stays 24sp); the smaller end padding keeps the + // title gap roughly where it was (~12dp slack + 4dp). Text( text = getIconName("arrow_back"), fontFamily = iconFont, fontSize = 24.sp, color = textColor, - modifier = Modifier.padding(end = 16.dp).clickable { - NativeElementBridge.sendSystemBackEvent() - } + modifier = Modifier + .padding(end = 4.dp) + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + NativeElementBridge.sendSystemBackEvent() + } + .semantics { contentDescription = "Back" } ) } // Title + optional subtitle stacked @@ -101,15 +113,21 @@ object TopBarRenderer { for (action in actions.take(3)) { val icon = action.props.getString("icon", "more_vert") val url = action.props.getString("url") + val actionLabel = action.props.getString("label") Text( text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = textColor, - modifier = Modifier.padding(start = 8.dp).clickable { - if (url.isNotEmpty()) { - try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {} - } else if (action.onPress != 0) { - NativeElementBridge.sendPressEvent(action.onPress, action.id) + modifier = Modifier + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + if (url.isNotEmpty()) { + try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {} + } else if (action.onPress != 0) { + NativeElementBridge.sendPressEvent(action.onPress, action.id) + } + } + .let { m -> + if (actionLabel.isNotEmpty()) m.semantics { contentDescription = actionLabel } else m } - } ) } } @@ -174,9 +192,22 @@ object BottomNavRenderer { } Column( + // selectable(role = Tab) lets TalkBack announce + // "selected"/position; the min height guarantees a + // >=48dp touch target. When label_visibility hides the + // visible label, expose it as the contentDescription so + // icon-only tabs aren't unlabeled. modifier = Modifier.weight(1f) - .clickable { if (item.onPress != 0) NativeElementBridge.sendPressEvent(item.onPress, item.id) } - .padding(vertical = 8.dp), + .defaultMinSize(minHeight = 48.dp) + .selectable( + selected = active, + role = Role.Tab, + onClick = { if (item.onPress != 0) NativeElementBridge.sendPressEvent(item.onPress, item.id) }, + ) + .padding(vertical = 8.dp) + .let { m -> + if (!showLabel && label.isNotEmpty()) m.semantics { contentDescription = label } else m + }, horizontalAlignment = Alignment.CenterHorizontally ) { // Box wraps the icon at center; badge / news dot anchored diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 9e07afc..20e1bfa 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -97,7 +97,7 @@ object OutlinedTextInputRenderer { text = filtered dispatcher.onTextChanged(filtered) }, - modifier = modifier.withA11y(props.a11yLabel, props.a11yHint), + modifier = modifier.nuiA11y(props.a11yLabel, props.a11yHint), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, diff --git a/resources/android/RadioGroupRenderer.kt b/resources/android/RadioGroupRenderer.kt index f4e9800..5c0aa09 100644 --- a/resources/android/RadioGroupRenderer.kt +++ b/resources/android/RadioGroupRenderer.kt @@ -12,9 +12,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -50,9 +47,7 @@ object RadioGroupRenderer { } } - val groupModifier = modifier - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } - .let { m -> if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m } + val groupModifier = modifier.nuiA11y(a11yLabel, a11yHint) Column(modifier = groupModifier) { if (label.isNotEmpty()) { diff --git a/resources/android/RadioRenderer.kt b/resources/android/RadioRenderer.kt index 4b402e7..0d0fa42 100644 --- a/resources/android/RadioRenderer.kt +++ b/resources/android/RadioRenderer.kt @@ -1,16 +1,17 @@ package com.nativephp.plugins.native_ui.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.selectable import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -52,16 +53,24 @@ object RadioRenderer { disabledUnselectedColor = theme.onSurfaceVariant.copy(alpha = 0.38f), ) + // selectable on the row merges descendants into ONE TalkBack focus + // stop with a RadioButton role; the inner RadioButton gets + // onClick = null so there's no nested second tap target. Row( modifier = modifier .fillMaxWidth() - .clickable(enabled = !disabled) { onSelect?.invoke(value) }, + .selectable( + selected = isSelected, + enabled = !disabled, + role = Role.RadioButton, + onClick = { onSelect?.invoke(value) }, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { RadioButton( selected = isSelected, - onClick = { onSelect?.invoke(value) }, + onClick = null, enabled = !disabled, colors = colors, ) diff --git a/resources/android/SelectRenderer.kt b/resources/android/SelectRenderer.kt index 941ce6c..5a600b4 100644 --- a/resources/android/SelectRenderer.kt +++ b/resources/android/SelectRenderer.kt @@ -16,9 +16,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.TextStyle import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -57,9 +54,7 @@ object SelectRenderer { } } - val anchorModifier = modifier - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } - .let { m -> if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m } + val anchorModifier = modifier.nuiA11y(a11yLabel, a11yHint) ExposedDropdownMenuBox( expanded = expanded, diff --git a/resources/android/SliderRenderer.kt b/resources/android/SliderRenderer.kt index 1d93d8b..5aa5740 100644 --- a/resources/android/SliderRenderer.kt +++ b/resources/android/SliderRenderer.kt @@ -12,9 +12,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.plugins.native_ui.NativeUITheme @@ -109,9 +106,7 @@ object SliderRenderer { commit(value) } }, - modifier = modifier - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } - .let { m -> if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m }, + modifier = modifier.nuiA11y(a11yLabel, a11yHint), enabled = !disabled, valueRange = min..max, steps = steps.coerceAtLeast(0), diff --git a/resources/android/TabRowRenderer.kt b/resources/android/TabRowRenderer.kt index b19babd..ad34f0a 100644 --- a/resources/android/TabRowRenderer.kt +++ b/resources/android/TabRowRenderer.kt @@ -59,7 +59,9 @@ object TabRowRenderer { tabs.forEachIndexed { index, tabNode -> val tabLabel = tabNode.props.getString("label") val tabIcon = tabNode.props.getString("icon") - val tabA11y = tabNode.props.getString("a11y_label") + // Explicit a11y_label wins; otherwise fall back to the + // tab's label prop so icon-only tabs are never unlabeled. + val tabA11y = tabNode.props.getString("a11y_label").ifEmpty { tabLabel } val isSelected = index == selectedIndex Tab( diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 422a460..de43f9b 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -231,10 +230,14 @@ internal fun leadingIconSlot(name: String): (@Composable () -> Unit)? = internal fun trailingIconSlot(name: String): (@Composable () -> Unit)? = if (name.isEmpty()) null else ({ MaterialIcon(name = name, contentDescription = null) }) -/** Apply optional a11y label/hint to a modifier. */ -internal fun Modifier.withA11y(label: String, hint: String): Modifier { - var m = this - if (label.isNotEmpty()) m = m.semantics { contentDescription = label } - if (hint.isNotEmpty()) m = m.semantics { stateDescription = hint } - return m +/** + * Apply optional a11y label/hint to a modifier. + * + * The hint is merged into the contentDescription (TalkBack reads it right + * after the label) — never into stateDescription, which is reserved for + * actual widget state ("On", "Loading", ...). + */ +internal fun Modifier.nuiA11y(label: String, hint: String): Modifier { + val merged = listOf(label, hint).filter { it.isNotEmpty() }.joinToString(". ") + return if (merged.isEmpty()) this else semantics { contentDescription = merged } } diff --git a/resources/android/ToggleRenderer.kt b/resources/android/ToggleRenderer.kt index 2a7df88..c0b4b25 100644 --- a/resources/android/ToggleRenderer.kt +++ b/resources/android/ToggleRenderer.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -15,9 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUIBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -72,17 +71,26 @@ object ToggleRenderer { } } - val rowModifier = modifier - .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } - .let { m -> if (a11yHint.isNotEmpty()) m.semantics { stateDescription = a11yHint } else m } + val rowModifier = modifier.nuiA11y(a11yLabel, a11yHint) if (label.isNotEmpty()) { - Row(modifier = rowModifier, verticalAlignment = Alignment.CenterVertically) { + // toggleable on the row merges descendants into ONE TalkBack focus + // stop and makes the label itself a tap target; the inner Switch + // gets onCheckedChange = null so there's no nested second target. + Row( + modifier = rowModifier.toggleable( + value = checked, + enabled = !disabled, + role = Role.Switch, + onValueChange = onChanged, + ), + verticalAlignment = Alignment.CenterVertically, + ) { Text(text = label, modifier = Modifier.weight(1f), color = theme.onSurface) Spacer(modifier = Modifier.width(8.dp)) Switch( checked = checked, - onCheckedChange = onChanged, + onCheckedChange = null, enabled = !disabled, colors = colors, ) diff --git a/resources/android/VirtualListRenderer.kt b/resources/android/VirtualListRenderer.kt index b8956a7..4e75476 100644 --- a/resources/android/VirtualListRenderer.kt +++ b/resources/android/VirtualListRenderer.kt @@ -9,12 +9,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeElementBridge import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -97,7 +97,7 @@ object VirtualListRenderer { Box(modifier = Modifier .fillMaxWidth() .height(estimatedRowHeight.dp) - .background(Color(0xFFEEEEEE))) + .background(MaterialTheme.colorScheme.surfaceVariant)) } } } diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 755d55d..fdc3477 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -1,61 +1,73 @@ ## nativephp/native-ui -A NativePHP Mobile plugin - -### Installation - -```bash -composer require nativephp/native-ui -``` - -### PHP Usage (Livewire/Blade) - -Use the `NativeUI` facade: +Native UI components for NativePHP Mobile. Every element renders as a real +platform primitive — Material3 on Android, SwiftUI on iOS — not a webview +widget. Elements are declared in Blade with `` tags or built +programmatically with the fluent `Nativephp\NativeUi\Elements\*` API; both +paths serialize to the same wire tree. + +### Core rules + +- Visual styling is theme-driven ("Model 3"): buttons, inputs, toggles, and + other controls take their colors, radii, and typography from the theme + (`Nativephp\NativeUi\Theme`). Use semantic props like `variant="primary"` + instead of per-instance colors — per-instance visual overrides on these + controls are intentionally ignored. +- Bind state with `native:model="property"` (works on toggle, checkbox, chip, + slider, select, radio-group, button-group, tab-row, and the text inputs). + Use `.live` / `.blur` / `.debounce.Xms` modifiers to control sync frequency. +- Wire callbacks with event attributes (`@press`, `@change`, `@submit`, + `@dismiss`) pointing at public methods on the component. @verbatim - -use Nativephp\NativeUi\Facades\NativeUI; - -// Execute the plugin functionality -$result = NativeUI::execute(['option1' => 'value']); - -// Get the current status -$status = NativeUI::getStatus(); + + + + + Save + @endverbatim -### Available Methods - -- `NativeUI::execute()`: Execute the plugin functionality -- `NativeUI::getStatus()`: Get the current status - -### Events - -- `NativeUICompleted`: Listen with `#[OnNative(NativeUICompleted::class)]` +### Accessibility + +Screen-reader support rides on two props that every element accepts: +`a11y-label` (what VoiceOver / TalkBack announces; maps to +`accessibilityLabel` on iOS and `contentDescription` on Android) and +`a11y-hint` (supplementary usage guidance, read after the label; maps to +`accessibilityHint` on iOS and is appended to the content description on +Android). Both are also available fluently as `->a11yLabel()` / `->a11yHint()`. + +- ALWAYS set `a11y-label` on icon-only buttons, chips, and tabs — with no + visible text there is nothing for the screen reader to announce. +- Icons are decorative by default: an `` without `a11y-label` is + silent to screen readers. Give it a label only when the icon itself carries + meaning. +- Use `alt` on `` for meaningful images; omit it for purely + decorative ones. +- Use `a11y-hint` sparingly, for supplementary guidance the label doesn't + cover ("Double-tap to reorder"). Never repeat the label in the hint. +- List items with a trailing icon button take `trailing-a11y-label` to label + that button separately from the row. +- Text scales with the user's system font size on both platforms + automatically — don't hardcode layouts that break at larger type sizes. @verbatim - -use Native\Mobile\Attributes\OnNative; -use Nativephp\NativeUi\Events\NativeUICompleted; - -#[OnNative(NativeUICompleted::class)] -public function handleNativeUICompleted($result, $id = null) -{ - // Handle the event -} + + + + @endverbatim -### JavaScript Usage (Vue/React/Inertia) - @verbatim - -import { nativeUI } from '@nativephp/native-ui'; - -// Execute the plugin functionality -const result = await nativeUI.execute({ option1: 'value' }); - -// Get the current status -const status = await nativeUI.getStatus(); + +use Nativephp\NativeUi\Elements\Button; + +Button::make() + ->icon('plus') + ->a11yLabel('Add item') + ->a11yHint('Adds a new item to the list') + ->onPress('addItem'); -@endverbatim \ No newline at end of file +@endverbatim diff --git a/resources/ios/NativeUIBadgeRenderer.swift b/resources/ios/NativeUIBadgeRenderer.swift index 1889a52..cfd285e 100644 --- a/resources/ios/NativeUIBadgeRenderer.swift +++ b/resources/ios/NativeUIBadgeRenderer.swift @@ -45,7 +45,7 @@ struct NativeUIBadgeRenderer: View { }() Text(text) - .font(.system(size: 12, weight: .bold)) + .nuiScaledFont(size: 12, weight: .bold) .foregroundColor(fg) .padding(.horizontal, 6) .padding(.vertical, 2) diff --git a/resources/ios/NativeUIButtonGroupRenderer.swift b/resources/ios/NativeUIButtonGroupRenderer.swift index a1608e3..d059d0f 100644 --- a/resources/ios/NativeUIButtonGroupRenderer.swift +++ b/resources/ios/NativeUIButtonGroupRenderer.swift @@ -41,7 +41,7 @@ struct NativeUIButtonGroupRenderer: View { } }) { Text(label) - .font(.system(size: theme.fontSm, weight: .medium)) + .nuiScaledFont(size: theme.fontSm, weight: .medium) .padding(.horizontal, 16) .padding(.vertical, 10) .frame(maxWidth: .infinity) diff --git a/resources/ios/NativeUIButtonRenderer.swift b/resources/ios/NativeUIButtonRenderer.swift index 8f7d673..a45c9a1 100644 --- a/resources/ios/NativeUIButtonRenderer.swift +++ b/resources/ios/NativeUIButtonRenderer.swift @@ -344,27 +344,38 @@ private struct ButtonContent: View { ProgressView() .controlSize(.small) if !label.isEmpty { - Text(label).font(.system(size: textSize, weight: .medium)) + Text(label).nuiScaledFont(size: textSize, weight: .medium) } } else { if !icon.isEmpty { Image(systemName: getIconForName(icon)) - .font(.system(size: iconSize)) + .nuiScaledFont(size: iconSize) } if !label.isEmpty { - Text(label).font(.system(size: textSize, weight: .medium)) + Text(label).nuiScaledFont(size: textSize, weight: .medium) } if !iconTrailing.isEmpty { Image(systemName: getIconForName(iconTrailing)) - .font(.system(size: iconSize)) + .nuiScaledFont(size: iconSize) } } } + // Merged up into the Button's accessibility element so VoiceOver + // announces the in-flight state. + .modifier(A11yLoadingValueModifier(loading: loading)) } } // MARK: - Accessibility modifiers (conditional) +private struct A11yLoadingValueModifier: ViewModifier { + let loading: Bool + func body(content: Content) -> some View { + if loading { content.accessibilityValue("Loading") } + else { content } + } +} + private struct A11yLabelModifier: ViewModifier { let label: String func body(content: Content) -> some View { diff --git a/resources/ios/NativeUICarouselRenderer.swift b/resources/ios/NativeUICarouselRenderer.swift index f340329..f94830f 100644 --- a/resources/ios/NativeUICarouselRenderer.swift +++ b/resources/ios/NativeUICarouselRenderer.swift @@ -7,6 +7,7 @@ struct NativeUICarouselRenderer: View { let p = node.props let itemWidth = CGFloat(p.getFloat("item_width").let { $0 > 0 ? $0 : 200 }) let itemSpacing = CGFloat(p.getFloat("item_spacing").let { $0 > 0 ? $0 : 8 }) + let a11yLabel = p.getString("a11y_label") ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: itemSpacing) { @@ -18,6 +19,17 @@ struct NativeUICarouselRenderer: View { } .padding(.horizontal, 16) } + .modifier(A11yLabelModifier(label: a11yLabel)) + } +} + +// MARK: - Accessibility modifiers (conditional) + +private struct A11yLabelModifier: ViewModifier { + let label: String + func body(content: Content) -> some View { + if label.isEmpty { content } + else { content.accessibilityLabel(label) } } } diff --git a/resources/ios/NativeUICheckboxRenderer.swift b/resources/ios/NativeUICheckboxRenderer.swift index f7d6145..b601bba 100644 --- a/resources/ios/NativeUICheckboxRenderer.swift +++ b/resources/ios/NativeUICheckboxRenderer.swift @@ -37,13 +37,14 @@ struct NativeUICheckboxRenderer: View { }) { HStack(spacing: 8) { Image(systemName: checked ? "checkmark.square.fill" : "square") - .font(.system(size: 22)) + .nuiScaledFont(size: 22) .foregroundColor(checked ? theme.primary : theme.onSurfaceVariant) if !label.isEmpty { Text(label) .foregroundColor(theme.onSurface) } } + .nuiMinTapTarget() } .buttonStyle(.plain) .disabled(disabled) @@ -61,7 +62,8 @@ struct NativeUICheckboxRenderer: View { lastSentValue = new } } - .accessibilityAddTraits(.isButton) + .modifier(A11yToggleTraitModifier()) + .accessibilityValue(checked ? "Checked" : "Unchecked") .modifier(A11yLabelModifier(label: a11yLabel)) .modifier(A11yHintModifier(hint: a11yHint)) } @@ -69,6 +71,18 @@ struct NativeUICheckboxRenderer: View { // MARK: - Accessibility modifiers (conditional) +/// VoiceOver announces the checkbox as a toggle where the trait exists +/// (iOS 17+), falling back to the button trait on older versions. +private struct A11yToggleTraitModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.accessibilityAddTraits(.isToggle) + } else { + content.accessibilityAddTraits(.isButton) + } + } +} + private struct A11yLabelModifier: ViewModifier { let label: String func body(content: Content) -> some View { diff --git a/resources/ios/NativeUIChipRenderer.swift b/resources/ios/NativeUIChipRenderer.swift index 2c31004..4212e1e 100644 --- a/resources/ios/NativeUIChipRenderer.swift +++ b/resources/ios/NativeUIChipRenderer.swift @@ -55,9 +55,9 @@ struct NativeUIChipRenderer: View { HStack(spacing: 6) { if !iconName.isEmpty { Image(systemName: getIconForName(iconName)) - .font(.system(size: 14)) + .nuiScaledFont(size: 14) } - Text(label).font(.system(size: theme.fontSm, weight: .medium)) + Text(label).nuiScaledFont(size: theme.fontSm, weight: .medium) } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -70,6 +70,11 @@ struct NativeUIChipRenderer: View { glassClear: glassClear, hasUserBg: hasUserBg )) + // Extend the hit area to a 44pt-tall band without inflating the + // visual pill (the capsule background is painted above, so the + // extra frame height stays transparent). + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(disabled) @@ -88,6 +93,7 @@ struct NativeUIChipRenderer: View { } } .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + .accessibilityValue(isSelected ? "Selected" : "Not selected") .modifier(A11yLabelModifier(label: a11yLabel)) .modifier(A11yHintModifier(hint: a11yHint)) } diff --git a/resources/ios/NativeUIDrawerHost.swift b/resources/ios/NativeUIDrawerHost.swift index 28b7a41..7215ad3 100644 --- a/resources/ios/NativeUIDrawerHost.swift +++ b/resources/ios/NativeUIDrawerHost.swift @@ -47,10 +47,18 @@ struct NativeDrawerHost: View { @ViewBuilder var content: Content @ObservedObject private var state = DrawerHostState.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var dragOffset: CGFloat = 0 private let edgeSwipeThreshold: CGFloat = 30 + /// Slide animation for the drawer. Suppressed when the user has Reduce + /// Motion enabled — open/close state then applies instantly instead of + /// sliding (`withAnimation(nil)` / `.animation(nil, value:)`). + private var drawerAnimation: Animation? { + reduceMotion ? nil : .easeOut(duration: 0.25) + } + /// Real top safe-area inset read straight from the window. The host's own /// nested GeometryReader reports an unreliable `safeAreaInsets.top` (it /// double-counts the content's safe-area handling), so position the ☰ @@ -160,22 +168,24 @@ struct NativeDrawerHost: View { // ☰ affordance, top-leading, shown while the drawer is closed. if !state.isOpen { Button { - withAnimation(.easeOut(duration: 0.25)) { state.isOpen = true } + withAnimation(drawerAnimation) { state.isOpen = true } } label: { Image(systemName: "line.3.horizontal") - .font(.system(size: 18, weight: .semibold)) + .nuiScaledFont(size: 18, weight: .semibold) .foregroundColor(.primary) .frame(width: 40, height: 40) .background(.ultraThinMaterial, in: Circle()) + .nuiMinTapTarget() } .buttonStyle(.plain) + .accessibilityLabel("Open menu") .padding(.leading, 12) .padding(.top, windowSafeAreaTop + 6) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .zIndex(4) } } - .animation(.easeOut(duration: 0.25), value: state.isOpen) + .animation(drawerAnimation, value: state.isOpen) } // Full-screen coordinate space — the wrapped content manages its own // safe-area inset (via the environment, like NativeTreeRenderer), so a @@ -207,7 +217,7 @@ struct NativeDrawerHost: View { private func settleOpen(value: DragGesture.Value, width: CGFloat) { let velocity = value.predictedEndTranslation.width - value.translation.width let opened = value.translation.width > width * 0.3 || velocity > 300 - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(drawerAnimation) { state.isOpen = opened dragOffset = 0 } @@ -216,14 +226,14 @@ struct NativeDrawerHost: View { private func settleClose(value: DragGesture.Value, width: CGFloat) { let velocity = value.predictedEndTranslation.width - value.translation.width let closed = abs(value.translation.width) > width * 0.3 || velocity < -300 - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(drawerAnimation) { state.isOpen = !closed dragOffset = 0 } } private func animateClosed() { - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(drawerAnimation) { state.isOpen = false dragOffset = 0 } diff --git a/resources/ios/NativeUIFilledTextInputRenderer.swift b/resources/ios/NativeUIFilledTextInputRenderer.swift index a7e0c25..2f9284e 100644 --- a/resources/ios/NativeUIFilledTextInputRenderer.swift +++ b/resources/ios/NativeUIFilledTextInputRenderer.swift @@ -49,10 +49,19 @@ struct NativeUIFilledTextInputRenderer: View { let supportingColor: Color = isError ? theme.destructive : theme.onSurfaceVariant + // The visible label doubles as the field's accessibility label unless + // an explicit a11y_label override was provided. When the field is in + // an error state, the supporting text must be announced: it rides the + // hint channel, or the value channel if a11y_hint is already taken. + let effectiveA11yLabel = a11yLabel.isEmpty ? label : a11yLabel + let errorText = (isError && !supporting.isEmpty) ? supporting : "" + let effectiveA11yHint = a11yHint.isEmpty ? errorText : a11yHint + let errorA11yValue = a11yHint.isEmpty ? "" : errorText + VStack(alignment: .leading, spacing: 4) { if !label.isEmpty { Text(label) - .font(.system(size: theme.fontSm, weight: .medium)) + .nuiScaledFont(size: theme.fontSm, weight: .medium) .foregroundStyle(labelColor) } @@ -60,12 +69,12 @@ struct NativeUIFilledTextInputRenderer: View { HStack(spacing: 8) { if !leadingIcon.isEmpty { Image(systemName: getIconForName(leadingIcon)) - .font(.system(size: metrics.iconSize)) + .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(theme.onSurfaceVariant) } if !prefixText.isEmpty { Text(prefixText) - .font(.system(size: metrics.textSize)) + .nuiScaledFont(size: metrics.textSize) .foregroundStyle(theme.onSurfaceVariant) } @@ -79,14 +88,14 @@ struct NativeUIFilledTextInputRenderer: View { if !suffixText.isEmpty { Text(suffixText) - .font(.system(size: metrics.textSize)) + .nuiScaledFont(size: metrics.textSize) .foregroundStyle(theme.onSurfaceVariant) } if loading { ProgressView().controlSize(.small) } else if !trailingIcon.isEmpty { Image(systemName: getIconForName(trailingIcon)) - .font(.system(size: metrics.iconSize)) + .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(theme.onSurfaceVariant) } } @@ -116,12 +125,13 @@ struct NativeUIFilledTextInputRenderer: View { if !supporting.isEmpty { Text(supporting) - .font(.system(size: theme.fontSm)) + .nuiScaledFont(size: theme.fontSm) .foregroundStyle(supportingColor) } } - .modifier(A11yLabelModifier(label: a11yLabel)) - .modifier(A11yHintModifier(hint: a11yHint)) + .modifier(A11yLabelModifier(label: effectiveA11yLabel)) + .modifier(A11yHintModifier(hint: effectiveA11yHint)) + .modifier(A11yValueModifier(value: errorA11yValue)) } // ─── Size metrics ──────────────────────────────────────────────────────── @@ -162,3 +172,11 @@ private struct A11yHintModifier: ViewModifier { else { content.accessibilityHint(hint) } } } + +private struct A11yValueModifier: ViewModifier { + let value: String + func body(content: Content) -> some View { + if value.isEmpty { content } + else { content.accessibilityValue(value) } + } +} diff --git a/resources/ios/NativeUIGestureAreaRenderer.swift b/resources/ios/NativeUIGestureAreaRenderer.swift index babbae1..85f7f1e 100644 --- a/resources/ios/NativeUIGestureAreaRenderer.swift +++ b/resources/ios/NativeUIGestureAreaRenderer.swift @@ -26,6 +26,8 @@ struct NativeUIGestureAreaRenderer: View { var body: some View { let panYId = node.props.getInt("pan-y-id", default: 0) let panYInitial = CGFloat(node.props.getFloat("pan-y-initial", default: 0)) + let a11yLabel = node.props.getString("a11y_label") + let a11yHint = node.props.getString("a11y_hint") VStack(spacing: 0) { ForEach(node.children) { child in @@ -49,5 +51,25 @@ struct NativeUIGestureAreaRenderer: View { store.seed(panYInitial, for: panYId) } } + .modifier(A11yLabelModifier(label: a11yLabel)) + .modifier(A11yHintModifier(hint: a11yHint)) + } +} + +// MARK: - Accessibility modifiers (conditional) + +private struct A11yLabelModifier: ViewModifier { + let label: String + func body(content: Content) -> some View { + if label.isEmpty { content } + else { content.accessibilityLabel(label) } + } +} + +private struct A11yHintModifier: ViewModifier { + let hint: String + func body(content: Content) -> some View { + if hint.isEmpty { content } + else { content.accessibilityHint(hint) } } } diff --git a/resources/ios/NativeUIIconRenderer.swift b/resources/ios/NativeUIIconRenderer.swift index 6bda773..5ed78a5 100644 --- a/resources/ios/NativeUIIconRenderer.swift +++ b/resources/ios/NativeUIIconRenderer.swift @@ -11,6 +11,13 @@ struct NativeUIIconRenderer: View { let size = CGFloat(p.getFloat("size", default: 24)) let lightArgb = p.getColor("color", default: 0xFF000000) let darkArgb = p.getColor("dark_color", default: 0) + let a11yLabel = p.getString("a11y_label") + + // Mirrors ViewClickHandlers' detection: an icon is interactive when + // any of the click callbacks it wires are present. + let interactive = node.onPress != 0 + || node.onLongPress != 0 + || p.getInt("on_double_tap") != 0 // When dark mode is active and a `dark-color` was supplied, use it; // otherwise fall through to the regular `color`. Same shape as @@ -25,6 +32,45 @@ struct NativeUIIconRenderer: View { .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .foregroundColor(Color(argb: effectiveArgb)) + // Extend clickable icons to a 44pt hit target BEFORE the click + // handlers attach, so the tap gesture covers the enlarged shape. + .modifier(IconTapTargetModifier(interactive: interactive)) .applyClickHandlers(node: node) + .modifier(IconA11yModifier(label: a11yLabel, interactive: interactive)) + } +} + +/// 44pt minimum hit target — only for icons that actually respond to taps. +/// Decorative icons keep their intrinsic frame so layouts aren't inflated. +private struct IconTapTargetModifier: ViewModifier { + let interactive: Bool + func body(content: Content) -> some View { + if interactive { content.nuiMinTapTarget() } + else { content } + } +} + +/// Icons with an `a11y_label` announce it; unlabeled decorative icons are +/// hidden from VoiceOver. Interactive icons always stay visible and carry +/// the button trait (falling back to a bare button when unlabeled rather +/// than disappearing from the accessibility tree). +private struct IconA11yModifier: ViewModifier { + let label: String + let interactive: Bool + + func body(content: Content) -> some View { + if interactive { + if label.isEmpty { + content.accessibilityAddTraits(.isButton) + } else { + content + .accessibilityLabel(label) + .accessibilityAddTraits(.isButton) + } + } else if label.isEmpty { + content.accessibilityHidden(true) + } else { + content.accessibilityLabel(label) + } } } diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift index 8377df8..d561b26 100644 --- a/resources/ios/NativeUIListItemRenderer.swift +++ b/resources/ios/NativeUIListItemRenderer.swift @@ -79,6 +79,11 @@ struct NativeUIListItemRenderer: View { .foregroundColor(supportingColor != 0 ? Color(argb: supportingColor) : .secondary) } } + // Read overline/headline/supporting as one VoiceOver element + // instead of three separate swipe stops. Trailing interactive + // controls (icon_button / menu) stay outside this container so + // they remain individually focusable. + .accessibilityElement(children: .combine) Spacer() @@ -110,7 +115,7 @@ struct NativeUIListItemRenderer: View { ZStack { Circle().fill(Color(argb: iconBgColor)) Image(systemName: getIconForName(value)) - .font(.system(size: 18, weight: .medium)) + .nuiScaledFont(size: 18, weight: .medium) .foregroundColor(.white) } .frame(width: 40, height: 40) @@ -120,6 +125,7 @@ struct NativeUIListItemRenderer: View { .foregroundColor(.secondary) } case "avatar": + // Decorative — the row's text content carries the meaning. AsyncImage(url: URL(string: value)) { image in image.resizable().scaledToFill() } placeholder: { @@ -127,16 +133,20 @@ struct NativeUIListItemRenderer: View { } .frame(width: 40, height: 40) .clipShape(Circle()) + .accessibilityHidden(true) case "monogram": + // Decorative — initials duplicate the headline for VoiceOver. let bgColor = monogramColor != 0 ? Color(argb: monogramColor) : Color.accentColor ZStack { Circle().fill(bgColor) Text(String(value.prefix(2)).uppercased()) - .font(.system(size: 16, weight: .medium)) + .nuiScaledFont(size: 16, weight: .medium) .foregroundColor(.white) } .frame(width: 40, height: 40) + .accessibilityHidden(true) case "image": + // Decorative — the row's text content carries the meaning. AsyncImage(url: URL(string: value)) { image in image.resizable().scaledToFill() } placeholder: { @@ -144,6 +154,7 @@ struct NativeUIListItemRenderer: View { } .frame(width: 56, height: 56) .clipShape(RoundedRectangle(cornerRadius: 4)) + .accessibilityHidden(true) case "checkbox": Image(systemName: "square") .foregroundColor(.secondary) @@ -165,7 +176,7 @@ struct NativeUIListItemRenderer: View { HStack(spacing: 6) { ForEach(badges) { b in Image(systemName: getIconForName(b.icon)) - .font(.system(size: 15)) + .nuiScaledFont(size: 15) .foregroundColor(parseListItemHex(b.color) ?? .secondary) } } @@ -185,6 +196,13 @@ struct NativeUIListItemRenderer: View { Text(value) .foregroundColor(textColor != 0 ? Color(argb: textColor) : .secondary) case "icon_button": + // Spoken name for the icon-only trailing button: explicit + // `trailing_a11y_label` prop first, then a humanized icon name. + let trailingA11y = node.props.getString("trailing_a11y_label") + let effectiveTrailingA11y = !trailingA11y.isEmpty + ? trailingA11y + : value.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") // When the row has `:trailing-menu` attached, the trailing // icon button becomes a Menu trigger instead of a plain // press. SwiftUI Menu absorbs the tap to open the dropdown, @@ -197,16 +215,17 @@ struct NativeUIListItemRenderer: View { listItemMenuItem(item) } } label: { - // `.contentShape(Rectangle())` expands the tap target - // to the full 24×24 frame; without it only the - // symbol's opaque pixels respond, which makes the - // ellipsis glyph (mostly empty space) almost - // un-tappable. + // `.nuiMinTapTarget()` expands the tap target to a + // 44×44 hit area (with `.contentShape(Rectangle())`); + // without it only the symbol's opaque pixels respond, + // which makes the ellipsis glyph (mostly empty space) + // almost un-tappable. Image(systemName: getIconForName(value)) .frame(width: 24, height: 24) .foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary) - .contentShape(Rectangle()) + .nuiMinTapTarget() } + .accessibilityLabel(effectiveTrailingA11y) } else { Button(action: { let onPressCb = node.props.getCallbackId("on_trailing_press") @@ -217,7 +236,9 @@ struct NativeUIListItemRenderer: View { Image(systemName: getIconForName(value)) .frame(width: 24, height: 24) .foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary) + .nuiMinTapTarget() } + .accessibilityLabel(effectiveTrailingA11y) } case "switch": EmptyView() // Switch requires state management - handled at a higher level diff --git a/resources/ios/NativeUIModalRenderer.swift b/resources/ios/NativeUIModalRenderer.swift index e87cee1..bd83b2c 100644 --- a/resources/ios/NativeUIModalRenderer.swift +++ b/resources/ios/NativeUIModalRenderer.swift @@ -45,6 +45,7 @@ struct NativeUIModalRenderer: View { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundStyle(theme.onSurfaceVariant) + .nuiMinTapTarget() } .padding() .accessibilityLabel("Close") @@ -57,6 +58,8 @@ struct NativeUIModalRenderer: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(theme.background.ignoresSafeArea()) + // Contain VoiceOver focus within the presented modal content. + .accessibilityAddTraits(.isModal) .modifier(A11yLabelModifier(label: a11yLabel)) } } diff --git a/resources/ios/NativeUINavRenderers.swift b/resources/ios/NativeUINavRenderers.swift index 34955b9..99b8b28 100644 --- a/resources/ios/NativeUINavRenderers.swift +++ b/resources/ios/NativeUINavRenderers.swift @@ -26,11 +26,13 @@ struct NativeUITopBarRenderer: View { NativeElementBridge.sendSystemBackEvent() } label: { Image(systemName: "chevron.backward") - .font(.system(size: 17, weight: .semibold)) + .nuiScaledFont(size: 17, weight: .semibold) .foregroundColor(textColor) .frame(width: 32, height: 32, alignment: .leading) + .nuiMinTapTarget() } .buttonStyle(.plain) + .accessibilityLabel("Back") } VStack(alignment: .leading, spacing: 2) { @@ -48,17 +50,30 @@ struct NativeUITopBarRenderer: View { ForEach(node.children.filter { $0.type == "top_bar_action" }) { action in let icon = action.props.getString("icon", default: "ellipsis") + // Icon-only action buttons need a spoken name: prefer an + // explicit a11y_label, then the action's label prop, then a + // humanized icon name so VoiceOver never reads them unlabeled. + let a11y = action.props.getString("a11y_label") + let actionLabel = action.props.getString("label", default: "") + let effectiveA11y = !a11y.isEmpty + ? a11y + : (!actionLabel.isEmpty + ? actionLabel + : icon.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ")) Button { if action.onPress != 0 { NativeElementBridge.sendPressEvent(action.onPress, nodeId: action.id) } } label: { Image(systemName: getIconForName(icon)) - .font(.system(size: 17, weight: .semibold)) + .nuiScaledFont(size: 17, weight: .semibold) .foregroundColor(textColor) .frame(width: 32, height: 32) + .nuiMinTapTarget() } .buttonStyle(.plain) + .modifier(A11yLabelModifier(label: effectiveA11y)) } } .padding(.horizontal, 16) @@ -146,10 +161,10 @@ struct NativeUIBottomNavRenderer: View { VStack(spacing: 4) { ZStack(alignment: .topTrailing) { Image(systemName: getIconForName(icon)) - .font(.system(size: 24)) + .nuiScaledFont(size: 24) if !badge.isEmpty { Text(badge) - .font(.system(size: 10, weight: .bold)) + .nuiScaledFont(size: 10, weight: .bold) .foregroundColor(.white) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -171,6 +186,10 @@ struct NativeUIBottomNavRenderer: View { .foregroundColor(active ? activeColor : inactiveColor) .frame(maxWidth: .infinity) } + // Announce the item name even when label_visibility hides the + // visible text, and expose the active state to VoiceOver. + .modifier(A11yLabelModifier(label: label)) + .accessibilityAddTraits(active ? .isSelected : []) } } .frame(maxWidth: .infinity) @@ -180,6 +199,16 @@ struct NativeUIBottomNavRenderer: View { } } +// MARK: - Accessibility modifiers (conditional) + +private struct A11yLabelModifier: ViewModifier { + let label: String + func body(content: Content) -> some View { + if label.isEmpty { content } + else { content.accessibilityLabel(label) } + } +} + // MARK: - Side Nav (stores node for drawer) struct NativeUISideNavRenderer: View { diff --git a/resources/ios/NativeUIOutlinedTextInputRenderer.swift b/resources/ios/NativeUIOutlinedTextInputRenderer.swift index 271241a..09729bb 100644 --- a/resources/ios/NativeUIOutlinedTextInputRenderer.swift +++ b/resources/ios/NativeUIOutlinedTextInputRenderer.swift @@ -51,22 +51,31 @@ struct NativeUIOutlinedTextInputRenderer: View { let supportingColor: Color = isError ? theme.destructive : theme.onSurfaceVariant + // The visible label doubles as the field's accessibility label unless + // an explicit a11y_label override was provided. When the field is in + // an error state, the supporting text must be announced: it rides the + // hint channel, or the value channel if a11y_hint is already taken. + let effectiveA11yLabel = a11yLabel.isEmpty ? label : a11yLabel + let errorText = (isError && !supporting.isEmpty) ? supporting : "" + let effectiveA11yHint = a11yHint.isEmpty ? errorText : a11yHint + let errorA11yValue = a11yHint.isEmpty ? "" : errorText + VStack(alignment: .leading, spacing: 4) { if !label.isEmpty { Text(label) - .font(.system(size: theme.fontSm, weight: .medium)) + .nuiScaledFont(size: theme.fontSm, weight: .medium) .foregroundStyle(labelColor) } HStack(spacing: 8) { if !leadingIcon.isEmpty { Image(systemName: getIconForName(leadingIcon)) - .font(.system(size: metrics.iconSize)) + .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(theme.onSurfaceVariant) } if !prefixText.isEmpty { Text(prefixText) - .font(.system(size: metrics.textSize)) + .nuiScaledFont(size: metrics.textSize) .foregroundStyle(theme.onSurfaceVariant) } @@ -80,14 +89,14 @@ struct NativeUIOutlinedTextInputRenderer: View { if !suffixText.isEmpty { Text(suffixText) - .font(.system(size: metrics.textSize)) + .nuiScaledFont(size: metrics.textSize) .foregroundStyle(theme.onSurfaceVariant) } if loading { ProgressView().controlSize(.small) } else if !trailingIcon.isEmpty { Image(systemName: getIconForName(trailingIcon)) - .font(.system(size: metrics.iconSize)) + .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(theme.onSurfaceVariant) } } @@ -111,12 +120,13 @@ struct NativeUIOutlinedTextInputRenderer: View { if !supporting.isEmpty { Text(supporting) - .font(.system(size: theme.fontSm)) + .nuiScaledFont(size: theme.fontSm) .foregroundStyle(supportingColor) } } - .modifier(A11yLabelModifier(label: a11yLabel)) - .modifier(A11yHintModifier(hint: a11yHint)) + .modifier(A11yLabelModifier(label: effectiveA11yLabel)) + .modifier(A11yHintModifier(hint: effectiveA11yHint)) + .modifier(A11yValueModifier(value: errorA11yValue)) } // ─── Size metrics ──────────────────────────────────────────────────────── @@ -159,3 +169,11 @@ private struct A11yHintModifier: ViewModifier { else { content.accessibilityHint(hint) } } } + +private struct A11yValueModifier: ViewModifier { + let value: String + func body(content: Content) -> some View { + if value.isEmpty { content } + else { content.accessibilityValue(value) } + } +} diff --git a/resources/ios/NativeUIRadioGroupRenderer.swift b/resources/ios/NativeUIRadioGroupRenderer.swift index b51668e..6a5392d 100644 --- a/resources/ios/NativeUIRadioGroupRenderer.swift +++ b/resources/ios/NativeUIRadioGroupRenderer.swift @@ -26,7 +26,7 @@ struct NativeUIRadioGroupRenderer: View { VStack(alignment: .leading, spacing: 8) { if !label.isEmpty { Text(label) - .font(.system(size: theme.fontSm, weight: .medium)) + .nuiScaledFont(size: theme.fontSm, weight: .medium) .foregroundStyle(theme.onSurfaceVariant) } @@ -84,12 +84,13 @@ struct NativeUIRadioRenderer: View { }) { HStack(spacing: 8) { Image(systemName: isSelected ? "circle.inset.filled" : "circle") - .font(.system(size: 22)) + .nuiScaledFont(size: 22) .foregroundColor(isSelected ? theme.primary : theme.onSurfaceVariant) if !label.isEmpty { Text(label).foregroundColor(theme.onSurface) } } + .nuiMinTapTarget() } .buttonStyle(.plain) .disabled(disabled) diff --git a/resources/ios/NativeUISelectRenderer.swift b/resources/ios/NativeUISelectRenderer.swift index bf252f7..dcc757d 100644 --- a/resources/ios/NativeUISelectRenderer.swift +++ b/resources/ios/NativeUISelectRenderer.swift @@ -29,7 +29,7 @@ struct NativeUISelectRenderer: View { VStack(alignment: .leading, spacing: 4) { if !label.isEmpty { Text(label) - .font(.system(size: theme.fontSm, weight: .medium)) + .nuiScaledFont(size: theme.fontSm, weight: .medium) .foregroundStyle(theme.onSurfaceVariant) } @@ -60,6 +60,9 @@ struct NativeUISelectRenderer: View { } .disabled(disabled) .opacity(disabled ? 0.6 : 1.0) + // Announce the current selection as the control's value so + // VoiceOver reads "…, " on focus. + .accessibilityValue(selected) } .onAppear { if !initialized { diff --git a/resources/ios/NativeUISimpleRenderers.swift b/resources/ios/NativeUISimpleRenderers.swift index 4b9200b..ccfc093 100644 --- a/resources/ios/NativeUISimpleRenderers.swift +++ b/resources/ios/NativeUISimpleRenderers.swift @@ -123,6 +123,7 @@ struct NativeUIImageRenderer: View { let tintArgb = p.getColor("tint_color", default: 0) let contentMode = resolveContentMode(fit) let cornerRadius = CGFloat(node.style?.borderRadius ?? 0) + let alt = p.getString("alt") if contentMode == .fill { // Cover / fill (object-cover, object-fill): the image fills its @@ -139,6 +140,7 @@ struct NativeUIImageRenderer: View { Color.clear .overlay(imageContent(src: src, contentMode: contentMode, tintArgb: tintArgb, cornerRadius: cornerRadius)) .clipped() + .modifier(ImageAltModifier(alt: alt)) } else { // Fit (default / object-contain / scale-down / none): the image // is letterboxed and never overflows, so render it directly. @@ -147,6 +149,7 @@ struct NativeUIImageRenderer: View { // `` lays out at its natural ratio like an // HTML . Within a definite frame it just letterboxes. imageContent(src: src, contentMode: contentMode, tintArgb: tintArgb, cornerRadius: cornerRadius) + .modifier(ImageAltModifier(alt: alt)) } } @@ -210,6 +213,22 @@ struct NativeUIImageRenderer: View { } } + /// HTML `` semantics for VoiceOver: an `alt` prop makes the + /// image a labeled image element; no `alt` marks it decorative and hides + /// it from the accessibility tree entirely. + private struct ImageAltModifier: ViewModifier { + let alt: String + func body(content: Content) -> some View { + if alt.isEmpty { + content.accessibilityHidden(true) + } else { + content + .accessibilityLabel(alt) + .accessibilityAddTraits(.isImage) + } + } + } + /// Resolves `src` to a local filesystem path when it points at an /// on-device file (`file://…` URL or an absolute `/…` path), or nil /// when it's a remote URL that should go through AsyncImage. diff --git a/resources/ios/NativeUITabRowRenderer.swift b/resources/ios/NativeUITabRowRenderer.swift index 7906005..9a801bb 100644 --- a/resources/ios/NativeUITabRowRenderer.swift +++ b/resources/ios/NativeUITabRowRenderer.swift @@ -35,6 +35,16 @@ struct NativeUITabRowRenderer: View { let icon = tab.props.getString("icon") let tabA11y = tab.props.getString("a11y_label") let isSelected = index == selectedIndex + // Icon-only tabs (no visible label) must still be + // labeled for VoiceOver: prefer the explicit + // a11y_label, then a humanized icon name. Tabs + // with a visible label are read automatically. + let effectiveA11y = !tabA11y.isEmpty + ? tabA11y + : (label.isEmpty + ? icon.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + : "") Button(action: { selectedIndex = index @@ -48,12 +58,16 @@ struct NativeUITabRowRenderer: View { Image(systemName: getIconForName(icon)) } if !label.isEmpty { - Text(label).font(.system(size: theme.fontSm, weight: .medium)) + Text(label).nuiScaledFont(size: theme.fontSm, weight: .medium) } } .padding(.horizontal, 16) .padding(.vertical, 10) .foregroundStyle(isSelected ? theme.primary : theme.onSurfaceVariant) + // Extend the hit area to 44pt without changing + // the visual label/padding metrics. + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) .overlay(alignment: .bottom) { @@ -64,7 +78,7 @@ struct NativeUITabRowRenderer: View { } } .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) - .modifier(A11yLabelModifier(label: tabA11y)) + .modifier(A11yLabelModifier(label: effectiveA11y)) } } } diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 498b905..2180e80 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -63,7 +63,7 @@ struct NativeUITextInputCore: View { .focused($isFocused) } } - .font(.system(size: textSize)) + .nuiScaledFont(size: textSize) .tint(tintColor) .keyboardType(keyboard) .disabled(disabled || readOnly) diff --git a/resources/ios/NativeUITextRenderer.swift b/resources/ios/NativeUITextRenderer.swift index dfc9482..38535d7 100644 --- a/resources/ios/NativeUITextRenderer.swift +++ b/resources/ios/NativeUITextRenderer.swift @@ -28,9 +28,12 @@ struct NativeUITextRenderer: View { if !text.isEmpty { // Style the `Text` itself (italic/underline/strikethrough on Text are // iOS 13+, keeping us below the iOS 16 View-level variants). Kerning - // is iOS 16+, so it's guarded. + // is iOS 16+, so it's guarded. The font itself is applied at the + // View level via `nuiScaledFont` so it participates in Dynamic + // Type; the Text-level decorations operate on that environment + // font. let styledText: Text = { - var t = Text(text).font(.system(size: CGFloat(fontSize), weight: fontWeight, design: fontDesign)) + var t = Text(text) if isItalic { t = t.italic() } if isUnderline { t = t.underline() } if isStrikethrough { t = t.strikethrough() } @@ -41,6 +44,7 @@ struct NativeUITextRenderer: View { }() styledText + .nuiScaledFont(size: CGFloat(fontSize), weight: fontWeight, design: fontDesign) .foregroundColor(Color(argb: color)) .multilineTextAlignment(textAlign) .lineLimit(maxLines > 0 ? maxLines : nil) diff --git a/resources/ios/NativeUITheme.swift b/resources/ios/NativeUITheme.swift index e28affc..552d108 100644 --- a/resources/ios/NativeUITheme.swift +++ b/resources/ios/NativeUITheme.swift @@ -43,7 +43,7 @@ struct NativeUITokens: Equatable { static let fallback = NativeUITokens( primary: Color(hex: "#0F766E"), onPrimary: Color(hex: "#FFFFFF"), - secondary: Color(hex: "#64748B"), + secondary: Color(hex: "#475569"), onSecondary: Color(hex: "#FFFFFF"), surface: Color(hex: "#FFFFFF"), onSurface: Color(hex: "#0F172A"), @@ -52,9 +52,9 @@ struct NativeUITokens: Equatable { surfaceVariant: Color(hex: "#F1F5F9"), onSurfaceVariant: Color(hex: "#475569"), outline: Color(hex: "#CBD5E1"), - destructive: Color(hex: "#DC2626"), + destructive: Color(hex: "#B91C1C"), onDestructive: Color(hex: "#FFFFFF"), - accent: Color(hex: "#FB923C"), + accent: Color(hex: "#C2410C"), onAccent: Color(hex: "#FFFFFF"), radiusSm: 4, radiusMd: 8, radiusLg: 16, radiusFull: 9999, fontSm: 14, fontMd: 16, fontLg: 20, fontXl: 24, @@ -165,6 +165,43 @@ private func cgf(_ any: Any?, fallback: CGFloat) -> CGFloat { return fallback } +// MARK: - Accessibility helpers + +/// Renders a fixed design-point font size through Dynamic Type so text tracks +/// the user's preferred content size. `@ScaledMetric` scales the base size +/// relative to `.body`, matching how UIFontMetrics scales custom fonts. +struct NUIScaledFontModifier: ViewModifier { + @ScaledMetric private var size: CGFloat + private let weight: Font.Weight + private let design: Font.Design + + init(size: CGFloat, weight: Font.Weight, design: Font.Design) { + _size = ScaledMetric(wrappedValue: size, relativeTo: .body) + self.weight = weight + self.design = design + } + + func body(content: Content) -> some View { + content.font(.system(size: size, weight: weight, design: design)) + } +} + +extension View { + /// Dynamic-Type-aware replacement for `.font(.system(size:weight:design:))`. + /// Use this for all text (and glyph icons rendered as text) so type scales + /// with the user's accessibility settings. + func nuiScaledFont(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> some View { + modifier(NUIScaledFontModifier(size: size, weight: weight, design: design)) + } + + /// Ensures a minimum 44×44pt hit target (Apple HIG) without changing the + /// visual size of the content. Apply to small tappable controls. + func nuiMinTapTarget() -> some View { + frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } +} + extension Color { /// Construct from `#RRGGBB` or `#AARRGGBB` hex string. Falls back to black /// on malformed input. diff --git a/resources/ios/NativeUITransitionFunctions.swift b/resources/ios/NativeUITransitionFunctions.swift index a8e9219..957a5e1 100644 --- a/resources/ios/NativeUITransitionFunctions.swift +++ b/resources/ios/NativeUITransitionFunctions.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit // MARK: - NativeUI.Transition.* bridge functions // @@ -16,7 +17,16 @@ enum NativeUITransitionFunctions { /// `NativeUI.Transition.Set` — stage a transition for the next published tree. class Set: BridgeFunction { func execute(parameters: [String: Any]) throws -> [String: Any] { - let type = (parameters["type"] as? String) ?? "fade" + let requested = (parameters["type"] as? String) ?? "fade" + + // Reduce Motion: swap directional/scaling transitions for a plain + // cross-fade. The Edge\Transition → AnyTransition mapping lives in + // core (`nativeScreenTransition(for:)`), so this staging point is + // where the substitution happens — "none" is left untouched since + // it's already motionless. + let type = (UIAccessibility.isReduceMotionEnabled && requested != "none" && requested != "fade") + ? "fade" + : requested if Thread.isMainThread { NativeUIBridge.shared.setNavigationPending(transition: type) diff --git a/resources/ios/NativeUIVirtualListRenderer.swift b/resources/ios/NativeUIVirtualListRenderer.swift index bf29683..327ce18 100644 --- a/resources/ios/NativeUIVirtualListRenderer.swift +++ b/resources/ios/NativeUIVirtualListRenderer.swift @@ -76,7 +76,10 @@ private struct VirtualListBody: View { if let child = rowByIndex[index] { NodeView(node: child).equatable() } else { - Color(.systemGray6).frame(height: estimatedRowHeight) + // Placeholder skeleton — meaningless to VoiceOver. + Color(.systemGray6) + .frame(height: estimatedRowHeight) + .accessibilityHidden(true) } } .listRowInsets(EdgeInsets()) diff --git a/src/Concerns/HasA11y.php b/src/Concerns/HasA11y.php new file mode 100644 index 0000000..5410f38 --- /dev/null +++ b/src/Concerns/HasA11y.php @@ -0,0 +1,64 @@ +setProp('a11y_label', $value); + + return $this; + } + + /** + * Supplementary usage guidance, read after the label. + * iOS: `accessibilityHint`. Android: appended to `contentDescription`. + */ + public function a11yHint(string $value): static + { + $this->setProp('a11y_hint', $value); + + return $this; + } + + /** + * Hydrate the a11y props from Blade attributes. The Blade precompiler + * keeps attribute names verbatim, so both the kebab-case spelling + * (`a11y-label` / `a11y-hint`) and the camelCase one (`a11yLabel` / + * `a11yHint`) must be accepted. Call this from `applyAttributes()`. + */ + protected function applyA11yAttributes(array $attrs): void + { + if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { + $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); + } + if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { + $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); + } + } +} diff --git a/src/Elements/ActivityIndicator.php b/src/Elements/ActivityIndicator.php index 2241f49..1b780fd 100644 --- a/src/Elements/ActivityIndicator.php +++ b/src/Elements/ActivityIndicator.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Circular activity indicator (spinner). Always indeterminate — use @@ -14,6 +15,8 @@ */ class ActivityIndicator extends Element { + use HasA11y; + protected string $type = 'activity_indicator'; /** @var array */ @@ -29,9 +32,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['size'])) { $this->size($attrs['size']); } if (isset($attrs['color'])) { $this->color((string) $attrs['color']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); } /** @@ -59,13 +60,6 @@ public function size(string|int $size): static return $this; } - public function a11yLabel(string $value): static - { - $this->indicatorProps['a11y_label'] = $value; - - return $this; - } - protected function resolveProps(CallbackRegistry $registry): array { return $this->indicatorProps; diff --git a/src/Elements/Badge.php b/src/Elements/Badge.php index e41a09b..08371c7 100644 --- a/src/Elements/Badge.php +++ b/src/Elements/Badge.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Badge — small count or text marker, typically used as an overlay on nav @@ -19,6 +20,8 @@ */ class Badge extends Element { + use HasA11y; + protected string $type = 'badge'; /** @var array */ @@ -41,9 +44,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['label'])) { $this->label($attrs['label']); } if (isset($attrs['variant'])) { $this->variant((string) $attrs['variant']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); } public function count(int $count): static @@ -67,13 +68,6 @@ public function variant(string $variant): static return $this; } - public function a11yLabel(string $value): static - { - $this->badgeProps['a11y_label'] = $value; - - return $this; - } - protected function resolveProps(CallbackRegistry $registry): array { return $this->badgeProps; diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index d38c28b..7a96d14 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -7,6 +7,7 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Shared base for the text input variants (`outlined-text-input`, @@ -30,6 +31,8 @@ */ abstract class BaseTextInput extends Element { + use HasA11y; + /** @var array */ protected array $inputProps = []; @@ -86,12 +89,7 @@ public function applyAttributes(array $attrs): void // Size + a11y if (isset($attrs['size'])) { $this->size($attrs['size']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); // Sync mode + debounce (from `native:model` expansion, or set manually). if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { @@ -255,7 +253,7 @@ public function trailingIcon( return $this; } - // ── Size + a11y ────────────────────────────────────────────────────────── + // ── Size ───────────────────────────────────────────────────────────────── /** sm | md | lg. Default: md. */ public function size(string $value): static @@ -265,20 +263,6 @@ public function size(string $value): static return $this; } - public function a11yLabel(string $value): static - { - $this->inputProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->inputProps['a11y_hint'] = $value; - - return $this; - } - // ── Sync mode ──────────────────────────────────────────────────────────── /** diff --git a/src/Elements/BottomSheet.php b/src/Elements/BottomSheet.php index 35ba086..6329682 100644 --- a/src/Elements/BottomSheet.php +++ b/src/Elements/BottomSheet.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Bottom sheet — dismissible panel that slides up from the bottom. Visibility @@ -15,6 +16,8 @@ */ class BottomSheet extends Element { + use HasA11y; + protected string $type = 'bottom_sheet'; /** @var array */ @@ -32,9 +35,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['visible'])) { $this->visible((bool) $attrs['visible']); } if (isset($attrs['detents'])) { $this->detents($attrs['detents']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); } public function visible(bool $value = true): static @@ -57,13 +58,6 @@ public function detents(string $detents): static return $this; } - public function a11yLabel(string $value): static - { - $this->sheetProps['a11y_label'] = $value; - - return $this; - } - public function onDismiss(string $method): static { $this->dismissCallback = $method; diff --git a/src/Elements/Button.php b/src/Elements/Button.php index e70cdcf..011b6b0 100644 --- a/src/Elements/Button.php +++ b/src/Elements/Button.php @@ -8,6 +8,7 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Native button. @@ -33,6 +34,8 @@ */ class Button extends Element { + use HasA11y; + protected string $type = 'button'; /** @var array */ @@ -74,12 +77,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['icon-trailing']) || isset($attrs['iconTrailing'])) { $this->iconTrailing($attrs['icon-trailing'] ?? $attrs['iconTrailing']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); // Optional tap-to-open dropdown menu — see Pressable.php for the // wire shape. When `:menu` is set, tapping shadows `@press` and @@ -165,20 +163,6 @@ public function iconTrailing( return $this; } - public function a11yLabel(string $value): static - { - $this->buttonProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->buttonProps['a11y_hint'] = $value; - - return $this; - } - public function onPress(string $method): static { $this->pressCallback = $method; diff --git a/src/Elements/ButtonGroup.php b/src/Elements/ButtonGroup.php index 71afd98..6bd7d84 100644 --- a/src/Elements/ButtonGroup.php +++ b/src/Elements/ButtonGroup.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * ButtonGroup — segmented single-choice selector. Options are a flat array of @@ -16,6 +17,8 @@ */ class ButtonGroup extends Element { + use HasA11y; + protected string $type = 'button_group'; /** @var array */ @@ -36,9 +39,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['selected-index'])){ $this->selectedIndex((int) $attrs['selected-index']); } if (! empty($attrs['disabled'])) { $this->disabled(); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { $this->syncMode($attrs['sync-mode'] ?? $attrs['syncMode']); @@ -67,13 +68,6 @@ public function disabled(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->buttonGroupProps['a11y_label'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->buttonGroupProps['sync_mode'] = $mode; diff --git a/src/Elements/Carousel.php b/src/Elements/Carousel.php index bbbeb44..44ee3dd 100644 --- a/src/Elements/Carousel.php +++ b/src/Elements/Carousel.php @@ -4,9 +4,12 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; class Carousel extends Element { + use HasA11y; + protected string $type = 'carousel'; protected array $carouselProps = []; @@ -30,6 +33,8 @@ public function applyAttributes(array $attrs): void if (isset($attrs['itemSpacing']) || isset($attrs['item-spacing'])) { $this->itemSpacing((float) ($attrs['itemSpacing'] ?? $attrs['item-spacing'])); } + + $this->applyA11yAttributes($attrs); } public function variant(string $variant): static diff --git a/src/Elements/Checkbox.php b/src/Elements/Checkbox.php index 1b8ad34..60eaca1 100644 --- a/src/Elements/Checkbox.php +++ b/src/Elements/Checkbox.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Checkbox — binary tick/untick with optional inline label. @@ -16,6 +17,8 @@ */ class Checkbox extends Element { + use HasA11y; + protected string $type = 'checkbox'; /** @var array */ @@ -34,12 +37,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['label'])) { $this->label($attrs['label']); } if (! empty($attrs['disabled'])) { $this->disabled(); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { $this->syncMode($attrs['sync-mode'] ?? $attrs['syncMode']); @@ -70,20 +68,6 @@ public function disabled(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->checkboxProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->checkboxProps['a11y_hint'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->checkboxProps['sync_mode'] = $mode; diff --git a/src/Elements/Chip.php b/src/Elements/Chip.php index 61b1648..6316eee 100644 --- a/src/Elements/Chip.php +++ b/src/Elements/Chip.php @@ -5,6 +5,7 @@ use Native\Mobile\Concerns\HasPlatformIcon; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Chip — compact selectable tag. Bool selected state, optional leading icon @@ -15,6 +16,7 @@ */ class Chip extends Element { + use HasA11y; use HasPlatformIcon; protected string $type = 'chip'; @@ -49,12 +51,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['icon'])) { $this->icon($attrs['icon']); } if (! empty($attrs['disabled'])) { $this->disabled(); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { $this->syncMode($attrs['sync-mode'] ?? $attrs['syncMode']); @@ -84,20 +81,6 @@ public function disabled(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->chipProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->chipProps['a11y_hint'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->chipProps['sync_mode'] = $mode; diff --git a/src/Elements/Icon.php b/src/Elements/Icon.php index 6402275..85cdf85 100644 --- a/src/Elements/Icon.php +++ b/src/Elements/Icon.php @@ -7,9 +7,12 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Concerns\HasA11y; class Icon extends Element { + use HasA11y; + protected string $type = 'icon'; protected array $iconProps = []; @@ -40,6 +43,9 @@ public function applyAttributes(array $attrs): void if (isset($attrs['dark-color']) || isset($attrs['darkColor'])) { $this->darkColor($attrs['dark-color'] ?? $attrs['darkColor']); } + + // Icons are decorative (silent to screen readers) unless given a label. + $this->applyA11yAttributes($attrs); } /** diff --git a/src/Elements/ListItem.php b/src/Elements/ListItem.php index 977b6d4..217fd38 100644 --- a/src/Elements/ListItem.php +++ b/src/Elements/ListItem.php @@ -8,9 +8,12 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; +use Nativephp\NativeUi\Concerns\HasA11y; class ListItem extends Element { + use HasA11y; + protected string $type = 'list_item'; protected array $listItemProps = []; @@ -112,6 +115,9 @@ public function applyAttributes(array $attrs): void if (isset($attrs['trailingIconButton'])) { $this->trailingIconButton($attrs['trailingIconButton']); } + if (isset($attrs['trailing-a11y-label']) || isset($attrs['trailingA11yLabel'])) { + $this->trailingA11yLabel($attrs['trailing-a11y-label'] ?? $attrs['trailingA11yLabel']); + } // Optional `:trailing-menu` attribute attaches a tap-to-open menu // to the row's trailing slot. The renderer wraps the existing @@ -198,6 +204,8 @@ public function applyAttributes(array $attrs): void if (isset($attrs['trailing-badges']) && is_array($attrs['trailing-badges'])) { $this->trailingBadges($attrs['trailing-badges']); } + + $this->applyA11yAttributes($attrs); } /** @@ -406,6 +414,20 @@ public function trailingIconButton( return $this; } + /** + * Screen-reader label for the trailing icon button. Icon buttons have + * no visible text, so without this VoiceOver / TalkBack announce + * nothing useful. Stored as `trailing_a11y_label` alongside the other + * `trailing_*` props; iOS applies it as the button's + * `accessibilityLabel`, Android as its `contentDescription`. + */ + public function trailingA11yLabel(string $value): static + { + $this->listItemProps['trailing_a11y_label'] = $value; + + return $this; + } + // ── Callbacks ──────────────────────────────────── public function onLeadingChange(string $method): static diff --git a/src/Elements/ListSection.php b/src/Elements/ListSection.php index fc071dd..d1ace66 100644 --- a/src/Elements/ListSection.php +++ b/src/Elements/ListSection.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * A grouped section inside a {@see NativeList}. Renders as a SwiftUI @@ -18,6 +19,8 @@ */ class ListSection extends Element { + use HasA11y; + protected string $type = 'list_section'; protected array $sectionProps = []; @@ -41,6 +44,8 @@ public function applyAttributes(array $attrs): void if (isset($attrs['footer'])) { $this->footer($attrs['footer']); } + + $this->applyA11yAttributes($attrs); } public function header(string $text): static diff --git a/src/Elements/Modal.php b/src/Elements/Modal.php index 265f583..cccb07d 100644 --- a/src/Elements/Modal.php +++ b/src/Elements/Modal.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Modal — full-screen overlay presentation. @@ -17,6 +18,8 @@ */ class Modal extends Element { + use HasA11y; + protected string $type = 'modal'; /** @var array */ @@ -35,9 +38,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['dismissible']) || isset($attrs['dismissable'])) { $this->dismissible((bool) ($attrs['dismissible'] ?? $attrs['dismissable'])); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); } public function visible(bool $value = true): static @@ -54,13 +55,6 @@ public function dismissible(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->modalProps['a11y_label'] = $value; - - return $this; - } - public function onDismiss(string $method): static { $this->dismissCallback = $method; diff --git a/src/Elements/NativeDrawer.php b/src/Elements/NativeDrawer.php index e655443..4c3464d 100644 --- a/src/Elements/NativeDrawer.php +++ b/src/Elements/NativeDrawer.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Sentinel element produced by the native-ui layout-drawer chrome contributor @@ -22,6 +23,8 @@ */ class NativeDrawer extends Element { + use HasA11y; + protected string $type = 'native_drawer'; protected array $props = ['mode' => 'modal']; @@ -39,6 +42,8 @@ public function applyAttributes(array $attrs): void if (isset($attrs['width']) && $attrs['width'] !== null) { $this->props['width'] = (int) $attrs['width']; } + + $this->applyA11yAttributes($attrs); } protected function resolveProps(CallbackRegistry $registry): array diff --git a/src/Elements/NativeList.php b/src/Elements/NativeList.php index 4bc2e8a..5768eb2 100644 --- a/src/Elements/NativeList.php +++ b/src/Elements/NativeList.php @@ -4,9 +4,12 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; class NativeList extends Element { + use HasA11y; + protected string $type = 'list'; protected array $listProps = []; @@ -43,6 +46,8 @@ public function applyAttributes(array $attrs): void if (isset($attrs['on-end-reached']) || isset($attrs['onEndReached'])) { $this->onEndReached($attrs['on-end-reached'] ?? $attrs['onEndReached']); } + + $this->applyA11yAttributes($attrs); } public function horizontal(bool $value = true): static diff --git a/src/Elements/NativeVirtualList.php b/src/Elements/NativeVirtualList.php index 0cd2374..66d4197 100644 --- a/src/Elements/NativeVirtualList.php +++ b/src/Elements/NativeVirtualList.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Windowed list. Native renders a LazyColumn/List with `count` logical @@ -19,6 +20,8 @@ */ class NativeVirtualList extends Element { + use HasA11y; + protected string $type = 'virtual_list'; protected array $listProps = []; @@ -54,6 +57,8 @@ public function applyAttributes(array $attrs): void if ($cb !== null) { $this->windowCallback = $cb; } + + $this->applyA11yAttributes($attrs); } protected function resolveProps(CallbackRegistry $registry): array diff --git a/src/Elements/ProgressBar.php b/src/Elements/ProgressBar.php index 4d350a4..4dadd1b 100644 --- a/src/Elements/ProgressBar.php +++ b/src/Elements/ProgressBar.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Linear progress bar. Value in [0.0, 1.0]. Omit `value` for indeterminate @@ -14,6 +15,8 @@ */ class ProgressBar extends Element { + use HasA11y; + protected string $type = 'progress_bar'; /** @var array */ @@ -35,9 +38,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['color'])) { $this->color((string) $attrs['color']); } if (isset($attrs['trackColor'])) { $this->trackColor((string) $attrs['trackColor']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); } /** @@ -74,13 +75,6 @@ public function indeterminate(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->progressBarProps['a11y_label'] = $value; - - return $this; - } - protected function resolveProps(CallbackRegistry $registry): array { return $this->progressBarProps; diff --git a/src/Elements/Radio.php b/src/Elements/Radio.php index 185ba74..e1ba270 100644 --- a/src/Elements/Radio.php +++ b/src/Elements/Radio.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Radio — child of ``. Declares a value + optional label. @@ -13,6 +14,8 @@ */ class Radio extends Element { + use HasA11y; + protected string $type = 'radio'; /** @var array */ @@ -36,6 +39,8 @@ public function applyAttributes(array $attrs): void if (isset($attrs['radioValue'])) { $this->radioProps['value'] = (string) $attrs['radioValue']; } if (isset($attrs['label'])) { $this->label($attrs['label']); } if (! empty($attrs['disabled'])) { $this->disabled(); } + + $this->applyA11yAttributes($attrs); } public function label(string $label): static diff --git a/src/Elements/RadioGroup.php b/src/Elements/RadioGroup.php index 31cad89..488bb1c 100644 --- a/src/Elements/RadioGroup.php +++ b/src/Elements/RadioGroup.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * RadioGroup — single-choice container holding `` children. @@ -15,6 +16,8 @@ */ class RadioGroup extends Element { + use HasA11y; + protected string $type = 'radio_group'; /** @var array */ @@ -36,12 +39,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['label'])) { $this->label($attrs['label']); } if (! empty($attrs['disabled'])) { $this->disabled(); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { $this->syncMode($attrs['sync-mode'] ?? $attrs['syncMode']); @@ -69,20 +67,6 @@ public function disabled(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->radioGroupProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->radioGroupProps['a11y_hint'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->radioGroupProps['sync_mode'] = $mode; diff --git a/src/Elements/Select.php b/src/Elements/Select.php index a7bcc8f..a38f2a8 100644 --- a/src/Elements/Select.php +++ b/src/Elements/Select.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Select — single-choice dropdown over a string option list. @@ -16,6 +17,8 @@ */ class Select extends Element { + use HasA11y; + protected string $type = 'select'; /** @var array */ @@ -36,12 +39,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['options'])) { $this->options((array) $attrs['options']); } if (! empty($attrs['disabled'])) { $this->disabled(); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { $this->syncMode($attrs['sync-mode'] ?? $attrs['syncMode']); @@ -84,20 +82,6 @@ public function disabled(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->selectProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->selectProps['a11y_hint'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->selectProps['sync_mode'] = $mode; diff --git a/src/Elements/Slider.php b/src/Elements/Slider.php index 5d75edb..687e764 100644 --- a/src/Elements/Slider.php +++ b/src/Elements/Slider.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Slider — continuous (or stepped) value selection. @@ -18,6 +19,8 @@ */ class Slider extends Element { + use HasA11y; + protected string $type = 'slider'; /** @var array */ @@ -40,12 +43,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['size'])) { $this->size($attrs['size']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); // Sync mode + debounce, normally populated by the `native:model` // directive expansion. Can also be set manually. @@ -99,20 +97,6 @@ public function size(string $value): static return $this; } - public function a11yLabel(string $value): static - { - $this->sliderProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->sliderProps['a11y_hint'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->sliderProps['sync_mode'] = $mode; diff --git a/src/Elements/Tab.php b/src/Elements/Tab.php index 93bb97e..d9bbcff 100644 --- a/src/Elements/Tab.php +++ b/src/Elements/Tab.php @@ -5,6 +5,7 @@ use Native\Mobile\Concerns\HasPlatformIcon; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Tab — child of ``. Declares a label + optional leading icon. @@ -12,6 +13,7 @@ */ class Tab extends Element { + use HasA11y; use HasPlatformIcon; protected string $type = 'tab'; @@ -34,9 +36,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['label'])) { $this->tabProps['label'] = $attrs['label']; } if (isset($attrs['icon'])) { $this->icon($attrs['icon']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->tabProps['a11y_label'] = $attrs['a11y-label'] ?? $attrs['a11yLabel']; - } + $this->applyA11yAttributes($attrs); } protected function resolveProps(CallbackRegistry $registry): array diff --git a/src/Elements/TabRow.php b/src/Elements/TabRow.php index 5dcf18d..dcf70fa 100644 --- a/src/Elements/TabRow.php +++ b/src/Elements/TabRow.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * TabRow — horizontal segmented selector. Holds `` children; the @@ -13,6 +14,8 @@ */ class TabRow extends Element { + use HasA11y; + protected string $type = 'tab_row'; /** @var array */ @@ -36,9 +39,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['selectedIndex'])) { $this->selectedIndex((int) $attrs['selectedIndex']); } if (isset($attrs['selected-index'])){ $this->selectedIndex((int) $attrs['selected-index']); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } + $this->applyA11yAttributes($attrs); if (isset($attrs['sync-mode']) || isset($attrs['syncMode'])) { $this->syncMode($attrs['sync-mode'] ?? $attrs['syncMode']); @@ -54,13 +55,6 @@ public function selectedIndex(int $index): static return $this; } - public function a11yLabel(string $value): static - { - $this->tabRowProps['a11y_label'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->tabRowProps['sync_mode'] = $mode; diff --git a/src/Elements/Toggle.php b/src/Elements/Toggle.php index 244cdca..062ca99 100644 --- a/src/Elements/Toggle.php +++ b/src/Elements/Toggle.php @@ -4,6 +4,7 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Nativephp\NativeUi\Concerns\HasA11y; /** * Toggle — binary on/off switch. Renders as a SwiftUI `Toggle` on iOS and an @@ -20,6 +21,8 @@ */ class Toggle extends Element { + use HasA11y; + protected string $type = 'toggle'; /** @var array */ @@ -38,12 +41,7 @@ public function applyAttributes(array $attrs): void if (isset($attrs['value'])) { $this->value((bool) $attrs['value']); } if (! empty($attrs['disabled'])) { $this->disabled(); } - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } + $this->applyA11yAttributes($attrs); // Sync mode + debounce, normally populated by the `native:model` // directive expansion. @@ -76,20 +74,6 @@ public function disabled(bool $value = true): static return $this; } - public function a11yLabel(string $value): static - { - $this->toggleProps['a11y_label'] = $value; - - return $this; - } - - public function a11yHint(string $value): static - { - $this->toggleProps['a11y_hint'] = $value; - - return $this; - } - public function syncMode(string $mode): static { $this->toggleProps['sync_mode'] = $mode; diff --git a/tests/A11yTest.php b/tests/A11yTest.php new file mode 100644 index 0000000..caab2cb --- /dev/null +++ b/tests/A11yTest.php @@ -0,0 +1,171 @@ +a11yLabel('Save changes') + ->a11yHint('Saves the current document') + ->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('button'); + expect($tree['props']['label'])->toBe('Save'); + expect($tree['props']['a11y_label'])->toBe('Save changes'); + expect($tree['props']['a11y_hint'])->toBe('Saves the current document'); +}); + +it('serializes fluent a11y props on a toggle', function () { + $tree = Toggle::make() + ->value(true) + ->a11yLabel('Notifications') + ->a11yHint('Toggles push notifications') + ->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('toggle'); + expect($tree['props']['value'])->toBeTrue(); + expect($tree['props']['a11y_label'])->toBe('Notifications'); + expect($tree['props']['a11y_hint'])->toBe('Toggles push notifications'); +}); + +it('serializes fluent a11y props on an icon', function () { + // Icons are decorative (silent to screen readers) by default; giving + // one a label makes it announced. + $tree = Icon::make('trash') + ->a11yLabel('Delete') + ->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('icon'); + expect($tree['props']['a11y_label'])->toBe('Delete'); +}); + +it('serializes fluent a11y props on a tab through the tab row tree', function () { + // Tabs serialize via the normal node path as children of the row, so + // the trait's extraProps must survive full-tree serialization. + $tree = TabRow::make( + Tab::make('Home')->a11yLabel('Home tab')->a11yHint('Shows the home feed'), + Tab::make('Search'), + )->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('tab_row'); + expect($tree['children'])->toHaveCount(2); + expect($tree['children'][0]['props']['label'])->toBe('Home'); + expect($tree['children'][0]['props']['a11y_label'])->toBe('Home tab'); + expect($tree['children'][0]['props']['a11y_hint'])->toBe('Shows the home feed'); + expect($tree['children'][1]['props'])->not->toHaveKey('a11y_label'); +}); + +it('serializes fluent a11y props on a list item', function () { + $tree = ListItem::make('Inbox') + ->a11yLabel('Inbox, 3 unread') + ->a11yHint('Opens the inbox') + ->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('list_item'); + expect($tree['props']['headline'])->toBe('Inbox'); + expect($tree['props']['a11y_label'])->toBe('Inbox, 3 unread'); + expect($tree['props']['a11y_hint'])->toBe('Opens the inbox'); +}); + +it('hydrates kebab-case a11y attributes from blade', function () { + NativeElementCollector::leaf('button', [ + 'label' => 'Save', + 'a11y-label' => 'Save changes', + 'a11y-hint' => 'Saves the current document', + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['a11y_label'])->toBe('Save changes'); + expect($tree['props']['a11y_hint'])->toBe('Saves the current document'); +}); + +it('hydrates camelCase a11y attributes from blade', function () { + NativeElementCollector::leaf('toggle', [ + 'value' => false, + 'a11yLabel' => 'Dark mode', + 'a11yHint' => 'Switches the app theme', + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['a11y_label'])->toBe('Dark mode'); + expect($tree['props']['a11y_hint'])->toBe('Switches the app theme'); +}); + +it('hydrates a11y attributes on elements that previously had none', function () { + NativeElementCollector::leaf('icon', [ + 'name' => 'trash', + 'a11y-label' => 'Delete', + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('icon'); + expect($tree['props']['a11y_label'])->toBe('Delete'); +}); + +it('serializes the trailing a11y label on a list item', function () { + $tree = ListItem::make('Song title') + ->trailingIconButton('ellipsis') + ->trailingA11yLabel('More options') + ->toArray(new CallbackRegistry); + + expect($tree['props']['trailing_type'])->toBe('icon_button'); + expect($tree['props']['trailing_value'])->toBe('ellipsis'); + expect($tree['props']['trailing_a11y_label'])->toBe('More options'); +}); + +it('hydrates the trailing a11y label from both blade spellings', function () { + NativeElementCollector::leaf('list_item', [ + 'headline' => 'Song title', + 'trailingIconButton' => 'ellipsis', + 'trailing-a11y-label' => 'More options', + ]); + + $kebab = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + NativeElementCollector::reset(); + NativeElementCollector::leaf('list_item', [ + 'headline' => 'Song title', + 'trailingIconButton' => 'ellipsis', + 'trailingA11yLabel' => 'More options', + ]); + + $camel = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($kebab['props']['trailing_a11y_label'])->toBe('More options'); + expect($camel['props']['trailing_a11y_label'])->toBe('More options'); +}); From 15d8ae42cd9bfd6ea9fc65efb84256eee0c30494 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 4 Jul 2026 14:30:47 -0400 Subject: [PATCH 2/6] refactor(a11y): HasA11y moved to nativephp/mobile core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trait now lives on the base Edge Element (mobile-air 8797c71), so every element inherits a11yLabel()/a11yHint()/applyA11yAttributes() without per-element adoption — the plugin-local trait and its 24 use statements are gone. The applyAttributes() hydration calls stay for paths that bypass the collector's central attr pass. Wire output is unchanged. Co-Authored-By: Claude Fable 5 --- src/Concerns/HasA11y.php | 64 ------------------------------ src/Elements/ActivityIndicator.php | 2 - src/Elements/Badge.php | 2 - src/Elements/BaseTextInput.php | 2 - src/Elements/BottomSheet.php | 2 - src/Elements/Button.php | 2 - src/Elements/ButtonGroup.php | 2 - src/Elements/Carousel.php | 2 - src/Elements/Checkbox.php | 2 - src/Elements/Chip.php | 2 - src/Elements/Icon.php | 2 - src/Elements/ListItem.php | 2 - src/Elements/ListSection.php | 2 - src/Elements/Modal.php | 2 - src/Elements/NativeDrawer.php | 2 - src/Elements/NativeList.php | 2 - src/Elements/NativeVirtualList.php | 2 - src/Elements/ProgressBar.php | 2 - src/Elements/Radio.php | 2 - src/Elements/RadioGroup.php | 2 - src/Elements/Select.php | 2 - src/Elements/Slider.php | 2 - src/Elements/Tab.php | 2 - src/Elements/TabRow.php | 2 - src/Elements/Toggle.php | 2 - 25 files changed, 112 deletions(-) delete mode 100644 src/Concerns/HasA11y.php diff --git a/src/Concerns/HasA11y.php b/src/Concerns/HasA11y.php deleted file mode 100644 index 5410f38..0000000 --- a/src/Concerns/HasA11y.php +++ /dev/null @@ -1,64 +0,0 @@ -setProp('a11y_label', $value); - - return $this; - } - - /** - * Supplementary usage guidance, read after the label. - * iOS: `accessibilityHint`. Android: appended to `contentDescription`. - */ - public function a11yHint(string $value): static - { - $this->setProp('a11y_hint', $value); - - return $this; - } - - /** - * Hydrate the a11y props from Blade attributes. The Blade precompiler - * keeps attribute names verbatim, so both the kebab-case spelling - * (`a11y-label` / `a11y-hint`) and the camelCase one (`a11yLabel` / - * `a11yHint`) must be accepted. Call this from `applyAttributes()`. - */ - protected function applyA11yAttributes(array $attrs): void - { - if (isset($attrs['a11y-label']) || isset($attrs['a11yLabel'])) { - $this->a11yLabel($attrs['a11y-label'] ?? $attrs['a11yLabel']); - } - if (isset($attrs['a11y-hint']) || isset($attrs['a11yHint'])) { - $this->a11yHint($attrs['a11y-hint'] ?? $attrs['a11yHint']); - } - } -} diff --git a/src/Elements/ActivityIndicator.php b/src/Elements/ActivityIndicator.php index 1b780fd..6f21f4f 100644 --- a/src/Elements/ActivityIndicator.php +++ b/src/Elements/ActivityIndicator.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Circular activity indicator (spinner). Always indeterminate — use @@ -15,7 +14,6 @@ */ class ActivityIndicator extends Element { - use HasA11y; protected string $type = 'activity_indicator'; diff --git a/src/Elements/Badge.php b/src/Elements/Badge.php index 08371c7..7d2c4dc 100644 --- a/src/Elements/Badge.php +++ b/src/Elements/Badge.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Badge — small count or text marker, typically used as an overlay on nav @@ -20,7 +19,6 @@ */ class Badge extends Element { - use HasA11y; protected string $type = 'badge'; diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 7a96d14..c54b61f 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -7,7 +7,6 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Shared base for the text input variants (`outlined-text-input`, @@ -31,7 +30,6 @@ */ abstract class BaseTextInput extends Element { - use HasA11y; /** @var array */ protected array $inputProps = []; diff --git a/src/Elements/BottomSheet.php b/src/Elements/BottomSheet.php index 6329682..3f129bc 100644 --- a/src/Elements/BottomSheet.php +++ b/src/Elements/BottomSheet.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Bottom sheet — dismissible panel that slides up from the bottom. Visibility @@ -16,7 +15,6 @@ */ class BottomSheet extends Element { - use HasA11y; protected string $type = 'bottom_sheet'; diff --git a/src/Elements/Button.php b/src/Elements/Button.php index 011b6b0..6c4b25e 100644 --- a/src/Elements/Button.php +++ b/src/Elements/Button.php @@ -8,7 +8,6 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Native button. @@ -34,7 +33,6 @@ */ class Button extends Element { - use HasA11y; protected string $type = 'button'; diff --git a/src/Elements/ButtonGroup.php b/src/Elements/ButtonGroup.php index 6bd7d84..56f7068 100644 --- a/src/Elements/ButtonGroup.php +++ b/src/Elements/ButtonGroup.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * ButtonGroup — segmented single-choice selector. Options are a flat array of @@ -17,7 +16,6 @@ */ class ButtonGroup extends Element { - use HasA11y; protected string $type = 'button_group'; diff --git a/src/Elements/Carousel.php b/src/Elements/Carousel.php index 44ee3dd..45c0f20 100644 --- a/src/Elements/Carousel.php +++ b/src/Elements/Carousel.php @@ -4,11 +4,9 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; class Carousel extends Element { - use HasA11y; protected string $type = 'carousel'; diff --git a/src/Elements/Checkbox.php b/src/Elements/Checkbox.php index 60eaca1..85cea3d 100644 --- a/src/Elements/Checkbox.php +++ b/src/Elements/Checkbox.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Checkbox — binary tick/untick with optional inline label. @@ -17,7 +16,6 @@ */ class Checkbox extends Element { - use HasA11y; protected string $type = 'checkbox'; diff --git a/src/Elements/Chip.php b/src/Elements/Chip.php index 6316eee..a756def 100644 --- a/src/Elements/Chip.php +++ b/src/Elements/Chip.php @@ -5,7 +5,6 @@ use Native\Mobile\Concerns\HasPlatformIcon; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Chip — compact selectable tag. Bool selected state, optional leading icon @@ -16,7 +15,6 @@ */ class Chip extends Element { - use HasA11y; use HasPlatformIcon; protected string $type = 'chip'; diff --git a/src/Elements/Icon.php b/src/Elements/Icon.php index 85cdf85..e66ea41 100644 --- a/src/Elements/Icon.php +++ b/src/Elements/Icon.php @@ -7,11 +7,9 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; -use Nativephp\NativeUi\Concerns\HasA11y; class Icon extends Element { - use HasA11y; protected string $type = 'icon'; diff --git a/src/Elements/ListItem.php b/src/Elements/ListItem.php index 217fd38..52bb61a 100644 --- a/src/Elements/ListItem.php +++ b/src/Elements/ListItem.php @@ -8,11 +8,9 @@ use Native\Mobile\Icon\AndroidSymbol; use Native\Mobile\Icon\IconResolver; use Native\Mobile\Icon\IosSymbol; -use Nativephp\NativeUi\Concerns\HasA11y; class ListItem extends Element { - use HasA11y; protected string $type = 'list_item'; diff --git a/src/Elements/ListSection.php b/src/Elements/ListSection.php index d1ace66..df400bc 100644 --- a/src/Elements/ListSection.php +++ b/src/Elements/ListSection.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * A grouped section inside a {@see NativeList}. Renders as a SwiftUI @@ -19,7 +18,6 @@ */ class ListSection extends Element { - use HasA11y; protected string $type = 'list_section'; diff --git a/src/Elements/Modal.php b/src/Elements/Modal.php index cccb07d..3d4e55d 100644 --- a/src/Elements/Modal.php +++ b/src/Elements/Modal.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Modal — full-screen overlay presentation. @@ -18,7 +17,6 @@ */ class Modal extends Element { - use HasA11y; protected string $type = 'modal'; diff --git a/src/Elements/NativeDrawer.php b/src/Elements/NativeDrawer.php index 4c3464d..2979d04 100644 --- a/src/Elements/NativeDrawer.php +++ b/src/Elements/NativeDrawer.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Sentinel element produced by the native-ui layout-drawer chrome contributor @@ -23,7 +22,6 @@ */ class NativeDrawer extends Element { - use HasA11y; protected string $type = 'native_drawer'; diff --git a/src/Elements/NativeList.php b/src/Elements/NativeList.php index 5768eb2..c777709 100644 --- a/src/Elements/NativeList.php +++ b/src/Elements/NativeList.php @@ -4,11 +4,9 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; class NativeList extends Element { - use HasA11y; protected string $type = 'list'; diff --git a/src/Elements/NativeVirtualList.php b/src/Elements/NativeVirtualList.php index 66d4197..5a29d5a 100644 --- a/src/Elements/NativeVirtualList.php +++ b/src/Elements/NativeVirtualList.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Windowed list. Native renders a LazyColumn/List with `count` logical @@ -20,7 +19,6 @@ */ class NativeVirtualList extends Element { - use HasA11y; protected string $type = 'virtual_list'; diff --git a/src/Elements/ProgressBar.php b/src/Elements/ProgressBar.php index 4dadd1b..6b68809 100644 --- a/src/Elements/ProgressBar.php +++ b/src/Elements/ProgressBar.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Linear progress bar. Value in [0.0, 1.0]. Omit `value` for indeterminate @@ -15,7 +14,6 @@ */ class ProgressBar extends Element { - use HasA11y; protected string $type = 'progress_bar'; diff --git a/src/Elements/Radio.php b/src/Elements/Radio.php index e1ba270..67d3602 100644 --- a/src/Elements/Radio.php +++ b/src/Elements/Radio.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Radio — child of ``. Declares a value + optional label. @@ -14,7 +13,6 @@ */ class Radio extends Element { - use HasA11y; protected string $type = 'radio'; diff --git a/src/Elements/RadioGroup.php b/src/Elements/RadioGroup.php index 488bb1c..693b7e1 100644 --- a/src/Elements/RadioGroup.php +++ b/src/Elements/RadioGroup.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * RadioGroup — single-choice container holding `` children. @@ -16,7 +15,6 @@ */ class RadioGroup extends Element { - use HasA11y; protected string $type = 'radio_group'; diff --git a/src/Elements/Select.php b/src/Elements/Select.php index a38f2a8..7226819 100644 --- a/src/Elements/Select.php +++ b/src/Elements/Select.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Select — single-choice dropdown over a string option list. @@ -17,7 +16,6 @@ */ class Select extends Element { - use HasA11y; protected string $type = 'select'; diff --git a/src/Elements/Slider.php b/src/Elements/Slider.php index 687e764..7bfc250 100644 --- a/src/Elements/Slider.php +++ b/src/Elements/Slider.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Slider — continuous (or stepped) value selection. @@ -19,7 +18,6 @@ */ class Slider extends Element { - use HasA11y; protected string $type = 'slider'; diff --git a/src/Elements/Tab.php b/src/Elements/Tab.php index d9bbcff..f5f1f25 100644 --- a/src/Elements/Tab.php +++ b/src/Elements/Tab.php @@ -5,7 +5,6 @@ use Native\Mobile\Concerns\HasPlatformIcon; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Tab — child of ``. Declares a label + optional leading icon. @@ -13,7 +12,6 @@ */ class Tab extends Element { - use HasA11y; use HasPlatformIcon; protected string $type = 'tab'; diff --git a/src/Elements/TabRow.php b/src/Elements/TabRow.php index dcf70fa..e27e3c1 100644 --- a/src/Elements/TabRow.php +++ b/src/Elements/TabRow.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * TabRow — horizontal segmented selector. Holds `` children; the @@ -14,7 +13,6 @@ */ class TabRow extends Element { - use HasA11y; protected string $type = 'tab_row'; diff --git a/src/Elements/Toggle.php b/src/Elements/Toggle.php index 062ca99..90bc8d5 100644 --- a/src/Elements/Toggle.php +++ b/src/Elements/Toggle.php @@ -4,7 +4,6 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; -use Nativephp\NativeUi\Concerns\HasA11y; /** * Toggle — binary on/off switch. Renders as a SwiftUI `Toggle` on iOS and an @@ -21,7 +20,6 @@ */ class Toggle extends Element { - use HasA11y; protected string $type = 'toggle'; From 4e48bd31ce43cd8ea2b79ebc2d6b55de07b6a7a2 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 4 Jul 2026 16:01:49 -0400 Subject: [PATCH 3/6] docs(android): explain the unbounded-scroller fallback in LazyGridRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion comment for 2913a0c — records WHY the non-lazy chunked grid exists (Compose lazy grids throw under infinite max-height constraints where SwiftUI's LazyVGrid sizes to content). Co-Authored-By: Claude Fable 5 --- resources/android/ContainerRenderers.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/android/ContainerRenderers.kt b/resources/android/ContainerRenderers.kt index 2b48f78..6f1a71a 100644 --- a/resources/android/ContainerRenderers.kt +++ b/resources/android/ContainerRenderers.kt @@ -208,6 +208,12 @@ object CanvasRenderer { * without paying for them at first paint. Use in place of a manually * chunked row-of-row grid whenever the cell count is large enough to * matter. + * + * When the main axis is UNBOUNDED (the grid sits inside a scroll_view / + * scrollable column), Compose's lazy grids throw ("measured with an + * infinity maximum height constraints") where SwiftUI's LazyVGrid just + * sizes to content — so we fall back to a non-lazy chunked grid that + * wraps its content. Same visual result, no virtualization. */ object LazyGridRenderer { @Composable From 95e56c6679b4c77ae78b7a5489947604529a03e3 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 4 Jul 2026 16:01:49 -0400 Subject: [PATCH 4/6] =?UTF-8?q?chore(icons):=20refresh=20Material=20catalo?= =?UTF-8?q?g=20snapshot=20(2234=20=E2=86=92=202122,=20pure=20subset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-fetched from Google's metadata endpoint; verified against the previous font-extracted set: 112 stale names removed, ZERO added — every remaining name still renders with the bundled ligature font. Re-added the provenance caution to _meta: web refreshes are only safe as a subset of the font's ligature names unless the bundled font upgrades in the same change. Apps regenerate enums via native-ui:generate-icons; removed names surface as compile errors at their call sites (the intended failure mode). Co-Authored-By: Claude Fable 5 --- resources/icons/material-icons.json | 117 +--------------------------- 1 file changed, 3 insertions(+), 114 deletions(-) diff --git a/resources/icons/material-icons.json b/resources/icons/material-icons.json index 27633c2..41f3c85 100644 --- a/resources/icons/material-icons.json +++ b/resources/icons/material-icons.json @@ -1,8 +1,9 @@ { "_meta": { - "source": "Extracted from the GSUB ligature table of the bundled Android runtime font (mobile-air resources/androidstudio/.../res/font/material_icons.ttf, Material Icons v1.017) \u2014 the authoritative set of names that actually render. Names from Google's metadata endpoint that are missing from the font render as literal text, so do not refresh from the web without also upgrading the bundled font.", + "source": "Auto-fetched from https://fonts.google.com/metadata/icons", "format": "Material Icons font ligature names (snake_case). Same name renders in both Filled and Outlined fonts.", - "updated": "2026-07-02" + "updated": "2026-07-04", + "caution": "Names must exist in the bundled Android runtime font's ligature table (mobile-air res/font/material_icons.ttf) or they render as literal text. Refreshing from the web is only safe when the result is a SUBSET of the font's names (this refresh was: -112/+0) or the bundled font is upgraded in the same change." }, "symbols": [ "10k", @@ -85,7 +86,6 @@ "add_alert", "add_box", "add_business", - "add_call", "add_card", "add_chart", "add_circle", @@ -111,7 +111,6 @@ "adf_scanner", "adjust", "admin_panel_settings", - "adobe", "ads_click", "agriculture", "air", @@ -128,8 +127,6 @@ "airplane_ticket", "airplanemode_active", "airplanemode_inactive", - "airplanemode_off", - "airplanemode_on", "airplay", "airport_shuttle", "alarm", @@ -148,7 +145,6 @@ "all_out", "alt_route", "alternate_email", - "amp_stories", "analytics", "anchor", "android", @@ -161,7 +157,6 @@ "app_registration", "app_settings_alt", "app_shortcut", - "apple", "approval", "apps", "apps_outage", @@ -191,7 +186,6 @@ "aspect_ratio", "assessment", "assignment", - "assignment_add", "assignment_ind", "assignment_late", "assignment_return", @@ -200,7 +194,6 @@ "assist_walker", "assistant", "assistant_direction", - "assistant_navigation", "assistant_photo", "assured_workload", "atm", @@ -237,7 +230,6 @@ "balcony", "ballot", "bar_chart", - "barcode_reader", "batch_prediction", "bathroom", "bathtub", @@ -270,7 +262,6 @@ "blinds", "blinds_closed", "block", - "block_flipped", "bloodtype", "bluetooth", "bluetooth_audio", @@ -289,7 +280,6 @@ "bookmark_add", "bookmark_added", "bookmark_border", - "bookmark_outline", "bookmark_remove", "bookmarks", "border_all", @@ -431,7 +421,6 @@ "cloud_queue", "cloud_sync", "cloud_upload", - "cloudy_snowing", "co2", "co_present", "code", @@ -453,7 +442,6 @@ "compost", "compress", "computer", - "confirmation_num", "confirmation_number", "connect_without_contact", "connected_tv", @@ -476,7 +464,6 @@ "control_camera", "control_point", "control_point_duplicate", - "conveyor_belt", "cookie", "copy_all", "copyright", @@ -560,7 +547,6 @@ "devices", "devices_fold", "devices_other", - "dew_point", "dialer_sip", "dialpad", "diamond", @@ -575,14 +561,12 @@ "directions_bus_filled", "directions_car", "directions_car_filled", - "directions_ferry", "directions_off", "directions_railway", "directions_railway_filled", "directions_run", "directions_subway", "directions_subway_filled", - "directions_train", "directions_transit", "directions_transit_filled", "directions_walk", @@ -590,13 +574,11 @@ "disabled_by_default", "disabled_visible", "disc_full", - "discord", "discount", "display_settings", "diversity_1", "diversity_2", "diversity_3", - "dnd_forwardslash", "dns", "do_disturb", "do_disturb_alt", @@ -636,7 +618,6 @@ "draw", "drive_eta", "drive_file_move", - "drive_file_move_outline", "drive_file_move_rtl", "drive_file_rename_outline", "drive_folder_upload", @@ -650,20 +631,17 @@ "earbuds", "earbuds_battery", "east", - "eco", "edgesensor_high", "edgesensor_low", "edit", "edit_attributes", "edit_calendar", - "edit_document", "edit_location", "edit_location_alt", "edit_note", "edit_notifications", "edit_off", "edit_road", - "edit_square", "egg", "egg_alt", "eject", @@ -684,7 +662,6 @@ "emergency_share", "emoji_emotions", "emoji_events", - "emoji_flags", "emoji_food_beverage", "emoji_nature", "emoji_objects", @@ -693,7 +670,6 @@ "emoji_transportation", "energy_savings_leaf", "engineering", - "enhance_photo_translate", "enhanced_encryption", "equalizer", "error", @@ -718,8 +694,6 @@ "explore", "explore_off", "exposure", - "exposure_minus_1", - "exposure_minus_2", "exposure_neg_1", "exposure_neg_2", "exposure_plus_1", @@ -735,7 +709,6 @@ "face_6", "face_retouching_natural", "face_retouching_off", - "facebook", "fact_check", "factory", "family_restroom", @@ -744,7 +717,6 @@ "fastfood", "favorite", "favorite_border", - "favorite_outline", "fax", "featured_play_list", "featured_video", @@ -765,7 +737,6 @@ "file_open", "file_present", "file_upload", - "file_upload_off", "filter", "filter_1", "filter_2", @@ -785,7 +756,6 @@ "filter_frames", "filter_hdr", "filter_list", - "filter_list_alt", "filter_list_off", "filter_none", "filter_tilt_shift", @@ -794,7 +764,6 @@ "find_replace", "fingerprint", "fire_extinguisher", - "fire_hydrant", "fire_hydrant_alt", "fire_truck", "fireplace", @@ -822,12 +791,10 @@ "flip_to_back", "flip_to_front", "flood", - "flourescent", "fluorescent", "flutter_dash", "fmd_bad", "fmd_good", - "foggy", "folder", "folder_copy", "folder_delete", @@ -843,7 +810,6 @@ "forest", "fork_left", "fork_right", - "forklift", "format_align_center", "format_align_justify", "format_align_left", @@ -858,7 +824,6 @@ "format_italic", "format_line_spacing", "format_list_bulleted", - "format_list_bulleted_add", "format_list_numbered", "format_list_numbered_rtl", "format_overline", @@ -869,7 +834,6 @@ "format_strikethrough", "format_textdirection_l_to_r", "format_textdirection_r_to_l", - "format_underline", "format_underlined", "fort", "forum", @@ -882,7 +846,6 @@ "free_breakfast", "free_cancellation", "front_hand", - "front_loader", "fullscreen", "fullscreen_exit", "functions", @@ -900,7 +863,6 @@ "gif_box", "girl", "gite", - "goat", "golf_course", "gpp_bad", "gpp_good", @@ -968,7 +930,6 @@ "highlight", "highlight_alt", "highlight_off", - "highlight_remove", "hiking", "history", "history_edu", @@ -978,7 +939,6 @@ "hls_off", "holiday_village", "home", - "home_filled", "home_max", "home_mini", "home_repair_service", @@ -1018,7 +978,6 @@ "incomplete_circle", "indeterminate_check_box", "info", - "info_outline", "input", "insert_chart", "insert_chart_outlined", @@ -1039,7 +998,6 @@ "inventory_2", "invert_colors", "invert_colors_off", - "invert_colors_on", "ios_share", "iron", "iso", @@ -1060,16 +1018,13 @@ "keyboard_arrow_up", "keyboard_backspace", "keyboard_capslock", - "keyboard_command", "keyboard_command_key", - "keyboard_control", "keyboard_control_key", "keyboard_double_arrow_down", "keyboard_double_arrow_left", "keyboard_double_arrow_right", "keyboard_double_arrow_up", "keyboard_hide", - "keyboard_option", "keyboard_option_key", "keyboard_return", "keyboard_tab", @@ -1079,9 +1034,7 @@ "kitesurfing", "label", "label_important", - "label_important_outline", "label_off", - "label_outline", "lan", "landscape", "landslide", @@ -1097,7 +1050,6 @@ "leaderboard", "leak_add", "leak_remove", - "leave_bags_at_home", "legend_toggle", "lens", "lens_blur", @@ -1109,7 +1061,6 @@ "light_mode", "lightbulb", "lightbulb_circle", - "lightbulb_outline", "line_axis", "line_style", "line_weight", @@ -1126,7 +1077,6 @@ "local_activity", "local_airport", "local_atm", - "local_attraction", "local_bar", "local_cafe", "local_car_wash", @@ -1151,23 +1101,18 @@ "local_play", "local_police", "local_post_office", - "local_print_shop", "local_printshop", - "local_restaurant", "local_see", "local_shipping", "local_taxi", "location_city", "location_disabled", - "location_history", "location_off", "location_on", - "location_pin", "location_searching", "lock", "lock_clock", "lock_open", - "lock_outline", "lock_person", "lock_reset", "login", @@ -1230,8 +1175,6 @@ "merge", "merge_type", "message", - "messenger", - "messenger_outline", "mic", "mic_external_off", "mic_external_on", @@ -1278,27 +1221,21 @@ "motion_photos_on", "motion_photos_pause", "motion_photos_paused", - "motorcycle", "mouse", "move_down", "move_to_inbox", "move_up", "movie", "movie_creation", - "movie_edit", "movie_filter", "moving", "mp", "multiline_chart", "multiple_stop", - "multitrack_audio", "museum", "music_note", "music_off", "music_video", - "my_library_add", - "my_library_books", - "my_library_music", "my_location", "nat", "nature", @@ -1342,7 +1279,6 @@ "no_food", "no_luggage", "no_meals", - "no_meals_ouline", "no_meeting_room", "no_photography", "no_sim", @@ -1368,10 +1304,7 @@ "notifications_active", "notifications_none", "notifications_off", - "notifications_on", "notifications_paused", - "now_wallpaper", - "now_widgets", "numbers", "offline_bolt", "offline_pin", @@ -1387,11 +1320,9 @@ "open_in_new_off", "open_with", "other_houses", - "outbond", "outbound", "outbox", "outdoor_grill", - "outgoing_mail", "outlet", "outlined_flag", "output", @@ -1400,12 +1331,10 @@ "pageview", "paid", "palette", - "pallet", "pan_tool", "pan_tool_alt", "panorama", "panorama_fish_eye", - "panorama_fisheye", "panorama_horizontal", "panorama_horizontal_select", "panorama_photosphere", @@ -1426,7 +1355,6 @@ "pause_presentation", "payment", "payments", - "paypal", "pedal_bike", "pending", "pending_actions", @@ -1436,10 +1364,8 @@ "people_outline", "percent", "perm_camera_mic", - "perm_contact_cal", "perm_contact_calendar", "perm_data_setting", - "perm_device_info", "perm_device_information", "perm_identity", "perm_media", @@ -1473,7 +1399,6 @@ "phone_disabled", "phone_enabled", "phone_forwarded", - "phone_in_talk", "phone_iphone", "phone_locked", "phone_missed", @@ -1502,7 +1427,6 @@ "picture_in_picture_alt", "pie_chart", "pie_chart_outline", - "pie_chart_outlined", "pin", "pin_drop", "pin_end", @@ -1514,7 +1438,6 @@ "plagiarism", "play_arrow", "play_circle", - "play_circle_fill", "play_circle_filled", "play_circle_outline", "play_disabled", @@ -1574,17 +1497,12 @@ "queue", "queue_music", "queue_play_next", - "quick_contacts_dialer", - "quick_contacts_mail", "quickreply", "quiz", - "quora", "r_mobiledata", "radar", "radio", "radio_button_checked", - "radio_button_off", - "radio_button_on", "radio_button_unchecked", "railway_alert", "ramen_dining", @@ -1595,7 +1513,6 @@ "raw_on", "read_more", "real_estate_agent", - "rebase_edit", "receipt", "receipt_long", "recent_actors", @@ -1603,7 +1520,6 @@ "record_voice_over", "rectangle", "recycling", - "reddit", "redeem", "redo", "reduce_capacity", @@ -1742,7 +1658,6 @@ "settings_bluetooth", "settings_brightness", "settings_cell", - "settings_display", "settings_ethernet", "settings_input_antenna", "settings_input_component", @@ -1759,15 +1674,12 @@ "severe_cold", "shape_line", "share", - "share_arrival_time", "share_location", - "shelves", "shield", "shield_moon", "shop", "shop_2", "shop_two", - "shopify", "shopping_bag", "shopping_basket", "shopping_cart", @@ -1822,11 +1734,9 @@ "smoking_rooms", "sms", "sms_failed", - "snapchat", "snippet_folder", "snooze", "snowboarding", - "snowing", "snowmobile", "snowshoeing", "soap", @@ -1921,8 +1831,6 @@ "subtitles_off", "subway", "summarize", - "sunny", - "sunny_snowing", "superscript", "supervised_user_circle", "supervisor_account", @@ -1934,7 +1842,6 @@ "swap_horiz", "swap_horizontal_circle", "swap_vert", - "swap_vert_circle", "swap_vertical_circle", "swipe", "swipe_down", @@ -1964,7 +1871,6 @@ "system_security_update_warning", "system_update", "system_update_alt", - "system_update_tv", "tab", "tab_unselected", "table_bar", @@ -1983,7 +1889,6 @@ "task", "task_alt", "taxi_alert", - "telegram", "temple_buddhist", "temple_hindu", "terminal", @@ -2013,7 +1918,6 @@ "thumb_up_off_alt", "thumbs_up_down", "thunderstorm", - "tiktok", "time_to_leave", "timelapse", "timeline", @@ -2051,10 +1955,8 @@ "travel_explore", "trending_down", "trending_flat", - "trending_neutral", "trending_up", "trip_origin", - "trolley", "troubleshoot", "try", "tsunami", @@ -2109,7 +2011,6 @@ "video_camera_back", "video_camera_front", "video_chat", - "video_collection", "video_file", "video_label", "video_library", @@ -2123,7 +2024,6 @@ "view_array", "view_carousel", "view_column", - "view_comfortable", "view_comfy", "view_comfy_alt", "view_compact", @@ -2149,7 +2049,6 @@ "voicemail", "volcano", "volume_down", - "volume_down_alt", "volume_mute", "volume_off", "volume_up", @@ -2159,9 +2058,6 @@ "vpn_lock", "vrpano", "wallet", - "wallet_giftcard", - "wallet_membership", - "wallet_travel", "wallpaper", "warehouse", "warning", @@ -2182,7 +2078,6 @@ "wb_iridescent", "wb_shade", "wb_sunny", - "wb_twighlight", "wb_twilight", "wc", "web", @@ -2190,7 +2085,6 @@ "web_asset_off", "web_stories", "webhook", - "wechat", "weekend", "west", "whatshot", @@ -2213,23 +2107,18 @@ "wifi_protected_setup", "wifi_tethering", "wifi_tethering_error", - "wifi_tethering_error_rounded", "wifi_tethering_off", "wind_power", "window", "wine_bar", "woman", "woman_2", - "woo_commerce", - "wordpress", "work", "work_history", "work_off", "work_outline", "workspace_premium", "workspaces", - "workspaces_filled", - "workspaces_outline", "wrap_text", "wrong_location", "wysiwyg", From f9bd1d2638f993c31c7873ca77484aab46179a48 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sun, 5 Jul 2026 16:23:34 -0400 Subject: [PATCH 5/6] ci: add PHPUnit config and tests workflow Adds phpunit.xml and a GitHub Actions tests workflow, and ignores the .phpunit.cache/ directory. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 75 +++++++++++++++++++++++++++++++++++++ .gitignore | 1 + phpunit.xml | 13 +++++++ 3 files changed, 89 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 phpunit.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..08605d1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,75 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + pest: + name: Plugin tests (Pest) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + # Unlike the leaf plugins, this suite exercises nativephp/mobile at + # runtime (the Edge element collector, elements, facades), so CI must + # actually install it. Until v4 is released we build against the + # mobile-air `element` branch (composer constraint `dev-element`); + # after release, replace the require below with a normal `^4.0` pin + # and drop the VCS repository + stability tweaks. + # + # NativePHP/mobile-air is private, so cloning is authenticated with + # the MOBILE_AIR_TOKEN repo secret (a PAT / fine-grained token with + # read access to NativePHP/mobile-air). If the secret is absent the + # step is skipped so public forks still get a clear failure. + - name: Configure private repo auth + env: + TOKEN: ${{ secrets.MOBILE_AIR_TOKEN }} + run: | + if [ -n "$TOKEN" ]; then + composer config --global github-oauth.github.com "$TOKEN" + else + echo "MOBILE_AIR_TOKEN not set — assuming public access to nativephp/mobile" + fi + + - name: Install dependencies (mobile-air @ dev-element) + run: | + composer config minimum-stability dev + composer config prefer-stable true + composer config repositories.mobile-air vcs https://github.com/NativePHP/mobile-air.git + composer require "nativephp/mobile:dev-element" --no-interaction --with-all-dependencies + + - name: Run tests + run: ./vendor/bin/pest --colors=always + + - name: Lint PHP sources + run: find src -name '*.php' -print0 | xargs -0 -n1 php -l + + swift-parse: + name: Swift syntax check + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + # Full compilation needs the NativePHP Xcode project (BridgeFunction, + # NativeUINode etc. live in the app target), so CI runs a parse-only + # pass: it catches syntax errors — the realistic failure mode for a + # plugin PR — without resolving types. Kotlin has no parse-only mode + # (kotlinc always typechecks), so Android syntax is covered by the + # consuming app build instead. + - name: Parse Swift sources + run: | + set -e + for f in resources/ios/*.swift; do + echo "parsing $f" + xcrun swiftc -parse "$f" + done diff --git a/.gitignore b/.gitignore index 8e5dd86..283d9da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ composer.lock .phpunit.result.cache +.phpunit.cache/ .DS_Store diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..13ad511 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,13 @@ + + + + + tests + + + From e15a45d6e6570edb478ed855720da747e370fa44 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sun, 5 Jul 2026 16:26:43 -0400 Subject: [PATCH 6/6] ci: bump test PHP to 8.4 for endroid/qr-code dependency nativephp/mobile pulls in endroid/qr-code ^6.1.3, which requires PHP ^8.4, so the dependency chain could not resolve on 8.3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08605d1..a4a849a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,9 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + # 8.4+ is required: nativephp/mobile pulls in endroid/qr-code ^6.1.3, + # which needs PHP ^8.4, so the dependency chain won't resolve on 8.3. + php-version: '8.4' coverage: none # Unlike the leaf plugins, this suite exercises nativephp/mobile at