@designbasekorea/ui 0.2.36 → 0.2.38

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/dist/index.umd.js CHANGED
@@ -5323,14 +5323,13 @@
5323
5323
  /* ----------------------- 유틸 ----------------------- */
5324
5324
  const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
5325
5325
  const normalizeHex = (s) => (s || '').trim().toUpperCase();
5326
- /** #RRGGBB {r,g,b} */
5326
+ const isHex = (s) => /^#?[0-9A-Fa-f]{6}$/.test(s || '');
5327
5327
  const hexToRgb = (hex) => {
5328
5328
  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
5329
5329
  if (!m)
5330
5330
  return null;
5331
5331
  return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
5332
5332
  };
5333
- /** RGB → HEX */
5334
5333
  const toHex = (r, g, b) => {
5335
5334
  const h = (x) => clamp(Math.round(x), 0, 255).toString(16).padStart(2, '0');
5336
5335
  return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
@@ -5429,9 +5428,9 @@
5429
5428
  return { h, s: s * 100, l: l * 100 };
5430
5429
  };
5431
5430
  /* ----------------------- 컴포넌트 ----------------------- */
5432
- const ColorPicker = ({ size = 'm', type = 'dropdown', position = 'bottom-left', value, defaultValue = '#006FFF', showInput = true, showAlpha = true, showFormatSelector = true, showCopyButton = true, disabled = false, readonly = false, onChangeFormat = 'hex', fireOnInit = false, changeDebounceMs = 0, onChange, onApply, onCancel, className, }) => {
5433
- /** 초기 HSV props에서 즉시 도출(StrictMode 안전) */
5434
- const initialHex = normalizeHex(value || defaultValue) || '#006FFF';
5431
+ const ColorPicker = ({ size = 'm', type = 'dropdown', position = 'bottom-left', value, defaultValue = '#006FFF', showInput = true, showAlpha = true, showFormatSelector = true, showCopyButton = true, showEyedropper = true, disabled = false, readonly = false, onChangeFormat = 'hex', fireOnInit = false, changeDebounceMs = 0, emitDuringDrag = true, onChange, onApply, onCancel, className, }) => {
5432
+ /** 초기 HSV StrictMode에서도 안전하도록 lazy init */
5433
+ const initialHex = (isHex(value) ? normalizeHex(value) : normalizeHex(defaultValue)) || '#006FFF';
5435
5434
  const initialRgb = hexToRgb(initialHex) || { r: 0, g: 111, b: 255 };
5436
5435
  const initialHsv = rgbToHsv(initialRgb.r, initialRgb.g, initialRgb.b);
5437
5436
  const [h, setH] = React.useState(() => initialHsv.h);
@@ -5446,17 +5445,26 @@
5446
5445
  const [tempColor, setTempColor] = React.useState('');
5447
5446
  const pickerRef = React.useRef(null);
5448
5447
  const areaRef = React.useRef(null);
5448
+ /** 드래그 상태/자체 발사 억제 */
5449
5449
  const dragging = React.useRef(false);
5450
- /** onChange 보호 상태 */
5451
- const lastEmittedRef = React.useRef(normalizeHex(initialHex)); // ✅ 초기값 미리 기록
5452
- const didMountRef = React.useRef(false); // ✅ 첫 렌더 감지
5450
+ const didMountRef = React.useRef(false);
5451
+ const lastEmittedHexRef = React.useRef(initialHex);
5452
+ const suppressNextValueSyncRef = React.useRef(false);
5453
5453
  const emitErrorCountRef = React.useRef(0);
5454
5454
  const emitBlockedUntilRef = React.useRef(0);
5455
- /** 제어형(value) 변화 → HSV 반영 */
5455
+ const debounceTimerRef = React.useRef(null);
5456
+ /** 외부 value 변화 → 내부 HSV 반영(자기 발사 직후는 무시 가능) */
5456
5457
  React.useEffect(() => {
5457
5458
  if (value == null)
5458
5459
  return;
5459
5460
  const norm = normalizeHex(value);
5461
+ if (!isHex(norm))
5462
+ return;
5463
+ // 자기 자신이 방금 보낸 값이면 동기화 스킵(미세 진동 방지)
5464
+ if (suppressNextValueSyncRef.current && norm === lastEmittedHexRef.current) {
5465
+ suppressNextValueSyncRef.current = false;
5466
+ return;
5467
+ }
5460
5468
  const rgb = hexToRgb(norm);
5461
5469
  if (!rgb)
5462
5470
  return;
@@ -5470,7 +5478,7 @@
5470
5478
  const rgb = React.useMemo(() => hsvToRgb(h, s, v), [h, s, v]);
5471
5479
  const hex = React.useMemo(() => toHex(rgb.r, rgb.g, rgb.b), [rgb]);
5472
5480
  const hsl = React.useMemo(() => rgbToHsl(rgb.r, rgb.g, rgb.b), [rgb]);
5473
- /** UI 표시 문자열 */
5481
+ /** 표시 문자열(UI 포맷) */
5474
5482
  const uiFormatted = React.useMemo(() => {
5475
5483
  const alpha = a / 100;
5476
5484
  switch (format) {
@@ -5482,56 +5490,9 @@
5482
5490
  default: return hex;
5483
5491
  }
5484
5492
  }, [format, hex, rgb, hsl, a]);
5485
- /** 콜백으로 내보낼 문자열 */
5486
- const outward = React.useMemo(() => {
5487
- if (onChangeFormat === 'hex')
5488
- return hex;
5489
- return uiFormatted;
5490
- }, [hex, uiFormatted, onChangeFormat]);
5491
- /** 최초 렌더 스킵 + 동일값/레이트리밋 검사 후 onChange */
5492
- React.useEffect(() => {
5493
- if (!onChange)
5494
- return;
5495
- // 첫 렌더는 기본 차단 (옵션으로 허용)
5496
- if (!didMountRef.current) {
5497
- didMountRef.current = true;
5498
- if (!fireOnInit)
5499
- return; // 기본: 초기 발사 금지
5500
- }
5501
- const now = Date.now();
5502
- if (emitBlockedUntilRef.current > now)
5503
- return;
5504
- const currentHexNorm = normalizeHex(outward);
5505
- const last = lastEmittedRef.current;
5506
- // 제어형: 부모 value와 동일하면 발사 금지
5507
- if (value && normalizeHex(value) === currentHexNorm)
5508
- return;
5509
- // 직전 발사값과 동일하면 금지
5510
- if (last === currentHexNorm)
5511
- return;
5512
- const fire = () => {
5513
- try {
5514
- lastEmittedRef.current = currentHexNorm;
5515
- onChange(onChangeFormat === 'hex' ? currentHexNorm : outward);
5516
- emitErrorCountRef.current = 0;
5517
- }
5518
- catch (e) {
5519
- emitErrorCountRef.current += 1;
5520
- if (emitErrorCountRef.current >= 20) {
5521
- emitBlockedUntilRef.current = Date.now() + 3000;
5522
- // eslint-disable-next-line no-console
5523
- console.warn('[ColorPicker] onChange errors too frequent; temporarily muted.');
5524
- }
5525
- }
5526
- };
5527
- if (changeDebounceMs > 0) {
5528
- const t = setTimeout(fire, changeDebounceMs);
5529
- return () => clearTimeout(t);
5530
- }
5531
- fire();
5532
- // eslint-disable-next-line react-hooks/exhaustive-deps
5533
- }, [outward]); // 의도적으로 onChange/value 제외
5534
- /** colorInput 동기화(표시 전용) */
5493
+ /** 바깥으로 내보낼 문자열 */
5494
+ const outward = React.useMemo(() => (onChangeFormat === 'hex' ? hex : uiFormatted), [hex, uiFormatted, onChangeFormat]);
5495
+ /** colorInput(표시 전용) 동기화 — 순수 표시만, 포커스 중이면 건드리지 않는 게 안전하지만 간단히 비교 후 반영 */
5535
5496
  React.useEffect(() => {
5536
5497
  const next = format === 'hex' ? normalizeHex(uiFormatted) : uiFormatted;
5537
5498
  if (colorInput !== next)
@@ -5548,7 +5509,67 @@
5548
5509
  document.addEventListener('mousedown', handler);
5549
5510
  return () => document.removeEventListener('mousedown', handler);
5550
5511
  }, [isOpen, type]);
5551
- /** S/V 영역 업데이트 */
5512
+ /** ===== onChange 발사 컨트롤러 ===== */
5513
+ const actuallyEmit = React.useCallback(() => {
5514
+ if (!onChange)
5515
+ return;
5516
+ const now = Date.now();
5517
+ if (emitBlockedUntilRef.current > now)
5518
+ return;
5519
+ const currentHex = normalizeHex(hex);
5520
+ const parentHex = isHex(value) ? normalizeHex(value) : null;
5521
+ // 부모값과 동치면 발사 금지
5522
+ if (parentHex && parentHex === currentHex)
5523
+ return;
5524
+ // 직전 발사와 동일하면 발사 금지
5525
+ if (lastEmittedHexRef.current === currentHex)
5526
+ return;
5527
+ try {
5528
+ lastEmittedHexRef.current = currentHex;
5529
+ suppressNextValueSyncRef.current = true; // 다음 value 동기화 스킵(왕복 진동 방지)
5530
+ onChange(onChangeFormat === 'hex' ? currentHex : outward);
5531
+ emitErrorCountRef.current = 0;
5532
+ }
5533
+ catch {
5534
+ emitErrorCountRef.current += 1;
5535
+ if (emitErrorCountRef.current >= 20) {
5536
+ emitBlockedUntilRef.current = Date.now() + 3000;
5537
+ console.warn('[ColorPicker] onChange errors too frequent; temporarily muted.');
5538
+ }
5539
+ }
5540
+ }, [hex, outward, onChange, onChangeFormat, value]);
5541
+ /** 변경 감지 → 발사(드래그 중 정책 + 디바운스 반영) */
5542
+ React.useEffect(() => {
5543
+ // 첫 렌더
5544
+ if (!didMountRef.current) {
5545
+ didMountRef.current = true;
5546
+ if (!fireOnInit)
5547
+ return;
5548
+ }
5549
+ // 드래그 중이면 정책 적용
5550
+ if (dragging.current && !emitDuringDrag) {
5551
+ // 드래그 끝에서 발사하므로 지금은 무시
5552
+ return;
5553
+ }
5554
+ if (!onChange)
5555
+ return;
5556
+ if (changeDebounceMs > 0) {
5557
+ if (debounceTimerRef.current)
5558
+ window.clearTimeout(debounceTimerRef.current);
5559
+ debounceTimerRef.current = window.setTimeout(() => {
5560
+ actuallyEmit();
5561
+ }, changeDebounceMs);
5562
+ return () => {
5563
+ if (debounceTimerRef.current)
5564
+ window.clearTimeout(debounceTimerRef.current);
5565
+ };
5566
+ }
5567
+ else {
5568
+ actuallyEmit();
5569
+ }
5570
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5571
+ }, [hex, a, format]); // h/s/v → hex로 수렴됨. a/format도 외부로 나갈 수 있으니 포함.
5572
+ /** ===== SV 영역 ===== */
5552
5573
  const updateFromArea = (clientX, clientY) => {
5553
5574
  const el = areaRef.current;
5554
5575
  if (!el)
@@ -5558,26 +5579,69 @@
5558
5579
  const y = clamp(clientY - rect.top, 0, rect.height);
5559
5580
  const newS = (x / rect.width) * 100;
5560
5581
  const newV = 100 - (y / rect.height) * 100;
5582
+ // 소수점 그대로 보관 → 색 떨림 감소
5561
5583
  setS(newS);
5562
5584
  setV(newV);
5563
5585
  };
5564
- const onAreaMouseDown = (e) => { dragging.current = true; updateFromArea(e.clientX, e.clientY); };
5565
- const onAreaMouseMove = (e) => { if (dragging.current)
5566
- updateFromArea(e.clientX, e.clientY); };
5567
- const onAreaMouseUp = () => { dragging.current = false; };
5568
- const onAreaLeave = () => { dragging.current = false; };
5569
- const onAreaTouchStart = (e) => { dragging.current = true; const t = e.touches[0]; updateFromArea(t.clientX, t.clientY); };
5570
- const onAreaTouchMove = (e) => { if (!dragging.current)
5571
- return; const t = e.touches[0]; updateFromArea(t.clientX, t.clientY); };
5572
- const onAreaTouchEnd = () => { dragging.current = false; };
5586
+ const onAreaMouseDown = (e) => {
5587
+ if (disabled || readonly)
5588
+ return;
5589
+ dragging.current = true;
5590
+ updateFromArea(e.clientX, e.clientY);
5591
+ };
5592
+ const onAreaMouseMove = (e) => {
5593
+ if (!dragging.current || disabled || readonly)
5594
+ return;
5595
+ updateFromArea(e.clientX, e.clientY);
5596
+ };
5597
+ const finishDrag = () => {
5598
+ if (!dragging.current)
5599
+ return;
5600
+ dragging.current = false;
5601
+ // 드래그 종료 시 한 번만 발사
5602
+ if (!emitDuringDrag) {
5603
+ if (changeDebounceMs > 0) {
5604
+ if (debounceTimerRef.current)
5605
+ window.clearTimeout(debounceTimerRef.current);
5606
+ debounceTimerRef.current = window.setTimeout(() => {
5607
+ actuallyEmit();
5608
+ }, changeDebounceMs);
5609
+ }
5610
+ else {
5611
+ actuallyEmit();
5612
+ }
5613
+ }
5614
+ };
5615
+ const onAreaMouseUp = finishDrag;
5616
+ const onAreaLeave = finishDrag;
5617
+ const onAreaTouchStart = (e) => {
5618
+ if (disabled || readonly)
5619
+ return;
5620
+ dragging.current = true;
5621
+ const t = e.touches[0];
5622
+ updateFromArea(t.clientX, t.clientY);
5623
+ };
5624
+ const onAreaTouchMove = (e) => {
5625
+ if (!dragging.current || disabled || readonly)
5626
+ return;
5627
+ const t = e.touches[0];
5628
+ updateFromArea(t.clientX, t.clientY);
5629
+ };
5630
+ const onAreaTouchEnd = finishDrag;
5573
5631
  /** 슬라이더 */
5574
- const onHueChange = React.useCallback((e) => setH(parseFloat(e.target.value)), []);
5632
+ const onHueChange = React.useCallback((e) => {
5633
+ if (disabled || readonly)
5634
+ return;
5635
+ setH(parseFloat(e.target.value));
5636
+ }, [disabled, readonly]);
5575
5637
  const onAlphaChange = React.useCallback((e) => {
5638
+ if (disabled || readonly)
5639
+ return;
5576
5640
  const newAlpha = parseInt(e.target.value, 10);
5577
5641
  setA(newAlpha);
5578
5642
  setAlphaInput(String(newAlpha));
5579
- }, []);
5580
- /** 컬러 인풋 확정 */
5643
+ }, [disabled, readonly]);
5644
+ /** 입력 확정 */
5581
5645
  const commitColorInput = React.useCallback(() => {
5582
5646
  const str = colorInput.trim();
5583
5647
  if (/^#([0-9A-Fa-f]{6})$/.test(str)) {
@@ -5617,7 +5681,9 @@
5617
5681
  return;
5618
5682
  }
5619
5683
  // 인식 실패 → 표시값으로 원복
5620
- setColorInput(format === 'hex' ? normalizeHex(uiFormatted) : uiFormatted);
5684
+ const next = format === 'hex' ? normalizeHex(uiFormatted) : uiFormatted;
5685
+ if (colorInput !== next)
5686
+ setColorInput(next);
5621
5687
  }, [colorInput, uiFormatted, format]);
5622
5688
  const onColorKeyDown = (e) => { if (e.key === 'Enter')
5623
5689
  commitColorInput(); };
@@ -5626,7 +5692,7 @@
5626
5692
  if (colorInput !== next)
5627
5693
  setColorInput(next);
5628
5694
  };
5629
- /** 알파 인풋 확정 */
5695
+ /** 알파 입력 확정 */
5630
5696
  const commitAlphaInput = React.useCallback(() => {
5631
5697
  const n = Number(alphaInput.trim());
5632
5698
  if (!Number.isNaN(n) && n >= 0 && n <= 100) {
@@ -5671,6 +5737,7 @@
5671
5737
  console.error(e);
5672
5738
  }
5673
5739
  }, []);
5740
+ const isEyedropperAvailable = typeof window !== 'undefined' && 'EyeDropper' in window;
5674
5741
  /** 모달 */
5675
5742
  const handleModalOpen = React.useCallback(() => {
5676
5743
  if (type === 'modal')
@@ -5735,7 +5802,7 @@
5735
5802
  const Trigger = (jsxRuntime.jsxs("div", { className: "designbase-color-picker__trigger", onClick: () => !disabled && !readonly && (type === 'modal' ? handleModalOpen() : setIsOpen(v => !v)), children: [jsxRuntime.jsx("div", { className: "designbase-color-picker__color-display", children: jsxRuntime.jsx("div", { className: "designbase-color-picker__color-box", style: {
5736
5803
  backgroundColor: showAlpha ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${a / 100})` : hex
5737
5804
  } }) }), showInput && (jsxRuntime.jsx("input", { type: "text", value: colorInput, onChange: (e) => setColorInput(e.target.value), onKeyDown: onColorKeyDown, onBlur: onColorBlur, onClick: (e) => e.stopPropagation(), disabled: disabled, readOnly: readonly, className: "designbase-color-picker__input", placeholder: "#000000" })), showInput && showCopyButton && (jsxRuntime.jsx("button", { type: "button", className: "designbase-color-picker__copy-button-inline", onClick: (e) => { e.stopPropagation(); onCopy(); }, disabled: disabled, "aria-label": "Copy color value", children: isCopied ? jsxRuntime.jsx(icons.DoneIcon, { size: 14 }) : jsxRuntime.jsx(icons.CopyIcon, { size: 14 }) })), jsxRuntime.jsx("button", { type: "button", className: "designbase-color-picker__toggle", disabled: disabled, "aria-label": "Toggle color picker", children: jsxRuntime.jsx(icons.ChevronDownIcon, { size: 16 }) })] }));
5738
- const Selector = (jsxRuntime.jsxs("div", { className: "designbase-color-picker__selector", children: [jsxRuntime.jsx("div", { className: "designbase-color-picker__color-area", children: jsxRuntime.jsx("div", { ref: areaRef, className: "designbase-color-picker__color-field", style: areaBackground, onMouseDown: onAreaMouseDown, onMouseMove: onAreaMouseMove, onMouseUp: onAreaMouseUp, onMouseLeave: onAreaLeave, onTouchStart: onAreaTouchStart, onTouchMove: onAreaTouchMove, onTouchEnd: onAreaTouchEnd, children: jsxRuntime.jsx("div", { className: "designbase-color-picker__color-pointer", style: { left: `${s}%`, top: `${100 - v}%`, backgroundColor: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` } }) }) }), jsxRuntime.jsxs("div", { className: "designbase-color-picker__controls", children: [jsxRuntime.jsx(Button, { variant: "tertiary", size: "m", iconOnly: true, onClick: onEyedrop, "aria-label": "Eyedropper tool", children: jsxRuntime.jsx(icons.EyedropperIcon, {}) }), jsxRuntime.jsxs("div", { className: "designbase-color-picker__slider-container", children: [jsxRuntime.jsx("div", { className: "designbase-color-picker__hue-slider", children: jsxRuntime.jsx("input", { type: "range", min: 0, max: 360, value: h, onChange: onHueChange, className: "designbase-color-picker__slider designbase-color-picker__slider--hue", style: hueTrackStyle }) }), showAlpha && (jsxRuntime.jsx("div", { className: "designbase-color-picker__alpha-slider", children: jsxRuntime.jsx("input", { type: "range", min: 0, max: 100, value: a, onChange: onAlphaChange, className: "designbase-color-picker__slider designbase-color-picker__slider--alpha", style: alphaTrackStyle }) }))] })] }), jsxRuntime.jsxs("div", { className: "designbase-color-picker__value-display", children: [showFormatSelector && (jsxRuntime.jsx(Select, { value: format, onChange: (v) => setFormat(v), showClearButton: false, options: [
5805
+ const Selector = (jsxRuntime.jsxs("div", { className: "designbase-color-picker__selector", children: [jsxRuntime.jsx("div", { className: "designbase-color-picker__color-area", children: jsxRuntime.jsx("div", { ref: areaRef, className: "designbase-color-picker__color-field", style: areaBackground, onMouseDown: onAreaMouseDown, onMouseMove: onAreaMouseMove, onMouseUp: onAreaMouseUp, onMouseLeave: onAreaLeave, onTouchStart: onAreaTouchStart, onTouchMove: onAreaTouchMove, onTouchEnd: onAreaTouchEnd, children: jsxRuntime.jsx("div", { className: "designbase-color-picker__color-pointer", style: { left: `${s}%`, top: `${100 - v}%`, backgroundColor: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` } }) }) }), jsxRuntime.jsxs("div", { className: "designbase-color-picker__controls", children: [showEyedropper && isEyedropperAvailable && (jsxRuntime.jsx(Button, { variant: "tertiary", size: "m", iconOnly: true, onClick: onEyedrop, "aria-label": "Eyedropper tool", children: jsxRuntime.jsx(icons.EyedropperIcon, {}) })), jsxRuntime.jsxs("div", { className: "designbase-color-picker__slider-container", children: [jsxRuntime.jsx("div", { className: "designbase-color-picker__hue-slider", children: jsxRuntime.jsx("input", { type: "range", min: 0, max: 360, value: h, onChange: onHueChange, className: "designbase-color-picker__slider designbase-color-picker__slider--hue", style: hueTrackStyle }) }), showAlpha && (jsxRuntime.jsx("div", { className: "designbase-color-picker__alpha-slider", children: jsxRuntime.jsx("input", { type: "range", min: 0, max: 100, value: a, onChange: onAlphaChange, className: "designbase-color-picker__slider designbase-color-picker__slider--alpha", style: alphaTrackStyle }) }))] })] }), jsxRuntime.jsxs("div", { className: "designbase-color-picker__value-display", children: [showFormatSelector && (jsxRuntime.jsx(Select, { value: format, onChange: (v) => setFormat(v), showClearButton: false, options: [
5739
5806
  { label: 'HEX', value: 'hex' },
5740
5807
  { label: 'RGB', value: 'rgb' },
5741
5808
  { label: 'RGBA', value: 'rgba' },