@w5-ui/svelte 0.4.0 → 0.6.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.4.0",
3
+ "version": "0.6.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.2.1"
48
+ "@w5-ui/tokens": "^0.5.0"
49
49
  },
50
50
  "devDependencies": {
51
- "svelte": "^5.55.8",
51
+ "svelte": "^5.56.4",
52
52
  "typescript": "^5.5.0"
53
53
  },
54
54
  "files": [
@@ -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
- 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 aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
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.label}</li>
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="w-[520px] max-w-[90vw] overflow-hidden rounded-xl border border-border-2 bg-bg-1 shadow-modal"
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}
@@ -1,17 +1,27 @@
1
1
  <script module lang="ts">
2
2
  let counter = 0;
3
+ export type PopoverWidth = 'sm' | 'md' | 'lg' | 'xl';
3
4
  </script>
4
5
 
5
6
  <script lang="ts">
6
7
  import { onMount, onDestroy, tick } from 'svelte';
7
8
  import Button from './Button.svelte';
8
9
  import { portal } from '../actions/portal.ts';
10
+ import { computePanelPosition } from './popover-position.ts';
9
11
 
10
12
  interface Props {
11
13
  label: string;
12
14
  variant?: 'ghost' | 'primary';
13
15
  title?: string | undefined;
14
16
  placement?: 'bottom-start' | 'bottom-end' | 'bottom';
17
+ /**
18
+ * Panel max-width. `sm` (320px) is the default and suits menus /
19
+ * option lists. Wider sizes suit content-dense panels (e.g. a
20
+ * folder picker with a path field, breadcrumb and file rows).
21
+ * The panel still shrinks to its content and is kept on-screen by
22
+ * the viewport-clamp in `recomputePosition`.
23
+ */
24
+ width?: PopoverWidth;
15
25
  /**
16
26
  * Controlled open state. Default uncontrolled (`$bindable(false)`):
17
27
  * the trigger button toggles it and outside-clicks close it.
@@ -41,10 +51,20 @@
41
51
  title = undefined,
42
52
  placement = 'bottom-start',
43
53
  open = $bindable(false),
54
+ width = 'sm',
44
55
  trigger,
45
56
  children,
46
57
  }: Props = $props();
47
58
 
59
+ // Pre-composed width utilities so Tailwind's static scanner keeps
60
+ // each class in the build (mirrors `Modal`'s `size` scale).
61
+ const WIDTH: Record<PopoverWidth, string> = {
62
+ sm: 'max-w-[320px]',
63
+ md: 'max-w-[400px]',
64
+ lg: 'max-w-[480px]',
65
+ xl: 'max-w-[560px]',
66
+ };
67
+
48
68
  const uid = `dash-ui-pop-${++counter}`;
49
69
  const titleId = `${uid}-title`;
50
70
 
@@ -67,17 +87,28 @@
67
87
  function recomputePosition() {
68
88
  if (!triggerEl || !panelEl) return;
69
89
  const tr = triggerEl.getBoundingClientRect();
70
- const margin = 8;
71
- const offset = 6; // gap between trigger bottom and panel top
72
- const panelWidth = panelEl.offsetWidth;
73
- let left: number;
74
- if (placement === 'bottom-start') left = tr.left;
75
- else if (placement === 'bottom-end') left = tr.right - panelWidth;
76
- else left = tr.left + tr.width / 2 - panelWidth / 2; // 'bottom'
77
- // Clamp into the visible viewport with the configured gutter.
78
- left = Math.max(margin, Math.min(left, window.innerWidth - panelWidth - margin));
90
+ // Delegate the actual math to a pure helper so the auto-flip
91
+ // rule (panel goes above the trigger when below would overflow
92
+ // AND above has more room) is unit-testable without mounting
93
+ // the component. See `popover-position.ts`.
94
+ const { top, left } = computePanelPosition({
95
+ triggerRect: {
96
+ top: tr.top,
97
+ bottom: tr.bottom,
98
+ left: tr.left,
99
+ right: tr.right,
100
+ width: tr.width,
101
+ },
102
+ panelWidth: panelEl.offsetWidth,
103
+ panelHeight: panelEl.offsetHeight,
104
+ viewportWidth: window.innerWidth,
105
+ viewportHeight: window.innerHeight,
106
+ placement,
107
+ margin: 8,
108
+ offset: 6,
109
+ });
110
+ panelTop = top;
79
111
  panelLeft = left;
80
- panelTop = tr.bottom + offset;
81
112
  }
82
113
 
83
114
  async function toggle() {
@@ -169,7 +200,7 @@
169
200
  use:portal
170
201
  role="dialog"
171
202
  aria-labelledby={title ? titleId : undefined}
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"
203
+ class={`fixed z-[9100] min-w-[200px] ${WIDTH[width]} rounded-[10px] border border-border-3 bg-bg-2 shadow-[0_8px_32px_rgba(0,0,0,0.5)] focus:outline-none`}
173
204
  style={`top: ${panelTop}px; left: ${panelLeft}px`}
174
205
  tabindex={-1}
175
206
  >
@@ -107,6 +107,28 @@
107
107
  let logoRingClass = $derived(
108
108
  `inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-2 ring-inset ${LOGO_RING[status]}`,
109
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
+ });
110
132
  </script>
111
133
 
112
134
  <!--
@@ -122,39 +144,65 @@
122
144
  {#if siteSwitchable}
123
145
  <button
124
146
  type="button"
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"
147
+ class="flex shrink-0 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 max-md:px-1.5"
126
148
  aria-label="Switch site: {siteName}"
127
149
  aria-haspopup="menu"
128
150
  title={`${siteName} — status: ${status}`}
129
151
  >
130
152
  {#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" />
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>
133
163
  </span>
134
164
  {:else}
135
165
  <span class={ringWrapClass}><span class={ringDotClass}></span></span>
136
166
  {/if}
137
- <span class="text-13 font-medium text-text-1" aria-hidden="true">{siteName}</span>
138
- <CaretIcon class="h-3.5 w-3.5 text-text-3" />
167
+ <span class="text-13 font-medium text-text-1 max-md:hidden" aria-hidden="true">{siteName}</span>
168
+ <CaretIcon class="h-3.5 w-3.5 text-text-3 max-md:hidden" />
139
169
  </button>
140
170
  {:else}
141
171
  <div
142
- class="flex items-center gap-2 px-2.5 py-1.5"
172
+ class="flex shrink-0 items-center gap-2 px-2.5 py-1.5 max-md:px-1.5"
143
173
  role="presentation"
144
174
  title={`${siteName} — status: ${status}`}
145
175
  >
146
176
  {#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" />
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>
149
187
  </span>
150
188
  {:else}
151
189
  <span class={ringWrapClass}><span class={ringDotClass}></span></span>
152
190
  {/if}
153
- <span class="text-13 font-medium text-text-1">{siteName}</span>
191
+ <span class="text-13 font-medium text-text-1 max-md:hidden">{siteName}</span>
154
192
  </div>
155
193
  {/if}
156
194
 
157
- <nav class="ml-3.5 flex h-12 items-center gap-0.5" aria-label="Apps">
195
+ <!--
196
+ Apps nav. On phones (< md) the fixed-width app buttons can exceed
197
+ the viewport, so the strip becomes a horizontally swipeable rail
198
+ (`flex-1 min-w-0 overflow-x-auto`, scrollbar hidden) instead of
199
+ overflowing the header. Desktop keeps its natural auto width with
200
+ the flex spacer pushing the actions to the right edge.
201
+ -->
202
+ <nav
203
+ class="ml-3.5 flex h-12 items-center gap-0.5 max-md:ml-1 max-md:min-w-0 max-md:flex-1 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&::-webkit-scrollbar]:hidden"
204
+ aria-label="Apps"
205
+ >
158
206
  {#each apps as a (a.id)}
159
207
  <button
160
208
  type="button"
@@ -175,14 +223,14 @@
175
223
  ? 'opacity-100 dark:brightness-[1.6]'
176
224
  : 'opacity-65 hover:opacity-90 dark:hover:brightness-[1.4]'}"
177
225
  />
178
- <span class="mt-0.5 text-[10px] font-medium leading-none">{a.label}</span>
226
+ <span class="mt-0.5 text-[10px] font-medium leading-none max-md:hidden">{a.label}</span>
179
227
  </button>
180
228
  {/each}
181
229
  </nav>
182
230
 
183
- <div class="flex-1"></div>
231
+ <div class="flex-1 max-md:hidden"></div>
184
232
 
185
- <div class="flex items-center gap-1 pr-2">
233
+ <div class="flex shrink-0 items-center gap-1 pr-2">
186
234
  {#if actions}
187
235
  {@render actions()}
188
236
  {:else}
@@ -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'
@@ -67,6 +68,7 @@ export type { StepperStep } from './components/Stepper.svelte'
67
68
  export { default as SegmentedControl } from './components/SegmentedControl.svelte'
68
69
  export type { SegmentOption } from './components/SegmentedControl.svelte'
69
70
  export { default as Popover } from './components/Popover.svelte'
71
+ export type { PopoverWidth } from './components/Popover.svelte'
70
72
  export { default as FileUpload } from './components/FileUpload.svelte'
71
73
  export { default as Combobox } from './components/Combobox.svelte'
72
74
  export type { ComboboxOption } from './components/Combobox.svelte'