Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ createApp(App)
## Documentation

- [Using ion-item-group](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/using-ion-item-group) — shared inset-list markup for iOS 26 and MD3.
- [Special markup](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/special-markup) — opt-in component combinations used by the demo.
- [Special markup](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/special-markup) — opt-in component combinations used by the demo, including the shared `ion-toolbar.tab-accessory` mini-player.
- [ESLint](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/eslint) — check list structure with ESLint rules.
- [Migration](https://docs.rdlabo.dev/projects/ionic-theme-md3/docs/migration) — changes required when updating theme markup.

Expand Down
19 changes: 19 additions & 0 deletions demo/src/app/tabs/tabs.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@

<section id="main-content">
<ion-tabs>
<ion-toolbar class="tab-accessory" [class.tab-bar-hidden]="!showAccessory" (click)="onAccessoryActivate()">
<ion-thumbnail slot="start">
<img data-tab-accessory="artwork" src="https://picsum.photos/128/128?id=12" alt="" />
</ion-thumbnail>
<ion-label>
<h2 data-tab-accessory="title">Now Playing</h2>
<p data-tab-accessory="subtitle">Demo Artist</p>
</ion-label>
<ion-buttons slot="end">
<span data-tab-accessory="time">
<span data-tab-accessory="elapsed">1:24</span>
<span data-tab-accessory="duration">3:45</span>
</span>
<ion-button data-tab-accessory="play" fill="clear" (click)="togglePlay($event)" [attr.aria-label]="playing ? 'Pause' : 'Play'">
<ion-icon slot="icon-only" [name]="playing ? 'pause' : 'play'" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
<ion-progress-bar value="0.4"></ion-progress-bar>
</ion-toolbar>
<ion-tab-bar id="tab-bar-bottom" slot="bottom">
<ion-tab-button tab="index" href="/main/index">
<ion-icon aria-hidden="true" name="documents"></ion-icon>
Expand Down
87 changes: 84 additions & 3 deletions demo/src/app/tabs/tabs.page.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,103 @@
import { Component } from '@angular/core';
import { Component, DestroyRef, ElementRef, inject, OnDestroy, OnInit } from '@angular/core';
import {
IonButton,
IonButtons,
IonContent,
IonIcon,
IonItem,
IonItemGroup,
IonLabel,
IonList,
IonMenu,
IonProgressBar,
IonSplitPane,
IonTabBar,
IonTabButton,
IonTabs,
IonThumbnail,
IonToolbar,
ViewDidEnter,
ViewDidLeave,
} from '@demo/ionic';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

import { enableTabAccessory, type TabAccessoryHandle } from '../../../../src';

@Component({
selector: 'app-tabs',
templateUrl: 'tabs.page.html',
styleUrls: ['tabs.page.scss'],
imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, IonSplitPane, IonMenu, IonContent, IonList, IonItemGroup, IonItem],
imports: [
IonTabs,
IonTabBar,
IonTabButton,
IonIcon,
IonLabel,
IonSplitPane,
IonMenu,
IonContent,
IonList,
IonItemGroup,
IonItem,
IonToolbar,
IonThumbnail,
IonButton,
IonButtons,
IonProgressBar,
],
})
export class TabsPage {}
export class TabsPage implements OnInit, OnDestroy, ViewDidEnter, ViewDidLeave {
readonly #router = inject(Router);
readonly #el = inject(ElementRef);
readonly #destroyRef = inject(DestroyRef);
#accessory?: TabAccessoryHandle;
playing = true;
showAccessory = false;
accessoryActivated = false;

ngOnInit() {
this.#router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntilDestroyed(this.#destroyRef),
)
.subscribe((params) => {
const tabBar = this.#el.nativeElement.querySelector('ion-tab-bar');
if (!tabBar) {
return;
}
const path = params.urlAfterRedirects.split(/[?#]/, 1)[0];
const hideTabs = ['/main/settings'].includes(path);
if (hideTabs) {
tabBar.classList.add('tab-bar-hidden');
} else {
tabBar.classList.remove('tab-bar-hidden');
}
this.showAccessory = !hideTabs && (path === '/main/album' || /[?&]miniPlayer(?:=|$|&)/.test(params.urlAfterRedirects));
});
}

ngOnDestroy() {
this.ionViewDidLeave();
}

togglePlay(event: Event) {
event.stopPropagation();
this.playing = !this.playing;
}

onAccessoryActivate() {
this.accessoryActivated = true;
}

ionViewDidEnter() {
this.#accessory = enableTabAccessory();
}

ionViewDidLeave() {
this.#accessory?.destroy();
this.#accessory = undefined;
}
}
159 changes: 159 additions & 0 deletions demo/src/tab-accessory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
attachTabAccessory,
enableTabAccessory,
TAB_ACCESSORY_GESTURE_ACTIVE_CLASS,
TAB_ACCESSORY_PANNING_X_CLASS,
} from '../../src/tab-accessory';

const gestureMocks = vi.hoisted(() => ({
createGesture: vi.fn(() => ({ enable: vi.fn(), destroy: vi.fn() })),
}));

vi.mock('@ionic/core', () => ({
createGesture: gestureMocks.createGesture,
}));

const createGestureMock = gestureMocks.createGesture;

type GestureConfig = {
el: HTMLElement;
direction?: string;
gesturePriority?: number;
passive?: boolean;
disableScroll?: boolean;
maxAngle?: number;
canStart?: (detail: { event?: Event }) => boolean;
onStart?: (detail?: unknown) => void;
onMove?: (detail: { deltaY: number }) => void;
onEnd?: (detail: { deltaY: number; velocityY?: number }) => void;
};

const config = (): GestureConfig => {
const last = createGestureMock.mock.calls.at(-1) as [GestureConfig] | undefined;
if (!last) throw new Error('createGesture was not called');
return last[0];
};

const mount = (lift = false, alias = false) => {
const clip = document.createElement('div');
if (lift) clip.className = 'tab-accessory-lift';
const toolbarClass = alias ? 'md ios-theme-tab-accessory' : 'md tab-accessory';
clip.innerHTML = `
<ion-toolbar class="${toolbarClass}">
<img data-tab-accessory="artwork" alt="" />
<ion-button data-tab-accessory="play"></ion-button>
</ion-toolbar>`;
document.body.append(clip);
return {
clip,
toolbar: clip.querySelector<HTMLElement>('ion-toolbar')!,
play: clip.querySelector<HTMLElement>('ion-button')!,
};
};

beforeEach(() => {
createGestureMock.mockClear();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
});

test('attaches an upward swipe gesture with follow-the-finger priority', () => {
const { toolbar } = mount();
const effect = attachTabAccessory(toolbar);
expect(createGestureMock).toHaveBeenCalledTimes(1);
expect(config().el).toBe(toolbar);
expect(config().direction).toBe('y');
expect(config().gesturePriority).toBe(20);
expect(config().passive).toBe(false);
expect(config().disableScroll).toBe(true);
expect(config().maxAngle).toBe(45);
effect?.destroy();
});

test('attaches to the ios-theme-tab-accessory alias', () => {
const { toolbar } = mount(false, true);
attachTabAccessory(toolbar);
expect(createGestureMock).toHaveBeenCalledTimes(1);
});

test('lifts a marked wrapper instead of the toolbar', () => {
const { toolbar, clip } = mount(true);
attachTabAccessory(toolbar);
expect(config().el).toBe(clip);
});

test('clicks the toolbar after a -50px swipe-up', () => {
const { toolbar } = mount();
const click = vi.fn();
toolbar.addEventListener('click', click);
attachTabAccessory(toolbar);
config().onEnd?.({ deltaY: -80, velocityY: 0 } as never);
expect(click).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(click).toHaveBeenCalledTimes(1);
});

test('clicks the toolbar on a fast upward flick', () => {
const { toolbar } = mount();
const click = vi.fn();
toolbar.addEventListener('click', click);
attachTabAccessory(toolbar);
config().onEnd?.({ deltaY: -10, velocityY: -0.5 } as never);
vi.advanceTimersByTime(200);
expect(click).toHaveBeenCalledTimes(1);
});

test('snaps back when the swipe is too small', () => {
const { toolbar } = mount();
const click = vi.fn();
toolbar.addEventListener('click', click);
attachTabAccessory(toolbar);
config().onStart?.({} as never);
config().onEnd?.({ deltaY: -20, velocityY: -0.1 } as never);
expect(click).not.toHaveBeenCalled();
expect(toolbar.style.transform).toBe('translateY(0)');
});

test('follows the finger and fades while dragging up', () => {
const { toolbar } = mount();
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
attachTabAccessory(toolbar);
config().onStart?.({} as never);
config().onMove?.({ deltaY: -80 } as never);
expect(toolbar.style.transform).toBe('translateY(-80px)');
expect(toolbar.classList.contains(TAB_ACCESSORY_GESTURE_ACTIVE_CLASS)).toBe(true);
expect(Number(toolbar.style.opacity)).toBeLessThan(1);
});

test('does not start on the play control', () => {
const { toolbar, play } = mount();
attachTabAccessory(toolbar);
const event = { target: play } as unknown as Event;
expect(config().canStart?.({ event } as never)).toBe(false);
});

test('does not activate while the host owns a horizontal pan', () => {
const { toolbar, clip } = mount(true);
const click = vi.fn();
toolbar.addEventListener('click', click);
attachTabAccessory(toolbar);
clip.classList.add(TAB_ACCESSORY_PANNING_X_CLASS);
config().onEnd?.({ deltaY: -80, velocityY: 0 } as never);
vi.advanceTimersByTime(200);
expect(click).not.toHaveBeenCalled();
});

test('enableTabAccessory attaches and tears down observed toolbars', () => {
const { toolbar } = mount();
const handle = enableTabAccessory();
expect(createGestureMock).toHaveBeenCalled();
expect(config().el).toBe(toolbar);
handle.destroy();
const gesture = createGestureMock.mock.results.at(-1)?.value as { destroy: ReturnType<typeof vi.fn> };
expect(gesture.destroy).toHaveBeenCalled();
});
22 changes: 22 additions & 0 deletions docs/special-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,28 @@ This is an introductory group. Place regular list items in a separate `ion-item-
</ion-list>
```

## Tab accessory mini-player

Add `.tab-accessory` to an `ion-toolbar` above `ion-tab-bar` (not inside `ion-content`). This theme styles the Material capsule. The same markup works with `@rdlabo/ionic-theme-ios27` (`ios-theme-tab-accessory` is kept as an alias). `.md3-disabled` opts an individual toolbar out. Call `enableTabAccessory()` once from this package for swipe-up; do not also call the ios27 copy.

```html preview
<ion-toolbar class="tab-accessory">
<ion-thumbnail slot="start">
<img data-tab-accessory="artwork" src="cover.jpg" alt="" />
</ion-thumbnail>
<ion-label>
<h2 data-tab-accessory="title">Now Playing</h2>
<p data-tab-accessory="subtitle">Artist</p>
</ion-label>
<ion-button slot="end" data-tab-accessory="play" fill="clear">
<ion-icon slot="icon-only" name="pause"></ion-icon>
</ion-button>
<ion-progress-bar value="0.4"></ion-progress-bar>
</ion-toolbar>
```

Override layout with `--md3-tab-accessory-height`, `--md3-tab-accessory-inset`, `--md3-tab-accessory-radius`, and `--md3-tab-accessory-bottom`. Hosts that swipe the bar can wrap it in `.accessory-slide-clip`.

## Opting out

Add `.md3-disabled` to an individual Ionic component when it must retain Ionic's standard Material styling.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"",
"fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"",
"release": "np --no-tests --no-publish",
"prepare": "npm run build",
"prepublishOnly": "npm run build",
"test:esm": "rdlabo-check-esm"
},
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './transition/md.transition';
export * from './tab-accessory';
Loading