@jungherz-de/glasskit-elements 1.19.2 → 1.20.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 +27 -0
- package/README.md +40 -27
- package/SKILL.md +105 -23
- package/dist/components/base.js +145 -10
- package/dist/components/glk-accordion-item.js +20 -5
- package/dist/components/glk-button.js +69 -2
- package/dist/components/glk-calendar.js +3 -0
- package/dist/components/glk-checkbox.js +12 -2
- package/dist/components/glk-input.js +25 -4
- package/dist/components/glk-modal.js +99 -21
- package/dist/components/glk-pill.js +14 -1
- package/dist/components/glk-popover.js +21 -3
- package/dist/components/glk-radio.js +26 -2
- package/dist/components/glk-range.js +3 -1
- package/dist/components/glk-search.js +14 -1
- package/dist/components/glk-segmented.js +4 -0
- package/dist/components/glk-select.js +8 -2
- package/dist/components/glk-textarea.js +8 -2
- package/dist/components/glk-toggle.js +18 -11
- package/dist/glasskit-elements.esm.js +487 -65
- package/dist/glasskit-elements.js +487 -65
- package/dist/glasskit-elements.min.js +1 -1
- package/package.json +3 -3
- package/src/base.js +144 -9
- package/src/components/buttons/glk-button.js +69 -2
- package/src/components/containers/glk-accordion-item.js +20 -5
- package/src/components/feedback/glk-modal.js +99 -21
- package/src/components/feedback/glk-popover.js +21 -3
- package/src/components/forms/glk-calendar.js +3 -0
- package/src/components/forms/glk-checkbox.js +12 -2
- package/src/components/forms/glk-input.js +25 -4
- package/src/components/forms/glk-radio.js +26 -2
- package/src/components/forms/glk-range.js +3 -1
- package/src/components/forms/glk-search.js +14 -1
- package/src/components/forms/glk-segmented.js +4 -0
- package/src/components/forms/glk-select.js +8 -2
- package/src/components/forms/glk-textarea.js +8 -2
- package/src/components/forms/glk-toggle.js +18 -11
- package/src/components/navigation/glk-pill.js +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jungherz-de/glasskit-elements",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Vanilla JavaScript Web Components wrapping the GlassKit CSS glassmorphism library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://glasskit-elements.jungherz.com",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"vanilla-js"
|
|
50
50
|
],
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@jungherz-de/glasskit": ">=1.
|
|
52
|
+
"@jungherz-de/glasskit": ">=1.20.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@jungherz-de/glasskit": "^1.
|
|
55
|
+
"@jungherz-de/glasskit": "^1.20.0",
|
|
56
56
|
"@rollup/plugin-node-resolve": "^15.0.0",
|
|
57
57
|
"@rollup/plugin-terser": "^0.4.0",
|
|
58
58
|
"rollup": "^4.0.0"
|
package/src/base.js
CHANGED
|
@@ -11,18 +11,44 @@ import { componentsSheet, tokensCss } from '@jungherz-de/glasskit/glasskit-style
|
|
|
11
11
|
// inherits them like any other custom property. They are wrapped in a cascade
|
|
12
12
|
// layer so an ordinary (unlayered) brand stylesheet wins over them, no matter
|
|
13
13
|
// whether it loads before or after this module.
|
|
14
|
+
//
|
|
15
|
+
// Only the custom properties go there. GlassKit's token blocks also set
|
|
16
|
+
// color-scheme, and on the document that is no default: it switches the whole
|
|
17
|
+
// page to dark — native controls, scrollbars, the root text colour, link
|
|
18
|
+
// colours — on pages that never asked for GlassKit's theme. The elements set
|
|
19
|
+
// color-scheme on their own theme wrapper instead (host sheet below).
|
|
20
|
+
//
|
|
21
|
+
// The --gl-* names themselves are still global. A page that declares them
|
|
22
|
+
// itself, or wants them on part of the page only, switches the defaults off
|
|
23
|
+
// with <html data-glk-defaults="off">. The attribute is watched, so it may
|
|
24
|
+
// also be set or removed later.
|
|
14
25
|
|
|
15
26
|
const TOKENS_INJECTED = '__glkDefaultTokensInjected';
|
|
27
|
+
const DEFAULTS_ATTR = 'data-glk-defaults';
|
|
28
|
+
let defaultTokensSheet = null;
|
|
16
29
|
|
|
17
30
|
function injectDefaultTokens() {
|
|
18
31
|
if (typeof document === 'undefined') return; // SSR / non-DOM
|
|
19
32
|
if (globalThis[TOKENS_INJECTED]) return; // another bundle copy did it
|
|
20
33
|
globalThis[TOKENS_INJECTED] = true;
|
|
21
34
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
const tokens = tokensCss.replace(/color-scheme\s*:[^;}]*;?/g, '');
|
|
36
|
+
defaultTokensSheet = new CSSStyleSheet();
|
|
37
|
+
defaultTokensSheet.replaceSync(`@layer glasskit-defaults { ${tokens} }`);
|
|
38
|
+
syncDefaultTokens();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Adopts or drops the default tokens to match <html data-glk-defaults>. */
|
|
42
|
+
function syncDefaultTokens() {
|
|
43
|
+
if (!defaultTokensSheet) return;
|
|
44
|
+
const wanted = document.documentElement.getAttribute(DEFAULTS_ATTR) !== 'off';
|
|
45
|
+
const sheets = document.adoptedStyleSheets;
|
|
46
|
+
if (wanted === sheets.includes(defaultTokensSheet)) return;
|
|
47
|
+
// Add or remove only this sheet — never assign a fresh list — so an app's
|
|
48
|
+
// own adopted sheets survive.
|
|
49
|
+
document.adoptedStyleSheets = wanted
|
|
50
|
+
? [...sheets, defaultTokensSheet]
|
|
51
|
+
: sheets.filter(sheet => sheet !== defaultTokensSheet);
|
|
26
52
|
}
|
|
27
53
|
|
|
28
54
|
injectDefaultTokens();
|
|
@@ -45,31 +71,94 @@ function syncAllThemes() {
|
|
|
45
71
|
}
|
|
46
72
|
|
|
47
73
|
if (typeof window !== 'undefined' && typeof MutationObserver !== 'undefined') {
|
|
48
|
-
const observer = new MutationObserver(
|
|
74
|
+
const observer = new MutationObserver(records => {
|
|
75
|
+
if (records.some(r => r.attributeName === 'data-theme')) syncAllThemes();
|
|
76
|
+
if (records.some(r => r.attributeName === DEFAULTS_ATTR)) syncDefaultTokens();
|
|
77
|
+
});
|
|
49
78
|
observer.observe(document.documentElement, {
|
|
50
79
|
attributes: true,
|
|
51
|
-
attributeFilter: ['data-theme']
|
|
80
|
+
attributeFilter: ['data-theme', DEFAULTS_ATTR]
|
|
52
81
|
});
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
// ── Host Stylesheet ──
|
|
56
85
|
// Sets display:block on all custom elements by default.
|
|
57
86
|
// Inline components (badge, avatar) override this.
|
|
87
|
+
//
|
|
88
|
+
// The wrapper carries the theme's color-scheme, so native parts inside the
|
|
89
|
+
// elements — a select's list, a date picker, scrollbars — match the element,
|
|
90
|
+
// whatever the page around it uses.
|
|
91
|
+
|
|
92
|
+
const wrapperRules = `
|
|
93
|
+
.glk-wrapper { display: contents; }
|
|
94
|
+
.glk-wrapper[data-theme="dark"] { color-scheme: dark; }
|
|
95
|
+
.glk-wrapper[data-theme="light"] { color-scheme: light; }
|
|
96
|
+
`;
|
|
58
97
|
|
|
59
98
|
const hostSheet = new CSSStyleSheet();
|
|
60
99
|
hostSheet.replaceSync(`
|
|
61
100
|
:host { display: block; }
|
|
62
101
|
:host([hidden]) { display: none; }
|
|
63
|
-
|
|
102
|
+
${wrapperRules}
|
|
64
103
|
`);
|
|
65
104
|
|
|
66
105
|
const inlineHostSheet = new CSSStyleSheet();
|
|
67
106
|
inlineHostSheet.replaceSync(`
|
|
68
107
|
:host { display: inline-block; }
|
|
69
108
|
:host([hidden]) { display: none; }
|
|
70
|
-
|
|
109
|
+
${wrapperRules}
|
|
110
|
+
`);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Host rules for glk-checkbox, glk-radio and glk-toggle. Their control is an
|
|
114
|
+
* inline-flex label inside a block host; sitting on the text baseline it left
|
|
115
|
+
* room for descenders below, so the host was about 4px taller than the
|
|
116
|
+
* control and the control sat high in a centred row. Aligned to the top of
|
|
117
|
+
* its line, the label is as tall as the host.
|
|
118
|
+
*/
|
|
119
|
+
export const checkControlSheet = new CSSStyleSheet();
|
|
120
|
+
checkControlSheet.replaceSync(`
|
|
121
|
+
.glass-checkbox, .glass-radio, .glass-toggle { vertical-align: top; }
|
|
71
122
|
`);
|
|
72
123
|
|
|
124
|
+
// ── Clicks Aimed at the Host ──
|
|
125
|
+
// The innermost target of the latest pointerdown anywhere in the document;
|
|
126
|
+
// tracked once, on first use.
|
|
127
|
+
let lastPressed = null;
|
|
128
|
+
let trackingPresses = false;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* For elements whose control is a native input or button inside the shadow
|
|
132
|
+
* root: glk-checkbox, glk-radio, glk-toggle, glk-button. A click aimed at the
|
|
133
|
+
* host itself — host.click(), or the click a <label> around the element or
|
|
134
|
+
* naming it dispatches — reaches only the host. The control never saw it, so
|
|
135
|
+
* nothing changed where a native checkbox toggles and a native button
|
|
136
|
+
* submits. Such a click is handed to the control; the original is stopped, so
|
|
137
|
+
* listeners see one click, the control's.
|
|
138
|
+
*
|
|
139
|
+
* A pointer press on the host's own empty box (the host is a block, the
|
|
140
|
+
* control is narrower) also lands on the host. Chrome cannot tell it apart
|
|
141
|
+
* from a label's click by the event, only by where the pointer went down; it
|
|
142
|
+
* stays a no-op, as before. Returns the teardown.
|
|
143
|
+
*/
|
|
144
|
+
export function forwardHostClicks(host, getField) {
|
|
145
|
+
if (!trackingPresses && typeof window !== 'undefined') {
|
|
146
|
+
trackingPresses = true;
|
|
147
|
+
window.addEventListener('pointerdown', e => { lastPressed = e.composedPath()[0]; },
|
|
148
|
+
{ capture: true, passive: true });
|
|
149
|
+
}
|
|
150
|
+
const onClick = (e) => {
|
|
151
|
+
if (e.composedPath()[0] !== host) return; // from inside: the input had it
|
|
152
|
+
if (e.isTrusted && lastPressed === host) return; // a press on the empty box
|
|
153
|
+
const field = getField();
|
|
154
|
+
if (!field || field.disabled) return;
|
|
155
|
+
e.stopImmediatePropagation();
|
|
156
|
+
field.click();
|
|
157
|
+
};
|
|
158
|
+
host.addEventListener('click', onClick);
|
|
159
|
+
return () => host.removeEventListener('click', onClick);
|
|
160
|
+
}
|
|
161
|
+
|
|
73
162
|
// ── Base Class ──
|
|
74
163
|
|
|
75
164
|
export class GlkElement extends HTMLElement {
|
|
@@ -92,6 +181,13 @@ export class GlkElement extends HTMLElement {
|
|
|
92
181
|
*/
|
|
93
182
|
static get observesLightDom() { return false; }
|
|
94
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Turned on by elements built around one native field: focus() on the
|
|
186
|
+
* host, a click on the label text and a <label for> outside all land in
|
|
187
|
+
* that field.
|
|
188
|
+
*/
|
|
189
|
+
static get delegatesFocus() { return false; }
|
|
190
|
+
|
|
95
191
|
static get observedAttributes() {
|
|
96
192
|
return [];
|
|
97
193
|
}
|
|
@@ -99,7 +195,7 @@ export class GlkElement extends HTMLElement {
|
|
|
99
195
|
constructor() {
|
|
100
196
|
super();
|
|
101
197
|
this._initialized = false;
|
|
102
|
-
this._shadow = this.attachShadow({ mode: 'open' });
|
|
198
|
+
this._shadow = this.attachShadow({ mode: 'open', delegatesFocus: this.constructor.delegatesFocus });
|
|
103
199
|
const displaySheet = this.constructor.displayInline ? inlineHostSheet : hostSheet;
|
|
104
200
|
const sheets = [componentsSheet, displaySheet];
|
|
105
201
|
const extra = this.constructor.hostStyles;
|
|
@@ -240,14 +336,26 @@ export class GlkFormElement extends GlkElement {
|
|
|
240
336
|
|
|
241
337
|
static formAssociated = true;
|
|
242
338
|
|
|
339
|
+
// One native field inside, so focus goes there. The button groups
|
|
340
|
+
// (glk-segmented, glk-calendar) turn this off again: their first button is
|
|
341
|
+
// not the chosen one.
|
|
342
|
+
static get delegatesFocus() { return true; }
|
|
343
|
+
|
|
243
344
|
constructor() {
|
|
244
345
|
super();
|
|
245
346
|
this._internals = this.attachInternals();
|
|
246
347
|
}
|
|
247
348
|
|
|
349
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
350
|
+
super.attributeChangedCallback(name, oldValue, newValue);
|
|
351
|
+
// required, min, pattern, type … all change what counts as valid.
|
|
352
|
+
if (this._initialized) this.syncValidity();
|
|
353
|
+
}
|
|
354
|
+
|
|
248
355
|
get form() { return this._internals.form; }
|
|
249
356
|
get validationMessage() { return this._internals.validationMessage; }
|
|
250
357
|
get validity() { return this._internals.validity; }
|
|
358
|
+
get willValidate() { return this._internals.willValidate; }
|
|
251
359
|
|
|
252
360
|
checkValidity() { return this._internals.checkValidity(); }
|
|
253
361
|
reportValidity() { return this._internals.reportValidity(); }
|
|
@@ -266,9 +374,36 @@ export class GlkFormElement extends GlkElement {
|
|
|
266
374
|
|
|
267
375
|
setFormValue(value) {
|
|
268
376
|
this._internals.setFormValue(value);
|
|
377
|
+
this.syncValidity();
|
|
269
378
|
}
|
|
270
379
|
|
|
271
380
|
setValidity(flags, message, anchor) {
|
|
272
381
|
this._internals.setValidity(flags, message, anchor);
|
|
273
382
|
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The native field whose constraint validation the host reports, or null.
|
|
386
|
+
* Subclasses built around one field return it.
|
|
387
|
+
*/
|
|
388
|
+
get _validityField() { return null; }
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A form asks the host — checkValidity(), reportValidity(), submitting — and
|
|
392
|
+
* the host knows nothing by itself, so the inner field's verdict is copied
|
|
393
|
+
* over after everything that can change it: a value (every setFormValue
|
|
394
|
+
* lands here), an attribute, a reset. The field is the anchor: the browser's
|
|
395
|
+
* bubble points at it, and reportValidity() focuses it.
|
|
396
|
+
*/
|
|
397
|
+
syncValidity() {
|
|
398
|
+
const field = this._validityField;
|
|
399
|
+
if (!field) return;
|
|
400
|
+
const message = field.validationMessage;
|
|
401
|
+
// A field barred from validation (disabled) can still carry flags, but
|
|
402
|
+
// it has no message, and setValidity() refuses flags without one.
|
|
403
|
+
if (!field.willValidate || field.validity.valid || !message) {
|
|
404
|
+
this._internals.setValidity({});
|
|
405
|
+
} else {
|
|
406
|
+
this._internals.setValidity(field.validity, message, field);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
274
409
|
}
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
import { GlkElement } from '../../base.js';
|
|
1
|
+
import { GlkElement, forwardHostClicks } from '../../base.js';
|
|
2
2
|
|
|
3
3
|
const VARIANTS = ['primary', 'secondary', 'tertiary'];
|
|
4
4
|
const SIZES = ['sm', 'md', 'lg', 'auto'];
|
|
5
5
|
|
|
6
|
+
// States that whoever owns the element sets on it — <glk-popover> sets
|
|
7
|
+
// aria-expanded on its trigger. Assistive technology reads the native button
|
|
8
|
+
// inside, so they are handed down to it.
|
|
9
|
+
const FORWARDED_ARIA = ['aria-expanded', 'aria-haspopup', 'aria-pressed'];
|
|
10
|
+
|
|
6
11
|
class GlkButton extends GlkElement {
|
|
7
12
|
static get observedAttributes() {
|
|
8
|
-
return ['variant', 'size', 'disabled', 'type'];
|
|
13
|
+
return ['variant', 'size', 'disabled', 'type', ...FORWARDED_ARIA];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The native button sits in the shadow root, outside every form. Form
|
|
17
|
+
// association gives the element its form, so type="submit" and "reset"
|
|
18
|
+
// can act on it (see _activateForm).
|
|
19
|
+
static formAssociated = true;
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
this._internals = this.attachInternals();
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
render() {
|
|
@@ -16,6 +31,7 @@ class GlkButton extends GlkElement {
|
|
|
16
31
|
if (this.getBoolAttr('disabled')) {
|
|
17
32
|
this._btn.disabled = true;
|
|
18
33
|
}
|
|
34
|
+
for (const attr of FORWARDED_ARIA) this._forward(attr);
|
|
19
35
|
|
|
20
36
|
this._btn.appendChild(document.createElement('slot'));
|
|
21
37
|
this._wrapper.appendChild(this._btn);
|
|
@@ -29,12 +45,50 @@ class GlkButton extends GlkElement {
|
|
|
29
45
|
return;
|
|
30
46
|
}
|
|
31
47
|
this.emit('glk-click');
|
|
48
|
+
this._activateForm(e);
|
|
32
49
|
};
|
|
33
50
|
this._btn.addEventListener('click', this._onClick);
|
|
51
|
+
// host.click() and a <label> for the element reach the host only.
|
|
52
|
+
this._unforwardClicks = forwardHostClicks(this, () => this._btn);
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
teardownEvents() {
|
|
37
56
|
this._btn?.removeEventListener('click', this._onClick);
|
|
57
|
+
this._unforwardClicks?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* type="submit" submits the element's form, type="reset" resets it. Both
|
|
62
|
+
* wait until the click has finished bubbling, so a listener that calls
|
|
63
|
+
* preventDefault() on the click stops them — as on a native button.
|
|
64
|
+
*/
|
|
65
|
+
_activateForm(click) {
|
|
66
|
+
const type = this.getAttribute('type');
|
|
67
|
+
const form = this._internals.form;
|
|
68
|
+
if (!form || (type !== 'submit' && type !== 'reset')) return;
|
|
69
|
+
// Through the prototype: a control with id or name "reset" or
|
|
70
|
+
// "requestSubmit" in the form shadows the methods on the form itself.
|
|
71
|
+
const { reset, requestSubmit } = HTMLFormElement.prototype;
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
if (click.defaultPrevented || this._internals.form !== form) return;
|
|
74
|
+
if (type === 'reset') {
|
|
75
|
+
reset.call(form);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// requestSubmit() accepts native buttons only as the submitter, so the
|
|
79
|
+
// submit event is told about this element instead. The listener goes
|
|
80
|
+
// again right away: a form that fails validation fires no submit event,
|
|
81
|
+
// and the next submit must not inherit this submitter.
|
|
82
|
+
const nameSubmitter = (e) => {
|
|
83
|
+
Object.defineProperty(e, 'submitter', { configurable: true, get: () => this });
|
|
84
|
+
};
|
|
85
|
+
form.addEventListener('submit', nameSubmitter, { capture: true });
|
|
86
|
+
try {
|
|
87
|
+
requestSubmit.call(form);
|
|
88
|
+
} finally {
|
|
89
|
+
form.removeEventListener('submit', nameSubmitter, { capture: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
38
92
|
}
|
|
39
93
|
|
|
40
94
|
onAttributeChanged(name) {
|
|
@@ -50,9 +104,17 @@ class GlkButton extends GlkElement {
|
|
|
50
104
|
case 'type':
|
|
51
105
|
this._btn.setAttribute('type', this.getAttribute('type') || 'button');
|
|
52
106
|
break;
|
|
107
|
+
default:
|
|
108
|
+
if (FORWARDED_ARIA.includes(name)) this._forward(name);
|
|
53
109
|
}
|
|
54
110
|
}
|
|
55
111
|
|
|
112
|
+
_forward(attr) {
|
|
113
|
+
const value = this.getAttribute(attr);
|
|
114
|
+
if (value === null) this._btn.removeAttribute(attr);
|
|
115
|
+
else this._btn.setAttribute(attr, value);
|
|
116
|
+
}
|
|
117
|
+
|
|
56
118
|
_computeClasses() {
|
|
57
119
|
const classes = ['glass-btn'];
|
|
58
120
|
const variant = this.getAttribute('variant');
|
|
@@ -74,6 +136,11 @@ class GlkButton extends GlkElement {
|
|
|
74
136
|
|
|
75
137
|
get disabled() { return this.getBoolAttr('disabled'); }
|
|
76
138
|
set disabled(v) { this.setBoolAttr('disabled', v); }
|
|
139
|
+
|
|
140
|
+
get type() { return this.getAttribute('type') || 'button'; }
|
|
141
|
+
set type(v) { this.setAttribute('type', v); }
|
|
142
|
+
|
|
143
|
+
get form() { return this._internals.form; }
|
|
77
144
|
}
|
|
78
145
|
|
|
79
146
|
customElements.define('glk-button', GlkButton);
|
|
@@ -9,17 +9,19 @@ class GlkAccordionItem extends GlkElement {
|
|
|
9
9
|
|
|
10
10
|
render() {
|
|
11
11
|
this._item = this.createElement('div', ['glass-accordion__item']);
|
|
12
|
-
if (this.getBoolAttr('open')) this._item.classList.add('is-open');
|
|
13
12
|
|
|
14
|
-
// Trigger button
|
|
15
|
-
this._trigger = this.createElement('button', ['glass-accordion__trigger']
|
|
13
|
+
// Trigger button — says whether its section is open, and which one it is
|
|
14
|
+
this._trigger = this.createElement('button', ['glass-accordion__trigger'], {
|
|
15
|
+
type: 'button',
|
|
16
|
+
'aria-controls': 'content'
|
|
17
|
+
});
|
|
16
18
|
this.takeTitle();
|
|
17
19
|
this._triggerText = document.createTextNode(this._title || '');
|
|
18
20
|
this._trigger.appendChild(this._triggerText);
|
|
19
21
|
this._trigger.insertAdjacentHTML('beforeend', CHEVRON_SVG);
|
|
20
22
|
|
|
21
23
|
// Content
|
|
22
|
-
this._content = this.createElement('div', ['glass-accordion__content']);
|
|
24
|
+
this._content = this.createElement('div', ['glass-accordion__content'], { id: 'content' });
|
|
23
25
|
const body = this.createElement('div', ['glass-accordion__body']);
|
|
24
26
|
body.appendChild(document.createElement('slot'));
|
|
25
27
|
this._content.appendChild(body);
|
|
@@ -28,6 +30,19 @@ class GlkAccordionItem extends GlkElement {
|
|
|
28
30
|
this._item.appendChild(this._content);
|
|
29
31
|
|
|
30
32
|
this._wrapper.appendChild(this._item);
|
|
33
|
+
this._applyOpen();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A closed section is only squeezed to zero height; its links and fields
|
|
38
|
+
* stayed in the tab order. inert takes them out, and out of the
|
|
39
|
+
* accessibility tree, until the section opens.
|
|
40
|
+
*/
|
|
41
|
+
_applyOpen() {
|
|
42
|
+
const open = this.getBoolAttr('open');
|
|
43
|
+
this._item.classList.toggle('is-open', open);
|
|
44
|
+
this._trigger.setAttribute('aria-expanded', String(open));
|
|
45
|
+
this._content.inert = !open;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
setupEvents() {
|
|
@@ -46,7 +61,7 @@ class GlkAccordionItem extends GlkElement {
|
|
|
46
61
|
if (!this._item) return;
|
|
47
62
|
switch (name) {
|
|
48
63
|
case 'open':
|
|
49
|
-
this.
|
|
64
|
+
this._applyOpen();
|
|
50
65
|
break;
|
|
51
66
|
case 'title':
|
|
52
67
|
if (this.takeTitle(value)) this._applyTitle();
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import { GlkElement } from '../../base.js';
|
|
2
2
|
|
|
3
|
+
// Since 1.20.0 the overlay is a native <dialog>, opened with showModal(). The
|
|
4
|
+
// browser then does what a modal needs: the dialog lies in the top layer, the
|
|
5
|
+
// page behind it is inert — for the pointer, the keyboard and screen readers
|
|
6
|
+
// alike — focus moves in on opening and goes back to where it came from on
|
|
7
|
+
// closing, and the content is out of reach while the modal is closed.
|
|
8
|
+
// GlassKit (1.20.0) styles dialog.glass-modal-overlay for this.
|
|
9
|
+
//
|
|
10
|
+
// The element keeps the fade: the dialog opens before .is-active is set and
|
|
11
|
+
// closes only after the overlay has faded (safety net 400 ms; at once under
|
|
12
|
+
// prefers-reduced-motion). Escape arrives as the dialog's cancel event and
|
|
13
|
+
// takes the same path as a click on the dimmed area.
|
|
14
|
+
//
|
|
15
|
+
// Which element gets the focus on opening, the browsers decide differently:
|
|
16
|
+
// Chrome takes the first field of the content, Safari the first action. An
|
|
17
|
+
// element marked autofocus — in the content or among the actions — settles
|
|
18
|
+
// it in both.
|
|
19
|
+
|
|
20
|
+
const CLOSE_FALLBACK_MS = 400;
|
|
21
|
+
|
|
22
|
+
function reducedMotion() {
|
|
23
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
24
|
+
}
|
|
25
|
+
|
|
3
26
|
class GlkModal extends GlkElement {
|
|
4
27
|
static get observedAttributes() {
|
|
5
28
|
return ['open', 'title'];
|
|
@@ -8,13 +31,13 @@ class GlkModal extends GlkElement {
|
|
|
8
31
|
static get observesLightDom() { return true; }
|
|
9
32
|
|
|
10
33
|
render() {
|
|
11
|
-
this._overlay = this.createElement('
|
|
34
|
+
this._overlay = this.createElement('dialog', ['glass-modal-overlay'], { part: 'overlay' });
|
|
12
35
|
|
|
13
36
|
const modal = this.createElement('div', ['glass-modal'], { part: 'modal' });
|
|
14
37
|
|
|
15
|
-
// Header
|
|
38
|
+
// Header — the title names the dialog
|
|
16
39
|
const header = this.createElement('div', ['glass-modal__header'], { part: 'header' });
|
|
17
|
-
this._titleEl = this.createElement('h2', ['glass-modal__title']);
|
|
40
|
+
this._titleEl = this.createElement('h2', ['glass-modal__title'], { id: 'title' });
|
|
18
41
|
this.takeTitle();
|
|
19
42
|
this._applyTitle();
|
|
20
43
|
header.appendChild(this._titleEl);
|
|
@@ -35,10 +58,8 @@ class GlkModal extends GlkElement {
|
|
|
35
58
|
|
|
36
59
|
this._overlay.appendChild(modal);
|
|
37
60
|
this._wrapper.appendChild(this._overlay);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this._overlay.classList.add('is-active');
|
|
41
|
-
}
|
|
61
|
+
// `open` is applied in setupEvents, which also runs when a moved element
|
|
62
|
+
// comes back.
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
projectLightDom() {
|
|
@@ -63,33 +84,49 @@ class GlkModal extends GlkElement {
|
|
|
63
84
|
setupEvents() {
|
|
64
85
|
// Close on overlay click (outside modal)
|
|
65
86
|
this._onOverlayClick = (e) => {
|
|
66
|
-
if (e.target === this._overlay)
|
|
67
|
-
this.removeAttribute('open');
|
|
68
|
-
this.emit('glk-close');
|
|
69
|
-
}
|
|
87
|
+
if (e.target === this._overlay) this._dismiss();
|
|
70
88
|
};
|
|
71
89
|
this._overlay.addEventListener('click', this._onOverlayClick);
|
|
72
90
|
|
|
73
|
-
//
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.emit('glk-close');
|
|
78
|
-
}
|
|
91
|
+
// Escape: the dialog would close at once; it fades out like a click outside.
|
|
92
|
+
this._onCancel = (e) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
this._dismiss();
|
|
79
95
|
};
|
|
80
|
-
|
|
96
|
+
this._overlay.addEventListener('cancel', this._onCancel);
|
|
97
|
+
|
|
98
|
+
// The browser may also close the dialog by itself — Chrome does on a
|
|
99
|
+
// second Escape without user activation in between, skipping cancel.
|
|
100
|
+
this._onClose = () => {
|
|
101
|
+
if (this._overlay.open) return; // a late event from an earlier close
|
|
102
|
+
this._overlay.classList.remove('is-active');
|
|
103
|
+
if (this.open) this._dismiss();
|
|
104
|
+
};
|
|
105
|
+
this._overlay.addEventListener('close', this._onClose);
|
|
106
|
+
|
|
107
|
+
if (this.open) this._show();
|
|
81
108
|
}
|
|
82
109
|
|
|
83
110
|
teardownEvents() {
|
|
84
111
|
this._overlay?.removeEventListener('click', this._onOverlayClick);
|
|
85
|
-
|
|
112
|
+
this._overlay?.removeEventListener('cancel', this._onCancel);
|
|
113
|
+
this._overlay?.removeEventListener('close', this._onClose);
|
|
114
|
+
// Taken out of the document, the dialog leaves the top layer but stays
|
|
115
|
+
// open — shown in place, not modal. Closed now, it opens as a modal again
|
|
116
|
+
// when the element comes back.
|
|
117
|
+
clearTimeout(this._closeTimer);
|
|
118
|
+
this._overlay?.removeEventListener('transitionend', this._onFaded);
|
|
119
|
+
this._overlay?.classList.remove('is-active');
|
|
120
|
+
if (this._overlay?.open) this._overlay.close();
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
onAttributeChanged(name, _old, value) {
|
|
89
124
|
if (!this._overlay) return;
|
|
90
125
|
switch (name) {
|
|
91
126
|
case 'open':
|
|
92
|
-
|
|
127
|
+
if (!this.isConnected) return; // setupEvents applies it on connect
|
|
128
|
+
if (this.open) this._show();
|
|
129
|
+
else this._hide();
|
|
93
130
|
break;
|
|
94
131
|
case 'title':
|
|
95
132
|
if (this.takeTitle(value)) this._applyTitle();
|
|
@@ -97,11 +134,52 @@ class GlkModal extends GlkElement {
|
|
|
97
134
|
}
|
|
98
135
|
}
|
|
99
136
|
|
|
137
|
+
_show() {
|
|
138
|
+
const dialog = this._overlay;
|
|
139
|
+
clearTimeout(this._closeTimer);
|
|
140
|
+
dialog.removeEventListener('transitionend', this._onFaded);
|
|
141
|
+
if (!dialog.open) {
|
|
142
|
+
dialog.showModal();
|
|
143
|
+
// The actions are copies in the shadow root; the originals are not shown.
|
|
144
|
+
(this._footer.querySelector('[autofocus]') || this.querySelector('[autofocus]'))?.focus();
|
|
145
|
+
}
|
|
146
|
+
void dialog.offsetHeight; // reflow, so the fade starts from the closed state
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
if (this.open) dialog.classList.add('is-active');
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_hide() {
|
|
153
|
+
const dialog = this._overlay;
|
|
154
|
+
dialog.classList.remove('is-active');
|
|
155
|
+
this._onFaded = (event) => {
|
|
156
|
+
if (event && event.target !== dialog) return; // the panel's own transition
|
|
157
|
+
clearTimeout(this._closeTimer);
|
|
158
|
+
dialog.removeEventListener('transitionend', this._onFaded);
|
|
159
|
+
if (!this.open && dialog.open) dialog.close();
|
|
160
|
+
};
|
|
161
|
+
if (!dialog.open || reducedMotion()) {
|
|
162
|
+
this._onFaded();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
dialog.addEventListener('transitionend', this._onFaded);
|
|
166
|
+
this._closeTimer = setTimeout(this._onFaded, CLOSE_FALLBACK_MS);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Closed by the user — a click outside, Escape. */
|
|
170
|
+
_dismiss() {
|
|
171
|
+
this.removeAttribute('open');
|
|
172
|
+
this.emit('glk-close');
|
|
173
|
+
}
|
|
174
|
+
|
|
100
175
|
show() { this.setAttribute('open', ''); }
|
|
101
176
|
close() { this.removeAttribute('open'); }
|
|
102
177
|
|
|
103
178
|
_applyTitle() {
|
|
104
|
-
if (this._titleEl)
|
|
179
|
+
if (!this._titleEl) return;
|
|
180
|
+
this._titleEl.textContent = this._title || '';
|
|
181
|
+
if (this._title) this._overlay.setAttribute('aria-labelledby', 'title');
|
|
182
|
+
else this._overlay.removeAttribute('aria-labelledby');
|
|
105
183
|
}
|
|
106
184
|
|
|
107
185
|
// `title` is the heading, never a tooltip — see GlkElement.takeTitle().
|
|
@@ -10,9 +10,9 @@ class GlkPopover extends GlkElement {
|
|
|
10
10
|
render() {
|
|
11
11
|
this._anchor = this.createElement('div', ['glass-popover-anchor']);
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
this._anchor.appendChild(
|
|
13
|
+
this._triggerSlot = document.createElement('slot');
|
|
14
|
+
this._triggerSlot.setAttribute('name', 'trigger');
|
|
15
|
+
this._anchor.appendChild(this._triggerSlot);
|
|
16
16
|
|
|
17
17
|
this._popover = this.createElement('div', ['glass-popover']);
|
|
18
18
|
this._applyPlacement();
|
|
@@ -36,7 +36,23 @@ class GlkPopover extends GlkElement {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The trigger says whether the popover is open. It lives in the light DOM,
|
|
41
|
+
* so aria-expanded goes on it directly; <glk-button> and <glk-pill> hand
|
|
42
|
+
* it on to their native button. Runs again when the trigger is swapped.
|
|
43
|
+
*/
|
|
44
|
+
_applyExpanded() {
|
|
45
|
+
const open = String(this.getBoolAttr('open'));
|
|
46
|
+
for (const trigger of this._triggerSlot.assignedElements()) {
|
|
47
|
+
trigger.setAttribute('aria-expanded', open);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
setupEvents() {
|
|
52
|
+
this._onSlotChange = () => this._applyExpanded();
|
|
53
|
+
this._triggerSlot.addEventListener('slotchange', this._onSlotChange);
|
|
54
|
+
this._applyExpanded();
|
|
55
|
+
|
|
40
56
|
// Toggle when the slotted trigger is clicked.
|
|
41
57
|
this._onTriggerClick = (e) => {
|
|
42
58
|
const path = e.composedPath();
|
|
@@ -68,6 +84,7 @@ class GlkPopover extends GlkElement {
|
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
teardownEvents() {
|
|
87
|
+
this._triggerSlot?.removeEventListener('slotchange', this._onSlotChange);
|
|
71
88
|
this.removeEventListener('click', this._onTriggerClick);
|
|
72
89
|
document.removeEventListener('click', this._onDocClick);
|
|
73
90
|
document.removeEventListener('keydown', this._onKeydown);
|
|
@@ -79,6 +96,7 @@ class GlkPopover extends GlkElement {
|
|
|
79
96
|
case 'open': {
|
|
80
97
|
const isOpen = this.getBoolAttr('open');
|
|
81
98
|
this._popover.classList.toggle('is-open', isOpen);
|
|
99
|
+
this._applyExpanded();
|
|
82
100
|
this.emit(isOpen ? 'glk-open' : 'glk-close');
|
|
83
101
|
break;
|
|
84
102
|
}
|
|
@@ -45,6 +45,9 @@ class GlkCalendar extends GlkFormElement {
|
|
|
45
45
|
return ['month', 'value', 'today', 'min', 'max', 'marks', 'locale', 'week-start', 'label', 'prev-label', 'next-label'];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Delegated focus would land on the month arrow, not on the chosen day.
|
|
49
|
+
static get delegatesFocus() { return false; }
|
|
50
|
+
|
|
48
51
|
render() {
|
|
49
52
|
this._root = this.createElement('div', ['glass-calendar'], { part: 'calendar' });
|
|
50
53
|
const head = this.createElement('div', ['glass-calendar__head'], { part: 'head' });
|