@oscarpalmer/oui 0.20.0 → 0.21.0

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.
@@ -0,0 +1,260 @@
1
+ import type {EventPosition, PlainObject} from '@oscarpalmer/atoms/models';
2
+ import {getPosition, on} from '@oscarpalmer/toretto/event';
3
+ import {attributable} from './internal/attributable';
4
+ import {
5
+ addDraggable,
6
+ Draggable,
7
+ removeDraggable,
8
+ type DraggableBound,
9
+ type DraggableState,
10
+ type DragMovePosition,
11
+ } from './internal/draggable';
12
+
13
+ // #region Types
14
+
15
+ export class Movable extends Draggable {
16
+ inset: string;
17
+
18
+ moved = false;
19
+
20
+ offset: Partial<EventPosition> | undefined;
21
+
22
+ position: string;
23
+
24
+ get dragged(): HTMLElement {
25
+ return this.element;
26
+ }
27
+
28
+ get origin(): HTMLElement | undefined {
29
+ return this.element;
30
+ }
31
+
32
+ constructor(element: HTMLElement) {
33
+ super('move', element, {
34
+ container: {
35
+ attribute: ATTRIBUTE_CONTAINER,
36
+ map: CONTAINERS,
37
+ onDestroy: destroyContainer,
38
+ onInitialize: initializeContainer,
39
+ },
40
+ direction: {
41
+ attribute: ATTRIBUTE_DIRECTION,
42
+ onAfter: afterDirection,
43
+ },
44
+ drag: {
45
+ onBegin,
46
+ onCancel,
47
+ onEnd,
48
+ onMove,
49
+ },
50
+ position: {
51
+ getOffset: getOffsetPosition,
52
+ getOriginal: getOriginalPosition,
53
+ },
54
+ });
55
+
56
+ this.hasHandles = element.querySelector(SELECTOR_HANDLE) != null;
57
+
58
+ this.inset = element.style.inset;
59
+ this.position = element.style.position;
60
+ }
61
+
62
+ override destroy(): void {
63
+ super.destroy();
64
+
65
+ MOVED.delete(this);
66
+
67
+ this.element.style.inset = this.inset;
68
+ this.element.style.position = this.position;
69
+ }
70
+ }
71
+
72
+ // #endregion
73
+
74
+ // #region Functions
75
+
76
+ function afterDirection(draggable: Draggable): void {
77
+ const container = draggable.container?.getBoundingClientRect();
78
+
79
+ if (container != null) {
80
+ setOffset(draggable as Movable, container);
81
+ }
82
+ }
83
+
84
+ function destroyContainer(_: Draggable, container: HTMLElement, empty: boolean): void {
85
+ if (empty) {
86
+ resizer.unobserve(container);
87
+ }
88
+ }
89
+
90
+ function getOffsetPosition(
91
+ state: DraggableState,
92
+ _: Draggable,
93
+ __: HTMLElement,
94
+ position: EventPosition,
95
+ ): EventPosition {
96
+ return {
97
+ x: position.x - (state.element?.rectangle.left ?? 0),
98
+ y: position.y - (state.element?.rectangle.top ?? 0),
99
+ };
100
+ }
101
+
102
+ function getOriginalPosition(state: DraggableState): EventPosition {
103
+ return {
104
+ x: state.element?.rectangle.left ?? 0,
105
+ y: state.element?.rectangle.top ?? 0,
106
+ };
107
+ }
108
+
109
+ function initializeContainer(draggable: Draggable): void {
110
+ resizer.observe(draggable.container!);
111
+ }
112
+
113
+ function onBegin(): void {}
114
+
115
+ function onCancel(state: DraggableState, draggable: Draggable): void {
116
+ const movable = draggable as Movable;
117
+
118
+ const {element, inset, moved, position} = movable;
119
+ const {x, y} = state.original ?? {x: 0, y: 0};
120
+
121
+ if (moved) {
122
+ element.style.inset = `${y}px auto auto ${x}px`;
123
+ } else {
124
+ element.style.position = position;
125
+ element.style.inset = inset;
126
+ }
127
+ }
128
+
129
+ function onEnd(
130
+ event: MouseEvent | TouchEvent,
131
+ state: DraggableState,
132
+ draggable: Draggable,
133
+ reset: () => void,
134
+ ): void {
135
+ const movable = draggable as Movable;
136
+
137
+ if (!movable.moved) {
138
+ movable.moved = true;
139
+
140
+ if (movable.container != null) {
141
+ MOVED.add(movable);
142
+ }
143
+ }
144
+
145
+ const container = movable.container?.getBoundingClientRect();
146
+
147
+ if (container != null) {
148
+ setOffset(movable, container);
149
+ }
150
+
151
+ const end = new CustomEvent(EVENT_END, {
152
+ detail: {
153
+ from: {...state.original},
154
+ to: getPosition(event),
155
+ },
156
+ cancelable: true,
157
+ });
158
+
159
+ movable.element.dispatchEvent(end);
160
+
161
+ setTimeout(() => {
162
+ if (end.defaultPrevented) {
163
+ onCancel(state, draggable);
164
+ }
165
+
166
+ reset();
167
+ });
168
+ }
169
+
170
+ function onMove(
171
+ _: MouseEvent | TouchEvent,
172
+ state: DraggableState,
173
+ __: Draggable,
174
+ position: DragMovePosition,
175
+ ): PlainObject | undefined {
176
+ return {
177
+ from: {...state.original},
178
+ to: {...position.calculated},
179
+ };
180
+ }
181
+
182
+ function onResize(entries: ResizeObserverEntry[]): void {
183
+ const {length} = entries;
184
+
185
+ for (let index = 0; index < length; index += 1) {
186
+ const entry = entries[index];
187
+ const set = CONTAINERS.get(entry.target as HTMLElement);
188
+
189
+ if (set == null) {
190
+ continue;
191
+ }
192
+
193
+ for (const movable of set) {
194
+ reposition(movable);
195
+ }
196
+ }
197
+ }
198
+
199
+ function onScroll(): void {
200
+ for (const moved of MOVED) {
201
+ reposition(moved);
202
+ }
203
+ }
204
+
205
+ function reposition(movable: Movable): void {
206
+ if (!movable.moved || movable.container == null) {
207
+ return;
208
+ }
209
+
210
+ const container = movable.container.getBoundingClientRect();
211
+
212
+ const x = container.left + (movable.offset?.x ?? 0);
213
+ const y = container.top + (movable.offset?.y ?? 0);
214
+
215
+ movable.element.style.inset = `${y}px auto auto ${x}px`;
216
+ }
217
+
218
+ function setOffset(movable: Movable, container: DOMRect): void {
219
+ movable.offset = {};
220
+
221
+ const element = movable.element.getBoundingClientRect();
222
+
223
+ movable.offset.y = element.top - container.top;
224
+ movable.offset.x = element.left - container.left;
225
+ }
226
+
227
+ // #endregion
228
+
229
+ // #region Variables
230
+
231
+ const ATTRIBUTE = 'oui-movable';
232
+
233
+ const ATTRIBUTE_CONTAINER = `${ATTRIBUTE}-container`;
234
+
235
+ const ATTRIBUTE_DIRECTION = `${ATTRIBUTE}-direction`;
236
+
237
+ const CONTAINERS = new Map<HTMLElement, Set<Movable>>();
238
+
239
+ const EVENT_END = 'move:end';
240
+
241
+ const MOVED = new Set<Movable>();
242
+
243
+ const SELECTOR_HANDLE = `[${ATTRIBUTE}-handle]`;
244
+
245
+ const bound: DraggableBound = {
246
+ constructor: Movable,
247
+ property: 'movable',
248
+ };
249
+
250
+ const resizer = new ResizeObserver(onResize);
251
+
252
+ // #endregion
253
+
254
+ // #region Initialization
255
+
256
+ attributable(ATTRIBUTE, addDraggable.bind(bound), removeDraggable.bind(bound));
257
+
258
+ on(document, 'scroll', onScroll);
259
+
260
+ // #endregion
package/src/js/popover.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {isNullableOrWhitespace} from '@oscarpalmer/atoms/is';
2
2
  import {getFocusable} from '@oscarpalmer/toretto/focusable';
3
- import {Floatable} from './floatable';
3
+ import {Floatable} from './internal/floatable';
4
4
  import {createFocusTrap, type FocusTrap} from './focus-trap/embedded';
5
5
 
6
6
  declare global {
@@ -0,0 +1,467 @@
1
+ import type {EventPosition, PlainObject} from '@oscarpalmer/atoms/models';
2
+ import {createElement} from '@oscarpalmer/toretto/create';
3
+ import {getPosition} from '@oscarpalmer/toretto/event';
4
+ import {findAncestor, getElementFromPosition} from '@oscarpalmer/toretto/find';
5
+ import {attributable} from './internal/attributable';
6
+ import {
7
+ addDraggable,
8
+ Draggable,
9
+ removeDraggable,
10
+ type DraggableBound,
11
+ type DraggableState,
12
+ type DragMovePosition,
13
+ } from './internal/draggable';
14
+
15
+ // #region Types
16
+
17
+ export class Sortable extends Draggable {
18
+ connections: string | null;
19
+
20
+ origin: HTMLElement | undefined;
21
+
22
+ get dragged(): HTMLElement {
23
+ return getDragPlaceholder();
24
+ }
25
+
26
+ constructor(element: HTMLElement) {
27
+ super('sort', element, {
28
+ container: {
29
+ attribute: ATTRIBUTE_CONTAINER,
30
+ map: CONTAINERS,
31
+ },
32
+ direction: {
33
+ attribute: ATTRIBUTE_DIRECTION,
34
+ },
35
+ drag: {
36
+ onBegin,
37
+ onCancel,
38
+ onEnd,
39
+ onMove,
40
+ },
41
+ position: {
42
+ getOffset: getOffsetPosition,
43
+ getOriginal: getOriginalPosition,
44
+ },
45
+ });
46
+
47
+ this.connections = this.element.getAttribute(ATTRIBUTE_CONNECTIONS);
48
+ }
49
+ }
50
+
51
+ type SortablePlaceholder = {
52
+ drag: SortablePlaceholderDrag;
53
+ position: HTMLElement | undefined;
54
+ };
55
+
56
+ type SortablePlaceholderDrag = {
57
+ custom: HTMLElement | undefined;
58
+ default: HTMLElement | undefined;
59
+ };
60
+
61
+ // #endregion
62
+
63
+ // #region Functions
64
+
65
+ function afterEnd(
66
+ insert: 'append' | 'insert' | 'prepend' | undefined,
67
+ from: Sortable,
68
+ events: CustomEvent[],
69
+ elements: {origin?: HTMLElement; target: HTMLElement},
70
+ position: EventPosition | undefined,
71
+ valid: boolean,
72
+ ): void {
73
+ elements.origin?.removeAttribute(ATTRIBUTE_ORIGIN);
74
+
75
+ from.origin = undefined;
76
+
77
+ resetPlaceholders();
78
+
79
+ if (!valid || events.some(event => event.defaultPrevented)) {
80
+ return;
81
+ }
82
+
83
+ if (insert !== 'insert') {
84
+ elements.target.append(elements.origin!);
85
+
86
+ return;
87
+ }
88
+
89
+ if (position == null) {
90
+ return;
91
+ }
92
+
93
+ const {height, top} = elements.target.getBoundingClientRect();
94
+
95
+ let insertion: 'afterend' | 'beforebegin';
96
+
97
+ if (position.y < top + height / 2) {
98
+ insertion = INSERT_BEFORE;
99
+ } else {
100
+ insertion = INSERT_AFTER;
101
+ }
102
+
103
+ elements.target?.insertAdjacentElement(insertion, elements.origin!);
104
+
105
+ document.body.removeAttribute(ATTRIBUTE_ACTIVE);
106
+ }
107
+
108
+ function getDragPlaceholder(): HTMLElement {
109
+ placeholder.drag.default ??= createElement('div');
110
+
111
+ return placeholder.drag.custom ?? placeholder.drag.default;
112
+ }
113
+
114
+ function getOffsetPosition(
115
+ _: DraggableState,
116
+ draggable: Draggable,
117
+ element: HTMLElement,
118
+ position: EventPosition,
119
+ ): EventPosition {
120
+ const rectangle = element.getBoundingClientRect();
121
+
122
+ return {
123
+ x: draggable.horizontal ? 0 : position.x - rectangle.left,
124
+ y: draggable.vertical ? 0 : position.y - rectangle.top,
125
+ };
126
+ }
127
+
128
+ function getOriginalPosition(
129
+ _: DraggableState,
130
+ __: Draggable,
131
+ position: EventPosition,
132
+ ): EventPosition {
133
+ return position;
134
+ }
135
+
136
+ function getPositionPlaceholder(sortable: Sortable): HTMLElement {
137
+ placeholder.position ??= createElement('div');
138
+
139
+ placeholder.position.setAttribute(ATTRIBUTE_PLACEHOLDER_POSITION, '');
140
+
141
+ if (sortable.origin != null) {
142
+ placeholder.position.style.setProperty(
143
+ '--placeholder-height',
144
+ `${sortable.origin.getBoundingClientRect().height}px`,
145
+ );
146
+ }
147
+
148
+ return placeholder.position;
149
+ }
150
+
151
+ function onBegin(
152
+ _: MouseEvent | TouchEvent,
153
+ state: DraggableState,
154
+ draggable: Draggable,
155
+ element: HTMLElement,
156
+ ): void {
157
+ const sortable = draggable as Sortable;
158
+
159
+ sortable.origin = element;
160
+
161
+ setPlaceholder(state, sortable, element);
162
+
163
+ element.setAttribute(ATTRIBUTE_ORIGIN, '');
164
+
165
+ document.body.setAttribute(ATTRIBUTE_ACTIVE, '');
166
+ }
167
+
168
+ function onCancel(_: DraggableState, draggable: Draggable): void {
169
+ const sortable = draggable as Sortable;
170
+
171
+ const {origin} = sortable;
172
+
173
+ origin?.removeAttribute(ATTRIBUTE_ORIGIN);
174
+
175
+ document.body.removeAttribute(ATTRIBUTE_ACTIVE);
176
+
177
+ resetPlaceholders();
178
+
179
+ sortable.origin = undefined;
180
+ }
181
+
182
+ function onEnd(
183
+ event: MouseEvent | TouchEvent,
184
+ state: DraggableState,
185
+ draggable: Draggable,
186
+ reset: () => void,
187
+ ): void {
188
+ const from = draggable as Sortable;
189
+ const {origin} = from;
190
+
191
+ const position = getPosition(event) ?? {x: 0, y: 0};
192
+
193
+ const target = getElementFromPosition(position)[0];
194
+
195
+ let element = findAncestor(target, SELECTOR_ITEM) as HTMLElement;
196
+
197
+ if (element?.hasAttribute(ATTRIBUTE_PLACEHOLDER_POSITION)) {
198
+ const position = element.getAttribute(ATTRIBUTE_PLACEHOLDER_POSITION);
199
+
200
+ if (position === 'after') {
201
+ element = element.previousElementSibling as HTMLElement;
202
+ } else {
203
+ element = element.nextElementSibling as HTMLElement;
204
+ }
205
+ }
206
+
207
+ let insert: 'append' | 'insert' | 'prepend' | undefined = 'insert';
208
+ let to: Sortable | undefined;
209
+
210
+ if (element == null) {
211
+ element = findAncestor(target, SELECTOR) as HTMLElement;
212
+
213
+ to = state.instances.get(element)?.sortable;
214
+
215
+ insert = element == null ? undefined : element.children.length === 0 ? 'append' : 'prepend';
216
+ } else {
217
+ to = state.instances.get(element.parentElement!)?.sortable;
218
+ }
219
+
220
+ const valid = insert != null && validatePosition(state, from, to, position);
221
+
222
+ const detail = valid
223
+ ? {
224
+ from: {
225
+ element: origin,
226
+ position: {...state.original},
227
+ },
228
+ to: {
229
+ element: element,
230
+ position: {...position},
231
+ },
232
+ }
233
+ : undefined;
234
+
235
+ const options = {
236
+ detail,
237
+ bubbles: false,
238
+ cancelable: true,
239
+ };
240
+
241
+ const fromEvent = new CustomEvent(EVENT_END, options);
242
+ const toEvent = new CustomEvent(EVENT_END, options);
243
+
244
+ from.element.dispatchEvent(fromEvent);
245
+
246
+ if (from !== to) {
247
+ to?.element.dispatchEvent(toEvent);
248
+ }
249
+
250
+ setTimeout(() => {
251
+ afterEnd(insert, from, [fromEvent, toEvent], {origin, target: element}, position, valid);
252
+
253
+ reset();
254
+ });
255
+ }
256
+
257
+ function onMove(
258
+ event: MouseEvent | TouchEvent,
259
+ state: DraggableState,
260
+ draggable: Draggable,
261
+ position: DragMovePosition,
262
+ ): PlainObject | undefined {
263
+ const from = draggable as Sortable;
264
+
265
+ const fromElement = from.origin;
266
+ const toElement = findAncestor(event, SELECTOR_ITEM) as HTMLElement;
267
+
268
+ let to: Sortable | undefined;
269
+
270
+ if (toElement == null) {
271
+ to = state.instances.get(findAncestor(event, SELECTOR) as HTMLElement)?.sortable;
272
+ } else {
273
+ to = state.instances.get(toElement.parentElement!)?.sortable;
274
+ }
275
+
276
+ if (!validatePosition(state, from, to, position.original)) {
277
+ return;
278
+ }
279
+
280
+ const placeholder = getPositionPlaceholder(from);
281
+
282
+ if (toElement != null && toElement !== fromElement) {
283
+ const {height, top} = toElement.getBoundingClientRect();
284
+
285
+ let place: 'after' | 'before';
286
+
287
+ if (position.calculated.y < top + height / 2) {
288
+ place = 'before';
289
+
290
+ toElement.insertAdjacentElement(INSERT_BEFORE, placeholder);
291
+ } else {
292
+ place = 'after';
293
+
294
+ toElement.insertAdjacentElement(INSERT_AFTER, placeholder);
295
+ }
296
+
297
+ placeholder.setAttribute(ATTRIBUTE_PLACEHOLDER_POSITION, place);
298
+ } else {
299
+ placeholder.remove();
300
+ }
301
+
302
+ return {
303
+ from: {
304
+ element: fromElement,
305
+ position: {...state.original},
306
+ },
307
+ to: {
308
+ element: toElement,
309
+ position: {...position.calculated},
310
+ },
311
+ };
312
+ }
313
+
314
+ function resetPlaceholders(): void {
315
+ placeholder.drag.custom?.remove();
316
+ placeholder.drag.default?.remove();
317
+ placeholder.position?.remove();
318
+
319
+ placeholder.drag.custom = undefined;
320
+ }
321
+
322
+ function setPlaceholder(state: DraggableState, sortable: Sortable, origin: HTMLElement): void {
323
+ const element = getDragPlaceholder();
324
+
325
+ placeholder.drag.default!.innerHTML = '';
326
+
327
+ updatePlaceholder(sortable, element);
328
+
329
+ const event = new CustomEvent(EVENT_PLACEHOLDER, {
330
+ cancelable: true,
331
+ detail: {
332
+ origin,
333
+ parent: sortable.element,
334
+ placeholder: element,
335
+ create: (value: unknown) => {
336
+ if (!(value instanceof HTMLElement)) {
337
+ return;
338
+ }
339
+
340
+ placeholder.drag.custom = value;
341
+
342
+ updatePlaceholder(sortable, value);
343
+
344
+ state.element = {
345
+ rectangle: value.getBoundingClientRect(),
346
+ node: value,
347
+ };
348
+ },
349
+ },
350
+ });
351
+
352
+ sortable.element.dispatchEvent(event);
353
+
354
+ setTimeout(() => {
355
+ if (!event.defaultPrevented && placeholder.drag.custom == null) {
356
+ placeholder.drag.default?.append(element.cloneNode(true));
357
+ }
358
+
359
+ document.body.append(placeholder.drag.custom ?? placeholder.drag.default ?? '');
360
+ });
361
+ }
362
+
363
+ function updatePlaceholder(sortable: Sortable, element: HTMLElement): void {
364
+ element.style.position = 'fixed';
365
+
366
+ element.setAttribute(ATTRIBUTE_PLACEHOLDER_DRAG, '');
367
+
368
+ if (sortable.horizontal) {
369
+ element.setAttribute(ATTRIBUTE_HORIZONTAL, '');
370
+ } else {
371
+ element.removeAttribute(ATTRIBUTE_HORIZONTAL);
372
+ }
373
+
374
+ if (sortable.vertical) {
375
+ element.setAttribute(ATTRIBUTE_VERTICAL, '');
376
+ } else {
377
+ element.removeAttribute(ATTRIBUTE_VERTICAL);
378
+ }
379
+ }
380
+
381
+ function validatePosition(
382
+ _: DraggableState,
383
+ from: Sortable,
384
+ to: Sortable | undefined,
385
+ position: EventPosition,
386
+ ): boolean {
387
+ let valid = true;
388
+
389
+ const rectangle = from.origin?.getBoundingClientRect();
390
+
391
+ if (rectangle != null && !from.horizontal) {
392
+ valid = position.x >= rectangle.left && position.x <= rectangle.right;
393
+ }
394
+
395
+ if (valid && rectangle != null && !from.vertical) {
396
+ valid = position.y >= rectangle.top && position.y <= rectangle.bottom;
397
+ }
398
+
399
+ if (valid && from.connections != null) {
400
+ valid = to?.element.matches(from.connections) ?? true;
401
+ }
402
+
403
+ if (valid && to?.connections != null) {
404
+ valid = from.element.matches(to.connections);
405
+ }
406
+
407
+ return valid;
408
+ }
409
+
410
+ // #endregion
411
+
412
+ // #region Variables
413
+
414
+ const ATTRIBUTE = 'oui-sortable';
415
+
416
+ const ATTRIBUTE_ACTIVE = `${ATTRIBUTE}-active`;
417
+
418
+ const ATTRIBUTE_CONNECTIONS = `${ATTRIBUTE}-connections`;
419
+
420
+ const ATTRIBUTE_CONTAINER = `${ATTRIBUTE}-container`;
421
+
422
+ const ATTRIBUTE_DIRECTION = `${ATTRIBUTE}-direction`;
423
+
424
+ const ATTRIBUTE_HORIZONTAL = `${ATTRIBUTE}-horizontal`;
425
+
426
+ const ATTRIBUTE_ORIGIN = `${ATTRIBUTE}-origin`;
427
+
428
+ const ATTRIBUTE_PLACEHOLDER_DRAG = `${ATTRIBUTE}-placeholder-drag`;
429
+
430
+ const ATTRIBUTE_PLACEHOLDER_POSITION = `${ATTRIBUTE}-placeholder-position`;
431
+
432
+ const ATTRIBUTE_VERTICAL = `${ATTRIBUTE}-vertical`;
433
+
434
+ const CONTAINERS = new Map<HTMLElement, Set<Sortable>>();
435
+
436
+ const EVENT_END = 'sort:end';
437
+
438
+ const EVENT_PLACEHOLDER = 'sort:placeholder';
439
+
440
+ const INSERT_AFTER = 'afterend';
441
+
442
+ const INSERT_BEFORE = 'beforebegin';
443
+
444
+ const SELECTOR = `[${ATTRIBUTE}]`;
445
+
446
+ const SELECTOR_ITEM = `[${ATTRIBUTE}] > *`;
447
+
448
+ const bound: DraggableBound = {
449
+ constructor: Sortable,
450
+ property: 'sortable',
451
+ };
452
+
453
+ const placeholder: SortablePlaceholder = {
454
+ drag: {
455
+ custom: undefined,
456
+ default: undefined,
457
+ },
458
+ position: undefined,
459
+ };
460
+
461
+ // #endregion
462
+
463
+ // #region Initialization
464
+
465
+ attributable(ATTRIBUTE, addDraggable.bind(bound), removeDraggable.bind(bound));
466
+
467
+ // #endregion