@royallondon/design-system-component-drawer 0.0.0-stage → 1.1.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/LICENSE.md +54 -0
- package/README.md +202 -2
- package/dist/drawer.css +1 -0
- package/dist/drawer.d.ts +13 -0
- package/dist/drawer.js +352 -0
- package/package.json +41 -3
package/LICENSE.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Authorised Use Licence v1.0
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Royal London.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
1. Grant of Licence
|
|
7
|
+
|
|
8
|
+
The Licensor grants a non-exclusive, non-transferable,
|
|
9
|
+
revocable, royalty-free licence to use the Software only where:
|
|
10
|
+
|
|
11
|
+
(a) the individual, organisation, or business has been
|
|
12
|
+
expressly authorised in writing by the Licensor; and
|
|
13
|
+
|
|
14
|
+
(b) such authorisation remains in force.
|
|
15
|
+
|
|
16
|
+
2. Restrictions
|
|
17
|
+
|
|
18
|
+
The Software may not be:
|
|
19
|
+
|
|
20
|
+
(a) used by any unauthorised person or organisation;
|
|
21
|
+
|
|
22
|
+
(b) redistributed, sublicensed, sold, rented or made
|
|
23
|
+
available to third parties;
|
|
24
|
+
|
|
25
|
+
(c) incorporated into another product or service without
|
|
26
|
+
written approval;
|
|
27
|
+
|
|
28
|
+
(d) reverse engineered except where prohibited by law.
|
|
29
|
+
|
|
30
|
+
3. Ownership
|
|
31
|
+
|
|
32
|
+
The Software remains the exclusive property of the Licensor.
|
|
33
|
+
No ownership rights are transferred under this Licence.
|
|
34
|
+
|
|
35
|
+
4. Termination
|
|
36
|
+
|
|
37
|
+
The Licensor may revoke authorisation at any time by
|
|
38
|
+
written notice.
|
|
39
|
+
|
|
40
|
+
Upon termination the Licensee must immediately cease use
|
|
41
|
+
of the Software and destroy all copies.
|
|
42
|
+
|
|
43
|
+
5. No Warranty
|
|
44
|
+
|
|
45
|
+
The Software is provided "as is" without warranty of any kind.
|
|
46
|
+
|
|
47
|
+
6. Limitation of Liability
|
|
48
|
+
|
|
49
|
+
The Licensor shall not be liable for any damages arising
|
|
50
|
+
from use of the Software.
|
|
51
|
+
|
|
52
|
+
7. Governing Law
|
|
53
|
+
|
|
54
|
+
This Licence shall be governed by the laws of England and Wales.
|
package/README.md
CHANGED
|
@@ -1,3 +1,203 @@
|
|
|
1
|
-
|
|
1
|
+
### Installation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install the Drawer package from npm
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @royallondon/design-system-component-drawer
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Import the required CSS file
|
|
10
|
+
|
|
11
|
+
```css
|
|
12
|
+
@import '@royallondon/design-system-component-drawer/dist/drawer';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Import and initialise the JavaScript
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import drawer from '@royallondon/design-system-component-drawer/dist/drawer';
|
|
19
|
+
|
|
20
|
+
drawer(document.querySelectorAll('.rl-c-drawer'));
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The Drawer comes in two patterns, built on different platform features:
|
|
24
|
+
|
|
25
|
+
- **Standard drawer**: a `<dialog>` that is always modal. The browser handles
|
|
26
|
+
opening, closing, focus and the scrim.
|
|
27
|
+
- **On display drawer**: a landmark with the `popover` attribute, shown inline
|
|
28
|
+
from `768px` up and modal below that. The browser handles opening, closing
|
|
29
|
+
and the scrim; the JavaScript makes the rest of the page inert while it is
|
|
30
|
+
open.
|
|
31
|
+
|
|
32
|
+
### Standard drawer
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<button type="button" commandfor="filters-drawer" command="show-modal">
|
|
36
|
+
Filters
|
|
37
|
+
</button>
|
|
38
|
+
|
|
39
|
+
<dialog
|
|
40
|
+
class="rl-c-drawer"
|
|
41
|
+
id="filters-drawer"
|
|
42
|
+
aria-labelledby="filters-drawer-title"
|
|
43
|
+
closedby="any"
|
|
44
|
+
>
|
|
45
|
+
<div class="rl-c-drawer__header">
|
|
46
|
+
<h2 class="rl-c-drawer__title" id="filters-drawer-title">Filters</h2>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="rl-c-drawer__close"
|
|
50
|
+
commandfor="filters-drawer"
|
|
51
|
+
command="close"
|
|
52
|
+
aria-label="Close filters"
|
|
53
|
+
>
|
|
54
|
+
×
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="rl-c-drawer__content">…</div>
|
|
58
|
+
<div class="rl-c-drawer__footer">…</div>
|
|
59
|
+
</dialog>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- The drawer must be a `<dialog>` element. The scrim is its `::backdrop`.
|
|
63
|
+
- `aria-labelledby` must reference the drawer's title.
|
|
64
|
+
- Toggles and close buttons must be `<button>` elements, using `commandfor`
|
|
65
|
+
with `command="show-modal"` or `command="close"`.
|
|
66
|
+
- `closedby="any"` lets users close the drawer by clicking the scrim. Leave it
|
|
67
|
+
out and the drawer closes only with <kbd>Escape</kbd> or its close button.
|
|
68
|
+
- Do not add `role`, `aria-modal` or `tabindex` to the dialog.
|
|
69
|
+
|
|
70
|
+
### On display drawer
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
class="rl-c-drawer__toggle--on-display"
|
|
76
|
+
popovertarget="plans-drawer"
|
|
77
|
+
>
|
|
78
|
+
Your plans
|
|
79
|
+
</button>
|
|
80
|
+
|
|
81
|
+
<nav
|
|
82
|
+
class="rl-c-drawer rl-c-drawer--on-display"
|
|
83
|
+
id="plans-drawer"
|
|
84
|
+
aria-labelledby="plans-drawer-title"
|
|
85
|
+
popover
|
|
86
|
+
>
|
|
87
|
+
<div class="rl-c-drawer__header">
|
|
88
|
+
<h2 class="rl-c-drawer__title" id="plans-drawer-title">Your plans</h2>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="rl-c-drawer__close"
|
|
92
|
+
popovertarget="plans-drawer"
|
|
93
|
+
popovertargetaction="hide"
|
|
94
|
+
aria-label="Close your plans"
|
|
95
|
+
>
|
|
96
|
+
×
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="rl-c-drawer__content">…</div>
|
|
100
|
+
</nav>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Use the landmark element that matches the content: `<nav>` for navigation,
|
|
104
|
+
`<aside>` for complementary content. The on-display drawer is for landmark
|
|
105
|
+
content only; for anything else, use the standard drawer.
|
|
106
|
+
- `aria-labelledby` must reference the drawer's title, so the landmark is
|
|
107
|
+
named in both states.
|
|
108
|
+
- The toggle uses `popovertarget`, and the close button adds
|
|
109
|
+
`popovertargetaction="hide"`. The browser sets `aria-expanded` on the
|
|
110
|
+
toggle.
|
|
111
|
+
- Add `rl-c-drawer__toggle--on-display` to the toggle, so it is hidden from
|
|
112
|
+
`768px` up without waiting for the JavaScript.
|
|
113
|
+
- Clicking the scrim and pressing <kbd>Escape</kbd> both close the drawer.
|
|
114
|
+
- The drawer sticks to the top of the viewport in its inline state. Its
|
|
115
|
+
position in the page, such as a grid column, is set by the consuming layout.
|
|
116
|
+
|
|
117
|
+
### Variants
|
|
118
|
+
|
|
119
|
+
| Class | Behaviour |
|
|
120
|
+
| --------------------------------- | ----------------------------------------------------------------- |
|
|
121
|
+
| `rl-c-drawer` | Full viewport height at the inline start edge, opens over a scrim |
|
|
122
|
+
| `rl-c-drawer--end` | Full viewport height at the inline end edge instead |
|
|
123
|
+
| `rl-c-drawer--on-display` | On display drawer, shown inline from `768px` (`48rem`) up |
|
|
124
|
+
| `rl-c-drawer__toggle--on-display` | Hides an on display drawer's toggle from `768px` up |
|
|
125
|
+
|
|
126
|
+
### Options
|
|
127
|
+
|
|
128
|
+
`drawer(elements, options)` and `createDrawer(element, options)` accept:
|
|
129
|
+
|
|
130
|
+
| Option | Default | Description |
|
|
131
|
+
| ---------------- | -------------------- | -------------------------------------------------------------------------- |
|
|
132
|
+
| `onDisplayQuery` | `(min-width: 48rem)` | Media query for the on display drawer's inline state. Must match `--mq-md` |
|
|
133
|
+
|
|
134
|
+
### API
|
|
135
|
+
|
|
136
|
+
`createDrawer` is available as a named export:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
import { createDrawer } from '@royallondon/design-system-component-drawer/dist/drawer';
|
|
140
|
+
|
|
141
|
+
const plansDrawer = createDrawer(document.getElementById('plans-drawer'));
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Each instance returns `{ open, close, destroy, isOpen, isOnDisplay }`.
|
|
145
|
+
|
|
146
|
+
- `open()` does nothing while an on display drawer is in its inline state.
|
|
147
|
+
- `isOnDisplay()` is always `false` for a standard drawer.
|
|
148
|
+
- Initialising an element that already has an instance returns the existing
|
|
149
|
+
instance.
|
|
150
|
+
- To react to a standard drawer closing, listen for the dialog's `close`
|
|
151
|
+
event. For an on display drawer, listen for the popover's `toggle` event and
|
|
152
|
+
check `event.newState`.
|
|
153
|
+
|
|
154
|
+
### Accessibility
|
|
155
|
+
|
|
156
|
+
When a drawer opens:
|
|
157
|
+
|
|
158
|
+
- focus moves into it
|
|
159
|
+
- the rest of the page becomes inert
|
|
160
|
+
- <kbd>Escape</kbd> closes it, and focus returns to the toggle
|
|
161
|
+
|
|
162
|
+
The on display drawer keeps its landmark semantics in both states. If the
|
|
163
|
+
viewport changes size while focus is inside an on display drawer, focus moves
|
|
164
|
+
to wherever the drawer's content can still be reached.
|
|
165
|
+
|
|
166
|
+
The CSS locks page scrolling while a drawer is open, keeps a visible panel
|
|
167
|
+
edge in forced colours mode, and removes the slide animation for users who
|
|
168
|
+
prefer reduced motion.
|
|
169
|
+
|
|
170
|
+
### Browser support
|
|
171
|
+
|
|
172
|
+
Supports the latest version, and the two before it, of Chrome, Edge, Firefox
|
|
173
|
+
and Safari.
|
|
174
|
+
|
|
175
|
+
| Feature | Used by | Without native support |
|
|
176
|
+
| ------------------------------------ | ----------------- | --------------------------------------------- |
|
|
177
|
+
| `command` / `commandfor` | Standard drawer | Handled by the JavaScript fallback |
|
|
178
|
+
| `closedby` | Standard drawer | Handled by the JavaScript fallback |
|
|
179
|
+
| `popover` / `popovertarget`, `inert` | On display drawer | Native in every supported version |
|
|
180
|
+
| Open and close animation | Both | The drawer opens and closes without animating |
|
|
181
|
+
|
|
182
|
+
[Start using the Drawer component](https://design.royallondon.com/components/drawer/#live-example)
|
|
183
|
+
|
|
184
|
+
### Support
|
|
185
|
+
|
|
186
|
+
Issues and questions are handled through the [Royal London Design System support page](https://design.royallondon.com/support/).
|
|
187
|
+
|
|
188
|
+
### Licence
|
|
189
|
+
|
|
190
|
+
#### @royallondon/design-system-component-drawer
|
|
191
|
+
|
|
192
|
+
This package is published primarily for use in Royal London products. It is made available publicly so that partners and suppliers building Royal London experiences can consume it directly.
|
|
193
|
+
|
|
194
|
+
Use of this package is subject to the Royal London Authorised Use Licence. See the `LICENSE.md` file included in this package.
|
|
195
|
+
|
|
196
|
+
The Drawer component for the [Royal London Design System](https://design.royallondon.com). For design guidance, examples and accessibility notes, see the [Drawer page](https://design.royallondon.com/components/drawer/).
|
|
197
|
+
|
|
198
|
+
### Release log
|
|
199
|
+
|
|
200
|
+
| Version | Release notes |
|
|
201
|
+
| ------- | ---------------------------------------------------------------------------------------------------- |
|
|
202
|
+
| 1.1.0 | Prepare for public npm release: add licence, publish `dist` only (`src` removed), remove source maps |
|
|
203
|
+
| 1.0.0 | First release of Drawer component |
|
package/dist/drawer.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@custom-media --mq-xsmall (max-width: 31.9375rem);@custom-media --mq-small (min-width: 32rem);@custom-media --mq-medium (min-width: 48rem);@custom-media --mq-large (min-width: 64rem);@custom-media --mq-medium-max (max-width: 47.9375rem);@custom-media --mq-large-max (max-width: 63.9375rem);@custom-media --mq-xs-max (max-width: 31.9375rem);@custom-media --mq-sm (min-width: 32rem);@custom-media --mq-sm-max (max-width: 47.9375rem);@custom-media --mq-md (min-width: 48rem);@custom-media --mq-md-max (max-width: 63.9375rem);@custom-media --mq-lg (min-width: 64rem);@custom-media --mq-lg-max (max-width: 79.9375rem);@custom-media --mq-xl (min-width: 80rem);@custom-media --mq-xl-max (max-width: 95.9375rem);@custom-media --mq-xxl (min-width: 96rem);:root{--drawer-inline-size:20rem;--drawer-inline-size-max:calc(100% - var(--spacing-07));--drawer-fill-surface:var(--card-fill-white);--drawer-fill-scrim:var(--modal-fill-overlay);--drawer-text-title:var(--modal-text-title);--drawer-text-body:var(--modal-text-body);--drawer-duration:0.3s;--drawer-easing:ease-in-out;--drawer-offset:-100%}.rl-c-drawer{background-color:var(--drawer-fill-surface);block-size:auto;border:0;box-shadow:var(--ui-shadow);color:var(--drawer-text-body);flex-direction:column;inline-size:20rem;inline-size:var(--drawer-inline-size);inset-block:0;inset-inline:0 auto;margin:0;max-block-size:none;max-inline-size:var(--drawer-inline-size-max);outline:1px solid transparent;overflow:hidden;padding:0;position:fixed;transition:translate .3s ease-in-out,display allow-discrete .3s,overlay allow-discrete .3s;transition:translate var(--drawer-duration) var(--drawer-easing),display var(--drawer-duration) allow-discrete,overlay var(--drawer-duration) allow-discrete;translate:-100% 0;translate:var(--drawer-offset) 0}:where(.rl-c-drawer) .rl-c-chips:not(:empty){margin-bottom:var(--spacing-04)}.rl-c-drawer--end{--drawer-offset:100%;inset-inline:auto 0}.rl-c-drawer:is([open],:popover-open){display:flex;translate:0 0}.rl-c-drawer::backdrop{background-color:transparent;transition:background-color .3s ease-in-out,display allow-discrete .3s,overlay allow-discrete .3s;transition:background-color var(--drawer-duration) var(--drawer-easing),display var(--drawer-duration) allow-discrete,overlay var(--drawer-duration) allow-discrete}.rl-c-drawer:is([open],:popover-open)::backdrop{background-color:rgba(0,0,0,.2);background-color:color-mix(in srgb,#000,transparent 80%);background-color:color-mix(in srgb,var(--drawer-fill-scrim,#000),transparent 80%)}@starting-style{.rl-c-drawer:is([open],:popover-open){translate:-100% 0;translate:var(--drawer-offset) 0}.rl-c-drawer:is([open],:popover-open)::backdrop{background-color:transparent}}.rl-c-drawer__header{align-items:center;border-block-end:1px solid #a0a7b1;border-block-end:1px solid var(--drawer-stroke,#a0a7b1);display:flex;gap:var(--spacing-04);justify-content:space-between;padding:var(--spacing-03)}.rl-c-drawer__title{color:var(--drawer-text-title);font-family:var(--typography-font-family-title-medium);font-size:var(--typography-size-title-medium);font-weight:var(--typography-weight-title-medium);line-height:var(--type-line-height);margin:0;min-inline-size:0;overflow-wrap:anywhere}.rl-c-drawer__close{align-items:center;background:transparent;block-size:1.5rem;border:0;color:var(--drawer-text-title);cursor:pointer;display:flex;flex-shrink:0;inline-size:1.5rem;justify-content:center;padding:0}.rl-c-drawer__content{flex:1 1 auto;overflow-y:auto;overscroll-behavior:contain;padding:var(--spacing-03)}.rl-c-drawer__footer{border-block-start:1px solid #a0a7b1;border-block-start:1px solid var(--drawer-stroke,#a0a7b1);padding:var(--spacing-03)}:root:has(.rl-c-drawer:is(:modal,:popover-open)){overflow:hidden;scrollbar-gutter:stable}@media (min-width:48rem){.rl-c-drawer--on-display:not(:popover-open){box-shadow:none;display:flex;inline-size:auto;inset:auto;max-inline-size:none;position:relative;transition:none;translate:none}.rl-c-drawer--on-display:not(:popover-open) .rl-c-drawer__close{display:none}.rl-c-drawer__toggle--on-display[popovertarget]{display:none}}@media (--mq-md ){.rl-c-drawer--on-display:not(:popover-open){box-shadow:none;display:flex;inline-size:auto;inset:auto;max-inline-size:none;position:relative;transition:none;translate:none}.rl-c-drawer--on-display:not(:popover-open) .rl-c-drawer__close{display:none}.rl-c-drawer__toggle--on-display[popovertarget]{display:none}}@media (prefers-reduced-motion:reduce){.rl-c-drawer,.rl-c-drawer::backdrop{transition-duration:1ms}}
|
package/dist/drawer.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function createDrawer(element: Element, options?: DrawerOptions): DrawerApi | null;
|
|
2
|
+
export default drawer;
|
|
3
|
+
export type DrawerOptions = {
|
|
4
|
+
onDisplayQuery?: string;
|
|
5
|
+
};
|
|
6
|
+
export type DrawerApi = {
|
|
7
|
+
open: () => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
destroy: () => void;
|
|
10
|
+
isOpen: () => boolean;
|
|
11
|
+
isOnDisplay: () => boolean;
|
|
12
|
+
};
|
|
13
|
+
declare function drawer(drawers: Element | Iterable<Element> | NodeListOf<Element> | null | undefined, options?: DrawerOptions): DrawerApi[];
|
package/dist/drawer.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.createDrawer = createDrawer;
|
|
7
|
+
exports.default = void 0;
|
|
8
|
+
const SELECTORS = {
|
|
9
|
+
focusable: 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
10
|
+
};
|
|
11
|
+
const CLASSES = {
|
|
12
|
+
onDisplay: 'rl-c-drawer--on-display'
|
|
13
|
+
};
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
// Matches the --mq-md custom media in _breakpoints.pcss
|
|
16
|
+
onDisplayQuery: '(min-width: 48rem)'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/* Delete each fallback once every browser in the support matrix has the feature */
|
|
20
|
+
const supportsInvokerCommands = typeof HTMLButtonElement !== 'undefined' && 'commandForElement' in HTMLButtonElement.prototype;
|
|
21
|
+
const supportsClosedBy = typeof HTMLDialogElement !== 'undefined' && 'closedBy' in HTMLDialogElement.prototype;
|
|
22
|
+
|
|
23
|
+
/* drawer element -> public API, so repeat initialisation returns the same instance */
|
|
24
|
+
const instances = new WeakMap();
|
|
25
|
+
let isCommandFallbackInstalled = false;
|
|
26
|
+
|
|
27
|
+
/* Minimal command/commandfor fallback for standard drawers */
|
|
28
|
+
function handleCommandClick(event) {
|
|
29
|
+
const button = event.target instanceof Element ? event.target.closest('button[commandfor]') : null;
|
|
30
|
+
if (!button || button.disabled) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Native invoker commands do not act on a submit or reset button that belongs to a form
|
|
35
|
+
if (button.form && button.type !== 'button') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const target = document.getElementById(button.getAttribute('commandfor'));
|
|
39
|
+
const instance = target ? instances.get(target) : null;
|
|
40
|
+
if (!instance) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const command = button.getAttribute('command');
|
|
44
|
+
if (command === 'show-modal') {
|
|
45
|
+
instance.open();
|
|
46
|
+
} else if (command === 'close') {
|
|
47
|
+
instance.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function installCommandFallback() {
|
|
51
|
+
if (supportsInvokerCommands || isCommandFallbackInstalled) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
document.addEventListener('click', handleCommandClick);
|
|
55
|
+
isCommandFallbackInstalled = true;
|
|
56
|
+
}
|
|
57
|
+
function isRendered(element) {
|
|
58
|
+
return element.getClientRects().length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* Standard drawer: a <dialog> that is always modal.
|
|
63
|
+
* The browser handles focus, inertness, Escape and the scrim.
|
|
64
|
+
*/
|
|
65
|
+
function createModalDrawer(element) {
|
|
66
|
+
const needsLightDismissFallback = !supportsClosedBy && element.getAttribute('closedby') === 'any';
|
|
67
|
+
let pointerDownOutside = false;
|
|
68
|
+
installCommandFallback();
|
|
69
|
+
if (needsLightDismissFallback) {
|
|
70
|
+
element.addEventListener('pointerdown', handlePointerDown);
|
|
71
|
+
element.addEventListener('click', handleLightDismissClick);
|
|
72
|
+
}
|
|
73
|
+
function open() {
|
|
74
|
+
if (!element.open) {
|
|
75
|
+
element.showModal();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function close() {
|
|
79
|
+
if (element.open) {
|
|
80
|
+
element.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Clicks on the ::backdrop are dispatched to the dialog itself */
|
|
85
|
+
function isOutside(event) {
|
|
86
|
+
if (event.target !== element) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const rect = element.getBoundingClientRect();
|
|
90
|
+
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Both ends of the gesture must be outside, so a text selection dragged out of the panel does not close it */
|
|
94
|
+
function handlePointerDown(event) {
|
|
95
|
+
pointerDownOutside = element.open && isOutside(event);
|
|
96
|
+
}
|
|
97
|
+
function handleLightDismissClick(event) {
|
|
98
|
+
if (pointerDownOutside && isOutside(event)) {
|
|
99
|
+
close();
|
|
100
|
+
}
|
|
101
|
+
pointerDownOutside = false;
|
|
102
|
+
}
|
|
103
|
+
function destroy() {
|
|
104
|
+
close();
|
|
105
|
+
if (needsLightDismissFallback) {
|
|
106
|
+
element.removeEventListener('pointerdown', handlePointerDown);
|
|
107
|
+
element.removeEventListener('click', handleLightDismissClick);
|
|
108
|
+
}
|
|
109
|
+
instances.delete(element);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
open,
|
|
113
|
+
close,
|
|
114
|
+
destroy,
|
|
115
|
+
isOpen: () => element.open,
|
|
116
|
+
isOnDisplay: () => false
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* On display drawer: a landmark with the popover attribute. Inline from the
|
|
122
|
+
* on display breakpoint up; below it, a popover made modal by inerting the
|
|
123
|
+
* rest of the page. The browser handles light dismiss, Escape, the scrim
|
|
124
|
+
* and aria-expanded on the toggle.
|
|
125
|
+
*/
|
|
126
|
+
function createOnDisplayDrawer(element, config) {
|
|
127
|
+
const mediaQuery = window.matchMedia(config.onDisplayQuery);
|
|
128
|
+
const inertedElements = [];
|
|
129
|
+
const removalObserver = new MutationObserver(handleMutations);
|
|
130
|
+
let hasFocusWithin = false;
|
|
131
|
+
element.addEventListener('beforetoggle', handleBeforeToggle);
|
|
132
|
+
element.addEventListener('toggle', handleToggle);
|
|
133
|
+
element.addEventListener('focusin', handleFocusIn);
|
|
134
|
+
element.addEventListener('focusout', handleFocusOut);
|
|
135
|
+
mediaQuery.addEventListener('change', handleBreakpointChange);
|
|
136
|
+
function isOpen() {
|
|
137
|
+
return element.matches(':popover-open');
|
|
138
|
+
}
|
|
139
|
+
function isOnDisplay() {
|
|
140
|
+
return mediaQuery.matches;
|
|
141
|
+
}
|
|
142
|
+
function open() {
|
|
143
|
+
if (!isOpen() && !isOnDisplay()) {
|
|
144
|
+
element.showPopover();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function close() {
|
|
148
|
+
if (isOpen()) {
|
|
149
|
+
element.hidePopover();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function handleBeforeToggle(event) {
|
|
153
|
+
if (event.newState === 'open') {
|
|
154
|
+
// A stray toggle must not open a modal over the drawer while it is visible inline
|
|
155
|
+
if (isOnDisplay()) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// beforetoggle is synchronous, so the page is interactive again before
|
|
162
|
+
// the browser returns focus to the toggle
|
|
163
|
+
setPageInert(false);
|
|
164
|
+
}
|
|
165
|
+
function handleToggle(event) {
|
|
166
|
+
// toggle is asynchronous and can coalesce, so confirm the popover is still open
|
|
167
|
+
if (event.newState !== 'open' || !isOpen()) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
setPageInert(true);
|
|
171
|
+
|
|
172
|
+
// The browser has already focused any [autofocus] element
|
|
173
|
+
if (!element.contains(document.activeElement)) {
|
|
174
|
+
const firstFocusable = element.querySelector(SELECTORS.focusable);
|
|
175
|
+
if (firstFocusable) {
|
|
176
|
+
firstFocusable.focus();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/*
|
|
182
|
+
* Hides everything outside the drawer from pointers, tabbing and screen
|
|
183
|
+
* readers, matching what the browser does for a modal <dialog>.
|
|
184
|
+
*/
|
|
185
|
+
function setPageInert(shouldInert) {
|
|
186
|
+
if (!shouldInert) {
|
|
187
|
+
removalObserver.disconnect();
|
|
188
|
+
inertedElements.forEach(inerted => {
|
|
189
|
+
inerted.inert = false;
|
|
190
|
+
});
|
|
191
|
+
inertedElements.length = 0;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (inertedElements.length) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let node = element;
|
|
198
|
+
while (node && node !== document.body && node.parentElement) {
|
|
199
|
+
[...node.parentElement.children].forEach(sibling => {
|
|
200
|
+
if (sibling !== node && !sibling.inert) {
|
|
201
|
+
sibling.inert = true;
|
|
202
|
+
inertedElements.push(sibling);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
node = node.parentElement;
|
|
206
|
+
}
|
|
207
|
+
removalObserver.observe(document.body, {
|
|
208
|
+
childList: true,
|
|
209
|
+
subtree: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
* A popover removed or moved while open is hidden without beforetoggle
|
|
215
|
+
* firing, so the page would stay inert. Only observed while the page is
|
|
216
|
+
* inert, which is also when DOM changes elsewhere are least likely.
|
|
217
|
+
*/
|
|
218
|
+
function handleMutations() {
|
|
219
|
+
if (!isOpen()) {
|
|
220
|
+
setPageInert(false);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* Tracks focus inside the drawer so focus can be recovered when the drawer
|
|
226
|
+
* is hidden by a breakpoint change. Hiding a focused element may not fire
|
|
227
|
+
* focusout, so the flag can outlive the focus it records; it is only
|
|
228
|
+
* trusted when focus has fallen back to the body.
|
|
229
|
+
*/
|
|
230
|
+
function handleFocusIn() {
|
|
231
|
+
hasFocusWithin = true;
|
|
232
|
+
}
|
|
233
|
+
function handleFocusOut(event) {
|
|
234
|
+
hasFocusWithin = element.contains(event.relatedTarget);
|
|
235
|
+
}
|
|
236
|
+
function handleBreakpointChange() {
|
|
237
|
+
if (isOnDisplay()) {
|
|
238
|
+
enterOnDisplayState();
|
|
239
|
+
} else {
|
|
240
|
+
leaveOnDisplayState();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* Modal to inline: hide the popover and keep focus where the user was */
|
|
245
|
+
function enterOnDisplayState() {
|
|
246
|
+
if (!isOpen()) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const focused = document.activeElement;
|
|
250
|
+
close();
|
|
251
|
+
|
|
252
|
+
// The browser returns focus to the toggle, which is hidden at this breakpoint
|
|
253
|
+
if (focused && element.contains(focused)) {
|
|
254
|
+
focused.focus();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Inline to hidden: move focus to the now visible toggle rather than losing it to the body */
|
|
259
|
+
function leaveOnDisplayState() {
|
|
260
|
+
const active = document.activeElement;
|
|
261
|
+
const focusWasInside = element.contains(active) || hasFocusWithin && (!active || active === document.body);
|
|
262
|
+
hasFocusWithin = false;
|
|
263
|
+
if (!focusWasInside) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const toggle = getVisibleToggle();
|
|
267
|
+
if (toggle) {
|
|
268
|
+
toggle.focus();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function getVisibleToggle() {
|
|
272
|
+
if (!element.id) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const selector = `button[popovertarget="${CSS.escape(element.id)}"]:not([popovertargetaction="hide"])`;
|
|
276
|
+
return [...document.querySelectorAll(selector)].find(isRendered) || null;
|
|
277
|
+
}
|
|
278
|
+
function destroy() {
|
|
279
|
+
close();
|
|
280
|
+
setPageInert(false);
|
|
281
|
+
element.removeEventListener('beforetoggle', handleBeforeToggle);
|
|
282
|
+
element.removeEventListener('toggle', handleToggle);
|
|
283
|
+
element.removeEventListener('focusin', handleFocusIn);
|
|
284
|
+
element.removeEventListener('focusout', handleFocusOut);
|
|
285
|
+
mediaQuery.removeEventListener('change', handleBreakpointChange);
|
|
286
|
+
instances.delete(element);
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
open,
|
|
290
|
+
close,
|
|
291
|
+
destroy,
|
|
292
|
+
isOpen,
|
|
293
|
+
isOnDisplay
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @typedef {object} DrawerOptions
|
|
299
|
+
* @property {string} [onDisplayQuery]
|
|
300
|
+
*/
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @typedef {object} DrawerApi
|
|
304
|
+
* @property {() => void} open
|
|
305
|
+
* @property {() => void} close
|
|
306
|
+
* @property {() => void} destroy
|
|
307
|
+
* @property {() => boolean} isOpen
|
|
308
|
+
* @property {() => boolean} isOnDisplay
|
|
309
|
+
*/
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {Element} element
|
|
313
|
+
* @param {DrawerOptions} [options]
|
|
314
|
+
* @returns {DrawerApi | null}
|
|
315
|
+
*/
|
|
316
|
+
function createDrawer(element, options = {}) {
|
|
317
|
+
const existing = element ? instances.get(element) : null;
|
|
318
|
+
if (existing) {
|
|
319
|
+
return existing;
|
|
320
|
+
}
|
|
321
|
+
const config = {
|
|
322
|
+
...DEFAULTS,
|
|
323
|
+
...options
|
|
324
|
+
};
|
|
325
|
+
let api = null;
|
|
326
|
+
if (element instanceof HTMLDialogElement) {
|
|
327
|
+
if (element.classList.contains(CLASSES.onDisplay)) {
|
|
328
|
+
// eslint-disable-next-line no-console
|
|
329
|
+
console.warn('createDrawer: on display drawers must be a landmark element with the popover attribute, not a <dialog>');
|
|
330
|
+
}
|
|
331
|
+
api = createModalDrawer(element);
|
|
332
|
+
} else if (element instanceof HTMLElement && element.hasAttribute('popover') && element.classList.contains(CLASSES.onDisplay)) {
|
|
333
|
+
api = createOnDisplayDrawer(element, config);
|
|
334
|
+
} else {
|
|
335
|
+
// eslint-disable-next-line no-console
|
|
336
|
+
console.warn('createDrawer: expected a <dialog class="rl-c-drawer">, or a landmark with the popover attribute and the rl-c-drawer--on-display class');
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
instances.set(element, api);
|
|
340
|
+
return api;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @param {Element | Iterable<Element> | NodeListOf<Element> | null | undefined} drawers
|
|
345
|
+
* @param {DrawerOptions} [options]
|
|
346
|
+
* @returns {DrawerApi[]}
|
|
347
|
+
*/
|
|
348
|
+
function drawer(drawers, options) {
|
|
349
|
+
const elements = drawers instanceof Element ? [drawers] : [...(drawers || [])];
|
|
350
|
+
return elements.map(element => createDrawer(element, options)).filter(Boolean);
|
|
351
|
+
}
|
|
352
|
+
var _default = exports.default = drawer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@royallondon/design-system-component-drawer",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Royal London Design System - Drawer package",
|
|
5
|
+
"homepage": "https://design.royallondon.com/components/drawer/",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://dev.azure.com/RLG-UK/GTC/_git/design-system",
|
|
9
|
+
"directory": "ds-packages/packages/design-system-component-drawer"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://design.royallondon.com/support/"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"royal-london-design-system-component-drawer",
|
|
16
|
+
"rlds-drawer",
|
|
17
|
+
"royal-london"
|
|
18
|
+
],
|
|
19
|
+
"author": "Design System Squad <DesignSystem@royallondon.com>",
|
|
20
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
21
|
+
"main": "dist/drawer.css",
|
|
22
|
+
"style": "dist/drawer.css",
|
|
23
|
+
"types": "dist/drawer.d.ts",
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build:clean": "npx rimraf dist",
|
|
26
|
+
"build:lint": "stylelint src/**/*.{css,pcss} --fix",
|
|
27
|
+
"build:css": "postcss src/**/drawer.pcss --base src --dir dist/ --ext css",
|
|
28
|
+
"build:js": "babel src/ --out-dir dist/ --config-file ../../babel.config",
|
|
29
|
+
"build:types": "tsc -p tsconfig.json",
|
|
30
|
+
"build": "npm-run-all --serial build:clean build:css build:js build:types"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@royallondon/design-system-foundations": "^2.23.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@royallondon/design-system-foundations": ">= 2.22.0 < 3"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
6
44
|
}
|