fix(datetime): allow disabling datetime with prefer-wheel (#28511)

Issue number: 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. -->
It is possible to navigate the columns of a disabled Datetime with
`prefer-wheel` via the keyboard.



https://github.com/ionic-team/ionic-framework/assets/14926794/9c9dafc4-4b77-45a6-a276-70201c5c3ea5



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

- Picker Column Internal has a disabled state that disables the full
column
- When a Datetime is disabled with `prefer-wheel`, the columns in the
Datetime will be disabled
- It is no longer possible to navigate the wheels in a disabled Datetime
via the keyboard


Comparison of native & Ionic components:

![Screenshot 2023-11-10 at 10 58
25 AM](https://github.com/ionic-team/ionic-framework/assets/14926794/e2bec1b3-30f8-4f64-8658-27b971884b7a)


## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Liam DeBeasi <liamdebeasi@users.noreply.github.com>
This commit is contained in:
Shawn Taylor
2023-11-22 11:07:43 -05:00
committed by GitHub
parent b833f0e826
commit 01130e12e1
14 changed files with 153 additions and 32 deletions

View File

@ -2043,6 +2043,10 @@ export namespace Components {
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/ */
"color"?: Color; "color"?: Color;
/**
* If `true`, the user cannot interact with the picker.
*/
"disabled": boolean;
/** /**
* A list of options to be displayed in the picker * A list of options to be displayed in the picker
*/ */
@ -6683,6 +6687,10 @@ declare namespace LocalJSX {
* The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics).
*/ */
"color"?: Color; "color"?: Color;
/**
* If `true`, the user cannot interact with the picker.
*/
"disabled"?: boolean;
/** /**
* A list of options to be displayed in the picker * A list of options to be displayed in the picker
*/ */

View File

@ -1538,7 +1538,7 @@ export class Datetime implements ComponentInterface {
} }
private renderCombinedDatePickerColumn() { private renderCombinedDatePickerColumn() {
const { defaultParts, workingParts, locale, minParts, maxParts, todayParts, isDateEnabled } = this; const { defaultParts, disabled, workingParts, locale, minParts, maxParts, todayParts, isDateEnabled } = this;
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -1617,6 +1617,7 @@ export class Datetime implements ComponentInterface {
<ion-picker-column-internal <ion-picker-column-internal
class="date-column" class="date-column"
color={this.color} color={this.color}
disabled={disabled}
items={items} items={items}
value={todayString} value={todayString}
onIonChange={(ev: CustomEvent) => { onIonChange={(ev: CustomEvent) => {
@ -1728,7 +1729,7 @@ export class Datetime implements ComponentInterface {
return []; return [];
} }
const { workingParts } = this; const { disabled, workingParts } = this;
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -1736,6 +1737,7 @@ export class Datetime implements ComponentInterface {
<ion-picker-column-internal <ion-picker-column-internal
class="day-column" class="day-column"
color={this.color} color={this.color}
disabled={disabled}
items={days} items={days}
value={(workingParts.day !== null ? workingParts.day : this.defaultParts.day) ?? undefined} value={(workingParts.day !== null ? workingParts.day : this.defaultParts.day) ?? undefined}
onIonChange={(ev: CustomEvent) => { onIonChange={(ev: CustomEvent) => {
@ -1772,7 +1774,7 @@ export class Datetime implements ComponentInterface {
return []; return [];
} }
const { workingParts } = this; const { disabled, workingParts } = this;
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -1780,6 +1782,7 @@ export class Datetime implements ComponentInterface {
<ion-picker-column-internal <ion-picker-column-internal
class="month-column" class="month-column"
color={this.color} color={this.color}
disabled={disabled}
items={months} items={months}
value={workingParts.month} value={workingParts.month}
onIonChange={(ev: CustomEvent) => { onIonChange={(ev: CustomEvent) => {
@ -1815,7 +1818,7 @@ export class Datetime implements ComponentInterface {
return []; return [];
} }
const { workingParts } = this; const { disabled, workingParts } = this;
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -1823,6 +1826,7 @@ export class Datetime implements ComponentInterface {
<ion-picker-column-internal <ion-picker-column-internal
class="year-column" class="year-column"
color={this.color} color={this.color}
disabled={disabled}
items={years} items={years}
value={workingParts.year} value={workingParts.year}
onIonChange={(ev: CustomEvent) => { onIonChange={(ev: CustomEvent) => {
@ -1888,7 +1892,7 @@ export class Datetime implements ComponentInterface {
} }
private renderHourPickerColumn(hoursData: PickerColumnItem[]) { private renderHourPickerColumn(hoursData: PickerColumnItem[]) {
const { workingParts } = this; const { disabled, workingParts } = this;
if (hoursData.length === 0) return []; if (hoursData.length === 0) return [];
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -1896,6 +1900,7 @@ export class Datetime implements ComponentInterface {
return ( return (
<ion-picker-column-internal <ion-picker-column-internal
color={this.color} color={this.color}
disabled={disabled}
value={activePart.hour} value={activePart.hour}
items={hoursData} items={hoursData}
numericInput numericInput
@ -1916,7 +1921,7 @@ export class Datetime implements ComponentInterface {
); );
} }
private renderMinutePickerColumn(minutesData: PickerColumnItem[]) { private renderMinutePickerColumn(minutesData: PickerColumnItem[]) {
const { workingParts } = this; const { disabled, workingParts } = this;
if (minutesData.length === 0) return []; if (minutesData.length === 0) return [];
const activePart = this.getActivePartsWithFallback(); const activePart = this.getActivePartsWithFallback();
@ -1924,6 +1929,7 @@ export class Datetime implements ComponentInterface {
return ( return (
<ion-picker-column-internal <ion-picker-column-internal
color={this.color} color={this.color}
disabled={disabled}
value={activePart.minute} value={activePart.minute}
items={minutesData} items={minutesData}
numericInput numericInput
@ -1944,7 +1950,7 @@ export class Datetime implements ComponentInterface {
); );
} }
private renderDayPeriodPickerColumn(dayPeriodData: PickerColumnItem[]) { private renderDayPeriodPickerColumn(dayPeriodData: PickerColumnItem[]) {
const { workingParts } = this; const { disabled, workingParts } = this;
if (dayPeriodData.length === 0) { if (dayPeriodData.length === 0) {
return []; return [];
} }
@ -1956,6 +1962,7 @@ export class Datetime implements ComponentInterface {
<ion-picker-column-internal <ion-picker-column-internal
style={isDayPeriodRTL ? { order: '-1' } : {}} style={isDayPeriodRTL ? { order: '-1' } : {}}
color={this.color} color={this.color}
disabled={disabled}
value={activePart.ampm} value={activePart.ampm}
items={dayPeriodData} items={dayPeriodData}
onIonChange={(ev: CustomEvent) => { onIonChange={(ev: CustomEvent) => {

View File

@ -0,0 +1,39 @@
import { h } from '@stencil/core';
import { newSpecPage } from '@stencil/core/testing';
import { Datetime } from '../../../datetime/datetime';
import { PickerColumnInternal } from '../../../picker-column-internal/picker-column-internal';
import { PickerInternal } from '../../../picker-internal/picker-internal';
describe('ion-datetime disabled', () => {
beforeEach(() => {
// IntersectionObserver isn't available in test environment
const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
unobserve: () => null,
disconnect: () => null,
});
global.IntersectionObserver = mockIntersectionObserver;
});
it('picker should be disabled in prefer wheel mode', async () => {
const page = await newSpecPage({
components: [Datetime, PickerColumnInternal, PickerInternal],
template: () => (
<ion-datetime id="inline-datetime-wheel" disabled prefer-wheel value="2022-04-21T00:00:00"></ion-datetime>
),
});
await page.waitForChanges();
const datetime = page.body.querySelector('ion-datetime')!;
const columns = datetime.shadowRoot!.querySelectorAll('ion-picker-column-internal');
await expect(columns.length).toEqual(4);
columns.forEach((column) => {
expect(column.disabled).toBe(true);
});
});
});

View File

@ -66,6 +66,11 @@
<h2>Inline - No Default Value</h2> <h2>Inline - No Default Value</h2>
<ion-datetime id="inline-datetime-no-value" disabled></ion-datetime> <ion-datetime id="inline-datetime-no-value" disabled></ion-datetime>
</div> </div>
<div class="grid-item">
<h2>Inline - Prefer Wheel</h2>
<ion-datetime id="inline-datetime-wheel" disabled prefer-wheel value="2022-04-21T00:00:00"></ion-datetime>
</div>
</div> </div>
</ion-content> </ion-content>
<script> <script>

View File

@ -71,13 +71,20 @@
} }
:host .picker-item-empty, :host .picker-item-empty,
:host .picker-item.picker-item-disabled { :host .picker-item[disabled] {
scroll-snap-align: none;
cursor: default; cursor: default;
} }
:host .picker-item.picker-item-disabled { :host .picker-item-empty,
:host(:not([disabled])) .picker-item[disabled] {
scroll-snap-align: none;
}
:host([disabled]) {
overflow-y: hidden;
}
:host .picker-item[disabled] {
opacity: 0.4; opacity: 0.4;
} }

View File

@ -35,6 +35,11 @@ export class PickerColumnInternal implements ComponentInterface {
@Element() el!: HTMLIonPickerColumnInternalElement; @Element() el!: HTMLIonPickerColumnInternalElement;
/**
* If `true`, the user cannot interact with the picker.
*/
@Prop() disabled = false;
/** /**
* A list of options to be displayed in the picker * A list of options to be displayed in the picker
*/ */
@ -408,13 +413,15 @@ export class PickerColumnInternal implements ComponentInterface {
}; };
get activeItem() { get activeItem() {
return getElementRoot(this.el).querySelector( // If the whole picker column is disabled, the current value should appear active
`.picker-item[data-value="${this.value}"]:not([disabled])` // If the current value item is specifically disabled, it should not appear active
) as HTMLElement | null; const selector = `.picker-item[data-value="${this.value}"]${this.disabled ? '' : ':not([disabled])'}`;
return getElementRoot(this.el).querySelector(selector) as HTMLElement | null;
} }
render() { render() {
const { items, color, isActive, numericInput } = this; const { items, color, disabled: pickerDisabled, isActive, numericInput } = this;
const mode = getIonMode(this); const mode = getIonMode(this);
/** /**
@ -423,10 +430,12 @@ export class PickerColumnInternal implements ComponentInterface {
* the attribute can be moved to datetime.tsx and set on every * the attribute can be moved to datetime.tsx and set on every
* instance of ion-picker-column-internal there instead. * instance of ion-picker-column-internal there instead.
*/ */
return ( return (
<Host <Host
exportparts={`${PICKER_ITEM_PART}, ${PICKER_ITEM_ACTIVE_PART}`} exportparts={`${PICKER_ITEM_PART}, ${PICKER_ITEM_ACTIVE_PART}`}
tabindex={0} disabled={pickerDisabled}
tabindex={pickerDisabled ? null : 0}
class={createColorClasses(color, { class={createColorClasses(color, {
[mode]: true, [mode]: true,
['picker-column-active']: isActive, ['picker-column-active']: isActive,
@ -443,6 +452,8 @@ export class PickerColumnInternal implements ComponentInterface {
&nbsp; &nbsp;
</div> </div>
{items.map((item, index) => { {items.map((item, index) => {
const isItemDisabled = pickerDisabled || item.disabled || false;
{ {
/* /*
Users should be able to tab Users should be able to tab
@ -458,14 +469,13 @@ export class PickerColumnInternal implements ComponentInterface {
tabindex="-1" tabindex="-1"
class={{ class={{
'picker-item': true, 'picker-item': true,
'picker-item-disabled': item.disabled || false,
}} }}
data-value={item.value} data-value={item.value}
data-index={index} data-index={index}
onClick={(ev: Event) => { onClick={(ev: Event) => {
this.centerPickerItemInView(ev.target as HTMLElement, true); this.centerPickerItemInView(ev.target as HTMLElement, true);
}} }}
disabled={item.disabled} disabled={isItemDisabled}
part={PICKER_ITEM_PART} part={PICKER_ITEM_PART}
> >
{item.text} {item.text}

View File

@ -45,26 +45,39 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<div class="grid"> <div class="grid">
<div class="grid-item"> <div class="grid-item">
<h2>Default</h2> <h2>Even items disabled</h2>
<ion-picker-internal> <ion-picker-internal>
<ion-picker-column-internal id="default"></ion-picker-column-internal> <ion-picker-column-internal id="half-disabled"></ion-picker-column-internal>
</ion-picker-internal>
</div>
<div class="grid-item">
<h2>Column disabled</h2>
<ion-picker-internal>
<ion-picker-column-internal id="column-disabled" value="11" disabled></ion-picker-column-internal>
</ion-picker-internal> </ion-picker-internal>
</div> </div>
</div> </div>
</ion-content> </ion-content>
<script> <script>
const defaultPickerColumn = document.getElementById('default'); const halfDisabledPicker = document.getElementById('half-disabled');
const halfDisabledItems = Array(24)
const items = Array(24)
.fill() .fill()
.map((_, i) => ({ .map((_, i) => ({
text: `${i}`, text: `${i}`,
value: i, value: i,
disabled: i % 2 === 0, disabled: i % 2 === 0,
})); }));
halfDisabledPicker.items = halfDisabledItems;
halfDisabledPicker.value = 12;
defaultPickerColumn.items = items; const fullDisabledPicker = document.getElementById('column-disabled');
defaultPickerColumn.value = 12; const items = Array(24)
.fill()
.map((_, i) => ({
text: `${i}`,
value: i,
}));
fullDisabledPicker.items = items;
</script> </script>
</ion-app> </ion-app>
</body> </body>

View File

@ -35,7 +35,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
* This behavior does not vary across modes/directions. * This behavior does not vary across modes/directions.
*/ */
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal: disabled'), () => { test.describe(title('picker-column-internal: disabled items'), () => {
test('all picker items should be enabled by default', async ({ page }) => { test('all picker items should be enabled by default', async ({ page }) => {
await page.setContent( await page.setContent(
` `
@ -55,9 +55,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config config
); );
const pickerItems = page.locator( const pickerItems = page.locator('ion-picker-column-internal .picker-item:not(.picker-item-empty, [disabled])');
'ion-picker-column-internal .picker-item:not(.picker-item-empty, .picker-item-disabled)'
);
expect(await pickerItems.count()).toBe(3); expect(await pickerItems.count()).toBe(3);
}); });
@ -80,7 +78,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
config config
); );
const disabledItem = page.locator('ion-picker-column-internal .picker-item.picker-item-disabled'); const disabledItem = page.locator('ion-picker-column-internal .picker-item[disabled]');
await expect(disabledItem).not.toBeEnabled(); await expect(disabledItem).not.toBeEnabled();
}); });
test('disabled picker item should not be considered active', async ({ page }) => { test('disabled picker item should not be considered active', async ({ page }) => {
@ -130,7 +128,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await page.waitForChanges(); await page.waitForChanges();
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]'); const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).toHaveClass(/picker-item-disabled/); await expect(disabledItem).toBeDisabled();
await expect(disabledItem).not.toHaveClass(/picker-item-active/); await expect(disabledItem).not.toHaveClass(/picker-item-active/);
}); });
test('defaulting the value to a disabled item should not cause that item to be active', async ({ page }) => { test('defaulting the value to a disabled item should not cause that item to be active', async ({ page }) => {
@ -154,8 +152,42 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
); );
const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]'); const disabledItem = page.locator('ion-picker-column-internal .picker-item[data-value="b"]');
await expect(disabledItem).toHaveClass(/picker-item-disabled/); await expect(disabledItem).toBeDisabled();
await expect(disabledItem).not.toHaveClass(/picker-item-active/); await expect(disabledItem).not.toHaveClass(/picker-item-active/);
}); });
}); });
}); });
/**
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('picker-column-internal: disabled column rendering'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/disabled', config);
});
test('disabled column should not have visual regressions', async ({ page }) => {
const disabledColumn = page.locator('#column-disabled');
await page.waitForChanges();
await expect(disabledColumn).toHaveScreenshot(screenshot('picker-internal-disabled-column'));
});
});
});
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('picker-column-internal: disabled column'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/picker-column-internal/test/disabled', config);
});
test('item in disabled column should not be interactive', async ({ page }) => {
const secondItem = page.locator('#column-disabled .picker-item:not(.picker-item-empty)').nth(1);
await expect(secondItem).toBeDisabled();
});
});
});