diff --git a/src/assets/styles/includes.scss b/src/assets/styles/includes.scss index 151624b7e1c..0fc6a3c2422 100644 --- a/src/assets/styles/includes.scss +++ b/src/assets/styles/includes.scss @@ -41,3 +41,18 @@ z-index: -1 !important; } } + +// mixin to provide base disabled styles for interactive components +// additional styling can be passed via @content +@mixin disabled() { + :host([disabled]) { + @apply cursor-default opacity-disabled pointer-events-none select-none; + @content; + + ::slotted([calcite-hydrated][disabled]), + [calcite-hydrated][disabled] { + /* prevent opacity stacking */ + @apply opacity-100; + } + } +} diff --git a/src/components/action/action.e2e.ts b/src/components/action/action.e2e.ts index ea079d7f110..7adb28be1ab 100755 --- a/src/components/action/action.e2e.ts +++ b/src/components/action/action.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, hidden, renders } from "../../tests/commonTests"; +import { accessible, disabled, hidden, renders } from "../../tests/commonTests"; import { CSS } from "./resources"; describe("calcite-action", () => { @@ -7,6 +7,8 @@ describe("calcite-action", () => { it("honors hidden attribute", async () => hidden("calcite-action")); + it("can be disabled", () => disabled("calcite-action")); + it("should have visible text when text is enabled", async () => { const page = await newE2EPage(); await page.setContent(``); @@ -99,14 +101,6 @@ describe("calcite-action", () => { expect(button.getAttribute("aria-label")).toBe("hi"); }); - it("should be disabled", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const button = await page.find(`calcite-action >>> .${CSS.button}`); - expect(button).toHaveAttribute("disabled"); - }); - it("should have appearance=solid", async () => { const page = await newE2EPage(); await page.setContent(``); @@ -119,18 +113,4 @@ describe("calcite-action", () => { await accessible(``); await accessible(``); }); - - it("should not emit click event when disabled", async () => { - const page = await newE2EPage(); - - await page.setContent(``); - - const action = await page.find("calcite-action"); - - const clickSpy = await action.spyOnEvent("click"); - - await action.click(); - - expect(clickSpy).toHaveReceivedEventTimes(0); - }); }); diff --git a/src/components/action/action.scss b/src/components/action/action.scss index f147b5188eb..99dd652edc1 100755 --- a/src/components/action/action.scss +++ b/src/components/action/action.scss @@ -3,9 +3,7 @@ @apply flex bg-transparent; } -:host([disabled]) { - @apply pointer-events-none; -} +@include disabled(); .button { @apply bg-foreground-1 diff --git a/src/components/action/action.tsx b/src/components/action/action.tsx index d0d6e86f0bb..0688be60d6c 100755 --- a/src/components/action/action.tsx +++ b/src/components/action/action.tsx @@ -16,6 +16,7 @@ import { Alignment, Appearance, Scale } from "../interfaces"; import { CSS, TEXT } from "./resources"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding a `calcite-icon`. @@ -25,7 +26,7 @@ import { createObserver } from "../../utils/observers"; styleUrl: "action.scss", shadow: true }) -export class Action { +export class Action implements InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -133,6 +134,10 @@ export class Action { this.mutationObserver?.disconnect(); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Methods diff --git a/src/components/block/block.e2e.ts b/src/components/block/block.e2e.ts index 921801be993..e6135f83f3b 100644 --- a/src/components/block/block.e2e.ts +++ b/src/components/block/block.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { CSS, SLOTS, TEXT } from "./resources"; -import { accessible, defaults, hidden, renders, slots } from "../../tests/commonTests"; +import { accessible, defaults, disabled, hidden, renders, slots } from "../../tests/commonTests"; import { html } from "../../tests/utils"; describe("calcite-block", () => { @@ -43,42 +43,8 @@ describe("calcite-block", () => { `)); - it("can be disabled", async () => { - const page = await newE2EPage({ - html: ` - -
content
-
- ` - }); - - const content = await page.find(".content"); - const clickSpy = await content.spyOnEvent("click"); - await content.click(); - expect(clickSpy).toHaveReceivedEventTimes(1); - - const block = await page.find("calcite-block"); - block.setProperty("disabled", true); - await page.waitForChanges(); - - // `tabindex=-1` on host removes children from the tab order - expect(block.getAttribute("tabindex")).toBe("-1"); - - await content.click(); - expect(clickSpy).toHaveReceivedEventTimes(1); - - const header = await page.find(`calcite-block >>> .${CSS.headerContainer}`); - const toggleSpy = await block.spyOnEvent("calciteBlockToggle"); - - await header.click(); - await header.click(); - expect(toggleSpy).toHaveReceivedEventTimes(0); - - block.setAttribute("disabled", false); - await page.waitForChanges(); - - expect(block.getAttribute("tabindex")).toBeNull(); - }); + it("can be disabled", () => + disabled(html``)); it("has a loading state", async () => { const page = await newE2EPage({ diff --git a/src/components/block/block.scss b/src/components/block/block.scss index 2bb33081fe4..e9c472d9ffe 100644 --- a/src/components/block/block.scss +++ b/src/components/block/block.scss @@ -17,6 +17,8 @@ flex-basis: auto; } +@include disabled(); + @import "../../assets/styles/header"; .header { @@ -161,14 +163,3 @@ calcite-action-menu { @apply text-color-1; } } - -:host([disabled]) { - pointer-events: none; - user-select: none; - - @apply pointer-events-none select-none; - - .header-container { - @apply opacity-50; - } -} diff --git a/src/components/block/block.stories.ts b/src/components/block/block.stories.ts index 27456e2edf4..a36ce2cd9ed 100644 --- a/src/components/block/block.stories.ts +++ b/src/components/block/block.stories.ts @@ -167,3 +167,9 @@ export const withHeaderControl = (): string => export const withIconAndHeader = (): string => create("calcite-block", createBlockAttributes({ exceptions: ["open", "collapsible"] }), `
`); + +export const disabled = (): string => html` + + demo + +`; diff --git a/src/components/block/block.tsx b/src/components/block/block.tsx index 7e8bff1b950..b048b13eb53 100644 --- a/src/components/block/block.tsx +++ b/src/components/block/block.tsx @@ -8,6 +8,7 @@ import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding content to the block. @@ -20,7 +21,7 @@ import { styleUrl: "block.scss", shadow: true }) -export class Block implements ConditionalSlotComponent { +export class Block implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -92,6 +93,16 @@ export class Block implements ConditionalSlotComponent { */ @Prop() summary: string; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Private Properties @@ -190,8 +201,7 @@ export class Block implements ConditionalSlotComponent { } render(): VNode { - const { collapsible, disabled, el, intlCollapse, intlExpand, loading, open, intlLoading } = - this; + const { collapsible, el, intlCollapse, intlExpand, loading, open, intlLoading } = this; const toggleLabel = open ? intlCollapse || TEXT.collapse : intlExpand || TEXT.expand; @@ -246,7 +256,7 @@ export class Block implements ConditionalSlotComponent { ); return ( - +
{ it("is labelable", async () => labelable("calcite-button")); + it("can be disabled", () => disabled("calcite-button")); + it("should update childElType when href changes", async () => { const page = await newE2EPage({ html: `Continue` }); const link = await page.find("calcite-button"); diff --git a/src/components/button/button.scss b/src/components/button/button.scss index f715a240ebe..2a35e9d0ad6 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -159,9 +159,9 @@ line-height: inherit; } -// disabled styles -:host([loading]), -:host([disabled]) { +@include disabled(); + +:host([loading]) { @apply pointer-events-none; button, a { diff --git a/src/components/button/button.stories.ts b/src/components/button/button.stories.ts index e5fb9890540..8ae2a410f6e 100644 --- a/src/components/button/button.stories.ts +++ b/src/components/button/button.stories.ts @@ -1,7 +1,7 @@ import { text, select } from "@storybook/addon-knobs"; import { iconNames, boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; -import { html } from "../../tests/utils"; +import { html, placeholderImage } from "../../tests/utils"; import readme from "./readme.md"; export default { @@ -175,3 +175,5 @@ export const RTL = (): string => html` ${text("text", "button text here")} `; + +export const disabled = (): string => html`disabled`; diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 1b80aad7a90..b5f63187151 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -6,6 +6,7 @@ import { ButtonAlignment, ButtonAppearance, ButtonColor } from "./interfaces"; import { FlipContext, Scale, Width } from "../interfaces"; import { LabelableComponent, connectLabel, disconnectLabel, getLabelText } from "../../utils/label"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** Passing a 'href' will render an anchor link, instead of a button. Role will be set to link, or button, depending on this. */ /** It is the consumers responsibility to add aria information, rel, target, for links, and any button attributes for form submission */ @@ -16,7 +17,7 @@ import { createObserver } from "../../utils/observers"; styleUrl: "button.scss", shadow: true }) -export class Button implements LabelableComponent { +export class Button implements LabelableComponent, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -146,6 +147,10 @@ export class Button implements LabelableComponent { } } + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { const Tag = this.childElType; const loaderNode = this.hasLoader ? ( diff --git a/src/components/checkbox/checkbox.e2e.ts b/src/components/checkbox/checkbox.e2e.ts index 3e9e280f07d..14bf4ce2b01 100644 --- a/src/components/checkbox/checkbox.e2e.ts +++ b/src/components/checkbox/checkbox.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, focusable, formAssociated, HYDRATED_ATTR, labelable } from "../../tests/commonTests"; +import { accessible, disabled, focusable, formAssociated, HYDRATED_ATTR, labelable } from "../../tests/commonTests"; describe("calcite-checkbox", () => { it("is accessible", async () => @@ -15,6 +15,8 @@ describe("calcite-checkbox", () => { it("is form-associated", async () => formAssociated("calcite-checkbox", { testValue: true })); + it("can be disabled", () => disabled("calcite-checkbox")); + it("renders with correct default attributes", async () => { const page = await newE2EPage(); await page.setContent(""); @@ -70,21 +72,6 @@ describe("calcite-checkbox", () => { expect(spy).toHaveReceivedEventTimes(0); }); - it("does not toggle when clicked if disabled", async () => { - const page = await newE2EPage(); - await page.setContent(""); - - const calciteCheckbox = await page.find("calcite-checkbox"); - - expect(calciteCheckbox).not.toHaveAttribute("checked"); - - await calciteCheckbox.click(); - - await page.waitForChanges(); - - expect(calciteCheckbox).not.toHaveAttribute("checked"); - }); - it("removes the indeterminate attribute when clicked", async () => { const page = await newE2EPage(); await page.setContent(""); diff --git a/src/components/checkbox/checkbox.scss b/src/components/checkbox/checkbox.scss index c4e42c63e29..79a8a3bcd2c 100644 --- a/src/components/checkbox/checkbox.scss +++ b/src/components/checkbox/checkbox.scss @@ -59,8 +59,6 @@ @include focus-box-shadow(inset 0 0 0 1px var(--calcite-ui-brand)); } } -:host([disabled]) { - @apply cursor-default opacity-disabled pointer-events-none; -} +@include disabled(); @include hidden-form-input(); diff --git a/src/components/checkbox/checkbox.stories.ts b/src/components/checkbox/checkbox.stories.ts index 89b18f8c33b..f5811b714e0 100644 --- a/src/components/checkbox/checkbox.stories.ts +++ b/src/components/checkbox/checkbox.stories.ts @@ -49,3 +49,5 @@ export const RTL = (): string => html` ${text("label", "Checkbox")} `; + +export const disabled = (): string => html``; diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx index 0c944356ad7..bce3b27fffe 100644 --- a/src/components/checkbox/checkbox.tsx +++ b/src/components/checkbox/checkbox.tsx @@ -14,13 +14,14 @@ import { Scale } from "../interfaces"; import { CheckableFormCompoment, HiddenFormInputSlot } from "../../utils/form"; import { LabelableComponent, connectLabel, disconnectLabel, getLabelText } from "../../utils/label"; import { connectForm, disconnectForm } from "../../utils/form"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @Component({ tag: "calcite-checkbox", styleUrl: "checkbox.scss", shadow: true }) -export class Checkbox implements LabelableComponent, CheckableFormCompoment { +export class Checkbox implements LabelableComponent, CheckableFormCompoment, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -196,6 +197,10 @@ export class Checkbox implements LabelableComponent, CheckableFormCompoment { disconnectForm(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Render Methods diff --git a/src/components/color-picker/color-picker.e2e.ts b/src/components/color-picker/color-picker.e2e.ts index 67822ec6d4c..f2e985935fa 100644 --- a/src/components/color-picker/color-picker.e2e.ts +++ b/src/components/color-picker/color-picker.e2e.ts @@ -1,4 +1,4 @@ -import { accessible, defaults, hidden, reflects, renders, focusable } from "../../tests/commonTests"; +import { accessible, defaults, hidden, reflects, renders, focusable, disabled } from "../../tests/commonTests"; import { CSS, DEFAULT_COLOR, DEFAULT_STORAGE_KEY_PREFIX, DIMENSIONS, TEXT } from "./resources"; import { E2EElement, E2EPage, EventSpy, newE2EPage } from "@stencil/core/testing"; @@ -163,6 +163,9 @@ describe("calcite-color-picker", () => { } ])); + // #408047 is a color in the middle of the color field + it("can be disabled", () => disabled("")); + it(`should set all internal calcite-button types to 'button'`, async () => { const page = await newE2EPage({ html: "" diff --git a/src/components/color-picker/color-picker.scss b/src/components/color-picker/color-picker.scss index 3404202d609..8caa3cb8e5f 100644 --- a/src/components/color-picker/color-picker.scss +++ b/src/components/color-picker/color-picker.scss @@ -8,6 +8,8 @@ $gap--large: 12px; @apply inline-block text-n2h font-normal; } +@include disabled(); + :host([scale="s"]) { .container { width: 160px; diff --git a/src/components/color-picker/color-picker.stories.ts b/src/components/color-picker/color-picker.stories.ts index 6fee334519d..6a687f720b2 100644 --- a/src/components/color-picker/color-picker.stories.ts +++ b/src/components/color-picker/color-picker.stories.ts @@ -8,6 +8,7 @@ import { } from "../../../.storybook/utils"; import colorReadme from "./readme.md"; import { ATTRIBUTES } from "../../../.storybook/resources"; +import { html } from "../../tests/utils"; export default { title: "Components/Controls/ColorPicker", @@ -97,3 +98,5 @@ export const AllowingEmpty = (): string => { name: "allow-empty", value: true }, { name: "value", value: text("value", "") } ]); + +export const disabled = (): string => html``; diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx index 00de55eb8bf..d9422927715 100644 --- a/src/components/color-picker/color-picker.tsx +++ b/src/components/color-picker/color-picker.tsx @@ -29,6 +29,7 @@ import { colorEqual, CSSColorMode, Format, normalizeHex, parseMode, SupportedMod import { throttle } from "lodash-es"; import { clamp } from "../../utils/math"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; const throttleFor60FpsInMs = 16; const defaultValue = normalizeHex(DEFAULT_COLOR.hex()); @@ -39,7 +40,7 @@ const defaultFormat = "auto"; styleUrl: "color-picker.scss", shadow: true }) -export class ColorPicker { +export class ColorPicker implements InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -85,6 +86,11 @@ export class ColorPicker { this.value = this.toValue(color); } + /** + * When true, disabled prevents user interaction. + */ + @Prop({ reflect: true }) disabled = false; + /** * The format of the value property. * @@ -296,6 +302,8 @@ export class ColorPicker { private hueThumbState: "idle" | "hover" | "drag" = "idle"; + private hueScopeNode: HTMLDivElement; + private internalColorUpdateContext: "internal" | "initial" | null = null; private previousColor: InternalColor | null; @@ -516,9 +524,11 @@ export class ColorPicker { if (region === "color-field") { this.hueThumbState = "drag"; this.captureColorFieldColor(offsetX, offsetY); + this.colorFieldScopeNode.focus(); } else if (region === "slider") { this.sliderThumbState = "drag"; this.captureHueSliderColor(offsetX); + this.hueScopeNode.focus(); } // prevent text selection outside of color field & slider area @@ -717,6 +727,10 @@ export class ColorPicker { document.removeEventListener("mouseup", this.globalMouseUpHandler); } + componentDidRender(): void { + updateHostInteraction(this); + } + //-------------------------------------------------------------------------- // // Render Methods @@ -788,6 +802,7 @@ export class ColorPicker { aria-valuenow={color?.round().hue() || DEFAULT_COLOR.round().hue()} class={{ [CSS.scope]: true, [CSS.hueScope]: true }} onKeyDown={this.handleHueScopeKeyDown} + ref={this.storeHueScope} role="slider" style={{ top: `${hueTop}px`, left: `${hueLeft}px` }} tabindex="0" @@ -894,6 +909,10 @@ export class ColorPicker { this.colorFieldScopeNode = node; }; + private storeHueScope = (node: HTMLDivElement): void => { + this.hueScopeNode = node; + }; + private renderChannelsTabTitle = (channelMode: this["channelMode"]): VNode => { const { channelMode: activeChannelMode, intlRgb, intlHsv } = this; const active = channelMode === activeChannelMode; diff --git a/src/components/combobox-item/combobox-item.e2e.ts b/src/components/combobox-item/combobox-item.e2e.ts index ca9dafd950a..10bcbcbe025 100644 --- a/src/components/combobox-item/combobox-item.e2e.ts +++ b/src/components/combobox-item/combobox-item.e2e.ts @@ -1,4 +1,4 @@ -import { hidden, renders, slots } from "../../tests/commonTests"; +import { disabled, hidden, renders, slots } from "../../tests/commonTests"; describe("calcite-combobox-item", () => { it("renders", async () => renders("calcite-combobox-item", { display: "flex" })); @@ -6,4 +6,6 @@ describe("calcite-combobox-item", () => { it("honors hidden attribute", async () => hidden("calcite-combobox-item")); it("has slots", () => slots("calcite-combobox-item", [], true)); + + it("can be disabled", () => disabled("calcite-combobox-item", { focusTarget: "none" })); }); diff --git a/src/components/combobox-item/combobox-item.scss b/src/components/combobox-item/combobox-item.scss index 0b068171123..e2da5d71536 100644 --- a/src/components/combobox-item/combobox-item.scss +++ b/src/components/combobox-item/combobox-item.scss @@ -29,6 +29,8 @@ @apply shadow-none; } +@include disabled(); + :host, ul { @apply flex flex-col m-0 p-0 outline-none; diff --git a/src/components/combobox-item/combobox-item.tsx b/src/components/combobox-item/combobox-item.tsx index a057f98c607..a865192b80d 100644 --- a/src/components/combobox-item/combobox-item.tsx +++ b/src/components/combobox-item/combobox-item.tsx @@ -21,16 +21,17 @@ import { disconnectConditionalSlotComponent, ConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding nested `calcite-combobox-item`s. */ @Component({ tag: "calcite-combobox-item", - styleUrl: "combobox-item.scss", + styleUrl: "./combobox-item.scss", shadow: true }) -export class ComboboxItem implements ConditionalSlotComponent { +export class ComboboxItem implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -97,6 +98,10 @@ export class ComboboxItem implements ConditionalSlotComponent { disconnectConditionalSlotComponent(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Events diff --git a/src/components/combobox/combobox.e2e.ts b/src/components/combobox/combobox.e2e.ts index f260a04d449..504514cd6b6 100644 --- a/src/components/combobox/combobox.e2e.ts +++ b/src/components/combobox/combobox.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { renders, hidden, accessible, defaults, labelable, formAssociated } from "../../tests/commonTests"; +import { renders, hidden, accessible, defaults, labelable, formAssociated, disabled } from "../../tests/commonTests"; import { html } from "../../tests/utils"; import { TEXT } from "./resources"; @@ -45,6 +45,8 @@ describe("calcite-combobox", () => { it("is labelable", async () => labelable("calcite-combobox")); + it("can be disabled", () => disabled("calcite-combobox")); + it("should show the listbox when it receives focus", async () => { const page = await newE2EPage(); await page.setContent(` diff --git a/src/components/combobox/combobox.scss b/src/components/combobox/combobox.scss index fb839fd7eda..e4cae958d4d 100644 --- a/src/components/combobox/combobox.scss +++ b/src/components/combobox/combobox.scss @@ -10,9 +10,7 @@ @apply block relative; } -:host([disabled]) { - @apply pointer-events-none select-none opacity-50; -} +@include disabled(); :host([scale="s"]) { @apply text-n2; diff --git a/src/components/combobox/combobox.stories.ts b/src/components/combobox/combobox.stories.ts index 1792d1f2ed9..98ff8e4efca 100644 --- a/src/components/combobox/combobox.stories.ts +++ b/src/components/combobox/combobox.stories.ts @@ -256,3 +256,16 @@ export const FlipPositioning = stepStory( FlipPositioning.parameters = { layout: "fullscreen" }; + +export const disabled = (): string => html` + + + + + + + + + + +`; diff --git a/src/components/combobox/combobox.tsx b/src/components/combobox/combobox.tsx index 498c435f343..3e1f5b1e60c 100644 --- a/src/components/combobox/combobox.tsx +++ b/src/components/combobox/combobox.tsx @@ -42,6 +42,7 @@ import { HiddenFormInputSlot } from "../../utils/form"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; interface ItemData { label: string; value: string; @@ -64,7 +65,7 @@ const inputUidPrefix = "combobox-input-"; styleUrl: "combobox.scss", shadow: true }) -export class Combobox implements LabelableComponent, FormComponent { +export class Combobox implements LabelableComponent, FormComponent, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -83,6 +84,11 @@ export class Combobox implements LabelableComponent, FormComponent { @Watch("active") activeHandler(newValue: boolean, oldValue: boolean): void { + if (this.disabled) { + this.active = false; + return; + } + // when closing, wait transition time then hide to prevent overscroll if (oldValue && !newValue) { this.el.addEventListener("calciteComboboxClose", this.toggleCloseEnd); @@ -97,6 +103,13 @@ export class Combobox implements LabelableComponent, FormComponent { /** Disable combobox input */ @Prop({ reflect: true }) disabled = false; + @Watch("disabled") + handleDisabledChange(value: boolean): void { + if (!value) { + this.active = false; + } + } + /** Aria label for combobox (required) */ @Prop() label!: string; @@ -278,6 +291,8 @@ export class Combobox implements LabelableComponent, FormComponent { this.reposition(); this.inputHeight = this.el.offsetHeight; } + + updateHostInteraction(this); } disconnectedCallback(): void { diff --git a/src/components/date-picker-day/date-picker-day.e2e.ts b/src/components/date-picker-day/date-picker-day.e2e.ts new file mode 100644 index 00000000000..19655d068ee --- /dev/null +++ b/src/components/date-picker-day/date-picker-day.e2e.ts @@ -0,0 +1,18 @@ +import { disabled } from "../../tests/commonTests"; +import { newProgrammaticE2EPage } from "../../tests/utils"; + +describe("calcite-date-picker-day", () => { + it("can be disabled", async () => { + const page = await newProgrammaticE2EPage(); + await page.evaluate(() => { + const dateEl = document.createElement("calcite-date-picker-day") as HTMLCalciteDatePickerDayElement; + dateEl.active = true; + dateEl.day = 3; + dateEl.localeData = { numerals: "0123456789" } as HTMLCalciteDatePickerDayElement["localeData"]; + document.body.append(dateEl); + }); + await page.waitForChanges(); + + return disabled({ tag: "calcite-date-picker-day", page }); + }); +}); diff --git a/src/components/date-picker-day/date-picker-day.scss b/src/components/date-picker-day/date-picker-day.scss index 2e81f96c201..aaf5ba24631 100644 --- a/src/components/date-picker-day/date-picker-day.scss +++ b/src/components/date-picker-day/date-picker-day.scss @@ -7,6 +7,8 @@ width: calc(100% / 7); } +@include disabled(); + .day-v-wrapper { @apply flex-auto; } @@ -81,11 +83,6 @@ @apply opacity-100; } -:host([disabled]) { - cursor: default; - @apply opacity-25; -} - :host(:hover:not([disabled])), :host([active]:not([range])) { & .day { diff --git a/src/components/date-picker-day/date-picker-day.tsx b/src/components/date-picker-day/date-picker-day.tsx index 4d12ff8d431..a8550d45bc1 100644 --- a/src/components/date-picker-day/date-picker-day.tsx +++ b/src/components/date-picker-day/date-picker-day.tsx @@ -14,13 +14,14 @@ import { getElementDir } from "../../utils/dom"; import { DateLocaleData } from "../date-picker/utils"; import { Scale } from "../interfaces"; import { CSS_UTILITY } from "../../utils/resources"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @Component({ tag: "calcite-date-picker-day", styleUrl: "date-picker-day.scss", shadow: true }) -export class DatePickerDay { +export class DatePickerDay implements InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -128,12 +129,7 @@ export class DatePickerDay { .join(""); const dir = getElementDir(this.el); return ( - +
@@ -144,4 +140,12 @@ export class DatePickerDay { ); } + + componentDidRender(): void { + updateHostInteraction(this, this.isTabbable); + } + + isTabbable(): boolean { + return this.active; + } } diff --git a/src/components/dropdown/dropdown.e2e.ts b/src/components/dropdown/dropdown.e2e.ts index 050265d652a..dfa40207c1c 100644 --- a/src/components/dropdown/dropdown.e2e.ts +++ b/src/components/dropdown/dropdown.e2e.ts @@ -1,5 +1,5 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, renders } from "../../tests/commonTests"; +import { accessible, defaults, disabled, renders } from "../../tests/commonTests"; import dedent from "dedent"; import { html } from "../../tests/utils"; @@ -24,6 +24,20 @@ describe("calcite-dropdown", () => { defaultValue: "absolute" } ])); + + it("can be disabled", () => + disabled( + html` + Open dropdown + + Dropdown Item Content + Dropdown Item Content + Dropdown Item Content + + `, + { focusTarget: "child" } + )); + /** * Test helper for selected calcite-dropdown items. Expects items to have IDs to test against. */ @@ -796,28 +810,6 @@ describe("calcite-dropdown", () => { expect(await page.evaluate(() => document.activeElement.id)).toEqual("trigger"); }); - it("when disabled, clicks on slotted dropdown trigger do not open dropdown", async () => { - const page = await newE2EPage(); - await page.setContent(html` - - Open dropdown - - Dropdown Item Content - Dropdown Item Content - Dropdown Item Content - - - `); - - const element = await page.find("calcite-dropdown"); - const trigger = await element.find("#trigger"); - const dropdownWrapper = await page.find("calcite-dropdown >>> .calcite-dropdown-wrapper"); - expect(await dropdownWrapper.isVisible()).toBe(false); - await trigger.click(); - await page.waitForChanges(); - expect(await dropdownWrapper.isVisible()).toBe(false); - }); - it("accepts multiple triggers", async () => { const page = await newE2EPage(); await page.setContent(html` diff --git a/src/components/dropdown/dropdown.scss b/src/components/dropdown/dropdown.scss index 5dd286d1699..0514d4ff561 100644 --- a/src/components/dropdown/dropdown.scss +++ b/src/components/dropdown/dropdown.scss @@ -10,10 +10,8 @@ @apply inline-flex flex-initial; } -// disabled styles -:host([disabled]) { - @apply pointer-events-none opacity-disabled; -} +@include disabled(); + :host .calcite-dropdown-wrapper { @include popperContainer(); @include popperWrapper(); diff --git a/src/components/dropdown/dropdown.stories.ts b/src/components/dropdown/dropdown.stories.ts index 8d6fd3e4c64..fc9ef551d8b 100644 --- a/src/components/dropdown/dropdown.stories.ts +++ b/src/components/dropdown/dropdown.stories.ts @@ -386,3 +386,21 @@ export const FlipPositioning = stepStory( FlipPositioning.parameters = { layout: "fullscreen" }; + +export const disabled = (): string => html` + Open Dropdown + + 1 + 2 + 3 + 4 + 5 + + + 6 + 7 + 8 + 9 + 10 + +`; diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index cfe10b78b73..d5cee22368b 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -24,6 +24,7 @@ import { Instance as Popper, StrictModifiers } from "@popperjs/core"; import { Scale } from "../interfaces"; import { DefaultDropdownPlacement, SLOTS } from "./resources"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-dropdown-group`s or `calcite-dropdown-item`s. @@ -34,7 +35,7 @@ import { createObserver } from "../../utils/observers"; styleUrl: "dropdown.scss", shadow: true }) -export class Dropdown { +export class Dropdown implements InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -54,7 +55,12 @@ export class Dropdown { @Watch("active") activeHandler(): void { - this.reposition(); + if (!this.disabled) { + this.reposition(); + return; + } + + this.active = false; } /** @@ -66,6 +72,13 @@ export class Dropdown { /** is the dropdown disabled */ @Prop({ reflect: true }) disabled = false; + @Watch("disabled") + handleDisabledChange(value: boolean): void { + if (!value) { + this.active = false; + } + } + /** specify the maximum number of calcite-dropdown-items to display before showing the scroller, must be greater than 0 - this value does not include groupTitles passed to calcite-dropdown-group @@ -123,6 +136,10 @@ export class Dropdown { this.reposition(); } + componentDidRender(): void { + updateHostInteraction(this); + } + disconnectedCallback(): void { this.mutationObserver?.disconnect(); this.resizeObserver?.disconnect(); @@ -133,7 +150,7 @@ export class Dropdown { const { active } = this; return ( - +
{ diff --git a/src/components/fab/fab.e2e.ts b/src/components/fab/fab.e2e.ts index af14f0df3d3..ca7a5caf1eb 100755 --- a/src/components/fab/fab.e2e.ts +++ b/src/components/fab/fab.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, hidden, renders } from "../../tests/commonTests"; +import { accessible, disabled, hidden, renders } from "../../tests/commonTests"; import { CSS } from "./resources"; import { defaults } from "../../tests/commonTests"; @@ -20,6 +20,8 @@ describe("calcite-fab", () => { } ])); + it("can be disabled", () => disabled("calcite-fab")); + it(`should set all internal calcite-button types to 'button'`, async () => { const page = await newE2EPage({ html: "" @@ -82,14 +84,6 @@ describe("calcite-fab", () => { expect(await calciteButton.getProperty("label")).toBe("hi"); }); - it("should be disabled", async () => { - const page = await newE2EPage(); - await page.setContent(``); - - const button = await page.find(`calcite-fab >>> .${CSS.button}`); - expect(button).toHaveAttribute("disabled"); - }); - it("should have appearance=outline", async () => { const page = await newE2EPage(); await page.setContent(``); diff --git a/src/components/fab/fab.scss b/src/components/fab/fab.scss index 7ea0c545f17..9df9e1eaa3b 100755 --- a/src/components/fab/fab.scss +++ b/src/components/fab/fab.scss @@ -2,6 +2,8 @@ @apply flex bg-transparent; } +@include disabled(); + calcite-button { @apply shadow-2; &:hover { diff --git a/src/components/fab/fab.stories.ts b/src/components/fab/fab.stories.ts index 7dbef3f56c9..084efe82798 100644 --- a/src/components/fab/fab.stories.ts +++ b/src/components/fab/fab.stories.ts @@ -9,6 +9,7 @@ import { import readme from "./readme.md"; import { ATTRIBUTES } from "../../../.storybook/resources"; import { ICONS } from "./resources"; +import { html } from "../../tests/utils"; const { scale } = ATTRIBUTES; export default { @@ -107,3 +108,5 @@ export const darkThemeRTL = (): string => ); darkThemeRTL.parameters = { themes: themesDarkDefault }; + +export const disabled = (): string => html``; diff --git a/src/components/fab/fab.tsx b/src/components/fab/fab.tsx index b3d07c6d509..a56d52c30b6 100755 --- a/src/components/fab/fab.tsx +++ b/src/components/fab/fab.tsx @@ -3,13 +3,14 @@ import { Appearance, Scale } from "../interfaces"; import { ButtonColor } from "../button/interfaces"; import { CSS, ICONS } from "./resources"; import { focusElement } from "../../utils/dom"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @Component({ tag: "calcite-fab", styleUrl: "fab.scss", shadow: true }) -export class Fab { +export class Fab implements InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -72,6 +73,16 @@ export class Fab { private buttonEl: HTMLElement; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Methods diff --git a/src/components/filter/filter.e2e.ts b/src/components/filter/filter.e2e.ts index 94a35fc7bed..77d691aa4d1 100644 --- a/src/components/filter/filter.e2e.ts +++ b/src/components/filter/filter.e2e.ts @@ -1,5 +1,5 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, focusable, hidden, reflects, renders } from "../../tests/commonTests"; +import { accessible, defaults, disabled, focusable, hidden, reflects, renders } from "../../tests/commonTests"; import { CSS } from "./resources"; describe("calcite-filter", () => { @@ -11,6 +11,8 @@ describe("calcite-filter", () => { it("is focusable", async () => focusable("calcite-filter")); + it("can be disabled", () => disabled("calcite-filter")); + it("reflects", async () => reflects("calcite-filter", [ { diff --git a/src/components/filter/filter.scss b/src/components/filter/filter.scss index 6e05d4bf7a2..296d043b380 100644 --- a/src/components/filter/filter.scss +++ b/src/components/filter/filter.scss @@ -3,6 +3,8 @@ @apply flex w-full; } +@include disabled(); + .container { @apply flex w-full p-2; } diff --git a/src/components/filter/filter.tsx b/src/components/filter/filter.tsx index e0ca71191ce..b25c5de98cd 100644 --- a/src/components/filter/filter.tsx +++ b/src/components/filter/filter.tsx @@ -14,6 +14,7 @@ import { debounce, forIn } from "lodash-es"; import { CSS, ICONS, TEXT } from "./resources"; import { Scale } from "../interfaces"; import { focusElement } from "../../utils/dom"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; const filterDebounceInMs = 250; @@ -22,7 +23,7 @@ const filterDebounceInMs = 250; styleUrl: "filter.scss", shadow: true }) -export class Filter { +export class Filter implements InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -92,6 +93,16 @@ export class Filter { textInput: HTMLCalciteInputElement; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Events @@ -187,12 +198,11 @@ export class Filter { return ( - {disabled ? : null}
} - `; + +export const disabled = (): string => html`disabled component will propagate to the rendered child */ /** Passing a 'href' will render an anchor link, instead of a span. Role will be set to link, or link, depending on this. */ @@ -13,7 +14,7 @@ import { CSS_UTILITY } from "../../utils/resources"; styleUrl: "link.scss", shadow: true }) -export class Link { +export class Link implements InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -70,6 +71,10 @@ export class Link { this.childElType = this.href ? "a" : "span"; } + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { const { download, el } = this; const dir = getElementDir(el); @@ -94,7 +99,7 @@ export class Link { const Tag = this.childElType; const role = this.childElType === "span" ? "link" : null; - const tabIndex = this.disabled ? -1 : this.childElType === "span" ? 0 : null; + const tabIndex = this.childElType === "span" ? 0 : null; return ( diff --git a/src/components/list-item/list-item.e2e.ts b/src/components/list-item/list-item.e2e.ts index 5a56e314f83..d34f0cbbe28 100755 --- a/src/components/list-item/list-item.e2e.ts +++ b/src/components/list-item/list-item.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { hidden, renders, focusable, slots } from "../../tests/commonTests"; +import { hidden, renders, focusable, slots, disabled } from "../../tests/commonTests"; import { defaults } from "../../tests/commonTests"; import { CSS, SLOTS } from "./resources"; @@ -35,6 +35,8 @@ describe("calcite-list-item", () => { it("has slots", () => slots("calcite-list-item", SLOTS)); + it("can be disabled", () => disabled(``)); + it("renders content node when label is provided", async () => { const page = await newE2EPage({ html: `` }); diff --git a/src/components/list-item/list-item.scss b/src/components/list-item/list-item.scss index f4571203830..ac678c4d270 100755 --- a/src/components/list-item/list-item.scss +++ b/src/components/list-item/list-item.scss @@ -2,9 +2,7 @@ @apply flex flex-col; } -:host([disabled]) { - @apply pointer-events-none cursor-default; -} +@include disabled(); .container { @apply bg-foreground-1 diff --git a/src/components/list-item/list-item.tsx b/src/components/list-item/list-item.tsx index 37fd6a53362..51dadee36f4 100755 --- a/src/components/list-item/list-item.tsx +++ b/src/components/list-item/list-item.tsx @@ -6,6 +6,7 @@ import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-list-item` and `calcite-list-item-group` elements. @@ -19,7 +20,7 @@ import { styleUrl: "list-item.scss", shadow: true }) -export class ListItem implements ConditionalSlotComponent { +export class ListItem implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -56,6 +57,16 @@ export class ListItem implements ConditionalSlotComponent { focusEl: HTMLButtonElement; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Lifecycle diff --git a/src/components/list/list.e2e.ts b/src/components/list/list.e2e.ts index 9fbca27c8c1..66afa9ff498 100755 --- a/src/components/list/list.e2e.ts +++ b/src/components/list/list.e2e.ts @@ -1,4 +1,4 @@ -import { accessible, hidden, renders, focusable } from "../../tests/commonTests"; +import { accessible, hidden, renders, focusable, disabled } from "../../tests/commonTests"; import { html } from "../../tests/utils"; describe("calcite-list", () => { @@ -29,4 +29,12 @@ describe("calcite-list", () => { `); }); + + it("can be disabled", () => + disabled( + html` + + `, + { focusTarget: "child" } + )); }); diff --git a/src/components/list/list.scss b/src/components/list/list.scss index 1c85e7a4c0b..e29df6265e8 100755 --- a/src/components/list/list.scss +++ b/src/components/list/list.scss @@ -2,6 +2,8 @@ @apply block; } +@include disabled(); + .container { @apply bg-transparent box-border diff --git a/src/components/list/list.stories.ts b/src/components/list/list.stories.ts index 1fb44527247..1d3ee9308b8 100644 --- a/src/components/list/list.stories.ts +++ b/src/components/list/list.stories.ts @@ -187,3 +187,21 @@ export const DarkMode = (): string => html` DarkMode.storyName = "Dark mode"; DarkMode.parameters = { themes: themesDarkDefault }; + +export const disabled = (): string => html` + + + +`; diff --git a/src/components/list/list.tsx b/src/components/list/list.tsx index 168b7c54380..fa0c77c18b3 100755 --- a/src/components/list/list.tsx +++ b/src/components/list/list.tsx @@ -1,6 +1,7 @@ import { Component, Element, h, VNode, Host, Prop, Method } from "@stencil/core"; import { CSS } from "./resources"; import { HeadingLevel } from "../functional/Heading"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * A general purpose list that enables users to construct list items that conform to Calcite styling. @@ -11,18 +12,33 @@ import { HeadingLevel } from "../functional/Heading"; styleUrl: "list.scss", shadow: true }) -export class List { +export class List implements InteractiveComponent { // -------------------------------------------------------------------------- // // Properties // // -------------------------------------------------------------------------- + /** + * When true, disabled prevents user interaction. + */ + @Prop({ reflect: true }) disabled = false; + /** * Number at which section headings should start for this component. */ @Prop() headingLevel: HeadingLevel; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Private Properties diff --git a/src/components/panel/panel.e2e.ts b/src/components/panel/panel.e2e.ts index 2d3f4553f41..7c419af4dae 100644 --- a/src/components/panel/panel.e2e.ts +++ b/src/components/panel/panel.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, focusable, hidden, renders, slots } from "../../tests/commonTests"; +import { accessible, defaults, disabled, focusable, hidden, renders, slots } from "../../tests/commonTests"; import { html } from "../../tests/utils"; import { CSS, SLOTS } from "./resources"; @@ -22,6 +22,8 @@ describe("calcite-panel", () => { it("has slots", () => slots("calcite-panel", SLOTS)); + it("can be disabled", () => disabled(`scrolling content`)); + it("honors dismissed prop", async () => { const page = await newE2EPage(); diff --git a/src/components/panel/panel.scss b/src/components/panel/panel.scss index fd143670ae3..2d9e0233a43 100644 --- a/src/components/panel/panel.scss +++ b/src/components/panel/panel.scss @@ -19,6 +19,8 @@ --calcite-panel-max-width: unset; } +@include disabled(); + @import "../../assets/styles/header"; .container { diff --git a/src/components/panel/panel.stories.ts b/src/components/panel/panel.stories.ts index 12c69836b99..8a11b7bda79 100644 --- a/src/components/panel/panel.stories.ts +++ b/src/components/panel/panel.stories.ts @@ -163,3 +163,5 @@ export const darkThemeRTL = (): string => ); darkThemeRTL.parameters = { themes: themesDarkDefault }; + +export const disabled = (): string => html`disabled`; diff --git a/src/components/panel/panel.tsx b/src/components/panel/panel.tsx index 1d99ef0f9d7..87ba2a9070c 100644 --- a/src/components/panel/panel.tsx +++ b/src/components/panel/panel.tsx @@ -20,6 +20,7 @@ import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding custom content. @@ -36,7 +37,7 @@ import { styleUrl: "panel.scss", shadow: true }) -export class Panel implements ConditionalSlotComponent { +export class Panel implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -123,6 +124,16 @@ export class Panel implements ConditionalSlotComponent { */ @Prop({ reflect: true }) menuOpen = false; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Private Properties @@ -452,7 +463,7 @@ export class Panel implements ConditionalSlotComponent { } render(): VNode { - const { dismissed, disabled, dismissible, loading, panelKeyDownHandler } = this; + const { dismissed, dismissible, loading, panelKeyDownHandler } = this; const panelNode = (
- {loading || disabled ? : null} + {loading ? : null} {panelNode} ); diff --git a/src/components/pick-list-item/pick-list-item.scss b/src/components/pick-list-item/pick-list-item.scss index ca17776c7ae..9847543ca51 100644 --- a/src/components/pick-list-item/pick-list-item.scss +++ b/src/components/pick-list-item/pick-list-item.scss @@ -11,7 +11,7 @@ bg-foreground-1 text-n1h; transition: background-color var(--calcite-animation-timing); - animation: calcite-fade-in var(--calcite-animation-timing); + animation: -fade-in var(--calcite-animation-timing); * { @apply box-border; diff --git a/src/components/pick-list/pick-list.e2e.ts b/src/components/pick-list/pick-list.e2e.ts index 016bc1f84d7..3d83425024b 100644 --- a/src/components/pick-list/pick-list.e2e.ts +++ b/src/components/pick-list/pick-list.e2e.ts @@ -4,10 +4,11 @@ import { accessible, hidden, renders, defaults } from "../../tests/commonTests"; import { selectionAndDeselection, filterBehavior, - disabledStates, + loadingState, keyboardNavigation, itemRemoval, - focusing + focusing, + disabling } from "./shared-list-tests"; import { html } from "../../tests/utils"; import { CSS as PICK_LIST_GROUP_CSS } from "../pick-list-group/resources"; @@ -32,6 +33,10 @@ describe("calcite-pick-list", () => { `)); + describe("disabling", () => { + disabling("pick"); + }); + describe("Selection and Deselection", () => { selectionAndDeselection("pick"); }); @@ -183,8 +188,8 @@ describe("calcite-pick-list", () => { itemRemoval("pick"); }); - describe("disabled states", () => { - disabledStates("pick"); + describe("loading state", () => { + loadingState("pick"); }); describe("setFocus", () => { diff --git a/src/components/pick-list/pick-list.scss b/src/components/pick-list/pick-list.scss index b6bc68355a9..976db2a89ae 100644 --- a/src/components/pick-list/pick-list.scss +++ b/src/components/pick-list/pick-list.scss @@ -15,6 +15,8 @@ } } +@include disabled(); + :host([filter-enabled]) header { @apply bg-foreground-1 flex diff --git a/src/components/pick-list/pick-list.stories.ts b/src/components/pick-list/pick-list.stories.ts index 726b4ba0055..344ef61f598 100644 --- a/src/components/pick-list/pick-list.stories.ts +++ b/src/components/pick-list/pick-list.stories.ts @@ -165,3 +165,14 @@ export const nested = (): string => ` ); + +export const disabled = (): string => html` + + + +`; diff --git a/src/components/pick-list/pick-list.tsx b/src/components/pick-list/pick-list.tsx index 60551bcb8d3..f33bb74df9d 100644 --- a/src/components/pick-list/pick-list.tsx +++ b/src/components/pick-list/pick-list.tsx @@ -34,6 +34,7 @@ import { import List from "./shared-list-render"; import { HeadingLevel } from "../functional/Heading"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-pick-list-item` elements or `calcite-pick-list-group` elements. Items are displayed as a vertical list. @@ -46,7 +47,8 @@ import { createObserver } from "../../utils/observers"; }) export class PickList< ItemElement extends HTMLCalcitePickListItemElement = HTMLCalcitePickListItemElement -> { +> implements InteractiveComponent +{ // -------------------------------------------------------------------------- // // Properties @@ -128,6 +130,10 @@ export class PickList< cleanUpObserver.call(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Events diff --git a/src/components/pick-list/shared-list-render.tsx b/src/components/pick-list/shared-list-render.tsx index 813f8425685..c632f659e8d 100644 --- a/src/components/pick-list/shared-list-render.tsx +++ b/src/components/pick-list/shared-list-render.tsx @@ -29,7 +29,7 @@ export const List: FunctionalComponent<{ props: ListProps } & DOMAttributes }): VNode => { const defaultSlot = ; return ( - +
{filterEnabled ? ( @@ -44,7 +44,7 @@ export const List: FunctionalComponent<{ props: ListProps } & DOMAttributes ) : null}
- {loading || disabled ? : null} + {loading ? : null} {defaultSlot}
diff --git a/src/components/pick-list/shared-list-tests.ts b/src/components/pick-list/shared-list-tests.ts index 2cec0fb5719..ff1d4334e2c 100644 --- a/src/components/pick-list/shared-list-tests.ts +++ b/src/components/pick-list/shared-list-tests.ts @@ -1,5 +1,5 @@ import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; -import { focusable } from "../../tests/commonTests"; +import { disabled, focusable } from "../../tests/commonTests"; import { html } from "../../tests/utils"; import { CSS as PICK_LIST_ITEM_CSS } from "../pick-list-item/resources"; @@ -514,24 +514,7 @@ export function filterBehavior(listType: ListType): void { }); } -export function disabledStates(listType: ListType): void { - it("disabled", async () => { - const page = await newE2EPage({ - html: html` - - - - ` - }); - - const list = await page.find(`calcite-${listType}-list`); - const item1 = await list.find("[value=one]"); - const toggleSpy = await list.spyOnEvent("calciteListChange"); - - await item1.click(); - expect(toggleSpy).toHaveReceivedEventTimes(0); - }); - +export function loadingState(listType: ListType): void { it("loading", async () => { const page = await newE2EPage(); await page.setContent(` @@ -598,3 +581,17 @@ export function focusing(listType: ListType): void { )); }); } + +export function disabling(listType: ListType): void { + it("can be disabled", () => + disabled( + html` + + + + `, + { + focusTarget: "child" + } + )); +} diff --git a/src/components/radio-button/radio-button.e2e.ts b/src/components/radio-button/radio-button.e2e.ts index 42300312e09..5b180fd414c 100644 --- a/src/components/radio-button/radio-button.e2e.ts +++ b/src/components/radio-button/radio-button.e2e.ts @@ -2,6 +2,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, + disabled, focusable, formAssociated, hidden, @@ -31,6 +32,8 @@ describe("calcite-radio-button", () => { propertyToToggle: "checked" })); + it("can be disabled", () => disabled("calcite-radio-button")); + it("focusing skips over hidden radio-buttons", async () => { const page = await newE2EPage(); await page.setContent(` diff --git a/src/components/radio-button/radio-button.scss b/src/components/radio-button/radio-button.scss index fe05d3b6594..828579bae26 100644 --- a/src/components/radio-button/radio-button.scss +++ b/src/components/radio-button/radio-button.scss @@ -16,8 +16,7 @@ } } -:host([disabled]) { - @apply cursor-pointer; +@include disabled() { .radio { @apply cursor-default opacity-disabled; } diff --git a/src/components/radio-button/radio-button.stories.ts b/src/components/radio-button/radio-button.stories.ts index 9057cba5519..19485c79e92 100644 --- a/src/components/radio-button/radio-button.stories.ts +++ b/src/components/radio-button/radio-button.stories.ts @@ -59,3 +59,5 @@ export const RTL = (): string => html` ${text("label", "Radio Button")} `; + +export const disabled = (): string => html``; diff --git a/src/components/radio-button/radio-button.tsx b/src/components/radio-button/radio-button.tsx index a0f7607e95a..8e9479be043 100644 --- a/src/components/radio-button/radio-button.tsx +++ b/src/components/radio-button/radio-button.tsx @@ -23,13 +23,16 @@ import { } from "../../utils/form"; import { CSS } from "./resources"; import { getRoundRobinIndex } from "../../utils/array"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @Component({ tag: "calcite-radio-button", styleUrl: "radio-button.scss", shadow: true }) -export class RadioButton implements LabelableComponent, CheckableFormCompoment { +export class RadioButton + implements LabelableComponent, CheckableFormCompoment, InteractiveComponent +{ //-------------------------------------------------------------------------- // // Element @@ -388,6 +391,10 @@ export class RadioButton implements LabelableComponent, CheckableFormCompoment { disconnectForm(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Render Methods diff --git a/src/components/radio-group/radio-group.e2e.ts b/src/components/radio-group/radio-group.e2e.ts index f658c27b572..46a63b271f3 100644 --- a/src/components/radio-group/radio-group.e2e.ts +++ b/src/components/radio-group/radio-group.e2e.ts @@ -1,5 +1,5 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { focusable, formAssociated, labelable, renders } from "../../tests/commonTests"; +import { disabled, focusable, formAssociated, labelable, renders } from "../../tests/commonTests"; import { html } from "../../tests/utils"; describe("calcite-radio-group", () => { @@ -15,6 +15,16 @@ describe("calcite-radio-group", () => { { focusTargetSelector: "calcite-radio-group-item" } )); + it("can be disabled", () => + disabled( + html` + + + + `, + { focusTarget: "child" } + )); + it("does not require an item to be checked", async () => { const page = await newE2EPage(); await page.setContent( diff --git a/src/components/radio-group/radio-group.scss b/src/components/radio-group/radio-group.scss index 825c1dead76..4b4691bfc8f 100644 --- a/src/components/radio-group/radio-group.scss +++ b/src/components/radio-group/radio-group.scss @@ -5,6 +5,8 @@ outline-offset: -1px; } +@include disabled(); + :host([layout="vertical"]) { @apply flex-col items-start self-start; } @@ -28,9 +30,4 @@ @apply z-0; } -// disabled styles -:host([disabled]) { - @apply opacity-disabled pointer-events-none; -} - @include hidden-form-input(); diff --git a/src/components/radio-group/radio-group.stories.ts b/src/components/radio-group/radio-group.stories.ts index df88899a531..2d432e1381d 100644 --- a/src/components/radio-group/radio-group.stories.ts +++ b/src/components/radio-group/radio-group.stories.ts @@ -115,3 +115,10 @@ export const RTL = (): string => html` Vue `; + +export const disabled = (): string => html` + React + Ember + Angular + Vue +`; diff --git a/src/components/radio-group/radio-group.tsx b/src/components/radio-group/radio-group.tsx index 75a2247b378..2eef5d8ed0b 100644 --- a/src/components/radio-group/radio-group.tsx +++ b/src/components/radio-group/radio-group.tsx @@ -18,6 +18,7 @@ import { Layout, Scale, Width } from "../interfaces"; import { LabelableComponent, connectLabel, disconnectLabel } from "../../utils/label"; import { connectForm, disconnectForm, FormComponent, HiddenFormInputSlot } from "../../utils/form"; import { RadioAppearance } from "./interfaces"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-radio-group-item`s. @@ -27,7 +28,7 @@ import { RadioAppearance } from "./interfaces"; styleUrl: "radio-group.scss", shadow: true }) -export class RadioGroup implements LabelableComponent, FormComponent { +export class RadioGroup implements LabelableComponent, FormComponent, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -133,9 +134,13 @@ export class RadioGroup implements LabelableComponent, FormComponent { disconnectForm(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { return ( - + diff --git a/src/components/rating/rating.e2e.ts b/src/components/rating/rating.e2e.ts index 7dbfd8aae05..9aa2b3dbfef 100644 --- a/src/components/rating/rating.e2e.ts +++ b/src/components/rating/rating.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { renders, accessible, focusable, labelable, formAssociated } from "../../tests/commonTests"; +import { renders, accessible, focusable, labelable, formAssociated, disabled } from "../../tests/commonTests"; describe("calcite-rating", () => { it("renders", async () => renders("", { display: "flex" })); @@ -8,6 +8,8 @@ describe("calcite-rating", () => { it("is labelable", async () => labelable("calcite-rating")); + it("can be disabled", () => disabled("")); + it("renders outlined star when no value or average is set", async () => { const page = await newE2EPage(); await page.setContent(""); @@ -371,16 +373,6 @@ describe("calcite-rating", () => { expect(element).toEqualAttribute("value", "4"); }); - it("disables click interaction when disabled is requested", async () => { - const page = await newE2EPage(); - await page.setContent(""); - const element = await page.find("calcite-rating"); - const ratingItem1 = await page.find("calcite-rating >>> .star"); - expect(element).toEqualAttribute("value", "0"); - await ratingItem1.click(); - expect(element).toEqualAttribute("value", "0"); - }); - it("does not render the calcite chip when count and average are not present", async () => { const page = await newE2EPage(); await page.setContent(""); diff --git a/src/components/rating/rating.scss b/src/components/rating/rating.scss index 22271da7f6e..4d82bffb5f6 100644 --- a/src/components/rating/rating.scss +++ b/src/components/rating/rating.scss @@ -11,6 +11,8 @@ width: fit-content; } +@include disabled(); + :host([scale="s"]) { @apply h-6; --calcite-rating-spacing-unit: theme("spacing.1"); @@ -26,10 +28,6 @@ --calcite-rating-spacing-unit: theme("spacing.3"); } -:host([disabled]) { - @apply pointer-events-none opacity-50; -} - :host([read-only]) { @apply pointer-events-none; } diff --git a/src/components/rating/rating.stories.ts b/src/components/rating/rating.stories.ts index c9c14b76aaa..54de1913fb9 100644 --- a/src/components/rating/rating.stories.ts +++ b/src/components/rating/rating.stories.ts @@ -80,3 +80,5 @@ export const Rtl = (): string => html` `; Rtl.storyName = "RTL"; + +export const disabled = (): string => html``; diff --git a/src/components/rating/rating.tsx b/src/components/rating/rating.tsx index b8535aa5efc..90d90e3b0cb 100644 --- a/src/components/rating/rating.tsx +++ b/src/components/rating/rating.tsx @@ -16,13 +16,14 @@ import { Scale } from "../interfaces"; import { LabelableComponent, connectLabel, disconnectLabel } from "../../utils/label"; import { connectForm, disconnectForm, FormComponent, HiddenFormInputSlot } from "../../utils/form"; import { TEXT } from "./resources"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @Component({ tag: "calcite-rating", styleUrl: "rating.scss", shadow: true }) -export class Rating implements LabelableComponent, FormComponent { +export class Rating implements LabelableComponent, FormComponent, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -94,6 +95,10 @@ export class Rating implements LabelableComponent, FormComponent { disconnectForm(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + //-------------------------------------------------------------------------- // // Events @@ -158,6 +163,10 @@ export class Rating implements LabelableComponent, FormComponent { id={`${this.guid}-${i}`} name={this.guid} onChange={() => this.updateValue(i)} + onClick={(event) => + // click is fired from the the component's label, so we treat this as an internal event + event.stopPropagation() + } onFocus={() => { this.hasFocus = true; this.focusValue = i; @@ -174,12 +183,13 @@ export class Rating implements LabelableComponent, FormComponent { } render() { - const { intlRating, showChip, scale, count, average } = this; + const { disabled, intlRating, showChip, scale, count, average } = this; return (
(this.hoverValue = null)} onMouseLeave={() => (this.hoverValue = null)} onTouchEnd={() => (this.hoverValue = null)} diff --git a/src/components/scrim/scrim.scss b/src/components/scrim/scrim.scss index a3916ef34ee..4c2b28c3df7 100644 --- a/src/components/scrim/scrim.scss +++ b/src/components/scrim/scrim.scss @@ -27,7 +27,7 @@ items-center flex-col justify-center; - animation: calcite-scrim-fade-in var(--calcite-internal-animation-timing-medium) ease-in-out; + animation: -scrim-fade-in var(--calcite-internal-animation-timing-medium) ease-in-out; background-color: var(--calcite-scrim-background); } diff --git a/src/components/select/select.e2e.ts b/src/components/select/select.e2e.ts index 6b3784e7ac8..801158426c4 100644 --- a/src/components/select/select.e2e.ts +++ b/src/components/select/select.e2e.ts @@ -1,5 +1,5 @@ import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; -import { accessible, focusable, formAssociated, labelable, reflects, renders } from "../../tests/commonTests"; +import { accessible, disabled, focusable, formAssociated, labelable, reflects, renders } from "../../tests/commonTests"; import { html } from "../../tests/utils"; import { CSS } from "./resources"; @@ -41,6 +41,8 @@ describe("calcite-select", () => { it("is labelable", async () => labelable("calcite-select")); + it("can be disabled", () => disabled("calcite-select")); + describe("flat options", () => { it("allows selecting items", async () => { const page = await newE2EPage({ diff --git a/src/components/select/select.scss b/src/components/select/select.scss index 52b5f14a96a..51efbfc79bd 100644 --- a/src/components/select/select.scss +++ b/src/components/select/select.scss @@ -16,6 +16,8 @@ width: var(--select-width); } +@include disabled(); + :host([scale="s"]) { @apply h-6; --calcite-select-font-size: theme("fontSize.n2h"); @@ -88,10 +90,6 @@ select:disabled { @apply border-color-input bg-opacity-100; } -:host([disabled]) { - @apply pointer-events-none select-none opacity-disabled; -} - .icon-container { @apply items-center bg-transparent diff --git a/src/components/select/select.stories.ts b/src/components/select/select.stories.ts index e93d50edd26..0a4edf3969f 100644 --- a/src/components/select/select.stories.ts +++ b/src/components/select/select.stories.ts @@ -138,3 +138,8 @@ export const RTL = (): string => ` ); + +export const disabled = (): string => html` + + +`; diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index aefcfc707a0..c0e2a8987eb 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -23,6 +23,7 @@ import { } from "../../utils/form"; import { CSS } from "./resources"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; type OptionOrGroup = HTMLCalciteOptionElement | HTMLCalciteOptionGroupElement; type NativeOptionOrGroup = HTMLOptionElement | HTMLOptGroupElement; @@ -45,7 +46,7 @@ function isOptionGroup( styleUrl: "select.scss", shadow: true }) -export class Select implements LabelableComponent, FormComponent { +export class Select implements LabelableComponent, FormComponent, InteractiveComponent { //-------------------------------------------------------------------------- // // Properties @@ -154,6 +155,10 @@ export class Select implements LabelableComponent, FormComponent { afterConnectDefaultValueSet(this, this.selectedOption?.value ?? ""); } + componentDidRender(): void { + updateHostInteraction(this); + } + //-------------------------------------------------------------------------- // // Public Methods diff --git a/src/components/slider/slider.e2e.ts b/src/components/slider/slider.e2e.ts index 5f4862cea37..a2a803a1130 100644 --- a/src/components/slider/slider.e2e.ts +++ b/src/components/slider/slider.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { defaults, formAssociated, labelable, renders } from "../../tests/commonTests"; +import { defaults, disabled, formAssociated, labelable, renders } from "../../tests/commonTests"; import { getElementXY, html } from "../../tests/utils"; describe("calcite-slider", () => { @@ -53,12 +53,7 @@ describe("calcite-slider", () => { it("is labelable", async () => labelable("calcite-slider")); - it("becomes inactive from disabled prop", async () => { - const page = await newE2EPage(); - await page.setContent(``); - const slider = await page.find("calcite-slider"); - expect(slider).toHaveAttribute("disabled"); - }); + it("can be disabled", () => disabled("calcite-slider")); it("sets aria attributes properly for single value", async () => { const page = await newE2EPage(); diff --git a/src/components/slider/slider.scss b/src/components/slider/slider.scss index af075d82014..4a21477e538 100644 --- a/src/components/slider/slider.scss +++ b/src/components/slider/slider.scss @@ -47,8 +47,7 @@ ); } -:host([disabled]) { - @apply opacity-disabled pointer-events-none; +@include disabled() { .track__range, .tick--active { background-color: var(--calcite-ui-text-3); diff --git a/src/components/slider/slider.stories.ts b/src/components/slider/slider.stories.ts index bcea6ea2885..2d9c8be6d97 100644 --- a/src/components/slider/slider.stories.ts +++ b/src/components/slider/slider.stories.ts @@ -281,3 +281,5 @@ export const HistogramDark = (): HTMLCalciteSliderElement => { HistogramDark.storyName = "Histogram Dark theme"; HistogramDark.parameters = { themes: themesDarkDefault }; + +export const disabled = (): string => html``; diff --git a/src/components/slider/slider.tsx b/src/components/slider/slider.tsx index 3bb7eb65d90..ec26eac0b38 100644 --- a/src/components/slider/slider.tsx +++ b/src/components/slider/slider.tsx @@ -26,6 +26,7 @@ import { FormComponent, HiddenFormInputSlot } from "../../utils/form"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; type ActiveSliderProperty = "minValue" | "maxValue" | "value" | "minMaxValue"; @@ -34,7 +35,7 @@ type ActiveSliderProperty = "minValue" | "maxValue" | "value" | "minMaxValue"; styleUrl: "slider.scss", shadow: true }) -export class Slider implements LabelableComponent, FormComponent { +export class Slider implements LabelableComponent, FormComponent, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -56,7 +57,7 @@ export class Slider implements LabelableComponent, FormComponent { /** * List of x,y coordinates within the slider's min and max, displays above the slider track. - * @see [DataSeries](https://github.com/Esri/calcite-components/blob/master/src/components/graph/interfaces.ts#L5) + * @see [DataSeries](https://github.com/Esri/calcite-components/blob/master/src/components/calcite-graph/interfaces.ts#L5) */ @Prop() histogram?: DataSeries; @@ -173,6 +174,7 @@ export class Slider implements LabelableComponent, FormComponent { } } this.hideObscuredBoundingTickLabels(); + updateHostInteraction(this); } render(): VNode { diff --git a/src/components/sortable-list/sortable-list.e2e.ts b/src/components/sortable-list/sortable-list.e2e.ts index 26302a68b86..96dd457e771 100644 --- a/src/components/sortable-list/sortable-list.e2e.ts +++ b/src/components/sortable-list/sortable-list.e2e.ts @@ -1,6 +1,6 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { accessible, hidden, renders } from "../../tests/commonTests"; -import { dragAndDrop } from "../../tests/utils"; +import { accessible, disabled, hidden, renders } from "../../tests/commonTests"; +import { dragAndDrop, html } from "../../tests/utils"; describe("calcite-sortable-list", () => { it("renders", async () => renders("calcite-sortable-list", { display: "flex" })); @@ -9,6 +9,16 @@ describe("calcite-sortable-list", () => { it("is accessible", async () => accessible(``)); + it("can be disabled", () => + disabled( + html` +
1
+
2
+
3
+
`, + { focusTarget: "child" } + )); + const worksUsingMouse = async (page: E2EPage): Promise => { await dragAndDrop(page, `#one calcite-handle`, `#two calcite-handle`); diff --git a/src/components/sortable-list/sortable-list.scss b/src/components/sortable-list/sortable-list.scss index 26031b4ded5..eb852e1694d 100644 --- a/src/components/sortable-list/sortable-list.scss +++ b/src/components/sortable-list/sortable-list.scss @@ -2,6 +2,8 @@ @apply flex; } +@include disabled(); + .container { @apply flex flex-auto; } diff --git a/src/components/sortable-list/sortable-list.tsx b/src/components/sortable-list/sortable-list.tsx index f3074b68a04..c0ea02e679f 100644 --- a/src/components/sortable-list/sortable-list.tsx +++ b/src/components/sortable-list/sortable-list.tsx @@ -13,6 +13,7 @@ import { import { createObserver } from "../../utils/observers"; import { Layout } from "../interfaces"; import { CSS } from "./resources"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding sortable items. @@ -22,7 +23,7 @@ import { CSS } from "./resources"; styleUrl: "sortable-list.scss", shadow: true }) -export class SortableList { +export class SortableList implements InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -98,6 +99,10 @@ export class SortableList { this.cleanUpDragAndDrop(); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Events diff --git a/src/components/split-button/split-button.e2e.ts b/src/components/split-button/split-button.e2e.ts index 8bdd0c701d6..655cb0fb6a1 100644 --- a/src/components/split-button/split-button.e2e.ts +++ b/src/components/split-button/split-button.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, renders, defaults } from "../../tests/commonTests"; +import { accessible, renders, defaults, disabled } from "../../tests/commonTests"; import { html } from "../../tests/utils"; import { CSS } from "./resources"; @@ -52,6 +52,8 @@ describe("calcite-split-button", () => { ${content} `)); + it("can be disabled", () => disabled("calcite-split-button")); + it("renders default props when none are provided", async () => { const page = await newE2EPage(); await page.setContent(` diff --git a/src/components/split-button/split-button.scss b/src/components/split-button/split-button.scss index f1dd393c912..c4508ae2312 100644 --- a/src/components/split-button/split-button.scss +++ b/src/components/split-button/split-button.scss @@ -123,7 +123,7 @@ } } -:host([disabled]) { +@include disabled() { .split-button__divider-container { @apply opacity-disabled; } diff --git a/src/components/split-button/split-button.stories.ts b/src/components/split-button/split-button.stories.ts index 3def9c4b835..cc17efa4c57 100644 --- a/src/components/split-button/split-button.stories.ts +++ b/src/components/split-button/split-button.stories.ts @@ -139,3 +139,11 @@ export const DarkMode = (): string => html` DarkMode.storyName = "Dark mode"; DarkMode.parameters = { themes: themesDarkDefault }; + +export const disabled = (): string => html` + + Option 2 + Option 3 + Option 4 + +`; diff --git a/src/components/split-button/split-button.tsx b/src/components/split-button/split-button.tsx index 64a7ca0007b..9193a75e8c7 100644 --- a/src/components/split-button/split-button.tsx +++ b/src/components/split-button/split-button.tsx @@ -1,8 +1,9 @@ -import { Component, Element, Event, EventEmitter, h, Prop, VNode } from "@stencil/core"; +import { Component, Element, Event, EventEmitter, h, Prop, VNode, Watch } from "@stencil/core"; import { CSS } from "./resources"; import { ButtonAppearance, ButtonColor, DropdownIconType } from "../button/interfaces"; import { FlipContext, Scale, Width } from "../interfaces"; import { OverlayPositioning } from "../../utils/popper"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-dropdown` content. @@ -12,7 +13,7 @@ import { OverlayPositioning } from "../../utils/popper"; styleUrl: "split-button.scss", shadow: true }) -export class SplitButton { +export class SplitButton implements InteractiveComponent { @Element() el: HTMLCalciteSplitButtonElement; /** specify the appearance style of the button, defaults to solid. */ @@ -24,11 +25,25 @@ export class SplitButton { /** is the control disabled */ @Prop({ reflect: true }) disabled = false; + @Watch("disabled") + handleDisabledChange(value: boolean): void { + if (!value) { + this.active = false; + } + } + /** * Is the dropdown currently active or not * @internal */ - @Prop({ reflect: true }) active = false; + @Prop({ mutable: true, reflect: true }) active = false; + + @Watch("active") + activeHandler(): void { + if (this.disabled) { + this.active = false; + } + } /** specify the icon used for the dropdown menu, defaults to chevron */ @Prop({ reflect: true }) dropdownIconType: DropdownIconType = "chevron"; @@ -70,6 +85,16 @@ export class SplitButton { /** fired when the secondary button is clicked */ @Event() calciteSplitButtonSecondaryClick: EventEmitter; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { const widthClasses = { [CSS.container]: true, @@ -103,6 +128,7 @@ export class SplitButton {
{ it("renders", () => renders("calcite-stepper-item", { display: "flex" })); + it("can be disabled", () => disabled("calcite-stepper-item")); }); diff --git a/src/components/stepper-item/stepper-item.scss b/src/components/stepper-item/stepper-item.scss index 3680da4044e..28c85c8fbd0 100644 --- a/src/components/stepper-item/stepper-item.scss +++ b/src/components/stepper-item/stepper-item.scss @@ -131,13 +131,7 @@ margin-inline-end: var(--calcite-stepper-item-spacing-unit-m); } -:host([disabled]) { - @apply opacity-disabled; -} -:host([disabled]), -:host([disabled]) * { - @apply cursor-not-allowed pointer-events-auto; -} +@include disabled(); :host([complete]) .container { // todo dark theme diff --git a/src/components/stepper-item/stepper-item.tsx b/src/components/stepper-item/stepper-item.tsx index 79f371e8a10..0b32ec99be7 100644 --- a/src/components/stepper-item/stepper-item.tsx +++ b/src/components/stepper-item/stepper-item.tsx @@ -12,6 +12,7 @@ import { } from "@stencil/core"; import { getElementProp } from "../../utils/dom"; import { Scale } from "../interfaces"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding custom content. @@ -21,7 +22,7 @@ import { Scale } from "../interfaces"; styleUrl: "stepper-item.scss", shadow: true }) -export class StepperItem { +export class StepperItem implements InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -45,7 +46,7 @@ export class StepperItem { @Prop() error = false; /** is the step disabled and not navigable to by a user */ - @Prop() disabled = false; + @Prop({ reflect: true }) disabled = false; /** pass a title for the stepper item */ @Prop() itemTitle?: string; @@ -126,13 +127,13 @@ export class StepperItem { } } + componentDidRender(): void { + updateHostInteraction(this, true); + } + render(): VNode { return ( - this.emitRequestedItem()} - tabindex={this.disabled ? null : 0} - > + this.emitRequestedItem()}>
{this.icon ? this.renderIcon() : null} diff --git a/src/components/stepper/stepper.stories.ts b/src/components/stepper/stepper.stories.ts index 48ee5c5b89d..2ff6b08a14d 100644 --- a/src/components/stepper/stepper.stories.ts +++ b/src/components/stepper/stepper.stories.ts @@ -175,3 +175,10 @@ export const Rtl = (): string => html` `; Rtl.storyName = "RTL"; + +export const disabled = (): string => html` + 1 + 2 + 3 + 4 +`; diff --git a/src/components/switch/switch.e2e.ts b/src/components/switch/switch.e2e.ts index fbf1c8a60cb..59b560e8393 100644 --- a/src/components/switch/switch.e2e.ts +++ b/src/components/switch/switch.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, formAssociated, HYDRATED_ATTR, labelable } from "../../tests/commonTests"; +import { accessible, disabled, formAssociated, HYDRATED_ATTR, labelable } from "../../tests/commonTests"; describe("calcite-switch", () => { it("renders with correct default attributes", async () => { @@ -20,6 +20,8 @@ describe("calcite-switch", () => { it("is form-associated", async () => formAssociated("calcite-switch", { testValue: true })); + it("can be disabled", () => disabled("calcite-switch")); + it("toggles the checked attributes appropriately when clicked", async () => { const page = await newE2EPage(); await page.setContent(""); @@ -74,18 +76,6 @@ describe("calcite-switch", () => { expect(spy).toHaveReceivedEventTimes(0); }); - it("does not toggle when disabled", async () => { - const page = await newE2EPage(); - await page.setContent(``); - const calciteSwitch = await page.find("calcite-switch"); - const changeEvent = await calciteSwitch.spyOnEvent("calciteSwitchChange"); - expect(changeEvent).toHaveReceivedEventTimes(0); - await calciteSwitch.click(); - expect(changeEvent).toHaveReceivedEventTimes(0); - expect(calciteSwitch).not.toHaveAttribute("checked"); - expect(await calciteSwitch.getProperty("checked")).toBe(false); - }); - it("toggles the checked attributes when the checkbox is toggled", async () => { const page = await newE2EPage(); await page.setContent(``); diff --git a/src/components/switch/switch.scss b/src/components/switch/switch.scss index ee8c645eb5e..652e7734446 100644 --- a/src/components/switch/switch.scss +++ b/src/components/switch/switch.scss @@ -45,9 +45,8 @@ tap-highlight-color: transparent; } -:host([disabled]) { - @apply opacity-disabled pointer-events-none cursor-default; -} +@include disabled(); + // focus styles :host { @apply focus-base w-auto; diff --git a/src/components/switch/switch.stories.ts b/src/components/switch/switch.stories.ts index 235fa02c4a7..a3ebc769f8d 100644 --- a/src/components/switch/switch.stories.ts +++ b/src/components/switch/switch.stories.ts @@ -64,3 +64,5 @@ export const Rtl = (): string => html` `; Rtl.storyName = "RTL"; + +export const disabled = (): string => html``; diff --git a/src/components/switch/switch.tsx b/src/components/switch/switch.tsx index 587930aceab..8ba297109a3 100644 --- a/src/components/switch/switch.tsx +++ b/src/components/switch/switch.tsx @@ -7,7 +7,6 @@ import { Host, Method, Prop, - State, VNode, Watch } from "@stencil/core"; @@ -20,13 +19,14 @@ import { CheckableFormCompoment, HiddenFormInputSlot } from "../../utils/form"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; @Component({ tag: "calcite-switch", styleUrl: "switch.scss", shadow: true }) -export class Switch implements LabelableComponent, CheckableFormCompoment { +export class Switch implements LabelableComponent, CheckableFormCompoment, InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -44,11 +44,6 @@ export class Switch implements LabelableComponent, CheckableFormCompoment { /** True if the switch is disabled */ @Prop({ reflect: true }) disabled = false; - @Watch("disabled") - disabledWatcher(newDisabled: boolean): void { - this.tabindex = newDisabled ? -1 : 0; - } - /** Applies to the aria-label attribute on the switch */ @Prop() label?: string; @@ -90,14 +85,6 @@ export class Switch implements LabelableComponent, CheckableFormCompoment { defaultChecked: boolean; - //-------------------------------------------------------------------------- - // - // State - // - //-------------------------------------------------------------------------- - - @State() tabindex: number; - //-------------------------------------------------------------------------- // // Public Methods @@ -181,8 +168,8 @@ export class Switch implements LabelableComponent, CheckableFormCompoment { disconnectForm(this); } - componentWillLoad(): void { - this.tabindex = this.el.getAttribute("tabindex") || this.disabled ? -1 : 0; + componentDidRender(): void { + updateHostInteraction(this); } // -------------------------------------------------------------------------- @@ -201,7 +188,7 @@ export class Switch implements LabelableComponent, CheckableFormCompoment { onClick={this.clickHandler} ref={this.setSwitchEl} role="switch" - tabindex={this.tabindex} + tabIndex={0} >
diff --git a/src/components/tab-title/tab-title.e2e.ts b/src/components/tab-title/tab-title.e2e.ts index 1c6460f4705..59818919455 100644 --- a/src/components/tab-title/tab-title.e2e.ts +++ b/src/components/tab-title/tab-title.e2e.ts @@ -1,11 +1,13 @@ import { newE2EPage } from "@stencil/core/testing"; -import { HYDRATED_ATTR, renders } from "../../tests/commonTests"; +import { disabled, HYDRATED_ATTR, renders } from "../../tests/commonTests"; describe("calcite-tab-title", () => { const tabTitleHtml = ""; it("renders", async () => renders(tabTitleHtml, { display: "block" })); + it("can be disabled", () => disabled("calcite-tab-title")); + it("renders with an icon-start", async () => { const page = await newE2EPage(); await page.setContent(`Text`); diff --git a/src/components/tab-title/tab-title.scss b/src/components/tab-title/tab-title.scss index d4c554444e9..2fb761bd81d 100644 --- a/src/components/tab-title/tab-title.scss +++ b/src/components/tab-title/tab-title.scss @@ -36,8 +36,7 @@ @apply text-color-1 border-color-transparent; } -:host([disabled]) { - @apply pointer-events-none; +@include disabled() { span, a { @apply pointer-events-none opacity-50; diff --git a/src/components/tab-title/tab-title.tsx b/src/components/tab-title/tab-title.tsx index 173bb2aea66..79963bbe7e7 100644 --- a/src/components/tab-title/tab-title.tsx +++ b/src/components/tab-title/tab-title.tsx @@ -19,6 +19,7 @@ import { getElementProp, getElementDir } from "../../utils/dom"; import { TabID, TabLayout, TabPosition } from "../tabs/interfaces"; import { FlipContext, Scale } from "../interfaces"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding text. @@ -28,10 +29,10 @@ import { createObserver } from "../../utils/observers"; styleUrl: "tab-title.scss", shadow: true }) -export class TabTitle { +export class TabTitle implements InteractiveComponent { //-------------------------------------------------------------------------- // - // Events + // Element // //-------------------------------------------------------------------------- @@ -130,7 +131,6 @@ export class TabTitle { render(): VNode { const id = this.el.id || this.guid; - const Tag = this.disabled ? "span" : "a"; const showSideBorders = this.bordered && !this.disabled && this.layout !== "center"; const iconStartEl = ( @@ -152,14 +152,8 @@ export class TabTitle { ); return ( - - + {this.iconEnd ? iconEndEl : null} - + ); } @@ -178,6 +172,10 @@ export class TabTitle { this.calciteTabTitleRegister.emit(await this.getTabIdentifier()); } + componentDidRender(): void { + updateHostInteraction(this, true); + } + //-------------------------------------------------------------------------- // // Event Listeners @@ -235,7 +233,7 @@ export class TabTitle { /** * Fires when a specific tab is activated (`event.details`) - * @see [TabChangeEventDetail](https://github.com/Esri/calcite-components/blob/master/src/components/tab/interfaces.ts#L1) + * @see [TabChangeEventDetail](https://github.com/Esri/calcite-components/blob/master/src/components/calcite-tab/interfaces.ts#L1) */ @Event() calciteTabsActivate: EventEmitter; diff --git a/src/components/tabs/tabs.e2e.ts b/src/components/tabs/tabs.e2e.ts index feab26c49ad..a5e58b3d0e9 100644 --- a/src/components/tabs/tabs.e2e.ts +++ b/src/components/tabs/tabs.e2e.ts @@ -106,30 +106,6 @@ describe("calcite-tabs", () => { } }); - it("disallows selection of a disabled tab", async () => { - const page = await newE2EPage(); - - await page.setContent(` - - - Tab 1 Title - Tab 2 Title - - - Tab 1 Content - Tab 2 Content - - `); - - await page.waitForChanges(); - - const [, tab2] = await page.findAll("calcite-tab"); - const [, tabTitle2] = await page.findAll("calcite-tab-title"); - - await tabTitle2.click(); - expect(tab2).not.toHaveAttribute("active"); - }); - describe("when no scale is provided", () => { it("should render itself and child tab elements with default medium scale", async () => { const page = await newE2EPage({ diff --git a/src/components/tabs/tabs.stories.ts b/src/components/tabs/tabs.stories.ts index 5fd82e98b39..6b90e5992fe 100644 --- a/src/components/tabs/tabs.stories.ts +++ b/src/components/tabs/tabs.stories.ts @@ -238,3 +238,12 @@ export const RTL = (): string => html`

Tab 4 Content

`; + +export const disabled = (): string => html` + + Tab 1 Title + Tab 2 Title + +

Tab 1 Content

+

Tab 2 Content

+
`; diff --git a/src/components/tile-select-group/tile-select-group.e2e.ts b/src/components/tile-select-group/tile-select-group.e2e.ts index 73e95d7e3e7..20e250aaa8b 100644 --- a/src/components/tile-select-group/tile-select-group.e2e.ts +++ b/src/components/tile-select-group/tile-select-group.e2e.ts @@ -1,4 +1,5 @@ -import { accessible, defaults, reflects, renders } from "../../tests/commonTests"; +import { accessible, defaults, disabled, reflects, renders } from "../../tests/commonTests"; +import { html } from "../../tests/utils"; describe("calcite-tile-select-group", () => { it("renders", async () => renders("calcite-tile-select-group", { display: "flex" })); @@ -9,4 +10,14 @@ describe("calcite-tile-select-group", () => { defaults("calcite-tile-select-group", [{ propertyName: "layout", defaultValue: "horizontal" }])); it("reflects", async () => reflects("calcite-tile-select-group", [{ propertyName: "layout", value: "horizontal" }])); + + it("can be disabled", () => + disabled( + html` + + + + `, + { focusTarget: "child" } + )); }); diff --git a/src/components/tile-select-group/tile-select-group.scss b/src/components/tile-select-group/tile-select-group.scss index f6f79f485de..d5fa2093f61 100644 --- a/src/components/tile-select-group/tile-select-group.scss +++ b/src/components/tile-select-group/tile-select-group.scss @@ -8,3 +8,5 @@ :host([layout="vertical"]) { @apply flex-col; } + +@include disabled(); diff --git a/src/components/tile-select-group/tile-select-group.stories.ts b/src/components/tile-select-group/tile-select-group.stories.ts index 591bb6c4407..43bf52a8a27 100644 --- a/src/components/tile-select-group/tile-select-group.stories.ts +++ b/src/components/tile-select-group/tile-select-group.stories.ts @@ -171,3 +171,8 @@ export const RTL = (): string => html` > `; + +export const disabled = (): string => html` + + +`; diff --git a/src/components/tile-select-group/tile-select-group.tsx b/src/components/tile-select-group/tile-select-group.tsx index b5d6c01076b..501052244a4 100644 --- a/src/components/tile-select-group/tile-select-group.tsx +++ b/src/components/tile-select-group/tile-select-group.tsx @@ -1,5 +1,6 @@ -import { Component, h, VNode, Prop } from "@stencil/core"; +import { Component, h, VNode, Prop, Element } from "@stencil/core"; import { TileSelectGroupLayout } from "./interfaces"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-tile-select`s. @@ -9,15 +10,37 @@ import { TileSelectGroupLayout } from "./interfaces"; styleUrl: "tile-select-group.scss", shadow: true }) -export class TileSelectGroup { +export class TileSelectGroup implements InteractiveComponent { + //-------------------------------------------------------------------------- + // + // Element + // + //-------------------------------------------------------------------------- + + @Element() el: HTMLCalciteTileSelectGroupElement; + //-------------------------------------------------------------------------- // // Properties // //-------------------------------------------------------------------------- + + /** The disabled state of the tile select. */ + @Prop({ reflect: true }) disabled = false; + /** Tiles by default move horizontally, stacking with each row, vertical allows single-column layouts */ @Prop({ reflect: true }) layout?: TileSelectGroupLayout = "horizontal"; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { return ; } diff --git a/src/components/tile-select/tile-select.e2e.ts b/src/components/tile-select/tile-select.e2e.ts index d0e7c4478f6..a482366c4ea 100644 --- a/src/components/tile-select/tile-select.e2e.ts +++ b/src/components/tile-select/tile-select.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, focusable, hidden, reflects, renders } from "../../tests/commonTests"; +import { accessible, defaults, disabled, focusable, hidden, reflects, renders } from "../../tests/commonTests"; import { html } from "../../tests/utils"; describe("calcite-tile-select", () => { @@ -32,6 +32,14 @@ describe("calcite-tile-select", () => { it("honors hidden attribute", async () => hidden("calcite-tile-select")); + it("can be disabled", () => + disabled( + "calcite-tile-select", + + /* focusing on child since tile appends to light DOM */ + { focusTarget: "child" } + )); + it("renders a calcite-tile", async () => { const page = await newE2EPage(); await page.setContent(""); diff --git a/src/components/tile-select/tile-select.scss b/src/components/tile-select/tile-select.scss index fd10d330197..42871b1ec8c 100644 --- a/src/components/tile-select/tile-select.scss +++ b/src/components/tile-select/tile-select.scss @@ -129,7 +129,4 @@ $spacing: $baseline * 0.5; } } -:host([disabled]) { - @apply opacity-disabled; - pointer-events: none; -} +@include disabled(); diff --git a/src/components/tile-select/tile-select.tsx b/src/components/tile-select/tile-select.tsx index 61d8306102d..2ef91e0356b 100644 --- a/src/components/tile-select/tile-select.tsx +++ b/src/components/tile-select/tile-select.tsx @@ -15,6 +15,7 @@ import { Alignment, Width } from "../interfaces"; import { TileSelectType } from "./interfaces"; import { guid } from "../../utils/guid"; import { CSS } from "./resources"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding custom content. @@ -24,7 +25,7 @@ import { CSS } from "./resources"; styleUrl: "tile-select.scss", shadow: true }) -export class TileSelect { +export class TileSelect implements InteractiveComponent { //-------------------------------------------------------------------------- // // Element @@ -225,6 +226,10 @@ export class TileSelect { this.input.parentNode.removeChild(this.input); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Render Methods diff --git a/src/components/tile/tile.e2e.ts b/src/components/tile/tile.e2e.ts index 2ca84e1f58b..07eb41f8ef5 100644 --- a/src/components/tile/tile.e2e.ts +++ b/src/components/tile/tile.e2e.ts @@ -1,6 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, hidden, reflects, renders, slots } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, defaults, disabled, hidden, reflects, renders, slots } from "../../tests/commonTests"; import { SLOTS } from "./resources"; describe("calcite-tile", () => { @@ -30,6 +29,8 @@ describe("calcite-tile", () => { it("honors hidden attribute", async () => hidden("calcite-tile")); + it("can be disabled", () => disabled("")); + it("renders without a link by default", async () => { const page = await newE2EPage(); await page.setContent(""); @@ -59,22 +60,6 @@ describe("calcite-tile", () => { expect(description).toBeNull(); }); - it("disabling it also disables link", async () => { - const page = await newE2EPage({ - html: html`` - }); - const tile = await page.find("calcite-tile"); - const link = await page.find("calcite-tile >>> calcite-link"); - - expect(await link.getProperty("disabled")).toBe(false); - - tile.setProperty("disabled", true); - - await page.waitForChanges(); - - expect(await link.getProperty("disabled")).toBe(true); - }); - it("renders icon only when supplied", async () => { const page = await newE2EPage(); await page.setContent(""); diff --git a/src/components/tile/tile.scss b/src/components/tile/tile.scss index 2489dc6b7cc..7c2c8cc34cc 100644 --- a/src/components/tile/tile.scss +++ b/src/components/tile/tile.scss @@ -90,11 +90,17 @@ :host([icon][heading]:not([description]):not([embed])) { @apply p-0; } -:host([disabled]) { - @apply opacity-disabled; - @apply pointer-events-none; +:host([icon][heading]:not([description])) { + .icon { + @apply flex justify-center; + } + .large-visual { + @apply text-center; + } } +@include disabled(); + :host(:hover), :host([active]) { .heading { diff --git a/src/components/tile/tile.stories.ts b/src/components/tile/tile.stories.ts index 1194b3069a5..fd21b181391 100644 --- a/src/components/tile/tile.stories.ts +++ b/src/components/tile/tile.stories.ts @@ -76,3 +76,5 @@ export const LargeTile = (): string => html` > `; + +export const disabled = (): string => html``; diff --git a/src/components/tile/tile.tsx b/src/components/tile/tile.tsx index 8902f1f62d4..c4dae0ed30d 100644 --- a/src/components/tile/tile.tsx +++ b/src/components/tile/tile.tsx @@ -6,6 +6,7 @@ import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot content-start - A slot for adding non-actionable elements before the tile content. @@ -16,7 +17,7 @@ import { styleUrl: "tile.scss", shadow: true }) -export class Tile implements ConditionalSlotComponent { +export class Tile implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Private Properties @@ -77,6 +78,10 @@ export class Tile implements ConditionalSlotComponent { disconnectConditionalSlotComponent(this); } + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Render Methods diff --git a/src/components/tip-manager/tip-manager.spec.ts b/src/components/tip-manager/tip-manager.spec.ts index 8b3bee0c8ff..a69452f9f15 100644 --- a/src/components/tip-manager/tip-manager.spec.ts +++ b/src/components/tip-manager/tip-manager.spec.ts @@ -1,5 +1,5 @@ // TODO: Uncomment this test when there's a resolution to this bug. https://github.com/ionic-team/stencil/issues/1669 -// import { TipManager } from "./calcite-tip-manager"; +// import { TipManager } from "./tip-manager"; describe.skip("TipManager", () => { it("should increment/decrement the selectedIndex when the public next/prev methods are called", () => { diff --git a/src/components/value-list/value-list.e2e.ts b/src/components/value-list/value-list.e2e.ts index 975a6e45eb4..c0b8529294b 100644 --- a/src/components/value-list/value-list.e2e.ts +++ b/src/components/value-list/value-list.e2e.ts @@ -4,10 +4,11 @@ import { accessible, hidden, renders } from "../../tests/commonTests"; import { selectionAndDeselection, filterBehavior, - disabledStates, + loadingState, keyboardNavigation, itemRemoval, - focusing + focusing, + disabling } from "../pick-list/shared-list-tests"; import { dragAndDrop, html } from "../../tests/utils"; @@ -23,6 +24,10 @@ describe("calcite-value-list", () => { `)); + describe("disabling", () => { + disabling("value"); + }); + describe("Selection and Deselection", () => { selectionAndDeselection("value"); }); @@ -64,8 +69,8 @@ describe("calcite-value-list", () => { itemRemoval("value"); }); - describe("disabled states", () => { - disabledStates("value"); + describe("loading state", () => { + loadingState("value"); }); describe("setFocus", () => { diff --git a/src/components/value-list/value-list.scss b/src/components/value-list/value-list.scss index ac2357185f8..332a13b21be 100644 --- a/src/components/value-list/value-list.scss +++ b/src/components/value-list/value-list.scss @@ -14,6 +14,8 @@ } } +@include disabled(); + calcite-value-list-item:last-of-type { @apply shadow-none; } diff --git a/src/components/value-list/value-list.stories.ts b/src/components/value-list/value-list.stories.ts index 6df0b85a193..5231c458d08 100644 --- a/src/components/value-list/value-list.stories.ts +++ b/src/components/value-list/value-list.stories.ts @@ -130,3 +130,14 @@ export const darkThemeRTL = (): string => ); darkThemeRTL.parameters = { themes: themesDarkDefault }; + +export const disabled = (): string => html` + + + +`; diff --git a/src/components/value-list/value-list.tsx b/src/components/value-list/value-list.tsx index 1b11662c53b..26ee728cad8 100644 --- a/src/components/value-list/value-list.tsx +++ b/src/components/value-list/value-list.tsx @@ -34,6 +34,7 @@ import { import List from "../pick-list/shared-list-render"; import { getRoundRobinIndex } from "../../utils/array"; import { createObserver } from "../../utils/observers"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding `calcite-value-list-item` elements. Items are displayed as a vertical list. @@ -46,7 +47,8 @@ import { createObserver } from "../../utils/observers"; }) export class ValueList< ItemElement extends HTMLCalciteValueListItemElement = HTMLCalciteValueListItemElement -> { +> implements InteractiveComponent +{ // -------------------------------------------------------------------------- // // Properties @@ -137,6 +139,10 @@ export class ValueList< this.setUpDragAndDrop(); } + componentDidRender(): void { + updateHostInteraction(this); + } + disconnectedCallback(): void { cleanUpObserver.call(this); this.cleanUpDragAndDrop(); diff --git a/src/tests/commonTests.ts b/src/tests/commonTests.ts index 6713b4aaf8a..ef9a76fe885 100644 --- a/src/tests/commonTests.ts +++ b/src/tests/commonTests.ts @@ -3,7 +3,7 @@ import { JSX } from "../components"; import { toHaveNoViolations } from "jest-axe"; import axe from "axe-core"; import { config } from "../../stencil.config"; -import { GlobalTestProps, html } from "./utils"; +import { GlobalTestProps, html, waitForAnimationFrame } from "./utils"; import { hiddenFormInputSlotName } from "../utils/form"; expect.extend(toHaveNoViolations); @@ -12,6 +12,10 @@ type ComponentTag = keyof JSX.IntrinsicElements; type AxeOwningWindow = GlobalTestProps<{ axe: typeof axe }>; type ComponentHTML = string; type TagOrHTML = ComponentTag | ComponentHTML; +type TagAndPage = { + tag: ComponentTag; + page: E2EPage; +}; export const HYDRATED_ATTR = config.hydratedFlag.name; @@ -517,3 +521,151 @@ export async function formAssociated(componentTagOrHtml: TagOrHTML, options: For } } } + +interface TabAndClickTargets { + tab: string; + click: string; +} + +type FocusTarget = "host" | "child" | "none"; + +interface DisabledOptions { + /** + * Use this to specify whether the test should cover focusing. + */ + focusTarget: FocusTarget | TabAndClickTargets; +} + +async function getTagAndPage(componentSetup: TagOrHTML | TagAndPage): Promise { + if (typeof componentSetup === "string") { + const page = await simplePageSetup(componentSetup); + const tag = getTag(componentSetup); + + return { page, tag }; + } + + return componentSetup; +} + +/** + * Helper to test the disabled prop disabling user interaction. + * + * @param componentTagOrHTML - the component tag or HTML markup to test against + */ +export async function disabled( + componentSetup: TagOrHTML | TagAndPage, + options: DisabledOptions = { focusTarget: "host" } +): Promise { + const { page, tag } = await getTagAndPage(componentSetup); + + const component = await page.find(tag); + const enabledComponentClickSpy = await component.spyOnEvent("click"); + await page.addStyleTag({ + // skip animations/transitions + content: `:root { --calcite-duration-factor: 0; }` + }); + + await page.$eval(tag, (el) => { + el.addEventListener( + "click", + (event) => { + const path = event.composedPath() as HTMLElement[]; + + if (path.find((el) => el?.tagName === "A")) { + // we prevent the default behavior to avoid a page redirect + el.addEventListener("click", (event) => event.preventDefault(), { once: true }); + } + }, + true + ); + }); + + async function expectToBeFocused(tag: string): Promise { + const focusedTag = await page.evaluate(() => document.activeElement?.tagName.toLowerCase()); + expect(focusedTag).toBe(tag); + } + + expect(component.getAttribute("aria-disabled")).toBeNull(); + + if (options.focusTarget === "none") { + await page.click(tag); + await expectToBeFocused("body"); + + expect(enabledComponentClickSpy).toHaveReceivedEventTimes(1); + + component.setProperty("disabled", true); + await page.waitForChanges(); + const disabledComponentClickSpy = await component.spyOnEvent("click"); + + expect(component.getAttribute("aria-disabled")).toBe("true"); + + await page.click(tag); + await expectToBeFocused("body"); + + await component.callMethod("click"); + await expectToBeFocused("body"); + + expect(disabledComponentClickSpy).toHaveReceivedEventTimes(0); + + return; + } + + async function getFocusTarget(focusTarget: FocusTarget): Promise { + return focusTarget === "host" ? tag : await page.evaluate(() => document.activeElement?.tagName.toLowerCase()); + } + + await page.keyboard.press("Tab"); + + let tabFocusTarget: string; + let clickFocusTarget: string; + + if (typeof options.focusTarget === "object") { + tabFocusTarget = options.focusTarget.tab; + clickFocusTarget = options.focusTarget.click; + } else { + tabFocusTarget = clickFocusTarget = await getFocusTarget(options.focusTarget); + } + + expect(tabFocusTarget).not.toBe("body"); + await expectToBeFocused(tabFocusTarget); + + const [shadowFocusableCenterX, shadowFocusableCenterY] = await page.$eval(tabFocusTarget, (element: HTMLElement) => { + const focusTarget = element.shadowRoot.activeElement || element; + const rect = focusTarget.getBoundingClientRect(); + + return [rect.x + rect.width / 2, rect.y + rect.height / 2]; + }); + + async function resetFocusOrder(): Promise { + // test page has default margin, so clicking on 0,0 will not hit the test element + await page.mouse.click(0, 0); + } + + await resetFocusOrder(); + await expectToBeFocused("body"); + + await page.mouse.click(shadowFocusableCenterX, shadowFocusableCenterY); + await expectToBeFocused(clickFocusTarget); + + await component.callMethod("click"); + await expectToBeFocused(clickFocusTarget); + + // some components emit more than one click event, + // so we check if at least one event is received + expect(enabledComponentClickSpy.length).toBeGreaterThanOrEqual(2); + + component.setProperty("disabled", true); + await page.waitForChanges(); + const disabledComponentClickSpy = await component.spyOnEvent("click"); + + expect(component.getAttribute("aria-disabled")).toBe("true"); + + await resetFocusOrder(); + await page.keyboard.press("Tab"); + await expectToBeFocused("body"); + + await page.mouse.click(shadowFocusableCenterX, shadowFocusableCenterY); + await expectToBeFocused("body"); + + expect(disabledComponentClickSpy).toHaveReceivedEventTimes(0); +} diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 4ac23a7d7cb..77aab72da97 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -1,4 +1,4 @@ -import { E2EElement, E2EPage } from "@stencil/core/testing"; +import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; import { BoundingBox, JSONObject } from "puppeteer"; import dedent from "dedent"; @@ -328,3 +328,15 @@ export async function visualizeMouseCursor(page: E2EPage): Promise { export async function waitForAnimationFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } + +/** + * Creates an E2E page for tests that need to create and set up elements programmatically. + */ +export async function newProgrammaticE2EPage(): Promise { + const page = await newE2EPage(); + // we need to initialize the page with any component to ensure they are available in the browser context + await page.setContent(""); + await page.evaluate(() => document.querySelector("calcite-icon").remove()); + + return page; +} diff --git a/src/utils/interactive.spec.ts b/src/utils/interactive.spec.ts new file mode 100644 index 00000000000..16c7a20c4c5 --- /dev/null +++ b/src/utils/interactive.spec.ts @@ -0,0 +1,28 @@ +import { updateHostInteraction } from "./interactive"; + +describe("interactive", () => { + it("updateHostInteraction", () => { + document.body.innerHTML = ` + + `; + + const fakeInteractiveEl = document.querySelector("fake-interactive"); + + const fakeInteractive = { + el: fakeInteractiveEl, + disabled: false + }; + + updateHostInteraction(fakeInteractive); + + expect(fakeInteractiveEl.getAttribute("tabindex")).toBeNull(); + expect(fakeInteractiveEl.getAttribute("aria-disabled")).toBeNull(); + + fakeInteractive.disabled = true; + + updateHostInteraction(fakeInteractive); + + expect(fakeInteractiveEl.getAttribute("tabindex")).toBe("-1"); + expect(fakeInteractiveEl.getAttribute("aria-disabled")).toBe("true"); + }); +}); diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts new file mode 100644 index 00000000000..568d04dcd0f --- /dev/null +++ b/src/utils/interactive.ts @@ -0,0 +1,62 @@ +export interface InteractiveComponent { + /** + * The host element. + */ + readonly el: HTMLElement; + + /** + * When true, prevents user interaction. + * + * Notes: + * + * * This prop should use the @Prop decorator and reflect. + * * The `disabled` Sass mixin must be added to the component's stylesheet. + */ + disabled: boolean; +} + +type HostIsTabbablePredicate = () => boolean; + +function noopClick(): void { + /** noop **/ +} + +/** + * This helper updates the host element to prevent keyboard interaction on its subtree and sets the appropriate aria attribute for accessibility. + * + * This should be used in the `componentDidRender` lifecycle hook. + * + * **Notes** + * + * * this util is not needed for simple components whose root element or elements are an interactive component (custom element or native control). For those cases, set the `disabled` props on the root components instead. + * * technically, users can override `tabindex` and restore keyboard navigation, but this will be considered user error + */ +export function updateHostInteraction( + component: InteractiveComponent, + hostIsTabbable: boolean | HostIsTabbablePredicate = false +): void { + if (component.disabled) { + component.el.setAttribute("tabindex", "-1"); + component.el.setAttribute("aria-disabled", "true"); + + if (component.el.contains(document.activeElement)) { + (document.activeElement as HTMLElement).blur(); + } + + component.el.click = noopClick; + + return; + } + + component.el.click = HTMLElement.prototype.click; + + if (typeof hostIsTabbable === "function") { + component.el.setAttribute("tabindex", hostIsTabbable.call(component) ? "0" : "-1"); + } else if (hostIsTabbable === true) { + component.el.setAttribute("tabindex", "0"); + } else { + component.el.removeAttribute("tabindex"); + } + + component.el.removeAttribute("aria-disabled"); +}