@w5-ui/svelte 0.3.0 → 0.5.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 +3 -3
- package/src/lib/actions/portal.ts +55 -0
- package/src/lib/components/ContextMenu.svelte +39 -2
- package/src/lib/components/Modal.svelte +20 -1
- package/src/lib/components/Popover.svelte +33 -12
- package/src/lib/components/Topbar.svelte +95 -5
- package/src/lib/components/popover-position.ts +92 -0
- package/src/lib/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@w5-ui/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@w5-ui/assets": "^0.2.1",
|
|
48
|
-
"@w5-ui/tokens": "^0.
|
|
48
|
+
"@w5-ui/tokens": "^0.5.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"svelte": "^5.55.
|
|
51
|
+
"svelte": "^5.55.9",
|
|
52
52
|
"typescript": "^5.5.0"
|
|
53
53
|
},
|
|
54
54
|
"files": [
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
+
export type ContextMenuPillVariant = 'success' | 'warn' | 'danger' | 'info' | 'neutral';
|
|
3
|
+
/** Inline badge rendered where the label's `{pill}` placeholder sits. */
|
|
4
|
+
export interface ContextMenuPill {
|
|
5
|
+
text: string;
|
|
6
|
+
variant?: ContextMenuPillVariant;
|
|
7
|
+
}
|
|
2
8
|
export interface ContextMenuItem {
|
|
3
9
|
id: string;
|
|
10
|
+
/** Item copy. May contain a single `{pill}` placeholder slot which is
|
|
11
|
+
* replaced by an inline {@link ContextMenuPill} when `pill` is set. */
|
|
4
12
|
label: string;
|
|
13
|
+
/** Inline badge substituted for the `{pill}` slot in `label`. */
|
|
14
|
+
pill?: ContextMenuPill;
|
|
15
|
+
/** Red tone (destructive). Mutually exclusive with `warning`. */
|
|
5
16
|
danger?: boolean;
|
|
17
|
+
/** Yellow tone (caution / heads-up). Mutually exclusive with `danger`. */
|
|
18
|
+
warning?: boolean;
|
|
19
|
+
/** Green tone (active / positive state). Mutually exclusive with `danger` + `warning`. */
|
|
20
|
+
success?: boolean;
|
|
6
21
|
disabled?: boolean;
|
|
7
22
|
}
|
|
8
23
|
export type ContextMenuEntry = ContextMenuItem | { separator: true };
|
|
@@ -10,6 +25,22 @@
|
|
|
10
25
|
|
|
11
26
|
<script lang="ts">
|
|
12
27
|
import { onDestroy, tick } from 'svelte';
|
|
28
|
+
import Pill from './Pill.svelte';
|
|
29
|
+
|
|
30
|
+
const PILL_SLOT = '{pill}';
|
|
31
|
+
|
|
32
|
+
/** Split a label around its first `{pill}` slot so the pill can be
|
|
33
|
+
* rendered inline between the surrounding copy. Returns null when the
|
|
34
|
+
* item has no pill or no slot, signalling a plain-text label. */
|
|
35
|
+
function pillParts(entry: ContextMenuItem): { before: string; after: string } | null {
|
|
36
|
+
if (entry.pill === undefined) return null;
|
|
37
|
+
const idx = entry.label.indexOf(PILL_SLOT);
|
|
38
|
+
if (idx === -1) return null;
|
|
39
|
+
return {
|
|
40
|
+
before: entry.label.slice(0, idx),
|
|
41
|
+
after: entry.label.slice(idx + PILL_SLOT.length),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
13
44
|
|
|
14
45
|
interface Props {
|
|
15
46
|
items?: ContextMenuEntry[];
|
|
@@ -110,10 +141,16 @@
|
|
|
110
141
|
aria-disabled={entry.disabled || undefined}
|
|
111
142
|
data-active={actionIndex(entry) === activeIdx ? 'true' : undefined}
|
|
112
143
|
data-danger={entry.danger || undefined}
|
|
113
|
-
|
|
144
|
+
data-warning={entry.warning || undefined}
|
|
145
|
+
data-success={entry.success || undefined}
|
|
146
|
+
class="flex cursor-pointer items-center whitespace-nowrap rounded-[5px] px-3 py-1.5 text-13 text-text-2 outline-none hover:bg-row-active hover:text-text-1 data-[active=true]:bg-row-active data-[active=true]:text-text-1 data-[danger=true]:text-status-danger data-[danger=true]:hover:bg-status-danger/10 data-[warning=true]:text-status-warning data-[warning=true]:hover:bg-status-warning/10 data-[success=true]:text-status-success data-[success=true]:hover:bg-status-success/10 aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
|
|
114
147
|
onmouseenter={() => (activeIdx = actionIndex(entry))}
|
|
115
148
|
onmousedown={(e) => { e.preventDefault(); (() => { if (!entry.disabled) activate(entry.id); })(); }}
|
|
116
|
-
>{entry
|
|
149
|
+
>{#if pillParts(entry)}{@const parts = pillParts(entry)}{parts?.before}<Pill
|
|
150
|
+
variant={entry.pill?.variant ?? 'neutral'}
|
|
151
|
+
showDot={false}
|
|
152
|
+
class="mx-0.5 !px-1.5 !py-0 text-[10px] font-semibold leading-[1]"
|
|
153
|
+
>{entry.pill?.text}</Pill>{parts?.after}{:else}{entry.label}{/if}</li>
|
|
117
154
|
{/if}
|
|
118
155
|
{/each}
|
|
119
156
|
</ul>
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
1
5
|
<script lang="ts">
|
|
2
6
|
import { tick } from 'svelte';
|
|
3
7
|
import IconButton from './IconButton.svelte';
|
|
@@ -5,6 +9,9 @@
|
|
|
5
9
|
interface Props {
|
|
6
10
|
open?: boolean;
|
|
7
11
|
title: string;
|
|
12
|
+
/** Panel width. `md` (520px) is the default; larger sizes suit
|
|
13
|
+
* data-dense or editor-style dialogs. Always capped at 90vw. */
|
|
14
|
+
size?: ModalSize;
|
|
8
15
|
children?: import('svelte').Snippet;
|
|
9
16
|
footer?: import('svelte').Snippet;
|
|
10
17
|
}
|
|
@@ -12,9 +19,21 @@
|
|
|
12
19
|
let {
|
|
13
20
|
open = $bindable(false),
|
|
14
21
|
title,
|
|
22
|
+
size = 'md',
|
|
15
23
|
children,
|
|
16
24
|
footer
|
|
17
25
|
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
// Pre-composed width utilities so Tailwind's static scanner keeps
|
|
28
|
+
// each class in the build. The React sibling expresses the same
|
|
29
|
+
// scale via `.modal--*` rules in dashboard.css.
|
|
30
|
+
const SIZE: Record<ModalSize, string> = {
|
|
31
|
+
sm: 'w-[400px]',
|
|
32
|
+
md: 'w-[520px]',
|
|
33
|
+
lg: 'w-[720px]',
|
|
34
|
+
xl: 'w-[960px]',
|
|
35
|
+
'2xl': 'w-[1200px]'
|
|
36
|
+
};
|
|
18
37
|
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
|
|
19
38
|
const FOCUSABLE =
|
|
20
39
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
@@ -90,7 +109,7 @@
|
|
|
90
109
|
>
|
|
91
110
|
<div
|
|
92
111
|
bind:this={modalEl}
|
|
93
|
-
class="
|
|
112
|
+
class="{SIZE[size]} max-w-[90vw] overflow-hidden rounded-xl border border-border-2 bg-bg-1 shadow-modal"
|
|
94
113
|
role="dialog"
|
|
95
114
|
aria-modal="true"
|
|
96
115
|
aria-labelledby={titleId}
|
|
@@ -5,6 +5,8 @@
|
|
|
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';
|
|
9
|
+
import { computePanelPosition } from './popover-position.ts';
|
|
8
10
|
|
|
9
11
|
interface Props {
|
|
10
12
|
label: string;
|
|
@@ -66,17 +68,28 @@
|
|
|
66
68
|
function recomputePosition() {
|
|
67
69
|
if (!triggerEl || !panelEl) return;
|
|
68
70
|
const tr = triggerEl.getBoundingClientRect();
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
// Delegate the actual math to a pure helper so the auto-flip
|
|
72
|
+
// rule (panel goes above the trigger when below would overflow
|
|
73
|
+
// AND above has more room) is unit-testable without mounting
|
|
74
|
+
// the component. See `popover-position.ts`.
|
|
75
|
+
const { top, left } = computePanelPosition({
|
|
76
|
+
triggerRect: {
|
|
77
|
+
top: tr.top,
|
|
78
|
+
bottom: tr.bottom,
|
|
79
|
+
left: tr.left,
|
|
80
|
+
right: tr.right,
|
|
81
|
+
width: tr.width,
|
|
82
|
+
},
|
|
83
|
+
panelWidth: panelEl.offsetWidth,
|
|
84
|
+
panelHeight: panelEl.offsetHeight,
|
|
85
|
+
viewportWidth: window.innerWidth,
|
|
86
|
+
viewportHeight: window.innerHeight,
|
|
87
|
+
placement,
|
|
88
|
+
margin: 8,
|
|
89
|
+
offset: 6,
|
|
90
|
+
});
|
|
91
|
+
panelTop = top;
|
|
78
92
|
panelLeft = left;
|
|
79
|
-
panelTop = tr.bottom + offset;
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
async function toggle() {
|
|
@@ -111,7 +124,14 @@
|
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
function onPointer(e: PointerEvent) {
|
|
114
|
-
if (open
|
|
127
|
+
if (!open) return;
|
|
128
|
+
const target = e.target as Node;
|
|
129
|
+
// The panel is portalled to <body> so it's no longer a descendant
|
|
130
|
+
// of rootEl. Check both so clicks INSIDE the panel don't count as
|
|
131
|
+
// outside-clicks and immediately close the popover.
|
|
132
|
+
if (rootEl?.contains(target)) return;
|
|
133
|
+
if (panelEl?.contains(target)) return;
|
|
134
|
+
open = false;
|
|
115
135
|
}
|
|
116
136
|
|
|
117
137
|
function onResize() {
|
|
@@ -141,7 +161,7 @@
|
|
|
141
161
|
});
|
|
142
162
|
</script>
|
|
143
163
|
|
|
144
|
-
<div class="relative inline-
|
|
164
|
+
<div class="relative inline-flex items-center" bind:this={rootEl}>
|
|
145
165
|
{#if trigger}
|
|
146
166
|
{@render trigger({ toggle, open })}
|
|
147
167
|
{:else}
|
|
@@ -158,6 +178,7 @@
|
|
|
158
178
|
-->
|
|
159
179
|
<div
|
|
160
180
|
bind:this={panelEl}
|
|
181
|
+
use:portal
|
|
161
182
|
role="dialog"
|
|
162
183
|
aria-labelledby={title ? titleId : undefined}
|
|
163
184
|
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,45 @@
|
|
|
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
|
+
);
|
|
110
|
+
|
|
111
|
+
// Status-coloured glow drawn behind the site logo. Replaces the old
|
|
112
|
+
// "N down" headline pill as the at-a-glance health signal: green when
|
|
113
|
+
// healthy, amber when degraded, red when something is down. Rendered
|
|
114
|
+
// as a blurred radial puddle behind the logo so the colour bleeds
|
|
115
|
+
// softly past the rim rather than reading as a hard second ring.
|
|
116
|
+
// Neutral (no data yet) gets no glow so a cold-start chrome stays calm.
|
|
117
|
+
const GLOW_TOKEN: Record<TopbarStatus, string | null> = {
|
|
118
|
+
ok: '--status-success',
|
|
119
|
+
warn: '--status-warning',
|
|
120
|
+
danger: '--status-danger',
|
|
121
|
+
neutral: null,
|
|
122
|
+
};
|
|
123
|
+
let logoGlowStyle = $derived.by(() => {
|
|
124
|
+
const token = GLOW_TOKEN[status];
|
|
125
|
+
if (token === null) return 'opacity: 0;';
|
|
126
|
+
const colour = `var(${token})`;
|
|
127
|
+
return (
|
|
128
|
+
`background: radial-gradient(closest-side, ${colour}, transparent);` +
|
|
129
|
+
' opacity: 0.85; filter: blur(5px);'
|
|
130
|
+
);
|
|
131
|
+
});
|
|
75
132
|
</script>
|
|
76
133
|
|
|
77
134
|
<!--
|
|
@@ -90,14 +147,47 @@
|
|
|
90
147
|
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
148
|
aria-label="Switch site: {siteName}"
|
|
92
149
|
aria-haspopup="menu"
|
|
150
|
+
title={`${siteName} — status: ${status}`}
|
|
93
151
|
>
|
|
94
|
-
|
|
152
|
+
{#if siteLogo !== undefined}
|
|
153
|
+
<span class="relative inline-flex h-6 w-6 items-center justify-center">
|
|
154
|
+
<span
|
|
155
|
+
data-testid="site-logo-glow"
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
class="pointer-events-none absolute -inset-1.5 rounded-full transition-opacity duration-300"
|
|
158
|
+
style={logoGlowStyle}
|
|
159
|
+
></span>
|
|
160
|
+
<span class={`relative ${logoRingClass}`} aria-hidden="true">
|
|
161
|
+
<img src={resolveLogo(siteLogo)} alt="" width="24" height="24" class="h-full w-full object-cover" />
|
|
162
|
+
</span>
|
|
163
|
+
</span>
|
|
164
|
+
{:else}
|
|
165
|
+
<span class={ringWrapClass}><span class={ringDotClass}></span></span>
|
|
166
|
+
{/if}
|
|
95
167
|
<span class="text-13 font-medium text-text-1" aria-hidden="true">{siteName}</span>
|
|
96
168
|
<CaretIcon class="h-3.5 w-3.5 text-text-3" />
|
|
97
169
|
</button>
|
|
98
170
|
{:else}
|
|
99
|
-
<div
|
|
100
|
-
|
|
171
|
+
<div
|
|
172
|
+
class="flex items-center gap-2 px-2.5 py-1.5"
|
|
173
|
+
role="presentation"
|
|
174
|
+
title={`${siteName} — status: ${status}`}
|
|
175
|
+
>
|
|
176
|
+
{#if siteLogo !== undefined}
|
|
177
|
+
<span class="relative inline-flex h-6 w-6 items-center justify-center">
|
|
178
|
+
<span
|
|
179
|
+
data-testid="site-logo-glow"
|
|
180
|
+
aria-hidden="true"
|
|
181
|
+
class="pointer-events-none absolute -inset-1.5 rounded-full transition-opacity duration-300"
|
|
182
|
+
style={logoGlowStyle}
|
|
183
|
+
></span>
|
|
184
|
+
<span class={`relative ${logoRingClass}`} aria-hidden="true">
|
|
185
|
+
<img src={resolveLogo(siteLogo)} alt="" width="24" height="24" class="h-full w-full object-cover" />
|
|
186
|
+
</span>
|
|
187
|
+
</span>
|
|
188
|
+
{:else}
|
|
189
|
+
<span class={ringWrapClass}><span class={ringDotClass}></span></span>
|
|
190
|
+
{/if}
|
|
101
191
|
<span class="text-13 font-medium text-text-1">{siteName}</span>
|
|
102
192
|
</div>
|
|
103
193
|
{/if}
|
|
@@ -114,7 +204,7 @@
|
|
|
114
204
|
onclick={() => { activeApp = a.id; onappchange?.(a.id); }}
|
|
115
205
|
>
|
|
116
206
|
<img
|
|
117
|
-
src={
|
|
207
|
+
src={resolveLogo(a.logo)}
|
|
118
208
|
alt=""
|
|
119
209
|
width="24"
|
|
120
210
|
height="24"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure positioning helper for `Popover.svelte`.
|
|
3
|
+
*
|
|
4
|
+
* Computes the viewport-coord `{ top, left }` for the panel given
|
|
5
|
+
* the trigger geometry, panel size, viewport size, and placement.
|
|
6
|
+
* Lives in its own module so the layout policy (which side wins on
|
|
7
|
+
* tight viewports, how flips clamp to the margin, etc.) is unit-
|
|
8
|
+
* testable without spinning up the Svelte component + jsdom.
|
|
9
|
+
*
|
|
10
|
+
* Auto-flip behaviour: the documented placements only mention
|
|
11
|
+
* `bottom-*`, but when the trigger sits near the bottom of the
|
|
12
|
+
* viewport (e.g. a pill on a session footer) the panel would open
|
|
13
|
+
* off-screen. We treat the placement's `bottom` half as a HINT
|
|
14
|
+
* rather than a hard rule \u2014 if the panel doesn't fit below AND
|
|
15
|
+
* there's more room above than below, we flip the vertical anchor
|
|
16
|
+
* to open upward. Horizontal alignment (`-start` / `-end` /
|
|
17
|
+
* centred) is preserved across the flip.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface TriggerRect {
|
|
21
|
+
top: number
|
|
22
|
+
bottom: number
|
|
23
|
+
left: number
|
|
24
|
+
right: number
|
|
25
|
+
width: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PopoverPlacement = 'bottom-start' | 'bottom-end' | 'bottom'
|
|
29
|
+
|
|
30
|
+
export interface ComputePanelPositionInput {
|
|
31
|
+
triggerRect: TriggerRect
|
|
32
|
+
panelWidth: number
|
|
33
|
+
panelHeight: number
|
|
34
|
+
viewportWidth: number
|
|
35
|
+
viewportHeight: number
|
|
36
|
+
placement: PopoverPlacement
|
|
37
|
+
/** Gutter between the panel and the viewport edge on every side. */
|
|
38
|
+
margin: number
|
|
39
|
+
/** Gap between the trigger edge and the panel edge along the axis. */
|
|
40
|
+
offset: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PanelPosition {
|
|
44
|
+
top: number
|
|
45
|
+
left: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const computePanelPosition = (input: ComputePanelPositionInput): PanelPosition => {
|
|
49
|
+
const {
|
|
50
|
+
triggerRect,
|
|
51
|
+
panelWidth,
|
|
52
|
+
panelHeight,
|
|
53
|
+
viewportWidth,
|
|
54
|
+
viewportHeight,
|
|
55
|
+
placement,
|
|
56
|
+
margin,
|
|
57
|
+
offset,
|
|
58
|
+
} = input
|
|
59
|
+
|
|
60
|
+
// Horizontal: align per placement, then clamp into the viewport so
|
|
61
|
+
// edge-anchored triggers don't push the panel off-screen.
|
|
62
|
+
let left: number
|
|
63
|
+
if (placement === 'bottom-start') {
|
|
64
|
+
left = triggerRect.left
|
|
65
|
+
} else if (placement === 'bottom-end') {
|
|
66
|
+
left = triggerRect.right - panelWidth
|
|
67
|
+
} else {
|
|
68
|
+
left = triggerRect.left + triggerRect.width / 2 - panelWidth / 2
|
|
69
|
+
}
|
|
70
|
+
left = Math.max(margin, Math.min(left, viewportWidth - panelWidth - margin))
|
|
71
|
+
|
|
72
|
+
// Vertical: prefer below the trigger (documented behaviour). Flip
|
|
73
|
+
// above when:
|
|
74
|
+
// 1. the panel does NOT fit below (panel height + offset would
|
|
75
|
+
// cross the bottom margin), AND
|
|
76
|
+
// 2. above has more room than below.
|
|
77
|
+
// When neither side fully fits, we still prefer the side with more
|
|
78
|
+
// breathing room \u2014 the panel then gets clamped to the viewport
|
|
79
|
+
// margin so its content scrolls instead of clipping at the edge.
|
|
80
|
+
const spaceBelow = viewportHeight - triggerRect.bottom - margin
|
|
81
|
+
const spaceAbove = triggerRect.top - margin
|
|
82
|
+
const fitsBelow = panelHeight + offset <= spaceBelow
|
|
83
|
+
|
|
84
|
+
let top: number
|
|
85
|
+
if (fitsBelow || spaceBelow >= spaceAbove) {
|
|
86
|
+
top = triggerRect.bottom + offset
|
|
87
|
+
} else {
|
|
88
|
+
top = Math.max(margin, triggerRect.top - offset - panelHeight)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { top, left }
|
|
92
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { default as TabPanel } from './components/TabPanel.svelte'
|
|
|
20
20
|
export { default as Toggle } from './components/Toggle.svelte'
|
|
21
21
|
export { default as RowToggle } from './components/RowToggle.svelte'
|
|
22
22
|
export { default as Modal } from './components/Modal.svelte'
|
|
23
|
+
export type { ModalSize } from './components/Modal.svelte'
|
|
23
24
|
export { default as Sparkline } from './components/Sparkline.svelte'
|
|
24
25
|
export { default as SparklineMatrix } from './components/SparklineMatrix.svelte'
|
|
25
26
|
export type { SparklineMatrixRow } from './components/SparklineMatrix.svelte'
|
|
@@ -308,3 +309,5 @@ export { default as H3 } from './components/H3.svelte'
|
|
|
308
309
|
export { default as H4 } from './components/H4.svelte'
|
|
309
310
|
|
|
310
311
|
export * from './icons.js'
|
|
312
|
+
|
|
313
|
+
export { portal } from './actions/portal.ts'
|