@hadoku/task-ui-components 0.0.0-stage → 4.20.4
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 +368 -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 +75 -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 +144 -0
- package/dist/lib/settingsClient.d.ts.map +1 -0
- package/dist/lib/settingsClient.js +157 -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 +545 -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 +153 -0
- package/src/lib/menuPlacement.ts +31 -0
- package/src/lib/selectMenus.ts +303 -0
- package/src/lib/settingsClient.ts +425 -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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HadokuThemePicker — the platform theme picker, placeable anywhere, and
|
|
3
|
+
* configurable nowhere.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* Locking the picker inside AppHeader made it uniform, but it also made it
|
|
8
|
+
* unreachable for an app that has no header row. watchpart2 is that app: its
|
|
9
|
+
* UI is an overlay on a video surface, and its picker lives in the chat panel's
|
|
10
|
+
* button strip. Under a header-only API its only options were to grow a header
|
|
11
|
+
* it does not want, or keep hand-rolling — and hand-rolling is what this whole
|
|
12
|
+
* effort removes.
|
|
13
|
+
*
|
|
14
|
+
* So the enforcement is not "you may not PLACE the picker", it is "you may not
|
|
15
|
+
* CONFIGURE it". This component takes no props at all. There is no themeFamilies
|
|
16
|
+
* to pass a different list to, no getThemeIcon to slip a debug logger into, no
|
|
17
|
+
* className to restyle it with, no isOpen to manage differently — those props
|
|
18
|
+
* are exactly how the old ConnectedThemePicker drifted. Everything comes from
|
|
19
|
+
* <HadokuThemeRoot>.
|
|
20
|
+
*
|
|
21
|
+
* AppHeader renders this internally. An app with no header renders it directly.
|
|
22
|
+
* Both get the identical control, because there is no way to ask for a
|
|
23
|
+
* different one.
|
|
24
|
+
*/
|
|
25
|
+
import React from 'react'
|
|
26
|
+
import { ConnectedThemePicker } from './ConnectedThemePicker'
|
|
27
|
+
import { useHadokuTheme } from '@wolffm/themes'
|
|
28
|
+
|
|
29
|
+
export function HadokuThemePicker() {
|
|
30
|
+
const t = useHadokuTheme()
|
|
31
|
+
return (
|
|
32
|
+
<ConnectedThemePicker
|
|
33
|
+
themeFamilies={t.themeFamilies}
|
|
34
|
+
currentTheme={t.theme}
|
|
35
|
+
onThemeChange={t.setTheme}
|
|
36
|
+
getThemeIcon={t.getThemeIcon}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadingSkeleton Component
|
|
3
|
+
* Displays a loading skeleton with theme support
|
|
4
|
+
* Can be customized with different layouts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react'
|
|
8
|
+
|
|
9
|
+
export interface LoadingSkeletonProps {
|
|
10
|
+
isDarkTheme?: boolean
|
|
11
|
+
className?: string
|
|
12
|
+
layout?: 'default' | 'minimal' | 'custom'
|
|
13
|
+
customContent?: React.ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LoadingSkeleton({
|
|
17
|
+
isDarkTheme = false,
|
|
18
|
+
className = '',
|
|
19
|
+
layout = 'default',
|
|
20
|
+
customContent
|
|
21
|
+
}: LoadingSkeletonProps) {
|
|
22
|
+
if (layout === 'custom' && customContent) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={`loading-skeleton ${className}`}
|
|
26
|
+
data-dark-theme={isDarkTheme ? 'true' : 'false'}
|
|
27
|
+
>
|
|
28
|
+
{customContent}
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (layout === 'minimal') {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={`loading-skeleton loading-skeleton--minimal ${className}`}
|
|
37
|
+
data-dark-theme={isDarkTheme ? 'true' : 'false'}
|
|
38
|
+
>
|
|
39
|
+
<div className="skeleton-spinner"></div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Default layout - task app specific but can be overridden
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={`loading-skeleton ${className}`}
|
|
48
|
+
data-dark-theme={isDarkTheme ? 'true' : 'false'}
|
|
49
|
+
>
|
|
50
|
+
<div className="loading-skeleton__content">
|
|
51
|
+
{/* Header */}
|
|
52
|
+
<div className="skeleton-header-row">
|
|
53
|
+
<div className="skeleton-header"></div>
|
|
54
|
+
<div className="skeleton-button"></div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* Navigation */}
|
|
58
|
+
<div className="skeleton-nav">
|
|
59
|
+
<div className="skeleton-nav-item"></div>
|
|
60
|
+
<div className="skeleton-nav-item"></div>
|
|
61
|
+
<div className="skeleton-nav-item"></div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Input */}
|
|
65
|
+
<div className="skeleton-input"></div>
|
|
66
|
+
|
|
67
|
+
{/* Filters */}
|
|
68
|
+
<div className="skeleton-filters">
|
|
69
|
+
<div className="skeleton-filter"></div>
|
|
70
|
+
<div className="skeleton-filter"></div>
|
|
71
|
+
<div className="skeleton-filter"></div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Content */}
|
|
75
|
+
<div className="skeleton-content">
|
|
76
|
+
<div className="skeleton-content-header"></div>
|
|
77
|
+
<div className="skeleton-item"></div>
|
|
78
|
+
<div className="skeleton-item"></div>
|
|
79
|
+
<div className="skeleton-item"></div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal Component
|
|
3
|
+
* Generic modal dialog with keyboard shortcuts and customizable actions
|
|
4
|
+
*
|
|
5
|
+
* Styles ship with the package: import '@wolffm/task-ui-components/modal.css'
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useId, useRef } from 'react'
|
|
9
|
+
|
|
10
|
+
export interface ModalProps {
|
|
11
|
+
isOpen: boolean
|
|
12
|
+
title: string
|
|
13
|
+
onClose: () => void
|
|
14
|
+
onConfirm?: () => void | Promise<void>
|
|
15
|
+
children?: React.ReactNode
|
|
16
|
+
inputValue?: string
|
|
17
|
+
onInputChange?: (value: string) => void
|
|
18
|
+
inputPlaceholder?: string
|
|
19
|
+
confirmLabel?: string
|
|
20
|
+
cancelLabel?: string
|
|
21
|
+
confirmDisabled?: boolean
|
|
22
|
+
confirmDanger?: boolean
|
|
23
|
+
showCancel?: boolean
|
|
24
|
+
showConfirm?: boolean
|
|
25
|
+
className?: string
|
|
26
|
+
overlayClassName?: string
|
|
27
|
+
/**
|
|
28
|
+
* Replaces the whole action row. A wizard has Back/Cancel/Regenerate/Create,
|
|
29
|
+
* which the fixed Cancel+Confirm pair cannot express — and that is what
|
|
30
|
+
* pushes an app to hand-roll its own dialog instead of adopting this one.
|
|
31
|
+
*
|
|
32
|
+
* `null` means *no* row at all (a progress step, a viewer); omitting it means
|
|
33
|
+
* "use the default row". `??` collapses those two, so the check below is
|
|
34
|
+
* against `undefined` explicitly.
|
|
35
|
+
*/
|
|
36
|
+
actions?: React.ReactNode
|
|
37
|
+
/** Rendered left of the title, e.g. a wizard's Back button. */
|
|
38
|
+
lead?: React.ReactNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open dialogs, outermost first. Each mounted-and-open Modal pushes a token
|
|
43
|
+
* and pops it on close, so the handler can tell whether it is the one the
|
|
44
|
+
* keypress is meant for.
|
|
45
|
+
*/
|
|
46
|
+
const escapeStack: symbol[] = []
|
|
47
|
+
|
|
48
|
+
export function Modal({
|
|
49
|
+
isOpen,
|
|
50
|
+
title,
|
|
51
|
+
onClose,
|
|
52
|
+
onConfirm,
|
|
53
|
+
children,
|
|
54
|
+
inputValue,
|
|
55
|
+
onInputChange,
|
|
56
|
+
inputPlaceholder,
|
|
57
|
+
confirmLabel = 'Confirm',
|
|
58
|
+
cancelLabel = 'Cancel',
|
|
59
|
+
confirmDisabled = false,
|
|
60
|
+
confirmDanger = false,
|
|
61
|
+
showCancel = true,
|
|
62
|
+
showConfirm = true,
|
|
63
|
+
className = '',
|
|
64
|
+
overlayClassName = '',
|
|
65
|
+
actions,
|
|
66
|
+
lead
|
|
67
|
+
}: ModalProps) {
|
|
68
|
+
const titleId = useId()
|
|
69
|
+
const cardRef = useRef<HTMLDivElement>(null)
|
|
70
|
+
|
|
71
|
+
// Escape closes from anywhere in the dialog, not just from a focused input.
|
|
72
|
+
// Bound to the document rather than the card so it works before anything
|
|
73
|
+
// inside has been focused — previously the handler hung off the optional
|
|
74
|
+
// text input, so a dialog without one could not be dismissed by keyboard.
|
|
75
|
+
//
|
|
76
|
+
// Only the TOP-MOST open dialog answers, which is what makes a confirm
|
|
77
|
+
// nested inside another modal usable: the parent's document listener is
|
|
78
|
+
// still live underneath it, so without the stack one Escape closes both.
|
|
79
|
+
// onClose is read through a ref so a caller passing an unmemoised handler
|
|
80
|
+
// cannot re-run this effect and shuffle itself back to the top of the stack.
|
|
81
|
+
const onCloseRef = useRef(onClose)
|
|
82
|
+
onCloseRef.current = onClose
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!isOpen) return
|
|
85
|
+
const id = Symbol('modal')
|
|
86
|
+
escapeStack.push(id)
|
|
87
|
+
const onKey = (e: KeyboardEvent) => {
|
|
88
|
+
if (e.key !== 'Escape') return
|
|
89
|
+
if (escapeStack[escapeStack.length - 1] !== id) return
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
onCloseRef.current()
|
|
92
|
+
}
|
|
93
|
+
document.addEventListener('keydown', onKey)
|
|
94
|
+
return () => {
|
|
95
|
+
document.removeEventListener('keydown', onKey)
|
|
96
|
+
const at = escapeStack.lastIndexOf(id)
|
|
97
|
+
if (at >= 0) escapeStack.splice(at, 1)
|
|
98
|
+
}
|
|
99
|
+
}, [isOpen])
|
|
100
|
+
|
|
101
|
+
// Move focus into the dialog on open, so the keyboard starts inside it.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!isOpen) return
|
|
104
|
+
const card = cardRef.current
|
|
105
|
+
if (!card) return
|
|
106
|
+
const focusable = card.querySelector<HTMLElement>(
|
|
107
|
+
'input:not([type=hidden]), textarea, select, button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
|
|
108
|
+
)
|
|
109
|
+
;(focusable ?? card).focus()
|
|
110
|
+
}, [isOpen])
|
|
111
|
+
|
|
112
|
+
if (!isOpen) return null
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className={`modal-overlay ${overlayClassName}`.trim()} onClick={onClose}>
|
|
116
|
+
<div
|
|
117
|
+
ref={cardRef}
|
|
118
|
+
className={`modal-card ${className}`.trim()}
|
|
119
|
+
onClick={e => e.stopPropagation()}
|
|
120
|
+
role="dialog"
|
|
121
|
+
aria-modal="true"
|
|
122
|
+
aria-labelledby={titleId}
|
|
123
|
+
tabIndex={-1}
|
|
124
|
+
>
|
|
125
|
+
<div className="modal-head">
|
|
126
|
+
{lead}
|
|
127
|
+
<h3 id={titleId}>{title}</h3>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{children}
|
|
131
|
+
|
|
132
|
+
{onInputChange && (
|
|
133
|
+
<input
|
|
134
|
+
type="text"
|
|
135
|
+
className="modal-input"
|
|
136
|
+
value={inputValue || ''}
|
|
137
|
+
onChange={e => onInputChange(e.target.value)}
|
|
138
|
+
placeholder={inputPlaceholder}
|
|
139
|
+
onKeyDown={e => {
|
|
140
|
+
if (e.key === 'Enter' && !confirmDisabled && onConfirm) {
|
|
141
|
+
e.preventDefault()
|
|
142
|
+
void onConfirm()
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{actions !== undefined ? (
|
|
149
|
+
actions
|
|
150
|
+
) : (
|
|
151
|
+
<div className="modal-actions">
|
|
152
|
+
{showCancel && (
|
|
153
|
+
<button className="modal-button" onClick={onClose}>
|
|
154
|
+
{cancelLabel}
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
{showConfirm && onConfirm && (
|
|
158
|
+
<button
|
|
159
|
+
className={`modal-button ${confirmDanger ? 'modal-button--danger' : 'modal-button--primary'}`}
|
|
160
|
+
onClick={() => void onConfirm()}
|
|
161
|
+
disabled={confirmDisabled}
|
|
162
|
+
>
|
|
163
|
+
{confirmLabel}
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NumberInput Component
|
|
3
|
+
* A themed number field with its own stepper, for the OS spinner arrows that
|
|
4
|
+
* no CSS can style (controls.css removes them from a bare <input type=number>).
|
|
5
|
+
*
|
|
6
|
+
* It is a real <input type="number"> with every input prop passed through, so
|
|
7
|
+
* it works controlled (`value` + `onChange`) or uncontrolled (`defaultValue`),
|
|
8
|
+
* submits in a form, and keeps native min/max/step validation and the arrow
|
|
9
|
+
* keys. The buttons call the input's own stepUp()/stepDown() and then fire
|
|
10
|
+
* `input` and `change`, so `onChange` sees an ordinary edit: React's value
|
|
11
|
+
* tracker compares against what React last wrote, and stepUp() goes around it.
|
|
12
|
+
*
|
|
13
|
+
* Holding a button repeats, like the native spinner. The buttons are not tab
|
|
14
|
+
* stops — the arrow keys on the field already step — but they are labelled.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
18
|
+
import { Icon } from '@wolffm/themes'
|
|
19
|
+
import '../number-input.css'
|
|
20
|
+
|
|
21
|
+
export type NumberInputSize = 'sm' | 'md' | 'lg'
|
|
22
|
+
|
|
23
|
+
export interface NumberInputProps
|
|
24
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
|
25
|
+
size?: NumberInputSize
|
|
26
|
+
/** Labels for the two buttons; default "Increase" / "Decrease". */
|
|
27
|
+
increaseLabel?: string
|
|
28
|
+
decreaseLabel?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const REPEAT_DELAY_MS = 400
|
|
32
|
+
const REPEAT_EVERY_MS = 60
|
|
33
|
+
const ICON_SIZE: Record<NumberInputSize, number> = { sm: 12, md: 14, lg: 16 }
|
|
34
|
+
|
|
35
|
+
export const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
|
36
|
+
function NumberInput(
|
|
37
|
+
{
|
|
38
|
+
size = 'md',
|
|
39
|
+
increaseLabel = 'Increase',
|
|
40
|
+
decreaseLabel = 'Decrease',
|
|
41
|
+
className,
|
|
42
|
+
disabled,
|
|
43
|
+
readOnly,
|
|
44
|
+
onInput,
|
|
45
|
+
...rest
|
|
46
|
+
},
|
|
47
|
+
ref
|
|
48
|
+
) {
|
|
49
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
50
|
+
const repeat = useRef<{
|
|
51
|
+
delay?: ReturnType<typeof setTimeout>
|
|
52
|
+
every?: ReturnType<typeof setInterval>
|
|
53
|
+
}>({})
|
|
54
|
+
// Mirrors the field so the buttons can disable at min/max, controlled or not.
|
|
55
|
+
const [current, setCurrent] = useState<string>(String(rest.value ?? rest.defaultValue ?? ''))
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (rest.value !== undefined) setCurrent(String(rest.value))
|
|
58
|
+
}, [rest.value])
|
|
59
|
+
|
|
60
|
+
const setRef = useCallback(
|
|
61
|
+
(node: HTMLInputElement | null) => {
|
|
62
|
+
inputRef.current = node
|
|
63
|
+
if (typeof ref === 'function') ref(node)
|
|
64
|
+
else if (ref) ref.current = node
|
|
65
|
+
},
|
|
66
|
+
[ref]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const stop = useCallback(() => {
|
|
70
|
+
clearTimeout(repeat.current.delay)
|
|
71
|
+
clearInterval(repeat.current.every)
|
|
72
|
+
repeat.current = {}
|
|
73
|
+
}, [])
|
|
74
|
+
useEffect(() => stop, [stop])
|
|
75
|
+
|
|
76
|
+
const stepOnce = useCallback((dir: 1 | -1) => {
|
|
77
|
+
const input = inputRef.current
|
|
78
|
+
if (!input || input.disabled || input.readOnly) return false
|
|
79
|
+
const before = input.value
|
|
80
|
+
if (dir === 1) input.stepUp()
|
|
81
|
+
else input.stepDown()
|
|
82
|
+
if (input.value === before) return false
|
|
83
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
84
|
+
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
85
|
+
return true
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
const start = (dir: 1 | -1) => (e: React.PointerEvent) => {
|
|
89
|
+
if (e.button !== 0) return
|
|
90
|
+
// Keep focus (and the caret) in the field, as the native spinner does.
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
inputRef.current?.focus({ preventScroll: true })
|
|
93
|
+
stop()
|
|
94
|
+
if (!stepOnce(dir)) return
|
|
95
|
+
repeat.current.delay = setTimeout(() => {
|
|
96
|
+
repeat.current.every = setInterval(() => {
|
|
97
|
+
if (!stepOnce(dir)) stop()
|
|
98
|
+
}, REPEAT_EVERY_MS)
|
|
99
|
+
}, REPEAT_DELAY_MS)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const n = current === '' ? NaN : Number(current)
|
|
103
|
+
const min = rest.min === undefined || rest.min === '' ? -Infinity : Number(rest.min)
|
|
104
|
+
const max = rest.max === undefined || rest.max === '' ? Infinity : Number(rest.max)
|
|
105
|
+
const locked = disabled || readOnly
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<span
|
|
109
|
+
className={[
|
|
110
|
+
'hdk-number',
|
|
111
|
+
`hdk-number--${size}`,
|
|
112
|
+
disabled && 'hdk-number--disabled',
|
|
113
|
+
className
|
|
114
|
+
]
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(' ')}
|
|
117
|
+
>
|
|
118
|
+
<input
|
|
119
|
+
ref={setRef}
|
|
120
|
+
type="number"
|
|
121
|
+
className="hdk-number__input"
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
readOnly={readOnly}
|
|
124
|
+
onInput={e => {
|
|
125
|
+
setCurrent(e.currentTarget.value)
|
|
126
|
+
onInput?.(e)
|
|
127
|
+
}}
|
|
128
|
+
{...rest}
|
|
129
|
+
/>
|
|
130
|
+
<span className="hdk-number__steps">
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
tabIndex={-1}
|
|
134
|
+
className="hdk-number__step"
|
|
135
|
+
aria-label={increaseLabel}
|
|
136
|
+
disabled={locked || n >= max}
|
|
137
|
+
onPointerDown={start(1)}
|
|
138
|
+
onPointerUp={stop}
|
|
139
|
+
onPointerLeave={stop}
|
|
140
|
+
onPointerCancel={stop}
|
|
141
|
+
>
|
|
142
|
+
<Icon name="chevron-up" size={ICON_SIZE[size]} />
|
|
143
|
+
</button>
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
tabIndex={-1}
|
|
147
|
+
className="hdk-number__step"
|
|
148
|
+
aria-label={decreaseLabel}
|
|
149
|
+
disabled={locked || n <= min}
|
|
150
|
+
onPointerDown={start(-1)}
|
|
151
|
+
onPointerUp={stop}
|
|
152
|
+
onPointerLeave={stop}
|
|
153
|
+
onPointerCancel={stop}
|
|
154
|
+
>
|
|
155
|
+
<Icon name="chevron-down" size={ICON_SIZE[size]} />
|
|
156
|
+
</button>
|
|
157
|
+
</span>
|
|
158
|
+
</span>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
)
|