@w5-ui/svelte 0.2.1 → 0.3.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.
Files changed (138) hide show
  1. package/README.md +116 -4
  2. package/package.json +2 -2
  3. package/src/lib/components/Accordion.svelte +1 -1
  4. package/src/lib/components/AccordionItem.svelte +6 -7
  5. package/src/lib/components/ActionMenu.svelte +17 -9
  6. package/src/lib/components/ActivityFeed.svelte +20 -14
  7. package/src/lib/components/Alert.svelte +22 -4
  8. package/src/lib/components/AppLayout.svelte +34 -0
  9. package/src/lib/components/Avatar.svelte +28 -11
  10. package/src/lib/components/AvatarGroup.svelte +21 -4
  11. package/src/lib/components/Badge.svelte +22 -6
  12. package/src/lib/components/Banner.svelte +36 -7
  13. package/src/lib/components/Body.svelte +29 -0
  14. package/src/lib/components/BodyLg.svelte +26 -0
  15. package/src/lib/components/Breadcrumb.svelte +19 -12
  16. package/src/lib/components/Button.svelte +30 -1
  17. package/src/lib/components/CIDRInput.svelte +17 -9
  18. package/src/lib/components/Callout.svelte +29 -8
  19. package/src/lib/components/Caption.svelte +26 -0
  20. package/src/lib/components/Card.svelte +11 -3
  21. package/src/lib/components/CardMore.svelte +20 -0
  22. package/src/lib/components/CardTitle.svelte +20 -0
  23. package/src/lib/components/Carousel.svelte +27 -20
  24. package/src/lib/components/Checkbox.svelte +24 -3
  25. package/src/lib/components/Code.svelte +24 -0
  26. package/src/lib/components/CodeBlock.svelte +8 -7
  27. package/src/lib/components/ColorPicker.svelte +13 -13
  28. package/src/lib/components/ColumnToggle.svelte +10 -9
  29. package/src/lib/components/Combobox.svelte +31 -17
  30. package/src/lib/components/CommandPalette.svelte +42 -26
  31. package/src/lib/components/ConfirmDialog.svelte +21 -15
  32. package/src/lib/components/ContextMenu.svelte +12 -12
  33. package/src/lib/components/ContextualHelp.svelte +11 -6
  34. package/src/lib/components/CopyButton.svelte +2 -1
  35. package/src/lib/components/DatePicker.svelte +27 -23
  36. package/src/lib/components/DateRangePicker.svelte +46 -39
  37. package/src/lib/components/Delta.svelte +25 -0
  38. package/src/lib/components/Display1.svelte +29 -0
  39. package/src/lib/components/Display2.svelte +29 -0
  40. package/src/lib/components/Drawer.svelte +6 -5
  41. package/src/lib/components/DurationInput.svelte +16 -8
  42. package/src/lib/components/EmptyState.svelte +16 -7
  43. package/src/lib/components/ExpandableRow.svelte +8 -15
  44. package/src/lib/components/Eyebrow.svelte +25 -0
  45. package/src/lib/components/Field.svelte +7 -7
  46. package/src/lib/components/FieldStack.svelte +18 -0
  47. package/src/lib/components/FileUpload.svelte +8 -10
  48. package/src/lib/components/FilterBuilder.svelte +52 -28
  49. package/src/lib/components/FormActions.svelte +18 -0
  50. package/src/lib/components/FormRow.svelte +17 -0
  51. package/src/lib/components/Grid.svelte +17 -0
  52. package/src/lib/components/GroupedList.svelte +35 -21
  53. package/src/lib/components/H1.svelte +29 -0
  54. package/src/lib/components/H2.svelte +29 -0
  55. package/src/lib/components/H3.svelte +29 -0
  56. package/src/lib/components/H4.svelte +29 -0
  57. package/src/lib/components/HealthBar.svelte +6 -3
  58. package/src/lib/components/HoverCard.svelte +16 -5
  59. package/src/lib/components/IPInput.svelte +14 -8
  60. package/src/lib/components/IconButton.svelte +1 -2
  61. package/src/lib/components/InlineEdit.svelte +25 -14
  62. package/src/lib/components/Input.svelte +13 -4
  63. package/src/lib/components/InputGroup.svelte +14 -3
  64. package/src/lib/components/JsonViewer.svelte +7 -2
  65. package/src/lib/components/JsonViewerNode.svelte +38 -32
  66. package/src/lib/components/KVTable.svelte +10 -4
  67. package/src/lib/components/KanbanBoard.svelte +29 -27
  68. package/src/lib/components/Kbd.svelte +8 -5
  69. package/src/lib/components/Label.svelte +28 -0
  70. package/src/lib/components/LogViewer.svelte +24 -20
  71. package/src/lib/components/MACInput.svelte +14 -8
  72. package/src/lib/components/Mac.svelte +23 -0
  73. package/src/lib/components/Menubar.svelte +30 -13
  74. package/src/lib/components/Modal.svelte +15 -6
  75. package/src/lib/components/Mono.svelte +28 -0
  76. package/src/lib/components/MultiSelect.svelte +43 -23
  77. package/src/lib/components/Muted.svelte +24 -0
  78. package/src/lib/components/NameCell.svelte +35 -0
  79. package/src/lib/components/NotifDot.svelte +23 -0
  80. package/src/lib/components/NotificationPanel.svelte +44 -31
  81. package/src/lib/components/NumberInput.svelte +13 -12
  82. package/src/lib/components/OTPInput.svelte +14 -8
  83. package/src/lib/components/PageHeader.svelte +25 -0
  84. package/src/lib/components/Pagination.svelte +16 -12
  85. package/src/lib/components/PasswordInput.svelte +8 -7
  86. package/src/lib/components/Pill.svelte +34 -6
  87. package/src/lib/components/Popover.svelte +91 -16
  88. package/src/lib/components/ProgressBar.svelte +10 -7
  89. package/src/lib/components/RadioGroup.svelte +22 -7
  90. package/src/lib/components/RangeSlider.svelte +17 -12
  91. package/src/lib/components/RankedList.svelte +12 -18
  92. package/src/lib/components/ResizablePanel.svelte +25 -14
  93. package/src/lib/components/RowToggle.svelte +6 -4
  94. package/src/lib/components/SearchBox.svelte +6 -2
  95. package/src/lib/components/SegmentedControl.svelte +16 -2
  96. package/src/lib/components/Select.svelte +20 -8
  97. package/src/lib/components/SelectionToolbar.svelte +13 -9
  98. package/src/lib/components/Sidebar.svelte +32 -11
  99. package/src/lib/components/Signal.svelte +3 -2
  100. package/src/lib/components/Skeleton.svelte +21 -9
  101. package/src/lib/components/SkipLink.svelte +5 -2
  102. package/src/lib/components/Slider.svelte +16 -9
  103. package/src/lib/components/SortHeader.svelte +13 -4
  104. package/src/lib/components/SortableList.svelte +17 -18
  105. package/src/lib/components/Sparkline.svelte +20 -14
  106. package/src/lib/components/Spinner.svelte +16 -1
  107. package/src/lib/components/SplitButton.svelte +27 -8
  108. package/src/lib/components/Spoiler.svelte +8 -6
  109. package/src/lib/components/SrOnly.svelte +23 -0
  110. package/src/lib/components/StackedProgress.svelte +17 -9
  111. package/src/lib/components/StarRating.svelte +35 -10
  112. package/src/lib/components/Stat.svelte +19 -5
  113. package/src/lib/components/StatNumber.svelte +22 -0
  114. package/src/lib/components/StatusIndicator.svelte +9 -2
  115. package/src/lib/components/StatusRing.svelte +36 -0
  116. package/src/lib/components/Stepper.svelte +30 -8
  117. package/src/lib/components/Submeta.svelte +19 -0
  118. package/src/lib/components/SwitchPortGrid.svelte +20 -7
  119. package/src/lib/components/TablePager.svelte +22 -0
  120. package/src/lib/components/TableShell.svelte +19 -0
  121. package/src/lib/components/Tabs.svelte +16 -5
  122. package/src/lib/components/Tag.svelte +5 -3
  123. package/src/lib/components/TagInput.svelte +9 -10
  124. package/src/lib/components/Textarea.svelte +7 -1
  125. package/src/lib/components/TimePicker.svelte +14 -8
  126. package/src/lib/components/TimeRange.svelte +20 -22
  127. package/src/lib/components/Timeline.svelte +20 -9
  128. package/src/lib/components/Toast.svelte +24 -5
  129. package/src/lib/components/Toggle.svelte +10 -4
  130. package/src/lib/components/ToggleGroup.svelte +15 -5
  131. package/src/lib/components/Tooltip.svelte +20 -5
  132. package/src/lib/components/Topbar.svelte +108 -27
  133. package/src/lib/components/TopbarActions.svelte +52 -0
  134. package/src/lib/components/TransferList.svelte +30 -22
  135. package/src/lib/components/TreeItem.svelte +11 -13
  136. package/src/lib/components/Unit.svelte +22 -0
  137. package/src/lib/components/VirtualList.svelte +12 -9
  138. package/src/lib/index.ts +35 -0
@@ -3,8 +3,6 @@
3
3
  </script>
4
4
 
5
5
  <script lang="ts">
6
-
7
-
8
6
  interface Props {
9
7
  label?: string | undefined;
10
8
  length?: number;
@@ -73,16 +71,24 @@
73
71
  commit(next);
74
72
  setTimeout(() => focusAt(Math.min(text.length, length - 1)), 0);
75
73
  }
74
+
75
+ const DIGIT_CLS =
76
+ 'h-9 w-9 rounded-md border border-border-2 bg-bg-page text-center font-mono text-15 text-text-1 outline-none transition-colors duration-100 focus:border-brand-05 focus:shadow-[0_0_0_2px_rgba(0,111,255,0.2)]';
76
77
  </script>
77
78
 
78
- <div class="otp-input-wrapper {className}">
79
+ <div class={className}>
79
80
  {#if label}
80
- <label for="{uid}-0" class="otp-input__label">{label}</label>
81
+ <label for="{uid}-0" class="text-12 text-text-4">{label}</label>
81
82
  {/if}
82
- <div class="otp-input{disabled ? ' otp-input--disabled' : ''}" role="group" aria-label={label ?? 'One-time password'}>
83
+ <div
84
+ role="group"
85
+ aria-label={label ?? 'One-time password'}
86
+ data-disabled={disabled ? 'true' : undefined}
87
+ class="inline-flex items-center gap-1.5 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-40"
88
+ >
83
89
  {#each digits as d, i}
84
90
  {#if i === half}
85
- <span class="otp-input__separator" aria-hidden="true">&ndash;</span>
91
+ <span aria-hidden="true" class="select-none text-13 text-text-4">&ndash;</span>
86
92
  {/if}
87
93
  <input
88
94
  bind:this={inputs[i]}
@@ -91,10 +97,10 @@
91
97
  inputmode="numeric"
92
98
  pattern="[0-9]*"
93
99
  maxlength={1}
94
- aria-label="Digit {i + 1} of {length}"
100
+ aria-label={`Digit ${i + 1} of ${length}`}
95
101
  value={d}
96
102
  {disabled}
97
- class="otp-input__digit"
103
+ class={DIGIT_CLS}
98
104
  autocomplete={i === 0 ? 'one-time-code' : 'off'}
99
105
  oninput={(e) => handleInput(i, e.currentTarget.value)}
100
106
  onkeydown={(e) => handleKey(i, e)}
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ title: string;
4
+ sticky?: boolean;
5
+ class?: string;
6
+ actions?: import('svelte').Snippet;
7
+ }
8
+
9
+ let {
10
+ title,
11
+ sticky = true,
12
+ class: className = '',
13
+ actions,
14
+ }: Props = $props();
15
+ </script>
16
+
17
+ <div
18
+ class="ph-bar {className}"
19
+ style={sticky ? undefined : 'position: static'}
20
+ >
21
+ <h2 class="ph-title">{title}</h2>
22
+ {#if actions}
23
+ <div class="ph-actions">{@render actions?.()}</div>
24
+ {/if}
25
+ </div>
@@ -1,6 +1,4 @@
1
1
  <script lang="ts">
2
-
3
-
4
2
  interface Props {
5
3
  page?: number;
6
4
  pageSize?: number;
@@ -8,9 +6,8 @@
8
6
  onchange?: (payload: number) => void;
9
7
  }
10
8
 
11
- let { page = 1, pageSize = 10, total = 0,
12
- onchange,
13
- }: Props = $props();
9
+ let { page = 1, pageSize = 10, total = 0, onchange }: Props = $props();
10
+
14
11
  function getPageNumbers(p: number, tp: number): (number | '...')[] {
15
12
  if (tp <= 7) return Array.from({ length: tp }, (_, i) => i + 1);
16
13
  const items: (number | '...')[] = [1];
@@ -23,38 +20,45 @@
23
20
  return items;
24
21
  }
25
22
 
26
- function go(p: number) { onchange?.(p); }
23
+ function go(p: number) {
24
+ onchange?.(p);
25
+ }
26
+
27
27
  let totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)));
28
28
  let pages = $derived(getPageNumbers(page, totalPages));
29
+
30
+ const BTN_BASE =
31
+ 'inline-flex h-[30px] min-w-[30px] cursor-pointer items-center justify-center rounded-md border border-transparent bg-transparent px-1.5 text-13 font-medium leading-none text-text-3 transition-[background-color,color,border-color] duration-100 hover:bg-row-hover hover:text-text-1 hover:border-border-2 disabled:cursor-not-allowed disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05';
32
+ const BTN_ACTIVE = 'bg-brand-05 text-white border-brand-05 hover:bg-brand-06 hover:border-brand-06';
29
33
  </script>
30
34
 
31
35
  {#if totalPages > 1}
32
- <nav class="pagination" aria-label="Pagination">
36
+ <nav class="flex items-center gap-0.5" aria-label="Pagination">
33
37
  <button
34
38
  type="button"
35
- class="pagination-btn"
36
39
  aria-label="Previous page"
37
40
  disabled={page <= 1}
41
+ class={BTN_BASE}
38
42
  onclick={() => go(page - 1)}
39
43
  >&#x2039;</button>
40
44
  {#each pages as p, i (typeof p === 'number' ? p : `e${i}`)}
41
45
  {#if p === '...'}
42
- <span class="pagination-ellipsis" aria-hidden="true">&#x2026;</span>
46
+ <span aria-hidden="true" class="inline-flex h-[30px] min-w-[30px] items-center justify-center text-13 text-text-4">&#x2026;</span>
43
47
  {:else}
44
48
  <button
45
49
  type="button"
46
- class="pagination-btn{p === page ? ' active' : ''}"
47
- aria-label="Page {p}"
50
+ aria-label={`Page ${p}`}
48
51
  aria-current={p === page ? 'page' : undefined}
52
+ class="{BTN_BASE} {p === page ? BTN_ACTIVE : ''}"
49
53
  onclick={() => go(p)}
50
54
  >{p}</button>
51
55
  {/if}
52
56
  {/each}
53
57
  <button
54
58
  type="button"
55
- class="pagination-btn"
56
59
  aria-label="Next page"
57
60
  disabled={page >= totalPages}
61
+ class={BTN_BASE}
58
62
  onclick={() => go(page + 1)}
59
63
  >&#x203A;</button>
60
64
  </nav>
@@ -3,8 +3,6 @@
3
3
  </script>
4
4
 
5
5
  <script lang="ts">
6
-
7
-
8
6
  interface Props {
9
7
  label?: string | undefined;
10
8
  value?: string;
@@ -34,15 +32,18 @@
34
32
  }
35
33
  </script>
36
34
 
37
- <div class="pwd-input-wrapper {className}">
35
+ <div class={className}>
38
36
  {#if label}
39
- <label for={uid} class="pwd-input__label">{label}</label>
37
+ <label for={uid} class="text-12 text-text-4">{label}</label>
40
38
  {/if}
41
- <div class="pwd-input{disabled ? ' pwd-input--disabled' : ''}">
39
+ <div
40
+ data-disabled={disabled ? 'true' : undefined}
41
+ class="flex h-[34px] items-stretch overflow-hidden rounded-md border border-border-2 bg-bg-page transition-colors duration-100 focus-within:border-brand-05 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-40"
42
+ >
42
43
  <input
43
44
  id={uid}
44
45
  type={shown ? 'text' : 'password'}
45
- class="pwd-input__field"
46
+ class="min-w-0 flex-1 border-0 bg-transparent px-3 text-13 text-text-1 outline-none placeholder:text-text-4"
46
47
  {value}
47
48
  {placeholder}
48
49
  {disabled}
@@ -51,10 +52,10 @@
51
52
  />
52
53
  <button
53
54
  type="button"
54
- class="pwd-input__toggle"
55
55
  aria-pressed={shown}
56
56
  aria-label={shown ? 'Hide password' : 'Show password'}
57
57
  {disabled}
58
+ class="inline-flex h-full w-9 shrink-0 cursor-pointer items-center justify-center border-0 border-l border-l-white/[0.08] bg-transparent text-text-4 transition-colors duration-100 hover:bg-row-hover hover:text-text-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-brand-05"
58
59
  onclick={() => (shown = !shown)}
59
60
  >
60
61
  {#if shown}
@@ -1,11 +1,14 @@
1
1
  <script lang="ts">
2
-
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export type PillVariant = 'success' | 'warn' | 'danger' | 'info' | 'neutral';
5
+
3
6
  interface Props {
4
- variant?: 'success' | 'warn' | 'danger' | 'info' | 'neutral';
7
+ variant?: PillVariant;
5
8
  showDot?: boolean;
6
9
  class?: string;
7
10
  style?: string;
8
- children?: import('svelte').Snippet;
11
+ children?: Snippet;
9
12
  }
10
13
 
11
14
  let {
@@ -13,11 +16,36 @@
13
16
  showDot = true,
14
17
  class: className = '',
15
18
  style = '',
16
- children
19
+ children,
17
20
  }: Props = $props();
21
+
22
+ // Pre-compose so Tailwind's static scanner picks up every variant.
23
+ // Backgrounds use the literal 14% opacity from `dashboard.css`; text
24
+ // colours resolve to the semantic status tokens so they stay legible
25
+ // against the 14% tinted background in both motifs. The neutral variant
26
+ // sits on `--row-active` (brand-blue tint) so it stays visible on a
27
+ // white page bg.
28
+ const PILL: Record<PillVariant, string> = {
29
+ success: 'bg-status-success/14 text-status-success',
30
+ warn: 'bg-status-warning/14 text-status-warning',
31
+ danger: 'bg-status-danger/14 text-status-danger',
32
+ info: 'bg-status-info/14 text-status-info',
33
+ neutral: 'bg-row-active text-text-2',
34
+ };
35
+ const DOT: Record<PillVariant, string> = {
36
+ success: 'bg-status-success',
37
+ warn: 'bg-status-warning',
38
+ danger: 'bg-status-danger',
39
+ info: 'bg-status-info',
40
+ neutral: 'bg-status-neutral',
41
+ };
42
+
43
+ let pillClass = $derived(
44
+ `inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.4] ${PILL[variant]} ${className}`,
45
+ );
18
46
  </script>
19
47
 
20
- <span class="pill pill-{variant} {className}" {style}>
21
- {#if showDot}<span class="dot" aria-hidden="true"></span>{/if}
48
+ <span class={pillClass} {style}>
49
+ {#if showDot}<span class="h-1.5 w-1.5 rounded-full {DOT[variant]}" aria-hidden="true"></span>{/if}
22
50
  {@render children?.()}
23
51
  </span>
@@ -4,12 +4,33 @@
4
4
 
5
5
  <script lang="ts">
6
6
  import { onMount, onDestroy, tick } from 'svelte';
7
+ import Button from './Button.svelte';
7
8
 
8
9
  interface Props {
9
10
  label: string;
10
11
  variant?: 'ghost' | 'primary';
11
12
  title?: string | undefined;
12
13
  placement?: 'bottom-start' | 'bottom-end' | 'bottom';
14
+ /**
15
+ * Controlled open state. Default uncontrolled (`$bindable(false)`):
16
+ * the trigger button toggles it and outside-clicks close it.
17
+ * Callers can `bind:open` to drive it programmatically (e.g. close
18
+ * the popover after a form submit) or to coordinate with other
19
+ * UI. When the trigger button is used, the bound variable updates
20
+ * automatically.
21
+ */
22
+ open?: boolean;
23
+ /**
24
+ * Optional custom trigger snippet. When provided, renders inline
25
+ * with the rest of the popover root and receives a `toggle` fn so
26
+ * the snippet can drive open/close. The default `label`-Button
27
+ * trigger renders only when no `trigger` snippet is passed.
28
+ *
29
+ * Use for icon-only triggers (eye-icon view-options menus, gear
30
+ * icons, etc.) where the upstream `<Button label>` chrome doesn't
31
+ * fit the surrounding UI.
32
+ */
33
+ trigger?: import('svelte').Snippet<[{ toggle: () => void; open: boolean }]>;
13
34
  children?: import('svelte').Snippet;
14
35
  }
15
36
 
@@ -18,7 +39,9 @@
18
39
  variant = 'ghost',
19
40
  title = undefined,
20
41
  placement = 'bottom-start',
21
- children
42
+ open = $bindable(false),
43
+ trigger,
44
+ children,
22
45
  }: Props = $props();
23
46
 
24
47
  const uid = `dash-ui-pop-${++counter}`;
@@ -27,15 +50,40 @@
27
50
  const FOCUSABLE =
28
51
  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
29
52
 
30
- let open = $state(false);
31
53
  let rootEl = $state<HTMLDivElement | undefined>(undefined);
32
- let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
54
+ let triggerEl = $state<HTMLElement | undefined>(undefined);
33
55
  let panelEl = $state<HTMLDivElement | undefined>(undefined);
56
+ /** Top + left in viewport coordinates. Computed from the trigger's
57
+ * bounding rect every time the panel opens / window resizes /
58
+ * the user scrolls. The panel uses `position: fixed` so we never
59
+ * get clipped by an ancestor's `overflow: hidden`. */
60
+ let panelTop = $state(0);
61
+ let panelLeft = $state(0);
62
+
63
+ /** Recompute the panel's viewport coords. The panel itself must be
64
+ * rendered (size > 0) for accurate measurement; call after the
65
+ * open + tick + paint. */
66
+ function recomputePosition() {
67
+ if (!triggerEl || !panelEl) return;
68
+ const tr = triggerEl.getBoundingClientRect();
69
+ const margin = 8;
70
+ const offset = 6; // gap between trigger bottom and panel top
71
+ const panelWidth = panelEl.offsetWidth;
72
+ let left: number;
73
+ if (placement === 'bottom-start') left = tr.left;
74
+ else if (placement === 'bottom-end') left = tr.right - panelWidth;
75
+ else left = tr.left + tr.width / 2 - panelWidth / 2; // 'bottom'
76
+ // Clamp into the visible viewport with the configured gutter.
77
+ left = Math.max(margin, Math.min(left, window.innerWidth - panelWidth - margin));
78
+ panelLeft = left;
79
+ panelTop = tr.bottom + offset;
80
+ }
34
81
 
35
82
  async function toggle() {
36
83
  if (!open) {
37
84
  open = true;
38
85
  await tick();
86
+ recomputePosition();
39
87
  const first = panelEl?.querySelector<HTMLElement>(FOCUSABLE);
40
88
  (first ?? panelEl)?.focus();
41
89
  } else {
@@ -66,35 +114,62 @@
66
114
  if (open && !rootEl?.contains(e.target as Node)) open = false;
67
115
  }
68
116
 
117
+ function onResize() {
118
+ if (open) recomputePosition();
119
+ }
120
+
121
+ function onScroll() {
122
+ if (open) recomputePosition();
123
+ }
124
+
69
125
  onMount(() => {
70
126
  window.addEventListener('keydown', onKey);
127
+ window.addEventListener('resize', onResize);
128
+ // Capture-phase scroll listener picks up scrolls in ANY ancestor
129
+ // (the sidebar's own overflow, the page itself, etc.) so the
130
+ // fixed-positioned panel tracks the trigger as it moves.
131
+ window.addEventListener('scroll', onScroll, true);
71
132
  document.addEventListener('pointerdown', onPointer);
133
+ triggerEl =
134
+ rootEl?.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]') ?? undefined;
72
135
  });
73
136
  onDestroy(() => {
74
137
  window.removeEventListener('keydown', onKey);
138
+ window.removeEventListener('resize', onResize);
139
+ window.removeEventListener('scroll', onScroll, true);
75
140
  document.removeEventListener('pointerdown', onPointer);
76
141
  });
77
142
  </script>
78
143
 
79
- <div class="popover-root" bind:this={rootEl}>
80
- <button
81
- bind:this={triggerEl}
82
- type="button"
83
- class="btn btn-{variant}"
84
- aria-haspopup="dialog"
85
- aria-expanded={open}
86
- onclick={toggle}
87
- >{label}</button>
144
+ <div class="relative inline-block" bind:this={rootEl}>
145
+ {#if trigger}
146
+ {@render trigger({ toggle, open })}
147
+ {:else}
148
+ <Button {variant} aria-label={label} onclick={toggle}>
149
+ {label}
150
+ </Button>
151
+ {/if}
88
152
  {#if open}
153
+ <!--
154
+ `position: fixed` so the panel can extend outside any ancestor
155
+ with `overflow: hidden` (the sidebar's session-list scroll, the
156
+ app's main grid columns, etc.). Coords are computed from the
157
+ trigger's `getBoundingClientRect()` in `recomputePosition`.
158
+ -->
89
159
  <div
90
160
  bind:this={panelEl}
91
161
  role="dialog"
92
162
  aria-labelledby={title ? titleId : undefined}
93
- class="popover popover-{placement}"
94
- tabindex="-1"
163
+ 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"
164
+ style={`top: ${panelTop}px; left: ${panelLeft}px`}
165
+ tabindex={-1}
95
166
  >
96
- {#if title}<div id={titleId} class="popover-h">{title}</div>{/if}
97
- <div class="popover-b">{@render children?.()}</div>
167
+ {#if title}
168
+ <div id={titleId} class="border-b border-border-1 px-3.5 pb-2 pt-2.5 text-11 font-semibold uppercase tracking-[0.06em] text-text-4">
169
+ {title}
170
+ </div>
171
+ {/if}
172
+ <div class="flex flex-col gap-2.5 px-3.5 py-3">{@render children?.()}</div>
98
173
  </div>
99
174
  {/if}
100
175
  </div>
@@ -10,22 +10,22 @@
10
10
  value,
11
11
  label = undefined,
12
12
  valueText = undefined,
13
- color = '#006FFF'
13
+ color = '#006FFF',
14
14
  }: Props = $props();
15
15
  let clamped = $derived(Math.max(0, Math.min(100, value)));
16
16
  let displayed = $derived(valueText ?? `${Math.round(clamped)}%`);
17
17
  let hasHeader = $derived(label !== undefined || valueText !== undefined);
18
18
  </script>
19
19
 
20
- <div class="pb">
20
+ <div class="flex flex-col gap-1">
21
21
  {#if hasHeader}
22
- <div class="pb-header">
23
- {#if label}<span class="pb-label">{label}</span>{/if}
24
- <span class="pb-value">{displayed}</span>
22
+ <div class="flex justify-between text-12">
23
+ {#if label}<span class="text-text-2">{label}</span>{/if}
24
+ <span class="font-mono text-text-3">{displayed}</span>
25
25
  </div>
26
26
  {/if}
27
27
  <div
28
- class="pb-track"
28
+ class="h-1.5 overflow-hidden rounded-[3px] bg-row-active"
29
29
  role="progressbar"
30
30
  aria-valuenow={Math.round(clamped)}
31
31
  aria-valuemin={0}
@@ -33,6 +33,9 @@
33
33
  aria-label={label}
34
34
  aria-valuetext={displayed}
35
35
  >
36
- <div class="pb-fill" style="width:{clamped}%;background:{color};"></div>
36
+ <div
37
+ class="h-full rounded-[3px] transition-[width] duration-300 ease-out motion-reduce:transition-none"
38
+ style="width:{clamped}%;background:{color};"
39
+ ></div>
37
40
  </div>
38
41
  </div>
@@ -24,20 +24,34 @@
24
24
  options = [],
25
25
  value = $bindable(undefined),
26
26
  srOnlyLegend = false,
27
- horizontal = false
27
+ horizontal = false,
28
28
  }: Props = $props();
29
29
 
30
30
  // svelte-ignore state_referenced_locally
31
31
  const groupName = name ?? `dash-ui-rg-${++counter}`;
32
+
33
+ // Native radio input drawn with Tailwind utilities. The selected dot is
34
+ // an inline-SVG bg-image (matches the legacy checkbox approach).
35
+ const DOT_SVG = `url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='3' fill='%23fff'/%3E%3C/svg%3E")`;
36
+
37
+ const INPUT_CLS =
38
+ 'mt-px relative inline-block h-4 w-4 min-w-4 shrink-0 cursor-pointer appearance-none rounded-full border-[1.5px] border-border-3 bg-bg-1 align-middle bg-center bg-no-repeat transition-[border-color,background-color] duration-100 hover:border-text-3 checked:border-brand-05 checked:bg-brand-05 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05';
39
+
40
+ // Horizontal layout uses flex-wrap row gap; vertical stacks the options.
41
+ let groupCls = $derived(
42
+ horizontal ? 'flex flex-wrap gap-x-5 gap-y-1 border-0 m-0 p-0' : 'flex flex-col border-0 m-0 p-0',
43
+ );
32
44
  </script>
33
45
 
34
- <fieldset class="radio-group {horizontal ? 'radio-group--h' : ''}">
35
- <legend class={srOnlyLegend ? 'sr-only' : 'radio-group__legend'}>{legend}</legend>
46
+ <fieldset class={groupCls}>
47
+ <legend class={srOnlyLegend ? 'sr-only' : 'block p-0 mb-2 text-12 text-text-3'}>{legend}</legend>
36
48
  {#each options as opt}
37
49
  {@const optId = `${groupName}-${opt.value}`}
38
50
  <label
39
51
  for={optId}
40
- class="radio-option{opt.disabled ? ' radio-option--disabled' : ''}"
52
+ class="flex cursor-pointer select-none items-start gap-2 py-1.5 text-13 text-text-2 {opt.disabled
53
+ ? 'cursor-not-allowed opacity-40'
54
+ : ''}"
41
55
  >
42
56
  <input
43
57
  type="radio"
@@ -46,13 +60,14 @@
46
60
  value={opt.value}
47
61
  checked={value === opt.value}
48
62
  disabled={opt.disabled}
49
- class="radio"
63
+ class={INPUT_CLS}
64
+ style={value === opt.value ? `background-image: ${DOT_SVG};` : ''}
50
65
  onchange={() => { value = opt.value; }}
51
66
  />
52
- <span class="radio-text">
67
+ <span class="flex flex-col gap-px">
53
68
  <span>{opt.label}</span>
54
69
  {#if opt.description}
55
- <span class="radio-description">{opt.description}</span>
70
+ <span class="text-12 text-text-3">{opt.description}</span>
56
71
  {/if}
57
72
  </span>
58
73
  </label>
@@ -1,6 +1,4 @@
1
1
  <script lang="ts">
2
-
3
-
4
2
  interface Props {
5
3
  label?: string | undefined;
6
4
  min?: number;
@@ -37,26 +35,33 @@
37
35
  high = Math.max(Number((e.target as HTMLInputElement).value), low + step);
38
36
  onchange?.([low, high]);
39
37
  }
38
+
39
+ // Native dual-thumb range slider. Both `<input type="range">` overlap
40
+ // visually; we toggle z-index so the focused one is on top.
41
+ const INPUT_CLS =
42
+ 'pointer-events-auto absolute inset-x-0 top-0 m-0 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none [&::-webkit-slider-thumb]:[-webkit-appearance:none] [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-brand-05 [&::-webkit-slider-thumb]:bg-white [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-brand-05 [&::-moz-range-thumb]:bg-white';
40
43
  </script>
41
44
 
42
- <div class="range-slider">
45
+ <div class="flex flex-col gap-1.5">
43
46
  {#if label}
44
- <div class="range-slider__header">
45
- <span class="range-slider__label">{label}</span>
46
- <span class="range-slider__value" aria-live="polite">{low}{suffix ?? ''} &ndash; {high}{suffix ?? ''}</span>
47
+ <div class="flex items-center justify-between gap-2">
48
+ <span class="text-12 text-text-4">{label}</span>
49
+ <span class="text-12 font-medium text-text-1 tabular-nums" aria-live="polite">{low}{suffix ?? ''} &ndash; {high}{suffix ?? ''}</span>
47
50
  </div>
48
51
  {/if}
49
52
  <div
50
- class="range-slider__wrap"
51
- style="--rs-low:{lowPct}%;--rs-high:{highPct}%"
53
+ class="relative h-4"
52
54
  role="group"
53
55
  aria-label={label}
54
56
  >
55
- <div class="range-slider__track-bg"></div>
56
- <div class="range-slider__fill"></div>
57
+ <div class="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-sm bg-bg-3"></div>
58
+ <div
59
+ class="absolute top-1/2 h-1 -translate-y-1/2 rounded-sm bg-brand-05"
60
+ style="left:{lowPct}%; right:{100 - highPct}%"
61
+ ></div>
57
62
  <input
58
63
  type="range"
59
- class="range-slider__input"
64
+ class={INPUT_CLS}
60
65
  {min} {max} {step}
61
66
  value={low}
62
67
  {disabled}
@@ -67,7 +72,7 @@
67
72
  />
68
73
  <input
69
74
  type="range"
70
- class="range-slider__input"
75
+ class={INPUT_CLS}
71
76
  {min} {max} {step}
72
77
  value={high}
73
78
  {disabled}
@@ -8,7 +8,6 @@
8
8
  </script>
9
9
 
10
10
  <script lang="ts">
11
-
12
11
  interface Props {
13
12
  items: RankedItem[];
14
13
  unit?: string | undefined;
@@ -16,17 +15,12 @@
16
15
  ariaLabel?: string;
17
16
  }
18
17
 
19
- let {
20
- items,
21
- unit = undefined,
22
- max = undefined,
23
- ariaLabel = 'Ranked list'
24
- }: Props = $props();
18
+ let { items, unit = undefined, max = undefined, ariaLabel = 'Ranked list' }: Props = $props();
25
19
 
26
20
  let resolvedMax = $derived(max ?? Math.max(...items.map((i) => i.value), 1));
27
21
  </script>
28
22
 
29
- <table class="rl" aria-label={ariaLabel}>
23
+ <table class="w-full border-collapse text-13" aria-label={ariaLabel}>
30
24
  <caption class="sr-only">{ariaLabel}</caption>
31
25
  <thead class="sr-only">
32
26
  <tr>
@@ -38,22 +32,22 @@
38
32
  </thead>
39
33
  <tbody>
40
34
  {#each items as item, i}
41
- <tr class="rl-row">
42
- <td class="rl-rank">{i + 1}</td>
43
- <td class="rl-label-cell">
44
- <div class="rl-label">{item.label}</div>
45
- {#if item.sublabel}<div class="rl-sublabel">{item.sublabel}</div>{/if}
35
+ <tr class="border-b border-border-1 last:border-b-0">
36
+ <td class="w-8 py-1.5 text-12 text-text-4 tabular-nums">{i + 1}</td>
37
+ <td class="py-1.5 pr-3">
38
+ <div class="text-text-2">{item.label}</div>
39
+ {#if item.sublabel}<div class="text-11 text-text-4">{item.sublabel}</div>{/if}
46
40
  </td>
47
- <td class="rl-bar-col" aria-hidden="true">
48
- <div class="rl-bar-track">
41
+ <td class="w-1/3 py-1.5 pr-3" aria-hidden="true">
42
+ <div class="h-1.5 w-full overflow-hidden rounded-sm bg-row-active">
49
43
  <div
50
- class="rl-bar-fill"
44
+ class="h-full rounded-sm"
51
45
  style="width:{((item.value / resolvedMax) * 100).toFixed(1)}%;background:{item.color ?? '#006FFF'}"
52
46
  ></div>
53
47
  </div>
54
48
  </td>
55
- <td class="rl-val">
56
- {item.value.toLocaleString()}{#if unit}<span class="rl-unit"> {unit}</span>{/if}
49
+ <td class="py-1.5 text-right font-medium text-text-1 tabular-nums">
50
+ {item.value.toLocaleString()}{#if unit}<span class="ml-1 text-11 text-text-4"> {unit}</span>{/if}
57
51
  </td>
58
52
  </tr>
59
53
  {/each}