chore: sync with main
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 33 KiB |
@ -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),
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@ -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);
|
||||
}
|
||||
|
||||
27
core/src/components/label/test/item/item.e2e.ts
Normal 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`));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 34 KiB |
@ -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;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
39
core/src/components/modal/test/modal-attributes.spec.tsx
Normal 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);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 47 KiB |
@ -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)}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |