npm package: @makigamestudio/ui-ionic
Description: Ionic implementation of @makigamestudio/ui-core. Provides Ionic-specific button components with signal-based state management.
Author: MakiGameStudio
Latest version: 0.9.1

# @makigamestudio/ui-ionic

Ionic 8 component implementations for the ui-core library. This package provides ready-to-use Angular 21 standalone components built with Ionic Framework.

## Features

- **Zoneless Change Detection** — No zone.js dependency
- **Signal-Based Architecture** — Angular Signals for all reactivity
- **Standalone Components** — No NgModules required
- **Ionic 8 Integration** — Native Ionic components and styling
- **TypeScript Strict Mode** — Full type safety

## Installation

```bash
npm install @makigamestudio/ui-ionic @makigamestudio/ui-core
```

### Peer Dependencies

```json
{
  "@angular/core": "^21.0.0",
  "@ionic/angular": "^8.0.0",
  "@makigamestudio/ui-core": "^0.5.0"
}
```

## Theming

### Quick Start

Import the theme file in your global styles to get both CSS variables and component styles:

```scss
// src/global.scss or src/styles.scss
@use '@makigamestudio/ui-ionic/theme.scss';

// Your custom overrides AFTER the import
:root {
  --maki-spacing-md: 16px; // Override default spacing
}
```

**Note:** Importing `theme.scss` automatically includes `global-styles.scss`, so you only need one import.

### Theming Strategy

This library follows a **hybrid approach** for maximum flexibility:

1. **Uses Ionic's color system** — All components use `--ion-color-*` variables (primary, secondary, success, etc.) so they automatically match your app's Ionic theme.

2. **Defines custom design tokens** — Only for properties that Ionic doesn't provide: spacing, border radius, shadows, and typography.

This means when you change your Ionic theme colors, all ui-ionic components automatically adapt. No duplicate configuration needed!

### Import Order

Always import the theme **before** your custom overrides:

```scss
// ✅ CORRECT ORDER
@use '@makigamestudio/ui-ionic/theme.scss';

:root {
  --maki-spacing-lg: 20px;
}

// ❌ WRONG ORDER (overrides won't work)
:root {
  --maki-spacing-lg: 20px;
}
@use '@makigamestudio/ui-ionic/theme.scss'; // This resets your overrides!
```

### Available CSS Variables

All variables include fallback values for robustness.

#### Spacing Scale

```css
--maki-spacing-xs: 4px;
--maki-spacing-sm: 8px;
--maki-spacing-md: 12px;
--maki-spacing-lg: 16px;
--maki-spacing-xl: 24px;
--maki-spacing-xxl: 32px;
```

#### Border Radius Scale

```css
--maki-radius-xs: 4px;
--maki-radius-sm: 8px;
--maki-radius-md: 12px;
--maki-radius-lg: 16px;
--maki-radius-xl: 24px;
--maki-radius-full: 9999px;
```

#### Shadows

```css
--maki-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
/* Dark mode: 0 2px 8px rgba(0, 0, 0, 0.3) */
```

#### Typography - Font Sizes

```css
--maki-font-size-xs: 0.75rem;
--maki-font-size-sm: 0.875rem;
--maki-font-size-md: 1rem;
--maki-font-size-lg: 1.125rem;
--maki-font-size-xl: 1.25rem;
--maki-font-size-xxl: 1.5rem;
--maki-font-size-xxxl: 2rem;
```

#### Typography - Line Heights

```css
--maki-line-height-tight: 1.25;
--maki-line-height-normal: 1.5;
--maki-line-height-relaxed: 1.75;
```

#### Typography - Letter Spacing

```css
--maki-letter-spacing-normal: 0;
--maki-letter-spacing-wide: 0.02em;
```

````

**Note:** Tooltip show/hide delays (250ms/200ms) are intentionally hardcoded in the service to maintain consistent UX and prevent consumers from setting extreme values that would break expected behavior.


**Z-Index Hierarchy:**

```
Modal (10000)         ← Highest layer (blocks everything)
├─ Tooltip (9999)     ← Above popovers, below modals
├─ Popover (9000)     ← Dropdowns and context menus
├─ Sticky (1020)      ← Sticky headers
└─ Dropdown (1000)    ← Basic dropdowns
```

This hierarchy ensures tooltips appear above popovers/dropdowns but below modal backdrops.

### Customization Examples

#### Override Spacing

```scss
@use '@makigamestudio/ui-ionic/theme.scss';

:root {
  --maki-spacing-md: 16px; // Change medium spacing
  --maki-spacing-lg: 24px; // Change large spacing
}
```

#### Change Shadows

```scss
@use '@makigamestudio/ui-ionic/theme.scss';

:root {
  --maki-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); // Stronger shadow
}
```

#### Customize Border Radius

```scss
@use '@makigamestudio/ui-ionic/theme.scss';

:root {
  --maki-radius-sm: 12px; // Rounder tooltips and cards
}
```

### Shadow DOM and RTL Support

The library properly handles Ionic's Shadow DOM components and provides RTL (Right-to-Left) language support through CSS custom properties.

#### Styling Ionic Shadow DOM Components

Ionic components like `ion-button` use Shadow DOM, which prevents regular CSS from penetrating component boundaries. The library uses Ionic's exposed CSS custom properties (e.g., `--padding-start`, `--padding-end`) to style these components:

```typescript
// button.component.ts
ion-button {
  --padding-start: var(--maki-spacing-sm, 0.5rem); // Maps to Shadow DOM
  --padding-end: var(--maki-spacing-sm, 0.5rem);
}
```

**Why this pattern is correct:**

1. **Shadow DOM Encapsulation**: Regular `padding` CSS won't work on `ion-button` — you must use Ionic's custom properties
2. **Consistent Theming**: All spacing uses `--maki-*` tokens, ensuring layout themes affect Ionic components
3. **RTL Support**: `--padding-start`/`--padding-end` automatically flip in RTL languages (unlike `padding-left`/`padding-right`)

**RTL Behavior:**

```
LTR (English):  --padding-start → left,  --padding-end → right
RTL (Arabic):   --padding-start → right, --padding-end → left
```

### Theming Architecture

The library follows a **hybrid theming approach** that cleanly separates UI-agnostic logic from CSS-specific implementation:

#### Separation of Concerns

**ui-core (UI-Agnostic):**

- ✅ Pure computational logic (position calculations, visibility rules)
- ✅ State management with signals
- ✅ Behavioral timing (show/close delays remain hardcoded: 250ms/200ms)
- ❌ No CSS, no DOM manipulation, no framework-specific code

**ui-ionic (Ionic-Specific):**

- ✅ CSS variables and theming
- ✅ DOM manipulation and rendering
- ✅ Ionic component integration
- ✅ CSS transitions and animations

#### Timing Strategy

The library uses a **hybrid timing approach**:

**Behavioral Delays (Service-Controlled):**

```typescript
// ui-core/tooltip.service.ts - Hardcoded for consistent UX
getShowDelayMs(): number { return 250; }  // Wait before showing tooltip
getCloseDelayMs(): number { return 200; } // Grace period before hiding
```

These values are intentionally hardcoded to prevent consumers from setting extreme values (e.g., 5000ms) that would break expected tooltip behavior.

**CSS Timing (Theme-Controlled):**

```scss
// ui-ionic/theme.scss - Themable for visual preferences
```

These values can be customized per layout theme (compact uses faster transitions, comfortable uses slower).

**Why This Separation:**

- **Behavioral delays** define UX patterns (how long to wait before showing)
- **CSS timing** defines visual polish (how fast animations feel)
- Mixing these concerns would make layout themes accidentally break tooltip interaction patterns

### Dark Mode Toggle

The library is **theme-agnostic** and works with both light and dark modes. Theme customization and dark mode implementation is the **consumer's responsibility**. The playground app demonstrates a complete dark theme implementation pattern.

#### 1. Create a Theme Toggle Service

Create a signal-based service to manage theme state:

```typescript
// services/theme-toggle.service.ts
import { effect, inject, Injectable, Renderer2, RendererFactory2, signal } from '@angular/core';
import { DOCUMENT } from '@angular/common';

export type ThemeMode = 'light' | 'dark';

@Injectable({ providedIn: 'root' })
export class ThemeToggleService {
  private readonly document = inject(DOCUMENT);
  private readonly renderer: Renderer2;
  private readonly storageKey = 'theme-mode';

  private readonly _colorThemeMode = signal<ThemeMode>(this.getInitialColorTheme());
  readonly mode = this._colorThemeMode.asReadonly();

  constructor() {
    const rendererFactory = inject(RendererFactory2);
    this.renderer = rendererFactory.createRenderer(null, null);

    // Apply initial theme class
    this.applyThemeClass(this._colorThemeMode());

    // Batch localStorage writes using effect
    effect(() => {
      const currentMode = this._colorThemeMode();
      try {
        localStorage.setItem(this.storageKey, currentMode);
      } catch (error) {
        console.error('Failed to persist theme:', error);
      }
    });
  }

  toggleColorTheme(): void {
    this.setColorTheme(this._colorThemeMode() === 'light' ? 'dark' : 'light');
  }

  setColorTheme(mode: ThemeMode): void {
    this._colorThemeMode.set(mode);
    this.applyThemeClass(mode);
  }

  private getInitialColorTheme(): ThemeMode {
    try {
      const stored = localStorage.getItem(this.storageKey);
      if (stored === 'light' || stored === 'dark') return stored;
    } catch {}

    // Fallback to prefers-color-scheme
    if (this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }
    return 'light';
  }

  private applyThemeClass(mode: ThemeMode): void {
    const body = this.document.body;
    if (!body) return;

    if (mode === 'dark') {
      this.renderer.addClass(body, 'dark');
    } else {
      this.renderer.removeClass(body, 'dark');
    }
  }
}
```

#### 2. Define Dark Theme Variables

Create a dark theme stylesheet with Ionic's recommended dark palette:

```scss
// theme-dark.scss
body.dark {
  // Ionic Core Colors (Dark Mode)
  --ion-background-color: #121212;
  --ion-text-color: #ffffff;

  // Primary - Lighter for better contrast on dark backgrounds
  --ion-color-primary: #428cff;
  --ion-color-primary-contrast: #ffffff;

  // Secondary
  --ion-color-secondary: #50c8ff;
  --ion-color-secondary-contrast: #ffffff;

  // Success
  --ion-color-success: #2fdf75;
  --ion-color-success-contrast: #000000;

  // Warning
  --ion-color-warning: #ffd534;
  --ion-color-warning-contrast: #000000;

  // Danger
  --ion-color-danger: #ff4961;
  --ion-color-danger-contrast: #ffffff;

  // Light - Inverted (becomes darker)
  --ion-color-light: #222428;
  --ion-color-light-contrast: #ffffff;

  // Medium
  --ion-color-medium: #989aa2;
  --ion-color-medium-contrast: #000000;

  // Dark - Inverted (becomes lighter)
  --ion-color-dark: #f4f5f8;
  --ion-color-dark-contrast: #000000;

  // Maki Design Tokens
  --maki-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
```

#### 3. Import Dark Theme

Add the dark theme import to your global styles:

```scss
// styles.scss
@use '@makigamestudio/ui-ionic/theme.scss';
@use './theme-dark.scss'; // Your dark theme overrides
```

#### 4. Add Theme Toggle UI

Use the theme service in your components:

```typescript
import { computed, inject } from '@angular/core';
import { IonToggle } from '@ionic/angular/standalone';
import { ThemeToggleService } from './services/theme-toggle.service';

@Component({
  imports: [IonToggle],
  template: `
    <ion-toggle
      [checked]="isDark()"
      (ionChange)="onThemeToggle($event)"
      aria-label="Toggle dark mode"
    />
  `
})
export class MyComponent {
  readonly themeService = inject(ThemeToggleService);
  readonly isDark = computed(() => this.themeService.mode() === 'dark');

  onThemeToggle(event: CustomEvent): void {
    this.themeService.setColorTheme(event.detail.checked ? 'dark' : 'light');
  }
}
```

#### Key Points

- **CSS Variables Required**: Always use CSS variables in your components (e.g., `var(--ion-color-primary)`) so they adapt automatically to theme changes
- **Class-Based Theming**: The service applies/removes the `dark` class on `<body>` to trigger theme overrides
- **Persistence**: Theme preference is saved to `localStorage` and restored on app load
- **Fallback Support**: Automatically detects `prefers-color-scheme: dark` if no saved preference exists
- **Signal-Based**: Fully reactive using Angular signals for zoneless compatibility

For a complete working example, see the [playground app](../../projects/playground/src/app/home/home.page.ts) source code.

### Layout Theme Toggle

In addition to color themes (light/dark), you can implement layout themes to adjust spacing, typography for different user preferences. The playground demonstrates three layout modes: **compact**, **default**, and **comfortable**.

#### Layout Theme Comparison

| Property           | Compact | Default (Baseline) | Comfortable |
| ------------------ | ------- | ------------------ | ----------- |
| **Line Height**    |         |                    |             |
| Tight              | 1.2     | 1.25               | 1.4         |
| Normal             | 1.3     | 1.5                | 1.75        |
| Relaxed            | 1.5     | 1.75               | 2.0         |
| **Letter Spacing** |         |                    |             |
| Normal             | 0       | 0                  | 0           |
| Wide               | 0.02em  | 0.02em             | 0.04em      |


#### Layout Theme Options

- **Compact**: Reduced spacing (15-20% smaller), tighter line heights — ideal for information-dense interfaces or large screens
- **Default**: Standard spacing and typography (library baseline) — balanced for most use cases
- **Comfortable**: Increased spacing (20-25% larger), relaxed line heights, wider letter spacing — better accessibility and readability

#### 1. Create Layout Theme Stylesheets

Create separate SCSS files for each layout variant:

```scss
// theme-layout-compact.scss
body.layout-compact {
  // Reduced spacing (20% smaller)
  --maki-spacing-xxs: 0.1rem;
  --maki-spacing-xs: 0.2rem;
  --maki-spacing-sm: 0.4rem;
  --maki-spacing-md: 0.6rem;
  --maki-spacing-lg: 0.8rem;
  --maki-spacing-xl: 1.2rem;
  --maki-spacing-xxl: 1.6rem;

  // Tighter typography
  --maki-font-size-xs: 0.6875rem;
  --maki-font-size-sm: 0.8125rem;
  --maki-font-size-md: 0.9375rem;
  // ... other font sizes

  // Smaller border radius
  --maki-radius-xs: 0.2rem;
  --maki-radius-sm: 0.375rem;
  // ... other radius values
}

// theme-layout-comfortable.scss
body.layout-comfortable {
  // Increased spacing (25% larger)
  --maki-spacing-xxs: 0.15625rem;
  --maki-spacing-xs: 0.3125rem;
  --maki-spacing-sm: 0.625rem;
  --maki-spacing-md: 0.9375rem;
  --maki-spacing-lg: 1.25rem;
  --maki-spacing-xl: 1.875rem;
  --maki-spacing-xxl: 2.5rem;

  // Larger typography for readability
  --maki-font-size-xs: 0.8125rem;
  --maki-font-size-sm: 0.9375rem;
  --maki-font-size-md: 1.0625rem;
  // ... other font sizes

  // Larger border radius
  --maki-radius-xs: 0.3125rem;
  --maki-radius-sm: 0.625rem;
  // ... other radius values
}
```

#### 2. Update Theme Service

Extend your `ThemeToggleService` to manage layout modes:

```typescript
export type LayoutMode = 'compact' | 'default' | 'comfortable';

@Injectable({ providedIn: 'root' })
export class ThemeToggleService {
  private readonly layoutStorageKey = 'layout-mode';
  private readonly _layoutThemeMode = signal<LayoutMode>(this.getInitialLayoutTheme());
  readonly layoutThemeMode = this._layoutThemeMode.asReadonly();

  constructor() {
    // ... existing color theme setup

    // Apply initial layout class
    this.applyLayoutClass(this._layoutThemeMode());

    // Batch layout localStorage writes
    effect(() => {
      const currentLayoutMode = this._layoutThemeMode();
      try {
        localStorage.setItem(this.layoutStorageKey, currentLayoutMode);
      } catch (error) {
        console.error('Failed to persist layout theme:', error);
      }
    });
  }

  setLayoutTheme(mode: LayoutMode): void {
    this._layoutThemeMode.set(mode);
    this.applyLayoutClass(mode);
  }

  private getInitialLayoutTheme(): LayoutMode {
    try {
      const stored = localStorage.getItem(this.layoutStorageKey);
      if (stored === 'compact' || stored === 'default' || stored === 'comfortable') {
        return stored;
      }
    } catch {}
    return 'default';
  }

  private applyLayoutClass(mode: LayoutMode): void {
    const body = this.document.body;
    if (!body) return;

    // Remove all layout classes
    this.renderer.removeClass(body, 'layout-compact');
    this.renderer.removeClass(body, 'layout-comfortable');

    // Apply appropriate class (default has no class)
    if (mode === 'compact') {
      this.renderer.addClass(body, 'layout-compact');
    } else if (mode === 'comfortable') {
      this.renderer.addClass(body, 'layout-comfortable');
    }
  }
}
```

#### 3. Import Layout Themes

Add layout theme imports to your global styles:

```scss
// styles.scss
@use '@makigamestudio/ui-ionic/theme.scss';
@use './theme-dark.scss';
@use './theme-layout-compact.scss';
@use './theme-layout-comfortable.scss';
```

#### 4. Add Layout Switcher UI

Use `ion-segment` for layout mode selection:

```typescript
import { computed, inject } from '@angular/core';
import { IonSegment, IonSegmentButton, IonLabel } from '@ionic/angular/standalone';

@Component({
  imports: [IonSegment, IonSegmentButton, IonLabel],
  template: `
    <ion-segment [value]="layoutThemeMode()" (ionChange)="onLayoutChange($event)">
      <ion-segment-button value="compact">
        <ion-label>Compact</ion-label>
      </ion-segment-button>
      <ion-segment-button value="default">
        <ion-label>Default</ion-label>
      </ion-segment-button>
      <ion-segment-button value="comfortable">
        <ion-label>Comfortable</ion-label>
      </ion-segment-button>
    </ion-segment>
  `
})
export class MyComponent {
  readonly themeService = inject(ThemeToggleService);
  readonly layoutThemeMode = computed(() => this.themeService.layoutThemeMode());

  onLayoutChange(event: CustomEvent): void {
    const layout = event.detail.value as LayoutMode;
    this.themeService.setLayoutTheme(layout);
  }
}
```

#### Key Points

- **Independent Themes**: Color and layout themes work independently — combine light/dark with any layout mode
- **Signal-Based**: Use `computed()` to derive component state from service signals for zoneless compatibility
- **Default Has No Class**: The `default` layout uses the base theme variables (no class applied)
- **Consistent Variable Names**: Layout themes only override `--maki-*` spacing, typography, and radius variables
- **Accessibility Friendly**: `comfortable` mode improves readability for users who need larger text

For complete examples including both color and layout themes, see the [playground app](../../projects/playground/src/app/home/home.page.ts) source code.

## Components

### ButtonComponent

Ionic button with loading state, icon support, and dropdown functionality.

```typescript
import { ButtonComponent } from '@makigamestudio/ui-ionic';

// In your component
button: IonicActionButton = {
  type: ActionButtonType.Button,
  label: 'Save',
  icon: 'save-outline',
  color: 'primary',
  fill: 'solid'
};
```

```html
<maki-button [button]="button" />
```

### ActionButtonListComponent

Popover list of action buttons.

```typescript
import { ActionButtonListComponent } from '@makigamestudio/ui-ionic';

buttons: IonicActionButton[] = [
  { type: ActionButtonType.Button, label: 'Edit', icon: 'create-outline' },
  { type: ActionButtonType.Button, label: 'Delete', icon: 'trash-outline', color: 'danger' }
];
```

```html
<maki-action-button-list [buttons]="buttons" />
```

### MakiTooltipDirective

Device-aware tooltips with Ionic color support.

```html
<button makiTooltip="Click to save" makiTooltipColor="primary">Save</button>
```

## Development

### Building the Library

```bash
npm run build:ionic
```

### Testing

```bash
npm run test:ionic
```

### Watch Mode

```bash
npm run watch:ionic
```

## License

MIT

## More Information

For detailed API documentation, examples, and architectural guidelines, see the [GitHub repository](https://github.com/gdor/ui-core).
````