diff --git a/packages/calcite-components/src/components.d.ts b/packages/calcite-components/src/components.d.ts index 8b767bb340a..dd887407571 100644 --- a/packages/calcite-components/src/components.d.ts +++ b/packages/calcite-components/src/components.d.ts @@ -61,6 +61,7 @@ import { ListDragDetail } from "./components/list/interfaces"; import { ItemData } from "./components/list-item/interfaces"; import { ListMessages } from "./components/list/assets/list/t9n"; import { SelectionAppearance } from "./components/list/resources"; +import { MoveEventDetail, MoveTo, ReorderEventDetail } from "./components/sort-handle/interfaces"; import { ListItemMessages } from "./components/list-item/assets/list-item/t9n"; import { MenuMessages } from "./components/menu/assets/menu/t9n"; import { MenuItemMessages } from "./components/menu-item/assets/menu-item/t9n"; @@ -76,6 +77,8 @@ import { ScrimMessages } from "./components/scrim/assets/scrim/t9n"; import { DisplayMode } from "./components/sheet/interfaces"; import { DisplayMode as DisplayMode1 } from "./components/shell-panel/interfaces"; import { ShellPanelMessages } from "./components/shell-panel/assets/shell-panel/t9n"; +import { FlipPlacement as FlipPlacement1, MenuPlacement as MenuPlacement1, OverlayPositioning as OverlayPositioning1 } from "./components"; +import { SortHandleMessages } from "./components/sort-handle/assets/sort-handle/t9n"; import { DragDetail } from "./utils/sortableComponent"; import { StepperItemChangeEventDetail, StepperItemEventDetail, StepperItemKeyEventDetail, StepperLayout } from "./components/stepper/interfaces"; import { StepperMessages } from "./components/stepper/assets/stepper/t9n"; @@ -151,6 +154,7 @@ export { ListDragDetail } from "./components/list/interfaces"; export { ItemData } from "./components/list-item/interfaces"; export { ListMessages } from "./components/list/assets/list/t9n"; export { SelectionAppearance } from "./components/list/resources"; +export { MoveEventDetail, MoveTo, ReorderEventDetail } from "./components/sort-handle/interfaces"; export { ListItemMessages } from "./components/list-item/assets/list-item/t9n"; export { MenuMessages } from "./components/menu/assets/menu/t9n"; export { MenuItemMessages } from "./components/menu-item/assets/menu-item/t9n"; @@ -166,6 +170,8 @@ export { ScrimMessages } from "./components/scrim/assets/scrim/t9n"; export { DisplayMode } from "./components/sheet/interfaces"; export { DisplayMode as DisplayMode1 } from "./components/shell-panel/interfaces"; export { ShellPanelMessages } from "./components/shell-panel/assets/shell-panel/t9n"; +export { FlipPlacement as FlipPlacement1, MenuPlacement as MenuPlacement1, OverlayPositioning as OverlayPositioning1 } from "./components"; +export { SortHandleMessages } from "./components/sort-handle/assets/sort-handle/t9n"; export { DragDetail } from "./utils/sortableComponent"; export { StepperItemChangeEventDetail, StepperItemEventDetail, StepperItemKeyEventDetail, StepperLayout } from "./components/stepper/interfaces"; export { StepperMessages } from "./components/stepper/assets/stepper/t9n"; @@ -3327,10 +3333,6 @@ export namespace Components { * When `true`, the component displays a draggable button. */ "dragHandle": boolean; - /** - * When `true`, the component's drag handle is selected. - */ - "dragSelected": boolean; /** * Hides the component when filtered. */ @@ -3355,6 +3357,10 @@ export namespace Components { * Provides additional metadata to the component. Primary use is for a filter on the parent `calcite-list`. */ "metadata": Record; + /** + * Sets the item to display a border. + */ + "moveToItems": MoveTo[]; /** * When `true`, the item is open to show child components. */ @@ -3379,13 +3385,17 @@ export namespace Components { */ "setFocus": () => Promise; /** - * Used to specify the aria-posinset attribute to define the number or position in the current set of list items for accessibility. + * Used to determine what menu options are available in the sort-handle */ "setPosition": number; /** - * Used to specify the aria-setsize attribute to define the number of items in the current set of list for accessibility. + * Used to determine what menu options are available in the sort-handle */ "setSize": number; + /** + * When `true`, displays and positions the sort handle. + */ + "sortHandleOpen": boolean; /** * When `true`, the component's content appears inactive. */ @@ -4718,6 +4728,70 @@ export namespace Components { */ "value": null | number | number[]; } + interface CalciteSortHandle { + /** + * When `true`, interaction is prevented and the component is displayed with lower opacity. + */ + "disabled": boolean; + /** + * Specifies the component's fallback `calcite-dropdown-item` `placement` when it's initial or specified `placement` has insufficient space available. + */ + "flipPlacements": FlipPlacement1[]; + /** + * Specifies the label of the component. + */ + "label": string; + /** + * Specifies the maximum number of `calcite-dropdown-item`s to display before showing a scroller. Value must be greater than `0`, and does not include `groupTitle`'s from `calcite-dropdown-group`. + */ + "maxItems": number; + /** + * Use this property to override individual strings used by the component. + */ + "messageOverrides": Partial; + /** + * Made into a prop for testing purposes only. + * @readonly + */ + "messages": SortHandleMessages; + /** + * Defines the "Move to" items. + */ + "moveToItems": MoveTo[]; + /** + * When `true`, displays and positions the component. + */ + "open": boolean; + /** + * Determines the type of positioning to use for the overlaid content. Using `"absolute"` will work for most cases. The component will be positioned inside of overflowing parent containers and will affect the container's layout. `"fixed"` should be used to escape an overflowing parent container, or when the reference element's `position` CSS property is `"fixed"`. + */ + "overlayPositioning": OverlayPositioning1; + /** + * Determines where the component will be positioned relative to the container element. + * @default "bottom-start" + */ + "placement": MenuPlacement1; + /** + * Specifies the size of the component. + */ + "scale": Scale; + /** + * Sets focus on the component. + */ + "setFocus": () => Promise; + /** + * The current position of the handle. + */ + "setPosition": number; + /** + * The total number of sortable items. + */ + "setSize": number; + /** + * Specifies the width of the component. + */ + "widthScale": Scale; + } interface CalciteSortableList { /** * When provided, the method will be called to determine whether the element can move from the list. @@ -6044,6 +6118,10 @@ export interface CalciteSliderCustomEvent extends CustomEvent { detail: T; target: HTMLCalciteSliderElement; } +export interface CalciteSortHandleCustomEvent extends CustomEvent { + detail: T; + target: HTMLCalciteSortHandleElement; +} export interface CalciteSortableListCustomEvent extends CustomEvent { detail: T; target: HTMLCalciteSortableListElement; @@ -6950,7 +7028,10 @@ declare global { interface HTMLCalciteListItemElementEventMap { "calciteListItemSelect": void; "calciteListItemClose": void; - "calciteListItemDragHandleChange": void; + "calciteListItemSortHandleBeforeClose": void; + "calciteListItemSortHandleClose": void; + "calciteListItemSortHandleBeforeOpen": void; + "calciteListItemSortHandleOpen": void; "calciteListItemToggle": void; "calciteInternalListItemSelect": void; "calciteInternalListItemSelectMultiple": { @@ -7384,6 +7465,28 @@ declare global { prototype: HTMLCalciteSliderElement; new (): HTMLCalciteSliderElement; }; + interface HTMLCalciteSortHandleElementEventMap { + "calciteSortHandleBeforeClose": void; + "calciteSortHandleBeforeOpen": void; + "calciteSortHandleReorder": ReorderEventDetail; + "calciteSortHandleMove": MoveEventDetail; + "calciteSortHandleClose": void; + "calciteSortHandleOpen": void; + } + interface HTMLCalciteSortHandleElement extends Components.CalciteSortHandle, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLCalciteSortHandleElement, ev: CalciteSortHandleCustomEvent) => 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: HTMLCalciteSortHandleElement, ev: CalciteSortHandleCustomEvent) => 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 HTMLCalciteSortHandleElement: { + prototype: HTMLCalciteSortHandleElement; + new (): HTMLCalciteSortHandleElement; + }; interface HTMLCalciteSortableListElementEventMap { "calciteListOrderChange": void; } @@ -7873,6 +7976,7 @@ declare global { "calcite-shell-center-row": HTMLCalciteShellCenterRowElement; "calcite-shell-panel": HTMLCalciteShellPanelElement; "calcite-slider": HTMLCalciteSliderElement; + "calcite-sort-handle": HTMLCalciteSortHandleElement; "calcite-sortable-list": HTMLCalciteSortableListElement; "calcite-split-button": HTMLCalciteSplitButtonElement; "calcite-stack": HTMLCalciteStackElement; @@ -11220,10 +11324,6 @@ declare namespace LocalJSX { * When `true`, the component displays a draggable button. */ "dragHandle"?: boolean; - /** - * When `true`, the component's drag handle is selected. - */ - "dragSelected"?: boolean; /** * Hides the component when filtered. */ @@ -11248,6 +11348,10 @@ declare namespace LocalJSX { * Provides additional metadata to the component. Primary use is for a filter on the parent `calcite-list`. */ "metadata"?: Record; + /** + * Sets the item to display a border. + */ + "moveToItems"?: MoveTo[]; "onCalciteInternalFocusPreviousItem"?: (event: CalciteListItemCustomEvent) => void; "onCalciteInternalListItemActive"?: (event: CalciteListItemCustomEvent) => void; "onCalciteInternalListItemChange"?: (event: CalciteListItemCustomEvent) => void; @@ -11260,14 +11364,26 @@ declare namespace LocalJSX { * Fires when the close button is clicked. */ "onCalciteListItemClose"?: (event: CalciteListItemCustomEvent) => void; - /** - * Fires when the drag handle is selected. - */ - "onCalciteListItemDragHandleChange"?: (event: CalciteListItemCustomEvent) => void; /** * Fires when the component is selected. */ "onCalciteListItemSelect"?: (event: CalciteListItemCustomEvent) => void; + /** + * Fires when the sort handle is requested to be closed and before the closing transition begins. + */ + "onCalciteListItemSortHandleBeforeClose"?: (event: CalciteListItemCustomEvent) => void; + /** + * Fires when the sort handle is added to the DOM but not rendered, and before the opening transition begins. + */ + "onCalciteListItemSortHandleBeforeOpen"?: (event: CalciteListItemCustomEvent) => void; + /** + * Fires when the sort handle is closed and animation is complete. + */ + "onCalciteListItemSortHandleClose"?: (event: CalciteListItemCustomEvent) => void; + /** + * Fires when the sort handle is open and animation is complete. + */ + "onCalciteListItemSortHandleOpen"?: (event: CalciteListItemCustomEvent) => void; /** * Fires when the open button is clicked. */ @@ -11292,13 +11408,17 @@ declare namespace LocalJSX { SelectionMode >; /** - * Used to specify the aria-posinset attribute to define the number or position in the current set of list items for accessibility. + * Used to determine what menu options are available in the sort-handle */ "setPosition"?: number; /** - * Used to specify the aria-setsize attribute to define the number of items in the current set of list for accessibility. + * Used to determine what menu options are available in the sort-handle */ "setSize"?: number; + /** + * When `true`, displays and positions the sort handle. + */ + "sortHandleOpen"?: boolean; /** * When `true`, the component's content appears inactive. */ @@ -12660,6 +12780,90 @@ declare namespace LocalJSX { */ "value"?: null | number | number[]; } + interface CalciteSortHandle { + /** + * When `true`, interaction is prevented and the component is displayed with lower opacity. + */ + "disabled"?: boolean; + /** + * Specifies the component's fallback `calcite-dropdown-item` `placement` when it's initial or specified `placement` has insufficient space available. + */ + "flipPlacements"?: FlipPlacement1[]; + /** + * Specifies the label of the component. + */ + "label"?: string; + /** + * Specifies the maximum number of `calcite-dropdown-item`s to display before showing a scroller. Value must be greater than `0`, and does not include `groupTitle`'s from `calcite-dropdown-group`. + */ + "maxItems"?: number; + /** + * Use this property to override individual strings used by the component. + */ + "messageOverrides"?: Partial; + /** + * Made into a prop for testing purposes only. + * @readonly + */ + "messages"?: SortHandleMessages; + /** + * Defines the "Move to" items. + */ + "moveToItems"?: MoveTo[]; + /** + * Fires when the component is requested to be closed and before the closing transition begins. + */ + "onCalciteSortHandleBeforeClose"?: (event: CalciteSortHandleCustomEvent) => void; + /** + * Fires when the component is added to the DOM but not rendered, and before the opening transition begins. + */ + "onCalciteSortHandleBeforeOpen"?: (event: CalciteSortHandleCustomEvent) => void; + /** + * Fires when the component is closed and animation is complete. + */ + "onCalciteSortHandleClose"?: (event: CalciteSortHandleCustomEvent) => void; + /** + * Fires when a move item has been selected. + */ + "onCalciteSortHandleMove"?: (event: CalciteSortHandleCustomEvent) => void; + /** + * Fires when the component is open and animation is complete. + */ + "onCalciteSortHandleOpen"?: (event: CalciteSortHandleCustomEvent) => void; + /** + * Fires when a reorder has been selected. + */ + "onCalciteSortHandleReorder"?: (event: CalciteSortHandleCustomEvent) => void; + /** + * When `true`, displays and positions the component. + */ + "open"?: boolean; + /** + * Determines the type of positioning to use for the overlaid content. Using `"absolute"` will work for most cases. The component will be positioned inside of overflowing parent containers and will affect the container's layout. `"fixed"` should be used to escape an overflowing parent container, or when the reference element's `position` CSS property is `"fixed"`. + */ + "overlayPositioning"?: OverlayPositioning1; + /** + * Determines where the component will be positioned relative to the container element. + * @default "bottom-start" + */ + "placement"?: MenuPlacement1; + /** + * Specifies the size of the component. + */ + "scale"?: Scale; + /** + * The current position of the handle. + */ + "setPosition"?: number; + /** + * The total number of sortable items. + */ + "setSize"?: number; + /** + * Specifies the width of the component. + */ + "widthScale"?: Scale; + } interface CalciteSortableList { /** * When provided, the method will be called to determine whether the element can move from the list. @@ -13867,6 +14071,7 @@ declare namespace LocalJSX { "calcite-shell-center-row": CalciteShellCenterRow; "calcite-shell-panel": CalciteShellPanel; "calcite-slider": CalciteSlider; + "calcite-sort-handle": CalciteSortHandle; "calcite-sortable-list": CalciteSortableList; "calcite-split-button": CalciteSplitButton; "calcite-stack": CalciteStack; @@ -14007,6 +14212,7 @@ declare module "@stencil/core" { "calcite-shell-center-row": LocalJSX.CalciteShellCenterRow & JSXBase.HTMLAttributes; "calcite-shell-panel": LocalJSX.CalciteShellPanel & JSXBase.HTMLAttributes; "calcite-slider": LocalJSX.CalciteSlider & JSXBase.HTMLAttributes; + "calcite-sort-handle": LocalJSX.CalciteSortHandle & JSXBase.HTMLAttributes; "calcite-sortable-list": LocalJSX.CalciteSortableList & JSXBase.HTMLAttributes; "calcite-split-button": LocalJSX.CalciteSplitButton & JSXBase.HTMLAttributes; "calcite-stack": LocalJSX.CalciteStack & JSXBase.HTMLAttributes; diff --git a/packages/calcite-components/src/components/action/action.scss b/packages/calcite-components/src/components/action/action.scss index 716007fec7b..578f2fe9864 100755 --- a/packages/calcite-components/src/components/action/action.scss +++ b/packages/calcite-components/src/components/action/action.scss @@ -18,7 +18,7 @@ :host { @extend %component-host; - @apply flex bg-transparent; + @apply flex bg-transparent cursor-pointer; } :host, @@ -62,7 +62,6 @@ button { m-0 flex w-auto - cursor-pointer items-center justify-start border-none @@ -73,6 +72,7 @@ button { color: var(--calcite-action-text-color, var(--calcite-color-text-3)); text-align: unset; flex: 1 0 auto; + cursor: inherit; &:hover, &:focus { @@ -173,7 +173,8 @@ button { :host([scale="s"]) { .button { - @apply text-n2h px-2 font-normal; + @apply text-n2h font-normal; + padding-inline: var(--calcite-internal-action-padding-inline, theme("spacing.2")); padding-block: var(--calcite-internal-action-padding-block, var(--calcite-size-xxs)); } .button--text-visible .icon-container { @@ -183,7 +184,8 @@ button { :host([scale="m"]) { .button { - @apply text-n1h px-4 font-normal; + @apply text-n1h font-normal; + padding-inline: var(--calcite-internal-action-padding-inline, theme("spacing.4")); padding-block: var(--calcite-internal-action-padding-block, var(--calcite-size-md)); } .button--text-visible .icon-container { @@ -193,7 +195,8 @@ button { :host([scale="l"]) { .button { - @apply text-0h px-5 font-normal; + @apply text-0h font-normal; + padding-inline: var(--calcite-internal-action-padding-inline, theme("spacing.5")); padding-block: var(--calcite-internal-action-padding-block, var(--calcite-size-xl)); } .button--text-visible .icon-container { diff --git a/packages/calcite-components/src/components/dropdown/dropdown.tsx b/packages/calcite-components/src/components/dropdown/dropdown.tsx index 3cddbf15b33..b1d55fcbc0b 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.tsx +++ b/packages/calcite-components/src/components/dropdown/dropdown.tsx @@ -591,6 +591,7 @@ export class Dropdown this.toggleDropdown(); event.preventDefault(); } else if (key === "ArrowDown" || key === "ArrowUp") { + event.preventDefault(); this.focusLastDropdownItem = key === "ArrowUp"; this.open = true; this.el.addEventListener("calciteDropdownOpen", this.onOpenEnd); diff --git a/packages/calcite-components/src/components/list-item/list-item.e2e.ts b/packages/calcite-components/src/components/list-item/list-item.e2e.ts index 9734bdd100d..9f1e60b2dcf 100755 --- a/packages/calcite-components/src/components/list-item/list-item.e2e.ts +++ b/packages/calcite-components/src/components/list-item/list-item.e2e.ts @@ -48,10 +48,6 @@ describe("calcite-list-item", () => { propertyName: "dragHandle", defaultValue: false, }, - { - propertyName: "dragSelected", - defaultValue: false, - }, { propertyName: "filterHidden", defaultValue: false, @@ -139,7 +135,7 @@ describe("calcite-list-item", () => { await page.setContent(``); await page.waitForChanges(); - let handleNode = await page.find("calcite-list-item >>> calcite-handle"); + let handleNode = await page.find("calcite-list-item >>> calcite-sort-handle"); expect(handleNode).toBeNull(); @@ -147,7 +143,7 @@ describe("calcite-list-item", () => { item.setProperty("dragHandle", true); await page.waitForChanges(); - handleNode = await page.find("calcite-list-item >>> calcite-handle"); + handleNode = await page.find("calcite-list-item >>> calcite-sort-handle"); expect(handleNode).not.toBeNull(); }); @@ -375,29 +371,4 @@ describe("calcite-list-item", () => { expect(await listItem.getProperty("open")).toBe(false); expect(calciteListItemToggle).toHaveReceivedEventTimes(2); }); - - it("should fire calciteListItemDragHandleChange event when drag handle is clicked", async () => { - const page = await newE2EPage({ - html: html``, - }); - - const listItem = await page.find("calcite-list-item"); - const calciteListItemDragHandleChange = await page.spyOnEvent("calciteListItemDragHandleChange", "window"); - - expect(await listItem.getProperty("dragSelected")).toBe(false); - - const dragHandle = await page.find(`calcite-list-item >>> calcite-handle`); - await dragHandle.callMethod("setFocus"); - await page.waitForChanges(); - - await dragHandle.press("Space"); - await page.waitForChanges(); - expect(await listItem.getProperty("dragSelected")).toBe(true); - expect(calciteListItemDragHandleChange).toHaveReceivedEventTimes(1); - - await dragHandle.press("Space"); - await page.waitForChanges(); - expect(await listItem.getProperty("dragSelected")).toBe(false); - expect(calciteListItemDragHandleChange).toHaveReceivedEventTimes(2); - }); }); diff --git a/packages/calcite-components/src/components/list-item/list-item.scss b/packages/calcite-components/src/components/list-item/list-item.scss index 92359f9b250..ba109552b30 100755 --- a/packages/calcite-components/src/components/list-item/list-item.scss +++ b/packages/calcite-components/src/components/list-item/list-item.scss @@ -193,7 +193,7 @@ @apply flex items-center; calcite-action, - calcite-handle { + calcite-sort-handle { @apply self-stretch; } } @@ -208,7 +208,7 @@ @apply p-0 relative; ::slotted(calcite-action), ::slotted(calcite-action-menu), - ::slotted(calcite-handle), + ::slotted(calcite-sort-handle), ::slotted(calcite-dropdown) { @apply self-stretch; @@ -223,7 +223,7 @@ .close, ::slotted(calcite-action), ::slotted(calcite-action-menu), - ::slotted(calcite-handle), + ::slotted(calcite-sort-handle), ::slotted(calcite-dropdown) { block-size: calc(100% - theme("spacing[1]")); } diff --git a/packages/calcite-components/src/components/list-item/list-item.tsx b/packages/calcite-components/src/components/list-item/list-item.tsx index ac3919536ea..c1c61a0b394 100644 --- a/packages/calcite-components/src/components/list-item/list-item.tsx +++ b/packages/calcite-components/src/components/list-item/list-item.tsx @@ -40,13 +40,9 @@ import { setUpLoadableComponent, } from "../../utils/loadable"; import { SortableComponentItem } from "../../utils/sortableComponent"; +import { MoveTo } from "../sort-handle/interfaces"; import { ListItemMessages } from "./assets/list-item/t9n"; -import { - getDepth, - getListItemChildren, - getListItemChildLists, - updateListItemChildren, -} from "./utils"; +import { getDepth, hasListItemChildren } from "./utils"; import { CSS, activeCellTestAttribute, ICONS, SLOTS } from "./resources"; const focusMap = new Map(); @@ -140,11 +136,6 @@ export class ListItem */ @Prop() dragHandle = false; - /** - * When `true`, the component's drag handle is selected. - */ - @Prop({ mutable: true, reflect: true }) dragSelected = false; - /** * Hides the component when filtered. * @@ -162,6 +153,13 @@ export class ListItem */ @Prop() metadata: Record; + /** + * Sets the item to display a border. + * + * @internal + */ + @Prop() moveToItems: MoveTo[] = []; + /** * When `true`, the item is open to show child components. */ @@ -173,14 +171,14 @@ export class ListItem } /** - * Used to specify the aria-setsize attribute to define the number of items in the current set of list for accessibility. + * Used to determine what menu options are available in the sort-handle * * @internal */ @Prop() setSize: number = null; /** - * Used to specify the aria-posinset attribute to define the number or position in the current set of list items for accessibility. + * Used to determine what menu options are available in the sort-handle * * @internal */ @@ -229,6 +227,21 @@ export class ListItem */ @Prop({ mutable: true }) selectionAppearance: SelectionAppearance = null; + /** + * When `true`, displays and positions the sort handle. + */ + @Prop({ mutable: true }) sortHandleOpen = false; + + @Watch("sortHandleOpen") + sortHandleOpenHandler(): void { + if (!this.sortHandleEl) { + return; + } + + // we set the property instead of the attribute to ensure open/close events are emitted properly + this.sortHandleEl.open = this.sortHandleOpen; + } + /** * Use this property to override individual strings used by the component. */ @@ -264,10 +277,17 @@ export class ListItem */ @Event({ cancelable: false }) calciteListItemClose: EventEmitter; - /** - * Fires when the drag handle is selected. - */ - @Event({ cancelable: false }) calciteListItemDragHandleChange: EventEmitter; + /** Fires when the sort handle is requested to be closed and before the closing transition begins. */ + @Event({ cancelable: false }) calciteListItemSortHandleBeforeClose: EventEmitter; + + /** Fires when the sort handle is closed and animation is complete. */ + @Event({ cancelable: false }) calciteListItemSortHandleClose: EventEmitter; + + /** Fires when the sort handle is added to the DOM but not rendered, and before the opening transition begins. */ + @Event({ cancelable: false }) calciteListItemSortHandleBeforeOpen: EventEmitter; + + /** Fires when the sort handle is open and animation is complete. */ + @Event({ cancelable: false }) calciteListItemSortHandleOpen: EventEmitter; /** * Fires when the open button is clicked. @@ -367,6 +387,8 @@ export class ListItem defaultSlotEl: HTMLSlotElement; + private sortHandleEl: HTMLCalciteSortHandleElement; + // -------------------------------------------------------------------------- // // Lifecycle @@ -466,7 +488,7 @@ export class ListItem } renderDragHandle(): VNode { - const { label, dragHandle, dragSelected, dragDisabled, setPosition, setSize } = this; + const { label, dragHandle, dragDisabled, setPosition, setSize, moveToItems } = this; return dragHandle ? (
(this.handleGridEl = el)} role="gridcell" > - @@ -659,8 +686,6 @@ export class ListItem openable, open, level, - setPosition, - setSize, active, label, selected, @@ -691,9 +716,7 @@ export class ListItem aria-expanded={openable ? toAriaBoolean(open) : null} aria-label={label} aria-level={level} - aria-posinset={setPosition} aria-selected={toAriaBoolean(selected)} - aria-setsize={setSize} class={{ [CSS.row]: true, [CSS.container]: true, @@ -731,10 +754,31 @@ export class ListItem // // -------------------------------------------------------------------------- - private dragHandleSelectedChangeHandler = (event: CustomEvent): void => { - this.dragSelected = (event.target as HTMLCalciteHandleElement).selected; - this.calciteListItemDragHandleChange.emit(); + private setSortHandleEl = (el: HTMLCalciteSortHandleElement): void => { + this.sortHandleEl = el; + this.sortHandleOpenHandler(); + }; + + private handleSortHandleBeforeOpen = (event: CustomEvent): void => { + event.stopPropagation(); + this.calciteListItemSortHandleBeforeOpen.emit(); + }; + + private handleSortHandleBeforeClose = (event: CustomEvent): void => { + event.stopPropagation(); + this.calciteListItemSortHandleBeforeClose.emit(); + }; + + private handleSortHandleClose = (event: CustomEvent): void => { event.stopPropagation(); + this.sortHandleOpen = false; + this.calciteListItemSortHandleClose.emit(); + }; + + private handleSortHandleOpen = (event: CustomEvent): void => { + event.stopPropagation(); + this.sortHandleOpen = true; + this.calciteListItemSortHandleOpen.emit(); }; private emitInternalListItemActive = (): void => { @@ -815,11 +859,7 @@ export class ListItem return; } - const listItemChildren = getListItemChildren(slotEl); - const listItemChildLists = getListItemChildLists(slotEl); - updateListItemChildren(listItemChildren); - - this.openable = !!listItemChildren.length || !!listItemChildLists.length; + this.openable = hasListItemChildren(slotEl); } private handleDefaultSlotChange = (event: Event): void => { diff --git a/packages/calcite-components/src/components/list-item/utils.ts b/packages/calcite-components/src/components/list-item/utils.ts index b9c81414875..77bb359a87d 100644 --- a/packages/calcite-components/src/components/list-item/utils.ts +++ b/packages/calcite-components/src/components/list-item/utils.ts @@ -4,33 +4,39 @@ const listSelector = "calcite-list"; const listItemGroupSelector = "calcite-list-item-group"; const listItemSelector = "calcite-list-item"; -export function getListItemChildLists(slotEl: HTMLSlotElement): HTMLCalciteListElement[] { - return Array.from( - slotEl.assignedElements({ flatten: true }).filter((el): el is HTMLCalciteListElement => el.matches(listSelector)), - ); +export function openAncestors(el: HTMLCalciteListItemElement): void { + const ancestor = el.parentElement?.closest(listItemSelector); + + if (!ancestor) { + return; + } + + ancestor.open = true; + openAncestors(ancestor); } -export function getListItemChildren(slotEl: HTMLSlotElement): HTMLCalciteListItemElement[] { +export function hasListItemChildren(slotEl: HTMLSlotElement): boolean { const assignedElements = slotEl.assignedElements({ flatten: true }); - const listItemGroupChildren = assignedElements + const groupChildren = assignedElements .filter((el): el is HTMLCalciteListItemGroupElement => el?.matches(listItemGroupSelector)) - .map((group) => Array.from(group.querySelectorAll(listItemSelector))) - .reduce((previousValue, currentValue) => [...previousValue, ...currentValue], []); + .map((group) => Array.from(group.querySelectorAll(listItemSelector))) + .flat(); const listItemChildren = assignedElements.filter((el): el is HTMLCalciteListItemElement => el?.matches(listItemSelector), ); - const listItemListChildren = assignedElements - .filter((el): el is HTMLCalciteListElement => el?.matches(listSelector)) - .map((list) => Array.from(list.querySelectorAll(listItemSelector))) - .reduce((previousValue, currentValue) => [...previousValue, ...currentValue], []); + const listChildren = assignedElements.filter((el): el is HTMLCalciteListElement => el?.matches(listSelector)); - return [...listItemListChildren, ...listItemGroupChildren, ...listItemChildren]; + return [...listChildren, ...groupChildren, ...listItemChildren].length > 0; } -export function updateListItemChildren(listItemChildren: HTMLCalciteListItemElement[]): void { +export function updateListItemChildren(slotEl: HTMLSlotElement): void { + const listItemChildren = slotEl + .assignedElements({ flatten: true }) + .filter((el): el is HTMLCalciteListItemElement => el?.matches(listItemSelector)); + listItemChildren.forEach((listItem) => { listItem.setPosition = listItemChildren.indexOf(listItem) + 1; listItem.setSize = listItemChildren.length; diff --git a/packages/calcite-components/src/components/list/interfaces.ts b/packages/calcite-components/src/components/list/interfaces.ts index a9fe444f94b..b6ce4f0c5a6 100644 --- a/packages/calcite-components/src/components/list/interfaces.ts +++ b/packages/calcite-components/src/components/list/interfaces.ts @@ -1,7 +1,14 @@ -import { DragDetail } from "../../utils/sortableComponent"; +import { DragDetail, MoveDetail } from "../../utils/sortableComponent"; export interface ListDragDetail extends DragDetail { toEl: HTMLCalciteListElement; fromEl: HTMLCalciteListElement; dragEl: HTMLCalciteListItemElement; } + +export interface ListMoveDetail extends MoveDetail { + toEl: HTMLCalciteListElement; + fromEl: HTMLCalciteListElement; + dragEl: HTMLCalciteListItemElement; + relatedEl: HTMLCalciteListItemElement; +} diff --git a/packages/calcite-components/src/components/list/list.e2e.ts b/packages/calcite-components/src/components/list/list.e2e.ts index 5e48cfcfd97..ff204f8e3e9 100755 --- a/packages/calcite-components/src/components/list/list.e2e.ts +++ b/packages/calcite-components/src/components/list/list.e2e.ts @@ -5,6 +5,7 @@ import { html } from "../../../support/formatting"; import { CSS as ListItemCSS, activeCellTestAttribute } from "../list-item/resources"; import { GlobalTestProps, dragAndDrop, isElementFocused, getFocusedElementProp } from "../../tests/utils"; import { DEBOUNCE } from "../../utils/resources"; +import { Reorder } from "../sort-handle/interfaces"; import { ListDragDetail } from "./interfaces"; const placeholder = placeholderImage({ @@ -1051,7 +1052,7 @@ describe("calcite-list", () => { await list.press("ArrowRight"); - expect(await isElementFocused(page, `calcite-handle`, { shadowed: true })).toBe(true); + expect(await isElementFocused(page, `calcite-sort-handle`, { shadowed: true })).toBe(true); await list.press("ArrowRight"); @@ -1067,7 +1068,7 @@ describe("calcite-list", () => { await list.press("ArrowLeft"); - expect(await isElementFocused(page, `calcite-handle`, { shadowed: true })).toBe(true); + expect(await isElementFocused(page, `calcite-sort-handle`, { shadowed: true })).toBe(true); await list.press("ArrowLeft"); @@ -1158,7 +1159,7 @@ describe("calcite-list", () => { expect(await items[2].getProperty("active")).toBe(false); expect(secondHandleCell.getAttribute(activeCellTestAttribute)).toBe(null); - const secondDragHandle = await page.find("#two >>> calcite-handle"); + const secondDragHandle = await page.find("#two >>> calcite-sort-handle"); await secondDragHandle.click(); @@ -1176,10 +1177,10 @@ describe("calcite-list", () => { async function createSimpleList(): Promise { const page = await newE2EPage(); await page.setContent( - html` - - - + html` + + + `, ); await page.waitForChanges(); @@ -1189,8 +1190,13 @@ describe("calcite-list", () => { type TestWindow = GlobalTestProps<{ calledTimes: number; + list1CalledTimes: number; + list2CalledTimes: number; newIndex: number; oldIndex: number; + fromEl: string; + toEl: string; + el: string; startCalledTimes: number; endCalledTimes: number; endNewIndex: number; @@ -1231,11 +1237,11 @@ describe("calcite-list", () => { page, { element: `calcite-list-item[value="one"]`, - shadow: "calcite-handle", + shadow: "calcite-sort-handle", }, { element: `calcite-list-item[value="two"]`, - shadow: "calcite-handle", + shadow: "calcite-sort-handle", }, ); @@ -1314,7 +1320,7 @@ describe("calcite-list", () => { page, { element: `calcite-list-item[value="d"]`, - shadow: "calcite-handle", + shadow: "calcite-sort-handle", }, { element: `#first-letters`, @@ -1328,7 +1334,7 @@ describe("calcite-list", () => { page, { element: `calcite-list-item[value="e"]`, - shadow: "calcite-handle", + shadow: "calcite-sort-handle", }, { element: `#numbers`, @@ -1342,7 +1348,7 @@ describe("calcite-list", () => { page, { element: `calcite-list-item[value="e"]`, - shadow: "calcite-handle", + shadow: "calcite-sort-handle", }, { element: `#no-group`, @@ -1367,17 +1373,9 @@ describe("calcite-list", () => { expect(await page.evaluate(() => (window as TestWindow).calledTimes)).toBe(2); }); - it("works using a keyboard", async () => { + it("reorders using a keyboard", async () => { const page = await createSimpleList(); - const handle = await page.find(`calcite-list-item[value="one"] >>> calcite-handle`); - - await page.keyboard.press("Tab"); - await page.keyboard.press("Tab"); - await page.keyboard.press("Space"); - expect(await handle.getProperty("selected")).toBe(true); - await page.waitForChanges(); - let totalMoves = 0; // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 @@ -1388,17 +1386,30 @@ describe("calcite-list", () => { testWindow.calledTimes++; testWindow.newIndex = event.detail.newIndex; testWindow.oldIndex = event.detail.oldIndex; + testWindow.fromEl = event.detail.fromEl.id; + testWindow.toEl = event.detail.toEl.id; + testWindow.el = event.detail.dragEl.id; }); }); - async function assertKeyboardMove( - arrowKey: "ArrowDown" | "ArrowUp", + async function assertReorder( + reorder: Reorder, expectedValueOrder: string[], newIndex: number, oldIndex: number, ): Promise { + const eventName = `calciteSortHandleReorder`; + const event = page.waitForEvent(eventName); + await page.$eval( + `calcite-list-item[value="one"]`, + (item1: HTMLCalciteListItemElement, reorder, eventName) => { + item1.dispatchEvent(new CustomEvent(eventName, { detail: { reorder }, bubbles: true })); + }, + reorder, + eventName, + ); + await event; await page.waitForChanges(); - await page.keyboard.press(arrowKey); const itemsAfter = await page.findAll("calcite-list-item"); expect(itemsAfter.length).toBe(3); @@ -1413,124 +1424,154 @@ describe("calcite-list", () => { calledTimes: testWindow.calledTimes, oldIndex: testWindow.oldIndex, newIndex: testWindow.newIndex, + fromEl: testWindow.fromEl, + toEl: testWindow.toEl, + el: testWindow.el, }; }); + const listId = "list1"; + expect(results.calledTimes).toBe(++totalMoves); expect(results.newIndex).toBe(newIndex); expect(results.oldIndex).toBe(oldIndex); + expect(results.fromEl).toBe(listId); + expect(results.toEl).toBe(listId); + expect(results.el).toBe("one"); } - await assertKeyboardMove("ArrowDown", ["two", "one", "three"], 1, 0); - await assertKeyboardMove("ArrowDown", ["two", "three", "one"], 2, 1); - await assertKeyboardMove("ArrowDown", ["one", "two", "three"], 0, 2); + await assertReorder("down", ["two", "one", "three"], 1, 0); + await assertReorder("down", ["two", "three", "one"], 2, 1); + await assertReorder("down", ["two", "three", "one"], 2, 2); - await assertKeyboardMove("ArrowUp", ["two", "three", "one"], 2, 0); - await assertKeyboardMove("ArrowUp", ["two", "one", "three"], 1, 2); - await assertKeyboardMove("ArrowUp", ["one", "two", "three"], 0, 1); - }); + await assertReorder("up", ["two", "one", "three"], 1, 2); + await assertReorder("up", ["one", "two", "three"], 0, 1); + await assertReorder("up", ["one", "two", "three"], 0, 0); - it("is drag and drop list accessible", async () => { - const page = await createSimpleList(); - let startIndex = 0; + await assertReorder("bottom", ["two", "three", "one"], 2, 0); + await assertReorder("top", ["one", "two", "three"], 0, 2); + }); - await page.keyboard.press("Tab"); - await page.keyboard.press("Tab"); + it("moves using a keyboard", async () => { + const page = await newE2EPage(); + const group = "my-group"; + await page.setContent( + html` + + + + + + `, + ); await page.waitForChanges(); + await page.waitForTimeout(DEBOUNCE.filter); - const items = await page.findAll("calcite-list-item"); - const item = await page.find('calcite-list-item[value="one"]'); - const handle = await page.find('calcite-list-item[value="one"] >>> calcite-handle'); - const assistiveTextElement = await page.find("calcite-list >>> .assistive-text"); - - async function getAriaLabel(): Promise { - return page.$eval("calcite-list-item[value='one']", (el: HTMLCalciteListItemElement) => { - return el.shadowRoot - .querySelector("calcite-handle") - .shadowRoot.querySelector("span") - .getAttribute("aria-label"); + let list1Moves = 0; + let list2Moves = 0; + + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.$eval("#list1", (list: HTMLCalciteListElement) => { + const testWindow = window as TestWindow; + testWindow.list1CalledTimes = 0; + list.addEventListener("calciteListOrderChange", (event: CustomEvent) => { + testWindow.list1CalledTimes++; + testWindow.newIndex = event.detail.newIndex; + testWindow.oldIndex = event.detail.oldIndex; + testWindow.fromEl = event.detail.fromEl.id; + testWindow.toEl = event.detail.toEl.id; + testWindow.el = event.detail.dragEl.id; }); - } + }); - const handleAriaLabel = await getAriaLabel(); - const itemLabel = await item.getProperty("label"); - - /* eslint-disable import/no-dynamic-require -- allowing dynamic asset path for maintainability */ - const langTranslations = await import(`../handle/assets/handle/t9n/messages.json`); - /* eslint-enable import/no-dynamic-require */ - - function messageSubstitute({ - text, - setPosition, - label, - setSize, - }: { - text: string; - setPosition: number; - label: string; - setSize: number; - }): string { - const replacePosition = text.replace("{position}", setPosition.toString()); - const replaceLabel = replacePosition.replace("{itemLabel}", label); - return replaceLabel.replace("{total}", setSize.toString()); - } + // Workaround for page.spyOnEvent() failing due to drag event payload being serialized and there being circular JSON structures from the payload elements. See: https://github.com/Esri/calcite-design-system/issues/7643 + await page.$eval("#list2", (list: HTMLCalciteListElement) => { + const testWindow = window as TestWindow; + testWindow.list2CalledTimes = 0; + list.addEventListener("calciteListOrderChange", (event: CustomEvent) => { + testWindow.list2CalledTimes++; + testWindow.newIndex = event.detail.newIndex; + testWindow.oldIndex = event.detail.oldIndex; + testWindow.fromEl = event.detail.fromEl.id; + testWindow.toEl = event.detail.toEl.id; + testWindow.el = event.detail.dragEl.id; + }); + }); - expect(handleAriaLabel).toBe( - messageSubstitute({ - text: langTranslations.dragHandleIdle, - setPosition: startIndex + 1, - label: itemLabel, - setSize: items.length, - }), - ); + async function assertMove( + listItemId: string, + moveFromListId: string, + moveToListId: string, + list1Order: string[], + list2Order: string[], + newIndex: number, + oldIndex: number, + ): Promise { + const eventName = `calciteSortHandleMove`; + const event = page.waitForEvent(eventName); + await page.$eval( + `#${listItemId}`, + (item: HTMLCalciteListItemElement, moveToListId, eventName) => { + const element = document.querySelector(`#${moveToListId}`); + item.dispatchEvent( + new CustomEvent(eventName, { + detail: { + moveTo: { + element, + id: element.id, + label: element.label, + }, + }, + bubbles: true, + }), + ); + }, + moveToListId, + eventName, + ); + await event; + await page.waitForChanges(); + const list1Id = "list1"; + const list2Id = "list2"; + const list1After = await page.findAll(`#${list1Id} calcite-list-item`); + expect(list1After.length).toBe(list1Order.length); - await page.keyboard.press("Space"); - expect(await handle.getProperty("selected")).toBe(true); - await page.waitForChanges(); + for (let i = 0; i < list1After.length; i++) { + expect(await list1After[i].getProperty("value")).toBe(list1Order[i]); + } - expect(assistiveTextElement.textContent).toBe( - messageSubstitute({ - text: langTranslations.dragHandleActive, - setPosition: startIndex + 1, - label: itemLabel, - setSize: items.length, - }), - ); + const list2After = await page.findAll(`#${list2Id} calcite-list-item`); + expect(list2After.length).toBe(list2Order.length); - await page.keyboard.press("ArrowDown"); - await page.waitForChanges(); - expect(await handle.getProperty("selected")).toBe(true); - await page.waitForTimeout(DEBOUNCE.nextTick); - - startIndex += 1; - const changeHandleLabel = await getAriaLabel(); - - expect(changeHandleLabel).toBe( - messageSubstitute({ - text: langTranslations.dragHandleChange, - setPosition: startIndex + 1, - label: itemLabel, - setSize: items.length, - }), - ); - await page.keyboard.press("Space"); - await page.waitForChanges(); + for (let i = 0; i < list2After.length; i++) { + expect(await list2After[i].getProperty("value")).toBe(list2Order[i]); + } - expect(assistiveTextElement.textContent).toBe( - messageSubstitute({ - text: langTranslations.dragHandleCommit, - setPosition: startIndex + 1, - label: itemLabel, - setSize: items.length, - }), - ); + const results = await page.evaluate(() => { + const testWindow = window as TestWindow; - await page.keyboard.press("Space"); - await page.waitForChanges(); - await page.keyboard.press("ArrowUp"); - await page.keyboard.press("Space"); - await page.waitForChanges(); - expect(await handle.getProperty("selected")).toBe(false); + return { + list1CalledTimes: testWindow.list1CalledTimes, + list2CalledTimes: testWindow.list2CalledTimes, + oldIndex: testWindow.oldIndex, + newIndex: testWindow.newIndex, + fromEl: testWindow.fromEl, + toEl: testWindow.toEl, + el: testWindow.el, + }; + }); + + expect(results.list1CalledTimes).toBe(moveFromListId === list1Id ? ++list1Moves : list1Moves); + expect(results.list2CalledTimes).toBe(moveFromListId === list2Id ? ++list2Moves : list2Moves); + expect(results.newIndex).toBe(newIndex); + expect(results.oldIndex).toBe(oldIndex); + expect(results.fromEl).toBe(moveFromListId); + expect(results.toEl).toBe(moveToListId); + expect(results.el).toBe(listItemId); + } + + await assertMove("one", "list1", "list2", ["two"], ["one", "three"], 0, 0); + await assertMove("three", "list2", "list1", ["three", "two"], ["one"], 0, 1); }); }); }); diff --git a/packages/calcite-components/src/components/list/list.stories.ts b/packages/calcite-components/src/components/list/list.stories.ts index 3b8050a37df..cc8ba7c178b 100644 --- a/packages/calcite-components/src/components/list/list.stories.ts +++ b/packages/calcite-components/src/components/list/list.stories.ts @@ -846,7 +846,7 @@ export const filterActions_TestOnly = (): string => `; export const sortableList_TestOnly = (): string => - html` + html` `; export const sortableNestedList_TestOnly = (): string => - html` + html` - + - + - + @@ -932,11 +932,11 @@ export const sortableNestedList_TestOnly = (): string => `; export const emptyOpenLists_TestOnly = (): string => - html` + html` - + - + @@ -945,29 +945,34 @@ export const emptyOpenLists_TestOnly = (): string => - + - + - + - + - `; export const listWithEmptyChildList_TestOnly = (): string => - html` + html` - + `; @@ -1108,13 +1113,13 @@ export const closedItems_TestOnly = (): string => `; export const dragEnabledNestedLists = (): string => - html` + html` - + - + @@ -1127,16 +1132,16 @@ export const dragEnabledNestedLists = (): string => `; export const dragEnabledNestedListsIndirectChildren = (): string => - html` + html`
- +
- +
diff --git a/packages/calcite-components/src/components/list/list.tsx b/packages/calcite-components/src/components/list/list.tsx index c55043ec29e..6a363efc3ce 100755 --- a/packages/calcite-components/src/components/list/list.tsx +++ b/packages/calcite-components/src/components/list/list.tsx @@ -13,7 +13,7 @@ import { } from "@stencil/core"; import Sortable from "sortablejs"; import { debounce } from "lodash-es"; -import { slotChangeHasAssignedElement, toAriaBoolean } from "../../utils/dom"; +import { getRootNode, slotChangeHasAssignedElement, toAriaBoolean } from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, @@ -22,7 +22,7 @@ import { import { createObserver } from "../../utils/observers"; import { SelectionMode, InteractionMode } from "../interfaces"; import { ItemData } from "../list-item/interfaces"; -import { getListItemChildren, updateListItemChildren } from "../list-item/utils"; +import { openAncestors, updateListItemChildren } from "../list-item/utils"; import { connectSortableComponent, disconnectSortableComponent, @@ -35,7 +35,6 @@ import { setComponentLoaded, setUpLoadableComponent, } from "../../utils/loadable"; -import { HandleNudge } from "../handle/interfaces"; import { connectMessages, disconnectMessages, @@ -50,9 +49,11 @@ import { NumberingSystem, numberStringFormatter, } from "../../utils/locale"; +import { MoveEventDetail, MoveTo, ReorderEventDetail } from "../sort-handle/interfaces"; +import { guid } from "../../utils/guid"; import { CSS, debounceTimeout, SelectionAppearance, SLOTS } from "./resources"; import { ListMessages } from "./assets/list/t9n"; -import { ListDragDetail } from "./interfaces"; +import { ListDragDetail, ListMoveDetail } from "./interfaces"; const listItemSelector = "calcite-list-item"; const parentSelector = "calcite-list-item-group, calcite-list-item" as const; @@ -339,13 +340,22 @@ export class List event.stopPropagation(); } - @Listen("calciteHandleNudge") - handleCalciteHandleNudge(event: CustomEvent): void { + @Listen("calciteSortHandleReorder") + handleSortReorder(event: CustomEvent): void { if (this.parentListEl) { return; } - this.handleNudgeEvent(event); + this.handleReorder(event); + } + + @Listen("calciteSortHandleMove") + handleSortMove(event: CustomEvent): void { + if (this.parentListEl) { + return; + } + + this.handleMove(event); } @Listen("calciteInternalListItemSelect") @@ -466,13 +476,15 @@ export class List @State() dataForFilter: ItemData = []; + @State() moveToItems: MoveTo[] = []; + dragSelector = listItemSelector; filterEl: HTMLCalciteFilterElement; focusableItems: HTMLCalciteListItemElement[] = []; - handleSelector = "calcite-handle"; + handleSelector = "calcite-sort-handle"; @State() hasFilterActionsEnd = false; @@ -678,7 +690,12 @@ export class List this.calciteListDragEnd.emit(detail); } + onDragMove({ relatedEl }: ListMoveDetail): void { + relatedEl.open = true; + } + onDragStart(detail: ListDragDetail): void { + detail.dragEl.sortHandleOpen = false; this.calciteListDragStart.emit(detail); } @@ -694,7 +711,7 @@ export class List } private handleDefaultSlotChange = (event: Event): void => { - updateListItemChildren(getListItemChildren(event.target as HTMLSlotElement)); + updateListItemChildren(event.target as HTMLSlotElement); if (this.parentListEl) { this.calciteInternalListDefaultSlotChange.emit(); } @@ -861,11 +878,31 @@ export class List })); }; + private updateGroupItems(): void { + const { el, group } = this; + + const rootNode = getRootNode(el); + + const lists = group + ? Array.from( + rootNode.querySelectorAll(`calcite-list[group="${group}"]`), + ).filter((list) => !list.disabled && list.dragEnabled) + : []; + + this.moveToItems = lists.map((element) => ({ + element, + label: element.label ?? element.id, + id: el.id || guid(), + })); + } + private updateListItems = debounce( (options?: { emitFilterChange?: boolean; performFilter?: boolean }): void => { const emitFilterChange = options?.emitFilterChange ?? false; const performFilter = options?.performFilter ?? false; + this.updateGroupItems(); + const { selectionAppearance, selectionMode, @@ -874,6 +911,7 @@ export class List el, filterEl, filterEnabled, + moveToItems, } = this; const items = Array.from(this.el.querySelectorAll(listItemSelector)); @@ -883,6 +921,9 @@ export class List item.selectionMode = selectionMode; item.interactionMode = interactionMode; if (item.closest("calcite-list") === el) { + item.moveToItems = moveToItems.filter( + (moveToItem) => moveToItem.element !== el && !item.contains(moveToItem.element), + ); item.dragHandle = dragEnabled; } }); @@ -990,47 +1031,75 @@ export class List } } - handleNudgeEvent(event: CustomEvent): void { - const { handleSelector, dragSelector } = this; - const { direction } = event.detail; + private handleMove(event: CustomEvent): void { + const { moveTo } = event.detail; - const composedPath = event.composedPath(); + const dragEl = event.target as HTMLCalciteListItemElement; + const fromEl = dragEl?.parentElement as HTMLCalciteListElement; + const oldIndex = Array.from(fromEl.children).indexOf(dragEl); + const toEl = moveTo.element as HTMLCalciteListElement; - const handle = composedPath.find( - (el: HTMLElement): el is HTMLCalciteHandleElement => - el?.tagName && el.matches(handleSelector), - ); + if (!fromEl) { + return; + } - const dragEl = composedPath.find( - (el: HTMLElement): el is HTMLCalciteListItemElement => - el?.tagName && el.matches(dragSelector), - ); + dragEl.sortHandleOpen = false; + + this.disconnectObserver(); + + toEl.prepend(dragEl); + openAncestors(dragEl); + const newIndex = Array.from(toEl.children).indexOf(dragEl); + + this.updateListItems(); + this.connectObserver(); + + this.calciteListOrderChange.emit({ + dragEl, + fromEl, + toEl, + newIndex, + oldIndex, + }); + } + private handleReorder(event: CustomEvent): void { + const { reorder } = event.detail; + + const dragEl = event.target as HTMLCalciteListItemElement; const parentEl = dragEl?.parentElement as HTMLCalciteListElement; if (!parentEl) { return; } - const { filteredItems } = this; + dragEl.sortHandleOpen = false; - const sameParentItems = filteredItems.filter((item) => item.parentElement === parentEl); + const sameParentItems = this.filteredItems.filter((item) => item.parentElement === parentEl); const lastIndex = sameParentItems.length - 1; const oldIndex = sameParentItems.indexOf(dragEl); - let newIndex: number; - - if (direction === "up") { - newIndex = oldIndex === 0 ? lastIndex : oldIndex - 1; - } else { - newIndex = oldIndex === lastIndex ? 0 : oldIndex + 1; + let newIndex: number = oldIndex; + + switch (reorder) { + case "top": + newIndex = 0; + break; + case "bottom": + newIndex = lastIndex; + break; + case "up": + newIndex = oldIndex === 0 ? 0 : oldIndex - 1; + break; + case "down": + newIndex = oldIndex === lastIndex ? lastIndex : oldIndex + 1; + break; } this.disconnectObserver(); - handle.blurUnselectDisabled = true; const referenceEl = - (direction === "up" && newIndex !== lastIndex) || (direction === "down" && newIndex === 0) + reorder === "up" || reorder === "top" ? sameParentItems[newIndex] : sameParentItems[newIndex].nextSibling; @@ -1046,7 +1115,5 @@ export class List newIndex, oldIndex, }); - - handle.setFocus().then(() => (handle.blurUnselectDisabled = false)); } } diff --git a/packages/calcite-components/src/components/sort-handle/interfaces.ts b/packages/calcite-components/src/components/sort-handle/interfaces.ts new file mode 100644 index 00000000000..55c3ce7d933 --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/interfaces.ts @@ -0,0 +1,15 @@ +export type Reorder = "up" | "down" | "top" | "bottom"; + +export interface MoveTo { + element: HTMLElement; + id: string; + label: string; +} + +export interface ReorderEventDetail { + reorder: Reorder; +} + +export interface MoveEventDetail { + moveTo: MoveTo; +} diff --git a/packages/calcite-components/src/components/sort-handle/readme.md b/packages/calcite-components/src/components/sort-handle/readme.md new file mode 100644 index 00000000000..35535a556de --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/readme.md @@ -0,0 +1,3 @@ +# calcite-sort-handle + + diff --git a/packages/calcite-components/src/components/sort-handle/resources.ts b/packages/calcite-components/src/components/sort-handle/resources.ts new file mode 100644 index 00000000000..c8769c5ff7d --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/resources.ts @@ -0,0 +1,19 @@ +import { Reorder } from "./interfaces"; + +export const CSS = { + handle: "handle", + dropdown: "dropdown", +} as const; + +export const ICONS = { + drag: "drag", + blank: "blank", +} as const; + +export const SUBSTITUTIONS = { + label: "{label}", + position: "{position}", + total: "{total}", +} as const; + +export const REORDER_VALUES: Reorder[] = ["top", "up", "down", "bottom"] as const; diff --git a/packages/calcite-components/src/components/sort-handle/sort-handle.e2e.ts b/packages/calcite-components/src/components/sort-handle/sort-handle.e2e.ts new file mode 100644 index 00000000000..23bf6186dd8 --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/sort-handle.e2e.ts @@ -0,0 +1,103 @@ +import { newE2EPage } from "@stencil/core/testing"; +import { accessible, disabled, hidden, renders, t9n, openClose } from "../../tests/commonTests"; +import { skipAnimations } from "../../tests/utils"; +import type { SortHandleMessages } from "./assets/sort-handle/t9n"; +import { CSS, REORDER_VALUES, SUBSTITUTIONS } from "./resources"; + +describe("calcite-sort-handle", () => { + describe("renders", () => { + renders("calcite-sort-handle", { display: "flex" }); + }); + + describe("honors hidden attribute", () => { + hidden("calcite-sort-handle"); + }); + + describe("disabled", () => { + disabled(``); + }); + + describe("accessible", () => { + accessible(``); + }); + + it("sets handle tooltip", async () => { + const page = await newE2EPage(); + const label = "Hello World"; + await page.setContent( + ``, + ); + await page.waitForChanges(); + + const handle = await page.find("calcite-sort-handle"); + await handle.callMethod("setFocus"); + const button = await page.find(`calcite-sort-handle >>> .${CSS.handle}`); + const messages: SortHandleMessages = await handle.getProperty("messages"); + + expect(await button.getProperty("title")).toBe( + messages.repositionLabel + .replace(SUBSTITUTIONS.label, label) + .replace(SUBSTITUTIONS.position, "4") + .replace(SUBSTITUTIONS.total, "10"), + ); + }); + + it("fires calciteSortHandleReorder event", async () => { + const page = await newE2EPage(); + await page.setContent(``); + await skipAnimations(page); + + const sortHandle = await page.find("calcite-sort-handle"); + + const calciteSortHandleReorderSpy = await page.spyOnEvent("calciteSortHandleReorder"); + + const action = await page.find(`calcite-sort-handle >>> .${CSS.handle}`); + await action.callMethod("setFocus"); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + expect(await sortHandle.getProperty("open")).toBe(true); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + expect(await calciteSortHandleReorderSpy.lastEvent.detail.reorder).toBe(REORDER_VALUES[0]); + expect(calciteSortHandleReorderSpy).toHaveReceivedEventTimes(1); + }); + + it("fires calciteSortHandleMove event", async () => { + const page = await newE2EPage(); + await page.setContent(``); + await skipAnimations(page); + + const moveToItems = [ + { label: "List 2", id: "list2" }, + { label: "List 3", id: "list3" }, + ]; + + const sortHandle = await page.find("calcite-sort-handle"); + sortHandle.setProperty("moveToItems", moveToItems); + await page.waitForChanges(); + + const calciteSortHandleMoveSpy = await page.spyOnEvent("calciteSortHandleMove"); + + const action = await page.find(`calcite-sort-handle >>> .${CSS.handle}`); + await action.callMethod("setFocus"); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + expect(await sortHandle.getProperty("open")).toBe(true); + + await page.keyboard.press(" "); + await page.waitForChanges(); + expect(await calciteSortHandleMoveSpy.lastEvent.detail.moveTo.id).toBe(moveToItems[1].id); + expect(calciteSortHandleMoveSpy).toHaveReceivedEventTimes(1); + }); + + describe("translation support", () => { + t9n("calcite-sort-handle"); + }); + + describe("openClose", () => { + openClose(``); + }); +}); diff --git a/packages/calcite-components/src/components/sort-handle/sort-handle.scss b/packages/calcite-components/src/components/sort-handle/sort-handle.scss new file mode 100644 index 00000000000..ab962dda2ca --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/sort-handle.scss @@ -0,0 +1,16 @@ +:host { + @apply flex; +} + +.handle { + @apply cursor-move; + --calcite-internal-action-padding-inline: var(--calcite-spacing-xxs); +} + +.dropdown { + block-size: 100%; +} + +@include disabled(); + +@include base-component(); diff --git a/packages/calcite-components/src/components/sort-handle/sort-handle.stories.ts b/packages/calcite-components/src/components/sort-handle/sort-handle.stories.ts new file mode 100644 index 00000000000..efa09476cbb --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/sort-handle.stories.ts @@ -0,0 +1,65 @@ +import { html } from "../../../support/formatting"; + +export default { + title: "Components/SortHandle", + parameters: { + chromatic: { + delay: 500, + }, + }, +}; + +export const closed = (): string => html` + +`; + +export const open = (): string => + html``; + +export const positions = (): string => html` + +
+
+ First Position + +
+
+ Second Position + +
+
+ Second to Last Position + +
+
+ Last Position + +
+
+`; + +export const withItems = (): string => html` +
+ +
+ +`; + +export const disabled = (): string => html` + +`; diff --git a/packages/calcite-components/src/components/sort-handle/sort-handle.tsx b/packages/calcite-components/src/components/sort-handle/sort-handle.tsx new file mode 100644 index 00000000000..f8481208e38 --- /dev/null +++ b/packages/calcite-components/src/components/sort-handle/sort-handle.tsx @@ -0,0 +1,431 @@ +import { + Event, + Component, + Element, + EventEmitter, + h, + Method, + Prop, + State, + VNode, + Watch, +} from "@stencil/core"; +import { + componentFocusable, + LoadableComponent, + setComponentLoaded, + setUpLoadableComponent, +} from "../../utils/loadable"; +import { connectLocalized, disconnectLocalized } from "../../utils/locale"; +import { + connectMessages, + disconnectMessages, + setUpMessages, + T9nComponent, + updateMessages, +} from "../../utils/t9n"; +import { + InteractiveComponent, + InteractiveContainer, + updateHostInteraction, +} from "../../utils/interactive"; +import { Scale } from "../interfaces"; +import { FlipPlacement, MenuPlacement, OverlayPositioning } from "../../components"; +import { defaultMenuPlacement } from "../../utils/floating-ui"; +import { SortHandleMessages } from "./assets/sort-handle/t9n"; +import { CSS, ICONS, REORDER_VALUES, SUBSTITUTIONS } from "./resources"; +import { MoveEventDetail, MoveTo, Reorder, ReorderEventDetail } from "./interfaces"; + +@Component({ + tag: "calcite-sort-handle", + styleUrl: "sort-handle.scss", + shadow: true, + assetsDirs: ["assets"], +}) +export class SortHandle implements LoadableComponent, T9nComponent, InteractiveComponent { + // -------------------------------------------------------------------------- + // + // Properties + // + // -------------------------------------------------------------------------- + + /** + * When `true`, interaction is prevented and the component is displayed with lower opacity. + */ + @Prop({ reflect: true }) disabled = false; + + /** + * Specifies the component's fallback `calcite-dropdown-item` `placement` when it's initial or specified `placement` has insufficient space available. + */ + @Prop() flipPlacements: FlipPlacement[]; + + /** + * Specifies the maximum number of `calcite-dropdown-item`s to display before showing a scroller. + * Value must be greater than `0`, and does not include `groupTitle`'s from `calcite-dropdown-group`. + */ + @Prop({ reflect: true }) maxItems = 0; + + /** + * Determines the type of positioning to use for the overlaid content. + * + * Using `"absolute"` will work for most cases. The component will be positioned inside of overflowing parent containers and will affect the container's layout. + * + * `"fixed"` should be used to escape an overflowing parent container, or when the reference element's `position` CSS property is `"fixed"`. + * + */ + @Prop({ reflect: true }) overlayPositioning: OverlayPositioning = "absolute"; + + /** + * Determines where the component will be positioned relative to the container element. + * + * @default "bottom-start" + */ + @Prop({ reflect: true }) placement: MenuPlacement = defaultMenuPlacement; + + /** + * Made into a prop for testing purposes only. + * + * @internal + * @readonly + */ + // eslint-disable-next-line @stencil-community/strict-mutable -- updated by t9n module + @Prop({ mutable: true }) messages: SortHandleMessages; + + /** + * Specifies the label of the component. + */ + @Prop() label: string; + + /** + * Use this property to override individual strings used by the component. + */ + // eslint-disable-next-line @stencil-community/strict-mutable -- updated by t9n module + @Prop({ mutable: true }) messageOverrides: Partial; + + @Watch("messageOverrides") + onMessagesChange(): void { + /* wired up by t9n util */ + } + + /** + * Defines the "Move to" items. + */ + @Prop() moveToItems: MoveTo[]; + + /** + * When `true`, displays and positions the component. + */ + @Prop({ reflect: true, mutable: true }) open = false; + + @Watch("open") + openHandler(): void { + if (this.disabled) { + this.open = false; + return; + } + + // we set the property instead of the attribute to ensure dropdown's open/close events are emitted properly + this.dropdownEl.open = this.open; + } + + /** Specifies the size of the component. */ + @Prop({ reflect: true }) scale: Scale = "m"; + + /** + * The current position of the handle. + */ + @Prop() setPosition: number; + + /** + * The total number of sortable items. + */ + @Prop() setSize: number; + + /** + * Specifies the width of the component. + */ + @Prop({ reflect: true }) widthScale: Scale; + + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + connectedCallback(): void { + connectMessages(this); + connectLocalized(this); + } + + async componentWillLoad(): Promise { + setUpLoadableComponent(this); + await setUpMessages(this); + } + + componentDidLoad(): void { + setComponentLoaded(this); + } + + componentDidRender(): void { + updateHostInteraction(this); + } + + disconnectedCallback(): void { + disconnectMessages(this); + disconnectLocalized(this); + } + + // -------------------------------------------------------------------------- + // + // Private Properties + // + // -------------------------------------------------------------------------- + + @Element() el: HTMLCalciteSortHandleElement; + + @State() defaultMessages: SortHandleMessages; + + @State() effectiveLocale: string; + + @Watch("effectiveLocale") + effectiveLocaleChange(): void { + updateMessages(this, this.effectiveLocale); + } + + dropdownEl: HTMLCalciteDropdownElement; + + // -------------------------------------------------------------------------- + // + // Events + // + // -------------------------------------------------------------------------- + + /** Fires when the component is requested to be closed and before the closing transition begins. */ + @Event({ cancelable: false }) calciteSortHandleBeforeClose: EventEmitter; + + /** Fires when the component is added to the DOM but not rendered, and before the opening transition begins. */ + @Event({ cancelable: false }) calciteSortHandleBeforeOpen: EventEmitter; + + /** + * Fires when a reorder has been selected. + */ + @Event({ cancelable: false }) calciteSortHandleReorder: EventEmitter; + + /** + * Fires when a move item has been selected. + */ + @Event({ cancelable: false }) calciteSortHandleMove: EventEmitter; + + /** Fires when the component is closed and animation is complete. */ + @Event({ cancelable: false }) calciteSortHandleClose: EventEmitter; + + /** Fires when the component is open and animation is complete. */ + @Event({ cancelable: false }) calciteSortHandleOpen: EventEmitter; + + // -------------------------------------------------------------------------- + // + // Methods + // + // -------------------------------------------------------------------------- + + /** Sets focus on the component. */ + @Method() + async setFocus(): Promise { + await componentFocusable(this); + this.dropdownEl?.setFocus(); + } + + // -------------------------------------------------------------------------- + // + // Private Methods + // + // -------------------------------------------------------------------------- + + private setDropdownEl = (el: HTMLCalciteDropdownElement): void => { + this.dropdownEl = el; + this.openHandler(); + }; + + private getLabel(): string { + const { label, messages, setPosition, setSize } = this; + + let formattedLabel = label + ? messages.repositionLabel.replace(SUBSTITUTIONS.label, label) + : messages.reposition; + + formattedLabel = formattedLabel.replace( + SUBSTITUTIONS.position, + setPosition ? setPosition.toString() : "", + ); + + return formattedLabel.replace(SUBSTITUTIONS.total, setSize ? setSize.toString() : ""); + } + + private handleBeforeOpen = (event: CustomEvent): void => { + event.stopPropagation(); + this.calciteSortHandleBeforeOpen.emit(); + }; + + private handleOpen = (event: CustomEvent): void => { + event.stopPropagation(); + this.calciteSortHandleOpen.emit(); + this.open = true; + }; + + private handleBeforeClose = (event: CustomEvent): void => { + event.stopPropagation(); + this.calciteSortHandleBeforeClose.emit(); + }; + + private handleClose = (event: CustomEvent): void => { + event.stopPropagation(); + this.calciteSortHandleClose.emit(); + this.open = false; + }; + + private handleReorder = (event: Event): void => { + this.calciteSortHandleReorder.emit({ + reorder: (event.target as HTMLElement).dataset.value as Reorder, + }); + }; + + private handleMoveTo = (event: Event): void => { + const id = (event.target as HTMLElement).dataset.id; + const moveTo = this.moveToItems.find((item) => item.id === id); + this.calciteSortHandleMove.emit({ moveTo }); + }; + + // -------------------------------------------------------------------------- + // + // Render Methods + // + // -------------------------------------------------------------------------- + + render(): VNode { + const { + disabled, + flipPlacements, + messages, + open, + overlayPositioning, + placement, + scale, + setPosition, + setSize, + widthScale, + } = this; + const text = this.getLabel(); + + const isDisabled = disabled || !setPosition || !setSize; + + return ( + + + + + {this.renderTop()} + {this.renderUp()} + {this.renderDown()} + {this.renderBottom()} + + {this.renderMoveToGroup()} + + + ); + } + + private renderMoveToItem(moveToItem: MoveTo): VNode { + return ( + + {moveToItem.label} + + ); + } + + private renderMoveToGroup(): VNode { + const { messages, moveToItems, scale } = this; + + return moveToItems?.length ? ( + + {moveToItems.map((moveToItem) => this.renderMoveToItem(moveToItem))} + + ) : null; + } + + private renderDropdownItem(positionIndex: number, label: string): VNode { + return ( + + {label} + + ); + } + + private renderTop(): VNode | null { + const { setPosition } = this; + + return setPosition !== 1 && setPosition !== 2 + ? this.renderDropdownItem(0, this.messages.moveToTop) + : null; + } + + private renderUp(): VNode | null { + return this.setPosition !== 1 ? this.renderDropdownItem(1, this.messages.moveUp) : null; + } + + private renderDown(): VNode | null { + return this.setPosition !== this.setSize + ? this.renderDropdownItem(2, this.messages.moveDown) + : null; + } + + private renderBottom(): VNode | null { + const { setPosition, setSize } = this; + + return setPosition !== setSize && setPosition !== setSize - 1 + ? this.renderDropdownItem(3, this.messages.moveToBottom) + : null; + } +} diff --git a/packages/calcite-components/src/demos/list.html b/packages/calcite-components/src/demos/list.html index 330842570b0..59560609f66 100644 --- a/packages/calcite-components/src/demos/list.html +++ b/packages/calcite-components/src/demos/list.html @@ -813,13 +813,13 @@

List

nested & draggable
- + - + - + - + diff --git a/packages/calcite-components/src/demos/sort-handle.html b/packages/calcite-components/src/demos/sort-handle.html new file mode 100644 index 00000000000..24477de930b --- /dev/null +++ b/packages/calcite-components/src/demos/sort-handle.html @@ -0,0 +1,36 @@ + + + + + + + Calcite Components: calcite-sort-handle + + + + + + +
+
+

sort-handle

+ + + +
+
+
+ + diff --git a/packages/calcite-components/src/index.html b/packages/calcite-components/src/index.html index e51e398af44..461faf072d2 100644 --- a/packages/calcite-components/src/index.html +++ b/packages/calcite-components/src/index.html @@ -419,6 +419,13 @@

Calcite demo

+ +
diff --git a/packages/calcite-components/src/utils/sortableComponent.ts b/packages/calcite-components/src/utils/sortableComponent.ts index 6da6c5830b5..d0f5581a761 100644 --- a/packages/calcite-components/src/utils/sortableComponent.ts +++ b/packages/calcite-components/src/utils/sortableComponent.ts @@ -1,6 +1,13 @@ import Sortable from "sortablejs"; const sortableComponentSet = new Set(); +export interface MoveDetail { + toEl: HTMLElement; + fromEl: HTMLElement; + dragEl: HTMLElement; + relatedEl: HTMLElement; +} + export interface DragDetail { toEl: HTMLElement; fromEl: HTMLElement; @@ -75,6 +82,11 @@ export interface SortableComponent { */ onDragEnd: (detail: DragDetail) => void; + /** + * Called when a component's dragging ends. + */ + onDragMove?: (detail: MoveDetail) => void; + /** * Called when a component's dragging starts. */ @@ -132,6 +144,13 @@ export function connectSortableComponent(component: SortableComponent): void { }), }, }), + onMove: ({ from: fromEl, dragged: dragEl, to: toEl, related: relatedEl }) => { + if (!component.onDragMove) { + return; + } + + component.onDragMove({ fromEl, dragEl, toEl, relatedEl }); + }, handle, filter: `${handle}[disabled]`, onStart: ({ from: fromEl, item: dragEl, to: toEl, newIndex, oldIndex }) => {