@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
|
@@ -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
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
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={
|
|
169
|
+
src={resolveLogo(a.logo)}
|
|
118
170
|
alt=""
|
|
119
171
|
width="24"
|
|
120
172
|
height="24"
|