@runwisp/ui 0.9.0 → 0.11.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.
@@ -14,7 +14,9 @@
14
14
  onClose?: () => void;
15
15
  header?: Snippet;
16
16
  footer?: Snippet;
17
- children?: Snippet;
17
+ // Explicitly nullable so callers can pass `children={cond ? snip : undefined}`
18
+ // to suppress the padded body band entirely when there's nothing to show.
19
+ children?: Snippet | undefined;
18
20
  class?: string;
19
21
  }
20
22
 
@@ -4,15 +4,19 @@
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
6
  import type { Placement } from "@floating-ui/dom";
7
- import { tick, onDestroy } from "svelte";
7
+ import { onDestroy } from "svelte";
8
8
  import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
9
- import { scale } from "svelte/transition";
9
+ import { scale, fade } from "svelte/transition";
10
10
  import { quintOut } from "svelte/easing";
11
11
  import { portal } from "../actions/portal.js";
12
12
 
13
13
  interface Props {
14
14
  open?: boolean;
15
15
  placement?: Placement;
16
+ // Opt-in: below the `md` breakpoint, render the content as a bottom
17
+ // sheet (full-width, pinned to the viewport bottom, dimmed scrim behind)
18
+ // instead of a floating popover. Leaves desktop behavior untouched.
19
+ mobileSheet?: boolean;
16
20
  trigger?: Snippet;
17
21
  children?: Snippet;
18
22
  class?: string;
@@ -21,6 +25,7 @@
21
25
  let {
22
26
  open = $bindable(false),
23
27
  placement = "bottom",
28
+ mobileSheet = false,
24
29
  trigger,
25
30
  children,
26
31
  class: className = "",
@@ -30,20 +35,38 @@
30
35
  let contentEl = $state<HTMLElement | null>(null);
31
36
  let cleanupFloating = $state<(() => void) | null>(null);
32
37
 
33
- async function setupFloating() {
34
- if (!triggerEl || !contentEl) return;
38
+ // Track the `md` breakpoint only when sheet mode is requested, so the
39
+ // content can switch between floating (desktop) and bottom sheet (phone)
40
+ // live on resize. matchMedia is client-only; the effect never runs on SSR.
41
+ let isDesktop = $state(true);
42
+ $effect(() => {
43
+ if (!mobileSheet) return;
44
+ const mq = window.matchMedia("(min-width: 768px)");
45
+ isDesktop = mq.matches;
46
+ const onChange = (e: MediaQueryListEvent) => (isDesktop = e.matches);
47
+ mq.addEventListener("change", onChange);
48
+ return () => mq.removeEventListener("change", onChange);
49
+ });
50
+
51
+ const asSheet = $derived(mobileSheet && !isDesktop);
52
+
53
+ function teardown() {
54
+ cleanupFloating?.();
55
+ cleanupFloating = null;
56
+ }
35
57
 
58
+ function setupFloating() {
59
+ if (!triggerEl || !contentEl) return;
36
60
  cleanupFloating = autoUpdate(triggerEl, contentEl, () => {
37
61
  if (!triggerEl || !contentEl) return;
38
-
39
62
  void computePosition(triggerEl, contentEl, {
40
63
  placement,
41
64
  middleware: [offset(8), flip(), shift({ padding: 8 })],
42
65
  }).then(({ x, y }) => {
43
66
  if (contentEl) {
44
67
  Object.assign(contentEl.style, {
45
- left: `${x}px`,
46
- top: `${y}px`,
68
+ left: `${String(x)}px`,
69
+ top: `${String(y)}px`,
47
70
  position: "absolute",
48
71
  });
49
72
  }
@@ -51,40 +74,40 @@
51
74
  });
52
75
  }
53
76
 
77
+ // Position the content while open: float it on desktop, or pin it as a
78
+ // sheet on phone (clearing any inline coords floating-ui left behind).
79
+ $effect(() => {
80
+ if (!open || !contentEl) {
81
+ teardown();
82
+ return;
83
+ }
84
+ if (asSheet) {
85
+ teardown();
86
+ contentEl.style.removeProperty("left");
87
+ contentEl.style.removeProperty("top");
88
+ contentEl.style.removeProperty("position");
89
+ return;
90
+ }
91
+ setupFloating();
92
+ return teardown;
93
+ });
94
+
54
95
  function toggle() {
55
96
  open = !open;
56
- if (open) {
57
- void tick().then(() => {
58
- void setupFloating();
59
- });
60
- } else {
61
- cleanupFloating?.();
62
- cleanupFloating = null;
63
- }
64
97
  }
65
98
 
66
99
  function handleOutsideClick(e: MouseEvent) {
67
100
  const path = e.composedPath();
68
101
  if (triggerEl && path.includes(triggerEl)) return;
69
102
  if (contentEl && path.includes(contentEl)) return;
70
- if (open) {
71
- open = false;
72
- cleanupFloating?.();
73
- cleanupFloating = null;
74
- }
103
+ if (open) open = false;
75
104
  }
76
105
 
77
106
  function handleKeyDown(e: KeyboardEvent) {
78
- if (e.key === "Escape" && open) {
79
- open = false;
80
- cleanupFloating?.();
81
- cleanupFloating = null;
82
- }
107
+ if (e.key === "Escape" && open) open = false;
83
108
  }
84
109
 
85
- onDestroy(() => {
86
- cleanupFloating?.();
87
- });
110
+ onDestroy(teardown);
88
111
  </script>
89
112
 
90
113
  <svelte:window onclick={handleOutsideClick} onkeydown={handleKeyDown} />
@@ -112,14 +135,24 @@
112
135
  </div>
113
136
 
114
137
  {#if open}
138
+ {#if asSheet}
139
+ <div
140
+ use:portal
141
+ transition:fade={{ duration: 120 }}
142
+ class="fixed inset-0 z-[9998] bg-on-surface/40"
143
+ aria-hidden="true"
144
+ ></div>
145
+ {/if}
115
146
  <div
116
147
  use:portal
117
148
  bind:this={contentEl}
118
149
  transition:scale={{ duration: 150, start: 0.95, easing: quintOut }}
119
150
  class="
120
- z-[9999]
121
- rounded-xl
122
- border border-outline bg-surface-overlay p-4 shadow-xl shadow-on-surface/5
151
+ z-[9999] max-h-[80vh] overflow-y-auto
152
+ border border-outline bg-surface-overlay shadow-xl shadow-on-surface/5
153
+ {asSheet
154
+ ? 'fixed inset-x-0 bottom-0 max-h-[85vh] rounded-t-2xl border-x-0 border-b-0 p-5'
155
+ : 'rounded-xl p-4'}
123
156
  "
124
157
  >
125
158
  {#if children}