Skip to content

Commit 07d6b83

Browse files
committed
refactor: AFCL works inside a shadow root, popups, modals, tooltips and the calendar teleport to an injected target which stays body in apps, outside clicks and focus checks look past the shadow host AdminForth/2005
1 parent d1530cf commit 07d6b83

9 files changed

Lines changed: 63 additions & 12 deletions

File tree

‎adminforth/design/system-concepts.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ Apps restyle and test AdminForth by `af-*` and `afcl-*` classes. A restyle never
2525
- A new AFCL component gets a section on the page of its category in the AFCL docs (`documentation/docs/tutorial/03-Customization/15-afcl/`, a link in its `index.md` as well) and its states and variants in [system.html](./system.html). Docs examples are live, not screenshots: wrap the code blocks of an example into `<AfclPreview id="...">` and the docs build renders them with real AFCL (see `documentation/afcl-preview/vite.config.ts`). Nothing is registered globally, an example imports every AFCL component it uses from `@/afcl`, otherwise the build fails.
2626
- Example: `DropdownMenu` and `DropdownMenuItem` replaced the hand-made menus of the list (⋯ in the header and in rows), the user menu and the import-export plugin.
2727

28+
## AFCL works inside a shadow root
29+
30+
The live examples of the docs render AFCL in shadow roots of the docs page (`documentation/afcl-preview/mount.ts`). Code which reaches out of the tree of a component breaks there, so an AFCL component:
31+
32+
- teleports popups, modals, tooltips and pickers to `useTeleportTarget()` from `afcl/shadowRoot.ts`, never `to="body"`. The target is `body` in an app and an element of the shadow root in the docs, content teleported out of the root loses its styles. A library which appends its popup to `document.body` (the calendar of flowbite-datepicker in `DatePicker`) is moved to that target too;
33+
- takes the clicked or scrolled element as `e.composedPath()[0]` in `document` and `window` listeners (outside click, scroll), `e.target` there is the shadow host;
34+
- reads the focus with `activeElementIn(el)`, `document.activeElement` there is the shadow host as well;
35+
- finds its own elements by template refs, not by `document.getElementById` or `document.querySelector`.
36+
37+
In an app these give exactly what `body`, `e.target` and `document.activeElement` give, so apps see no difference.
38+
2839
## Disabled state
2940

3041
A disabled control gets the native `disabled` attribute (`aria-disabled` on a link, which then has no `href`), not `pointer-events-none`: the attribute also blocks the keyboard and form submit, is announced by screen readers and does not pass the click to a parent. Hover styles are left out for a disabled control.

‎adminforth/spa/src/afcl/CodeInput.vue‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';
4545
import { type ClassNameValue } from 'tailwind-merge';
4646
import { mergeClasses } from './classMerge';
47+
import { activeElementIn } from './shadowRoot';
4748
4849
defineOptions({ inheritAttrs: false });
4950
@@ -169,7 +170,7 @@ function onBlur(e: FocusEvent) {
169170
}
170171
// the clicked button still gets its click, the focus is back before the user types again
171172
requestAnimationFrame(() => {
172-
if (inputRef.value?.isConnected && !document.activeElement?.matches(EDITABLE_SELECTOR)) {
173+
if (inputRef.value?.isConnected && !activeElementIn(inputRef.value)?.matches(EDITABLE_SELECTOR)) {
173174
focus();
174175
}
175176
});

‎adminforth/spa/src/afcl/DatePicker.vue‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import utc from 'dayjs/plugin/utc';
4747
4848
import {useCoreStore} from '@/stores/core';
4949
import {FLEXIBLE_DATEPICKER_FORMAT} from '@/utils/dateUtils';
50+
import { useTeleportTarget } from './shadowRoot';
5051
5152
import { IconCalendar, IconChevronDown, IconClock } from '@iconify-prerendered/vue-lucide';
5253
@@ -134,6 +135,8 @@ watch(start, () => {
134135
emit('update:valueStart', start.value)
135136
})
136137
138+
const teleportTarget = useTeleportTarget();
139+
137140
async function initDatepickers() {
138141
const LS_LANG_KEY = `afLanguage`;
139142
const options = {format: FLEXIBLE_DATEPICKER_FORMAT, language: localStorage.getItem(LS_LANG_KEY)};
@@ -147,6 +150,11 @@ async function initDatepickers() {
147150
);
148151
149152
datepickerObject.value = new Datepicker(datepickerStartEl.value, options);
153+
// the calendar goes to body and takes only a selector of the document for another container
154+
if (teleportTarget instanceof HTMLElement) {
155+
datepickerObject.value.config.container = teleportTarget;
156+
teleportTarget.appendChild(datepickerObject.value.picker.element);
157+
}
150158
151159
addChangeDateListener();
152160
}

‎adminforth/spa/src/afcl/DropdownMenu.vue‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<div class="afcl-dropdown-menu-trigger" @click="onTriggerClick">
44
<slot name="trigger" :open="isOpen" />
55
</div>
6-
<Teleport to="body" :disabled="!teleportToBody">
6+
<Teleport :to="teleportTarget" :disabled="!teleportToBody">
77
<div
88
v-if="keepMounted || isOpen"
99
v-show="isOpen"
@@ -23,6 +23,7 @@ import { computed, inject, nextTick, onBeforeUnmount, provide, ref, useAttrs, wa
2323
import { type ClassNameValue } from 'tailwind-merge';
2424
import { mergeClasses } from './classMerge';
2525
import { DROPDOWN_MENU_KEY, type DropdownMenuPlacement } from './dropdownMenuContext';
26+
import { useTeleportTarget } from './shadowRoot';
2627
2728
defineOptions({ inheritAttrs: false });
2829
@@ -39,6 +40,8 @@ const props = withDefaults(defineProps<{
3940
teleportToBody: false,
4041
});
4142
43+
const teleportTarget = useTeleportTarget();
44+
4245
const isOpen = defineModel<boolean>('open', { default: false });
4346
4447
const attrs = useAttrs();
@@ -148,7 +151,8 @@ function placeFixed() {
148151
}
149152
150153
function onDocumentMousedown(e: MouseEvent) {
151-
const target = e.target as Node;
154+
// inside a shadow root e.target is the host, composedPath() starts at the clicked element
155+
const target = e.composedPath()[0] as Node;
152156
if (rootRef.value?.contains(target) || contentRef.value?.contains(target)) {
153157
return;
154158
}
@@ -163,7 +167,8 @@ function onKeydown(e: KeyboardEvent) {
163167
164168
function onViewportChange(e: Event) {
165169
// scrolling inside the menu itself must not close it
166-
if (e.target instanceof Node && contentRef.value?.contains(e.target)) {
170+
const target = e.composedPath()[0];
171+
if (target instanceof Node && contentRef.value?.contains(target)) {
167172
return;
168173
}
169174
close();

‎adminforth/spa/src/afcl/Dropzone.vue‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
@dragleave.prevent="dragging = false"
66
@drop.prevent="dragging = false; doEmit(($event.dataTransfer as DataTransfer).files)"
77
>
8-
<label :id="id" class="flex flex-col items-center justify-center w-full border border-dashed rounded-lg cursor-pointer transition-colors
8+
<label ref="labelEl" :id="id" class="flex flex-col items-center justify-center w-full border border-dashed rounded-lg cursor-pointer transition-colors
99
hover:bg-lightDropzoneBackgroundHover hover:border-lightDropzoneBorderHover dark:hover:border-darkDropzoneBorderHover dark:hover:bg-darkDropzoneBackgroundHover"
1010
:class="{
1111
'border-lightDropzoneBorderDragging dark:border-darkDropzoneBorderDragging': dragging,
@@ -203,6 +203,7 @@ function doEmit(filesIn: FileList) {
203203
}
204204
205205
const dragging = ref(false);
206+
const labelEl = ref<HTMLLabelElement>();
206207
207208
function removeFile(index: number) {
208209
storedFiles.value = storedFiles.value.filter((_, i) => i !== index);
@@ -214,7 +215,7 @@ function clear() {
214215
selectedFiles.value = [];
215216
storedFiles.value = [];
216217
emit('update:modelValue', []);
217-
const form = document.getElementById(id)?.closest('form');
218+
const form = labelEl.value?.closest('form');
218219
form?.reset();
219220
}
220221

‎adminforth/spa/src/afcl/Modal.vue‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
>
66
<slot name="trigger"></slot>
77
</div>
8-
<Teleport to="body">
8+
<Teleport :to="teleportTarget">
99
<Transition
1010
enter-from-class="af-modal-hidden opacity-0"
1111
enter-active-class="transition-opacity duration-150 ease-out motion-reduce:transition-none"
@@ -81,9 +81,11 @@ import Button from "./Button.vue";
8181
import { ref, onMounted, nextTick, onUnmounted, computed, watch, useId, type Ref } from 'vue';
8282
import { useI18n } from 'vue-i18n';
8383
import { dialogPanelClasses, dialogFooterClasses } from './dialogStyles';
84+
import { useTeleportTarget, activeElementIn } from './shadowRoot';
8485
8586
const isModalOpen = defineModel<boolean>('open', { default: false });
8687
const { t } = useI18n();
88+
const teleportTarget = useTeleportTarget();
8789
8890
const removeFromDom = computed(() => {
8991
return props.removeFromDomOnClose && !isModalOpen.value;
@@ -126,7 +128,7 @@ watch(isModalOpen, async (open) => {
126128
if (open) {
127129
focusBeforeOpen = document.activeElement as HTMLElement | null;
128130
await nextTick();
129-
if (!panelRef.value!.contains(document.activeElement)) {
131+
if (!panelRef.value!.contains(activeElementIn(panelRef.value!))) {
130132
panelRef.value!.focus({ preventScroll: true });
131133
}
132134
} else {

‎adminforth/spa/src/afcl/Select.vue‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
/>
4545
</div>
4646
</div>
47-
<teleport to="body" v-if="(teleportToBody || teleportToTop) && showDropdown">
47+
<teleport :to="teleportTarget" v-if="(teleportToBody || teleportToTop) && showDropdown">
4848
<div ref="dropdownEl" :style="getDropdownPosition" :class="{'shadow-none': isTop, 'z-30': teleportToBody, 'z-[1000]': teleportToTop}"
4949
class="fixed w-full bg-lightDropdownOptionsBackground shadow-popover dark:shadow-darkPopover dark:bg-darkDropdownOptionsBackground
5050
rounded-lg p-1 text-base overflow-auto focus:outline-none sm:text-sm max-h-60"
@@ -118,6 +118,7 @@
118118
import { ref, computed, onMounted, onUnmounted, watch, nextTick, useId,type PropType, type Ref } from 'vue';
119119
import { IconChevronDown, IconX } from '@iconify-prerendered/vue-lucide';
120120
import { useElementSize } from '@vueuse/core'
121+
import { useTeleportTarget } from './shadowRoot';
121122
122123
type ISingleSelectModelValue = string | number | boolean | null;
123124
@@ -310,9 +311,12 @@ const filteredItems: Ref<any[]> = computed(() => {
310311
});
311312
312313
314+
const teleportTarget = useTeleportTarget();
315+
313316
const handleClickOutside = (event: MouseEvent) => {
314-
const targetEl = event.target as HTMLElement | null;
315-
const closestSelect = targetEl?.closest('.afcl-select');
317+
// inside a shadow root event.target is the host, composedPath() starts at the clicked element
318+
const targetEl = event.composedPath()[0] as HTMLElement;
319+
const closestSelect = targetEl.closest('.afcl-select');
316320
if (closestSelect !== internalSelect.value)
317321
showDropdown.value = false;
318322
};

‎adminforth/spa/src/afcl/Tooltip.vue‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div ref="triggerEl" class="afcl-tooltip inline-flex items-center" :style="{ anchorName }" @mouseenter="show" @mouseleave="hide">
33
<slot></slot>
44
</div>
5-
<teleport to="body" v-if="isOpen">
5+
<teleport :to="teleportTarget" v-if="isOpen">
66
<div
77
ref="tooltipEl"
88
popover="manual"
@@ -19,11 +19,13 @@
1919

2020
<script setup lang="ts">
2121
import { ref, nextTick, onBeforeUnmount, useId } from 'vue';
22+
import { useTeleportTarget } from './shadowRoot';
2223
2324
const OFFSET = 8;
2425
const SUPPORTS_ANCHOR = CSS.supports('anchor-name: --a');
2526
2627
const anchorName = `--afcl-tooltip-${useId()}`;
28+
const teleportTarget = useTeleportTarget();
2729
const triggerEl = ref<HTMLElement>();
2830
const tooltipEl = ref<HTMLElement>();
2931
const isOpen = ref(false);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { inject, type InjectionKey } from 'vue';
2+
3+
// AFCL also renders inside a shadow root (the live examples of the docs): popups, modals and tooltips go to an
4+
// element of that root instead of body, or they lose the styles of the root. The element is positioned,
5+
// DatePicker places its calendar relative to it
6+
7+
export const AFCL_TELEPORT_TARGET: InjectionKey<string | HTMLElement> = Symbol('afclTeleportTarget');
8+
9+
export function useTeleportTarget() {
10+
return inject(AFCL_TELEPORT_TARGET, 'body');
11+
}
12+
13+
// the focused element as the tree of node sees it: document.activeElement in a page, inside a shadow root the
14+
// element of that root (document.activeElement there is the shadow host)
15+
export function activeElementIn(node: Node) {
16+
return (node.getRootNode() as Document | ShadowRoot).activeElement;
17+
}

0 commit comments

Comments
 (0)