feat(segment-view): adds support for new ion-segment-view component (#29969)

Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Segments can only be changed by clicking a segment button, or dragging
the indicator

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

The segment/segment buttons can now be linked to segment content within
a segment view component. This content is scrollable/swipeable. Changing
the content will update the segment/indicator and vice-versa.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

**Limitations:**
- Segment buttons **cannot** be disabled when connected ton
`ion-segment-content` instances
- The `ion-segment` **cannot** be without a value when linked with an
`ion-segment-view`. If no value is provided, the value will default to
the value of the first `ion-segment-content`


[Preview](https://ionic-framework-jlt8by2io-ionic1.vercel.app/src/components/segment-view/test/basic)
[Preview (disabled
state)](https://ionic-framework-jlt8by2io-ionic1.vercel.app/src/components/segment-view/test/disabled)

---------

Co-authored-by: Brandy Carney <brandyscarney@gmail.com>
This commit is contained in:
Tanner Reits
2024-10-31 12:20:02 -04:00
committed by Tanner Reits
parent 3628ea875a
commit 89508fb891
26 changed files with 1096 additions and 23 deletions

View File

@ -0,0 +1,4 @@
export interface SegmentViewScrollEvent {
scrollRatio: number;
isManualScroll: boolean;
}

View File

@ -0,0 +1,9 @@
@import "./segment-view";
@import "../segment-button/segment-button.ios.vars";
// iOS Segment View
// --------------------------------------------------
:host(.segment-view-disabled) {
opacity: $segment-button-ios-opacity-disabled;
}

View File

@ -0,0 +1,9 @@
@import "./segment-view";
@import "../segment-button/segment-button.md.vars";
// Material Design Segment View
// --------------------------------------------------
:host(.segment-view-disabled) {
opacity: $segment-button-md-opacity-disabled;
}

View File

@ -0,0 +1,31 @@
// Segment View
// --------------------------------------------------
:host {
display: flex;
height: 100%;
overflow-x: scroll;
scroll-snap-type: x mandatory;
/* Hide scrollbar in Firefox */
scrollbar-width: none;
/* Hide scrollbar in IE and Edge */
-ms-overflow-style: none;
}
/* Hide scrollbar in webkit */
:host::-webkit-scrollbar {
display: none;
}
:host(.segment-view-disabled) {
touch-action: none;
overflow-x: hidden;
}
:host(.segment-view-scroll-disabled) {
pointer-events: none;
}

View File

@ -0,0 +1,153 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, State, h } from '@stencil/core';
import type { SegmentViewScrollEvent } from './segment-view-interface';
@Component({
tag: 'ion-segment-view',
styleUrls: {
ios: 'segment-view.ios.scss',
md: 'segment-view.md.scss',
},
shadow: true,
})
export class SegmentView implements ComponentInterface {
private scrollEndTimeout: ReturnType<typeof setTimeout> | null = null;
private isTouching = false;
@Element() el!: HTMLElement;
/**
* If `true`, the segment view cannot be interacted with.
*/
@Prop() disabled = false;
/**
* @internal
*
* If `true`, the segment view is scrollable.
* If `false`, pointer events will be disabled. This is to prevent issues with
* quickly scrolling after interacting with a segment button.
*/
@State() isManualScroll?: boolean;
/**
* Emitted when the segment view is scrolled.
*/
@Event() ionSegmentViewScroll!: EventEmitter<SegmentViewScrollEvent>;
@Listen('scroll')
handleScroll(ev: Event) {
const { scrollLeft, scrollWidth, clientWidth } = ev.target as HTMLElement;
const scrollRatio = scrollLeft / (scrollWidth - clientWidth);
this.ionSegmentViewScroll.emit({
scrollRatio,
isManualScroll: this.isManualScroll ?? true,
});
// Reset the timeout to check for scroll end
this.resetScrollEndTimeout();
}
/**
* Handle touch start event to know when the user is actively dragging the segment view.
*/
@Listen('touchstart')
handleScrollStart() {
if (this.scrollEndTimeout) {
clearTimeout(this.scrollEndTimeout);
this.scrollEndTimeout = null;
}
this.isTouching = true;
}
/**
* Handle touch end event to know when the user is no longer dragging the segment view.
*/
@Listen('touchend')
handleTouchEnd() {
this.isTouching = false;
}
/**
* Reset the scroll end detection timer. This is called on every scroll event.
*/
private resetScrollEndTimeout() {
if (this.scrollEndTimeout) {
clearTimeout(this.scrollEndTimeout);
this.scrollEndTimeout = null;
}
this.scrollEndTimeout = setTimeout(
() => {
this.checkForScrollEnd();
},
// Setting this to a lower value may result in inconsistencies in behavior
// across browsers (particularly Firefox).
// Ideally, all of this logic is removed once the scroll end event is
// supported on all browsers (https://caniuse.com/?search=scrollend)
100
);
}
/**
* Check if the scroll has ended and the user is not actively touching.
* If the conditions are met (active content is enabled and no active touch),
* reset the scroll position and emit the scroll end event.
*/
private checkForScrollEnd() {
// Only emit scroll end event if the active content is not disabled and
// the user is not touching the segment view
if (!this.isTouching) {
this.isManualScroll = undefined;
}
}
/**
* @internal
*
* This method is used to programmatically set the displayed segment content
* in the segment view. Calling this method will update the `value` of the
* corresponding segment button.
*
* @param id: The id of the segment content to display.
* @param smoothScroll: Whether to animate the scroll transition.
*/
@Method()
async setContent(id: string, smoothScroll = true) {
const contents = this.getSegmentContents();
const index = contents.findIndex((content) => content.id === id);
if (index === -1) return;
this.isManualScroll = false;
this.resetScrollEndTimeout();
const contentWidth = this.el.offsetWidth;
this.el.scrollTo({
top: 0,
left: index * contentWidth,
behavior: smoothScroll ? 'smooth' : 'instant',
});
}
private getSegmentContents(): HTMLIonSegmentContentElement[] {
return Array.from(this.el.querySelectorAll('ion-segment-content'));
}
render() {
const { disabled, isManualScroll } = this;
return (
<Host
class={{
'segment-view-disabled': disabled,
'segment-view-scroll-disabled': isManualScroll === false,
}}
>
<slot></slot>
</Host>
);
}
}

View File

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Segment View - Basic</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
ion-segment-view {
height: 100px;
margin-bottom: 20px;
}
ion-segment-content {
display: flex;
justify-content: center;
align-items: center;
}
ion-segment-content:nth-of-type(3n + 1) {
background: lightpink;
}
ion-segment-content:nth-of-type(3n + 2) {
background: lightblue;
}
ion-segment-content:nth-of-type(3n + 3) {
background: lightgreen;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Segment View - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-segment id="noValueSegment">
<ion-segment-button content-id="no" value="no">
<ion-label>No</ion-label>
</ion-segment-button>
<ion-segment-button content-id="value" value="value">
<ion-label>Value</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view id="noValueSegmentView">
<ion-segment-content id="no">No</ion-segment-content>
<ion-segment-content id="value">Value</ion-segment-content>
</ion-segment-view>
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button style="min-width: 200px" content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
<ion-segment value="peach" scrollable>
<ion-segment-button content-id="orange" value="orange">
<ion-label>Orange</ion-label>
</ion-segment-button>
<ion-segment-button content-id="banana" value="banana">
<ion-label>Banana</ion-label>
</ion-segment-button>
<ion-segment-button content-id="pear" value="pear">
<ion-label>Pear</ion-label>
</ion-segment-button>
<ion-segment-button content-id="peach" value="peach">
<ion-label>Peach</ion-label>
</ion-segment-button>
<ion-segment-button content-id="grape" value="grape">
<ion-label>Grape</ion-label>
</ion-segment-button>
<ion-segment-button content-id="mango" value="mango">
<ion-label>Mango</ion-label>
</ion-segment-button>
<ion-segment-button content-id="apple" value="apple">
<ion-label>Apple</ion-label>
</ion-segment-button>
<ion-segment-button content-id="strawberry" value="strawberry">
<ion-label>Strawberry</ion-label>
</ion-segment-button>
<ion-segment-button content-id="cherry" value="cherry">
<ion-label>Cherry</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="orange">Orange</ion-segment-content>
<ion-segment-content id="banana">Banana</ion-segment-content>
<ion-segment-content id="pear">Pear</ion-segment-content>
<ion-segment-content id="peach">Peach</ion-segment-content>
<ion-segment-content id="grape">Grape</ion-segment-content>
<ion-segment-content id="mango">Mango</ion-segment-content>
<ion-segment-content id="apple">Apple</ion-segment-content>
<ion-segment-content id="strawberry">Strawberry</ion-segment-content>
<ion-segment-content id="cherry">Cherry</ion-segment-content>
</ion-segment-view>
<button class="expand" onClick="changeSegmentContent()">Change Segment Content</button>
<button class="expand" onClick="clearSegmentValue()">Clear Segment Value</button>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
<script>
function changeSegmentContent() {
const segment = document.querySelector('#noValueSegment');
const segmentView = document.querySelector('#noValueSegmentView');
let currentValue = segment.value;
if (currentValue === 'value') {
currentValue = 'no';
} else {
currentValue = 'value';
}
segment.value = currentValue;
}
async function clearSegmentValue() {
const segmentView = document.querySelector('#noValueSegmentView');
segmentView.setContent('no', false);
// Set timeout to ensure the value is cleared after
// the segment content is updated
setTimeout(() => {
const segment = document.querySelector('#noValueSegment');
segment.value = undefined;
});
}
</script>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,173 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('segment-view: basic'), () => {
test('should show the first content with no initial value', async ({ page }) => {
await page.setContent(
`
<ion-segment>
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
const segmentContent = page.locator('ion-segment-content[id="paid"]');
await expect(segmentContent).toBeInViewport();
});
test('should show the content matching the initial value', async ({ page }) => {
await page.setContent(
`
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
const segmentContent = page.locator('ion-segment-content[id="free"]');
await expect(segmentContent).toBeInViewport();
});
test('should update the content when changing the value by clicking a segment button', async ({ page }) => {
await page.setContent(
`
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
await page.locator('ion-segment-button[value="top"]').click();
const segmentContent = page.locator('ion-segment-content[id="top"]');
await expect(segmentContent).toBeInViewport();
});
});
test('should set correct segment button as checked when changing the value by scrolling the segment content', async ({
page,
}) => {
await page.setContent(
`
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
await page
.locator('ion-segment-view')
.evaluate(
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
);
await page.waitForChanges();
await page.locator('ion-segment-content[id="top"]').scrollIntoViewIfNeeded();
const segmentButton = page.locator('ion-segment-button[value="top"]');
await expect(segmentButton).toHaveClass(/segment-button-checked/);
});
test('should set correct segment button as checked and show correct content when programmatically setting the segment vale', async ({
page,
}) => {
await page.setContent(
`
<ion-segment value="free">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
await page
.locator('ion-segment-view')
.evaluate(
(segmentView: HTMLIonSegmentViewElement) => !segmentView.classList.contains('segment-view-scroll-disabled')
);
await page.waitForChanges();
await page.locator('ion-segment').evaluate((segment: HTMLIonSegmentElement) => (segment.value = 'top'));
const segmentButton = page.locator('ion-segment-button[value="top"]');
await expect(segmentButton).toHaveClass(/segment-button-checked/);
const segmentContent = page.locator('ion-segment-content[id="top"]');
await expect(segmentContent).toBeInViewport();
});
});

View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Segment View - Disabled</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
ion-segment-view {
height: 100px;
}
ion-segment-content {
display: flex;
justify-content: center;
align-items: center;
}
ion-segment-content:nth-of-type(1) {
background: lightpink;
}
ion-segment-content:nth-of-type(2) {
background: lightblue;
}
ion-segment-content:nth-of-type(3) {
background: lightgreen;
}
ion-segment-content:nth-of-type(4) {
background: lightgoldenrodyellow;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Segment View - Disabled</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-segment>
<ion-segment-button disabled content-id="all" value="all">
<ion-label>All</ion-label>
</ion-segment-button>
<ion-segment-button content-id="favorites" value="favorites">
<ion-label>Favorites</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="all">All</ion-segment-content>
<ion-segment-content id="favorites">Favorites</ion-segment-content>
</ion-segment-view>
<ion-segment disabled value="paid">
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
<ion-segment value="reading-list">
<ion-segment-button content-id="bookmarks" value="bookmarks">
<ion-label>Bookmarks</ion-label>
</ion-segment-button>
<ion-segment-button content-id="reading-list" value="reading-list">
<ion-label>Reading List</ion-label>
</ion-segment-button>
<ion-segment-button content-id="shared-links" value="shared-links">
<ion-label>Shared Links</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view disabled>
<ion-segment-content id="bookmarks">Bookmarks</ion-segment-content>
<ion-segment-content id="reading-list">Reading List</ion-segment-content>
<ion-segment-content id="shared-links">Shared Links</ion-segment-content>
</ion-segment-view>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,49 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across directions
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('segment-view: disabled'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto('/src/components/segment-view/test/disabled', config);
await expect(page).toHaveScreenshot(screenshot(`segment-view-disabled`));
});
});
});
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('segment-view: disabled'), () => {
test('should keep button enabled even when disabled prop is set', async ({ page }) => {
await page.setContent(
`
<ion-segment>
<ion-segment-button content-id="paid" value="paid">
<ion-label>Paid</ion-label>
</ion-segment-button>
<ion-segment-button disabled content-id="free" value="free">
<ion-label>Free</ion-label>
</ion-segment-button>
<ion-segment-button content-id="top" value="top">
<ion-label>Top</ion-label>
</ion-segment-button>
</ion-segment>
<ion-segment-view>
<ion-segment-content disabled id="paid">Paid</ion-segment-content>
<ion-segment-content id="free">Free</ion-segment-content>
<ion-segment-content id="top">Top</ion-segment-content>
</ion-segment-view>
`,
config
);
const segmentButton = page.locator('ion-segment-button[value="free"]');
await expect(segmentButton).not.toHaveClass(/segment-button-disabled/);
});
});
});