@w5-ui/svelte 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@w5-ui/svelte",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Svelte 5 components for the Dash UI design system - mirrors the @w5-ui/react API 1:1. Part of Webber's design systems.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -0,0 +1,55 @@
1
+ import type { Action } from 'svelte/action'
2
+
3
+ /**
4
+ * Move a DOM node into `target` (default: `document.body`) for the
5
+ * lifetime of the action. Used by floating UI primitives so
6
+ * `position: fixed` + `z-index` always escape:
7
+ *
8
+ * - ancestor `overflow: hidden` / `overflow: clip` (would clip
9
+ * a non-portalled child even at `position: fixed` if the
10
+ * ancestor establishes a containing block)
11
+ * - ancestor `transform`, `filter`, `perspective`, `contain: paint`,
12
+ * `will-change: transform` (any of which create a containing
13
+ * block for `position: fixed` descendants, defeating viewport
14
+ * positioning)
15
+ * - ancestor stacking contexts with a lower `z-index` than the
16
+ * popover's `z-index`, which would otherwise trap it underneath
17
+ * sibling chrome
18
+ *
19
+ * The node still belongs to the Svelte component that owns it: props,
20
+ * reactivity, lifecycle, and event handlers continue to work; only
21
+ * the DOM position changes. The action removes the node on destroy
22
+ * so cleanup is automatic.
23
+ *
24
+ * SSR-safe: actions don't run during server rendering.
25
+ */
26
+ export const portal: Action<HTMLElement, HTMLElement | string | undefined> = (
27
+ node,
28
+ target = 'body',
29
+ ) => {
30
+ const resolveTarget = (value: HTMLElement | string | undefined): HTMLElement | null => {
31
+ if (value === undefined) return document.body
32
+ if (typeof value === 'string') {
33
+ return document.querySelector<HTMLElement>(value)
34
+ }
35
+ return value
36
+ }
37
+
38
+ const move = (value: HTMLElement | string | undefined) => {
39
+ const parent = resolveTarget(value)
40
+ if (parent !== null && parent !== node.parentNode) {
41
+ parent.appendChild(node)
42
+ }
43
+ }
44
+
45
+ move(target)
46
+
47
+ return {
48
+ update(next: HTMLElement | string | undefined) {
49
+ move(next)
50
+ },
51
+ destroy() {
52
+ node.remove()
53
+ },
54
+ }
55
+ }
@@ -5,6 +5,7 @@
5
5
  <script lang="ts">
6
6
  import { onMount, onDestroy, tick } from 'svelte';
7
7
  import Button from './Button.svelte';
8
+ import { portal } from '../actions/portal.ts';
8
9
 
9
10
  interface Props {
10
11
  label: string;
@@ -111,7 +112,14 @@
111
112
  }
112
113
 
113
114
  function onPointer(e: PointerEvent) {
114
- if (open && !rootEl?.contains(e.target as Node)) open = false;
115
+ if (!open) return;
116
+ const target = e.target as Node;
117
+ // The panel is portalled to <body> so it's no longer a descendant
118
+ // of rootEl. Check both so clicks INSIDE the panel don't count as
119
+ // outside-clicks and immediately close the popover.
120
+ if (rootEl?.contains(target)) return;
121
+ if (panelEl?.contains(target)) return;
122
+ open = false;
115
123
  }
116
124
 
117
125
  function onResize() {
@@ -141,7 +149,7 @@
141
149
  });
142
150
  </script>
143
151
 
144
- <div class="relative inline-block" bind:this={rootEl}>
152
+ <div class="relative inline-flex items-center" bind:this={rootEl}>
145
153
  {#if trigger}
146
154
  {@render trigger({ toggle, open })}
147
155
  {:else}
@@ -158,6 +166,7 @@
158
166
  -->
159
167
  <div
160
168
  bind:this={panelEl}
169
+ use:portal
161
170
  role="dialog"
162
171
  aria-labelledby={title ? titleId : undefined}
163
172
  class="fixed z-[9100] min-w-[200px] max-w-[320px] rounded-[10px] border border-border-3 bg-bg-2 shadow-[0_8px_32px_rgba(0,0,0,0.5)] focus:outline-none"
@@ -1,11 +1,27 @@
1
1
  <script module lang="ts">
2
+ import type { AppLogoKey } from '@w5-ui/assets';
3
+
4
+ /** Logo identifier. Either a built-in `AppLogoKey` (resolved via
5
+ * `@w5-ui/assets`'s `appLogos` map) or a raw URL string — use the
6
+ * latter when the consuming app wants to brand an entry with its
7
+ * own image without having to upstream a new asset into the
8
+ * design system. */
9
+ export type AppLogo = AppLogoKey | (string & {})
2
10
  export interface AppDef { id: string; label: string; logo: AppLogo }
3
11
  export type TopbarStatus = 'ok' | 'warn' | 'danger' | 'neutral';
4
12
  </script>
5
13
 
6
14
  <script lang="ts">
7
15
  import type { Snippet } from 'svelte';
8
- import { appLogos, type AppLogoKey as AppLogo } from '@w5-ui/assets';
16
+ import { appLogos, type AppLogoKey } from '@w5-ui/assets';
17
+
18
+ /** Resolve an `AppLogo` to an `<img src>` URL. Known keys go
19
+ * through the bundled `appLogos` map; anything else is treated as
20
+ * a URL the consumer supplied directly. */
21
+ function resolveLogo(logo: string): string {
22
+ if (logo in appLogos) return appLogos[logo as AppLogoKey]
23
+ return logo
24
+ }
9
25
  import IconButton from './IconButton.svelte';
10
26
  import Avatar from './Avatar.svelte';
11
27
  import CaretIcon from '../icons/CaretIcon.svelte';
@@ -28,6 +44,12 @@
28
44
  notificationCount?: number;
29
45
  /** Health colour of the site-name status ring. Defaults to 'ok' (green). */
30
46
  status?: TopbarStatus;
47
+ /** Optional URL of a square logo (or w5-ui `AppLogoKey`) to render in
48
+ * place of the status dot, next to `siteName`. The status colour
49
+ * becomes a thin ring AROUND the logo so the health signal is
50
+ * preserved without claiming a dedicated dot. Falls back to the
51
+ * classic status-dot layout when undefined. */
52
+ siteLogo?: AppLogo;
31
53
  /** When false, the site label renders as a static element instead of a
32
54
  * site-switcher dropdown trigger. Use for single-site dashboards. */
33
55
  siteSwitchable?: boolean;
@@ -45,6 +67,7 @@
45
67
  initials = 'MS',
46
68
  notificationCount = 1,
47
69
  status = 'ok',
70
+ siteLogo,
48
71
  siteSwitchable = true,
49
72
  actions,
50
73
  onappchange,
@@ -67,11 +90,23 @@
67
90
  danger: 'bg-status-danger',
68
91
  neutral: 'bg-status-neutral',
69
92
  };
93
+ /** Solid-colour ring drawn AROUND the site logo (when `siteLogo` is
94
+ * set) so the health signal stays visible without the dot+halo
95
+ * treatment. Pre-composed for the Tailwind static scanner. */
96
+ const LOGO_RING: Record<TopbarStatus, string> = {
97
+ ok: 'ring-status-success',
98
+ warn: 'ring-status-warning',
99
+ danger: 'ring-status-danger',
100
+ neutral: 'ring-status-neutral',
101
+ };
70
102
 
71
103
  let ringWrapClass = $derived(
72
104
  `inline-flex h-[18px] w-[18px] items-center justify-center rounded-full ${RING_BG[status]}`,
73
105
  );
74
106
  let ringDotClass = $derived(`h-2 w-2 rounded-full ${RING_DOT[status]}`);
107
+ let logoRingClass = $derived(
108
+ `inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-2 ring-inset ${LOGO_RING[status]}`,
109
+ );
75
110
  </script>
76
111
 
77
112
  <!--
@@ -90,14 +125,31 @@
90
125
  class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-row-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05"
91
126
  aria-label="Switch site: {siteName}"
92
127
  aria-haspopup="menu"
128
+ title={`${siteName} — status: ${status}`}
93
129
  >
94
- <span class={ringWrapClass}><span class={ringDotClass}></span></span>
130
+ {#if siteLogo !== undefined}
131
+ <span class={logoRingClass} aria-hidden="true">
132
+ <img src={resolveLogo(siteLogo)} alt="" width="24" height="24" class="h-full w-full object-cover" />
133
+ </span>
134
+ {:else}
135
+ <span class={ringWrapClass}><span class={ringDotClass}></span></span>
136
+ {/if}
95
137
  <span class="text-13 font-medium text-text-1" aria-hidden="true">{siteName}</span>
96
138
  <CaretIcon class="h-3.5 w-3.5 text-text-3" />
97
139
  </button>
98
140
  {:else}
99
- <div class="flex items-center gap-2 px-2.5 py-1.5" role="presentation">
100
- <span class={ringWrapClass}><span class={ringDotClass}></span></span>
141
+ <div
142
+ class="flex items-center gap-2 px-2.5 py-1.5"
143
+ role="presentation"
144
+ title={`${siteName} — status: ${status}`}
145
+ >
146
+ {#if siteLogo !== undefined}
147
+ <span class={logoRingClass} aria-hidden="true">
148
+ <img src={resolveLogo(siteLogo)} alt="" width="24" height="24" class="h-full w-full object-cover" />
149
+ </span>
150
+ {:else}
151
+ <span class={ringWrapClass}><span class={ringDotClass}></span></span>
152
+ {/if}
101
153
  <span class="text-13 font-medium text-text-1">{siteName}</span>
102
154
  </div>
103
155
  {/if}
@@ -114,7 +166,7 @@
114
166
  onclick={() => { activeApp = a.id; onappchange?.(a.id); }}
115
167
  >
116
168
  <img
117
- src={appLogos[a.logo]}
169
+ src={resolveLogo(a.logo)}
118
170
  alt=""
119
171
  width="24"
120
172
  height="24"
package/src/lib/index.ts CHANGED
@@ -308,3 +308,5 @@ export { default as H3 } from './components/H3.svelte'
308
308
  export { default as H4 } from './components/H4.svelte'
309
309
 
310
310
  export * from './icons.js'
311
+
312
+ export { portal } from './actions/portal.ts'