Skip to content

Commit 4760523

Browse files
siderisltdasideris
andauthored
Added modal component (#25)
* Added modal component --------- Co-authored-by: asideris <[email protected]>
1 parent a4191b9 commit 4760523

File tree

9 files changed

+517
-51
lines changed

9 files changed

+517
-51
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@amadeus-it-group/svelvunity",
33
"description": "Reusable components and utilities svelte library.",
4-
"version": "0.0.6",
4+
"version": "0.0.7",
55
"bugs": "https://github.com/AmadeusITGroup/Svelvunity/issues",
66
"license": "MIT",
77
"type": "module",

src/lib/components/Modal.svelte

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<script lang="ts">
2+
import { twMerge } from 'tailwind-merge';
3+
import { trapFocus } from '$lib/utils/actions.svelte';
4+
import type { Snippet } from 'svelte';
5+
import { CLOSE_SVG, Frame, Icon, Size } from '$lib';
6+
import { Position } from '$lib/enums/position.enum';
7+
8+
let sizes = {
9+
[Size.XSmall]: 'max-w-md',
10+
[Size.Small]: 'max-w-lg',
11+
[Size.Medium]: 'max-w-2xl',
12+
[Size.Large]: 'max-w-4xl',
13+
[Size.XLarge]: 'max-w-7xl',
14+
[Size.Unset]: ''
15+
};
16+
17+
let {
18+
open = $bindable(false),
19+
title = '',
20+
size = Size.Large,
21+
position = Position.Center,
22+
autoclose = false,
23+
dismissable = true,
24+
dismissIconLabel = '',
25+
outsideclose = false,
26+
frameClasses = '',
27+
backdropClasses = '',
28+
modalClasses = '',
29+
color = '',
30+
modalBodyClasses = '',
31+
extraModalProps = {},
32+
headerSnippet,
33+
footerSnippet,
34+
onClose,
35+
children
36+
}: {
37+
open?: boolean;
38+
title?: string;
39+
size?: Size;
40+
position?: Position;
41+
autoclose?: boolean;
42+
dismissable?: boolean;
43+
dismissIconLabel?: string;
44+
outsideclose?: boolean;
45+
frameClasses?: string;
46+
backdropClasses?: string;
47+
modalClasses?: string;
48+
color?: string;
49+
modalBodyClasses?: string;
50+
extraModalProps?: Record<string, any>;
51+
headerSnippet?: Snippet;
52+
footerSnippet?: Snippet;
53+
onClose?: () => void;
54+
children?: Snippet;
55+
} = $props();
56+
57+
let defaultBackdropClass = 'fixed inset-0 z-40 bg-gray-900/50',
58+
defaultFrameClasses = 'relative flex flex-col mx-auto',
59+
defaultDialogClasses = 'fixed inset-0 z-50 flex p-4',
60+
frameCls = $state<string>(twMerge(defaultFrameClasses, 'w-full divide-y', frameClasses)),
61+
backdropCls: string = twMerge(defaultBackdropClass, backdropClasses),
62+
modalWrapperCls: string = twMerge(
63+
defaultDialogClasses,
64+
modalClasses,
65+
...getTailwindPositionClasses()
66+
),
67+
previousOpenState = open;
68+
69+
$effect(() => {
70+
frameCls = twMerge(defaultFrameClasses, 'w-full divide-y', frameClasses);
71+
if (previousOpenState === true && !open) {
72+
onClose?.();
73+
}
74+
previousOpenState = open;
75+
});
76+
77+
function getTailwindPositionClasses() {
78+
switch (position) {
79+
case Position.TopLeft:
80+
return ['justify-start', 'items-start'];
81+
case Position.TopCenter:
82+
return ['justify-center', 'items-start'];
83+
case Position.TopRight:
84+
return ['justify-end', 'items-start'];
85+
86+
case Position.CenterLeft:
87+
return ['justify-start', 'items-center'];
88+
case Position.Center:
89+
return ['justify-center', 'items-center'];
90+
case Position.CenterRight:
91+
return ['justify-end', 'items-center'];
92+
93+
case Position.BottomLeft:
94+
return ['justify-start', 'items-end'];
95+
case Position.BottomCenter:
96+
return ['justify-center', 'items-end'];
97+
case Position.BottomRight:
98+
return ['justify-end', 'items-end'];
99+
100+
default:
101+
return ['justify-center', 'items-center'];
102+
}
103+
}
104+
105+
function escapeKeyHandler(e: KeyboardEvent) {
106+
if (e.key === 'Escape' && dismissable) return hide(e);
107+
}
108+
109+
function wheelNonPassiveHandler(node: HTMLElement) {
110+
const wheelHandler = function (event: WheelEvent) {
111+
event.preventDefault();
112+
};
113+
114+
node.addEventListener('wheel', wheelHandler, { passive: false });
115+
116+
return {
117+
destroy() {
118+
node.removeEventListener('wheel', wheelHandler);
119+
}
120+
};
121+
}
122+
123+
const onAutoClose = (e: MouseEvent) => {
124+
// close on any button click
125+
const target: Element = e.target as Element;
126+
if (autoclose && target?.tagName === 'BUTTON') {
127+
hide(e);
128+
}
129+
};
130+
131+
const onOutsideClose = (e: MouseEvent) => {
132+
// close on click outside
133+
if (!outsideclose) {
134+
e.preventDefault();
135+
}
136+
137+
const target: Element = e.target as Element;
138+
if (outsideclose && target === e.currentTarget) {
139+
hide(e);
140+
}
141+
};
142+
143+
const hide = (e: Event) => {
144+
e.preventDefault();
145+
open = false;
146+
};
147+
</script>
148+
149+
{#if open}
150+
<!-- backdrop -->
151+
<div class={backdropCls}></div>
152+
<!-- dialog -->
153+
<div
154+
onkeydown={escapeKeyHandler}
155+
use:wheelNonPassiveHandler
156+
use:trapFocus
157+
onclick={onAutoClose}
158+
onmousedown={onOutsideClose}
159+
class={modalWrapperCls}
160+
tabindex="-1"
161+
aria-modal="true"
162+
aria-hidden={!open}
163+
aria-label={title}
164+
{...extraModalProps}
165+
role="dialog"
166+
>
167+
<div class="flex relative {sizes[size]} {modalWrapperCls} w-full max-h-full">
168+
<!-- Modal content -->
169+
170+
<Frame shadow classes={frameCls} tabindex={1} action={() => {}}>
171+
<!-- Modal header -->
172+
{#if headerSnippet || title}
173+
<Frame
174+
tabindex={1}
175+
action={() => {}}
176+
bgColor={color}
177+
classes="flex justify-between items-center p-4 rounded-t-lg border-b-2 border-gray-300"
178+
>
179+
{#if title}
180+
<h3
181+
class="text-lg xs:text-xl font-semibold p-0"
182+
class:text-gray-900={!color}
183+
>
184+
{title}
185+
</h3>
186+
{/if}
187+
{@render headerSnippet?.()}
188+
189+
{#if dismissable}
190+
<Icon
191+
iconSVG={CLOSE_SVG}
192+
label={dismissIconLabel}
193+
clickLogic={hide}
194+
height={24}
195+
width={24}
196+
viewBox="-70 0 448 512"
197+
classes="cursor-pointer !rotate-45 !min-h-[24px] !min-w-[24px]"
198+
fill="#000"
199+
/>
200+
{/if}
201+
</Frame>
202+
{/if}
203+
204+
<!-- Modal body -->
205+
<div
206+
class={twMerge(
207+
'p-6 flex-1 overflow-y-auto overscroll-contain',
208+
modalBodyClasses
209+
)}
210+
role="document"
211+
>
212+
{#if dismissable && !headerSnippet && !title}
213+
<Icon
214+
iconSVG={CLOSE_SVG}
215+
clickLogic={hide}
216+
height={24}
217+
width={24}
218+
viewBox="-70 0 448 512"
219+
classes="cursor-pointer !rotate-45 !min-h-[24px] !min-w-[24px]"
220+
fill="#000"
221+
/>
222+
{/if}
223+
224+
{@render children?.()}
225+
</div>
226+
227+
<!-- Modal footer -->
228+
{#if footerSnippet}
229+
<Frame
230+
bgColor={color}
231+
tabindex={1}
232+
action={() => {}}
233+
classes="flex items-center p-6 space-x-2 rtl:space-x-reverse rounded-b-lg justify-end border-t !border-gray-300"
234+
>
235+
{@render footerSnippet()}
236+
</Frame>
237+
{/if}
238+
</Frame>
239+
</div>
240+
</div>
241+
{/if}

src/lib/enums/position.enum.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export enum Position {
2+
Center = 'center',
3+
CenterLeft = 'center-left',
4+
CenterRight = 'center-right',
5+
TopLeft = 'top-left',
6+
TopCenter = 'top-center',
7+
TopRight = 'top-right',
8+
BottomLeft = 'bottom-left',
9+
BottomCenter = 'bottom-center',
10+
BottomRight = 'bottom-right'
11+
}

src/lib/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export { default as Tabs } from './components/Tabs.svelte';
2121
export { toast } from './components/Toast/stores.js';
2222
export { default as Tooltip } from './components/Tooltip.svelte';
2323
export { default as UserProfileMenu } from './components/UserProfileMenu.svelte';
24+
export { default as Modal } from './components/Modal.svelte';
2425
export {
2526
ADD_REMOVE_FAVORITE_SVG,
2627
ANGLE_DOWN_SVG,
@@ -70,5 +71,6 @@ export { ButtonType } from './enums/buttontype.enum';
7071
export { Direction } from './enums/direction.enum';
7172
export { InputTypes } from './enums/inputtypes.enum';
7273
export { Size } from './enums/size.enum';
74+
export { Position } from './enums/position.enum';
7375
export { clickOutside } from './utils/clickOutside';
7476
export { formatDate } from './utils/date';

src/lib/utils/actions.svelte.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const selectorTabbable = `
2+
a[href], area[href], input:not([disabled]):not([tabindex='-1']),
3+
button:not([disabled]):not([tabindex='-1']),select:not([disabled]):not([tabindex='-1']),
4+
textarea:not([disabled]):not([tabindex='-1']),
5+
iframe, object, embed, *[tabindex]:not([tabindex='-1']):not([disabled]), *[contenteditable=true]
6+
`;
7+
8+
export function trapFocus(node: HTMLElement) {
9+
function handleFocusTrap(e: KeyboardEvent) {
10+
const isTabPressed = e.key === 'Tab' || e.keyCode === 9;
11+
12+
if (!isTabPressed) {
13+
return;
14+
}
15+
16+
const tabbable = Array.from(node.querySelectorAll(selectorTabbable));
17+
18+
let index = tabbable.indexOf(document.activeElement ?? node);
19+
if (index === -1 && e.shiftKey) {
20+
index = 0;
21+
}
22+
index += tabbable.length + (e.shiftKey ? -1 : 1);
23+
index %= tabbable.length;
24+
25+
const element = tabbable[index];
26+
27+
if (element instanceof HTMLElement) {
28+
element.focus();
29+
}
30+
31+
e.preventDefault();
32+
}
33+
34+
document.addEventListener('keydown', handleFocusTrap, true);
35+
36+
return {
37+
destroy() {
38+
document.removeEventListener('keydown', handleFocusTrap, true);
39+
}
40+
};
41+
}

0 commit comments

Comments
 (0)