@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 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
- # Temporary Holding Version
1
+ ### Installation
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ &times;
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
+ &times;
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 |
@@ -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}}
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }