diff --git a/packages/calcite-components/src/components.d.ts b/packages/calcite-components/src/components.d.ts index d4b59e7feb8..509a35b3259 100644 --- a/packages/calcite-components/src/components.d.ts +++ b/packages/calcite-components/src/components.d.ts @@ -5,7 +5,7 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { Alignment, Appearance, Columns, FlipContext, Kind, Layout, LogicalFlowPosition, Position, Scale, SelectionMode, Status, Width } from "./components/interfaces"; +import { Alignment, Appearance, Columns, FlipContext, Kind, Layout, LogicalFlowPosition, Position, Scale, SelectionAppearance as SelectionAppearance1, SelectionMode, Status, Width } from "./components/interfaces"; import { RequestedItem } from "./components/accordion/interfaces"; import { RequestedItem as RequestedItem1 } from "./components/accordion-item/interfaces"; import { ActionMessages } from "./components/action/assets/action/t9n"; @@ -94,7 +94,7 @@ import { TipManagerMessages } from "./components/tip-manager/assets/tip-manager/ import { TreeItemSelectDetail } from "./components/tree-item/interfaces"; import { ValueListMessages } from "./components/value-list/assets/value-list/t9n"; import { ListItemAndHandle } from "./components/value-list-item/interfaces"; -export { Alignment, Appearance, Columns, FlipContext, Kind, Layout, LogicalFlowPosition, Position, Scale, SelectionMode, Status, Width } from "./components/interfaces"; +export { Alignment, Appearance, Columns, FlipContext, Kind, Layout, LogicalFlowPosition, Position, Scale, SelectionAppearance as SelectionAppearance1, SelectionMode, Status, Width } from "./components/interfaces"; export { RequestedItem } from "./components/accordion/interfaces"; export { RequestedItem as RequestedItem1 } from "./components/accordion-item/interfaces"; export { ActionMessages } from "./components/action/assets/action/t9n"; @@ -5071,6 +5071,7 @@ export namespace Components { interface CalciteTile { /** * When `true`, the component is active. + * @deprecated */ "active": boolean; /** @@ -5090,10 +5091,6 @@ export namespace Components { * @deprecated No longer necessary. */ "embed": boolean; - /** - * The focused state of the component. - */ - "focused": boolean; /** * The component header text, which displays between the icon and description. */ @@ -5110,16 +5107,51 @@ export namespace Components { * When `true`, the icon will be flipped when the element direction is right-to-left (`"rtl"`). */ "iconFlipRtl": boolean; + /** + * When true, enables the tile to be focused, and allows the `calciteTileSelect` to emit. This is set to `true` by a parent Tile Group component. + */ + "interactive": boolean; + /** + * Accessible name for the component. + */ + "label": string; + /** + * Defines the layout of the component. Use `"horizontal"` for rows, and `"vertical"` for a single column. + */ + "layout": Exclude; /** * Specifies the size of the component. */ "scale": Scale; + /** + * When `true` and the parent's `selectionMode` is `"single"`, `"single-persist"', or `"multiple"`, the component is selected. + */ + "selected": boolean; + /** + * Specifies the selection appearance, where: - `"icon"` (displays a checkmark or dot), or - `"border"` (displays a border). This property is set by the parent tile-group. + */ + "selectionAppearance": SelectionAppearance1; + /** + * Specifies the selection mode, where: - `"multiple"` (allows any number of selected items), - `"single"` (allows only one selected item), - `"single-persist"` (allows only one selected item and prevents de-selection), - `"none"` (allows no selected items). This property is set by the parent tile-group. + */ + "selectionMode": Extract< + "multiple" | "none" | "single" | "single-persist", + SelectionMode + >; + /** + * Sets focus on the component. + */ + "setFocus": () => Promise; } interface CalciteTileGroup { /** * When `true`, interaction is prevented and the component is displayed with lower opacity. */ "disabled": boolean; + /** + * Accessible name for the component. + */ + "label": string; /** * Defines the layout of the component. Use `"horizontal"` for rows, and `"vertical"` for a single column. */ @@ -5128,6 +5160,22 @@ export namespace Components { * Specifies the size of the component. */ "scale": Scale; + /** + * Specifies the component's selected items. + * @readonly + */ + "selectedItems": HTMLCalciteTileElement[]; + /** + * Specifies the selection appearance, where: - `"icon"` (displays a checkmark or dot), or - `"border"` (displays a border). + */ + "selectionAppearance": SelectionAppearance1; + /** + * Specifies the selection mode, where: - `"multiple"` (allows any number of selected items), - `"single"` (allows only one selected item), - `"single-persist"` (allows only one selected item and prevents de-selection), - `"none"` (allows no selected items). + */ + "selectionMode": Extract< + "multiple" | "none" | "single" | "single-persist", + SelectionMode + >; } interface CalciteTileSelect { /** @@ -5793,6 +5841,14 @@ export interface CalciteTextAreaCustomEvent extends CustomEvent { detail: T; target: HTMLCalciteTextAreaElement; } +export interface CalciteTileCustomEvent extends CustomEvent { + detail: T; + target: HTMLCalciteTileElement; +} +export interface CalciteTileGroupCustomEvent extends CustomEvent { + detail: T; + target: HTMLCalciteTileGroupElement; +} export interface CalciteTileSelectCustomEvent extends CustomEvent { detail: T; target: HTMLCalciteTileSelectElement; @@ -7333,13 +7389,36 @@ declare global { prototype: HTMLCalciteTextAreaElement; new (): HTMLCalciteTextAreaElement; }; + interface HTMLCalciteTileElementEventMap { + "calciteInternalTileKeyEvent": KeyboardEvent; + "calciteTileSelect": void; + } interface HTMLCalciteTileElement extends Components.CalciteTile, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLCalciteTileElement, ev: CalciteTileCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLCalciteTileElement, ev: CalciteTileCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; } var HTMLCalciteTileElement: { prototype: HTMLCalciteTileElement; new (): HTMLCalciteTileElement; }; + interface HTMLCalciteTileGroupElementEventMap { + "calciteTileGroupSelect": void; + } interface HTMLCalciteTileGroupElement extends Components.CalciteTileGroup, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLCalciteTileGroupElement, ev: CalciteTileGroupCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLCalciteTileGroupElement, ev: CalciteTileGroupCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; } var HTMLCalciteTileGroupElement: { prototype: HTMLCalciteTileGroupElement; @@ -12777,6 +12856,7 @@ declare namespace LocalJSX { interface CalciteTile { /** * When `true`, the component is active. + * @deprecated */ "active"?: boolean; /** @@ -12796,10 +12876,6 @@ declare namespace LocalJSX { * @deprecated No longer necessary. */ "embed"?: boolean; - /** - * The focused state of the component. - */ - "focused"?: boolean; /** * The component header text, which displays between the icon and description. */ @@ -12816,24 +12892,80 @@ declare namespace LocalJSX { * When `true`, the icon will be flipped when the element direction is right-to-left (`"rtl"`). */ "iconFlipRtl"?: boolean; + /** + * When true, enables the tile to be focused, and allows the `calciteTileSelect` to emit. This is set to `true` by a parent Tile Group component. + */ + "interactive"?: boolean; + /** + * Accessible name for the component. + */ + "label"?: string; + /** + * Defines the layout of the component. Use `"horizontal"` for rows, and `"vertical"` for a single column. + */ + "layout"?: Exclude; + "onCalciteInternalTileKeyEvent"?: (event: CalciteTileCustomEvent) => void; + /** + * Fires when the selected state of the component changes. + */ + "onCalciteTileSelect"?: (event: CalciteTileCustomEvent) => void; /** * Specifies the size of the component. */ "scale"?: Scale; + /** + * When `true` and the parent's `selectionMode` is `"single"`, `"single-persist"', or `"multiple"`, the component is selected. + */ + "selected"?: boolean; + /** + * Specifies the selection appearance, where: - `"icon"` (displays a checkmark or dot), or - `"border"` (displays a border). This property is set by the parent tile-group. + */ + "selectionAppearance"?: SelectionAppearance1; + /** + * Specifies the selection mode, where: - `"multiple"` (allows any number of selected items), - `"single"` (allows only one selected item), - `"single-persist"` (allows only one selected item and prevents de-selection), - `"none"` (allows no selected items). This property is set by the parent tile-group. + */ + "selectionMode"?: Extract< + "multiple" | "none" | "single" | "single-persist", + SelectionMode + >; } interface CalciteTileGroup { /** * When `true`, interaction is prevented and the component is displayed with lower opacity. */ "disabled"?: boolean; + /** + * Accessible name for the component. + */ + "label": string; /** * Defines the layout of the component. Use `"horizontal"` for rows, and `"vertical"` for a single column. */ "layout"?: Exclude; + /** + * Fires when the component's selection changes. + */ + "onCalciteTileGroupSelect"?: (event: CalciteTileGroupCustomEvent) => void; /** * Specifies the size of the component. */ "scale"?: Scale; + /** + * Specifies the component's selected items. + * @readonly + */ + "selectedItems"?: HTMLCalciteTileElement[]; + /** + * Specifies the selection appearance, where: - `"icon"` (displays a checkmark or dot), or - `"border"` (displays a border). + */ + "selectionAppearance"?: SelectionAppearance1; + /** + * Specifies the selection mode, where: - `"multiple"` (allows any number of selected items), - `"single"` (allows only one selected item), - `"single-persist"` (allows only one selected item and prevents de-selection), - `"none"` (allows no selected items). + */ + "selectionMode"?: Extract< + "multiple" | "none" | "single" | "single-persist", + SelectionMode + >; } interface CalciteTileSelect { /** diff --git a/packages/calcite-components/src/components/interfaces.ts b/packages/calcite-components/src/components/interfaces.ts index d2c87069134..32f257ae8f6 100644 --- a/packages/calcite-components/src/components/interfaces.ts +++ b/packages/calcite-components/src/components/interfaces.ts @@ -9,6 +9,7 @@ export type LogicalFlowPosition = "inline-start" | "inline-end" | "block-start" export type ModeClass = "calcite-mode-light" | "calcite-mode-dark" | "calcite-mode-auto"; export type ModeName = "light" | "dark" | "auto"; export type Position = "start" | "end"; +export type SelectionAppearance = "border" | "icon"; export type SelectionMode = | "single" | "none" diff --git a/packages/calcite-components/src/components/tile-group/tile-group.e2e.ts b/packages/calcite-components/src/components/tile-group/tile-group.e2e.ts index 54b8899955c..9900feba1ab 100644 --- a/packages/calcite-components/src/components/tile-group/tile-group.e2e.ts +++ b/packages/calcite-components/src/components/tile-group/tile-group.e2e.ts @@ -1,69 +1,185 @@ import { newE2EPage } from "@stencil/core/testing"; import { accessible, defaults, disabled, reflects, renders, hidden } from "../../tests/commonTests"; import { html } from "../../../support/formatting"; +import { assertSelectedItems, isElementFocused } from "../../tests/utils"; describe("calcite-tile-group", () => { - describe("renders", () => { - renders("calcite-tile-group", { display: "inline-block" }); - }); - - describe("honors hidden attribute.", () => { - hidden("calcite-tile-group"); - }); - describe("accessible", () => { - accessible(``); + describe("accessible in selection-mode none", () => { + accessible(html` + + + + + `); + }); + describe("accessible in selection-mode single", () => { + accessible(html` + + + + + `); + }); + describe("accessible in selection-mode single-persist", () => { + accessible(html` + + + + + `); + }); + describe("accessible in selection-mode multiple", () => { + accessible(html` + + + + + `); + }); + describe("accessible as links", () => { + accessible(html` + + + + + `); + }); }); describe("defaults", () => { defaults("calcite-tile-group", [ { propertyName: "layout", defaultValue: "horizontal" }, { propertyName: "scale", defaultValue: "m" }, + { propertyName: "selectionAppearance", defaultValue: "icon" }, + { propertyName: "selectionMode", defaultValue: "none" }, ]); - }); - describe("reflects", () => { - reflects("calcite-tile-group", [ - { propertyName: "layout", value: "horizontal" }, - { propertyName: "scale", value: "m" }, - ]); + it("selectedItems property is set correctly at load when tiles include the selected attribute in initial HTML", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + + + `); + const item4 = await page.find("#item-4"); + const item5 = await page.find("#item-5"); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item4.id, item5.id] }); + }); }); describe("disabled", () => { disabled( - html` + html` `, - { focusTarget: "none" }, + { focusTarget: "child" }, ); }); - describe("disabled with link tiles", () => { - disabled( - html` - - - - `, - { focusTarget: "child" }, - ); + describe("hidden", () => { + hidden("calcite-tile-group"); + }); + + describe("reflects", () => { + reflects("calcite-tile-group", [ + { propertyName: "layout", value: "horizontal" }, + { propertyName: "scale", value: "m" }, + { propertyName: "selectionAppearance", value: "icon" }, + { propertyName: "selectionMode", value: "none" }, + ]); + }); + + describe("renders", () => { + renders("calcite-tile-group", { display: "inline-block" }); + }); + + describe("keyboard", () => { + it("focuses tiles with the tab key and arrow keys and allows selection with the enter and space key", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + + + `); + await assertSelectedItems.setUpEvents("calciteTileGroupSelect", page); + + const element = await page.find("calcite-tile-group"); + const groupSelectSpy = await element.spyOnEvent("calciteTileGroupSelect"); + const item1 = await page.find("#item-1"); + const item4 = await page.find("#item-4"); + const item5 = await page.find("#item-5"); + + await item1.click(); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-1")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id] }); + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-2")).toBe(true); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + + await page.keyboard.press("End"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-5")).toBe(true); + + await page.keyboard.press("Space"); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(2); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id, item5.id] }); + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-4")).toBe(true); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(3); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id, item4.id, item5.id] }); + await page.keyboard.press("Space"); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(4); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id, item5.id] }); + await page.keyboard.press("Home"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-1")).toBe(true); + + await page.keyboard.press("ArrowLeft"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-5")).toBe(true); + + await page.keyboard.press("ArrowRight"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-1")).toBe(true); + }); }); describe("prop passing", () => { @@ -93,4 +209,245 @@ describe("calcite-tile-group", () => { }); }); }); + + describe("selection modes", () => { + it("none selection mode (default) allows no item to be selected", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + `); + await page.waitForChanges(); + await assertSelectedItems.setUpEvents("calciteTileGroupSelect", page); + + const element = await page.find("calcite-tile-group"); + const item1 = await page.find("#item-1"); + const item2 = await page.find("#item-2"); + const itemGroupSelectSpy = await element.spyOnEvent("calciteTileGroupSelect"); + + expect(itemGroupSelectSpy).toHaveReceivedEventTimes(0); + expect(await element.getProperty("selectedItems")).toEqual([]); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + await item1.click(); + await page.waitForChanges(); + + expect(itemGroupSelectSpy).toHaveReceivedEventTimes(1); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + await item2.click(); + await page.waitForChanges(); + + expect(itemGroupSelectSpy).toHaveReceivedEventTimes(2); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + await item2.click(); + await page.waitForChanges(); + + expect(itemGroupSelectSpy).toHaveReceivedEventTimes(3); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + }); + + it("single selection-mode allows only 1 item to be selected and allows deselecting", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + `); + await page.waitForChanges(); + await assertSelectedItems.setUpEvents("calciteTileSelect", page); + + const element = await page.find("calcite-tile-group"); + const item1 = await page.find("#item-1"); + const item2 = await page.find("#item-2"); + const groupSelectSpy = await element.spyOnEvent("calciteTileGroupSelect"); + const tileSelectSpy1 = await item1.spyOnEvent("calciteTileSelect"); + const tileSelectSpy2 = await item2.spyOnEvent("calciteTileSelect"); + + expect(groupSelectSpy).toHaveReceivedEventTimes(0); + expect(tileSelectSpy1).toHaveReceivedEventTimes(0); + expect(tileSelectSpy2).toHaveReceivedEventTimes(0); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item2.id] }); + await item1.click(); + await page.waitForChanges(); + + expect(await groupSelectSpy).toHaveReceivedEventTimes(1); + expect(await tileSelectSpy1).toHaveReceivedEventTimes(1); + expect(await tileSelectSpy2).toHaveReceivedEventTimes(0); + expect(await item1.getProperty("selected")).toBe(true); + expect(await item2.getProperty("selected")).toBe(false); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id] }); + await item2.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(2); + expect(tileSelectSpy1).toHaveReceivedEventTimes(1); + expect(tileSelectSpy2).toHaveReceivedEventTimes(1); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item2.id] }); + await item2.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(3); + expect(tileSelectSpy1).toHaveReceivedEventTimes(1); + expect(tileSelectSpy2).toHaveReceivedEventTimes(2); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(false); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + }); + + it("single-persist selection-mode allows only 1 item to be selected and disallows deselecting", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + `); + await page.waitForChanges(); + await assertSelectedItems.setUpEvents("calciteTileSelect", page); + + const element = await page.find("calcite-tile-group"); + const item1 = await page.find("#item-1"); + const item2 = await page.find("#item-2"); + const groupSelectSpy = await element.spyOnEvent("calciteTileGroupSelect"); + const tileSelectSpy1 = await item1.spyOnEvent("calciteTileSelect"); + const tileSelectSpy2 = await item2.spyOnEvent("calciteTileSelect"); + + expect(groupSelectSpy).toHaveReceivedEventTimes(0); + expect(tileSelectSpy1).toHaveReceivedEventTimes(0); + expect(tileSelectSpy2).toHaveReceivedEventTimes(0); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item2.id] }); + await item1.click(); + await page.waitForChanges(); + + expect(await groupSelectSpy).toHaveReceivedEventTimes(1); + expect(await tileSelectSpy1).toHaveReceivedEventTimes(1); + expect(await tileSelectSpy2).toHaveReceivedEventTimes(0); + expect(await item1.getProperty("selected")).toBe(true); + expect(await item2.getProperty("selected")).toBe(false); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id] }); + await item2.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(2); + expect(tileSelectSpy1).toHaveReceivedEventTimes(1); + expect(tileSelectSpy2).toHaveReceivedEventTimes(1); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item2.id] }); + await item2.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(2); + expect(tileSelectSpy1).toHaveReceivedEventTimes(1); + expect(tileSelectSpy2).toHaveReceivedEventTimes(1); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item2.id] }); + }); + + it("multiple selection-mode allows multiple items to be selected and allows deselecting", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + `); + await page.waitForChanges(); + await assertSelectedItems.setUpEvents("calciteTileSelect", page); + + const element = await page.find("calcite-tile-group"); + const item1 = await page.find("#item-1"); + const item2 = await page.find("#item-2"); + const item3 = await page.find("#item-3"); + const groupSelectSpy = await element.spyOnEvent("calciteTileGroupSelect"); + + expect(groupSelectSpy).toHaveReceivedEventTimes(0); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + await item1.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(1); + expect(await item1.getProperty("selected")).toBe(true); + expect(await item2.getProperty("selected")).toBe(false); + expect(await item3.getProperty("selected")).toBe(false); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id] }); + await item2.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(2); + expect(await item1.getProperty("selected")).toBe(true); + expect(await item2.getProperty("selected")).toBe(true); + expect(await item3.getProperty("selected")).toBe(false); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id, item2.id] }); + await item3.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(3); + expect(await item1.getProperty("selected")).toBe(true); + expect(await item2.getProperty("selected")).toBe(true); + expect(await item3.getProperty("selected")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item1.id, item2.id, item3.id] }); + await item1.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(4); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(true); + expect(await item3.getProperty("selected")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item2.id, item3.id] }); + await item2.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(5); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(false); + expect(await item3.getProperty("selected")).toBe(true); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [item3.id] }); + await item3.click(); + await page.waitForChanges(); + + expect(groupSelectSpy).toHaveReceivedEventTimes(6); + expect(await item1.getProperty("selected")).toBe(false); + expect(await item2.getProperty("selected")).toBe(false); + expect(await item3.getProperty("selected")).toBe(false); + expect(await element.getProperty("selectedItems")).toEqual([]); + + await assertSelectedItems("calcite-tile-group", page, { expectedItemIds: [] }); + }); + }); }); diff --git a/packages/calcite-components/src/components/tile-group/tile-group.stories.ts b/packages/calcite-components/src/components/tile-group/tile-group.stories.ts index 79b68579fec..c82c6a0a2c6 100644 --- a/packages/calcite-components/src/components/tile-group/tile-group.stories.ts +++ b/packages/calcite-components/src/components/tile-group/tile-group.stories.ts @@ -4,7 +4,7 @@ import { html } from "../../../support/formatting"; export default { title: "Components/Tiles/Tile Group", parameters: { - chromatic: { delay: 10000 }, + chromatic: { delay: 10000, viewports: [1728] }, }, }; @@ -60,38 +60,46 @@ export const allVariants = (): string => html` flex: 0 0 21%; } + .screenshot-test { + gap: 1em; + padding: 0 1em; + } + + .spaced-column { + display: flex; + flex-direction: column; + gap: 1em; + } + hr { margin: 25px 0; border-top: 1px solid var(--calcite-color-border-2); } - -
-

horizontal

-
- -
-
-
small
-
medium
-
large
-
- -
-
icon, heading, description
-
- + +
+
+ single + + + + html` > - -
-
- + +
-
- +
+ multiple + + + + - -
-
- - -
-
icon, heading, description as link
-
- + +
-
- +
+ none + + + - -
-
- + + +
- + +
+

horizontal

+
+ +
+
+
small
+
medium
+
large
+
+ +
-
icon, heading, description disabled
+
single selection-appearance="border"
- + + + + +
+
+ + +
- + + + + +
+
+ + +
+
multiple selection-appearance="border"
+
+ + +
- + + + + +
+
+ + +
- +
-
heading only
+
single-persist
- - - - - + + + + +
- - - - - + + + + +
- - - - - + + + + +
- +
-
heading only as link
+
none
- - - - - -
+ + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
links
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
disabled
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
disabled links
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
heading
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
heading links
+
+ + + + + + +
@@ -378,9 +836,9 @@ export const allVariants = (): string => html`
- +
-
description only
+
description
@@ -407,162 +865,578 @@ export const allVariants = (): string => html`
- + +
+
description links
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
heading and description
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
heading and description links
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+ + +
+

vertical

+
+ +
+
+
small
+
medium
+
large
+
+ + +
+
single
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
single selection-appearance="border"
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ +
-
description only as link
+
multiple
- - - - - + + + + +
- - - - - + + + + +
- - - - - + + + + +
- +
-
heading and description
+
single-persist
- - - - - + + + + +
- - - - - + + + + +
- - - - - + + + + +
- +
-
heading and description as link
+
none
- - - - - + + + + +
- - - - - + + + + +
- - - - - + + + + +
- +
-
icon and heading (large visual)
+
links
- - - - - + + + + +
- - - - - + + + + +
- - - - - + + + + +
- +
-
icon and heading (large visual) as link
+
disabled
- - - - - + + + + +
- - - - - + + + + +
- - - - - + + + + +
- -
- - -
-

vertical

-
- - +
-
heading only
+
heading
@@ -589,9 +1463,9 @@ export const allVariants = (): string => html`
- +
-
heading only as link
+
heading links
@@ -618,9 +1492,9 @@ export const allVariants = (): string => html`
- +
-
description only
+
description
@@ -647,9 +1521,9 @@ export const allVariants = (): string => html`
- +
-
description only as link
+
description links
@@ -705,9 +1579,9 @@ export const allVariants = (): string => html`
- +
-
heading and description as link
+
heading and description links
@@ -733,228 +1607,4 @@ export const allVariants = (): string => html`
- - -
-
icon, heading, description
-
- - - - - - -
-
- - - - - - -
-
- - - - - - -
-
- - -
-
icon, heading, description as link
-
- - - - - - -
-
- - - - - - -
-
- - - - - - -
-
- - -
-
icon and heading (large visual)
-
- - - - - - -
-
- - - - - - -
-
- - - - - - -
-
- - -
-
icon and heading (large visual) as link
-
- - - - - - -
-
- - - - - - -
-
- - - - - - -
-
`; diff --git a/packages/calcite-components/src/components/tile-group/tile-group.tsx b/packages/calcite-components/src/components/tile-group/tile-group.tsx index ee07080d81f..a5545cd1344 100644 --- a/packages/calcite-components/src/components/tile-group/tile-group.tsx +++ b/packages/calcite-components/src/components/tile-group/tile-group.tsx @@ -1,4 +1,14 @@ -import { Component, Element, h, Prop, VNode, Watch } from "@stencil/core"; +import { + Component, + Element, + Event, + EventEmitter, + h, + Listen, + Prop, + VNode, + Watch, +} from "@stencil/core"; import { connectInteractive, disconnectInteractive, @@ -6,7 +16,10 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { Layout, Scale } from "../interfaces"; +import { Layout, Scale, SelectionAppearance, SelectionMode } from "../interfaces"; +import { createObserver } from "../../utils/observers"; +import { focusElementInGroup } from "../../utils/dom"; +import { SelectableGroupComponent } from "../../utils/selectableComponent"; import { CSS } from "./resources"; /** @@ -17,7 +30,7 @@ import { CSS } from "./resources"; styleUrl: "tile-group.scss", shadow: true, }) -export class TileGroup implements InteractiveComponent { +export class TileGroup implements InteractiveComponent, SelectableGroupComponent { //-------------------------------------------------------------------------- // // Properties @@ -27,6 +40,9 @@ export class TileGroup implements InteractiveComponent { /** When `true`, interaction is prevented and the component is displayed with lower opacity. */ @Prop({ reflect: true }) disabled = false; + /** Accessible name for the component. */ + @Prop() label!: string; + /** * Defines the layout of the component. * @@ -44,6 +60,40 @@ export class TileGroup implements InteractiveComponent { this.updateTiles(); } + /** + * Specifies the component's selected items. + * + * @readonly + */ + @Prop({ mutable: true }) selectedItems: HTMLCalciteTileElement[] = []; + + /** + * Specifies the selection appearance, where: + * + * - `"icon"` (displays a checkmark or dot), or + * - `"border"` (displays a border). + */ + @Prop({ reflect: true }) selectionAppearance: SelectionAppearance = "icon"; + + /** + * Specifies the selection mode, where: + * + * - `"multiple"` (allows any number of selected items), + * - `"single"` (allows only one selected item), + * - `"single-persist"` (allows only one selected item and prevents de-selection), + * - `"none"` (allows no selected items). + */ + @Prop({ reflect: true }) selectionMode: Extract< + "multiple" | "none" | "single" | "single-persist", + SelectionMode + > = "none"; + + @Watch("selectionMode") + @Watch("selectionAppearance") + handleSelectionModeOrAppearanceChange(): void { + this.updateTiles(); + } + //-------------------------------------------------------------------------- // // Private Properties @@ -52,16 +102,79 @@ export class TileGroup implements InteractiveComponent { @Element() el: HTMLCalciteTileGroupElement; + private items: HTMLCalciteTileElement[] = []; + + private slotEl: HTMLSlotElement; + //-------------------------------------------------------------------------- // // Private Methods // //-------------------------------------------------------------------------- + private getSlottedTiles = (): HTMLCalciteTileElement[] => { + return this.slotEl + ?.assignedElements({ flatten: true }) + .filter((el) => el?.matches("calcite-tile")) as HTMLCalciteTileElement[]; + }; + + private mutationObserver = createObserver("mutation", () => this.updateTiles()); + + private selectItem = (item: HTMLCalciteTileElement): void => { + if (!item) { + return; + } + this.items?.forEach((el) => { + const matchingEl = item === el; + switch (this.selectionMode) { + case "multiple": + if (matchingEl) { + el.selected = !el.selected; + } + break; + + case "single": + el.selected = matchingEl && !el.selected; + break; + + case "single-persist": + el.selected = !!matchingEl; + break; + } + }); + this.updateSelectedItems(); + this.calciteTileGroupSelect.emit(); + }; + + private setSlotEl = (el: HTMLSlotElement): void => { + this.slotEl = el; + }; + + private updateSelectedItems = (): void => { + this.selectedItems = this.items?.filter((el) => el.selected); + }; + private updateTiles = (): void => { - this.el.querySelectorAll("calcite-tile").forEach((item) => (item.scale = this.scale)); + this.items = this.getSlottedTiles(); + this.items?.forEach((el) => { + el.interactive = true; + el.layout = this.layout; + el.scale = this.scale; + el.selectionAppearance = this.selectionAppearance; + el.selectionMode = this.selectionMode; + }); + this.updateSelectedItems(); }; + //-------------------------------------------------------------------------- + // + // Events + // + //-------------------------------------------------------------------------- + + /** Fires when the component's selection changes. */ + @Event({ cancelable: false }) calciteTileGroupSelect: EventEmitter; + //-------------------------------------------------------------------------- // // Lifecycle @@ -70,6 +183,7 @@ export class TileGroup implements InteractiveComponent { connectedCallback(): void { connectInteractive(this); + this.mutationObserver?.observe(this.el, { childList: true }); this.updateTiles(); } @@ -79,13 +193,64 @@ export class TileGroup implements InteractiveComponent { disconnectedCallback(): void { disconnectInteractive(this); + this.mutationObserver?.disconnect(); } + //-------------------------------------------------------------------------- + // + // Event Listeners + // + //-------------------------------------------------------------------------- + + @Listen("calciteInternalTileKeyEvent") + calciteInternalTileKeyEventListener(event: CustomEvent): void { + if (event.composedPath().includes(this.el)) { + event.preventDefault(); + event.stopPropagation(); + const interactiveItems = this.items?.filter((el) => !el.disabled); + switch (event.detail.key) { + case "ArrowDown": + case "ArrowRight": + focusElementInGroup(interactiveItems, event.detail.target, "next"); + break; + case "ArrowUp": + case "ArrowLeft": + focusElementInGroup(interactiveItems, event.detail.target, "previous"); + break; + case "Home": + focusElementInGroup(interactiveItems, event.detail.target, "first"); + break; + case "End": + focusElementInGroup(interactiveItems, event.detail.target, "last"); + break; + } + } + } + + @Listen("calciteTileSelect") + calciteTileSelectHandler(event: CustomEvent): void { + if (event.composedPath().includes(this.el)) { + this.selectItem(event.target as HTMLCalciteTileElement); + } + } + + //-------------------------------------------------------------------------- + // + // Render Methods + // + //-------------------------------------------------------------------------- + render(): VNode { + const role = + this.selectionMode === "none" || this.selectionMode === "multiple" ? "group" : "radiogroup"; return ( -
- +
+
); diff --git a/packages/calcite-components/src/components/tile/resources.ts b/packages/calcite-components/src/components/tile/resources.ts index ea9d3d028b6..80011dd70c0 100644 --- a/packages/calcite-components/src/components/tile/resources.ts +++ b/packages/calcite-components/src/components/tile/resources.ts @@ -1,10 +1,22 @@ export const CSS = { + column: "column", container: "container", - content: "content", contentContainer: "content-container", description: "description", heading: "heading", - largeVisual: "large-visual", + interactive: "interactive", + largeVisualDeprecated: "large-visual-deprecated", + row: "row", + selected: "selected", + selectionIcon: "selection-icon", + textContent: "text-content", +}; + +export const ICONS = { + selectedMultiple: "check-square-f", + selectedSingle: "circle-f", + unselectedMultiple: "square", + unselectedSingle: "circle", }; export const SLOTS = { diff --git a/packages/calcite-components/src/components/tile/tile.e2e.ts b/packages/calcite-components/src/components/tile/tile.e2e.ts index b5182e1f7d0..1f7580e132e 100644 --- a/packages/calcite-components/src/components/tile/tile.e2e.ts +++ b/packages/calcite-components/src/components/tile/tile.e2e.ts @@ -1,32 +1,125 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, disabled, reflects, renders, slots, hidden } from "../../tests/commonTests"; +import { accessible, defaults, disabled, focusable, hidden, reflects, renders, slots } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; +import { isElementFocused } from "../../tests/utils"; import { SLOTS } from "./resources"; describe("calcite-tile", () => { - describe("renders", () => { - renders("calcite-tile", { display: "inline-block" }); - }); - - describe("honors hidden attribute", () => { - hidden("calcite-tile"); + describe("accessibility", () => { + describe("accessible without label", () => { + accessible(html` `); + }); + describe("accessible with label only", () => { + accessible(html` `); + }); + describe("accessible in single selection-mode", () => { + accessible(html` `); + }); + describe("accessible in single-persist selection-mode", () => { + accessible(html` `); + }); + describe("accessible in multiple selection-mode", () => { + accessible(html` `); + }); + describe("accessible as link with heading", () => { + accessible(html` `); + }); + describe("accessible as link with description", () => { + accessible(html` `); + }); + describe("accessible as link with heading and label", () => { + accessible(html` `); + }); + describe("accessible as link with description and label", () => { + accessible(html` `); + }); }); - describe("accessible.", () => { - accessible(``); + describe("click", () => { + it("should not receive focus when clicked", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + await page.click("#tile-1"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#tile-1")).toBe(false); + }); + it("should receive focus when clicked and interactive", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + await page.click("#tile-1"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#tile-1")).toBe(true); + }); }); describe("defaults", () => { defaults("calcite-tile", [ + { propertyName: "active", defaultValue: false }, { propertyName: "alignment", defaultValue: "start" }, { propertyName: "disabled", defaultValue: false }, { propertyName: "embed", defaultValue: false }, - { propertyName: "focused", defaultValue: false }, { propertyName: "hidden", defaultValue: false }, { propertyName: "iconFlipRtl", defaultValue: false }, + { propertyName: "interactive", defaultValue: false }, + { propertyName: "layout", defaultValue: "horizontal" }, { propertyName: "scale", defaultValue: "m" }, + { propertyName: "selected", defaultValue: false }, + { propertyName: "selectionAppearance", defaultValue: "icon" }, + { propertyName: "selectionMode", defaultValue: "none" }, ]); }); + describe("disabled when interactive", () => { + disabled(html` `); + }); + + describe("events", () => { + it("should not emit select event after the tile is clicked if interactive is not set", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const eventSpy = await page.spyOnEvent("calciteTileSelect"); + + await page.click("#tile-1"); + await page.waitForChanges(); + + expect(eventSpy).not.toHaveReceivedEvent(); + }); + + it("should emit select event after the tile is clicked when interactive", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const eventSpy = await page.spyOnEvent("calciteTileSelect"); + + await page.click("#tile-1"); + await page.waitForChanges(); + + expect(eventSpy).toHaveReceivedEvent(); + }); + }); + + describe("focusable", () => { + focusable(html` `); + }); + + describe("hidden", () => { + hidden("calcite-tile"); + }); + + describe("keyboard", () => { + it("should receive focus when tabbed to with keyboard", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#tile-1")).toBe(true); + }); + }); + describe("slots", () => { slots("calcite-tile", SLOTS); }); @@ -38,81 +131,84 @@ describe("calcite-tile", () => { { propertyName: "description", value: "My test description" }, { propertyName: "disabled", value: true }, { propertyName: "embed", value: true }, - { propertyName: "focused", value: true }, { propertyName: "heading", value: "My test heading" }, { propertyName: "href", value: "http://www.esri.com" }, { propertyName: "icon", value: "layers" }, + { propertyName: "iconFlipRtl", value: true }, { propertyName: "scale", value: "s" }, + { propertyName: "selected", value: true }, + { propertyName: "selectionAppearance", value: "border" }, + { propertyName: "selectionMode", value: "single-persist" }, ]); }); - describe("disabled", () => { - disabled(""); - }); - - it("renders without a link by default", async () => { - const page = await newE2EPage(); - await page.setContent(""); - const link = await page.find("calcite-tile >>> calcite-link"); - expect(link).toBeNull(); - }); - - it("renders a link when href attribute is supplied", async () => { - const page = await newE2EPage(); - await page.setContent(""); - - const link = await page.find("calcite-tile >>> calcite-link"); - const anchor = await page.find("calcite-tile >>> calcite-link >>> a"); - expect(link).toEqualAttribute("href", "http://www.esri.com"); - expect(anchor).toEqualAttribute("href", "http://www.esri.com"); - }); - - it("renders heading only when supplied", async () => { - const page = await newE2EPage(); - await page.setContent(""); - - const icon = await page.find("calcite-tile >>> .icon"); - const heading = await page.find("calcite-tile >>> .heading"); - const description = await page.find("calcite-tile >>> .description"); - expect(icon).toBeNull(); - expect(heading).toEqualText("My Calcite Tile"); - expect(description).toBeNull(); - }); - - it("renders icon only when supplied", async () => { - const page = await newE2EPage(); - await page.setContent(""); - - const icon = await page.find("calcite-tile >>> .icon"); - const heading = await page.find("calcite-tile >>> .heading"); - const description = await page.find("calcite-tile >>> .description"); - expect(icon).toBeDefined(); - expect(heading).toBeNull(); - expect(description).toBeNull(); - }); - - it("renders description only when supplied", async () => { - const page = await newE2EPage(); - await page.setContent(""); - - const icon = await page.find("calcite-tile >>> .icon"); - const heading = await page.find("calcite-tile >>> .heading"); - const description = await page.find("calcite-tile >>> .description"); - expect(icon).toBeNull(); - expect(heading).toBeNull(); - expect(description).toEqualText("My Calcite Tile Description."); - }); + describe("renders", () => { + renders("calcite-tile", { display: "inline-block" }); - it("renders large icon when only icon and heading are supplied", async () => { - const page = await newE2EPage(); - await page.setContent(''); - - const icon = await page.find("calcite-tile >>> calcite-icon"); - const heading = await page.find("calcite-tile >>> .heading"); - const description = await page.find("calcite-tile >>> .description"); - expect(icon).toEqualAttribute("icon", "layers"); - expect(icon).toEqualAttribute("scale", "l"); - expect(heading).toEqualText("My Large Visual Calcite Tile"); - expect(description).toBeNull(); + it("renders without a link by default", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + const link = await page.find("calcite-tile >>> calcite-link"); + expect(link).toBeNull(); + }); + + it("renders a link when href attribute is supplied", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const link = await page.find("calcite-tile >>> calcite-link"); + const anchor = await page.find("calcite-tile >>> calcite-link >>> a"); + expect(link).toEqualAttribute("href", "http://www.esri.com"); + expect(anchor).toEqualAttribute("href", "http://www.esri.com"); + }); + + it("renders heading only when supplied", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const icon = await page.find("calcite-tile >>> .icon"); + const heading = await page.find("calcite-tile >>> .heading"); + const description = await page.find("calcite-tile >>> .description"); + expect(icon).toBeNull(); + expect(heading).toEqualText("My Calcite Tile"); + expect(description).toBeNull(); + }); + + it("renders icon only when supplied", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const icon = await page.find("calcite-tile >>> .icon"); + const heading = await page.find("calcite-tile >>> .heading"); + const description = await page.find("calcite-tile >>> .description"); + expect(icon).toBeDefined(); + expect(heading).toBeNull(); + expect(description).toBeNull(); + }); + + it("renders description only when supplied", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const icon = await page.find("calcite-tile >>> .icon"); + const heading = await page.find("calcite-tile >>> .heading"); + const description = await page.find("calcite-tile >>> .description"); + expect(icon).toBeNull(); + expect(heading).toBeNull(); + expect(description).toEqualText("My Calcite Tile Description."); + }); + + it("renders large icon when only icon and heading are supplied", async () => { + const page = await newE2EPage(); + await page.setContent(html` `); + + const icon = await page.find("calcite-tile >>> calcite-icon"); + const heading = await page.find("calcite-tile >>> .heading"); + const description = await page.find("calcite-tile >>> .description"); + expect(icon).toEqualAttribute("icon", "layers"); + expect(icon).toEqualAttribute("scale", "l"); + expect(heading).toEqualText("My Large Visual Calcite Tile"); + expect(description).toBeNull(); + }); }); }); diff --git a/packages/calcite-components/src/components/tile/tile.scss b/packages/calcite-components/src/components/tile/tile.scss index b23cfc38094..5e0115b5f17 100644 --- a/packages/calcite-components/src/components/tile/tile.scss +++ b/packages/calcite-components/src/components/tile/tile.scss @@ -14,72 +14,94 @@ --calcite-tile-border-color: var(--calcite-color-border-2); --calcite-tile-description-text-color: var(--calcite-color-text-3); --calcite-tile-heading-text-color: var(--calcite-color-text-2); + --calcite-ui-icon-color: var(--calcite-color-text-3); box-sizing: border-box; display: inline-block; - max-inline-size: 460px; - min-inline-size: 140px; +} - .container { - align-items: flex-start; - background-color: var(--calcite-tile-background-color); - block-size: var(--calcite-container-size-content-fluid); - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: var(--calcite-spacing-md); - inline-size: var(--calcite-container-size-content-fluid); - pointer-events: none; - transition-duration: 150ms; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - user-select: none; +.container { + background-color: var(--calcite-tile-background-color); + block-size: var(--calcite-container-size-content-fluid); + box-sizing: border-box; + inline-size: var(--calcite-container-size-content-fluid); + outline: var(--calcite-border-width-sm, 1px) solid var(--calcite-tile-border-color); + user-select: none; + &.interactive { + cursor: pointer; + &:hover, + &:focus, + &.selected { + outline-color: var(--calcite-color-brand); + position: relative; + .selection-icon { + --calcite-ui-icon-color: var(--calcite-color-brand); + } + } + &.selected { + z-index: var(--calcite-z-index); + } + &:focus { + box-shadow: inset 0px 0px 0px 1px var(--calcite-color-brand); + z-index: var(--calcite-z-index-sticky); + } } +} - .content-container { - display: flex; - gap: var(--calcite-spacing-md); - inline-size: 100%; - outline-color: transparent; - padding: 0; - } +.column, +.row { + align-items: flex-start; + display: flex; +} - .content { - display: flex; - flex-direction: column; - } +.column { + flex-direction: column; +} - .heading { - color: var(--calcite-tile-heading-text-color); - font-size: var(--calcite-font-size--1); - font-weight: var(--calcite-font-weight-medium); - line-height: 1.20313rem; - overflow-wrap: break-word; - } - .large-visual { - align-items: center; - justify-content: center; - min-block-size: 12rem; - text-align: center; +.content-container { + inline-size: 100%; + outline-color: transparent; + padding: 0; +} - calcite-icon { - block-size: 64px; - inline-size: 64px; - } - .content-container { - justify-content: center; - } +.text-content { + display: flex; + flex-direction: column; +} + +.heading { + color: var(--calcite-tile-heading-text-color); + font-weight: var(--calcite-font-weight-medium); + line-height: 1.20313rem; + overflow-wrap: break-word; +} + +/* [Deprecated] Use the content-top slot for rendering icon with alignment="center" instead */ +.large-visual-deprecated { + align-items: center; + justify-content: center; + min-block-size: 12rem; + text-align: center; + + calcite-icon { + align-self: center; + block-size: 64px; + inline-size: 64px; } - .description { - color: var(--calcite-tile-description-text-color); - font-size: var(--calcite-font-size--2); - font-weight: var(--calcite-font-weight-regular); - line-height: 1.03125rem; - overflow-wrap: break-word; + .content-container { + justify-content: center; } } +.description { + color: var(--calcite-tile-description-text-color); + font-weight: var(--calcite-font-weight-regular); + overflow-wrap: break-word; +} + :host([alignment="center"]) { - .container { + .row, + .column { align-items: center; text-align: center; } @@ -92,8 +114,11 @@ :host([scale="s"]) { max-inline-size: 400px; min-inline-size: 100px; - .container, - .content-container { + .container { + padding: var(--calcite-spacing-sm); + } + .column, + .row { gap: var(--calcite-spacing-sm); } .heading { @@ -106,11 +131,34 @@ } } +:host([scale="m"]) { + max-inline-size: 460px; + min-inline-size: 140px; + .container { + padding: var(--calcite-spacing-md); + } + .column, + .row { + gap: var(--calcite-spacing-md); + } + .heading { + font-size: var(--calcite-font-size--1); + line-height: 1.20313rem; + } + .description { + font-size: var(--calcite-font-size--2); + line-height: 1.03125rem; + } +} + :host([scale="l"]) { max-inline-size: 520px; min-inline-size: 160px; - .container, - .content-container { + .container { + padding: var(--calcite-spacing-xl); + } + .column, + .row { gap: var(--calcite-spacing-xl); } .heading { @@ -123,54 +171,70 @@ } } -:host(:not([href])) { - --calcite-ui-icon-color: var(--calcite-color-text-3); +:host([selection-appearance="border"][layout="horizontal"]), +:host([selection-appearance="border"][layout="vertical"]) { + .container.selected:focus::before { + block-size: 100%; + box-shadow: inset 0px 0px 0px 1px var(--calcite-color-brand); + content: ""; + display: block; + inline-size: 100%; + inset-block-start: 0; + inset-inline-start: 0; + position: absolute; + } } -:host([href]), -:host([href]:hover) { - --calcite-tile-heading-text-color: var(--calcite-color-text-link); - .heading { - text-decoration-line: underline; +:host([selection-appearance="border"][layout="horizontal"]) { + .container.selected { + box-shadow: inset 0px -4px 0px 0px var(--calcite-color-brand); } } -:host(:not([embed])) { - .container { - box-shadow: 0 0 0 1px var(--calcite-tile-border-color); - padding: var(--calcite-spacing-md); + +:host([selection-appearance="border"][layout="vertical"]) { + .container.selected { + box-shadow: inset 4px 0px 0px 0px var(--calcite-color-brand); } } -:host(:not([embed])[scale="s"]) { + +:host([href]:focus:not([disabled])), +:host([href]:hover:not([disabled])) { + --calcite-tile-border-color: var(--calcite-color-text-link); + --calcite-tile-heading-text-color: var(--calcite-color-text-link); + --calcite-ui-icon-color: var(--calcite-color-text-link); .container { - padding: var(--calcite-spacing-sm); + position: relative; + z-index: var(--calcite-z-index); } } -:host(:not([embed])[scale="l"]) { + +:host([href]:active:not([disabled])) { .container { - padding: var(--calcite-spacing-xl); + box-shadow: 0 0 0 3px var(--calcite-tile-border-color); } } -:host(:not([embed])[href]:hover), -:host(:not([embed])[href]:focus) { - --calcite-tile-border-color: var(--calcite-color-brand); + +:host([embed]) { .container { - box-shadow: 0 0 0 2px var(--calcite-tile-border-color); - cursor: pointer; - position: relative; - z-index: var(--calcite-z-index); + box-shadow: none; + padding: 0; } } -:host(:not([embed])[href]:active) { - --calcite-tile-border-color: var(--calcite-color-brand); + +:host([selection-mode="none"]) { .container { - box-shadow: 0 0 0 3px var(--calcite-tile-border-color); + outline-color: var(--calcite-tile-border-color); + &:focus { + outline-color: var(--calcite-color-brand); + position: relative; + } } } @include disabled(); -:host(:hover), -:host([active]) { +:host(:hover:not([disabled])), +:host([active]:not([disabled])) { --calcite-tile-description-text-color: var(--calcite-color-text-2); --calcite-tile-heading-text-color: var(--calcite-color-text-1); } diff --git a/packages/calcite-components/src/components/tile/tile.stories.ts b/packages/calcite-components/src/components/tile/tile.stories.ts index fa6f1e45765..69e62a7f65d 100644 --- a/packages/calcite-components/src/components/tile/tile.stories.ts +++ b/packages/calcite-components/src/components/tile/tile.stories.ts @@ -798,16 +798,10 @@ export const allVariants = (): string => html` export const darkModeRTL_TestOnly = (): string => html` @@ -816,22 +810,14 @@ export const darkModeRTL_TestOnly = (): string => html` darkModeRTL_TestOnly.parameters = { themes: modesDarkDefault }; export const contentTopBotton_TestOnly = (): string => html` - + `; export const contentStartRTL_TestOnly = (): string => html` - + @@ -848,16 +834,10 @@ export const overflowingContent_TestOnly = (): string => html` export const disabled_TestOnly = (): string => html` `; diff --git a/packages/calcite-components/src/components/tile/tile.tsx b/packages/calcite-components/src/components/tile/tile.tsx index aaa8aab29e0..609aeeac000 100644 --- a/packages/calcite-components/src/components/tile/tile.tsx +++ b/packages/calcite-components/src/components/tile/tile.tsx @@ -1,4 +1,14 @@ -import { Component, Element, h, Prop, VNode } from "@stencil/core"; +import { + Component, + Element, + Event, + EventEmitter, + h, + Listen, + Method, + Prop, + VNode, +} from "@stencil/core"; import { connectInteractive, disconnectInteractive, @@ -6,8 +16,15 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { Alignment, Scale } from "../interfaces"; -import { CSS, SLOTS } from "./resources"; +import { Alignment, Layout, Scale, SelectionAppearance, SelectionMode } from "../interfaces"; +import { toAriaBoolean } from "../../utils/dom"; +import { + componentFocusable, + setComponentLoaded, + setUpLoadableComponent, +} from "../../utils/loadable"; +import { SelectableComponent } from "../../utils/selectableComponent"; +import { CSS, ICONS, SLOTS } from "./resources"; /** * @slot content-top - A slot for adding non-actionable elements above the component's content. Content slotted here will render in place of the `icon` property. @@ -20,7 +37,7 @@ import { CSS, SLOTS } from "./resources"; styleUrl: "tile.scss", shadow: true, }) -export class Tile implements InteractiveComponent { +export class Tile implements InteractiveComponent, SelectableComponent { //-------------------------------------------------------------------------- // // Properties @@ -29,6 +46,8 @@ export class Tile implements InteractiveComponent { /** * When `true`, the component is active. + * + * @deprecated */ @Prop({ reflect: true }) active = false; @@ -56,13 +75,6 @@ export class Tile implements InteractiveComponent { */ @Prop({ reflect: true }) embed = false; - /** - * The focused state of the component. - * - * @internal - */ - @Prop({ reflect: true }) focused = false; - /** The component header text, which displays between the icon and description. */ @Prop({ reflect: true }) heading: string; @@ -76,11 +88,82 @@ export class Tile implements InteractiveComponent { @Prop({ reflect: true }) iconFlipRtl = false; + /** + * When true, enables the tile to be focused, and allows the `calciteTileSelect` to emit. + * This is set to `true` by a parent Tile Group component. + * + * @internal + */ + @Prop() interactive = false; + + /** Accessible name for the component. */ + @Prop() label: string; + + /** + * Defines the layout of the component. + * + * Use `"horizontal"` for rows, and `"vertical"` for a single column. + * + * @internal + */ + @Prop({ reflect: true }) layout: Exclude = "horizontal"; + /** * Specifies the size of the component. */ @Prop({ reflect: true }) scale: Scale = "m"; + /** + * When `true` and the parent's `selectionMode` is `"single"`, `"single-persist"', or `"multiple"`, the component is selected. + * + * @internal + */ + @Prop({ reflect: true }) selected = false; + + /** + * Specifies the selection appearance, where: + * + * - `"icon"` (displays a checkmark or dot), or + * - `"border"` (displays a border). + * + * This property is set by the parent tile-group. + * + * @internal + */ + @Prop({ reflect: true }) selectionAppearance: SelectionAppearance = "icon"; + + /** + * Specifies the selection mode, where: + * + * - `"multiple"` (allows any number of selected items), + * - `"single"` (allows only one selected item), + * - `"single-persist"` (allows only one selected item and prevents de-selection), + * - `"none"` (allows no selected items). + * + * This property is set by the parent tile-group. + * + * @internal + */ + @Prop({ reflect: true }) selectionMode: Extract< + "multiple" | "none" | "single" | "single-persist", + SelectionMode + > = "none"; + + //-------------------------------------------------------------------------- + // + // Public Methods + // + //-------------------------------------------------------------------------- + + /** Sets focus on the component. */ + @Method() + async setFocus(): Promise { + await componentFocusable(this); + if (!this.disabled && this.interactive) { + this.containerEl?.focus(); + } + } + // -------------------------------------------------------------------------- // // Private Properties @@ -89,6 +172,52 @@ export class Tile implements InteractiveComponent { @Element() el: HTMLCalciteTileElement; + private clickHandler = (): void => { + if (this.interactive) { + this.setFocus(); + this.handleSelectEvent(); + } + }; + + private containerEl: HTMLDivElement; + + //-------------------------------------------------------------------------- + // + // Events + // + //-------------------------------------------------------------------------- + + /** + * @internal + */ + @Event({ cancelable: false }) calciteInternalTileKeyEvent: EventEmitter; + + /** + * Fires when the selected state of the component changes. + */ + @Event() calciteTileSelect: EventEmitter; + + // -------------------------------------------------------------------------- + // + // Private Methods + // + // -------------------------------------------------------------------------- + + private handleSelectEvent = (): void => { + if ( + this.disabled || + !this.interactive || + (this.selectionMode === "single-persist" && this.selected === true) + ) { + return; + } + this.calciteTileSelect.emit(); + }; + + private setContainerEl = (el): void => { + this.containerEl = el; + }; + // -------------------------------------------------------------------------- // // Lifecycle @@ -99,6 +228,10 @@ export class Tile implements InteractiveComponent { connectInteractive(this); } + componentDidLoad(): void { + setComponentLoaded(this); + } + disconnectedCallback(): void { disconnectInteractive(this); } @@ -107,29 +240,113 @@ export class Tile implements InteractiveComponent { updateHostInteraction(this); } + async componentWillLoad(): Promise { + setUpLoadableComponent(this); + } + + //-------------------------------------------------------------------------- + // + // Event Listeners + // + //-------------------------------------------------------------------------- + + @Listen("keydown") + keyDownHandler(event: KeyboardEvent): void { + if (event.target === this.el) { + switch (event.key) { + case " ": + case "Enter": + this.handleSelectEvent(); + event.preventDefault(); + break; + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + case "ArrowUp": + case "Home": + case "End": + this.calciteInternalTileKeyEvent.emit(event); + event.preventDefault(); + break; + } + } + } + // -------------------------------------------------------------------------- // // Render Methods // // -------------------------------------------------------------------------- - renderTile(): VNode { - const { icon, heading, description, iconFlipRtl } = this; - const isLargeVisual = heading && icon && !description; + renderSelectionIcon(): VNode { + const { selected, selectionAppearance, selectionMode } = this; + if (selectionAppearance === "icon" && selectionMode !== "none") { + return ( + + ); + } + return; + } + renderTile(): VNode { + const { description, disabled, heading, icon, iconFlipRtl, interactive, selectionMode } = this; + const isLargeVisual = heading && icon && !Boolean(description); + const disableInteraction = Boolean(this.href) || !interactive; + const role = + selectionMode === "multiple" && interactive + ? "checkbox" + : selectionMode !== "none" && interactive + ? "radio" + : interactive + ? "button" + : undefined; return ( -
- - {icon && } -
- -
- {heading &&
{heading}
} - {description &&
{description}
} +
+ {this.renderSelectionIcon()} +
+ + {icon && } +
+ +
+ {heading &&
{heading}
} + {description &&
{description}
} +
+
- +
-
); } @@ -139,7 +356,7 @@ export class Tile implements InteractiveComponent { return ( - {this.href ? ( + {!!this.href ? ( {this.renderTile()} diff --git a/packages/calcite-components/src/demos/tile-group.html b/packages/calcite-components/src/demos/tile-group.html index 145e632be28..dd7bf7bba88 100644 --- a/packages/calcite-components/src/demos/tile-group.html +++ b/packages/calcite-components/src/demos/tile-group.html @@ -6,14 +6,17 @@ Tile Group