@panphora/clayjs 1.5.3 → 1.6.1
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/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +15041 -10657
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-core.js +35 -2
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +157 -3
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +64 -16
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +37 -1
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +10 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +130 -109
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- package/src/vendor/richclay.vendor.js +22 -41
package/src/sync/presence.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* presence.js — who else is on this document.
|
|
3
3
|
*
|
|
4
|
-
* A fixed-corner stack of
|
|
4
|
+
* A fixed-corner stack of squares, one per participant the host chose to name,
|
|
5
5
|
* plus a count of everyone it did not. The host decides who is named, per
|
|
6
6
|
* recipient, from that recipient's own access; nothing here asks for a name and
|
|
7
7
|
* nothing here can widen what arrived.
|
|
@@ -37,26 +37,24 @@
|
|
|
37
37
|
|
|
38
38
|
import { hostSupports } from '../core/host-meta.js';
|
|
39
39
|
import { make, set } from '../lib/hostile-css.js';
|
|
40
|
+
import { pageScheme, RUNTIME_ONLY } from '../ui/bevel-controls.js';
|
|
41
|
+
import { TOKENS, FONT_MONO, SHADOW } from '../ui/bevel.js';
|
|
40
42
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// Two initials at 12px need about 15px of the circle, so the overlap has to
|
|
45
|
-
// leave that much clear or the neighbouring circle eats the second letter.
|
|
43
|
+
// Two initials at 11px need about 15px of the square, so the overlap has to
|
|
44
|
+
// leave that much clear or the neighbouring square eats the second letter.
|
|
46
45
|
const SIZE = 30;
|
|
47
46
|
const OVERLAP = 6;
|
|
48
|
-
const FONT =
|
|
49
|
-
const LABEL_FONT =
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const FACE = '#ffffff';
|
|
47
|
+
const FONT = `600 11px/1 ${FONT_MONO}`;
|
|
48
|
+
const LABEL_FONT = `500 11px/1.4 ${FONT_MONO}`;
|
|
49
|
+
|
|
50
|
+
// all:initial first on every element, so a page rule has nothing left to reach; it
|
|
51
|
+
// resets color-scheme and direction too, which are put back straight after.
|
|
52
|
+
const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
|
|
53
|
+
|
|
54
|
+
// The paper a participant's colour sits on: the initials of a solid square, the fill
|
|
55
|
+
// of a hollow one. One literal in both schemes, because every colour below was chosen
|
|
56
|
+
// against it.
|
|
57
|
+
const FACE = '#FFFCF6';
|
|
60
58
|
|
|
61
59
|
// A fixed set rather than a computed hue: every one of these was looked at
|
|
62
60
|
// against white initials, and a hue wheel puts neighbouring pseudonyms on two
|
|
@@ -97,7 +95,7 @@ function initialsOf(name) {
|
|
|
97
95
|
/**
|
|
98
96
|
* Read the roster frame defensively. Everything here arrived over a wire, and a
|
|
99
97
|
* frame this function cannot understand must draw nothing rather than draw a
|
|
100
|
-
* guess: `[object Object]` in a
|
|
98
|
+
* guess: `[object Object]` in a square is worse than an empty corner.
|
|
101
99
|
*/
|
|
102
100
|
function normalize(data) {
|
|
103
101
|
const list = data && Array.isArray(data.people) ? data.people : [];
|
|
@@ -176,11 +174,12 @@ class Presence {
|
|
|
176
174
|
if (!this.build()) return;
|
|
177
175
|
|
|
178
176
|
this.hideTip();
|
|
177
|
+
set(this.root, 'color-scheme', pageScheme());
|
|
179
178
|
this.stack.textContent = '';
|
|
180
179
|
for (const person of people) {
|
|
181
180
|
this.stack.appendChild(this.avatar(person));
|
|
182
181
|
}
|
|
183
|
-
// The overlap belongs between
|
|
182
|
+
// The overlap belongs between squares, so the leftmost one does not pull
|
|
184
183
|
// itself into the count beside it.
|
|
185
184
|
if (this.stack.firstElementChild) set(this.stack.firstElementChild, 'margin-left', '0');
|
|
186
185
|
|
|
@@ -207,44 +206,50 @@ class Presence {
|
|
|
207
206
|
if (typeof document === 'undefined' || !document.body) return false;
|
|
208
207
|
|
|
209
208
|
this.root = runtimeRoot(make('div', [
|
|
209
|
+
...RESET,
|
|
210
|
+
`color-scheme:${pageScheme()}`,
|
|
210
211
|
'position:fixed',
|
|
211
212
|
'top:calc(12px + env(safe-area-inset-top,0px))',
|
|
212
213
|
'right:calc(12px + env(safe-area-inset-right,0px))',
|
|
213
214
|
'z-index:2147483000',
|
|
214
215
|
'display:flex', 'align-items:center', 'gap:8px',
|
|
215
216
|
'max-width:calc(100vw - 24px)',
|
|
216
|
-
`font:${LABEL_FONT}`, `color:${
|
|
217
|
-
// Only the
|
|
217
|
+
`font:${LABEL_FONT}`, `color:${TOKENS.ink}`,
|
|
218
|
+
// Only the squares take a pointer. A fixed corner that swallowed clicks
|
|
218
219
|
// would take a bite out of whatever the page put underneath it.
|
|
219
220
|
'pointer-events:none',
|
|
220
221
|
]));
|
|
221
222
|
this.root.setAttribute('data-clay-presence', '');
|
|
222
223
|
|
|
223
224
|
this.count = runtimeRoot(make('span', [
|
|
225
|
+
...RESET,
|
|
224
226
|
'box-sizing:border-box', 'flex:none', 'white-space:nowrap',
|
|
225
|
-
'padding:
|
|
226
|
-
`background
|
|
227
|
-
|
|
228
|
-
`font:${LABEL_FONT}`,
|
|
229
|
-
|
|
227
|
+
'padding:3px 8px', 'border-radius:0',
|
|
228
|
+
`background:${TOKENS.surface}`, `color:${TOKENS.muted}`,
|
|
229
|
+
`border:1px solid ${TOKENS['line-2']}`,
|
|
230
|
+
`font:${LABEL_FONT}`, 'letter-spacing:.02em',
|
|
231
|
+
`box-shadow:${SHADOW}`,
|
|
230
232
|
'display:none',
|
|
233
|
+
'pointer-events:none',
|
|
231
234
|
]));
|
|
232
235
|
this.count.setAttribute('data-clay-presence-count', '');
|
|
233
236
|
|
|
234
237
|
this.stack = runtimeRoot(make('div', [
|
|
235
|
-
|
|
238
|
+
...RESET,
|
|
239
|
+
'display:flex', 'align-items:center', 'flex:none', 'pointer-events:none',
|
|
236
240
|
]));
|
|
237
241
|
|
|
238
242
|
// Inside the root rather than appended to <body>, so the one element that
|
|
239
243
|
// holds a name at rest cannot outlive the subtree that keeps it out of a
|
|
240
244
|
// snapshot. It carries the marking of its own too.
|
|
241
245
|
this.tip = runtimeRoot(make('span', [
|
|
246
|
+
...RESET,
|
|
242
247
|
'position:absolute', 'top:100%', 'right:0', 'margin-top:6px',
|
|
243
248
|
'box-sizing:border-box', 'white-space:nowrap', 'pointer-events:none',
|
|
244
|
-
'padding:
|
|
245
|
-
`background
|
|
246
|
-
`
|
|
247
|
-
|
|
249
|
+
'padding:5px 9px', 'border-radius:0',
|
|
250
|
+
`background:${TOKENS.ink}`, `color:${TOKENS.ground}`,
|
|
251
|
+
`font:${LABEL_FONT}`, 'letter-spacing:.02em',
|
|
252
|
+
`box-shadow:${SHADOW}`,
|
|
248
253
|
'display:none',
|
|
249
254
|
]));
|
|
250
255
|
this.tip.setAttribute('data-clay-presence-tip', '');
|
|
@@ -264,20 +269,21 @@ class Presence {
|
|
|
264
269
|
: [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
|
|
265
270
|
|
|
266
271
|
const el = runtimeRoot(make('div', [
|
|
272
|
+
...RESET,
|
|
267
273
|
'box-sizing:border-box', 'flex:none',
|
|
268
|
-
`width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:
|
|
274
|
+
`width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:0',
|
|
269
275
|
'display:flex', 'align-items:center', 'justify-content:center',
|
|
270
|
-
`font:${FONT}`, '
|
|
276
|
+
`font:${FONT}`, 'letter-spacing:.02em',
|
|
271
277
|
`margin-left:-${OVERLAP}px`,
|
|
272
278
|
'border-width:2px', 'border-style:solid', 'background-image:none',
|
|
273
|
-
|
|
279
|
+
`box-shadow:${SHADOW}`,
|
|
274
280
|
'user-select:none', 'cursor:default', 'pointer-events:auto',
|
|
275
281
|
...skin,
|
|
276
282
|
], initialsOf(person.name)));
|
|
277
283
|
el.setAttribute('data-clay-presence-avatar', '');
|
|
278
284
|
|
|
279
285
|
// The name lives in this closure, never in an attribute on the element, and
|
|
280
|
-
// it reaches the DOM only while a pointer is actually on the
|
|
286
|
+
// it reaches the DOM only while a pointer is actually on the square.
|
|
281
287
|
const label = person.you ? `${person.name} (you)` : person.name;
|
|
282
288
|
el.addEventListener('mouseenter', () => this.showTip(label));
|
|
283
289
|
el.addEventListener('mouseleave', () => this.hideTip());
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
* The region is kept AFTER focus leaves, deliberately. The three-way merge
|
|
32
32
|
* keeps the reader's own typing in the focused region and restores the caret,
|
|
33
33
|
* so the frame worth reporting is the one that lands on the paragraph the
|
|
34
|
-
* reader just left.
|
|
34
|
+
* reader just left. The line is drawn beside that region, so the reader sees
|
|
35
|
+
* which section it means.
|
|
35
36
|
*
|
|
36
37
|
* DISMISS ONLY. An undo here would mean recovering displaced local work, which
|
|
37
38
|
* nothing in this library builds. Unsaved local work survives the three-way
|
|
@@ -56,15 +57,8 @@
|
|
|
56
57
|
* nowhere else — no attribute, no data store, nothing a serializer walks.
|
|
57
58
|
*/
|
|
58
59
|
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
-
// Runtime-only chrome, in one string so no root can carry two of the three.
|
|
62
|
-
const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
|
|
63
|
-
|
|
64
|
-
const BG = 'var(--clay-section-bg,#222)';
|
|
65
|
-
const INK = 'var(--clay-section-ink,#fff)';
|
|
66
|
-
const EDGE = 'var(--clay-section-edge,rgba(255,255,255,.28))';
|
|
67
|
-
const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
60
|
+
import { set } from '../lib/hostile-css.js';
|
|
61
|
+
import { bevelButton, bevelSurface, bevelText, pageScheme, RUNTIME_ONLY } from '../ui/bevel-controls.js';
|
|
68
62
|
|
|
69
63
|
// A control's work is its live value, which its markup does not carry. An
|
|
70
64
|
// editable subtree's is its markup, and `outerHTML` covers the region's own
|
|
@@ -114,6 +108,7 @@ class SectionNotice {
|
|
|
114
108
|
this._onFocusIn = (event) => this.remember(event.target);
|
|
115
109
|
this._onEdit = (event) => this.refresh(event.target);
|
|
116
110
|
this._onApplied = (event) => this.applied(event && event.detail);
|
|
111
|
+
this._onMove = () => this.place();
|
|
117
112
|
}
|
|
118
113
|
|
|
119
114
|
/** Idempotent, so importing this module twice cannot double-report a frame. */
|
|
@@ -130,6 +125,7 @@ class SectionNotice {
|
|
|
130
125
|
destroy() {
|
|
131
126
|
if (!this._wired) return;
|
|
132
127
|
this._wired = false;
|
|
128
|
+
this.follow(false);
|
|
133
129
|
document.removeEventListener('focusin', this._onFocusIn);
|
|
134
130
|
document.removeEventListener('input', this._onEdit);
|
|
135
131
|
document.removeEventListener('clay:sync-applied', this._onApplied);
|
|
@@ -168,21 +164,62 @@ class SectionNotice {
|
|
|
168
164
|
|
|
169
165
|
show(name) {
|
|
170
166
|
if (!this.build()) return;
|
|
167
|
+
// The conflict notice sits in the same corner and matters more.
|
|
168
|
+
const conflict = document.querySelector('[data-clay-conflict]');
|
|
169
|
+
if (conflict && conflict.style.display !== 'none') return;
|
|
171
170
|
this.line.textContent = `${name} changed this section`;
|
|
171
|
+
set(this.root, 'color-scheme', pageScheme());
|
|
172
172
|
set(this.root, 'display', 'flex');
|
|
173
|
+
this.place();
|
|
174
|
+
this.follow(true);
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
hide() {
|
|
176
178
|
if (!this.root) return;
|
|
177
179
|
this.line.textContent = '';
|
|
178
180
|
set(this.root, 'display', 'none');
|
|
181
|
+
this.follow(false);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Beside the section it names: just above its top edge, or just below it when there
|
|
185
|
+
// is no room above. A section scrolled out of view, removed by the frame, or never
|
|
186
|
+
// focused leaves the line in its corner, clear of the conflict bar.
|
|
187
|
+
place() {
|
|
188
|
+
if (!this.root) return;
|
|
189
|
+
// The conflict notice hides the line directly; stop following until the next show().
|
|
190
|
+
if (this.root.style.display === 'none') { this.follow(false); return; }
|
|
191
|
+
const r = this.region && this.region.isConnected ? this.region.getBoundingClientRect() : null;
|
|
192
|
+
const vw = document.documentElement.clientWidth || window.innerWidth;
|
|
193
|
+
const vh = document.documentElement.clientHeight || window.innerHeight;
|
|
194
|
+
if (!r || !(r.width || r.height) || r.bottom <= 0 || r.top >= vh) {
|
|
195
|
+
set(this.root, 'top', 'auto');
|
|
196
|
+
set(this.root, 'left', 'calc(12px + env(safe-area-inset-left,0px))');
|
|
197
|
+
set(this.root, 'bottom', 'calc(72px + env(safe-area-inset-bottom,0px))');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const h = this.root.offsetHeight || 40;
|
|
201
|
+
const w = this.root.offsetWidth || 280;
|
|
202
|
+
const gap = 6;
|
|
203
|
+
const top = r.top - h - gap >= 8 ? r.top - h - gap : Math.min(r.bottom + gap, vh - h - 8);
|
|
204
|
+
const left = Math.max(12, Math.min(r.left, vw - w - 12));
|
|
205
|
+
set(this.root, 'bottom', 'auto');
|
|
206
|
+
set(this.root, 'top', `${Math.round(top)}px`);
|
|
207
|
+
set(this.root, 'left', `${Math.round(left)}px`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
follow(on) {
|
|
211
|
+
if (typeof window === 'undefined' || !!on === !!this._following) return;
|
|
212
|
+
this._following = !!on;
|
|
213
|
+
const method = on ? 'addEventListener' : 'removeEventListener';
|
|
214
|
+
window[method]('scroll', this._onMove, { capture: true, passive: true });
|
|
215
|
+
window[method]('resize', this._onMove, { passive: true });
|
|
179
216
|
}
|
|
180
217
|
|
|
181
218
|
build() {
|
|
182
219
|
if (this.root && this.root.isConnected) return true;
|
|
183
220
|
if (typeof document === 'undefined' || !document.body) return false;
|
|
184
221
|
|
|
185
|
-
this.root = runtimeRoot(
|
|
222
|
+
this.root = runtimeRoot(bevelSurface('div', [
|
|
186
223
|
'position:fixed',
|
|
187
224
|
'left:calc(12px + env(safe-area-inset-left,0px))',
|
|
188
225
|
// Clear of the conflict bar, which is bottom-anchored and can span almost
|
|
@@ -190,30 +227,18 @@ class SectionNotice {
|
|
|
190
227
|
// this one moves rather than covering it.
|
|
191
228
|
'bottom:calc(72px + env(safe-area-inset-bottom,0px))',
|
|
192
229
|
'z-index:2147483000',
|
|
193
|
-
'display:none', 'align-items:center', 'gap:10px',
|
|
194
|
-
'
|
|
195
|
-
|
|
196
|
-
`background-color:${BG}`, 'background-image:none', `color:${INK}`,
|
|
197
|
-
'border-width:1px', 'border-style:solid', `border-color:${EDGE}`,
|
|
198
|
-
'box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)',
|
|
199
|
-
`font:${FONT}`, 'text-align:left',
|
|
230
|
+
'display:none', 'align-items:center', 'gap:10px 12px',
|
|
231
|
+
'max-width:calc(100vw - 24px)', 'padding:8px 8px 8px 14px', 'text-align:left',
|
|
232
|
+
`color-scheme:${pageScheme()}`,
|
|
200
233
|
]));
|
|
201
234
|
this.root.setAttribute('data-clay-section-notice', '');
|
|
202
235
|
this.root.setAttribute('role', 'status');
|
|
203
236
|
|
|
204
|
-
this.line = runtimeRoot(
|
|
237
|
+
this.line = runtimeRoot(bevelText('span', ['flex:1 1 auto']));
|
|
205
238
|
this.line.setAttribute('data-clay-section-notice-line', '');
|
|
206
239
|
|
|
207
|
-
|
|
208
|
-
// case rather than the adversarial one; everything it needs is restated.
|
|
209
|
-
const dismiss = runtimeRoot(make('button', [
|
|
210
|
-
'all:initial', 'box-sizing:border-box', 'cursor:pointer', `font:${FONT}`,
|
|
211
|
-
'font-weight:500', 'border-radius:6px', 'padding:4px 8px',
|
|
212
|
-
'white-space:nowrap', 'flex:none', `color:${INK}`, 'opacity:.72',
|
|
213
|
-
], 'Dismiss'));
|
|
214
|
-
dismiss.type = 'button';
|
|
240
|
+
const dismiss = bevelButton('Dismiss', { small: true, variant: 'quiet', onClick: () => this.hide() });
|
|
215
241
|
dismiss.setAttribute('data-clay-section-notice-dismiss', '');
|
|
216
|
-
dismiss.addEventListener('click', () => this.hide());
|
|
217
242
|
|
|
218
243
|
this.root.append(this.line, dismiss);
|
|
219
244
|
document.body.appendChild(this.root);
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Bevel controls for UI ClayJS draws on somebody else's page. Every declaration
|
|
2
|
+
// goes on inline and !important (hostile-css.js), so the page's stylesheet cannot
|
|
3
|
+
// repaint them. Inline styles have no :hover or :active, so each button keeps its
|
|
4
|
+
// state in flags and rebuilds its whole inline style from them on every change:
|
|
5
|
+
// a property one state set can never survive into the next. Style a button only
|
|
6
|
+
// through `extra` and `labelExtra`; anything set on it afterwards is rebuilt away.
|
|
7
|
+
//
|
|
8
|
+
// all:initial resets color-scheme and direction too. Both are put back after every
|
|
9
|
+
// reset, so light-dark() follows the scheme the UI root sets and a page cannot
|
|
10
|
+
// flip the text direction.
|
|
11
|
+
import { style, make, set } from '../lib/hostile-css.js';
|
|
12
|
+
import { RULES, MEDIA, FONT_SANS, TOKENS } from './bevel.js';
|
|
13
|
+
|
|
14
|
+
export const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
|
|
15
|
+
|
|
16
|
+
const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
|
|
17
|
+
|
|
18
|
+
const SIZE = {
|
|
19
|
+
normal: [`font:500 14px/1.45 ${FONT_SANS}`, 'padding:4px 12px'],
|
|
20
|
+
small: [`font:500 12.5px/1.45 ${FONT_SANS}`, 'padding:1px 8px'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Bevel composes :hover and :active in source order; so do these lists.
|
|
24
|
+
const VARIANT = {
|
|
25
|
+
default: { rest: RULES.button, hover: RULES.buttonHover, active: RULES.buttonActive },
|
|
26
|
+
primary: { rest: RULES.buttonPrimary, hover: [...RULES.buttonPrimaryHoverBase, ...RULES.buttonPrimaryHover], active: RULES.buttonPrimaryActive },
|
|
27
|
+
quiet: { rest: [...RULES.button, ...RULES.buttonQuiet], hover: [...RULES.buttonHover, ...RULES.buttonQuietHover], active: [...RULES.buttonActive, ...RULES.buttonQuietActive] },
|
|
28
|
+
danger: { rest: RULES.buttonDanger, hover: RULES.buttonDangerHover, active: RULES.buttonDangerActive },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const media = (query) => typeof matchMedia === 'function' && matchMedia(query).matches;
|
|
32
|
+
|
|
33
|
+
let keyboardModality = false;
|
|
34
|
+
const focusedButtons = new Set();
|
|
35
|
+
if (typeof document !== 'undefined') {
|
|
36
|
+
document.addEventListener('keydown', () => {
|
|
37
|
+
keyboardModality = true;
|
|
38
|
+
for (const refresh of focusedButtons) refresh();
|
|
39
|
+
}, true);
|
|
40
|
+
document.addEventListener('pointerdown', () => { keyboardModality = false; }, true);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runtime(el) {
|
|
44
|
+
el.setAttribute('clay', RUNTIME_ONLY);
|
|
45
|
+
return el;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function restyle(el, rules) {
|
|
49
|
+
el.style.cssText = '';
|
|
50
|
+
style(el, rules);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
54
|
+
const SHAPES = new Set(['path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'g']);
|
|
55
|
+
|
|
56
|
+
// Presentation attributes lose to any author rule, so the icon's own attributes are
|
|
57
|
+
// restated as inline !important declarations: the page cannot hide, resize or
|
|
58
|
+
// repaint it. A path's geometry is pinned through the `d` property as well.
|
|
59
|
+
// It inherits colour, so a currentColor shape takes the ink of the control it sits in.
|
|
60
|
+
function protectIcon(svg, size) {
|
|
61
|
+
if (!svg || svg.namespaceURI !== SVG_NS) return;
|
|
62
|
+
runtime(svg);
|
|
63
|
+
style(svg, [
|
|
64
|
+
...RESET, 'color:inherit', 'display:block', `width:${size}px`, `height:${size}px`, 'overflow:visible',
|
|
65
|
+
'visibility:visible', 'opacity:1', 'transform:none', 'pointer-events:none',
|
|
66
|
+
]);
|
|
67
|
+
for (const el of svg.querySelectorAll('*')) {
|
|
68
|
+
if (!SHAPES.has(el.localName)) continue;
|
|
69
|
+
const attr = (name, fallback) => el.getAttribute(name) ?? fallback;
|
|
70
|
+
const rules = [
|
|
71
|
+
'visibility:visible', 'opacity:1', 'transform:none', 'display:inline',
|
|
72
|
+
`fill:${attr('fill', svg.getAttribute('fill') ?? 'currentColor')}`,
|
|
73
|
+
`stroke:${attr('stroke', svg.getAttribute('stroke') ?? 'none')}`,
|
|
74
|
+
];
|
|
75
|
+
if (el.hasAttribute('stroke-width')) rules.push(`stroke-width:${el.getAttribute('stroke-width')}`);
|
|
76
|
+
if (el.hasAttribute('stroke-linecap')) rules.push(`stroke-linecap:${el.getAttribute('stroke-linecap')}`);
|
|
77
|
+
if (el.localName === 'path' && el.hasAttribute('d')) rules.push(`d:path("${el.getAttribute('d')}")`);
|
|
78
|
+
style(el, rules);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function bevelButton(label, { variant = 'default', small = false, onClick = null, extra = [], labelExtra = [], onState = null } = {}) {
|
|
83
|
+
const v = VARIANT[variant] || VARIANT.default;
|
|
84
|
+
const size = small ? SIZE.small : SIZE.normal;
|
|
85
|
+
const reducedMotion = media('(prefers-reduced-motion: reduce)');
|
|
86
|
+
const forced = media('(forced-colors: active)');
|
|
87
|
+
const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
|
|
88
|
+
const pinned = new Map();
|
|
89
|
+
|
|
90
|
+
const b = runtime(make('button', []));
|
|
91
|
+
b.type = 'button';
|
|
92
|
+
const span = runtime(make('span', []));
|
|
93
|
+
span.textContent = label;
|
|
94
|
+
b.append(span);
|
|
95
|
+
|
|
96
|
+
function applyState() {
|
|
97
|
+
const rules = [...RESET, 'box-sizing:border-box', ...v.rest, ...size];
|
|
98
|
+
if (flags.disabled) {
|
|
99
|
+
rules.push(...RULES.buttonDisabled);
|
|
100
|
+
} else {
|
|
101
|
+
if (flags.hovered) rules.push(...v.hover);
|
|
102
|
+
if (flags.pressed) rules.push(...v.active);
|
|
103
|
+
}
|
|
104
|
+
if (flags.focusVisible) rules.push(...RULES.focus);
|
|
105
|
+
if (forced) rules.push(...MEDIA.forcedColors);
|
|
106
|
+
rules.push(...extra);
|
|
107
|
+
for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
|
|
108
|
+
restyle(b, rules);
|
|
109
|
+
const labelRules = [...RESET, 'font:inherit', 'color:inherit', ...RULES.buttonLabel];
|
|
110
|
+
if (flags.disabled) labelRules.push(...RULES.buttonDisabledLabel);
|
|
111
|
+
else if (flags.pressed) labelRules.push(...(reducedMotion ? MEDIA.reducedMotion : RULES.buttonActiveLabel));
|
|
112
|
+
labelRules.push(...labelExtra);
|
|
113
|
+
restyle(span, labelRules);
|
|
114
|
+
onState?.({ ...flags });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function refreshFocus() {
|
|
118
|
+
let visible;
|
|
119
|
+
try { visible = b.matches(':focus-visible'); } catch { visible = false; }
|
|
120
|
+
const next = visible || keyboardModality;
|
|
121
|
+
if (next !== flags.focusVisible) { flags.focusVisible = next; applyState(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const on = (type, fn) => b.addEventListener(type, fn);
|
|
125
|
+
on('pointerenter', () => { flags.hovered = true; applyState(); });
|
|
126
|
+
on('pointerleave', () => { flags.hovered = false; flags.pressed = false; applyState(); });
|
|
127
|
+
on('pointercancel', () => { flags.pressed = false; applyState(); });
|
|
128
|
+
on('pointerdown', (e) => {
|
|
129
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
130
|
+
if (!flags.disabled) { flags.pressed = true; applyState(); }
|
|
131
|
+
});
|
|
132
|
+
on('pointerup', () => { flags.pressed = false; applyState(); });
|
|
133
|
+
on('keydown', (e) => {
|
|
134
|
+
if ((e.key === ' ' || e.key === 'Enter') && !flags.disabled) { flags.pressed = true; applyState(); }
|
|
135
|
+
});
|
|
136
|
+
on('keyup', () => { if (flags.pressed) { flags.pressed = false; applyState(); } });
|
|
137
|
+
on('focus', () => { focusedButtons.add(refreshFocus); flags.focusVisible = false; refreshFocus(); applyState(); });
|
|
138
|
+
on('blur', () => { focusedButtons.delete(refreshFocus); flags.focusVisible = false; flags.pressed = false; applyState(); });
|
|
139
|
+
on('click', (e) => {
|
|
140
|
+
if (flags.pressed) { flags.pressed = false; applyState(); }
|
|
141
|
+
if (flags.disabled) { e.preventDefault(); e.stopImmediatePropagation(); return; }
|
|
142
|
+
if (onClick) onClick(e);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
b.setLabel = (text) => { span.textContent = text; };
|
|
146
|
+
b.setDisabled = (disabled) => {
|
|
147
|
+
flags.disabled = !!disabled;
|
|
148
|
+
if (flags.disabled) b.setAttribute('aria-disabled', 'true');
|
|
149
|
+
else b.removeAttribute('aria-disabled');
|
|
150
|
+
applyState();
|
|
151
|
+
};
|
|
152
|
+
b.isDisabled = () => flags.disabled;
|
|
153
|
+
// Declarations that must outlive every state rebuild: where a floating button sits,
|
|
154
|
+
// whether it shows. Written anywhere else, they would be rebuilt away on the next hover.
|
|
155
|
+
b.pin = (props) => {
|
|
156
|
+
for (const [prop, value] of Object.entries(props)) pinned.set(prop, value);
|
|
157
|
+
applyState();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
applyState();
|
|
161
|
+
return b;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function bevelIconButton(svg, { label, onClick = null }) {
|
|
165
|
+
const b = bevelButton('', {
|
|
166
|
+
small: true,
|
|
167
|
+
onClick,
|
|
168
|
+
extra: ['width:26px', 'height:26px', 'padding:0', 'display:inline-grid', 'place-items:center'],
|
|
169
|
+
labelExtra: ['display:inline-grid', 'width:12px', 'height:12px'],
|
|
170
|
+
});
|
|
171
|
+
b.firstChild.innerHTML = svg;
|
|
172
|
+
protectIcon(b.firstChild.firstElementChild, 12);
|
|
173
|
+
b.setAttribute('aria-label', label);
|
|
174
|
+
b.title = label;
|
|
175
|
+
return b;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Bevel's overlay close: a 68px corner cut on the diagonal, the pixel X drawn on it.
|
|
179
|
+
// Hover lifts the corner to the face, keyboard focus fills it with brass.
|
|
180
|
+
const CORNER = [
|
|
181
|
+
'position:absolute', 'top:-1px', 'right:-1px', 'z-index:2', 'display:block',
|
|
182
|
+
'width:68px', 'height:68px', 'padding:0', 'margin:0', 'border:0', 'outline:none',
|
|
183
|
+
'background:transparent', 'clip-path:polygon(0 4%, 0 0, 100% 0, 100% 100%, 94% 100%)',
|
|
184
|
+
];
|
|
185
|
+
const CORNER_BG = 'M132 132.5 1 1.5h131v131Z';
|
|
186
|
+
const CORNER_X = 'M0 0h3v1.5h1.5V3H6v1.5h1.5V6H9v1.5h1.5V9H12v1.5h1.5V12H15v1.5h1.5V15H18v1.5h1.5V18H21v1.5h1.5V21H24v1.5h1.5V24H27v1.5h1.5V27H30v1.5h1.5V30H33v1.5h1.5V33H36v1.5h1.5V36H39v1.5h1.5V39H42v1.5h1.5V42H45v1.5h1.5V45H48v1.5h1.5V48H51v1.5h1.5V51H54v1.5h1.5V54H57v1.5h1.5V57H60v1.5h1.5V60H63v1.5h1.5V63H66v1.5h1.5V66H69v1.5h1.5V69H72v1.5h1.5V72H75v1.5h1.5V75H78v1.5h1.5V78H81v1.5h1.5V81H84v1.5h1.5V84H87v1.5h1.5V87H90v1.5h1.5V90H93v1.5h1.5V93H96v1.5h1.5V96H99v1.5h1.5V99h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v1.5h1.5v3h-3V132H129v-1.5h-1.5V129H126v-1.5h-1.5V126H123v-1.5h-1.5V123H120v-1.5h-1.5V120H117v-1.5h-1.5V117H114v-1.5h-1.5V114H111v-1.5h-1.5V111H108v-1.5h-1.5V108H105v-1.5h-1.5V105H102v-1.5h-1.5V102H99v-1.5h-1.5V99H96v-1.5h-1.5V96H93v-1.5h-1.5V93H90v-1.5h-1.5V90H87v-1.5h-1.5V87H84v-1.5h-1.5V84H81v-1.5h-1.5V81H78v-1.5h-1.5V78H75v-1.5h-1.5V75H72v-1.5h-1.5V72H69v-1.5h-1.5V69H66v-1.5h-1.5V66H63v-1.5h-1.5V63H60v-1.5h-1.5V60H57v-1.5h-1.5V57H54v-1.5h-1.5V54H51v-1.5h-1.5V51H48v-1.5h-1.5V48H45v-1.5h-1.5V45H42v-1.5h-1.5V42H39v-1.5h-1.5V39H36v-1.5h-1.5V36H33v-1.5h-1.5V33H30v-1.5h-1.5V30H27v-1.5h-1.5V27H24v-1.5h-1.5V24H21v-1.5h-1.5V21H18v-1.5h-1.5V18H15v-1.5h-1.5V15H12v-1.5h-1.5V12H9v-1.5H7.5V9H6V7.5H4.5V6H3V4.5H1.5V3H0V0ZM108.8 22h5.2v5.1h-2.6v2.6H109v2.6h-2.6v2.6h-2.6v2.5h-2.6v5.2h2.6V45h2.6v2.6h2.6v2.6h2.5v2.6h2.6V58h-5.1v-2.6h-2.6V53h-2.6v-2.6h-2.6v-2.6h-2.5v-2.6h-5.2v2.6H91v2.6h-2.6v2.6h-2.6v2.5h-2.6V58H78v-5.1h2.6v-2.6H83v-2.6h2.6v-2.6h2.6v-2.5h2.6v-5.2h-2.6V35h-2.6v-2.6h-2.6v-2.6h-2.5v-2.6H78V22h5.2v2.6h2.5V27h2.6v2.6h2.6v2.6h2.5v2.6h5.2v-2.6h2.5v-2.6h2.6v-2.6h2.6v-2.5h2.5V22Z';
|
|
187
|
+
|
|
188
|
+
export function bevelCornerClose({ label = 'Close', onClick = null } = {}) {
|
|
189
|
+
let bg = null;
|
|
190
|
+
let x = null;
|
|
191
|
+
const paint = ({ hovered, focusVisible }) => {
|
|
192
|
+
if (!bg) return;
|
|
193
|
+
set(bg, 'fill', focusVisible ? TOKENS.brass : hovered ? TOKENS.face : TOKENS.sunk);
|
|
194
|
+
set(x, 'fill', focusVisible ? TOKENS.face : TOKENS.ink);
|
|
195
|
+
};
|
|
196
|
+
const b = bevelButton('', {
|
|
197
|
+
variant: 'quiet', onClick, extra: CORNER,
|
|
198
|
+
labelExtra: ['display:block', 'width:100%', 'height:100%'], onState: paint,
|
|
199
|
+
});
|
|
200
|
+
b.setAttribute('aria-label', label);
|
|
201
|
+
b.firstChild.innerHTML = `<svg viewBox="0 0 134 134" xmlns="http://www.w3.org/2000/svg"><path d="${CORNER_BG}"/><path fill-rule="evenodd" clip-rule="evenodd" d="${CORNER_X}"/></svg>`;
|
|
202
|
+
const svg = b.firstChild.firstElementChild;
|
|
203
|
+
protectIcon(svg, 68);
|
|
204
|
+
[bg, x] = svg.children;
|
|
205
|
+
set(x, 'fill-rule', 'evenodd');
|
|
206
|
+
set(x, 'clip-rule', 'evenodd');
|
|
207
|
+
paint({ hovered: false, focusVisible: false });
|
|
208
|
+
return b;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A plain runtime-only box: reset, then only the given rules. For structure inside a
|
|
212
|
+
// surface (rows, stacks, backdrops) that carries no material of its own.
|
|
213
|
+
export function bevelBox(tag, rules = []) {
|
|
214
|
+
return runtime(make(tag, [...RESET, 'box-sizing:border-box', ...rules]));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function bevelSurface(tag, rules = []) {
|
|
218
|
+
return runtime(make(tag, [...RESET, 'box-sizing:border-box', ...RULES.surface, `font:14px/1.5 ${FONT_SANS}`, ...rules]));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function bevelWell(rules = []) {
|
|
222
|
+
return runtime(make('div', [...RESET, 'display:block', 'box-sizing:border-box', `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`, ...RULES.recess, ...rules]));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function bevelText(tag, rules = [], text) {
|
|
226
|
+
const el = runtime(make(tag, [...RESET, 'font:inherit', `color:${TOKENS.ink}`, ...rules]));
|
|
227
|
+
if (text != null) el.textContent = text;
|
|
228
|
+
return el;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A text field in Bevel's input material, on an element that already exists (markup a
|
|
232
|
+
// dialog wrote) or a new one. Focus and hover live in flags, as on a button. A
|
|
233
|
+
// textarea keeps the height a person dragged it to across every repaint.
|
|
234
|
+
export function paintInput(el, { rules = [] } = {}) {
|
|
235
|
+
const flags = { hovered: false, focused: false };
|
|
236
|
+
const forced = media('(forced-colors: active)');
|
|
237
|
+
runtime(el);
|
|
238
|
+
function applyState() {
|
|
239
|
+
const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
|
|
240
|
+
const list = [...RESET, 'box-sizing:border-box', ...RULES.input];
|
|
241
|
+
if (flags.hovered) list.push(...RULES.inputHover);
|
|
242
|
+
if (flags.focused) list.push(...RULES.inputFocus);
|
|
243
|
+
if (forced) list.push(...MEDIA.forcedColors);
|
|
244
|
+
list.push(...rules);
|
|
245
|
+
if (height) list.push(`height:${height}`);
|
|
246
|
+
restyle(el, list);
|
|
247
|
+
}
|
|
248
|
+
el.addEventListener('pointerenter', () => { flags.hovered = true; applyState(); });
|
|
249
|
+
el.addEventListener('pointerleave', () => { flags.hovered = false; applyState(); });
|
|
250
|
+
el.addEventListener('focus', () => { flags.focused = true; applyState(); });
|
|
251
|
+
el.addEventListener('blur', () => { flags.focused = false; applyState(); });
|
|
252
|
+
applyState();
|
|
253
|
+
return el;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function bevelInput(tag = 'input', opts = {}) {
|
|
257
|
+
const el = document.createElement(tag);
|
|
258
|
+
if (tag === 'input') el.type = 'text';
|
|
259
|
+
return paintInput(el, opts);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The scheme a UI root gives its subtree: the page's own when it declares one, so
|
|
263
|
+
// light-dark() matches what the person is looking at, else the reader's preference.
|
|
264
|
+
export function pageScheme() {
|
|
265
|
+
const s = getComputedStyle(document.documentElement).colorScheme;
|
|
266
|
+
return s === 'light' || s === 'dark' ? s : 'light dark';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// `hidden` loses to an inline !important display, so showing and hiding a control
|
|
270
|
+
// sets both: the attribute for assistive tech and callers, the declaration for looks.
|
|
271
|
+
export function setShown(el, shown, display = 'block') {
|
|
272
|
+
el.hidden = !shown;
|
|
273
|
+
if (el.pin) el.pin({ display: shown ? display : 'none' });
|
|
274
|
+
else set(el, 'display', shown ? display : 'none');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export { protectIcon };
|