diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..a4a849a
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,77 @@
+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:
+ # 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
+ # 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/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/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..13ad511
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ tests
+
+
+
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/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
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/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",
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/Elements/ActivityIndicator.php b/src/Elements/ActivityIndicator.php
index 2241f49..6f21f4f 100644
--- a/src/Elements/ActivityIndicator.php
+++ b/src/Elements/ActivityIndicator.php
@@ -14,6 +14,7 @@
*/
class ActivityIndicator extends Element
{
+
protected string $type = 'activity_indicator';
/** @var array */
@@ -29,9 +30,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 +58,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..7d2c4dc 100644
--- a/src/Elements/Badge.php
+++ b/src/Elements/Badge.php
@@ -19,6 +19,7 @@
*/
class Badge extends Element
{
+
protected string $type = 'badge';
/** @var array */
@@ -41,9 +42,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 +66,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..c54b61f 100644
--- a/src/Elements/BaseTextInput.php
+++ b/src/Elements/BaseTextInput.php
@@ -30,6 +30,7 @@
*/
abstract class BaseTextInput extends Element
{
+
/** @var array */
protected array $inputProps = [];
@@ -86,12 +87,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 +251,7 @@ public function trailingIcon(
return $this;
}
- // ── Size + a11y ──────────────────────────────────────────────────────────
+ // ── Size ─────────────────────────────────────────────────────────────────
/** sm | md | lg. Default: md. */
public function size(string $value): static
@@ -265,20 +261,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..3f129bc 100644
--- a/src/Elements/BottomSheet.php
+++ b/src/Elements/BottomSheet.php
@@ -15,6 +15,7 @@
*/
class BottomSheet extends Element
{
+
protected string $type = 'bottom_sheet';
/** @var array */
@@ -32,9 +33,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 +56,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..6c4b25e 100644
--- a/src/Elements/Button.php
+++ b/src/Elements/Button.php
@@ -33,6 +33,7 @@
*/
class Button extends Element
{
+
protected string $type = 'button';
/** @var array */
@@ -74,12 +75,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 +161,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..56f7068 100644
--- a/src/Elements/ButtonGroup.php
+++ b/src/Elements/ButtonGroup.php
@@ -16,6 +16,7 @@
*/
class ButtonGroup extends Element
{
+
protected string $type = 'button_group';
/** @var array */
@@ -36,9 +37,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 +66,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..45c0f20 100644
--- a/src/Elements/Carousel.php
+++ b/src/Elements/Carousel.php
@@ -7,6 +7,7 @@
class Carousel extends Element
{
+
protected string $type = 'carousel';
protected array $carouselProps = [];
@@ -30,6 +31,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..85cea3d 100644
--- a/src/Elements/Checkbox.php
+++ b/src/Elements/Checkbox.php
@@ -16,6 +16,7 @@
*/
class Checkbox extends Element
{
+
protected string $type = 'checkbox';
/** @var array */
@@ -34,12 +35,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 +66,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..a756def 100644
--- a/src/Elements/Chip.php
+++ b/src/Elements/Chip.php
@@ -49,12 +49,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 +79,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..e66ea41 100644
--- a/src/Elements/Icon.php
+++ b/src/Elements/Icon.php
@@ -10,6 +10,7 @@
class Icon extends Element
{
+
protected string $type = 'icon';
protected array $iconProps = [];
@@ -40,6 +41,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..52bb61a 100644
--- a/src/Elements/ListItem.php
+++ b/src/Elements/ListItem.php
@@ -11,6 +11,7 @@
class ListItem extends Element
{
+
protected string $type = 'list_item';
protected array $listItemProps = [];
@@ -112,6 +113,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 +202,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 +412,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..df400bc 100644
--- a/src/Elements/ListSection.php
+++ b/src/Elements/ListSection.php
@@ -18,6 +18,7 @@
*/
class ListSection extends Element
{
+
protected string $type = 'list_section';
protected array $sectionProps = [];
@@ -41,6 +42,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..3d4e55d 100644
--- a/src/Elements/Modal.php
+++ b/src/Elements/Modal.php
@@ -17,6 +17,7 @@
*/
class Modal extends Element
{
+
protected string $type = 'modal';
/** @var array */
@@ -35,9 +36,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 +53,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..2979d04 100644
--- a/src/Elements/NativeDrawer.php
+++ b/src/Elements/NativeDrawer.php
@@ -22,6 +22,7 @@
*/
class NativeDrawer extends Element
{
+
protected string $type = 'native_drawer';
protected array $props = ['mode' => 'modal'];
@@ -39,6 +40,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..c777709 100644
--- a/src/Elements/NativeList.php
+++ b/src/Elements/NativeList.php
@@ -7,6 +7,7 @@
class NativeList extends Element
{
+
protected string $type = 'list';
protected array $listProps = [];
@@ -43,6 +44,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..5a29d5a 100644
--- a/src/Elements/NativeVirtualList.php
+++ b/src/Elements/NativeVirtualList.php
@@ -19,6 +19,7 @@
*/
class NativeVirtualList extends Element
{
+
protected string $type = 'virtual_list';
protected array $listProps = [];
@@ -54,6 +55,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..6b68809 100644
--- a/src/Elements/ProgressBar.php
+++ b/src/Elements/ProgressBar.php
@@ -14,6 +14,7 @@
*/
class ProgressBar extends Element
{
+
protected string $type = 'progress_bar';
/** @var array */
@@ -35,9 +36,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 +73,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..67d3602 100644
--- a/src/Elements/Radio.php
+++ b/src/Elements/Radio.php
@@ -13,6 +13,7 @@
*/
class Radio extends Element
{
+
protected string $type = 'radio';
/** @var array */
@@ -36,6 +37,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..693b7e1 100644
--- a/src/Elements/RadioGroup.php
+++ b/src/Elements/RadioGroup.php
@@ -15,6 +15,7 @@
*/
class RadioGroup extends Element
{
+
protected string $type = 'radio_group';
/** @var array */
@@ -36,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']);
@@ -69,20 +65,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..7226819 100644
--- a/src/Elements/Select.php
+++ b/src/Elements/Select.php
@@ -16,6 +16,7 @@
*/
class Select extends Element
{
+
protected string $type = 'select';
/** @var array */
@@ -36,12 +37,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 +80,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..7bfc250 100644
--- a/src/Elements/Slider.php
+++ b/src/Elements/Slider.php
@@ -18,6 +18,7 @@
*/
class Slider extends Element
{
+
protected string $type = 'slider';
/** @var array */
@@ -40,12 +41,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 +95,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..f5f1f25 100644
--- a/src/Elements/Tab.php
+++ b/src/Elements/Tab.php
@@ -34,9 +34,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..e27e3c1 100644
--- a/src/Elements/TabRow.php
+++ b/src/Elements/TabRow.php
@@ -13,6 +13,7 @@
*/
class TabRow extends Element
{
+
protected string $type = 'tab_row';
/** @var array */
@@ -36,9 +37,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 +53,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..90bc8d5 100644
--- a/src/Elements/Toggle.php
+++ b/src/Elements/Toggle.php
@@ -20,6 +20,7 @@
*/
class Toggle extends Element
{
+
protected string $type = 'toggle';
/** @var array */
@@ -38,12 +39,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 +72,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');
+});