diff --git a/.gitignore b/.gitignore index f803538..151488f 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,6 @@ BenchmarkDotNet.Artifacts/ # Project-specific *.DotSettings.user launchSettings.json + +# VS-generated .sln alongside the canonical ShellUI.Native.slnx +/shellui-native.sln diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 784d3b6..f070b56 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -1,580 +1,837 @@ # Component Reference -Complete list of available ShellUI Native components. +Complete list of available ShellUI Native components. Examples assume +`xmlns:ui="clr-namespace:YourApp.Components.UI"` on the page. + +## Theming + +Every component takes its colors from **theme tokens** mirroring ShellUI's CSS variables +(`--background`, `--primary`, `--border`, …). The `shell` utility (installed by +`shellui-native init`) defines a light and a dark palette in `ShellTheme` and publishes the +active one as application resources, so switching theme repaints every component. + +| Token | Role | +|-------|------| +| `Background` / `Foreground` | Page and dialog surface, body text | +| `Card` / `CardForeground` | Card surface | +| `Popover` / `PopoverForeground` | Dropdown, popover and select panels | +| `Primary` / `PrimaryForeground` | Default button, checked checkbox/switch/radio, progress fill | +| `Secondary`, `Muted`, `Accent` (+ `*Foreground`) | Secondary button, tab list and skeleton, hover backgrounds | +| `Destructive`, `Success`, `Warning`, `Info` (+ `*Foreground`) | Status variants (badge, alert, progress, labels) | +| `Border`, `Input`, `Ring` | Dividers, field borders, focus ring | +| `Overlay` | Dialog / drawer / sheet backdrop | + +**Use the tokens in your own XAML** — each token is published as a Color (`ShellUI`) and a +Brush (`ShellUIBrush`): + +```xml + + +``` + +**Initialize early** so page-level `DynamicResource`s resolve on first load (components also do it +on first use): + +```csharp +public App() +{ + InitializeComponent(); + Components.UI.ShellTheme.EnsureInitialized(); +} +``` + +**Switch theme** with `ShellTheme.SetTheme(AppTheme.Dark)`, `ShellTheme.ToggleTheme()`, or the +`ThemeToggle` component. The theme follows the OS setting until you set one. + +**Customize tokens** before the first page loads, then re-publish: + +```csharp +ShellTheme.Light[ShellToken.Primary] = Color.FromArgb("#2563EB"); +ShellTheme.Dark[ShellToken.Primary] = Color.FromArgb("#3B82F6"); +ShellTheme.Apply(); +``` + +In code, bind any Color/Brush property to a token with the `Token` extension: +`myBorder.Token(Border.StrokeProperty, ShellToken.Border);` + +**Android system bars (opt-in).** Set `ShellTheme.SyncSystemBars = true` before +`EnsureInitialized()` to make the status and navigation bars follow the theme: the bars turn +transparent over the edge-to-edge page, the page layer draws a `Background`-colored strip behind +the status bar (so dialog backdrops and sheets cover it), and the bar icons flip with light/dark. +Also set `colorPrimary` / `colorPrimaryDark` in `Platforms/Android/Resources/values/colors.xml` to +your background so the splash-to-app transition doesn't flash the template's purple. + +--- + +## Icon + +Stroke icons from [ShellIcons](../../../icons/shell-icons) (Lucide 0.475.0 — the set ShellUI +uses), drawn with MAUI shapes: no icon font or package, crisp at any size, theme-aware. The +template ships a curated set of ~110 icons with exact Lucide names (`trash-2` → `Trash2`). +To include more, add their names to `ICONS` in `scripts/generate-icons.py`, then run it and +`scripts/sync-templates.py`. + +```bash +shellui-native add icon +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| Name | IconName | None | Which icon (`Check`, `X`, `Plus`, `ChevronDown`, `Search`, `Settings`, `Trash2`, `Sun`, `Moon`, `Info`, `CircleAlert`, `CircleCheck`, `TriangleAlert`, `User`, `Bell`, … see `IconName`) | +| Size | double | 16 | Width and height in DIPs; the stroke scales with it | +| StrokeWidth | double | 2 | Stroke width on Lucide's 24×24 grid | +| Token | ShellToken | Foreground | Theme color | +| Color | Color? | null | Explicit color (overrides `Token`) | + +```xml + +``` + +--- ## Form Components ### Button -Interactive button with multiple variants and sizes. +Interactive button with variants, sizes, an optional icon and a loading state. Sizes to its +content (set `HorizontalOptions="Fill"` for a full-width button). ```bash shellui-native add button ``` -**Variants:** Default, Destructive, Outline, Secondary, Ghost - -**Sizes:** Sm, Default, Lg, Icon +**Variants:** Default, Secondary, Outline, Destructive, Ghost, Link · **Sizes:** Sm (36), Default (40), Lg (44), Icon (40×40) -**Properties:** | Property | Type | Default | Description | |----------|------|---------|-------------| -| Variant | ButtonVariant | Default | Visual style variant | -| Size | ButtonSize | Default | Button size | | Text | string | "" | Button text | -| IsLoading | bool | false | Shows loading indicator | -| IsEnabled | bool | true | Enable/disable button | +| Variant | ButtonVariant | Default | Visual style | +| Size | ButtonSize | Default | Height / padding | +| Icon | IconName | None | Optional icon | +| IconPosition | IconPosition | Left | `Left` or `Right` of the text | +| IsLoading | bool | false | Shows a spinner and ignores clicks | +| IsEnabled | bool | true | Disabled buttons render at 50% opacity | -**Usage:** ```xml - + + + + ``` -**Events:** `Clicked` +**Events:** `Clicked`. On Windows buttons are keyboard tab stops (Enter/Space activate). + +--- + +### Toggle +Two-state button that stays pressed — transparent (or outlined) when off, `Accent` when on. + +```bash +shellui-native add toggle +``` + +**Properties:** `IsPressed` (two-way), `Text`, `Icon`, `Variant` (Default, Outline), +`Size` (Sm 36, Default 40, Lg 44) · **Events:** `PressedChanged` + +```xml + + +``` --- ### Input -Text input field with validation support. +Single-line text field — 40px, one themed border (the platform control's own frame is removed), +ring color + soft glow on focus, destructive border on error. ```bash shellui-native add input ``` -**Properties:** | Property | Type | Default | Description | |----------|------|---------|-------------| -| Text | string | "" | Input value (two-way binding) | +| Text | string | "" | Value (two-way) | | Placeholder | string | "" | Placeholder text | -| IsPassword | bool | false | Mask input as password | -| HasError | bool | false | Show error state | +| IsPassword | bool | false | Mask input | +| HasError | bool | false | Error state | | IsReadOnly | bool | false | Prevent editing | | MaxLength | int | int.MaxValue | Maximum characters | +| Keyboard | Keyboard | Default | Soft keyboard type | -**Usage:** ```xml - + - + ``` **Events:** `TextChanged`, `Completed` --- -### Checkbox -Checkbox input with label and validation states. +### Input OTP +One-time-code input: a row of 40px slots over one hidden text field, so paste, autofill and the +platform keyboard all work. The active slot shows the ring color and a blinking caret. ```bash -shellui-native add checkbox +shellui-native add input-otp ``` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| IsChecked | bool | false | Checked state (two-way binding) | -| Label | string | "" | Label text displayed next to checkbox | -| HasError | bool | false | Show error state | -| IsEnabled | bool | true | Enable/disable checkbox | +**Properties:** `Length` (6), `Value` (two-way), `IsNumeric` (true — digits and numeric keyboard), +`HasError` · **Events:** `ValueChanged`, `Completed` (every slot filled) -**Usage:** ```xml - - + ``` -**Events:** `CheckedChanged` - --- -### Switch -Toggle switch component with label support. +### Textarea +Multi-line text field — min 80px, grows with content. ```bash -shellui-native add switch +shellui-native add textarea ``` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| IsToggled | bool | false | Toggled state (two-way binding) | -| Label | string | "" | Label text displayed next to switch | -| IsEnabled | bool | true | Enable/disable switch | +**Properties:** `Text` (two-way), `Placeholder`, `MaxLength`, `HasError` · **Events:** `TextChanged` -**Usage:** ```xml - + ``` -**Events:** `Toggled` - --- -### Label (ShellLabel) -Typography label with size, weight, and color variants. +### Checkbox +16×16 box with a check icon; the whole row (box + label) is the hit target. ```bash -shellui-native add label +shellui-native add checkbox ``` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Text | string | "" | Label text | -| Size | LabelSize | Default | Font size (Xs, Sm, Default, Lg, Xl, Xxl, Xxxl) | -| Weight | LabelWeight | Normal | Font weight (Light, Normal, Medium, Semibold, Bold) | -| Variant | LabelVariant | Default | Color variant (Default, Muted, Destructive, Success, Warning) | +**Properties:** `IsChecked` (two-way), `Label`, `HasError`, `IsEnabled` · **Events:** `CheckedChanged` -**Usage:** ```xml - - - + ``` --- -### Textarea -Multi-line text input. +### Switch +44×24 track with a 20px thumb that slides inside it. ```bash -shellui-native add textarea +shellui-native add switch ``` -**Properties:** `Text` (two-way), `Placeholder`, `MaxLength`, `IsReadOnly`, `HasError` +**Properties:** `IsToggled` (two-way), `Label`, `IsEnabled` · **Events:** `Toggled` -**Usage:** ```xml - + ``` --- -### Slider -Range slider input. +### RadioGroup +Single choice. The group drives its items. ```bash -shellui-native add slider +shellui-native add radio-group ``` -**Properties:** `Value` (two-way), `Minimum`, `Maximum` +**Properties (RadioGroup):** `Value` (two-way — the selected item's `Value`) · **Events:** `ValueChanged` +**Properties (RadioGroupItem):** `Value`, `Text` -**Usage:** ```xml - + + + + + ``` --- ### Select -Dropdown select / picker. +Custom-drawn select: a 40px trigger with a chevrons icon and a floating list with a check on the +selected item. Looks the same on every platform. ```bash shellui-native add select ``` -**Properties:** `SelectedIndex` (two-way), `Items` (ObservableCollection), `Placeholder` +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| ItemsSource | IList<string> | null | Options | +| SelectedIndex | int | -1 | Selected option (two-way) | +| SelectedItem | string? | — | Read-only selected option | +| Placeholder | string | "Select..." | Shown when nothing is selected | -**Usage:** ```xml - + ``` +**Events:** `SelectedIndexChanged` + --- -### RadioGroup -Radio button group. Uses compositional pattern. +### Combobox +A Select with a search field: the floating panel has a filter input above the option list, a check +on the selected option and a "no results" message. On desktop the search field takes focus when +the panel opens, and Enter picks the first match. ```bash -shellui-native add radio-group +shellui-native add combobox ``` -**Auto-installs:** `radio-group-item` - -**Properties:** `Value` (two-way — matches the `Value` of the selected item) +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| ItemsSource | IList<string> | null | Options | +| Value | string | "" | Selected option (two-way) | +| Placeholder | string | "Select..." | Shown when nothing is selected | +| SearchPlaceholder | string | "Search..." | Placeholder of the filter field | +| EmptyText | string | "No results found." | Shown when nothing matches | -**Usage:** ```xml - - - - - + ``` +**Events:** `ValueChanged` + --- -### DatePicker -Date selection picker. +### Slider +Platform slider tinted with the theme (primary range and thumb, secondary track). + +```bash +shellui-native add slider +``` + +**Properties:** `Value` (two-way), `Minimum`, `Maximum` · **Events:** `ValueChanged` + +--- + +### Calendar +Month grid with previous/next navigation. The selected day is primary, today is accented, and +month names, weekday names and the first day of the week follow the current culture. ```bash -shellui-native add date-picker +shellui-native add calendar ``` -**Properties:** `Date` (two-way, `DateTime`), `MinimumDate`, `MaximumDate`, `Format` +**Properties:** `SelectedDate` (DateTime?, two-way), `DisplayMonth`, `MinimumDate`, `MaximumDate` · **Events:** `DateSelected` -**Usage:** ```xml - + ``` ---- +### DatePicker +Custom-drawn: a 40px trigger (calendar icon + formatted date) that opens a `Calendar` floating +over the page. Looks the same on every platform. + +```bash +shellui-native add date-picker # also installs calendar +``` + +**Properties:** `Date` (two-way), `MinimumDate`, `MaximumDate`, `Format` (.NET date format, default `MMMM d, yyyy`) · **Events:** `DateChanged` + +```xml + +``` ### TimePicker -Time selection picker. +Custom-drawn: a 40px trigger (clock icon + formatted time) that opens scrollable hour and minute +columns — plus AM/PM on a 12-hour clock — floating over the page. Picking a value updates `Time` +straight away; click outside or press Escape to close. Looks the same on every platform. ```bash shellui-native add time-picker ``` -**Properties:** `Time` (two-way, `TimeSpan`), `Format` +**Properties:** `Time` (two-way), `MinuteStep` (default 5), `Is24Hour` (defaults to the current +culture's clock), `Format` (.NET time format; default `HH:mm` or `h:mm tt`) · **Events:** `TimeChanged` + +```xml + +``` + +--- + +### Label (ShellLabel) +Typography with size, weight and color variants. + +```bash +shellui-native add label +``` + +**Properties:** `Text`, `Size` (Xs…Xxxl), `Weight` (Light…Bold), `Variant` (Default, Muted, Destructive, Success, Warning) -**Usage:** ```xml - + +``` + +--- + +### ThemeToggle +36×36 outline icon button that switches light/dark; the sun and moon cross-fade. + +```bash +shellui-native add theme-toggle ``` +**Events:** `ThemeChanged(bool isDark)` · **Methods:** `Toggle()` + --- ## Layout Components ### Card -Container for grouping related content with header, content, and footer. +`rounded-xl border bg-card shadow-sm` container. ```bash -shellui-native add card +shellui-native add card # also installs card-header, card-content, card-footer ``` -**Variants:** Default, Bordered, Elevated - -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Variant | CardVariant | Default | Visual style variant | -| IsPressable | bool | false | Enable tap interactions | +**Properties (Card):** `Variant` (Default, Elevated), `IsPressable` · **Events:** `Clicked` (when pressable) +**CardHeader:** `Title`, `Description` · **CardContent:** any content · **CardFooter:** actions, right-aligned -**Usage:** ```xml - - - - + + + - + + ``` -**Events:** `Clicked` (when IsPressable=true) - --- ### Separator -Visual divider/separator line for layout. +1px divider in the `Border` token. **Properties:** `Orientation` (Horizontal, Vertical) + +--- + +### Collapsible +Expand/collapse with a height animation. The trigger can wrap a Button or any view. ```bash -shellui-native add separator +shellui-native add collapsible ``` -**Variants:** Horizontal, Vertical +**Properties:** `Open` (two-way; also `SetOpen(bool)`, `Toggle()`) · **Events:** `OpenChanged` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Orientation | SeparatorOrientation | Horizontal | Direction of separator line | - -**Usage:** ```xml - - + + + + + + ``` --- -### CardHeader -Header section for Card with title and description. +### Accordion +Stacked sections with dividers and a chevron that rotates when open; content expands and +collapses its height. ```bash -shellui-native add card-header +shellui-native add accordion ``` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Title | string | "" | Header title text | -| Description | string | "" | Optional subtitle/description | +**Properties (Accordion):** `Type` (Single, Multiple), `Value` (initially open item; comma-separated for Multiple) +**Events (Accordion):** `ItemToggled(value, isOpen)` +**AccordionItem:** `Value` · **AccordionTrigger:** `Text`, or any view as content + +```xml + + + + + + + + + + +``` --- -### CardContent -Main content section for Card. +### ScrollArea +`ScrollView` wrapper. Put it in a bordered `Border` for the shadcn look. -```bash -shellui-native add card-content +```xml + + ... + ``` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| NoPadding | bool | false | Remove default padding | - --- -### CardFooter -Footer section for Card, typically used for actions. +## Navigation Components + +### Tabs +A muted pill-shaped list; the active tab is raised on the background color. Panels fade in on switch. ```bash -shellui-native add card-footer +shellui-native add tabs ``` -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Orientation | StackOrientation | Horizontal | Layout direction | -| Justify | FooterJustify | End | Content alignment (Start, Center, End, SpaceBetween) | +**Tabs:** `Value` (active tab, two-way) · **Events:** `ValueChanged(old, new)` +**TabsTrigger:** `Value`, `Text` · **TabsContent:** `Value` + +```xml + + + + + + ... + ... + +``` --- -## Data Display +### Breadcrumb +Trail with chevron separators; links turn foreground on hover. -### Badge -Small status indicator with color variants. +**BreadcrumbItem:** `Text`, `IsCurrent` · **Events:** `Clicked` (not raised for the current item) -```bash -shellui-native add badge +```xml + + + + + ``` -**Variants:** Default, Secondary, Destructive, Outline, Success, Warning +--- -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Text | string | "" | Badge text | -| Variant | BadgeVariant | Default | Color variant | +### Pagination +Previous / page numbers / Next, with ellipses for skipped ranges. The current page is outlined; +Previous and Next disable at the ends. + +```bash +shellui-native add pagination +``` + +**Properties:** `Page` (two-way, 1-based), `TotalPages`, `SiblingCount` (pages shown each side of +the current one, default 1), `ShowLabels` (text next to the chevrons) · **Events:** `PageChanged` -**Usage:** ```xml - - - + ``` --- +## Data Display & Feedback + +### Badge +Pill label. **Variants:** Default, Secondary, Outline, Destructive, Success, Warning, Info + +```xml + +``` + ### Progress -Progress bar indicator with percentage support. +8px bar; the track is the fill color at 20%. Animates to new values. +**Properties:** `Value`, `Maximum`, `Variant` (Default, Success, Warning, Destructive), `ShowLabel` + +### Skeleton +Pulsing placeholder in the `Muted` token (opacity 1 → 0.5 → 1 every 2s, only while on screen). +**Properties:** `CornerRadius` (default 6) plus `WidthRequest` / `HeightRequest` + +### Empty State +Placeholder for an empty list or screen: icon in a muted tile, title, description and optional +actions, centered. `Bordered="True"` adds a dashed outline. ```bash -shellui-native add progress +shellui-native add empty-state ``` -**Variants:** Default, Success, Warning, Destructive +**Properties:** `Icon`, `Title`, `Description`, `Bordered` · child views become the action row -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Value | double | 0.0 | Current progress value | -| Maximum | double | 100.0 | Maximum value | -| Variant | ProgressVariant | Default | Color variant | -| ShowLabel | bool | false | Display percentage label | +```xml + + + +``` + +### Avatar +Circular image over a muted fallback — initials, or a user icon when `Fallback` is empty. The +fallback shows until the image loads and stays if it fails. + +```bash +shellui-native add avatar +``` + +**Properties:** `Source` (ImageSource), `Fallback` (initials), `Size` (Sm 32, Default 40, Lg 48, Xl 64) -**Usage:** ```xml - - + + ``` -**Computed Properties:** `Percentage` - Calculated percentage (0-100) +### Spinner +Rotating loader icon; spins only while on screen. **Properties:** `Size` (Sm 16, Default 24, Lg 32), `Token` (color, default Foreground), `IsRunning` ---- +```xml + + +``` -### Skeleton -*(Planned — P3.6, see [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md))* — Loading placeholder animation. +### Toast +Sonner-style notifications that stack in a corner, slide in, pause while hovered and dismiss +themselves (4s default). Call the static API from anywhere — toasts float above the current page. +A `` is optional: declare one anywhere on a page only to change position or count. ---- +```bash +shellui-native add toast +``` -## Feedback +```xml + +``` -### Alert -Contextual feedback messages with variants. +```csharp +Toast.Show("Event has been created", "Sunday, December 03 at 9:00 AM"); +Toast.Success("Profile saved"); +Toast.Error("Upload failed", "The file is larger than 10 MB."); +Toast.Warning("Storage almost full"); +Toast.Info("New version available"); +var id = Toast.Show("Message archived", actionText: "Undo", action: Undo); +Toast.Dismiss(id); +``` + +**Toaster:** `Position` (BottomRight, BottomCenter, TopRight, TopCenter), `MaxVisible` (default 3) + +### Callout +Highlighted note on a faint tint of the variant color, with an icon, an optional title, text +and/or any child content. **Variants:** Info, Warning, Danger, Tip, Default ```bash -shellui-native add alert +shellui-native add callout ``` -**Variants:** Default, Destructive, Success, Warning, Info +**Properties:** `Variant`, `Title`, `Text`, `Icon` (overrides the variant's icon) · child view becomes the body -**Properties:** -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| Title | string | "" | Alert title text | -| Message | string | "" | Alert message/body text | -| Variant | AlertVariant | Default | Color variant | +```xml + + + +``` + +### Alert +Bordered callout with an icon. **Properties:** `Title`, `Message`, `Variant` (Default, Destructive, Success, Warning, Info) -**Usage:** ```xml - - - + + ``` --- ## Overlay Components -Modal dialogs, drawers, sheets, dropdowns, and popovers. Use compositional pattern: parent + trigger + content. +### Dialog, Drawer, Sheet +Modal overlays with a dimmed backdrop (tap it to close) and open/close animations: the dialog +fades and zooms in, the drawer slides up with a grab handle, the sheet slides in from the side. +Dialog and sheet have a close (X) button. -**Tip:** Place `Dialog`, `Drawer`, or `Sheet` at the page root (e.g. last child of a Grid) with `HorizontalOptions="Fill"` and `VerticalOptions="Fill"` so the overlay covers the full screen. - -### Dialog -Modal dialog overlay. +**Declare them anywhere** — next to the button that opens them is fine. When opened, the content +is shown in a page-level layer above everything else (see *How overlays float* below). ```bash shellui-native add dialog +shellui-native add drawer +shellui-native add sheet ``` -**Usage:** +**Properties:** `Open` (also `SetOpen(bool)`) · **Events:** `OpenChanged` · Drawer/Sheet: `Side` (Left, Right, Top, Bottom) + ```xml - - - - + + - - - + + - ``` -### Drawer -Slide-out panel (Left, Right, Top, Bottom). +`DialogTrigger` / `DrawerTrigger` / `SheetTrigger` and `DialogClose` wrap a Button or any view. +The trigger is optional: open from code with `EditDialog.SetOpen(true)`. + +### Alert Dialog +A confirmation that requires a choice: no close button, and the backdrop doesn't dismiss it. +Declare it anywhere, like Dialog. ```bash -shellui-native add drawer +shellui-native add alert-dialog ``` -**Properties:** `Open`, `Side` (DrawerSide: Left, Right, Top, Bottom) +**Properties:** `Title`, `Description`, `ConfirmText` ("Continue"), `CancelText` ("Cancel"; empty hides it), `ConfirmVariant` (ButtonVariant), optional extra content inside the tag +**Events:** `Confirmed`, `Cancelled` · **Methods:** `Task ShowAsync()`, `SetOpen(bool)` -### Sheet -Bottom/top sheet panel. +```xml + +``` -```bash -shellui-native add sheet +```csharp +if (await DeleteDialog.ShowAsync()) + await DeleteAccountAsync(); ``` -**Properties:** `Open`, `Side` (SheetSide: Left, Right, Top, Bottom) +`AlertDialogTrigger` wraps a Button to open it from XAML instead. -### Dropdown -Dropdown menu. +### Dropdown, Popover +Panels that float next to their trigger — below it, or above when there is no room, and always +inside the window. Clicking outside, or opening another dropdown, popover or select, closes them. ```bash shellui-native add dropdown +shellui-native add popover ``` -**Usage:** +**Properties:** `IsOpen` (also `SetOpen`, `Toggle`, `Close`) · **Events:** `IsOpenChanged` +**DropdownItem:** `Text`, `Icon` · **Events:** `Clicked` (the menu closes first) + ```xml - + - - + + -``` - -### Popover -Floating popover panel. - -```bash -shellui-native add popover -``` -**Usage:** -```xml - - - + - ``` ---- +### Tooltip +Small label shown above (or below) a view after the pointer rests on it. Pointer devices only. -## Navigation +```bash +shellui-native add tooltip +``` -### Tabs -*(Coming Soon)* - Tabbed navigation interface. +**Properties:** `Text`, `Placement` (Top, Bottom), `Delay` (ms, default 400) + +```xml + + + +``` + +### Hover Card +Rich content that floats next to its trigger while the pointer is over the trigger or the card. ```bash -shellui-native add tabs +shellui-native add hover-card ``` ---- +**Properties:** `OpenDelay` (300 ms), `CloseDelay` (200 ms), `IsOpen` -## Adding Components +On touch devices (no pointer hover) tapping the trigger toggles the card, and tapping outside +closes it. -```bash -# Single component -shellui-native add button +```xml + + + + + +``` + +### Closing overlays from the keyboard +Open dialogs, drawers, sheets and popups (select, combobox, dropdown, popover, date and time +pickers) are tracked newest-last in `ShellDismiss`. **Escape** on Windows and the **back button** +on Android close the one on top; an Alert Dialog treats that as Cancel. With nothing open the key +keeps its normal behavior. Call `ShellDismiss.DismissTop()` to do the same from your own code. + +### How overlays float +`ShellPortal` (in `Shell.cs`) keeps one layer above each page's content — set up as the page +appears, as the last child of the page's root `Grid`; a page whose root isn't a Grid gets wrapped +in one, once. Dialogs, drawers, sheets, menus, selects, tooltips, hover cards and toasts are all +placed in that layer, so they are never clipped by a `ScrollView` and always draw on top. Content +moved there keeps a link to its component, so bindings and lookups keep working. + +The layer is edge-to-edge: backdrops dim the whole window, drawers and sheets run under the +system bars, and their content (plus popups and toasts) is kept clear of the bars and notch with +`ShellPortal.GetSafeInsets`. If you build your own overlay chrome, call +`ShellPortal.EdgeToEdge(...)` on its layouts so MAUI doesn't inset them a second time. -# Multiple components -shellui-native add button input card +--- -# Card with all sub-components -shellui-native add card card-header card-content card-footer +## Adding Components -# With --force to overwrite existing -shellui-native add button --force +```bash +shellui-native add button # single +shellui-native add button input card # several +shellui-native add button --force # overwrite an installed copy ``` ## Listing Components ```bash -# All available components shellui-native list - -# Only installed shellui-native list --installed - -# Only available (not installed) shellui-native list --available ``` ## Component Dependencies -When you add a component, its dependencies are automatically installed: +Dependencies install automatically. Almost every component depends on `shell` (theme tokens and +core helpers); components that draw icons also depend on `icon`. | Component | Auto-installs | |-----------|---------------| -| button | button-variants | -| card | card-header, card-content, card-footer | -| dialog | element-extensions, dialog-trigger, dialog-content, dialog-header, dialog-footer, dialog-title, dialog-description, dialog-close | -| drawer | element-extensions, drawer-trigger, drawer-content | -| sheet | element-extensions, sheet-trigger, sheet-content | -| dropdown | element-extensions, dropdown-trigger, dropdown-content, dropdown-item | -| popover | element-extensions, popover-trigger, popover-content | - -## Component Categories - -Components are organized into the following categories: - -- **Form Components**: `button`, `input`, `label`, `checkbox`, `switch`, `textarea`, `slider`, `select`, `radio-group` (+ `radio-group-item`), `date-picker`, `time-picker` -- **Layout Components**: `card` (+ `card-header`, `card-content`, `card-footer`), `separator` -- **Data Display**: `badge`, `progress` -- **Feedback**: `alert` -- **Overlay**: `dialog`, `drawer`, `sheet`, `dropdown`, `popover` (+ their trigger/content sub-components) -- **Utility**: `shell`, `element-extensions` (auto-installed by overlay components) - -For P3+ components (Tabs, Accordion, Tooltip, Toast, Skeleton, Table, etc.) see [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md). +| button | shell, icon, button-variants | +| card | shell, card-header, card-content, card-footer | +| select, combobox, time-picker, checkbox, alert, callout, breadcrumb-item, theme-toggle, avatar, spinner, toggle, pagination, empty-state | shell, icon | +| input-otp | shell | +| alert-dialog | shell, element-extensions, button | +| toast | shell, icon, button | +| dialog | shell, dialog-trigger, dialog-content, dialog-header, dialog-footer, dialog-title, dialog-description, dialog-close | +| drawer / sheet | shell, *-trigger, *-content (content also installs icon for the close button) | +| dropdown / popover | shell, *-trigger, *-content (+ dropdown-item) | +| hover-card | shell, hover-card-trigger, hover-card-content | +| date-picker | shell, icon, calendar | +| tooltip | shell | +| collapsible / accordion / tabs | element-extensions + their sub-components | +| breadcrumb | breadcrumb-item | + +**Upgrading an existing project:** components now require the new `Shell.cs` (theme tokens). If +your project was initialized earlier, refresh it once with `shellui-native add shell --force`. diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index c754e55..9b76d8e 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -2,7 +2,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [ShellUI Components](https://github.com/shellui/shell-ui) patterns. All components should follow **compositional patterns** using Dependencies (parent + sub-components) instead of monolithic ChildContent. -Last revised: **2026-08-29** (post PR #2 merge — Phase 1b live on `main`). +Last revised: **2026-09-27** (theme tokens, icons and component polish on `feat/p3-navigation-layout`). **Status at a glance:** P0 ✅ done · P1 ✅ done · P2 ✅ done · **P3 ⏭️ next (`feat/p3-navigation-layout`)** · P4–P7 backlog · Avalonia (Phase 2) unblocked, sequenced after P3 to avoid a moving target. @@ -14,40 +14,60 @@ Every single-line form control renders at **40px** high (matches shadcn `h-10` / | Component | Height | Padding | Notes | |-----------|--------|---------|-------| -| `Button` (Default) | 40 (from `ButtonStyle.Height`) | `(16, 10)` | Variant-driven: Sm=36, Lg=44, Icon=40×40 | -| `Input` | 40 | `(12, 0)` | Fixed 2026-08-29 — was unset + `(12, 8)`, rendered inconsistent across platforms | -| `Select` | 40 | `(12, 0)` | | -| `DatePicker` | 40 | `(12, 0)` | | -| `TimePicker` | 40 | `(12, 0)` | | +| `Button` (Default) | 40 (from `ButtonStyle.Height`) | `(16, 0)` | Sm=36 `(12,0)`, Lg=44 `(32,0)`, Icon=40×40. Sizes to content (`HorizontalOptions=Start`) | +| `Input` | 40 | `(12, 0)` | Native `Entry` frame stripped (`ShellPlatform.StripNativeChrome`) — one border only | +| `Select` | 40 | `(12, 0)` | Custom-drawn trigger + floating list (rows 32) since 2026-09-27 | +| `DatePicker` | 40 | `(12, 0)` | Custom-drawn trigger + floating `Calendar` (32px day cells) since 2026-10-02 | +| `TimePicker` | 40 | `(12, 0)` | Custom-drawn trigger + floating hour / minute / AM-PM columns (32px cells) since 2026-10-04 | | `Textarea` | `MinimumHeightRequest=80` | `(12, 8)` | Multi-line — grows with content | -| `Checkbox` | 20×20 (box) | — | Icon-shaped, not a field | -| `RadioGroupItem` | 20×20 (dot) | — | Icon-shaped, not a field | +| `TabsList` | 40 | `(4)` | Triggers fill the remaining 32 | +| `Checkbox` / `RadioGroupItem` | 16×16 | — | Icon-shaped, not a field (shadcn h-4 w-4) | +| `Switch` | 44×24 track, 20 thumb | inset 2 | Thumb travels exactly `44 - 2*2 - 20 = 20` | -**Rule for new form controls:** if it visually sits in a form row next to `Input`, it MUST be 40px tall. If it's a compositional container that hosts its own field (e.g. `Combobox`, `InputOTP`), the inner field carries the 40. Don't rely on platform default heights — MAUI's `Entry` / `Picker` defaults vary wildly across Android / iOS / Windows. +**Rule for new form controls:** if it visually sits in a form row next to `Input`, it MUST be 40px tall. If it's a compositional container that hosts its own field (e.g. `Combobox`, `InputOTP`), the inner field carries the 40. Don't rely on platform default heights — MAUI's `Entry` / `Picker` defaults vary wildly across Android / iOS / Windows. When wrapping a platform control in a ShellUI `Border`, call `ShellPlatform.StripNativeChrome` so it doesn't draw a second frame inside ours. -**Menu / list rows** (`DropdownItem`, `SelectItem` when added, `ContextMenuOption`) use `MinimumHeightRequest = 40` plus symmetric `Padding = (12, 12)` — they need a real touch target (44px iOS / 48dp Android guidance), not just enough space to draw the text. +**Menu / list rows** (`DropdownItem`, `Select` items) are 32 tall with `Padding = (8, 0)` inside a panel with 4px padding — shadcn's `px-2 py-1.5` rows. -**Icon-shaped controls** (`Checkbox` box 20×20, `RadioGroupItem` dot 20×20, `Switch` track 44×24) keep their exact pixel dimensions — those numbers are the design, not a fill. Their outer row inherits its hit area from the surrounding `HorizontalStackLayout`, which currently follows the icon height. If a future accessibility pass needs 44px touch targets for these, expand the container's `MinimumHeightRequest`, not the icon size. +**Row labels** that sit in a fixed-height row set both `VerticalOptions = Center` and `VerticalTextAlignment = Center`; without the latter a label that gets stretched to the row height draws its text at the top. + +**Icon-shaped controls** keep their exact pixel dimensions — those numbers are the design, not a fill. Their outer row is the hit target. --- ## Design Token Contract -Templates hardcode ARGB strings today (no shared token file until Phase 2 lands the Avalonia `ResourceDictionary`). While we're still copy-pasting them, they MUST agree — otherwise "primary" reads as two different blues across the demo. +Since 2026-09-27 colors live in one place: `ShellTheme` in the `shell` template (`Shell.cs`). It +holds a `Light` and a `Dark` palette keyed by `ShellToken` (ShellUI's CSS variables: +`Background`, `Foreground`, `Card`, `Popover`, `Primary`, `Secondary`, `Muted`, `Accent`, +`Destructive`, `Border`, `Input`, `Ring`, plus `Success` / `Warning` / `Info` / `Overlay`), and +publishes the active palette as app resources (`ShellUIPrimary` Color + `ShellUIPrimaryBrush` +Brush, …) that it swaps on theme change. Values follow ShellUI's default neutral theme. + +Components bind with `element.Token(property, ShellToken.X)` (a `SetDynamicResource` wrapper), so a +theme switch or a runtime override repaints everything. The `init` XAML +(`StyleTemplates.ThemeResourceDictionary`) mirrors the same palettes for design-time use. + +**Rules:** +- Never write a hex color in a component template — `TemplateContentTests.MAUI_components_use_theme_tokens_not_hardcoded_colors` fails the build. Shadows may use `Colors.Black` with an opacity. +- Need a new role? Add it to `ShellToken`, both palettes and `StyleTemplates` (checked by `StyleTemplatesTests`), then use it. +- Avalonia (Phase 2) maps the same keys to `{DynamicResource ShellUIPrimary}` in its own dictionaries. + +--- + +## Contributor Workflow: demo first, templates generated + +Components are written and run as normal C# in `examples/MAUI.Demo/Components/UI/`, then copied +into the CLI templates by a script — the demo always tests exactly what `shellui-native add` installs. -| Token | Value | Used by | -|-------|-------|---------| -| `Primary` | `#2563EB` | Button (Default), Checkbox (fill+border when checked), RadioGroupItem (fill+border when checked, fixed 2026-08-29), Switch (track when on), Progress (Default fill), Input (focus border, fixed 2026-08-29) | -| `Destructive` | `#EF4444` | Button (Destructive), Badge (Destructive), Progress (Destructive), Input (error border), Alert title (Destructive) | -| `Border` | `#E5E7EB` | Input (idle), Card (Default/Bordered), Checkbox (unchecked ring), RadioGroupItem (unchecked ring), Separator, Alert (Default) | -| `Muted background` | `#F3F4F6` | Badge (Secondary bg), Alert (Default bg) | -| `Foreground` | `#1F2937` | Input text, Label (Default), Checkbox label, Switch label, Alert title (Default) | -| `Muted foreground` | `#6B7280` | Progress label, Label (Muted variant) | -| `Placeholder` | `#9CA3AF` | Input placeholder | -| `Success` | `#22C55E` | Badge/Alert/Progress Success | -| `Warning` | `#F59E0B` | Badge/Alert/Progress Warning | +1. Edit or add `examples/MAUI.Demo/Components/UI/.cs` and exercise it in `MainPage.xaml`. +2. New component? Add it to `NEW` in `scripts/sync-templates.py` (registry key, display name, category, tags). +3. `python scripts/sync-templates.py` — rewrites each `Template.cs` content string (namespace + placeholder restored, quotes escaped) and recomputes `shell` / `icon` / `element-extensions` / `button` + dependencies from the code. Running it twice writes nothing. +4. Register new templates in `ComponentRegistry`, then `dotnet test`. -**Rule:** never introduce a new hex for a role that already has a token above. If you need a token that isn't listed, add it here first, then use it — that keeps Phase 2's `ResourceDictionary` extraction mechanical (grep the hex, replace with `{DynamicResource ShellUIPrimary}` in Avalonia XAML). +Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIcons catalog +(`../../icons/shell-icons`); edit its `ICONS` list to change the curated set. --- @@ -147,22 +167,23 @@ Templates hardcode ARGB strings today (no shared token file until Phase 2 lands ### P4 — Medium (Feedback & Overlays) | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| -| **tooltip** | P4.1 | — | Tooltip | Hover tooltip (simpler than Popover) | -| **toast** | P4.2 | — | Toast | Transient notification | -| **loading** | P4.3 | — | Loading | Loading spinner/state | -| **alert-dialog** | P4.4 | — | AlertDialog | Confirm/cancel dialog | -| **hover-card** | P4.5 | hover-card-trigger, hover-card-content | HoverCard | Hover-triggered popover | +| **tooltip** ✅ | P4.1 | shell | Tooltip | Done 2026-10-02 (floats in the page layer) | +| **toast** ✅ | P4.2 | shell, icon, button | Sonner | Done 2026-09-27 — `Toaster` host + static `Toast.Show/Success/Error/...` | +| **spinner** ✅ | P4.3 | shell, icon | Loading | Done 2026-09-27 as `spinner` (Loading's spinner variant) | +| **alert-dialog** ✅ | P4.4 | shell, element-extensions, button | AlertDialog | Done 2026-09-27 — `ShowAsync()` returns the choice | +| **hover-card** ✅ | P4.5 | shell, hover-card-trigger, hover-card-content | HoverCard | Done 2026-10-02 | --- ### P5 — Lower (Data Display) | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| -| **avatar** | P5.1 | — | Avatar | User avatar image/initials | +| **avatar** ✅ | P5.1 | shell, icon | Avatar | Done 2026-09-27 | | **table** | P5.2 | table-header, table-body, table-row, table-cell, table-head | Table | Data table | -| **empty-state** | P5.3 | — | EmptyState | Empty list/state message | -| **callout** | P5.4 | — | Callout | Info/warning callout block | -| **pagination** | P5.5 | — | Pagination | Page navigation | +| **empty-state** ✅ | P5.3 | shell, icon | EmptyState | Done 2026-10-04 — icon tile, title, description, action row, optional dashed border | +| **callout** ✅ | P5.4 | shell, icon | Callout | Done 2026-10-04 — Info / Warning / Danger / Tip / Default, tinted background | +| **pagination** ✅ | P5.5 | shell, icon | Pagination | Done 2026-10-04 — previous/next, sibling window, ellipses | +| **toggle** ✅ | — | shell, icon | Toggle | Done 2026-10-04 — pressed-state button (Default / Outline) | --- @@ -182,9 +203,9 @@ Templates hardcode ARGB strings today (no shared token file until Phase 2 lands | Component | Priority | Notes | |-----------|----------|-------| | chart (line, bar, pie, area) | P7 | Consider external chart lib integration | -| combobox | P7 | Searchable select | +| combobox ✅ | P7 | Done 2026-10-04 — Select with a filter field, Enter picks the first match | | file-upload | P7 | File picker | -| input-otp | P7 | OTP input | +| input-otp ✅ | P7 | Done 2026-10-04 — slots over one hidden field (paste / autofill / numeric keyboard) | | date-range-picker | P7 | Date range selection | | theme-toggle | P7 | Already in demo; consider as component | | copy-button | P7 | Copy to clipboard | @@ -227,7 +248,7 @@ public static T? FindParentOfType(this Element element) where T : Element ### DRY / SOLID - **Single template per component** — one `.cs` file per logical component - **Shared enums/variants** — `ButtonVariants`, `AlertVariant`, etc. in `Variants/` folder -- **Design tokens** — use `ShellTheme` or shared color constants +- **Design tokens** — bind colors with `.Token(property, ShellToken.X)`; never hardcode a hex (see Design Token Contract) - **Composition over configuration** — prefer Trigger+Content over `IsModal`/`RenderMode` flags - **Minimal surface** — only expose properties that map to real use cases diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index f0f560b..c6ad5d7 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -4,7 +4,7 @@ Living document tracking **branches**, **phases**, and **feature implementations ShellUI Native. Companion to [PLAN.md](./PLAN.md) (long-term strategy) and [COMPONENTS_ROADMAP.md](./COMPONENTS_ROADMAP.md) (prioritized component backlog). -Last revised: **2026-08-29** (post-PR #2 merge). +Last revised: **2026-08-30** (Phase 1c in flight, P4 polish scoped). --- @@ -17,7 +17,8 @@ into it, not directly into `main`. ``` main ← Phase 1a merged (2026-07-05), Phase 1b merged (2026-08-29 via PR #2) - └─ feat/p3-navigation-layout ← Phase 1c (next) — MAUI P3 tier (tabs, accordion, …) + └─ feat/p3-navigation-layout ← Phase 1c (active) — MAUI P3 tier (tabs, accordion, …) + └─ feat/p4-overlay-portal ← Phase 1d (queued after 1c) — portal rewrite + shadcn-style controls └─ feat/avalonia-implementation ← Phase 2 (planned) — Avalonia templates + reference impl └─ feat/winui ← Phase 3 (conditional) ``` @@ -239,7 +240,7 @@ public static class ButtonTemplate --- -## Phase 1c — `feat/p3-navigation-layout` (**next**) +## Phase 1c — `feat/p3-navigation-layout` (**in review**) Finish MAUI's component vocabulary through P3 before opening the cross-platform front. Rationale: Phase 2 (Avalonia) turns every new MAUI component into two components to @@ -283,13 +284,18 @@ exclusion. Breadcrumb and skeleton are standalone and can slot in wherever. ### Deliverables -- [ ] 6 new template classes + 3 sub-components (9 files total) -- [ ] `ComponentRegistry` entries for all 9 -- [ ] `MAUI.Demo` gains a demo section for each family (Tabs / Accordion / Collapsible / +- [x] 6 new template families + their sub-components +- [x] `ComponentRegistry` entries for all of them +- [x] `MAUI.Demo` gains a demo section for each family (Tabs / Accordion / Collapsible / Breadcrumb / ScrollArea / Skeleton) -- [ ] `TemplateContentTests` picks the new components up automatically (parameterized +- [x] `TemplateContentTests` picks the new components up automatically (parameterized over the whole registry — nothing to add manually) -- [ ] [COMPONENTS.md](./COMPONENTS.md) updated with usage snippets for each +- [x] [COMPONENTS.md](./COMPONENTS.md) updated with usage snippets for each + +Fix-forwards that landed here (despite the "no P0–P2 refactoring" rule below) because they +blocked the demo from rendering at all on Windows: overlays moved to page-root in +`MAUI.Demo`, outer `Border` removed from Select/DatePicker/TimePicker, and .NET 10 MAUI +nullable `Date`/`Time` + non-generic `ItemsSource` adaptations. Proper fixes → Phase 1d. ### Exit criteria to merge → `main` @@ -311,7 +317,70 @@ exclusion. Breadcrumb and skeleton are standalone and can slot in wherever. --- -## Phase 2 — `feat/avalonia-implementation` (unblocked, waiting on Phase 1c) +## Phase 1d — `feat/p4-overlay-portal` (queued after Phase 1c) + +Surface polish pass driven by live testing on Windows 2026-08-30. Two distinct problems +that both need architectural fixes rather than sizing tweaks. + +### Problem 1: Dialog / Drawer / Sheet require a page-root parent + +Today these components extend `AbsoluteLayout` with an inner `_overlayLayer` set to +proportional-fill (0,0,1,1). When placed inside a `VerticalStackLayout` (the natural +place a consumer would drop them next to a form), the AbsoluteLayout sizes to children +while the overlay layer wants to fill the parent — circular sizing produces a giant +empty inline box. The Phase 1c demo pushed them to page-root as a workaround, but that's +a footgun consumers WILL hit. + +**Fix:** rewrite as `ContentView`s that walk up to `ContentPage.Content` on attach, wrap +it in a `Grid` once (tracked via attached property), inject the overlay layer as the top +child of that grid. Trigger renders inline; content teleports to page root. Portal-style, +matches shadcn's `Dialog`/`Sheet` behavior in React. + +### Problem 2: Select / DatePicker / TimePicker use native chrome + +Same fix as Phase 1c's short-term patch (remove the outer `Border` to stop overflow) — +functional but not shadcn-quality. Windows' native `Picker`/`CalendarDatePicker`/`TimePicker` +draw their own chrome that we can't override cleanly cross-platform. + +**Fix:** replace each with a `Popover`-based custom control that renders its own trigger +(bounded, our styling) and opens a `PopoverContent` with the item list / calendar / +hour-minute wheels. Native pickers drop out entirely. Matches shadcn `Select` / +`DatePicker` / `TimePicker` — inspiration: `nativewind` + `shadcn/ui`. + +### Deliverables + +- [x] Portal helper — landed 2026-10-02 as `ShellPortal` in `Shell.cs` (page layer, logical owner, anchored popups with flip + click-outside) +- [x] Dialog / Drawer / Sheet / AlertDialog show their content in the portal layer — declare them anywhere +- [x] Custom `Select` — trigger + floating list (landed early, 2026-09-27, with the theme-token pass) +- [x] Custom `DatePicker` — trigger + floating `calendar` component (month navigation + day grid) +- [x] Custom `TimePicker` — trigger + floating hour / minute / AM-PM columns (2026-10-04); native picker dropped +- [x] `MAUI.Demo` no longer needs the page-root `Grid` workaround — its root is a plain `ScrollView` and overlays sit next to their triggers +- [x] Click-outside closes Dropdown / Popover / Select / DatePicker +- [x] `tooltip` and `hover-card` (unblocked by the portal) +- [x] Android pass (2026-10-04, Pixel 7 / API 34 emulator): native underline and padding stripped + from text fields; Select list sized and placed from real layout; `BoxView`s no longer pick up + the stock dark background; page layer set up as the page appears (first overlay no longer + resets the scroll position); overlays edge-to-edge with their content kept inside the safe + area; Hover Card opens on tap; opt-in `ShellTheme.SyncSystemBars` +- [x] `toggle`, `input-otp`, `pagination`, `empty-state` +- [x] Escape (Windows) and the Android back button close the overlay on top — `ShellDismiss` (2026-10-04; Mac Catalyst still open) +- [x] `callout`, `combobox` + +Already in place from the 2026-09-27 pass: theme tokens (`ShellTheme`), overlay open/close +animations, closed overlays no longer block input, triggers that wrap a Button +(`ShellTriggerView`), floating Dropdown/Popover/Select via `ShellAnchorLayout`, and +Date/Time pickers inside a themed border with the native frame stripped. + +### Exit criteria + +1. `MAUI.Demo` Dialog / Drawer / Sheet sections can move back into the `ScrollView` and still render + open correctly +2. Select / DatePicker / TimePicker render with our own border + rounded corners on Windows (no native chrome bleed) +3. `dotnet test` still green (visual tests remain manual until we automate them) +4. Every color still reuses the [Design Token Contract](./COMPONENTS_ROADMAP.md#design-token-contract) + +--- + +## Phase 2 — `feat/avalonia-implementation` (queued after Phase 1d) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. diff --git a/examples/MAUI.Demo/App.xaml.cs b/examples/MAUI.Demo/App.xaml.cs index cc5cce1..b86dca0 100644 --- a/examples/MAUI.Demo/App.xaml.cs +++ b/examples/MAUI.Demo/App.xaml.cs @@ -7,6 +7,9 @@ public partial class App : Application public App() { InitializeComponent(); + // Publish the ShellUI theme tokens before the first page resolves {DynamicResource ShellUI*}. + Components.UI.ShellTheme.SyncSystemBars = true; // Android status/navigation bars follow the theme + Components.UI.ShellTheme.EnsureInitialized(); } protected override Window CreateWindow(IActivationState? activationState) diff --git a/examples/MAUI.Demo/Components/UI/Accordion.cs b/examples/MAUI.Demo/Components/UI/Accordion.cs new file mode 100644 index 0000000..4a16feb --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Accordion.cs @@ -0,0 +1,90 @@ +namespace MAUI.Demo.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Stacked expandable sections with dividers and rotating chevrons. +// +// +// +// +// +// +public partial class Accordion : VerticalStackLayout +{ + public static readonly BindableProperty TypeProperty = + BindableProperty.Create(nameof(Type), typeof(AccordionType), typeof(Accordion), AccordionType.Single); + + // Initially open item (Single) — or comma-separated items (Multiple). + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(Accordion), string.Empty, + propertyChanged: (b, o, n) => ((Accordion)b).OnValueChanged((string?)n)); + + public AccordionType Type + { + get => (AccordionType)GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Fired when any item's open state changes: (value, isOpen). + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + private readonly HashSet _openValues = new(); + + public Accordion() + { + Spacing = 0; + Loaded += (_, _) => Refresh(animate: false); + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + var changed = new List<(string, bool)>(); + + if (Type == AccordionType.Single) + { + foreach (var other in _openValues.Where(v => v != value).ToList()) + { + _openValues.Remove(other); + changed.Add((other, false)); + } + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + changed.Add((value, !wasOpen)); + + Refresh(animate: true); + foreach (var change in changed) ItemToggled?.Invoke(this, change); + } + + private void OnValueChanged(string? value) + { + _openValues.Clear(); + foreach (var v in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + _openValues.Add(v); + if (Type == AccordionType.Single) break; + } + Refresh(animate: IsLoaded); + } + + // The accordion drives its items, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + var items = this.FindDescendantsOfType(e => e is Accordion).ToList(); + for (var i = 0; i < items.Count; i++) + { + items[i].ApplyOpen(_openValues.Contains(items[i].Value), animate); + items[i].SetDividerVisible(i < items.Count - 1); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/AccordionContent.cs b/examples/MAUI.Demo/Components/UI/AccordionContent.cs new file mode 100644 index 0000000..edb7530 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AccordionContent.cs @@ -0,0 +1,27 @@ +namespace MAUI.Demo.Components.UI; + +// Section body — pb-4 text-sm; expands/collapses its height with the item. +[ContentProperty(nameof(Content))] +public partial class AccordionContent : ContentView +{ + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + IsClippedToBounds = true; + Padding = new Thickness(0, 0, 0, 16); + } + + internal void Apply(bool open, bool animate) + { + if (animate) + { + _ = this.AnimateExpandAsync(open); + return; + } + this.AbortAnimation("ShellExpand"); + IsVisible = open; + Opacity = open ? 1 : 0; + HeightRequest = -1; + } +} diff --git a/examples/MAUI.Demo/Components/UI/AccordionItem.cs b/examples/MAUI.Demo/Components/UI/AccordionItem.cs new file mode 100644 index 0000000..7e76c09 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AccordionItem.cs @@ -0,0 +1,46 @@ +namespace MAUI.Demo.Components.UI; + +// One section: trigger + content, with a bottom divider (border-b, none on the last item). +[ContentProperty(nameof(Children))] +public partial class AccordionItem : ContentView +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(AccordionItem), string.Empty); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public bool IsOpen { get; private set; } + public event EventHandler? OpenChanged; + + private readonly VerticalStackLayout _stack; + private readonly BoxView _divider; + + public new IList Children => _stack.Children; + + public AccordionItem() + { + _stack = new VerticalStackLayout { Spacing = 0 }; + _divider = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + _divider.Token(BoxView.ColorProperty, ShellToken.Border); + Content = new VerticalStackLayout { Spacing = 0, Children = { _stack, _divider } }; + } + + public void Toggle() => this.FindParentOfType()?.Toggle(Value); + + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; + + internal void ApplyOpen(bool open, bool animate) + { + var changed = IsOpen != open; + IsOpen = open; + foreach (var trigger in this.FindDescendantsOfType(e => e is AccordionItem)) + trigger.SetOpen(open, animate); + foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) + content.Apply(open, animate); + if (changed) OpenChanged?.Invoke(this, open); + } +} diff --git a/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs b/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs new file mode 100644 index 0000000..e723f91 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs @@ -0,0 +1,74 @@ +namespace MAUI.Demo.Components.UI; + +// Section header — flex justify-between py-4 font-medium hover:underline, with a chevron +// that rotates 180° when open. Use Text for a plain title, or put any view inside. +[ContentProperty(nameof(Body))] +public partial class AccordionTrigger : ShellTriggerView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(AccordionTrigger), string.Empty, + propertyChanged: (b, o, n) => ((AccordionTrigger)b).UpdateBody()); + + public static readonly BindableProperty BodyProperty = + BindableProperty.Create(nameof(Body), typeof(View), typeof(AccordionTrigger), null, + propertyChanged: (b, o, n) => ((AccordionTrigger)b).UpdateBody()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public View? Body + { + get => (View?)GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + private readonly Grid _row; + private readonly Label _label; + private readonly Icon _chevron; + + public AccordionTrigger() + { + _label = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center }; + _label.Token(Label.TextColorProperty, ShellToken.Foreground); + _chevron = new Icon { Name = IconName.ChevronDown, Size = 16, Token = ShellToken.MutedForeground }; + + _row = new Grid + { + Padding = new Thickness(0, 16), + ColumnSpacing = 16, + MinimumHeightRequest = 40, + BackgroundColor = Colors.Transparent, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } + }; + _row.Add(_label, 0, 0); + _row.Add(_chevron, 1, 0); + + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => _label.TextDecorations = TextDecorations.Underline; + pointer.PointerExited += (_, _) => _label.TextDecorations = TextDecorations.None; + _row.GestureRecognizers.Add(pointer); + + Content = _row; + } + + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + + internal void SetOpen(bool open, bool animate) + { + var target = open ? 180 : 0; + if (animate) _ = _chevron.RotateToAsync(target, 200, Easing.CubicOut); + else _chevron.Rotation = target; + } + + private void UpdateBody() + { + var current = _row.Children.FirstOrDefault(c => Grid.GetColumn((BindableObject)c) == 0); + if (current != null) _row.Children.Remove(current); + View view = Body ?? _label; + _label.Text = Text ?? string.Empty; + _row.Add(view, 0, 0); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Alert.cs b/examples/MAUI.Demo/Components/UI/Alert.cs index 72959c2..c29e512 100644 --- a/examples/MAUI.Demo/Components/UI/Alert.cs +++ b/examples/MAUI.Demo/Components/UI/Alert.cs @@ -2,25 +2,25 @@ namespace MAUI.Demo.Components.UI; -// Alert component for notifications and feedback +// Callout with icon, title and message — rounded-lg border p-4, icon + text tinted per variant. public partial class Alert : ContentView { public static readonly BindableProperty TitleProperty = - BindableProperty.Create(nameof(Title), typeof(string), typeof(Alert), - string.Empty, propertyChanged: OnTitleChanged); + BindableProperty.Create(nameof(Title), typeof(string), typeof(Alert), + string.Empty, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty MessageProperty = - BindableProperty.Create(nameof(Message), typeof(string), typeof(Alert), - string.Empty, propertyChanged: OnMessageChanged); + BindableProperty.Create(nameof(Message), typeof(string), typeof(Alert), + string.Empty, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty VariantProperty = - BindableProperty.Create(nameof(Variant), typeof(AlertVariant), typeof(Alert), + BindableProperty.Create(nameof(Variant), typeof(AlertVariant), typeof(Alert), AlertVariant.Default, propertyChanged: OnVisualPropertyChanged); private readonly Border _container; + private readonly Icon _icon; private readonly Label _titleLabel; private readonly Label _messageLabel; - private readonly VerticalStackLayout _contentStack; public string Title { @@ -42,110 +42,58 @@ public AlertVariant Variant public Alert() { - _titleLabel = new Label - { - FontSize = 16, - FontAttributes = FontAttributes.Bold, - Margin = new Thickness(0, 0, 0, 4) - }; - - _messageLabel = new Label - { - FontSize = 14 - }; + _icon = new Icon { Size = 16, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) }; + _titleLabel = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold }; + _messageLabel = new Label { FontSize = 14 }; - _contentStack = new VerticalStackLayout + var text = new VerticalStackLayout { Spacing = 4, Children = { _titleLabel, _messageLabel } }; + var row = new Grid { - Spacing = 0, - Padding = new Thickness(16), - Children = { _titleLabel, _messageLabel } + ColumnSpacing = 12, + ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) } }; + row.Add(_icon, 0, 0); + row.Add(text, 1, 0); _container = new Border { - Content = _contentStack, - Padding = new Thickness(0), + Content = row, + Padding = new Thickness(16, 12), StrokeThickness = 1, - StrokeShape = new RoundRectangle { CornerRadius = 6 } + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg } }; + _container.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); Content = _container; UpdateVisualState(); } - private static void OnTitleChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Alert alert) - { - var text = newValue as string ?? string.Empty; - alert._titleLabel.Text = text; - alert._titleLabel.IsVisible = !string.IsNullOrEmpty(text); - } - } - - private static void OnMessageChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Alert alert) - { - var text = newValue as string ?? string.Empty; - alert._messageLabel.Text = text; - alert._messageLabel.IsVisible = !string.IsNullOrEmpty(text); - } - } - private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Alert alert) - alert.UpdateVisualState(); - } + => (bindable as Alert)?.UpdateVisualState(); private void UpdateVisualState() { - // Design tokens matching ShellUI theme - variant color schemes - var (bg, fg, border, titleColor) = Variant switch + var (icon, accent) = Variant switch { - AlertVariant.Default => ( - Color.FromArgb("#F3F4F6"), - Color.FromArgb("#374151"), - Color.FromArgb("#E5E7EB"), - Color.FromArgb("#1F2937") - ), - AlertVariant.Destructive => ( - Color.FromArgb("#FEF2F2"), - Color.FromArgb("#991B1B"), - Color.FromArgb("#FECACA"), - Color.FromArgb("#DC2626") - ), - AlertVariant.Success => ( - Color.FromArgb("#F0FDF4"), - Color.FromArgb("#166534"), - Color.FromArgb("#BBF7D0"), - Color.FromArgb("#22C55E") - ), - AlertVariant.Warning => ( - Color.FromArgb("#FFFBEB"), - Color.FromArgb("#92400E"), - Color.FromArgb("#FDE68A"), - Color.FromArgb("#F59E0B") - ), - AlertVariant.Info => ( - Color.FromArgb("#EFF6FF"), - Color.FromArgb("#1E40AF"), - Color.FromArgb("#BFDBFE"), - Color.FromArgb("#3B82F6") - ), - _ => ( - Color.FromArgb("#F3F4F6"), - Color.FromArgb("#374151"), - Color.FromArgb("#E5E7EB"), - Color.FromArgb("#1F2937") - ) + AlertVariant.Destructive => (IconName.CircleAlert, ShellToken.Destructive), + AlertVariant.Success => (IconName.CircleCheck, ShellToken.Success), + AlertVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning), + AlertVariant.Info => (IconName.Info, ShellToken.Info), + _ => (IconName.Info, ShellToken.Foreground) }; - _container.BackgroundColor = bg; - _container.Stroke = border; - _titleLabel.TextColor = titleColor; - _messageLabel.TextColor = fg; + _icon.Name = icon; + _icon.Token = accent; + _titleLabel.Text = Title ?? string.Empty; + _titleLabel.IsVisible = !string.IsNullOrEmpty(Title); + _messageLabel.Text = Message ?? string.Empty; + _messageLabel.IsVisible = !string.IsNullOrEmpty(Message); + + // Tinted variants color the title; the message stays readable in the muted foreground. + _titleLabel.Token(Label.TextColorProperty, accent); + _messageLabel.Token(Label.TextColorProperty, + Variant == AlertVariant.Default ? ShellToken.MutedForeground : ShellToken.Foreground); + _container.Token(Border.StrokeProperty, Variant == AlertVariant.Default ? ShellToken.Border : accent); } } diff --git a/examples/MAUI.Demo/Components/UI/AlertDialog.cs b/examples/MAUI.Demo/Components/UI/AlertDialog.cs new file mode 100644 index 0000000..60a656d --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/AlertDialog.cs @@ -0,0 +1,217 @@ +using Microsoft.Maui.Controls.Shapes; +using MAUI.Demo.Components.UI.Variants; + +namespace MAUI.Demo.Components.UI; + +// Confirmation dialog that requires a choice — no close button, and the backdrop doesn't +// dismiss it (shadcn AlertDialog). Place it where it can fill the page, like Dialog. +// +// From code: if (await DeleteDialog.ShowAsync()) { ... } +// Optional extra content (between description and buttons) goes inside the tag. +[ContentProperty(nameof(Body))] +public partial class AlertDialog : ShellOverlayHost +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(AlertDialog), string.Empty, + propertyChanged: (b, o, n) => ((AlertDialog)b)._panel.Update()); + + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(AlertDialog), string.Empty, + propertyChanged: (b, o, n) => ((AlertDialog)b)._panel.Update()); + + public static readonly BindableProperty ConfirmTextProperty = + BindableProperty.Create(nameof(ConfirmText), typeof(string), typeof(AlertDialog), "Continue", + propertyChanged: (b, o, n) => ((AlertDialog)b)._panel.Update()); + + // Empty hides the cancel button. + public static readonly BindableProperty CancelTextProperty = + BindableProperty.Create(nameof(CancelText), typeof(string), typeof(AlertDialog), "Cancel", + propertyChanged: (b, o, n) => ((AlertDialog)b)._panel.Update()); + + public static readonly BindableProperty ConfirmVariantProperty = + BindableProperty.Create(nameof(ConfirmVariant), typeof(ButtonVariant), typeof(AlertDialog), ButtonVariant.Default, + propertyChanged: (b, o, n) => ((AlertDialog)b)._panel.Update()); + + public static readonly BindableProperty BodyProperty = + BindableProperty.Create(nameof(Body), typeof(View), typeof(AlertDialog), null, + propertyChanged: (b, o, n) => ((AlertDialog)b)._panel.Update()); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public string ConfirmText + { + get => (string)GetValue(ConfirmTextProperty); + set => SetValue(ConfirmTextProperty, value); + } + + public string CancelText + { + get => (string)GetValue(CancelTextProperty); + set => SetValue(CancelTextProperty, value); + } + + public ButtonVariant ConfirmVariant + { + get => (ButtonVariant)GetValue(ConfirmVariantProperty); + set => SetValue(ConfirmVariantProperty, value); + } + + public View? Body + { + get => (View?)GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public event EventHandler? Confirmed; + public event EventHandler? Cancelled; + + private readonly AlertDialogPanel _panel; + private TaskCompletionSource? _pending; + + public AlertDialog() + { + _panel = new AlertDialogPanel(this); + Children.Add(_panel); + } + + protected override bool IsTrigger(Element child) => child is AlertDialogTrigger; + + // Escape / back counts as Cancel. + protected override void Dismiss() => Resolve(false); + + // Opens the dialog and completes with true (confirm) or false (cancel). + public Task ShowAsync() + { + _pending?.TrySetResult(false); + _pending = new TaskCompletionSource(); + SetOpen(true); + return _pending.Task; + } + + internal void Resolve(bool confirmed) + { + SetOpen(false); + if (confirmed) Confirmed?.Invoke(this, EventArgs.Empty); + else Cancelled?.Invoke(this, EventArgs.Empty); + _pending?.TrySetResult(confirmed); + _pending = null; + } +} + +// Opens the enclosing AlertDialog. Usage: +public partial class AlertDialogTrigger : ShellTriggerView +{ + protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true); +} + +// Backdrop + centered box: max-w-lg rounded-lg border bg-background p-6 shadow-lg, fade + zoom-in-95. +internal sealed class AlertDialogPanel : ContentView, IShellOverlayContent +{ + private readonly AlertDialog _owner; + private readonly BoxView _backdrop; + private readonly Border _box; + private readonly Label _title; + private readonly Label _description; + private readonly ContentView _body; + private readonly Button _cancel; + private readonly Button _confirm; + + public AlertDialogPanel(AlertDialog owner) + { + _owner = owner; + _backdrop = new BoxView { BackgroundColor = Colors.Transparent }; + _backdrop.Token(BoxView.ColorProperty, ShellToken.Overlay); + // Alert dialogs require a choice: swallow backdrop taps instead of closing. + _backdrop.GestureRecognizers.Add(new TapGestureRecognizer()); + + _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold }; + _title.Token(Label.TextColorProperty, ShellToken.Foreground); + _description = new Label { FontSize = 14 }; + _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); + _body = new ContentView(); + + _cancel = new Button { Variant = ButtonVariant.Outline }; + _cancel.Clicked += (_, _) => _owner.Resolve(false); + _confirm = new Button(); + _confirm.Clicked += (_, _) => _owner.Resolve(true); + + var footer = new HorizontalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.End, + Margin = new Thickness(0, 8, 0, 0), + Children = { _cancel, _confirm } + }; + + _box = new Border + { + Content = new VerticalStackLayout + { + Spacing = 8, + Children = { _title, _description, _body, footer } + }, + Padding = new Thickness(24), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg }, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Center, + Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 10), Radius = 24, Opacity = 0.2f } + }; + _box.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + _box.Token(Border.StrokeProperty, ShellToken.Border); + _box.GestureRecognizers.Add(new TapGestureRecognizer()); + + var root = new Grid { Children = { _backdrop, _box } }; + // The backdrop dims under the system bars; the centered box stays clear of them. + ShellPortal.EdgeToEdge(this, root); + root.SizeChanged += (_, _) => _box.WidthRequest = Math.Max(0, Math.Min(512, root.Width - 32)); + Content = root; + } + + public void Update() + { + _title.Text = _owner.Title; + _description.Text = _owner.Description; + _description.IsVisible = !string.IsNullOrEmpty(_owner.Description); + _body.Content = _owner.Body; + _body.IsVisible = _owner.Body != null; + _cancel.Text = _owner.CancelText; + _cancel.IsVisible = !string.IsNullOrEmpty(_owner.CancelText); + _confirm.Text = _owner.ConfirmText; + _confirm.Variant = _owner.ConfirmVariant; + } + + public async Task AnimateAsync(bool open) + { + if (open) + { + Update(); + _backdrop.Opacity = 0; + _box.Opacity = 0; + _box.Scale = 0.95; + await Task.WhenAll( + _backdrop.FadeToAsync(1, 150, Easing.CubicOut), + _box.FadeToAsync(1, 150, Easing.CubicOut), + _box.ScaleToAsync(1, 150, Easing.CubicOut)); + } + else + { + await Task.WhenAll( + _backdrop.FadeToAsync(0, 120, Easing.CubicIn), + _box.FadeToAsync(0, 120, Easing.CubicIn), + _box.ScaleToAsync(0.95, 120, Easing.CubicIn)); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/Avatar.cs b/examples/MAUI.Demo/Components/UI/Avatar.cs new file mode 100644 index 0000000..4f2fcee --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Avatar.cs @@ -0,0 +1,99 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Avatar — rounded-full image over a bg-muted fallback (initials, or a user icon when no +// Fallback is set). The fallback shows until the image loads, and stays if it fails. +// Usage: +public partial class Avatar : ContentView +{ + public static readonly BindableProperty SourceProperty = + BindableProperty.Create(nameof(Source), typeof(ImageSource), typeof(Avatar), null, + propertyChanged: (b, o, n) => ((Avatar)b)._image.Source = (ImageSource?)n); + + public static readonly BindableProperty FallbackProperty = + BindableProperty.Create(nameof(Fallback), typeof(string), typeof(Avatar), string.Empty, + propertyChanged: (b, o, n) => ((Avatar)b).UpdateFallback()); + + public static readonly BindableProperty SizeProperty = + BindableProperty.Create(nameof(Size), typeof(AvatarSize), typeof(Avatar), AvatarSize.Default, + propertyChanged: (b, o, n) => ((Avatar)b).UpdateSize()); + + public ImageSource? Source + { + get => (ImageSource?)GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + public string Fallback + { + get => (string)GetValue(FallbackProperty); + set => SetValue(FallbackProperty, value); + } + + public AvatarSize Size + { + get => (AvatarSize)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + private readonly Border _circle; + private readonly Label _initials; + private readonly Icon _userIcon; + private readonly Image _image; + + public Avatar() + { + _initials = new Label + { + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center + }; + _initials.Token(Label.TextColorProperty, ShellToken.Foreground); + _userIcon = new Icon { Name = IconName.User, Token = ShellToken.MutedForeground }; + _image = new Image { Aspect = Aspect.AspectFill }; + + _circle = new Border + { + StrokeThickness = 0, + Content = new Grid { Children = { _initials, _userIcon, _image } } + }; + _circle.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted); + + Content = _circle; + HorizontalOptions = LayoutOptions.Start; + VerticalOptions = LayoutOptions.Center; + UpdateSize(); + UpdateFallback(); + } + + private void UpdateSize() + { + var size = Size switch + { + AvatarSize.Sm => 32, + AvatarSize.Lg => 48, + AvatarSize.Xl => 64, + _ => 40 + }; + _circle.WidthRequest = size; + _circle.HeightRequest = size; + _circle.StrokeShape = new RoundRectangle { CornerRadius = size / 2f }; + _initials.FontSize = Math.Round(size * 0.36); + _userIcon.Size = size / 2.0; + } + + private void UpdateFallback() + { + var hasText = !string.IsNullOrWhiteSpace(Fallback); + _initials.Text = Fallback ?? string.Empty; + _initials.IsVisible = hasText; + _userIcon.IsVisible = !hasText; + SemanticProperties.SetDescription(this, hasText ? Fallback : "Avatar"); + } +} + +public enum AvatarSize { Sm, Default, Lg, Xl } diff --git a/examples/MAUI.Demo/Components/UI/Badge.cs b/examples/MAUI.Demo/Components/UI/Badge.cs index 85add8f..f0668a0 100644 --- a/examples/MAUI.Demo/Components/UI/Badge.cs +++ b/examples/MAUI.Demo/Components/UI/Badge.cs @@ -2,15 +2,15 @@ namespace MAUI.Demo.Components.UI; -// Badge status indicator component - theme-aware +// Status pill — rounded-full border px-2.5 py-0.5 text-xs font-semibold. public partial class Badge : ContentView { public static readonly BindableProperty TextProperty = - BindableProperty.Create(nameof(Text), typeof(string), typeof(Badge), + BindableProperty.Create(nameof(Text), typeof(string), typeof(Badge), string.Empty, propertyChanged: OnTextChanged); public static readonly BindableProperty VariantProperty = - BindableProperty.Create(nameof(Variant), typeof(BadgeVariant), typeof(Badge), + BindableProperty.Create(nameof(Variant), typeof(BadgeVariant), typeof(Badge), BadgeVariant.Default, propertyChanged: OnVisualPropertyChanged); private readonly Border _border; @@ -34,8 +34,6 @@ public Badge() { FontSize = 12, FontAttributes = FontAttributes.Bold, - VerticalOptions = LayoutOptions.Center, - HorizontalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, HorizontalTextAlignment = TextAlignment.Center }; @@ -43,20 +41,14 @@ public Badge() _border = new Border { Content = _label, - Padding = new Thickness(12, 4), // More padding + Padding = new Thickness(10, 2), StrokeThickness = 1, - VerticalOptions = LayoutOptions.Center, - HorizontalOptions = LayoutOptions.Start + StrokeShape = new RoundRectangle { CornerRadius = 999 } }; Content = _border; - - // Listen for theme changes - if (Application.Current != null) - { - Application.Current.RequestedThemeChanged += (s, e) => UpdateVisualState(); - } - + HorizontalOptions = LayoutOptions.Start; + VerticalOptions = LayoutOptions.Center; UpdateVisualState(); } @@ -67,60 +59,32 @@ private static void OnTextChanged(BindableObject bindable, object oldValue, obje } private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Badge badge) - badge.UpdateVisualState(); - } + => (bindable as Badge)?.UpdateVisualState(); private void UpdateVisualState() { - var isDark = ShellTheme.IsDarkMode; - - // Theme-aware color schemes for each variant + // (background, foreground, border); null background = transparent. var (bg, fg, border) = Variant switch { - BadgeVariant.Default => ( - isDark ? Color.FromArgb("#F8FAFC") : Color.FromArgb("#0F172A"), - isDark ? Color.FromArgb("#0F172A") : Color.FromArgb("#F8FAFC"), - isDark ? Color.FromArgb("#F8FAFC") : Color.FromArgb("#0F172A") - ), - BadgeVariant.Secondary => ( - isDark ? Color.FromArgb("#334155") : Color.FromArgb("#F1F5F9"), - isDark ? Color.FromArgb("#F8FAFC") : Color.FromArgb("#0F172A"), - isDark ? Color.FromArgb("#475569") : Color.FromArgb("#E2E8F0") - ), - BadgeVariant.Destructive => ( - Color.FromArgb("#EF4444"), - Colors.White, - Color.FromArgb("#EF4444") - ), - BadgeVariant.Outline => ( - Colors.Transparent, - isDark ? Color.FromArgb("#F8FAFC") : Color.FromArgb("#0F172A"), - isDark ? Color.FromArgb("#475569") : Color.FromArgb("#E2E8F0") - ), - BadgeVariant.Success => ( - Color.FromArgb("#22C55E"), - Colors.White, - Color.FromArgb("#22C55E") - ), - BadgeVariant.Warning => ( - Color.FromArgb("#F59E0B"), - Colors.White, - Color.FromArgb("#F59E0B") - ), - _ => ( - isDark ? Color.FromArgb("#F8FAFC") : Color.FromArgb("#0F172A"), - isDark ? Color.FromArgb("#0F172A") : Color.FromArgb("#F8FAFC"), - isDark ? Color.FromArgb("#F8FAFC") : Color.FromArgb("#0F172A") - ) + BadgeVariant.Secondary => ((ShellToken?)ShellToken.Secondary, ShellToken.SecondaryForeground, ShellToken.Secondary), + BadgeVariant.Destructive => (ShellToken.Destructive, ShellToken.DestructiveForeground, ShellToken.Destructive), + BadgeVariant.Outline => (null, ShellToken.Foreground, ShellToken.Border), + BadgeVariant.Success => (ShellToken.Success, ShellToken.SuccessForeground, ShellToken.Success), + BadgeVariant.Warning => (ShellToken.Warning, ShellToken.WarningForeground, ShellToken.Warning), + BadgeVariant.Info => (ShellToken.Info, ShellToken.InfoForeground, ShellToken.Info), + _ => (ShellToken.Primary, ShellToken.PrimaryForeground, ShellToken.Primary) }; - _border.BackgroundColor = bg; - _border.Stroke = border; - _border.StrokeShape = new RoundRectangle { CornerRadius = 9999 }; // Full rounded (pill shape) - _label.TextColor = fg; + if (bg.HasValue) + _border.Token(VisualElement.BackgroundColorProperty, bg.Value); + else + { + _border.ClearValue(VisualElement.BackgroundColorProperty); + _border.BackgroundColor = Colors.Transparent; + } + _border.Token(Border.StrokeProperty, border); + _label.Token(Label.TextColorProperty, fg); } } -public enum BadgeVariant { Default, Secondary, Destructive, Outline, Success, Warning } +public enum BadgeVariant { Default, Secondary, Destructive, Outline, Success, Warning, Info } diff --git a/examples/MAUI.Demo/Components/UI/Breadcrumb.cs b/examples/MAUI.Demo/Components/UI/Breadcrumb.cs new file mode 100644 index 0000000..6032142 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Breadcrumb.cs @@ -0,0 +1,29 @@ +namespace MAUI.Demo.Components.UI; + +// Navigation trail — items separated by chevrons; the last item hides its separator. +public partial class Breadcrumb : HorizontalStackLayout +{ + public Breadcrumb() + { + Spacing = 0; + } + + protected override void OnChildAdded(Element child) + { + base.OnChildAdded(child); + Dispatcher.Dispatch(RefreshItemSeparators); + } + + protected override void OnChildRemoved(Element child, int oldLogicalIndex) + { + base.OnChildRemoved(child, oldLogicalIndex); + Dispatcher.Dispatch(RefreshItemSeparators); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (int i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} diff --git a/examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs b/examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs new file mode 100644 index 0000000..083ba41 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/BreadcrumbItem.cs @@ -0,0 +1,66 @@ +namespace MAUI.Demo.Components.UI; + +// Breadcrumb link — text-sm text-muted-foreground hover:text-foreground; the current page is +// text-foreground and not clickable. A chevron separator follows every item but the last. +public partial class BreadcrumbItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(BreadcrumbItem), string.Empty, + propertyChanged: (b, o, n) => ((BreadcrumbItem)b).UpdateVisualState()); + + public static readonly BindableProperty IsCurrentProperty = + BindableProperty.Create(nameof(IsCurrent), typeof(bool), typeof(BreadcrumbItem), false, + propertyChanged: (b, o, n) => ((BreadcrumbItem)b).UpdateVisualState()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsCurrent + { + get => (bool)GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Label _text; + private readonly Icon _separator; + private bool _hovered; + + public BreadcrumbItem() + { + _text = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center }; + _separator = new Icon + { + Name = IconName.ChevronRight, + Size = 14, + Token = ShellToken.MutedForeground, + Margin = new Thickness(6, 0) + }; + Content = new HorizontalStackLayout { Spacing = 0, Children = { _text, _separator } }; + + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; UpdateVisualState(); }; + pointer.PointerExited += (_, _) => { _hovered = false; UpdateVisualState(); }; + _text.GestureRecognizers.Add(pointer); + + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { if (IsCurrent) return; ShellFocus.FocusPressed(this); Clicked?.Invoke(this, EventArgs.Empty); }; + _text.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, () => { if (!IsCurrent) Clicked?.Invoke(this, EventArgs.Empty); }, when: () => !IsCurrent); + + UpdateVisualState(); + } + + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.Token(Label.TextColorProperty, + IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Button.cs b/examples/MAUI.Demo/Components/UI/Button.cs index 4002af1..d524bf6 100644 --- a/examples/MAUI.Demo/Components/UI/Button.cs +++ b/examples/MAUI.Demo/Components/UI/Button.cs @@ -1,43 +1,44 @@ using Microsoft.Maui.Controls.Shapes; -using MauiIcons.Core; -using MauiIcons.Fluent; using MAUI.Demo.Components.UI.Variants; namespace MAUI.Demo.Components.UI; -// Interactive button with variant, size, icon support, and click animations -public partial class Button : ContentView +// Button with ShellUI variants, sizes, optional icon and loading state. +// Sizes to its content like an inline-flex button; set HorizontalOptions="Fill" for a block button. +// Usage: +public partial class Button : ContentView, IShellFocusable { public static readonly BindableProperty VariantProperty = - BindableProperty.Create(nameof(Variant), typeof(ButtonVariant), typeof(Button), + BindableProperty.Create(nameof(Variant), typeof(ButtonVariant), typeof(Button), ButtonVariant.Default, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty SizeProperty = - BindableProperty.Create(nameof(Size), typeof(ButtonSize), typeof(Button), + BindableProperty.Create(nameof(Size), typeof(ButtonSize), typeof(Button), ButtonSize.Default, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty IsLoadingProperty = - BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(Button), + BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(Button), false, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty TextProperty = - BindableProperty.Create(nameof(Text), typeof(string), typeof(Button), - string.Empty, propertyChanged: OnTextChanged); + BindableProperty.Create(nameof(Text), typeof(string), typeof(Button), + string.Empty, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty IconProperty = - BindableProperty.Create(nameof(Icon), typeof(FluentIcons?), typeof(Button), - null, propertyChanged: OnIconChanged); + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(Button), + IconName.None, propertyChanged: OnVisualPropertyChanged); public static readonly BindableProperty IconPositionProperty = - BindableProperty.Create(nameof(IconPosition), typeof(IconPosition), typeof(Button), - IconPosition.Left, propertyChanged: OnIconChanged); + BindableProperty.Create(nameof(IconPosition), typeof(IconPosition), typeof(Button), + IconPosition.Left, propertyChanged: OnVisualPropertyChanged); - private readonly ActivityIndicator _loadingIndicator; - private readonly Label _textLabel; - private readonly MauiIcon _iconView; private readonly Border _border; - private readonly HorizontalStackLayout _contentLayout; - private ButtonStyle _currentStyle = new(); + private readonly HorizontalStackLayout _row; + private readonly Icon _spinner; + private readonly Icon _icon; + private readonly Label _label; + private ButtonStyle _style = new(); + private bool _hovered; public ButtonVariant Variant { @@ -63,9 +64,9 @@ public string Text set => SetValue(TextProperty, value); } - public FluentIcons? Icon + public IconName Icon { - get => (FluentIcons?)GetValue(IconProperty); + get => (IconName)GetValue(IconProperty); set => SetValue(IconProperty, value); } @@ -79,179 +80,126 @@ public IconPosition IconPosition public Button() { - _loadingIndicator = new ActivityIndicator + _spinner = new Icon { Name = IconName.LoaderCircle, IsVisible = false }; + _icon = new Icon { IsVisible = false }; + _label = new Label { - IsRunning = false, - IsVisible = false, - WidthRequest = 16, - HeightRequest = 16, - Margin = new Thickness(0, 0, 8, 0) - }; - - _iconView = new MauiIcon - { - IconSize = 18, - IsVisible = false, VerticalOptions = LayoutOptions.Center, - HorizontalOptions = LayoutOptions.Center - }; - - _textLabel = new Label - { - VerticalOptions = LayoutOptions.Center, - HorizontalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, - HorizontalTextAlignment = TextAlignment.Center + LineBreakMode = LineBreakMode.NoWrap, + FontAttributes = FontAttributes.Bold }; - - _contentLayout = new HorizontalStackLayout + _row = new HorizontalStackLayout { - HorizontalOptions = LayoutOptions.Center, - VerticalOptions = LayoutOptions.Center, Spacing = 8, - Children = { _loadingIndicator, _iconView, _textLabel } + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center }; - _border = new Border { - Content = _contentLayout, - StrokeThickness = 0 + Content = _row, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd } }; - // Add pointer events for hover/press states - var pointerGesture = new PointerGestureRecognizer(); - pointerGesture.PointerEntered += OnPointerEntered; - pointerGesture.PointerExited += OnPointerExited; - pointerGesture.PointerPressed += OnPointerPressed; - pointerGesture.PointerReleased += OnPointerReleased; - _border.GestureRecognizers.Add(pointerGesture); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; ApplyHover(); }; + pointer.PointerExited += (_, _) => { _hovered = false; ApplyHover(); _border.Scale = 1; }; + pointer.PointerPressed += (_, _) => { if (CanClick) _ = _border.ScaleToAsync(0.97, 60, Easing.CubicOut); }; + pointer.PointerReleased += (_, _) => _ = _border.ScaleToAsync(1, 90, Easing.CubicOut); + _border.GestureRecognizers.Add(pointer); - // Add tap gesture for click handling - var tapGesture = new TapGestureRecognizer(); - tapGesture.Tapped += OnTapped; - _border.GestureRecognizers.Add(tapGesture); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); }; + _border.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, Press); Content = _border; - - // Listen for theme changes - if (Application.Current != null) - { - Application.Current.RequestedThemeChanged += (s, e) => UpdateVisualState(); - } - + HorizontalOptions = LayoutOptions.Start; UpdateVisualState(); } - private void OnPointerEntered(object? sender, PointerEventArgs e) - { - if (!IsLoading && IsEnabled) - { - _border.Opacity = 0.9; - } - } + private bool CanClick => IsEnabled && !IsLoading; - private void OnPointerExited(object? sender, PointerEventArgs e) + private void Press() { - _border.Opacity = 1.0; - _border.Scale = 1.0; + if (!CanClick) return; + Clicked?.Invoke(this, EventArgs.Empty); + // Inside a DialogTrigger / CollapsibleTrigger / ... the click also activates the trigger. + ShellTriggerView.ActivateAncestor(this); } - private async void OnPointerPressed(object? sender, PointerEventArgs e) + protected override void OnPropertyChanged(string? propertyName = null) { - if (!IsLoading && IsEnabled) - { - await _border.ScaleToAsync(0.96, 50, Easing.CubicOut); - } + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; } - private async void OnPointerReleased(object? sender, PointerEventArgs e) - { - await _border.ScaleToAsync(1.0, 100, Easing.CubicOut); - } + private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) + => (bindable as Button)?.UpdateVisualState(); - private async void OnTapped(object? sender, TappedEventArgs e) + private void UpdateVisualState() { - if (!IsLoading && IsEnabled) - { - await _border.ScaleToAsync(0.95, 50, Easing.CubicOut); - await _border.ScaleToAsync(1.0, 100, Easing.CubicOut); - Clicked?.Invoke(this, EventArgs.Empty); - } - } + _style = ButtonVariants.GetStyle(Variant, Size); - private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Button button) - button.UpdateVisualState(); - } + _border.HeightRequest = _style.Height; + _border.WidthRequest = _style.Width; + _border.Padding = _style.Padding; + _border.StrokeShape = new RoundRectangle { CornerRadius = _style.CornerRadius }; + _border.StrokeThickness = _style.Border.HasValue ? 1 : 0; + if (_style.Border.HasValue) + _border.Token(Border.StrokeProperty, _style.Border.Value); - private static void OnTextChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Button button) - button._textLabel.Text = newValue as string ?? string.Empty; - } + _label.Text = Text ?? string.Empty; + _label.IsVisible = !string.IsNullOrEmpty(Text); + _label.FontSize = _style.FontSize; + _label.Token(Label.TextColorProperty, _style.Foreground); - private static void OnIconChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Button button) - button.UpdateIconState(); + _icon.Name = Icon; + _icon.Size = _style.IconSize; + _icon.Token = _style.Foreground; + _icon.IsVisible = Icon != IconName.None && !IsLoading; + + _spinner.Size = _style.IconSize; + _spinner.Token = _style.Foreground; + _spinner.IsVisible = IsLoading; + if (IsLoading) StartSpinner(); else StopSpinner(); + + _row.Children.Clear(); + _row.Children.Add(_spinner); + if (IconPosition == IconPosition.Left) { _row.Children.Add(_icon); _row.Children.Add(_label); } + else { _row.Children.Add(_label); _row.Children.Add(_icon); } + + ApplyHover(); } - private void UpdateIconState() + private void ApplyHover() { - var hasIcon = Icon.HasValue; - _iconView.IsVisible = hasIcon && !IsLoading; - - if (hasIcon) + var hover = _hovered && CanClick; + var background = hover && _style.HoverBackground.HasValue ? _style.HoverBackground : _style.Background; + if (background.HasValue) + _border.Token(VisualElement.BackgroundColorProperty, background.Value); + else { - _iconView.Icon = Icon!.Value; - - // Reorder children based on icon position - _contentLayout.Children.Clear(); - _contentLayout.Children.Add(_loadingIndicator); - - if (IconPosition == IconPosition.Left) - { - _contentLayout.Children.Add(_iconView); - _contentLayout.Children.Add(_textLabel); - } - else - { - _contentLayout.Children.Add(_textLabel); - _contentLayout.Children.Add(_iconView); - } + _border.ClearValue(VisualElement.BackgroundColorProperty); + _border.BackgroundColor = Colors.Transparent; } + _border.Opacity = hover ? _style.HoverOpacity : 1.0; + _label.TextDecorations = hover && _style.UnderlineOnHover ? TextDecorations.Underline : TextDecorations.None; } - private void UpdateVisualState() + private void StartSpinner() + { + if (this.AnimationIsRunning("ButtonSpin")) return; + new Animation(v => _spinner.Rotation = v, 0, 360) + .Commit(this, "ButtonSpin", 16, 800, Easing.Linear, repeat: () => IsLoading); + } + + private void StopSpinner() { - _currentStyle = ButtonVariants.GetStyle(Variant, Size); - - _border.BackgroundColor = _currentStyle.BackgroundColor; - _border.Stroke = _currentStyle.BorderColor; - _border.StrokeThickness = _currentStyle.BorderThickness; - _border.StrokeShape = new RoundRectangle { CornerRadius = _currentStyle.CornerRadius }; - _border.Padding = _currentStyle.Padding; - _border.HeightRequest = _currentStyle.Height; - _border.MinimumWidthRequest = _currentStyle.MinWidth; - - _textLabel.TextColor = _currentStyle.TextColor; - _textLabel.FontSize = _currentStyle.FontSize; - _textLabel.FontAttributes = FontAttributes.Bold; - - // Icon color matches text color - _iconView.IconColor = _currentStyle.TextColor; - _iconView.IconSize = _currentStyle.FontSize + 2; - - _loadingIndicator.Color = _currentStyle.TextColor; - _loadingIndicator.IsVisible = IsLoading; - _loadingIndicator.IsRunning = IsLoading; - - // Hide icon when loading - if (Icon.HasValue) - _iconView.IsVisible = !IsLoading; - - Opacity = IsEnabled ? 1.0 : 0.5; + this.AbortAnimation("ButtonSpin"); + _spinner.Rotation = 0; } } diff --git a/examples/MAUI.Demo/Components/UI/Calendar.cs b/examples/MAUI.Demo/Components/UI/Calendar.cs new file mode 100644 index 0000000..a991325 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Calendar.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Month calendar — prev/next header, weekday row, 6x7 day grid of 32px cells. Selected day is +// bg-primary, today is bg-accent, days outside the month are muted. Month names, weekday names +// and the first day of the week follow the current culture. +// Usage: +public partial class Calendar : ContentView +{ + public static readonly BindableProperty SelectedDateProperty = + BindableProperty.Create(nameof(SelectedDate), typeof(DateTime?), typeof(Calendar), null, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Calendar)b).OnSelectedDateChanged()); + + public static readonly BindableProperty MinimumDateProperty = + BindableProperty.Create(nameof(MinimumDate), typeof(DateTime?), typeof(Calendar), null, + propertyChanged: (b, o, n) => ((Calendar)b).Refresh()); + + public static readonly BindableProperty MaximumDateProperty = + BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(Calendar), null, + propertyChanged: (b, o, n) => ((Calendar)b).Refresh()); + + public DateTime? SelectedDate + { + get => (DateTime?)GetValue(SelectedDateProperty); + set => SetValue(SelectedDateProperty, value); + } + + public DateTime? MinimumDate + { + get => (DateTime?)GetValue(MinimumDateProperty); + set => SetValue(MinimumDateProperty, value); + } + + public DateTime? MaximumDate + { + get => (DateTime?)GetValue(MaximumDateProperty); + set => SetValue(MaximumDateProperty, value); + } + + // First day of the month currently shown. + public DateTime DisplayMonth + { + get => _month; + set { _month = new DateTime(value.Year, value.Month, 1); Refresh(); } + } + + public event EventHandler? DateSelected; + + private const double Cell = 32; + + private DateTime _month = new(DateTime.Today.Year, DateTime.Today.Month, 1); + private readonly Label _title; + private readonly List<(Border Box, Label Text)> _cells = new(); + private readonly DateTime[] _dates = new DateTime[42]; + private int _hovered = -1; + + public Calendar() + { + _title = new Label + { + FontSize = 14, + FontAttributes = FontAttributes.Bold, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + }; + _title.Token(Label.TextColorProperty, ShellToken.Foreground); + + var header = new Grid + { + ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } + }; + header.Add(NavButton(IconName.ChevronLeft, -1, "Previous month"), 0, 0); + header.Add(_title, 1, 0); + header.Add(NavButton(IconName.ChevronRight, 1, "Next month"), 2, 0); + + var format = CultureInfo.CurrentCulture.DateTimeFormat; + var first = (int)format.FirstDayOfWeek; + var weekdays = new Grid(); + var days = new Grid { RowSpacing = 2 }; + for (var c = 0; c < 7; c++) + { + weekdays.ColumnDefinitions.Add(new ColumnDefinition(Cell)); + days.ColumnDefinitions.Add(new ColumnDefinition(Cell)); + var name = format.AbbreviatedDayNames[(first + c) % 7]; + var label = new Label + { + Text = name.Length > 2 ? name[..2] : name, + FontSize = 12, + HorizontalTextAlignment = TextAlignment.Center + }; + label.Token(Label.TextColorProperty, ShellToken.MutedForeground); + weekdays.Add(label, c, 0); + } + + for (var i = 0; i < 42; i++) + { + var index = i; + if (i % 7 == 0) days.RowDefinitions.Add(new RowDefinition(Cell)); + var text = new Label + { + FontSize = 13, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center + }; + var box = new Border + { + Content = text, + WidthRequest = Cell, + HeightRequest = Cell, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = index; StyleCell(index); }; + pointer.PointerExited += (_, _) => { if (_hovered == index) _hovered = -1; StyleCell(index); }; + box.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Select(index); + box.GestureRecognizers.Add(tap); + _cells.Add((box, text)); + days.Add(box, i % 7, i / 7); + } + + Content = new VerticalStackLayout { Spacing = 8, Children = { header, weekdays, days } }; + HorizontalOptions = LayoutOptions.Start; + Refresh(); + } + + private View NavButton(IconName icon, int direction, string description) + { + var button = new Border + { + Content = new Icon { Name = icon, Size = 16, Token = ShellToken.MutedForeground }, + WidthRequest = 28, + HeightRequest = 28, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + button.Token(Border.StrokeProperty, ShellToken.Input); + SemanticProperties.SetDescription(button, description); + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => button.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { button.ClearValue(VisualElement.BackgroundColorProperty); button.BackgroundColor = Colors.Transparent; }; + button.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => DisplayMonth = _month.AddMonths(direction); + button.GestureRecognizers.Add(tap); + return button; + } + + private bool IsSelectable(DateTime date) => + (MinimumDate is null || date >= MinimumDate.Value.Date) && + (MaximumDate is null || date <= MaximumDate.Value.Date); + + private void Select(int index) + { + var date = _dates[index]; + if (!IsSelectable(date)) return; + SelectedDate = date; + DateSelected?.Invoke(this, date); + } + + private void OnSelectedDateChanged() + { + // Follow the selection when it lands in another month (e.g. set from code). + if (SelectedDate is { } date && (date.Year != _month.Year || date.Month != _month.Month)) + _month = new DateTime(date.Year, date.Month, 1); + Refresh(); + } + + private void Refresh() + { + _title.Text = _month.ToString("MMMM yyyy", CultureInfo.CurrentCulture); + var first = (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek; + var offset = ((int)_month.DayOfWeek - first + 7) % 7; + var start = _month.AddDays(-offset); + for (var i = 0; i < 42; i++) + { + _dates[i] = start.AddDays(i); + _cells[i].Text.Text = _dates[i].Day.ToString(CultureInfo.CurrentCulture); + StyleCell(i); + } + } + + private void StyleCell(int index) + { + var (box, text) = _cells[index]; + var date = _dates[index]; + var inMonth = date.Month == _month.Month; + var selectable = IsSelectable(date); + var selected = SelectedDate?.Date == date; + var today = date == DateTime.Today; + + if (selected) + box.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary); + else if (today || (index == _hovered && selectable)) + box.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + else + { + box.ClearValue(VisualElement.BackgroundColorProperty); + box.BackgroundColor = Colors.Transparent; + } + + text.Token(Label.TextColorProperty, + selected ? ShellToken.PrimaryForeground : inMonth ? ShellToken.Foreground : ShellToken.MutedForeground); + box.Opacity = !selectable ? 0.3 : inMonth || selected ? 1 : 0.5; + } +} diff --git a/examples/MAUI.Demo/Components/UI/Callout.cs b/examples/MAUI.Demo/Components/UI/Callout.cs new file mode 100644 index 0000000..df6518f --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Callout.cs @@ -0,0 +1,134 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Highlighted note — rounded-lg border px-4 py-3 on a faint tint of the variant color, with an +// icon, an optional title and text and/or any content. +// +// +[ContentProperty(nameof(Body))] +public partial class Callout : ContentView +{ + public static readonly BindableProperty VariantProperty = + BindableProperty.Create(nameof(Variant), typeof(CalloutVariant), typeof(Callout), CalloutVariant.Info, + propertyChanged: (b, o, n) => ((Callout)b).Update()); + + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(Callout), string.Empty, + propertyChanged: (b, o, n) => ((Callout)b).Update()); + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(Callout), string.Empty, + propertyChanged: (b, o, n) => ((Callout)b).Update()); + + // None uses the variant's own icon. + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(Callout), IconName.None, + propertyChanged: (b, o, n) => ((Callout)b).Update()); + + public static readonly BindableProperty BodyProperty = + BindableProperty.Create(nameof(Body), typeof(View), typeof(Callout), null, + propertyChanged: (b, o, n) => ((Callout)b).Update()); + + public CalloutVariant Variant + { + get => (CalloutVariant)GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public View? Body + { + get => (View?)GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + private readonly BoxView _tint; + private readonly Icon _icon; + private readonly Label _title; + private readonly Label _text; + private readonly ContentView _body; + + public Callout() + { + _tint = new BoxView { BackgroundColor = Colors.Transparent }; + _icon = new Icon { Size = 16, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) }; + _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold }; + _text = new Label { FontSize = 14, LineHeight = 1.4 }; + _text.Token(Label.TextColorProperty, ShellToken.Foreground); + _body = new ContentView(); + + var row = new Grid + { + Margin = new Thickness(16, 12), + ColumnSpacing = 12, + ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) } + }; + row.Add(_icon, 0, 0); + row.Add(new VerticalStackLayout { Spacing = 6, Children = { _title, _text, _body } }, 1, 0); + + var frame = new Border + { + Content = new Grid { Children = { _tint, row } }, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg }, + BackgroundColor = Colors.Transparent + }; + frame.Token(Border.StrokeProperty, ShellToken.Border); + Content = frame; + Update(); + } + + private void Update() + { + var (icon, accent) = Variant switch + { + CalloutVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning), + CalloutVariant.Danger => (IconName.CircleAlert, ShellToken.Destructive), + CalloutVariant.Tip => (IconName.Lightbulb, ShellToken.Success), + CalloutVariant.Default => (IconName.Info, ShellToken.MutedForeground), + _ => (IconName.Info, ShellToken.Info) + }; + var neutral = Variant == CalloutVariant.Default; + + // bg-/8 for the tinted variants, bg-muted/50 for the neutral one. + _tint.Token(BoxView.ColorProperty, neutral ? ShellToken.Muted : accent); + _tint.Opacity = neutral ? 0.5 : 0.08; + + _icon.Name = Icon == IconName.None ? icon : Icon; + _icon.Token = accent; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _title.Token(Label.TextColorProperty, neutral ? ShellToken.Foreground : accent); + _text.Text = Text ?? string.Empty; + _text.IsVisible = !string.IsNullOrEmpty(Text); + _body.Content = Body; + _body.IsVisible = Body != null; + } +} + +public enum CalloutVariant +{ + Info, + Warning, + Danger, + Tip, + Default +} diff --git a/examples/MAUI.Demo/Components/UI/Card.cs b/examples/MAUI.Demo/Components/UI/Card.cs index 85174ae..dc16f39 100644 --- a/examples/MAUI.Demo/Components/UI/Card.cs +++ b/examples/MAUI.Demo/Components/UI/Card.cs @@ -2,8 +2,8 @@ namespace MAUI.Demo.Components.UI; -// Card - A container component with header, content, footer slots -// Usage: +// Card container — rounded-lg border bg-card shadow-sm. +// Usage: ...... [ContentProperty(nameof(CardContent))] public partial class Card : ContentView { @@ -40,18 +40,18 @@ public Card() _border = new Border { Content = _container, - Padding = 0 + Padding = 0, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg + 4 } }; + _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Card); + _border.Token(Border.StrokeProperty, ShellToken.Border); var tap = new TapGestureRecognizer(); tap.Tapped += (s, e) => { if (IsPressable) Clicked?.Invoke(this, EventArgs.Empty); }; _border.GestureRecognizers.Add(tap); Content = _border; - - if (Application.Current != null) - Application.Current.RequestedThemeChanged += (s, e) => UpdateVisuals(); - UpdateVisuals(); } @@ -59,120 +59,14 @@ public Card() private void UpdateVisuals() { - _border.BackgroundColor = ShellTheme.BackgroundCard; - _border.StrokeShape = new RoundRectangle { CornerRadius = 8 }; - - switch (Variant) + _border.Shadow = new Shadow { - case CardVariant.Default: - _border.Stroke = ShellTheme.Border; - _border.StrokeThickness = 1; - _border.Shadow = new Shadow { Opacity = 0 }; - break; - case CardVariant.Elevated: - _border.Stroke = Colors.Transparent; - _border.StrokeThickness = 0; - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(Color.FromArgb("#30000000")), - Offset = new Point(0, 4), - Radius = 12, - Opacity = ShellTheme.IsDarkMode ? 0.4f : 0.15f - }; - break; - } - } -} - -// Standalone CardHeader - use ui:CardHeader to avoid XAML parsing "Card.Header" as property -public class CardHeader : ContentView -{ - public static readonly BindableProperty TitleProperty = - BindableProperty.Create(nameof(Title), typeof(string), typeof(CardHeader), "", propertyChanged: OnTextChanged); - - public static readonly BindableProperty DescriptionProperty = - BindableProperty.Create(nameof(Description), typeof(string), typeof(CardHeader), "", propertyChanged: OnTextChanged); - - private readonly Label _title; - private readonly Label _description; - - public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); } - public string Description { get => (string)GetValue(DescriptionProperty); set => SetValue(DescriptionProperty, value); } - - public CardHeader() - { - _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold }; - _description = new Label { FontSize = 14, IsVisible = false }; - - Content = new VerticalStackLayout - { - Spacing = 4, - Padding = new Thickness(16, 16, 16, 8), - Children = { _title, _description } + Brush = new SolidColorBrush(Colors.Black), + Offset = Variant == CardVariant.Elevated ? new Point(0, 4) : new Point(0, 1), + Radius = Variant == CardVariant.Elevated ? 12 : 2, + Opacity = Variant == CardVariant.Elevated ? 0.10f : 0.05f }; - - if (Application.Current != null) - Application.Current.RequestedThemeChanged += (s, e) => UpdateColors(); - UpdateColors(); - } - - private static void OnTextChanged(BindableObject b, object o, object n) - { - if (b is CardHeader h) - { - h._title.Text = h.Title; - h._description.Text = h.Description; - h._description.IsVisible = !string.IsNullOrEmpty(h.Description); - } } - - private void UpdateColors() - { - _title.TextColor = ShellTheme.Foreground; - _description.TextColor = ShellTheme.ForegroundMuted; - } -} - -// Standalone CardContent - body area (inherits ContentProperty from ContentView) -public class CardContent : ContentView -{ - public CardContent() - { - Padding = new Thickness(16, 8); - } -} - -// Standalone CardFooter - footer area with action buttons -[ContentProperty(nameof(FooterContent))] -public class CardFooter : ContentView -{ - private readonly BoxView _separator; - private readonly HorizontalStackLayout _content; - - public IList FooterContent => _content.Children; - - public CardFooter() - { - _separator = new BoxView { HeightRequest = 1, HorizontalOptions = LayoutOptions.Fill }; - _content = new HorizontalStackLayout - { - Spacing = 8, - Padding = new Thickness(16, 12), - HorizontalOptions = LayoutOptions.End - }; - - Content = new VerticalStackLayout - { - Spacing = 0, - Children = { _separator, _content } - }; - - if (Application.Current != null) - Application.Current.RequestedThemeChanged += (s, e) => UpdateColors(); - UpdateColors(); - } - - private void UpdateColors() => _separator.BackgroundColor = ShellTheme.Border; } public enum CardVariant { Default, Elevated } diff --git a/examples/MAUI.Demo/Components/UI/CardContent.cs b/examples/MAUI.Demo/Components/UI/CardContent.cs new file mode 100644 index 0000000..55f7ae0 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CardContent.cs @@ -0,0 +1,10 @@ +namespace MAUI.Demo.Components.UI; + +// Card body — p-6 pt-0. +public partial class CardContent : ContentView +{ + public CardContent() + { + Padding = new Thickness(24, 0, 24, 24); + } +} diff --git a/examples/MAUI.Demo/Components/UI/CardFooter.cs b/examples/MAUI.Demo/Components/UI/CardFooter.cs new file mode 100644 index 0000000..3e747d2 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CardFooter.cs @@ -0,0 +1,21 @@ +namespace MAUI.Demo.Components.UI; + +// Card footer — flex items-center p-6 pt-0; actions right-aligned. +[ContentProperty(nameof(FooterContent))] +public partial class CardFooter : ContentView +{ + private readonly HorizontalStackLayout _content; + + public IList FooterContent => _content.Children; + + public CardFooter() + { + _content = new HorizontalStackLayout + { + Spacing = 8, + HorizontalOptions = LayoutOptions.End + }; + Padding = new Thickness(24, 0, 24, 24); + Content = _content; + } +} diff --git a/examples/MAUI.Demo/Components/UI/CardHeader.cs b/examples/MAUI.Demo/Components/UI/CardHeader.cs new file mode 100644 index 0000000..3927c51 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CardHeader.cs @@ -0,0 +1,50 @@ +namespace MAUI.Demo.Components.UI; + +// Card header — p-6 space-y-1.5; title font-semibold, description text-sm text-muted-foreground. +public partial class CardHeader : ContentView +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(CardHeader), string.Empty, + propertyChanged: (b, o, n) => (b as CardHeader)?.UpdateText()); + + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(CardHeader), string.Empty, + propertyChanged: (b, o, n) => (b as CardHeader)?.UpdateText()); + + private readonly Label _title; + private readonly Label _description; + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public CardHeader() + { + _title = new Label { FontSize = 18, FontAttributes = FontAttributes.Bold }; + _title.Token(Label.TextColorProperty, ShellToken.CardForeground); + _description = new Label { FontSize = 14, IsVisible = false }; + _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + Content = new VerticalStackLayout + { + Spacing = 6, + Padding = new Thickness(24, 24, 24, 16), + Children = { _title, _description } + }; + } + + private void UpdateText() + { + _title.Text = Title ?? string.Empty; + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Checkbox.cs b/examples/MAUI.Demo/Components/UI/Checkbox.cs index a834e4e..26b1a2e 100644 --- a/examples/MAUI.Demo/Components/UI/Checkbox.cs +++ b/examples/MAUI.Demo/Components/UI/Checkbox.cs @@ -2,29 +2,25 @@ namespace MAUI.Demo.Components.UI; -// Checkbox component with label support +// Checkbox — h-4 w-4 rounded-sm border border-primary; checked: bg-primary + check icon. +// The whole row (box + label) is the hit target. public partial class Checkbox : ContentView { public static readonly BindableProperty IsCheckedProperty = - BindableProperty.Create(nameof(IsChecked), typeof(bool), typeof(Checkbox), + BindableProperty.Create(nameof(IsChecked), typeof(bool), typeof(Checkbox), false, BindingMode.TwoWay, propertyChanged: OnIsCheckedChanged); public static readonly BindableProperty LabelProperty = - BindableProperty.Create(nameof(Label), typeof(string), typeof(Checkbox), - string.Empty, propertyChanged: OnLabelChanged); + BindableProperty.Create(nameof(Label), typeof(string), typeof(Checkbox), + string.Empty, propertyChanged: (b, o, n) => ((Checkbox)b).UpdateLabel()); public static readonly BindableProperty HasErrorProperty = - BindableProperty.Create(nameof(HasError), typeof(bool), typeof(Checkbox), - false, propertyChanged: OnVisualPropertyChanged); + BindableProperty.Create(nameof(HasError), typeof(bool), typeof(Checkbox), + false, propertyChanged: (b, o, n) => ((Checkbox)b).UpdateVisualState()); - public static new readonly BindableProperty IsEnabledProperty = - BindableProperty.Create(nameof(IsEnabled), typeof(bool), typeof(Checkbox), - true, propertyChanged: OnIsEnabledChanged); - - private readonly Border _checkboxBorder; - private readonly Label _checkmark; + private readonly Border _box; + private readonly Icon _check; private readonly Label _label; - private readonly TapGestureRecognizer _tapGesture; public bool IsChecked { @@ -44,106 +40,72 @@ public bool HasError set => SetValue(HasErrorProperty, value); } - public new bool IsEnabled - { - get => (bool)GetValue(IsEnabledProperty); - set => SetValue(IsEnabledProperty, value); - } - public event EventHandler? CheckedChanged; public Checkbox() { - _checkmark = new Label - { - Text = "✓", - FontSize = 14, - TextColor = Colors.White, - HorizontalOptions = LayoutOptions.Center, - VerticalOptions = LayoutOptions.Center, - IsVisible = false - }; + _check = new Icon { Name = IconName.Check, Size = 12, StrokeWidth = 3, Token = ShellToken.PrimaryForeground }; - _checkboxBorder = new Border + _box = new Border { - Content = _checkmark, - WidthRequest = 20, - HeightRequest = 20, - StrokeThickness = 2, - StrokeShape = new RoundRectangle { CornerRadius = 4 } + Content = _check, + WidthRequest = 16, + HeightRequest = 16, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + VerticalOptions = LayoutOptions.Center }; - _label = new Label - { - FontSize = 14, - VerticalOptions = LayoutOptions.Center, - Margin = new Thickness(8, 0, 0, 0) - }; + _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false }; + _label.Token(Microsoft.Maui.Controls.Label.TextColorProperty, ShellToken.Foreground); - _tapGesture = new TapGestureRecognizer(); - _tapGesture.Tapped += OnTapped; - - var container = new HorizontalStackLayout + var row = new HorizontalStackLayout { - Spacing = 0, - Children = { _checkboxBorder, _label } + Spacing = 8, + MinimumHeightRequest = 24, + Children = { _box, _label } }; - container.GestureRecognizers.Add(_tapGesture); - Content = container; + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); IsChecked = !IsChecked; }; + row.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) IsChecked = !IsChecked; }); + Content = row; + HorizontalOptions = LayoutOptions.Start; UpdateVisualState(); } - private void OnTapped(object? sender, EventArgs e) - { - if (IsEnabled) - IsChecked = !IsChecked; - } - private static void OnIsCheckedChanged(BindableObject bindable, object oldValue, object newValue) { - if (bindable is Checkbox checkbox) - { - checkbox.UpdateVisualState(); - checkbox.CheckedChanged?.Invoke(checkbox, (bool)newValue); - } + if (bindable is not Checkbox checkbox) return; + checkbox.UpdateVisualState(); + checkbox.CheckedChanged?.Invoke(checkbox, (bool)newValue); } - private static void OnLabelChanged(BindableObject bindable, object oldValue, object newValue) + protected override void OnPropertyChanged(string? propertyName = null) { - if (bindable is Checkbox checkbox) - checkbox._label.Text = newValue as string ?? string.Empty; + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; } - private static void OnIsEnabledChanged(BindableObject bindable, object oldValue, object newValue) + private void UpdateLabel() { - if (bindable is Checkbox checkbox) - checkbox.UpdateVisualState(); - } - - private static void OnVisualPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - if (bindable is Checkbox checkbox) - checkbox.UpdateVisualState(); + _label.Text = Label ?? string.Empty; + _label.IsVisible = !string.IsNullOrEmpty(Label); } private void UpdateVisualState() { - // Design tokens matching ShellUI theme - var borderColor = HasError - ? Color.FromArgb("#EF4444") - : (IsChecked ? Color.FromArgb("#2563EB") : Color.FromArgb("#E5E7EB")); - - var backgroundColor = IsChecked - ? Color.FromArgb("#2563EB") - : Colors.Transparent; - - _checkboxBorder.BackgroundColor = backgroundColor; - _checkboxBorder.Stroke = borderColor; - _checkmark.IsVisible = IsChecked; - _label.TextColor = Color.FromArgb("#1F2937"); - _label.Opacity = IsEnabled ? 1.0 : 0.5; - _checkboxBorder.Opacity = IsEnabled ? 1.0 : 0.5; + _box.Token(Border.StrokeProperty, HasError ? ShellToken.Destructive : ShellToken.Primary); + if (IsChecked) + _box.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary); + else + { + _box.ClearValue(VisualElement.BackgroundColorProperty); + _box.BackgroundColor = Colors.Transparent; + } + _check.IsVisible = IsChecked; } } diff --git a/examples/MAUI.Demo/Components/UI/Collapsible.cs b/examples/MAUI.Demo/Components/UI/Collapsible.cs new file mode 100644 index 0000000..eaf3c57 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Collapsible.cs @@ -0,0 +1,44 @@ +namespace MAUI.Demo.Components.UI; + +// Expand/collapse. The trigger can wrap a Button or any view; content animates its height. +// +// +// +// +public partial class Collapsible : VerticalStackLayout +{ + public static readonly BindableProperty OpenProperty = + BindableProperty.Create(nameof(Open), typeof(bool), typeof(Collapsible), false, + BindingMode.TwoWay, propertyChanged: (b, o, n) => ((Collapsible)b).OnOpenChanged()); + + public bool Open + { + get => (bool)GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 8; + Loaded += (_, _) => Refresh(animate: false); + } + + public void SetOpen(bool value) => Open = value; + + public void Toggle() => Open = !Open; + + private void OnOpenChanged() + { + Refresh(animate: true); + OpenChanged?.Invoke(this, Open); + } + + // The collapsible drives its content, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var content in this.FindDescendantsOfType(e => e is Collapsible)) + content.Apply(Open, animate); + } +} diff --git a/examples/MAUI.Demo/Components/UI/CollapsibleContent.cs b/examples/MAUI.Demo/Components/UI/CollapsibleContent.cs new file mode 100644 index 0000000..e596569 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CollapsibleContent.cs @@ -0,0 +1,26 @@ +namespace MAUI.Demo.Components.UI; + +// Content shown while the enclosing Collapsible is open; expands/collapses its height. +[ContentProperty(nameof(Content))] +public partial class CollapsibleContent : ContentView +{ + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + IsClippedToBounds = true; + } + + internal void Apply(bool open, bool animate) + { + if (animate) + { + _ = this.AnimateExpandAsync(open); + return; + } + this.AbortAnimation("ShellExpand"); + IsVisible = open; + Opacity = open ? 1 : 0; + HeightRequest = -1; + } +} diff --git a/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs b/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs new file mode 100644 index 0000000..fa53dee --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs @@ -0,0 +1,7 @@ +namespace MAUI.Demo.Components.UI; + +// Toggles the enclosing Collapsible. Wrap a Button or any view (a row, an icon, a label). +public partial class CollapsibleTrigger : ShellTriggerView +{ + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); +} diff --git a/examples/MAUI.Demo/Components/UI/Combobox.cs b/examples/MAUI.Demo/Components/UI/Combobox.cs new file mode 100644 index 0000000..3f79c9c --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Combobox.cs @@ -0,0 +1,288 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Combobox — a Select with a search field: h-10 trigger, and a floating panel with a filter +// input above the option list (check on the selected option, "no results" when nothing matches). +// Usage: then set +// ItemsSource in code or by binding. +public partial class Combobox : ContentView, IShellPopup +{ + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(Combobox), string.Empty, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Combobox)b).OnValueChanged()); + + public static readonly BindableProperty ItemsSourceProperty = + BindableProperty.Create(nameof(ItemsSource), typeof(IList), typeof(Combobox), null, + propertyChanged: (b, o, n) => ((Combobox)b).RebuildItems()); + + public static readonly BindableProperty PlaceholderProperty = + BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(Combobox), "Select...", + propertyChanged: (b, o, n) => ((Combobox)b).UpdateTrigger()); + + public static readonly BindableProperty SearchPlaceholderProperty = + BindableProperty.Create(nameof(SearchPlaceholder), typeof(string), typeof(Combobox), "Search...", + propertyChanged: (b, o, n) => ((Combobox)b)._search.Placeholder = (string)n); + + public static readonly BindableProperty EmptyTextProperty = + BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(Combobox), "No results found.", + propertyChanged: (b, o, n) => ((Combobox)b)._empty.Text = (string)n); + + public string Value + { + get => (string)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IList? ItemsSource + { + get => (IList?)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public string Placeholder + { + get => (string)GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string SearchPlaceholder + { + get => (string)GetValue(SearchPlaceholderProperty); + set => SetValue(SearchPlaceholderProperty, value); + } + + public string EmptyText + { + get => (string)GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public bool IsOpen { get; private set; } + + public event EventHandler? ValueChanged; + + private const double ItemHeight = 32; + private const double MaxListHeight = 240; + + private readonly Border _trigger; + private readonly Label _value; + private readonly Border _panel; + private readonly Entry _search; + private readonly VerticalStackLayout _list; + private readonly Grid _listArea; + private readonly Label _empty; + private readonly List<(string Text, Border Row, Icon Check)> _rows = new(); + private ShellPopupHandle? _handle; + + public Combobox() + { + _value = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation }; + var triggerRow = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } + }; + triggerRow.Add(_value, 0, 0); + triggerRow.Add(new Icon { Name = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground }, 1, 0); + + _trigger = new Border + { + Content = triggerRow, + HeightRequest = 40, + Padding = new Thickness(12, 0), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + _trigger.Token(Border.StrokeProperty, ShellToken.Input); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); SetOpen(!IsOpen); }; + _trigger.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); }); + + _search = new Entry + { + Placeholder = SearchPlaceholder, + FontSize = 14, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + ClearButtonVisibility = ClearButtonVisibility.Never, + IsTextPredictionEnabled = false, + IsSpellCheckEnabled = false + }; + _search.Token(Entry.TextColorProperty, ShellToken.PopoverForeground); + _search.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground); + ShellPlatform.StripNativeChrome(_search); + ShellFocus.Track(_search); + _search.TextChanged += (_, _) => Filter(); + // Enter picks the first match. + _search.Completed += (_, _) => + { + var first = _rows.FirstOrDefault(r => r.Row.IsVisible); + if (first.Row != null) Pick(first.Text); + }; + + var searchRow = new Grid + { + HeightRequest = 40, + Padding = new Thickness(12, 0), + ColumnSpacing = 8, + ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) } + }; + searchRow.Add(new Icon { Name = IconName.Search, Size = 16, Token = ShellToken.MutedForeground }, 0, 0); + searchRow.Add(_search, 1, 0); + + var divider = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + divider.Token(BoxView.ColorProperty, ShellToken.Border); + + _list = new VerticalStackLayout { Spacing = 0 }; + _empty = new Label + { + Text = EmptyText, + FontSize = 14, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + IsVisible = false + }; + _empty.Token(Label.TextColorProperty, ShellToken.MutedForeground); + // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering. + _listArea = new Grid { Margin = new Thickness(4), Children = { new ScrollView { Content = _list }, _empty } }; + + _panel = new Border + { + Content = new VerticalStackLayout { Spacing = 0, Children = { searchRow, divider, _listArea } }, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + Shadow = ShellPopups.PanelShadow() + }; + _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover); + _panel.Token(Border.StrokeProperty, ShellToken.Border); + // Taps on the panel's own chrome must not fall through to the click-outside catcher. + _panel.GestureRecognizers.Add(new TapGestureRecognizer()); + + Content = _trigger; + RebuildItems(); + } + + public void SetOpen(bool open) + { + if (IsOpen == open) return; + IsOpen = open; + _trigger.Token(Border.StrokeProperty, open ? ShellToken.Ring : ShellToken.Input); + if (open) + { + _search.Text = string.Empty; + Filter(); + ShellPopups.Opened(this); + _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions + { + MatchAnchorWidth = true, + Owner = this, + OnDismiss = Close + }); + // Type-to-filter straight away where there is a hardware keyboard; on touch devices + // the soft keyboard would cover the list, so the user taps the field to search. + if (DeviceInfo.Idiom == DeviceIdiom.Desktop) + Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(150), () => { if (IsOpen) _search.Focus(); }); + } + else + { + ShellPopups.Closed(this); + if (_search.IsFocused) _search.Unfocus(); + var handle = _handle; + _handle = null; + if (handle != null) _ = handle.CloseAsync(); + } + } + + public void Close() => SetOpen(false); + + private void Pick(string text) + { + Value = text; + SetOpen(false); + } + + private void OnValueChanged() + { + UpdateTrigger(); + UpdateChecks(); + ValueChanged?.Invoke(this, Value ?? string.Empty); + } + + private void UpdateTrigger() + { + var hasValue = !string.IsNullOrEmpty(Value); + _value.Text = hasValue ? Value : Placeholder; + _value.Token(Label.TextColorProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground); + } + + private void RebuildItems() + { + _list.Children.Clear(); + _rows.Clear(); + var items = ItemsSource; + _listArea.HeightRequest = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight); + if (items != null) + { + foreach (var text in items) + _list.Children.Add(CreateItem(text)); + } + UpdateTrigger(); + UpdateChecks(); + Filter(); + } + + private View CreateItem(string text) + { + var label = new Label { Text = text, FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation }; + label.Token(Label.TextColorProperty, ShellToken.PopoverForeground); + var check = new Icon { Name = IconName.Check, Size = 16, IsVisible = false }; + var row = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) } + }; + row.Add(label, 0, 0); + row.Add(check, 1, 0); + + var item = new Border + { + Content = row, + HeightRequest = ItemHeight, + Padding = new Thickness(8, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => item.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; }; + item.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Pick(text); + item.GestureRecognizers.Add(tap); + + _rows.Add((text, item, check)); + return item; + } + + private void UpdateChecks() + { + foreach (var (text, _, check) in _rows) + check.IsVisible = text == Value; + } + + private void Filter() + { + var query = _search.Text?.Trim() ?? string.Empty; + var any = false; + foreach (var (text, row, _) in _rows) + { + row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase); + any |= row.IsVisible; + } + _empty.IsVisible = !any; + } +} diff --git a/examples/MAUI.Demo/Components/UI/DatePicker.cs b/examples/MAUI.Demo/Components/UI/DatePicker.cs index 65faec9..145a18c 100644 --- a/examples/MAUI.Demo/Components/UI/DatePicker.cs +++ b/examples/MAUI.Demo/Components/UI/DatePicker.cs @@ -1,18 +1,29 @@ +using System.Globalization; using Microsoft.Maui.Controls.Shapes; namespace MAUI.Demo.Components.UI; -public partial class DatePicker : ContentView +// Date picker — shadcn-style trigger (calendar icon + formatted date, h-10 rounded-md border) +// that opens a Calendar floating in the page layer. Custom-drawn, so it looks the same on every +// platform. Usage: +public partial class DatePicker : ContentView, IShellPopup { public static readonly BindableProperty DateProperty = - BindableProperty.Create(nameof(Date), typeof(DateTime), typeof(DatePicker), + BindableProperty.Create(nameof(Date), typeof(DateTime), typeof(DatePicker), DateTime.Today, BindingMode.TwoWay, propertyChanged: OnDateChanged); public static readonly BindableProperty MinimumDateProperty = - BindableProperty.Create(nameof(MinimumDate), typeof(DateTime?), typeof(DatePicker), null); + BindableProperty.Create(nameof(MinimumDate), typeof(DateTime?), typeof(DatePicker), null, + propertyChanged: (b, o, n) => ((DatePicker)b)._calendar.MinimumDate = (DateTime?)n); public static readonly BindableProperty MaximumDateProperty = - BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(DatePicker), null); + BindableProperty.Create(nameof(MaximumDate), typeof(DateTime?), typeof(DatePicker), null, + propertyChanged: (b, o, n) => ((DatePicker)b)._calendar.MaximumDate = (DateTime?)n); + + // .NET date format string for the trigger text. + public static readonly BindableProperty FormatProperty = + BindableProperty.Create(nameof(Format), typeof(string), typeof(DatePicker), "MMMM d, yyyy", + propertyChanged: (b, o, n) => ((DatePicker)b).UpdateText()); public DateTime Date { @@ -32,42 +43,100 @@ public DateTime? MaximumDate set => SetValue(MaximumDateProperty, value); } + public string Format + { + get => (string)GetValue(FormatProperty); + set => SetValue(FormatProperty, value); + } + + public bool IsOpen { get; private set; } + public event EventHandler? DateChanged; - private readonly Microsoft.Maui.Controls.DatePicker _nativePicker; - private readonly Border _border; + private readonly Border _trigger; + private readonly Label _text; + private readonly Calendar _calendar; + private readonly Border _panel; + private ShellPopupHandle? _handle; public DatePicker() { - _nativePicker = new Microsoft.Maui.Controls.DatePicker(); - _nativePicker.DateSelected += (s, e) => + _text = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center }; + _text.Token(Label.TextColorProperty, ShellToken.Foreground); + + _trigger = new Border + { + Content = new HorizontalStackLayout + { + Spacing = 8, + Children = { new Icon { Name = IconName.Calendar, Size = 16, Token = ShellToken.MutedForeground }, _text } + }, + HeightRequest = 40, + MinimumWidthRequest = 200, + Padding = new Thickness(12, 0), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + _trigger.Token(Border.StrokeProperty, ShellToken.Input); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); SetOpen(!IsOpen); }; + _trigger.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); }); + + _calendar = new Calendar { SelectedDate = Date }; + _calendar.DateSelected += (_, date) => { - var newDate = e.NewDate ?? DateTime.Today; - Date = newDate; - DateChanged?.Invoke(this, new DateChangedEventArgs(e.OldDate, newDate)); + Date = date; + SetOpen(false); }; - _border = new Border + + _panel = new Border { - Content = _nativePicker, - Padding = new Thickness(12, 0), - HeightRequest = 40, + Content = _calendar, + Padding = new Thickness(12), StrokeThickness = 1, - StrokeShape = new RoundRectangle { CornerRadius = 6 } + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + Shadow = ShellPopups.PanelShadow() }; - Content = _border; + _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover); + _panel.Token(Border.StrokeProperty, ShellToken.Border); + + Content = _trigger; + HorizontalOptions = LayoutOptions.Start; + UpdateText(); } - protected override void OnPropertyChanged(string? propertyName = null) + public void SetOpen(bool open) { - base.OnPropertyChanged(propertyName); - if (propertyName == DateProperty.PropertyName) _nativePicker.Date = Date; - else if (propertyName == MinimumDateProperty.PropertyName) _nativePicker.MinimumDate = MinimumDate ?? DateTime.MinValue; - else if (propertyName == MaximumDateProperty.PropertyName) _nativePicker.MaximumDate = MaximumDate ?? DateTime.MaxValue; + if (IsOpen == open) return; + IsOpen = open; + _trigger.Token(Border.StrokeProperty, open ? ShellToken.Ring : ShellToken.Input); + if (open) + { + _calendar.SelectedDate = Date; + _calendar.DisplayMonth = Date; + ShellPopups.Opened(this); + _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close }); + } + else + { + ShellPopups.Closed(this); + var handle = _handle; + _handle = null; + if (handle != null) _ = handle.CloseAsync(); + } } + public void Close() => SetOpen(false); + private static void OnDateChanged(BindableObject b, object o, object n) { - if (b is DatePicker dp && n is DateTime dt && dp._nativePicker.Date != dt) - dp._nativePicker.Date = dt; + var picker = (DatePicker)b; + picker.UpdateText(); + picker._calendar.SelectedDate = (DateTime)n; + picker.DateChanged?.Invoke(picker, new DateChangedEventArgs((DateTime)o, (DateTime)n)); } + + private void UpdateText() => _text.Text = Date.ToString(Format, CultureInfo.CurrentCulture); } diff --git a/examples/MAUI.Demo/Components/UI/Dialog.cs b/examples/MAUI.Demo/Components/UI/Dialog.cs index cfa233d..15e8648 100644 --- a/examples/MAUI.Demo/Components/UI/Dialog.cs +++ b/examples/MAUI.Demo/Components/UI/Dialog.cs @@ -1,59 +1,12 @@ namespace MAUI.Demo.Components.UI; -// Modal dialog - compositional. Usage: ...... -public partial class Dialog : Grid +// Modal dialog. Place it where it can fill the page (e.g. last child of the page's root Grid): +// +// (optional) +// ... +// +// Open from code with ConfirmDialog.SetOpen(true). +public partial class Dialog : ShellOverlayHost { - public static readonly BindableProperty OpenProperty = - BindableProperty.Create(nameof(Open), typeof(bool), typeof(Dialog), false, - propertyChanged: (b, o, n) => (b as Dialog)?.OnOpenChanged()); - - public bool Open - { - get => (bool)GetValue(OpenProperty); - set => SetValue(OpenProperty, value); - } - - public event EventHandler? OpenChanged; - - private readonly VerticalStackLayout _triggerContainer; - private readonly Grid _overlayLayer; - - public Dialog() - { - RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - _triggerContainer = new VerticalStackLayout { Spacing = 0, VerticalOptions = LayoutOptions.Start }; - _overlayLayer = new Grid { IsVisible = false, ZIndex = 1000 }; - Children.Add(_triggerContainer); - Children.Add(_overlayLayer); - Grid.SetRow(_triggerContainer, 0); - Grid.SetRow(_overlayLayer, 0); - } - - protected override void OnChildAdded(Element child) - { - base.OnChildAdded(child); - if (child == _triggerContainer || child == _overlayLayer) return; - Dispatcher.Dispatch(() => - { - if (child is IView view) - { - Children.Remove(view); - if (child is DialogTrigger dt) - _triggerContainer.Children.Add(dt); - else if (child is DialogContent dc) - _overlayLayer.Children.Add(dc); - } - }); - } - - public void SetOpen(bool value) - { - if (Open != value) { Open = value; OnOpenChanged(); } - } - - private void OnOpenChanged() - { - _overlayLayer.IsVisible = Open; - OpenChanged?.Invoke(this, Open); - } + protected override bool IsTrigger(Element child) => child is DialogTrigger; } diff --git a/examples/MAUI.Demo/Components/UI/DialogClose.cs b/examples/MAUI.Demo/Components/UI/DialogClose.cs index c1a88d6..d4190b1 100644 --- a/examples/MAUI.Demo/Components/UI/DialogClose.cs +++ b/examples/MAUI.Demo/Components/UI/DialogClose.cs @@ -1,11 +1,7 @@ namespace MAUI.Demo.Components.UI; -public partial class DialogClose : ContentView +// Closes the enclosing Dialog. Wrap a Button: +public partial class DialogClose : ShellTriggerView { - public DialogClose() - { - var tap = new TapGestureRecognizer(); - tap.Tapped += (s, e) => this.FindParentOfType()?.SetOpen(false); - GestureRecognizers.Add(tap); - } + protected override void OnActivated() => this.FindParentOfType()?.SetOpen(false); } diff --git a/examples/MAUI.Demo/Components/UI/DialogContent.cs b/examples/MAUI.Demo/Components/UI/DialogContent.cs index 70a60d0..db99c65 100644 --- a/examples/MAUI.Demo/Components/UI/DialogContent.cs +++ b/examples/MAUI.Demo/Components/UI/DialogContent.cs @@ -2,51 +2,100 @@ namespace MAUI.Demo.Components.UI; -// Modal content overlay - backdrop + centered box. Usage: ... +// Dialog panel — backdrop + centered box: max-w-lg rounded-lg border bg-background p-6 +// shadow-lg, close button top-right; fades and zooms in from 95%. [ContentProperty(nameof(Children))] -public partial class DialogContent : ContentView +public partial class DialogContent : ContentView, IShellOverlayContent { - private readonly Grid _root; - private readonly Border _backdrop; - private readonly Border _modalBox; + public static readonly BindableProperty ShowCloseButtonProperty = + BindableProperty.Create(nameof(ShowCloseButton), typeof(bool), typeof(DialogContent), true, + propertyChanged: (b, o, n) => ((DialogContent)b)._close.IsVisible = (bool)n); + + public bool ShowCloseButton + { + get => (bool)GetValue(ShowCloseButtonProperty); + set => SetValue(ShowCloseButtonProperty, value); + } + + private readonly BoxView _backdrop; + private readonly Border _box; + private readonly Border _close; private readonly VerticalStackLayout _body; public new IList Children => _body.Children; public DialogContent() { - _backdrop = new Border - { - BackgroundColor = Color.FromArgb("#80000000"), - StrokeThickness = 0, - ZIndex = 0 - }; + _backdrop = new BoxView { BackgroundColor = Colors.Transparent }; + _backdrop.Token(BoxView.ColorProperty, ShellToken.Overlay); var tapBackdrop = new TapGestureRecognizer(); - tapBackdrop.Tapped += (s, e) => this.FindParentOfType()?.SetOpen(false); + tapBackdrop.Tapped += (_, _) => this.FindParentOfType()?.SetOpen(false); _backdrop.GestureRecognizers.Add(tapBackdrop); _body = new VerticalStackLayout { Spacing = 16 }; - _modalBox = new Border + + _close = new Border { - Content = _body, - BackgroundColor = Color.FromArgb("#FFFFFF"), - Stroke = Color.FromArgb("#E5E7EB"), - StrokeThickness = 1, - StrokeShape = new RoundRectangle { CornerRadius = 8 }, + Content = new Icon { Name = IconName.X, Size = 16, Token = ShellToken.MutedForeground }, + WidthRequest = 24, + HeightRequest = 24, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent, + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Start, + Margin = new Thickness(0, -8, -8, 0) + }; + SemanticProperties.SetDescription(_close, "Close"); + var closePointer = new PointerGestureRecognizer(); + closePointer.PointerEntered += (_, _) => _close.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + closePointer.PointerExited += (_, _) => { _close.ClearValue(VisualElement.BackgroundColorProperty); _close.BackgroundColor = Colors.Transparent; }; + _close.GestureRecognizers.Add(closePointer); + var closeTap = new TapGestureRecognizer(); + closeTap.Tapped += (_, _) => this.FindParentOfType()?.SetOpen(false); + _close.GestureRecognizers.Add(closeTap); + + _box = new Border + { + Content = new Grid { Children = { _body, _close } }, Padding = new Thickness(24), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg }, VerticalOptions = LayoutOptions.Center, HorizontalOptions = LayoutOptions.Center, - MaximumWidthRequest = 400, - ZIndex = 1 + Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 10), Radius = 24, Opacity = 0.2f } }; + _box.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + _box.Token(Border.StrokeProperty, ShellToken.Border); + // Swallow taps on the box so they don't fall through to the backdrop. + _box.GestureRecognizers.Add(new TapGestureRecognizer()); - _root = new Grid - { - Children = { _backdrop, _modalBox }, - VerticalOptions = LayoutOptions.Fill, - HorizontalOptions = LayoutOptions.Fill - }; - Content = _root; + var root = new Grid { Children = { _backdrop, _box } }; + // The backdrop dims under the system bars; the centered box stays clear of them. + ShellPortal.EdgeToEdge(this, root); + root.SizeChanged += (_, _) => _box.WidthRequest = Math.Max(0, Math.Min(512, root.Width - 32)); + Content = root; } + public async Task AnimateAsync(bool open) + { + _box.AbortAnimation("FadeTo"); + if (open) + { + _backdrop.Opacity = 0; + _box.Opacity = 0; + _box.Scale = 0.95; + await Task.WhenAll( + _backdrop.FadeToAsync(1, 150, Easing.CubicOut), + _box.FadeToAsync(1, 150, Easing.CubicOut), + _box.ScaleToAsync(1, 150, Easing.CubicOut)); + } + else + { + await Task.WhenAll( + _backdrop.FadeToAsync(0, 120, Easing.CubicIn), + _box.FadeToAsync(0, 120, Easing.CubicIn), + _box.ScaleToAsync(0.95, 120, Easing.CubicIn)); + } + } } diff --git a/examples/MAUI.Demo/Components/UI/DialogDescription.cs b/examples/MAUI.Demo/Components/UI/DialogDescription.cs index 1f42f1c..d7253b9 100644 --- a/examples/MAUI.Demo/Components/UI/DialogDescription.cs +++ b/examples/MAUI.Demo/Components/UI/DialogDescription.cs @@ -1,10 +1,11 @@ namespace MAUI.Demo.Components.UI; +// text-sm text-muted-foreground public partial class DialogDescription : Label { public DialogDescription() { FontSize = 14; - TextColor = Color.FromArgb("#6B7280"); + this.Token(TextColorProperty, ShellToken.MutedForeground); } } diff --git a/examples/MAUI.Demo/Components/UI/DialogFooter.cs b/examples/MAUI.Demo/Components/UI/DialogFooter.cs index 8f6dfd3..01a1cec 100644 --- a/examples/MAUI.Demo/Components/UI/DialogFooter.cs +++ b/examples/MAUI.Demo/Components/UI/DialogFooter.cs @@ -1,5 +1,6 @@ namespace MAUI.Demo.Components.UI; +// Actions row — flex justify-end gap-2. [ContentProperty(nameof(Children))] public partial class DialogFooter : ContentView { @@ -12,7 +13,8 @@ public DialogFooter() _stack = new HorizontalStackLayout { Spacing = 8, - HorizontalOptions = LayoutOptions.End + HorizontalOptions = LayoutOptions.End, + Margin = new Thickness(0, 8, 0, 0) }; Content = _stack; } diff --git a/examples/MAUI.Demo/Components/UI/DialogHeader.cs b/examples/MAUI.Demo/Components/UI/DialogHeader.cs index c514a4a..20325eb 100644 --- a/examples/MAUI.Demo/Components/UI/DialogHeader.cs +++ b/examples/MAUI.Demo/Components/UI/DialogHeader.cs @@ -1,5 +1,6 @@ namespace MAUI.Demo.Components.UI; +// Title + description stack — flex flex-col space-y-1.5. [ContentProperty(nameof(Children))] public partial class DialogHeader : ContentView { @@ -9,7 +10,7 @@ public partial class DialogHeader : ContentView public DialogHeader() { - _stack = new VerticalStackLayout { Spacing = 4 }; + _stack = new VerticalStackLayout { Spacing = 6, Margin = new Thickness(0, 0, 24, 0) }; Content = _stack; } } diff --git a/examples/MAUI.Demo/Components/UI/DialogTitle.cs b/examples/MAUI.Demo/Components/UI/DialogTitle.cs index 827ae23..70fb8d2 100644 --- a/examples/MAUI.Demo/Components/UI/DialogTitle.cs +++ b/examples/MAUI.Demo/Components/UI/DialogTitle.cs @@ -1,10 +1,12 @@ namespace MAUI.Demo.Components.UI; +// text-lg font-semibold leading-none public partial class DialogTitle : Label { public DialogTitle() { FontSize = 18; FontAttributes = FontAttributes.Bold; + this.Token(TextColorProperty, ShellToken.Foreground); } } diff --git a/examples/MAUI.Demo/Components/UI/DialogTrigger.cs b/examples/MAUI.Demo/Components/UI/DialogTrigger.cs index e143699..ad66aac 100644 --- a/examples/MAUI.Demo/Components/UI/DialogTrigger.cs +++ b/examples/MAUI.Demo/Components/UI/DialogTrigger.cs @@ -1,20 +1,7 @@ namespace MAUI.Demo.Components.UI; -// Tap to open dialog. Usage: