diff --git a/core/api.txt b/core/api.txt index a6112897278..557ba4fc74f 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 82ace58b59e..4d62da92011 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 00000000000..c8150971853 --- /dev/null +++ b/core/src/components/button-group/button-group.ionic.scss @@ -0,0 +1,103 @@ +@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; + + background-color: transparent; + transition: color 0.3s ease; + will-change: color; + + 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 00000000000..7dce680a956 --- /dev/null +++ b/core/src/components/button-group/button-group.tsx @@ -0,0 +1,110 @@ +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); + button.setAttribute('aria-pressed', index === this.activeIndex ? 'true' : 'false'); + })} + + ); + } +} \ 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 00000000000..02e4c128e6d --- /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 76325ada4d2..ac889026e67 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 815b742a0db..0c2a923275e 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 590555cdc8d..2543fd45153 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 4bb4ff89582..c2ace33b553 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 0c5a6ba2d44..fac5baf3fc0 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' ]);