scrubtime 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 falkenhawk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # scrubtime
2
+
3
+ A React time picker with draggable scrubber and slider — minimal clicks, maximum control.
4
+
5
+ [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20it%20now-blue?style=for-the-badge)](https://falkenhawk.github.io/scrubtime/)
6
+
7
+ ![npm](https://img.shields.io/npm/v/scrubtime)
8
+ ![npm bundle size](https://img.shields.io/bundlephobia/minzip/scrubtime)
9
+ ![license](https://img.shields.io/npm/l/scrubtime)
10
+
11
+ ## Features
12
+
13
+ - **Draggable scrubber** — Click and drag on hours/minutes to adjust values (like Figma, Blender, After Effects)
14
+ - **Horizontal slider** — Quick selection with configurable step intervals
15
+ - **Minimal clicks** — Designed for efficiency
16
+ - **Fully accessible** — Keyboard navigation and ARIA support
17
+ - **Customizable** — CSS variables for easy theming
18
+ - **Lightweight** — No dependencies besides React
19
+ - **TypeScript** — Full type support
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install scrubtime
25
+ # or
26
+ yarn add scrubtime
27
+ # or
28
+ pnpm add scrubtime
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```tsx
34
+ import { useState } from 'react';
35
+ import { TimePicker } from 'scrubtime';
36
+ import 'scrubtime/styles.css'; // Import default styles
37
+
38
+ function App() {
39
+ const [time, setTime] = useState('14:30');
40
+
41
+ return (
42
+ <TimePicker
43
+ value={time}
44
+ onChange={setTime}
45
+ label="Select time"
46
+ />
47
+ );
48
+ }
49
+ ```
50
+
51
+ ## Props
52
+
53
+ | Prop | Type | Default | Description |
54
+ |------|------|---------|-------------|
55
+ | `value` | `string` | required | Time in "H:mm" or "HH:mm" format |
56
+ | `onChange` | `(value: string) => void` | required | Called when time changes |
57
+ | `label` | `string` | - | Optional label above picker |
58
+ | `className` | `string` | - | Custom class for root element |
59
+ | `disabled` | `boolean` | `false` | Disable the picker |
60
+ | `sliderStep` | `number` | `15` | Slider step in minutes |
61
+ | `dragSensitivity` | `number` | `3` | Pixels per unit when dragging |
62
+ | `divisions` | `number` | `4` | Number of equal parts to divide the 24h range (4 = labels at 0, 6, 12, 18, 24) |
63
+
64
+ ## Interaction
65
+
66
+ ### Dragging (Scrubber)
67
+
68
+ - **Hours**: Click and drag left/right to decrease/increase (0-23, clamped)
69
+ - **Minutes**: Click and drag to change. Crossing 0 or 59 automatically adjusts the hour
70
+
71
+ ### Slider
72
+
73
+ - Drag the slider for quick 15-minute jumps (configurable via `sliderStep`)
74
+ - Snaps to step intervals
75
+
76
+ ### Keyboard
77
+
78
+ - Focus the value and use arrow keys to adjust
79
+
80
+ ## Customization
81
+
82
+ ### CSS Variables
83
+
84
+ Override these variables to customize the appearance:
85
+
86
+ ```css
87
+ :root {
88
+ --scrubtime-bg: #27272a;
89
+ --scrubtime-bg-hover: #3f3f46;
90
+ --scrubtime-border: #3f3f46;
91
+ --scrubtime-text: #ffffff;
92
+ --scrubtime-text-muted: #71717a;
93
+ --scrubtime-value-bg: #3f3f46;
94
+ --scrubtime-slider-bg: #3f3f46;
95
+ --scrubtime-slider-thumb: #3b82f6;
96
+ --scrubtime-slider-thumb-border: #1e3a5f;
97
+ --scrubtime-radius: 0.75rem;
98
+ --scrubtime-radius-sm: 0.5rem;
99
+ --scrubtime-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
100
+ }
101
+ ```
102
+
103
+ ### Without Default Styles
104
+
105
+ Don't import the CSS and style the classes yourself:
106
+
107
+ ```tsx
108
+ import { TimePicker } from 'scrubtime';
109
+ // Don't import styles.css
110
+
111
+ // Style these classes:
112
+ // .scrubtime
113
+ // .scrubtime-label
114
+ // .scrubtime-container
115
+ // .scrubtime-display
116
+ // .scrubtime-value
117
+ // .scrubtime-hours
118
+ // .scrubtime-minutes
119
+ // .scrubtime-separator
120
+ // .scrubtime-slider-container
121
+ // .scrubtime-slider
122
+ // .scrubtime-slider-labels
123
+ ```
124
+
125
+ ### With Tailwind CSS
126
+
127
+ You can use Tailwind by passing a className and not importing the default styles:
128
+
129
+ ```tsx
130
+ <TimePicker
131
+ value={time}
132
+ onChange={setTime}
133
+ className="[&_.scrubtime-container]:bg-zinc-800 [&_.scrubtime-value]:bg-zinc-700"
134
+ />
135
+ ```
136
+
137
+ ## Browser Support
138
+
139
+ - Chrome, Firefox, Safari, Edge (latest versions)
140
+ - Requires React 18+
141
+
142
+ ## License
143
+
144
+ MIT © [falkenhawk](https://github.com/falkenhawk)
package/dist/index.cjs ADDED
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ TimePicker: () => TimePicker
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/TimePicker.tsx
28
+ var import_react = require("react");
29
+ var import_jsx_runtime = require("react/jsx-runtime");
30
+ var DRAG_THRESHOLD = 3;
31
+ function DraggableValue({
32
+ value,
33
+ onDelta,
34
+ onSet,
35
+ formatValue,
36
+ className,
37
+ disabled,
38
+ sensitivity,
39
+ min,
40
+ max
41
+ }) {
42
+ const [isEditing, setIsEditing] = (0, import_react.useState)(false);
43
+ const [editValue, setEditValue] = (0, import_react.useState)("");
44
+ const isDragging = (0, import_react.useRef)(false);
45
+ const hasDragged = (0, import_react.useRef)(false);
46
+ const startX = (0, import_react.useRef)(0);
47
+ const lastX = (0, import_react.useRef)(0);
48
+ const accumulatedDelta = (0, import_react.useRef)(0);
49
+ const onDeltaRef = (0, import_react.useRef)(onDelta);
50
+ onDeltaRef.current = onDelta;
51
+ const handleMouseDown = (0, import_react.useCallback)(
52
+ (e) => {
53
+ if (disabled || isEditing) return;
54
+ isDragging.current = true;
55
+ hasDragged.current = false;
56
+ startX.current = e.clientX;
57
+ lastX.current = e.clientX;
58
+ accumulatedDelta.current = 0;
59
+ document.body.style.cursor = "ew-resize";
60
+ document.body.style.userSelect = "none";
61
+ const handleMouseMove = (e2) => {
62
+ if (!isDragging.current) return;
63
+ const totalDeltaX = Math.abs(e2.clientX - startX.current);
64
+ if (totalDeltaX > DRAG_THRESHOLD) {
65
+ hasDragged.current = true;
66
+ }
67
+ const deltaX = e2.clientX - lastX.current;
68
+ const deltaValue = deltaX / sensitivity;
69
+ accumulatedDelta.current += deltaValue;
70
+ const wholeDelta = Math.trunc(accumulatedDelta.current);
71
+ if (wholeDelta !== 0) {
72
+ accumulatedDelta.current -= wholeDelta;
73
+ onDeltaRef.current(wholeDelta);
74
+ }
75
+ lastX.current = e2.clientX;
76
+ };
77
+ const handleMouseUp = () => {
78
+ isDragging.current = false;
79
+ document.body.style.cursor = "";
80
+ document.body.style.userSelect = "";
81
+ document.removeEventListener("mousemove", handleMouseMove);
82
+ document.removeEventListener("mouseup", handleMouseUp);
83
+ };
84
+ document.addEventListener("mousemove", handleMouseMove);
85
+ document.addEventListener("mouseup", handleMouseUp);
86
+ },
87
+ [disabled, sensitivity, isEditing]
88
+ );
89
+ const displayValue = formatValue ? formatValue(value) : String(value);
90
+ const handleClick = (0, import_react.useCallback)(() => {
91
+ if (disabled || hasDragged.current) return;
92
+ setEditValue(displayValue);
93
+ setIsEditing(true);
94
+ }, [disabled, displayValue]);
95
+ const handleDivKeyDown = (0, import_react.useCallback)(
96
+ (e) => {
97
+ if (disabled) return;
98
+ if (/^[0-9]$/.test(e.key)) {
99
+ e.preventDefault();
100
+ setEditValue(e.key);
101
+ setIsEditing(true);
102
+ }
103
+ },
104
+ [disabled]
105
+ );
106
+ const commitEdit = (0, import_react.useCallback)(() => {
107
+ const parsed = parseInt(editValue, 10);
108
+ if (!isNaN(parsed)) {
109
+ const clamped = Math.max(min, Math.min(max, parsed));
110
+ onSet(clamped);
111
+ }
112
+ setIsEditing(false);
113
+ }, [editValue, min, max, onSet]);
114
+ const handleKeyDown = (0, import_react.useCallback)(
115
+ (e) => {
116
+ if (e.key === "Enter") {
117
+ commitEdit();
118
+ } else if (e.key === "Escape") {
119
+ setIsEditing(false);
120
+ }
121
+ },
122
+ [commitEdit]
123
+ );
124
+ if (isEditing) {
125
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
126
+ "input",
127
+ {
128
+ type: "text",
129
+ inputMode: "numeric",
130
+ value: editValue,
131
+ onChange: (e) => setEditValue(e.target.value),
132
+ onBlur: commitEdit,
133
+ onKeyDown: handleKeyDown,
134
+ className: `scrubtime-value scrubtime-value--editing ${className || ""}`,
135
+ autoFocus: true
136
+ }
137
+ );
138
+ }
139
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
140
+ "div",
141
+ {
142
+ onMouseDown: handleMouseDown,
143
+ onClick: handleClick,
144
+ onKeyDown: handleDivKeyDown,
145
+ className: `scrubtime-value ${className || ""} ${disabled ? "scrubtime-value--disabled" : ""}`,
146
+ role: "spinbutton",
147
+ "aria-valuenow": value,
148
+ "aria-disabled": disabled,
149
+ tabIndex: disabled ? -1 : 0,
150
+ children: displayValue
151
+ }
152
+ );
153
+ }
154
+ function parseTime(time) {
155
+ const [h, m] = time.split(":").map(Number);
156
+ return { hours: h || 0, minutes: m || 0 };
157
+ }
158
+ function formatTime(hours, minutes) {
159
+ return `${hours}:${String(minutes).padStart(2, "0")}`;
160
+ }
161
+ function clampTotalMinutes(totalMins) {
162
+ return Math.max(0, Math.min(23 * 60 + 59, totalMins));
163
+ }
164
+ function TimePicker({
165
+ value,
166
+ onChange,
167
+ label,
168
+ className,
169
+ disabled = false,
170
+ sliderStep = 15,
171
+ dragSensitivity = 3,
172
+ divisions = 4
173
+ }) {
174
+ const labelCount = divisions + 1;
175
+ const { hours, minutes } = parseTime(value);
176
+ const totalMinutes = hours * 60 + minutes;
177
+ const handleSliderChange = (e) => {
178
+ if (disabled) return;
179
+ const mins = clampTotalMinutes(parseInt(e.target.value));
180
+ const h = Math.floor(mins / 60);
181
+ const m = mins % 60;
182
+ onChange(formatTime(h, m));
183
+ };
184
+ const handleHoursDelta = (0, import_react.useCallback)(
185
+ (delta) => {
186
+ const newHours = Math.max(0, Math.min(23, hours + delta));
187
+ onChange(formatTime(newHours, minutes));
188
+ },
189
+ [hours, minutes, onChange]
190
+ );
191
+ const handleHoursSet = (0, import_react.useCallback)(
192
+ (newHours) => {
193
+ onChange(formatTime(newHours, minutes));
194
+ },
195
+ [minutes, onChange]
196
+ );
197
+ const handleMinutesDelta = (0, import_react.useCallback)(
198
+ (delta) => {
199
+ const newTotalMinutes = clampTotalMinutes(totalMinutes + delta);
200
+ const h = Math.floor(newTotalMinutes / 60);
201
+ const m = newTotalMinutes % 60;
202
+ onChange(formatTime(h, m));
203
+ },
204
+ [totalMinutes, onChange]
205
+ );
206
+ const handleMinutesSet = (0, import_react.useCallback)(
207
+ (newMinutes) => {
208
+ onChange(formatTime(hours, newMinutes));
209
+ },
210
+ [hours, onChange]
211
+ );
212
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `scrubtime ${className || ""} ${disabled ? "scrubtime--disabled" : ""}`, children: [
213
+ label && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "scrubtime-label", children: label }),
214
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "scrubtime-container", children: [
215
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "scrubtime-display", children: [
216
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
217
+ DraggableValue,
218
+ {
219
+ value: hours,
220
+ onDelta: handleHoursDelta,
221
+ onSet: handleHoursSet,
222
+ disabled,
223
+ sensitivity: dragSensitivity,
224
+ min: 0,
225
+ max: 23,
226
+ className: "scrubtime-hours"
227
+ }
228
+ ),
229
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "scrubtime-separator", children: ":" }),
230
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
231
+ DraggableValue,
232
+ {
233
+ value: minutes,
234
+ onDelta: handleMinutesDelta,
235
+ onSet: handleMinutesSet,
236
+ formatValue: (v) => String(v).padStart(2, "0"),
237
+ disabled,
238
+ sensitivity: dragSensitivity,
239
+ min: 0,
240
+ max: 59,
241
+ className: "scrubtime-minutes"
242
+ }
243
+ )
244
+ ] }),
245
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "scrubtime-slider-container", children: [
246
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
247
+ "input",
248
+ {
249
+ type: "range",
250
+ min: 0,
251
+ max: 23 * 60 + 59,
252
+ step: sliderStep,
253
+ value: totalMinutes,
254
+ onChange: handleSliderChange,
255
+ disabled,
256
+ tabIndex: -1,
257
+ className: "scrubtime-slider",
258
+ "aria-label": "Time slider"
259
+ }
260
+ ),
261
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "scrubtime-slider-labels", children: Array.from({ length: labelCount }, (_, i) => {
262
+ const hour = Math.round(24 / divisions * i);
263
+ const percent = i / divisions * 100;
264
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { left: `${percent}%` }, children: hour }, i);
265
+ }) })
266
+ ] })
267
+ ] })
268
+ ] });
269
+ }
270
+ // Annotate the CommonJS export names for ESM import in node:
271
+ 0 && (module.exports = {
272
+ TimePicker
273
+ });
274
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/TimePicker.tsx"],"sourcesContent":["export { TimePicker } from './TimePicker';\nexport type { TimePickerProps } from './TimePicker';\n","import { useRef, useCallback, useState } from 'react';\n\nexport interface TimePickerProps {\n /** Current time value in \"H:mm\" or \"HH:mm\" format */\n value: string;\n /** Callback fired when time changes */\n onChange: (value: string) => void;\n /** Optional label displayed above the picker */\n label?: string;\n /** Custom class name for the root element */\n className?: string;\n /** Disable the picker */\n disabled?: boolean;\n /** Slider step in minutes (default: 15) */\n sliderStep?: number;\n /** Drag sensitivity - pixels per unit change (default: 3) */\n dragSensitivity?: number;\n /** Number of equal parts to divide the 24h range into (default: 4 = labels at 0,6,12,18,24) */\n divisions?: number;\n}\n\ninterface DraggableValueProps {\n value: number;\n onDelta: (delta: number) => void;\n onSet: (value: number) => void;\n formatValue?: (v: number) => string;\n className?: string;\n disabled?: boolean;\n sensitivity: number;\n min: number;\n max: number;\n}\n\nconst DRAG_THRESHOLD = 3;\n\nfunction DraggableValue({\n value,\n onDelta,\n onSet,\n formatValue,\n className,\n disabled,\n sensitivity,\n min,\n max,\n}: DraggableValueProps) {\n const [isEditing, setIsEditing] = useState(false);\n const [editValue, setEditValue] = useState('');\n const isDragging = useRef(false);\n const hasDragged = useRef(false);\n const startX = useRef(0);\n const lastX = useRef(0);\n const accumulatedDelta = useRef(0);\n const onDeltaRef = useRef(onDelta);\n onDeltaRef.current = onDelta;\n\n const handleMouseDown = useCallback(\n (e: React.MouseEvent) => {\n if (disabled || isEditing) return;\n\n isDragging.current = true;\n hasDragged.current = false;\n startX.current = e.clientX;\n lastX.current = e.clientX;\n accumulatedDelta.current = 0;\n document.body.style.cursor = 'ew-resize';\n document.body.style.userSelect = 'none';\n\n const handleMouseMove = (e: MouseEvent) => {\n if (!isDragging.current) return;\n\n const totalDeltaX = Math.abs(e.clientX - startX.current);\n if (totalDeltaX > DRAG_THRESHOLD) {\n hasDragged.current = true;\n }\n\n const deltaX = e.clientX - lastX.current;\n const deltaValue = deltaX / sensitivity;\n accumulatedDelta.current += deltaValue;\n\n const wholeDelta = Math.trunc(accumulatedDelta.current);\n if (wholeDelta !== 0) {\n accumulatedDelta.current -= wholeDelta;\n onDeltaRef.current(wholeDelta);\n }\n lastX.current = e.clientX;\n };\n\n const handleMouseUp = () => {\n isDragging.current = false;\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n document.removeEventListener('mousemove', handleMouseMove);\n document.removeEventListener('mouseup', handleMouseUp);\n };\n\n document.addEventListener('mousemove', handleMouseMove);\n document.addEventListener('mouseup', handleMouseUp);\n },\n [disabled, sensitivity, isEditing]\n );\n\n const displayValue = formatValue ? formatValue(value) : String(value);\n\n const handleClick = useCallback(() => {\n if (disabled || hasDragged.current) return;\n setEditValue(displayValue);\n setIsEditing(true);\n }, [disabled, displayValue]);\n\n const handleDivKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (disabled) return;\n if (/^[0-9]$/.test(e.key)) {\n e.preventDefault();\n setEditValue(e.key);\n setIsEditing(true);\n }\n },\n [disabled]\n );\n\n const commitEdit = useCallback(() => {\n const parsed = parseInt(editValue, 10);\n if (!isNaN(parsed)) {\n const clamped = Math.max(min, Math.min(max, parsed));\n onSet(clamped);\n }\n setIsEditing(false);\n }, [editValue, min, max, onSet]);\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === 'Enter') {\n commitEdit();\n } else if (e.key === 'Escape') {\n setIsEditing(false);\n }\n },\n [commitEdit]\n );\n\n if (isEditing) {\n return (\n <input\n type=\"text\"\n inputMode=\"numeric\"\n value={editValue}\n onChange={(e) => setEditValue(e.target.value)}\n onBlur={commitEdit}\n onKeyDown={handleKeyDown}\n className={`scrubtime-value scrubtime-value--editing ${className || ''}`}\n autoFocus\n />\n );\n }\n\n return (\n <div\n onMouseDown={handleMouseDown}\n onClick={handleClick}\n onKeyDown={handleDivKeyDown}\n className={`scrubtime-value ${className || ''} ${disabled ? 'scrubtime-value--disabled' : ''}`}\n role=\"spinbutton\"\n aria-valuenow={value}\n aria-disabled={disabled}\n tabIndex={disabled ? -1 : 0}\n >\n {displayValue}\n </div>\n );\n}\n\nfunction parseTime(time: string): { hours: number; minutes: number } {\n const [h, m] = time.split(':').map(Number);\n return { hours: h || 0, minutes: m || 0 };\n}\n\nfunction formatTime(hours: number, minutes: number): string {\n return `${hours}:${String(minutes).padStart(2, '0')}`;\n}\n\nfunction clampTotalMinutes(totalMins: number): number {\n return Math.max(0, Math.min(23 * 60 + 59, totalMins));\n}\n\nexport function TimePicker({\n value,\n onChange,\n label,\n className,\n disabled = false,\n sliderStep = 15,\n dragSensitivity = 3,\n divisions = 4,\n}: TimePickerProps) {\n const labelCount = divisions + 1;\n const { hours, minutes } = parseTime(value);\n const totalMinutes = hours * 60 + minutes;\n\n const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n if (disabled) return;\n const mins = clampTotalMinutes(parseInt(e.target.value));\n const h = Math.floor(mins / 60);\n const m = mins % 60;\n onChange(formatTime(h, m));\n };\n\n const handleHoursDelta = useCallback(\n (delta: number) => {\n const newHours = Math.max(0, Math.min(23, hours + delta));\n onChange(formatTime(newHours, minutes));\n },\n [hours, minutes, onChange]\n );\n\n const handleHoursSet = useCallback(\n (newHours: number) => {\n onChange(formatTime(newHours, minutes));\n },\n [minutes, onChange]\n );\n\n const handleMinutesDelta = useCallback(\n (delta: number) => {\n const newTotalMinutes = clampTotalMinutes(totalMinutes + delta);\n const h = Math.floor(newTotalMinutes / 60);\n const m = newTotalMinutes % 60;\n onChange(formatTime(h, m));\n },\n [totalMinutes, onChange]\n );\n\n const handleMinutesSet = useCallback(\n (newMinutes: number) => {\n onChange(formatTime(hours, newMinutes));\n },\n [hours, onChange]\n );\n\n return (\n <div className={`scrubtime ${className || ''} ${disabled ? 'scrubtime--disabled' : ''}`}>\n {label && <label className=\"scrubtime-label\">{label}</label>}\n\n <div className=\"scrubtime-container\">\n <div className=\"scrubtime-display\">\n <DraggableValue\n value={hours}\n onDelta={handleHoursDelta}\n onSet={handleHoursSet}\n disabled={disabled}\n sensitivity={dragSensitivity}\n min={0}\n max={23}\n className=\"scrubtime-hours\"\n />\n <span className=\"scrubtime-separator\">:</span>\n <DraggableValue\n value={minutes}\n onDelta={handleMinutesDelta}\n onSet={handleMinutesSet}\n formatValue={(v) => String(v).padStart(2, '0')}\n disabled={disabled}\n sensitivity={dragSensitivity}\n min={0}\n max={59}\n className=\"scrubtime-minutes\"\n />\n </div>\n\n <div className=\"scrubtime-slider-container\">\n <input\n type=\"range\"\n min={0}\n max={23 * 60 + 59}\n step={sliderStep}\n value={totalMinutes}\n onChange={handleSliderChange}\n disabled={disabled}\n tabIndex={-1}\n className=\"scrubtime-slider\"\n aria-label=\"Time slider\"\n />\n <div className=\"scrubtime-slider-labels\">\n {Array.from({ length: labelCount }, (_, i) => {\n const hour = Math.round((24 / divisions) * i);\n const percent = (i / divisions) * 100;\n return (\n <span key={i} style={{ left: `${percent}%` }}>\n {hour}\n </span>\n );\n })}\n </div>\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":["e"]}
@@ -0,0 +1,23 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ interface TimePickerProps {
4
+ /** Current time value in "H:mm" or "HH:mm" format */
5
+ value: string;
6
+ /** Callback fired when time changes */
7
+ onChange: (value: string) => void;
8
+ /** Optional label displayed above the picker */
9
+ label?: string;
10
+ /** Custom class name for the root element */
11
+ className?: string;
12
+ /** Disable the picker */
13
+ disabled?: boolean;
14
+ /** Slider step in minutes (default: 15) */
15
+ sliderStep?: number;
16
+ /** Drag sensitivity - pixels per unit change (default: 3) */
17
+ dragSensitivity?: number;
18
+ /** Number of equal parts to divide the 24h range into (default: 4 = labels at 0,6,12,18,24) */
19
+ divisions?: number;
20
+ }
21
+ declare function TimePicker({ value, onChange, label, className, disabled, sliderStep, dragSensitivity, divisions, }: TimePickerProps): react_jsx_runtime.JSX.Element;
22
+
23
+ export { TimePicker, type TimePickerProps };
@@ -0,0 +1,23 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ interface TimePickerProps {
4
+ /** Current time value in "H:mm" or "HH:mm" format */
5
+ value: string;
6
+ /** Callback fired when time changes */
7
+ onChange: (value: string) => void;
8
+ /** Optional label displayed above the picker */
9
+ label?: string;
10
+ /** Custom class name for the root element */
11
+ className?: string;
12
+ /** Disable the picker */
13
+ disabled?: boolean;
14
+ /** Slider step in minutes (default: 15) */
15
+ sliderStep?: number;
16
+ /** Drag sensitivity - pixels per unit change (default: 3) */
17
+ dragSensitivity?: number;
18
+ /** Number of equal parts to divide the 24h range into (default: 4 = labels at 0,6,12,18,24) */
19
+ divisions?: number;
20
+ }
21
+ declare function TimePicker({ value, onChange, label, className, disabled, sliderStep, dragSensitivity, divisions, }: TimePickerProps): react_jsx_runtime.JSX.Element;
22
+
23
+ export { TimePicker, type TimePickerProps };
package/dist/index.js ADDED
@@ -0,0 +1,247 @@
1
+ // src/TimePicker.tsx
2
+ import { useRef, useCallback, useState } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ var DRAG_THRESHOLD = 3;
5
+ function DraggableValue({
6
+ value,
7
+ onDelta,
8
+ onSet,
9
+ formatValue,
10
+ className,
11
+ disabled,
12
+ sensitivity,
13
+ min,
14
+ max
15
+ }) {
16
+ const [isEditing, setIsEditing] = useState(false);
17
+ const [editValue, setEditValue] = useState("");
18
+ const isDragging = useRef(false);
19
+ const hasDragged = useRef(false);
20
+ const startX = useRef(0);
21
+ const lastX = useRef(0);
22
+ const accumulatedDelta = useRef(0);
23
+ const onDeltaRef = useRef(onDelta);
24
+ onDeltaRef.current = onDelta;
25
+ const handleMouseDown = useCallback(
26
+ (e) => {
27
+ if (disabled || isEditing) return;
28
+ isDragging.current = true;
29
+ hasDragged.current = false;
30
+ startX.current = e.clientX;
31
+ lastX.current = e.clientX;
32
+ accumulatedDelta.current = 0;
33
+ document.body.style.cursor = "ew-resize";
34
+ document.body.style.userSelect = "none";
35
+ const handleMouseMove = (e2) => {
36
+ if (!isDragging.current) return;
37
+ const totalDeltaX = Math.abs(e2.clientX - startX.current);
38
+ if (totalDeltaX > DRAG_THRESHOLD) {
39
+ hasDragged.current = true;
40
+ }
41
+ const deltaX = e2.clientX - lastX.current;
42
+ const deltaValue = deltaX / sensitivity;
43
+ accumulatedDelta.current += deltaValue;
44
+ const wholeDelta = Math.trunc(accumulatedDelta.current);
45
+ if (wholeDelta !== 0) {
46
+ accumulatedDelta.current -= wholeDelta;
47
+ onDeltaRef.current(wholeDelta);
48
+ }
49
+ lastX.current = e2.clientX;
50
+ };
51
+ const handleMouseUp = () => {
52
+ isDragging.current = false;
53
+ document.body.style.cursor = "";
54
+ document.body.style.userSelect = "";
55
+ document.removeEventListener("mousemove", handleMouseMove);
56
+ document.removeEventListener("mouseup", handleMouseUp);
57
+ };
58
+ document.addEventListener("mousemove", handleMouseMove);
59
+ document.addEventListener("mouseup", handleMouseUp);
60
+ },
61
+ [disabled, sensitivity, isEditing]
62
+ );
63
+ const displayValue = formatValue ? formatValue(value) : String(value);
64
+ const handleClick = useCallback(() => {
65
+ if (disabled || hasDragged.current) return;
66
+ setEditValue(displayValue);
67
+ setIsEditing(true);
68
+ }, [disabled, displayValue]);
69
+ const handleDivKeyDown = useCallback(
70
+ (e) => {
71
+ if (disabled) return;
72
+ if (/^[0-9]$/.test(e.key)) {
73
+ e.preventDefault();
74
+ setEditValue(e.key);
75
+ setIsEditing(true);
76
+ }
77
+ },
78
+ [disabled]
79
+ );
80
+ const commitEdit = useCallback(() => {
81
+ const parsed = parseInt(editValue, 10);
82
+ if (!isNaN(parsed)) {
83
+ const clamped = Math.max(min, Math.min(max, parsed));
84
+ onSet(clamped);
85
+ }
86
+ setIsEditing(false);
87
+ }, [editValue, min, max, onSet]);
88
+ const handleKeyDown = useCallback(
89
+ (e) => {
90
+ if (e.key === "Enter") {
91
+ commitEdit();
92
+ } else if (e.key === "Escape") {
93
+ setIsEditing(false);
94
+ }
95
+ },
96
+ [commitEdit]
97
+ );
98
+ if (isEditing) {
99
+ return /* @__PURE__ */ jsx(
100
+ "input",
101
+ {
102
+ type: "text",
103
+ inputMode: "numeric",
104
+ value: editValue,
105
+ onChange: (e) => setEditValue(e.target.value),
106
+ onBlur: commitEdit,
107
+ onKeyDown: handleKeyDown,
108
+ className: `scrubtime-value scrubtime-value--editing ${className || ""}`,
109
+ autoFocus: true
110
+ }
111
+ );
112
+ }
113
+ return /* @__PURE__ */ jsx(
114
+ "div",
115
+ {
116
+ onMouseDown: handleMouseDown,
117
+ onClick: handleClick,
118
+ onKeyDown: handleDivKeyDown,
119
+ className: `scrubtime-value ${className || ""} ${disabled ? "scrubtime-value--disabled" : ""}`,
120
+ role: "spinbutton",
121
+ "aria-valuenow": value,
122
+ "aria-disabled": disabled,
123
+ tabIndex: disabled ? -1 : 0,
124
+ children: displayValue
125
+ }
126
+ );
127
+ }
128
+ function parseTime(time) {
129
+ const [h, m] = time.split(":").map(Number);
130
+ return { hours: h || 0, minutes: m || 0 };
131
+ }
132
+ function formatTime(hours, minutes) {
133
+ return `${hours}:${String(minutes).padStart(2, "0")}`;
134
+ }
135
+ function clampTotalMinutes(totalMins) {
136
+ return Math.max(0, Math.min(23 * 60 + 59, totalMins));
137
+ }
138
+ function TimePicker({
139
+ value,
140
+ onChange,
141
+ label,
142
+ className,
143
+ disabled = false,
144
+ sliderStep = 15,
145
+ dragSensitivity = 3,
146
+ divisions = 4
147
+ }) {
148
+ const labelCount = divisions + 1;
149
+ const { hours, minutes } = parseTime(value);
150
+ const totalMinutes = hours * 60 + minutes;
151
+ const handleSliderChange = (e) => {
152
+ if (disabled) return;
153
+ const mins = clampTotalMinutes(parseInt(e.target.value));
154
+ const h = Math.floor(mins / 60);
155
+ const m = mins % 60;
156
+ onChange(formatTime(h, m));
157
+ };
158
+ const handleHoursDelta = useCallback(
159
+ (delta) => {
160
+ const newHours = Math.max(0, Math.min(23, hours + delta));
161
+ onChange(formatTime(newHours, minutes));
162
+ },
163
+ [hours, minutes, onChange]
164
+ );
165
+ const handleHoursSet = useCallback(
166
+ (newHours) => {
167
+ onChange(formatTime(newHours, minutes));
168
+ },
169
+ [minutes, onChange]
170
+ );
171
+ const handleMinutesDelta = useCallback(
172
+ (delta) => {
173
+ const newTotalMinutes = clampTotalMinutes(totalMinutes + delta);
174
+ const h = Math.floor(newTotalMinutes / 60);
175
+ const m = newTotalMinutes % 60;
176
+ onChange(formatTime(h, m));
177
+ },
178
+ [totalMinutes, onChange]
179
+ );
180
+ const handleMinutesSet = useCallback(
181
+ (newMinutes) => {
182
+ onChange(formatTime(hours, newMinutes));
183
+ },
184
+ [hours, onChange]
185
+ );
186
+ return /* @__PURE__ */ jsxs("div", { className: `scrubtime ${className || ""} ${disabled ? "scrubtime--disabled" : ""}`, children: [
187
+ label && /* @__PURE__ */ jsx("label", { className: "scrubtime-label", children: label }),
188
+ /* @__PURE__ */ jsxs("div", { className: "scrubtime-container", children: [
189
+ /* @__PURE__ */ jsxs("div", { className: "scrubtime-display", children: [
190
+ /* @__PURE__ */ jsx(
191
+ DraggableValue,
192
+ {
193
+ value: hours,
194
+ onDelta: handleHoursDelta,
195
+ onSet: handleHoursSet,
196
+ disabled,
197
+ sensitivity: dragSensitivity,
198
+ min: 0,
199
+ max: 23,
200
+ className: "scrubtime-hours"
201
+ }
202
+ ),
203
+ /* @__PURE__ */ jsx("span", { className: "scrubtime-separator", children: ":" }),
204
+ /* @__PURE__ */ jsx(
205
+ DraggableValue,
206
+ {
207
+ value: minutes,
208
+ onDelta: handleMinutesDelta,
209
+ onSet: handleMinutesSet,
210
+ formatValue: (v) => String(v).padStart(2, "0"),
211
+ disabled,
212
+ sensitivity: dragSensitivity,
213
+ min: 0,
214
+ max: 59,
215
+ className: "scrubtime-minutes"
216
+ }
217
+ )
218
+ ] }),
219
+ /* @__PURE__ */ jsxs("div", { className: "scrubtime-slider-container", children: [
220
+ /* @__PURE__ */ jsx(
221
+ "input",
222
+ {
223
+ type: "range",
224
+ min: 0,
225
+ max: 23 * 60 + 59,
226
+ step: sliderStep,
227
+ value: totalMinutes,
228
+ onChange: handleSliderChange,
229
+ disabled,
230
+ tabIndex: -1,
231
+ className: "scrubtime-slider",
232
+ "aria-label": "Time slider"
233
+ }
234
+ ),
235
+ /* @__PURE__ */ jsx("div", { className: "scrubtime-slider-labels", children: Array.from({ length: labelCount }, (_, i) => {
236
+ const hour = Math.round(24 / divisions * i);
237
+ const percent = i / divisions * 100;
238
+ return /* @__PURE__ */ jsx("span", { style: { left: `${percent}%` }, children: hour }, i);
239
+ }) })
240
+ ] })
241
+ ] })
242
+ ] });
243
+ }
244
+ export {
245
+ TimePicker
246
+ };
247
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/TimePicker.tsx"],"sourcesContent":["import { useRef, useCallback, useState } from 'react';\n\nexport interface TimePickerProps {\n /** Current time value in \"H:mm\" or \"HH:mm\" format */\n value: string;\n /** Callback fired when time changes */\n onChange: (value: string) => void;\n /** Optional label displayed above the picker */\n label?: string;\n /** Custom class name for the root element */\n className?: string;\n /** Disable the picker */\n disabled?: boolean;\n /** Slider step in minutes (default: 15) */\n sliderStep?: number;\n /** Drag sensitivity - pixels per unit change (default: 3) */\n dragSensitivity?: number;\n /** Number of equal parts to divide the 24h range into (default: 4 = labels at 0,6,12,18,24) */\n divisions?: number;\n}\n\ninterface DraggableValueProps {\n value: number;\n onDelta: (delta: number) => void;\n onSet: (value: number) => void;\n formatValue?: (v: number) => string;\n className?: string;\n disabled?: boolean;\n sensitivity: number;\n min: number;\n max: number;\n}\n\nconst DRAG_THRESHOLD = 3;\n\nfunction DraggableValue({\n value,\n onDelta,\n onSet,\n formatValue,\n className,\n disabled,\n sensitivity,\n min,\n max,\n}: DraggableValueProps) {\n const [isEditing, setIsEditing] = useState(false);\n const [editValue, setEditValue] = useState('');\n const isDragging = useRef(false);\n const hasDragged = useRef(false);\n const startX = useRef(0);\n const lastX = useRef(0);\n const accumulatedDelta = useRef(0);\n const onDeltaRef = useRef(onDelta);\n onDeltaRef.current = onDelta;\n\n const handleMouseDown = useCallback(\n (e: React.MouseEvent) => {\n if (disabled || isEditing) return;\n\n isDragging.current = true;\n hasDragged.current = false;\n startX.current = e.clientX;\n lastX.current = e.clientX;\n accumulatedDelta.current = 0;\n document.body.style.cursor = 'ew-resize';\n document.body.style.userSelect = 'none';\n\n const handleMouseMove = (e: MouseEvent) => {\n if (!isDragging.current) return;\n\n const totalDeltaX = Math.abs(e.clientX - startX.current);\n if (totalDeltaX > DRAG_THRESHOLD) {\n hasDragged.current = true;\n }\n\n const deltaX = e.clientX - lastX.current;\n const deltaValue = deltaX / sensitivity;\n accumulatedDelta.current += deltaValue;\n\n const wholeDelta = Math.trunc(accumulatedDelta.current);\n if (wholeDelta !== 0) {\n accumulatedDelta.current -= wholeDelta;\n onDeltaRef.current(wholeDelta);\n }\n lastX.current = e.clientX;\n };\n\n const handleMouseUp = () => {\n isDragging.current = false;\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n document.removeEventListener('mousemove', handleMouseMove);\n document.removeEventListener('mouseup', handleMouseUp);\n };\n\n document.addEventListener('mousemove', handleMouseMove);\n document.addEventListener('mouseup', handleMouseUp);\n },\n [disabled, sensitivity, isEditing]\n );\n\n const displayValue = formatValue ? formatValue(value) : String(value);\n\n const handleClick = useCallback(() => {\n if (disabled || hasDragged.current) return;\n setEditValue(displayValue);\n setIsEditing(true);\n }, [disabled, displayValue]);\n\n const handleDivKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (disabled) return;\n if (/^[0-9]$/.test(e.key)) {\n e.preventDefault();\n setEditValue(e.key);\n setIsEditing(true);\n }\n },\n [disabled]\n );\n\n const commitEdit = useCallback(() => {\n const parsed = parseInt(editValue, 10);\n if (!isNaN(parsed)) {\n const clamped = Math.max(min, Math.min(max, parsed));\n onSet(clamped);\n }\n setIsEditing(false);\n }, [editValue, min, max, onSet]);\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === 'Enter') {\n commitEdit();\n } else if (e.key === 'Escape') {\n setIsEditing(false);\n }\n },\n [commitEdit]\n );\n\n if (isEditing) {\n return (\n <input\n type=\"text\"\n inputMode=\"numeric\"\n value={editValue}\n onChange={(e) => setEditValue(e.target.value)}\n onBlur={commitEdit}\n onKeyDown={handleKeyDown}\n className={`scrubtime-value scrubtime-value--editing ${className || ''}`}\n autoFocus\n />\n );\n }\n\n return (\n <div\n onMouseDown={handleMouseDown}\n onClick={handleClick}\n onKeyDown={handleDivKeyDown}\n className={`scrubtime-value ${className || ''} ${disabled ? 'scrubtime-value--disabled' : ''}`}\n role=\"spinbutton\"\n aria-valuenow={value}\n aria-disabled={disabled}\n tabIndex={disabled ? -1 : 0}\n >\n {displayValue}\n </div>\n );\n}\n\nfunction parseTime(time: string): { hours: number; minutes: number } {\n const [h, m] = time.split(':').map(Number);\n return { hours: h || 0, minutes: m || 0 };\n}\n\nfunction formatTime(hours: number, minutes: number): string {\n return `${hours}:${String(minutes).padStart(2, '0')}`;\n}\n\nfunction clampTotalMinutes(totalMins: number): number {\n return Math.max(0, Math.min(23 * 60 + 59, totalMins));\n}\n\nexport function TimePicker({\n value,\n onChange,\n label,\n className,\n disabled = false,\n sliderStep = 15,\n dragSensitivity = 3,\n divisions = 4,\n}: TimePickerProps) {\n const labelCount = divisions + 1;\n const { hours, minutes } = parseTime(value);\n const totalMinutes = hours * 60 + minutes;\n\n const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n if (disabled) return;\n const mins = clampTotalMinutes(parseInt(e.target.value));\n const h = Math.floor(mins / 60);\n const m = mins % 60;\n onChange(formatTime(h, m));\n };\n\n const handleHoursDelta = useCallback(\n (delta: number) => {\n const newHours = Math.max(0, Math.min(23, hours + delta));\n onChange(formatTime(newHours, minutes));\n },\n [hours, minutes, onChange]\n );\n\n const handleHoursSet = useCallback(\n (newHours: number) => {\n onChange(formatTime(newHours, minutes));\n },\n [minutes, onChange]\n );\n\n const handleMinutesDelta = useCallback(\n (delta: number) => {\n const newTotalMinutes = clampTotalMinutes(totalMinutes + delta);\n const h = Math.floor(newTotalMinutes / 60);\n const m = newTotalMinutes % 60;\n onChange(formatTime(h, m));\n },\n [totalMinutes, onChange]\n );\n\n const handleMinutesSet = useCallback(\n (newMinutes: number) => {\n onChange(formatTime(hours, newMinutes));\n },\n [hours, onChange]\n );\n\n return (\n <div className={`scrubtime ${className || ''} ${disabled ? 'scrubtime--disabled' : ''}`}>\n {label && <label className=\"scrubtime-label\">{label}</label>}\n\n <div className=\"scrubtime-container\">\n <div className=\"scrubtime-display\">\n <DraggableValue\n value={hours}\n onDelta={handleHoursDelta}\n onSet={handleHoursSet}\n disabled={disabled}\n sensitivity={dragSensitivity}\n min={0}\n max={23}\n className=\"scrubtime-hours\"\n />\n <span className=\"scrubtime-separator\">:</span>\n <DraggableValue\n value={minutes}\n onDelta={handleMinutesDelta}\n onSet={handleMinutesSet}\n formatValue={(v) => String(v).padStart(2, '0')}\n disabled={disabled}\n sensitivity={dragSensitivity}\n min={0}\n max={59}\n className=\"scrubtime-minutes\"\n />\n </div>\n\n <div className=\"scrubtime-slider-container\">\n <input\n type=\"range\"\n min={0}\n max={23 * 60 + 59}\n step={sliderStep}\n value={totalMinutes}\n onChange={handleSliderChange}\n disabled={disabled}\n tabIndex={-1}\n className=\"scrubtime-slider\"\n aria-label=\"Time slider\"\n />\n <div className=\"scrubtime-slider-labels\">\n {Array.from({ length: labelCount }, (_, i) => {\n const hour = Math.round((24 / divisions) * i);\n const percent = (i / divisions) * 100;\n return (\n <span key={i} style={{ left: `${percent}%` }}>\n {hour}\n </span>\n );\n })}\n </div>\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":["e"]}
@@ -0,0 +1,190 @@
1
+ /* scrubtime - Default Styles
2
+ * These styles can be customized via CSS variables or overridden entirely.
3
+ */
4
+
5
+ :root {
6
+ --scrubtime-bg: #27272a;
7
+ --scrubtime-bg-hover: #3f3f46;
8
+ --scrubtime-border: #3f3f46;
9
+ --scrubtime-text: #ffffff;
10
+ --scrubtime-text-muted: #71717a;
11
+ --scrubtime-value-bg: #3f3f46;
12
+ --scrubtime-slider-bg: #3f3f46;
13
+ --scrubtime-slider-thumb: #3b82f6;
14
+ --scrubtime-slider-thumb-border: #1e3a5f;
15
+ --scrubtime-radius: 0.75rem;
16
+ --scrubtime-radius-sm: 0.5rem;
17
+ --scrubtime-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
18
+ }
19
+
20
+ /* Light mode support */
21
+ @media (prefers-color-scheme: light) {
22
+ :root {
23
+ --scrubtime-bg: #f4f4f5;
24
+ --scrubtime-bg-hover: #e4e4e7;
25
+ --scrubtime-border: #d4d4d8;
26
+ --scrubtime-text: #18181b;
27
+ --scrubtime-text-muted: #71717a;
28
+ --scrubtime-value-bg: #e4e4e7;
29
+ --scrubtime-slider-bg: #d4d4d8;
30
+ }
31
+ }
32
+
33
+ .scrubtime {
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: 0.5rem;
37
+ }
38
+
39
+ .scrubtime--disabled {
40
+ opacity: 0.5;
41
+ pointer-events: none;
42
+ }
43
+
44
+ .scrubtime-label {
45
+ font-size: 0.875rem;
46
+ color: var(--scrubtime-text-muted);
47
+ }
48
+
49
+ .scrubtime-container {
50
+ background: var(--scrubtime-bg);
51
+ border: 1px solid var(--scrubtime-border);
52
+ border-radius: var(--scrubtime-radius);
53
+ padding: 0.75rem;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 0.5rem;
57
+ }
58
+
59
+ .scrubtime-display {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ gap: 0.25rem;
64
+ }
65
+
66
+ .scrubtime-value {
67
+ width: 3.5rem;
68
+ background: var(--scrubtime-value-bg);
69
+ border-radius: var(--scrubtime-radius-sm);
70
+ text-align: center;
71
+ font-size: 1.5rem;
72
+ font-family: var(--scrubtime-font-mono);
73
+ padding: 0.5rem;
74
+ cursor: ew-resize;
75
+ user-select: none;
76
+ color: var(--scrubtime-text);
77
+ transition: background-color 0.15s ease;
78
+ }
79
+
80
+ .scrubtime-value:hover:not(.scrubtime-value--disabled) {
81
+ background: var(--scrubtime-bg-hover);
82
+ }
83
+
84
+ .scrubtime-value:focus {
85
+ outline: 2px solid var(--scrubtime-slider-thumb);
86
+ outline-offset: 2px;
87
+ }
88
+
89
+ .scrubtime-value--disabled {
90
+ cursor: not-allowed;
91
+ }
92
+
93
+ .scrubtime-value--editing {
94
+ width: 3.5rem;
95
+ border: none;
96
+ font-size: 1.5rem;
97
+ font-family: var(--scrubtime-font-mono);
98
+ background: var(--scrubtime-value-bg);
99
+ color: var(--scrubtime-text);
100
+ text-align: center;
101
+ border-radius: var(--scrubtime-radius-sm);
102
+ padding: 0.5rem;
103
+ outline: 2px solid var(--scrubtime-slider-thumb);
104
+ outline-offset: 0;
105
+ box-sizing: border-box;
106
+ }
107
+
108
+ .scrubtime-separator {
109
+ font-size: 1.5rem;
110
+ font-family: var(--scrubtime-font-mono);
111
+ color: var(--scrubtime-text-muted);
112
+ }
113
+
114
+ .scrubtime-slider-container {
115
+ display: flex;
116
+ flex-direction: column;
117
+ --thumb-width: 1.25rem;
118
+ }
119
+
120
+ .scrubtime-slider {
121
+ -webkit-appearance: none;
122
+ appearance: none;
123
+ width: 100%;
124
+ height: 0.5rem;
125
+ background: var(--scrubtime-slider-bg);
126
+ border-radius: 0.25rem;
127
+ cursor: pointer;
128
+ margin: 0;
129
+ }
130
+
131
+ .scrubtime-slider::-webkit-slider-thumb {
132
+ -webkit-appearance: none;
133
+ appearance: none;
134
+ width: 1.25rem;
135
+ height: 1.25rem;
136
+ background: var(--scrubtime-slider-thumb);
137
+ border-radius: 50%;
138
+ cursor: pointer;
139
+ border: 2px solid var(--scrubtime-slider-thumb-border);
140
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
141
+ transition: transform 0.1s ease;
142
+ }
143
+
144
+ .scrubtime-slider::-webkit-slider-thumb:hover {
145
+ transform: scale(1.1);
146
+ }
147
+
148
+ .scrubtime-slider::-webkit-slider-thumb:active {
149
+ transform: scale(0.95);
150
+ }
151
+
152
+ .scrubtime-slider::-moz-range-thumb {
153
+ width: 1.25rem;
154
+ height: 1.25rem;
155
+ background: var(--scrubtime-slider-thumb);
156
+ border-radius: 50%;
157
+ cursor: pointer;
158
+ border: 2px solid var(--scrubtime-slider-thumb-border);
159
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
160
+ }
161
+
162
+ .scrubtime-slider:focus {
163
+ outline: none;
164
+ }
165
+
166
+ .scrubtime-slider:focus::-webkit-slider-thumb {
167
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
168
+ }
169
+
170
+ .scrubtime-slider:disabled {
171
+ cursor: not-allowed;
172
+ }
173
+
174
+ .scrubtime-slider-labels {
175
+ position: relative;
176
+ height: 1em;
177
+ font-size: 0.625rem;
178
+ color: var(--scrubtime-text-muted);
179
+ margin-top: 0.25rem;
180
+ /* Inset to match thumb travel range */
181
+ margin-left: calc(var(--thumb-width) / 2);
182
+ margin-right: calc(var(--thumb-width) / 2 + 1px);
183
+ }
184
+
185
+ .scrubtime-slider-labels span {
186
+ position: absolute;
187
+ transform: translateX(-50%);
188
+ width: 2ch;
189
+ text-align: center;
190
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "scrubtime",
3
+ "version": "0.1.0",
4
+ "description": "A React time picker with draggable scrubber and slider - minimal clicks, maximum control",
5
+ "author": "falkenhawk",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/falkenhawk/scrubtime.git"
10
+ },
11
+ "homepage": "https://github.com/falkenhawk/scrubtime#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/falkenhawk/scrubtime/issues"
14
+ },
15
+ "keywords": [
16
+ "react",
17
+ "time-picker",
18
+ "timepicker",
19
+ "scrubber",
20
+ "draggable",
21
+ "slider",
22
+ "input",
23
+ "component",
24
+ "ui"
25
+ ],
26
+ "type": "module",
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "import": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "require": {
37
+ "types": "./dist/index.d.cts",
38
+ "default": "./dist/index.cjs"
39
+ }
40
+ },
41
+ "./styles.css": "./dist/styles.css"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "sideEffects": [
49
+ "*.css"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "dev": "tsup --watch",
54
+ "lint": "eslint src --ext .ts,.tsx",
55
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
56
+ "format": "prettier --write \"src/**/*.{ts,tsx}\"",
57
+ "typecheck": "tsc --noEmit",
58
+ "prepublishOnly": "npm run build"
59
+ },
60
+ "peerDependencies": {
61
+ "react": ">=18.0.0",
62
+ "react-dom": ">=18.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@types/react": "^18.3.0",
66
+ "@types/react-dom": "^18.3.0",
67
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
68
+ "@typescript-eslint/parser": "^8.0.0",
69
+ "eslint": "^9.0.0",
70
+ "eslint-plugin-react": "^7.35.0",
71
+ "eslint-plugin-react-hooks": "^5.0.0",
72
+ "prettier": "^3.3.0",
73
+ "react": "^18.3.0",
74
+ "react-dom": "^18.3.0",
75
+ "tsup": "^8.3.0",
76
+ "typescript": "^5.6.0"
77
+ }
78
+ }