@hadoku/task-ui-components 0.0.0-stage → 4.20.3

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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +917 -2
  3. package/dist/app-header.css +288 -0
  4. package/dist/bento.css +143 -0
  5. package/dist/button.css +227 -0
  6. package/dist/components/AppHeader.d.ts +61 -0
  7. package/dist/components/AppHeader.d.ts.map +1 -0
  8. package/dist/components/AppHeader.js +23 -0
  9. package/dist/components/AppHeader.js.map +1 -0
  10. package/dist/components/BentoCard.d.ts +23 -0
  11. package/dist/components/BentoCard.d.ts.map +1 -0
  12. package/dist/components/BentoCard.js +49 -0
  13. package/dist/components/BentoCard.js.map +1 -0
  14. package/dist/components/BentoGrid.d.ts +24 -0
  15. package/dist/components/BentoGrid.d.ts.map +1 -0
  16. package/dist/components/BentoGrid.js +22 -0
  17. package/dist/components/BentoGrid.js.map +1 -0
  18. package/dist/components/Button.d.ts +28 -0
  19. package/dist/components/Button.d.ts.map +1 -0
  20. package/dist/components/Button.js +51 -0
  21. package/dist/components/Button.js.map +1 -0
  22. package/dist/components/ConnectedSettings.d.ts +53 -0
  23. package/dist/components/ConnectedSettings.d.ts.map +1 -0
  24. package/dist/components/ConnectedSettings.js +302 -0
  25. package/dist/components/ConnectedSettings.js.map +1 -0
  26. package/dist/components/ConnectedThemePicker.d.ts +52 -0
  27. package/dist/components/ConnectedThemePicker.d.ts.map +1 -0
  28. package/dist/components/ConnectedThemePicker.js +32 -0
  29. package/dist/components/ConnectedThemePicker.js.map +1 -0
  30. package/dist/components/ContextMenu.d.ts +22 -0
  31. package/dist/components/ContextMenu.d.ts.map +1 -0
  32. package/dist/components/ContextMenu.js +23 -0
  33. package/dist/components/ContextMenu.js.map +1 -0
  34. package/dist/components/HadokuThemePicker.d.ts +27 -0
  35. package/dist/components/HadokuThemePicker.d.ts.map +1 -0
  36. package/dist/components/HadokuThemePicker.js +19 -0
  37. package/dist/components/HadokuThemePicker.js.map +1 -0
  38. package/dist/components/LoadingSkeleton.d.ts +14 -0
  39. package/dist/components/LoadingSkeleton.d.ts.map +1 -0
  40. package/dist/components/LoadingSkeleton.js +62 -0
  41. package/dist/components/LoadingSkeleton.js.map +1 -0
  42. package/dist/components/Modal.d.ts +39 -0
  43. package/dist/components/Modal.d.ts.map +1 -0
  44. package/dist/components/Modal.js +106 -0
  45. package/dist/components/Modal.js.map +1 -0
  46. package/dist/components/NumberInput.d.ts +26 -0
  47. package/dist/components/NumberInput.d.ts.map +1 -0
  48. package/dist/components/NumberInput.js +131 -0
  49. package/dist/components/NumberInput.js.map +1 -0
  50. package/dist/components/Select.d.ts +52 -0
  51. package/dist/components/Select.d.ts.map +1 -0
  52. package/dist/components/Select.js +281 -0
  53. package/dist/components/Select.js.map +1 -0
  54. package/dist/components/SettingsModal.d.ts +69 -0
  55. package/dist/components/SettingsModal.d.ts.map +1 -0
  56. package/dist/components/SettingsModal.js +141 -0
  57. package/dist/components/SettingsModal.js.map +1 -0
  58. package/dist/components/ThemeIcons.d.ts +26 -0
  59. package/dist/components/ThemeIcons.d.ts.map +1 -0
  60. package/dist/components/ThemeIcons.js +74 -0
  61. package/dist/components/ThemeIcons.js.map +1 -0
  62. package/dist/components/ThemePicker.d.ts +28 -0
  63. package/dist/components/ThemePicker.d.ts.map +1 -0
  64. package/dist/components/ThemePicker.js +101 -0
  65. package/dist/components/ThemePicker.js.map +1 -0
  66. package/dist/components/Toast.d.ts +13 -0
  67. package/dist/components/Toast.d.ts.map +1 -0
  68. package/dist/components/Toast.js +70 -0
  69. package/dist/components/Toast.js.map +1 -0
  70. package/dist/components/Toaster.d.ts +19 -0
  71. package/dist/components/Toaster.d.ts.map +1 -0
  72. package/dist/components/Toaster.js +19 -0
  73. package/dist/components/Toaster.js.map +1 -0
  74. package/dist/components/Tooltip.d.ts +49 -0
  75. package/dist/components/Tooltip.d.ts.map +1 -0
  76. package/dist/components/Tooltip.js +143 -0
  77. package/dist/components/Tooltip.js.map +1 -0
  78. package/dist/context-menu.css +11 -0
  79. package/dist/controls.css +137 -0
  80. package/dist/hooks/useReducedMotion.d.ts +7 -0
  81. package/dist/hooks/useReducedMotion.d.ts.map +1 -0
  82. package/dist/hooks/useReducedMotion.js +25 -0
  83. package/dist/hooks/useReducedMotion.js.map +1 -0
  84. package/dist/hooks/useToast.d.ts +18 -0
  85. package/dist/hooks/useToast.d.ts.map +1 -0
  86. package/dist/hooks/useToast.js +28 -0
  87. package/dist/hooks/useToast.js.map +1 -0
  88. package/dist/icon-btn.css +64 -0
  89. package/dist/index.d.ts +49 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +72 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/lib/menuPlacement.d.ts +16 -0
  94. package/dist/lib/menuPlacement.d.ts.map +1 -0
  95. package/dist/lib/menuPlacement.js +19 -0
  96. package/dist/lib/menuPlacement.js.map +1 -0
  97. package/dist/lib/selectMenus.d.ts +30 -0
  98. package/dist/lib/selectMenus.d.ts.map +1 -0
  99. package/dist/lib/selectMenus.js +236 -0
  100. package/dist/lib/selectMenus.js.map +1 -0
  101. package/dist/lib/settingsClient.d.ts +120 -0
  102. package/dist/lib/settingsClient.d.ts.map +1 -0
  103. package/dist/lib/settingsClient.js +140 -0
  104. package/dist/lib/settingsClient.js.map +1 -0
  105. package/dist/lib/themeScope.d.ts +19 -0
  106. package/dist/lib/themeScope.d.ts.map +1 -0
  107. package/dist/lib/themeScope.js +10 -0
  108. package/dist/lib/themeScope.js.map +1 -0
  109. package/dist/lib/titleTooltips.d.ts +38 -0
  110. package/dist/lib/titleTooltips.d.ts.map +1 -0
  111. package/dist/lib/titleTooltips.js +166 -0
  112. package/dist/lib/titleTooltips.js.map +1 -0
  113. package/dist/lib/tooltipPlacement.d.ts +14 -0
  114. package/dist/lib/tooltipPlacement.d.ts.map +1 -0
  115. package/dist/lib/tooltipPlacement.js +34 -0
  116. package/dist/lib/tooltipPlacement.js.map +1 -0
  117. package/dist/modal.css +129 -0
  118. package/dist/number-input.css +131 -0
  119. package/dist/scrollbar.css +165 -0
  120. package/dist/select.css +251 -0
  121. package/dist/styles.css +1866 -0
  122. package/dist/task-ui-components.css +1866 -0
  123. package/dist/theme-picker.css +219 -0
  124. package/dist/toaster.css +159 -0
  125. package/dist/tooltip.css +103 -0
  126. package/dist/types/index.d.ts +37 -0
  127. package/dist/types/index.d.ts.map +1 -0
  128. package/package.json +60 -4
  129. package/src/app-header.css +288 -0
  130. package/src/bento.css +143 -0
  131. package/src/button.css +227 -0
  132. package/src/components/AppHeader.tsx +87 -0
  133. package/src/components/BentoCard.tsx +72 -0
  134. package/src/components/BentoGrid.tsx +48 -0
  135. package/src/components/Button.tsx +81 -0
  136. package/src/components/ConnectedSettings.tsx +465 -0
  137. package/src/components/ConnectedThemePicker.tsx +79 -0
  138. package/src/components/ContextMenu.tsx +48 -0
  139. package/src/components/HadokuThemePicker.tsx +39 -0
  140. package/src/components/LoadingSkeleton.tsx +84 -0
  141. package/src/components/Modal.tsx +171 -0
  142. package/src/components/NumberInput.tsx +161 -0
  143. package/src/components/Select.tsx +359 -0
  144. package/src/components/SettingsModal.tsx +235 -0
  145. package/src/components/ThemeIcons.tsx +127 -0
  146. package/src/components/ThemePicker.tsx +149 -0
  147. package/src/components/Toast.tsx +92 -0
  148. package/src/components/Toaster.tsx +43 -0
  149. package/src/components/Tooltip.tsx +212 -0
  150. package/src/context-menu.css +11 -0
  151. package/src/controls.css +137 -0
  152. package/src/hooks/useReducedMotion.ts +36 -0
  153. package/src/hooks/useToast.ts +57 -0
  154. package/src/icon-btn.css +64 -0
  155. package/src/index.ts +140 -0
  156. package/src/lib/menuPlacement.ts +31 -0
  157. package/src/lib/selectMenus.ts +303 -0
  158. package/src/lib/settingsClient.ts +377 -0
  159. package/src/lib/themeScope.ts +23 -0
  160. package/src/lib/titleTooltips.ts +236 -0
  161. package/src/lib/tooltipPlacement.ts +59 -0
  162. package/src/modal.css +129 -0
  163. package/src/number-input.css +131 -0
  164. package/src/scrollbar.css +165 -0
  165. package/src/select.css +251 -0
  166. package/src/theme-picker.css +219 -0
  167. package/src/toaster.css +159 -0
  168. package/src/tooltip.css +103 -0
  169. package/src/types/index.ts +43 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Component icons - Simple monochrome SVG icons for theme picker
3
+ * All icons use currentColor so they can be styled with CSS
4
+ */
5
+
6
+ import React from 'react'
7
+
8
+ const iconProps = {
9
+ width: 20,
10
+ height: 20,
11
+ viewBox: '0 0 24 24',
12
+ fill: 'none',
13
+ stroke: 'currentColor',
14
+ strokeWidth: 2,
15
+ strokeLinecap: 'round' as const,
16
+ strokeLinejoin: 'round' as const
17
+ }
18
+
19
+ export const SettingsIcon = () => (
20
+ <svg {...iconProps}>
21
+ {/* Gear teeth as rectangles */}
22
+ <rect x="11" y="1" width="2" height="3" fill="currentColor" />
23
+ <rect x="16.5" y="3.5" width="3" height="2" fill="currentColor" transform="rotate(45 18 4.5)" />
24
+ <rect x="19" y="11" width="3" height="2" fill="currentColor" />
25
+ <rect
26
+ x="16.5"
27
+ y="18.5"
28
+ width="3"
29
+ height="2"
30
+ fill="currentColor"
31
+ transform="rotate(-45 18 19.5)"
32
+ />
33
+ <rect x="11" y="20" width="2" height="3" fill="currentColor" />
34
+ <rect x="4.5" y="18.5" width="3" height="2" fill="currentColor" transform="rotate(45 6 19.5)" />
35
+ <rect x="2" y="11" width="3" height="2" fill="currentColor" />
36
+ <rect x="4.5" y="3.5" width="3" height="2" fill="currentColor" transform="rotate(-45 6 4.5)" />
37
+
38
+ {/* Outer ring */}
39
+ <circle cx="12" cy="12" r="7" fill="currentColor" />
40
+
41
+ {/* Center hole */}
42
+ <circle cx="12" cy="12" r="4" fill="var(--color-bg-card)" />
43
+ </svg>
44
+ )
45
+
46
+ export const TagIcon = () => (
47
+ <svg {...iconProps} width={16} height={16} viewBox="0 0 20 20">
48
+ {/* Tag body - rectangular shape pointing right */}
49
+ <path d="M2 4 L12 4 L16 10 L12 16 L2 16 Z" fill="currentColor" />
50
+ {/* Hole in tag */}
51
+ <circle cx="6" cy="10" r="1.5" fill="white" />
52
+ </svg>
53
+ )
54
+
55
+ export const CircleIcon = () => (
56
+ <svg {...iconProps}>
57
+ <circle cx="12" cy="12" r="8" />
58
+ </svg>
59
+ )
60
+
61
+ export const SquareIcon = () => (
62
+ <svg {...iconProps}>
63
+ <rect x="5" y="5" width="14" height="14" rx="2" />
64
+ </svg>
65
+ )
66
+
67
+ export const TriangleIcon = () => (
68
+ <svg {...iconProps}>
69
+ <path d="M12 5 L21 19 L3 19 Z" />
70
+ </svg>
71
+ )
72
+
73
+ export const DiamondIcon = () => (
74
+ <svg {...iconProps}>
75
+ <path d="M12 2 L22 12 L12 22 L2 12 Z" />
76
+ </svg>
77
+ )
78
+
79
+ export const StarIcon = () => (
80
+ <svg {...iconProps}>
81
+ <polygon points="12,2 15,10 23,10 17,15 19,23 12,18 5,23 7,15 1,10 9,10" />
82
+ </svg>
83
+ )
84
+
85
+ export const HexagonIcon = () => (
86
+ <svg {...iconProps}>
87
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
88
+ </svg>
89
+ )
90
+
91
+ export const PentagonIcon = () => (
92
+ <svg {...iconProps}>
93
+ <path d="M12 2 L22 9 L18 20 L6 20 L2 9 Z" />
94
+ </svg>
95
+ )
96
+
97
+ export const OctagonIcon = () => (
98
+ <svg {...iconProps}>
99
+ <polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86" />
100
+ </svg>
101
+ )
102
+
103
+ /**
104
+ * Fallback icon sets for themes - provides 8 distinct shapes
105
+ * Use these when you don't have specific icons for your themes
106
+ */
107
+
108
+ export const FALLBACK_ICONS = [
109
+ CircleIcon,
110
+ SquareIcon,
111
+ TriangleIcon,
112
+ DiamondIcon,
113
+ StarIcon,
114
+ HexagonIcon,
115
+ PentagonIcon,
116
+ OctagonIcon
117
+ ] as const
118
+
119
+ /**
120
+ * Get a fallback icon for a theme by index
121
+ * Icons repeat after 8 themes to provide consistent differentiation
122
+ */
123
+
124
+ export function getFallbackIcon(index: number) {
125
+ const Icon = FALLBACK_ICONS[index % FALLBACK_ICONS.length]
126
+ return <Icon />
127
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * ThemePicker component
3
+ * A beautiful theme picker with light/dark variants for each theme family
4
+ * Designed to work with any theme system via configuration
5
+ */
6
+
7
+ import React, { useRef, useLayoutEffect, useState } from 'react'
8
+ import { Tooltip } from './Tooltip'
9
+ import type { ThemePickerProps } from '../types'
10
+ import { SettingsIcon, getFallbackIcon } from './ThemeIcons'
11
+ // MoonIcon is a theme-family icon and lives with the theme model now.
12
+ import { MoonIcon } from '@wolffm/themes'
13
+ import '../theme-picker.css'
14
+
15
+ /**
16
+ * ThemePicker - Dropdown theme selector with optional settings button
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { ThemePicker } from '@wolffm/task-ui-components'
21
+ * import '@wolffm/task-ui-components/theme-picker.css'
22
+ *
23
+ * <ThemePicker
24
+ * currentTheme="dark"
25
+ * isOpen={showPicker}
26
+ * themeFamilies={MY_THEME_FAMILIES}
27
+ * onThemeChange={setTheme}
28
+ * onToggle={() => setShowPicker(!showPicker)}
29
+ * onSettingsClick={() => openSettings()}
30
+ * />
31
+ * ```
32
+ */
33
+ export function ThemePicker({
34
+ currentTheme,
35
+ isOpen,
36
+ themeFamilies,
37
+ onThemeChange,
38
+ onToggle,
39
+ onSettingsClick,
40
+ getThemeIcon,
41
+ className = '',
42
+ dropdownPlacement = 'auto'
43
+ }: ThemePickerProps) {
44
+ const dropdownRef = useRef<HTMLDivElement>(null)
45
+ const [resolvedPlacement, setResolvedPlacement] = useState<'left' | 'right'>('right')
46
+
47
+ // Edge detection: when 'auto', measure the dropdown after render and flip if it overflows
48
+ useLayoutEffect(() => {
49
+ if (!isOpen) return
50
+
51
+ if (dropdownPlacement !== 'auto') {
52
+ setResolvedPlacement(dropdownPlacement)
53
+ return
54
+ }
55
+
56
+ // Start with right (default), then check if it overflows
57
+ setResolvedPlacement('right')
58
+
59
+ // Use rAF to measure after the browser has laid out the dropdown
60
+ const frameId = requestAnimationFrame(() => {
61
+ const el = dropdownRef.current
62
+ if (!el) return
63
+ const rect = el.getBoundingClientRect()
64
+ if (rect.right > window.innerWidth) {
65
+ setResolvedPlacement('left')
66
+ }
67
+ })
68
+
69
+ return () => cancelAnimationFrame(frameId)
70
+ }, [isOpen, dropdownPlacement])
71
+
72
+ // Get icon for current theme (use provided function or fallback to MoonIcon)
73
+ const currentIcon = getThemeIcon ? getThemeIcon(currentTheme) : <MoonIcon />
74
+
75
+ const openLeftClass = resolvedPlacement === 'left' ? 'theme-picker--open-left' : ''
76
+
77
+ return (
78
+ <div className={`theme-picker ${openLeftClass} ${className}`.trim()}>
79
+ <Tooltip content="Choose theme">
80
+ <button
81
+ className="app-header__icon-btn theme-toggle-btn"
82
+ onClick={onToggle}
83
+ aria-label="Choose theme"
84
+ >
85
+ {currentIcon}
86
+ </button>
87
+ </Tooltip>
88
+ {isOpen && (
89
+ <div
90
+ ref={dropdownRef}
91
+ className="theme-picker__dropdown"
92
+ onClick={e => e.stopPropagation()}
93
+ >
94
+ <div className="theme-picker__main">
95
+ <div className="theme-picker__pills">
96
+ {themeFamilies.map((family, idx) => {
97
+ // Use provided icons or fallback to generic shapes
98
+ const lightIcon = family.lightIcon ?? getFallbackIcon(idx)
99
+ const darkIcon = family.darkIcon ?? getFallbackIcon(idx)
100
+
101
+ return (
102
+ <div key={idx} className="theme-pill">
103
+ {/* Light variant button */}
104
+ <Tooltip content={family.lightLabel}>
105
+ <button
106
+ className={`theme-pill__btn theme-pill__btn--light ${currentTheme === family.lightTheme ? 'active' : ''}`}
107
+ onClick={() => onThemeChange(family.lightTheme)}
108
+ aria-label={family.lightLabel}
109
+ >
110
+ <div className="theme-pill__icon">{lightIcon}</div>
111
+ </button>
112
+ </Tooltip>
113
+
114
+ {/* Dark variant button */}
115
+ <Tooltip content={family.darkLabel}>
116
+ <button
117
+ className={`theme-pill__btn theme-pill__btn--dark ${currentTheme === family.darkTheme ? 'active' : ''}`}
118
+ onClick={() => onThemeChange(family.darkTheme)}
119
+ aria-label={family.darkLabel}
120
+ >
121
+ <div className="theme-pill__icon">{darkIcon}</div>
122
+ </button>
123
+ </Tooltip>
124
+ </div>
125
+ )
126
+ })}
127
+ </div>
128
+ {/* Settings button - separate column on the right */}
129
+ {onSettingsClick && (
130
+ <Tooltip content="Settings">
131
+ <button
132
+ className="theme-picker__settings-icon"
133
+ onClick={() => {
134
+ onSettingsClick()
135
+ onToggle() // Close picker after opening settings
136
+ }}
137
+ aria-label="Settings"
138
+ >
139
+ <SettingsIcon />
140
+ </button>
141
+ </Tooltip>
142
+ )}
143
+ </div>
144
+ </div>
145
+ )}
146
+ {isOpen && <div className="theme-picker__overlay" onClick={onToggle} />}
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Toast Notification Component
3
+ * Displays temporary notification messages with different types
4
+ */
5
+
6
+ import React, { useEffect, useState } from 'react'
7
+
8
+ export interface ToastProps {
9
+ message: string
10
+ type?: 'success' | 'error' | 'info' | 'warning'
11
+ duration?: number
12
+ onClose: () => void
13
+ }
14
+
15
+ /**
16
+ * Helper function to render SVG icon wrapper with consistent styling
17
+ */
18
+ const renderIcon = (children: React.ReactNode) => (
19
+ <svg
20
+ className="toast__icon"
21
+ width="20"
22
+ height="20"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth="2"
27
+ >
28
+ {children}
29
+ </svg>
30
+ )
31
+
32
+ export function Toast({ message, type = 'info', duration = 3000, onClose }: ToastProps) {
33
+ const [isVisible, setIsVisible] = useState(false)
34
+
35
+ useEffect(() => {
36
+ // Fade in with slight delay for smoother appearance
37
+ const showTimer = setTimeout(() => {
38
+ setIsVisible(true)
39
+ }, 50)
40
+
41
+ // Auto-dismiss after duration
42
+ const hideTimer = setTimeout(() => {
43
+ setIsVisible(false)
44
+ // Wait for fade-out animation before calling onClose
45
+ setTimeout(onClose, 500)
46
+ }, duration)
47
+
48
+ return () => {
49
+ clearTimeout(showTimer)
50
+ clearTimeout(hideTimer)
51
+ }
52
+ }, [duration, onClose])
53
+
54
+ const getIcon = () => {
55
+ switch (type) {
56
+ case 'success':
57
+ return renderIcon(<path d="M20 6L9 17l-5-5" />)
58
+ case 'error':
59
+ return renderIcon(
60
+ <>
61
+ <circle cx="12" cy="12" r="10" />
62
+ <line x1="15" y1="9" x2="9" y2="15" />
63
+ <line x1="9" y1="9" x2="15" y2="15" />
64
+ </>
65
+ )
66
+ case 'warning':
67
+ return renderIcon(
68
+ <>
69
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
70
+ <line x1="12" y1="9" x2="12" y2="13" />
71
+ <line x1="12" y1="17" x2="12.01" y2="17" />
72
+ </>
73
+ )
74
+ case 'info':
75
+ default:
76
+ return renderIcon(
77
+ <>
78
+ <circle cx="12" cy="12" r="10" />
79
+ <line x1="12" y1="16" x2="12" y2="12" />
80
+ <line x1="12" y1="8" x2="12.01" y2="8" />
81
+ </>
82
+ )
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className={`toast toast--${type} ${isVisible ? 'toast--visible' : ''}`}>
88
+ {getIcon()}
89
+ <span className="toast__message">{message}</span>
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Toaster Container Component
3
+ * Manages and displays multiple toast notifications
4
+ */
5
+
6
+ import React from 'react'
7
+ import { Toast } from './Toast'
8
+ import '../toaster.css'
9
+
10
+ export interface ToastState {
11
+ id: number
12
+ message: string
13
+ type: 'success' | 'error' | 'info' | 'warning'
14
+ duration?: number
15
+ }
16
+
17
+ export interface ToasterProps {
18
+ toasts: ToastState[]
19
+ onDismiss: (id: number) => void
20
+ position?:
21
+ | 'top-left'
22
+ | 'top-center'
23
+ | 'top-right'
24
+ | 'bottom-left'
25
+ | 'bottom-center'
26
+ | 'bottom-right'
27
+ }
28
+
29
+ export function Toaster({ toasts, onDismiss, position = 'bottom-center' }: ToasterProps) {
30
+ return (
31
+ <div className={`toast-container toast-container--${position}`}>
32
+ {toasts.map(toast => (
33
+ <Toast
34
+ key={toast.id}
35
+ message={toast.message}
36
+ type={toast.type}
37
+ duration={toast.duration}
38
+ onClose={() => onDismiss(toast.id)}
39
+ />
40
+ ))}
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Tooltip Component
3
+ * Themed replacement for `title=`. A native title tooltip is OS-drawn, waits
4
+ * about a second, never appears on keyboard focus and cannot be themed.
5
+ *
6
+ * Meets WCAG 1.4.13 (content on hover or focus): it shows on hover AND focus,
7
+ * Escape dismisses it, and the pointer can move onto the bubble without it
8
+ * closing. The child gets `aria-describedby`, so the text is announced —
9
+ * unless it is the child's own `aria-label`, which would read it twice. That
10
+ * is the common case for an icon button: the label names it, and the tooltip
11
+ * shows sighted users the same words.
12
+ *
13
+ * Like <Select>, the bubble is portalled into the anchor's theme scope (see
14
+ * themeScopeOf), so it keeps the theme's tokens and is placed correctly even
15
+ * under a transformed ancestor.
16
+ */
17
+
18
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
19
+ import { createPortal } from 'react-dom'
20
+ import '../tooltip.css'
21
+ import { themeScopeOf } from '../lib/themeScope'
22
+ import { placeTooltip, type Placed, type TooltipPlacement } from '../lib/tooltipPlacement'
23
+
24
+ export type { TooltipPlacement }
25
+ export type TooltipVariant = 'inverse' | 'card' | 'primary'
26
+
27
+ export interface TooltipProps {
28
+ content: React.ReactNode
29
+ /** Preferred side; flips to the opposite one when it would leave the viewport. */
30
+ placement?: TooltipPlacement
31
+ variant?: TooltipVariant
32
+ /** Hover delay before showing, ms. Focus shows immediately. */
33
+ delay?: number
34
+ /** Render the child alone, e.g. while a menu it belongs to is open. */
35
+ disabled?: boolean
36
+ /** A single element. Tooltip attaches its ref and pointer/focus handlers to it
37
+ * directly (composed with the child's own) and adds aria-describedby. */
38
+ children: React.ReactElement<AnchorProps>
39
+ }
40
+
41
+ /** What Tooltip reads from, and adds to, its child. No wrapper element: a
42
+ * wrapper — even `display: contents` — still changes the DOM tree, and with it
43
+ * every `:last-child`, `>` and `+` selector aimed at the child. */
44
+ interface AnchorProps {
45
+ 'aria-describedby'?: string
46
+ 'aria-label'?: string
47
+ ref?: React.Ref<HTMLElement>
48
+ onPointerEnter?: React.PointerEventHandler<HTMLElement>
49
+ onPointerLeave?: React.PointerEventHandler<HTMLElement>
50
+ onPointerDown?: React.PointerEventHandler<HTMLElement>
51
+ onFocus?: React.FocusEventHandler<HTMLElement>
52
+ onBlur?: React.FocusEventHandler<HTMLElement>
53
+ }
54
+
55
+ /** The child's own ref: a prop in React 19, on the element in React 18 (where
56
+ * reading it from props is always undefined, and in 19 `element.ref` warns). */
57
+ function childRefOf(child: React.ReactElement<AnchorProps>): React.Ref<HTMLElement> | undefined {
58
+ if (!React.version.startsWith('18')) return child.props.ref
59
+ return (child as unknown as { ref?: React.Ref<HTMLElement> }).ref ?? undefined
60
+ }
61
+
62
+ const HIDE_GRACE_MS = 120
63
+
64
+ export function Tooltip({
65
+ content,
66
+ placement = 'top',
67
+ variant = 'card',
68
+ delay = 300,
69
+ disabled = false,
70
+ children
71
+ }: TooltipProps) {
72
+ const tipId = useId()
73
+ const [open, setOpen] = useState(false)
74
+ const [placed, setPlaced] = useState<Placed | null>(null)
75
+ const [scope, setScope] = useState<HTMLElement | null>(null)
76
+ const anchorRef = useRef<HTMLElement | null>(null)
77
+ const bubbleRef = useRef<HTMLDivElement>(null)
78
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
79
+
80
+ const show = useCallback((after: number) => {
81
+ clearTimeout(timer.current)
82
+ timer.current = setTimeout(() => {
83
+ setScope(themeScopeOf(anchorRef.current))
84
+ setOpen(true)
85
+ }, after)
86
+ }, [])
87
+
88
+ const hide = useCallback((after: number) => {
89
+ clearTimeout(timer.current)
90
+ timer.current = setTimeout(() => {
91
+ setOpen(false)
92
+ setPlaced(null)
93
+ }, after)
94
+ }, [])
95
+
96
+ useEffect(() => () => clearTimeout(timer.current), [])
97
+
98
+ const childRef = childRefOf(children)
99
+ const setAnchor = useCallback(
100
+ (node: HTMLElement | null) => {
101
+ anchorRef.current = node
102
+ if (typeof childRef === 'function') childRef(node)
103
+ else if (childRef) (childRef as React.MutableRefObject<HTMLElement | null>).current = node
104
+ },
105
+ [childRef]
106
+ )
107
+
108
+ const place = useCallback(() => {
109
+ const target = anchorRef.current
110
+ const bubble = bubbleRef.current
111
+ if (!target || !bubble) return
112
+ setPlaced(
113
+ placeTooltip(
114
+ placement,
115
+ target.getBoundingClientRect(),
116
+ bubble.offsetWidth,
117
+ bubble.offsetHeight
118
+ )
119
+ )
120
+ }, [placement])
121
+
122
+ useLayoutEffect(() => {
123
+ if (open && scope) place()
124
+ }, [open, scope, place, content])
125
+
126
+ useEffect(() => {
127
+ if (!open) return
128
+ const onKey = (e: KeyboardEvent) => {
129
+ if (e.key === 'Escape') hide(0)
130
+ }
131
+ window.addEventListener('scroll', place, true)
132
+ window.addEventListener('resize', place)
133
+ document.addEventListener('keydown', onKey)
134
+ return () => {
135
+ window.removeEventListener('scroll', place, true)
136
+ window.removeEventListener('resize', place)
137
+ document.removeEventListener('keydown', onKey)
138
+ }
139
+ }, [open, place, hide])
140
+
141
+ if (disabled || content == null || content === '') return children
142
+
143
+ const visible = open && placed !== null
144
+ const restatesLabel = typeof content === 'string' && content === children.props['aria-label']
145
+ const style: React.CSSProperties = placed
146
+ ? ({
147
+ left: placed.left,
148
+ top: placed.top,
149
+ '--hdk-tooltip-arrow': `${placed.arrow}px`
150
+ } as React.CSSProperties)
151
+ : { left: 0, top: 0 }
152
+
153
+ const own = children.props
154
+ return (
155
+ <>
156
+ {React.cloneElement(children, {
157
+ ref: setAnchor,
158
+ onPointerEnter: e => {
159
+ own.onPointerEnter?.(e)
160
+ if (e.pointerType === 'mouse') show(delay)
161
+ },
162
+ onPointerLeave: e => {
163
+ own.onPointerLeave?.(e)
164
+ if (e.pointerType === 'mouse') hide(HIDE_GRACE_MS)
165
+ },
166
+ // A press dismisses it, as it does a native title: the click usually
167
+ // opens something (a menu, a dialog) the bubble would sit on top of.
168
+ onPointerDown: e => {
169
+ own.onPointerDown?.(e)
170
+ hide(0)
171
+ },
172
+ onFocus: e => {
173
+ own.onFocus?.(e)
174
+ // Keyboard focus only — a mouse click focuses too, and the hover
175
+ // path already handles that pointer.
176
+ if (e.currentTarget.matches(':focus-visible')) show(0)
177
+ },
178
+ onBlur: e => {
179
+ own.onBlur?.(e)
180
+ hide(0)
181
+ },
182
+ 'aria-describedby':
183
+ [own['aria-describedby'], open && !restatesLabel ? tipId : null]
184
+ .filter(Boolean)
185
+ .join(' ') || undefined
186
+ })}
187
+ {open &&
188
+ scope &&
189
+ createPortal(
190
+ <div
191
+ ref={bubbleRef}
192
+ id={tipId}
193
+ role="tooltip"
194
+ className={[
195
+ 'hdk-tooltip',
196
+ `hdk-tooltip--${variant}`,
197
+ `hdk-tooltip--${placed?.side ?? placement}`,
198
+ visible && 'hdk-tooltip--visible'
199
+ ]
200
+ .filter(Boolean)
201
+ .join(' ')}
202
+ style={style}
203
+ onPointerEnter={() => clearTimeout(timer.current)}
204
+ onPointerLeave={() => hide(HIDE_GRACE_MS)}
205
+ >
206
+ {content}
207
+ </div>,
208
+ scope
209
+ )}
210
+ </>
211
+ )
212
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * ContextMenu Component Styles
3
+ * Generic context menu with dynamic positioning
4
+ */
5
+
6
+ .context-menu {
7
+ position: fixed;
8
+ left: var(--context-menu-x, 0);
9
+ top: var(--context-menu-y, 0);
10
+ z-index: var(--context-menu-z-index, 1000);
11
+ }