chore: sync with main

This commit is contained in:
Liam DeBeasi
2024-03-12 13:11:23 -04:00
92 changed files with 922 additions and 1894 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -251,6 +251,7 @@ export class Checkbox implements ComponentInterface {
return (
<Host
aria-checked={indeterminate ? 'mixed' : `${checked}`}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),

View File

@ -39,3 +39,18 @@ describe('ion-checkbox: disabled', () => {
expect(checkbox.checked).toBe(false);
});
});
describe('ion-checkbox: indeterminate', () => {
it('should have a mixed value for aria-checked', async () => {
const page = await newSpecPage({
components: [Checkbox],
html: `
<ion-checkbox indeterminate="true">Checkbox</ion-checkbox>
`,
});
const checkbox = page.body.querySelector('ion-checkbox')!;
expect(checkbox.getAttribute('aria-checked')).toBe('mixed');
});
});

View File

@ -323,7 +323,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: visibility'), () => {
test('should reset month/year interface when hiding datetime', async ({ page }) => {
// TODO FW-6015 re-enable on webkit when bug is fixed
test('should reset month/year interface when hiding datetime', async ({ page, skip }) => {
skip.browser(
'webkit',
'This is buggy in a headless Linux environment: https://bugs.webkit.org/show_bug.cgi?id=270358'
);
await page.setContent(
`
<ion-datetime></ion-datetime>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -29,14 +29,6 @@
overflow: hidden;
}
// TODO(FW-5289): move to :host-context(.item)
// Shouldn't need :not(.item-input) as this was
// only needed because of the specificity with
// :not(.item-legacy)
:host-context(.item:not(.item-input):not(.item-legacy)) {
flex-grow: 1;
}
:host(.ion-color) {
color: current-color(base);
}

View File

@ -0,0 +1,27 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('label: in item'), () => {
test('should render correctly in an item', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29033',
});
await page.setContent(
`
<ion-item>
<ion-label slot="start">Start</ion-label>
<ion-label>Default</ion-label>
<ion-label slot="end">End</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
await expect(item).toHaveScreenshot(screenshot(`label-item`));
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -345,10 +345,47 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
componentWillLoad() {
const { breakpoints, initialBreakpoint, el } = this;
const { breakpoints, initialBreakpoint, el, htmlAttributes } = this;
const isSheetModal = (this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined);
this.inheritedAttributes = inheritAttributes(el, ['aria-label', 'role']);
const attributesToInherit = ['aria-label', 'role'];
this.inheritedAttributes = inheritAttributes(el, attributesToInherit);
/**
* When using a controller modal you can set attributes
* using the htmlAttributes property. Since the above attributes
* need to be inherited inside of the modal, we need to look
* and see if these attributes are being set via htmlAttributes.
*
* We could alternatively move this to componentDidLoad to simplify the work
* here, but we'd then need to make inheritedAttributes a State variable,
* thus causing another render to always happen after the first render.
*/
if (htmlAttributes !== undefined) {
attributesToInherit.forEach((attribute) => {
const attributeValue = htmlAttributes[attribute];
if (attributeValue) {
/**
* If an attribute we need to inherit was
* set using htmlAttributes then add it to
* inheritedAttributes and remove it from htmlAttributes.
* This ensures the attribute is inherited and not
* set on the host.
*
* In this case, if an inherited attribute is set
* on the host element and using htmlAttributes then
* htmlAttributes wins, but that's not a pattern that we recommend.
* The only time you'd need htmlAttributes is when using modalController.
*/
this.inheritedAttributes = {
...this.inheritedAttributes,
[attribute]: htmlAttributes[attribute],
};
delete htmlAttributes[attribute];
}
});
}
if (isSheetModal) {
this.currentBreakpoint = this.initialBreakpoint;

View File

@ -1,23 +0,0 @@
import { newSpecPage } from '@stencil/core/testing';
import { Modal } from '../../modal';
describe('modal: a11y', () => {
it('should allow for custom role', async () => {
/**
* Note: This example should not be used in production.
* This only serves to check that `role` can be customized.
*/
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal role="alertdialog"></ion-modal>
`,
});
const modal = page.body.querySelector('ion-modal')!;
const modalWrapper = modal.shadowRoot!.querySelector('.modal-wrapper')!;
await expect(modalWrapper.getAttribute('role')).toBe('alertdialog');
});
});

View File

@ -0,0 +1,39 @@
import { h } from '@stencil/core';
import { newSpecPage } from '@stencil/core/testing';
import { Modal } from '../modal';
it('should inherit attributes', async () => {
/**
* Note: This example should not be used in production.
* This only serves to check that `role` can be customized.
*/
const page = await newSpecPage({
components: [Modal],
template: () => <ion-modal overlayIndex={1} aria-label="my label" role="presentation"></ion-modal>,
});
const modal = page.body.querySelector('ion-modal')!;
const contentWrapper = modal.shadowRoot!.querySelector('[part="content"]')!;
expect(contentWrapper.getAttribute('aria-label')).toBe('my label');
expect(contentWrapper.getAttribute('role')).toBe('presentation');
});
it('should inherit attributes when set via htmlAttributes', async () => {
const page = await newSpecPage({
components: [Modal],
template: () => (
<ion-modal overlayIndex={1} htmlAttributes={{ 'aria-label': 'my label', role: 'presentation' }}></ion-modal>
),
});
const modal = page.body.querySelector('ion-modal')!;
const contentWrapper = modal.shadowRoot!.querySelector('[part="content"]')!;
expect(contentWrapper.getAttribute('aria-label')).toBe('my label');
expect(contentWrapper.getAttribute('role')).toBe('presentation');
expect(modal.hasAttribute('aria-label')).toBe(false);
expect(modal.hasAttribute('role')).toBe(false);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -299,8 +299,14 @@ export class Range implements ComponentInterface {
el: rangeSlider,
gestureName: 'range',
gesturePriority: 100,
threshold: 0,
onStart: (ev) => this.onStart(ev),
/**
* Provide a threshold since the drag movement
* might be a user scrolling the view.
* If this is true, then the range
* should not move.
*/
threshold: 10,
onStart: () => this.onStart(),
onMove: (ev) => this.onMove(ev),
onEnd: (ev) => this.onEnd(ev),
});
@ -418,42 +424,101 @@ export class Range implements ComponentInterface {
this.ionChange.emit({ value: this.value });
}
private onStart(detail: GestureDetail) {
const { contentEl } = this;
if (contentEl) {
this.initialContentScrollY = disableContentScrollY(contentEl);
}
const rect = (this.rect = this.rangeSlider!.getBoundingClientRect() as any);
const currentX = detail.currentX;
// figure out which knob they started closer to
let ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
if (isRTL(this.el)) {
ratio = 1 - ratio;
}
this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B';
this.setFocus(this.pressedKnob);
// update the active knob's position
this.update(currentX);
/**
* The value should be updated on touch end or
* when the component is being dragged.
* This follows the native behavior of mobile devices.
*
* For example: When the user lifts their finger from the
* screen after tapping the bar or dragging the bar or knob.
*/
private onStart() {
this.ionKnobMoveStart.emit({ value: this.ensureValueInBounds(this.value) });
}
/**
* The value should be updated while dragging the
* bar or knob.
*
* While the user is dragging, the view
* should not scroll. This is to prevent the user from
* feeling disoriented while dragging.
*
* The user can scroll on the view if the knob or
* bar is not being dragged.
*
* @param detail The details of the gesture event.
*/
private onMove(detail: GestureDetail) {
this.update(detail.currentX);
const { contentEl, pressedKnob } = this;
const currentX = detail.currentX;
/**
* Since the user is dragging on the bar or knob, the view should not scroll.
*
* This only needs to be done once.
*/
if (contentEl && this.initialContentScrollY === undefined) {
this.initialContentScrollY = disableContentScrollY(contentEl);
}
/**
* The `pressedKnob` can be undefined if the user just
* started dragging the knob.
*
* This is necessary to determine which knob the user is dragging,
* especially when it's a dual knob.
* Plus, it determines when to apply certain styles.
*
* This only needs to be done once since the knob won't change
* while the user is dragging.
*/
if (pressedKnob === undefined) {
this.setPressedKnob(currentX);
}
this.update(currentX);
}
private onEnd(detail: GestureDetail) {
/**
* The value should be updated on touch end:
* - When the user lifts their finger from the screen after
* tapping the bar.
*
* @param detail The details of the gesture or mouse event.
*/
private onEnd(detail: GestureDetail | MouseEvent) {
const { contentEl, initialContentScrollY } = this;
if (contentEl) {
const currentX = (detail as GestureDetail).currentX || (detail as MouseEvent).clientX;
/**
* The `pressedKnob` can be undefined if the user never
* dragged the knob. They just tapped on the bar.
*
* This is necessary to determine which knob the user is changing,
* especially when it's a dual knob.
* Plus, it determines when to apply certain styles.
*/
if (this.pressedKnob === undefined) {
this.setPressedKnob(currentX);
}
/**
* The user is no longer dragging the bar or
* knob (if they were dragging it).
*
* The user can now scroll on the view in the next gesture event.
*/
if (contentEl && initialContentScrollY !== undefined) {
resetContentScrollY(contentEl, initialContentScrollY);
}
this.update(detail.currentX);
// update the active knob's position
this.update(currentX);
/**
* Reset the pressed knob to undefined since the user
* may start dragging a different knob in the next gesture event.
*/
this.pressedKnob = undefined;
this.emitValueChange();
@ -485,6 +550,19 @@ export class Range implements ComponentInterface {
this.updateValue();
}
private setPressedKnob(currentX: number) {
const rect = (this.rect = this.rangeSlider!.getBoundingClientRect() as any);
// figure out which knob they started closer to
let ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
if (isRTL(this.el)) {
ratio = 1 - ratio;
}
this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B';
this.setFocus(this.pressedKnob);
}
private get valA() {
return ratioToValue(this.ratioA, this.min, this.max, this.step);
}
@ -799,7 +877,39 @@ Developers can dismiss this warning by removing their usage of the "legacy" prop
}
return (
<div class="range-slider" ref={(rangeEl) => (this.rangeSlider = rangeEl)}>
<div
class="range-slider"
ref={(rangeEl) => (this.rangeSlider = rangeEl)}
/**
* Since the gesture has a threshold, the value
* won't change until the user has dragged past
* the threshold. This is to prevent the range
* from moving when the user is scrolling.
*
* This results in the value not being updated
* and the event emitters not being triggered
* if the user taps on the range. This is why
* we need to listen for the "pointerUp" event.
*/
onPointerUp={(ev: PointerEvent) => {
/**
* If the user drags the knob on the web
* version (does not occur on mobile),
* the "pointerUp" event will be triggered
* along with the gesture's events.
* This leads to duplicate events.
*
* By checking if the pressedKnob is undefined,
* we can determine if the "pointerUp" event was
* triggered by a tap or a drag. If it was
* dragged, the pressedKnob will be defined.
*/
if (this.pressedKnob === undefined) {
this.onStart();
this.onEnd(ev);
}
}}
>
{ticks.map((tick) => (
<div
style={tickStyle(tick)}

View File

@ -67,6 +67,39 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(rangeEnd).toHaveReceivedEventDetail({ value: 21 });
});
test('should emit end event on tap', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/28487',
});
await page.setContent(`<ion-range aria-label="Range" value="20"></ion-range>`, config);
const range = page.locator('ion-range');
const rangeEndSpy = await page.spyOnEvent('ionKnobMoveEnd');
const rangeBoundingBox = await range.boundingBox();
/**
* Coordinates for the click event.
* These need to be near the end of the range
* (or anything that isn't the current value).
*
* The number 50 is arbitrary, but it should be
* less than the width of the range.
*/
const x = rangeBoundingBox!.width - 50;
// The y coordinate is the middle of the range.
const y = rangeBoundingBox!.height / 2;
// Click near the end of the range.
await range.click({
position: { x, y },
});
await rangeEndSpy.next();
expect(rangeEndSpy.length).toBe(1);
});
// TODO FW-2873
test.skip('should not scroll when the knob is swiped', async ({ page, skip }) => {
skip.browser('webkit', 'mouse.wheel is not available in WebKit');

View File

@ -12,6 +12,12 @@
<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>
app-reorder {
display: block;
}
</style>
</head>
<body>

View File

@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
import type { LegacyFormController, NotchController } from '@utils/forms';
import { compareOptions, createLegacyFormController, createNotchController, isOptionSelected } from '@utils/forms';
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import { findItemLabel, focusVisibleElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
@ -329,7 +329,7 @@ export class Select implements ComponentInterface {
);
if (selectedItem) {
focusElement(selectedItem);
focusVisibleElement(selectedItem);
/**
* Browsers such as Firefox do not
@ -355,7 +355,7 @@ export class Select implements ComponentInterface {
'ion-radio:not(.radio-disabled), ion-checkbox:not(.checkbox-disabled)'
);
if (firstEnabledOption) {
focusElement(firstEnabledOption.closest('ion-item')!);
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
/**
* Focus the option for the same reason as we do above.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB