Skip to content

Commit

Permalink
Feat: Dialog (1/3) (#43)
Browse files Browse the repository at this point in the history
* added trapfocus, focus handling, scroll lock, focus lock
  • Loading branch information
k11q committed Jun 24, 2023
1 parent a86ddc7 commit 9c22bd3
Show file tree
Hide file tree
Showing 16 changed files with 344 additions and 2 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ An accessible, unstyled and customisable UI Library for building top quality des
| Checkbox || | |
| Collapsible ||| |
| Context Menu | | | |
| Dialog | | | |
| Dialog | | | |
| Dropdown Menu | | | |
| Form | | | |
| Hover Card | | | |
Expand Down
4 changes: 4 additions & 0 deletions packages/playground-vue/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import AspectRatioDemo from "./components/Demo/AspectRatioDemo.vue";
import TabsDemo from "./components/Demo/TabsDemo.vue";
import SliderDemo from "./components/Demo/SliderDemo.vue";
import ProgressDemo from "./components/Demo/ProgressDemo.vue";
import DialogDemo from "./components/Demo/DialogDemo.vue";
</script>

Expand Down Expand Up @@ -50,6 +51,9 @@ import ProgressDemo from "./components/Demo/ProgressDemo.vue";
<Card>
<ProgressDemo />
</Card>
<Card>
<DialogDemo />
</Card>
</div>
</div>
</template>
Expand Down
74 changes: 74 additions & 0 deletions packages/playground-vue/src/components/Demo/DialogDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from "vue";
import {
DialogRoot,
DialogTrigger,
DialogContent,
DialogOverlay,
DialogClose,
DialogPortal,
DialogTitle,
DialogDescription
} from "radix-vue";
import { Icon } from "@iconify/vue";
const dialogOpen = ref(false);
</script>

<template>
<DialogRoot v-model="dialogOpen">
<DialogTrigger
class="text-violet11 shadow-blackA7 hover:bg-mauve3 inline-flex h-[35px] items-center justify-center rounded-[4px] bg-white px-[15px] font-medium leading-none shadow-[0_2px_10px] focus:shadow-[0_0_0_2px] focus:shadow-black focus:outline-none"
>
Edit profile
</DialogTrigger>
<DialogPortal>
<DialogOverlay
class="bg-blackA9 data-[state=open]:animate-overlayShow fixed inset-0"
/>
<DialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-[6px] bg-white p-[25px] shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none"
>
<DialogTitle class="text-mauve12 m-0 text-[17px] font-medium">
Edit profile
</DialogTitle>
<DialogDescription class="text-mauve11 mt-[10px] mb-5 text-[15px] leading-normal">
Make changes to your profile here. Click save when you're done.
</DialogDescription>
<fieldset class="mb-[15px] flex items-center gap-5">
<label class="text-violet11 w-[90px] text-right text-[15px]" for="name">
Name
</label>
<input
class="text-violet11 shadow-violet7 focus:shadow-violet8 inline-flex h-[35px] w-full flex-1 items-center justify-center rounded-[4px] px-[10px] text-[15px] leading-none shadow-[0_0_0_1px] outline-none focus:shadow-[0_0_0_2px]"
id="name"
defaultValue="Pedro Duarte"
/>
</fieldset>
<fieldset class="mb-[15px] flex items-center gap-5">
<label class="text-violet11 w-[90px] text-right text-[15px]" for="username">
Username
</label>
<input
class="text-violet11 shadow-violet7 focus:shadow-violet8 inline-flex h-[35px] w-full flex-1 items-center justify-center rounded-[4px] px-[10px] text-[15px] leading-none shadow-[0_0_0_1px] outline-none focus:shadow-[0_0_0_2px]"
id="username"
defaultValue="@peduarte"
/>
</fieldset>
<div class="mt-[25px] flex justify-end">
<DialogClose
class="bg-green4 text-green11 hover:bg-green5 focus:shadow-green7 inline-flex h-[35px] items-center justify-center rounded-[4px] px-[15px] font-medium leading-none focus:shadow-[0_0_0_2px] focus:outline-none"
>
Save changes
</DialogClose>
</div>
<DialogClose
class="text-violet11 hover:bg-violet4 focus:shadow-violet7 absolute top-[10px] right-[10px] inline-flex h-[25px] w-[25px] appearance-none items-center justify-center rounded-full focus:shadow-[0_0_0_2px] focus:outline-none"
aria-label="Close"
>
<Icon icon="lucide:x" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
14 changes: 14 additions & 0 deletions packages/playground-vue/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ export default {
...violet,
...green,
},
keyframes: {
overlayShow: {
from: { opacity: 0 },
to: { opacity: 1 },
},
contentShow: {
from: { opacity: 0, transform: 'translate(-50%, -48%) scale(0.96)' },
to: { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
},
},
animation: {
overlayShow: 'overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1)',
contentShow: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)',
},
},
},
plugins: [],
Expand Down
20 changes: 20 additions & 0 deletions packages/radix-vue/src/Dialog/DialogClose.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { inject } from "vue";
import {
DIALOG_INJECTION_KEY,
type DialogProvideValue,
} from "./DialogRoot.vue";
const injectedValue = inject<DialogProvideValue>(DIALOG_INJECTION_KEY);
</script>

<template>
<button
type="button"
:aria-expanded="injectedValue?.open.value || false"
:data-state="injectedValue?.open.value ? 'open' : 'closed'"
@click="injectedValue?.closeModal"
>
<slot />
</button>
</template>
63 changes: 63 additions & 0 deletions packages/radix-vue/src/Dialog/DialogContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { inject, ref, watchEffect } from "vue";
import { trapFocus } from "../shared/trap-focus.ts";
import {
DIALOG_INJECTION_KEY,
type DialogProvideValue,
} from "./DialogRoot.vue";
const injectedValue = inject<DialogProvideValue>(DIALOG_INJECTION_KEY);
const dialogContentElement = ref<HTMLElement>();
watchEffect(() => {
if (injectedValue?.open.value && dialogContentElement) {
trapFocus(dialogContentElement.value);
document.querySelector("body")!.style.pointerEvents = "none";
window.addEventListener("wheel", lockScroll, { passive: false });
window.addEventListener("keydown", lockKeydown);
} else {
document.querySelector("body")!.style.pointerEvents = "";
window.removeEventListener("wheel", lockScroll);
window.removeEventListener("keydown", lockKeydown);
if (injectedValue.triggerButton.value) {
injectedValue.triggerButton.value.focus();
}
}
});
function lockScroll(e: WheelEvent) {
e.preventDefault();
}
function lockKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
const activeElement = document.activeElement;
const inputs = ["input", "select", "textarea"];
if (
activeElement &&
inputs.indexOf(activeElement.tagName.toLowerCase()) === -1
) {
e.preventDefault();
}
}
if (e.key === "Escape") {
injectedValue.closeDialog();
}
}
</script>

<template>
<div
ref="dialogContentElement"
v-if="injectedValue?.open.value"
:data-state="injectedValue?.open.value ? 'open' : 'closed'"
role="dialog"
tabindex="-1"
style="pointer-events: auto"
>
<slot />
</div>
</template>
3 changes: 3 additions & 0 deletions packages/radix-vue/src/Dialog/DialogDescription.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<p><slot /></p>
</template>
22 changes: 22 additions & 0 deletions packages/radix-vue/src/Dialog/DialogOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { inject } from "vue";
import {
DIALOG_INJECTION_KEY,
type DialogProvideValue,
} from "./DialogRoot.vue";
const injectedValue = inject<DialogProvideValue>(DIALOG_INJECTION_KEY);
</script>

<template>
<div
v-if="injectedValue?.open.value"
:data-state="injectedValue?.open.value ? 'open' : 'closed'"
style="pointer-events: auto"
data-aria-hidden="true"
aria-hidden="true"
@click="injectedValue?.closeModal"
>
<slot />
</div>
</template>
5 changes: 5 additions & 0 deletions packages/radix-vue/src/Dialog/DialogPortal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<Teleport to="body">
<slot />
</Teleport>
</template>
48 changes: 48 additions & 0 deletions packages/radix-vue/src/Dialog/DialogRoot.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script lang="ts">
import type { Ref, InjectionKey } from "vue";
export interface DialogRootProps {
modelValue?: boolean;
defaultOpen?: boolean;
//open?: boolean;
}
export const DIALOG_INJECTION_KEY =
Symbol() as InjectionKey<DialogProvideValue>;
export type DialogProvideValue = {
open: Readonly<Ref<boolean>>;
openModal(): void;
closeModal(): void;
triggerButton: Readonly<Ref<HTMLElement>>;
};
</script>

<script setup lang="ts">
import { provide, toRef, ref } from "vue";
const props = withDefaults(defineProps<DialogRootProps>(), {
//open: false,
});
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const triggerButton = ref<HTMLElement>();
provide<DialogProvideValue>(DIALOG_INJECTION_KEY, {
open: toRef(() => props.modelValue),
openModal: () => {
emit("update:modelValue", true);
},
closeModal: () => {
emit("update:modelValue", false);
},
triggerButton: triggerButton,
});
</script>

<template>
<slot />
</template>
3 changes: 3 additions & 0 deletions packages/radix-vue/src/Dialog/DialogTitle.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h2><slot /></h2>
</template>
26 changes: 26 additions & 0 deletions packages/radix-vue/src/Dialog/DialogTrigger.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { inject, ref, onMounted } from "vue";
import {
DIALOG_INJECTION_KEY,
type DialogProvideValue,
} from "./DialogRoot.vue";
const injectedValue = inject<DialogProvideValue>(DIALOG_INJECTION_KEY);
const triggerElement = ref<HTMLElement>();
onMounted(() => {
injectedValue.triggerButton.value = triggerElement.value;
});
</script>

<template>
<button
type="button"
ref="triggerElement"
:aria-expanded="injectedValue?.open.value || false"
:data-state="injectedValue?.open.value ? 'open' : 'closed'"
@click="injectedValue?.openModal"
>
<slot />
</button>
</template>
8 changes: 8 additions & 0 deletions packages/radix-vue/src/Dialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { default as DialogRoot } from "./DialogRoot.vue";
export { default as DialogTrigger } from "./DialogTrigger.vue";
export { default as DialogPortal } from "./DialogPortal.vue";
export { default as DialogContent } from "./DialogContent.vue";
export { default as DialogOverlay } from "./DialogOverlay.vue";
export { default as DialogClose } from "./DialogClose.vue";
export { default as DialogTitle } from "./DialogTitle.vue";
export { default as DialogDescription } from "./DialogDescription.vue";
2 changes: 1 addition & 1 deletion packages/radix-vue/src/Tabs/TabsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ provide<Ref<HTMLElement | undefined>>("parentElement", parentElementRef);
<div
role="tablist"
ref="parentElementRef"
:aria-orientation="injectedValue?.dir"
:aria-orientation="injectedValue?.orientation"
tabindex="0"
:data-orientation="injectedValue?.orientation"
>
Expand Down
10 changes: 10 additions & 0 deletions packages/radix-vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,13 @@ export { AspectRatio } from "./AspectRatio";
export { TabsRoot, TabsList, TabsContent, TabsTrigger } from "./Tabs";
export { SliderRoot, SliderThumb, SliderTrack, SliderRange } from "./Slider";
export { ProgressRoot, ProgressIndicator } from "./Progress";
export {
DialogRoot,
DialogTrigger,
DialogContent,
DialogClose,
DialogPortal,
DialogOverlay,
DialogTitle,
DialogDescription,
} from "./Dialog";
42 changes: 42 additions & 0 deletions packages/radix-vue/src/shared/trap-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export function trapFocus(element: HTMLElement) {
if (element) {
const focusableEls = [
...element.querySelectorAll(
'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'
),
].filter(
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden")
);
const firstFocusableEl = focusableEls[0] as HTMLElement;
console.log("trap-focused");
const lastFocusableEl = focusableEls[
focusableEls.length - 1
] as HTMLElement;
const KEYCODE_TAB = 9;

if (firstFocusableEl) {
firstFocusableEl.focus();
}

element.addEventListener("keydown", function (e) {
const isTabPressed = e.key === "Tab" || e.keyCode === KEYCODE_TAB;

if (!isTabPressed) {
return;
}

if (e.shiftKey) {
/* shift + tab */ if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
e.preventDefault();
}
} /* tab */ else {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
e.preventDefault();
}
}
});
return firstFocusableEl;
}
}

0 comments on commit 9c22bd3

Please sign in to comment.