feat(button-group): add IonButtonGroup component with styles and events

This commit is contained in:
BenOsodrac
2025-05-06 17:15:37 +01:00
parent 3b643a75d1
commit cbe85730fb
10 changed files with 490 additions and 0 deletions

View File

@ -474,6 +474,12 @@ ion-button,css-prop,--transition,ios
ion-button,css-prop,--transition,md
ion-button,part,native
ion-button-group,scoped
ion-button-group,prop,mode,"ios" | "md",undefined,false,false
ion-button-group,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-button-group,prop,value,null | number | string,null,false,false
ion-button-group,event,ionChange,{ value: string | number | null; activeIndex: number; },true
ion-buttons,scoped
ion-buttons,prop,collapse,boolean,false,false,false
ion-buttons,prop,mode,"ios" | "md",undefined,false,false

View File

@ -607,6 +607,24 @@ export namespace Components {
*/
"type": 'submit' | 'reset' | 'button';
}
interface IonButtonGroup {
"color"?: Color;
"fill"?: 'outline' | 'solid';
/**
* The mode determines the platform behaviors of the component.
*/
"mode"?: "ios" | "md";
"shape"?: 'soft' | 'round' | 'rectangular';
"size"?: 'small' | 'default' | 'large';
/**
* The theme determines the visual appearance of the component.
*/
"theme"?: "ios" | "md" | "ionic";
/**
* The value of the currently selected button.
*/
"value": string | number | null;
}
interface IonButtons {
/**
* If true, buttons will disappear when its parent toolbar has fully collapsed if the toolbar is not the first toolbar. If the toolbar is the first toolbar, the buttons will be hidden and will only be shown once all toolbars have fully collapsed. Only applies in the `ios` theme with `collapse` set to `true` on `ion-header`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles)
@ -4019,6 +4037,10 @@ export interface IonButtonCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonButtonElement;
}
export interface IonButtonGroupCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonButtonGroupElement;
}
export interface IonCheckboxCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLIonCheckboxElement;
@ -4351,6 +4373,24 @@ declare global {
prototype: HTMLIonButtonElement;
new (): HTMLIonButtonElement;
};
interface HTMLIonButtonGroupElementEventMap {
"ionChange": { value: string | number | null; activeIndex: number };
"ionValueChange": { value: string | number | null};
}
interface HTMLIonButtonGroupElement extends Components.IonButtonGroup, HTMLStencilElement {
addEventListener<K extends keyof HTMLIonButtonGroupElementEventMap>(type: K, listener: (this: HTMLIonButtonGroupElement, ev: IonButtonGroupCustomEvent<HTMLIonButtonGroupElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLIonButtonGroupElementEventMap>(type: K, listener: (this: HTMLIonButtonGroupElement, ev: IonButtonGroupCustomEvent<HTMLIonButtonGroupElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLIonButtonGroupElement: {
prototype: HTMLIonButtonGroupElement;
new (): HTMLIonButtonGroupElement;
};
interface HTMLIonButtonsElement extends Components.IonButtons, HTMLStencilElement {
}
var HTMLIonButtonsElement: {
@ -5419,6 +5459,7 @@ declare global {
"ion-breadcrumb": HTMLIonBreadcrumbElement;
"ion-breadcrumbs": HTMLIonBreadcrumbsElement;
"ion-button": HTMLIonButtonElement;
"ion-button-group": HTMLIonButtonGroupElement;
"ion-buttons": HTMLIonButtonsElement;
"ion-card": HTMLIonCardElement;
"ion-card-content": HTMLIonCardContentElement;
@ -6088,6 +6129,29 @@ declare namespace LocalJSX {
*/
"type"?: 'submit' | 'reset' | 'button';
}
interface IonButtonGroup {
"color"?: Color;
"fill"?: 'outline' | 'solid';
/**
* The mode determines the platform behaviors of the component.
*/
"mode"?: "ios" | "md";
/**
* Emitted when the active button changes.
*/
"onIonChange"?: (event: IonButtonGroupCustomEvent<{ value: string | number | null; activeIndex: number }>) => void;
"onIonValueChange"?: (event: IonButtonGroupCustomEvent<{ value: string | number | null}>) => void;
"shape"?: 'soft' | 'round' | 'rectangular';
"size"?: 'small' | 'default' | 'large';
/**
* The theme determines the visual appearance of the component.
*/
"theme"?: "ios" | "md" | "ionic";
/**
* The value of the currently selected button.
*/
"value"?: string | number | null;
}
interface IonButtons {
/**
* If true, buttons will disappear when its parent toolbar has fully collapsed if the toolbar is not the first toolbar. If the toolbar is the first toolbar, the buttons will be hidden and will only be shown once all toolbars have fully collapsed. Only applies in the `ios` theme with `collapse` set to `true` on `ion-header`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles)
@ -9579,6 +9643,7 @@ declare namespace LocalJSX {
"ion-breadcrumb": IonBreadcrumb;
"ion-breadcrumbs": IonBreadcrumbs;
"ion-button": IonButton;
"ion-button-group": IonButtonGroup;
"ion-buttons": IonButtons;
"ion-card": IonCard;
"ion-card-content": IonCardContent;
@ -9682,6 +9747,7 @@ declare module "@stencil/core" {
"ion-breadcrumb": LocalJSX.IonBreadcrumb & JSXBase.HTMLAttributes<HTMLIonBreadcrumbElement>;
"ion-breadcrumbs": LocalJSX.IonBreadcrumbs & JSXBase.HTMLAttributes<HTMLIonBreadcrumbsElement>;
"ion-button": LocalJSX.IonButton & JSXBase.HTMLAttributes<HTMLIonButtonElement>;
"ion-button-group": LocalJSX.IonButtonGroup & JSXBase.HTMLAttributes<HTMLIonButtonGroupElement>;
"ion-buttons": LocalJSX.IonButtons & JSXBase.HTMLAttributes<HTMLIonButtonsElement>;
"ion-card": LocalJSX.IonCard & JSXBase.HTMLAttributes<HTMLIonCardElement>;
"ion-card-content": LocalJSX.IonCardContent & JSXBase.HTMLAttributes<HTMLIonCardContentElement>;

View File

@ -0,0 +1,102 @@
@use "../../themes/ionic/ionic.globals.scss" as globals;
:host {
--border-radius: #{globals.$ion-border-radius-full};
display: flex;
position: relative;
border-radius: var(--border-radius);
background-color: #f2f4fd;
}
.active-indicator {
position: absolute;
top: 0;
bottom: 0;
left: 0;
transition: transform 0.3s ease;
border-radius: var(--border-radius);
background-color: #105cef;
z-index: 0;
}
::slotted(ion-button) {
--color: #105cef;
--background-hover: transparent;
--background-activated: transparent;
flex: 1;
transition: color 0.3s ease;
background-color: transparent;
z-index: 1;
}
::slotted(ion-button.active) {
color: #fff;
}
// ButtonGroup Shapes
// -------------------------------------------------------------------------------
// Soft Button
// --------------------------------------------------
:host(.button-group-soft) {
--border-radius: #{globals.$ion-border-radius-200};
}
// Round Button
// --------------------------------------------------
:host(.button-group-round) {
--border-radius: #{globals.$ion-border-radius-full};
}
// Rectangular Button
// --------------------------------------------------
:host(.button-group-rectangular) {
--border-radius: #{globals.$ion-border-radius-0};
}
// ButtonGroup Ouline
// -------------------------------------------------------------------------------
:host(.button-group-outline) .active-indicator {
border: 1px solid #105cef;
background-color: #fff;
}
:host(.button-group-outline)::slotted(ion-button.active) {
color: #105cef;
}
// ButtonGroup Medium
// -------------------------------------------------------------------------------
:host(.ion-color-medium) {
background-color: #EFEFEF;
}
:host(.ion-color-medium) .active-indicator {
background-color: #3B3B3B;
}
:host(.ion-color-medium)::slotted(ion-button.active) {
--color: #fff;
}
:host(.ion-color-medium)::slotted(ion-button) {
--color: #242424;
}

View File

@ -0,0 +1,109 @@
import type { ComponentInterface , EventEmitter} from '@stencil/core';
import { Component, Host, Prop, h, Element, State, Event, Watch } from '@stencil/core';
import { createColorClasses, hostContext } from '@utils/theme';
import { getIonTheme } from '../../global/ionic-global';
import type { Color } from '../../interface';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
* @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component.
*/
@Component({
tag: 'ion-button-group',
styleUrls: {
ios: 'button-group.ionic.scss',
md: 'button-group.ionic.scss',
ionic: 'button-group.ionic.scss',
},
scoped: true,
})
export class ButtonGroup implements ComponentInterface {
@Element() el!: HTMLElement;
@State() activeIndex: number = 0;
@Prop({ reflect: true, mutable: true }) fill?: 'outline' | 'solid';
@Prop({ reflect: true }) shape?: 'soft' | 'round' | 'rectangular';
@Prop({ reflect: true }) size?: 'small' | 'default' | 'large';
@Prop({ reflect: true }) color?: Color;
/**
* The value of the currently selected button.
*/
@Prop({ mutable: true, reflect: true }) value: string | number | null = null;
/**
* Emitted when the active button changes.
*/
@Event() ionChange!: EventEmitter<{ value: string | number | null; activeIndex: number }>;
@Event() ionValueChange!: EventEmitter<{ value: string | number | null}>;
@Watch('value')
valueChanged(value: any | undefined) {
this.ionValueChange.emit({ value });
}
private getButtons(): HTMLIonButtonElement[] {
return Array.from(this.el.querySelectorAll('ion-button'));
}
private handleButtonClick(index: number, value: string | number | null) {
this.activeIndex = index;
this.value = value;
this.ionChange.emit({ value, activeIndex: index });
}
private renderActiveIndicator() {
const buttons = this.getButtons();
const indicatorStyle = {
width: `${100 / buttons.length}%`,
transform: `translateX(${this.activeIndex * 100}%)`,
};
return <div class="active-indicator" style={indicatorStyle} />;
}
componentWillLoad() {
// Initialize the active button based on the value prop
const buttons = Array.from(this.el.querySelectorAll('ion-button'));
const initialIndex = buttons.findIndex((button) => button.getAttribute('value') === `${this.value}`);
if (initialIndex !== -1) {
this.activeIndex = initialIndex;
}
}
render() {
const {color, size, shape, fill} = this;
const theme = getIonTheme(this);
const buttons = this.getButtons();
return (
<Host
class={createColorClasses(color, {
[theme]: true,
[`button-group-${size}`]: size !== undefined,
[`button-group-${shape}`]: true,
[`button-group-${fill}`]: true,
'in-toolbar': hostContext('ion-toolbar', this.el)
})}
>
{this.renderActiveIndicator()}
<slot></slot>
{buttons.map((button, index) => {
button.fill = 'clear';
button.shape = shape;
button.size = size;
button.onclick = () => this.handleButtonClick(index, button.getAttribute('value'));
button.classList.toggle('active', index === this.activeIndex);
})}
</Host>
);
}
}

View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en" dir="ltr" mode="md" theme="ionic">
<head>
<meta charset="UTF-8" />
<title>Button - Basic</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../css/ionic/bundle.ionic.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>ButtonGroup - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" id="content" no-bounce>
<p class="ion-margin">
<ion-title>Default</ion-title>
<ion-button-group id="buttonGroup" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Shape: rectangular</ion-title>
<ion-button-group shape="rectangular" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Shape: soft</ion-title>
<ion-button-group shape="soft" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Shape: round</ion-title>
<ion-button-group shape="round" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Size: small</ion-title>
<ion-button-group size="small" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Size: medium</ion-title>
<ion-button-group size="default" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Size: large</ion-title>
<ion-button-group size="large" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Fill: filled</ion-title>
<ion-button-group fill="solid" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Fill: outline</ion-title>
<ion-button-group fill="outline" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Color: primary</ion-title>
<ion-button-group color="primary" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<p class="ion-margin">
<ion-title>Color: neutral</ion-title>
<ion-button-group color="medium" value="Second">
<ion-button value="First">First</ion-button>
<ion-button value="Second">Second</ion-button>
<ion-button value="Third">Third</ion-button>
</ion-button-group>
</p>
<script>
const buttonGroup = document.getElementById('buttonGroup');
buttonGroup.addEventListener('ionValueChange', (event) => {
console.log('Value changed (manual listener):', event.detail.value);
});
</script>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -13,6 +13,7 @@ export const DIRECTIVES = [
d.IonBreadcrumb,
d.IonBreadcrumbs,
d.IonButton,
d.IonButtonGroup,
d.IonButtons,
d.IonCard,
d.IonCardContent,

View File

@ -376,6 +376,36 @@ export declare interface IonButton extends Components.IonButton {
}
@ProxyCmp({
inputs: ['color', 'fill', 'mode', 'shape', 'size', 'theme', 'value']
})
@Component({
selector: 'ion-button-group',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['color', 'fill', 'mode', 'shape', 'size', 'theme', 'value'],
})
export class IonButtonGroup {
protected el: HTMLIonButtonGroupElement;
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
c.detach();
this.el = r.nativeElement;
proxyOutputs(this, this.el, ['ionChange', 'ionValueChange']);
}
}
export declare interface IonButtonGroup extends Components.IonButtonGroup {
/**
* Emitted when the active button changes.
*/
ionChange: EventEmitter<CustomEvent<{ value: string | number | null; activeIndex: number }>>;
ionValueChange: EventEmitter<CustomEvent<{ value: string | number | null}>>;
}
@ProxyCmp({
inputs: ['collapse', 'mode', 'theme']
})

View File

@ -17,6 +17,7 @@ import { defineCustomElement as defineIonBadge } from '@ionic/core/components/io
import { defineCustomElement as defineIonBreadcrumb } from '@ionic/core/components/ion-breadcrumb.js';
import { defineCustomElement as defineIonBreadcrumbs } from '@ionic/core/components/ion-breadcrumbs.js';
import { defineCustomElement as defineIonButton } from '@ionic/core/components/ion-button.js';
import { defineCustomElement as defineIonButtonGroup } from '@ionic/core/components/ion-button-group.js';
import { defineCustomElement as defineIonButtons } from '@ionic/core/components/ion-buttons.js';
import { defineCustomElement as defineIonCard } from '@ionic/core/components/ion-card.js';
import { defineCustomElement as defineIonCardContent } from '@ionic/core/components/ion-card-content.js';
@ -472,6 +473,38 @@ export declare interface IonButton extends Components.IonButton {
}
@ProxyCmp({
defineCustomElementFn: defineIonButtonGroup,
inputs: ['color', 'fill', 'mode', 'shape', 'size', 'theme', 'value']
})
@Component({
selector: 'ion-button-group',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['color', 'fill', 'mode', 'shape', 'size', 'theme', 'value'],
standalone: true
})
export class IonButtonGroup {
protected el: HTMLIonButtonGroupElement;
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
c.detach();
this.el = r.nativeElement;
proxyOutputs(this, this.el, ['ionChange', 'ionValueChange']);
}
}
export declare interface IonButtonGroup extends Components.IonButtonGroup {
/**
* Emitted when the active button changes.
*/
ionChange: EventEmitter<CustomEvent<{ value: string | number | null; activeIndex: number }>>;
ionValueChange: EventEmitter<CustomEvent<{ value: string | number | null}>>;
}
@ProxyCmp({
defineCustomElementFn: defineIonButtons,
inputs: ['collapse', 'mode', 'theme']

View File

@ -11,6 +11,7 @@ import { defineCustomElement as defineIonAvatar } from '@ionic/core/components/i
import { defineCustomElement as defineIonBackdrop } from '@ionic/core/components/ion-backdrop.js';
import { defineCustomElement as defineIonBadge } from '@ionic/core/components/ion-badge.js';
import { defineCustomElement as defineIonBreadcrumbs } from '@ionic/core/components/ion-breadcrumbs.js';
import { defineCustomElement as defineIonButtonGroup } from '@ionic/core/components/ion-button-group.js';
import { defineCustomElement as defineIonButtons } from '@ionic/core/components/ion-buttons.js';
import { defineCustomElement as defineIonCardContent } from '@ionic/core/components/ion-card-content.js';
import { defineCustomElement as defineIonCardHeader } from '@ionic/core/components/ion-card-header.js';
@ -84,6 +85,7 @@ export const IonAvatar = /*@__PURE__*/createReactComponent<JSX.IonAvatar, HTMLIo
export const IonBackdrop = /*@__PURE__*/createReactComponent<JSX.IonBackdrop, HTMLIonBackdropElement>('ion-backdrop', undefined, undefined, defineIonBackdrop);
export const IonBadge = /*@__PURE__*/createReactComponent<JSX.IonBadge, HTMLIonBadgeElement>('ion-badge', undefined, undefined, defineIonBadge);
export const IonBreadcrumbs = /*@__PURE__*/createReactComponent<JSX.IonBreadcrumbs, HTMLIonBreadcrumbsElement>('ion-breadcrumbs', undefined, undefined, defineIonBreadcrumbs);
export const IonButtonGroup = /*@__PURE__*/createReactComponent<JSX.IonButtonGroup, HTMLIonButtonGroupElement>('ion-button-group', undefined, undefined, defineIonButtonGroup);
export const IonButtons = /*@__PURE__*/createReactComponent<JSX.IonButtons, HTMLIonButtonsElement>('ion-buttons', undefined, undefined, defineIonButtons);
export const IonCardContent = /*@__PURE__*/createReactComponent<JSX.IonCardContent, HTMLIonCardContentElement>('ion-card-content', undefined, undefined, defineIonCardContent);
export const IonCardHeader = /*@__PURE__*/createReactComponent<JSX.IonCardHeader, HTMLIonCardHeaderElement>('ion-card-header', undefined, undefined, defineIonCardHeader);

View File

@ -13,6 +13,7 @@ import { defineCustomElement as defineIonBadge } from '@ionic/core/components/io
import { defineCustomElement as defineIonBreadcrumb } from '@ionic/core/components/ion-breadcrumb.js';
import { defineCustomElement as defineIonBreadcrumbs } from '@ionic/core/components/ion-breadcrumbs.js';
import { defineCustomElement as defineIonButton } from '@ionic/core/components/ion-button.js';
import { defineCustomElement as defineIonButtonGroup } from '@ionic/core/components/ion-button-group.js';
import { defineCustomElement as defineIonButtons } from '@ionic/core/components/ion-buttons.js';
import { defineCustomElement as defineIonCard } from '@ionic/core/components/ion-card.js';
import { defineCustomElement as defineIonCardContent } from '@ionic/core/components/ion-card-content.js';
@ -197,6 +198,20 @@ export const IonButton: StencilVueComponent<JSX.IonButton> = /*@__PURE__*/ defin
]);
export const IonButtonGroup: StencilVueComponent<JSX.IonButtonGroup> = /*@__PURE__*/ defineContainer<JSX.IonButtonGroup>('ion-button-group', defineIonButtonGroup, [
'fill',
'shape',
'size',
'color',
'value',
'ionChange',
'ionValueChange'
], [
'ionChange',
'ionValueChange'
]);
export const IonButtons: StencilVueComponent<JSX.IonButtons> = /*@__PURE__*/ defineContainer<JSX.IonButtons>('ion-buttons', defineIonButtons, [
'collapse'
]);