From cbe85730fbf518f95f7d8fe8ab2246509e9f6689 Mon Sep 17 00:00:00 2001 From: BenOsodrac Date: Tue, 6 May 2025 17:15:37 +0100 Subject: [PATCH] feat(button-group): add IonButtonGroup component with styles and events --- core/api.txt | 6 + core/src/components.d.ts | 66 +++++++++ .../button-group/button-group.ionic.scss | 102 ++++++++++++++ .../components/button-group/button-group.tsx | 109 +++++++++++++++ .../components/button-group/test/index.html | 126 ++++++++++++++++++ .../angular/src/directives/proxies-list.ts | 1 + packages/angular/src/directives/proxies.ts | 30 +++++ .../standalone/src/directives/proxies.ts | 33 +++++ packages/react/src/components/proxies.ts | 2 + packages/vue/src/proxies.ts | 15 +++ 10 files changed, 490 insertions(+) create mode 100644 core/src/components/button-group/button-group.ionic.scss create mode 100644 core/src/components/button-group/button-group.tsx create mode 100644 core/src/components/button-group/test/index.html diff --git a/core/api.txt b/core/api.txt index a611289727..557ba4fc74 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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 diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 82ace58b59..4d62da9201 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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 extends CustomEvent { detail: T; target: HTMLIonButtonElement; } +export interface IonButtonGroupCustomEvent extends CustomEvent { + detail: T; + target: HTMLIonButtonGroupElement; +} export interface IonCheckboxCustomEvent extends CustomEvent { 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(type: K, listener: (this: HTMLIonButtonGroupElement, ev: IonButtonGroupCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLIonButtonGroupElement, ev: IonButtonGroupCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(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; "ion-breadcrumbs": LocalJSX.IonBreadcrumbs & JSXBase.HTMLAttributes; "ion-button": LocalJSX.IonButton & JSXBase.HTMLAttributes; + "ion-button-group": LocalJSX.IonButtonGroup & JSXBase.HTMLAttributes; "ion-buttons": LocalJSX.IonButtons & JSXBase.HTMLAttributes; "ion-card": LocalJSX.IonCard & JSXBase.HTMLAttributes; "ion-card-content": LocalJSX.IonCardContent & JSXBase.HTMLAttributes; diff --git a/core/src/components/button-group/button-group.ionic.scss b/core/src/components/button-group/button-group.ionic.scss new file mode 100644 index 0000000000..ca97b75978 --- /dev/null +++ b/core/src/components/button-group/button-group.ionic.scss @@ -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; + } \ No newline at end of file diff --git a/core/src/components/button-group/button-group.tsx b/core/src/components/button-group/button-group.tsx new file mode 100644 index 0000000000..e9ba122c46 --- /dev/null +++ b/core/src/components/button-group/button-group.tsx @@ -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
; + } + + 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 ( + + {this.renderActiveIndicator()} + + {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); + })} + + ); + } +} \ No newline at end of file diff --git a/core/src/components/button-group/test/index.html b/core/src/components/button-group/test/index.html new file mode 100644 index 0000000000..02e4c128e6 --- /dev/null +++ b/core/src/components/button-group/test/index.html @@ -0,0 +1,126 @@ + + + + + Button - Basic + + + + + + + + + + + + + ButtonGroup - Basic + + + + +

+ Default + + First + Second + Third + +

+

+ Shape: rectangular + + First + Second + Third + +

+

+ Shape: soft + + First + Second + Third + +

+

+ Shape: round + + First + Second + Third + +

+

+ Size: small + + First + Second + Third + +

+

+ Size: medium + + First + Second + Third + +

+

+ Size: large + + First + Second + Third + +

+

+ Fill: filled + + First + Second + Third + +

+

+ Fill: outline + + First + Second + Third + +

+ +

+ Color: primary + + First + Second + Third + +

+

+ Color: neutral + + First + Second + Third + +

+ +
+
+ + + + diff --git a/packages/angular/src/directives/proxies-list.ts b/packages/angular/src/directives/proxies-list.ts index 76325ada4d..ac889026e6 100644 --- a/packages/angular/src/directives/proxies-list.ts +++ b/packages/angular/src/directives/proxies-list.ts @@ -13,6 +13,7 @@ export const DIRECTIVES = [ d.IonBreadcrumb, d.IonBreadcrumbs, d.IonButton, + d.IonButtonGroup, d.IonButtons, d.IonCard, d.IonCardContent, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 815b742a0d..0c2a923275 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -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: '', + // 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>; + + ionValueChange: EventEmitter>; +} + + @ProxyCmp({ inputs: ['collapse', 'mode', 'theme'] }) diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 590555cdc8..2543fd4515 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -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: '', + // 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>; + + ionValueChange: EventEmitter>; +} + + @ProxyCmp({ defineCustomElementFn: defineIonButtons, inputs: ['collapse', 'mode', 'theme'] diff --git a/packages/react/src/components/proxies.ts b/packages/react/src/components/proxies.ts index 4bb4ff8958..c2ace33b55 100644 --- a/packages/react/src/components/proxies.ts +++ b/packages/react/src/components/proxies.ts @@ -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('ion-backdrop', undefined, undefined, defineIonBackdrop); export const IonBadge = /*@__PURE__*/createReactComponent('ion-badge', undefined, undefined, defineIonBadge); export const IonBreadcrumbs = /*@__PURE__*/createReactComponent('ion-breadcrumbs', undefined, undefined, defineIonBreadcrumbs); +export const IonButtonGroup = /*@__PURE__*/createReactComponent('ion-button-group', undefined, undefined, defineIonButtonGroup); export const IonButtons = /*@__PURE__*/createReactComponent('ion-buttons', undefined, undefined, defineIonButtons); export const IonCardContent = /*@__PURE__*/createReactComponent('ion-card-content', undefined, undefined, defineIonCardContent); export const IonCardHeader = /*@__PURE__*/createReactComponent('ion-card-header', undefined, undefined, defineIonCardHeader); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 0c5a6ba2d4..fac5baf3fc 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -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 = /*@__PURE__*/ defin ]); +export const IonButtonGroup: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-button-group', defineIonButtonGroup, [ + 'fill', + 'shape', + 'size', + 'color', + 'value', + 'ionChange', + 'ionValueChange' +], [ + 'ionChange', + 'ionValueChange' +]); + + export const IonButtons: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-buttons', defineIonButtons, [ 'collapse' ]);