@webjsdev/ui 0.3.9 → 0.3.11

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.
Files changed (37) hide show
  1. package/README.md +21 -13
  2. package/package.json +3 -3
  3. package/packages/registry/README.md +14 -10
  4. package/packages/registry/components/accordion.ts +18 -0
  5. package/packages/registry/components/alert-dialog.ts +203 -22
  6. package/packages/registry/components/aspect-ratio.ts +12 -0
  7. package/packages/registry/components/card.ts +20 -1
  8. package/packages/registry/components/checkbox.ts +45 -3
  9. package/packages/registry/components/collapsible.ts +15 -0
  10. package/packages/registry/components/dialog.ts +205 -22
  11. package/packages/registry/components/dropdown-menu.ts +390 -22
  12. package/packages/registry/components/hover-card.ts +162 -4
  13. package/packages/registry/components/input.ts +34 -2
  14. package/packages/registry/components/kbd.ts +35 -2
  15. package/packages/registry/components/label.ts +21 -0
  16. package/packages/registry/components/native-select.ts +28 -7
  17. package/packages/registry/components/popover.ts +43 -7
  18. package/packages/registry/components/radio-group.ts +37 -9
  19. package/packages/registry/components/sonner.ts +53 -5
  20. package/packages/registry/components/switch.ts +23 -1
  21. package/packages/registry/components/tabs.ts +29 -2
  22. package/packages/registry/components/textarea.ts +21 -2
  23. package/packages/registry/components/toggle-group.ts +81 -8
  24. package/packages/registry/components/toggle.ts +82 -3
  25. package/packages/registry/components/tooltip.ts +57 -0
  26. package/packages/registry/lib/utils.ts +178 -13
  27. package/packages/registry/registry.json +1 -1
  28. package/packages/registry/themes/base-colors.js +1 -1
  29. package/packages/registry/themes/index.css +11 -0
  30. package/src/commands/add.js +50 -0
  31. package/src/commands/info.js +1 -4
  32. package/src/commands/init.js +126 -24
  33. package/src/registry/extract.js +1 -7
  34. package/src/registry/fetcher.js +9 -1
  35. package/src/registry/local.js +1 -1
  36. package/src/utils/index.js +0 -1
  37. 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
- if (desc && !panel.hasAttribute('aria-describedby')) {
96
- panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-dialog-desc'));
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
- // Body scroll lock. Refcounted so nested dialogs unlock in order. Native
165
- // <dialog> does not lock body scroll, only inert-ifies the background.
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
- let scrollLockCount = 0;
169
- let savedOverflow = '';
170
- let savedPaddingRight = '';
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
- if (scrollLockCount === 0) {
174
- const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
175
- savedOverflow = document.body.style.overflow;
176
- savedPaddingRight = document.body.style.paddingRight;
177
- document.body.style.overflow = 'hidden';
178
- if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
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
- scrollLockCount++;
329
+ state.count++;
181
330
  }
182
331
 
183
332
  function unlockScroll(): void {
184
- scrollLockCount = Math.max(0, scrollLockCount - 1);
185
- if (scrollLockCount === 0) {
186
- document.body.style.overflow = savedOverflow;
187
- document.body.style.paddingRight = savedPaddingRight;
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
- lockScroll();
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
- unlockScroll();
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
  }