Skip to content

Commit 0415bc4

Browse files
driskullbenelan
authored andcommitted
feat(list): Add support for dragging items. (#7109)
**Related Issue:** #6554 ## Summary - SortableComponent - Moves all configuration options into the interface so a component can consistently setup SortableJS. - Changes usage of `onUpdate` to `onSort` for moving between lists and getting `calciteListOrderChange` event. - Adds support for `canPut`/`canPull` so users can configure whether an item can be dragged to another list and vice versa. - List - Sets up sorting - keyboard sorting only works within a list. Cannot keyboard sort across lists at this time (including nested lists). - Adds dragHandle rendering to `list-item` - ListItemGroup and List emit an internal event when its default slot changes in order to update whether an expand caret shows or not. - Handle - Updates handle to support displaying ariaLabel (logic taken from value-list) - Handle will emit an internal event for parent components to update an aria-live region. - No breaking changes necessary. We can advise users to nest another `calcite-list` to work with sorting on children. - `calcite-list-item-group` will not be draggable/sortable at this time.
1 parent 5f1e7a7 commit 0415bc4

File tree

21 files changed

+920
-88
lines changed

21 files changed

+920
-88
lines changed
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
{
2-
"dragHandle": "Drag handle"
2+
"dragHandle": "Drag handle",
3+
"dragHandleActive": "Reordering {itemLabel}, current position {position} of {total}.",
4+
"dragHandleChange": "{itemLabel}, new position {position} of {total}. Press space to confirm.",
5+
"dragHandleCommit": "{itemLabel}, current position {position} of {total}.",
6+
"dragHandleIdle": "{itemLabel}, press space and use arrow keys to reorder content. Current position {position} of {total}."
37
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
{
2-
"dragHandle": "Drag handle"
2+
"dragHandle": "Drag handle",
3+
"dragHandleActive": "Reordering {itemLabel}, current position {position} of {total}.",
4+
"dragHandleChange": "{itemLabel}, new position {position} of {total}. Press space to confirm.",
5+
"dragHandleCommit": "{itemLabel}, current position {position} of {total}.",
6+
"dragHandleIdle": "{itemLabel}, press space and use arrow keys to reorder content. Current position {position} of {total}."
37
}

packages/calcite-components/src/components/handle/handle.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
updateMessages,
2727
} from "../../utils/t9n";
2828
import { HandleMessages } from "./assets/handle/t9n";
29-
import { HandleNudge } from "./interfaces";
29+
import { HandleChange, HandleNudge } from "./interfaces";
3030
import { CSS, ICONS } from "./resources";
3131

3232
@Component({
@@ -47,6 +47,21 @@ export class Handle implements LoadableComponent, T9nComponent {
4747
*/
4848
@Prop({ mutable: true, reflect: true }) activated = false;
4949

50+
@Watch("messages")
51+
@Watch("label")
52+
@Watch("activated")
53+
@Watch("setPosition")
54+
@Watch("setSize")
55+
handleAriaTextChange(): void {
56+
const message = this.getAriaText("live");
57+
58+
if (message) {
59+
this.calciteInternalHandleChange.emit({
60+
message,
61+
});
62+
}
63+
}
64+
5065
/**
5166
* Value for the button title attribute
5267
*/
@@ -59,6 +74,27 @@ export class Handle implements LoadableComponent, T9nComponent {
5974
*/
6075
@Prop() messages: HandleMessages;
6176

77+
/**
78+
*
79+
*
80+
* @internal
81+
*/
82+
@Prop() setPosition: number;
83+
84+
/**
85+
*
86+
*
87+
* @internal
88+
*/
89+
@Prop() setSize: number;
90+
91+
/**
92+
*
93+
*
94+
* @internal
95+
*/
96+
@Prop() label: string;
97+
6298
/**
6399
* Use this property to override individual strings used by the component.
64100
*/
@@ -124,6 +160,11 @@ export class Handle implements LoadableComponent, T9nComponent {
124160
*/
125161
@Event({ cancelable: false }) calciteHandleNudge: EventEmitter<HandleNudge>;
126162

163+
/**
164+
* Emitted when the handle is activated or deactivated.
165+
*/
166+
@Event({ cancelable: false }) calciteInternalHandleChange: EventEmitter<HandleChange>;
167+
127168
// --------------------------------------------------------------------------
128169
//
129170
// Methods
@@ -144,6 +185,27 @@ export class Handle implements LoadableComponent, T9nComponent {
144185
//
145186
// --------------------------------------------------------------------------
146187

188+
getAriaText(type: "label" | "live"): string {
189+
const { setPosition, setSize, label, messages, activated } = this;
190+
191+
if (!messages || !label || typeof setSize !== "number" || typeof setPosition !== "number") {
192+
return null;
193+
}
194+
195+
const text =
196+
type === "label"
197+
? activated
198+
? messages.dragHandleChange
199+
: messages.dragHandleIdle
200+
: activated
201+
? messages.dragHandleActive
202+
: messages.dragHandleCommit;
203+
204+
const replacePosition = text.replace("{position}", setPosition.toString());
205+
const replaceLabel = replacePosition.replace("{itemLabel}", label);
206+
return replaceLabel.replace("{total}", setSize.toString());
207+
}
208+
147209
handleKeyDown = (event: KeyboardEvent): void => {
148210
switch (event.key) {
149211
case " ":
@@ -181,13 +243,14 @@ export class Handle implements LoadableComponent, T9nComponent {
181243
return (
182244
// Needs to be a span because of https://github.com/SortableJS/Sortable/issues/1486
183245
<span
246+
aria-label={this.getAriaText("label")}
184247
aria-pressed={toAriaBoolean(this.activated)}
185248
class={{ [CSS.handle]: true, [CSS.handleActivated]: this.activated }}
186249
onBlur={this.handleBlur}
187250
onKeyDown={this.handleKeyDown}
188251
role="button"
189252
tabindex="0"
190-
title={this.messages.dragHandle}
253+
title={this.messages?.dragHandle}
191254
// eslint-disable-next-line react/jsx-sort-props
192255
ref={(el): void => {
193256
this.handleButton = el;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export interface HandleNudge {
22
direction: "up" | "down";
33
}
4+
5+
export interface HandleChange {
6+
message: string;
7+
}

packages/calcite-components/src/components/list-item-group/list-item-group.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { Component, Element, h, Host, Prop, State, VNode } from "@stencil/core";
1+
import {
2+
Component,
3+
Element,
4+
Event,
5+
EventEmitter,
6+
h,
7+
Host,
8+
Prop,
9+
State,
10+
VNode,
11+
} from "@stencil/core";
212
import {
313
connectInteractive,
414
disconnectInteractive,
@@ -34,6 +44,18 @@ export class ListItemGroup implements InteractiveComponent {
3444
*/
3545
@Prop({ reflect: true }) heading: string;
3646

47+
//--------------------------------------------------------------------------
48+
//
49+
// Events
50+
//
51+
//--------------------------------------------------------------------------
52+
53+
/**
54+
* Emitted when the default slot has changes in order to notify parent lists.
55+
*/
56+
@Event({ cancelable: false })
57+
calciteInternalListItemGroupDefaultSlotChange: EventEmitter<DragEvent>;
58+
3759
// --------------------------------------------------------------------------
3860
//
3961
// Lifecycle
@@ -82,8 +104,18 @@ export class ListItemGroup implements InteractiveComponent {
82104
{heading}
83105
</td>
84106
</tr>
85-
<slot />
107+
<slot onSlotchange={this.handleDefaultSlotChange} />
86108
</Host>
87109
);
88110
}
111+
112+
// --------------------------------------------------------------------------
113+
//
114+
// Private Methods
115+
//
116+
// --------------------------------------------------------------------------
117+
118+
private handleDefaultSlotChange = (): void => {
119+
this.calciteInternalListItemGroupDefaultSlotChange.emit();
120+
};
89121
}

packages/calcite-components/src/components/list-item/list-item.e2e.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ describe("calcite-list-item", () => {
4343
propertyName: "open",
4444
defaultValue: false,
4545
},
46+
{
47+
propertyName: "dragHandle",
48+
defaultValue: false,
49+
},
4650
]);
4751
});
4852

@@ -54,6 +58,24 @@ describe("calcite-list-item", () => {
5458
disabled(`<calcite-list-item label="test" active></calcite-list-item>`);
5559
});
5660

61+
it("renders dragHandle when property is true", async () => {
62+
const page = await newE2EPage();
63+
await page.setContent(`<calcite-list-item></calcite-list-item>`);
64+
await page.waitForChanges();
65+
66+
let handleNode = await page.find("calcite-list-item >>> calcite-handle");
67+
68+
expect(handleNode).toBeNull();
69+
70+
const item = await page.find("calcite-list-item");
71+
item.setProperty("dragHandle", true);
72+
await page.waitForChanges();
73+
74+
handleNode = await page.find("calcite-list-item >>> calcite-handle");
75+
76+
expect(handleNode).not.toBeNull();
77+
});
78+
5779
it("renders content node when label is provided", async () => {
5880
const page = await newE2EPage({ html: `<calcite-list-item label="test"></calcite-list-item>` });
5981

packages/calcite-components/src/components/list-item/list-item.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ td:focus {
113113
.content-start,
114114
.content-end,
115115
.selection-container,
116+
.drag-container,
116117
.open-container {
117118
@apply flex items-center;
118119
}

packages/calcite-components/src/components/list-item/list-item.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
EventEmitter,
66
h,
77
Host,
8+
Listen,
89
Method,
910
Prop,
1011
State,
@@ -106,6 +107,13 @@ export class ListItem
106107
this.emitCalciteInternalListItemChange();
107108
}
108109

110+
/**
111+
* When `true`, the component displays a draggable button.
112+
*
113+
* @internal
114+
*/
115+
@Prop() dragHandle = false;
116+
109117
/**
110118
* The label text of the component. Displays above the description text.
111119
*/
@@ -226,6 +234,13 @@ export class ListItem
226234
*/
227235
@Event({ cancelable: false }) calciteInternalListItemChange: EventEmitter<void>;
228236

237+
@Listen("calciteInternalListItemGroupDefaultSlotChange")
238+
@Listen("calciteInternalListDefaultSlotChange")
239+
handleCalciteInternalListDefaultSlotChanges(event: CustomEvent): void {
240+
event.stopPropagation();
241+
this.handleOpenableChange(this.defaultSlotEl);
242+
}
243+
229244
// --------------------------------------------------------------------------
230245
//
231246
// Private Properties
@@ -269,6 +284,14 @@ export class ListItem
269284

270285
actionsEndEl: HTMLTableCellElement;
271286

287+
defaultSlotEl: HTMLSlotElement;
288+
289+
// --------------------------------------------------------------------------
290+
//
291+
// Lifecycle
292+
//
293+
// --------------------------------------------------------------------------
294+
272295
connectedCallback(): void {
273296
connectInteractive(this);
274297
connectLocalized(this);
@@ -354,6 +377,14 @@ export class ListItem
354377
);
355378
}
356379

380+
renderDragHandle(): VNode {
381+
return this.dragHandle ? (
382+
<td class={CSS.dragContainer} key="drag-handle-container">
383+
<calcite-handle label={this.label} setPosition={this.setPosition} setSize={this.setSize} />
384+
</td>
385+
) : null;
386+
}
387+
357388
renderOpen(): VNode {
358389
const { el, open, openable, parentListEl } = this;
359390
const dir = getElementDir(el);
@@ -533,6 +564,7 @@ export class ListItem
533564
// eslint-disable-next-line react/jsx-sort-props
534565
ref={(el) => (this.containerEl = el)}
535566
>
567+
{this.renderDragHandle()}
536568
{this.renderSelected()}
537569
{this.renderOpen()}
538570
{this.renderActionsStart()}
@@ -545,7 +577,10 @@ export class ListItem
545577
[CSS.nestedContainerHidden]: openable && !open,
546578
}}
547579
>
548-
<slot onSlotchange={this.handleDefaultSlotChange} />
580+
<slot
581+
onSlotchange={this.handleDefaultSlotChange}
582+
ref={(el: HTMLSlotElement) => (this.defaultSlotEl = el)}
583+
/>
549584
</div>
550585
</Host>
551586
);
@@ -602,9 +637,13 @@ export class ListItem
602637
}
603638
}
604639

605-
handleDefaultSlotChange = (event: Event): void => {
640+
handleOpenableChange(slotEl: HTMLSlotElement): void {
641+
if (!slotEl) {
642+
return;
643+
}
644+
606645
const { parentListEl } = this;
607-
const listItemChildren = getListItemChildren(event);
646+
const listItemChildren = getListItemChildren(slotEl);
608647
updateListItemChildren(listItemChildren);
609648
const openable = !!listItemChildren.length;
610649

@@ -617,6 +656,10 @@ export class ListItem
617656
if (!openable) {
618657
this.open = false;
619658
}
659+
}
660+
661+
handleDefaultSlotChange = (event: Event): void => {
662+
this.handleOpenableChange(event.target as HTMLSlotElement);
620663
};
621664

622665
toggleOpen = (): void => {

packages/calcite-components/src/components/list-item/resources.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const CSS = {
1717
actionsEnd: "actions-end",
1818
selectionContainer: "selection-container",
1919
openContainer: "open-container",
20+
dragContainer: "drag-container",
2021
};
2122

2223
export const SLOTS = {
@@ -27,7 +28,8 @@ export const SLOTS = {
2728
actionsEnd: "actions-end",
2829
};
2930

30-
export const MAX_COLUMNS = 5;
31+
// Set to zero to extend until the end of the table section.
32+
export const MAX_COLUMNS = 0;
3133

3234
export const ICONS = {
3335
selectedMultiple: "check-circle-f",

packages/calcite-components/src/components/list-item/utils.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Build } from "@stencil/core";
22

3+
const listSelector = "calcite-list";
34
const listItemGroupSelector = "calcite-list-item-group";
45
const listItemSelector = "calcite-list-item";
56

6-
export function getListItemChildren(event: Event): HTMLCalciteListItemElement[] {
7-
const assignedElements = (event.target as HTMLSlotElement).assignedElements({ flatten: true });
7+
export function getListItemChildren(slotEl: HTMLSlotElement): HTMLCalciteListItemElement[] {
8+
const assignedElements = slotEl.assignedElements({ flatten: true });
89

910
const listItemGroupChildren = (
1011
assignedElements.filter((el) => el?.matches(listItemGroupSelector)) as HTMLCalciteListItemGroupElement[]
@@ -16,7 +17,11 @@ export function getListItemChildren(event: Event): HTMLCalciteListItemElement[]
1617
el?.matches(listItemSelector)
1718
) as HTMLCalciteListItemElement[];
1819

19-
return [...listItemGroupChildren, ...listItemChildren];
20+
const listItemListChildren = (assignedElements.filter((el) => el?.matches(listSelector)) as HTMLCalciteListElement[])
21+
.map((list) => Array.from(list.querySelectorAll(listItemSelector)))
22+
.reduce((previousValue, currentValue) => [...previousValue, ...currentValue], []);
23+
24+
return [...listItemListChildren, ...listItemGroupChildren, ...listItemChildren];
2025
}
2126

2227
export function updateListItemChildren(listItemChildren: HTMLCalciteListItemElement[]): void {

0 commit comments

Comments
 (0)