@moises.ai/design-system 4.18.1 → 4.18.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moises.ai/design-system",
3
- "version": "4.18.1",
3
+ "version": "4.18.2",
4
4
  "description": "Design System package based on @radix-ui/themes with custom defaults",
5
5
  "private": false,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  import styles from './Knob.module.css'
2
2
  import classNames from 'classnames'
3
- import { useMemo, useState } from 'react'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { useKnob } from './useKnob'
5
5
  import {
6
6
  KNOB_CENTER_X,
@@ -11,6 +11,7 @@ import {
11
11
  } from './constants'
12
12
  import { getActiveDotIndex, getPointerProperties } from './utils'
13
13
  import React from 'react'
14
+ import { Tooltip } from '../Tooltip/Tooltip'
14
15
 
15
16
  export const Knob = ({
16
17
  className,
@@ -19,13 +20,22 @@ export const Knob = ({
19
20
  disabled,
20
21
  clickableDots = false,
21
22
  dots = [],
23
+ showTooltip = true,
24
+ tooltipDelayDuration = 0,
25
+ tooltipCloseDelay = 500,
26
+ formatValue,
22
27
  }) => {
23
28
  const [hoveredDot, setHoveredDot] = useState(null)
29
+ const [isHovering, setIsHovering] = useState(false)
30
+ const openTimeoutRef = useRef(null)
31
+ const closeTimeoutRef = useRef(null)
32
+ const tooltipTimingRef = useRef({
33
+ tooltipDelayDuration,
34
+ tooltipCloseDelay,
35
+ })
36
+ tooltipTimingRef.current = { tooltipDelayDuration, tooltipCloseDelay }
24
37
 
25
- if (!dots || dots.length === 0) {
26
- console.warn('Knob: dots prop is required and must be a non-empty array')
27
- return null
28
- }
38
+ const hasDots = Array.isArray(dots) && dots.length > 0
29
39
 
30
40
  const defaultDot = useMemo(() => {
31
41
  return (
@@ -50,10 +60,69 @@ export const Knob = ({
50
60
  return getActiveDotIndex(value, dots)
51
61
  }, [value, dots])
52
62
 
63
+ const activeDot = useMemo(() => {
64
+ if (typeof value === 'object' && value !== null) return value
65
+ return dots?.find((dot) => dot.position === activeDotIndex) ?? null
66
+ }, [value, dots, activeDotIndex])
67
+
53
68
  const { pointerRotation, pointerColor } = useMemo(() => {
54
69
  return getPointerProperties(activeDotIndex, disabled)
55
70
  }, [activeDotIndex, disabled])
56
71
 
72
+ useEffect(() => {
73
+ return () => {
74
+ if (openTimeoutRef.current != null) clearTimeout(openTimeoutRef.current)
75
+ if (closeTimeoutRef.current != null) clearTimeout(closeTimeoutRef.current)
76
+ }
77
+ }, [])
78
+
79
+ if (!hasDots) {
80
+ console.warn('Knob: dots prop is required and must be a non-empty array')
81
+ return null
82
+ }
83
+
84
+ const clearOpenTimeout = () => {
85
+ if (openTimeoutRef.current != null) {
86
+ clearTimeout(openTimeoutRef.current)
87
+ openTimeoutRef.current = null
88
+ }
89
+ }
90
+
91
+ const clearCloseTimeout = () => {
92
+ if (closeTimeoutRef.current != null) {
93
+ clearTimeout(closeTimeoutRef.current)
94
+ closeTimeoutRef.current = null
95
+ }
96
+ }
97
+
98
+ const handlePointerEnter = () => {
99
+ if (disabled) return
100
+ clearCloseTimeout()
101
+ clearOpenTimeout()
102
+ const { tooltipDelayDuration: delay } = tooltipTimingRef.current
103
+ if (delay <= 0) {
104
+ setIsHovering(true)
105
+ return
106
+ }
107
+ openTimeoutRef.current = setTimeout(() => {
108
+ openTimeoutRef.current = null
109
+ setIsHovering(true)
110
+ }, delay)
111
+ }
112
+
113
+ const handlePointerLeave = () => {
114
+ clearOpenTimeout()
115
+ const { tooltipCloseDelay: delay } = tooltipTimingRef.current
116
+ if (delay <= 0) {
117
+ setIsHovering(false)
118
+ return
119
+ }
120
+ closeTimeoutRef.current = setTimeout(() => {
121
+ closeTimeoutRef.current = null
122
+ setIsHovering(false)
123
+ }, delay)
124
+ }
125
+
57
126
  const getDotColor = (dotConfig) => {
58
127
  const colorConfig = dotColors[dotConfig.color] || dotColors.neutral
59
128
  const isActive = dotConfig.position === activeDotIndex
@@ -72,100 +141,125 @@ export const Knob = ({
72
141
  }
73
142
  }
74
143
 
75
- return (
144
+ const displayValue = formatValue
145
+ ? formatValue(activeDot ?? value)
146
+ : (activeDot?.label ?? activeDot?.value ?? activeDotIndex)
147
+
148
+ const tooltipOpen = !disabled && (isHovering || isDragging)
149
+
150
+ const knob = (
76
151
  <div
77
- ref={containerRef}
78
- className={classNames(styles.content, className)}
79
- data-no-drag="true"
80
- onClick={handleClick}
81
- style={{ cursor: disabled ? 'default' : 'ns-resize' }}
152
+ className={classNames(
153
+ styles.pan,
154
+ isDragging && styles.active,
155
+ disabled && styles.disabled,
156
+ )}
157
+ onPointerEnter={handlePointerEnter}
158
+ onPointerLeave={handlePointerLeave}
82
159
  >
83
- <div
84
- className={classNames(
85
- styles.pan,
86
- isDragging && styles.active,
87
- disabled && styles.disabled,
88
- )}
89
- >
90
- <svg width={39} height={39} viewBox="0 0 39 39" fill="none">
91
- {/* Background and outline */}
92
- <circle cx={KNOB_CENTER_X} cy={KNOB_CENTER_Y} r={KNOB_RADIUS} />
93
- <circle
94
- cx={KNOB_CENTER_X}
95
- cy={KNOB_CENTER_Y}
96
- r={KNOB_RADIUS + 0.5}
97
- stroke="#D3EDF8"
98
- strokeOpacity={0.113725}
160
+ <svg width={39} height={39} viewBox="0 0 39 39" fill="none">
161
+ {/* Background and outline */}
162
+ <circle cx={KNOB_CENTER_X} cy={KNOB_CENTER_Y} r={KNOB_RADIUS} />
163
+ <circle
164
+ cx={KNOB_CENTER_X}
165
+ cy={KNOB_CENTER_Y}
166
+ r={KNOB_RADIUS + 0.5}
167
+ stroke="#D3EDF8"
168
+ strokeOpacity={0.113725}
169
+ />
170
+
171
+ {/* Pointer at the end of the arc */}
172
+ <g
173
+ transform={`rotate(${pointerRotation} ${KNOB_CENTER_X} ${KNOB_CENTER_Y})`}
174
+ >
175
+ <path
176
+ d="M19.5 4.5 L19.5 12"
177
+ stroke={pointerColor}
178
+ strokeWidth={1}
179
+ transform="rotate(90 19.5 19.5)"
99
180
  />
181
+ </g>
182
+
183
+ {/* dots around the knob */}
184
+ {dots?.map((dotConfig) => {
185
+ const dot = dotPositions[dotConfig.position]
186
+ if (!dot) return null
100
187
 
101
- {/* Pointer at the end of the arc */}
102
- <g
103
- transform={`rotate(${pointerRotation} ${KNOB_CENTER_X} ${KNOB_CENTER_Y})`}
104
- >
105
- <path
106
- d="M19.5 4.5 L19.5 12"
107
- stroke={pointerColor}
108
- strokeWidth={1}
109
- transform="rotate(90 19.5 19.5)"
110
- />
111
- </g>
112
-
113
- {/* dots around the knob */}
114
- {dots?.map((dotConfig) => {
115
- const dot = dotPositions[dotConfig.position]
116
- if (!dot) return null
117
-
118
- const fillColor = getDotColor(dotConfig)
119
- // only clickable if not disabled
120
- const isClickable = clickableDots && !disabled
121
-
122
- return (
123
- <React.Fragment key={`dot-${dotConfig.position}`}>
124
- {isClickable && (
125
- <circle
126
- cx={dot.x}
127
- cy={dot.y}
128
- r={4}
129
- style={{
130
- cursor: 'pointer',
131
- pointerEvents: 'all',
132
- }}
133
- onMouseEnter={() => setHoveredDot(dotConfig.position)}
134
- onMouseLeave={() => setHoveredDot(null)}
135
- onClick={(e) => {
136
- e.stopPropagation()
137
- handleDotClick(dotConfig.position)
138
- }}
139
- />
140
- )}
188
+ const fillColor = getDotColor(dotConfig)
189
+ // only clickable if not disabled
190
+ const isClickable = clickableDots && !disabled
191
+
192
+ return (
193
+ <React.Fragment key={`dot-${dotConfig.position}`}>
194
+ {isClickable && (
141
195
  <circle
142
196
  cx={dot.x}
143
197
  cy={dot.y}
144
- r={1}
145
- fill={fillColor}
198
+ r={4}
146
199
  style={{
147
- cursor: isClickable ? 'pointer' : 'default',
148
- transition: 'fill 0.2s ease',
149
- pointerEvents: isClickable ? 'none' : 'all',
150
- }}
151
- onMouseEnter={() => {
152
- if (isClickable) setHoveredDot(dotConfig.position)
153
- }}
154
- onMouseLeave={() => {
155
- if (isClickable) setHoveredDot(null)
200
+ cursor: 'pointer',
201
+ pointerEvents: 'all',
156
202
  }}
203
+ onMouseEnter={() => setHoveredDot(dotConfig.position)}
204
+ onMouseLeave={() => setHoveredDot(null)}
157
205
  onClick={(e) => {
158
- if (isClickable) {
159
- e.stopPropagation()
160
- handleDotClick(dotConfig.position)
161
- }
206
+ e.stopPropagation()
207
+ handleDotClick(dotConfig.position)
162
208
  }}
163
209
  />
164
- </React.Fragment>
165
- )
166
- })}
167
- </svg>
168
- </div>
210
+ )}
211
+ <circle
212
+ cx={dot.x}
213
+ cy={dot.y}
214
+ r={1}
215
+ fill={fillColor}
216
+ style={{
217
+ cursor: isClickable ? 'pointer' : 'default',
218
+ transition: 'fill 0.2s ease',
219
+ pointerEvents: isClickable ? 'none' : 'all',
220
+ }}
221
+ onMouseEnter={() => {
222
+ if (isClickable) setHoveredDot(dotConfig.position)
223
+ }}
224
+ onMouseLeave={() => {
225
+ if (isClickable) setHoveredDot(null)
226
+ }}
227
+ onClick={(e) => {
228
+ if (isClickable) {
229
+ e.stopPropagation()
230
+ handleDotClick(dotConfig.position)
231
+ }
232
+ }}
233
+ />
234
+ </React.Fragment>
235
+ )
236
+ })}
237
+ </svg>
238
+ </div>
239
+ )
240
+
241
+ return (
242
+ <div
243
+ ref={containerRef}
244
+ className={classNames(styles.content, className)}
245
+ data-no-drag="true"
246
+ onClick={handleClick}
247
+ style={{ cursor: disabled ? 'default' : 'ns-resize' }}
248
+ >
249
+ {showTooltip ? (
250
+ <Tooltip
251
+ content={displayValue}
252
+ open={tooltipOpen}
253
+ delayDuration={tooltipDelayDuration}
254
+ side="bottom"
255
+ align="center"
256
+ sideOffset={3}
257
+ >
258
+ {knob}
259
+ </Tooltip>
260
+ ) : (
261
+ knob
262
+ )}
169
263
  </div>
170
264
  )
171
265
  }
@@ -21,6 +21,8 @@ export default {
21
21
  ' value={selectedDot}\n' +
22
22
  ' onChange={setSelectedDot}\n' +
23
23
  ' clickableDots={true}\n' +
24
+ ' tooltipDelayDuration={0}\n' +
25
+ ' tooltipCloseDelay={500}\n' +
24
26
  ' dots={[\n' +
25
27
  ' { position: 0, color: "neutral", default: true, label: "Normal", value: "NORMAL" },\n' +
26
28
  ' { position: 2, color: "red", label: "High", value: "HIGH" },\n' +
@@ -43,11 +45,18 @@ export default {
43
45
  '- `7`: 10h30\n\n' +
44
46
  '## Clickable Dots\n\n' +
45
47
  'When `clickableDots={true}`, clicking on a dot will set the knob value to point to that position.\n\n' +
48
+ '## Tooltip\n\n' +
49
+ 'By default the knob shows a tooltip with the active dot `label` (or `value`) on hover/drag. Use `tooltipDelayDuration` and `tooltipCloseDelay` to control open/close timing, or `showTooltip={false}` to hide it.\n\n' +
46
50
  '## Object Return\n\n' +
47
51
  'The `onChange` callback now receives the complete dot object including all properties like `label`, `value`, `position`, `color`, etc.',
48
52
  },
49
53
  },
50
54
  },
55
+ args: {
56
+ showTooltip: true,
57
+ tooltipDelayDuration: 0,
58
+ tooltipCloseDelay: 500,
59
+ },
51
60
  argTypes: {
52
61
  value: {
53
62
  description:
@@ -101,6 +110,30 @@ export default {
101
110
  type: { summary: 'string' },
102
111
  },
103
112
  },
113
+ showTooltip: {
114
+ control: 'boolean',
115
+ description: 'Shows a tooltip with the active dot label/value on hover/drag',
116
+ table: {
117
+ type: { summary: 'boolean' },
118
+ defaultValue: { summary: true },
119
+ },
120
+ },
121
+ tooltipDelayDuration: {
122
+ control: 'number',
123
+ description: 'Ms before the tooltip opens on hover',
124
+ table: {
125
+ type: { summary: 'number' },
126
+ defaultValue: { summary: 0 },
127
+ },
128
+ },
129
+ tooltipCloseDelay: {
130
+ control: 'number',
131
+ description: 'Ms the tooltip stays open after the pointer leaves',
132
+ table: {
133
+ type: { summary: 'number' },
134
+ defaultValue: { summary: 500 },
135
+ },
136
+ },
104
137
  },
105
138
  tags: ['autodocs'],
106
139
  }
@@ -114,10 +147,10 @@ export const Default = {
114
147
  value={value}
115
148
  onChange={setValue}
116
149
  dots={[
117
- { position: 0, color: 'neutral', default: true },
118
- { position: 2, color: 'red' },
119
- { position: 4, color: 'neutral' },
120
- { position: 6, color: 'red' },
150
+ { position: 0, color: 'neutral', default: true, label: 'Normal' },
151
+ { position: 2, color: 'red', label: 'High' },
152
+ { position: 4, color: 'neutral', label: 'Low' },
153
+ { position: 6, color: 'red', label: 'Very High' },
121
154
  ]}
122
155
  />
123
156
  )
@@ -195,16 +228,98 @@ export const Disabled = {
195
228
  onChange={setValue}
196
229
  disabled={true}
197
230
  dots={[
198
- { position: 0, color: 'neutral', default: true },
199
- { position: 2, color: 'red' },
200
- { position: 4, color: 'neutral' },
201
- { position: 6, color: 'red' },
231
+ { position: 0, color: 'neutral', default: true, label: 'Normal' },
232
+ { position: 2, color: 'red', label: 'High' },
233
+ { position: 4, color: 'neutral', label: 'Low' },
234
+ { position: 6, color: 'red', label: 'Very High' },
235
+ ]}
236
+ />
237
+ )
238
+ },
239
+ }
240
+
241
+ export const WithoutTooltip = {
242
+ args: {
243
+ showTooltip: false,
244
+ },
245
+ render: (args) => {
246
+ const [value, setValue] = useState(null)
247
+ return (
248
+ <Knob
249
+ {...args}
250
+ value={value}
251
+ onChange={setValue}
252
+ dots={[
253
+ { position: 0, color: 'neutral', default: true, label: 'Normal' },
254
+ { position: 2, color: 'red', label: 'High' },
255
+ { position: 4, color: 'neutral', label: 'Low' },
256
+ { position: 6, color: 'red', label: 'Very High' },
202
257
  ]}
203
258
  />
204
259
  )
205
260
  },
206
261
  }
207
262
 
263
+ export const TooltipTiming = {
264
+ name: 'Tooltip timing',
265
+ args: {
266
+ tooltipDelayDuration: 200,
267
+ tooltipCloseDelay: 500,
268
+ },
269
+ render: (args) => {
270
+ const [value, setValue] = useState(null)
271
+ return (
272
+ <Flex direction="column" align="center" gap="2">
273
+ <Knob
274
+ {...args}
275
+ value={value}
276
+ onChange={setValue}
277
+ clickableDots={true}
278
+ dots={[
279
+ {
280
+ position: 0,
281
+ color: 'neutral',
282
+ default: true,
283
+ label: 'Normal',
284
+ value: 'NORMAL',
285
+ },
286
+ {
287
+ position: 2,
288
+ color: 'red',
289
+ label: 'High',
290
+ value: 'HIGH',
291
+ },
292
+ {
293
+ position: 4,
294
+ color: 'neutral',
295
+ label: 'Low',
296
+ value: 'LOW',
297
+ },
298
+ {
299
+ position: 6,
300
+ color: 'red',
301
+ label: 'Very High',
302
+ value: 'VERY_HIGH',
303
+ },
304
+ ]}
305
+ />
306
+ <Text size="1" color="gray">
307
+ Opens after {args.tooltipDelayDuration}ms · closes{' '}
308
+ {args.tooltipCloseDelay}ms after leave
309
+ </Text>
310
+ </Flex>
311
+ )
312
+ },
313
+ parameters: {
314
+ docs: {
315
+ description: {
316
+ story:
317
+ 'Adjust `tooltipDelayDuration` (open) and `tooltipCloseDelay` (close grace) via controls.',
318
+ },
319
+ },
320
+ },
321
+ }
322
+
208
323
  export const WithClickableDots = {
209
324
  render: (args) => {
210
325
  const [value, setValue] = useState(null)
@@ -217,10 +332,10 @@ export const WithClickableDots = {
217
332
  onChange={setValue}
218
333
  clickableDots={true}
219
334
  dots={[
220
- { position: 0, color: 'neutral', default: true },
221
- { position: 2, color: 'red' },
222
- { position: 4, color: 'neutral' },
223
- { position: 6, color: 'red' },
335
+ { position: 0, color: 'neutral', default: true, label: 'Normal' },
336
+ { position: 2, color: 'red', label: 'High' },
337
+ { position: 4, color: 'neutral', label: 'Low' },
338
+ { position: 6, color: 'red', label: 'Very High' },
224
339
  ]}
225
340
  />
226
341
  <Text>
@@ -241,14 +356,14 @@ export const FullRotation = {
241
356
  onChange={setValue}
242
357
  clickableDots={true}
243
358
  dots={[
244
- { position: 0, color: 'neutral' }, // 12h
245
- { position: 1, color: 'neutral' }, // 1h30
246
- { position: 2, color: 'red' }, // 3h
247
- { position: 3, color: 'neutral' }, // 4h30
248
- { position: 4, color: 'neutral' }, // 6h
249
- { position: 5, color: 'neutral' }, // 7h30
250
- { position: 6, color: 'red' }, // 9h
251
- { position: 7, color: 'neutral' }, // 10h30
359
+ { position: 0, color: 'neutral', label: '12h' },
360
+ { position: 1, color: 'neutral', label: '1h30' },
361
+ { position: 2, color: 'red', label: '3h' },
362
+ { position: 3, color: 'neutral', label: '4h30' },
363
+ { position: 4, color: 'neutral', label: '6h' },
364
+ { position: 5, color: 'neutral', label: '7h30' },
365
+ { position: 6, color: 'red', label: '9h' },
366
+ { position: 7, color: 'neutral', label: '10h30' },
252
367
  ]}
253
368
  />
254
369
  )
@@ -272,9 +387,9 @@ export const ThreeDots = {
272
387
  value={value}
273
388
  onChange={setValue}
274
389
  dots={[
275
- { position: 0, color: 'neutral', default: true },
276
- { position: 2, color: 'red' },
277
- { position: 4, color: 'neutral' },
390
+ { position: 0, color: 'neutral', default: true, label: 'Top' },
391
+ { position: 2, color: 'red', label: 'Right' },
392
+ { position: 4, color: 'neutral', label: 'Bottom' },
278
393
  ]}
279
394
  />
280
395
  )