@tide-tds/components 0.3.1 → 0.5.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.
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn.js";
4
+ import { listHeadingClass, listItemClass, listSeparatorClass } from "./list-style.js";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { useMemo, useRef } from "react";
7
+ import { CheckIcon } from "@tide-tds/icons";
8
+ import { mergeProps, mergeRefs, useListBoxSection, useOption } from "react-aria";
9
+
10
+ // src/listbox.tsx
11
+ /**
12
+ * 고른 옵션 끝의 체크 아이콘. 장식용이다(선택은 `aria-selected`가 알린다). 배경(subtle-active)이 지워지는 고대비
13
+ * 모드에서도 고른 옵션이 보이게 한다. 글자와의 간격은 항목의 gap(8px)이고 `ms-auto`로 오른쪽 끝에 붙는다.
14
+ */
15
+ const checkClass = "ms-auto";
16
+ /**
17
+ * 목록 상태의 옵션·묶음을 그린다. 묶음과 이웃한 옵션·묶음 사이에 장식용 구분선을 둔다. 목록의 처음·끝과 이어진
18
+ * 구분선은 그리지 않는다(Menu와 같다). listbox의 자식은 option·group만 될 수 있어(WAI-ARIA, axe
19
+ * aria-required-children) Menu와 달리 구분선은 `separator` 역할 없이 보조기술에서 숨긴다. 묶음 경계는 group이 알린다.
20
+ */
21
+ function ListBoxNodes({ state }) {
22
+ const nodes = [...state.collection];
23
+ return nodes.map((node, index) => {
24
+ const previous = nodes[index - 1];
25
+ const separator = previous !== void 0 && (node.type === "section" || previous.type === "section");
26
+ const element = node.type === "section" ? /* @__PURE__ */ jsx(ListBoxSectionView, {
27
+ node,
28
+ state
29
+ }, node.key) : /* @__PURE__ */ jsx(ListBoxOptionView, {
30
+ node,
31
+ state
32
+ }, node.key);
33
+ if (!separator) return element;
34
+ return [/* @__PURE__ */ jsx("div", {
35
+ role: "presentation",
36
+ "aria-hidden": "true",
37
+ className: listSeparatorClass
38
+ }, `${String(node.key)}-separator`), element];
39
+ });
40
+ }
41
+ /** 옵션 하나를 그린다. node.props는 앱이 옵션에 준 props(와 컬렉션이 더한 isDisabled)다. */
42
+ function ListBoxOptionView({ node, state }) {
43
+ const optionRef = useRef(null);
44
+ const { ref, className, value: _value, disabled: _disabled, textValue: _textValue, isDisabled: _isDisabled, children: _children, id: _id, ...props } = node.props;
45
+ const { optionProps, isFocused, isSelected, isDisabled } = useOption({
46
+ key: node.key,
47
+ "aria-label": props["aria-label"]
48
+ }, state, optionRef);
49
+ const mergedRef = useMemo(() => mergeRefs(ref, optionRef), [ref, optionRef]);
50
+ const merged = mergeProps(props, optionProps);
51
+ return /* @__PURE__ */ jsxs("div", {
52
+ ...merged,
53
+ ref: mergedRef,
54
+ "aria-labelledby": props["aria-labelledby"],
55
+ "aria-describedby": props["aria-describedby"],
56
+ "data-highlighted": isFocused ? "" : void 0,
57
+ "data-selected": isSelected ? "" : void 0,
58
+ "data-disabled": isDisabled ? "" : void 0,
59
+ className: cn(listItemClass, className),
60
+ children: [node.rendered, isSelected ? /* @__PURE__ */ jsx(CheckIcon, {
61
+ size: 16,
62
+ className: checkClass
63
+ }) : null]
64
+ });
65
+ }
66
+ /** 묶음 하나를 그린다. 제목은 보조기술에서 숨기고(`presentation`) 묶음의 이름으로만 쓴다(useListBoxSection). */
67
+ function ListBoxSectionView({ node, state }) {
68
+ const { className } = node.props;
69
+ const { itemProps, headingProps, groupProps } = useListBoxSection({
70
+ heading: node.rendered,
71
+ "aria-label": node["aria-label"]
72
+ });
73
+ return /* @__PURE__ */ jsxs("div", {
74
+ ...itemProps,
75
+ className,
76
+ children: [node.rendered != null && /* @__PURE__ */ jsx("div", {
77
+ ...headingProps,
78
+ className: "px-2 pt-1.5 pb-1 text-label-sm text-muted select-none",
79
+ children: node.rendered
80
+ }), /* @__PURE__ */ jsx("div", {
81
+ ...groupProps,
82
+ children: [...node.childNodes].map((child) => /* @__PURE__ */ jsx(ListBoxOptionView, {
83
+ node: child,
84
+ state
85
+ }, child.key))
86
+ })]
87
+ });
88
+ }
89
+
90
+ export { ListBoxNodes };
@@ -31,5 +31,54 @@ const popoverMotionClass = [
31
31
  "motion-safe:data-[placement=bottom]:starting:-translate-y-1 motion-safe:data-[placement=top]:starting:translate-y-1",
32
32
  "motion-safe:data-[placement=right]:starting:-translate-x-1 motion-safe:data-[placement=left]:starting:translate-x-1"
33
33
  ].join(" ");
34
+ /**
35
+ * 「자주 씀」 오버레이(Tooltip, Menu, Select·Combobox 목록)의 등장·퇴장(spec 「motion 적용」, 티켓 26).
36
+ * 요소에 `data-open`(열림)이 있어야 한다.
37
+ *
38
+ * - 등장: 바로 나타난다. 열린 동안은 `transition-none`이라 퇴장 중에 다시 열려도 바로 불투명해진다.
39
+ * 새로 넣은 요소는 이전 스타일이 없어 전환이 생기지 않으므로 `starting:`도 두지 않는다.
40
+ * - 퇴장: `data-open`이 빠지면 opacity만 `fast`(150ms), out으로 사라진다. 전환 대상은 opacity와 display다.
41
+ * duration·전환 대상은 도착 상태의 값을 쓰므로 닫힌 상태에서만 전환이 있다.
42
+ * - 닫힌 상태는 `display: none`(`hidden`)이고 `transition-discrete`로 퇴장이 끝날 때 사라진다(popoverMotionClass와 같다).
43
+ * DOM에서 빼는 시점은 `useOverlayPresence`가 정한다. 퇴장 중에는 `pointer-events-none`이다.
44
+ * - reduced-motion: 이동·blur·확대가 없으므로 바꿀 것이 없다. opacity 전환은 그대로 둔다.
45
+ */
46
+ const frequentMotionClass = ["transition-[opacity,display] transition-discrete duration-fast ease-out data-open:transition-none", "not-data-open:pointer-events-none not-data-open:hidden not-data-open:opacity-0"].join(" ");
47
+ /**
48
+ * Dialog류(Dialog, AlertDialog) 표면(spec radius·Elevation, 티켓 30). 가운데에 뜨는 모달이라 트리거에 붙는
49
+ * 오버레이보다 한 단계 높다. raised 표면(`bg-raised`), `shadow-lg`, radius `xl`, 경계 `border-raised`.
50
+ * 글자·경계·forced-colors 처리는 `overlaySurfaceClass`와 같다(`text-default × bg-raised` 4.5:1 등록, `border-raised`는 장식용).
51
+ */
52
+ const dialogSurfaceClass = "rounded-xl border border-raised bg-raised text-default shadow-lg";
53
+ /**
54
+ * 모달 뒤를 가리는 반투명 막(backdrop, `bg-overlay`)의 등장·퇴장(티켓 30). 요소에 `data-open`이 있어야 한다.
55
+ *
56
+ * - 등장: `starting:`에서 opacity 0으로 시작해 `normal`(300ms)로 나타난다. 퇴장: `data-open`이 빠지면 `fast`(150ms)로 사라진다.
57
+ * - 닫힌 상태는 `display: none`(`hidden`) + `transition-discrete`로 퇴장이 끝날 때 사라지고, 퇴장 중에는
58
+ * `pointer-events-none`이다(popoverMotionClass와 같은 구조). 안의 Dialog도 이 값을 물려받아 눌리지 않는다.
59
+ * - opacity만 바뀌므로 움직임 줄이기에서도 같다.
60
+ * - `useOverlayPresence`는 이 요소(막)의 전환이 끝나기를 기다린다. 안의 Dialog 전환(`dialogMotionClass`)도 같은
61
+ * duration이라 함께 끝난다.
62
+ */
63
+ const backdropMotionClass = [
64
+ "transition-[opacity,display] transition-discrete duration-fast ease-out data-open:duration-normal",
65
+ "not-data-open:pointer-events-none not-data-open:hidden not-data-open:opacity-0",
66
+ "starting:opacity-0"
67
+ ].join(" ");
68
+ /**
69
+ * Dialog류의 등장·퇴장(spec 「motion 적용」 「가끔 씀」 Dialog류, 티켓 30). 요소에 `data-open`이 있어야 한다.
70
+ *
71
+ * - 등장: `starting:`에서 opacity 0 + scale 0.96(`scale-96`)으로 시작해 `normal`(300ms), out으로 제자리에 온다.
72
+ * - 퇴장: `data-open`이 빠지면 같은 조합을 약하게(scale 0.98, `scale-98`) `fast`(150ms)로 사라진다.
73
+ * duration은 도착 상태의 값을 쓰므로 기본 `duration-fast`에 열림만 `data-open:duration-normal`이다.
74
+ * - 화면에서 빼는 일(display)은 바깥 막(`backdropMotionClass`)이 한다. 이 요소는 opacity·scale만 전환한다.
75
+ * - reduced-motion: 확대(scale)와 그 전환 대상은 `motion-safe:`일 때만 둔다. 움직임 줄이기에서는 opacity만 바뀐다.
76
+ * - `transition-all`은 쓰지 않는다.
77
+ */
78
+ const dialogMotionClass = [
79
+ "transition-[opacity] duration-fast ease-out data-open:duration-normal motion-safe:transition-[opacity,scale]",
80
+ "not-data-open:opacity-0 motion-safe:not-data-open:scale-98",
81
+ "starting:opacity-0 motion-safe:starting:scale-96"
82
+ ].join(" ");
34
83
 
35
- export { overlaySurfaceClass, popoverMotionClass };
84
+ export { backdropMotionClass, dialogMotionClass, dialogSurfaceClass, frequentMotionClass, overlaySurfaceClass, popoverMotionClass };
package/dist/overlay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { jsx } from "react/jsx-runtime";
4
- import { useLayoutEffect, useState } from "react";
4
+ import { useEffect, useLayoutEffect, useState } from "react";
5
5
  import { Overlay, usePopover } from "react-aria";
6
6
 
7
7
  // src/overlay.tsx
@@ -92,15 +92,39 @@ function useOverlayTheme(triggerRef, overlayRef) {
92
92
  }, [triggerRef, overlayRef]);
93
93
  }
94
94
  /**
95
+ * 오버레이 안에서 Tab을 누르면 기본 동작을 막고 닫는다(포커스는 `OverlayPortal`의 FocusScope가 트리거로 돌린다).
96
+ * 목록 오버레이(Menu, Select)가 쓴다(WAI-ARIA APG Menu Button·Select-Only Combobox: Tab은 목록을 닫는다).
97
+ *
98
+ * React Aria 목록 hook(useMenu·useListBox)은 Tab으로 닫지 않고, 항목의 keydown 처리가 전파를 멈추며 FocusScope가
99
+ * document capture에서 Tab을 먼저 다룬다. 열린 동안 바깥은 inert라 브라우저 기본 Tab은 갈 곳이 없다. 그래서 그보다
100
+ * 앞인 window capture에서 받는다(티켓 27에서 Menu 안에 두었다가 티켓 28에서 이곳으로 옮겼다).
101
+ * 전파도 멈춘다. listbox(useListBox 안의 useSelectableCollection)는 Tab에서 포커스를 목록 안 요소로 옮기는데, 그러면
102
+ * 닫힌 뒤 포커스가 트리거로 돌아오지 않았다(Select 테스트로 확인).
103
+ */
104
+ function useCloseOnTab(overlayRef, close) {
105
+ useEffect(() => {
106
+ const onKeyDown = (event) => {
107
+ if (event.key !== "Tab" || !overlayRef.current?.contains(event.target)) return;
108
+ event.preventDefault();
109
+ event.stopPropagation();
110
+ close();
111
+ };
112
+ window.addEventListener("keydown", onKeyDown, true);
113
+ return () => window.removeEventListener("keydown", onKeyDown, true);
114
+ }, [close, overlayRef]);
115
+ }
116
+ /**
95
117
  * 오버레이를 `document.body`에 portal한다(React Aria `Overlay`). top-layer(`<dialog>`, popover API)는 쓰지 않는다.
96
118
  * 안을 `FocusScope`(`restoreFocus`)로 감싸서, DOM에서 빠질 때 포커스가 열기 전 요소(보통 트리거)로 돌아간다.
97
119
  * 역할 hook이 포커스 가두기를 요청하면(`useDialog` 등) Tab이 안에서 돈다. 서버 렌더에서는 아무것도 그리지 않는다.
120
+ * `focusManagement={false}`면 FocusScope 없이 portal만 한다(Tooltip).
98
121
  */
99
- function OverlayPortal({ isExiting, children }) {
122
+ function OverlayPortal({ isExiting, focusManagement = true, children }) {
100
123
  return /* @__PURE__ */ jsx(Overlay, {
101
124
  isExiting,
125
+ disableFocusManagement: !focusManagement,
102
126
  children
103
127
  });
104
128
  }
105
129
 
106
- export { OverlayPortal, overlayPositionProps, useOverlayPresence, useOverlayTheme, usePopoverOverlay };
130
+ export { OverlayPortal, overlayPositionProps, useCloseOnTab, useOverlayPresence, useOverlayTheme, usePopoverOverlay };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tide-tds/components",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Tide 디자인 시스템의 React Component",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,8 +23,8 @@
23
23
  "./styles.css": "./dist/styles.css"
24
24
  },
25
25
  "dependencies": {
26
- "@tide-tds/foundation": "0.3.1",
27
- "@tide-tds/icons": "0.3.1",
26
+ "@tide-tds/foundation": "0.5.0",
27
+ "@tide-tds/icons": "0.5.0",
28
28
  "clsx": "^2.1.1",
29
29
  "react-aria": "^3.53.1",
30
30
  "react-stately": "^3.51.0",