diff --git a/.github/COMPONENT-GUIDE.md b/.github/COMPONENT-GUIDE.md index 5e54bec125..ad0d240ffa 100644 --- a/.github/COMPONENT-GUIDE.md +++ b/.github/COMPONENT-GUIDE.md @@ -12,6 +12,7 @@ - [Accessibility](#accessibility) * [Checkbox](#checkbox) * [Switch](#switch) + * [Accordion](#accordion) - [Rendering Anchor or Button](#rendering-anchor-or-button) * [Example Components](#example-components-1) * [Component Structure](#component-structure-1) @@ -623,6 +624,19 @@ You are currently on a switch. To select or deselect this checkbox, press Contro There is a WebKit bug open for this: https://bugs.webkit.org/show_bug.cgi?id=196354 +### Accordion + +#### Example Components + +- [ion-accordion](https://github.com/ionic-team/ionic/tree/master/core/src/components/accordion) +- [ion-accordion-group](https://github.com/ionic-team/ionic/tree/master/core/src/components/accordion-group) + +#### NVDA + +In order to use the arrow keys to navigate the accordions, users must be in "Focus Mode". Typically, NVDA automatically switches between Browse and Focus modes when inside of a form, but not every accordion needs a form. + +You can either wrap your `ion-accordion-group` in a form, or manually toggle Focus Mode using NVDA's keyboard shortcut. + ## Rendering Anchor or Button diff --git a/angular/src/directives/proxies-list.txt b/angular/src/directives/proxies-list.txt index beb4264e42..1d0a9e4bf2 100644 --- a/angular/src/directives/proxies-list.txt +++ b/angular/src/directives/proxies-list.txt @@ -2,6 +2,8 @@ import type * as d from './proxies'; export const DIRECTIVES = [ + d.IonAccordion, + d.IonAccordionGroup, d.IonApp, d.IonAvatar, d.IonBackButton, diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index 00a615791f..de592ce60b 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -4,6 +4,30 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, NgZone } from "@angular/core"; import { ProxyCmp, proxyOutputs } from "./proxies-utils"; import { Components } from "@ionic/core"; +export declare interface IonAccordion extends Components.IonAccordion { +} +@ProxyCmp({ inputs: ["disabled", "mode", "readonly", "toggleIcon", "toggleIconSlot", "value"] }) +@Component({ selector: "ion-accordion", changeDetection: ChangeDetectionStrategy.OnPush, template: "", inputs: ["disabled", "mode", "readonly", "toggleIcon", "toggleIconSlot", "value"] }) +export class IonAccordion { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} +export declare interface IonAccordionGroup extends Components.IonAccordionGroup { +} +@ProxyCmp({ inputs: ["disabled", "expand", "mode", "multiple", "readonly", "value"] }) +@Component({ selector: "ion-accordion-group", changeDetection: ChangeDetectionStrategy.OnPush, template: "", inputs: ["disabled", "expand", "mode", "multiple", "readonly", "value"] }) +export class IonAccordionGroup { + ionChange!: EventEmitter; + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + proxyOutputs(this, this.el, ["ionChange"]); + } +} export declare interface IonApp extends Components.IonApp { } @Component({ selector: "ion-app", changeDetection: ChangeDetectionStrategy.OnPush, template: "" }) diff --git a/angular/src/ionic-module.ts b/angular/src/ionic-module.ts index a3ea4a8fb7..8adfbab859 100644 --- a/angular/src/ionic-module.ts +++ b/angular/src/ionic-module.ts @@ -13,7 +13,7 @@ import { IonRouterOutlet } from './directives/navigation/ion-router-outlet'; import { IonTabs } from './directives/navigation/ion-tabs'; import { NavDelegate } from './directives/navigation/nav-delegate'; import { RouterLinkDelegate } from './directives/navigation/router-link-delegate'; -import { IonApp, IonAvatar, IonBackButton, IonBackdrop, IonBadge, IonButton, IonButtons, IonCard, IonCardContent, IonCardHeader, IonCardSubtitle, IonCardTitle, IonCheckbox, IonChip, IonCol, IonContent, IonDatetime, IonFab, IonFabButton, IonFabList, IonFooter, IonGrid, IonHeader, IonIcon, IonImg, IonInfiniteScroll, IonInfiniteScrollContent, IonInput, IonItem, IonItemDivider, IonItemGroup, IonItemOption, IonItemOptions, IonItemSliding, IonLabel, IonList, IonListHeader, IonMenu, IonMenuButton, IonMenuToggle, IonNav, IonNavLink, IonNote, IonProgressBar, IonRadio, IonRadioGroup, IonRange, IonRefresher, IonRefresherContent, IonReorder, IonReorderGroup, IonRippleEffect, IonRow, IonSearchbar, IonSegment, IonSegmentButton, IonSelect, IonSelectOption, IonSkeletonText, IonSlide, IonSlides, IonSpinner, IonSplitPane, IonTabBar, IonTabButton, IonText, IonTextarea, IonThumbnail, IonTitle, IonToggle, IonToolbar } from './directives/proxies'; +import { IonAccordion, IonAccordionGroup, IonApp, IonAvatar, IonBackButton, IonBackdrop, IonBadge, IonButton, IonButtons, IonCard, IonCardContent, IonCardHeader, IonCardSubtitle, IonCardTitle, IonCheckbox, IonChip, IonCol, IonContent, IonDatetime, IonFab, IonFabButton, IonFabList, IonFooter, IonGrid, IonHeader, IonIcon, IonImg, IonInfiniteScroll, IonInfiniteScrollContent, IonInput, IonItem, IonItemDivider, IonItemGroup, IonItemOption, IonItemOptions, IonItemSliding, IonLabel, IonList, IonListHeader, IonMenu, IonMenuButton, IonMenuToggle, IonNav, IonNavLink, IonNote, IonProgressBar, IonRadio, IonRadioGroup, IonRange, IonRefresher, IonRefresherContent, IonReorder, IonReorderGroup, IonRippleEffect, IonRow, IonSearchbar, IonSegment, IonSegmentButton, IonSelect, IonSelectOption, IonSkeletonText, IonSlide, IonSlides, IonSpinner, IonSplitPane, IonTabBar, IonTabButton, IonText, IonTextarea, IonThumbnail, IonTitle, IonToggle, IonToolbar } from './directives/proxies'; import { VirtualFooter } from './directives/virtual-scroll/virtual-footer'; import { VirtualHeader } from './directives/virtual-scroll/virtual-header'; import { VirtualItem } from './directives/virtual-scroll/virtual-item'; @@ -25,6 +25,8 @@ import { PopoverController } from './providers/popover-controller'; const DECLARATIONS = [ // proxies + IonAccordion, + IonAccordionGroup, IonApp, IonAvatar, IonBackButton, diff --git a/core/api.txt b/core/api.txt index 5d34b7902c..d628e4012c 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1,4 +1,24 @@ +ion-accordion,shadow +ion-accordion,prop,disabled,boolean,false,false,false +ion-accordion,prop,mode,"ios" | "md",undefined,false,false +ion-accordion,prop,readonly,boolean,false,false,false +ion-accordion,prop,toggleIcon,string,'chevron-down',false,false +ion-accordion,prop,toggleIconSlot,"end" | "start",'end',false,false +ion-accordion,prop,value,string,`ion-accordion-${accordionIds++}`,false,false +ion-accordion,part,content +ion-accordion,part,expanded +ion-accordion,part,header + +ion-accordion-group,shadow +ion-accordion-group,prop,disabled,boolean,false,false,false +ion-accordion-group,prop,expand,"compact" | "inset",'compact',false,false +ion-accordion-group,prop,mode,"ios" | "md",undefined,false,false +ion-accordion-group,prop,multiple,boolean | undefined,undefined,false,false +ion-accordion-group,prop,readonly,boolean,false,false,false +ion-accordion-group,prop,value,null | string | string[] | undefined,undefined,false,false +ion-accordion-group,event,ionChange,AccordionGroupChangeEventDetail,true + ion-action-sheet,scoped ion-action-sheet,prop,animated,boolean,true,false,false ion-action-sheet,prop,backdropDismiss,boolean,true,false,false diff --git a/core/package-lock.json b/core/package-lock.json index f98a2e5f2e..6def257efd 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -14,6 +14,7 @@ "tslib": "^1.10.0" }, "devDependencies": { + "@axe-core/puppeteer": "^4.1.1", "@jest/core": "^26.6.3", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", @@ -43,6 +44,21 @@ "typescript": "^4.0.5" } }, + "node_modules/@axe-core/puppeteer": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@axe-core/puppeteer/-/puppeteer-4.1.1.tgz", + "integrity": "sha512-Ao9N7HL//s26hdasx3Ba18tlJgxpoO+1SmIN6eSx5vC50dqYhiRU0xp6wBKWqzo10u1jpzl/s4RFsOAuolFMBA==", + "dev": true, + "dependencies": { + "axe-core": "^4.1.1" + }, + "engines": { + "node": ">=6.4.0" + }, + "peerDependencies": { + "puppeteer": ">=1.10.0 < 6" + } + }, "node_modules/@babel/code-frame": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", @@ -2029,6 +2045,15 @@ "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==", "dev": true }, + "node_modules/axe-core": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz", + "integrity": "sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-istanbul": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", @@ -13778,6 +13803,15 @@ } }, "dependencies": { + "@axe-core/puppeteer": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@axe-core/puppeteer/-/puppeteer-4.1.1.tgz", + "integrity": "sha512-Ao9N7HL//s26hdasx3Ba18tlJgxpoO+1SmIN6eSx5vC50dqYhiRU0xp6wBKWqzo10u1jpzl/s4RFsOAuolFMBA==", + "dev": true, + "requires": { + "axe-core": "^4.1.1" + } + }, "@babel/code-frame": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", @@ -15457,6 +15491,12 @@ "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==", "dev": true }, + "axe-core": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz", + "integrity": "sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ==", + "dev": true + }, "babel-plugin-istanbul": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", diff --git a/core/package.json b/core/package.json index 3eeb39278a..7c63e2328c 100644 --- a/core/package.json +++ b/core/package.json @@ -36,6 +36,7 @@ "tslib": "^1.10.0" }, "devDependencies": { + "@axe-core/puppeteer": "^4.1.1", "@jest/core": "^26.6.3", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 69feb020ca..c352e05280 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -5,11 +5,65 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimeOptions, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, ViewController } from "./interface"; +import { AccordionGroupChangeEventDetail, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimeOptions, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, ViewController } from "./interface"; import { IonicSafeString } from "./utils/sanitization"; import { NavigationHookCallback } from "./components/route/route-interface"; import { SelectCompareFn } from "./components/select/select-interface"; export namespace Components { + interface IonAccordion { + /** + * If `true`, the accordion cannot be interacted with. + */ + "disabled": boolean; + /** + * The mode determines which platform styles to use. + */ + "mode"?: "ios" | "md"; + /** + * If `true`, the accordion cannot be interacted with, but does not alter the opacity. + */ + "readonly": boolean; + /** + * The toggle icon to use. This icon will be rotated when the accordion is expanded or collapsed. + */ + "toggleIcon": string; + /** + * The slot inside of `ion-item` to place the toggle icon. Defaults to `'end'`. + */ + "toggleIconSlot": 'start' | 'end'; + /** + * The value of the accordion. Defaults to an autogenerated value. + */ + "value": string; + } + interface IonAccordionGroup { + /** + * If `true`, the accordion group cannot be interacted with. + */ + "disabled": boolean; + /** + * Describes the expansion behavior for each accordion. Possible values are `"compact"` and `"inset"`. Defaults to `"compact"`. + */ + "expand": 'compact' | 'inset'; + "getAccordions": () => Promise; + /** + * The mode determines which platform styles to use. + */ + "mode"?: "ios" | "md"; + /** + * If `true`, the accordion group can have multiple accordion components expanded at the same time. + */ + "multiple"?: boolean; + /** + * If `true`, the accordion group cannot be interacted with, but does not alter the opacity. + */ + "readonly": boolean; + "requestAccordionToggle": (accordionValue: string | undefined, accordionExpand: boolean) => Promise; + /** + * The value of the accordion group. + */ + "value"?: string | string[] | null; + } interface IonActionSheet { /** * If `true`, the action sheet will animate. @@ -2708,6 +2762,18 @@ export namespace Components { } } declare global { + interface HTMLIonAccordionElement extends Components.IonAccordion, HTMLStencilElement { + } + var HTMLIonAccordionElement: { + prototype: HTMLIonAccordionElement; + new (): HTMLIonAccordionElement; + }; + interface HTMLIonAccordionGroupElement extends Components.IonAccordionGroup, HTMLStencilElement { + } + var HTMLIonAccordionGroupElement: { + prototype: HTMLIonAccordionGroupElement; + new (): HTMLIonAccordionGroupElement; + }; interface HTMLIonActionSheetElement extends Components.IonActionSheet, HTMLStencilElement { } var HTMLIonActionSheetElement: { @@ -3231,6 +3297,8 @@ declare global { new (): HTMLIonVirtualScrollElement; }; interface HTMLElementTagNameMap { + "ion-accordion": HTMLIonAccordionElement; + "ion-accordion-group": HTMLIonAccordionGroupElement; "ion-action-sheet": HTMLIonActionSheetElement; "ion-alert": HTMLIonAlertElement; "ion-app": HTMLIonAppElement; @@ -3321,6 +3389,62 @@ declare global { } } declare namespace LocalJSX { + interface IonAccordion { + /** + * If `true`, the accordion cannot be interacted with. + */ + "disabled"?: boolean; + /** + * The mode determines which platform styles to use. + */ + "mode"?: "ios" | "md"; + /** + * If `true`, the accordion cannot be interacted with, but does not alter the opacity. + */ + "readonly"?: boolean; + /** + * The toggle icon to use. This icon will be rotated when the accordion is expanded or collapsed. + */ + "toggleIcon"?: string; + /** + * The slot inside of `ion-item` to place the toggle icon. Defaults to `'end'`. + */ + "toggleIconSlot"?: 'start' | 'end'; + /** + * The value of the accordion. Defaults to an autogenerated value. + */ + "value"?: string; + } + interface IonAccordionGroup { + /** + * If `true`, the accordion group cannot be interacted with. + */ + "disabled"?: boolean; + /** + * Describes the expansion behavior for each accordion. Possible values are `"compact"` and `"inset"`. Defaults to `"compact"`. + */ + "expand"?: 'compact' | 'inset'; + /** + * The mode determines which platform styles to use. + */ + "mode"?: "ios" | "md"; + /** + * If `true`, the accordion group can have multiple accordion components expanded at the same time. + */ + "multiple"?: boolean; + /** + * Emitted when the value property has changed. + */ + "onIonChange"?: (event: CustomEvent) => void; + /** + * If `true`, the accordion group cannot be interacted with, but does not alter the opacity. + */ + "readonly"?: boolean; + /** + * The value of the accordion group. + */ + "value"?: string | string[] | null; + } interface IonActionSheet { /** * If `true`, the action sheet will animate. @@ -6044,6 +6168,8 @@ declare namespace LocalJSX { "renderItem"?: (item: any, index: number) => any; } interface IntrinsicElements { + "ion-accordion": IonAccordion; + "ion-accordion-group": IonAccordionGroup; "ion-action-sheet": IonActionSheet; "ion-alert": IonAlert; "ion-app": IonApp; @@ -6137,6 +6263,8 @@ export { LocalJSX as JSX }; declare module "@stencil/core" { export namespace JSX { interface IntrinsicElements { + "ion-accordion": LocalJSX.IonAccordion & JSXBase.HTMLAttributes; + "ion-accordion-group": LocalJSX.IonAccordionGroup & JSXBase.HTMLAttributes; "ion-action-sheet": LocalJSX.IonActionSheet & JSXBase.HTMLAttributes; "ion-alert": LocalJSX.IonAlert & JSXBase.HTMLAttributes; "ion-app": LocalJSX.IonApp & JSXBase.HTMLAttributes; diff --git a/core/src/components/accordion-group/accordion-group-interface.ts b/core/src/components/accordion-group/accordion-group-interface.ts new file mode 100644 index 0000000000..1d8701b5c1 --- /dev/null +++ b/core/src/components/accordion-group/accordion-group-interface.ts @@ -0,0 +1,8 @@ +export interface AccordionGroupChangeEventDetail { + value: T; +} + +export interface AccordionGroupChangeEvent extends CustomEvent { + detail: AccordionGroupChangeEventDetail; + target: HTMLIonAccordionGroupElement; +} diff --git a/core/src/components/accordion-group/accordion-group.ios.scss b/core/src/components/accordion-group/accordion-group.ios.scss new file mode 100644 index 0000000000..fdf32c14a7 --- /dev/null +++ b/core/src/components/accordion-group/accordion-group.ios.scss @@ -0,0 +1,9 @@ +@import "./accordion-group"; + +// iOS Accordion Group +// -------------------------------------------------- + +:host(.accordion-group-expand-inset) ::slotted(ion-accordion.accordion-expanding), +:host(.accordion-group-expand-inset) ::slotted(ion-accordion.accordion-expanded) { + border-bottom: none; +} diff --git a/core/src/components/accordion-group/accordion-group.md.scss b/core/src/components/accordion-group/accordion-group.md.scss new file mode 100644 index 0000000000..94ca1250f5 --- /dev/null +++ b/core/src/components/accordion-group/accordion-group.md.scss @@ -0,0 +1,27 @@ +@import "./accordion-group"; +@import "../accordion/accordion.md.vars"; + +// Material Design Accordion Group +// -------------------------------------------------- + +:host(.accordion-group-expand-inset) ::slotted(ion-accordion) { + box-shadow: $accordion-md-box-shadow; +} + +:host(.accordion-group-expand-inset) ::slotted(ion-accordion.accordion-expanding), +:host(.accordion-group-expand-inset) ::slotted(ion-accordion.accordion-expanded) { + @include margin($accordion-md-expanded-margin, 0, $accordion-md-expanded-margin, 0); + @include border-radius($accordion-md-border-radius, $accordion-md-border-radius, $accordion-md-border-radius, $accordion-md-border-radius); +} + +:host(.accordion-group-expand-inset) ::slotted(ion-accordion.accordion-previous) { + @include border-radius(null, null, $accordion-md-border-radius, $accordion-md-border-radius); +} + +:host(.accordion-group-expand-inset) ::slotted(ion-accordion.accordion-next) { + @include border-radius($accordion-md-border-radius, $accordion-md-border-radius, null, null); +} +:host(.accordion-group-expand-inset) ::slotted(ion-accordion):first-of-type, +:host(.accordion-group-expand-inset) ::slotted(ion-accordion):first-of-type { + @include margin(0, 0, 0, 0); +} diff --git a/core/src/components/accordion-group/accordion-group.scss b/core/src/components/accordion-group/accordion-group.scss new file mode 100644 index 0000000000..172e48a347 --- /dev/null +++ b/core/src/components/accordion-group/accordion-group.scss @@ -0,0 +1,13 @@ +@import "../../themes/ionic.globals"; +@import "../accordion/accordion.vars"; + +// Accordion Group +// -------------------------------------------------- + +:host { + display: block; +} + +:host(.accordion-group-expand-inset) { + @include margin($accordion-inset-margin, $accordion-inset-margin, $accordion-inset-margin, $accordion-inset-margin); +} diff --git a/core/src/components/accordion-group/accordion-group.tsx b/core/src/components/accordion-group/accordion-group.tsx new file mode 100644 index 0000000000..0f96e0cb44 --- /dev/null +++ b/core/src/components/accordion-group/accordion-group.tsx @@ -0,0 +1,214 @@ +import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Method, Prop, Watch, h } from '@stencil/core'; + +import { getIonMode } from '../../global/ionic-global'; +import { AccordionGroupChangeEventDetail } from '../../interface'; + +/** + * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. + */ +@Component({ + tag: 'ion-accordion-group', + styleUrls: { + ios: 'accordion-group.ios.scss', + md: 'accordion-group.md.scss' + }, + shadow: true +}) +export class AccordionGroup implements ComponentInterface { + @Element() el!: HTMLIonAccordionGroupElement; + + /** + * If `true`, the accordion group can have multiple + * accordion components expanded at the same time. + */ + @Prop() multiple?: boolean; + + /** + * The value of the accordion group. + */ + @Prop({ mutable: true }) value?: string | string[] | null; + + /** + * If `true`, the accordion group cannot be interacted with. + */ + @Prop() disabled = false; + + /** + * If `true`, the accordion group cannot be interacted with, + * but does not alter the opacity. + */ + @Prop() readonly = false; + + /** + * Describes the expansion behavior for each accordion. + * Possible values are `"compact"` and `"inset"`. + * Defaults to `"compact"`. + */ + @Prop() expand: 'compact' | 'inset' = 'compact'; + + /** + * Emitted when the value property has changed. + */ + @Event() ionChange!: EventEmitter; + + @Watch('value') + valueChanged() { + const { value, multiple } = this; + + /** + * If accordion group does not + * let multiple accordions be open + * at once, but user passes an array + * just grab the first value. + */ + if (!multiple && Array.isArray(value)) { + this.value = value[0]; + } else { + this.ionChange.emit({ value: this.value }); + } + } + + @Watch('disabled') + async disabledChanged() { + const { disabled } = this; + const accordions = await this.getAccordions(); + for (const accordion of accordions) { + accordion.disabled = disabled; + } + } + + @Watch('readonly') + async readonlyChanged() { + const { readonly } = this; + const accordions = await this.getAccordions(); + for (const accordion of accordions) { + accordion.readonly = readonly; + } + } + + @Listen('keydown') + async onKeydown(ev: KeyboardEvent) { + const activeElement = document.activeElement; + if (!activeElement) { return; } + + const accordionEl = (activeElement.tagName === 'ION-ACCORDION') ? activeElement : activeElement.closest('ion-accordion'); + if (!accordionEl) { return; } + + const closestGroup = accordionEl.closest('ion-accordion-group'); + if (closestGroup !== this.el) { return; } + + // If the active accordion is not in the current array of accordions, do not do anything + const accordions = await this.getAccordions(); + const startingIndex = accordions.findIndex(a => a === accordionEl); + if (startingIndex === -1) { return; } + + let accordion: HTMLIonAccordionElement | undefined; + if (ev.key === 'ArrowDown') { + accordion = this.findNextAccordion(accordions, startingIndex); + } else if (ev.key === 'ArrowUp') { + accordion = this.findPreviousAccordion(accordions, startingIndex); + } else if (ev.key === 'Home') { + accordion = accordions[0]; + } else if (ev.key === 'End') { + accordion = accordions[accordions.length - 1]; + } + + if (accordion !== undefined && accordion !== activeElement) { + accordion.focus(); + } + } + + async componentDidLoad() { + if (this.disabled) { + this.disabledChanged(); + } + if (this.readonly) { + this.readonlyChanged(); + } + } + + /** + * @internal + */ + @Method() + async requestAccordionToggle(accordionValue: string | undefined, accordionExpand: boolean) { + const { multiple, value, readonly, disabled } = this; + if (readonly || disabled) { return; } + + if (accordionExpand) { + /** + * If group accepts multiple values + * check to see if value is already in + * in values array. If not, add it + * to the array. + */ + if (multiple) { + const groupValue = (value || []) as string[]; + const valueExists = groupValue.find(v => v === accordionValue); + if (valueExists === undefined && accordionValue !== undefined) { + this.value = [...groupValue, accordionValue]; + } + } else { + this.value = accordionValue; + } + } else { + /** + * If collapsing accordion, either filter the value + * out of the values array or unset the value. + */ + if (multiple) { + const groupValue = (value || []) as string[]; + this.value = groupValue.filter(v => v !== accordionValue); + } else { + this.value = undefined; + } + } + } + + private findNextAccordion(accordions: HTMLIonAccordionElement[], startingIndex: number) { + const nextAccordion = accordions[startingIndex + 1]; + // tslint:disable-next-line:strict-type-predicates + if (nextAccordion === undefined) { + return accordions[0]; + } + + return nextAccordion; + } + + private findPreviousAccordion(accordions: HTMLIonAccordionElement[], startingIndex: number) { + const prevAccordion = accordions[startingIndex - 1]; + // tslint:disable-next-line:strict-type-predicates + if (prevAccordion === undefined) { + return accordions[accordions.length - 1]; + } + + return prevAccordion; + } + + /** + * @internal + */ + @Method() + async getAccordions() { + return Array.from(this.el.querySelectorAll('ion-accordion')); + } + + render() { + const { disabled, readonly, expand } = this; + const mode = getIonMode(this); + + return ( + + + + ); + } +} diff --git a/core/src/components/accordion-group/readme.md b/core/src/components/accordion-group/readme.md new file mode 100644 index 0000000000..603f41aaeb --- /dev/null +++ b/core/src/components/accordion-group/readme.md @@ -0,0 +1,31 @@ +# ion-accordion-group + +Accordion group is a container for accordion instances. It manages the state of the accordions and provides keyboard navigation. + +For more information as well as usage, see the [Accordion Documentation](../accordion) + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ---------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ----------- | +| `disabled` | `disabled` | If `true`, the accordion group cannot be interacted with. | `boolean` | `false` | +| `expand` | `expand` | Describes the expansion behavior for each accordion. Possible values are `"compact"` and `"inset"`. Defaults to `"compact"`. | `"compact" \| "inset"` | `'compact'` | +| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | +| `multiple` | `multiple` | If `true`, the accordion group can have multiple accordion components expanded at the same time. | `boolean \| undefined` | `undefined` | +| `readonly` | `readonly` | If `true`, the accordion group cannot be interacted with, but does not alter the opacity. | `boolean` | `false` | +| `value` | `value` | The value of the accordion group. | `null \| string \| string[] \| undefined` | `undefined` | + + +## Events + +| Event | Description | Type | +| ----------- | -------------------------------------------- | --------------------------------------------------- | +| `ionChange` | Emitted when the value property has changed. | `CustomEvent>` | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/accordion/accordion.ios.scss b/core/src/components/accordion/accordion.ios.scss new file mode 100644 index 0000000000..9c428be082 --- /dev/null +++ b/core/src/components/accordion/accordion.ios.scss @@ -0,0 +1,9 @@ +@import "./accordion.scss"; +@import "../item/item.ios.vars"; + +// iOS Accordion +// -------------------------------------------------- + +:host(.accordion-next) ::slotted(ion-item[slot="header"]) { + --border-width: #{$item-ios-border-bottom-width 0px $item-ios-border-bottom-width 0px}; +} diff --git a/core/src/components/accordion/accordion.md.scss b/core/src/components/accordion/accordion.md.scss new file mode 100644 index 0000000000..391934629c --- /dev/null +++ b/core/src/components/accordion/accordion.md.scss @@ -0,0 +1,4 @@ +@import "./accordion.scss"; + +// Material Design Accordion +// -------------------------------------------------- diff --git a/core/src/components/accordion/accordion.md.vars.scss b/core/src/components/accordion/accordion.md.vars.scss new file mode 100644 index 0000000000..1a6ffd64a5 --- /dev/null +++ b/core/src/components/accordion/accordion.md.vars.scss @@ -0,0 +1,13 @@ +@import "../../themes/ionic.globals.md"; + +// Accordion +// -------------------------------------------------- + +/// @prop - Border radius applied to the accordion +$accordion-md-border-radius: 6px !default; + +/// @prop - Box shadow of the accordion +$accordion-md-box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12) !default; + +/// @prop - Margin of the expanded accordion +$accordion-md-expanded-margin: 16px !default; diff --git a/core/src/components/accordion/accordion.scss b/core/src/components/accordion/accordion.scss new file mode 100644 index 0000000000..7f7521dec8 --- /dev/null +++ b/core/src/components/accordion/accordion.scss @@ -0,0 +1,81 @@ +@import "./accordion.vars.scss"; + +// Accordion +// -------------------------------------------------- + +:host { + display: block; + + position: relative; + + width: 100%; + + background-color: $accordion-background-color; + + overflow: hidden; + + /** + * This is required to force WebKit + * to create a new stacking context + * otherwise the border radius is + * temporarily lost when hovering over + * the ion-item or expanding/collapsing + * the accordion. + */ + z-index: 0; +} + +:host(.accordion-expanding) ::slotted(ion-item[slot="header"]), +:host(.accordion-expanded) ::slotted(ion-item[slot="header"]) { + --border-width: 0px; +} + +:host(.accordion-animated) { + transition: all $accordion-transition-duration $accordion-transition-easing; +} + +:host(.accordion-animated) #content { + transition: max-height $accordion-transition-duration $accordion-transition-easing; +} + +#content { + overflow: hidden; + + will-change: max-height; +} + +:host(.accordion-collapsing) #content { + /* stylelint-disable-next-line declaration-no-important */ + max-height: 0 !important; +} + +:host(.accordion-collapsed) #content { + display: none; +} + +:host(.accordion-expanding) #content { + max-height: 0; +} + +:host(.accordion-disabled) #header, +:host(.accordion-readonly) #header { + pointer-events: none; +} + +/** + * We do not set the opacity on the + * host otherwise you would see the + * box-shadow behind it. + */ +:host(.accordion-disabled) #header, +:host(.accordion-disabled) #content { + opacity: $accordion-disabled-opacity; +} + +@media (prefers-reduced-motion: reduce) { + :host, + #content { + /* stylelint-disable declaration-no-important */ + transition: none !important; + } +} diff --git a/core/src/components/accordion/accordion.tsx b/core/src/components/accordion/accordion.tsx new file mode 100644 index 0000000000..e51eda0f75 --- /dev/null +++ b/core/src/components/accordion/accordion.tsx @@ -0,0 +1,427 @@ +import { Component, ComponentInterface, Element, Host, Prop, State, h } from '@stencil/core'; + +import { config } from '../../global/config'; +import { getIonMode } from '../../global/ionic-global'; +import { addEventListener, getElementRoot, raf, removeEventListener, transitionEndAsync } from '../../utils/helpers'; + +const enum AccordionState { + Collapsed = 1 << 0, + Collapsing = 1 << 1, + Expanded = 1 << 2, + Expanding = 1 << 3 +} + +/** + * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. + * + * @slot header - Content is placed at the top and is used to + * expand or collapse the accordion item. + * @slot content - Content is placed below the header and is + * shown or hidden based on expanded state. + * + * @part header - The wrapper element for the header slot. + * @part content - The wrapper element for the content slot. + * @part expanded - The expanded element. Can be used in combination + * with the `header` and `content` parts (i.e. `::part(header expanded)`). + */ +@Component({ + tag: 'ion-accordion', + styleUrls: { + ios: 'accordion.ios.scss', + md: 'accordion.md.scss' + }, + shadow: { + delegatesFocus: true + } +}) +export class Accordion implements ComponentInterface { + private accordionGroupEl?: HTMLIonAccordionGroupElement | null; + private updateListener = () => this.updateState(false); + private contentEl: HTMLDivElement | undefined; + private contentElWrapper: HTMLDivElement | undefined; + private headerEl: HTMLDivElement | undefined; + + private currentRaf: number | undefined; + + @Element() el?: HTMLElement; + + @State() state: AccordionState = AccordionState.Collapsed; + @State() isNext = false; + @State() isPrevious = false; + + /** + * The value of the accordion. Defaults to an autogenerated + * value. + */ + @Prop() value = `ion-accordion-${accordionIds++}`; + + /** + * If `true`, the accordion cannot be interacted with. + */ + @Prop() disabled = false; + + /** + * If `true`, the accordion cannot be interacted with, + * but does not alter the opacity. + */ + @Prop() readonly = false; + + /** + * The toggle icon to use. This icon will be + * rotated when the accordion is expanded + * or collapsed. + */ + @Prop() toggleIcon = 'chevron-down'; + + /** + * The slot inside of `ion-item` to + * place the toggle icon. Defaults to `'end'`. + */ + @Prop() toggleIconSlot: 'start' | 'end' = 'end'; + + connectedCallback() { + const accordionGroupEl = this.accordionGroupEl = this.el && this.el.closest('ion-accordion-group'); + if (accordionGroupEl) { + this.updateState(true); + addEventListener(accordionGroupEl, 'ionChange', this.updateListener); + } + } + + disconnectedCallback() { + const accordionGroupEl = this.accordionGroupEl; + if (accordionGroupEl) { + removeEventListener(accordionGroupEl, 'ionChange', this.updateListener); + } + } + + componentDidLoad() { + this.setItemDefaults(); + this.slotToggleIcon(); + + /** + * We need to wait a tick because we + * just set ionItem.button = true and + * the button has not have been rendered yet. + */ + raf(() => { + /** + * Set aria label on button inside of ion-item + * once the inner content has been rendered. + */ + const expanded = this.state === AccordionState.Expanded || this.state === AccordionState.Expanding; + this.setAria(expanded); + }); + } + + private setItemDefaults = () => { + const ionItem = this.getSlottedHeaderIonItem(); + if (!ionItem) { return; } + + /** + * For a11y purposes, we make + * the ion-item a button so users + * can tab to it and use keyboard + * navigation to get around. + */ + ionItem.button = true; + ionItem.detail = false; + + /** + * By default, the lines in an + * item should be full here, but + * only do that if a user has + * not explicitly overridden them + */ + if (ionItem.lines === undefined) { + ionItem.lines = 'full'; + } + } + + private getSlottedHeaderIonItem = () => { + const { headerEl } = this; + if (!headerEl) { return; } + + /** + * Get the first ion-item + * slotted in the header slot + */ + const slot = headerEl.querySelector('slot'); + if (!slot) { return; } + + // This is not defined in unit tests + const ionItem = slot.assignedElements && (slot.assignedElements().find(el => el.tagName === 'ION-ITEM') as HTMLIonItemElement | undefined); + + return ionItem; + } + + private setAria = (expanded = false) => { + const ionItem = this.getSlottedHeaderIonItem(); + if (!ionItem) { return; } + + /** + * Get the native element inside of + * ion-item because that is what will be focused + */ + const root = getElementRoot(ionItem); + const button = root.querySelector('button'); + if (!button) { return; } + + button.setAttribute('aria-expanded', `${expanded}`); + } + + private slotToggleIcon = () => { + const ionItem = this.getSlottedHeaderIonItem(); + if (!ionItem) { return; } + + const { toggleIconSlot, toggleIcon } = this; + + /** + * Check if there already is a toggle icon. + * If so, do not add another one. + */ + const existingToggleIcon = ionItem.querySelector('.ion-accordion-toggle-icon'); + if (existingToggleIcon) { return; } + + const iconEl = document.createElement('ion-icon'); + iconEl.slot = toggleIconSlot; + iconEl.lazy = false; + iconEl.classList.add('ion-accordion-toggle-icon'); + iconEl.icon = toggleIcon; + iconEl.ariaHidden = 'true'; + + ionItem.appendChild(iconEl); + } + + private expandAccordion = (initialUpdate = false) => { + if (initialUpdate) { + this.state = AccordionState.Expanded; + return; + } + + if (this.state === AccordionState.Expanded) { return; } + + const { contentEl, contentElWrapper } = this; + if (contentEl === undefined || contentElWrapper === undefined) { return; } + + if (this.currentRaf !== undefined) { + cancelAnimationFrame(this.currentRaf); + } + + if (this.shouldAnimate()) { + this.state = AccordionState.Expanding; + + this.currentRaf = raf(async () => { + const contentHeight = contentElWrapper.offsetHeight; + const waitForTransition = transitionEndAsync(contentEl, 2000); + contentEl.style.setProperty('max-height', `${contentHeight}px`); + + /** + * Force a repaint. We can't use an raf + * here as it could cause the collapse animation + * to get out of sync with the other + * accordion's expand animation. + */ + // tslint:disable-next-line + void contentEl.offsetHeight; + + await waitForTransition; + + this.state = AccordionState.Expanded; + contentEl.style.removeProperty('max-height'); + }); + } else { + this.state = AccordionState.Expanded; + } + } + + private collapseAccordion = (initialUpdate = false) => { + if (initialUpdate) { + this.state = AccordionState.Collapsed; + return; + } + + if (this.state === AccordionState.Collapsed) { return; } + + const { contentEl } = this; + if (contentEl === undefined) { return; } + + if (this.currentRaf !== undefined) { + cancelAnimationFrame(this.currentRaf); + } + + if (this.shouldAnimate()) { + this.currentRaf = raf(async () => { + const contentHeight = contentEl.offsetHeight; + contentEl.style.setProperty('max-height', `${contentHeight}px`); + + /** + * Force a repaint. We can't use an raf + * here as it could cause the collapse animation + * to get out of sync with the other + * accordion's expand animation. + */ + // tslint:disable-next-line + void contentEl.offsetHeight; + + const waitForTransition = transitionEndAsync(contentEl, 2000); + this.state = AccordionState.Collapsing; + + await waitForTransition; + + this.state = AccordionState.Collapsed; + contentEl.style.removeProperty('max-height'); + }); + } else { + this.state = AccordionState.Collapsed; + } + } + + /** + * Helper function to determine if + * something should animate. + * If prefers-reduced-motion is set + * then we should not animate, regardless + * of what is set in the config. + */ + private shouldAnimate = () => { + if (typeof (window as any) === 'undefined') { return false; } + + const prefersReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReducedMotion) { return false; } + + const animated = config.get('animated', true); + if (!animated) { return false; } + + return true; + } + + private updateState = async (initialUpdate = false) => { + const accordionGroup = this.accordionGroupEl; + const accordionValue = this.value; + + if (!accordionGroup) { return; } + + const value = accordionGroup.value; + + const shouldExpand = (Array.isArray(value)) ? value.includes(accordionValue) : value === accordionValue; + + if (shouldExpand) { + this.expandAccordion(initialUpdate); + this.isNext = this.isPrevious = false; + } else { + this.collapseAccordion(initialUpdate); + + /** + * When using popout or inset, + * the collapsed accordion items + * may need additional border radius + * applied. Check to see if the + * next or previous accordion is selected. + */ + const nextAccordion = this.getNextSibling(); + const nextAccordionValue = nextAccordion && nextAccordion.value; + + if (nextAccordionValue !== undefined) { + this.isPrevious = (Array.isArray(value)) ? value.includes(nextAccordionValue) : value === nextAccordionValue; + } + + const previousAccordion = this.getPreviousSibling(); + const previousAccordionValue = previousAccordion && previousAccordion.value; + + if (previousAccordionValue !== undefined) { + this.isNext = (Array.isArray(value)) ? value.includes(previousAccordionValue) : value === previousAccordionValue; + } + } + } + + private getNextSibling = () => { + if (!this.el) { return; } + + const nextSibling = this.el.nextElementSibling; + + if (nextSibling?.tagName !== 'ION-ACCORDION') { return; } + + return nextSibling as HTMLIonAccordionElement; + } + + private getPreviousSibling = () => { + if (!this.el) { return; } + + const previousSibling = this.el.previousElementSibling; + + if (previousSibling?.tagName !== 'ION-ACCORDION') { return; } + + return previousSibling as HTMLIonAccordionElement; + } + + private toggleExpanded() { + const { accordionGroupEl, value, state } = this; + if (accordionGroupEl) { + /** + * Because the accordion group may or may + * not allow multiple accordions open, we + * need to request the toggling of this + * accordion and the accordion group will + * make the decision on whether or not + * to allow it. + */ + const expand = state === AccordionState.Collapsed || state === AccordionState.Collapsing; + accordionGroupEl.requestAccordionToggle(value, expand); + } + } + + render() { + const { disabled, readonly } = this; + const mode = getIonMode(this); + const expanded = this.state === AccordionState.Expanded || this.state === AccordionState.Expanding; + const headerPart = expanded ? 'header expanded' : 'header'; + const contentPart = expanded ? 'content expanded' : 'content'; + + this.setAria(expanded); + + return ( + + this.toggleExpanded()} + id="header" + part={headerPart} + aria-controls="content" + ref={headerEl => this.headerEl = headerEl} + > + + + + this.contentEl = contentEl} + > + this.contentElWrapper = contentElWrapper}> + + + + + ); + } +} + +let accordionIds = 0; diff --git a/core/src/components/accordion/accordion.vars.scss b/core/src/components/accordion/accordion.vars.scss new file mode 100644 index 0000000000..2899324be1 --- /dev/null +++ b/core/src/components/accordion/accordion.vars.scss @@ -0,0 +1,19 @@ +@import "../../themes/ionic.globals"; + +// Accordion +// -------------------------------------------------- + +/// @prop - Background color of the accordion +$accordion-background-color: var(--ion-background-color, #ffffff) !default; + +/// @prop - Duration of the accordion transition +$accordion-transition-duration: 300ms !default; + +/// @prop - Timing function of the accordion transition +$accordion-transition-easing: cubic-bezier(0.25, 0.8, 0.5, 1) !default; + +/// @prop - Opacity of the disabled accordion +$accordion-disabled-opacity: 0.4 !default; + +/// @prop - Margin of the inset accordion +$accordion-inset-margin: 16px !default; diff --git a/core/src/components/accordion/readme.md b/core/src/components/accordion/readme.md new file mode 100644 index 0000000000..511171b89e --- /dev/null +++ b/core/src/components/accordion/readme.md @@ -0,0 +1,1295 @@ +# ion-accordion + +Accordions provide collapsible sections in your content to reduce vertical space while providing a way of organizing and grouping information. All `ion-accordion` components should be grouped inside `ion-accordion-group` components. + +## Anatomy + +### Header + +The `header` slot is used as the toggle that will expand or collapse your accordion. We recommend you use an `ion-item` here to take advantage of the accessibility and theming functionalities. + +When using `ion-item` in the `header` slot, the `ion-item`'s `button` prop is set to `true` and the `detail` prop is set to `false`. In addition, we will also automatically add a toggle icon to the `ion-item`. This icon will automatically be rotated when you expand or collapse the accordion. See [Customizing Icons](#customizing-icons) for more information. + +### Content + +The `content` slot is used as the part of the accordion that is revealed or hidden depending on the state of your accordion. You can place anything here except for another `ion-content` instance as only one instance of `ion-content` should be added per page. + +## Customizing Icons + +When using an `ion-item` in the `header` slot, we automatically add an `ion-icon`. The type of icon used can be controlled by the `toggleIcon` property, and the slot it is added to can be controlled with the `toggleIconSlot` property. + +If you would like to manage the icon yourself or use an icon that is not an `ion-icon`, you can add the `ion-accordion-toggle-icon` class to the icon element. + +Regardless of which option you choose, the icon will automatically be rotated when you expand or collapse the accordion. + +## Expansion Styles + +### Built in Styles + +There are two built in expansion styles: `compact` and `inset`. This expansion style is set via the `expand` property on `ion-accordion-group`. When `expand="inset"`, the accordion group is given a border radius. On `md` mode, the entire accordion will shift down when it is opened. + +### Custom Styles + +You can customize the expansion behavior by styling based on the accordion's state: + +```css +ion-accordion { + margin: 0 auto; +} + +ion-accordion.accordion-expanding, +ion-accordion.accordion-expanded { + width: calc(100% - 32px); + margin: 16px auto; +} +``` + +This example will cause an accordion to have its width shrink when it is opened. You can also style the accordion differently when it is closing by targeting the `.accordion-collapsing` and `.accordion-collapsed` classes. + +If you need to target specific pieces of the accordion, we recommend targeting the element directly. For example, if you want to customize the `ion-item` in your `header` slot when the accordion is expanded, you can use the following selector: + +```css +ion-accordion.accordion-expanding ion-item[slot="header"], +ion-accordion.accordion-expanded ion-item[slot="header"] { + --color: red; +} +``` + +This example will set the text color of the header `ion-item` to red when the accordion is expanded. + + +## Accessibility + +### Animations + +By default, animations are enabled when expanding or collapsing an accordion item. Animations will be automatically disabled when the `prefers-reduced-motion` media query is supported and set to `reduce`. For browsers that do not support this, animations can be disabled by setting the `animated` config in your Ionic Framework app. + +### Keyboard Navigation + +When used inside an `ion-accordion-group`, `ion-accordion` has full keyboard support for interacting with the component. The following table details what each key does: + +| Key | Function | +| ------------------ | ------------------------------------------------------------ | +| `Space` or `Enter` | When focus is on the accordion header, the accordion will collapse or expand depending on the state of the component. | +| `Tab` | Moves focus to the next focusable element. | +| `Shift` + `Tab` | Moves focus to the previous focusable element. | +| `Down Arrow` | - When focus is on an accordion header, moves focus to the next accordion header. - When focus is on the last accordion header, moves focus to the first accordion header. | +| `Up Arrow` | - When focus is on an accordion header, moves focus to the previous accordion header. - When focus is on the first accordion header, moves focus to the last accordion header. | +| `Home` | When focus is on an accordion header, moves focus to the first accordion header. | +| `End` | When focus is on an accordion header, moves focus to the last accordion header. | + + + + +## Usage + +### Angular + +```html + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + +``` + + +### Javascript + +```html + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + +``` + + +### React + +```tsx +import React from 'react'; + +import { IonContent, IonAccordionGroup, IonAccordion, IonItem, IonLabel } from '@ionic/react'; +import { arrowDownCircle } from 'ionicons/icons'; + +export const AccordionExample: React.FC = () => ( + {/*-- Basic --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + {/*-- Custom Icon --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + {/*-- Open Accordion --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + {/*-- Multiple Accordions --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + +); +``` + + +### Stencil + +```tsx +import { Component, h } from '@stencil/core'; + +@Component({ + tag: 'accordion-example', + styleUrl: 'accordion-example.css' +}) +export const AccordionExample { + render() { + return [ + // Basic + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + // Custom Icon + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + // Open Accordion + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + // Multiple Accordions + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + ]; + } +); +``` + + +### Vue + +```html + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + +``` + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------- | ------------------ | ----------------------------------- | +| `disabled` | `disabled` | If `true`, the accordion cannot be interacted with. | `boolean` | `false` | +| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` | +| `readonly` | `readonly` | If `true`, the accordion cannot be interacted with, but does not alter the opacity. | `boolean` | `false` | +| `toggleIcon` | `toggle-icon` | The toggle icon to use. This icon will be rotated when the accordion is expanded or collapsed. | `string` | `'chevron-down'` | +| `toggleIconSlot` | `toggle-icon-slot` | The slot inside of `ion-item` to place the toggle icon. Defaults to `'end'`. | `"end" \| "start"` | `'end'` | +| `value` | `value` | The value of the accordion. Defaults to an autogenerated value. | `string` | ``ion-accordion-${accordionIds++}`` | + + +## Slots + +| Slot | Description | +| ----------- | ---------------------------------------------------------------------------------- | +| `"content"` | Content is placed below the header and is shown or hidden based on expanded state. | +| `"header"` | Content is placed at the top and is used to expand or collapse the accordion item. | + + +## Shadow Parts + +| Part | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `"content"` | The wrapper element for the content slot. | +| `"expanded"` | The expanded element. Can be used in combination with the `header` and `content` parts (i.e. `::part(header expanded)`). | +| `"header"` | The wrapper element for the header slot. | + + +## Dependencies + +### Depends on + +- ion-icon + +### Graph +```mermaid +graph TD; + ion-accordion --> ion-icon + style ion-accordion fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/core/src/components/accordion/test/a11y/index.html b/core/src/components/accordion/test/a11y/index.html new file mode 100644 index 0000000000..dc6dbb9dda --- /dev/null +++ b/core/src/components/accordion/test/a11y/index.html @@ -0,0 +1,114 @@ + + + + + Accordion - a11y + + + + + + + + + + + Accordion Group - a11y + + + + + Personal Information + + + + + Name + + + + Email + + + + Phone + + + + Extension + + + + Country + + + + City/Province + + + + + + + + Billing Address + + + + + Address 1 + + + + Address 2 + + + + City + + + + State + + + + Zip Code + + + + + + + + Shipping Address + + + + + Address 1 + + + + Address 2 + + + + City + + + + State + + + + Zip Code + + + + + + + + + diff --git a/core/src/components/accordion/test/a11y/screen-readers.md b/core/src/components/accordion/test/a11y/screen-readers.md new file mode 100644 index 0000000000..0a905aac9d --- /dev/null +++ b/core/src/components/accordion/test/a11y/screen-readers.md @@ -0,0 +1,21 @@ +"native" refers to this sample: https://www.w3.org/TR/wai-aria-practices/examples/accordion/accordion.html + +### Selecting Accordion + +Â | native | Ionic +-- | -- | -- +VoiceOver macOS - Chrome | Personal information, collapsed, button | Personal information, collapsed, button +VoiceOver macOS - Safari | Personal information, collapsed, button | Personal information, collapsed, button +VoiceOver iOS | Personal information, collapsed | Personal information, button, main, landmark, collapsed +Android TalkBack | Collapsed, personal information, button | Collapsed, personal information, button +Windows NVDA | Personal information, button, unavailable, collapsed | Clickable Personal Information button collapsed + +### Toggling Accordion + +Â | native | Ionic +-- | -- | -- +VoiceOver macOS - Chrome | Personal information, dimmed expanded, button | Personal information, expanded, button +VoiceOver macOS - Safari | Personal information, dimmed expanded, button | Personal information, expanded, button +VoiceOver iOS | Personal information, dimmed, expanded | Personal information, main, landmark, expanded +Android TalkBack | Expanded | Expanded +Windows NVDA | Unavailable, expanded | Expanded \ No newline at end of file diff --git a/core/src/components/accordion/test/accordion.spec.ts b/core/src/components/accordion/test/accordion.spec.ts new file mode 100644 index 0000000000..d7b9771870 --- /dev/null +++ b/core/src/components/accordion/test/accordion.spec.ts @@ -0,0 +1,259 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { AccordionGroup } from '../../accordion-group/accordion-group.tsx'; +import { Accordion } from '../accordion.tsx'; +import { Item } from '../../item/item.tsx'; + +it('should properly set readonly on child accordions', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + expect(accordions.length).toEqual(1); + accordions.forEach(accordion => { + expect(accordion.readonly).toEqual(false); + }); + + accordionGroup.readonly = true; + await page.waitForChanges(); + + accordions.forEach(accordion => { + expect(accordion.readonly).toEqual(true); + }); +}); + +it('should properly set disabled on child accordions', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + expect(accordions.length).toEqual(1); + accordions.forEach(accordion => { + expect(accordion.disabled).toEqual(false); + }); + + accordionGroup.disabled = true; + await page.waitForChanges(); + + accordions.forEach(accordion => { + expect(accordion.disabled).toEqual(true); + }); +}); + +it('should open correct accordions', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + Label + Content + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + accordions.forEach(accordion => { + expect(accordion.classList.contains('accordion-collapsed')).toEqual(true); + }); + + accordionGroup.value = 'second'; + await page.waitForChanges(); + + expect(accordions[0].classList.contains('accordion-collapsed')).toEqual(true); + expect(accordions[1].classList.contains('accordion-collapsed')).toEqual(false); + expect(accordions[2].classList.contains('accordion-collapsed')).toEqual(true); +}); + + +it('should not open more than one accordion when multiple="false"', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + Label + Content + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + accordions.forEach(accordion => { + expect(accordion.classList.contains('accordion-collapsed')).toEqual(true); + }); + + accordionGroup.value = ['first', 'second']; + await page.waitForChanges(); + + expect(accordions[0].classList.contains('accordion-collapsed')).toEqual(false); + expect(accordions[1].classList.contains('accordion-collapsed')).toEqual(true); + expect(accordions[2].classList.contains('accordion-collapsed')).toEqual(true); +}); + +it('should open more than one accordion when multiple="true"', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + Label + Content + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + accordions.forEach(accordion => { + expect(accordion.classList.contains('accordion-collapsed')).toEqual(true); + }); + + accordionGroup.value = ['first', 'second']; + await page.waitForChanges(); + + expect(accordions[0].classList.contains('accordion-collapsed')).toEqual(false); + expect(accordions[1].classList.contains('accordion-collapsed')).toEqual(false); + expect(accordions[2].classList.contains('accordion-collapsed')).toEqual(true); +}); + +it('should render with accordion open', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + Label + Content + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + expect(accordions[0].classList.contains('accordion-collapsed')).toEqual(false); + expect(accordions[1].classList.contains('accordion-collapsed')).toEqual(true); + expect(accordions[2].classList.contains('accordion-collapsed')).toEqual(true); +}); + +it('should accept a string when multiple="true"', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + Label + Content + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordions = accordionGroup.querySelectorAll('ion-accordion'); + + expect(accordions[0].classList.contains('accordion-collapsed')).toEqual(false); + expect(accordions[1].classList.contains('accordion-collapsed')).toEqual(true); + expect(accordions[2].classList.contains('accordion-collapsed')).toEqual(true); +}); + +it('should set default values if not provided', async () => { + const page = await newSpecPage({ + components: [Item, Accordion, AccordionGroup], + html: ` + + + Label + Content + + + ` + }); + + const accordionGroup = page.body.querySelector('ion-accordion-group'); + const accordion = accordionGroup.querySelector('ion-accordion'); + + /** + * ID is determined via an auto incrementing counter + * so do not hard code ion-accordion-0 as it might + * change depending on how many accordions + * are used in these tests. + */ + expect(accordion.value).toContain('ion-accordion-'); + + accordionGroup.value = accordion.value; + await page.waitForChanges(); + + expect(accordion.classList.contains('accordion-collapsed')).toEqual(false); +}); diff --git a/core/src/components/accordion/test/basic/index.html b/core/src/components/accordion/test/basic/index.html new file mode 100644 index 0000000000..87d1da1f8d --- /dev/null +++ b/core/src/components/accordion/test/basic/index.html @@ -0,0 +1,739 @@ + + + + + Accordion - Basic + + + + + + + + + + + + Accordion - Basic + + + + + + Accordion - Basic + + + + + + Inset, Color - iOS + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + Inset - iOS + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + Inset, Color - MD + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + Inset - MD + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + + Compact, Color - iOS + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + Compact - iOS + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + Compact, Color - MD + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + Compact - MD + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + + Multiple + + + + + Attractions + + + + Movie Theaters + + + Amusement Parks + + + Mini Golf + + + + + + + Dining + + + + Breakfast & Brunch + + + New American + + + Sushi Bars + + + + + + + Games + + + + + Xbox + + + Playstation + + + Switch + + + + + + + Exercise + + + + + Jog + + + Swim + + + Nap + + + + + + + + + + + + diff --git a/core/src/components/accordion/test/e2e.ts b/core/src/components/accordion/test/e2e.ts new file mode 100644 index 0000000000..305fdc7dca --- /dev/null +++ b/core/src/components/accordion/test/e2e.ts @@ -0,0 +1,64 @@ +import { newE2EPage } from '@stencil/core/testing'; +import { AxePuppeteer } from '@axe-core/puppeteer'; + +const getActiveElementText = async (page) => { + const activeElement = await page.evaluateHandle(() => document.activeElement); + return await page.evaluate(el => el && el.innerText, activeElement); +} + +test('accordion: a11y', async () => { + const page = await newE2EPage({ + url: '/src/components/accordion/test/a11y?ionic:_testing=true' + }); + + const compare = await page.compareScreenshot(); + expect(compare).toMatchScreenshot(); +}); + +test('accordion: basic', async () => { + const page = await newE2EPage({ + url: '/src/components/accordion/test/basic?ionic:_testing=true' + }); + + const compare = await page.compareScreenshot(); + expect(compare).toMatchScreenshot(); +}); + +test('accordion:rtl: a11y', async () => { + const page = await newE2EPage({ + url: '/src/components/accordion/test/a11y?ionic:_testing=true&rtl=true' + }); + + const compare = await page.compareScreenshot(); + expect(compare).toMatchScreenshot(); +}); + +test('accordion: keyboard navigation', async () => { + const page = await newE2EPage({ + url: '/src/components/accordion/test/a11y?ionic:_testing=true' + }); + + await page.keyboard.press('Tab'); + expect(await getActiveElementText(page)).toEqual('Personal Information'); + + await page.keyboard.press('ArrowDown'); + expect(await getActiveElementText(page)).toEqual('Billing Address'); + + await page.keyboard.press('ArrowDown'); + expect(await getActiveElementText(page)).toEqual('Shipping Address'); + + await page.keyboard.press('ArrowDown'); + expect(await getActiveElementText(page)).toEqual('Personal Information'); + + await page.keyboard.press('ArrowUp'); + expect(await getActiveElementText(page)).toEqual('Shipping Address'); +}); + +test('accordion: axe', async () => { + const page = await newE2EPage({ + url: '/src/components/accordion/test/standalone?ionic:_testing=true' + }); + + const results = await new AxePuppeteer(page).analyze(); + expect(results.violations.length).toEqual(0); +}); diff --git a/core/src/components/accordion/test/standalone/index.html b/core/src/components/accordion/test/standalone/index.html new file mode 100644 index 0000000000..de0b840e60 --- /dev/null +++ b/core/src/components/accordion/test/standalone/index.html @@ -0,0 +1,133 @@ + + + + + Accordion - Basic + + + + + + + + + Accordion + + + + Default Accordion + + + + + Attractions + + + Some content + + + + + Second one + + + Some content + + + + + + + Readonly Accordion (1st) + + + + + Readonly - expanded + + + + Check me! + + + + + Collapsed + + + + Check me! + + + + + + + Custom Accordion Colors + + + + + Danger + + + Some content + + + + + Primary + + + Some content + + + + + + + + + + diff --git a/core/src/components/accordion/usage/angular.md b/core/src/components/accordion/usage/angular.md new file mode 100644 index 0000000000..398805a23d --- /dev/null +++ b/core/src/components/accordion/usage/angular.md @@ -0,0 +1,221 @@ +```html + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + +``` diff --git a/core/src/components/accordion/usage/javascript.md b/core/src/components/accordion/usage/javascript.md new file mode 100644 index 0000000000..ce873dd5bd --- /dev/null +++ b/core/src/components/accordion/usage/javascript.md @@ -0,0 +1,226 @@ +```html + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + +``` diff --git a/core/src/components/accordion/usage/react.md b/core/src/components/accordion/usage/react.md new file mode 100644 index 0000000000..f046f15073 --- /dev/null +++ b/core/src/components/accordion/usage/react.md @@ -0,0 +1,228 @@ +```tsx +import React from 'react'; + +import { IonContent, IonAccordionGroup, IonAccordion, IonItem, IonLabel } from '@ionic/react'; +import { arrowDownCircle } from 'ionicons/icons'; + +export const AccordionExample: React.FC = () => ( + {/*-- Basic --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + {/*-- Custom Icon --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + {/*-- Open Accordion --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + {/*-- Multiple Accordions --*/} + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + +); +``` diff --git a/core/src/components/accordion/usage/stencil.md b/core/src/components/accordion/usage/stencil.md new file mode 100644 index 0000000000..216c584638 --- /dev/null +++ b/core/src/components/accordion/usage/stencil.md @@ -0,0 +1,233 @@ +```tsx +import { Component, h } from '@stencil/core'; + +@Component({ + tag: 'accordion-example', + styleUrl: 'accordion-example.css' +}) +export const AccordionExample { + render() { + return [ + // Basic + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + // Custom Icon + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + // Open Accordion + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + // Multiple Accordions + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + ]; + } +); +``` diff --git a/core/src/components/accordion/usage/vue.md b/core/src/components/accordion/usage/vue.md new file mode 100644 index 0000000000..52b5f05426 --- /dev/null +++ b/core/src/components/accordion/usage/vue.md @@ -0,0 +1,236 @@ +```html + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + + + + Colors + + + + + Red + + + Green + + + Blue + + + + + + Shapes + + + + + Circle + + + Triangle + + + Square + + + + + + Numbers + + + + + 1 + + + 2 + + + 3 + + + + + + + +``` diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index 08af0a2703..62d658acda 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -3,7 +3,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Meth import { getIonMode } from '../../global/ionic-global'; import { Animation, Gesture, GestureDetail, RefresherEventDetail } from '../../interface'; import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier'; -import { clamp, componentOnReady, getElementRoot, raf } from '../../utils/helpers'; +import { clamp, componentOnReady, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers'; import { hapticImpact } from '../../utils/native/haptic'; import { @@ -14,7 +14,6 @@ import { handleScrollWhileRefreshing, setSpinnerOpacity, shouldUseNativeRefresher, - transitionEndAsync, translateElement } from './refresher.utils'; diff --git a/core/src/components/refresher/refresher.utils.ts b/core/src/components/refresher/refresher.utils.ts index bcf7c90551..4b924716fe 100644 --- a/core/src/components/refresher/refresher.utils.ts +++ b/core/src/components/refresher/refresher.utils.ts @@ -1,7 +1,7 @@ import { writeTask } from '@stencil/core'; import { createAnimation } from '../../utils/animation/animation'; -import { clamp, componentOnReady } from '../../utils/helpers'; +import { clamp, componentOnReady, transitionEndAsync } from '../../utils/helpers'; import { isPlatform } from '../../utils/platform'; // MD Native Refresher @@ -198,46 +198,3 @@ export const shouldUseNativeRefresher = async (referenceEl: HTMLIonRefresherElem ); }; - -export const transitionEndAsync = (el: HTMLElement | null, expectedDuration = 0) => { - return new Promise(resolve => { - transitionEnd(el, expectedDuration, resolve); - }); -}; - -const transitionEnd = (el: HTMLElement | null, expectedDuration = 0, callback: (ev?: TransitionEvent) => void) => { - let unRegTrans: (() => void) | undefined; - let animationTimeout: any; - const opts: any = { passive: true }; - const ANIMATION_FALLBACK_TIMEOUT = 500; - - const unregister = () => { - if (unRegTrans) { - unRegTrans(); - } - }; - - const onTransitionEnd = (ev?: Event) => { - if (ev === undefined || el === ev.target) { - unregister(); - callback(ev as TransitionEvent); - } - }; - - if (el) { - el.addEventListener('webkitTransitionEnd', onTransitionEnd, opts); - el.addEventListener('transitionend', onTransitionEnd, opts); - animationTimeout = setTimeout(onTransitionEnd, expectedDuration + ANIMATION_FALLBACK_TIMEOUT); - - unRegTrans = () => { - if (animationTimeout) { - clearTimeout(animationTimeout); - animationTimeout = undefined; - } - el.removeEventListener('webkitTransitionEnd', onTransitionEnd, opts); - el.removeEventListener('transitionend', onTransitionEnd, opts); - }; - } - - return unregister; -}; diff --git a/core/src/css/core.scss b/core/src/css/core.scss index 16c0a57658..c171eafff8 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -201,3 +201,41 @@ ion-card-header.ion-color .ion-inherit-color { .md .menu-content-push { box-shadow: $menu-md-box-shadow; } + +// Accordion Styles +ion-accordion-group.accordion-group-expand-inset ion-accordion:first-of-type { + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +ion-accordion-group.accordion-group-expand-inset ion-accordion:last-of-type { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} +ion-accordion-group ion-accordion:last-of-type ion-item { + --border-width: 0px; +} + +ion-accordion.accordion-animated .ion-accordion-toggle-icon { + transition: 300ms transform cubic-bezier(0.25, 0.8, 0.5, 1); +} + +@media (prefers-reduced-motion: reduce) { + ion-accordion .ion-accordion-toggle-icon { + /* stylelint-disable declaration-no-important */ + transition: none !important; + } +} + +ion-accordion.accordion-expanding .ion-accordion-toggle-icon, +ion-accordion.accordion-expanded .ion-accordion-toggle-icon { + transform: rotate(180deg); +} +ion-accordion-group.accordion-group-expand-inset.md ion-accordion.accordion-previous ion-item[slot="header"] { + --border-width: 0px; + --inner-border-width: 0px; +} + +ion-accordion-group.accordion-group-expand-inset.md ion-accordion.accordion-expanding:first-of-type, +ion-accordion-group.accordion-group-expand-inset.md ion-accordion.accordion-expanded:first-of-type { + margin-top: 0; +} diff --git a/core/src/interface.d.ts b/core/src/interface.d.ts index 63c9d84196..ff5447b107 100644 --- a/core/src/interface.d.ts +++ b/core/src/interface.d.ts @@ -2,6 +2,7 @@ import { Components as IoniconsComponents, JSX as IoniconsJSX } from 'ionicons'; export * from './components'; export * from './index'; +export * from './components/accordion-group/accordion-group-interface'; export * from './components/alert/alert-interface'; export * from './components/action-sheet/action-sheet-interface'; export * from './components/content/content-interface'; diff --git a/core/src/utils/focus-visible.ts b/core/src/utils/focus-visible.ts index db1ce05ac9..529cd67b19 100644 --- a/core/src/utils/focus-visible.ts +++ b/core/src/utils/focus-visible.ts @@ -1,7 +1,7 @@ const ION_FOCUSED = 'ion-focused'; const ION_FOCUSABLE = 'ion-focusable'; -const FOCUS_KEYS = ['Tab', 'ArrowDown', 'Space', 'Escape', ' ', 'Shift', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp']; +const FOCUS_KEYS = ['Tab', 'ArrowDown', 'Space', 'Escape', ' ', 'Shift', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Home', 'End']; export const startFocusVisible = () => { diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index f35dca35bd..436616e3c3 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -5,6 +5,64 @@ import { Side } from '../interface'; declare const __zone_symbol__requestAnimationFrame: any; declare const requestAnimationFrame: any; +export const transitionEndAsync = (el: HTMLElement | null, expectedDuration = 0) => { + return new Promise(resolve => { + transitionEnd(el, expectedDuration, resolve); + }); +}; + +/** + * Allows developer to wait for a transition + * to finish and fallback to a timer if the + * transition is cancelled or otherwise + * never finishes. Also see transitionEndAsync + * which is an await-able version of this. + */ +const transitionEnd = (el: HTMLElement | null, expectedDuration = 0, callback: (ev?: TransitionEvent) => void) => { + let unRegTrans: (() => void) | undefined; + let animationTimeout: any; + const opts: any = { passive: true }; + const ANIMATION_FALLBACK_TIMEOUT = 500; + + const unregister = () => { + if (unRegTrans) { + unRegTrans(); + } + }; + + const onTransitionEnd = (ev?: Event) => { + if (ev === undefined || el === ev.target) { + unregister(); + callback(ev as TransitionEvent); + } + }; + + if (el) { + el.addEventListener('webkitTransitionEnd', onTransitionEnd, opts); + el.addEventListener('transitionend', onTransitionEnd, opts); + animationTimeout = setTimeout(onTransitionEnd, expectedDuration + ANIMATION_FALLBACK_TIMEOUT); + + unRegTrans = () => { + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = undefined; + } + el.removeEventListener('webkitTransitionEnd', onTransitionEnd, opts); + el.removeEventListener('transitionend', onTransitionEnd, opts); + }; + } + + return unregister; +}; + +/** + * Utility function to wait for + * componentOnReady on Stencil + * components if not using a + * custom elements build or + * quickly resolve if using + * a custom elements build. + */ export const componentOnReady = (el: any, callback: any) => { if (el.componentOnReady) { el.componentOnReady().then(callback); diff --git a/core/stencil.config.ts b/core/stencil.config.ts index c44e94a3b9..71d7601ffb 100644 --- a/core/stencil.config.ts +++ b/core/stencil.config.ts @@ -52,6 +52,7 @@ export const config: Config = { { components: ['ion-toast'] }, { components: ['ion-toggle'] }, { components: ['ion-virtual-scroll'] }, + { components: ['ion-accordion-group', 'ion-accordion'] }, ], plugins: [ sass({ diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index bdcd674465..427568d984 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -8,6 +8,25 @@ import type { JSX } from '@ionic/core'; +export const IonAccordion = /*@__PURE__*/ defineContainer('ion-accordion', [ + 'value', + 'disabled', + 'readonly', + 'toggleIcon', + 'toggleIconSlot' +]); + + +export const IonAccordionGroup = /*@__PURE__*/ defineContainer('ion-accordion-group', [ + 'multiple', + 'value', + 'disabled', + 'readonly', + 'expand', + 'ionChange' +]); + + export const IonAvatar = /*@__PURE__*/ defineContainer('ion-avatar');