@rozie-ui/sortable-list-lit 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/index.cjs +772 -0
- package/dist/index.d.cts +97 -0
- package/dist/index.d.mts +97 -0
- package/dist/index.mjs +744 -0
- package/package.json +72 -0
- package/src/SortableList.ts +419 -0
- package/src/index.ts +2 -0
- package/src/internal/useSortableJS.ts +475 -0
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rozie-ui/sortable-list-lit",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "Idiomatic Lit drag-and-drop sortable list — one Rozie source compiled to Lit via SortableJS.",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"rozie",
|
|
10
|
+
"rozie-ui",
|
|
11
|
+
"sortable",
|
|
12
|
+
"drag-and-drop",
|
|
13
|
+
"sortablejs",
|
|
14
|
+
"list",
|
|
15
|
+
"component",
|
|
16
|
+
"lit"
|
|
17
|
+
],
|
|
18
|
+
"author": "One Learning Community (https://github.com/One-Learning-Community)",
|
|
19
|
+
"homepage": "https://github.com/One-Learning-Community/rozie.js#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/One-Learning-Community/rozie.js.git",
|
|
23
|
+
"directory": "packages/ui/sortable-list/packages/lit"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/One-Learning-Community/rozie.js/issues"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.mjs",
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.mts",
|
|
34
|
+
"import": "./dist/index.mjs",
|
|
35
|
+
"require": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@rozie/runtime-lit": "0.1.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"lit": "^3.2",
|
|
47
|
+
"@lit-labs/preact-signals": "^1.0.0",
|
|
48
|
+
"@preact/signals-core": "^1.3.0",
|
|
49
|
+
"sortablejs": "^1.15"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"lit": {
|
|
53
|
+
"optional": false
|
|
54
|
+
},
|
|
55
|
+
"@lit-labs/preact-signals": {
|
|
56
|
+
"optional": false
|
|
57
|
+
},
|
|
58
|
+
"@preact/signals-core": {
|
|
59
|
+
"optional": false
|
|
60
|
+
},
|
|
61
|
+
"sortablejs": {
|
|
62
|
+
"optional": false
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "tsdown",
|
|
70
|
+
"typecheck": "tsc --noEmit"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { LitElement, css, html } from 'lit';
|
|
2
|
+
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
|
|
3
|
+
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
|
|
4
|
+
import { __rozieReconcileAfterDomMutation, createLitControllableProperty, rozieAttr, rozieClass, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
|
|
5
|
+
import { repeat } from 'lit/directives/repeat.js';
|
|
6
|
+
import { keyed } from 'lit/directives/keyed.js';
|
|
7
|
+
import { useSortableJS } from './internal/useSortableJS';
|
|
8
|
+
|
|
9
|
+
interface RozieDefaultSlotCtx {
|
|
10
|
+
item: unknown;
|
|
11
|
+
index: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@customElement('rozie-sortable-list')
|
|
15
|
+
export default class SortableList extends SignalWatcher(LitElement) {
|
|
16
|
+
static styles = css`
|
|
17
|
+
.rozie-sortable-wrap[data-rozie-s-0af24eae] { display: block; }
|
|
18
|
+
.rozie-sortable-list[data-rozie-s-0af24eae] { display: block; }
|
|
19
|
+
.rozie-sortable-item[data-rozie-s-0af24eae] { display: block; outline: none; }
|
|
20
|
+
.rozie-sortable-item[data-rozie-s-0af24eae]:focus { outline: 2px solid rgba(0, 102, 204, 0.6); outline-offset: -2px; }
|
|
21
|
+
.rozie-sortable-item-lifted[data-rozie-s-0af24eae] {
|
|
22
|
+
background: rgba(0, 102, 204, 0.08);
|
|
23
|
+
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.4) inset;
|
|
24
|
+
}
|
|
25
|
+
.rozie-sortable-aria-live[data-rozie-s-0af24eae] {
|
|
26
|
+
position: absolute;
|
|
27
|
+
width: 1px;
|
|
28
|
+
height: 1px;
|
|
29
|
+
padding: 0;
|
|
30
|
+
margin: -1px;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
clip: rect(0, 0, 0, 0);
|
|
33
|
+
white-space: nowrap;
|
|
34
|
+
border: 0;
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
@property({ type: Array, attribute: 'items' }) _items_attr: any[] = [];
|
|
39
|
+
private _itemsControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'items-change', defaultValue: [], initialControlledValue: undefined });
|
|
40
|
+
@property({ type: String }) itemKey: string | (((...args: unknown[]) => unknown) | null) = null;
|
|
41
|
+
@property({ type: String, reflect: true }) handle: string = null;
|
|
42
|
+
@property({ type: String, reflect: true }) group: string = null;
|
|
43
|
+
@property({ type: Number, reflect: true }) animation: number = 150;
|
|
44
|
+
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
|
|
45
|
+
@property({ type: Boolean, reflect: true }) disableKeyboard: boolean = false;
|
|
46
|
+
@property({ type: Object }) options: any = {};
|
|
47
|
+
@property({ type: Function }) labelFor: ((...args: unknown[]) => unknown) | null = null;
|
|
48
|
+
@property({ type: String, reflect: true }) ghostClass: string = null;
|
|
49
|
+
@property({ type: String, reflect: true }) chosenClass: string = null;
|
|
50
|
+
@property({ type: String, reflect: true }) dragClass: string = null;
|
|
51
|
+
@property({ type: String, reflect: true }) filter: string = null;
|
|
52
|
+
@property({ type: String, reflect: true }) easing: string = null;
|
|
53
|
+
@property({ type: Boolean, reflect: true }) forceFallback: boolean = false;
|
|
54
|
+
@property({ type: Number, reflect: true }) swapThreshold: number = 1;
|
|
55
|
+
@property({ type: Boolean, reflect: true }) cloneable: boolean = false;
|
|
56
|
+
@property({ type: String }) listClass: string | any[] | any = '';
|
|
57
|
+
@property({ type: String }) itemClass: string | any[] | any | (((...args: unknown[]) => unknown) | null) = '';
|
|
58
|
+
@property({ type: String }) itemStyle: string | any | (((...args: unknown[]) => unknown) | null) = null;
|
|
59
|
+
private _liftedIndex = signal(null);
|
|
60
|
+
private _ariaLiveText = signal('');
|
|
61
|
+
@query('[data-rozie-ref="listEl"]') private _refListEl!: HTMLElement;
|
|
62
|
+
@query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
|
|
63
|
+
private __rozieFirstUpdateDone = false;
|
|
64
|
+
|
|
65
|
+
@state() private _hasSlotHeader = false;
|
|
66
|
+
@queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
|
|
67
|
+
@state() private _hasSlotDefault = false;
|
|
68
|
+
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
|
|
69
|
+
@property({ attribute: false }) __rozieDefaultSlot__?: (scope: { item: unknown; index: unknown }) => unknown;
|
|
70
|
+
@state() private _hasSlotFooter = false;
|
|
71
|
+
@queryAssignedElements({ slot: 'footer', flatten: true }) private _slotFooterElements!: Element[];
|
|
72
|
+
|
|
73
|
+
private _disconnectCleanups: Array<() => void> = [];
|
|
74
|
+
// Re-parenting guard: set true once the deferred teardown has actually
|
|
75
|
+
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
|
|
76
|
+
private _rozieTornDown = false;
|
|
77
|
+
|
|
78
|
+
_rozieReconcileSeq = 0;
|
|
79
|
+
|
|
80
|
+
private _armListeners(): void {
|
|
81
|
+
{
|
|
82
|
+
const slotEl = this.shadowRoot?.querySelector('slot[name="header"]');
|
|
83
|
+
if (slotEl !== null && slotEl !== undefined) {
|
|
84
|
+
const update = () => { this._hasSlotHeader = this._slotHeaderElements.length > 0; };
|
|
85
|
+
slotEl.addEventListener('slotchange', update);
|
|
86
|
+
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
|
|
87
|
+
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
|
|
88
|
+
update();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
|
|
94
|
+
if (slotEl !== null && slotEl !== undefined) {
|
|
95
|
+
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
|
|
96
|
+
slotEl.addEventListener('slotchange', update);
|
|
97
|
+
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
|
|
98
|
+
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
|
|
99
|
+
update();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
const slotEl = this.shadowRoot?.querySelector('slot[name="footer"]');
|
|
105
|
+
if (slotEl !== null && slotEl !== undefined) {
|
|
106
|
+
const update = () => { this._hasSlotFooter = this._slotFooterElements.length > 0; };
|
|
107
|
+
slotEl.addEventListener('slotchange', update);
|
|
108
|
+
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
|
|
109
|
+
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
|
|
110
|
+
update();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
connectedCallback(): void {
|
|
116
|
+
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
|
|
117
|
+
this._hasSlotHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'header');
|
|
118
|
+
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
|
|
119
|
+
this._hasSlotFooter = Array.from(this.children).some((el) => el.getAttribute('slot') === 'footer');
|
|
120
|
+
super.connectedCallback();
|
|
121
|
+
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
firstUpdated(): void {
|
|
125
|
+
this._armListeners();
|
|
126
|
+
|
|
127
|
+
this._disconnectCleanups.push((() => this.instance?.destroy()));
|
|
128
|
+
|
|
129
|
+
// Named `sortable` (not `handle`) to avoid shadowing `$props.handle`
|
|
130
|
+
// when the options object below references it.
|
|
131
|
+
const sortable = useSortableJS(this._refListEl, {
|
|
132
|
+
items: () => this.items,
|
|
133
|
+
onCommit: (next: any) => {
|
|
134
|
+
this._itemsControllable.write(next);
|
|
135
|
+
},
|
|
136
|
+
options: {
|
|
137
|
+
animation: this.animation,
|
|
138
|
+
disabled: this.disabled,
|
|
139
|
+
// `cloneable` is a high-level Rozie prop that REPLACES a string
|
|
140
|
+
// `group` with SortableJS's `{ name, pull: 'clone', put: true }`
|
|
141
|
+
// object form. When `cloneable:false`, pass `$props.group` through
|
|
142
|
+
// verbatim. When `cloneable:true` AND `$props.group` is null,
|
|
143
|
+
// leave it null — a clone-mode list without a group name is not
|
|
144
|
+
// meaningful (no peer list can join the cross-list flow).
|
|
145
|
+
group: this.cloneable && typeof this.group === 'string' ? {
|
|
146
|
+
name: this.group,
|
|
147
|
+
pull: 'clone',
|
|
148
|
+
put: true
|
|
149
|
+
} : this.group,
|
|
150
|
+
handle: this.handle,
|
|
151
|
+
ghostClass: this.ghostClass,
|
|
152
|
+
chosenClass: this.chosenClass,
|
|
153
|
+
dragClass: this.dragClass,
|
|
154
|
+
filter: this.filter,
|
|
155
|
+
forceFallback: this.forceFallback,
|
|
156
|
+
swapThreshold: this.swapThreshold,
|
|
157
|
+
easing: this.easing,
|
|
158
|
+
...this.options
|
|
159
|
+
},
|
|
160
|
+
// Lit lit-html `repeat` directive caches its part array by sentinel-
|
|
161
|
+
// comment node identity; SortableJS's physical DOM mutation desyncs
|
|
162
|
+
// that cache. The sigil lowers to `__rozieReconcileAfterDomMutation(this)`
|
|
163
|
+
// on Lit (real call) and `void 0` on the other 5 targets (no-op).
|
|
164
|
+
afterCommit: () => __rozieReconcileAfterDomMutation(this),
|
|
165
|
+
onChange: ({
|
|
166
|
+
kind,
|
|
167
|
+
oldIndex,
|
|
168
|
+
newIndex,
|
|
169
|
+
item
|
|
170
|
+
}: any) => {
|
|
171
|
+
if (kind === 'reorder') this.dispatchEvent(new CustomEvent("change", {
|
|
172
|
+
detail: {
|
|
173
|
+
oldIndex,
|
|
174
|
+
newIndex,
|
|
175
|
+
item
|
|
176
|
+
},
|
|
177
|
+
bubbles: true,
|
|
178
|
+
composed: true
|
|
179
|
+
}));else if (kind === 'add') this.dispatchEvent(new CustomEvent("add", {
|
|
180
|
+
detail: {
|
|
181
|
+
newIndex,
|
|
182
|
+
item
|
|
183
|
+
},
|
|
184
|
+
bubbles: true,
|
|
185
|
+
composed: true
|
|
186
|
+
}));else if (kind === 'remove') this.dispatchEvent(new CustomEvent("remove", {
|
|
187
|
+
detail: {
|
|
188
|
+
oldIndex,
|
|
189
|
+
item
|
|
190
|
+
},
|
|
191
|
+
bubbles: true,
|
|
192
|
+
composed: true
|
|
193
|
+
}));
|
|
194
|
+
},
|
|
195
|
+
onStart: (e: any) => this.dispatchEvent(new CustomEvent("start", {
|
|
196
|
+
detail: e,
|
|
197
|
+
bubbles: true,
|
|
198
|
+
composed: true
|
|
199
|
+
})),
|
|
200
|
+
onEnd: (e: any) => this.dispatchEvent(new CustomEvent("end", {
|
|
201
|
+
detail: e,
|
|
202
|
+
bubbles: true,
|
|
203
|
+
composed: true
|
|
204
|
+
}))
|
|
205
|
+
});
|
|
206
|
+
this.instance = sortable.instance;
|
|
207
|
+
// $onMount's cleanup-return: closing over a setup-local (`sortable`) does
|
|
208
|
+
// not survive the Solid emitter's setup/cleanup split — it scopes cleanup
|
|
209
|
+
// outside the setup IIFE. Closing over `instance` (a module-scope `let`)
|
|
210
|
+
// works on every target.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
214
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('disabled'))) { const __watchVal = (() => this.disabled)(); ((v: any) => this.instance?.option('disabled', v))(__watchVal); }
|
|
215
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('group'))) { const __watchVal = (() => this.group)(); ((v: any) => this.instance?.option('group', v))(__watchVal); }
|
|
216
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('handle'))) { const __watchVal = (() => this.handle)(); ((v: any) => this.instance?.option('handle', v))(__watchVal); }
|
|
217
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('ghostClass'))) { const __watchVal = (() => this.ghostClass)(); ((v: any) => this.instance?.option('ghostClass', v))(__watchVal); }
|
|
218
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('chosenClass'))) { const __watchVal = (() => this.chosenClass)(); ((v: any) => this.instance?.option('chosenClass', v))(__watchVal); }
|
|
219
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('dragClass'))) { const __watchVal = (() => this.dragClass)(); ((v: any) => this.instance?.option('dragClass', v))(__watchVal); }
|
|
220
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('filter'))) { const __watchVal = (() => this.filter)(); ((v: any) => this.instance?.option('filter', v))(__watchVal); }
|
|
221
|
+
if (this.__rozieFirstUpdateDone && (changedProperties.has('easing'))) { const __watchVal = (() => this.easing)(); ((v: any) => this.instance?.option('easing', v))(__watchVal); }
|
|
222
|
+
this.__rozieFirstUpdateDone = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
disconnectedCallback(): void {
|
|
226
|
+
super.disconnectedCallback();
|
|
227
|
+
queueMicrotask(() => {
|
|
228
|
+
if (this.isConnected || this._rozieTornDown) return;
|
|
229
|
+
this._rozieTornDown = true;
|
|
230
|
+
for (const fn of this._disconnectCleanups) fn();
|
|
231
|
+
this._disconnectCleanups = [];
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
|
|
236
|
+
super.attributeChangedCallback(name, old, value);
|
|
237
|
+
if (name === 'items') this._itemsControllable.notifyAttributeChange(value as unknown as any[]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
render() {
|
|
241
|
+
return html`
|
|
242
|
+
<div class="rozie-sortable-wrap" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-0af24eae>
|
|
243
|
+
<div class="${(rozieClass(['rozie-sortable-list', this.listClass]))}" part="list" data-rozie-ref="listEl" data-rozie-s-0af24eae>${keyed(this._rozieReconcileSeq ?? 0, html`
|
|
244
|
+
<slot name="header"></slot>
|
|
245
|
+
${repeat<any>(this.items, (item, index) => this.keyFor(item, index), (item, index) => html`<div class="${(rozieClass(['rozie-sortable-item', this.itemClassFor(item, index), { 'rozie-sortable-item-lifted': this._liftedIndex.value === index }]))}" key=${rozieAttr(this.keyFor(item, index))} style=${rozieStyle(this.itemStyleFor(item, index))} data-id=${rozieAttr(this.keyFor(item, index))} role="listitem" tabindex=${rozieAttr(this.keyboardEnabled() ? 0 : null)} @keydown=${($event: Event) => { this.onRowKeyDown($event, index); }} data-rozie-s-0af24eae>
|
|
246
|
+
${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({item: item, index: index}) : html`<slot data-rozie-params=${(() => { try { return JSON.stringify({item: item, index: index}); } catch { return '{}'; } })()}></slot>`}
|
|
247
|
+
</div>`)}
|
|
248
|
+
<slot name="footer"></slot>
|
|
249
|
+
`)}</div>
|
|
250
|
+
<div class="rozie-sortable-aria-live" data-rozie-sortable-aria-live="" aria-live="polite" aria-atomic="true" data-rozie-s-0af24eae>${this._ariaLiveText.value}</div>
|
|
251
|
+
</div>
|
|
252
|
+
`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
instance: any = null;
|
|
256
|
+
|
|
257
|
+
__rowKey = {
|
|
258
|
+
map: new WeakMap(),
|
|
259
|
+
seq: 0
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
keyFor = (item: any, index: any) => {
|
|
263
|
+
// (a) function itemKey: consumer-supplied (item, index) => key.
|
|
264
|
+
if (typeof this.itemKey === 'function') {
|
|
265
|
+
return this.itemKey(item, index);
|
|
266
|
+
}
|
|
267
|
+
// (b) string itemKey: a property name on a non-null object item.
|
|
268
|
+
if (typeof this.itemKey === 'string' && item !== null && typeof item === 'object' && item[this.itemKey] != null) {
|
|
269
|
+
return item[this.itemKey];
|
|
270
|
+
}
|
|
271
|
+
// (c) id-less object (or function) item: assign-on-first-sight WeakMap
|
|
272
|
+
// synthetic id. Survives reorder because it is keyed by object identity.
|
|
273
|
+
if (item !== null && typeof item === 'object' || typeof item === 'function') {
|
|
274
|
+
if (!this.__rowKey.map.has(item)) {
|
|
275
|
+
this.__rowKey.map.set(item, '__rk' + this.__rowKey.seq++);
|
|
276
|
+
}
|
|
277
|
+
return this.__rowKey.map.get(item);
|
|
278
|
+
}
|
|
279
|
+
// (d) primitive item: fall back to index. NOTE: duplicate primitives are
|
|
280
|
+
// unsafe to reorder this way — pass a function itemKey for those.
|
|
281
|
+
return index;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
itemClassFor = (item: any, index: any) => {
|
|
285
|
+
const v = this.itemClass;
|
|
286
|
+
return typeof v === 'function' ? v(item, index) : v;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
itemStyleFor = (item: any, index: any) => {
|
|
290
|
+
const s = typeof this.itemStyle === 'function' ? this.itemStyle(item, index) : this.itemStyle;
|
|
291
|
+
return s == null || s === '' ? null : s;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
getLabel = (idx: any) => {
|
|
295
|
+
const item = this.items[idx];
|
|
296
|
+
if (this.labelFor !== null) return this.labelFor(item, idx);
|
|
297
|
+
if (item !== null && typeof item === 'object' && 'label' in item) return item.label;
|
|
298
|
+
return String(item);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
keyboardEnabled = () => !this.disabled && !this.disableKeyboard;
|
|
302
|
+
|
|
303
|
+
onRowKeyDown = ($event: any, index: any) => {
|
|
304
|
+
// Defense-in-depth: when keyboard reordering is off the rows carry no
|
|
305
|
+
// tabindex and can't receive focus, but a consumer-focused row (or a
|
|
306
|
+
// programmatic .focus()) must still no-op here rather than reorder.
|
|
307
|
+
if (!this.keyboardEnabled()) return;
|
|
308
|
+
const key = $event.key;
|
|
309
|
+
// Space (' ' on browsers; KeyboardEvent.key === ' ') OR Enter — lift/drop.
|
|
310
|
+
if (key === ' ' || key === 'Spacebar' || key === 'Enter') {
|
|
311
|
+
$event.preventDefault();
|
|
312
|
+
if (this._liftedIndex.value === null) {
|
|
313
|
+
// LIFT
|
|
314
|
+
this._liftedIndex.value = index;
|
|
315
|
+
this._ariaLiveText.value = 'Lifted ' + this.getLabel(index);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// DROP
|
|
319
|
+
const dropped = this.getLabel(this._liftedIndex.value);
|
|
320
|
+
const at = this._liftedIndex.value;
|
|
321
|
+
this._liftedIndex.value = null;
|
|
322
|
+
this._ariaLiveText.value = 'Dropped ' + dropped + ' at position ' + (at + 1);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (key === 'Escape') {
|
|
326
|
+
if (this._liftedIndex.value === null) return;
|
|
327
|
+
$event.preventDefault();
|
|
328
|
+
const cancelled = this.getLabel(this._liftedIndex.value);
|
|
329
|
+
this._liftedIndex.value = null;
|
|
330
|
+
this._ariaLiveText.value = 'Cancelled lift of ' + cancelled;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
|
334
|
+
if (this._liftedIndex.value === null) return;
|
|
335
|
+
$event.preventDefault();
|
|
336
|
+
const dir = key === 'ArrowDown' ? 1 : -1;
|
|
337
|
+
const from = this._liftedIndex.value;
|
|
338
|
+
const to = from + dir;
|
|
339
|
+
if (to < 0 || to >= this.items.length) return;
|
|
340
|
+
const next = [...this.items];
|
|
341
|
+
const [moved] = next.splice(from, 1);
|
|
342
|
+
next.splice(to, 0, moved);
|
|
343
|
+
this._itemsControllable.write(next);
|
|
344
|
+
this._liftedIndex.value = to;
|
|
345
|
+
this._ariaLiveText.value = 'Moved ' + this.getLabel(to) + ' to position ' + (to + 1);
|
|
346
|
+
// After the keyed reorder write, restore focus to the moved row. No-op
|
|
347
|
+
// on React/Vue/Angular (DOM identity preserved); queueMicrotask +
|
|
348
|
+
// querySelectorAll + .focus() on Svelte/Solid/Lit (DOM re-created).
|
|
349
|
+
queueMicrotask(() => (this.renderRoot.querySelectorAll('[role="listitem"]')?.[to] as HTMLElement | undefined)?.focus?.());
|
|
350
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
351
|
+
detail: {
|
|
352
|
+
oldIndex: from,
|
|
353
|
+
newIndex: to,
|
|
354
|
+
item: moved
|
|
355
|
+
},
|
|
356
|
+
bubbles: true,
|
|
357
|
+
composed: true
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
getInstance() {
|
|
363
|
+
return this.instance;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
toArray() {
|
|
367
|
+
return this.instance ? this.instance.toArray() : [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
sort(order: any, useAnimation = true) {
|
|
371
|
+
this.instance?.sort(order, useAnimation);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
option(name: any, value: any) {
|
|
375
|
+
if (!this.instance) return undefined;
|
|
376
|
+
if (value === undefined) return this.instance.option(name);
|
|
377
|
+
this.instance.option(name, value);
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
get items(): any[] { return this._itemsControllable.read(); }
|
|
382
|
+
set items(v: any[]) { this._itemsControllable.notifyPropertyWrite(v); }
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
|
|
386
|
+
* host custom element's attributes on each call so a consumer-side bound
|
|
387
|
+
* attribute flows through on every render. The `rozieSpread` directive
|
|
388
|
+
* (D-02) does the cross-render diff downstream.
|
|
389
|
+
*
|
|
390
|
+
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
|
|
391
|
+
* out so `$attrs` returns "rest after declared props" (semantic parity
|
|
392
|
+
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
|
|
393
|
+
* forms are folded into the skip set: kebab-case for model props
|
|
394
|
+
* (explicit `attribute:`) AND lowercased property name (Lit's default).
|
|
395
|
+
*/
|
|
396
|
+
private get $attrs(): Record<string, string> {
|
|
397
|
+
const __skip = new Set<string>(['items', 'item-key', 'itemkey', 'handle', 'group', 'animation', 'disabled', 'disable-keyboard', 'disablekeyboard', 'options', 'label-for', 'labelfor', 'ghost-class', 'ghostclass', 'chosen-class', 'chosenclass', 'drag-class', 'dragclass', 'filter', 'easing', 'force-fallback', 'forcefallback', 'swap-threshold', 'swapthreshold', 'cloneable', 'list-class', 'listclass', 'item-class', 'itemclass', 'item-style', 'itemstyle']);
|
|
398
|
+
const out: Record<string, string> = {};
|
|
399
|
+
for (const a of Array.from(this.attributes)) {
|
|
400
|
+
if (__skip.has(a.name)) continue;
|
|
401
|
+
out[a.name] = a.value;
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
|
|
408
|
+
* Lit attaches event listeners directly on the host element via
|
|
409
|
+
* `addEventListener` (no per-instance prop rest binding), so the
|
|
410
|
+
* runtime value is undefined; the `rozieListeners` directive's
|
|
411
|
+
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
|
|
412
|
+
* The declaration exists to satisfy `tsc --noEmit` on consumer
|
|
413
|
+
* projects with strict mode — bare `$listeners` in `render()`
|
|
414
|
+
* would otherwise raise TS2304 (Cannot find name).
|
|
415
|
+
*/
|
|
416
|
+
private get $listeners(): Record<string, EventListener> | undefined {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
}
|
package/src/index.ts
ADDED