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
32 changes: 25 additions & 7 deletions docs/BLAZOR_HYBRID.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,32 @@ shellui-native init --yes
shellui-native add button dialog
```

Run `shellui init` first. Both libraries have a `Button` (and many other components with the same
names), so they can't share a folder and namespace: when `shellui-native init` finds `shellui.json`,
it puts the native components in `Components/Native/`, namespace `YourApp.Components.Native`, and
leaves `Components/UI/` to ShellUI Blazor.

If ShellUI Native was set up first, run `shellui-native init --force` after `shellui init`, reinstall
your native components with `shellui-native add <names> --force`, and delete the old native `.cs`
files from `Components/UI/`. The folder is the `ComponentsPath` setting in `shellui-native.json`;
the namespace follows it.

## File Structure

With both libraries installed:

```
YourHybridApp/
├── Components/
│ ├── UI/ # ShellUI Native (MAUI)
│ ├── UI/ # ShellUI Blazor (YourApp.Components.UI)
│ │ ├── Button.razor
│ │ └── ...
│ ├── Native/ # ShellUI Native (YourApp.Components.Native)
│ │ ├── Button.cs
│ │ ├── Variants/
│ │ │ └── ButtonVariants.cs
│ │ └── Shell.cs
│ ├── Layout/ # Your Blazor layout (MainLayout.razor)
│ └── Pages/ # Your Blazor pages
│ └── Home.razor
├── wwwroot/
Expand All @@ -96,15 +110,19 @@ YourHybridApp/

### MainPage.xaml (Native MAUI)
```xml
<ContentPage xmlns:ui="clr-namespace:YourApp.Components.UI">
<Grid>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:components="clr-namespace:YourApp.Components"
xmlns:ui="clr-namespace:YourApp.Components.Native"
x:Class="YourApp.MainPage">
<Grid RowDefinitions="Auto,*">
<!-- Native header -->
<ui:Button Grid.Row="0" Variant="Primary" Text="Native Button" />
<ui:Button Grid.Row="0" Text="Native Button" />

<!-- Blazor content -->
<BlazorWebView Grid.Row="1" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Routes}" />
<RootComponent Selector="#app" ComponentType="{x:Type components:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</Grid>
Expand All @@ -117,7 +135,7 @@ YourHybridApp/

<div class="p-4">
<!-- This uses ShellUI Blazor components -->
<Button Variant="ButtonVariant.Primary">Blazor Button</Button>
<Button>Blazor Button</Button>

<Card>
<CardHeader>
Expand All @@ -136,7 +154,7 @@ YourHybridApp/

2. **Add Native as Needed** - Only add ShellUI Native when you need truly native controls.

3. **Keep Separate** - The two libraries have different file paths (`Components/UI/` vs Blazor components) and configs (`shellui-native.json` vs `shellui.json`).
3. **Keep Separate** - The two libraries have their own folders and namespaces (`Components/UI/` for Blazor, `Components/Native/` for native) and configs (`shellui.json` vs `shellui-native.json`).

4. **Consistent Design** - Both libraries share the same design tokens, so your app will look consistent even when mixing native and web components.

Expand Down
26 changes: 22 additions & 4 deletions docs/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ your background so the splash-to-app transition doesn't flash the template's pur

---

## Change events

Events that report a new value or selection (`Toggled`, `CheckedChanged`, `ValueChanged`,
`SelectedIndexChanged`, `SelectedValueChanged`, `PageChanged`, `DateChanged`, `TimeChanged`,
`ItemToggled` and the like) fire only for user input: a click, tap, swipe or key. Setting the
property from code or a binding updates the control without raising them, like Radix's
`onValueChange`, so filling a form from data or syncing a sidebar with the router doesn't look
like the user did it. To see every change, bind the property or watch it (`PropertyChanged` on
MAUI, `GetObservable` on Avalonia). The same goes for methods meant for code: `MultiSelect.Toggle`,
`TagInput.Add` / `Remove`, `DataTable.ClearSelection`, `DateRangePicker.Clear`, `Stepper.Next` /
`Previous`, `Carousel.Next` / `Previous` (and auto-play), `Accordion.Toggle`, `Collapsible.Toggle`.

These still report every change: text fields' `TextChanged` (the platform's own event),
`OpenChanged` / `IsOpenChanged` on overlays, `ThemeChanged`, `FileUpload.FilesChanged` (only the
file picker changes it) and `Sidebar.IsCollapsedChanged` (only code collapses a sidebar).

---

## Avalonia

Every component on this page has an Avalonia version, installed by the same CLI commands
Expand Down Expand Up @@ -665,7 +683,7 @@ Typography with size, weight and color variants.
shellui-native add label
```

**Properties:** `Text`, `Size` (Xs…Xxxl), `Weight` (Light…Bold), `Variant` (Default, Muted, Destructive, Success, Warning)
**Properties:** `Text`, `Size` (Xs…Xxxl), `Weight` (Light…Bold), `Variant` (Default, Muted, Destructive, Success, Warning), `HorizontalTextAlignment` and `LineBreakMode` (MAUI; `WordWrap` by default). On Avalonia `ShellLabel` is a `TextBlock`, so `TextAlignment` and `TextWrapping` do the same.

```xml
<ui:ShellLabel Text="This username is already taken." Size="Sm" Variant="Destructive" />
Expand Down Expand Up @@ -1478,7 +1496,7 @@ they take their colors from the theme: `Chart1` to `Chart5`, in order. Every cha
a tooltip under the pointer (or the finger) and a grow-in animation.

```bash
shellui-native add bar-chart line-chart area-chart pie-chart radar-chart radial-chart
shellui-native add bar-chart line-chart area-chart pie-chart donut-chart radar-chart radial-chart
```

Each chart installs `chart`, the shared core. The chart colors are new theme tokens: a project
Expand Down Expand Up @@ -1537,7 +1555,7 @@ A line per series with the space under it filled. **Properties:** `IsSmooth` (tr
### Pie Chart and Donut Chart
The first series' values as slices, clockwise from twelve o'clock, named by `Labels` and colored
from the chart colors in order. The tooltip shows a slice's value and its share. `DonutChart` is
a pie with a hole. **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees
a pie with a hole, and its own component (`shellui-native add donut-chart`, which brings `pie-chart`). **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees
between slices; 1), `ShowLabels` (the value outside each slice), `CenterValue`, `CenterLabel`
(text in a donut's hole)

Expand Down Expand Up @@ -1601,7 +1619,7 @@ shellui-native add drawer
shellui-native add sheet
```

**Properties:** `Open` (also `SetOpen(bool)`) · **Events:** `OpenChanged` · Drawer/Sheet: `Side` (Left, Right, Top, Bottom)
**Properties:** `Open` (also `SetOpen(bool)`) · **Events:** `OpenChanged` · Drawer/Sheet: `Side` (Left, Right, Top, Bottom) · `SheetContent.MaxPanelWidth`: how wide a left or right sheet gets (384 by default, shadcn's `sm:max-w-sm`; never more than 75% of the window)

```xml
<ui:Dialog x:Name="EditDialog">
Expand Down
4 changes: 2 additions & 2 deletions docs/DEVELOPMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni

| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#21 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content |
| `feat/charts` | `main` | **Active** | chart core plus bar, line, area, pie / donut, radar and radial charts for MAUI and Avalonia (114 registry entries, 111 CLI targets, 80 families) |
| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#23 merged since (charts in #23) | 114 registry entries (111 CLI targets, 80 families), each with MAUI and Avalonia content; 115 / 112 / 81 with `donut-chart` on `fix/pre-release-issues` |
| `fix/pre-release-issues` | `main` | **Active** | Issues before `0.2.0-alpha.1`: change events report user input only (#24), sheet `MaxPanelWidth` (#26), `ShellLabel` alignment and line break (#27), native components beside ShellUI Blazor (#28), `donut-chart` as its own component (as in ShellUI), did-you-mean, a dependency audit test |

---

Expand Down
3 changes: 2 additions & 1 deletion docs/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ shellui-native init --yes

This will:
- Detect your project type (MAUI, Avalonia, WinUI, or WPF — see [ARCHITECTURE.md § Platform Detection](./ARCHITECTURE.md#platform-detection))
- Create the `Components/UI/` folder
- Create the `Components/UI/` folder (`Components/Native/` when the project also has ShellUI for
Blazor, see [BLAZOR_HYBRID.md](./BLAZOR_HYBRID.md))
- Install the `Shell.cs` utility
- Create `shellui-native.json` configuration
- Generate theme resources (MAUI)
Expand Down
20 changes: 19 additions & 1 deletion examples/Avalonia.Demo/Components/UI/Accordion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,33 @@ public string? Value
}

// Fired when any item's open state changes: (value, isOpen).
// User input only: setting Value, or calling Toggle, from code doesn't raise it.
public event EventHandler<(string Value, bool IsOpen)>? ItemToggled;

// Set while user input changes the value, so change events report user input only.
private bool _byUser;

public Accordion()
{
Children.CollectionChanged += (_, _) => Refresh(animate: false);
}

public bool IsOpen(string value) => _openValues.Contains(value);

// Read by items, whose OpenChanged follows the same rule.
internal bool IsUserChange => _byUser;

// A click or key on a trigger.
internal void ToggleByUser(string value) => ByUser(() => Toggle(value));

private void ByUser(Action change)
{
var outer = _byUser;
_byUser = true;
try { change(); }
finally { _byUser = outer; }
}

public void Toggle(string value)
{
var wasOpen = _openValues.Contains(value);
Expand All @@ -72,7 +90,7 @@ public void Toggle(string value)
changed.Add((value, !wasOpen));

Refresh(animate: true);
foreach (var change in changed) ItemToggled?.Invoke(this, change);
if (_byUser) foreach (var change in changed) ItemToggled?.Invoke(this, change);
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
Expand Down
5 changes: 4 additions & 1 deletion examples/Avalonia.Demo/Components/UI/AccordionItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public string? Value

public bool IsOpen { get; private set; }

// User input only, like the accordion's ItemToggled.
public event EventHandler<bool>? OpenChanged;

public AccordionItem()
Expand All @@ -35,6 +36,8 @@ public AccordionItem()

public void Toggle() => this.FindParentOfType<Accordion>()?.Toggle(Value ?? string.Empty);

internal void ToggleByUser() => this.FindParentOfType<Accordion>()?.ToggleByUser(Value ?? string.Empty);

internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible;

internal void ApplyOpen(bool open, bool animate)
Expand All @@ -45,6 +48,6 @@ internal void ApplyOpen(bool open, bool animate)
trigger.SetOpen(open, animate);
foreach (var content in this.FindDescendantsOfType<AccordionContent>(e => e is AccordionItem))
content.Apply(open, animate);
if (changed) OpenChanged?.Invoke(this, open);
if (changed && this.FindParentOfType<Accordion>() is { IsUserChange: true }) OpenChanged?.Invoke(this, open);
}
}
2 changes: 1 addition & 1 deletion examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public AccordionTrigger()
HorizontalAlignment = HorizontalAlignment.Stretch;
}

protected override void OnActivated() => this.FindParentOfType<AccordionItem>()?.Toggle();
protected override void OnActivated() => this.FindParentOfType<AccordionItem>()?.ToggleByUser();

protected override void OnPointerEntered(PointerEventArgs e)
{
Expand Down
29 changes: 21 additions & 8 deletions examples/Avalonia.Demo/Components/UI/Carousel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,13 @@ public double AspectRatio
[Content]
public Controls Slides => _track.Children;

// User input only (arrows, dots, swipes, keys): auto-play, Next / Previous and setting Position
// from code don't raise it.
public event EventHandler<int>? PositionChanged;

// Set while user input changes the value, so change events report user input only.
private bool _byUser;

public Carousel()
{
// Transparent, not null, so a drag can start between the slides' own content.
Expand All @@ -128,8 +133,8 @@ public Carousel()
_track.PointerReleased += OnTrackReleased;
_track.PointerCaptureLost += (_, _) => EndDrag(cancelled: true);

_previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, "Previous slide", Previous);
_next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, "Next slide", Next);
_previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, "Previous slide", () => ByUser(Previous));
_next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, "Next slide", () => ByUser(Next));
foreach (var arrow in new[] { _previous, _next })
{
arrow.PointerEntered += (_, _) => UpdateControls();
Expand All @@ -153,6 +158,14 @@ public Carousel()
private int Count => _track.Children.Count;
private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1));

private void ByUser(Action change)
{
var outer = _byUser;
_byUser = true;
try { change(); }
finally { _byUser = outer; }
}

public void Next()
{
if (Current < Count - 1) Position = Current + 1;
Expand Down Expand Up @@ -192,8 +205,8 @@ protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Handled || e.Source is TextBox) return;
if (e.Key == Key.Left) Previous();
else if (e.Key == Key.Right) Next();
if (e.Key == Key.Left) ByUser(Previous);
else if (e.Key == Key.Right) ByUser(Next);
else return;
e.Handled = true;
}
Expand Down Expand Up @@ -258,7 +271,7 @@ private void OnSlidesChanged()
target.PointerReleased += (_, e) =>
{
if (e.InitialPressMouseButton != MouseButton.Left) return;
Position = index;
ByUser(() => Position = index);
e.Handled = true;
};
_dots.Children.Add(target);
Expand All @@ -274,7 +287,7 @@ private void OnPositionChanged()
{
UpdateControls();
AnimateTo(Current, 300);
PositionChanged?.Invoke(this, Current);
if (_byUser) PositionChanged?.Invoke(this, Current);
// A manual move restarts the auto-play countdown.
UpdateTimer();
}
Expand Down Expand Up @@ -383,8 +396,8 @@ private void EndDrag(bool cancelled)
if (!wasDragging) return;

var current = Current;
if (!cancelled && moved <= -0.2 && current < Count - 1) Position = current + 1;
else if (!cancelled && moved >= 0.2 && current > 0) Position = current - 1;
if (!cancelled && moved <= -0.2 && current < Count - 1) ByUser(() => Position = current + 1);
else if (!cancelled && moved >= 0.2 && current > 0) ByUser(() => Position = current - 1);
// Not far enough: settle back.
else AnimateTo(current, 200);
}
Expand Down
18 changes: 15 additions & 3 deletions examples/Avalonia.Demo/Components/UI/Checkbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ static Checkbox()
IsCheckedProperty.Changed.AddClassHandler<Checkbox>((c, e) =>
{
c.UpdateVisualState();
c.CheckedChanged?.Invoke(c, (bool)e.NewValue!);
if (c._byUser) c.CheckedChanged?.Invoke(c, (bool)e.NewValue!);
});
LabelProperty.Changed.AddClassHandler<Checkbox>((c, _) => c.UpdateLabel());
HasErrorProperty.Changed.AddClassHandler<Checkbox>((c, _) => c.UpdateVisualState());
Expand All @@ -55,8 +55,12 @@ public bool HasError
set => SetValue(HasErrorProperty, value);
}

// User input only: setting IsChecked from code or a binding doesn't raise it.
public event EventHandler<bool>? CheckedChanged;

// Set while user input changes the value, so change events report user input only.
private bool _byUser;

public Checkbox()
{
_check = new Icon { Kind = IconName.Check, Size = 12, StrokeWidth = 3, Token = ShellToken.PrimaryForeground };
Expand All @@ -80,19 +84,27 @@ public Checkbox()
UpdateVisualState();
}

private void ByUser(Action change)
{
var outer = _byUser;
_byUser = true;
try { change(); }
finally { _byUser = outer; }
}

protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
IsChecked = !IsChecked;
ByUser(() => IsChecked = !IsChecked);
e.Handled = true;
}

protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Key != Key.Space) return;
IsChecked = !IsChecked;
ByUser(() => IsChecked = !IsChecked);
e.Handled = true;
}

Expand Down
Loading
Loading