@teammodlr/ui-kit 3.0.0-beta.30 → 3.0.0-beta.34

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,500 @@
1
+ <template>
2
+ <DialogRoot :open="isOpen" :modal="modal" @update:open="handleOpenChange">
3
+ <DialogPortal to="body">
4
+ <Transition
5
+ type="animation"
6
+ :enter-active-class="fade.enterActive"
7
+ :leave-active-class="fade.leaveActive"
8
+ :enter-from-class="fade.enterFrom"
9
+ :leave-to-class="fade.leaveTo"
10
+ >
11
+ <DialogOverlay v-if="isOpen && modal" force-mount :class="style.backdrop" :style="{ zIndex, pointerEvents: 'auto' }" />
12
+ </Transition>
13
+ <Transition
14
+ type="animation"
15
+ :enter-active-class="modalScale.enterActive"
16
+ :leave-active-class="modalScale.leaveActive"
17
+ :enter-from-class="modalScale.enterFrom"
18
+ :leave-to-class="modalScale.leaveTo"
19
+ @after-leave="handleAfterLeave"
20
+ >
21
+ <DialogContent
22
+ v-if="isOpen"
23
+ ref="dialogContent"
24
+ force-mount
25
+ :class="contentClasses"
26
+ :style="contentStyle"
27
+ :disable-outside-pointer-events="modal"
28
+ @open-auto-focus="handleOpenAutoFocus"
29
+ @escape-key-down="handleEscapeKeyDown"
30
+ @pointer-down-outside.prevent
31
+ @focus-outside="handleFocusOutside"
32
+ @interact-outside.prevent
33
+ >
34
+ <FocusScope :trapped="focusTrap" :loop="focusTrap" :class="focusScopeClasses">
35
+ <slot name="backdrop" />
36
+ <div v-if="backdrop && !$slots.backdrop" :class="[style.backdrop, backdropClass]" />
37
+ <div
38
+ ref="panel"
39
+ :class="[style.panel, panelClass]"
40
+ :style="panelComputedStyle"
41
+ @pointerdown="onPanelPointerDown"
42
+ >
43
+ <slot :close="close" />
44
+ </div>
45
+ </FocusScope>
46
+ </DialogContent>
47
+ </Transition>
48
+ </DialogPortal>
49
+ </DialogRoot>
50
+ </template>
51
+ <script lang="ts">
52
+ import { fade, modalScale } from '../../styles/transitions.css';
53
+ import {
54
+ DialogContent,
55
+ DialogOverlay,
56
+ DialogPortal,
57
+ DialogRoot,
58
+ FocusScope
59
+ } from 'reka-ui';
60
+ import {
61
+ computed,
62
+ defineComponent,
63
+ nextTick,
64
+ onBeforeUnmount,
65
+ provide,
66
+ ref,
67
+ shallowRef,
68
+ watch,
69
+ type ComponentPublicInstance,
70
+ type CSSProperties,
71
+ type PropType
72
+ } from 'vue';
73
+ import type { ComputedRef } from 'vue';
74
+ import { useZIndex, ZINDEX } from '../../composables/useZIndex';
75
+ import { useEventListener } from '@vueuse/core';
76
+ import * as style from './style';
77
+
78
+ function toCssSize(value: number | string | undefined): string | undefined {
79
+ if (value === undefined) {
80
+ return undefined;
81
+ }
82
+
83
+ return typeof value === 'number' ? `${value}px` : value;
84
+ }
85
+
86
+ function resolveElement(
87
+ value: HTMLElement | ComponentPublicInstance | null | undefined
88
+ ): HTMLElement | null {
89
+ if (!value) {
90
+ return null;
91
+ }
92
+
93
+ if (value instanceof HTMLElement) {
94
+ return value;
95
+ }
96
+
97
+ if ('$el' in value && value.$el instanceof HTMLElement) {
98
+ return value.$el;
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ export default defineComponent({
105
+ name: 'PanelDialog',
106
+ components: {
107
+ DialogContent,
108
+ DialogOverlay,
109
+ DialogPortal,
110
+ DialogRoot,
111
+ FocusScope
112
+ },
113
+ props: {
114
+ open: {
115
+ type: Boolean,
116
+ default: false
117
+ },
118
+ modal: {
119
+ type: Boolean,
120
+ default: false
121
+ },
122
+ width: {
123
+ type: [Number, String] as PropType<number | string>,
124
+ default: '500px'
125
+ },
126
+ height: {
127
+ type: [Number, String] as PropType<number | string>,
128
+ default: undefined
129
+ },
130
+ maxHeight: {
131
+ type: [Number, String] as PropType<number | string>,
132
+ default: '85vh'
133
+ },
134
+ draggable: {
135
+ type: Boolean,
136
+ default: true
137
+ },
138
+ handleSelector: {
139
+ type: String,
140
+ default: '[data-panel-handle]'
141
+ },
142
+ backdrop: {
143
+ type: Boolean,
144
+ default: false
145
+ },
146
+ backdropClass: {
147
+ type: String,
148
+ default: ''
149
+ },
150
+ panelClass: {
151
+ type: [String, Array, Object] as PropType<string | unknown[] | Record<string, unknown>>,
152
+ default: ''
153
+ },
154
+ panelStyle: {
155
+ type: Object as PropType<CSSProperties>,
156
+ default: undefined
157
+ },
158
+ zIndex: {
159
+ type: Number
160
+ },
161
+ focusTrap: {
162
+ type: Boolean,
163
+ default: true
164
+ },
165
+ onClose: {
166
+ type: Function as PropType<() => void>,
167
+ default: () => {}
168
+ }
169
+ },
170
+ emits: ['onOpen', 'onClose', 'update:open', 'open', 'close'],
171
+ setup(props, { emit, expose }) {
172
+ const dialogContent = shallowRef<HTMLElement | ComponentPublicInstance | null>(null);
173
+ const panel = ref<HTMLElement | null>(null);
174
+ const { nextZIndex, current: maxZIndex } = useZIndex();
175
+ const zIndex = ref(props.zIndex ?? nextZIndex());
176
+ provide(ZINDEX, zIndex);
177
+ const hasOpened = ref(false);
178
+
179
+ const offsetX = ref(0);
180
+ const offsetY = ref(0);
181
+ const isDragging = ref(false);
182
+
183
+ const dialogContentElement = computed(() => resolveElement(dialogContent.value));
184
+
185
+ const isOpen = computed(() => props.open);
186
+
187
+ const contentClasses = computed(() => [
188
+ style.dialogContent,
189
+ props.modal ? style.dialogContentModal : style.dialogContentNonModal
190
+ ]);
191
+
192
+ const contentStyle: ComputedRef<CSSProperties> = computed(() => ({
193
+ zIndex: zIndex.value
194
+ }));
195
+
196
+ const focusScopeClasses = computed(() => [
197
+ style.focusScope,
198
+ props.modal ? style.focusScopeModal : style.focusScopeNonModal
199
+ ]);
200
+
201
+ const panelComputedStyle: ComputedRef<CSSProperties> = computed(() => ({
202
+ ...props.panelStyle,
203
+ width: toCssSize(props.width),
204
+ height: toCssSize(props.height),
205
+ maxHeight: toCssSize(props.maxHeight),
206
+ transform: `translate(${offsetX.value}px, ${offsetY.value}px)`
207
+ }));
208
+
209
+ const focusableSelector = [
210
+ '[autofocus]',
211
+ 'input:not([type="hidden"])',
212
+ 'textarea',
213
+ 'select',
214
+ '[contenteditable="true"]',
215
+ 'button',
216
+ 'a[href]',
217
+ '[tabindex]:not([tabindex="-1"])'
218
+ ].join(', ');
219
+
220
+ function close(): void {
221
+ handleOpenChange(false);
222
+ }
223
+
224
+ function handleEscapeKeyDown(event: KeyboardEvent): void {
225
+ event.preventDefault();
226
+ event.stopPropagation();
227
+ close();
228
+ }
229
+
230
+ function handleAfterLeave(): void {
231
+ emit('close');
232
+ }
233
+
234
+ function onCloseEmit(): void {
235
+ emit('onClose');
236
+ if (props.onClose) {
237
+ props.onClose();
238
+ }
239
+ }
240
+
241
+ function onEnter(): void {
242
+ emit('onOpen');
243
+ emit('open');
244
+ zIndex.value = props.zIndex ?? nextZIndex();
245
+ }
246
+
247
+ function handleFocusOutside(event: Event): void {
248
+ event.preventDefault();
249
+ }
250
+
251
+ function handleOpenAutoFocus(event: Event): void {
252
+ const element = dialogContentElement.value ?? panel.value;
253
+
254
+ if (!element) {
255
+ return;
256
+ }
257
+
258
+ const firstFocusableElement = Array.from(
259
+ element.querySelectorAll<HTMLElement>(focusableSelector)
260
+ ).find(candidate => {
261
+ return (
262
+ !candidate.matches(':disabled') &&
263
+ candidate.getAttribute('aria-disabled') !== 'true' &&
264
+ candidate.getAttribute('aria-hidden') !== 'true' &&
265
+ candidate.getClientRects().length > 0
266
+ );
267
+ });
268
+
269
+ if (!firstFocusableElement) {
270
+ return;
271
+ }
272
+
273
+ event.preventDefault();
274
+ firstFocusableElement.focus();
275
+ }
276
+
277
+ function handleOpenChange(value: boolean): void {
278
+ if (!value && props.open) {
279
+ emit('update:open', false);
280
+ onCloseEmit();
281
+ hasOpened.value = false;
282
+
283
+ return;
284
+ }
285
+
286
+ emit('update:open', value);
287
+ }
288
+
289
+ function clampOffset(): void {
290
+ const element = panel.value;
291
+
292
+ if (!element) {
293
+ return;
294
+ }
295
+
296
+ const rect = element.getBoundingClientRect();
297
+ let dx = 0;
298
+ let dy = 0;
299
+
300
+ if (rect.width < window.innerWidth) {
301
+ if (rect.left < 0) {
302
+ dx = -rect.left;
303
+ } else if (rect.right > window.innerWidth) {
304
+ dx = window.innerWidth - rect.right;
305
+ }
306
+ } else if (rect.left !== 0) {
307
+ dx = -rect.left;
308
+ }
309
+
310
+ if (rect.height < window.innerHeight) {
311
+ if (rect.top < 0) {
312
+ dy = -rect.top;
313
+ } else if (rect.bottom > window.innerHeight) {
314
+ dy = window.innerHeight - rect.bottom;
315
+ }
316
+ } else if (rect.top !== 0) {
317
+ dy = -rect.top;
318
+ }
319
+
320
+ if (dx !== 0 || dy !== 0) {
321
+ offsetX.value += dx;
322
+ offsetY.value += dy;
323
+ element.style.transform = `translate(${offsetX.value}px, ${offsetY.value}px)`;
324
+ }
325
+ }
326
+
327
+ function resetPosition(): void {
328
+ offsetX.value = 0;
329
+ offsetY.value = 0;
330
+
331
+ if (panel.value) {
332
+ panel.value.style.transform = 'translate(0px, 0px)';
333
+ }
334
+ }
335
+
336
+ interface DragState {
337
+ startX: number;
338
+ startY: number;
339
+ baseX: number;
340
+ baseY: number;
341
+ }
342
+
343
+ let dragState: DragState | null = null;
344
+
345
+ function onPointerMove(event: PointerEvent): void {
346
+ if (!dragState || !panel.value) {
347
+ return;
348
+ }
349
+
350
+ const element = panel.value;
351
+ const renderedRect = element.getBoundingClientRect();
352
+ const renderedX = offsetX.value;
353
+ const renderedY = offsetY.value;
354
+ let nextX = dragState.baseX + (event.clientX - dragState.startX);
355
+ let nextY = dragState.baseY + (event.clientY - dragState.startY);
356
+ const dx = nextX - renderedX;
357
+ const dy = nextY - renderedY;
358
+ const nextRect = {
359
+ left: renderedRect.left + dx,
360
+ right: renderedRect.right + dx,
361
+ top: renderedRect.top + dy,
362
+ bottom: renderedRect.bottom + dy,
363
+ width: renderedRect.width,
364
+ height: renderedRect.height
365
+ };
366
+
367
+ if (nextRect.width < window.innerWidth) {
368
+ if (nextRect.left < 0) {
369
+ nextX -= nextRect.left;
370
+ } else if (nextRect.right > window.innerWidth) {
371
+ nextX -= nextRect.right - window.innerWidth;
372
+ }
373
+ }
374
+
375
+ if (nextRect.height < window.innerHeight) {
376
+ if (nextRect.top < 0) {
377
+ nextY -= nextRect.top;
378
+ } else if (nextRect.bottom > window.innerHeight) {
379
+ nextY -= nextRect.bottom - window.innerHeight;
380
+ }
381
+ }
382
+
383
+ offsetX.value = nextX;
384
+ offsetY.value = nextY;
385
+ element.style.transform = `translate(${nextX}px, ${nextY}px)`;
386
+ }
387
+
388
+ function onPointerUp(): void {
389
+ dragState = null;
390
+ isDragging.value = false;
391
+ window.removeEventListener('pointermove', onPointerMove);
392
+ window.removeEventListener('pointerup', onPointerUp);
393
+ }
394
+
395
+ function onPanelPointerDown(event: PointerEvent): void {
396
+ if (!props.draggable || !panel.value) {
397
+ return;
398
+ }
399
+
400
+ if (event.button !== 0) {
401
+ return;
402
+ }
403
+
404
+ const target = event.target as HTMLElement | null;
405
+
406
+ if (!target || typeof target.closest !== 'function') {
407
+ return;
408
+ }
409
+
410
+ if (props.handleSelector && !target.closest(props.handleSelector)) {
411
+ return;
412
+ }
413
+
414
+ if (
415
+ target.closest(
416
+ 'button, input, select, textarea, a[href], [contenteditable="true"]'
417
+ )
418
+ ) {
419
+ return;
420
+ }
421
+
422
+ if (zIndex.value < maxZIndex.value) {
423
+ zIndex.value = nextZIndex();
424
+ }
425
+
426
+ dragState = {
427
+ startX: event.clientX,
428
+ startY: event.clientY,
429
+ baseX: offsetX.value,
430
+ baseY: offsetY.value
431
+ };
432
+ isDragging.value = true;
433
+ window.addEventListener('pointermove', onPointerMove);
434
+ window.addEventListener('pointerup', onPointerUp);
435
+ event.preventDefault();
436
+ }
437
+
438
+ function onWindowResize(): void {
439
+ if (props.open) {
440
+ nextTick(() => clampOffset());
441
+ }
442
+ }
443
+
444
+ useEventListener(window, 'resize', onWindowResize);
445
+
446
+ onBeforeUnmount(() => {
447
+ window.removeEventListener('pointermove', onPointerMove);
448
+ window.removeEventListener('pointerup', onPointerUp);
449
+ });
450
+
451
+ watch(
452
+ () => props.open,
453
+ async newVal => {
454
+ if (newVal) {
455
+ await nextTick();
456
+ resetPosition();
457
+ nextTick(() => clampOffset());
458
+ zIndex.value = props.zIndex ?? nextZIndex();
459
+ if (!hasOpened.value) {
460
+ onEnter();
461
+ hasOpened.value = true;
462
+ }
463
+ } else {
464
+ hasOpened.value = false;
465
+ if (dragState) {
466
+ onPointerUp();
467
+ }
468
+ }
469
+ },
470
+ { immediate: true }
471
+ );
472
+
473
+ expose({
474
+ close
475
+ });
476
+
477
+ return {
478
+ dialogContent,
479
+ panel,
480
+ zIndex,
481
+ fade,
482
+ modalScale,
483
+ style,
484
+ isOpen,
485
+ contentClasses,
486
+ contentStyle,
487
+ focusScopeClasses,
488
+ panelComputedStyle,
489
+ isDragging,
490
+ close,
491
+ handleAfterLeave,
492
+ handleEscapeKeyDown,
493
+ handleOpenChange,
494
+ handleOpenAutoFocus,
495
+ handleFocusOutside,
496
+ onPanelPointerDown
497
+ };
498
+ }
499
+ });
500
+ </script>
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <div>
3
+ <button @click="open = true">Open panel</button>
4
+ <MPanelDialog
5
+ v-model:open="open"
6
+ :modal="false"
7
+ width="500px"
8
+ :draggable="true"
9
+ @onClose="closed"
10
+ >
11
+ <div data-panel-handle style="padding: 12px 16px; border-bottom: 1px solid #e4e2ea; font-weight: 600;">
12
+ Draggable panel (grab here)
13
+ </div>
14
+ <div style="padding: 16px; display: flex; flex-direction: column; gap: 12px;">
15
+ <p style="margin: 0;">Non-modal floating panel. Drag by the header. Background stays interactive.</p>
16
+ <MInput placeholder="Focus me — blue ring must remain" />
17
+ <input type="text" placeholder="Native input" />
18
+ <div style="display: flex; gap: 8px; justify-content: flex-end;">
19
+ <button @click="open = false">Close</button>
20
+ </div>
21
+ </div>
22
+ </MPanelDialog>
23
+ </div>
24
+ </template>
25
+
26
+ <script lang="ts" setup>
27
+ import { ref } from 'vue';
28
+ import MPanelDialog from '../PanelDialog.vue';
29
+ import MInput from '../../input/Input.vue';
30
+
31
+ const open = ref(false);
32
+
33
+ function closed(): void {
34
+ console.log('closed');
35
+ }
36
+ </script>
@@ -0,0 +1,22 @@
1
+ import MPanelDialog from '../PanelDialog.vue';
2
+ import { MPanelDialogBasic, MPanelDialogModal } from './index';
3
+ import { createStory } from '../../../__storybook__';
4
+
5
+ export default {
6
+ component: MPanelDialog,
7
+ title: 'Components/PanelDialog',
8
+ argTypes: {
9
+ open: { control: 'boolean' },
10
+ modal: { control: 'boolean' },
11
+ draggable: { control: 'boolean' },
12
+ width: { control: 'text' },
13
+ height: { control: 'text' },
14
+ maxHeight: { control: 'text' },
15
+ handleSelector: { control: 'text' },
16
+ backdrop: { control: 'boolean' },
17
+ focusTrap: { control: 'boolean' }
18
+ }
19
+ };
20
+
21
+ export const Basic = createStory(MPanelDialogBasic);
22
+ export const Modal = createStory(MPanelDialogModal);
@@ -0,0 +1,2 @@
1
+ export { default as MPanelDialogBasic } from './basic.vue';
2
+ export { default as MPanelDialogModal } from './modal.vue';
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <div>
3
+ <button @click="open = true">Open modal panel</button>
4
+ <MPanelDialog
5
+ v-model:open="open"
6
+ :modal="true"
7
+ width="520px"
8
+ :draggable="false"
9
+ :backdrop="true"
10
+ >
11
+ <div data-panel-handle style="padding: 12px 16px; border-bottom: 1px solid #e4e2ea; font-weight: 600;">
12
+ Modal panel (drag disabled)
13
+ </div>
14
+ <div style="padding: 16px; display: flex; flex-direction: column; gap: 12px;">
15
+ <p style="margin: 0;">Modal variant blocks background interaction and shows an overlay.</p>
16
+ <MInput placeholder="Focus trap stays inside" />
17
+ <div style="display: flex; gap: 8px; justify-content: flex-end;">
18
+ <button @click="open = false">Close</button>
19
+ </div>
20
+ </div>
21
+ </MPanelDialog>
22
+ </div>
23
+ </template>
24
+
25
+ <script lang="ts" setup>
26
+ import { ref } from 'vue';
27
+ import MPanelDialog from '../PanelDialog.vue';
28
+ import MInput from '../../input/Input.vue';
29
+
30
+ const open = ref(false);
31
+ </script>
@@ -0,0 +1,90 @@
1
+ import { globalStyle, style } from '@vanilla-extract/css';
2
+ import { themeVars } from '../../../styles/theme.css';
3
+
4
+ export const backdrop = style({
5
+ position: 'fixed',
6
+ inset: 0,
7
+ backgroundColor: 'rgba(0,0,0,.45)',
8
+ pointerEvents: 'none'
9
+ });
10
+
11
+ export const dialogContent = style({
12
+ position: 'fixed',
13
+ inset: 0,
14
+ display: 'flex',
15
+ alignItems: 'center',
16
+ justifyContent: 'center',
17
+ outline: 'none',
18
+ selectors: {
19
+ '&:focus': {
20
+ outline: 'none'
21
+ },
22
+ '&:focus-visible': {
23
+ outline: 'none'
24
+ }
25
+ }
26
+ });
27
+
28
+ export const dialogContentModal = style({
29
+ pointerEvents: 'auto'
30
+ });
31
+
32
+ export const dialogContentNonModal = style({
33
+ pointerEvents: 'none'
34
+ });
35
+
36
+ export const focusScope = style({
37
+ display: 'flex',
38
+ alignItems: 'center',
39
+ justifyContent: 'center',
40
+ width: '100%',
41
+ height: '100%',
42
+ outline: 'none',
43
+ selectors: {
44
+ '&:focus': {
45
+ outline: 'none'
46
+ },
47
+ '&:focus-visible': {
48
+ outline: 'none'
49
+ }
50
+ }
51
+ });
52
+
53
+ export const focusScopeNonModal = style({
54
+ pointerEvents: 'none'
55
+ });
56
+
57
+ export const focusScopeModal = style({
58
+ pointerEvents: 'auto'
59
+ });
60
+
61
+ export const panel = style({
62
+ position: 'relative',
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ backgroundColor: '#FFF',
66
+ backgroundClip: 'padding-box',
67
+ border: `1px solid ${themeVars.color.gray[300]}`,
68
+ borderRadius: themeVars.rounded.medium,
69
+ boxShadow: `2px 2px 8px rgba(128, 128, 128, 0.1), 0 9px 28px 8px rgba(0,0,0,.05)`,
70
+ overflow: 'hidden',
71
+ outline: 'none',
72
+ pointerEvents: 'auto',
73
+ maxWidth: 'calc(100vw - 32px)',
74
+ selectors: {
75
+ '&:focus': {
76
+ outline: 'none'
77
+ },
78
+ '&:focus-visible': {
79
+ outline: 'none'
80
+ }
81
+ }
82
+ });
83
+
84
+ globalStyle(`${panel} [data-panel-handle]`, {
85
+ cursor: 'grab'
86
+ });
87
+
88
+ globalStyle(`${panel} [data-panel-handle]:active`, {
89
+ cursor: 'grabbing'
90
+ });
@@ -0,0 +1 @@
1
+ export * from './PanelDialog.css';