@webjsdev/ui 0.3.10 → 0.3.12
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 +26 -15
- package/package.json +3 -3
- package/packages/registry/README.md +16 -10
- package/packages/registry/components/accordion.ts +18 -0
- package/packages/registry/components/alert-dialog.ts +203 -22
- package/packages/registry/components/aspect-ratio.ts +12 -0
- package/packages/registry/components/card.ts +20 -1
- package/packages/registry/components/checkbox.ts +45 -3
- package/packages/registry/components/collapsible.ts +15 -0
- package/packages/registry/components/dialog.ts +205 -22
- package/packages/registry/components/dropdown-menu.ts +390 -22
- package/packages/registry/components/hover-card.ts +162 -4
- package/packages/registry/components/input.ts +34 -2
- package/packages/registry/components/kbd.ts +35 -2
- package/packages/registry/components/label.ts +21 -0
- package/packages/registry/components/native-select.ts +28 -7
- package/packages/registry/components/popover.ts +43 -7
- package/packages/registry/components/radio-group.ts +37 -9
- package/packages/registry/components/sonner.ts +53 -5
- package/packages/registry/components/switch.ts +23 -1
- package/packages/registry/components/tabs.ts +15 -0
- package/packages/registry/components/textarea.ts +21 -2
- package/packages/registry/components/toggle-group.ts +81 -8
- package/packages/registry/components/toggle.ts +82 -3
- package/packages/registry/components/tooltip.ts +57 -0
- package/packages/registry/lib/utils.ts +208 -14
- package/packages/registry/package.json +1 -1
- package/packages/registry/registry.json +1 -1
- package/packages/registry/themes/base-colors.js +2 -2
- package/src/commands/add.js +52 -2
- package/src/commands/diff.js +2 -2
- package/src/commands/info.js +2 -5
- package/src/commands/init.js +127 -25
- package/src/registry/example.js +7 -4
- package/src/registry/fetcher.js +10 -2
- package/src/registry/local.js +2 -2
- package/src/utils/index.js +0 -1
- package/src/utils/detect-project.js +0 -63
|
@@ -37,8 +37,28 @@
|
|
|
37
37
|
* Keyboard: Escape closes (native `cancel` event); Tab cycles trapped
|
|
38
38
|
* within the dialog (native focus trap).
|
|
39
39
|
*
|
|
40
|
+
* A11y (owned by the element, but SUPPLY A TITLE):
|
|
41
|
+
* On open the element names and describes the panel from the
|
|
42
|
+
* `data-slot="dialog-title"` / `dialog-description` nodes, falling back to
|
|
43
|
+
* the first heading / paragraph. An `aria-label` or `aria-labelledby` you put
|
|
44
|
+
* on `<ui-dialog-content>` is forwarded onto the panel and wins over both.
|
|
45
|
+
* A modal MUST have an accessible name, so with no title node and no name of
|
|
46
|
+
* your own the panel falls back to a generic `aria-label="Dialog"`. That is a
|
|
47
|
+
* floor to make an unnamed modal impossible, NOT a substitute: give the
|
|
48
|
+
* dialog a real title, since "Dialog" tells a screen reader user nothing
|
|
49
|
+
* about what they have been interrupted with.
|
|
50
|
+
*
|
|
40
51
|
* Design tokens used: --background, --border, --muted-foreground.
|
|
41
52
|
*
|
|
53
|
+
* Scroll lock (#1144): opening the dialog locks body scroll, which hides the
|
|
54
|
+
* page scrollbar and would widen the viewport. The lock reserves the scrollbar
|
|
55
|
+
* gutter so the width never changes and a `position: fixed` header stays put.
|
|
56
|
+
* Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the
|
|
57
|
+
* leftover width on <html> as `--wj-scrollbar-compensation`, so a fixed header
|
|
58
|
+
* opts in with `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`
|
|
59
|
+
* (a transparent border composes with existing padding, and a background paints
|
|
60
|
+
* across it, so a header keeps its own chrome full bleed).
|
|
61
|
+
*
|
|
42
62
|
* @example
|
|
43
63
|
* ```html
|
|
44
64
|
* <ui-dialog>
|
|
@@ -82,18 +102,61 @@ import { buttonClass } from './button.ts';
|
|
|
82
102
|
// data-slot="dialog-title" (falling back to the first heading); the
|
|
83
103
|
// description is data-slot="dialog-description" (falling back to the first
|
|
84
104
|
// paragraph). Author-set aria-labelledby / aria-describedby always win.
|
|
105
|
+
// The description is independent of the NAME, so it is wired on every path,
|
|
106
|
+
// including the two authored-name early returns below. An author who names the
|
|
107
|
+
// dialog still gets their description node described.
|
|
108
|
+
function wireDialogDescription(host: Element, panel: Element): void {
|
|
109
|
+
const authored = host.getAttribute('aria-describedby');
|
|
110
|
+
if (authored) {
|
|
111
|
+
panel.setAttribute('aria-describedby', authored);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const desc =
|
|
115
|
+
host.querySelector('[data-slot="dialog-description"]') ?? host.querySelector('p');
|
|
116
|
+
if (desc && !panel.hasAttribute('aria-describedby')) {
|
|
117
|
+
panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-dialog-desc'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
export function wireDialogLabels(host: Element, panelSelector: string): void {
|
|
86
122
|
const panel = host.querySelector(panelSelector);
|
|
87
123
|
if (!panel) return;
|
|
124
|
+
// A name the author put on <ui-dialog-content> is where they naturally write
|
|
125
|
+
// it, but role="dialog" lives on the inner panel, so forward it there.
|
|
126
|
+
//
|
|
127
|
+
// This RETURNS once an authored name is forwarded, rather than falling
|
|
128
|
+
// through to the title wiring below. Falling through would set
|
|
129
|
+
// aria-labelledby from the title alongside the forwarded aria-label, and
|
|
130
|
+
// aria-labelledby beats aria-label per accname, so the title would silently
|
|
131
|
+
// win over the name the author asked for.
|
|
132
|
+
const authoredLabelledBy = host.getAttribute('aria-labelledby');
|
|
133
|
+
if (authoredLabelledBy) {
|
|
134
|
+
panel.setAttribute('aria-labelledby', authoredLabelledBy);
|
|
135
|
+
wireDialogDescription(host, panel);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const authoredLabel = host.getAttribute('aria-label');
|
|
139
|
+
if (authoredLabel) {
|
|
140
|
+
panel.setAttribute('aria-label', authoredLabel);
|
|
141
|
+
// An authored name replaces the title as the NAME, so any stale
|
|
142
|
+
// aria-labelledby from an earlier open must go, or it would win over it.
|
|
143
|
+
panel.removeAttribute('aria-labelledby');
|
|
144
|
+
wireDialogDescription(host, panel);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
88
147
|
const title =
|
|
89
148
|
host.querySelector('[data-slot="dialog-title"]') ?? host.querySelector('h1, h2, h3');
|
|
90
|
-
const desc =
|
|
91
|
-
host.querySelector('[data-slot="dialog-description"]') ?? host.querySelector('p');
|
|
92
149
|
if (title && !panel.hasAttribute('aria-labelledby')) {
|
|
93
150
|
panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-dialog-title'));
|
|
94
151
|
}
|
|
95
|
-
|
|
96
|
-
|
|
152
|
+
wireDialogDescription(host, panel);
|
|
153
|
+
// APG: a modal MUST have an accessible name. Everything above supplies one
|
|
154
|
+
// only when the author gave the dialog a title node or named it directly, so
|
|
155
|
+
// a title-less dialog shipped unnamed and was announced as a bare "dialog".
|
|
156
|
+
// This generic name is a floor, not a substitute for a real title: it makes
|
|
157
|
+
// an unnamed modal impossible, and a supplied title always wins over it.
|
|
158
|
+
if (!panel.hasAttribute('aria-labelledby') && !panel.hasAttribute('aria-label')) {
|
|
159
|
+
panel.setAttribute('aria-label', 'Dialog');
|
|
97
160
|
}
|
|
98
161
|
}
|
|
99
162
|
|
|
@@ -161,30 +224,130 @@ function installStyles(): void {
|
|
|
161
224
|
}
|
|
162
225
|
|
|
163
226
|
// --------------------------------------------------------------------------
|
|
164
|
-
//
|
|
165
|
-
// <dialog> does not lock
|
|
227
|
+
// Page scroll lock, refcounted so nested dialogs release safely in ANY order.
|
|
228
|
+
// Native <dialog> does not lock scrolling, it only inert-ifies the background.
|
|
229
|
+
//
|
|
230
|
+
// Hiding the page scrollbar widens the viewport by the scrollbar's width, so
|
|
231
|
+
// anything laid out against the viewport moves. Padding the page holds in-flow
|
|
232
|
+
// content still, but a `position: fixed` header lays out against the initial
|
|
233
|
+
// containing block, never against any padding box, so padding alone leaves it
|
|
234
|
+
// sliding right by half the scrollbar width. Two mechanisms fix it:
|
|
235
|
+
//
|
|
236
|
+
// 1. Reserve the scrollbar gutter for the duration of the lock, so the
|
|
237
|
+
// viewport width never changes and NOTHING moves, in flow or fixed. This
|
|
238
|
+
// needs no cooperation from the page. Measured honoured on Chromium.
|
|
239
|
+
// 2. Where the engine ignores it (measured on WebKit), fall back to
|
|
240
|
+
// padding <html> and publish the leftover width as
|
|
241
|
+
// `--wj-scrollbar-compensation` on it, so a fixed element can opt in with
|
|
242
|
+
// `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`.
|
|
243
|
+
//
|
|
244
|
+
// Everything below is MEASURED rather than assumed, because engines disagree
|
|
245
|
+
// about both scrollbar geometry and gutter support. When mechanism 1 works the
|
|
246
|
+
// measured residual is zero, so no padding is applied and the custom property is
|
|
247
|
+
// never set: the two mechanisms cannot double-compensate.
|
|
166
248
|
// --------------------------------------------------------------------------
|
|
167
249
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
250
|
+
// The lock's state is DOCUMENT level, so it is keyed on globalThis rather than
|
|
251
|
+
// module scope. dialog.ts and alert-dialog.ts ship as separate copies (so
|
|
252
|
+
// `webjs ui add alert-dialog` stays self contained), and two independent
|
|
253
|
+
// counters mutating the same <html> can only be released safely in LIFO order.
|
|
254
|
+
// They are not: `disconnectedCallback` fires in tree order and the #766
|
|
255
|
+
// before-cache close runs in registration order, so a confirm inside a dialog
|
|
256
|
+
// releases OUTER first, and the inner unlock would then re-apply the values it
|
|
257
|
+
// captured with nothing left to clear them, leaving <html> padded for good. One
|
|
258
|
+
// shared counter makes release order irrelevant.
|
|
259
|
+
interface ScrollLockState {
|
|
260
|
+
count: number;
|
|
261
|
+
overflow: string;
|
|
262
|
+
rootPaddingRight: string;
|
|
263
|
+
scrollbarGutter: string;
|
|
264
|
+
compensation: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
|
|
268
|
+
|
|
269
|
+
function scrollLockState(): ScrollLockState {
|
|
270
|
+
// The key follows the framework's global-key prefix (see `__webjsSonnerBus` in
|
|
271
|
+
// sonner.ts, the same pattern for the same reason), and the cast is typed
|
|
272
|
+
// rather than a string-keyed bag, so a change to the shape is a type error in
|
|
273
|
+
// both copies instead of a silent undefined at runtime.
|
|
274
|
+
const store = globalThis as unknown as { __webjsScrollLock?: ScrollLockState };
|
|
275
|
+
let state = store.__webjsScrollLock;
|
|
276
|
+
if (!state) {
|
|
277
|
+
state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' };
|
|
278
|
+
store.__webjsScrollLock = state;
|
|
279
|
+
}
|
|
280
|
+
return state;
|
|
281
|
+
}
|
|
171
282
|
|
|
172
283
|
function lockScroll(): void {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
284
|
+
const state = scrollLockState();
|
|
285
|
+
if (state.count === 0) {
|
|
286
|
+
const root = document.documentElement;
|
|
287
|
+
const body = document.body;
|
|
288
|
+
const rootStyle = getComputedStyle(root);
|
|
289
|
+
|
|
290
|
+
state.overflow = body.style.overflow;
|
|
291
|
+
state.rootPaddingRight = root.style.paddingRight;
|
|
292
|
+
state.scrollbarGutter = root.style.scrollbarGutter;
|
|
293
|
+
state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
|
|
294
|
+
|
|
295
|
+
// <html> is an in-flow block filling the initial containing block, so its
|
|
296
|
+
// border box IS the viewport width, and it is re-laid-out synchronously.
|
|
297
|
+
// A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its
|
|
298
|
+
// old box until the next rendering update, so it reads a zero delta and the
|
|
299
|
+
// compensation below never fires.
|
|
300
|
+
const widthBefore = root.getBoundingClientRect().width;
|
|
301
|
+
const padBefore = parseFloat(rootStyle.paddingRight) || 0;
|
|
302
|
+
// An engine with no `scrollbar-gutter` at all reads back undefined, which
|
|
303
|
+
// must be treated as "the page has not chosen" so the gutter is still
|
|
304
|
+
// attempted (setting an unsupported property is a harmless no-op, and the
|
|
305
|
+
// measured residual below is what actually decides the fallback).
|
|
306
|
+
const chosenGutter = rootStyle.scrollbarGutter || 'auto';
|
|
307
|
+
|
|
308
|
+
// Reserve the gutter, but only when the page has not already made its own
|
|
309
|
+
// choice. An app running `stable both-edges` keeps both gutters through the
|
|
310
|
+
// lock, and overwriting that with the single-edge value would drop one and
|
|
311
|
+
// introduce the very shift this exists to prevent.
|
|
312
|
+
if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) {
|
|
313
|
+
root.style.scrollbarGutter = 'stable';
|
|
314
|
+
}
|
|
315
|
+
body.style.overflow = 'hidden';
|
|
316
|
+
|
|
317
|
+
const grew = root.getBoundingClientRect().width - widthBefore;
|
|
318
|
+
if (grew > 0) {
|
|
319
|
+
// Padding the ROOT holds in-flow content still whatever the body's own
|
|
320
|
+
// width is (a `max-width` body does not widen with the viewport, so
|
|
321
|
+
// padding the body would miss it), and it leaves the page's own body
|
|
322
|
+
// padding untouched.
|
|
323
|
+
root.style.paddingRight = `${padBefore + grew}px`;
|
|
324
|
+
// A fixed box lays out against the viewport, so no padding here can reach
|
|
325
|
+
// it. Publish what it moved by and let it opt in.
|
|
326
|
+
root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
|
|
327
|
+
}
|
|
179
328
|
}
|
|
180
|
-
|
|
329
|
+
state.count++;
|
|
181
330
|
}
|
|
182
331
|
|
|
183
332
|
function unlockScroll(): void {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
333
|
+
const state = scrollLockState();
|
|
334
|
+
// An unlock with no matching lock is a no-op, NOT the last release: clamping to
|
|
335
|
+
// zero and restoring would replay a stale snapshot onto whatever the page owns
|
|
336
|
+
// now. Belt-and-braces as things stand, because _teardown() only unlocks what
|
|
337
|
+
// its own element locked, so no path through the components can reach here with
|
|
338
|
+
// a zero count. It is kept because the cost is one comparison and the failure
|
|
339
|
+
// it prevents is silent.
|
|
340
|
+
if (state.count === 0) return;
|
|
341
|
+
state.count--;
|
|
342
|
+
if (state.count === 0) {
|
|
343
|
+
const root = document.documentElement;
|
|
344
|
+
document.body.style.overflow = state.overflow;
|
|
345
|
+
root.style.paddingRight = state.rootPaddingRight;
|
|
346
|
+
root.style.scrollbarGutter = state.scrollbarGutter;
|
|
347
|
+
// Restored rather than removed, so a value the PAGE set before any dialog
|
|
348
|
+
// opened survives the lock.
|
|
349
|
+
if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation);
|
|
350
|
+
else root.style.removeProperty(SCROLLBAR_COMPENSATION);
|
|
188
351
|
}
|
|
189
352
|
}
|
|
190
353
|
|
|
@@ -205,6 +368,7 @@ export class UiDialog extends WebComponent({
|
|
|
205
368
|
}
|
|
206
369
|
|
|
207
370
|
_disposeBeforeCache?: () => void;
|
|
371
|
+
_scrollLocked?: boolean;
|
|
208
372
|
|
|
209
373
|
connectedCallback(): void {
|
|
210
374
|
installStyles();
|
|
@@ -258,14 +422,33 @@ export class UiDialog extends WebComponent({
|
|
|
258
422
|
}
|
|
259
423
|
|
|
260
424
|
_setup(): void {
|
|
425
|
+
// A detached element must not lock: `updated()` defers this to a microtask,
|
|
426
|
+
// and a removal in between already ran _teardown() as a no-op, so locking
|
|
427
|
+
// here would never be released (nothing disconnects twice).
|
|
428
|
+
if (!this.isConnected) return;
|
|
261
429
|
const content = this._content;
|
|
262
430
|
if (!content) return;
|
|
263
|
-
|
|
431
|
+
// Idempotent, because the count is an integer and the flag is a boolean. A
|
|
432
|
+
// coalesced hide() then show() in one task produces ONE update whose
|
|
433
|
+
// changedProperties still reports open, so _setup() can run twice with no
|
|
434
|
+
// _teardown() between; double-counting there would leave the page locked for
|
|
435
|
+
// the rest of the session.
|
|
436
|
+
if (!this._scrollLocked) {
|
|
437
|
+
lockScroll();
|
|
438
|
+
this._scrollLocked = true;
|
|
439
|
+
}
|
|
264
440
|
content.showModal();
|
|
265
441
|
}
|
|
266
442
|
|
|
267
443
|
_teardown(): void {
|
|
268
|
-
|
|
444
|
+
// Release only what THIS element locked. _setup() returns before locking
|
|
445
|
+
// when there is no content child, so an unconditional unlock here would
|
|
446
|
+
// consume ANOTHER open dialog's count and restore the page out from under
|
|
447
|
+
// it, dropping its compensation while it is still open.
|
|
448
|
+
if (this._scrollLocked) {
|
|
449
|
+
this._scrollLocked = false;
|
|
450
|
+
unlockScroll();
|
|
451
|
+
}
|
|
269
452
|
this._content?.close();
|
|
270
453
|
}
|
|
271
454
|
}
|