Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/vendor/
composer.lock
.phpunit.result.cache
.phpunit.cache/
.DS_Store
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<native:button icon="trash" a11y-label="Delete draft" a11y-hint="Deletes the draft permanently" @press="deleteDraft" />
```

```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
9 changes: 6 additions & 3 deletions config/native-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand All @@ -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.
Expand All @@ -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',
],

Expand Down
13 changes: 13 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Plugin">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
9 changes: 8 additions & 1 deletion resources/android/ActivityIndicatorRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions resources/android/ButtonRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
6 changes: 4 additions & 2 deletions resources/android/CarouselRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
Expand All @@ -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))
Expand Down
35 changes: 21 additions & 14 deletions resources/android/CheckboxRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
7 changes: 1 addition & 6 deletions resources/android/ChipRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions resources/android/ContainerRenderers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion resources/android/GestureAreaRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ },
Expand Down
Loading
Loading