From 3d5ed7e81f2343bab3e4b618dee2a3b389d7ceca Mon Sep 17 00:00:00 2001 From: Brandy Carney Date: Mon, 17 Jul 2017 11:00:07 -0400 Subject: [PATCH] feat(item): initial checkin of item sliding --- .../components/item-sliding/item-options.tsx | 69 +++ .../components/item-sliding/item-sliding.scss | 171 ++++++ .../components/item-sliding/item-sliding.tsx | 521 ++++++++++++++++++ .../components/item-sliding/test/basic.html | 309 +++++++++++ packages/core/src/components/item/item.tsx | 8 +- packages/core/stencil.config.js | 2 +- 6 files changed, 1077 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/components/item-sliding/item-options.tsx create mode 100644 packages/core/src/components/item-sliding/item-sliding.scss create mode 100644 packages/core/src/components/item-sliding/item-sliding.tsx create mode 100644 packages/core/src/components/item-sliding/test/basic.html diff --git a/packages/core/src/components/item-sliding/item-options.tsx b/packages/core/src/components/item-sliding/item-options.tsx new file mode 100644 index 0000000000..c3d3d33394 --- /dev/null +++ b/packages/core/src/components/item-sliding/item-options.tsx @@ -0,0 +1,69 @@ +import { Component, h, Ionic, Prop } from '@stencil/core'; + +import { isRightSide, Side } from '../../utils/util'; + + +/** + * @name ItemOptions + * @description + * The option buttons for an `ion-item-sliding`. These buttons can be placed either on the left or right side. + * You can combine the `(ionSwipe)` event plus the `expandable` directive to create a full swipe action for the item. + * + * @usage + * + * ```html + * + * + * Item 1 + * + * + * + * + * + * + * + *``` + */ +@Component({ + tag: 'ion-item-options' +}) +export class ItemOptions { + $el: HTMLElement; + + /** + * @input {string} The side the option button should be on. Defaults to `"right"`. + * If you have multiple `ion-item-options`, a side must be provided for each. + */ + @Prop() side: Side = 'right'; + + /** + * @output {event} Emitted when the item has been fully swiped. + */ + // @Output() ionSwipe: EventEmitter = new EventEmitter(); + + /** + * @hidden + */ + isRightSide(): boolean { + const isRTL = document.dir === 'rtl'; + return isRightSide(this.side, isRTL, true); + } + + /** + * @output {event} Emitted when the item has been fully swiped. + */ + ionSwipe(itemSliding: any) { + Ionic.emit(itemSliding, 'ionSwipe'); + } + + /** + * @hidden + */ + width(): number { + return this.$el.offsetWidth; + } + + render() { + return ; + } +} diff --git a/packages/core/src/components/item-sliding/item-sliding.scss b/packages/core/src/components/item-sliding/item-sliding.scss new file mode 100644 index 0000000000..3b8554b620 --- /dev/null +++ b/packages/core/src/components/item-sliding/item-sliding.scss @@ -0,0 +1,171 @@ +@import "../../themes/ionic.globals"; + +// Item Sliding +// -------------------------------------------------- +// The hidden right-side buttons that can be exposed under a list item with dragging. + +ion-item-sliding { + position: relative; + display: block; + overflow: hidden; + + width: 100%; +} + +ion-item-sliding .item { + position: static; +} + +ion-item-options { + position: absolute; + z-index: $z-index-item-options; + display: none; + + height: 100%; + + font-size: 14px; + visibility: hidden; + + @include multi-dir() { + // scss-lint:disable PropertySpelling + top: 0; + + right: 0; + } + + @include ltr() { + justify-content: flex-end; + } + + @include rtl() { + justify-content: flex-start; + + &:not([side=right]) { + justify-content: flex-end; + + // scss-lint:disable PropertySpelling + right: auto; + left: 0; + } + } +} + +ion-item-options[side=left] { + @include multi-dir() { + // scss-lint:disable PropertySpelling + right: auto; + left: 0; + } + + @include ltr() { + justify-content: flex-start; + } + + @include rtl() { + justify-content: flex-end; + } +} + +ion-item-options .button { + @include margin(0); + @include padding(0, .7em); + @include border-radius(0); + + height: 100%; + + box-shadow: none; + + box-sizing: content-box; +} + +ion-item-options:not([icon-left]) .button:not([icon-only]), // deprecated +ion-item-options:not([icon-start]) .button:not([icon-only]) { + .button-inner { + flex-direction: column; + } + + ion-icon { + @include padding(null, 0, .3em, 0); + } +} + +ion-item-sliding.active-slide { + @include rtl() { + &.active-options-left ion-item-options:not([side=right]) { + width: 100%; + + visibility: visible; + } + } + + .item, + .item.activated { + position: relative; + z-index: $z-index-item-options + 1; + + opacity: 1; + transition: transform 500ms cubic-bezier(.36, .66, .04, 1); + + pointer-events: none; + + will-change: transform; + } + + ion-item-options { + display: flex; + } + + &.active-options-left ion-item-options[side=left], + &.active-options-right ion-item-options:not([side=left]) { + width: 100%; + + visibility: visible; + } +} + +// Item Expandable Animation +// -------------------------------------------------- + +button[expandable] { + flex-shrink: 0; + + transition-duration: 0; + transition-property: none; + transition-timing-function: cubic-bezier(.65, .05, .36, 1); +} + +ion-item-sliding.active-swipe-right button[expandable] { + transition-duration: .6s; + transition-property: padding-left; + + @include multi-dir() { + // scss-lint:disable PropertySpelling + padding-left: 90%; + } + + @include ltr() { + order: 1; + } + + @include rtl() { + order: -1; + } +} + +ion-item-sliding.active-swipe-left button[expandable] { + transition-duration: .6s; + transition-property: padding-right; + + @include multi-dir() { + // scss-lint:disable PropertySpelling + padding-right: 90%; + } + + @include ltr() { + order: -1; + } + + @include rtl() { + order: 1; + } +} diff --git a/packages/core/src/components/item-sliding/item-sliding.tsx b/packages/core/src/components/item-sliding/item-sliding.tsx new file mode 100644 index 0000000000..8e9aa3cf25 --- /dev/null +++ b/packages/core/src/components/item-sliding/item-sliding.tsx @@ -0,0 +1,521 @@ +import { Component, h, Ionic, State } from '@stencil/core'; + +import { GestureDetail, HostElement } from '../../utils/interfaces'; +import { swipeShouldReset } from '../../utils/util'; + +// import { ItemOptions } from './item-options'; + +const SWIPE_MARGIN = 30; +const ELASTIC_FACTOR = 0.55; + +const ITEM_SIDE_FLAG_NONE = 0; +const ITEM_SIDE_FLAG_LEFT = 1 << 0; +const ITEM_SIDE_FLAG_RIGHT = 1 << 1; +const ITEM_SIDE_FLAG_BOTH = ITEM_SIDE_FLAG_LEFT | ITEM_SIDE_FLAG_RIGHT; + + +const enum SlidingState { + Disabled = 1 << 1, + Enabled = 1 << 2, + Right = 1 << 3, + Left = 1 << 4, + + SwipeRight = 1 << 5, + SwipeLeft = 1 << 6, +} + + +/** + * @name ItemSliding + * @description + * A sliding item is a list item that can be swiped to reveal buttons. It requires + * an [Item](../Item) component as a child and a [List](../../list/List) component as + * a parent. All buttons to reveal can be placed in the `` element. + * + * @usage + * ```html + * + * + * + * Item + * + * + * Favorite + * Share + * + * + * + * Unread + * + * + * + * ``` + * + * ### Swipe Direction + * By default, the buttons are revealed when the sliding item is swiped from right to left, + * so the buttons are placed in the right side. But it's also possible to reveal them + * in the right side (sliding from left to right) by setting the `side` attribute + * on the `ion-item-options` element. Up to 2 `ion-item-options` can used at the same time + * in order to reveal two different sets of buttons depending the swipping direction. + * + * ```html + * + * + * + * Archive + * + * + * + * + * + * + * Archive + * + * + * ``` + * + * ### Listening for events (ionDrag) and (ionSwipe) + * It's possible to know the current relative position of the sliding item by subscribing + * to the (ionDrag)` event. + * + * ```html + * + * Item + * + * Favorite + * + * + * ``` + * + * ### Button Layout + * If an icon is placed with text in the option button, by default it will + * display the icon on top of the text. This can be changed to display the icon + * to the left of the text by setting `icon-start` as an attribute on the + * `` element. + * + * ```html + * + * + * + * Archive + * + * + * + * ``` + * + * ### Expandable Options + * + * Options can be expanded to take up the full width of the item if you swipe past + * a certain point. This can be combined with the `ionSwipe` event to call methods + * on the class. + * + * ```html + * + * + * Item + * + * Delete + * + * + * ``` + * + * We can call `delete` by either clicking the button, or by doing a full swipe on the item. + * + * @demo /docs/demos/src/item-sliding/ + * @see {@link /docs/components#lists List Component Docs} + * @see {@link ../Item Item API Docs} + * @see {@link ../../list/List List API Docs} + */ +@Component({ + tag: 'ion-item-sliding', + styleUrl: 'item-sliding.scss', + // TODO REMOVE + styleUrls: { + ios: 'item-sliding.scss', + md: 'item-sliding.scss', + wp: 'item-sliding.scss' + } +}) +export class ItemSliding { + $el: HTMLElement; + item: HostElement; + + openAmount: number = 0; + startX: number = 0; + optsWidthRightSide: number = 0; + optsWidthLeftSide: number = 0; + sides: number; + tmr: number = null; + + // TODO file with item sliding interfaces & item options implement + // leftOptions: ItemOptions; + // rightOptions: ItemOptions; + leftOptions: any; + rightOptions: any; + + optsDirty: boolean = true; + + @State() state: SlidingState = SlidingState.Disabled; + + preSelectedContainer: ItemSliding = null; + selectedContainer: ItemSliding = null; + openContainer: ItemSliding = null; + firstCoordX: number; + firstTimestamp: number; + + /** + * @output {event} Emitted when the sliding position changes. + * It reports the relative position. + * + * ```ts + * onDrag(slidingItem) { + * let percent = slidingItem.getSlidingPercent(); + * if (percent > 0) { + * // positive + * console.log('right side'); + * } else { + * // negative + * console.log('left side'); + * } + * if (Math.abs(percent) > 1) { + * console.log('overscroll'); + * } + * } + * ``` + * + */ + ionDrag() { + Ionic.emit(this, 'ionDrag'); + } + + ionViewDidLoad() { + const options = this.$el.querySelectorAll('ion-item-options') as NodeListOf; + + let sides = 0; + + // Reset left and right options in case they were removed + this.leftOptions = this.rightOptions = null; + + for (var i = 0; i < options.length; i++) { + let option = options[i].$instance; + + if (option.isRightSide()) { + this.rightOptions = option; + sides |= ITEM_SIDE_FLAG_RIGHT; + } else { + this.leftOptions = option; + sides |= ITEM_SIDE_FLAG_LEFT; + } + } + this.optsDirty = true; + this.sides = sides; + + this.item = this.$el.querySelector('ion-item') as HostElement; + } + + canStart(gesture: GestureDetail): boolean { + if (this.selectedContainer) { + return false; + } + // Get swiped sliding container + let container = this; + + // Close open container if it is not the selected one. + if (container !== this.openContainer) { + this.closeOpened(); + } + + this.preSelectedContainer = container; + this.firstCoordX = gesture.currentX; + this.firstTimestamp = Date.now(); + + return true; + } + + onDragStart(gesture: GestureDetail) { + this.selectedContainer = this.openContainer = this.preSelectedContainer; + this.selectedContainer.startSliding(gesture.currentX); + } + + onDragMove(gesture: GestureDetail) { + this.selectedContainer && this.selectedContainer.moveSliding(gesture.currentX); + } + + onDragEnd(gesture: GestureDetail) { + this.selectedContainer.endSliding(gesture.velocityX); + this.selectedContainer = null; + this.preSelectedContainer = null; + } + + closeOpened(): boolean { + this.selectedContainer = null; + + if (this.openContainer) { + this.openContainer.close(); + this.openContainer = null; + return true; + } + return false; + } + + /** + * @hidden + */ + getOpenAmount(): number { + return this.openAmount; + } + + /** + * @hidden + */ + getSlidingPercent(): number { + const openAmount = this.openAmount; + if (openAmount > 0) { + return openAmount / this.optsWidthRightSide; + } else if (openAmount < 0) { + return openAmount / this.optsWidthLeftSide; + } else { + return 0; + } + } + + /** + * @hidden + */ + startSliding(startX: number) { + if (this.tmr) { + clearTimeout(this.tmr); + this.tmr = null; + } + if (this.openAmount === 0) { + this.optsDirty = true; + this.setState(SlidingState.Enabled); + } + this.startX = startX + this.openAmount; + this.item.style.transition = 'none'; + } + + /** + * @hidden + */ + moveSliding(x: number): number { + if (this.optsDirty) { + this.calculateOptsWidth(); + return 0; + } + + let openAmount = (this.startX - x); + + switch (this.sides) { + case ITEM_SIDE_FLAG_RIGHT: openAmount = Math.max(0, openAmount); break; + case ITEM_SIDE_FLAG_LEFT: openAmount = Math.min(0, openAmount); break; + case ITEM_SIDE_FLAG_BOTH: break; + case ITEM_SIDE_FLAG_NONE: return 0; + default: console.warn('invalid ItemSideFlags value', this.sides); break; + } + + if (openAmount > this.optsWidthRightSide) { + var optsWidth = this.optsWidthRightSide; + openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR; + + } else if (openAmount < -this.optsWidthLeftSide) { + var optsWidth = -this.optsWidthLeftSide; + openAmount = optsWidth + (openAmount - optsWidth) * ELASTIC_FACTOR; + } + + this.setOpenAmount(openAmount, false); + return openAmount; + } + + /** + * @hidden + */ + endSliding(velocity: number): number { + let restingPoint = (this.openAmount > 0) + ? this.optsWidthRightSide + : -this.optsWidthLeftSide; + + // Check if the drag didn't clear the buttons mid-point + // and we aren't moving fast enough to swipe open + const isResetDirection = (this.openAmount > 0) === !(velocity < 0); + const isMovingFast = Math.abs(velocity) > 0.3; + const isOnCloseZone = Math.abs(this.openAmount) < Math.abs(restingPoint / 2); + if (swipeShouldReset(isResetDirection, isMovingFast, isOnCloseZone)) { + restingPoint = 0; + } + + this.setOpenAmount(restingPoint, true); + this.fireSwipeEvent(); + return restingPoint; + } + + /** + * @hidden + * Emit the ionSwipe event on the child options + */ + fireSwipeEvent() { + if (this.state & SlidingState.SwipeRight) { + this.rightOptions.ionSwipe(this); + } else if (this.state & SlidingState.SwipeLeft) { + this.leftOptions.ionSwipe(this); + } + } + + /** + * @hidden + */ + calculateOptsWidth() { + if (!this.optsDirty) { + return; + } + this.optsWidthRightSide = 0; + if (this.rightOptions) { + this.optsWidthRightSide = this.rightOptions.width(); + this.optsWidthRightSide == 0 && console.warn('optsWidthRightSide should not be zero'); + } + + this.optsWidthLeftSide = 0; + if (this.leftOptions) { + this.optsWidthLeftSide = this.leftOptions.width(); + this.optsWidthLeftSide == 0 && console.warn('optsWidthLeftSide should not be zero'); + } + this.optsDirty = false; + } + + setOpenAmount(openAmount: number, isFinal: boolean) { + if (this.tmr) { + clearTimeout(this.tmr); + this.tmr = null; + } + this.openAmount = openAmount; + + if (isFinal) { + this.item.style.transition = ''; + + } else { + if (openAmount > 0) { + var state = (openAmount >= (this.optsWidthRightSide + SWIPE_MARGIN)) + ? SlidingState.Right | SlidingState.SwipeRight + : SlidingState.Right; + + this.setState(state); + + } else if (openAmount < 0) { + var state = (openAmount <= (-this.optsWidthLeftSide - SWIPE_MARGIN)) + ? SlidingState.Left | SlidingState.SwipeLeft + : SlidingState.Left; + + this.setState(state); + } + } + if (openAmount === 0) { + this.setState(SlidingState.Disabled); + this.tmr = setTimeout(() => { + this.tmr = null; + this.setState(SlidingState.Disabled); + }, 600); + this.item.style.transform = ''; + return; + } + + this.item.style.transform = `translate3d(${-openAmount}px,0,0)`; + this.ionDrag(); + } + + private setState(state: SlidingState) { + console.log('setState', + this.state + '\n', + 'active-slide', (this.state !== SlidingState.Disabled), + 'active-options-right', !!(this.state & SlidingState.Right), + 'active-options-left', !!(this.state & SlidingState.Left), + 'active-swipe-right', !!(this.state & SlidingState.SwipeRight), + 'active-swipe-left', !!(this.state & SlidingState.SwipeLeft) + ); + + if (state === this.state) { + return; + } + this.state = state; + } + + /** + * Close the sliding item. Items can also be closed from the [List](../../list/List). + * + * The sliding item can be closed by grabbing a reference to `ItemSliding`. In the + * below example, the template reference variable `slidingItem` is placed on the element + * and passed to the `share` method. + * + * ```html + * + * + * + * Item + * + * + * Share + * + * + * + * ``` + * + * ```ts + * import { Component } from '@angular/core'; + * import { ItemSliding } from 'ionic-angular'; + * + * @Component({...}) + * export class MyClass { + * constructor() { } + * + * share(slidingItem: ItemSliding) { + * slidingItem.close(); + * } + * } + * ``` + */ + close() { + this.setOpenAmount(0, true); + } + + hostData() { + console.log('hostData', + this.state + '\n', + 'active-slide', (this.state !== SlidingState.Disabled), + 'active-options-right', !!(this.state & SlidingState.Right), + 'active-options-left', !!(this.state & SlidingState.Left), + 'active-swipe-right', !!(this.state & SlidingState.SwipeRight), + 'active-swipe-left', !!(this.state & SlidingState.SwipeLeft) + ); + + return { + class: { + 'item-wrapper': true, + 'active-slide': (this.state !== SlidingState.Disabled), + 'active-options-right': !!(this.state & SlidingState.Right), + 'active-options-left': !!(this.state & SlidingState.Left), + 'active-swipe-right': !!(this.state & SlidingState.SwipeRight), + 'active-swipe-left': !!(this.state & SlidingState.SwipeLeft) + } + } + } + + render() { + return ( + + + + + ); + } +} \ No newline at end of file diff --git a/packages/core/src/components/item-sliding/test/basic.html b/packages/core/src/components/item-sliding/test/basic.html new file mode 100644 index 0000000000..6aa4e6a81e --- /dev/null +++ b/packages/core/src/components/item-sliding/test/basic.html @@ -0,0 +1,309 @@ + + + + + Ionic Item Sliding + + + + + + + +
+ Toggle sliding + Change Dynamic Options + Close Opened Items +
+ + + + + + +

No Options

+

Should not error or swipe without options

+
+
+
+ + + + + One Line, dynamic option and text + + + + + + {{ moreText }} + + + + {{ archiveText }} + + + + + + + + Two options, one dynamic option and text + + + + + + + + + + + {{ moreText }} + + + + {{ archiveText }} + + + + + + + +

HubStruck Notifications

+

A new message from a repo in your network

+

Oceanic Next has joined your network

+
+ + 10:45 AM + +
+ + + + No close + + + + + + + + + + + +
+ + + + +

RIGHT side - no icons

+

Hey do you want to go to the game tonight?

+
+
+ + Archive + Delete + +
+ + + + +

LEFT side - no icons

+

I think I figured out how to get more Mountain Dew

+
+
+ + Archive + Delete + +
+ + + + + +

RIGHT/LEFT side - icons

+

I think I figured out how to get more Mountain Dew

+
+
+ + + Unread + + + + + + Archive + + + Delete + + +
+ + + + +

RIGHT/LEFT side - icons (slot="start")

+

I think I figured out how to get more Mountain Dew

+
+
+ + + Unread + + + + + + Archive + + + Delete + + +
+ + + + + + + One Line w/ Icon, div only text + + + + + Archive + + + + + + + + + + + + One Line w/ Avatar, div only text + + + + + More + + + Archive + + + Delete + + + + + + + + + One Line, dynamic icon-start option + + + + + + {{ moreText }} + + + + {{ archiveText }} + + + + + + + + + + +

DOWNLOAD

+

Paragraph text.

+
+
+ + + Archive + + + +
Download
+ +
+
+
+ + + + + + + +

ion-item-sliding without options (no sliding)

+

Paragraph text.

+
+
+
+ + + +

Normal ion-item (no sliding)

+

Paragraph text.

+
+
+ + + +

Normal button (no sliding)

+

Hey do you want to go to the game tonight?

+
+
+ +
+ + +
+
+ + diff --git a/packages/core/src/components/item/item.tsx b/packages/core/src/components/item/item.tsx index 9eb7aa13e9..fa73db6e11 100644 --- a/packages/core/src/components/item/item.tsx +++ b/packages/core/src/components/item/item.tsx @@ -17,6 +17,7 @@ export class Item { @Prop() mode: string; @Prop() color: string; + @Prop() href: string; @Listen('ionStyle') itemStyle(ev: UIEvent) { @@ -51,8 +52,11 @@ export class Item { 'item-block': true }; + // TODO add support for button items + const TagType = this.href ? 'a' : 'div'; + return ( -
+
@@ -60,7 +64,7 @@ export class Item {
-
+ ); // template: diff --git a/packages/core/stencil.config.js b/packages/core/stencil.config.js index ec749c6c73..ef8df16917 100644 --- a/packages/core/stencil.config.js +++ b/packages/core/stencil.config.js @@ -10,7 +10,7 @@ exports.config = { { components: ['ion-card', 'ion-card-content', 'ion-card-header', 'ion-card-title'] }, { components: ['ion-fab', 'ion-fab-button', 'ion-fab-list'] }, { components: ['ion-gesture', 'ion-scroll'], priority: 'low' }, - { components: ['ion-item', 'ion-item-divider', 'ion-label', 'ion-list', 'ion-list-header', 'ion-skeleton-text'] }, + { components: ['ion-item', 'ion-item-divider', 'ion-item-sliding', 'ion-item-options', 'ion-label', 'ion-list', 'ion-list-header', 'ion-skeleton-text'] }, { components: ['ion-loading', 'ion-loading-controller'] }, { components: ['ion-menu'], priority: 'low' }, { components: ['ion-modal', 'ion-modal-controller'] },