Skip to content
Merged
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ public function handleNativeUICompleted($result, $id = null)
}
```

## Theming & Colors

Theme tokens live in `config/native-ui.php` (publish with
`php artisan vendor:publish --tag=native-ui-config`). Every authored color —
theme tokens, element color props, and arbitrary-value classes — accepts the
same grammar:

```php
'light' => [
'primary' => 'violet-600', // Tailwind palette name
'secondary' => 'fuchsia-500/70', // opacity modifier → tonal fill
'surface' => '#F8FAFC', // plain hex (#RGB / #RRGGBB)
'accent' => '#00AAA680', // CSS alpha hex (#RRGGBBAA)
],
```

Alpha hex is authored in CSS `#RRGGBBAA` order; the framework converts to the
native wire format. Dark mode is auto-derived from `light` (alpha preserved)
unless a `dark` block overrides specific tokens.

Disabled controls draw from the `surface-variant` (fill) and
`on-surface-variant` (label) tokens on both platforms — adjust those two
tokens to tune disabled contrast app-wide.

Icons accept platform enum overrides in Blade, matching the fluent API:

```blade
<native:icon :ios="Ios::House" :android="Android::Home" :size="24" />
```

## Accessibility

Every element accepts a screen-reader label and an optional hint, via Blade
Expand Down
10 changes: 9 additions & 1 deletion config/native-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
| "on-X" means "color of content placed ON a surface of color X"
| — i.e., text/icons on that background.
|
| Color tokens accept:
| - CSS hex: '#B91C1C', '#F00', or with alpha '#8B5CF680' (#RRGGBBAA)
| - Tailwind palette names: 'red-300', 'orange-800'
| - Opacity modifiers on either: 'red-300/20', '#8B5CF6/50'
|
| Dark mode is auto-derived from `light` when `dark` is not set. To opt
| into explicit dark tokens, fill out the `dark` block.
|
Expand Down Expand Up @@ -105,7 +110,10 @@
'font-xl' => 24,

// 'System' resolves to the platform default (San Francisco on iOS, Roboto on Android).
// Use a specific family name to load a custom font.
// Set a bundled font token to apply it app-wide — a file from your app's
// resources/fonts/ minus the extension (e.g. 'Inter-Regular'). Download one
// with `php artisan native:font Inter`. Per-element `font` attributes and
// font-serif / font-mono classes still win over this default.
'font-family' => 'System',
],

Expand Down
1 change: 1 addition & 0 deletions resources/android/BadgeRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ object BadgeRenderer {
Box(modifier = boxModifier, contentAlignment = Alignment.Center) {
Text(
text = text,
fontFamily = nuiDefaultFontFamily(),
color = fg,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
Expand Down
3 changes: 2 additions & 1 deletion resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ object BareTextInputRenderer {
val props = parseTextInputProps(node)
val isDark = isSystemInDarkTheme()
val theme = if (isDark) NativeUITheme.dark else NativeUITheme.light
val customFontFamily = if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null
val customFontFamily = (if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null)
?: nuiThemeDefaultFontFamily(LocalContext.current)
val lineHeight = nuiLineHeightUnit(props.lineHeightPx, props.lineHeight, props.textSize.toFloat())

// Per-instance color override. `color` is the light value;
Expand Down
2 changes: 1 addition & 1 deletion resources/android/ButtonGroupRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ object ButtonGroupRenderer {
selected = index == selectedIndex,
enabled = !disabled,
colors = colors,
label = { Text(label) },
label = { Text(label, fontFamily = nuiDefaultFontFamily()) },
)
}
}
Expand Down
28 changes: 20 additions & 8 deletions resources/android/ButtonRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ object ButtonRenderer {
val pressCb = p.getCallbackId("on_press").let { if (it != 0) it else node.onPress }
val hasMenu = p.getBool("has_menu")
val fontName = p.getString("font_name")
val customFontFamily = if (fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, fontName) else null
val customFontFamily = (if (fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, fontName) else null)
?: nuiThemeDefaultFontFamily(LocalContext.current)

// Read the active token set from the shared store. Using the singleton
// rather than a CompositionLocal because nothing in the render tree
Expand Down Expand Up @@ -112,19 +113,23 @@ object ButtonRenderer {
// inside a Box (with DropdownMenu) when `:menu` is attached.
val buttonByVariant: @Composable () -> Unit = {
when (variant) {
// Disabled state (all variants): `surface-variant` fill +
// `on-surface-variant` label from the theme, replacing M3's
// default onSurface-at-38% which read too faint. iOS uses the
// same token pair, so disabled looks identical cross-platform.
"secondary" -> FilledTonalButton(
onClick = onClick,
enabled = enabled,
modifier = buttonModifier,
contentPadding = metrics.contentPadding,
// Colors come from the theme config (native-ui.php), matching
// iOS which uses `theme.secondary` + `theme.onSecondary`. The
// tonal alpha softens the fill a touch vs a fully-saturated
// solid (what read as "too saturated") while staying dark
// enough for the `onSecondary` label to read.
// Solid fill of the secondary token, matching iOS. No
// renderer-imposed alpha: transparency belongs to the
// theme config (e.g. `'secondary' => 'fuchsia-500/70'`).
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = theme.secondary.copy(alpha = 0.7f),
containerColor = theme.secondary,
contentColor = theme.onSecondary,
disabledContainerColor = theme.surfaceVariant,
disabledContentColor = theme.onSurfaceVariant,
),
content = { content() },
)
Expand All @@ -137,6 +142,8 @@ object ButtonRenderer {
colors = ButtonDefaults.buttonColors(
containerColor = theme.destructive,
contentColor = theme.onDestructive,
disabledContainerColor = theme.surfaceVariant,
disabledContentColor = theme.onSurfaceVariant,
),
content = { content() },
)
Expand All @@ -146,7 +153,10 @@ object ButtonRenderer {
enabled = enabled,
modifier = buttonModifier,
contentPadding = metrics.contentPadding,
colors = ButtonDefaults.textButtonColors(contentColor = theme.primary),
colors = ButtonDefaults.textButtonColors(
contentColor = theme.primary,
disabledContentColor = theme.onSurfaceVariant,
),
content = { content() },
)

Expand All @@ -159,6 +169,8 @@ object ButtonRenderer {
colors = ButtonDefaults.buttonColors(
containerColor = theme.primary,
contentColor = theme.onPrimary,
disabledContainerColor = theme.surfaceVariant,
disabledContentColor = theme.onSurfaceVariant,
),
content = { content() },
)
Expand Down
2 changes: 1 addition & 1 deletion resources/android/CheckboxRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ object CheckboxRenderer {
colors = colors,
)
if (label.isNotEmpty()) {
Text(text = label, color = theme.onSurface)
Text(text = label, fontFamily = nuiDefaultFontFamily(), color = theme.onSurface)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion resources/android/ChipRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ object ChipRenderer {
NativeUIBridge.sendToggleChangeEvent(onChangeCb, node.id, new)
}
},
label = { Text(label) },
label = { Text(label, fontFamily = nuiDefaultFontFamily()) },
modifier = chipModifier,
enabled = !disabled,
leadingIcon = if (iconName.isNotEmpty()) {
Expand Down
1 change: 1 addition & 0 deletions resources/android/ContainerRenderers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ internal fun renderAttachedMenuItem(item: NativeUINode, onSelected: () -> Unit)
}
Text(
itemLabel,
fontFamily = nuiDefaultFontFamily(),
color = labelColor,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
Expand Down
3 changes: 2 additions & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ object FilledTextInputRenderer {
"lg" -> theme.fontLg
else -> theme.fontMd
}
val customFontFamily = if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null
val customFontFamily = (if (props.fontName.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, props.fontName) else null)
?: nuiThemeDefaultFontFamily(LocalContext.current)
val lineHeight = nuiLineHeightUnit(props.lineHeightPx, props.lineHeight, textSize.value)

TextField(
Expand Down
5 changes: 5 additions & 0 deletions resources/android/ListItemRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ object ListItemRenderer {
headlineContent = {
Text(
text = headline,
fontFamily = nuiDefaultFontFamily(),
color = if (headlineColor != 0) Color(headlineColor) else Color.Unspecified
)
},
Expand All @@ -123,6 +124,7 @@ object ListItemRenderer {
{
Text(
text = overline,
fontFamily = nuiDefaultFontFamily(),
color = if (overlineColor != 0) Color(overlineColor) else Color.Unspecified
)
}
Expand All @@ -131,6 +133,7 @@ object ListItemRenderer {
{
Text(
text = supporting,
fontFamily = nuiDefaultFontFamily(),
color = if (supportingColor != 0) Color(supportingColor) else Color.Unspecified
)
}
Expand Down Expand Up @@ -260,6 +263,7 @@ object ListItemRenderer {
) {
Text(
text = effectiveValue.take(2).uppercase(),
fontFamily = nuiDefaultFontFamily(),
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
Expand Down Expand Up @@ -398,6 +402,7 @@ object ListItemRenderer {
"text" -> {
Text(
text = effectiveValue,
fontFamily = nuiDefaultFontFamily(),
color = if (textColor != 0) Color(textColor) else Color.Unspecified
)
}
Expand Down
3 changes: 3 additions & 0 deletions resources/android/ListRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ private fun SectionHeader(text: String) {
) {
Text(
text = text.uppercase(),
fontFamily = nuiDefaultFontFamily(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
Expand Down Expand Up @@ -263,6 +264,7 @@ private fun SectionFooter(text: String, grouped: Boolean) {
) {
Text(
text = text,
fontFamily = nuiDefaultFontFamily(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
Expand Down Expand Up @@ -445,6 +447,7 @@ private fun SwipeActionButton(
if (action.label.isNotEmpty()) {
Text(
text = action.label,
fontFamily = nuiDefaultFontFamily(),
color = Color.White,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
fontSize = 12.sp,
Expand Down
24 changes: 22 additions & 2 deletions resources/android/NativeUIChromeInit.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.nativephp.plugins.native_ui

import android.content.Context
import androidx.compose.ui.platform.LocalContext
import com.nativephp.mobile.ui.NativeUIThemeProvider
import com.nativephp.mobile.ui.nativerender.NativeRootHostRegistry
import com.nativephp.plugins.native_ui.ui.NativeFloatingOverlayHost
import com.nativephp.plugins.native_ui.ui.NativeLayoutDrawerHost
import com.nativephp.plugins.native_ui.ui.NativeUIFontResolver
import com.nativephp.plugins.native_ui.ui.nuiThemeDefaultTypography

/**
* Init function invoked by the generated `PluginBridgeFunctionRegistration` in
Expand All @@ -14,9 +17,9 @@ import com.nativephp.plugins.native_ui.ui.NativeLayoutDrawerHost
* manifest under `android.init_function`.
*
* The `context` parameter is supplied by the generated call site
* (`registerNativeUIChrome(context)`); these registrations need none of it.
* (`registerNativeUIChrome(context)`); the font resolver captures its
* application context for asset-backed font loading.
*/
@Suppress("UNUSED_PARAMETER")
fun registerNativeUIChrome(context: Context) {
NativeRootHostRegistry.register("native-ui.drawer", consumes = "native_drawer") { root, content ->
val drawerNode = root.children.firstOrNull { it.type == "native_drawer" }
Expand All @@ -34,4 +37,21 @@ fun registerNativeUIChrome(context: Context) {
NativeUIThemeProvider.colorSchemeFor = { isDark ->
(if (isDark) NativeUITheme.dark else NativeUITheme.light).toMaterialColorScheme(isDark)
}

// Supply the app's typography when a default font is configured (the
// theme's `font-family` token). Every Material text style keeps its size /
// weight / spacing and swaps only the family, so core chrome — top bar
// titles, tab labels, dropdowns — renders in the app's font. Returns null
// (Material defaults) when the token is "System".
NativeUIThemeProvider.typographyFor = {
nuiThemeDefaultTypography(LocalContext.current)
}

// Resolve chrome font tokens (per-layout / per-bar `font_name` props on
// the root sentinels) for core's chrome renderers. Captures the
// application context — asset-backed fonts don't need an activity.
val appContext = context.applicationContext
NativeUIThemeProvider.fontFamilyResolver = { name ->
NativeUIFontResolver.resolve(appContext, name)
}
}
65 changes: 65 additions & 0 deletions resources/android/NativeUIFontResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package com.nativephp.plugins.native_ui.ui

import android.content.Context
import android.content.res.AssetManager
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.nativephp.plugins.native_ui.NativeUITheme

// Tailwind `leading`: line_height_px is an absolute sp target; line_height is a
// unitless multiplier of the font size. Unspecified leaves Compose's default.
Expand All @@ -16,6 +20,67 @@ internal fun nuiLineHeightUnit(px: Float, mult: Float, fontSize: Float): TextUni
else -> TextUnit.Unspecified
}

/**
* App-wide default font from the theme's `font-family` token, or null when
* unset / "System". Read inside composition: the theme store is backed by
* `mutableStateOf`, so a PHP theme push recomposes consumers. The token is
* identical for light and dark, so reading the light set suffices.
*
* Precedence at call sites: explicit `font_name` prop, then an explicit
* `font-serif`/`font-mono` class, then this, then the system font.
*/
internal fun nuiThemeDefaultFontFamily(context: Context): FontFamily? {
val family = NativeUITheme.light.fontFamily
if (family.isEmpty() || family == "System") return null

return NativeUIFontResolver.resolve(context, family)
}

/**
* Composable sugar for [nuiThemeDefaultFontFamily] — grabs LocalContext so a
* `Text(...)` site can just say `fontFamily = nuiDefaultFontFamily()`.
*/
@Composable
internal fun nuiDefaultFontFamily(): FontFamily? = nuiThemeDefaultFontFamily(LocalContext.current)

/**
* Per-node font: an explicit `font_name` prop wins, then the app-wide theme
* default, then null (system font).
*/
@Composable
internal fun nuiNodeFontFamily(name: String): FontFamily? =
(if (name.isNotEmpty()) NativeUIFontResolver.resolve(LocalContext.current, name) else null)
?: nuiDefaultFontFamily()

/**
* Material3 [Typography] carrying the app-wide default font on every text
* style (sizes / weights / spacing untouched), or null when no default font
* is configured. Fed to core chrome through NativeUIThemeProvider.typographyFor
* so top bars, tab labels, and dropdowns render in the app's font.
*/
fun nuiThemeDefaultTypography(context: Context): Typography? {
val family = nuiThemeDefaultFontFamily(context) ?: return null

val base = Typography()
return Typography(
displayLarge = base.displayLarge.copy(fontFamily = family),
displayMedium = base.displayMedium.copy(fontFamily = family),
displaySmall = base.displaySmall.copy(fontFamily = family),
headlineLarge = base.headlineLarge.copy(fontFamily = family),
headlineMedium = base.headlineMedium.copy(fontFamily = family),
headlineSmall = base.headlineSmall.copy(fontFamily = family),
titleLarge = base.titleLarge.copy(fontFamily = family),
titleMedium = base.titleMedium.copy(fontFamily = family),
titleSmall = base.titleSmall.copy(fontFamily = family),
bodyLarge = base.bodyLarge.copy(fontFamily = family),
bodyMedium = base.bodyMedium.copy(fontFamily = family),
bodySmall = base.bodySmall.copy(fontFamily = family),
labelLarge = base.labelLarge.copy(fontFamily = family),
labelMedium = base.labelMedium.copy(fontFamily = family),
labelSmall = base.labelSmall.copy(fontFamily = family),
)
}

/**
* Resolves a custom-font token (a font file's basename, e.g. "Inter-Bold") to a
* Compose [FontFamily] loaded from the app's `assets/fonts/`. Fonts land there
Expand Down
Loading
Loading