@cahyo-dimas/freeday 1.52.1 → 1.54.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.
- package/CHANGELOG.md +105 -0
- package/COMPONENTS.md +30 -2
- package/README.id.md +2 -2
- package/README.md +2 -2
- package/USAGE.md +6 -2
- package/adapters/blazor/FdyAppShell.razor +50 -0
- package/adapters/blazor/FdyAppShell.razor.cs +114 -0
- package/adapters/core/app-shell.d.ts +30 -0
- package/adapters/core/app-shell.js +134 -0
- package/adapters/react/components/FdyAppShell.tsx +159 -0
- package/adapters/react/index.d.ts +1 -0
- package/adapters/react/index.js +1 -0
- package/adapters/vue/components/FdyAppShell.vue +160 -0
- package/adapters/vue/index.d.ts +1 -0
- package/adapters/vue/index.js +1 -0
- package/dist/freeday-app-shell.js +207 -0
- package/dist/freeday.js +208 -0
- package/docs/agent-onboarding.md +1 -1
- package/docs/reference-screen.html +5 -16
- package/package.json +2 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
NAV_QUERY,
|
|
5
|
+
applyShellState,
|
|
6
|
+
focusPanel,
|
|
7
|
+
restoreFocus,
|
|
8
|
+
trapTab,
|
|
9
|
+
} from '../../core/app-shell.js';
|
|
10
|
+
|
|
11
|
+
// A React wrapper over freeday's `.fdy-app` shell (src/components/app-shell.css). React port of
|
|
12
|
+
// adapters/vue/components/FdyAppShell.vue, sharing adapters/core/app-shell.js so the two cannot
|
|
13
|
+
// drift into two different focus traps.
|
|
14
|
+
//
|
|
15
|
+
// ONE model: `navOpen` means "the nav is visible to the reader". Above the nav breakpoint a hidden
|
|
16
|
+
// nav is `--nav-collapsed`, below it a visible nav is `--nav-open` — the kit owns that mapping so an
|
|
17
|
+
// app never reasons about the viewport to answer a question about its own UI.
|
|
18
|
+
//
|
|
19
|
+
// Omit `navOpen` and the component keeps its own, defaulting BY VIEWPORT: a column on a wide screen,
|
|
20
|
+
// hidden on a narrow one. That default is why the prop is optional — a parent cannot express it as a
|
|
21
|
+
// single initial value before it knows the viewport.
|
|
22
|
+
|
|
23
|
+
export interface FdyAppShellProps {
|
|
24
|
+
navOpen?: boolean;
|
|
25
|
+
onNavOpenChange?: (open: boolean) => void;
|
|
26
|
+
title?: ReactNode;
|
|
27
|
+
toggleLabel?: string;
|
|
28
|
+
toggleIcon?: ReactNode;
|
|
29
|
+
skip?: ReactNode;
|
|
30
|
+
brand?: ReactNode;
|
|
31
|
+
nav?: ReactNode;
|
|
32
|
+
topbar?: ReactNode;
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
|
|
37
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const restoreToRef = useRef<Element | null>(null);
|
|
39
|
+
/* Set while a viewport change is driving the state, so the effect below reconciles `inert` and
|
|
40
|
+
the classes but leaves FOCUS alone: a resize is not a reader asking to go somewhere. */
|
|
41
|
+
const fromResizeRef = useRef<boolean>(false);
|
|
42
|
+
const [overlay, setOverlay] = useState<boolean>(false);
|
|
43
|
+
const [uncontrolled, setUncontrolled] = useState<boolean>(true);
|
|
44
|
+
|
|
45
|
+
const controlled: boolean = props.navOpen !== undefined;
|
|
46
|
+
const navVisible: boolean = controlled ? props.navOpen === true : uncontrolled;
|
|
47
|
+
|
|
48
|
+
const setVisible = useCallback((next: boolean): void => {
|
|
49
|
+
if (!controlled) setUncontrolled(next);
|
|
50
|
+
if (props.onNavOpenChange !== undefined) props.onNavOpenChange(next);
|
|
51
|
+
}, [controlled, props.onNavOpenChange]);
|
|
52
|
+
|
|
53
|
+
/* The media listener reads these through refs and subscribes ONCE. Depending on navVisible would
|
|
54
|
+
resubscribe on every toggle and re-run the handler, and its "narrowing with the nav visible
|
|
55
|
+
hides it" rule would then fire on an ordinary open — closing the panel in the same tick the
|
|
56
|
+
reader opened it. */
|
|
57
|
+
const navVisibleRef = useRef<boolean>(navVisible);
|
|
58
|
+
navVisibleRef.current = navVisible;
|
|
59
|
+
const setVisibleRef = useRef<(next: boolean) => void>(setVisible);
|
|
60
|
+
setVisibleRef.current = setVisible;
|
|
61
|
+
|
|
62
|
+
// The media query owns `overlay`, and the two directions are not symmetrical. Narrowing with the
|
|
63
|
+
// nav visible would drop an overlay panel over a page nobody asked to leave, so it is hidden;
|
|
64
|
+
// widening is harmless, since a visible nav simply becomes the column again.
|
|
65
|
+
useEffect((): (() => void) => {
|
|
66
|
+
const media: MediaQueryList = window.matchMedia(NAV_QUERY);
|
|
67
|
+
const onChange = (): void => {
|
|
68
|
+
const nowOverlay: boolean = !media.matches;
|
|
69
|
+
setOverlay((was: boolean): boolean => {
|
|
70
|
+
if (was !== nowOverlay) fromResizeRef.current = true;
|
|
71
|
+
return nowOverlay;
|
|
72
|
+
});
|
|
73
|
+
if (nowOverlay && navVisibleRef.current) setVisibleRef.current(false);
|
|
74
|
+
};
|
|
75
|
+
// Mount: adopt the viewport without the hide-on-narrow side effect — nothing is open yet.
|
|
76
|
+
setOverlay(!media.matches);
|
|
77
|
+
if (!controlled) setUncontrolled(media.matches);
|
|
78
|
+
media.addEventListener('change', onChange);
|
|
79
|
+
return (): void => media.removeEventListener('change', onChange);
|
|
80
|
+
}, [controlled]);
|
|
81
|
+
|
|
82
|
+
// Focus moves only after the class change has been painted, or the panel is still off-canvas and
|
|
83
|
+
// the browser refuses to focus what it cannot lay out.
|
|
84
|
+
useEffect((): void => {
|
|
85
|
+
const el: HTMLDivElement | null = rootRef.current;
|
|
86
|
+
if (el === null) return;
|
|
87
|
+
applyShellState(el, { navVisible, overlay });
|
|
88
|
+
if (fromResizeRef.current) {
|
|
89
|
+
fromResizeRef.current = false;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!overlay) return;
|
|
93
|
+
if (navVisible) restoreToRef.current = focusPanel(el);
|
|
94
|
+
else restoreFocus(el, restoreToRef.current);
|
|
95
|
+
}, [navVisible, overlay]);
|
|
96
|
+
|
|
97
|
+
useEffect((): (() => void) => {
|
|
98
|
+
const onKeydown = (e: KeyboardEvent): void => {
|
|
99
|
+
const el: HTMLDivElement | null = rootRef.current;
|
|
100
|
+
if (el === null || !overlay || !navVisible) return;
|
|
101
|
+
if (e.key === 'Escape') {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setVisible(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
trapTab(el, e);
|
|
107
|
+
};
|
|
108
|
+
document.addEventListener('keydown', onKeydown);
|
|
109
|
+
return (): void => document.removeEventListener('keydown', onKeydown);
|
|
110
|
+
}, [overlay, navVisible, setVisible]);
|
|
111
|
+
|
|
112
|
+
const shellClass: string = overlay
|
|
113
|
+
? (navVisible ? 'fdy-app fdy-app--nav-open' : 'fdy-app')
|
|
114
|
+
: (navVisible ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed');
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div ref={rootRef} className={shellClass}>
|
|
118
|
+
{props.skip}
|
|
119
|
+
|
|
120
|
+
<aside
|
|
121
|
+
className="fdy-app__sidebar"
|
|
122
|
+
onClick={(e): void => {
|
|
123
|
+
// Following a link in an overlay nav means "take me there" — the panel must not stay over
|
|
124
|
+
// the page it was just asked for. On a wide viewport the nav is a column: nothing to close.
|
|
125
|
+
if (!overlay || !navVisible) return;
|
|
126
|
+
const target: HTMLElement | null = e.target as HTMLElement | null;
|
|
127
|
+
if (target !== null && target.closest('.fdy-nav__item') !== null) setVisible(false);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{props.brand}
|
|
131
|
+
{props.nav}
|
|
132
|
+
</aside>
|
|
133
|
+
|
|
134
|
+
<div className="fdy-app__content">
|
|
135
|
+
<header className="fdy-app__topbar">
|
|
136
|
+
<button
|
|
137
|
+
className="fdy-app__navtoggle"
|
|
138
|
+
type="button"
|
|
139
|
+
aria-label={props.toggleLabel ?? 'Toggle navigation'}
|
|
140
|
+
onClick={(): void => setVisible(!navVisible)}
|
|
141
|
+
>{props.toggleIcon ?? '☰'}</button>
|
|
142
|
+
|
|
143
|
+
<h1 className="fdy-app__title">{props.title}</h1>
|
|
144
|
+
|
|
145
|
+
{props.topbar}
|
|
146
|
+
</header>
|
|
147
|
+
|
|
148
|
+
<main className="fdy-app__main">{props.children}</main>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
className="fdy-app__backdrop"
|
|
153
|
+
onClick={(): void => {
|
|
154
|
+
if (overlay && navVisible) setVisible(false);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -41,6 +41,7 @@ export { FdyTable, type FdyTableProps } from './components/FdyTable';
|
|
|
41
41
|
export { FdyTableFooter, type FdyTableFooterProps } from './components/FdyTableFooter';
|
|
42
42
|
export { FdyModal, type FdyModalProps } from './components/FdyModal';
|
|
43
43
|
export { FdyDrawer, type FdyDrawerProps } from './components/FdyDrawer';
|
|
44
|
+
export { FdyAppShell, type FdyAppShellProps } from './components/FdyAppShell';
|
|
44
45
|
|
|
45
46
|
/** Controlled data-table types (shared, framework-agnostic core). */
|
|
46
47
|
export type {
|
package/adapters/react/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { FdyTable } from './components/FdyTable.tsx';
|
|
|
11
11
|
export { FdyTableFooter } from './components/FdyTableFooter.tsx';
|
|
12
12
|
export { FdyModal } from './components/FdyModal.tsx';
|
|
13
13
|
export { FdyDrawer } from './components/FdyDrawer.tsx';
|
|
14
|
+
export { FdyAppShell } from './components/FdyAppShell.tsx';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue';
|
|
3
|
+
import {
|
|
4
|
+
NAV_QUERY,
|
|
5
|
+
applyShellState,
|
|
6
|
+
focusPanel,
|
|
7
|
+
restoreFocus,
|
|
8
|
+
trapTab,
|
|
9
|
+
} from '../../core/app-shell.js';
|
|
10
|
+
|
|
11
|
+
// A Vue wrapper over freeday's `.fdy-app` shell (src/components/app-shell.css) that ships the
|
|
12
|
+
// behaviour the CSS cannot: Escape, backdrop, focus into the panel and back, `inert`, and a Tab
|
|
13
|
+
// trap. The DOM work lives in adapters/core/app-shell.js so this component, its React twin and the
|
|
14
|
+
// Blazor bridge cannot drift into three different focus traps.
|
|
15
|
+
//
|
|
16
|
+
// ONE model: `navOpen` means "the nav is visible to the reader". The two state classes are the
|
|
17
|
+
// kit's business — above the nav breakpoint a hidden nav is `--nav-collapsed`, below it a visible
|
|
18
|
+
// nav is `--nav-open` — so an app never reasons about the viewport to answer a question about its
|
|
19
|
+
// own UI.
|
|
20
|
+
//
|
|
21
|
+
// Leave `navOpen` unbound and the component keeps its own: visible on a wide viewport, hidden on a
|
|
22
|
+
// narrow one, which is the right default and one an app cannot express in a single initial value.
|
|
23
|
+
// Bind it (`v-model:navOpen`) when the app wants to drive it — a menu item, a persisted preference.
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
navOpen?: boolean;
|
|
27
|
+
title?: string;
|
|
28
|
+
toggleLabel?: string;
|
|
29
|
+
}>(), { navOpen: undefined, title: '', toggleLabel: 'Toggle navigation' });
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
'update:navOpen': [boolean];
|
|
33
|
+
}>();
|
|
34
|
+
|
|
35
|
+
const root: Ref<HTMLElement | null> = ref(null);
|
|
36
|
+
const overlay: Ref<boolean> = ref(false);
|
|
37
|
+
const uncontrolled: Ref<boolean> = ref(true);
|
|
38
|
+
const restoreTo: Ref<Element | null> = ref(null);
|
|
39
|
+
let media: MediaQueryList | null = null;
|
|
40
|
+
/* Set while a viewport change is driving the state, so the watcher below reconciles `inert` and the
|
|
41
|
+
classes but leaves FOCUS alone: a resize is not a reader asking to go somewhere. */
|
|
42
|
+
let fromResize: boolean = false;
|
|
43
|
+
|
|
44
|
+
const navVisible: ComputedRef<boolean> = computed((): boolean =>
|
|
45
|
+
props.navOpen === undefined ? uncontrolled.value : props.navOpen,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const shellClass: ComputedRef<string> = computed((): string => {
|
|
49
|
+
if (overlay.value) return navVisible.value ? 'fdy-app fdy-app--nav-open' : 'fdy-app';
|
|
50
|
+
return navVisible.value ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function setVisible(next: boolean): void {
|
|
54
|
+
uncontrolled.value = next;
|
|
55
|
+
emit('update:navOpen', next);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Focus is moved AFTER the class change has been rendered, or the panel is still off-canvas and
|
|
59
|
+
the browser refuses to focus what it cannot lay out. */
|
|
60
|
+
watch([navVisible, overlay], ([visible, isOverlay]: [boolean, boolean]): void => {
|
|
61
|
+
const el: HTMLElement | null = root.value;
|
|
62
|
+
if (el === null) return;
|
|
63
|
+
applyShellState(el, { navVisible: visible, overlay: isOverlay });
|
|
64
|
+
if (fromResize) {
|
|
65
|
+
fromResize = false;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!isOverlay) return;
|
|
69
|
+
if (visible) restoreTo.value = focusPanel(el);
|
|
70
|
+
else restoreFocus(el, restoreTo.value);
|
|
71
|
+
}, { flush: 'post' });
|
|
72
|
+
|
|
73
|
+
function onToggle(): void {
|
|
74
|
+
setVisible(!navVisible.value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onBackdrop(): void {
|
|
78
|
+
if (overlay.value && navVisible.value) setVisible(false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Following a link in an overlay nav means "take me there" — the panel must not stay over the page
|
|
82
|
+
it was just asked for. On a wide viewport the nav is a column and clicking it changes nothing. */
|
|
83
|
+
function onSidebarClick(e: MouseEvent): void {
|
|
84
|
+
if (!overlay.value || !navVisible.value) return;
|
|
85
|
+
const target: HTMLElement | null = e.target as HTMLElement | null;
|
|
86
|
+
if (target !== null && target.closest('.fdy-nav__item') !== null) setVisible(false);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
90
|
+
const el: HTMLElement | null = root.value;
|
|
91
|
+
if (el === null || !overlay.value || !navVisible.value) return;
|
|
92
|
+
if (e.key === 'Escape') {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
setVisible(false);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
trapTab(el, e);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Both directions matter, and they are not symmetrical.
|
|
101
|
+
Narrowing with the nav visible would drop an overlay panel over a page nobody asked to leave, so
|
|
102
|
+
the nav is hidden. Widening is harmless — a visible nav simply becomes the column again — and the
|
|
103
|
+
watcher clears the `inert` the overlay had put on the content. */
|
|
104
|
+
function onMediaChange(): void {
|
|
105
|
+
const nowOverlay: boolean = media !== null && !media.matches;
|
|
106
|
+
if (nowOverlay === overlay.value) return;
|
|
107
|
+
fromResize = true;
|
|
108
|
+
overlay.value = nowOverlay;
|
|
109
|
+
if (nowOverlay && navVisible.value) setVisible(false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onMounted((): void => {
|
|
113
|
+
media = window.matchMedia(NAV_QUERY);
|
|
114
|
+
overlay.value = !media.matches;
|
|
115
|
+
uncontrolled.value = media.matches;
|
|
116
|
+
media.addEventListener('change', onMediaChange);
|
|
117
|
+
document.addEventListener('keydown', onKeydown);
|
|
118
|
+
const el: HTMLElement | null = root.value;
|
|
119
|
+
if (el !== null) applyShellState(el, { navVisible: navVisible.value, overlay: overlay.value });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
onBeforeUnmount((): void => {
|
|
123
|
+
if (media !== null) media.removeEventListener('change', onMediaChange);
|
|
124
|
+
document.removeEventListener('keydown', onKeydown);
|
|
125
|
+
});
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
<template>
|
|
129
|
+
<div ref="root" :class="shellClass">
|
|
130
|
+
<slot name="skip" />
|
|
131
|
+
|
|
132
|
+
<aside class="fdy-app__sidebar" @click="onSidebarClick">
|
|
133
|
+
<slot name="brand" />
|
|
134
|
+
<slot name="nav" />
|
|
135
|
+
</aside>
|
|
136
|
+
|
|
137
|
+
<div class="fdy-app__content">
|
|
138
|
+
<header class="fdy-app__topbar">
|
|
139
|
+
<button
|
|
140
|
+
class="fdy-app__navtoggle"
|
|
141
|
+
type="button"
|
|
142
|
+
:aria-label="toggleLabel"
|
|
143
|
+
@click="onToggle"
|
|
144
|
+
><slot name="toggle-icon">☰</slot></button>
|
|
145
|
+
|
|
146
|
+
<h1 class="fdy-app__title">
|
|
147
|
+
<slot name="title">{{ title }}</slot>
|
|
148
|
+
</h1>
|
|
149
|
+
|
|
150
|
+
<slot name="topbar" />
|
|
151
|
+
</header>
|
|
152
|
+
|
|
153
|
+
<main class="fdy-app__main">
|
|
154
|
+
<slot />
|
|
155
|
+
</main>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div class="fdy-app__backdrop" @click="onBackdrop" />
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
package/adapters/vue/index.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export { default as FdyTable } from './components/FdyTable.vue';
|
|
|
40
40
|
export { default as FdyTableFooter } from './components/FdyTableFooter.vue';
|
|
41
41
|
export { default as FdyModal } from './components/FdyModal.vue';
|
|
42
42
|
export { default as FdyDrawer } from './components/FdyDrawer.vue';
|
|
43
|
+
export { default as FdyAppShell } from './components/FdyAppShell.vue';
|
|
43
44
|
|
|
44
45
|
/** Controlled data-table types (shared, framework-agnostic core). */
|
|
45
46
|
export type {
|
package/adapters/vue/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { default as FdyTable } from './components/FdyTable.vue';
|
|
|
11
11
|
export { default as FdyTableFooter } from './components/FdyTableFooter.vue';
|
|
12
12
|
export { default as FdyModal } from './components/FdyModal.vue';
|
|
13
13
|
export { default as FdyDrawer } from './components/FdyDrawer.vue';
|
|
14
|
+
export { default as FdyAppShell } from './components/FdyAppShell.vue';
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/* Freeday — app shell enhancer (optional, zero-dependency).
|
|
2
|
+
* Opt in with <div class="fdy-app" data-fdy-app>; the markup is otherwise unchanged.
|
|
3
|
+
*
|
|
4
|
+
* The shell has always shipped the state classes and no behaviour, which left every consumer to
|
|
5
|
+
* assemble the toggle, Escape, focus trap, focus restore and `inert` themselves — and the two
|
|
6
|
+
* hand-rolled copies in this repo's own docs already disagreed about which of those exist. Below
|
|
7
|
+
* 721px the sidebar is an OVERLAY (app-shell.css: position:fixed + translateX(-100%)), so it needs
|
|
8
|
+
* the same treatment a modal drawer gets; at or above 721px it is a column that merely collapses.
|
|
9
|
+
*
|
|
10
|
+
* One rule covers both a hidden nav and a hidden page:
|
|
11
|
+
* - `__sidebar` is inert whenever the nav is NOT visible. Off-canvas and collapsed panels stay in
|
|
12
|
+
* the tab order otherwise — translateX(-100%) and width:0 hide a thing from the eye, not from
|
|
13
|
+
* the keyboard, which is how a nav nobody can see still swallows Tab.
|
|
14
|
+
* - `__content` is inert only while the nav is an OPEN OVERLAY, so Tab cannot wander behind the
|
|
15
|
+
* backdrop.
|
|
16
|
+
*
|
|
17
|
+
* Emits a bubbling `fdy-app-nav` CustomEvent (detail {visible}) whenever the nav's visibility
|
|
18
|
+
* changes, and takes `FreedayAppShell.setVisible(root, visible)` from outside. Those two exist for
|
|
19
|
+
* the same reason the other enhancers have them: a host that keeps its own state — the Blazor
|
|
20
|
+
* wrapper binding @bind-NavOpen, an app persisting the collapsed preference — has to be able to
|
|
21
|
+
* hear the change and to drive it, without owning the behaviour twice.
|
|
22
|
+
* FreedayAppShell.init(root) for late-mounted markup.
|
|
23
|
+
*/
|
|
24
|
+
(function () {
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/* Must match app-shell.css, which switches at (min-width:721px) / (max-width:720px). */
|
|
28
|
+
var WIDE = '(min-width: 721px)';
|
|
29
|
+
|
|
30
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
|
|
31
|
+
+ ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
32
|
+
|
|
33
|
+
/* getClientRects(), not offsetParent: an overlay sidebar is position:fixed, and a fixed element
|
|
34
|
+
reports no offsetParent at all — filtering on it would call every nav item invisible. */
|
|
35
|
+
function focusables(root) {
|
|
36
|
+
return Array.prototype.filter.call(root.querySelectorAll(FOCUSABLE), function (el) {
|
|
37
|
+
return el.getClientRects().length > 0;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setInert(el, on) {
|
|
42
|
+
if (!el) return;
|
|
43
|
+
if (on) el.setAttribute('inert', '');
|
|
44
|
+
else el.removeAttribute('inert');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function initShell(app) {
|
|
48
|
+
if (app.dataset.fdyAppShellReady === '1') return;
|
|
49
|
+
app.dataset.fdyAppShellReady = '1';
|
|
50
|
+
|
|
51
|
+
var toggle = app.querySelector('.fdy-app__navtoggle');
|
|
52
|
+
var sidebar = app.querySelector('.fdy-app__sidebar');
|
|
53
|
+
if (!toggle || !sidebar) return;
|
|
54
|
+
var content = app.querySelector('.fdy-app__content');
|
|
55
|
+
var backdrop = app.querySelector('.fdy-app__backdrop');
|
|
56
|
+
|
|
57
|
+
var mqWide = window.matchMedia(WIDE);
|
|
58
|
+
var restoreTo = null;
|
|
59
|
+
var lastVisible = null;
|
|
60
|
+
|
|
61
|
+
function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
|
|
62
|
+
function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
|
|
63
|
+
function navVisible() { return mqWide.matches ? !isCollapsed() : isOverlayOpen(); }
|
|
64
|
+
|
|
65
|
+
/* aria-expanded answers "is the nav showing?" in BOTH modes — the two state classes are the
|
|
66
|
+
kit's business, not the reader's. */
|
|
67
|
+
function sync() {
|
|
68
|
+
var visible = navVisible();
|
|
69
|
+
toggle.setAttribute('aria-expanded', String(visible));
|
|
70
|
+
setInert(sidebar, !visible);
|
|
71
|
+
setInert(content, !mqWide.matches && visible);
|
|
72
|
+
/* Announce only real changes. The first sync() runs at init to describe the state the markup
|
|
73
|
+
arrived in, which is not something a host asked for and must not look like one. */
|
|
74
|
+
if (lastVisible !== null && visible !== lastVisible) {
|
|
75
|
+
app.dispatchEvent(new CustomEvent('fdy-app-nav', { bubbles: true, detail: { visible: visible } }));
|
|
76
|
+
}
|
|
77
|
+
lastVisible = visible;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function open() {
|
|
81
|
+
if (isOverlayOpen()) return;
|
|
82
|
+
restoreTo = document.activeElement;
|
|
83
|
+
app.classList.add('fdy-app--nav-open');
|
|
84
|
+
sync();
|
|
85
|
+
var first = focusables(sidebar)[0];
|
|
86
|
+
if (first) {
|
|
87
|
+
first.focus();
|
|
88
|
+
} else {
|
|
89
|
+
sidebar.setAttribute('tabindex', '-1');
|
|
90
|
+
sidebar.focus();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* restoreFocus is false when the viewport closed it rather than the user — a resize must not
|
|
95
|
+
yank focus out from under whatever the reader was doing. */
|
|
96
|
+
function close(restoreFocus) {
|
|
97
|
+
if (!isOverlayOpen()) return;
|
|
98
|
+
app.classList.remove('fdy-app--nav-open');
|
|
99
|
+
sync();
|
|
100
|
+
if (restoreFocus === false) return;
|
|
101
|
+
/* Back to the control that opened it. document.body is where a keyboard user gets stranded,
|
|
102
|
+
so anything that is gone, detached or the body itself falls back to the toggle. */
|
|
103
|
+
var target = (restoreTo && restoreTo !== document.body && document.contains(restoreTo))
|
|
104
|
+
? restoreTo : toggle;
|
|
105
|
+
if (typeof target.focus === 'function') target.focus();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
toggle.addEventListener('click', function () {
|
|
109
|
+
if (mqWide.matches) {
|
|
110
|
+
app.classList.toggle('fdy-app--nav-collapsed');
|
|
111
|
+
sync();
|
|
112
|
+
} else if (isOverlayOpen()) {
|
|
113
|
+
close();
|
|
114
|
+
} else {
|
|
115
|
+
open();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (backdrop) backdrop.addEventListener('click', function () { close(); });
|
|
120
|
+
|
|
121
|
+
/* Following a link inside an overlay nav means "take me there" — leaving the panel open over
|
|
122
|
+
the page you just asked for is the one thing every hand-rolled copy in this repo disagreed on. */
|
|
123
|
+
sidebar.addEventListener('click', function (e) {
|
|
124
|
+
if (!isOverlayOpen()) return;
|
|
125
|
+
if (e.target.closest && e.target.closest('.fdy-nav__item')) close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
document.addEventListener('keydown', function (e) {
|
|
129
|
+
if (!isOverlayOpen()) return;
|
|
130
|
+
if (e.key === 'Escape') {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
close();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (e.key !== 'Tab') return;
|
|
136
|
+
/* The trap. `inert` on the content stops Tab reaching the page, but without this the focus
|
|
137
|
+
would walk out of the document into the browser's own chrome instead of cycling. */
|
|
138
|
+
var items = focusables(sidebar);
|
|
139
|
+
if (items.length === 0) {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
var first = items[0], last = items[items.length - 1];
|
|
144
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
last.focus();
|
|
147
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
first.focus();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/* Crossing the breakpoint with the overlay open would otherwise leave --nav-open set and the
|
|
154
|
+
content inert forever: the panel becomes a static column again, and the page it is covering
|
|
155
|
+
can no longer be clicked or read. */
|
|
156
|
+
mqWide.addEventListener('change', function () {
|
|
157
|
+
if (mqWide.matches && isOverlayOpen()) close(false);
|
|
158
|
+
else sync();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
sync();
|
|
162
|
+
|
|
163
|
+
/* The same handle the other enhancers expose (`_fdyCombo` and friends): a host that binds its
|
|
164
|
+
own state needs to drive this without reaching for the class names the kit reserves. */
|
|
165
|
+
app._fdyAppShell = {
|
|
166
|
+
isVisible: navVisible,
|
|
167
|
+
setVisible: function (visible) {
|
|
168
|
+
if (visible === navVisible()) return;
|
|
169
|
+
if (mqWide.matches) {
|
|
170
|
+
app.classList.toggle('fdy-app--nav-collapsed', !visible);
|
|
171
|
+
sync();
|
|
172
|
+
} else if (visible) {
|
|
173
|
+
open();
|
|
174
|
+
} else {
|
|
175
|
+
close();
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function initShells(context) {
|
|
182
|
+
var ctx = context || document;
|
|
183
|
+
/* root included: querySelectorAll never matches its own root, and a framework ref often sits ON
|
|
184
|
+
the widget. */
|
|
185
|
+
if (ctx.matches && ctx.matches('[data-fdy-app]')) initShell(ctx);
|
|
186
|
+
Array.prototype.forEach.call(ctx.querySelectorAll('[data-fdy-app]'), initShell);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (document.readyState === 'loading') {
|
|
190
|
+
document.addEventListener('DOMContentLoaded', function () { initShells(); });
|
|
191
|
+
} else {
|
|
192
|
+
initShells();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
window.FreedayAppShell = {
|
|
196
|
+
init: initShells,
|
|
197
|
+
initAll: initShells,
|
|
198
|
+
/* Both take the shell root. A missing or un-initialised root is a no-op rather than a throw:
|
|
199
|
+
a host may race the enhancer on first render, and a crash there is worse than a late sync. */
|
|
200
|
+
setVisible: function (root, visible) {
|
|
201
|
+
if (root && root._fdyAppShell) root._fdyAppShell.setVisible(visible === true);
|
|
202
|
+
},
|
|
203
|
+
isVisible: function (root) {
|
|
204
|
+
return !!(root && root._fdyAppShell && root._fdyAppShell.isVisible());
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
})();
|