diff --git a/.storybook/utils.tsx b/.storybook/utils.tsx index e3a5db1d97e..3b6d0e9baa4 100644 --- a/.storybook/utils.tsx +++ b/.storybook/utils.tsx @@ -139,3 +139,78 @@ export const filterComponentAttributes = ( .filter((attr) => !exceptions.find((except) => except === attr.name)) .map((attr) => attr.commit()); }; + +/* +MIT License + +Copyright (c) 2020 Cloud Four + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +https://github.com/cloudfour/simple-svg-placeholder +*/ + +interface SimpleSvgPlaceholderParams { + width?: number; + height?: number; + text?: string; + fontFamily?: string; + fontWeight?: string; + fontSize?: number; + dy?: number; + bgColor?: string; + textColor?: string; + dataUri?: boolean; + charset?: string; +} + +export function placeholderImage({ + width = 300, + height = 150, + text = `${width}×${height}`, + fontFamily = "sans-serif", + fontWeight = "bold", + fontSize = Math.floor(Math.min(width, height) * 0.2), + dy = fontSize * 0.35, + bgColor = "#ddd", + textColor = "rgba(0,0,0,0.5)", + dataUri = true, + charset = "UTF-8" +}: SimpleSvgPlaceholderParams): string { + const str = ` + + ${text} + `; + + // Thanks to: filamentgroup/directory-encoder + const cleaned = str + .replace(/[\t\n\r]/gim, "") // Strip newlines and tabs + .replace(/\s\s+/g, " ") // Condense multiple spaces + .replace(/'/gim, "\\i"); // Normalize quotes + + if (dataUri) { + const encoded = encodeURIComponent(cleaned) + .replace(/\(/g, "%28") // Encode brackets + .replace(/\)/g, "%29"); + + return `data:image/svg+xml;charset=${charset},${encoded}`; + } + + return cleaned; +} diff --git a/src/assets/styles/includes.scss b/src/assets/styles/includes.scss index 151624b7e1c..b927bbc5b9f 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 opacity-disabled pointer-events-none cursor-default select-none; + @content; + + ::slotted([calcite-hydrated][disabled]), + [calcite-hydrated][disabled] { + /* prevent opacity stacking */ + @apply opacity-100; + } + } +} diff --git a/src/components/accordion/accordion.e2e.ts b/src/components/accordion/accordion.e2e.ts index b35486a85a4..2131b38c23b 100644 --- a/src/components/accordion/accordion.e2e.ts +++ b/src/components/accordion/accordion.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-accordion", () => { const accordionContent = ` diff --git a/src/components/accordion/accordion.stories.ts b/src/components/accordion/accordion.stories.ts index 6591eac3d7c..b2989f75fa9 100644 --- a/src/components/accordion/accordion.stories.ts +++ b/src/components/accordion/accordion.stories.ts @@ -3,15 +3,15 @@ import { Attributes, filterComponentAttributes, createComponentHTML as create, - themesDarkDefault + themesDarkDefault, + placeholderImage } from "../../../.storybook/utils"; -import { html } from "../../tests/utils"; import { ATTRIBUTES } from "../../../.storybook/resources"; import { iconNames } from "../../../.storybook/helpers"; import { select, text } from "@storybook/addon-knobs"; import accordionReadme from "./readme.md"; import accordionItemReadme from "../accordion-item/readme.md"; -import { placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; const createAccordionAttributes: (options?: { exceptions: string[] }) => Attributes = ( { exceptions } = { exceptions: [] } diff --git a/src/components/action-bar/action-bar.e2e.ts b/src/components/action-bar/action-bar.e2e.ts index 42180d4cf73..d6afaf2d6d7 100755 --- a/src/components/action-bar/action-bar.e2e.ts +++ b/src/components/action-bar/action-bar.e2e.ts @@ -2,7 +2,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, focusable, hidden, reflects, renders, slots } from "../../tests/commonTests"; import { CSS, SLOTS } from "./resources"; import { overflowActionsDebounceInMs } from "./utils"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-action-bar", () => { it("renders", async () => renders("calcite-action-bar", { display: "inline-flex" })); diff --git a/src/components/action-bar/action-bar.stories.ts b/src/components/action-bar/action-bar.stories.ts index a49835e0912..85b676af05a 100644 --- a/src/components/action-bar/action-bar.stories.ts +++ b/src/components/action-bar/action-bar.stories.ts @@ -8,7 +8,7 @@ import { } from "../../../.storybook/utils"; import readme from "./readme.md"; import { ATTRIBUTES } from "../../../.storybook/resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { TEXT } from "./resources"; export default { diff --git a/src/components/action-group/action-group.stories.ts b/src/components/action-group/action-group.stories.ts index 37519a7dd83..2f095558ecb 100644 --- a/src/components/action-group/action-group.stories.ts +++ b/src/components/action-group/action-group.stories.ts @@ -1,7 +1,7 @@ import { select, text } from "@storybook/addon-knobs"; import { iconNames } from "../../../.storybook/helpers"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Action Group", diff --git a/src/components/action-menu/action-menu.e2e.ts b/src/components/action-menu/action-menu.e2e.ts index d14996c7810..cffdcdfe915 100755 --- a/src/components/action-menu/action-menu.e2e.ts +++ b/src/components/action-menu/action-menu.e2e.ts @@ -1,7 +1,7 @@ import { accessible, hidden, renders, defaults, reflects, focusable, slots } from "../../tests/commonTests"; import { newE2EPage } from "@stencil/core/testing"; import { SLOTS, CSS } from "./resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-action-menu", () => { it("renders", async () => renders("calcite-action-menu", { display: "flex" })); diff --git a/src/components/action-pad/action-pad.e2e.ts b/src/components/action-pad/action-pad.e2e.ts index 34ff79c689b..458ee214486 100755 --- a/src/components/action-pad/action-pad.e2e.ts +++ b/src/components/action-pad/action-pad.e2e.ts @@ -1,7 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, focusable, hidden, reflects, renders, slots } from "../../tests/commonTests"; import { CSS, SLOTS } from "./resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-action-pad", () => { it("renders", async () => renders("calcite-action-pad", { display: "block" })); diff --git a/src/components/action-pad/action-pad.stories.ts b/src/components/action-pad/action-pad.stories.ts index 19e4b8d2f6f..43b74cc0b01 100644 --- a/src/components/action-pad/action-pad.stories.ts +++ b/src/components/action-pad/action-pad.stories.ts @@ -8,7 +8,7 @@ import { } from "../../../.storybook/utils"; import readme from "./readme.md"; import { ATTRIBUTES } from "../../../.storybook/resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { TEXT } from "./resources"; export default { 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 ba8d46d74a2..307751a3859 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.stories.ts b/src/components/action/action.stories.ts index 115cfb1112f..47cc5304429 100644 --- a/src/components/action/action.stories.ts +++ b/src/components/action/action.stories.ts @@ -6,7 +6,7 @@ import { createComponentHTML as create } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { createSteps, iconNames, stepStory, setTheme, setKnobs } from "../../../.storybook/helpers"; import { ATTRIBUTES } from "../../../.storybook/resources"; const { alignment, scale } = ATTRIBUTES; diff --git a/src/components/action/action.tsx b/src/components/action/action.tsx index 13ef69cb1d1..0585f182cfc 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/alert/alert.e2e.ts b/src/components/alert/alert.e2e.ts index 2c0e927afd6..beeb82751c9 100644 --- a/src/components/alert/alert.e2e.ts +++ b/src/components/alert/alert.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { renders, accessible, HYDRATED_ATTR } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-alert", () => { const alertContent = ` diff --git a/src/components/alert/alert.stories.ts b/src/components/alert/alert.stories.ts index 48f813ca736..a3d2af78423 100644 --- a/src/components/alert/alert.stories.ts +++ b/src/components/alert/alert.stories.ts @@ -2,7 +2,7 @@ import { select } from "@storybook/addon-knobs"; import { boolean, iconNames } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Alert", diff --git a/src/components/avatar/avatar.stories.ts b/src/components/avatar/avatar.stories.ts index 9b0e02b05e0..c28db3b59db 100644 --- a/src/components/avatar/avatar.stories.ts +++ b/src/components/avatar/avatar.stories.ts @@ -1,7 +1,7 @@ import { select, text } from "@storybook/addon-knobs"; -import { themesDarkDefault } from "../../../.storybook/utils"; -import { html, placeholderImage } from "../../tests/utils"; +import { placeholderImage, themesDarkDefault } from "../../../.storybook/utils"; +import { html } from "../../../support/formatting"; import readme from "./readme.md"; export default { diff --git a/src/components/block/block.e2e.ts b/src/components/block/block.e2e.ts index 921801be993..26e9301f4a7 100644 --- a/src/components/block/block.e2e.ts +++ b/src/components/block/block.e2e.ts @@ -1,7 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { CSS, SLOTS, TEXT } from "./resources"; -import { accessible, defaults, hidden, renders, slots } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, defaults, disabled, hidden, renders, slots } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; describe("calcite-block", () => { it("renders", async () => renders("calcite-block", { display: "flex" })); @@ -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 6f6b1e25b85..12b514cba1d 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..3ebad57bb93 100644 --- a/src/components/block/block.stories.ts +++ b/src/components/block/block.stories.ts @@ -3,11 +3,12 @@ import { Attribute, filterComponentAttributes, Attributes, - createComponentHTML as create + createComponentHTML as create, + placeholderImage } from "../../../.storybook/utils"; import blockReadme from "./readme.md"; import sectionReadme from "../block-section/readme.md"; -import { html, placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Block", @@ -167,3 +168,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 280a42a1a8d..b8b452d2588 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"; import { guid } from "../../utils/guid"; /** @@ -21,7 +22,7 @@ import { guid } from "../../utils/guid"; styleUrl: "block.scss", shadow: true }) -export class Block implements ConditionalSlotComponent { +export class Block implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -93,6 +94,16 @@ export class Block implements ConditionalSlotComponent { */ @Prop() summary: string; + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + componentDidRender(): void { + updateHostInteraction(this); + } + // -------------------------------------------------------------------------- // // Private Properties @@ -146,11 +157,10 @@ export class Block implements ConditionalSlotComponent { // -------------------------------------------------------------------------- renderScrim(): VNode[] { - const { disabled, loading } = this; - + const { loading } = this; const defaultSlot = ; - return [loading || disabled ? : null, defaultSlot]; + return [loading ? : null, defaultSlot]; } renderIcon(): VNode[] { @@ -193,8 +203,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; @@ -255,7 +264,7 @@ export class Block implements ConditionalSlotComponent { ); return ( - +
{ it("renders as a button with default props", async () => { @@ -44,6 +45,8 @@ describe("calcite-button", () => { 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 a522616c3bf..0aded600fb8 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..631cd82707c 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 } from "../../../support/formatting"; 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 58f5641314c..086fe734554 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 @@ -140,6 +141,10 @@ export class Button implements LabelableComponent { } } + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { const childElType = this.href ? "a" : "button"; const Tag = childElType; diff --git a/src/components/card/card.e2e.ts b/src/components/card/card.e2e.ts index 87d58f7ad58..a8caa6ee4df 100644 --- a/src/components/card/card.e2e.ts +++ b/src/components/card/card.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, renders, slots } from "../../tests/commonTests"; -import { placeholderImage } from "../../tests/utils"; +import { placeholderImage } from "../../../.storybook/utils"; import { CSS, SLOTS } from "./resources"; const placeholder = placeholderImage({ width: 350, diff --git a/src/components/card/card.stories.ts b/src/components/card/card.stories.ts index 3180f57db04..0122b3b6613 100644 --- a/src/components/card/card.stories.ts +++ b/src/components/card/card.stories.ts @@ -1,6 +1,6 @@ import { boolean } from "../../../.storybook/helpers"; -import { themesDarkDefault } from "../../../.storybook/utils"; -import { html, placeholderImage } from "../../tests/utils"; +import { placeholderImage, themesDarkDefault } from "../../../.storybook/utils"; +import { html } from "../../../support/formatting"; import readme from "./readme.md"; export default { 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 ea7833fc285..e30450c1c84 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 opacity-disabled pointer-events-none cursor-default; -} +@include disabled(); @include hidden-form-input(); diff --git a/src/components/checkbox/checkbox.stories.ts b/src/components/checkbox/checkbox.stories.ts index 89b18f8c33b..9da39d7cd45 100644 --- a/src/components/checkbox/checkbox.stories.ts +++ b/src/components/checkbox/checkbox.stories.ts @@ -1,7 +1,7 @@ import { select, text } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import readme from "./readme.md"; export default { @@ -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/chip/chip.stories.ts b/src/components/chip/chip.stories.ts index a8174cae770..12932041810 100644 --- a/src/components/chip/chip.stories.ts +++ b/src/components/chip/chip.stories.ts @@ -1,8 +1,8 @@ import { select } from "@storybook/addon-knobs"; import { iconNames, boolean } from "../../../.storybook/helpers"; -import { themesDarkDefault } from "../../../.storybook/utils"; +import { placeholderImage, themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html, placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Chip", 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 6cf61b953f9..9e52ec06ba2 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 text-n2h inline-block 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..dccfe46b36a 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 "../../../support/formatting"; 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 920b602b3e0..3e5e8795f99 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 m-0 flex flex-col p-0 outline-none; diff --git a/src/components/combobox-item/combobox-item.tsx b/src/components/combobox-item/combobox-item.tsx index a057f98c607..986391f7176 100644 --- a/src/components/combobox-item/combobox-item.tsx +++ b/src/components/combobox-item/combobox-item.tsx @@ -21,6 +21,7 @@ import { disconnectConditionalSlotComponent, ConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding nested `calcite-combobox-item`s. @@ -30,7 +31,7 @@ import { 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..ca687fe0ec0 100644 --- a/src/components/combobox/combobox.e2e.ts +++ b/src/components/combobox/combobox.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; -import { renders, hidden, accessible, defaults, labelable, formAssociated } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { renders, hidden, accessible, defaults, labelable, formAssociated, disabled } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; import { TEXT } from "./resources"; describe("calcite-combobox", () => { @@ -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 00ddc60133e..a76422e6c83 100644 --- a/src/components/combobox/combobox.scss +++ b/src/components/combobox/combobox.scss @@ -10,9 +10,7 @@ @apply relative block; } -: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 00c7964cbe8..7cbb0b673fe 100644 --- a/src/components/combobox/combobox.stories.ts +++ b/src/components/combobox/combobox.stories.ts @@ -4,7 +4,7 @@ import { boolean, createSteps, stepStory } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme1 from "./readme.md"; import readme2 from "../combobox-item/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Controls/Combobox", @@ -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 dcdd35e4789..beb1a955362 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/date-picker-month-header/date-picker-month-header.e2e.ts b/src/components/date-picker-month-header/date-picker-month-header.e2e.ts index 05f29bae488..09644dca685 100644 --- a/src/components/date-picker-month-header/date-picker-month-header.e2e.ts +++ b/src/components/date-picker-month-header/date-picker-month-header.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-date-picker-month-header", () => { it("renders", async () => renders("calcite-date-picker-month-header", { display: "block" })); diff --git a/src/components/date-picker/date-picker.e2e.ts b/src/components/date-picker/date-picker.e2e.ts index 8c550b50fb4..a19e397a60b 100644 --- a/src/components/date-picker/date-picker.e2e.ts +++ b/src/components/date-picker/date-picker.e2e.ts @@ -1,7 +1,7 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; import { renders, defaults, hidden } from "../../tests/commonTests"; import { TEXT } from "./resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-date-picker", () => { it("renders", async () => renders("calcite-date-picker", { display: "inline-block" })); diff --git a/src/components/date-picker/date-picker.stories.ts b/src/components/date-picker/date-picker.stories.ts index 0765ca5b6ed..e5ec9f25744 100644 --- a/src/components/date-picker/date-picker.stories.ts +++ b/src/components/date-picker/date-picker.stories.ts @@ -8,7 +8,7 @@ import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { locales } from "../../utils/locale"; import { createSteps, setKnobs, setTheme, stepStory } from "../../../.storybook/helpers"; import { ATTRIBUTES } from "../../../.storybook/resources"; diff --git a/src/components/dropdown/dropdown.e2e.ts b/src/components/dropdown/dropdown.e2e.ts index 050265d652a..91179911f98 100644 --- a/src/components/dropdown/dropdown.e2e.ts +++ b/src/components/dropdown/dropdown.e2e.ts @@ -1,7 +1,7 @@ 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"; +import { html } from "../../../support/formatting"; describe("calcite-dropdown", () => { it("renders", () => @@ -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 3249edb7c31..33a3acd13d1 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 opacity-disabled pointer-events-none; -} +@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..ca9cb123dfa 100644 --- a/src/components/dropdown/dropdown.stories.ts +++ b/src/components/dropdown/dropdown.stories.ts @@ -5,7 +5,7 @@ import { DefaultDropdownPlacement } from "./resources"; import readme1 from "./readme.md"; import readme2 from "../dropdown-group/readme.md"; import readme3 from "../dropdown-item/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; const placements = [ "top-start", @@ -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..42bb157ba2c 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 "../../../support/formatting"; 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 94455924d5d..cf5806fb7bc 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 @@ -61,6 +62,10 @@ export class Link { // //-------------------------------------------------------------------------- + componentDidRender(): void { + updateHostInteraction(this); + } + render(): VNode { const { download, el } = this; const dir = getElementDir(el); @@ -85,7 +90,7 @@ export class Link { const Tag = childElType; const role = childElType === "span" ? "link" : null; - const tabIndex = this.disabled ? -1 : childElType === "span" ? 0 : null; + const tabIndex = 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 472f08eb749..3fa6c466463 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..5e16eb3937c 100755 --- a/src/components/list/list.e2e.ts +++ b/src/components/list/list.e2e.ts @@ -1,5 +1,5 @@ -import { accessible, hidden, renders, focusable } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, hidden, renders, focusable, disabled } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; describe("calcite-list", () => { it("renders", async () => renders("calcite-list", { display: "block" })); @@ -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 d05a4d078bb..10351161b8f 100755 --- a/src/components/list/list.scss +++ b/src/components/list/list.scss @@ -2,6 +2,8 @@ @apply block; } +@include disabled(); + .container { @apply box-border flex diff --git a/src/components/list/list.stories.ts b/src/components/list/list.stories.ts index 1fb44527247..86701731a3f 100644 --- a/src/components/list/list.stories.ts +++ b/src/components/list/list.stories.ts @@ -1,8 +1,8 @@ -import { themesDarkDefault } from "../../../.storybook/utils"; +import { placeholderImage, themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; import itemReadme from "../list-item/readme.md"; import groupReadme from "../list-item-group/readme.md"; -import { html, placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/List", @@ -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/loader/loader.stories.ts b/src/components/loader/loader.stories.ts index 2bc05f66625..3ef94645178 100644 --- a/src/components/loader/loader.stories.ts +++ b/src/components/loader/loader.stories.ts @@ -2,7 +2,7 @@ import { number, color, select } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Loader", diff --git a/src/components/modal/modal.e2e.ts b/src/components/modal/modal.e2e.ts index 6be977f826b..09e30d7a0d7 100644 --- a/src/components/modal/modal.e2e.ts +++ b/src/components/modal/modal.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { focusable, renders, slots } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { SLOTS } from "./resources"; describe("calcite-modal properties", () => { diff --git a/src/components/modal/modal.stories.ts b/src/components/modal/modal.stories.ts index fa3915f5250..5893fbeb3bf 100644 --- a/src/components/modal/modal.stories.ts +++ b/src/components/modal/modal.stories.ts @@ -2,7 +2,7 @@ import { select, text, number } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Modal", diff --git a/src/components/notice/notice.e2e.ts b/src/components/notice/notice.e2e.ts index 517f6aaed63..585dc174c0f 100644 --- a/src/components/notice/notice.e2e.ts +++ b/src/components/notice/notice.e2e.ts @@ -1,7 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, focusable, renders, slots } from "../../tests/commonTests"; import { CSS, SLOTS } from "./resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-notice", () => { const noticeContent = ` diff --git a/src/components/notice/notice.stories.ts b/src/components/notice/notice.stories.ts index 5f8762f2cd2..5de29608ba4 100644 --- a/src/components/notice/notice.stories.ts +++ b/src/components/notice/notice.stories.ts @@ -2,7 +2,7 @@ import { select } from "@storybook/addon-knobs"; import { boolean, iconNames } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Notice", diff --git a/src/components/pagination/pagination.stories.ts b/src/components/pagination/pagination.stories.ts index 2aa07fcdab0..1ba8ec7fb4d 100644 --- a/src/components/pagination/pagination.stories.ts +++ b/src/components/pagination/pagination.stories.ts @@ -2,7 +2,7 @@ import { number, select } from "@storybook/addon-knobs"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Pagination", diff --git a/src/components/panel/panel.e2e.ts b/src/components/panel/panel.e2e.ts index 2d3f4553f41..9fb957c3715 100644 --- a/src/components/panel/panel.e2e.ts +++ b/src/components/panel/panel.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, focusable, hidden, renders, slots } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, defaults, disabled, focusable, hidden, renders, slots } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; import { CSS, SLOTS } from "./resources"; describe("calcite-panel", () => { @@ -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 f0152bf6e29..e4d408294e0 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..d18c271ff63 100644 --- a/src/components/panel/panel.stories.ts +++ b/src/components/panel/panel.stories.ts @@ -9,7 +9,7 @@ import { import { ATTRIBUTES } from "../../../.storybook/resources"; import readme from "./readme.md"; import { SLOTS, TEXT } from "./resources"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Panel", @@ -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-group/pick-list-group.e2e.ts b/src/components/pick-list-group/pick-list-group.e2e.ts index f4cb9724ba5..2e35b90199a 100644 --- a/src/components/pick-list-group/pick-list-group.e2e.ts +++ b/src/components/pick-list-group/pick-list-group.e2e.ts @@ -1,7 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { CSS, SLOTS } from "./resources"; import { accessible, defaults, renders, slots } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-pick-list-group", () => { it("renders", async () => renders("calcite-pick-list-group", { display: "block" })); diff --git a/src/components/pick-list-item/pick-list-item.e2e.ts b/src/components/pick-list-item/pick-list-item.e2e.ts index 0add40fe23b..92dc9173010 100644 --- a/src/components/pick-list-item/pick-list-item.e2e.ts +++ b/src/components/pick-list-item/pick-list-item.e2e.ts @@ -1,7 +1,7 @@ import { CSS, SLOTS } from "./resources"; -import { accessible, renders, slots } from "../../tests/commonTests"; +import { accessible, disabled, renders, slots } from "../../tests/commonTests"; import { newE2EPage } from "@stencil/core/testing"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-pick-list-item", () => { it("renders", async () => renders("calcite-pick-list-item", { display: "flex" })); @@ -28,6 +28,8 @@ describe("calcite-pick-list-item", () => { it("has slots", () => slots("calcite-pick-list-item", SLOTS)); + it("can be disabled", async () => disabled("calcite-pick-list-item")); + it("should toggle selected attribute when clicked", async () => { const page = await newE2EPage({ html: `` }); diff --git a/src/components/pick-list-item/pick-list-item.scss b/src/components/pick-list-item/pick-list-item.scss index be0670bfd1b..d3881fa3b13 100644 --- a/src/components/pick-list-item/pick-list-item.scss +++ b/src/components/pick-list-item/pick-list-item.scss @@ -97,3 +97,5 @@ .actions--start ~ .label { padding-inline-start: theme("padding.1"); } + +@include disabled(); diff --git a/src/components/pick-list-item/pick-list-item.tsx b/src/components/pick-list-item/pick-list-item.tsx index ce43e8f983e..c16e14d3a44 100644 --- a/src/components/pick-list-item/pick-list-item.tsx +++ b/src/components/pick-list-item/pick-list-item.tsx @@ -18,6 +18,7 @@ import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot actions-end - a slot for adding actions or content to the end side of the item. @@ -28,7 +29,7 @@ import { styleUrl: "pick-list-item.scss", shadow: true }) -export class PickListItem implements ConditionalSlotComponent { +export class PickListItem implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -48,7 +49,7 @@ export class PickListItem implements ConditionalSlotComponent { /** * When true, the item cannot be clicked and is visually muted. */ - @Prop({ reflect: true }) disabled? = false; + @Prop({ reflect: true }) disabled = false; /** * When false, the item cannot be deselected by user interaction. @@ -150,6 +151,10 @@ export class PickListItem implements ConditionalSlotComponent { disconnectConditionalSlotComponent(this); } + componentDidRender(): void { + updateHostInteraction(this, this.el.closest("calcite-pick-list") ? "managed" : false); + } + // -------------------------------------------------------------------------- // // Events @@ -198,10 +203,6 @@ export class PickListItem implements ConditionalSlotComponent { */ @Method() async toggleSelected(coerce?: boolean): Promise { - if (this.disabled) { - return; - } - this.selected = typeof coerce === "boolean" ? coerce : !this.selected; } diff --git a/src/components/pick-list/pick-list.e2e.ts b/src/components/pick-list/pick-list.e2e.ts index 016bc1f84d7..6d65227749f 100644 --- a/src/components/pick-list/pick-list.e2e.ts +++ b/src/components/pick-list/pick-list.e2e.ts @@ -4,12 +4,13 @@ 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 { html } from "../../../support/formatting"; import { CSS as PICK_LIST_GROUP_CSS } from "../pick-list-group/resources"; describe("calcite-pick-list", () => { @@ -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 4e0699ecedc..43a16b0d908 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 mb-1 diff --git a/src/components/pick-list/pick-list.stories.ts b/src/components/pick-list/pick-list.stories.ts index 726b4ba0055..fa5e517b368 100644 --- a/src/components/pick-list/pick-list.stories.ts +++ b/src/components/pick-list/pick-list.stories.ts @@ -9,7 +9,7 @@ import { import readme from "./readme.md"; import itemReadme from "../pick-list-item/readme.md"; import groupReadme from "../pick-list-group/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Pick List", @@ -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-logic.ts b/src/components/pick-list/shared-list-logic.ts index d505bb07765..be8b0697c4a 100644 --- a/src/components/pick-list/shared-list-logic.ts +++ b/src/components/pick-list/shared-list-logic.ts @@ -112,10 +112,10 @@ export function calciteListFocusOutHandler(this: List, event return; } - items.forEach((item) => { + filterOutDisabled(items).forEach((item) => { toggleSingleSelectItemTabbing( item, - selectedValues.size === 0 ? item.contains(event.target) || event.target === item : item.selected + selectedValues.size === 0 ? item.contains(event.target as HTMLElement) || event.target === item : item.selected ); }); } @@ -137,7 +137,7 @@ export function keyDownHandler(this: List, event: KeyboardEv event.preventDefault(); - const index = getRoundRobinIndex(currentIndex + (key === "ArrowUp" ? -1 : 1), totalItems); + const index = moveItemIndex(this, target as ListItemElement, key === "ArrowUp" ? "up" : "down"); const item = items[index]; items.forEach((i: HTMLCalcitePickListItemElement | HTMLCalciteValueListItemElement) => @@ -151,6 +151,34 @@ export function keyDownHandler(this: List, event: KeyboardEv focusElement(item); } +export function moveItemIndex( + list: List, + item: ListItemElement, + direction: "up" | "down" +): number { + const { items } = list; + const { length: totalItems } = items; + const currentIndex = (items as ListItemElement[]).indexOf(item); + const directionFactor = direction === "up" ? -1 : 1; + let moveOffset = 1; + let index = getRoundRobinIndex(currentIndex + directionFactor * moveOffset++, totalItems); + const firstMovedIndex = index; + + while (items[index].disabled) { + index = getRoundRobinIndex(currentIndex + directionFactor * moveOffset++, totalItems); + + if (index === firstMovedIndex) { + break; + } + } + + return index; +} + +function filterOutDisabled(items: ListItemElement[]): ListItemElement[] { + return items.filter((item) => !item.disabled); +} + export function internalCalciteListChangeEvent(this: List): void { this.calciteListChange.emit(this.selectedValues as any); } @@ -175,6 +203,10 @@ export function removeItem>(this: } function toggleSingleSelectItemTabbing(item: ListItemElement, selectable: boolean): void { + if (item.disabled) { + return; + } + // using attribute intentionally if (selectable) { item.removeAttribute("tabindex"); @@ -196,12 +228,13 @@ export async function setFocus(this: List, focusId: ListFocu } if (multiple) { - return items[0].setFocus(); + return filterOutDisabled(items)[0]?.setFocus(); } - const focusTarget = (items as ListItemElement[]).find((item) => item.selected) || items[0]; + const filtered = filterOutDisabled(items); + const focusTarget = filtered.find((item) => item.selected) || filtered[0]; - if (selectionFollowsFocus) { + if (selectionFollowsFocus && focusTarget) { focusTarget.selected = true; } @@ -232,7 +265,7 @@ export function setUpItems( const [first] = items; - if (!hasSelected && first) { + if (!hasSelected && first && !first.disabled) { toggleSingleSelectItemTabbing(first, true); } } 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 c6eb4e0dedb..f52ee7e1c4b 100644 --- a/src/components/pick-list/shared-list-tests.ts +++ b/src/components/pick-list/shared-list-tests.ts @@ -1,6 +1,6 @@ import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; -import { focusable } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { disabled, focusable } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; import { CSS as PICK_LIST_ITEM_CSS } from "../pick-list-item/resources"; type ListType = "pick" | "value"; @@ -23,8 +23,10 @@ export function keyboardNavigation(listType: ListType): void { const page = await newE2EPage({ html: ` + + ` }); @@ -203,14 +205,13 @@ export function keyboardNavigation(listType: ListType): void { }); it("resets tabindex to selected item when focusing out of list", async () => { - const page = await newE2EPage({ - html: ` + const page = await newE2EPage(); + await page.setContent(html` - ` - }); + `); await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); @@ -533,24 +534,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(` @@ -617,3 +601,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/popover/popover.stories.ts b/src/components/popover/popover.stories.ts index 15daa57571c..fa507bb3314 100644 --- a/src/components/popover/popover.stories.ts +++ b/src/components/popover/popover.stories.ts @@ -1,5 +1,5 @@ import { select, number, text } from "@storybook/addon-knobs"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { boolean, createSteps, stepStory, setTheme, setKnobs } from "../../../.storybook/helpers"; import readme from "./readme.md"; import managerReadme from "../popover-manager/readme.md"; diff --git a/src/components/progress/progress.stories.ts b/src/components/progress/progress.stories.ts index 8f78595b44c..7ec58e74a4b 100644 --- a/src/components/progress/progress.stories.ts +++ b/src/components/progress/progress.stories.ts @@ -2,7 +2,7 @@ import { select, number, text, boolean } from "@storybook/addon-knobs"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Progress", diff --git a/src/components/radio-button-group/radio-button-group.e2e.ts b/src/components/radio-button-group/radio-button-group.e2e.ts index 033688ea82a..d94b70271bf 100644 --- a/src/components/radio-button-group/radio-button-group.e2e.ts +++ b/src/components/radio-button-group/radio-button-group.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, hidden, reflects, renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-radio-button-group", () => { it("renders", async () => renders("calcite-radio-button-group", { display: "flex" })); diff --git a/src/components/radio-button-group/radio-button-group.stories.ts b/src/components/radio-button-group/radio-button-group.stories.ts index c9e734dc851..df010851b61 100644 --- a/src/components/radio-button-group/radio-button-group.stories.ts +++ b/src/components/radio-button-group/radio-button-group.stories.ts @@ -2,7 +2,7 @@ import { select } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Controls/Radio/Radio Button Group", 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 72b2bd167d7..b5020405e86 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 opacity-disabled cursor-default; } diff --git a/src/components/radio-button/radio-button.stories.ts b/src/components/radio-button/radio-button.stories.ts index 9057cba5519..e7bd5420e7c 100644 --- a/src/components/radio-button/radio-button.stories.ts +++ b/src/components/radio-button/radio-button.stories.ts @@ -2,7 +2,7 @@ import { select, text } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Controls/Radio/Radio Button", @@ -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..a562fda0973 100644 --- a/src/components/radio-group/radio-group.e2e.ts +++ b/src/components/radio-group/radio-group.e2e.ts @@ -1,6 +1,6 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { focusable, formAssociated, labelable, renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { disabled, focusable, formAssociated, labelable, renders } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; describe("calcite-radio-group", () => { it("renders", () => renders("calcite-radio-group", { display: "flex" })); @@ -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 868bdc46f8f..a14ddc8f9ea 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..b60a1c29adf 100644 --- a/src/components/radio-group/radio-group.stories.ts +++ b/src/components/radio-group/radio-group.stories.ts @@ -3,7 +3,7 @@ import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme1 from "./readme.md"; import readme2 from "../radio-group-item/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Controls/Radio/Radio Group", @@ -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 573ad0e83f8..48a5e7a0fdf 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..0ec5faf2f53 100644 --- a/src/components/rating/rating.stories.ts +++ b/src/components/rating/rating.stories.ts @@ -2,7 +2,7 @@ import { number, select, text } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Controls/Rating", @@ -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.stories.ts b/src/components/scrim/scrim.stories.ts index 6a47bf8c492..29e1469d24c 100644 --- a/src/components/scrim/scrim.stories.ts +++ b/src/components/scrim/scrim.stories.ts @@ -1,7 +1,7 @@ import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Scrim", diff --git a/src/components/select/select.e2e.ts b/src/components/select/select.e2e.ts index 6b3784e7ac8..dee7a5326c9 100644 --- a/src/components/select/select.e2e.ts +++ b/src/components/select/select.e2e.ts @@ -1,6 +1,6 @@ import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; -import { accessible, focusable, formAssociated, labelable, reflects, renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, disabled, focusable, formAssociated, labelable, reflects, renders } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; import { CSS } from "./resources"; describe("calcite-select", () => { @@ -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 1c7362b63e2..1706c1ebf33 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 opacity-disabled pointer-events-none select-none; -} - .icon-container { @apply border-color-input text-color-2 diff --git a/src/components/select/select.stories.ts b/src/components/select/select.stories.ts index e93d50edd26..db81f577390 100644 --- a/src/components/select/select.stories.ts +++ b/src/components/select/select.stories.ts @@ -4,7 +4,7 @@ import { Attributes, createComponentHTML as create } from "../../../.storybook/utils"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { boolean, text } from "@storybook/addon-knobs"; import selectReadme from "../select/readme.md"; import optionReadme from "../option/readme.md"; @@ -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/shell/shell.stories.ts b/src/components/shell/shell.stories.ts index ec2e1ea4e4d..a4df506226b 100644 --- a/src/components/shell/shell.stories.ts +++ b/src/components/shell/shell.stories.ts @@ -1,10 +1,15 @@ import { boolean, select } from "@storybook/addon-knobs"; -import { filterComponentAttributes, Attributes, createComponentHTML as create } from "../../../.storybook/utils"; +import { + filterComponentAttributes, + Attributes, + createComponentHTML as create, + placeholderImage +} from "../../../.storybook/utils"; import { ATTRIBUTES } from "../../../.storybook/resources"; import readme from "./readme.md"; import panelReadme from "../shell-panel/readme.md"; import centerRowReadme from "../shell-center-row/readme.md"; -import { html, placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Shell", diff --git a/src/components/slider/slider.e2e.ts b/src/components/slider/slider.e2e.ts index 5f4862cea37..71291914bca 100644 --- a/src/components/slider/slider.e2e.ts +++ b/src/components/slider/slider.e2e.ts @@ -1,6 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; -import { defaults, formAssociated, labelable, renders } from "../../tests/commonTests"; -import { getElementXY, html } from "../../tests/utils"; +import { defaults, disabled, formAssociated, labelable, renders } from "../../tests/commonTests"; +import { getElementXY } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-slider", () => { const sliderWidthFor1To1PixelValueTrack = "114px"; @@ -53,12 +54,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 95c16af78bf..6d56253095a 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 9023938c996..437055a8cfc 100644 --- a/src/components/slider/slider.stories.ts +++ b/src/components/slider/slider.stories.ts @@ -2,7 +2,7 @@ import { text, number, array, boolean as booleanFn, select } from "@storybook/ad import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Controls/Slider", @@ -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 df0d6abb51f..3a73d8245d7 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 @@ -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..d8eeb0dfcb0 100644 --- a/src/components/sortable-list/sortable-list.e2e.ts +++ b/src/components/sortable-list/sortable-list.e2e.ts @@ -1,6 +1,7 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { accessible, hidden, renders } from "../../tests/commonTests"; +import { accessible, disabled, hidden, renders } from "../../tests/commonTests"; import { dragAndDrop } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-sortable-list", () => { it("renders", async () => renders("calcite-sortable-list", { display: "flex" })); @@ -9,6 +10,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..95b85a2e632 100644 --- a/src/components/split-button/split-button.e2e.ts +++ b/src/components/split-button/split-button.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, renders, defaults } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, renders, defaults, disabled } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; import { CSS } from "./resources"; describe("calcite-split-button", () => { @@ -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 69bcb265dbe..42a481c9da9 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..1a7de6df8c4 100644 --- a/src/components/split-button/split-button.stories.ts +++ b/src/components/split-button/split-button.stories.ts @@ -2,7 +2,7 @@ import { text, select } from "@storybook/addon-knobs"; import { iconNames, boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Buttons/Split Button", @@ -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 1579baaf517..40b38a4a7ec 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 pointer-events-auto cursor-not-allowed; -} +@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.e2e.ts b/src/components/stepper/stepper.e2e.ts index 425d5c56d39..8ddadcf2fe6 100644 --- a/src/components/stepper/stepper.e2e.ts +++ b/src/components/stepper/stepper.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; // todo test the automatic setting of first item to active describe("calcite-stepper", () => { diff --git a/src/components/stepper/stepper.stories.ts b/src/components/stepper/stepper.stories.ts index 48ee5c5b89d..cd8320b9061 100644 --- a/src/components/stepper/stepper.stories.ts +++ b/src/components/stepper/stepper.stories.ts @@ -3,7 +3,7 @@ import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme1 from "./readme.md"; import readme2 from "../stepper-item/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Stepper", @@ -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 b4a5d902837..1448a039f9e 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..0dee1341c6d 100644 --- a/src/components/switch/switch.stories.ts +++ b/src/components/switch/switch.stories.ts @@ -1,7 +1,7 @@ import { select } from "@storybook/addon-knobs"; import { boolean } from "../../../.storybook/helpers"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { themesDarkDefault } from "../../../.storybook/utils"; export default { @@ -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 c8088480083..ec9053ff1da 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 bc756dcdac9..5231d735e1f 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 diff --git a/src/components/tabs/tabs.e2e.ts b/src/components/tabs/tabs.e2e.ts index 16d9ed1d5f4..41adaa317e6 100644 --- a/src/components/tabs/tabs.e2e.ts +++ b/src/components/tabs/tabs.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, renders, defaults } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-tabs", () => { const tabsContent = ` @@ -107,30 +107,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..18158f1e682 100644 --- a/src/components/tabs/tabs.stories.ts +++ b/src/components/tabs/tabs.stories.ts @@ -1,11 +1,11 @@ import { select, optionsKnob } from "@storybook/addon-knobs"; import { createSteps, iconNames, stepStory } from "../../../.storybook/helpers"; -import { themesDarkDefault } from "../../../.storybook/utils"; +import { placeholderImage, themesDarkDefault } from "../../../.storybook/utils"; import readme1 from "./readme.md"; import readme2 from "../tab/readme.md"; import readme3 from "../tab-nav/readme.md"; import readme4 from "../tab-title/readme.md"; -import { html, placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Tabs", @@ -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..1ab04860fde 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 "../../../support/formatting"; 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..7d3784e626f 100644 --- a/src/components/tile-select-group/tile-select-group.stories.ts +++ b/src/components/tile-select-group/tile-select-group.stories.ts @@ -1,7 +1,7 @@ import { select } from "@storybook/addon-knobs"; import { themesDarkDefault } from "../../../.storybook/utils"; import { boolean } from "@storybook/addon-knobs"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import readme from "./readme.md"; export default { @@ -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..fbed6977bab 100644 --- a/src/components/tile-select/tile-select.e2e.ts +++ b/src/components/tile-select/tile-select.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, focusable, hidden, reflects, renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { accessible, defaults, disabled, focusable, hidden, reflects, renders } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; describe("calcite-tile-select", () => { it("renders", async () => renders("calcite-tile-select", { display: "block" })); @@ -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 355c5e773b9..9bf2f250e20 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.stories.ts b/src/components/tile-select/tile-select.stories.ts index 45e2027724f..c14598d7343 100644 --- a/src/components/tile-select/tile-select.stories.ts +++ b/src/components/tile-select/tile-select.stories.ts @@ -8,7 +8,7 @@ import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { createSteps, stepStory, toggleCentered } from "../../../.storybook/helpers"; export default { 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 9d2ea839215..416ae93bdee 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..9492f4275c9 100644 --- a/src/components/tile/tile.stories.ts +++ b/src/components/tile/tile.stories.ts @@ -2,7 +2,7 @@ import { select, text } from "@storybook/addon-knobs"; import { iconNames, boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Tiles/Tile", @@ -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.stories.ts b/src/components/tip-manager/tip-manager.stories.ts index 518596ad877..bd9386d98a2 100644 --- a/src/components/tip-manager/tip-manager.stories.ts +++ b/src/components/tip-manager/tip-manager.stories.ts @@ -8,7 +8,8 @@ import { } from "../../../.storybook/utils"; import readme from "./readme.md"; import { TEXT } from "./resources"; -import { html, placeholderImage } from "../../tests/utils"; +import { html } from "../../../support/formatting"; +import { placeholderImage } from "../../../.storybook/utils"; export default { title: "Components/Tips/Tip Manager", diff --git a/src/components/tip/tip.stories.ts b/src/components/tip/tip.stories.ts index c6783eba9f5..fa76367cdbd 100644 --- a/src/components/tip/tip.stories.ts +++ b/src/components/tip/tip.stories.ts @@ -9,7 +9,7 @@ import { import readme from "./readme.md"; import groupReadme from "../tip-group/readme.md"; import { TEXT } from "./resources"; -import { placeholderImage } from "../../tests/utils"; +import { placeholderImage } from "../../../.storybook/utils"; export default { title: "Components/Tips/Tip", diff --git a/src/components/tooltip-manager/tooltip-manager.e2e.ts b/src/components/tooltip-manager/tooltip-manager.e2e.ts index 1ebf53a1a0b..972901f78c4 100644 --- a/src/components/tooltip-manager/tooltip-manager.e2e.ts +++ b/src/components/tooltip-manager/tooltip-manager.e2e.ts @@ -1,7 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { TOOLTIP_REFERENCE, TOOLTIP_DELAY_MS } from "../tooltip/resources"; import { accessible, defaults, hidden, renders } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-tooltip-manager", () => { it("renders", async () => renders(``, { display: "block" })); diff --git a/src/components/tooltip/tooltip.stories.ts b/src/components/tooltip/tooltip.stories.ts index ffa6434784e..a4e9c158dee 100644 --- a/src/components/tooltip/tooltip.stories.ts +++ b/src/components/tooltip/tooltip.stories.ts @@ -1,6 +1,6 @@ import { select, number } from "@storybook/addon-knobs"; import readme from "./readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { boolean, createSteps, stepStory, setTheme } from "../../../.storybook/helpers"; const placements = [ diff --git a/src/components/tree-item/tree-item.e2e.ts b/src/components/tree-item/tree-item.e2e.ts index d8938e5fcb2..9e3ba540794 100644 --- a/src/components/tree-item/tree-item.e2e.ts +++ b/src/components/tree-item/tree-item.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, renders, defaults, slots } from "../../tests/commonTests"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { SLOTS } from "./resources"; describe("calcite-tree-item", () => { diff --git a/src/components/tree/tree.e2e.ts b/src/components/tree/tree.e2e.ts index cac222b4a15..e96b427de04 100644 --- a/src/components/tree/tree.e2e.ts +++ b/src/components/tree/tree.e2e.ts @@ -1,6 +1,7 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, renders, defaults } from "../../tests/commonTests"; -import { GlobalTestProps, html } from "../../tests/utils"; +import { GlobalTestProps } from "../../tests/utils"; +import { html } from "../../../support/formatting"; import { CSS } from "../tree-item/resources"; import { TreeSelectionMode } from "./interfaces"; diff --git a/src/components/tree/tree.stories.ts b/src/components/tree/tree.stories.ts index 9826314f067..4a27c14ef35 100644 --- a/src/components/tree/tree.stories.ts +++ b/src/components/tree/tree.stories.ts @@ -3,7 +3,7 @@ import { boolean } from "../../../.storybook/helpers"; import { themesDarkDefault } from "../../../.storybook/utils"; import readme from "./readme.md"; import treeItemReadme from "../tree-item/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; const treeItems = ` diff --git a/src/components/value-list-item/value-list-item.e2e.ts b/src/components/value-list-item/value-list-item.e2e.ts index a786344e6fa..4ab97c5116f 100644 --- a/src/components/value-list-item/value-list-item.e2e.ts +++ b/src/components/value-list-item/value-list-item.e2e.ts @@ -1,7 +1,7 @@ import { CSS as PICK_LIST_ITEM_CSS, SLOTS } from "../pick-list-item/resources"; -import { accessible, focusable, renders, slots } from "../../tests/commonTests"; +import { accessible, disabled, focusable, renders, slots } from "../../tests/commonTests"; import { E2EPage, newE2EPage } from "@stencil/core/testing"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-value-list-item", () => { it("renders", async () => renders("calcite-value-list-item", { display: "flex" })); @@ -30,6 +30,8 @@ describe("calcite-value-list-item", () => { it("is focusable", async () => focusable("calcite-value-list-item")); + it("can be disabled", async () => disabled("calcite-value-list-item")); + it("should toggle selected attribute when clicked", async () => { const page = await newE2EPage({ html: `` }); diff --git a/src/components/value-list-item/value-list-item.scss b/src/components/value-list-item/value-list-item.scss index 10dad4137a1..1d46ddac4a1 100644 --- a/src/components/value-list-item/value-list-item.scss +++ b/src/components/value-list-item/value-list-item.scss @@ -49,3 +49,5 @@ calcite-pick-list-item { color: inherit; } } + +@include disabled(); diff --git a/src/components/value-list-item/value-list-item.tsx b/src/components/value-list-item/value-list-item.tsx index 0552e74a94f..c7c84483964 100644 --- a/src/components/value-list-item/value-list-item.tsx +++ b/src/components/value-list-item/value-list-item.tsx @@ -21,6 +21,7 @@ import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot actions-end - A slot for adding actions or content to the end side of the item. @@ -31,7 +32,7 @@ import { styleUrl: "value-list-item.scss", shadow: true }) -export class ValueListItem implements ConditionalSlotComponent { +export class ValueListItem implements ConditionalSlotComponent, InteractiveComponent { // -------------------------------------------------------------------------- // // Properties @@ -46,7 +47,7 @@ export class ValueListItem implements ConditionalSlotComponent { /** * When true, the item cannot be clicked and is visually muted */ - @Prop({ reflect: true }) disabled? = false; + @Prop({ reflect: true }) disabled = false; /** * @internal When false, the item cannot be deselected by user interaction. @@ -120,6 +121,10 @@ export class ValueListItem implements ConditionalSlotComponent { disconnectConditionalSlotComponent(this); } + componentDidRender(): void { + updateHostInteraction(this, this.el.closest("calcite-value-list") ? "managed" : false); + } + // -------------------------------------------------------------------------- // // Public Methods diff --git a/src/components/value-list/value-list.e2e.ts b/src/components/value-list/value-list.e2e.ts index 975a6e45eb4..d2f26eaedd0 100644 --- a/src/components/value-list/value-list.e2e.ts +++ b/src/components/value-list/value-list.e2e.ts @@ -4,12 +4,14 @@ 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"; +import { dragAndDrop } from "../../tests/utils"; +import { html } from "../../../support/formatting"; describe("calcite-value-list", () => { it("renders", () => renders("calcite-value-list", { display: "flex" })); @@ -23,6 +25,10 @@ describe("calcite-value-list", () => { `)); + describe("disabling", () => { + disabling("value"); + }); + describe("Selection and Deselection", () => { selectionAndDeselection("value"); }); @@ -64,8 +70,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 48f8759b2fb..46e9fe2c229 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..2b4ef490ee2 100644 --- a/src/components/value-list/value-list.stories.ts +++ b/src/components/value-list/value-list.stories.ts @@ -8,7 +8,7 @@ import { } from "../../../.storybook/utils"; import readme from "./readme.md"; import itemReadme from "../value-list-item/readme.md"; -import { html } from "../../tests/utils"; +import { html } from "../../../support/formatting"; export default { title: "Components/Value List", @@ -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 ff069485f99..ca757561710 100644 --- a/src/components/value-list/value-list.tsx +++ b/src/components/value-list/value-list.tsx @@ -30,11 +30,12 @@ import { removeItem, selectSiblings, setFocus, - setUpItems + setUpItems, + moveItemIndex } from "../pick-list/shared-list-logic"; 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. @@ -47,7 +48,8 @@ import { createObserver } from "../../utils/observers"; }) export class ValueList< ItemElement extends HTMLCalciteValueListItemElement = HTMLCalciteValueListItemElement -> { +> implements InteractiveComponent +{ // -------------------------------------------------------------------------- // // Properties @@ -138,6 +140,10 @@ export class ValueList< this.setUpDragAndDrop(); } + componentDidRender(): void { + updateHostInteraction(this); + } + disconnectedCallback(): void { cleanUpObserver.call(this); this.cleanUpDragAndDrop(); @@ -272,9 +278,7 @@ export class ValueList< event.preventDefault(); const { el } = this; - const moveOffset = event.key === "ArrowDown" ? 1 : -1; - const currentIndex = items.indexOf(item); - const nextIndex = getRoundRobinIndex(currentIndex + moveOffset, items.length); + const nextIndex = moveItemIndex(this, item, event.key === "ArrowUp" ? "up" : "down"); if (nextIndex === items.length - 1) { el.appendChild(item); diff --git a/src/tests/commonTests.ts b/src/tests/commonTests.ts index 6713b4aaf8a..5fe8a8b061c 100644 --- a/src/tests/commonTests.ts +++ b/src/tests/commonTests.ts @@ -3,8 +3,9 @@ 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, waitForAnimationFrame } from "./utils"; import { hiddenFormInputSlotName } from "../utils/form"; +import { html } from "../../support/formatting"; expect.extend(toHaveNoViolations); @@ -12,6 +13,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 +522,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/globalStyles.e2e.ts b/src/tests/globalStyles.e2e.ts index 33101c07598..01d54e3df8e 100644 --- a/src/tests/globalStyles.e2e.ts +++ b/src/tests/globalStyles.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { html } from "./utils"; +import { html } from "../../support/formatting"; describe("global styles", () => { describe("animation", () => { const snippet = ` diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 4ac23a7d7cb..e8b8d043aa3 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -1,6 +1,5 @@ -import { E2EElement, E2EPage } from "@stencil/core/testing"; +import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; import { BoundingBox, JSONObject } from "puppeteer"; -import dedent from "dedent"; /** * Util to help type global props for testing. @@ -116,105 +115,6 @@ export function selectText(input: E2EElement): Promise { return input.click({ clickCount: 3 }); } -/** - * Use this tagged template to help Prettier format any HTML template literals. - * @param strings the - * - * @example - * - * ```ts - * const page = await newE2EPage({ - * html: html` - * - * uno - * dos - * tres - * - * ` - * }); - * ``` - */ -export function html(strings: string): string; -export function html(strings: TemplateStringsArray, ...placeholders: any[]): string; -export function html(strings: any, ...placeholders: any[]): string { - return dedent(strings, ...placeholders); -} - -/* -MIT License - -Copyright (c) 2020 Cloud Four - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -https://github.com/cloudfour/simple-svg-placeholder -*/ - -interface SimpleSvgPlaceholderParams { - width?: number; - height?: number; - text?: string; - fontFamily?: string; - fontWeight?: string; - fontSize?: number; - dy?: number; - bgColor?: string; - textColor?: string; - dataUri?: boolean; - charset?: string; -} - -export function placeholderImage({ - width = 300, - height = 150, - text = `${width}×${height}`, - fontFamily = "sans-serif", - fontWeight = "bold", - fontSize = Math.floor(Math.min(width, height) * 0.2), - dy = fontSize * 0.35, - bgColor = "#ddd", - textColor = "rgba(0,0,0,0.5)", - dataUri = true, - charset = "UTF-8" -}: SimpleSvgPlaceholderParams): string { - const str = ` - - ${text} - `; - - // Thanks to: filamentgroup/directory-encoder - const cleaned = str - .replace(/[\t\n\r]/gim, "") // Strip newlines and tabs - .replace(/\s\s+/g, " ") // Condense multiple spaces - .replace(/'/gim, "\\i"); // Normalize quotes - - if (dataUri) { - const encoded = encodeURIComponent(cleaned) - .replace(/\(/g, "%28") // Encode brackets - .replace(/\)/g, "%29"); - - return `data:image/svg+xml;charset=${charset},${encoded}`; - } - - return cleaned; -} - /** * Helper to get an E2EElement's x,y coordinates * @param page @@ -328,3 +228,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/dom.spec.ts b/src/utils/dom.spec.ts index 52ceb1040a9..a5364cefd39 100644 --- a/src/utils/dom.spec.ts +++ b/src/utils/dom.spec.ts @@ -1,6 +1,6 @@ import { getElementProp, getSlotted, setRequestedIcon, ensureId, getThemeName } from "./dom"; import { guidPattern } from "./guid.spec"; -import { html } from "../tests/utils"; +import { html } from "../../support/formatting"; import { ThemeName } from "../../src/components/interfaces"; describe("dom", () => { 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..5a8a07a812b --- /dev/null +++ b/src/utils/interactive.ts @@ -0,0 +1,64 @@ +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 | "managed" = 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 if (hostIsTabbable === false) { + component.el.removeAttribute("tabindex"); + } else { + // noop for "managed" as owning component will manage its tab index + } + + component.el.removeAttribute("aria-disabled"); +} diff --git a/support/formatting.ts b/support/formatting.ts new file mode 100644 index 00000000000..5309cd032f1 --- /dev/null +++ b/support/formatting.ts @@ -0,0 +1,37 @@ +import dedent from "dedent"; + +/** + * Use this tagged template to help Prettier format any HTML template literals. + * @param strings the + * + * @example + * + * ```ts + * // select.e2e.ts + * const page = await newE2EPage({ + * html: html` + * + * uno + * dos + * tres + * + * ` + * }); + * ``` + * + * ```ts + * icon.stories.ts + * export const simple = (): string => html` + * + * uno + * dos + * tres + * + * `; + * ``` + */ +export function html(strings: string): string; +export function html(strings: TemplateStringsArray, ...placeholders: any[]): string; +export function html(strings: any, ...placeholders: any[]): string { + return dedent(strings, ...placeholders); +}