polymorph-ui-components 0.7.0 → 0.9.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.
@@ -1,3 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type ProgressMilestone = {
3
+ value: number;
4
+ label?: string;
5
+ };
1
6
  export type ProgressProperties = MandatoryProgressProperties & OptionalProgressProperties;
2
7
  export type MandatoryProgressProperties = {
3
8
  value: number;
@@ -5,6 +10,10 @@ export type MandatoryProgressProperties = {
5
10
  export type OptionalProgressProperties = {
6
11
  max?: number;
7
12
  showLabel?: boolean;
13
+ ariaLabel?: string;
14
+ valueText?: string;
15
+ milestones?: ProgressMilestone[];
16
+ milestone?: Snippet<[ProgressMilestone, boolean]>;
8
17
  testId?: string;
9
18
  classes?: string;
10
19
  };
@@ -11,6 +11,7 @@
11
11
  searchable = false,
12
12
  placeholder = '',
13
13
  disabled = false,
14
+ ariaLabel,
14
15
  testId,
15
16
  onchange,
16
17
  classes
@@ -240,6 +241,7 @@
240
241
  onclick={handleTriggerClick}
241
242
  onkeydown={handleKeydown}
242
243
  role="combobox"
244
+ aria-label={ariaLabel}
243
245
  aria-expanded={isOpen}
244
246
  aria-haspopup="listbox"
245
247
  aria-controls={listboxId}
@@ -265,6 +267,7 @@
265
267
  onfocus={handleSearchFocus}
266
268
  bind:this={searchInputEl}
267
269
  placeholder={value.length === 0 ? placeholder : ''}
270
+ aria-label={ariaLabel}
268
271
  {disabled}
269
272
  autocomplete="off"
270
273
  tabindex={disabled ? -1 : 0}
@@ -281,6 +284,7 @@
281
284
  onfocus={handleSearchFocus}
282
285
  bind:this={searchInputEl}
283
286
  placeholder={searchPlaceholder}
287
+ aria-label={ariaLabel}
284
288
  {disabled}
285
289
  autocomplete="off"
286
290
  tabindex={disabled ? -1 : 0}
@@ -295,7 +299,13 @@
295
299
  </div>
296
300
 
297
301
  {#if isOpen && !disabled}
298
- <div class="select-dropdown" role="listbox" id={listboxId} aria-multiselectable={multiple}>
302
+ <div
303
+ class="select-dropdown"
304
+ role="listbox"
305
+ id={listboxId}
306
+ aria-label={ariaLabel}
307
+ aria-multiselectable={multiple}
308
+ >
299
309
  {#if filteredItems.length === 0}
300
310
  <div class="select-empty">No results</div>
301
311
  {:else}
@@ -12,6 +12,7 @@ export type OptionalSelectProperties = {
12
12
  searchable?: boolean;
13
13
  placeholder?: string;
14
14
  disabled?: boolean;
15
+ ariaLabel?: string;
15
16
  testId?: string;
16
17
  classes?: string;
17
18
  };
@@ -1,8 +1,11 @@
1
1
  <script lang="ts">
2
2
  import type { SheetProperties } from './properties';
3
3
  import { fly, fade } from 'svelte/transition';
4
+ import { prefersReducedMotion } from 'svelte/motion';
4
5
  import { tick } from 'svelte';
5
6
  import Button from '../Button/Button.svelte';
7
+ import closeSvg from '../assets/close.svg?raw';
8
+ import { deepActiveElement, focusableElements, lockDocumentScroll } from '../utils';
6
9
 
7
10
  let {
8
11
  open = $bindable(false),
@@ -10,31 +13,57 @@
10
13
  title,
11
14
  showOverlay = true,
12
15
  showCloseButton = true,
16
+ closeLabel = 'Close',
13
17
  testId,
14
18
  content,
15
19
  footer,
20
+ closeIcon,
16
21
  onclose,
17
22
  classes
18
23
  }: SheetProperties = $props();
19
24
 
20
25
  let overlayDiv: HTMLDivElement | null = $state(null);
21
26
  let sheetPanel: HTMLDivElement | null = $state(null);
27
+ let openerElement: HTMLElement | null = null;
28
+
29
+ let fadeDuration = $derived(prefersReducedMotion.current ? 0 : 200);
22
30
 
23
31
  let flyParams = $derived.by(() => {
32
+ const duration = prefersReducedMotion.current ? 0 : 300;
24
33
  switch (side) {
25
34
  case 'left':
26
- return { x: -400, y: 0, duration: 300 };
35
+ return { x: '-100%', duration };
27
36
  case 'right':
28
- return { x: 400, y: 0, duration: 300 };
37
+ return { x: '100%', duration };
29
38
  case 'top':
30
- return { x: 0, y: -400, duration: 300 };
39
+ return { y: '-100%', duration };
31
40
  case 'bottom':
32
- return { x: 0, y: 400, duration: 300 };
41
+ return { y: '100%', duration };
33
42
  }
34
43
  });
35
44
 
45
+ function enter() {
46
+ if (openerElement === null) {
47
+ const active = deepActiveElement();
48
+ openerElement = active instanceof HTMLElement ? active : null;
49
+ }
50
+ tick().then(() => {
51
+ if (sheetPanel !== null) {
52
+ sheetPanel.focus();
53
+ }
54
+ });
55
+ }
56
+
57
+ function restoreFocus() {
58
+ if (openerElement !== null && openerElement.isConnected) {
59
+ openerElement.focus();
60
+ }
61
+ openerElement = null;
62
+ }
63
+
36
64
  function close() {
37
65
  open = false;
66
+ restoreFocus();
38
67
  onclose?.();
39
68
  }
40
69
 
@@ -44,65 +73,77 @@
44
73
  }
45
74
  }
46
75
 
47
- function handleKeyDown(event: KeyboardEvent) {
48
- if (event.key === 'Escape') {
49
- close();
76
+ function trapTab(event: KeyboardEvent) {
77
+ if (sheetPanel === null) {
50
78
  return;
51
79
  }
80
+ const focusable = focusableElements(sheetPanel);
81
+ const first = focusable.at(0);
82
+ const last = focusable.at(-1);
52
83
 
53
- if (event.key === 'Tab' && sheetPanel !== null) {
54
- const focusable = sheetPanel.querySelectorAll<HTMLElement>(
55
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
56
- );
57
- const first = focusable.item(0);
58
- const last = focusable.item(focusable.length - 1);
59
-
60
- if (first === null || last === null) {
61
- return;
62
- }
84
+ if (!(first instanceof HTMLElement) || !(last instanceof HTMLElement)) {
85
+ event.preventDefault();
86
+ sheetPanel.focus();
87
+ return;
88
+ }
63
89
 
64
- const atEdge = document.activeElement === (event.shiftKey ? first : last);
65
- if (atEdge) {
66
- event.preventDefault();
67
- (event.shiftKey ? last : first).focus();
68
- }
90
+ const active = deepActiveElement();
91
+ const inside = active === sheetPanel || focusable.some((element) => element === active);
92
+ if (!inside) {
93
+ event.preventDefault();
94
+ (event.shiftKey ? last : first).focus();
95
+ } else if (event.shiftKey && (active === first || active === sheetPanel)) {
96
+ event.preventDefault();
97
+ last.focus();
98
+ } else if (!event.shiftKey && active === last) {
99
+ event.preventDefault();
100
+ first.focus();
69
101
  }
70
102
  }
71
103
 
72
- function lockScroll() {
73
- document.body.style.overflow = 'hidden';
104
+ function handleKeyDown(event: KeyboardEvent) {
105
+ if (event.key === 'Escape') {
106
+ event.preventDefault();
107
+ close();
108
+ } else if (event.key === 'Tab') {
109
+ trapTab(event);
110
+ }
74
111
  }
75
112
 
76
- function unlockScroll() {
77
- document.body.style.overflow = '';
113
+ function handleWindowKeyDown(event: KeyboardEvent) {
114
+ if (!open || event.defaultPrevented) {
115
+ return;
116
+ }
117
+ if (overlayDiv !== null && event.composedPath().includes(overlayDiv)) {
118
+ return;
119
+ }
120
+ handleKeyDown(event);
78
121
  }
79
122
 
80
- function scrollLockAction(_node: HTMLElement) {
81
- lockScroll();
82
- tick().then(() => {
83
- if (sheetPanel !== null) {
84
- sheetPanel.focus();
85
- }
86
- });
123
+ function sheetAction(_node: HTMLElement) {
124
+ const unlockScroll = lockDocumentScroll();
125
+ enter();
87
126
  return {
88
127
  destroy() {
89
128
  unlockScroll();
129
+ restoreFocus();
90
130
  }
91
131
  };
92
132
  }
93
133
  </script>
94
134
 
135
+ <svelte:window onkeydown={handleWindowKeyDown} />
136
+
95
137
  {#if open}
96
138
  <div
97
139
  bind:this={overlayDiv}
98
- use:scrollLockAction
140
+ use:sheetAction
99
141
  class="sheet-overlay {showOverlay ? 'overlay-active' : 'overlay-inactive'} {classes ?? ''}"
100
142
  onclick={handleOverlayClick}
101
143
  onkeydown={handleKeyDown}
102
- role="button"
103
- tabindex="-1"
144
+ role="presentation"
104
145
  data-pw={testId}
105
- transition:fade={{ duration: 200 }}
146
+ transition:fade={{ duration: fadeDuration }}
106
147
  >
107
148
  <div
108
149
  bind:this={sheetPanel}
@@ -112,20 +153,26 @@
112
153
  aria-label={title ?? 'Sheet'}
113
154
  tabindex="-1"
114
155
  transition:fly|global={flyParams}
156
+ onintrostart={enter}
115
157
  >
116
158
  {#if typeof title === 'string' || showCloseButton}
117
159
  <div class="sheet-header">
118
160
  {#if typeof title === 'string'}
119
- <span class="sheet-title">{title}</span>
161
+ <h2 class="sheet-title">{title}</h2>
120
162
  {/if}
121
163
  {#if showCloseButton}
122
164
  <div class="sheet-close-button">
123
165
  <Button
124
166
  onclick={close}
125
- ariaLabel="Close"
167
+ ariaLabel={closeLabel}
126
168
  {...typeof testId === 'string' ? { testId: `${testId}-close` } : {}}
127
169
  >
128
- &#x2715;
170
+ {#if typeof closeIcon === 'function'}
171
+ {@render closeIcon()}
172
+ {:else}
173
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
174
+ {@html closeSvg}
175
+ {/if}
129
176
  </Button>
130
177
  </div>
131
178
  {/if}
@@ -213,6 +260,8 @@
213
260
  .sheet-header {
214
261
  display: flex;
215
262
  align-items: center;
263
+ justify-content: flex-end;
264
+ gap: var(--sheet-header-gap, 8px);
216
265
  padding: var(--sheet-header-padding, 16px 20px);
217
266
  background-color: var(--sheet-header-background, inherit);
218
267
  border-bottom: var(--sheet-header-border-bottom, 1px solid #e4e4e7);
@@ -221,6 +270,7 @@
221
270
 
222
271
  .sheet-title {
223
272
  flex: 1;
273
+ margin: var(--sheet-title-margin, 0);
224
274
  font-size: var(--sheet-title-font-size, 18px);
225
275
  font-weight: var(--sheet-title-font-weight, 600);
226
276
  font-family: var(--sheet-title-font-family, inherit);
@@ -244,9 +294,15 @@
244
294
  justify-content: center;
245
295
  }
246
296
 
297
+ .sheet-close-button :global(svg) {
298
+ width: var(--sheet-close-icon-size, var(--sheet-close-button-font-size, 16px));
299
+ height: var(--sheet-close-icon-size, var(--sheet-close-button-font-size, 16px));
300
+ }
301
+
247
302
  .sheet-content {
248
303
  flex: 1;
249
304
  overflow-y: var(--sheet-content-overflow-y, auto);
305
+ overscroll-behavior: var(--sheet-content-overscroll-behavior, contain);
250
306
  padding: var(--sheet-content-padding, 20px);
251
307
  scrollbar-width: var(--sheet-scrollbar-width, none);
252
308
  }
@@ -10,8 +10,10 @@ export type OptionalSheetProperties = {
10
10
  title?: string;
11
11
  showOverlay?: boolean;
12
12
  showCloseButton?: boolean;
13
+ closeLabel?: string;
13
14
  testId?: string;
14
15
  footer?: Snippet;
16
+ closeIcon?: Snippet;
15
17
  classes?: string;
16
18
  };
17
19
  export type SheetEventProperties = {
package/dist/index.d.ts CHANGED
@@ -65,6 +65,7 @@ export { default as Resizable } from './Resizable/Resizable.svelte';
65
65
  export { default as Draggable } from './Draggable/Draggable.svelte';
66
66
  export { default as ChatBubble } from './ChatBubble/ChatBubble.svelte';
67
67
  export { default as Gallery } from './Gallery/Gallery.svelte';
68
+ export { default as NumberStepper } from './NumberStepper/NumberStepper.svelte';
68
69
  export { ChatController } from './Chat/controller.svelte';
69
70
  export { partyOf } from './Chat/roles';
70
71
  export type * from './Button/properties';
@@ -129,4 +130,5 @@ export type * from './Resizable/properties';
129
130
  export type * from './Draggable/properties';
130
131
  export type * from './ChatBubble/properties';
131
132
  export type * from './Gallery/properties';
133
+ export type * from './NumberStepper/properties';
132
134
  export { validateInput } from './utils';
package/dist/index.js CHANGED
@@ -65,6 +65,7 @@ export { default as Resizable } from './Resizable/Resizable.svelte';
65
65
  export { default as Draggable } from './Draggable/Draggable.svelte';
66
66
  export { default as ChatBubble } from './ChatBubble/ChatBubble.svelte';
67
67
  export { default as Gallery } from './Gallery/Gallery.svelte';
68
+ export { default as NumberStepper } from './NumberStepper/NumberStepper.svelte';
68
69
  export { ChatController } from './Chat/controller.svelte';
69
70
  export { partyOf } from './Chat/roles';
70
71
  export { validateInput } from './utils';
package/dist/utils.d.ts CHANGED
@@ -26,3 +26,23 @@ export declare function hslToHsv(hDeg: number, sPct: number, lPct: number): Hsv;
26
26
  export declare function isValidHex(hex: string): boolean;
27
27
  export declare function clampInt(v: string, min: number, max: number): number;
28
28
  export declare function createDebouncer(delay: number): <T extends unknown[]>(callback: (...args: T) => void, ...args: T) => void;
29
+ /**
30
+ * @description The focused element in the same document or shadow root as `node`.
31
+ * `document.activeElement` stops at the shadow host, so focus checks inside web components need this.
32
+ */
33
+ export declare function activeElementOf(node: Node): Element | null;
34
+ /**
35
+ * @description The focused element, following open shadow roots down to the innermost one.
36
+ */
37
+ export declare function deepActiveElement(): Element | null;
38
+ /**
39
+ * @description Focusable elements inside `container`, in document order across the composed tree:
40
+ * content assigned to slots and open shadow roots are included, inert, hidden and undisplayed elements are not.
41
+ */
42
+ export declare function focusableElements(container: Element): HTMLElement[];
43
+ /**
44
+ * @description Stops the page behind an overlay from scrolling and returns the release function.
45
+ * Locks are counted, so closing a nested overlay keeps the page locked until the last one closes,
46
+ * and releasing restores whatever inline overflow the page had before.
47
+ */
48
+ export declare function lockDocumentScroll(): () => void;
package/dist/utils.js CHANGED
@@ -292,3 +292,110 @@ export function createDebouncer(delay) {
292
292
  }
293
293
  };
294
294
  }
295
+ // ── Focus & scrolling ───────────────────────────────────────────
296
+ /**
297
+ * @description The focused element in the same document or shadow root as `node`.
298
+ * `document.activeElement` stops at the shadow host, so focus checks inside web components need this.
299
+ */
300
+ export function activeElementOf(node) {
301
+ const root = node.getRootNode();
302
+ if (root instanceof Document || root instanceof ShadowRoot) {
303
+ return root.activeElement;
304
+ }
305
+ return null;
306
+ }
307
+ /**
308
+ * @description The focused element, following open shadow roots down to the innermost one.
309
+ */
310
+ export function deepActiveElement() {
311
+ let active = document.activeElement;
312
+ while (active !== null &&
313
+ active.shadowRoot !== null &&
314
+ active.shadowRoot.activeElement !== null) {
315
+ active = active.shadowRoot.activeElement;
316
+ }
317
+ return active;
318
+ }
319
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
320
+ function isRendered(element) {
321
+ return typeof element.checkVisibility !== 'function' || element.checkVisibility();
322
+ }
323
+ /**
324
+ * @description Focusable elements inside `container`, in document order across the composed tree:
325
+ * content assigned to slots and open shadow roots are included, inert, hidden and undisplayed elements are not.
326
+ */
327
+ export function focusableElements(container) {
328
+ const found = [];
329
+ const visit = (element) => {
330
+ if (element.hasAttribute('inert') || element.hasAttribute('hidden')) {
331
+ return;
332
+ }
333
+ if (element instanceof HTMLSlotElement) {
334
+ const assigned = element.assignedElements({ flatten: true });
335
+ (assigned.length > 0 ? assigned : Array.from(element.children)).forEach(visit);
336
+ return;
337
+ }
338
+ if (element instanceof HTMLElement &&
339
+ element.matches(FOCUSABLE_SELECTOR) &&
340
+ isRendered(element)) {
341
+ found.push(element);
342
+ }
343
+ Array.from((element.shadowRoot ?? element).children).forEach(visit);
344
+ };
345
+ Array.from(container.children).forEach(visit);
346
+ return found;
347
+ }
348
+ let scrollLocks = 0;
349
+ let releaseDocumentScroll = null;
350
+ function lockOverflow(element) {
351
+ const { style } = element;
352
+ const saved = ['overflow-x', 'overflow-y'].map((property) => ({
353
+ property,
354
+ value: style.getPropertyValue(property),
355
+ priority: style.getPropertyPriority(property)
356
+ }));
357
+ saved.forEach(({ property }) => style.setProperty(property, 'hidden', 'important'));
358
+ return () => {
359
+ saved.forEach(({ property, value, priority }) => {
360
+ if (value === '') {
361
+ style.removeProperty(property);
362
+ }
363
+ else {
364
+ style.setProperty(property, value, priority);
365
+ }
366
+ });
367
+ };
368
+ }
369
+ /**
370
+ * @description The element whose overflow scrolls the page: `<body>` unless the page gives `<html>` an overflow.
371
+ * Hiding overflow on the other one would turn it into a scroll container and unstick sticky headers.
372
+ */
373
+ function viewportOverflowElement() {
374
+ const root = document.documentElement;
375
+ const { overflowX, overflowY } = getComputedStyle(root);
376
+ const rootIsVisible = [overflowX, overflowY].every((value) => value === 'visible' || value === '');
377
+ return rootIsVisible ? document.body : root;
378
+ }
379
+ /**
380
+ * @description Stops the page behind an overlay from scrolling and returns the release function.
381
+ * Locks are counted, so closing a nested overlay keeps the page locked until the last one closes,
382
+ * and releasing restores whatever inline overflow the page had before.
383
+ */
384
+ export function lockDocumentScroll() {
385
+ if (scrollLocks === 0) {
386
+ releaseDocumentScroll = lockOverflow(viewportOverflowElement());
387
+ }
388
+ scrollLocks += 1;
389
+ let released = false;
390
+ return () => {
391
+ if (released) {
392
+ return;
393
+ }
394
+ released = true;
395
+ scrollLocks -= 1;
396
+ if (scrollLocks === 0) {
397
+ releaseDocumentScroll?.();
398
+ releaseDocumentScroll = null;
399
+ }
400
+ };
401
+ }