@w5-ui/react 0.1.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 (164) hide show
  1. package/README.md +9 -0
  2. package/package.json +59 -0
  3. package/src/components/Accordion.tsx +65 -0
  4. package/src/components/ActionMenu.tsx +131 -0
  5. package/src/components/ActivityFeed.tsx +77 -0
  6. package/src/components/Alert.tsx +33 -0
  7. package/src/components/AnnotatedTimeSeries.tsx +148 -0
  8. package/src/components/AppShell.tsx +158 -0
  9. package/src/components/ArcDiagram.tsx +114 -0
  10. package/src/components/AreaChart.tsx +101 -0
  11. package/src/components/Avatar.tsx +37 -0
  12. package/src/components/AvatarGroup.tsx +36 -0
  13. package/src/components/Badge.tsx +36 -0
  14. package/src/components/Banner.tsx +49 -0
  15. package/src/components/BarChart.tsx +106 -0
  16. package/src/components/BeeswarmChart.tsx +142 -0
  17. package/src/components/BoxPlot.tsx +142 -0
  18. package/src/components/Breadcrumb.tsx +44 -0
  19. package/src/components/BubbleChart.tsx +115 -0
  20. package/src/components/BulletChart.tsx +121 -0
  21. package/src/components/BumpChart.tsx +127 -0
  22. package/src/components/Button.tsx +61 -0
  23. package/src/components/CIDRInput.tsx +191 -0
  24. package/src/components/CalendarHeatmap.tsx +117 -0
  25. package/src/components/Callout.tsx +82 -0
  26. package/src/components/CandlestickChart.tsx +113 -0
  27. package/src/components/Card.tsx +27 -0
  28. package/src/components/Carousel.tsx +78 -0
  29. package/src/components/Checkbox.tsx +42 -0
  30. package/src/components/ChordDiagram.tsx +133 -0
  31. package/src/components/CirclePacking.tsx +189 -0
  32. package/src/components/CodeBlock.tsx +27 -0
  33. package/src/components/ColorPicker.tsx +120 -0
  34. package/src/components/ColumnToggle.tsx +106 -0
  35. package/src/components/Combobox.tsx +211 -0
  36. package/src/components/CommandPalette.tsx +168 -0
  37. package/src/components/ConfirmDialog.tsx +114 -0
  38. package/src/components/ContextMenu.tsx +127 -0
  39. package/src/components/ContextualHelp.tsx +69 -0
  40. package/src/components/ContourPlot.tsx +255 -0
  41. package/src/components/CopyButton.tsx +47 -0
  42. package/src/components/CorrelationMatrix.tsx +107 -0
  43. package/src/components/CountUp.tsx +70 -0
  44. package/src/components/CumulativeDistribution.tsx +155 -0
  45. package/src/components/DatePicker.tsx +315 -0
  46. package/src/components/DateRangePicker.tsx +327 -0
  47. package/src/components/Dendrogram.tsx +150 -0
  48. package/src/components/Donut.tsx +101 -0
  49. package/src/components/DotPlot.tsx +195 -0
  50. package/src/components/Drawer.tsx +81 -0
  51. package/src/components/DualAxisChart.tsx +209 -0
  52. package/src/components/DumbbellChart.tsx +102 -0
  53. package/src/components/DurationInput.tsx +144 -0
  54. package/src/components/EmptyState.tsx +40 -0
  55. package/src/components/ErrorBandChart.tsx +124 -0
  56. package/src/components/ExpandableRow.tsx +58 -0
  57. package/src/components/FileUpload.tsx +129 -0
  58. package/src/components/FilterBuilder.tsx +217 -0
  59. package/src/components/FlameGraph.tsx +119 -0
  60. package/src/components/ForceGraph.tsx +149 -0
  61. package/src/components/FunnelChart.tsx +79 -0
  62. package/src/components/GanttChart.tsx +96 -0
  63. package/src/components/Gauge.tsx +71 -0
  64. package/src/components/GroupedList.tsx +125 -0
  65. package/src/components/HealthBar.tsx +25 -0
  66. package/src/components/HeatMap.tsx +106 -0
  67. package/src/components/HexbinChart.tsx +152 -0
  68. package/src/components/Histogram.tsx +121 -0
  69. package/src/components/HorizonChart.tsx +110 -0
  70. package/src/components/HoverCard.tsx +52 -0
  71. package/src/components/IPInput.tsx +147 -0
  72. package/src/components/IcicleChart.tsx +118 -0
  73. package/src/components/InlineEdit.tsx +119 -0
  74. package/src/components/Input.tsx +94 -0
  75. package/src/components/InputGroup.tsx +21 -0
  76. package/src/components/JsonViewer.tsx +116 -0
  77. package/src/components/KVTable.tsx +29 -0
  78. package/src/components/KanbanBoard.tsx +171 -0
  79. package/src/components/Kbd.tsx +22 -0
  80. package/src/components/LineChart.tsx +112 -0
  81. package/src/components/LogViewer.tsx +102 -0
  82. package/src/components/LollipopChart.tsx +110 -0
  83. package/src/components/MACInput.tsx +164 -0
  84. package/src/components/MarimekkoChart.tsx +143 -0
  85. package/src/components/MatrixChart.tsx +118 -0
  86. package/src/components/Menubar.tsx +194 -0
  87. package/src/components/MirroredBarChart.tsx +148 -0
  88. package/src/components/Modal.tsx +87 -0
  89. package/src/components/MultiSelect.tsx +232 -0
  90. package/src/components/NightingaleChart.tsx +101 -0
  91. package/src/components/NotificationPanel.tsx +187 -0
  92. package/src/components/NumberInput.tsx +129 -0
  93. package/src/components/OTPInput.tsx +135 -0
  94. package/src/components/Pagination.tsx +73 -0
  95. package/src/components/ParallelCoordinates.tsx +149 -0
  96. package/src/components/ParetoChart.tsx +159 -0
  97. package/src/components/PasswordInput.tsx +112 -0
  98. package/src/components/PieChart.tsx +109 -0
  99. package/src/components/Pill.tsx +26 -0
  100. package/src/components/PolarHeatmap.tsx +118 -0
  101. package/src/components/Popover.tsx +103 -0
  102. package/src/components/ProgressBar.tsx +33 -0
  103. package/src/components/PunchCard.tsx +93 -0
  104. package/src/components/QuadrantChart.tsx +256 -0
  105. package/src/components/RadarChart.tsx +119 -0
  106. package/src/components/RadialBarChart.tsx +103 -0
  107. package/src/components/RadioGroup.tsx +69 -0
  108. package/src/components/RangeSlider.tsx +116 -0
  109. package/src/components/RankedList.tsx +61 -0
  110. package/src/components/ResizablePanel.tsx +122 -0
  111. package/src/components/RidgelinePlot.tsx +155 -0
  112. package/src/components/SankeyDiagram.tsx +156 -0
  113. package/src/components/ScatterPlot.tsx +96 -0
  114. package/src/components/SegmentedControl.tsx +78 -0
  115. package/src/components/Select.tsx +158 -0
  116. package/src/components/SelectionToolbar.tsx +55 -0
  117. package/src/components/Signal.tsx +19 -0
  118. package/src/components/Skeleton.tsx +37 -0
  119. package/src/components/SkipLink.tsx +19 -0
  120. package/src/components/Slider.tsx +74 -0
  121. package/src/components/SlopeChart.tsx +205 -0
  122. package/src/components/SortableList.tsx +130 -0
  123. package/src/components/SortableTable.tsx +46 -0
  124. package/src/components/Sparkline.tsx +50 -0
  125. package/src/components/SparklineMatrix.tsx +117 -0
  126. package/src/components/Spinner.tsx +8 -0
  127. package/src/components/SplitButton.tsx +151 -0
  128. package/src/components/Spoiler.tsx +47 -0
  129. package/src/components/StackedBarChart.tsx +145 -0
  130. package/src/components/StackedProgress.tsx +47 -0
  131. package/src/components/StarRating.tsx +103 -0
  132. package/src/components/Stat.tsx +48 -0
  133. package/src/components/StatusIndicator.tsx +26 -0
  134. package/src/components/StepChart.tsx +130 -0
  135. package/src/components/Stepper.tsx +51 -0
  136. package/src/components/StreamGraph.tsx +97 -0
  137. package/src/components/StripeChart.tsx +76 -0
  138. package/src/components/SunburstChart.tsx +142 -0
  139. package/src/components/SwitchPortGrid.tsx +59 -0
  140. package/src/components/Tabs.tsx +91 -0
  141. package/src/components/Tag.tsx +37 -0
  142. package/src/components/TagInput.tsx +99 -0
  143. package/src/components/Textarea.tsx +9 -0
  144. package/src/components/ThresholdAreaChart.tsx +113 -0
  145. package/src/components/TimePicker.tsx +144 -0
  146. package/src/components/TimeRange.tsx +152 -0
  147. package/src/components/Timeline.tsx +31 -0
  148. package/src/components/Toast.tsx +72 -0
  149. package/src/components/Toggle.tsx +68 -0
  150. package/src/components/ToggleGroup.tsx +57 -0
  151. package/src/components/Tooltip.tsx +25 -0
  152. package/src/components/TransferList.tsx +227 -0
  153. package/src/components/TreeMap.tsx +126 -0
  154. package/src/components/TreeView.tsx +221 -0
  155. package/src/components/UptimeTimeline.tsx +138 -0
  156. package/src/components/VennDiagram.tsx +151 -0
  157. package/src/components/ViolinPlot.tsx +158 -0
  158. package/src/components/VirtualList.tsx +59 -0
  159. package/src/components/WaffleChart.tsx +79 -0
  160. package/src/components/WaterfallChart.tsx +164 -0
  161. package/src/components/WordCloud.tsx +107 -0
  162. package/src/icons.tsx +241 -0
  163. package/src/index.ts +160 -0
  164. package/src/styles.css +2 -0
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @w5-ui/react
2
+
3
+ React component library for the Dash UI design system. Pairs with `@w5-ui/tokens` for tokens and
4
+ `@w5-ui/assets` for brand SVGs.
5
+
6
+ ```tsx
7
+ import '@w5-ui/react/styles.css'
8
+ import { Card, Pill, Button, Topbar, Sidebar } from '@w5-ui/react'
9
+ ```
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@w5-ui/react",
3
+ "version": "0.1.0",
4
+ "description": "React 18 components for the Dash UI design system - ~170 dashboard-focused components with full type defs and SSR support. Part of Webber's design systems.",
5
+ "keywords": [
6
+ "react",
7
+ "components",
8
+ "design-system",
9
+ "dashboard",
10
+ "ui-library",
11
+ "dash-ui"
12
+ ],
13
+ "author": "Webber Takken (https://github.com/webbertakken)",
14
+ "license": "MIT",
15
+ "homepage": "https://github.com/webbertakken/dash-ui#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/webbertakken/dash-ui.git",
19
+ "directory": "packages/react"
20
+ },
21
+ "bugs": "https://github.com/webbertakken/dash-ui/issues",
22
+ "type": "module",
23
+ "sideEffects": [
24
+ "**/*.css"
25
+ ],
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./styles.css": "./src/styles.css"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "peerDependencies": {
37
+ "react": ">=18",
38
+ "react-dom": ">=18"
39
+ },
40
+ "dependencies": {
41
+ "@w5-ui/assets": "workspace:*",
42
+ "@w5-ui/tokens": "workspace:*"
43
+ },
44
+ "devDependencies": {
45
+ "@types/react": "^18.3.0",
46
+ "@types/react-dom": "^18.3.0",
47
+ "react": "^18.3.0",
48
+ "react-dom": "^18.3.0",
49
+ "typescript": "^5.5.0"
50
+ },
51
+ "files": [
52
+ "src/**",
53
+ "!src/__tests__",
54
+ "!src/__tests__/**"
55
+ ],
56
+ "scripts": {
57
+ "typecheck": "yarn run -T tsgo --noEmit -p tsconfig.build.json"
58
+ }
59
+ }
@@ -0,0 +1,65 @@
1
+ import { useState, useId, type HTMLAttributes, type ReactNode } from 'react'
2
+
3
+ export interface AccordionItemProps {
4
+ title: string
5
+ children?: ReactNode
6
+ defaultOpen?: boolean
7
+ }
8
+
9
+ export function AccordionItem({ title, children, defaultOpen = false }: AccordionItemProps) {
10
+ const [open, setOpen] = useState(defaultOpen)
11
+ const id = useId()
12
+ const btnId = `acc-btn-${id}`
13
+ const panelId = `acc-panel-${id}`
14
+ return (
15
+ <div className="accordion-item">
16
+ <h3 className="accordion-header">
17
+ <button
18
+ id={btnId}
19
+ type="button"
20
+ className="accordion-trigger"
21
+ aria-expanded={open}
22
+ aria-controls={panelId}
23
+ onClick={() => setOpen((v) => !v)}
24
+ >
25
+ <span>{title}</span>
26
+ <svg
27
+ className={`accordion-chevron${open ? ' open' : ''}`}
28
+ viewBox="0 0 16 16"
29
+ fill="none"
30
+ aria-hidden="true"
31
+ >
32
+ <path
33
+ d="M4 6l4 4 4-4"
34
+ stroke="currentColor"
35
+ strokeWidth="1.5"
36
+ strokeLinecap="round"
37
+ strokeLinejoin="round"
38
+ />
39
+ </svg>
40
+ </button>
41
+ </h3>
42
+ <div
43
+ id={panelId}
44
+ role="region"
45
+ aria-labelledby={btnId}
46
+ className="accordion-panel"
47
+ hidden={!open}
48
+ >
49
+ <div className="accordion-content">{children}</div>
50
+ </div>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ export interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
56
+ children?: ReactNode
57
+ }
58
+
59
+ export function Accordion({ children, className = '', ...rest }: AccordionProps) {
60
+ return (
61
+ <div className={`accordion ${className}`.trim()} {...rest}>
62
+ {children}
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,131 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ export interface ActionMenuItem {
5
+ id: string
6
+ label: string
7
+ danger?: boolean
8
+ disabled?: boolean
9
+ }
10
+
11
+ export interface ActionMenuProps {
12
+ items: ActionMenuItem[]
13
+ onAction: (id: string) => void
14
+ label?: string
15
+ }
16
+
17
+ export function ActionMenu({ items, onAction, label = 'Actions' }: ActionMenuProps) {
18
+ const uid = useId()
19
+ const menuId = `${uid}-menu`
20
+ const [open, setOpen] = useState(false)
21
+ const [activeIdx, setActiveIdx] = useState(0)
22
+ const triggerRef = useRef<HTMLButtonElement>(null)
23
+ const wrapperRef = useRef<HTMLDivElement>(null)
24
+
25
+ useEffect(() => {
26
+ if (!open) return
27
+ function handler(e: MouseEvent) {
28
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false)
29
+ }
30
+ document.addEventListener('mousedown', handler)
31
+ return () => document.removeEventListener('mousedown', handler)
32
+ }, [open])
33
+
34
+ function toggle() {
35
+ setOpen((prev) => {
36
+ if (!prev) setActiveIdx(0)
37
+ return !prev
38
+ })
39
+ }
40
+
41
+ function activate(id: string) {
42
+ onAction(id)
43
+ setOpen(false)
44
+ triggerRef.current?.focus()
45
+ }
46
+
47
+ function onKeyDown(e: KeyboardEvent) {
48
+ if (!open) {
49
+ if (['ArrowDown', 'Enter', ' '].includes(e.key)) {
50
+ e.preventDefault()
51
+ setActiveIdx(0)
52
+ setOpen(true)
53
+ }
54
+ return
55
+ }
56
+ if (e.key === 'Escape') {
57
+ e.preventDefault()
58
+ setOpen(false)
59
+ triggerRef.current?.focus()
60
+ } else if (e.key === 'ArrowDown') {
61
+ e.preventDefault()
62
+ setActiveIdx((i) => Math.min(i + 1, items.length - 1))
63
+ } else if (e.key === 'ArrowUp') {
64
+ e.preventDefault()
65
+ setActiveIdx((i) => Math.max(i - 1, 0))
66
+ } else if (e.key === 'Home') {
67
+ e.preventDefault()
68
+ setActiveIdx(0)
69
+ } else if (e.key === 'End') {
70
+ e.preventDefault()
71
+ setActiveIdx(items.length - 1)
72
+ } else if (e.key === 'Enter' || e.key === ' ') {
73
+ e.preventDefault()
74
+ const item = items[activeIdx]
75
+ if (item && !item.disabled) activate(item.id)
76
+ } else if (e.key === 'Tab') {
77
+ setOpen(false)
78
+ }
79
+ }
80
+
81
+ return (
82
+ <div ref={wrapperRef} className="action-menu-root">
83
+ <button
84
+ ref={triggerRef}
85
+ type="button"
86
+ className="icon-btn action-menu-trigger"
87
+ aria-label={label}
88
+ aria-haspopup="menu"
89
+ aria-expanded={open}
90
+ aria-controls={open ? menuId : undefined}
91
+ onClick={toggle}
92
+ onKeyDown={onKeyDown}
93
+ >
94
+ <svg
95
+ width="14"
96
+ height="14"
97
+ viewBox="0 0 14 14"
98
+ aria-hidden="true"
99
+ focusable="false"
100
+ fill="currentColor"
101
+ >
102
+ <circle cx="7" cy="2.5" r="1.2" />
103
+ <circle cx="7" cy="7" r="1.2" />
104
+ <circle cx="7" cy="11.5" r="1.2" />
105
+ </svg>
106
+ </button>
107
+ {open && (
108
+ <ul id={menuId} role="menu" aria-label={label} className="action-menu">
109
+ {items.map((item, idx) => (
110
+ <li
111
+ key={item.id}
112
+ role="menuitem"
113
+ tabIndex={-1}
114
+ aria-disabled={item.disabled}
115
+ data-active={idx === activeIdx ? 'true' : undefined}
116
+ data-danger={item.danger ? 'true' : undefined}
117
+ className="action-menu-item"
118
+ onMouseEnter={() => setActiveIdx(idx)}
119
+ onMouseDown={(e) => {
120
+ e.preventDefault()
121
+ if (!item.disabled) activate(item.id)
122
+ }}
123
+ >
124
+ {item.label}
125
+ </li>
126
+ ))}
127
+ </ul>
128
+ )}
129
+ </div>
130
+ )
131
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ export type ActivitySeverity = 'info' | 'success' | 'warn' | 'error' | 'neutral'
4
+
5
+ export interface ActivityItem {
6
+ id: string
7
+ title: string
8
+ description?: string
9
+ time: string
10
+ severity?: ActivitySeverity
11
+ }
12
+
13
+ export interface ActivityFeedProps {
14
+ items: ActivityItem[]
15
+ label?: string
16
+ busy?: boolean
17
+ maxHeight?: number
18
+ autoScroll?: boolean
19
+ }
20
+
21
+ export function ActivityFeed({
22
+ items,
23
+ label = 'Activity feed',
24
+ busy = false,
25
+ maxHeight = 360,
26
+ autoScroll = false,
27
+ }: ActivityFeedProps) {
28
+ const endRef = useRef<HTMLDivElement>(null)
29
+
30
+ useEffect(() => {
31
+ if (autoScroll) endRef.current?.scrollIntoView({ block: 'nearest' })
32
+ }, [items, autoScroll])
33
+
34
+ return (
35
+ <div
36
+ role="feed"
37
+ aria-label={label}
38
+ aria-busy={busy}
39
+ className="activity-feed"
40
+ style={{ maxHeight, overflowY: 'auto' }}
41
+ >
42
+ {items.map((item, i) => (
43
+ <article
44
+ key={item.id}
45
+ className={`af-item af-item--${item.severity ?? 'neutral'}`}
46
+ aria-labelledby={`af-title-${item.id}`}
47
+ aria-describedby={item.description ? `af-desc-${item.id}` : undefined}
48
+ aria-posinset={i + 1}
49
+ aria-setsize={items.length}
50
+ // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- navigable feed item for keyboard users
51
+ tabIndex={0}
52
+ >
53
+ <div className="af-dot" aria-hidden="true" />
54
+ <div className="af-body">
55
+ <div className="af-header">
56
+ <span id={`af-title-${item.id}`} className="af-title">
57
+ {item.title}
58
+ </span>
59
+ <span className="af-time">{item.time}</span>
60
+ </div>
61
+ {item.description && (
62
+ <p id={`af-desc-${item.id}`} className="af-desc">
63
+ {item.description}
64
+ </p>
65
+ )}
66
+ </div>
67
+ </article>
68
+ ))}
69
+ {items.length === 0 && (
70
+ <div className="af-empty" role="status">
71
+ No activity
72
+ </div>
73
+ )}
74
+ <div ref={endRef} />
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,33 @@
1
+ import type { ReactNode } from 'react'
2
+ import { CloseIcon } from '../icons.js'
3
+
4
+ export type AlertVariant = 'success' | 'warn' | 'danger' | 'info'
5
+
6
+ export interface AlertProps {
7
+ variant?: AlertVariant
8
+ children: ReactNode
9
+ onDismiss?: () => void
10
+ }
11
+
12
+ export function Alert({ variant = 'info', children, onDismiss }: AlertProps) {
13
+ return (
14
+ <div
15
+ className={`alert alert-${variant}`}
16
+ role={variant === 'danger' ? 'alert' : 'status'}
17
+ aria-live={variant === 'danger' ? 'assertive' : 'polite'}
18
+ aria-atomic="true"
19
+ >
20
+ <span className="alert-body">{children}</span>
21
+ {onDismiss && (
22
+ <button
23
+ type="button"
24
+ className="alert-dismiss icon-btn"
25
+ onClick={onDismiss}
26
+ aria-label="Dismiss alert"
27
+ >
28
+ <CloseIcon aria-hidden="true" />
29
+ </button>
30
+ )}
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,148 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface TimeSeriesAnnotation {
4
+ index: number
5
+ label: string
6
+ color?: string
7
+ }
8
+
9
+ export interface AnnotatedTimeSeriesProps {
10
+ data: number[]
11
+ labels?: string[]
12
+ annotations?: TimeSeriesAnnotation[]
13
+ color?: string
14
+ height?: number
15
+ ariaLabel?: string
16
+ }
17
+
18
+ const VW = 400
19
+ const PAD = { t: 16, r: 8, b: 28, l: 32 }
20
+
21
+ export function AnnotatedTimeSeries({
22
+ data,
23
+ labels = [],
24
+ annotations = [],
25
+ color = '#006FFF',
26
+ height = 160,
27
+ ariaLabel = 'Annotated time series',
28
+ }: AnnotatedTimeSeriesProps) {
29
+ const { linePath, areaPath, gridLines, xLabels, markers } = useMemo(() => {
30
+ if (data.length < 2)
31
+ return { linePath: '', areaPath: '', gridLines: [], xLabels: [], markers: [] }
32
+ const n = data.length
33
+ const chartW = VW - PAD.l - PAD.r
34
+ const chartH = height - PAD.t - PAD.b
35
+ const minV = Math.min(...data)
36
+ const maxV = Math.max(...data) || 1
37
+ const range = maxV - minV || 1
38
+ const bot = PAD.t + chartH
39
+
40
+ const toX = (i: number) => PAD.l + (i / (n - 1)) * chartW
41
+ const toY = (v: number) => PAD.t + chartH - ((v - minV) / range) * chartH
42
+
43
+ const pts = data.map((v, i) => [toX(i), toY(v)] as [number, number])
44
+ const linePath = pts
45
+ .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`)
46
+ .join(' ')
47
+ const areaPath = `${linePath} L${pts[n - 1][0].toFixed(1)},${bot} L${pts[0][0].toFixed(1)},${bot} Z`
48
+
49
+ const gridLines = [0, 0.5, 1].map((f) => ({
50
+ y: PAD.t + (1 - f) * chartH,
51
+ label: String(Math.round(minV + f * range)),
52
+ }))
53
+
54
+ const stride = n <= 8 ? 1 : Math.ceil(n / 6)
55
+ const xLabels = data
56
+ .map((_, i) => ({ x: toX(i), text: labels[i] ?? String(i), i }))
57
+ .filter(({ i }) => i % stride === 0 || i === n - 1)
58
+
59
+ const markers = annotations.map((a) => ({
60
+ x: toX(Math.max(0, Math.min(n - 1, a.index))),
61
+ label: a.label,
62
+ color: a.color ?? '#F5A623',
63
+ chartH,
64
+ }))
65
+
66
+ return { linePath, areaPath, gridLines, xLabels, markers }
67
+ }, [data, labels, annotations, height])
68
+
69
+ if (data.length < 2) return null
70
+
71
+ const n = data.length
72
+
73
+ return (
74
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
75
+ <svg
76
+ viewBox={`0 0 ${VW} ${height}`}
77
+ style={{ width: '100%', height: 'auto', display: 'block' }}
78
+ aria-hidden="true"
79
+ focusable="false"
80
+ >
81
+ {gridLines.map((g, i) => (
82
+ <g key={i}>
83
+ <line
84
+ x1={PAD.l}
85
+ y1={g.y}
86
+ x2={VW - PAD.r}
87
+ y2={g.y}
88
+ stroke="rgba(255,255,255,0.06)"
89
+ strokeWidth={1}
90
+ />
91
+ <text
92
+ x={PAD.l - 4}
93
+ y={g.y + 3}
94
+ fill="#6E7079"
95
+ fontSize={8}
96
+ textAnchor="end"
97
+ fontFamily="inherit"
98
+ >
99
+ {g.label}
100
+ </text>
101
+ </g>
102
+ ))}
103
+
104
+ <path d={areaPath} fill={color} fillOpacity={0.08} />
105
+ <path
106
+ d={linePath}
107
+ fill="none"
108
+ stroke={color}
109
+ strokeWidth={1.5}
110
+ strokeLinejoin="round"
111
+ strokeLinecap="round"
112
+ />
113
+
114
+ {markers.map((m, i) => (
115
+ <g key={i}>
116
+ <line
117
+ x1={m.x}
118
+ y1={PAD.t}
119
+ x2={m.x}
120
+ y2={PAD.t + m.chartH}
121
+ stroke={m.color}
122
+ strokeWidth={1}
123
+ strokeDasharray="3,2"
124
+ opacity={0.85}
125
+ />
126
+ <text x={m.x + 2} y={PAD.t + 8} fill={m.color} fontSize={7} fontFamily="inherit">
127
+ {m.label}
128
+ </text>
129
+ </g>
130
+ ))}
131
+
132
+ {xLabels.map((l) => (
133
+ <text
134
+ key={l.i}
135
+ x={l.x.toFixed(1)}
136
+ y={height - 6}
137
+ fill="#6E7079"
138
+ fontSize={8}
139
+ textAnchor={l.i === 0 ? 'start' : l.i === n - 1 ? 'end' : 'middle'}
140
+ fontFamily="inherit"
141
+ >
142
+ {l.text}
143
+ </text>
144
+ ))}
145
+ </svg>
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,158 @@
1
+ import { appLogos, logos } from '@w5-ui/assets'
2
+ import { Fragment, type ReactNode } from 'react'
3
+ import { CaretIcon, SearchIcon, UpdatesIcon, BellIcon, HelpIcon } from '../icons.js'
4
+ import { Avatar } from './Avatar.js'
5
+ import { IconButton } from './Button.js'
6
+
7
+ void logos
8
+
9
+ export interface AppDef {
10
+ id: string
11
+ label: string
12
+ logo: keyof typeof appLogos
13
+ }
14
+
15
+ export const DEFAULT_APPS: AppDef[] = [
16
+ { id: 'system', label: 'System', logo: 'system' },
17
+ { id: 'instances', label: 'Instances', logo: 'instances' },
18
+ { id: 'agents', label: 'Agents', logo: 'agents' },
19
+ ]
20
+
21
+ export interface TopbarProps {
22
+ siteName: string
23
+ apps?: AppDef[]
24
+ activeApp: string
25
+ onAppChange?: (id: string) => void
26
+ initials?: string
27
+ notificationCount?: number
28
+ }
29
+
30
+ export function Topbar({
31
+ siteName,
32
+ apps = DEFAULT_APPS,
33
+ activeApp,
34
+ onAppChange,
35
+ initials = 'MS',
36
+ notificationCount = 1,
37
+ }: TopbarProps) {
38
+ return (
39
+ <header className="topbar">
40
+ <button
41
+ type="button"
42
+ className="site-switch"
43
+ aria-label={`Switch site: ${siteName}`}
44
+ aria-haspopup="menu"
45
+ >
46
+ <span className="status-ring" />
47
+ <span className="site-name" aria-hidden="true">
48
+ {siteName}
49
+ </span>
50
+ <CaretIcon className="caret" aria-hidden="true" />
51
+ </button>
52
+ <nav className="app-tabs" aria-label="Apps">
53
+ {apps.map((a) => (
54
+ <button
55
+ key={a.id}
56
+ type="button"
57
+ className={`app-tab ${a.id === activeApp ? 'active' : ''}`}
58
+ aria-current={a.id === activeApp ? 'page' : undefined}
59
+ onClick={() => onAppChange?.(a.id)}
60
+ >
61
+ <img src={appLogos[a.logo]} alt="" width={24} height={24} />
62
+ <span className="label">{a.label}</span>
63
+ </button>
64
+ ))}
65
+ </nav>
66
+ <div className="topbar-spacer" />
67
+ <div className="topbar-right">
68
+ <IconButton aria-label="Search" title="Search">
69
+ <SearchIcon />
70
+ </IconButton>
71
+ <IconButton aria-label="Updates" title="Updates">
72
+ <UpdatesIcon />
73
+ </IconButton>
74
+ <IconButton
75
+ aria-label={
76
+ notificationCount > 0 ? `Notifications, ${notificationCount} new` : 'Notifications'
77
+ }
78
+ title="Notifications"
79
+ style={{ position: 'relative' }}
80
+ >
81
+ <BellIcon />
82
+ {notificationCount > 0 && (
83
+ <span
84
+ aria-hidden="true"
85
+ style={{
86
+ position: 'absolute',
87
+ top: 6,
88
+ right: 6,
89
+ width: 6,
90
+ height: 6,
91
+ background: '#F03A3A',
92
+ borderRadius: '50%',
93
+ border: '1.5px solid #0A0A0B',
94
+ }}
95
+ />
96
+ )}
97
+ </IconButton>
98
+ <IconButton aria-label="Help" title="Help">
99
+ <HelpIcon />
100
+ </IconButton>
101
+ <Avatar initials={initials} size="sm" alt={`Account, ${initials}`} />
102
+ </div>
103
+ </header>
104
+ )
105
+ }
106
+
107
+ export interface SidebarSectionDef {
108
+ title: string
109
+ items: SidebarItemDef[]
110
+ }
111
+
112
+ export interface SidebarItemDef {
113
+ id: string
114
+ label: string
115
+ icon: ReactNode
116
+ count?: number
117
+ pill?: number
118
+ }
119
+
120
+ export interface SidebarProps {
121
+ sections: SidebarSectionDef[]
122
+ activeId: string
123
+ onChange?: (id: string) => void
124
+ }
125
+
126
+ export function Sidebar({ sections, activeId, onChange }: SidebarProps) {
127
+ return (
128
+ <nav className="sidebar" aria-label="Primary">
129
+ {sections.map((sec) => (
130
+ <Fragment key={sec.title}>
131
+ <h2 className="sb-section">{sec.title}</h2>
132
+ <ul className="sb-list">
133
+ {sec.items.map((it) => (
134
+ <li key={it.id}>
135
+ <button
136
+ type="button"
137
+ className={`sb-item ${it.id === activeId ? 'active' : ''}`}
138
+ aria-current={it.id === activeId ? 'page' : undefined}
139
+ onClick={() => onChange?.(it.id)}
140
+ >
141
+ <span className="sb-ico">{it.icon}</span>
142
+ {it.label}
143
+ {it.count !== undefined && <span className="sb-count">{it.count}</span>}
144
+ {it.pill !== undefined && (
145
+ <span className="sb-pill">
146
+ {it.pill}
147
+ <span className="sr-only"> alert{it.pill !== 1 ? 's' : ''}</span>
148
+ </span>
149
+ )}
150
+ </button>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ </Fragment>
155
+ ))}
156
+ </nav>
157
+ )
158
+ }