From 073883a0987149e9f6258ca43c46f5ed4bce0dc5 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Wed, 24 Mar 2021 09:17:54 -0400 Subject: [PATCH] feat(accordion): add accordion and accordion-group components (#22865) resolves #17094 --- .github/COMPONENT-GUIDE.md | 14 + angular/src/directives/proxies-list.txt | 2 + angular/src/directives/proxies.ts | 24 + angular/src/ionic-module.ts | 4 +- core/api.txt | 20 + core/package-lock.json | 40 + core/package.json | 1 + core/src/components.d.ts | 130 +- .../accordion-group-interface.ts | 8 + .../accordion-group/accordion-group.ios.scss | 9 + .../accordion-group/accordion-group.md.scss | 27 + .../accordion-group/accordion-group.scss | 13 + .../accordion-group/accordion-group.tsx | 214 +++ core/src/components/accordion-group/readme.md | 31 + .../components/accordion/accordion.ios.scss | 9 + .../components/accordion/accordion.md.scss | 4 + .../accordion/accordion.md.vars.scss | 13 + core/src/components/accordion/accordion.scss | 81 ++ core/src/components/accordion/accordion.tsx | 427 ++++++ .../components/accordion/accordion.vars.scss | 19 + core/src/components/accordion/readme.md | 1295 +++++++++++++++++ .../components/accordion/test/a11y/index.html | 114 ++ .../accordion/test/a11y/screen-readers.md | 21 + .../accordion/test/accordion.spec.ts | 259 ++++ .../accordion/test/basic/index.html | 739 ++++++++++ core/src/components/accordion/test/e2e.ts | 64 + .../accordion/test/standalone/index.html | 133 ++ .../src/components/accordion/usage/angular.md | 221 +++ .../components/accordion/usage/javascript.md | 226 +++ core/src/components/accordion/usage/react.md | 228 +++ .../src/components/accordion/usage/stencil.md | 233 +++ core/src/components/accordion/usage/vue.md | 236 +++ core/src/components/refresher/refresher.tsx | 3 +- .../components/refresher/refresher.utils.ts | 45 +- core/src/css/core.scss | 38 + core/src/interface.d.ts | 1 + core/src/utils/focus-visible.ts | 2 +- core/src/utils/helpers.ts | 58 + core/stencil.config.ts | 1 + packages/vue/src/proxies.ts | 19 + 40 files changed, 4977 insertions(+), 49 deletions(-) create mode 100644 core/src/components/accordion-group/accordion-group-interface.ts create mode 100644 core/src/components/accordion-group/accordion-group.ios.scss create mode 100644 core/src/components/accordion-group/accordion-group.md.scss create mode 100644 core/src/components/accordion-group/accordion-group.scss create mode 100644 core/src/components/accordion-group/accordion-group.tsx create mode 100644 core/src/components/accordion-group/readme.md create mode 100644 core/src/components/accordion/accordion.ios.scss create mode 100644 core/src/components/accordion/accordion.md.scss create mode 100644 core/src/components/accordion/accordion.md.vars.scss create mode 100644 core/src/components/accordion/accordion.scss create mode 100644 core/src/components/accordion/accordion.tsx create mode 100644 core/src/components/accordion/accordion.vars.scss create mode 100644 core/src/components/accordion/readme.md create mode 100644 core/src/components/accordion/test/a11y/index.html create mode 100644 core/src/components/accordion/test/a11y/screen-readers.md create mode 100644 core/src/components/accordion/test/accordion.spec.ts create mode 100644 core/src/components/accordion/test/basic/index.html create mode 100644 core/src/components/accordion/test/e2e.ts create mode 100644 core/src/components/accordion/test/standalone/index.html create mode 100644 core/src/components/accordion/usage/angular.md create mode 100644 core/src/components/accordion/usage/javascript.md create mode 100644 core/src/components/accordion/usage/react.md create mode 100644 core/src/components/accordion/usage/stencil.md create mode 100644 core/src/components/accordion/usage/vue.md 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 +
+ Some content +
+ + + +
+ Some content +
+
+ + + +
+

Readonly Accordion (1st)

+ + + + +
+ + +
+
+ + +
+ + +
+
+
+
+ +
+

Custom Accordion Colors

+ + + + +
+ Some content +
+
+ + +
+ 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 + + + +``` 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');