@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
@@ -0,0 +1,142 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface SunburstNode {
4
+ label: string
5
+ value?: number
6
+ color?: string
7
+ children?: SunburstNode[]
8
+ }
9
+
10
+ export interface SunburstChartProps {
11
+ root: SunburstNode
12
+ ariaLabel?: string
13
+ }
14
+
15
+ const PALETTE = ['#006FFF', '#00C875', '#A78BFA', '#F5A623', '#50B8E7', '#F04949']
16
+ const CX = 160,
17
+ CY = 160,
18
+ R_HOLE = 48,
19
+ R_MID = 97,
20
+ R_OUTER = 142,
21
+ GAP = 0.01
22
+
23
+ function arcPath(r1: number, r2: number, a1: number, a2: number): string {
24
+ const lg = a2 - a1 > Math.PI ? 1 : 0
25
+ const p = (r: number, a: number) =>
26
+ `${(CX + r * Math.cos(a)).toFixed(1)},${(CY + r * Math.sin(a)).toFixed(1)}`
27
+ return `M${p(r2, a1)} A${r2},${r2} 0 ${lg},1 ${p(r2, a2)} L${p(r1, a2)} A${r1},${r1} 0 ${lg},0 ${p(r1, a1)} Z`
28
+ }
29
+
30
+ export function SunburstChart({ root, ariaLabel = 'Sunburst chart' }: SunburstChartProps) {
31
+ const { paths, labels, total } = useMemo(() => {
32
+ const items = root.children ?? []
33
+ const total = items.reduce(
34
+ (s, c) => s + (c.value ?? c.children?.reduce((cs, x) => cs + (x.value ?? 0), 0) ?? 0),
35
+ 0,
36
+ )
37
+ if (!total)
38
+ return {
39
+ paths: [] as { d: string; fill: string }[],
40
+ labels: [] as { x: number; y: number; anchor: 'start' | 'end' | 'middle'; text: string }[],
41
+ total: 0,
42
+ }
43
+
44
+ const paths: { d: string; fill: string }[] = []
45
+ const labels: { x: number; y: number; anchor: 'start' | 'end' | 'middle'; text: string }[] = []
46
+ let a = -Math.PI / 2
47
+
48
+ for (let i = 0; i < items.length; i++) {
49
+ const c = items[i]
50
+ const fill = c.color ?? PALETTE[i % PALETTE.length]
51
+ const cv = c.value ?? c.children?.reduce((s, x) => s + (x.value ?? 0), 0) ?? 0
52
+ const span = (cv / total) * 2 * Math.PI
53
+ const a2 = a + span
54
+ const midA = (a + a2) / 2
55
+
56
+ paths.push({ d: arcPath(R_MID + 2, R_OUTER, a + GAP, a2 - GAP), fill })
57
+
58
+ if (span > 0.22) {
59
+ const r = (R_MID + 2 + R_OUTER) / 2
60
+ const anchor: 'start' | 'end' | 'middle' =
61
+ Math.cos(midA) > 0.15 ? 'start' : Math.cos(midA) < -0.15 ? 'end' : 'middle'
62
+ labels.push({
63
+ x: CX + r * Math.cos(midA),
64
+ y: CY + r * Math.sin(midA) + 4,
65
+ anchor,
66
+ text: c.label,
67
+ })
68
+ }
69
+
70
+ if (c.children?.length) {
71
+ let ca = a
72
+ for (const child of c.children) {
73
+ const cs = ((child.value ?? 0) / cv) * span
74
+ if (cs > GAP * 2)
75
+ paths.push({
76
+ d: arcPath(R_HOLE, R_MID - 2, ca + GAP, ca + cs - GAP),
77
+ fill: fill + '99',
78
+ })
79
+ ca += cs
80
+ }
81
+ } else {
82
+ paths.push({ d: arcPath(R_HOLE, R_MID - 2, a + GAP, a2 - GAP), fill: fill + '99' })
83
+ }
84
+
85
+ a = a2
86
+ }
87
+
88
+ return { paths, labels, total }
89
+ }, [root])
90
+
91
+ const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
92
+
93
+ return (
94
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
95
+ <svg
96
+ viewBox="0 0 320 320"
97
+ style={{ width: '100%', height: 'auto', display: 'block' }}
98
+ aria-hidden="true"
99
+ focusable="false"
100
+ >
101
+ {paths.map((p, i) => (
102
+ <path key={i} d={p.d} fill={p.fill} />
103
+ ))}
104
+ {labels.map((l, i) => (
105
+ <text
106
+ key={i}
107
+ x={l.x.toFixed(1)}
108
+ y={l.y.toFixed(1)}
109
+ textAnchor={l.anchor}
110
+ fill="#fff"
111
+ fontSize={10}
112
+ fontWeight={600}
113
+ fontFamily="inherit"
114
+ >
115
+ {l.text}
116
+ </text>
117
+ ))}
118
+ <text
119
+ x={CX}
120
+ y={CY - 8}
121
+ textAnchor="middle"
122
+ fill="#fff"
123
+ fontSize={22}
124
+ fontWeight={700}
125
+ fontFamily="inherit"
126
+ >
127
+ {fmt(total)}
128
+ </text>
129
+ <text
130
+ x={CX}
131
+ y={CY + 12}
132
+ textAnchor="middle"
133
+ fill="#6E7079"
134
+ fontSize={10}
135
+ fontFamily="inherit"
136
+ >
137
+ {root.label}
138
+ </text>
139
+ </svg>
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1,59 @@
1
+ export type SwitchPortStatus = 'up' | 'poe' | 'down'
2
+
3
+ export interface SwitchPort {
4
+ status: SwitchPortStatus
5
+ speed?: string
6
+ label?: string
7
+ }
8
+
9
+ export interface SwitchPortGridProps {
10
+ ports: SwitchPort[]
11
+ columns?: number
12
+ onPortClick?: (index: number) => void
13
+ ariaLabel?: string
14
+ }
15
+
16
+ const STATUS_LABEL: Record<SwitchPortStatus, string> = {
17
+ up: 'Connected',
18
+ poe: 'PoE Active',
19
+ down: 'Down',
20
+ }
21
+
22
+ export function SwitchPortGrid({
23
+ ports,
24
+ columns = 12,
25
+ onPortClick,
26
+ ariaLabel = 'Switch port panel',
27
+ }: SwitchPortGridProps) {
28
+ return (
29
+ <ul
30
+ className="spg"
31
+ aria-label={ariaLabel}
32
+ style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
33
+ >
34
+ {ports.map((port, i) => {
35
+ const n = i + 1
36
+ const desc = `Port ${n}: ${STATUS_LABEL[port.status]}${port.speed ? ` · ${port.speed}` : ''}${port.label ? ` · ${port.label}` : ''}`
37
+ return (
38
+ <li key={i}>
39
+ <button
40
+ type="button"
41
+ className="spg__btn"
42
+ data-status={port.status}
43
+ aria-label={desc}
44
+ onClick={() => onPortClick?.(i)}
45
+ >
46
+ <span className="spg__num">{n}</span>
47
+ {port.speed && <span className={`spg__speed--${port.status}`}>{port.speed}</span>}
48
+ {port.status === 'poe' && (
49
+ <span className="spg__poe" aria-hidden="true">
50
+ PoE
51
+ </span>
52
+ )}
53
+ </button>
54
+ </li>
55
+ )
56
+ })}
57
+ </ul>
58
+ )
59
+ }
@@ -0,0 +1,91 @@
1
+ import { useRef, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
2
+
3
+ export interface TabItem {
4
+ id: string
5
+ label: ReactNode
6
+ badge?: ReactNode
7
+ }
8
+
9
+ export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
10
+ id: string
11
+ active: string
12
+ children?: ReactNode
13
+ }
14
+
15
+ export function TabPanel({ id, active, children, ...rest }: TabPanelProps) {
16
+ const isActive = id === active
17
+ return (
18
+ <div
19
+ role="tabpanel"
20
+ id={`tabpanel-${id}`}
21
+ aria-labelledby={`tab-${id}`}
22
+ hidden={!isActive}
23
+ tabIndex={0}
24
+ {...rest}
25
+ >
26
+ {isActive ? children : null}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export interface TabsProps {
32
+ items: TabItem[]
33
+ active: string
34
+ onChange?: (id: string) => void
35
+ ariaLabel?: string
36
+ }
37
+
38
+ export function Tabs({ items, active, onChange, ariaLabel }: TabsProps) {
39
+ const refs = useRef<(HTMLButtonElement | null)[]>([])
40
+
41
+ const focusAt = (idx: number) => {
42
+ const next = items[(idx + items.length) % items.length]
43
+ if (!next) return
44
+ onChange?.(next.id)
45
+ refs.current[(idx + items.length) % items.length]?.focus()
46
+ }
47
+
48
+ const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
49
+ if (e.key === 'ArrowRight') {
50
+ e.preventDefault()
51
+ focusAt(i + 1)
52
+ } else if (e.key === 'ArrowLeft') {
53
+ e.preventDefault()
54
+ focusAt(i - 1)
55
+ } else if (e.key === 'Home') {
56
+ e.preventDefault()
57
+ focusAt(0)
58
+ } else if (e.key === 'End') {
59
+ e.preventDefault()
60
+ focusAt(items.length - 1)
61
+ }
62
+ }
63
+
64
+ return (
65
+ <div className="tabs" role="tablist" aria-label={ariaLabel}>
66
+ {items.map((t, i) => {
67
+ const selected = t.id === active
68
+ return (
69
+ <button
70
+ key={t.id}
71
+ ref={(el) => {
72
+ refs.current[i] = el
73
+ }}
74
+ type="button"
75
+ role="tab"
76
+ id={`tab-${t.id}`}
77
+ aria-selected={selected}
78
+ aria-controls={`tabpanel-${t.id}`}
79
+ tabIndex={selected ? 0 : -1}
80
+ className={`tab ${selected ? 'active' : ''}`}
81
+ onClick={() => onChange?.(t.id)}
82
+ onKeyDown={(e) => onKey(e, i)}
83
+ >
84
+ {t.label}
85
+ {t.badge !== undefined && <span className="badge">{t.badge}</span>}
86
+ </button>
87
+ )
88
+ })}
89
+ </div>
90
+ )
91
+ }
@@ -0,0 +1,37 @@
1
+ export interface TagProps {
2
+ label: string
3
+ onRemove?: () => void
4
+ className?: string
5
+ }
6
+
7
+ export function Tag({ label, onRemove, className = '' }: TagProps) {
8
+ return (
9
+ <span className={`tag ${className}`.trim()}>
10
+ <span className="tag__label">{label}</span>
11
+ {onRemove && (
12
+ <button
13
+ type="button"
14
+ className="tag__remove"
15
+ aria-label={`Remove ${label} filter`}
16
+ onClick={onRemove}
17
+ >
18
+ <svg
19
+ viewBox="0 0 10 10"
20
+ width="10"
21
+ height="10"
22
+ fill="none"
23
+ aria-hidden="true"
24
+ focusable={false}
25
+ >
26
+ <path
27
+ d="M2 2l6 6M8 2l-6 6"
28
+ stroke="currentColor"
29
+ strokeWidth="1.5"
30
+ strokeLinecap="round"
31
+ />
32
+ </svg>
33
+ </button>
34
+ )}
35
+ </span>
36
+ )
37
+ }
@@ -0,0 +1,99 @@
1
+ import { useId, useRef, useState } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+ import { Tag } from './Tag.js'
4
+
5
+ export interface TagInputProps {
6
+ label?: string
7
+ value?: string[]
8
+ defaultValue?: string[]
9
+ onChange?: (tags: string[]) => void
10
+ placeholder?: string
11
+ id?: string
12
+ disabled?: boolean
13
+ className?: string
14
+ }
15
+
16
+ export function TagInput({
17
+ label,
18
+ value: valueProp,
19
+ defaultValue = [],
20
+ onChange,
21
+ placeholder = 'Type and press Enter…',
22
+ id,
23
+ disabled = false,
24
+ className = '',
25
+ }: TagInputProps) {
26
+ const gid = useId()
27
+ const inputId = id ?? gid
28
+ const listId = `${inputId}-list`
29
+
30
+ const [internal, setInternal] = useState<string[]>(defaultValue)
31
+ const [draft, setDraft] = useState('')
32
+ const inputRef = useRef<HTMLInputElement>(null)
33
+
34
+ const tags = valueProp !== undefined ? valueProp : internal
35
+
36
+ function commit(next: string[]) {
37
+ if (valueProp === undefined) setInternal(next)
38
+ onChange?.(next)
39
+ }
40
+
41
+ function addTag(raw: string) {
42
+ const trimmed = raw.trim()
43
+ if (!trimmed || tags.includes(trimmed)) return
44
+ commit([...tags, trimmed])
45
+ setDraft('')
46
+ }
47
+
48
+ function removeTag(index: number) {
49
+ commit(tags.filter((_, i) => i !== index))
50
+ }
51
+
52
+ function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
53
+ if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
54
+ if (draft.trim()) {
55
+ e.preventDefault()
56
+ addTag(draft)
57
+ }
58
+ } else if (e.key === 'Backspace' && draft === '' && tags.length > 0) {
59
+ removeTag(tags.length - 1)
60
+ }
61
+ }
62
+
63
+ return (
64
+ <div className={`tag-input-wrapper${className ? ` ${className}` : ''}`}>
65
+ {label && (
66
+ <label htmlFor={inputId} className="tag-input__label">
67
+ {label}
68
+ </label>
69
+ )}
70
+ <div
71
+ id={listId}
72
+ role="group"
73
+ aria-label={label ?? 'Tags'}
74
+ className={`tag-input${disabled ? ' tag-input--disabled' : ''}`}
75
+ onClick={() => inputRef.current?.focus()}
76
+ >
77
+ {tags.map((t, i) => (
78
+ <Tag key={t} label={t} onRemove={disabled ? undefined : () => removeTag(i)} />
79
+ ))}
80
+ <input
81
+ ref={inputRef}
82
+ id={inputId}
83
+ type="text"
84
+ className="tag-input__field"
85
+ value={draft}
86
+ disabled={disabled}
87
+ placeholder={tags.length === 0 ? placeholder : ''}
88
+ aria-label={label ?? 'Add tag'}
89
+ autoComplete="off"
90
+ onChange={(e) => setDraft(e.target.value)}
91
+ onKeyDown={handleKeyDown}
92
+ onBlur={() => {
93
+ if (draft.trim()) addTag(draft)
94
+ }}
95
+ />
96
+ </div>
97
+ </div>
98
+ )
99
+ }
@@ -0,0 +1,9 @@
1
+ import type { TextareaHTMLAttributes } from 'react'
2
+
3
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
4
+ rows?: number
5
+ }
6
+
7
+ export function Textarea({ className = '', rows = 4, ...rest }: TextareaProps) {
8
+ return <textarea className={`input textarea ${className}`.trim()} rows={rows} {...rest} />
9
+ }
@@ -0,0 +1,113 @@
1
+ import { useId } from 'react'
2
+
3
+ export interface ThresholdAreaChartProps {
4
+ values: number[]
5
+ labels?: string[]
6
+ threshold: number
7
+ thresholdLabel?: string
8
+ belowColor?: string
9
+ aboveColor?: string
10
+ height?: number
11
+ ariaLabel?: string
12
+ }
13
+
14
+ const VW = 400
15
+ const PAD = { t: 16, r: 8, b: 28, l: 8 }
16
+
17
+ export function ThresholdAreaChart({
18
+ values,
19
+ labels = [],
20
+ threshold,
21
+ thresholdLabel,
22
+ belowColor = '#00C875',
23
+ aboveColor = '#FF7B7B',
24
+ height = 160,
25
+ ariaLabel = 'Threshold area chart',
26
+ }: ThresholdAreaChartProps) {
27
+ const uid = useId()
28
+ const n = values.length
29
+ if (!n) return null
30
+
31
+ const chartW = VW - PAD.l - PAD.r
32
+ const chartH = height - PAD.t - PAD.b
33
+ const maxV = Math.max(...values, threshold, 1)
34
+
35
+ const toX = (i: number) => PAD.l + (n > 1 ? (i / (n - 1)) * chartW : chartW / 2)
36
+ const toY = (v: number) => PAD.t + chartH - (v / maxV) * chartH
37
+
38
+ const threshY = toY(threshold)
39
+ const baseY = PAD.t + chartH
40
+ const pts = values.map((v, i) => `${toX(i).toFixed(1)},${toY(v).toFixed(1)}`)
41
+ const d = `M ${pts.join(' L ')} L ${toX(n - 1).toFixed(1)},${baseY} L ${toX(0).toFixed(1)},${baseY} Z`
42
+ const tLabel = thresholdLabel ?? String(threshold)
43
+
44
+ const belowId = `${uid}b`
45
+ const aboveId = `${uid}a`
46
+
47
+ return (
48
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
49
+ <svg
50
+ viewBox={`0 0 ${VW} ${height}`}
51
+ style={{ width: '100%', height: 'auto', display: 'block' }}
52
+ aria-hidden="true"
53
+ focusable="false"
54
+ >
55
+ <defs>
56
+ <clipPath id={belowId}>
57
+ <rect x={0} y={threshY} width={VW} height={height - threshY} />
58
+ </clipPath>
59
+ <clipPath id={aboveId}>
60
+ <rect x={0} y={0} width={VW} height={threshY} />
61
+ </clipPath>
62
+ </defs>
63
+ {[0.25, 0.5, 0.75, 1].map((f, i) => (
64
+ <line
65
+ key={i}
66
+ x1={PAD.l}
67
+ y1={PAD.t + (1 - f) * chartH}
68
+ x2={VW - PAD.r}
69
+ y2={PAD.t + (1 - f) * chartH}
70
+ stroke="rgba(255,255,255,0.06)"
71
+ strokeWidth={1}
72
+ />
73
+ ))}
74
+ <path d={d} fill={belowColor} fillOpacity={0.7} clipPath={`url(#${belowId})`} />
75
+ <path d={d} fill={aboveColor} fillOpacity={0.7} clipPath={`url(#${aboveId})`} />
76
+ <line
77
+ x1={PAD.l}
78
+ y1={threshY}
79
+ x2={VW - PAD.r}
80
+ y2={threshY}
81
+ stroke={aboveColor}
82
+ strokeWidth={1}
83
+ strokeDasharray="4 3"
84
+ opacity={0.8}
85
+ />
86
+ <text
87
+ x={VW - PAD.r - 2}
88
+ y={threshY - 4}
89
+ fill={aboveColor}
90
+ fontSize={9}
91
+ textAnchor="end"
92
+ fontFamily="inherit"
93
+ opacity={0.9}
94
+ >
95
+ {tLabel}
96
+ </text>
97
+ {labels.map((lbl, i) => (
98
+ <text
99
+ key={i}
100
+ x={toX(i)}
101
+ y={height - 4}
102
+ textAnchor="middle"
103
+ fill="#6E7079"
104
+ fontSize={9}
105
+ fontFamily="inherit"
106
+ >
107
+ {lbl}
108
+ </text>
109
+ ))}
110
+ </svg>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,144 @@
1
+ import { useId, useRef, useState } from 'react'
2
+ import type { KeyboardEvent, ChangeEvent } from 'react'
3
+
4
+ export interface TimePickerProps {
5
+ label?: string
6
+ value?: string
7
+ defaultValue?: string
8
+ disabled?: boolean
9
+ onChange?: (value: string) => void
10
+ id?: string
11
+ className?: string
12
+ }
13
+
14
+ function pad(n: number): string {
15
+ return String(n).padStart(2, '0')
16
+ }
17
+
18
+ function parseTime(t: string): [number, number] {
19
+ const [h = '0', m = '0'] = t.split(':')
20
+ return [
21
+ Math.min(23, Math.max(0, parseInt(h, 10) || 0)),
22
+ Math.min(59, Math.max(0, parseInt(m, 10) || 0)),
23
+ ]
24
+ }
25
+
26
+ export function TimePicker({
27
+ label,
28
+ value: valueProp,
29
+ defaultValue = '00:00',
30
+ disabled = false,
31
+ onChange,
32
+ id,
33
+ className = '',
34
+ }: TimePickerProps) {
35
+ const gid = useId()
36
+ const inputId = id ?? gid
37
+ const [internal, setInternal] = useState<[number, number]>(() =>
38
+ parseTime(valueProp ?? defaultValue),
39
+ )
40
+ const refs = useRef<[HTMLInputElement | null, HTMLInputElement | null]>([null, null])
41
+
42
+ const [hours, minutes] = valueProp !== undefined ? parseTime(valueProp) : internal
43
+
44
+ function commit(h: number, m: number) {
45
+ if (valueProp === undefined) setInternal([h, m])
46
+ onChange?.(`${pad(h)}:${pad(m)}`)
47
+ }
48
+
49
+ function handleKey(field: 0 | 1, e: KeyboardEvent<HTMLInputElement>) {
50
+ const val = field === 0 ? hours : minutes
51
+ const max = field === 0 ? 23 : 59
52
+ if (e.key === 'ArrowUp') {
53
+ e.preventDefault()
54
+ const next = val >= max ? 0 : val + 1
55
+ if (field === 0) commit(next, minutes)
56
+ else commit(hours, next)
57
+ } else if (e.key === 'ArrowDown') {
58
+ e.preventDefault()
59
+ const next = val <= 0 ? max : val - 1
60
+ if (field === 0) commit(next, minutes)
61
+ else commit(hours, next)
62
+ } else if ((e.key === ':' || e.key === 'ArrowRight') && field === 0) {
63
+ e.preventDefault()
64
+ refs.current[1]?.focus()
65
+ } else if (e.key === 'ArrowLeft' && field === 1) {
66
+ e.preventDefault()
67
+ refs.current[0]?.focus()
68
+ }
69
+ }
70
+
71
+ function handleChange(field: 0 | 1, e: ChangeEvent<HTMLInputElement>) {
72
+ const raw = e.target.value
73
+ if (!/^\d{0,2}$/.test(raw)) return
74
+ const n = parseInt(raw, 10)
75
+ if (isNaN(n)) return
76
+ const max = field === 0 ? 23 : 59
77
+ const clamped = Math.min(max, n)
78
+ if (field === 0) commit(clamped, minutes)
79
+ else commit(hours, clamped)
80
+ if (raw.length === 2 && field === 0) refs.current[1]?.focus()
81
+ }
82
+
83
+ return (
84
+ <div className={`time-picker-wrapper${className ? ` ${className}` : ''}`}>
85
+ {label && (
86
+ <label htmlFor={`${inputId}-h`} className="time-picker__label">
87
+ {label}
88
+ </label>
89
+ )}
90
+ <div
91
+ className={`time-picker${disabled ? ' time-picker--disabled' : ''}`}
92
+ role="group"
93
+ aria-label={label ?? 'Time'}
94
+ >
95
+ <input
96
+ ref={(el) => {
97
+ refs.current[0] = el
98
+ }}
99
+ id={`${inputId}-h`}
100
+ type="text"
101
+ inputMode="numeric"
102
+ pattern="[0-9]*"
103
+ role="spinbutton"
104
+ aria-label="Hours"
105
+ aria-valuenow={hours}
106
+ aria-valuemin={0}
107
+ aria-valuemax={23}
108
+ aria-valuetext={pad(hours)}
109
+ value={pad(hours)}
110
+ disabled={disabled}
111
+ maxLength={2}
112
+ className="time-picker__field"
113
+ onChange={(e) => handleChange(0, e)}
114
+ onKeyDown={(e) => handleKey(0, e)}
115
+ onFocus={(e) => e.currentTarget.select()}
116
+ />
117
+ <span className="time-picker__sep" aria-hidden="true">
118
+ :
119
+ </span>
120
+ <input
121
+ ref={(el) => {
122
+ refs.current[1] = el
123
+ }}
124
+ type="text"
125
+ inputMode="numeric"
126
+ pattern="[0-9]*"
127
+ role="spinbutton"
128
+ aria-label="Minutes"
129
+ aria-valuenow={minutes}
130
+ aria-valuemin={0}
131
+ aria-valuemax={59}
132
+ aria-valuetext={pad(minutes)}
133
+ value={pad(minutes)}
134
+ disabled={disabled}
135
+ maxLength={2}
136
+ className="time-picker__field"
137
+ onChange={(e) => handleChange(1, e)}
138
+ onKeyDown={(e) => handleKey(1, e)}
139
+ onFocus={(e) => e.currentTarget.select()}
140
+ />
141
+ </div>
142
+ </div>
143
+ )
144
+ }