@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.
- package/LICENSE +21 -0
- package/README.md +917 -2
- package/dist/app-header.css +288 -0
- package/dist/bento.css +143 -0
- package/dist/button.css +227 -0
- package/dist/components/AppHeader.d.ts +61 -0
- package/dist/components/AppHeader.d.ts.map +1 -0
- package/dist/components/AppHeader.js +23 -0
- package/dist/components/AppHeader.js.map +1 -0
- package/dist/components/BentoCard.d.ts +23 -0
- package/dist/components/BentoCard.d.ts.map +1 -0
- package/dist/components/BentoCard.js +49 -0
- package/dist/components/BentoCard.js.map +1 -0
- package/dist/components/BentoGrid.d.ts +24 -0
- package/dist/components/BentoGrid.d.ts.map +1 -0
- package/dist/components/BentoGrid.js +22 -0
- package/dist/components/BentoGrid.js.map +1 -0
- package/dist/components/Button.d.ts +28 -0
- package/dist/components/Button.d.ts.map +1 -0
- package/dist/components/Button.js +51 -0
- package/dist/components/Button.js.map +1 -0
- package/dist/components/ConnectedSettings.d.ts +53 -0
- package/dist/components/ConnectedSettings.d.ts.map +1 -0
- package/dist/components/ConnectedSettings.js +302 -0
- package/dist/components/ConnectedSettings.js.map +1 -0
- package/dist/components/ConnectedThemePicker.d.ts +52 -0
- package/dist/components/ConnectedThemePicker.d.ts.map +1 -0
- package/dist/components/ConnectedThemePicker.js +32 -0
- package/dist/components/ConnectedThemePicker.js.map +1 -0
- package/dist/components/ContextMenu.d.ts +22 -0
- package/dist/components/ContextMenu.d.ts.map +1 -0
- package/dist/components/ContextMenu.js +23 -0
- package/dist/components/ContextMenu.js.map +1 -0
- package/dist/components/HadokuThemePicker.d.ts +27 -0
- package/dist/components/HadokuThemePicker.d.ts.map +1 -0
- package/dist/components/HadokuThemePicker.js +19 -0
- package/dist/components/HadokuThemePicker.js.map +1 -0
- package/dist/components/LoadingSkeleton.d.ts +14 -0
- package/dist/components/LoadingSkeleton.d.ts.map +1 -0
- package/dist/components/LoadingSkeleton.js +62 -0
- package/dist/components/LoadingSkeleton.js.map +1 -0
- package/dist/components/Modal.d.ts +39 -0
- package/dist/components/Modal.d.ts.map +1 -0
- package/dist/components/Modal.js +106 -0
- package/dist/components/Modal.js.map +1 -0
- package/dist/components/NumberInput.d.ts +26 -0
- package/dist/components/NumberInput.d.ts.map +1 -0
- package/dist/components/NumberInput.js +131 -0
- package/dist/components/NumberInput.js.map +1 -0
- package/dist/components/Select.d.ts +52 -0
- package/dist/components/Select.d.ts.map +1 -0
- package/dist/components/Select.js +281 -0
- package/dist/components/Select.js.map +1 -0
- package/dist/components/SettingsModal.d.ts +69 -0
- package/dist/components/SettingsModal.d.ts.map +1 -0
- package/dist/components/SettingsModal.js +141 -0
- package/dist/components/SettingsModal.js.map +1 -0
- package/dist/components/ThemeIcons.d.ts +26 -0
- package/dist/components/ThemeIcons.d.ts.map +1 -0
- package/dist/components/ThemeIcons.js +74 -0
- package/dist/components/ThemeIcons.js.map +1 -0
- package/dist/components/ThemePicker.d.ts +28 -0
- package/dist/components/ThemePicker.d.ts.map +1 -0
- package/dist/components/ThemePicker.js +101 -0
- package/dist/components/ThemePicker.js.map +1 -0
- package/dist/components/Toast.d.ts +13 -0
- package/dist/components/Toast.d.ts.map +1 -0
- package/dist/components/Toast.js +70 -0
- package/dist/components/Toast.js.map +1 -0
- package/dist/components/Toaster.d.ts +19 -0
- package/dist/components/Toaster.d.ts.map +1 -0
- package/dist/components/Toaster.js +19 -0
- package/dist/components/Toaster.js.map +1 -0
- package/dist/components/Tooltip.d.ts +49 -0
- package/dist/components/Tooltip.d.ts.map +1 -0
- package/dist/components/Tooltip.js +143 -0
- package/dist/components/Tooltip.js.map +1 -0
- package/dist/context-menu.css +11 -0
- package/dist/controls.css +137 -0
- package/dist/hooks/useReducedMotion.d.ts +7 -0
- package/dist/hooks/useReducedMotion.d.ts.map +1 -0
- package/dist/hooks/useReducedMotion.js +25 -0
- package/dist/hooks/useReducedMotion.js.map +1 -0
- package/dist/hooks/useToast.d.ts +18 -0
- package/dist/hooks/useToast.d.ts.map +1 -0
- package/dist/hooks/useToast.js +28 -0
- package/dist/hooks/useToast.js.map +1 -0
- package/dist/icon-btn.css +64 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/menuPlacement.d.ts +16 -0
- package/dist/lib/menuPlacement.d.ts.map +1 -0
- package/dist/lib/menuPlacement.js +19 -0
- package/dist/lib/menuPlacement.js.map +1 -0
- package/dist/lib/selectMenus.d.ts +30 -0
- package/dist/lib/selectMenus.d.ts.map +1 -0
- package/dist/lib/selectMenus.js +236 -0
- package/dist/lib/selectMenus.js.map +1 -0
- package/dist/lib/settingsClient.d.ts +120 -0
- package/dist/lib/settingsClient.d.ts.map +1 -0
- package/dist/lib/settingsClient.js +140 -0
- package/dist/lib/settingsClient.js.map +1 -0
- package/dist/lib/themeScope.d.ts +19 -0
- package/dist/lib/themeScope.d.ts.map +1 -0
- package/dist/lib/themeScope.js +10 -0
- package/dist/lib/themeScope.js.map +1 -0
- package/dist/lib/titleTooltips.d.ts +38 -0
- package/dist/lib/titleTooltips.d.ts.map +1 -0
- package/dist/lib/titleTooltips.js +166 -0
- package/dist/lib/titleTooltips.js.map +1 -0
- package/dist/lib/tooltipPlacement.d.ts +14 -0
- package/dist/lib/tooltipPlacement.d.ts.map +1 -0
- package/dist/lib/tooltipPlacement.js +34 -0
- package/dist/lib/tooltipPlacement.js.map +1 -0
- package/dist/modal.css +129 -0
- package/dist/number-input.css +131 -0
- package/dist/scrollbar.css +165 -0
- package/dist/select.css +251 -0
- package/dist/styles.css +1866 -0
- package/dist/task-ui-components.css +1866 -0
- package/dist/theme-picker.css +219 -0
- package/dist/toaster.css +159 -0
- package/dist/tooltip.css +103 -0
- package/dist/types/index.d.ts +37 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +60 -4
- package/src/app-header.css +288 -0
- package/src/bento.css +143 -0
- package/src/button.css +227 -0
- package/src/components/AppHeader.tsx +87 -0
- package/src/components/BentoCard.tsx +72 -0
- package/src/components/BentoGrid.tsx +48 -0
- package/src/components/Button.tsx +81 -0
- package/src/components/ConnectedSettings.tsx +465 -0
- package/src/components/ConnectedThemePicker.tsx +79 -0
- package/src/components/ContextMenu.tsx +48 -0
- package/src/components/HadokuThemePicker.tsx +39 -0
- package/src/components/LoadingSkeleton.tsx +84 -0
- package/src/components/Modal.tsx +171 -0
- package/src/components/NumberInput.tsx +161 -0
- package/src/components/Select.tsx +359 -0
- package/src/components/SettingsModal.tsx +235 -0
- package/src/components/ThemeIcons.tsx +127 -0
- package/src/components/ThemePicker.tsx +149 -0
- package/src/components/Toast.tsx +92 -0
- package/src/components/Toaster.tsx +43 -0
- package/src/components/Tooltip.tsx +212 -0
- package/src/context-menu.css +11 -0
- package/src/controls.css +137 -0
- package/src/hooks/useReducedMotion.ts +36 -0
- package/src/hooks/useToast.ts +57 -0
- package/src/icon-btn.css +64 -0
- package/src/index.ts +140 -0
- package/src/lib/menuPlacement.ts +31 -0
- package/src/lib/selectMenus.ts +303 -0
- package/src/lib/settingsClient.ts +377 -0
- package/src/lib/themeScope.ts +23 -0
- package/src/lib/titleTooltips.ts +236 -0
- package/src/lib/tooltipPlacement.ts +59 -0
- package/src/modal.css +129 -0
- package/src/number-input.css +131 -0
- package/src/scrollbar.css +165 -0
- package/src/select.css +251 -0
- package/src/theme-picker.css +219 -0
- package/src/toaster.css +159 -0
- package/src/tooltip.css +103 -0
- 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
|
+
}
|