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
31 changes: 24 additions & 7 deletions resources/android/ButtonRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
Expand All @@ -43,10 +44,13 @@ import com.nativephp.plugins.native_ui.NativeUITokens
* - secondary → [FilledTonalButton] with theme.secondary / onSecondary
* - destructive → [Button] with theme.error / onError
* - ghost → [TextButton] with theme.primary as content color
* - any other → like primary, in the `tint_color` / `label_color` PHP sends
* for a variant that names a theme token (`variant="brand"`), or in
* primary when it sent none
*
* All colors come from [LocalNativeUITheme]. No per-instance color/radius/shadow
* overrides are honored — that's intentional (plan doc Model 3). For full visual
* control, use `<pressable>`.
* All colors come from the theme: [LocalNativeUITheme], or the theme token a
* variant names. No per-instance color/radius/shadow overrides are honored —
* that's intentional (plan doc Model 3). For full visual control, use `<pressable>`.
*/
object ButtonRenderer {
@Composable
Expand All @@ -71,8 +75,20 @@ object ButtonRenderer {
// rather than a CompositionLocal because nothing in the render tree
// currently provides one — the store is backed by `mutableStateOf`, so
// Compose recomposes automatically when PHP pushes a theme update.
val theme = if (isSystemInDarkTheme()) NativeUITheme.dark else NativeUITheme.light
val isDark = isSystemInDarkTheme()
val theme = if (isDark) NativeUITheme.dark else NativeUITheme.light
val metrics = sizeMetrics(size, theme)

// A variant that names a theme token arrives with that token's colours,
// which PHP resolves and sends as `tint_color` and `label_color`, each
// with its `dark_` shade when that differs. The built-ins send neither.
fun tokenColor(key: String, fallback: Color): Color {
val shade = if (isDark && p.has("dark_$key")) "dark_$key" else key

return if (p.has(shade)) Color(p.getColor(shade, 0)) else fallback
}
val tint = tokenColor("tint_color", theme.primary)
val onTint = if (p.has("tint_color")) tokenColor("label_color", theme.onPrimary) else theme.onPrimary
// Leading — button labels are single-line, so this is usually a no-op.
val lineHeight = nuiLineHeightUnit(p.getFloat("line_height_px", 0f), p.getFloat("line_height", 0f), metrics.textSize.value)

Expand Down Expand Up @@ -183,15 +199,16 @@ object ButtonRenderer {
content = { content() },
)

// "primary" (default) and any unknown value fall through to filled primary.
// "primary" (default), a theme token and any unknown value fall
// through to filled primary, in the token's colours if it sent them.
else -> Button(
onClick = onClick,
enabled = enabled,
modifier = buttonModifier,
contentPadding = metrics.contentPadding,
colors = ButtonDefaults.buttonColors(
containerColor = theme.primary,
contentColor = theme.onPrimary,
containerColor = tint,
contentColor = onTint,
disabledContainerColor = theme.surfaceVariant,
disabledContentColor = theme.onSurfaceVariant,
),
Expand Down
50 changes: 38 additions & 12 deletions resources/ios/NativeUIButtonRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import SwiftUI
/// - success → `.buttonStyle(.borderedProminent)` + `.tint(theme.success)`
/// - accent → `.buttonStyle(.borderedProminent)` + `.tint(theme.accent)`
/// - ghost → `.buttonStyle(.plain)` + `.foregroundStyle(theme.primary)`
/// - any other → like primary, in the `tint_color` / `label_color` PHP sends
/// for a variant that names a theme token (`variant="brand"`), or in
/// primary when it sent none
///
/// Tailwind `glass` family promotes the button to iOS 26's first-class
/// glass styles when available, falling back to bordered styles on older iOS.
Expand All @@ -36,9 +39,10 @@ import SwiftUI
/// `<button class="glass:prominent" variant="destructive">` reads as a
/// destructive-tinted prominent glass button on iOS 26+.
///
/// All colors come from the `\.nativeUITheme` environment. No per-instance
/// color/radius/shadow overrides are honored — that's intentional (plan doc
/// Model 3). For full visual control, use `<pressable>`.
/// All colors come from the theme: the `\.nativeUITheme` environment, or the
/// theme token a variant names. No per-instance color/radius/shadow overrides
/// are honored — that's intentional (plan doc Model 3). For full visual
/// control, use `<pressable>`.
struct NativeUIButtonRenderer: View {
let node: NativeUINode
@ObservedObject private var themeStore = NativeUITheme.shared
Expand Down Expand Up @@ -80,6 +84,7 @@ struct NativeUIButtonRenderer: View {

let metrics = sizeMetrics(for: size, theme: theme)
let enabled = !disabled && !loading
let token = tokenColors(p, theme: theme)

// Class-level text-size override. `text-xl` / `text-[28]` / etc.
// arrive as a `font_size` prop. When set (>0), it wins over the
Expand Down Expand Up @@ -136,6 +141,7 @@ struct NativeUIButtonRenderer: View {
action: action,
content: content,
variant: variant,
token: token,
theme: theme,
metrics: metrics,
enabled: enabled,
Expand All @@ -147,6 +153,7 @@ struct NativeUIButtonRenderer: View {
action: action,
content: content,
variant: variant,
token: token,
theme: theme,
metrics: metrics,
enabled: enabled,
Expand All @@ -156,6 +163,23 @@ struct NativeUIButtonRenderer: View {
}
}

/// The fill and label of a variant that names a theme token.
private typealias TokenColors = (tint: Color, onTint: Color)

/// A token variant's colours, which PHP resolves from the theme and sends
/// as `tint_color` and `label_color`, each with its `dark_` shade when that
/// differs. Nil for the built-in variants.
private func tokenColors(_ p: GenericProps, theme: NativeUITokens) -> TokenColors? {
guard p.has("tint_color") else { return nil }

func color(_ key: String) -> Color {
let shade = colorScheme == .dark && p.has("dark_" + key) ? "dark_" + key : key
return Color(argb: p.getColor(shade))
}

return (color("tint_color"), p.has("label_color") ? color("label_color") : theme.onPrimary)
}

// MARK: - Glass path (iOS 26 first-class, older iOS bordered fallback)

@ViewBuilder
Expand All @@ -166,14 +190,15 @@ struct NativeUIButtonRenderer: View {
action: @escaping () -> Void,
content: ButtonContent,
variant: String,
token: TokenColors?,
theme: NativeUITokens,
metrics: SizeMetrics,
enabled: Bool,
a11yLabel: String,
a11yHint: String
) -> some View {
let tint = tintForVariant(variant, theme: theme)
let onTint = onTintForVariant(variant, theme: theme)
let tint = tintForVariant(variant, token: token, theme: theme)
let onTint = onTintForVariant(variant, token: token, theme: theme)

// Note: `interactive` on a button is mostly redundant — the button
// already has built-in press feedback through SwiftUI's button styles.
Expand Down Expand Up @@ -244,6 +269,7 @@ struct NativeUIButtonRenderer: View {
action: @escaping () -> Void,
content: ButtonContent,
variant: String,
token: TokenColors?,
theme: NativeUITokens,
metrics: SizeMetrics,
enabled: Bool,
Expand Down Expand Up @@ -312,11 +338,11 @@ struct NativeUIButtonRenderer: View {
.modifier(A11yLabelModifier(label: a11yLabel))
.modifier(A11yHintModifier(hint: a11yHint))

default: // "primary" and any unknown value
default: // "primary", a theme token, and any unknown value
Button(action: action) { content.fillWidthIfRequested(node) }
.buttonStyle(.borderedProminent)
.tint(enabled ? theme.primary : theme.surfaceVariant)
.foregroundStyle(enabled ? theme.onPrimary : theme.onSurfaceVariant)
.tint(enabled ? (token?.tint ?? theme.primary) : theme.surfaceVariant)
.foregroundStyle(enabled ? (token?.onTint ?? theme.onPrimary) : theme.onSurfaceVariant)
.controlSize(metrics.controlSize)
.disabled(!enabled)
.modifier(A11yLabelModifier(label: a11yLabel))
Expand All @@ -326,25 +352,25 @@ struct NativeUIButtonRenderer: View {

// MARK: - Tint helpers (glass path only — variant path uses tints inline)

private func tintForVariant(_ variant: String, theme: NativeUITokens) -> Color {
private func tintForVariant(_ variant: String, token: TokenColors?, theme: NativeUITokens) -> Color {
switch variant {
case "secondary": return theme.secondary
case "destructive": return theme.destructive
case "success": return theme.success
case "accent": return theme.accent
case "ghost": return theme.primary
default: return theme.primary
default: return token?.tint ?? theme.primary
}
}

private func onTintForVariant(_ variant: String, theme: NativeUITokens) -> Color {
private func onTintForVariant(_ variant: String, token: TokenColors?, theme: NativeUITokens) -> Color {
switch variant {
case "secondary": return theme.onSecondary
case "destructive": return theme.onDestructive
case "success": return theme.onSuccess
case "accent": return theme.onAccent
case "ghost": return theme.primary
default: return theme.onPrimary
default: return token?.onTint ?? theme.onPrimary
}
}

Expand Down
50 changes: 48 additions & 2 deletions src/Elements/Button.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Native\Mobile\Icon\AndroidSymbol;
use Native\Mobile\Icon\IconResolver;
use Native\Mobile\Icon\IosSymbol;
use Native\Mobile\UI\Theme;

/**
* Native button.
Expand All @@ -16,7 +17,8 @@
* family on Android, SwiftUI `Button` with `buttonStyle(...)` on iOS.
*
* API shape (locked in plan doc, sections A/C/E):
* - `variant`: semantic vocabulary (primary | secondary | destructive | success | ghost)
* - `variant`: semantic vocabulary (primary | secondary | destructive | success | accent | ghost),
* or the name of any colour token in the theme
* - `size`: sm | md | lg
* - `disabled`, `loading`: state
* - `icon`, `icon-trailing`: optional icon names (leading/trailing)
Expand All @@ -33,6 +35,12 @@
*/
class Button extends Element
{
/**
* The variants each renderer draws from its own theme tokens. Any other
* variant that names a theme colour is sent with that colour.
*/
public const array Variants = ['primary', 'secondary', 'destructive', 'success', 'accent', 'ghost'];

protected string $type = 'button';

/** @var array<string, mixed> */
Expand Down Expand Up @@ -121,14 +129,52 @@ public function applyAttributes(array $attrs): void
}
}

/** primary | secondary | destructive | success | ghost. Default: primary. */
/**
* primary | secondary | destructive | success | accent | ghost, or the
* name of a colour token in the theme. Default: primary.
*
* A token variant fills the button like primary does, in that token's
* colour, with the label in its `on-` token (`variant="brand"` draws in
* `brand` and `on-brand`), or in `on-primary` when the theme has none.
* Both are sent in their light and dark shades. That lets an app pick a
* button's colour at runtime, say one per user, while the colours still
* live in the theme. A name the theme doesn't have draws as primary.
*/
public function variant(string $value): static
{
$this->buttonProps['variant'] = $value;

foreach (['tint_color', 'label_color'] as $prop) {
unset($this->buttonProps[$prop], $this->buttonProps['dark_'.$prop]);
}

// The label's colour can't be `on_tint`: the bridge sends any prop
// starting with `on_` as a callback.
if (! in_array($value, self::Variants, true) && is_string(Theme::get("light.{$value}"))) {
$this->tokenColor('tint_color', $value);
$this->tokenColor('label_color', is_string(Theme::get("light.on-{$value}")) ? "on-{$value}" : 'on-primary');
}

return $this;
}

/**
* Sends a theme token's light shade as `$prop`, and its dark shade as
* `dark_$prop` when that differs.
*/
protected function tokenColor(string $prop, string $token): void
{
$light = Theme::get("light.{$token}");
$dark = Theme::get("dark.{$token}");

if (is_string($light)) {
$this->buttonProps[$prop] = $light;
}
if (is_string($dark) && $dark !== $light) {
$this->buttonProps['dark_'.$prop] = $dark;
}
}

/** sm | md | lg. Default: md. */
public function size(string $value): static
{
Expand Down
Loading
Loading